@ucsandman/legcli 0.11.0 → 0.12.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 +142 -0
- package/README.md +95 -65
- package/bin/leg.mjs +123 -14
- package/docs/DECISIONS.md +10 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +41 -0
- package/docs/ROADMAP-v2.md +50 -5
- package/docs/VOCABULARY.md +27 -0
- package/docs/board-guide.md +401 -66
- package/docs/cli-contracts.md +199 -5
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +58 -0
- 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/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/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/attach.mjs +328 -46
- package/src/audit.mjs +1 -1
- package/src/board/board.css +119 -9
- package/src/board/board.js +482 -106
- package/src/board/index.html +67 -7
- package/src/board/sessions.js +1347 -117
- 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/orchestrator.mjs +13 -4
- package/src/preferences.mjs +214 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +546 -47
- package/src/sessions.mjs +17 -1
- package/src/taps/claude-usage.mjs +91 -2
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/usage.mjs +407 -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,29 @@ 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, recordUsage, usageIsStale, candidates, isAvailable, fmtReset } from './usage.mjs'
|
|
36
|
+
import { readUsage, recordUsage, usageIsStale, candidates, isAvailable, fmtReset, binding, evaluateLadder, rungLabel, wallActive } from './usage.mjs'
|
|
35
37
|
import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
|
|
36
|
-
import { readCodexUsage } from './taps/codex.mjs'
|
|
37
|
-
import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
|
|
38
|
+
import { readCodexUsage, transcriptTail as codexTranscriptTail } from './taps/codex.mjs'
|
|
39
|
+
import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder, ladderFor, requireHandoffLadder, requireClimbBack, requireReserve, orderFromLadder } from './preferences.mjs'
|
|
40
|
+
import { isDownshift } from './buckets.mjs'
|
|
38
41
|
import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
|
|
39
42
|
import { listWorktrees } from './history/worktrees.mjs'
|
|
40
43
|
|
|
@@ -75,16 +78,192 @@ export function columnOf(card) {
|
|
|
75
78
|
return card.station
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
|
|
81
|
+
// ---- the work stat on a live card's row (redesign C.3) --------------------
|
|
82
|
+
// `4 files, +212 -18`. Parsed from git's own one-line summary, never counted
|
|
83
|
+
// here: a part the line does not carry is left off the object, so the row can
|
|
84
|
+
// print only what was measured and never estimate the rest.
|
|
85
|
+
export function parseShortstat(line) {
|
|
86
|
+
const text = String(line ?? '')
|
|
87
|
+
const files = /(\d+)\s+files?\s+changed/.exec(text)
|
|
88
|
+
const ins = /(\d+)\s+insertions?\(\+\)/.exec(text)
|
|
89
|
+
const del = /(\d+)\s+deletions?\(-\)/.exec(text)
|
|
90
|
+
if (!files && !ins && !del) return null
|
|
91
|
+
const out = {}
|
|
92
|
+
if (files) out.files = parseInt(files[1], 10)
|
|
93
|
+
if (ins) out.insertions = parseInt(ins[1], 10)
|
|
94
|
+
if (del) out.deletions = parseInt(del[1], 10)
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function measureWork(card) {
|
|
99
|
+
if (!card.worktree || !existsSync(card.worktree)) return null
|
|
100
|
+
const base = card.trunk || 'main'
|
|
101
|
+
const r = spawnSync('git', ['diff', '--shortstat', `${base}..HEAD`], { cwd: card.worktree, windowsHide: true, encoding: 'utf8', timeout: 8000, env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
102
|
+
if (r.status !== 0) return null
|
|
103
|
+
return parseShortstat(r.stdout)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// One `git diff` per live card per push would put a subprocess per card inside
|
|
107
|
+
// the board's one event loop, which is the stall canLandFor already exists to
|
|
108
|
+
// avoid. Keyed on the card's own revision, with the expiries spread across the
|
|
109
|
+
// window so twenty cards never re-read together.
|
|
110
|
+
const WORK_TTL = 15000
|
|
111
|
+
const workCache = new Map()
|
|
112
|
+
function workFor(card) {
|
|
113
|
+
const key = `${card.card_id}|${card.updated_at ?? ''}`
|
|
114
|
+
const hit = workCache.get(key)
|
|
115
|
+
if (hit && Date.now() < hit.until) return hit.data
|
|
116
|
+
let data = null
|
|
117
|
+
try { data = measureWork(card) } catch { data = null }
|
|
118
|
+
if (workCache.size > 300) workCache.clear()
|
|
119
|
+
workCache.set(key, { until: Date.now() + WORK_TTL / 2 + Math.random() * WORK_TTL, data })
|
|
120
|
+
return data
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The test and land verdicts a live card has already earned. Both are read from
|
|
124
|
+
// the card's own ledger, because that is where each station records its result:
|
|
125
|
+
// a test station writes no run.json (src/stations/test.mjs), and a landing is a
|
|
126
|
+
// `landed` / `bounced` / `failed` event (src/chain.mjs). Last one wins; a card
|
|
127
|
+
// that has run neither carries neither key.
|
|
128
|
+
export function cardOutcomes(events) {
|
|
129
|
+
const out = {}
|
|
130
|
+
for (const ev of events ?? []) {
|
|
131
|
+
const summary = String(ev.summary ?? '')
|
|
132
|
+
const m = /^test (green|red)\b/.exec(summary)
|
|
133
|
+
if (m) out.tests = { state: m[1], at: ev.ts }
|
|
134
|
+
if (ev.type === 'landed') {
|
|
135
|
+
const sha = /\b([0-9a-f]{7,40})\b/.exec(summary)
|
|
136
|
+
out.land = { state: 'landed', reason: null, sha: sha ? sha[1] : null }
|
|
137
|
+
} else if (ev.type === 'bounced' && /^land bounced/.test(summary)) {
|
|
138
|
+
out.land = { state: 'bounced', reason: ev.body ? String(ev.body).slice(0, 200) : summary, sha: null }
|
|
139
|
+
} else if (ev.type === 'failed' && /\bland\b/.test(summary)) {
|
|
140
|
+
out.land = { state: 'failed', reason: ev.body ? String(ev.body).slice(0, 200) : summary, sha: null }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The last messages a hand-off bundle quotes, from the agent's own transcript.
|
|
147
|
+
// An agent Leg cannot read a transcript for contributes none, and the bundle is
|
|
148
|
+
// written from the record alone rather than with invented text.
|
|
149
|
+
function sessionMessages(s) {
|
|
150
|
+
try {
|
|
151
|
+
if (s.agent === 'claude') return claudeTranscriptTail(s.transcript_path)
|
|
152
|
+
if (s.agent === 'codex') return codexTranscriptTail(s.transcript_path)
|
|
153
|
+
} catch { /* an unreadable transcript is not a reason to refuse the bundle */ }
|
|
154
|
+
return []
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// This terminal's ladder from the rung it is standing on, downward. A card made
|
|
158
|
+
// from a terminal starts where the terminal is, not at the top: the rungs above
|
|
159
|
+
// it are the ones this work has already used up (redesign C.4).
|
|
160
|
+
export function ladderFromCurrentRung(session) {
|
|
161
|
+
const ladder = ladderFor(session).filter((r) => r && r.agent)
|
|
162
|
+
const exact = ladder.findIndex((r) => r.agent === session.agent && (r.account ?? 'default') === (session.account ?? 'default') && (r.model ?? null) === (session.model ?? null))
|
|
163
|
+
const byAgent = exact >= 0 ? exact : ladder.findIndex((r) => r.agent === session.agent)
|
|
164
|
+
return byAgent >= 0 ? ladder.slice(byAgent) : ladder
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- carrying a checkout's uncommitted work into another one --------------
|
|
168
|
+
// "End, and keep going as a card" on a lone terminal (the ordinary case: a
|
|
169
|
+
// terminal only cuts a worktree of its own when a second live session shares
|
|
170
|
+
// the checkout, src/attach.mjs isolate()) has to move the work the human was
|
|
171
|
+
// looking at, not just the branch it sits on. Captured in two halves, because
|
|
172
|
+
// git keeps them apart: a patch of everything it tracks, staged and unstaged
|
|
173
|
+
// (`git diff HEAD --binary`), and the bytes of the files it does not
|
|
174
|
+
// (`git ls-files --others`). The second half is the one `git stash create`
|
|
175
|
+
// cannot carry, and it is where a terminal's newest file always is.
|
|
176
|
+
const CARRY_SKIP = ['.git', '.leg-worktrees', '.baton-worktrees', '.context-handoffs', '.leg', '.baton', 'node_modules']
|
|
177
|
+
const CARRY_MAX_BYTES = 8 * 1024 * 1024
|
|
178
|
+
function gitIn(dir, args, opts = {}) {
|
|
179
|
+
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 })
|
|
180
|
+
if (r.error) throw r.error
|
|
181
|
+
if (r.status !== 0) throw new Error(`git ${args[0]} failed: ${String(r.stderr ?? '').trim().slice(0, 200)}`)
|
|
182
|
+
return r.stdout
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function captureUncommitted(dir) {
|
|
186
|
+
const status = gitIn(dir, ['status', '--porcelain'])
|
|
187
|
+
if (!status.trim()) return null
|
|
188
|
+
// --binary keeps a changed image or lockfile intact; the output is ASCII
|
|
189
|
+
const patch = gitIn(dir, ['diff', 'HEAD', '--binary'])
|
|
190
|
+
const files = []
|
|
191
|
+
for (const rel of gitIn(dir, ['ls-files', '--others', '--exclude-standard']).split('\n').map((s) => s.trim()).filter(Boolean)) {
|
|
192
|
+
if (CARRY_SKIP.includes(rel.split('/')[0])) continue
|
|
193
|
+
const from = join(dir, rel)
|
|
194
|
+
let st
|
|
195
|
+
try { st = statSync(from) } catch { continue }
|
|
196
|
+
if (!st.isFile()) continue
|
|
197
|
+
// refuse loudly rather than carry half the work: the caller turns this
|
|
198
|
+
// into a 409 and the terminal is left alone
|
|
199
|
+
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`)
|
|
200
|
+
files.push({ rel, data: readFileSync(from) })
|
|
201
|
+
}
|
|
202
|
+
if (!patch.trim() && !files.length) return null
|
|
203
|
+
const names = new Set(status.trim().split('\n').map((l) => l.slice(3).trim()).filter(Boolean))
|
|
204
|
+
for (const f of files) names.add(f.rel)
|
|
205
|
+
return { patch, files, names: [...names] }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function carryUncommitted(dir, carried) {
|
|
209
|
+
if (!carried) return 0
|
|
210
|
+
if (carried.patch.trim()) {
|
|
211
|
+
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' } })
|
|
212
|
+
if (r.status !== 0) throw new Error(`the tracked changes did not apply: ${String(r.stderr ?? '').trim().slice(0, 200)}`)
|
|
213
|
+
}
|
|
214
|
+
for (const f of carried.files) {
|
|
215
|
+
const to = join(dir, f.rel)
|
|
216
|
+
mkdirSync(dirname(to), { recursive: true })
|
|
217
|
+
writeFileSync(to, f.data)
|
|
218
|
+
}
|
|
219
|
+
return (carried.names ?? []).length
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The cards waiting on a human, for the terminals verdict to read (redesign
|
|
223
|
+
// C.5): `card 3e1c has waited on you for 12 minutes.` needs the id, the title,
|
|
224
|
+
// the station and the moment it stopped, so a bare count cannot write the
|
|
225
|
+
// sentence the spec asks for. The oldest one is `first`, because that is the
|
|
226
|
+
// one the sentence names.
|
|
227
|
+
// Cached for a beat: listCards() reads one file per card, and this is computed
|
|
228
|
+
// on every sessions push.
|
|
229
|
+
const CARDS_WAITING_TTL = 5000
|
|
230
|
+
const WAITING_STATUSES = ['needs_approval', 'waiting_human']
|
|
231
|
+
let cardsWaitingCache = { at: 0, data: null }
|
|
232
|
+
function cardsWaiting() {
|
|
233
|
+
if (cardsWaitingCache.data && Date.now() - cardsWaitingCache.at < CARDS_WAITING_TTL) return cardsWaitingCache.data
|
|
234
|
+
let waiting = []
|
|
235
|
+
try { waiting = listCards().filter((c) => WAITING_STATUSES.includes(c.status)) } catch { waiting = [] }
|
|
236
|
+
// `updated_at` is when the card reached this state, which is what "has
|
|
237
|
+
// waited on you for 12 minutes" measures from
|
|
238
|
+
waiting.sort((a, b) => (String(a.updated_at ?? '') < String(b.updated_at ?? '') ? -1 : 1))
|
|
239
|
+
const first = waiting[0] ?? null
|
|
240
|
+
const data = {
|
|
241
|
+
count: waiting.length,
|
|
242
|
+
first: first ? { id: first.card_id, title: first.title ?? null, station: first.station ?? null, since: first.updated_at ?? null } : null,
|
|
243
|
+
}
|
|
244
|
+
cardsWaitingCache = { at: Date.now(), data }
|
|
245
|
+
return data
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// `events` is the card's ledger, already read by the caller. readEvents() does
|
|
249
|
+
// a readdir, a full readFileSync, a JSON.parse per line and a sort every call,
|
|
250
|
+
// and this function needed it three times per push (the last event, the legs
|
|
251
|
+
// that started at a finished station, and the test/land outcomes of a live
|
|
252
|
+
// card) on the one event loop the board serves every request from. Read once,
|
|
253
|
+
// reused; a caller that has no events passes none and pays for one read.
|
|
254
|
+
export function summarize(card, events = null) {
|
|
79
255
|
const st = (card.pipeline ?? []).find((s) => s.name === card.station) ?? null
|
|
80
|
-
|
|
256
|
+
// `cards.map(summarize)` would hand this the array index, so the type is
|
|
257
|
+
// checked rather than the emptiness
|
|
258
|
+
const evs = Array.isArray(events) ? events : readEvents(card.card_id)
|
|
259
|
+
const last = evs.length ? evs[evs.length - 1] : null
|
|
81
260
|
const runs = readRuns(card.card_id)
|
|
82
261
|
const activeRun = runs.find((r) => ['launching', 'running'].includes(r.status)) ?? null
|
|
83
262
|
// Once a station is over (done/failed) card.leg is reset, so the rail is
|
|
84
263
|
// rebuilt from the legs that actually started at this station.
|
|
85
264
|
const terminal = ['done', 'failed', 'killed'].includes(card.status)
|
|
86
265
|
const startedLegs = terminal && st?.kind === 'agent'
|
|
87
|
-
?
|
|
266
|
+
? evs.filter((ev) => ev.type === 'leg_started' && ev.station === card.station).map((ev) => ev.leg)
|
|
88
267
|
: []
|
|
89
268
|
const lastLeg = startedLegs.length ? Math.max(...startedLegs) : card.leg
|
|
90
269
|
// card.leg is reset when the station ends, so a finished card read its adapter
|
|
@@ -101,12 +280,23 @@ export function summarize(card) {
|
|
|
101
280
|
if (i > card.leg) return 'pending'
|
|
102
281
|
return ['running', 'handing_off'].includes(card.status) ? 'active' : 'pending'
|
|
103
282
|
}
|
|
283
|
+
// What a live card is carrying right now: the diff it has built, the last
|
|
284
|
+
// test verdict, the last land verdict, and the rung it is on. Measured only
|
|
285
|
+
// for a card that is still going: a finished one is a single line in the
|
|
286
|
+
// ledger, and a git subprocess for each of ten of those buys nothing (C.1).
|
|
287
|
+
const live = NON_TERMINAL.includes(card.status)
|
|
288
|
+
const work = live ? workFor(card) : null
|
|
289
|
+
const outcomes = live ? cardOutcomes(evs) : {}
|
|
104
290
|
return {
|
|
105
291
|
...card,
|
|
106
292
|
column: columnOf(card),
|
|
107
293
|
station_kind: st?.kind ?? null,
|
|
108
294
|
active_adapter: entry?.adapter ?? null,
|
|
109
295
|
active_mode: entry?.mode ?? null,
|
|
296
|
+
...(work ? { work } : {}),
|
|
297
|
+
...(outcomes.tests ? { tests: outcomes.tests } : {}),
|
|
298
|
+
...(outcomes.land ? { land: outcomes.land } : {}),
|
|
299
|
+
...(live && entry ? { agent_model: { agent: entry.adapter ?? null, model: entry.model ?? null } } : {}),
|
|
110
300
|
chain_view: st?.kind === 'agent' ? st.chain.map((e, i) => ({
|
|
111
301
|
adapter: e.adapter, mode: e.mode ?? null, approve: Boolean(e.approve),
|
|
112
302
|
// a leg before the last one ended in a handoff (only the last leg can complete a station)
|
|
@@ -293,8 +483,18 @@ function trunkFor(repo) {
|
|
|
293
483
|
const hit = trunkCache.get(repo)
|
|
294
484
|
if (hit && Date.now() - hit.at < 15000) return hit.data
|
|
295
485
|
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 }
|
|
486
|
+
// The branch this repo actually calls its trunk: origin's default if there is
|
|
487
|
+
// one, then the usual names, then whatever this checkout is on. A repo whose
|
|
488
|
+
// default is `develop` used to read as "main" here, and the board's one-line
|
|
489
|
+
// entry then posted a card against a branch that does not exist.
|
|
296
490
|
let branch = null
|
|
297
|
-
|
|
491
|
+
const originHead = g(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
|
|
492
|
+
if (originHead) {
|
|
493
|
+
const name = originHead.replace(/^origin\//, '')
|
|
494
|
+
if (name && g(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`]) !== null) branch = name
|
|
495
|
+
}
|
|
496
|
+
if (!branch) for (const b of ['main', 'master', 'trunk']) if (g(['rev-parse', '--verify', '--quiet', b]) !== null) { branch = b; break }
|
|
497
|
+
if (!branch) branch = g(['symbolic-ref', '--short', 'HEAD']) || null
|
|
298
498
|
const log = branch ? g(['log', '--format=%h%x1f%s%x1f%cr%x1f%an', '-6', branch]) : null
|
|
299
499
|
const data = {
|
|
300
500
|
repo, repo_name: repo.split(/[\\/]/).filter(Boolean).pop(), branch,
|
|
@@ -319,14 +519,28 @@ function withLandings(t, landings) {
|
|
|
319
519
|
// What another human sees of a terminal that is not theirs: that it is there,
|
|
320
520
|
// nothing it has said, read or written. No task, no paths, no file names, no
|
|
321
521
|
// limit text, no bundle, no events.
|
|
522
|
+
// No usage either. `limits` is the five-hour and seven-day percentage of this
|
|
523
|
+
// machine's login, written onto the record by the poller and by every claude
|
|
524
|
+
// status line, and `warning` carries the same percentage with the clock it
|
|
525
|
+
// resets on. Both are the number the capacity drawer, the accounts payload and
|
|
526
|
+
// guestReason all withhold, so neither may ride out on a row instead
|
|
527
|
+
// (.design/BOARD-DESIGN.md 6.13). The band survives, the figure does not.
|
|
322
528
|
function redactSession(s) {
|
|
323
529
|
return {
|
|
324
530
|
session_id: s.session_id, agent: s.agent, account: s.account, status: s.status, active: s.active,
|
|
325
531
|
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
|
-
|
|
532
|
+
owner: s.owner ?? null, lineage: s.lineage ?? null,
|
|
533
|
+
warning: s.warning ? { window: s.warning.window } : null,
|
|
534
|
+
limit: s.limit ? { reason: s.limit.reason } : null,
|
|
535
|
+
// `ahead` is owner-only for the same reason `files` is: how far someone
|
|
536
|
+
// else's branch has moved is a fact about their work, and the register
|
|
537
|
+
// prints it beside the dirty count that is already withheld here.
|
|
538
|
+
// `waiting` and `model` are owner-only, and a guest keeps both on their own
|
|
539
|
+
// terminal (that row is not redacted at all). On someone else's row they are
|
|
540
|
+
// dropped: `waiting` carries either the verbatim question an agent asked —
|
|
541
|
+
// the prompt text this function exists to hide — or a reset time, which is
|
|
542
|
+
// this machine's usage data (.design/BOARD-DESIGN.md 6.13); and `model` is
|
|
543
|
+
// which of this machine's model buckets someone else's work is spending.
|
|
330
544
|
worktree: s.worktree ? { branch: s.worktree.branch, base: s.worktree.base } : null,
|
|
331
545
|
// the branch is already on the worktree chip: naming it again costs nothing
|
|
332
546
|
// and is what the board's land line reads
|
|
@@ -338,6 +552,40 @@ function redactSession(s) {
|
|
|
338
552
|
}
|
|
339
553
|
}
|
|
340
554
|
|
|
555
|
+
// A guest's OWN terminal is not redacted: it is their work. The login it runs
|
|
556
|
+
// on is still this machine's, though, and every figure on the record that was
|
|
557
|
+
// measured from the owner's accounts is the same secret `capacity`, `buckets`
|
|
558
|
+
// and the accounts payload already withhold: the two window percentages
|
|
559
|
+
// (`limits`), the near-wall warning, the reset clock on a limit or an all-out
|
|
560
|
+
// wait, and the name of the reading source. The row keeps every fact about the
|
|
561
|
+
// work and loses every figure about the login (.design/BOARD-DESIGN.md 6.13).
|
|
562
|
+
function scrubOwnerUsage(s) {
|
|
563
|
+
const out = { ...s }
|
|
564
|
+
delete out.limits
|
|
565
|
+
delete out.all_out
|
|
566
|
+
delete out.usage_source
|
|
567
|
+
delete out.usage_error
|
|
568
|
+
// the band survives, the figure and the clock do not: their own row may say
|
|
569
|
+
// it is near a wall, the same way a redacted row does
|
|
570
|
+
if (out.warning) out.warning = { window: out.warning.window }
|
|
571
|
+
if (out.limit) out.limit = { ...out.limit, resets_at: null }
|
|
572
|
+
// a 'reset' wait is a reset time with a sentence around it; the guest still
|
|
573
|
+
// learns that their terminal is waiting for one
|
|
574
|
+
if (out.waiting && out.waiting.type === 'reset') out.waiting = { type: 'reset', since: out.waiting.since ?? null }
|
|
575
|
+
return out
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// A guest owns their own terminal, so its picker rows are theirs to read, but
|
|
579
|
+
// a rung's reason can quote this machine's usage ("at 63%, not below 80%",
|
|
580
|
+
// "past your 10% reserve"), and a percentage of this machine's login belongs to
|
|
581
|
+
// nobody else (.design/BOARD-DESIGN.md 6.13). Only the reasons that say nothing
|
|
582
|
+
// about how much is left survive the crossing.
|
|
583
|
+
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'])
|
|
584
|
+
function guestReason(reason) {
|
|
585
|
+
if (!reason) return null
|
|
586
|
+
return GUEST_REASONS.has(reason) ? reason : 'not available right now'
|
|
587
|
+
}
|
|
588
|
+
|
|
341
589
|
function visibleSessionFile(file) {
|
|
342
590
|
const value = String(file ?? '')
|
|
343
591
|
return !value.includes('*** Begin Patch') && !value.includes('*** End Patch')
|
|
@@ -353,13 +601,24 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
353
601
|
const list = reapLost(listSessions())
|
|
354
602
|
const ov = overlaps(list)
|
|
355
603
|
const configuredAccounts = readAccounts()
|
|
604
|
+
// the spending rules the picker has to print, read once for the whole view
|
|
605
|
+
const prefs = readPreferences()
|
|
356
606
|
const sessions = list.map((s) => {
|
|
357
607
|
const land = readLand(s.session_id)
|
|
358
608
|
const handoffOrder = normalizeHandoffOrder(s.handoff_order)
|
|
359
|
-
|
|
609
|
+
// this terminal's own ladder, else the long-hand form of its order
|
|
610
|
+
const handoffLadder = ladderFor(s)
|
|
611
|
+
const from = { agent: s.agent, account: s.account, model: s.model ?? null }
|
|
612
|
+
const chain = candidates({ agent: s.agent, account: s.account, model: s.model ?? null, accounts: configuredAccounts, order: handoffOrder, ladder: handoffLadder })
|
|
360
613
|
const preferredNext = chain[0] ?? null
|
|
361
614
|
const availabilityKnown = Boolean(s.installed)
|
|
362
|
-
|
|
615
|
+
// one pass, the same one the chooser makes, so a greyed row in the picker
|
|
616
|
+
// and the rung an automatic hand-off would take can never disagree. The
|
|
617
|
+
// picker is a human pressing a button, so the reserve is a note here, not
|
|
618
|
+
// a refusal (B.3).
|
|
619
|
+
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 })
|
|
620
|
+
const open = availabilityKnown ? rungs.find((r) => r.ok) : null
|
|
621
|
+
const eligibleNext = open ? { agent: open.agent, account: open.account, ...(open.model ? { model: open.model } : {}) } : null
|
|
363
622
|
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
623
|
return {
|
|
365
624
|
...s,
|
|
@@ -370,19 +629,38 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
370
629
|
// every destination this terminal could be handed to, each with the
|
|
371
630
|
// reason it cannot be picked right now. The board's picker renders this
|
|
372
631
|
// list directly, so a greyed option always carries its own explanation.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
632
|
+
// one row per RUNG now: `claude/opus` and `claude/sonnet` are separate
|
|
633
|
+
// destinations, each with what it costs, whether it keeps the
|
|
634
|
+
// conversation, and the reason it cannot (or should not) be picked.
|
|
635
|
+
handoff_targets: rungs.map((r) => ({
|
|
636
|
+
agent: r.agent,
|
|
637
|
+
account: r.account,
|
|
638
|
+
model: r.model ?? null,
|
|
639
|
+
available: r.ok,
|
|
640
|
+
// A row that CAN be picked has nothing to explain: the reserve and the
|
|
641
|
+
// `below:N` rules come back as a note on an ok row (usage.mjs), and
|
|
642
|
+
// generalising that note reads as a refusal beside a button that works.
|
|
643
|
+
// A row that is blocked keeps a reason a guest may read.
|
|
644
|
+
reason: guest ? (r.ok ? null : guestReason(r.reason)) : r.reason,
|
|
645
|
+
resets_at: !guest ? (r.resets_at ?? null) : null,
|
|
646
|
+
// the probe in fixtures/live/claude/resume-model-probe.json: a claude
|
|
647
|
+
// downshift resumes the same conversation; everything else is primed
|
|
648
|
+
// from the bundle, codex included until its own resume is observed
|
|
649
|
+
keeps_conversation: Boolean(isDownshift(from, r) && r.agent === 'claude' && s.agent_session_id),
|
|
650
|
+
// the cost word is not static: `credits` on a claude/fable rung means
|
|
651
|
+
// this machine's login has usage credits switched on (preferences.mjs
|
|
652
|
+
// rungCost reads extra_usage.enabled), which is a fact about the
|
|
653
|
+
// owner's account that the accounts payload drops on purpose. A guest
|
|
654
|
+
// gets the row and not the word.
|
|
655
|
+
...(guest ? {} : { cost: r.cost }),
|
|
656
|
+
})),
|
|
385
657
|
handoff_availability_known: availabilityKnown,
|
|
658
|
+
handoff_ladder: handoffLadder,
|
|
659
|
+
// the bucket that will actually stop this terminal, computed per request
|
|
660
|
+
// and never persisted: it depends on the model the row is running, and
|
|
661
|
+
// the record only knows the login. A guest never gets it: it is a
|
|
662
|
+
// percentage of this machine's usage (.design/BOARD-DESIGN.md 6.13).
|
|
663
|
+
...(guest ? {} : { capacity: binding(readUsage(s.agent, s.account), s.model ?? null) }),
|
|
386
664
|
can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
|
|
387
665
|
active: isActive(s),
|
|
388
666
|
has_synthesis: hasRecentSynthesis(s),
|
|
@@ -401,7 +679,11 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
401
679
|
const accounts = []
|
|
402
680
|
for (const agent of Object.keys(configuredAccounts)) for (const account of configuredAccounts[agent]) {
|
|
403
681
|
const u = readUsage(agent, account)
|
|
404
|
-
|
|
682
|
+
// buckets, walls, extra_usage and facts are owner-only for the same reason
|
|
683
|
+
// the percentages are: they say how much of this machine's login is gone.
|
|
684
|
+
// The guest branch at the bottom of this function drops the slot to
|
|
685
|
+
// {agent, account, live, shared}, so nothing here reaches them.
|
|
686
|
+
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 })
|
|
405
687
|
}
|
|
406
688
|
const repos = new Map()
|
|
407
689
|
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 +692,11 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
410
692
|
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
693
|
const trunk = [...repos].map(([key, r]) => { try { return withLandings(trunkFor(r), landingsFor(key)) } catch { return { repo: r, commits: [] } } })
|
|
412
694
|
const mine = (s) => !shared || !viewer || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
|
|
413
|
-
const shown = sessions.map((s) =>
|
|
695
|
+
const shown = sessions.map((s) => {
|
|
696
|
+
if (!mine(s)) return redactSession(s)
|
|
697
|
+
const row = { ...s, requests: readRequests(s.session_id).filter((r) => r.state === 'pending') }
|
|
698
|
+
return guest ? scrubOwnerUsage(row) : row
|
|
699
|
+
})
|
|
414
700
|
return {
|
|
415
701
|
sessions: shown,
|
|
416
702
|
// a guest sees which accounts exist and which are busy, never how much of
|
|
@@ -421,6 +707,12 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
421
707
|
// a guest sees what landed, not where the repo lives on this machine
|
|
422
708
|
trunk: guest ? trunk.map((t) => ({ repo_name: t.repo_name, branch: t.branch, commits: t.commits ?? [] })) : trunk,
|
|
423
709
|
you: viewer,
|
|
710
|
+
// the terminals verdict has to be able to say "card 3e1c has waited on you
|
|
711
|
+
// for 12 minutes" without reading the whole pipeline board (redesign C.5).
|
|
712
|
+
// A guest has no cards at all; an operator runs them, approves them and is
|
|
713
|
+
// exactly the human one can be waiting on, so the gate is the cards
|
|
714
|
+
// permission the /api/cards routes use, not the owner flag.
|
|
715
|
+
...(!shared || mayUseCards(viewer?.role ?? 'owner') ? { cards_waiting: cardsWaiting() } : {}),
|
|
424
716
|
share: { on: shared, bind: shared ? share.bind : null, people: shared ? share.people.length : 0 },
|
|
425
717
|
preferences: guest ? null : readPreferences(),
|
|
426
718
|
ts: new Date().toISOString(),
|
|
@@ -488,7 +780,7 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
|
|
|
488
780
|
if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
|
|
489
781
|
const events = readEvents(id)
|
|
490
782
|
// broadcast() refreshes each client's viewer (and drops revoked ones) first
|
|
491
|
-
broadcast('card', forOwner(summarize(card)))
|
|
783
|
+
broadcast('card', forOwner(summarize(card, events)))
|
|
492
784
|
for (const c of [...clients]) {
|
|
493
785
|
if (!c.viewer || !mayUseCards(c.viewer.role)) { c.sig.set(id, events.length); continue }
|
|
494
786
|
const from = c.sig.get(id) ?? 0
|
|
@@ -655,6 +947,49 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
655
947
|
return null
|
|
656
948
|
}
|
|
657
949
|
const sse = createSse({ viewFor, reauth: reauthClient })
|
|
950
|
+
// A card that adopted a live terminal's checkout waits in the backlog until
|
|
951
|
+
// that terminal has really stopped. `end` is a REQUEST: src/attach.mjs reads
|
|
952
|
+
// control.json on its own poll (2s by default) and kills the child at the
|
|
953
|
+
// next tick, while the scheduler ticks every second, so a card queued here
|
|
954
|
+
// would put a headless agent in the same working tree as the interactive one
|
|
955
|
+
// for at least a tick, and longer if the agent is mid-turn. The session
|
|
956
|
+
// record is how the board learns a terminal ended (attach writes `ended`,
|
|
957
|
+
// sessions.mjs reaps a lost one), so that is what this waits on.
|
|
958
|
+
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))
|
|
959
|
+
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))
|
|
960
|
+
const enqueueTimers = new Map()
|
|
961
|
+
function enqueueWhenSessionEnds(cardId, sessionId, who) {
|
|
962
|
+
const from = Date.now()
|
|
963
|
+
const arm = () => {
|
|
964
|
+
const t = setTimeout(tick, ENQUEUE_POLL_MS)
|
|
965
|
+
t.unref?.()
|
|
966
|
+
enqueueTimers.set(cardId, t)
|
|
967
|
+
}
|
|
968
|
+
const tick = () => {
|
|
969
|
+
enqueueTimers.delete(cardId)
|
|
970
|
+
const card = readCard(cardId)
|
|
971
|
+
// killed, removed, or started by hand: it is not this timer's any more
|
|
972
|
+
if (!card || card.status !== 'backlog') return
|
|
973
|
+
const s = readSession(sessionId)
|
|
974
|
+
if (!s || !isActive(s)) {
|
|
975
|
+
try {
|
|
976
|
+
const next = humanAction(cardId, 'enqueue', {}, who)
|
|
977
|
+
log(`end-as-card ${cardId}: terminal ${sessionId} has stopped, the card is queued`)
|
|
978
|
+
sse.broadcast('card', forOwner(summarize(next)))
|
|
979
|
+
} catch (err) { log(`end-as-card ${cardId}: ${err.message}`) }
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
if (Date.now() - from > ENQUEUE_WAIT_MS) {
|
|
983
|
+
// never start it behind the human's back after a long wait: say why it
|
|
984
|
+
// is sitting there and leave Run to them
|
|
985
|
+
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 */ }
|
|
986
|
+
log(`end-as-card ${cardId}: terminal ${sessionId} still active after ${Math.round(ENQUEUE_WAIT_MS / 1000)}s, left in the backlog`)
|
|
987
|
+
return
|
|
988
|
+
}
|
|
989
|
+
arm()
|
|
990
|
+
}
|
|
991
|
+
arm()
|
|
992
|
+
}
|
|
658
993
|
let sched = null
|
|
659
994
|
let usageTimer = null
|
|
660
995
|
let usageController = null
|
|
@@ -732,12 +1067,13 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
732
1067
|
if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
|
|
733
1068
|
if (req.method === 'GET' && path === '/api/cards') {
|
|
734
1069
|
const cards = listCards()
|
|
735
|
-
return send(res, 200, { columns: columnsFor(cards), cards: cards.map(summarize) })
|
|
1070
|
+
return send(res, 200, { columns: columnsFor(cards), cards: cards.map((c) => summarize(c)) })
|
|
736
1071
|
}
|
|
737
1072
|
if (req.method === 'POST' && path === '/api/cards') {
|
|
738
1073
|
const body = await readBody(req)
|
|
739
1074
|
try {
|
|
740
|
-
|
|
1075
|
+
// a pipeline that names a file is a CLI flag, never a request body
|
|
1076
|
+
const card = await createCard(body, actor, { allowPipelineFile: false })
|
|
741
1077
|
sse.broadcast('card', forOwner(summarize(card)))
|
|
742
1078
|
return send(res, 201, { card: summarize(card) })
|
|
743
1079
|
} catch (err) {
|
|
@@ -748,7 +1084,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
748
1084
|
if (req.method === 'GET' && path === '/api/events') {
|
|
749
1085
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
|
|
750
1086
|
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`)
|
|
1087
|
+
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
1088
|
sse.add(res, cards, viewer, { token: presentedToken(req, url), loopback: isLoopbackRequest(req) })
|
|
753
1089
|
return
|
|
754
1090
|
}
|
|
@@ -761,6 +1097,15 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
761
1097
|
try {
|
|
762
1098
|
const patch = {}
|
|
763
1099
|
if (body.handoff_order !== undefined) patch.handoff_order = requireHandoffOrder(body.handoff_order)
|
|
1100
|
+
// the ladder and the rules around it (B.3). `handoff_order` is
|
|
1101
|
+
// rewritten from the ladder inside writePreferences, so the two
|
|
1102
|
+
// keys on disk can never disagree.
|
|
1103
|
+
if (body.handoff_ladder !== undefined) patch.handoff_ladder = requireHandoffLadder(body.handoff_ladder)
|
|
1104
|
+
if (body.climb_back !== undefined) patch.climb_back = requireClimbBack(body.climb_back)
|
|
1105
|
+
if (body.may_spend !== undefined) patch.may_spend = Boolean(body.may_spend)
|
|
1106
|
+
if (body.reserve !== undefined) patch.reserve = requireReserve(body.reserve)
|
|
1107
|
+
if (body.notify_terminal !== undefined) patch.notify_terminal = Boolean(body.notify_terminal)
|
|
1108
|
+
if (body.notify_board !== undefined) patch.notify_board = Boolean(body.notify_board)
|
|
764
1109
|
if (body.harness !== undefined) {
|
|
765
1110
|
// the board may narrow the policy or turn the feature off; turning
|
|
766
1111
|
// it on is the first-run consent flow, which shows what will be
|
|
@@ -771,7 +1116,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
771
1116
|
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
1117
|
patch.harness = { policy: body.harness?.policy, enabled: body.harness?.enabled === false ? false : undefined }
|
|
773
1118
|
}
|
|
774
|
-
if (!Object.keys(patch).length) return send(res, 400, { error: 'nothing to change: send handoff_order or harness' })
|
|
1119
|
+
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
1120
|
const preferences = writePreferences(patch)
|
|
776
1121
|
sse.broadcast('sessions', (v) => viewFor(v))
|
|
777
1122
|
return send(res, 200, { preferences })
|
|
@@ -842,7 +1187,13 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
842
1187
|
}
|
|
843
1188
|
const body = await readBody(req)
|
|
844
1189
|
try {
|
|
845
|
-
|
|
1190
|
+
// one route, two shapes: the old list of agents, and the ladder of
|
|
1191
|
+
// rungs that replaced it. Sending either rewrites the other, the
|
|
1192
|
+
// same way preferences.json keeps them in step.
|
|
1193
|
+
const ladder = body.handoff_ladder !== undefined ? requireHandoffLadder(body.handoff_ladder) : null
|
|
1194
|
+
const order = ladder ? orderFromLadder(ladder) : requireHandoffOrder(body.handoff_order)
|
|
1195
|
+
const rungs = ladder ?? ladderFor({ handoff_order: order })
|
|
1196
|
+
const summary = ladder ? `handoff ladder changed to ${rungs.map((r) => rungLabel(r)).join(' → ')}` : `handoff order changed to ${order.join(' → ')}`
|
|
846
1197
|
const next = updateSession(id, (current) => {
|
|
847
1198
|
if (!['starting', 'running', 'warning', 'limit', 'waiting'].includes(current.status)) {
|
|
848
1199
|
const conflict = new Error(`handoff order cannot change while this terminal is ${current.status}`)
|
|
@@ -851,9 +1202,10 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
851
1202
|
}
|
|
852
1203
|
return {
|
|
853
1204
|
handoff_order: order,
|
|
854
|
-
|
|
1205
|
+
handoff_ladder: rungs,
|
|
1206
|
+
chain: candidates({ agent: current.agent, account: current.account, model: current.model ?? null, accounts: readAccounts(), order, ladder: rungs }),
|
|
855
1207
|
}
|
|
856
|
-
}, { event: { type: 'status', by: actor.id, summary
|
|
1208
|
+
}, { event: { type: 'status', by: actor.id, summary } })
|
|
857
1209
|
sse.broadcast('sessions', (v) => viewFor(v))
|
|
858
1210
|
return send(res, 200, { session: next })
|
|
859
1211
|
} catch (err) {
|
|
@@ -889,6 +1241,107 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
889
1241
|
log(`land requested for ${id} by ${actor.id}`)
|
|
890
1242
|
return send(res, 202, { ok: true, requested: 'land' })
|
|
891
1243
|
}
|
|
1244
|
+
// "I have to leave, keep going." The terminal's context becomes a card
|
|
1245
|
+
// that continues from the SAME rung, and the terminal then ends exactly
|
|
1246
|
+
// the way End ends it. Where the card works depends on what the
|
|
1247
|
+
// terminal had:
|
|
1248
|
+
// - its own worktree: the card adopts it (redesign G4; two worktrees
|
|
1249
|
+
// on one branch is the conflict machine the roadmap rejects) and
|
|
1250
|
+
// waits in the BACKLOG until the terminal has really stopped, since
|
|
1251
|
+
// `end` is a request the runner reads on its own poll and the
|
|
1252
|
+
// scheduler ticks once a second.
|
|
1253
|
+
// - no worktree of its own (the ordinary case): a checkout of its own
|
|
1254
|
+
// is cut from that branch and the uncommitted work is carried into
|
|
1255
|
+
// it, so the card continues from what the human was looking at
|
|
1256
|
+
// rather than from the last commit.
|
|
1257
|
+
if (req.method === 'POST' && parts[3] === 'end-as-card') {
|
|
1258
|
+
// a card is the pipeline board, which belongs to the owner and the
|
|
1259
|
+
// operators of this machine even when the terminal is the caller's
|
|
1260
|
+
if (!canCards) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
|
|
1261
|
+
if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
|
|
1262
|
+
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' })
|
|
1263
|
+
let bundle
|
|
1264
|
+
try {
|
|
1265
|
+
bundle = saveSessionBundle(sess, { messages: sessionMessages(sess), why: 'ended as a card' })
|
|
1266
|
+
} catch (err) {
|
|
1267
|
+
// nothing has been ended yet: refuse rather than end a terminal
|
|
1268
|
+
// whose context was never written down
|
|
1269
|
+
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)}` })
|
|
1270
|
+
}
|
|
1271
|
+
const rungs = ladderFromCurrentRung(sess)
|
|
1272
|
+
// one model per adapter is all a card's chain can carry, so a ladder
|
|
1273
|
+
// with claude/fable and claude/opus in it keeps the FIRST, which is
|
|
1274
|
+
// the rung this terminal is on
|
|
1275
|
+
const models = {}
|
|
1276
|
+
for (const r of rungs) if (r.model && !models[r.agent]) models[r.agent] = r.model
|
|
1277
|
+
const task = `${sess.task ?? 'Continue the work already under way in this checkout.'}\n\nContinue from the bundle at ${bundle.path}.`
|
|
1278
|
+
const adopted = sess.worktree?.path && existsSync(sess.worktree.path) ? sess.worktree.path : null
|
|
1279
|
+
const trunkBranch = sess.worktree?.base || sess.branch || 'main'
|
|
1280
|
+
// The checkout the human has been working in. Read BEFORE anything is
|
|
1281
|
+
// created or ended: if the work cannot be read, nothing has happened
|
|
1282
|
+
// yet and the terminal is left exactly as it was.
|
|
1283
|
+
// the repository root, which is what `git status --porcelain`,
|
|
1284
|
+
// `git diff` and `git ls-files` all report paths against, so the
|
|
1285
|
+
// patch and the file list line up with the new checkout's root
|
|
1286
|
+
const workRoot = adopted ?? ((sess.repo && existsSync(sess.repo)) ? sess.repo : sess.cwd)
|
|
1287
|
+
let carried = null
|
|
1288
|
+
if (!adopted) {
|
|
1289
|
+
try { carried = captureUncommitted(workRoot) } catch (err) {
|
|
1290
|
+
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)}` })
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
let card
|
|
1294
|
+
try {
|
|
1295
|
+
card = await createCard({
|
|
1296
|
+
repo: sess.repo, task,
|
|
1297
|
+
chain: rungs.map((r) => r.agent).join(',') || sess.agent,
|
|
1298
|
+
model: models,
|
|
1299
|
+
trunk: trunkBranch,
|
|
1300
|
+
title: sess.task ? String(sess.task).slice(0, 60) : `continued from ${id}`,
|
|
1301
|
+
// never queued here: the card is enqueued below, once its
|
|
1302
|
+
// checkout is its own and nothing else is writing to it
|
|
1303
|
+
queue: false,
|
|
1304
|
+
}, actor)
|
|
1305
|
+
} catch (err) {
|
|
1306
|
+
if (err instanceof CardInputError) return send(res, 400, { error: `the card could not be created, so this terminal was left alone: ${err.message}` })
|
|
1307
|
+
throw err
|
|
1308
|
+
}
|
|
1309
|
+
let carriedFiles = 0
|
|
1310
|
+
if (adopted) {
|
|
1311
|
+
ledgerUpdate(card.card_id, { patch: { lineage: { from: id }, worktree: adopted, worktree_adopted: true, worktree_branch: sess.worktree?.branch ?? null } })
|
|
1312
|
+
} else {
|
|
1313
|
+
try {
|
|
1314
|
+
const wt = ensureWorktree(sess.repo, card.card_id, { trunk: trunkBranch })
|
|
1315
|
+
carriedFiles = carryUncommitted(wt.path, carried)
|
|
1316
|
+
ledgerUpdate(card.card_id, { patch: { lineage: { from: id }, worktree: wt.path, worktree_branch: wt.branch } })
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
// nothing has been ended and nothing has been queued: take the
|
|
1319
|
+
// half-made card off the board rather than leave it there
|
|
1320
|
+
try { removeWorktree(sess.repo, card.card_id, { force: true }) } catch { /* it may never have been cut */ }
|
|
1321
|
+
try { rmSync(cardDir(card.card_id), { recursive: true, force: true }) } catch { /* the board never saw it */ }
|
|
1322
|
+
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)}` })
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
// `handoff_written` is the ledger's word for "a bundle was written and
|
|
1326
|
+
// the work moved on", which is exactly what happened here. The audited
|
|
1327
|
+
// line with the actor is on the terminal's side, below.
|
|
1328
|
+
const where = adopted
|
|
1329
|
+
? ', in the terminal\'s own worktree, once that terminal has stopped'
|
|
1330
|
+
: `, in a worktree of its own cut from ${trunkBranch}${carriedFiles ? `, carrying ${carriedFiles} uncommitted file(s) over` : ''}`
|
|
1331
|
+
ledgerAppend(card.card_id, { actor, type: 'handoff_written', summary: `continued from terminal ${id}${where}` })
|
|
1332
|
+
requestControl(id, { end: true, by: actor.id })
|
|
1333
|
+
appendSessionEvent(id, { type: 'handed_off', by: actor.id, summary: `${actor.id} ended this terminal and kept it going as card ${card.card_id}` })
|
|
1334
|
+
updateSession(id, (cur) => ({ lineage: { ...(cur.lineage ?? {}), to: card.card_id } }))
|
|
1335
|
+
if (adopted) enqueueWhenSessionEnds(card.card_id, id, actor)
|
|
1336
|
+
else {
|
|
1337
|
+
try { humanAction(card.card_id, 'enqueue', {}, actor) } catch (err) { log(`end-as-card ${card.card_id}: ${err.message}`) }
|
|
1338
|
+
}
|
|
1339
|
+
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` : ''}`}`)
|
|
1340
|
+
const next = summarize(readCard(card.card_id))
|
|
1341
|
+
sse.broadcast('card', forOwner(next))
|
|
1342
|
+
sse.broadcast('sessions', (v) => viewFor(v))
|
|
1343
|
+
return send(res, 201, { card: next, bundle: { id: bundle.id, path: bundle.path }, carried: { files: carriedFiles, adopted: Boolean(adopted) } })
|
|
1344
|
+
}
|
|
892
1345
|
if (req.method === 'POST' && (parts[3] === 'handoff' || parts[3] === 'end')) {
|
|
893
1346
|
if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
|
|
894
1347
|
if (parts[3] === 'end') {
|
|
@@ -901,19 +1354,21 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
901
1354
|
const body = await readBody(req)
|
|
902
1355
|
let target = null
|
|
903
1356
|
if (body && body.agent !== undefined && body.agent !== null && body.agent !== '') {
|
|
904
|
-
const want = { agent: String(body.agent), account: String(body.account ?? 'default') }
|
|
1357
|
+
const want = { agent: String(body.agent), account: String(body.account ?? 'default'), model: body.model ? String(body.model).toLowerCase() : null }
|
|
905
1358
|
const order = normalizeHandoffOrder(sess.handoff_order)
|
|
906
|
-
const
|
|
907
|
-
const
|
|
908
|
-
const
|
|
909
|
-
|
|
1359
|
+
const ladder = ladderFor(sess)
|
|
1360
|
+
const chain = candidates({ agent: sess.agent, account: sess.account, model: sess.model ?? null, accounts: readAccounts(), order, ladder })
|
|
1361
|
+
const hit = chain.find((c) => c.agent === want.agent && c.account === want.account && (want.model ? (c.model ?? null) === want.model : true))
|
|
1362
|
+
const label = rungLabel(want)
|
|
1363
|
+
if (!hit) return send(res, 400, { error: `${label} is not a destination for this terminal (${chain.map((c) => rungLabel(c)).join(', ') || 'none'})` })
|
|
910
1364
|
if (sess.installed && sess.installed[want.agent] === false) return send(res, 409, { error: `${label} is not installed on this machine` })
|
|
911
1365
|
const u = readUsage(want.agent, want.account)
|
|
912
1366
|
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
|
-
|
|
1367
|
+
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` })
|
|
1368
|
+
target = { agent: hit.agent, account: hit.account, ...(hit.model ? { model: hit.model } : {}) }
|
|
914
1369
|
}
|
|
915
1370
|
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
|
|
1371
|
+
log(`handoff requested for ${id} by ${actor.id}${target ? ` to ${rungLabel(target)}` : ''}`)
|
|
917
1372
|
return send(res, 200, { ok: true, requested: 'handoff', target })
|
|
918
1373
|
}
|
|
919
1374
|
if (req.method === 'DELETE' && parts.length === 3) {
|
|
@@ -1039,6 +1494,48 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
1039
1494
|
sse.broadcast('removed', forOwner({ card_id: id }))
|
|
1040
1495
|
return send(res, 200, { removed: id })
|
|
1041
1496
|
}
|
|
1497
|
+
// Take over: sit down in the card's worktree yourself. The card is
|
|
1498
|
+
// paused first (its child is killed and its bundle written by the
|
|
1499
|
+
// existing transition), then Leg hands back the one command that opens
|
|
1500
|
+
// a terminal there. This is the only command the board ever hands a
|
|
1501
|
+
// human, because a browser tab cannot open one (redesign C.4).
|
|
1502
|
+
if (req.method === 'POST' && parts[3] === 'take-over') {
|
|
1503
|
+
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.` })
|
|
1504
|
+
const raw = String(summarize(card).active_adapter ?? '')
|
|
1505
|
+
const agent = [raw, raw.replace(/^fake-/, '')].find((n) => SUPERVISED_AGENTS.includes(n)) ?? null
|
|
1506
|
+
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(', ')})` })
|
|
1507
|
+
// Every non-terminal status moves to `paused` before the command is
|
|
1508
|
+
// handed back, not just `running`. A `queued` or `handing_off` card
|
|
1509
|
+
// is in the set the scheduler starts from, and it ticks once a
|
|
1510
|
+
// second: the human would paste this command into a worktree an
|
|
1511
|
+
// agent had just been launched in, which is the collision Take over
|
|
1512
|
+
// exists to prevent. The transition also writes the actor's own
|
|
1513
|
+
// `taken_over` line, so the audit trail names who has the checkout.
|
|
1514
|
+
let next
|
|
1515
|
+
try { next = humanAction(id, 'take_over', {}, actor) } catch (err) {
|
|
1516
|
+
if (err instanceof IllegalTransition) return send(res, 409, { error: err.message })
|
|
1517
|
+
throw err
|
|
1518
|
+
}
|
|
1519
|
+
// A card that never ran has no checkout of its own, and the terminal
|
|
1520
|
+
// that takes it over must never open in the human's main checkout
|
|
1521
|
+
// (src/attach.mjs cardWorkRoot refuses that). Cut its worktree now, on
|
|
1522
|
+
// the trunk the card would have used, so the command below has a
|
|
1523
|
+
// place to open.
|
|
1524
|
+
const cur = readCard(id) ?? next
|
|
1525
|
+
if (!(cur.worktree && existsSync(cur.worktree)) && cur.repo) {
|
|
1526
|
+
try {
|
|
1527
|
+
const wt = ensureWorktree(cur.repo, id, { trunk: cur.trunk || trunkFor(cur.repo).branch || 'main' })
|
|
1528
|
+
ledgerUpdate(id, { patch: { worktree: wt.path, worktree_branch: wt.branch } })
|
|
1529
|
+
} catch (err) {
|
|
1530
|
+
return send(res, 409, { error: `could not cut a checkout for ${id}: ${err.message}` })
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
const command = `leg ${agent} --resume-card ${id}`
|
|
1534
|
+
log(`take-over for ${id} by ${actor.id}: ${command}`)
|
|
1535
|
+
const view = summarize(readCard(id) ?? next)
|
|
1536
|
+
sse.broadcast('card', forOwner(view))
|
|
1537
|
+
return send(res, 200, { card: view, command })
|
|
1538
|
+
}
|
|
1042
1539
|
if (req.method === 'POST' && parts[3]) {
|
|
1043
1540
|
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
1541
|
const action = map[parts[3]]
|
|
@@ -1114,6 +1611,8 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
1114
1611
|
async stop() {
|
|
1115
1612
|
if (usageTimer) { clearInterval(usageTimer); usageTimer = null }
|
|
1116
1613
|
usageController?.abort()
|
|
1614
|
+
for (const t of enqueueTimers.values()) clearTimeout(t)
|
|
1615
|
+
enqueueTimers.clear()
|
|
1117
1616
|
sse.stop()
|
|
1118
1617
|
if (sched) sched.stop()
|
|
1119
1618
|
if (loopbackCompanion) await new Promise((r) => { loopbackCompanion.closeAllConnections?.(); loopbackCompanion.close(() => r()) })
|