@ucsandman/legcli 0.11.0 → 0.13.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 +213 -0
- package/README.md +95 -65
- package/bin/leg.mjs +123 -14
- package/docs/DECISIONS.md +18 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +68 -0
- package/docs/ROADMAP-v2.md +50 -5
- package/docs/VOCABULARY.md +27 -0
- package/docs/board-guide.md +529 -96
- package/docs/cli-contracts.md +241 -5
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +65 -1
- package/docs/faq.md +21 -5
- package/docs/getting-started.md +15 -11
- package/docs/redesign-2026-09-17.md +477 -0
- package/docs/screenshots/background-1280.png +0 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/capacity-drawer-1280.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/new-card-dialog.png +0 -0
- package/docs/screenshots/settings-ladder-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/fixtures/limits/claude/claude-fable-limit.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +1 -1
- package/fixtures/limits/claude/claude-session-limit.json +1 -1
- package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/verified.json +1 -1
- package/package.json +3 -2
- package/scripts/board-jump-probe.mjs +335 -0
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/attach.mjs +378 -93
- package/src/audit.mjs +1 -1
- package/src/board/board.css +203 -11
- package/src/board/board.js +664 -200
- package/src/board/entry.js +343 -0
- package/src/board/floor.html +51 -39
- package/src/board/floor.js +585 -73
- package/src/board/index.html +122 -45
- package/src/board/sessions.js +1569 -141
- package/src/board/strip.js +163 -0
- package/src/buckets.mjs +101 -0
- package/src/cards.mjs +9 -1
- package/src/chain.mjs +13 -0
- package/src/hook.mjs +7 -1
- package/src/ledger.mjs +10 -2
- package/src/models.mjs +265 -0
- package/src/orchestrator.mjs +13 -4
- package/src/preferences.mjs +278 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +625 -78
- package/src/sessions.mjs +17 -1
- package/src/taps/claude-usage.mjs +107 -3
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/usage-poll.mjs +260 -0
- package/src/usage.mjs +439 -12
package/src/server.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import http from 'node:http'
|
|
8
8
|
import https from 'node:https'
|
|
9
9
|
import { spawnSync, execFile } from 'node:child_process'
|
|
10
|
-
import { existsSync, readFileSync, readdirSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
|
|
11
11
|
import { join, dirname, resolve, extname, sep } from 'node:path'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
13
|
import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
|
|
@@ -15,26 +15,31 @@ import { readShare, isOn as shareIsOn, sharePath, identify, personNamed, mayUseC
|
|
|
15
15
|
import { auditTrail, ACTOR_KINDS } from './audit.mjs'
|
|
16
16
|
import { createLimiter } from './ratelimit.mjs'
|
|
17
17
|
import { realPath, canonPath } from './fsx.mjs'
|
|
18
|
-
import { listCards, readCard, readRuns, readEvents, cardDir, home } from './store.mjs'
|
|
18
|
+
import { listCards, readCard, readRuns, readEvents, cardDir, home, ledgerAppend, ledgerUpdate } from './store.mjs'
|
|
19
|
+
import { saveSessionBundle } from './bundle.mjs'
|
|
20
|
+
import { transcriptTail as claudeTranscriptTail } from './taps/claude.mjs'
|
|
19
21
|
import { humanAction } from './orchestrator.mjs'
|
|
20
22
|
import { createCard, CardInputError } from './cards.mjs'
|
|
21
|
-
import { IllegalTransition, availableActions } from './chain.mjs'
|
|
23
|
+
import { IllegalTransition, availableActions, NON_TERMINAL, TERMINAL } from './chain.mjs'
|
|
22
24
|
import { held } from './leases.mjs'
|
|
23
25
|
import { PRESETS } from './presets.mjs'
|
|
24
26
|
import { names as adapterNames, get as getAdapter, isFake } from './adapters/index.mjs'
|
|
25
27
|
import { createScheduler, schedulerStatus, MAX_CONCURRENT } from './scheduler.mjs'
|
|
26
|
-
import { remove as removeWorktree, worktreeDirty } from './worktree.mjs'
|
|
28
|
+
import { ensure as ensureWorktree, remove as removeWorktree, worktreeDirty } from './worktree.mjs'
|
|
27
29
|
import { scrub } from './runner.mjs'
|
|
28
30
|
import { resolveChb } from './handoff.mjs'
|
|
29
|
-
import { listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, overlaps, isActive, sessionsRoot, reapLost, readLand, readLandings, readRequests, writeRequests, appendEvent as appendSessionEvent, updateSession, HANDOFF_ORDER_CAPABILITY } from './sessions.mjs'
|
|
31
|
+
import { listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, overlaps, isActive, sessionsRoot, reapLost, readLand, readLandings, readRequests, writeRequests, appendEvent as appendSessionEvent, updateSession, HANDOFF_ORDER_CAPABILITY, SUPERVISED_AGENTS } from './sessions.mjs'
|
|
30
32
|
import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs'
|
|
31
33
|
import { hasRecentSynthesis } from './synthesis.mjs'
|
|
32
34
|
import { refreshPointers } from './resume.mjs'
|
|
33
35
|
import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
|
|
34
|
-
import { readUsage,
|
|
35
|
-
import { readAccounts
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
36
|
+
import { readUsage, usageIsStale, candidates, isAvailable, fmtReset, binding, evaluateLadder, rungLabel, wallActive } from './usage.mjs'
|
|
37
|
+
import { readAccounts } from './accounts.mjs'
|
|
38
|
+
import { createUsagePollers, USAGE_AGENTS } from './usage-poll.mjs'
|
|
39
|
+
import { readCodexUsage, transcriptTail as codexTranscriptTail } from './taps/codex.mjs'
|
|
40
|
+
import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder, ladderFor, requireHandoffLadder, requireClimbBack, requireReserve, orderFromLadder } from './preferences.mjs'
|
|
41
|
+
import { isDownshift } from './buckets.mjs'
|
|
42
|
+
import { listModels } from './models.mjs'
|
|
38
43
|
import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
|
|
39
44
|
import { listWorktrees } from './history/worktrees.mjs'
|
|
40
45
|
|
|
@@ -75,16 +80,192 @@ export function columnOf(card) {
|
|
|
75
80
|
return card.station
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
|
|
83
|
+
// ---- the work stat on a live card's row (redesign C.3) --------------------
|
|
84
|
+
// `4 files, +212 -18`. Parsed from git's own one-line summary, never counted
|
|
85
|
+
// here: a part the line does not carry is left off the object, so the row can
|
|
86
|
+
// print only what was measured and never estimate the rest.
|
|
87
|
+
export function parseShortstat(line) {
|
|
88
|
+
const text = String(line ?? '')
|
|
89
|
+
const files = /(\d+)\s+files?\s+changed/.exec(text)
|
|
90
|
+
const ins = /(\d+)\s+insertions?\(\+\)/.exec(text)
|
|
91
|
+
const del = /(\d+)\s+deletions?\(-\)/.exec(text)
|
|
92
|
+
if (!files && !ins && !del) return null
|
|
93
|
+
const out = {}
|
|
94
|
+
if (files) out.files = parseInt(files[1], 10)
|
|
95
|
+
if (ins) out.insertions = parseInt(ins[1], 10)
|
|
96
|
+
if (del) out.deletions = parseInt(del[1], 10)
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function measureWork(card) {
|
|
101
|
+
if (!card.worktree || !existsSync(card.worktree)) return null
|
|
102
|
+
const base = card.trunk || 'main'
|
|
103
|
+
const r = spawnSync('git', ['diff', '--shortstat', `${base}..HEAD`], { cwd: card.worktree, windowsHide: true, encoding: 'utf8', timeout: 8000, env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
104
|
+
if (r.status !== 0) return null
|
|
105
|
+
return parseShortstat(r.stdout)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// One `git diff` per live card per push would put a subprocess per card inside
|
|
109
|
+
// the board's one event loop, which is the stall canLandFor already exists to
|
|
110
|
+
// avoid. Keyed on the card's own revision, with the expiries spread across the
|
|
111
|
+
// window so twenty cards never re-read together.
|
|
112
|
+
const WORK_TTL = 15000
|
|
113
|
+
const workCache = new Map()
|
|
114
|
+
function workFor(card) {
|
|
115
|
+
const key = `${card.card_id}|${card.updated_at ?? ''}`
|
|
116
|
+
const hit = workCache.get(key)
|
|
117
|
+
if (hit && Date.now() < hit.until) return hit.data
|
|
118
|
+
let data = null
|
|
119
|
+
try { data = measureWork(card) } catch { data = null }
|
|
120
|
+
if (workCache.size > 300) workCache.clear()
|
|
121
|
+
workCache.set(key, { until: Date.now() + WORK_TTL / 2 + Math.random() * WORK_TTL, data })
|
|
122
|
+
return data
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The test and land verdicts a live card has already earned. Both are read from
|
|
126
|
+
// the card's own ledger, because that is where each station records its result:
|
|
127
|
+
// a test station writes no run.json (src/stations/test.mjs), and a landing is a
|
|
128
|
+
// `landed` / `bounced` / `failed` event (src/chain.mjs). Last one wins; a card
|
|
129
|
+
// that has run neither carries neither key.
|
|
130
|
+
export function cardOutcomes(events) {
|
|
131
|
+
const out = {}
|
|
132
|
+
for (const ev of events ?? []) {
|
|
133
|
+
const summary = String(ev.summary ?? '')
|
|
134
|
+
const m = /^test (green|red)\b/.exec(summary)
|
|
135
|
+
if (m) out.tests = { state: m[1], at: ev.ts }
|
|
136
|
+
if (ev.type === 'landed') {
|
|
137
|
+
const sha = /\b([0-9a-f]{7,40})\b/.exec(summary)
|
|
138
|
+
out.land = { state: 'landed', reason: null, sha: sha ? sha[1] : null }
|
|
139
|
+
} else if (ev.type === 'bounced' && /^land bounced/.test(summary)) {
|
|
140
|
+
out.land = { state: 'bounced', reason: ev.body ? String(ev.body).slice(0, 200) : summary, sha: null }
|
|
141
|
+
} else if (ev.type === 'failed' && /\bland\b/.test(summary)) {
|
|
142
|
+
out.land = { state: 'failed', reason: ev.body ? String(ev.body).slice(0, 200) : summary, sha: null }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The last messages a hand-off bundle quotes, from the agent's own transcript.
|
|
149
|
+
// An agent Leg cannot read a transcript for contributes none, and the bundle is
|
|
150
|
+
// written from the record alone rather than with invented text.
|
|
151
|
+
function sessionMessages(s) {
|
|
152
|
+
try {
|
|
153
|
+
if (s.agent === 'claude') return claudeTranscriptTail(s.transcript_path)
|
|
154
|
+
if (s.agent === 'codex') return codexTranscriptTail(s.transcript_path)
|
|
155
|
+
} catch { /* an unreadable transcript is not a reason to refuse the bundle */ }
|
|
156
|
+
return []
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// This terminal's ladder from the rung it is standing on, downward. A card made
|
|
160
|
+
// from a terminal starts where the terminal is, not at the top: the rungs above
|
|
161
|
+
// it are the ones this work has already used up (redesign C.4).
|
|
162
|
+
export function ladderFromCurrentRung(session) {
|
|
163
|
+
const ladder = ladderFor(session).filter((r) => r && r.agent)
|
|
164
|
+
const exact = ladder.findIndex((r) => r.agent === session.agent && (r.account ?? 'default') === (session.account ?? 'default') && (r.model ?? null) === (session.model ?? null))
|
|
165
|
+
const byAgent = exact >= 0 ? exact : ladder.findIndex((r) => r.agent === session.agent)
|
|
166
|
+
return byAgent >= 0 ? ladder.slice(byAgent) : ladder
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- carrying a checkout's uncommitted work into another one --------------
|
|
170
|
+
// "End, and keep going as a card" on a lone terminal (the ordinary case: a
|
|
171
|
+
// terminal only cuts a worktree of its own when a second live session shares
|
|
172
|
+
// the checkout, src/attach.mjs isolate()) has to move the work the human was
|
|
173
|
+
// looking at, not just the branch it sits on. Captured in two halves, because
|
|
174
|
+
// git keeps them apart: a patch of everything it tracks, staged and unstaged
|
|
175
|
+
// (`git diff HEAD --binary`), and the bytes of the files it does not
|
|
176
|
+
// (`git ls-files --others`). The second half is the one `git stash create`
|
|
177
|
+
// cannot carry, and it is where a terminal's newest file always is.
|
|
178
|
+
const CARRY_SKIP = ['.git', '.leg-worktrees', '.baton-worktrees', '.context-handoffs', '.leg', '.baton', 'node_modules']
|
|
179
|
+
const CARRY_MAX_BYTES = 8 * 1024 * 1024
|
|
180
|
+
function gitIn(dir, args, opts = {}) {
|
|
181
|
+
const r = spawnSync('git', ['-C', dir, ...args], { windowsHide: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 30000, env: { ...process.env, MSYS_NO_PATHCONV: '1' }, ...opts })
|
|
182
|
+
if (r.error) throw r.error
|
|
183
|
+
if (r.status !== 0) throw new Error(`git ${args[0]} failed: ${String(r.stderr ?? '').trim().slice(0, 200)}`)
|
|
184
|
+
return r.stdout
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function captureUncommitted(dir) {
|
|
188
|
+
const status = gitIn(dir, ['status', '--porcelain'])
|
|
189
|
+
if (!status.trim()) return null
|
|
190
|
+
// --binary keeps a changed image or lockfile intact; the output is ASCII
|
|
191
|
+
const patch = gitIn(dir, ['diff', 'HEAD', '--binary'])
|
|
192
|
+
const files = []
|
|
193
|
+
for (const rel of gitIn(dir, ['ls-files', '--others', '--exclude-standard']).split('\n').map((s) => s.trim()).filter(Boolean)) {
|
|
194
|
+
if (CARRY_SKIP.includes(rel.split('/')[0])) continue
|
|
195
|
+
const from = join(dir, rel)
|
|
196
|
+
let st
|
|
197
|
+
try { st = statSync(from) } catch { continue }
|
|
198
|
+
if (!st.isFile()) continue
|
|
199
|
+
// refuse loudly rather than carry half the work: the caller turns this
|
|
200
|
+
// into a 409 and the terminal is left alone
|
|
201
|
+
if (st.size > CARRY_MAX_BYTES) throw new Error(`${rel} is ${Math.round(st.size / 1048576)}MB, too large to carry into the card's checkout`)
|
|
202
|
+
files.push({ rel, data: readFileSync(from) })
|
|
203
|
+
}
|
|
204
|
+
if (!patch.trim() && !files.length) return null
|
|
205
|
+
const names = new Set(status.trim().split('\n').map((l) => l.slice(3).trim()).filter(Boolean))
|
|
206
|
+
for (const f of files) names.add(f.rel)
|
|
207
|
+
return { patch, files, names: [...names] }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function carryUncommitted(dir, carried) {
|
|
211
|
+
if (!carried) return 0
|
|
212
|
+
if (carried.patch.trim()) {
|
|
213
|
+
const r = spawnSync('git', ['-C', dir, 'apply', '--binary', '--whitespace=nowarn', '-'], { input: carried.patch, windowsHide: true, encoding: 'utf8', timeout: 30000, env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
214
|
+
if (r.status !== 0) throw new Error(`the tracked changes did not apply: ${String(r.stderr ?? '').trim().slice(0, 200)}`)
|
|
215
|
+
}
|
|
216
|
+
for (const f of carried.files) {
|
|
217
|
+
const to = join(dir, f.rel)
|
|
218
|
+
mkdirSync(dirname(to), { recursive: true })
|
|
219
|
+
writeFileSync(to, f.data)
|
|
220
|
+
}
|
|
221
|
+
return (carried.names ?? []).length
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The cards waiting on a human, for the terminals verdict to read (redesign
|
|
225
|
+
// C.5): `card 3e1c has waited on you for 12 minutes.` needs the id, the title,
|
|
226
|
+
// the station and the moment it stopped, so a bare count cannot write the
|
|
227
|
+
// sentence the spec asks for. The oldest one is `first`, because that is the
|
|
228
|
+
// one the sentence names.
|
|
229
|
+
// Cached for a beat: listCards() reads one file per card, and this is computed
|
|
230
|
+
// on every sessions push.
|
|
231
|
+
const CARDS_WAITING_TTL = 5000
|
|
232
|
+
const WAITING_STATUSES = ['needs_approval', 'waiting_human']
|
|
233
|
+
let cardsWaitingCache = { at: 0, data: null }
|
|
234
|
+
function cardsWaiting() {
|
|
235
|
+
if (cardsWaitingCache.data && Date.now() - cardsWaitingCache.at < CARDS_WAITING_TTL) return cardsWaitingCache.data
|
|
236
|
+
let waiting = []
|
|
237
|
+
try { waiting = listCards().filter((c) => WAITING_STATUSES.includes(c.status)) } catch { waiting = [] }
|
|
238
|
+
// `updated_at` is when the card reached this state, which is what "has
|
|
239
|
+
// waited on you for 12 minutes" measures from
|
|
240
|
+
waiting.sort((a, b) => (String(a.updated_at ?? '') < String(b.updated_at ?? '') ? -1 : 1))
|
|
241
|
+
const first = waiting[0] ?? null
|
|
242
|
+
const data = {
|
|
243
|
+
count: waiting.length,
|
|
244
|
+
first: first ? { id: first.card_id, title: first.title ?? null, station: first.station ?? null, since: first.updated_at ?? null } : null,
|
|
245
|
+
}
|
|
246
|
+
cardsWaitingCache = { at: Date.now(), data }
|
|
247
|
+
return data
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// `events` is the card's ledger, already read by the caller. readEvents() does
|
|
251
|
+
// a readdir, a full readFileSync, a JSON.parse per line and a sort every call,
|
|
252
|
+
// and this function needed it three times per push (the last event, the legs
|
|
253
|
+
// that started at a finished station, and the test/land outcomes of a live
|
|
254
|
+
// card) on the one event loop the board serves every request from. Read once,
|
|
255
|
+
// reused; a caller that has no events passes none and pays for one read.
|
|
256
|
+
export function summarize(card, events = null) {
|
|
79
257
|
const st = (card.pipeline ?? []).find((s) => s.name === card.station) ?? null
|
|
80
|
-
|
|
258
|
+
// `cards.map(summarize)` would hand this the array index, so the type is
|
|
259
|
+
// checked rather than the emptiness
|
|
260
|
+
const evs = Array.isArray(events) ? events : readEvents(card.card_id)
|
|
261
|
+
const last = evs.length ? evs[evs.length - 1] : null
|
|
81
262
|
const runs = readRuns(card.card_id)
|
|
82
263
|
const activeRun = runs.find((r) => ['launching', 'running'].includes(r.status)) ?? null
|
|
83
264
|
// Once a station is over (done/failed) card.leg is reset, so the rail is
|
|
84
265
|
// rebuilt from the legs that actually started at this station.
|
|
85
266
|
const terminal = ['done', 'failed', 'killed'].includes(card.status)
|
|
86
267
|
const startedLegs = terminal && st?.kind === 'agent'
|
|
87
|
-
?
|
|
268
|
+
? evs.filter((ev) => ev.type === 'leg_started' && ev.station === card.station).map((ev) => ev.leg)
|
|
88
269
|
: []
|
|
89
270
|
const lastLeg = startedLegs.length ? Math.max(...startedLegs) : card.leg
|
|
90
271
|
// card.leg is reset when the station ends, so a finished card read its adapter
|
|
@@ -101,12 +282,23 @@ export function summarize(card) {
|
|
|
101
282
|
if (i > card.leg) return 'pending'
|
|
102
283
|
return ['running', 'handing_off'].includes(card.status) ? 'active' : 'pending'
|
|
103
284
|
}
|
|
285
|
+
// What a live card is carrying right now: the diff it has built, the last
|
|
286
|
+
// test verdict, the last land verdict, and the rung it is on. Measured only
|
|
287
|
+
// for a card that is still going: a finished one is a single line in the
|
|
288
|
+
// ledger, and a git subprocess for each of ten of those buys nothing (C.1).
|
|
289
|
+
const live = NON_TERMINAL.includes(card.status)
|
|
290
|
+
const work = live ? workFor(card) : null
|
|
291
|
+
const outcomes = live ? cardOutcomes(evs) : {}
|
|
104
292
|
return {
|
|
105
293
|
...card,
|
|
106
294
|
column: columnOf(card),
|
|
107
295
|
station_kind: st?.kind ?? null,
|
|
108
296
|
active_adapter: entry?.adapter ?? null,
|
|
109
297
|
active_mode: entry?.mode ?? null,
|
|
298
|
+
...(work ? { work } : {}),
|
|
299
|
+
...(outcomes.tests ? { tests: outcomes.tests } : {}),
|
|
300
|
+
...(outcomes.land ? { land: outcomes.land } : {}),
|
|
301
|
+
...(live && entry ? { agent_model: { agent: entry.adapter ?? null, model: entry.model ?? null } } : {}),
|
|
110
302
|
chain_view: st?.kind === 'agent' ? st.chain.map((e, i) => ({
|
|
111
303
|
adapter: e.adapter, mode: e.mode ?? null, approve: Boolean(e.approve),
|
|
112
304
|
// a leg before the last one ended in a handoff (only the last leg can complete a station)
|
|
@@ -293,8 +485,18 @@ function trunkFor(repo) {
|
|
|
293
485
|
const hit = trunkCache.get(repo)
|
|
294
486
|
if (hit && Date.now() - hit.at < 15000) return hit.data
|
|
295
487
|
const g = (args) => { const r = spawnSync('git', args, { cwd: repo, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } }); return r.status === 0 ? r.stdout.trim() : null }
|
|
488
|
+
// The branch this repo actually calls its trunk: origin's default if there is
|
|
489
|
+
// one, then the usual names, then whatever this checkout is on. A repo whose
|
|
490
|
+
// default is `develop` used to read as "main" here, and the board's one-line
|
|
491
|
+
// entry then posted a card against a branch that does not exist.
|
|
296
492
|
let branch = null
|
|
297
|
-
|
|
493
|
+
const originHead = g(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
|
|
494
|
+
if (originHead) {
|
|
495
|
+
const name = originHead.replace(/^origin\//, '')
|
|
496
|
+
if (name && g(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`]) !== null) branch = name
|
|
497
|
+
}
|
|
498
|
+
if (!branch) for (const b of ['main', 'master', 'trunk']) if (g(['rev-parse', '--verify', '--quiet', b]) !== null) { branch = b; break }
|
|
499
|
+
if (!branch) branch = g(['symbolic-ref', '--short', 'HEAD']) || null
|
|
298
500
|
const log = branch ? g(['log', '--format=%h%x1f%s%x1f%cr%x1f%an', '-6', branch]) : null
|
|
299
501
|
const data = {
|
|
300
502
|
repo, repo_name: repo.split(/[\\/]/).filter(Boolean).pop(), branch,
|
|
@@ -319,14 +521,28 @@ function withLandings(t, landings) {
|
|
|
319
521
|
// What another human sees of a terminal that is not theirs: that it is there,
|
|
320
522
|
// nothing it has said, read or written. No task, no paths, no file names, no
|
|
321
523
|
// limit text, no bundle, no events.
|
|
524
|
+
// No usage either. `limits` is the five-hour and seven-day percentage of this
|
|
525
|
+
// machine's login, written onto the record by the poller and by every claude
|
|
526
|
+
// status line, and `warning` carries the same percentage with the clock it
|
|
527
|
+
// resets on. Both are the number the capacity drawer, the accounts payload and
|
|
528
|
+
// guestReason all withhold, so neither may ride out on a row instead
|
|
529
|
+
// (.design/BOARD-DESIGN.md 6.13). The band survives, the figure does not.
|
|
322
530
|
function redactSession(s) {
|
|
323
531
|
return {
|
|
324
532
|
session_id: s.session_id, agent: s.agent, account: s.account, status: s.status, active: s.active,
|
|
325
533
|
started_at: s.started_at, elapsed_ms: s.elapsed_ms, turns: s.turns, repo_name: s.repo_name, branch: s.branch,
|
|
326
|
-
owner: s.owner ?? null,
|
|
327
|
-
warning: s.warning ? { window: s.warning.window
|
|
328
|
-
limit: s.limit ? { reason: s.limit.reason
|
|
329
|
-
|
|
534
|
+
owner: s.owner ?? null, lineage: s.lineage ?? null,
|
|
535
|
+
warning: s.warning ? { window: s.warning.window } : null,
|
|
536
|
+
limit: s.limit ? { reason: s.limit.reason } : null,
|
|
537
|
+
// `ahead` is owner-only for the same reason `files` is: how far someone
|
|
538
|
+
// else's branch has moved is a fact about their work, and the register
|
|
539
|
+
// prints it beside the dirty count that is already withheld here.
|
|
540
|
+
// `waiting` and `model` are owner-only, and a guest keeps both on their own
|
|
541
|
+
// terminal (that row is not redacted at all). On someone else's row they are
|
|
542
|
+
// dropped: `waiting` carries either the verbatim question an agent asked —
|
|
543
|
+
// the prompt text this function exists to hide — or a reset time, which is
|
|
544
|
+
// this machine's usage data (.design/BOARD-DESIGN.md 6.13); and `model` is
|
|
545
|
+
// which of this machine's model buckets someone else's work is spending.
|
|
330
546
|
worktree: s.worktree ? { branch: s.worktree.branch, base: s.worktree.base } : null,
|
|
331
547
|
// the branch is already on the worktree chip: naming it again costs nothing
|
|
332
548
|
// and is what the board's land line reads
|
|
@@ -338,6 +554,48 @@ function redactSession(s) {
|
|
|
338
554
|
}
|
|
339
555
|
}
|
|
340
556
|
|
|
557
|
+
// A guest's OWN terminal is not redacted: it is their work. The login it runs
|
|
558
|
+
// on is still this machine's, though, and every figure on the record that was
|
|
559
|
+
// measured from the owner's accounts is the same secret `capacity`, `buckets`
|
|
560
|
+
// and the accounts payload already withhold: the two window percentages
|
|
561
|
+
// (`limits`), the near-wall warning, the reset clock on a limit or an all-out
|
|
562
|
+
// wait, and the name of the reading source. The row keeps every fact about the
|
|
563
|
+
// work and loses every figure about the login (.design/BOARD-DESIGN.md 6.13).
|
|
564
|
+
function scrubOwnerUsage(s) {
|
|
565
|
+
const out = { ...s }
|
|
566
|
+
delete out.limits
|
|
567
|
+
delete out.all_out
|
|
568
|
+
delete out.usage_source
|
|
569
|
+
delete out.usage_error
|
|
570
|
+
// the band survives, the figure and the clock do not: their own row may say
|
|
571
|
+
// it is near a wall, the same way a redacted row does
|
|
572
|
+
if (out.warning) out.warning = { window: out.warning.window }
|
|
573
|
+
if (out.limit) out.limit = { ...out.limit, resets_at: null }
|
|
574
|
+
// a 'reset' wait is a reset time with a sentence around it; the guest still
|
|
575
|
+
// learns that their terminal is waiting for one
|
|
576
|
+
if (out.waiting && out.waiting.type === 'reset') out.waiting = { type: 'reset', since: out.waiting.since ?? null }
|
|
577
|
+
return out
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// The usage poller appends `claude usage unavailable since 9:03 AM: <reason>`
|
|
581
|
+
// to every active session of a login, a guest's own terminal included
|
|
582
|
+
// (src/usage-poll.mjs). That line is the owner's reading — the reason their
|
|
583
|
+
// endpoint is refusing and the clock it started — so it leaves with the rest of
|
|
584
|
+
// the figures scrubOwnerUsage withholds. The recovery line says nothing about
|
|
585
|
+
// the login and stays.
|
|
586
|
+
const isUsageFailureEvent = (e) => e?.type === 'status' && /^\w+ usage unavailable since /.test(String(e?.summary ?? ''))
|
|
587
|
+
|
|
588
|
+
// A guest owns their own terminal, so its picker rows are theirs to read, but
|
|
589
|
+
// a rung's reason can quote this machine's usage ("at 63%, not below 80%",
|
|
590
|
+
// "past your 10% reserve"), and a percentage of this machine's login belongs to
|
|
591
|
+
// nobody else (.design/BOARD-DESIGN.md 6.13). Only the reasons that say nothing
|
|
592
|
+
// about how much is left survive the crossing.
|
|
593
|
+
const GUEST_REASONS = new Set(['not installed on this machine', 'at its usage limit', 'shares the window that is out, buys nothing', 'refused for this hand-off'])
|
|
594
|
+
function guestReason(reason) {
|
|
595
|
+
if (!reason) return null
|
|
596
|
+
return GUEST_REASONS.has(reason) ? reason : 'not available right now'
|
|
597
|
+
}
|
|
598
|
+
|
|
341
599
|
function visibleSessionFile(file) {
|
|
342
600
|
const value = String(file ?? '')
|
|
343
601
|
return !value.includes('*** Begin Patch') && !value.includes('*** End Patch')
|
|
@@ -353,13 +611,24 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
353
611
|
const list = reapLost(listSessions())
|
|
354
612
|
const ov = overlaps(list)
|
|
355
613
|
const configuredAccounts = readAccounts()
|
|
614
|
+
// the spending rules the picker has to print, read once for the whole view
|
|
615
|
+
const prefs = readPreferences()
|
|
356
616
|
const sessions = list.map((s) => {
|
|
357
617
|
const land = readLand(s.session_id)
|
|
358
618
|
const handoffOrder = normalizeHandoffOrder(s.handoff_order)
|
|
359
|
-
|
|
619
|
+
// this terminal's own ladder, else the long-hand form of its order
|
|
620
|
+
const handoffLadder = ladderFor(s)
|
|
621
|
+
const from = { agent: s.agent, account: s.account, model: s.model ?? null }
|
|
622
|
+
const chain = candidates({ agent: s.agent, account: s.account, model: s.model ?? null, accounts: configuredAccounts, order: handoffOrder, ladder: handoffLadder })
|
|
360
623
|
const preferredNext = chain[0] ?? null
|
|
361
624
|
const availabilityKnown = Boolean(s.installed)
|
|
362
|
-
|
|
625
|
+
// one pass, the same one the chooser makes, so a greyed row in the picker
|
|
626
|
+
// and the rung an automatic hand-off would take can never disagree. The
|
|
627
|
+
// picker is a human pressing a button, so the reserve is a note here, not
|
|
628
|
+
// a refusal (B.3).
|
|
629
|
+
const rungs = evaluateLadder({ from, list: chain, installed: availabilityKnown ? s.installed : null, maySpend: prefs.may_spend, reserve: prefs.reserve, automatic: false, climbBack: prefs.climb_back, ladder: handoffLadder })
|
|
630
|
+
const open = availabilityKnown ? rungs.find((r) => r.ok) : null
|
|
631
|
+
const eligibleNext = open ? { agent: open.agent, account: open.account, ...(open.model ? { model: open.model } : {}) } : null
|
|
363
632
|
const can = s.worktree ? canLandFor(s) : { ok: false, blockers: [{ code: 'no_worktree', message: 'this terminal works in the checkout itself: there is no branch of its own to land', fix: null }] }
|
|
364
633
|
return {
|
|
365
634
|
...s,
|
|
@@ -370,19 +639,38 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
370
639
|
// every destination this terminal could be handed to, each with the
|
|
371
640
|
// reason it cannot be picked right now. The board's picker renders this
|
|
372
641
|
// list directly, so a greyed option always carries its own explanation.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
642
|
+
// one row per RUNG now: `claude/opus` and `claude/sonnet` are separate
|
|
643
|
+
// destinations, each with what it costs, whether it keeps the
|
|
644
|
+
// conversation, and the reason it cannot (or should not) be picked.
|
|
645
|
+
handoff_targets: rungs.map((r) => ({
|
|
646
|
+
agent: r.agent,
|
|
647
|
+
account: r.account,
|
|
648
|
+
model: r.model ?? null,
|
|
649
|
+
available: r.ok,
|
|
650
|
+
// A row that CAN be picked has nothing to explain: the reserve and the
|
|
651
|
+
// `below:N` rules come back as a note on an ok row (usage.mjs), and
|
|
652
|
+
// generalising that note reads as a refusal beside a button that works.
|
|
653
|
+
// A row that is blocked keeps a reason a guest may read.
|
|
654
|
+
reason: guest ? (r.ok ? null : guestReason(r.reason)) : r.reason,
|
|
655
|
+
resets_at: !guest ? (r.resets_at ?? null) : null,
|
|
656
|
+
// the probe in fixtures/live/claude/resume-model-probe.json: a claude
|
|
657
|
+
// downshift resumes the same conversation; everything else is primed
|
|
658
|
+
// from the bundle, codex included until its own resume is observed
|
|
659
|
+
keeps_conversation: Boolean(isDownshift(from, r) && r.agent === 'claude' && s.agent_session_id),
|
|
660
|
+
// the cost word is not static: `credits` on a claude/fable rung means
|
|
661
|
+
// this machine's login has usage credits switched on (preferences.mjs
|
|
662
|
+
// rungCost reads extra_usage.enabled), which is a fact about the
|
|
663
|
+
// owner's account that the accounts payload drops on purpose. A guest
|
|
664
|
+
// gets the row and not the word.
|
|
665
|
+
...(guest ? {} : { cost: r.cost }),
|
|
666
|
+
})),
|
|
385
667
|
handoff_availability_known: availabilityKnown,
|
|
668
|
+
handoff_ladder: handoffLadder,
|
|
669
|
+
// the bucket that will actually stop this terminal, computed per request
|
|
670
|
+
// and never persisted: it depends on the model the row is running, and
|
|
671
|
+
// the record only knows the login. A guest never gets it: it is a
|
|
672
|
+
// percentage of this machine's usage (.design/BOARD-DESIGN.md 6.13).
|
|
673
|
+
...(guest ? {} : { capacity: binding(readUsage(s.agent, s.account), s.model ?? null) }),
|
|
386
674
|
can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
|
|
387
675
|
active: isActive(s),
|
|
388
676
|
has_synthesis: hasRecentSynthesis(s),
|
|
@@ -401,7 +689,15 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
401
689
|
const accounts = []
|
|
402
690
|
for (const agent of Object.keys(configuredAccounts)) for (const account of configuredAccounts[agent]) {
|
|
403
691
|
const u = readUsage(agent, account)
|
|
404
|
-
|
|
692
|
+
// buckets, walls, extra_usage and facts are owner-only for the same reason
|
|
693
|
+
// the percentages are: they say how much of this machine's login is gone.
|
|
694
|
+
// The guest branch at the bottom of this function drops the slot to
|
|
695
|
+
// {agent, account, live, shared}, so nothing here reaches them.
|
|
696
|
+
// `error`/`error_since` are the READING's health, not the login's: they say
|
|
697
|
+
// the board could not ask, which is why a percentage is old. Owner-only for
|
|
698
|
+
// the same reason the percentages are, and dropped by the guest branch below
|
|
699
|
+
// with the rest of the slot.
|
|
700
|
+
accounts.push({ agent, account, five_hour: u.five_hour, seven_day: u.seven_day, limited_until: u.limited_until, limited_reason: u.limited_reason, source: u.source, observed_at: u.observed_at, updated_at: u.updated_at, stale: usageIsStale(u), live: sessions.filter((s) => s.active && s.agent === agent && s.account === account).length, buckets: u.buckets ?? [], walls: u.walls ?? {}, extra_usage: u.extra_usage ?? null, facts: u.facts ?? null, error: u.error ?? null, error_since: u.error_since ?? null })
|
|
405
701
|
}
|
|
406
702
|
const repos = new Map()
|
|
407
703
|
for (const s of sessions) if (s.repo && (s.active || s.worktree) && !repos.has(canonPath(s.repo))) repos.set(canonPath(s.repo), s.repo)
|
|
@@ -410,7 +706,11 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
410
706
|
const landingsFor = (key) => landings.filter((l) => { if (!canon.has(l.repo)) canon.set(l.repo, canonPath(l.repo)); return canon.get(l.repo) === key })
|
|
411
707
|
const trunk = [...repos].map(([key, r]) => { try { return withLandings(trunkFor(r), landingsFor(key)) } catch { return { repo: r, commits: [] } } })
|
|
412
708
|
const mine = (s) => !shared || !viewer || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
|
|
413
|
-
const shown = sessions.map((s) =>
|
|
709
|
+
const shown = sessions.map((s) => {
|
|
710
|
+
if (!mine(s)) return redactSession(s)
|
|
711
|
+
const row = { ...s, requests: readRequests(s.session_id).filter((r) => r.state === 'pending') }
|
|
712
|
+
return guest ? scrubOwnerUsage(row) : row
|
|
713
|
+
})
|
|
414
714
|
return {
|
|
415
715
|
sessions: shown,
|
|
416
716
|
// a guest sees which accounts exist and which are busy, never how much of
|
|
@@ -421,6 +721,12 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
421
721
|
// a guest sees what landed, not where the repo lives on this machine
|
|
422
722
|
trunk: guest ? trunk.map((t) => ({ repo_name: t.repo_name, branch: t.branch, commits: t.commits ?? [] })) : trunk,
|
|
423
723
|
you: viewer,
|
|
724
|
+
// the terminals verdict has to be able to say "card 3e1c has waited on you
|
|
725
|
+
// for 12 minutes" without reading the whole pipeline board (redesign C.5).
|
|
726
|
+
// A guest has no cards at all; an operator runs them, approves them and is
|
|
727
|
+
// exactly the human one can be waiting on, so the gate is the cards
|
|
728
|
+
// permission the /api/cards routes use, not the owner flag.
|
|
729
|
+
...(!shared || mayUseCards(viewer?.role ?? 'owner') ? { cards_waiting: cardsWaiting() } : {}),
|
|
424
730
|
share: { on: shared, bind: shared ? share.bind : null, people: shared ? share.people.length : 0 },
|
|
425
731
|
preferences: guest ? null : readPreferences(),
|
|
426
732
|
ts: new Date().toISOString(),
|
|
@@ -488,7 +794,7 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
|
|
|
488
794
|
if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
|
|
489
795
|
const events = readEvents(id)
|
|
490
796
|
// broadcast() refreshes each client's viewer (and drops revoked ones) first
|
|
491
|
-
broadcast('card', forOwner(summarize(card)))
|
|
797
|
+
broadcast('card', forOwner(summarize(card, events)))
|
|
492
798
|
for (const c of [...clients]) {
|
|
493
799
|
if (!c.viewer || !mayUseCards(c.viewer.role)) { c.sig.set(id, events.length); continue }
|
|
494
800
|
const from = c.sig.get(id) ?? 0
|
|
@@ -611,8 +917,25 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
|
|
|
611
917
|
return { add, stop, broadcast, clients }
|
|
612
918
|
}
|
|
613
919
|
|
|
920
|
+
// Which logins this board reads usage for. LEG_CLAUDE_BIN and LEG_GROK_BIN say
|
|
921
|
+
// where a CLI lives, not that its login is fake, and gating on them switched
|
|
922
|
+
// every percentage, every 5h/7d number and every usage_error off for a user
|
|
923
|
+
// whose claude simply lives somewhere Leg's resolver does not look — with
|
|
924
|
+
// nothing on the board to say why. codex is the one agent whose binary the
|
|
925
|
+
// poller really needs, because it spawns the app-server to ask it; a stub there
|
|
926
|
+
// cannot answer, so codex alone is skipped on its BIN var. A suite that must
|
|
927
|
+
// reach no endpoint at all says so in one variable, LEG_NO_USAGE_POLL=1
|
|
928
|
+
// (test/helpers.mjs sets it for every spawned board), and an injected fetcher
|
|
929
|
+
// always wins: a test that supplied a reader is asking for it to be used.
|
|
930
|
+
export function usageAgentsFor({ env = process.env, injected = {} } = {}) {
|
|
931
|
+
const set = (name) => env[`LEG_${name}`] || env[`BATON_${name}`]
|
|
932
|
+
const off = set('NO_USAGE_POLL') === '1'
|
|
933
|
+
const stubbedCodex = Boolean(set('CODEX_BIN'))
|
|
934
|
+
return USAGE_AGENTS.filter((a) => injected[a] || (!off && !(a === 'codex' && stubbedCodex)))
|
|
935
|
+
}
|
|
936
|
+
|
|
614
937
|
// ---- the server ----
|
|
615
|
-
export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN || process.env.BATON_TOKEN || '', scheduler = (process.env.LEG_NO_SCHEDULER || process.env.BATON_NO_SCHEDULER) !== '1', share, usagePolling = false, usageReader = readCodexUsage } = {}) {
|
|
938
|
+
export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN || process.env.BATON_TOKEN || '', scheduler = (process.env.LEG_NO_SCHEDULER || process.env.BATON_NO_SCHEDULER) !== '1', share, usagePolling = false, usageReader = readCodexUsage, usageFetchers = {} } = {}) {
|
|
616
939
|
// An explicit `share` (tests) is fixed; the real server passes none and reads
|
|
617
940
|
// share.json from disk, re-reading it per request (mtime-cached) so `leg
|
|
618
941
|
// share add|rotate|rm` takes effect on a live board — a new link works at
|
|
@@ -655,28 +978,63 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
655
978
|
return null
|
|
656
979
|
}
|
|
657
980
|
const sse = createSse({ viewFor, reauth: reauthClient })
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
981
|
+
// A card that adopted a live terminal's checkout waits in the backlog until
|
|
982
|
+
// that terminal has really stopped. `end` is a REQUEST: src/attach.mjs reads
|
|
983
|
+
// control.json on its own poll (2s by default) and kills the child at the
|
|
984
|
+
// next tick, while the scheduler ticks every second, so a card queued here
|
|
985
|
+
// would put a headless agent in the same working tree as the interactive one
|
|
986
|
+
// for at least a tick, and longer if the agent is mid-turn. The session
|
|
987
|
+
// record is how the board learns a terminal ended (attach writes `ended`,
|
|
988
|
+
// sessions.mjs reaps a lost one), so that is what this waits on.
|
|
989
|
+
const ENQUEUE_POLL_MS = Math.max(50, Number(process.env.LEG_END_AS_CARD_POLL_MS || process.env.BATON_END_AS_CARD_POLL_MS || 500))
|
|
990
|
+
const ENQUEUE_WAIT_MS = Math.max(1000, Number(process.env.LEG_END_AS_CARD_WAIT_MS || process.env.BATON_END_AS_CARD_WAIT_MS || 600000))
|
|
991
|
+
const enqueueTimers = new Map()
|
|
992
|
+
function enqueueWhenSessionEnds(cardId, sessionId, who) {
|
|
993
|
+
const from = Date.now()
|
|
994
|
+
const arm = () => {
|
|
995
|
+
const t = setTimeout(tick, ENQUEUE_POLL_MS)
|
|
996
|
+
t.unref?.()
|
|
997
|
+
enqueueTimers.set(cardId, t)
|
|
998
|
+
}
|
|
999
|
+
const tick = () => {
|
|
1000
|
+
enqueueTimers.delete(cardId)
|
|
1001
|
+
const card = readCard(cardId)
|
|
1002
|
+
// killed, removed, or started by hand: it is not this timer's any more
|
|
1003
|
+
if (!card || card.status !== 'backlog') return
|
|
1004
|
+
const s = readSession(sessionId)
|
|
1005
|
+
if (!s || !isActive(s)) {
|
|
1006
|
+
try {
|
|
1007
|
+
const next = humanAction(cardId, 'enqueue', {}, who)
|
|
1008
|
+
log(`end-as-card ${cardId}: terminal ${sessionId} has stopped, the card is queued`)
|
|
1009
|
+
sse.broadcast('card', forOwner(summarize(next)))
|
|
1010
|
+
} catch (err) { log(`end-as-card ${cardId}: ${err.message}`) }
|
|
1011
|
+
return
|
|
1012
|
+
}
|
|
1013
|
+
if (Date.now() - from > ENQUEUE_WAIT_MS) {
|
|
1014
|
+
// never start it behind the human's back after a long wait: say why it
|
|
1015
|
+
// is sitting there and leave Run to them
|
|
1016
|
+
try { ledgerAppend(cardId, { actor: who, type: 'blocked_by', summary: `terminal ${sessionId} has not stopped, so this card is still in the backlog; press Run once it has` }) } catch { /* the log line below is the record */ }
|
|
1017
|
+
log(`end-as-card ${cardId}: terminal ${sessionId} still active after ${Math.round(ENQUEUE_WAIT_MS / 1000)}s, left in the backlog`)
|
|
1018
|
+
return
|
|
1019
|
+
}
|
|
1020
|
+
arm()
|
|
1021
|
+
}
|
|
1022
|
+
arm()
|
|
679
1023
|
}
|
|
1024
|
+
let sched = null
|
|
1025
|
+
// ---- usage polling: one poller per LOGIN, not one per terminal ----
|
|
1026
|
+
// The terminals used to ask their agent's endpoint once a minute each, so a
|
|
1027
|
+
// login with three of them drew three times the requests and three copies of
|
|
1028
|
+
// every 429 (src/usage-poll.mjs). The board asks once per login, backs off on
|
|
1029
|
+
// a failure, and pushes what it read onto that login's active sessions.
|
|
1030
|
+
const usageFetchersFor = { codex: usageReader, ...usageFetchers }
|
|
1031
|
+
const injectedUsage = { codex: usageReader !== readCodexUsage, claude: Boolean(usageFetchers.claude), grok: Boolean(usageFetchers.grok) }
|
|
1032
|
+
const usagePollers = createUsagePollers({
|
|
1033
|
+
agents: usageAgentsFor({ injected: injectedUsage }),
|
|
1034
|
+
fetchers: usageFetchersFor,
|
|
1035
|
+
onChange: () => sse.broadcast('sessions', (viewer) => viewFor(viewer)),
|
|
1036
|
+
onLog: (msg) => log(msg),
|
|
1037
|
+
})
|
|
680
1038
|
|
|
681
1039
|
async function handle(req, res) {
|
|
682
1040
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
|
|
@@ -719,7 +1077,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
719
1077
|
const canMachine = !shared || mayUseMachine(viewer.role)
|
|
720
1078
|
const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
|
|
721
1079
|
const parts = path.split('/').filter(Boolean) // ['api', ...]
|
|
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' })
|
|
1080
|
+
if (!canCards && ['cards', 'floor', 'presets', 'adapters', 'leases', 'models'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
|
|
723
1081
|
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.' })
|
|
724
1082
|
try {
|
|
725
1083
|
if (req.method === 'GET' && path === '/api/health') {
|
|
@@ -730,14 +1088,22 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
730
1088
|
}
|
|
731
1089
|
if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
|
|
732
1090
|
if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
|
|
1091
|
+
// Which models this machine can start each agent on (src/models.mjs).
|
|
1092
|
+
// Sits beside /api/adapters because it answers the second half of the
|
|
1093
|
+
// same question — an adapter says WHO can run, this says WHAT it runs as
|
|
1094
|
+
// — and it is guarded the same way: a guest picking a model is a guest
|
|
1095
|
+
// spending the owner's plan. Never waits on a child process: agy's and
|
|
1096
|
+
// grok's lists come off an hourly cache and refresh behind the answer.
|
|
1097
|
+
if (req.method === 'GET' && path === '/api/models') return send(res, 200, listModels())
|
|
733
1098
|
if (req.method === 'GET' && path === '/api/cards') {
|
|
734
1099
|
const cards = listCards()
|
|
735
|
-
return send(res, 200, { columns: columnsFor(cards), cards: cards.map(summarize) })
|
|
1100
|
+
return send(res, 200, { columns: columnsFor(cards), cards: cards.map((c) => summarize(c)) })
|
|
736
1101
|
}
|
|
737
1102
|
if (req.method === 'POST' && path === '/api/cards') {
|
|
738
1103
|
const body = await readBody(req)
|
|
739
1104
|
try {
|
|
740
|
-
|
|
1105
|
+
// a pipeline that names a file is a CLI flag, never a request body
|
|
1106
|
+
const card = await createCard(body, actor, { allowPipelineFile: false })
|
|
741
1107
|
sse.broadcast('card', forOwner(summarize(card)))
|
|
742
1108
|
return send(res, 201, { card: summarize(card) })
|
|
743
1109
|
} catch (err) {
|
|
@@ -748,7 +1114,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
748
1114
|
if (req.method === 'GET' && path === '/api/events') {
|
|
749
1115
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
|
|
750
1116
|
const cards = canCards ? listCards() : []
|
|
751
|
-
res.write(`event: hello\ndata: ${JSON.stringify({ columns: columnsFor(cards), cards: cards.map(summarize), sessions: viewFor(viewer), ts: new Date().toISOString() })}\n\n`)
|
|
1117
|
+
res.write(`event: hello\ndata: ${JSON.stringify({ columns: columnsFor(cards), cards: cards.map((c) => summarize(c)), sessions: viewFor(viewer), ts: new Date().toISOString() })}\n\n`)
|
|
752
1118
|
sse.add(res, cards, viewer, { token: presentedToken(req, url), loopback: isLoopbackRequest(req) })
|
|
753
1119
|
return
|
|
754
1120
|
}
|
|
@@ -761,6 +1127,15 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
761
1127
|
try {
|
|
762
1128
|
const patch = {}
|
|
763
1129
|
if (body.handoff_order !== undefined) patch.handoff_order = requireHandoffOrder(body.handoff_order)
|
|
1130
|
+
// the ladder and the rules around it (B.3). `handoff_order` is
|
|
1131
|
+
// rewritten from the ladder inside writePreferences, so the two
|
|
1132
|
+
// keys on disk can never disagree.
|
|
1133
|
+
if (body.handoff_ladder !== undefined) patch.handoff_ladder = requireHandoffLadder(body.handoff_ladder)
|
|
1134
|
+
if (body.climb_back !== undefined) patch.climb_back = requireClimbBack(body.climb_back)
|
|
1135
|
+
if (body.may_spend !== undefined) patch.may_spend = Boolean(body.may_spend)
|
|
1136
|
+
if (body.reserve !== undefined) patch.reserve = requireReserve(body.reserve)
|
|
1137
|
+
if (body.notify_terminal !== undefined) patch.notify_terminal = Boolean(body.notify_terminal)
|
|
1138
|
+
if (body.notify_board !== undefined) patch.notify_board = Boolean(body.notify_board)
|
|
764
1139
|
if (body.harness !== undefined) {
|
|
765
1140
|
// the board may narrow the policy or turn the feature off; turning
|
|
766
1141
|
// it on is the first-run consent flow, which shows what will be
|
|
@@ -771,7 +1146,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
771
1146
|
if (body.harness?.policy !== undefined && rank.indexOf(body.harness.policy) > rank.indexOf(current.policy)) return send(res, 400, { error: `the board may only narrow the harness policy (now ${current.policy}); widen it from a terminal: leg harness policy ${body.harness.policy}` })
|
|
772
1147
|
patch.harness = { policy: body.harness?.policy, enabled: body.harness?.enabled === false ? false : undefined }
|
|
773
1148
|
}
|
|
774
|
-
if (!Object.keys(patch).length) return send(res, 400, { error: 'nothing to change: send handoff_order or harness' })
|
|
1149
|
+
if (!Object.keys(patch).length) return send(res, 400, { error: 'nothing to change: send handoff_ladder, handoff_order, climb_back, may_spend, reserve, notify_terminal, notify_board or harness' })
|
|
775
1150
|
const preferences = writePreferences(patch)
|
|
776
1151
|
sse.broadcast('sessions', (v) => viewFor(v))
|
|
777
1152
|
return send(res, 200, { preferences })
|
|
@@ -820,11 +1195,32 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
820
1195
|
sse.broadcast('sessions', (v) => viewFor(v))
|
|
821
1196
|
return send(res, 200, { ok: true, request: hit })
|
|
822
1197
|
}
|
|
823
|
-
|
|
1198
|
+
// A guest owns their own terminal, so this route hands them the record
|
|
1199
|
+
// verbatim — including the two window percentages, the reading source
|
|
1200
|
+
// and the poller's usage_error, every one of which the LIST route
|
|
1201
|
+
// scrubs (`scrubOwnerUsage`, sessionsView). One GET of their own
|
|
1202
|
+
// session id was the whole share boundary walked around. The same scrub
|
|
1203
|
+
// runs here, and the poller's failure line is kept off their timeline:
|
|
1204
|
+
// it names the owner's reason and the clock it started.
|
|
1205
|
+
if (req.method === 'GET' && parts.length === 3) {
|
|
1206
|
+
const asGuest = shared && viewer.role !== 'owner'
|
|
1207
|
+
const events = readSessionEvents(id)
|
|
1208
|
+
return send(res, 200, {
|
|
1209
|
+
session: asGuest ? scrubOwnerUsage(sess) : sess,
|
|
1210
|
+
events: asGuest ? events.filter((e) => !isUsageFailureEvent(e)) : events,
|
|
1211
|
+
requests: readRequests(id),
|
|
1212
|
+
})
|
|
1213
|
+
}
|
|
824
1214
|
// the card's drawer: what the agent last said, what it changed, what it
|
|
825
1215
|
// has done. Only ever this viewer's own terminal; the guard above sent
|
|
826
1216
|
// anyone else away before we read a transcript.
|
|
827
|
-
if (req.method === 'GET' && parts[3] === 'detail')
|
|
1217
|
+
if (req.method === 'GET' && parts[3] === 'detail') {
|
|
1218
|
+
const detail = sessionDetail(sess)
|
|
1219
|
+
// the drawer of a guest's OWN terminal is theirs; the poller's line
|
|
1220
|
+
// about the owner's login is not (the same event the route above drops)
|
|
1221
|
+
if (shared && viewer.role !== 'owner') detail.events = detail.events.filter((e) => !isUsageFailureEvent(e))
|
|
1222
|
+
return send(res, 200, detail)
|
|
1223
|
+
}
|
|
828
1224
|
if (req.method === 'GET' && parts[3] === 'diff') {
|
|
829
1225
|
try {
|
|
830
1226
|
return send(res, 200, sessionDiff(sess, url.searchParams.get('file') ?? ''))
|
|
@@ -842,7 +1238,13 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
842
1238
|
}
|
|
843
1239
|
const body = await readBody(req)
|
|
844
1240
|
try {
|
|
845
|
-
|
|
1241
|
+
// one route, two shapes: the old list of agents, and the ladder of
|
|
1242
|
+
// rungs that replaced it. Sending either rewrites the other, the
|
|
1243
|
+
// same way preferences.json keeps them in step.
|
|
1244
|
+
const ladder = body.handoff_ladder !== undefined ? requireHandoffLadder(body.handoff_ladder) : null
|
|
1245
|
+
const order = ladder ? orderFromLadder(ladder) : requireHandoffOrder(body.handoff_order)
|
|
1246
|
+
const rungs = ladder ?? ladderFor({ handoff_order: order })
|
|
1247
|
+
const summary = ladder ? `handoff ladder changed to ${rungs.map((r) => rungLabel(r)).join(' → ')}` : `handoff order changed to ${order.join(' → ')}`
|
|
846
1248
|
const next = updateSession(id, (current) => {
|
|
847
1249
|
if (!['starting', 'running', 'warning', 'limit', 'waiting'].includes(current.status)) {
|
|
848
1250
|
const conflict = new Error(`handoff order cannot change while this terminal is ${current.status}`)
|
|
@@ -851,9 +1253,10 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
851
1253
|
}
|
|
852
1254
|
return {
|
|
853
1255
|
handoff_order: order,
|
|
854
|
-
|
|
1256
|
+
handoff_ladder: rungs,
|
|
1257
|
+
chain: candidates({ agent: current.agent, account: current.account, model: current.model ?? null, accounts: readAccounts(), order, ladder: rungs }),
|
|
855
1258
|
}
|
|
856
|
-
}, { event: { type: 'status', by: actor.id, summary
|
|
1259
|
+
}, { event: { type: 'status', by: actor.id, summary } })
|
|
857
1260
|
sse.broadcast('sessions', (v) => viewFor(v))
|
|
858
1261
|
return send(res, 200, { session: next })
|
|
859
1262
|
} catch (err) {
|
|
@@ -889,6 +1292,107 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
889
1292
|
log(`land requested for ${id} by ${actor.id}`)
|
|
890
1293
|
return send(res, 202, { ok: true, requested: 'land' })
|
|
891
1294
|
}
|
|
1295
|
+
// "I have to leave, keep going." The terminal's context becomes a card
|
|
1296
|
+
// that continues from the SAME rung, and the terminal then ends exactly
|
|
1297
|
+
// the way End ends it. Where the card works depends on what the
|
|
1298
|
+
// terminal had:
|
|
1299
|
+
// - its own worktree: the card adopts it (redesign G4; two worktrees
|
|
1300
|
+
// on one branch is the conflict machine the roadmap rejects) and
|
|
1301
|
+
// waits in the BACKLOG until the terminal has really stopped, since
|
|
1302
|
+
// `end` is a request the runner reads on its own poll and the
|
|
1303
|
+
// scheduler ticks once a second.
|
|
1304
|
+
// - no worktree of its own (the ordinary case): a checkout of its own
|
|
1305
|
+
// is cut from that branch and the uncommitted work is carried into
|
|
1306
|
+
// it, so the card continues from what the human was looking at
|
|
1307
|
+
// rather than from the last commit.
|
|
1308
|
+
if (req.method === 'POST' && parts[3] === 'end-as-card') {
|
|
1309
|
+
// a card is the pipeline board, which belongs to the owner and the
|
|
1310
|
+
// operators of this machine even when the terminal is the caller's
|
|
1311
|
+
if (!canCards) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
|
|
1312
|
+
if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
|
|
1313
|
+
if (!sess.repo) return send(res, 409, { error: 'this terminal is not in a git repository, so there is no branch for a card to continue on' })
|
|
1314
|
+
let bundle
|
|
1315
|
+
try {
|
|
1316
|
+
bundle = saveSessionBundle(sess, { messages: sessionMessages(sess), why: 'ended as a card' })
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
// nothing has been ended yet: refuse rather than end a terminal
|
|
1319
|
+
// whose context was never written down
|
|
1320
|
+
return send(res, 409, { error: `the hand-off bundle could not be written, so this terminal was left alone: ${scrub(err.message).slice(0, 200)}` })
|
|
1321
|
+
}
|
|
1322
|
+
const rungs = ladderFromCurrentRung(sess)
|
|
1323
|
+
// one model per adapter is all a card's chain can carry, so a ladder
|
|
1324
|
+
// with claude/fable and claude/opus in it keeps the FIRST, which is
|
|
1325
|
+
// the rung this terminal is on
|
|
1326
|
+
const models = {}
|
|
1327
|
+
for (const r of rungs) if (r.model && !models[r.agent]) models[r.agent] = r.model
|
|
1328
|
+
const task = `${sess.task ?? 'Continue the work already under way in this checkout.'}\n\nContinue from the bundle at ${bundle.path}.`
|
|
1329
|
+
const adopted = sess.worktree?.path && existsSync(sess.worktree.path) ? sess.worktree.path : null
|
|
1330
|
+
const trunkBranch = sess.worktree?.base || sess.branch || 'main'
|
|
1331
|
+
// The checkout the human has been working in. Read BEFORE anything is
|
|
1332
|
+
// created or ended: if the work cannot be read, nothing has happened
|
|
1333
|
+
// yet and the terminal is left exactly as it was.
|
|
1334
|
+
// the repository root, which is what `git status --porcelain`,
|
|
1335
|
+
// `git diff` and `git ls-files` all report paths against, so the
|
|
1336
|
+
// patch and the file list line up with the new checkout's root
|
|
1337
|
+
const workRoot = adopted ?? ((sess.repo && existsSync(sess.repo)) ? sess.repo : sess.cwd)
|
|
1338
|
+
let carried = null
|
|
1339
|
+
if (!adopted) {
|
|
1340
|
+
try { carried = captureUncommitted(workRoot) } catch (err) {
|
|
1341
|
+
return send(res, 409, { error: `the uncommitted work in this checkout could not be read, so this terminal was left alone: ${scrub(err.message).slice(0, 200)}` })
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
let card
|
|
1345
|
+
try {
|
|
1346
|
+
card = await createCard({
|
|
1347
|
+
repo: sess.repo, task,
|
|
1348
|
+
chain: rungs.map((r) => r.agent).join(',') || sess.agent,
|
|
1349
|
+
model: models,
|
|
1350
|
+
trunk: trunkBranch,
|
|
1351
|
+
title: sess.task ? String(sess.task).slice(0, 60) : `continued from ${id}`,
|
|
1352
|
+
// never queued here: the card is enqueued below, once its
|
|
1353
|
+
// checkout is its own and nothing else is writing to it
|
|
1354
|
+
queue: false,
|
|
1355
|
+
}, actor)
|
|
1356
|
+
} catch (err) {
|
|
1357
|
+
if (err instanceof CardInputError) return send(res, 400, { error: `the card could not be created, so this terminal was left alone: ${err.message}` })
|
|
1358
|
+
throw err
|
|
1359
|
+
}
|
|
1360
|
+
let carriedFiles = 0
|
|
1361
|
+
if (adopted) {
|
|
1362
|
+
ledgerUpdate(card.card_id, { patch: { lineage: { from: id }, worktree: adopted, worktree_adopted: true, worktree_branch: sess.worktree?.branch ?? null } })
|
|
1363
|
+
} else {
|
|
1364
|
+
try {
|
|
1365
|
+
const wt = ensureWorktree(sess.repo, card.card_id, { trunk: trunkBranch })
|
|
1366
|
+
carriedFiles = carryUncommitted(wt.path, carried)
|
|
1367
|
+
ledgerUpdate(card.card_id, { patch: { lineage: { from: id }, worktree: wt.path, worktree_branch: wt.branch } })
|
|
1368
|
+
} catch (err) {
|
|
1369
|
+
// nothing has been ended and nothing has been queued: take the
|
|
1370
|
+
// half-made card off the board rather than leave it there
|
|
1371
|
+
try { removeWorktree(sess.repo, card.card_id, { force: true }) } catch { /* it may never have been cut */ }
|
|
1372
|
+
try { rmSync(cardDir(card.card_id), { recursive: true, force: true }) } catch { /* the board never saw it */ }
|
|
1373
|
+
return send(res, 409, { error: `the work in this checkout could not be carried into a checkout of its own, so this terminal was left alone: ${scrub(err.message).slice(0, 200)}` })
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
// `handoff_written` is the ledger's word for "a bundle was written and
|
|
1377
|
+
// the work moved on", which is exactly what happened here. The audited
|
|
1378
|
+
// line with the actor is on the terminal's side, below.
|
|
1379
|
+
const where = adopted
|
|
1380
|
+
? ', in the terminal\'s own worktree, once that terminal has stopped'
|
|
1381
|
+
: `, in a worktree of its own cut from ${trunkBranch}${carriedFiles ? `, carrying ${carriedFiles} uncommitted file(s) over` : ''}`
|
|
1382
|
+
ledgerAppend(card.card_id, { actor, type: 'handoff_written', summary: `continued from terminal ${id}${where}` })
|
|
1383
|
+
requestControl(id, { end: true, by: actor.id })
|
|
1384
|
+
appendSessionEvent(id, { type: 'handed_off', by: actor.id, summary: `${actor.id} ended this terminal and kept it going as card ${card.card_id}` })
|
|
1385
|
+
updateSession(id, (cur) => ({ lineage: { ...(cur.lineage ?? {}), to: card.card_id } }))
|
|
1386
|
+
if (adopted) enqueueWhenSessionEnds(card.card_id, id, actor)
|
|
1387
|
+
else {
|
|
1388
|
+
try { humanAction(card.card_id, 'enqueue', {}, actor) } catch (err) { log(`end-as-card ${card.card_id}: ${err.message}`) }
|
|
1389
|
+
}
|
|
1390
|
+
log(`end-as-card for ${id} by ${actor.id}: ${card.card_id}${adopted ? ` in ${adopted} (queued when ${id} stops)` : ` in a checkout of its own${carriedFiles ? `, ${carriedFiles} file(s) carried` : ''}`}`)
|
|
1391
|
+
const next = summarize(readCard(card.card_id))
|
|
1392
|
+
sse.broadcast('card', forOwner(next))
|
|
1393
|
+
sse.broadcast('sessions', (v) => viewFor(v))
|
|
1394
|
+
return send(res, 201, { card: next, bundle: { id: bundle.id, path: bundle.path }, carried: { files: carriedFiles, adopted: Boolean(adopted) } })
|
|
1395
|
+
}
|
|
892
1396
|
if (req.method === 'POST' && (parts[3] === 'handoff' || parts[3] === 'end')) {
|
|
893
1397
|
if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
|
|
894
1398
|
if (parts[3] === 'end') {
|
|
@@ -901,19 +1405,21 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
901
1405
|
const body = await readBody(req)
|
|
902
1406
|
let target = null
|
|
903
1407
|
if (body && body.agent !== undefined && body.agent !== null && body.agent !== '') {
|
|
904
|
-
const want = { agent: String(body.agent), account: String(body.account ?? 'default') }
|
|
1408
|
+
const want = { agent: String(body.agent), account: String(body.account ?? 'default'), model: body.model ? String(body.model).toLowerCase() : null }
|
|
905
1409
|
const order = normalizeHandoffOrder(sess.handoff_order)
|
|
906
|
-
const
|
|
907
|
-
const
|
|
908
|
-
const
|
|
909
|
-
|
|
1410
|
+
const ladder = ladderFor(sess)
|
|
1411
|
+
const chain = candidates({ agent: sess.agent, account: sess.account, model: sess.model ?? null, accounts: readAccounts(), order, ladder })
|
|
1412
|
+
const hit = chain.find((c) => c.agent === want.agent && c.account === want.account && (want.model ? (c.model ?? null) === want.model : true))
|
|
1413
|
+
const label = rungLabel(want)
|
|
1414
|
+
if (!hit) return send(res, 400, { error: `${label} is not a destination for this terminal (${chain.map((c) => rungLabel(c)).join(', ') || 'none'})` })
|
|
910
1415
|
if (sess.installed && sess.installed[want.agent] === false) return send(res, 409, { error: `${label} is not installed on this machine` })
|
|
911
1416
|
const u = readUsage(want.agent, want.account)
|
|
912
1417
|
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
|
-
|
|
1418
|
+
if (hit.model && wallActive(u.walls?.[hit.model])) return send(res, 409, { error: `${label} is out until ${fmtReset(u.walls[hit.model].limited_until)}; pick another rung or use Hand off now without a destination` })
|
|
1419
|
+
target = { agent: hit.agent, account: hit.account, ...(hit.model ? { model: hit.model } : {}) }
|
|
914
1420
|
}
|
|
915
1421
|
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
|
|
1422
|
+
log(`handoff requested for ${id} by ${actor.id}${target ? ` to ${rungLabel(target)}` : ''}`)
|
|
917
1423
|
return send(res, 200, { ok: true, requested: 'handoff', target })
|
|
918
1424
|
}
|
|
919
1425
|
if (req.method === 'DELETE' && parts.length === 3) {
|
|
@@ -1039,6 +1545,48 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
1039
1545
|
sse.broadcast('removed', forOwner({ card_id: id }))
|
|
1040
1546
|
return send(res, 200, { removed: id })
|
|
1041
1547
|
}
|
|
1548
|
+
// Take over: sit down in the card's worktree yourself. The card is
|
|
1549
|
+
// paused first (its child is killed and its bundle written by the
|
|
1550
|
+
// existing transition), then Leg hands back the one command that opens
|
|
1551
|
+
// a terminal there. This is the only command the board ever hands a
|
|
1552
|
+
// human, because a browser tab cannot open one (redesign C.4).
|
|
1553
|
+
if (req.method === 'POST' && parts[3] === 'take-over') {
|
|
1554
|
+
if (TERMINAL.includes(card.status)) return send(res, 409, { error: `card ${id} is ${card.status}: there is nothing running to take over. Rerun it, or open its worktree yourself.` })
|
|
1555
|
+
const raw = String(summarize(card).active_adapter ?? '')
|
|
1556
|
+
const agent = [raw, raw.replace(/^fake-/, '')].find((n) => SUPERVISED_AGENTS.includes(n)) ?? null
|
|
1557
|
+
if (!agent) return send(res, 409, { error: `this card's current leg runs ${raw || 'no agent'}, which is not one of the agents leg can open a terminal for (${SUPERVISED_AGENTS.join(', ')})` })
|
|
1558
|
+
// Every non-terminal status moves to `paused` before the command is
|
|
1559
|
+
// handed back, not just `running`. A `queued` or `handing_off` card
|
|
1560
|
+
// is in the set the scheduler starts from, and it ticks once a
|
|
1561
|
+
// second: the human would paste this command into a worktree an
|
|
1562
|
+
// agent had just been launched in, which is the collision Take over
|
|
1563
|
+
// exists to prevent. The transition also writes the actor's own
|
|
1564
|
+
// `taken_over` line, so the audit trail names who has the checkout.
|
|
1565
|
+
let next
|
|
1566
|
+
try { next = humanAction(id, 'take_over', {}, actor) } catch (err) {
|
|
1567
|
+
if (err instanceof IllegalTransition) return send(res, 409, { error: err.message })
|
|
1568
|
+
throw err
|
|
1569
|
+
}
|
|
1570
|
+
// A card that never ran has no checkout of its own, and the terminal
|
|
1571
|
+
// that takes it over must never open in the human's main checkout
|
|
1572
|
+
// (src/attach.mjs cardWorkRoot refuses that). Cut its worktree now, on
|
|
1573
|
+
// the trunk the card would have used, so the command below has a
|
|
1574
|
+
// place to open.
|
|
1575
|
+
const cur = readCard(id) ?? next
|
|
1576
|
+
if (!(cur.worktree && existsSync(cur.worktree)) && cur.repo) {
|
|
1577
|
+
try {
|
|
1578
|
+
const wt = ensureWorktree(cur.repo, id, { trunk: cur.trunk || trunkFor(cur.repo).branch || 'main' })
|
|
1579
|
+
ledgerUpdate(id, { patch: { worktree: wt.path, worktree_branch: wt.branch } })
|
|
1580
|
+
} catch (err) {
|
|
1581
|
+
return send(res, 409, { error: `could not cut a checkout for ${id}: ${err.message}` })
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
const command = `leg ${agent} --resume-card ${id}`
|
|
1585
|
+
log(`take-over for ${id} by ${actor.id}: ${command}`)
|
|
1586
|
+
const view = summarize(readCard(id) ?? next)
|
|
1587
|
+
sse.broadcast('card', forOwner(view))
|
|
1588
|
+
return send(res, 200, { card: view, command })
|
|
1589
|
+
}
|
|
1042
1590
|
if (req.method === 'POST' && parts[3]) {
|
|
1043
1591
|
const map = { run: 'enqueue', queue: 'enqueue', approve: 'approve', reassign: 'reassign', pause: 'pause', resume: 'resume', kill: 'kill', handoff: 'handoff_now', 'handoff-now': 'handoff_now', rerun: 'rerun' }
|
|
1044
1592
|
const action = map[parts[3]]
|
|
@@ -1099,9 +1647,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
1099
1647
|
sched.run().catch((err) => log(`scheduler crashed: ${err.message}`))
|
|
1100
1648
|
}
|
|
1101
1649
|
if (usagePolling) {
|
|
1102
|
-
|
|
1103
|
-
usageTimer = setInterval(refreshCodexAccounts, 60000)
|
|
1104
|
-
usageTimer.unref?.()
|
|
1650
|
+
usagePollers.start().catch((err) => log(`usage polling: ${err.message}`))
|
|
1105
1651
|
}
|
|
1106
1652
|
if (loopbackCompanion) {
|
|
1107
1653
|
loopbackCompanion.on('error', (err) => log(`loopback companion: ${err.message}`))
|
|
@@ -1112,8 +1658,9 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
1112
1658
|
})
|
|
1113
1659
|
},
|
|
1114
1660
|
async stop() {
|
|
1115
|
-
|
|
1116
|
-
|
|
1661
|
+
usagePollers.stop()
|
|
1662
|
+
for (const t of enqueueTimers.values()) clearTimeout(t)
|
|
1663
|
+
enqueueTimers.clear()
|
|
1117
1664
|
sse.stop()
|
|
1118
1665
|
if (sched) sched.stop()
|
|
1119
1666
|
if (loopbackCompanion) await new Promise((r) => { loopbackCompanion.closeAllConnections?.(); loopbackCompanion.close(() => r()) })
|