@ucsandman/legcli 0.10.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 +212 -0
- package/README.md +158 -67
- package/bin/leg.mjs +168 -18
- package/docs/DECISIONS.md +10 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +94 -0
- package/docs/ROADMAP-v2.md +69 -11
- package/docs/VOCABULARY.md +27 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +401 -66
- package/docs/cli-contracts.md +235 -22
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +113 -5
- 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/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +3 -2
- package/scripts/build-docs-site.mjs +4 -4
- package/scripts/probe.mjs +2 -1
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +350 -42
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +134 -9
- package/src/board/board.js +482 -106
- package/src/board/index.html +89 -7
- package/src/board/sessions.js +1371 -113
- 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 +615 -50
- package/src/sessions.mjs +17 -1
- package/src/share.mjs +66 -6
- package/src/taps/claude-usage.mjs +91 -2
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/taps/grok.mjs +4 -0
- package/src/usage.mjs +424 -13
package/src/server.mjs
CHANGED
|
@@ -5,34 +5,39 @@
|
|
|
5
5
|
// LEG_BIND (127.0.0.1) + LEG_PORT (4747) + LEG_TOKEN are the
|
|
6
6
|
// multiplayer seams (src/auth.mjs). BATON_* names still work as fallback.
|
|
7
7
|
import http from 'node:http'
|
|
8
|
+
import https from 'node:https'
|
|
8
9
|
import { spawnSync, execFile } from 'node:child_process'
|
|
9
|
-
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'
|
|
10
11
|
import { join, dirname, resolve, extname, sep } from 'node:path'
|
|
11
12
|
import { fileURLToPath } from 'node:url'
|
|
12
13
|
import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
|
|
13
|
-
import { readShare, isOn as shareIsOn, sharePath, identify, personNamed } from './share.mjs'
|
|
14
|
+
import { readShare, isOn as shareIsOn, sharePath, identify, personNamed, mayUseCards, mayUseMachine, readTls } from './share.mjs'
|
|
15
|
+
import { auditTrail, ACTOR_KINDS } from './audit.mjs'
|
|
14
16
|
import { createLimiter } from './ratelimit.mjs'
|
|
15
17
|
import { realPath, canonPath } from './fsx.mjs'
|
|
16
|
-
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'
|
|
17
21
|
import { humanAction } from './orchestrator.mjs'
|
|
18
22
|
import { createCard, CardInputError } from './cards.mjs'
|
|
19
|
-
import { IllegalTransition, availableActions } from './chain.mjs'
|
|
23
|
+
import { IllegalTransition, availableActions, NON_TERMINAL, TERMINAL } from './chain.mjs'
|
|
20
24
|
import { held } from './leases.mjs'
|
|
21
25
|
import { PRESETS } from './presets.mjs'
|
|
22
26
|
import { names as adapterNames, get as getAdapter, isFake } from './adapters/index.mjs'
|
|
23
27
|
import { createScheduler, schedulerStatus, MAX_CONCURRENT } from './scheduler.mjs'
|
|
24
|
-
import { remove as removeWorktree, worktreeDirty } from './worktree.mjs'
|
|
28
|
+
import { ensure as ensureWorktree, remove as removeWorktree, worktreeDirty } from './worktree.mjs'
|
|
25
29
|
import { scrub } from './runner.mjs'
|
|
26
30
|
import { resolveChb } from './handoff.mjs'
|
|
27
|
-
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'
|
|
28
32
|
import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs'
|
|
29
33
|
import { hasRecentSynthesis } from './synthesis.mjs'
|
|
30
34
|
import { refreshPointers } from './resume.mjs'
|
|
31
35
|
import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
|
|
32
|
-
import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
|
|
36
|
+
import { readUsage, recordUsage, usageIsStale, candidates, isAvailable, fmtReset, binding, evaluateLadder, rungLabel, wallActive } from './usage.mjs'
|
|
33
37
|
import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
|
|
34
|
-
import { readCodexUsage } from './taps/codex.mjs'
|
|
35
|
-
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'
|
|
36
41
|
import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
|
|
37
42
|
import { listWorktrees } from './history/worktrees.mjs'
|
|
38
43
|
|
|
@@ -73,16 +78,192 @@ export function columnOf(card) {
|
|
|
73
78
|
return card.station
|
|
74
79
|
}
|
|
75
80
|
|
|
76
|
-
|
|
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) {
|
|
77
255
|
const st = (card.pipeline ?? []).find((s) => s.name === card.station) ?? null
|
|
78
|
-
|
|
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
|
|
79
260
|
const runs = readRuns(card.card_id)
|
|
80
261
|
const activeRun = runs.find((r) => ['launching', 'running'].includes(r.status)) ?? null
|
|
81
262
|
// Once a station is over (done/failed) card.leg is reset, so the rail is
|
|
82
263
|
// rebuilt from the legs that actually started at this station.
|
|
83
264
|
const terminal = ['done', 'failed', 'killed'].includes(card.status)
|
|
84
265
|
const startedLegs = terminal && st?.kind === 'agent'
|
|
85
|
-
?
|
|
266
|
+
? evs.filter((ev) => ev.type === 'leg_started' && ev.station === card.station).map((ev) => ev.leg)
|
|
86
267
|
: []
|
|
87
268
|
const lastLeg = startedLegs.length ? Math.max(...startedLegs) : card.leg
|
|
88
269
|
// card.leg is reset when the station ends, so a finished card read its adapter
|
|
@@ -99,12 +280,23 @@ export function summarize(card) {
|
|
|
99
280
|
if (i > card.leg) return 'pending'
|
|
100
281
|
return ['running', 'handing_off'].includes(card.status) ? 'active' : 'pending'
|
|
101
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) : {}
|
|
102
290
|
return {
|
|
103
291
|
...card,
|
|
104
292
|
column: columnOf(card),
|
|
105
293
|
station_kind: st?.kind ?? null,
|
|
106
294
|
active_adapter: entry?.adapter ?? null,
|
|
107
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 } } : {}),
|
|
108
300
|
chain_view: st?.kind === 'agent' ? st.chain.map((e, i) => ({
|
|
109
301
|
adapter: e.adapter, mode: e.mode ?? null, approve: Boolean(e.approve),
|
|
110
302
|
// a leg before the last one ended in a handoff (only the last leg can complete a station)
|
|
@@ -291,8 +483,18 @@ function trunkFor(repo) {
|
|
|
291
483
|
const hit = trunkCache.get(repo)
|
|
292
484
|
if (hit && Date.now() - hit.at < 15000) return hit.data
|
|
293
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.
|
|
294
490
|
let branch = null
|
|
295
|
-
|
|
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
|
|
296
498
|
const log = branch ? g(['log', '--format=%h%x1f%s%x1f%cr%x1f%an', '-6', branch]) : null
|
|
297
499
|
const data = {
|
|
298
500
|
repo, repo_name: repo.split(/[\\/]/).filter(Boolean).pop(), branch,
|
|
@@ -317,14 +519,28 @@ function withLandings(t, landings) {
|
|
|
317
519
|
// What another human sees of a terminal that is not theirs: that it is there,
|
|
318
520
|
// nothing it has said, read or written. No task, no paths, no file names, no
|
|
319
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.
|
|
320
528
|
function redactSession(s) {
|
|
321
529
|
return {
|
|
322
530
|
session_id: s.session_id, agent: s.agent, account: s.account, status: s.status, active: s.active,
|
|
323
531
|
started_at: s.started_at, elapsed_ms: s.elapsed_ms, turns: s.turns, repo_name: s.repo_name, branch: s.branch,
|
|
324
|
-
owner: s.owner ?? null,
|
|
325
|
-
warning: s.warning ? { window: s.warning.window
|
|
326
|
-
limit: s.limit ? { reason: s.limit.reason
|
|
327
|
-
|
|
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.
|
|
328
544
|
worktree: s.worktree ? { branch: s.worktree.branch, base: s.worktree.base } : null,
|
|
329
545
|
// the branch is already on the worktree chip: naming it again costs nothing
|
|
330
546
|
// and is what the board's land line reads
|
|
@@ -336,6 +552,40 @@ function redactSession(s) {
|
|
|
336
552
|
}
|
|
337
553
|
}
|
|
338
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
|
+
|
|
339
589
|
function visibleSessionFile(file) {
|
|
340
590
|
const value = String(file ?? '')
|
|
341
591
|
return !value.includes('*** Begin Patch') && !value.includes('*** End Patch')
|
|
@@ -343,16 +593,32 @@ function visibleSessionFile(file) {
|
|
|
343
593
|
|
|
344
594
|
export function sessionsView({ viewer = null, share = null } = {}) {
|
|
345
595
|
const shared = Boolean(share && shareIsOn(share))
|
|
596
|
+
// Decided before the map below, because the per-session payload has to know
|
|
597
|
+
// it: a guest owns their own terminal and may hand it off, so they get its
|
|
598
|
+
// list of destinations — but a reset time is this machine's usage data and
|
|
599
|
+
// belongs to nobody else, even on a terminal that is theirs.
|
|
600
|
+
const guest = shared && viewer && viewer.role !== 'owner'
|
|
346
601
|
const list = reapLost(listSessions())
|
|
347
602
|
const ov = overlaps(list)
|
|
348
603
|
const configuredAccounts = readAccounts()
|
|
604
|
+
// the spending rules the picker has to print, read once for the whole view
|
|
605
|
+
const prefs = readPreferences()
|
|
349
606
|
const sessions = list.map((s) => {
|
|
350
607
|
const land = readLand(s.session_id)
|
|
351
608
|
const handoffOrder = normalizeHandoffOrder(s.handoff_order)
|
|
352
|
-
|
|
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 })
|
|
353
613
|
const preferredNext = chain[0] ?? null
|
|
354
614
|
const availabilityKnown = Boolean(s.installed)
|
|
355
|
-
|
|
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
|
|
356
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 }] }
|
|
357
623
|
return {
|
|
358
624
|
...s,
|
|
@@ -360,7 +626,41 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
360
626
|
chain,
|
|
361
627
|
preferred_next: preferredNext,
|
|
362
628
|
eligible_next: eligibleNext,
|
|
629
|
+
// every destination this terminal could be handed to, each with the
|
|
630
|
+
// reason it cannot be picked right now. The board's picker renders this
|
|
631
|
+
// list directly, so a greyed option always carries its own explanation.
|
|
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
|
+
})),
|
|
363
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) }),
|
|
364
664
|
can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
|
|
365
665
|
active: isActive(s),
|
|
366
666
|
has_synthesis: hasRecentSynthesis(s),
|
|
@@ -379,7 +679,11 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
379
679
|
const accounts = []
|
|
380
680
|
for (const agent of Object.keys(configuredAccounts)) for (const account of configuredAccounts[agent]) {
|
|
381
681
|
const u = readUsage(agent, account)
|
|
382
|
-
|
|
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 })
|
|
383
687
|
}
|
|
384
688
|
const repos = new Map()
|
|
385
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)
|
|
@@ -387,9 +691,12 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
387
691
|
const canon = new Map()
|
|
388
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 })
|
|
389
693
|
const trunk = [...repos].map(([key, r]) => { try { return withLandings(trunkFor(r), landingsFor(key)) } catch { return { repo: r, commits: [] } } })
|
|
390
|
-
const guest = shared && viewer && viewer.role !== 'owner'
|
|
391
694
|
const mine = (s) => !shared || !viewer || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
|
|
392
|
-
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
|
+
})
|
|
393
700
|
return {
|
|
394
701
|
sessions: shown,
|
|
395
702
|
// a guest sees which accounts exist and which are busy, never how much of
|
|
@@ -400,6 +707,12 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
400
707
|
// a guest sees what landed, not where the repo lives on this machine
|
|
401
708
|
trunk: guest ? trunk.map((t) => ({ repo_name: t.repo_name, branch: t.branch, commits: t.commits ?? [] })) : trunk,
|
|
402
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() } : {}),
|
|
403
716
|
share: { on: shared, bind: shared ? share.bind : null, people: shared ? share.people.length : 0 },
|
|
404
717
|
preferences: guest ? null : readPreferences(),
|
|
405
718
|
ts: new Date().toISOString(),
|
|
@@ -461,14 +774,15 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
|
|
|
461
774
|
// resets the count the first is reading from.
|
|
462
775
|
const refreshCard = (id) => {
|
|
463
776
|
const card = readCard(id)
|
|
464
|
-
// pipeline cards and their events
|
|
465
|
-
|
|
777
|
+
// pipeline cards and their events belong to the people who may run them:
|
|
778
|
+
// the owner and any operator. A guest never gets them.
|
|
779
|
+
const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
|
|
466
780
|
if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
|
|
467
781
|
const events = readEvents(id)
|
|
468
782
|
// broadcast() refreshes each client's viewer (and drops revoked ones) first
|
|
469
|
-
broadcast('card', forOwner(summarize(card)))
|
|
783
|
+
broadcast('card', forOwner(summarize(card, events)))
|
|
470
784
|
for (const c of [...clients]) {
|
|
471
|
-
if (!c.viewer || c.viewer.role
|
|
785
|
+
if (!c.viewer || !mayUseCards(c.viewer.role)) { c.sig.set(id, events.length); continue }
|
|
472
786
|
const from = c.sig.get(id) ?? 0
|
|
473
787
|
c.sig.set(id, events.length)
|
|
474
788
|
for (const e of events.slice(from)) { try { c.res.write(`event: event\ndata: ${JSON.stringify(e)}\n\n`) } catch {} }
|
|
@@ -613,7 +927,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
613
927
|
}
|
|
614
928
|
const limiter = createLimiter()
|
|
615
929
|
const viewFor = (viewer, sh) => sessionsView({ viewer, share: sh ?? currentShare() })
|
|
616
|
-
const forOwner = (payload) => (viewer) => (viewer && viewer.role
|
|
930
|
+
const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
|
|
617
931
|
// SSE re-identifies each client from the live roster on every push
|
|
618
932
|
const reauthClient = (c) => {
|
|
619
933
|
const sh = currentShare()
|
|
@@ -633,6 +947,49 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
633
947
|
return null
|
|
634
948
|
}
|
|
635
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
|
+
}
|
|
636
993
|
let sched = null
|
|
637
994
|
let usageTimer = null
|
|
638
995
|
let usageController = null
|
|
@@ -688,30 +1045,35 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
688
1045
|
const rl = limiter.request(auth.person ? viewer.name : ip)
|
|
689
1046
|
if (!rl.ok) return send(res, 429, { error: `rate limit: more than ${limiter.max} requests a minute` }, { 'Retry-After': String(rl.retry_after) })
|
|
690
1047
|
const actor = { type: 'human', id: viewer.name }
|
|
691
|
-
//
|
|
692
|
-
|
|
1048
|
+
// What this viewer may reach, decided once from their role (src/share.mjs).
|
|
1049
|
+
// `canCards` is the pipeline board: cards, the floor, the adapters and the
|
|
1050
|
+
// leases, which an operator runs. `canMachine` is everything that describes
|
|
1051
|
+
// this computer rather than the work — the settings, the trunk's repo
|
|
1052
|
+
// paths, the history index, the worktree map — and stays the owner's.
|
|
1053
|
+
const canCards = !shared || mayUseCards(viewer.role)
|
|
1054
|
+
const canMachine = !shared || mayUseMachine(viewer.role)
|
|
693
1055
|
const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
|
|
694
1056
|
const parts = path.split('/').filter(Boolean) // ['api', ...]
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
if (guest && ['cards', 'floor', 'presets', 'adapters', 'leases', 'trunk', 'history', 'worktrees'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner of this machine' })
|
|
1057
|
+
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' })
|
|
1058
|
+
if (!canMachine && ['trunk', 'history', 'worktrees', 'audit'].includes(parts[1])) return send(res, 403, { error: 'this is the map of the machine itself: every repository path and every conversation on it. It belongs to the owner of this machine.' })
|
|
698
1059
|
try {
|
|
699
1060
|
if (req.method === 'GET' && path === '/api/health') {
|
|
700
1061
|
const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
|
|
701
|
-
if (
|
|
1062
|
+
if (!canCards) return send(res, 200, { ok: true, version: VERSION, you })
|
|
702
1063
|
const cards = listCards()
|
|
703
|
-
return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: home(), you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
|
|
1064
|
+
return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: canMachine ? home() : null, you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
|
|
704
1065
|
}
|
|
705
1066
|
if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
|
|
706
1067
|
if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
|
|
707
1068
|
if (req.method === 'GET' && path === '/api/cards') {
|
|
708
1069
|
const cards = listCards()
|
|
709
|
-
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)) })
|
|
710
1071
|
}
|
|
711
1072
|
if (req.method === 'POST' && path === '/api/cards') {
|
|
712
1073
|
const body = await readBody(req)
|
|
713
1074
|
try {
|
|
714
|
-
|
|
1075
|
+
// a pipeline that names a file is a CLI flag, never a request body
|
|
1076
|
+
const card = await createCard(body, actor, { allowPipelineFile: false })
|
|
715
1077
|
sse.broadcast('card', forOwner(summarize(card)))
|
|
716
1078
|
return send(res, 201, { card: summarize(card) })
|
|
717
1079
|
} catch (err) {
|
|
@@ -721,20 +1083,29 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
721
1083
|
}
|
|
722
1084
|
if (req.method === 'GET' && path === '/api/events') {
|
|
723
1085
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
|
|
724
|
-
const cards =
|
|
725
|
-
res.write(`event: hello\ndata: ${JSON.stringify({ columns: columnsFor(cards), cards: cards.map(summarize), sessions: viewFor(viewer), ts: new Date().toISOString() })}\n\n`)
|
|
1086
|
+
const cards = canCards ? listCards() : []
|
|
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`)
|
|
726
1088
|
sse.add(res, cards, viewer, { token: presentedToken(req, url), loopback: isLoopbackRequest(req) })
|
|
727
1089
|
return
|
|
728
1090
|
}
|
|
729
1091
|
if (req.method === 'GET' && path === '/api/sessions') return send(res, 200, viewFor(viewer))
|
|
730
1092
|
if (path === '/api/settings') {
|
|
731
|
-
if (
|
|
1093
|
+
if (!canMachine) return send(res, 403, { error: 'the machine settings belong to the owner of this board' })
|
|
732
1094
|
if (req.method === 'GET') return send(res, 200, { preferences: readPreferences() })
|
|
733
1095
|
if (req.method === 'POST' || req.method === 'PATCH') {
|
|
734
1096
|
const body = await readBody(req)
|
|
735
1097
|
try {
|
|
736
1098
|
const patch = {}
|
|
737
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)
|
|
738
1109
|
if (body.harness !== undefined) {
|
|
739
1110
|
// the board may narrow the policy or turn the feature off; turning
|
|
740
1111
|
// it on is the first-run consent flow, which shows what will be
|
|
@@ -745,7 +1116,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
745
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}` })
|
|
746
1117
|
patch.harness = { policy: body.harness?.policy, enabled: body.harness?.enabled === false ? false : undefined }
|
|
747
1118
|
}
|
|
748
|
-
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' })
|
|
749
1120
|
const preferences = writePreferences(patch)
|
|
750
1121
|
sse.broadcast('sessions', (v) => viewFor(v))
|
|
751
1122
|
return send(res, 200, { preferences })
|
|
@@ -816,7 +1187,13 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
816
1187
|
}
|
|
817
1188
|
const body = await readBody(req)
|
|
818
1189
|
try {
|
|
819
|
-
|
|
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(' → ')}`
|
|
820
1197
|
const next = updateSession(id, (current) => {
|
|
821
1198
|
if (!['starting', 'running', 'warning', 'limit', 'waiting'].includes(current.status)) {
|
|
822
1199
|
const conflict = new Error(`handoff order cannot change while this terminal is ${current.status}`)
|
|
@@ -825,9 +1202,10 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
825
1202
|
}
|
|
826
1203
|
return {
|
|
827
1204
|
handoff_order: order,
|
|
828
|
-
|
|
1205
|
+
handoff_ladder: rungs,
|
|
1206
|
+
chain: candidates({ agent: current.agent, account: current.account, model: current.model ?? null, accounts: readAccounts(), order, ladder: rungs }),
|
|
829
1207
|
}
|
|
830
|
-
}, { event: { type: 'status', by: actor.id, summary
|
|
1208
|
+
}, { event: { type: 'status', by: actor.id, summary } })
|
|
831
1209
|
sse.broadcast('sessions', (v) => viewFor(v))
|
|
832
1210
|
return send(res, 200, { session: next })
|
|
833
1211
|
} catch (err) {
|
|
@@ -863,11 +1241,135 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
863
1241
|
log(`land requested for ${id} by ${actor.id}`)
|
|
864
1242
|
return send(res, 202, { ok: true, requested: 'land' })
|
|
865
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
|
+
}
|
|
866
1345
|
if (req.method === 'POST' && (parts[3] === 'handoff' || parts[3] === 'end')) {
|
|
867
1346
|
if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1347
|
+
if (parts[3] === 'end') {
|
|
1348
|
+
requestControl(id, { end: true, by: actor.id })
|
|
1349
|
+
log(`end requested for ${id} by ${actor.id}`)
|
|
1350
|
+
return send(res, 200, { ok: true, requested: 'end' })
|
|
1351
|
+
}
|
|
1352
|
+
// Hand off now, optionally to a named destination. With no body the
|
|
1353
|
+
// chain decides, exactly as it did before the picker existed.
|
|
1354
|
+
const body = await readBody(req)
|
|
1355
|
+
let target = null
|
|
1356
|
+
if (body && body.agent !== undefined && body.agent !== null && body.agent !== '') {
|
|
1357
|
+
const want = { agent: String(body.agent), account: String(body.account ?? 'default'), model: body.model ? String(body.model).toLowerCase() : null }
|
|
1358
|
+
const order = normalizeHandoffOrder(sess.handoff_order)
|
|
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'})` })
|
|
1364
|
+
if (sess.installed && sess.installed[want.agent] === false) return send(res, 409, { error: `${label} is not installed on this machine` })
|
|
1365
|
+
const u = readUsage(want.agent, want.account)
|
|
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` })
|
|
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 } : {}) }
|
|
1369
|
+
}
|
|
1370
|
+
requestControl(id, target ? { handoff: true, target, by: actor.id } : { handoff: true, by: actor.id })
|
|
1371
|
+
log(`handoff requested for ${id} by ${actor.id}${target ? ` to ${rungLabel(target)}` : ''}`)
|
|
1372
|
+
return send(res, 200, { ok: true, requested: 'handoff', target })
|
|
871
1373
|
}
|
|
872
1374
|
if (req.method === 'DELETE' && parts.length === 3) {
|
|
873
1375
|
if (isActive(sess)) return send(res, 409, { error: 'end the session before removing it' })
|
|
@@ -944,6 +1446,18 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
944
1446
|
const q = url.searchParams
|
|
945
1447
|
return send(res, 200, worktreesFor({ repo: q.get('repo') || null, dirty: q.get('dirty') !== '0' }))
|
|
946
1448
|
}
|
|
1449
|
+
if (req.method === 'GET' && path === '/api/audit') {
|
|
1450
|
+
const q = url.searchParams
|
|
1451
|
+
const kind = q.get('kind')
|
|
1452
|
+
if (kind && !ACTOR_KINDS.includes(kind)) return send(res, 400, { error: `kind is one of ${ACTOR_KINDS.join(', ')}` })
|
|
1453
|
+
const limit = parseInt(q.get('limit') ?? '200', 10)
|
|
1454
|
+
return send(res, 200, auditTrail({
|
|
1455
|
+
limit: Number.isFinite(limit) ? limit : 200,
|
|
1456
|
+
since: q.get('since'),
|
|
1457
|
+
who: q.get('who'),
|
|
1458
|
+
kind,
|
|
1459
|
+
}))
|
|
1460
|
+
}
|
|
947
1461
|
if (req.method === 'GET' && path === '/api/floor') return send(res, 200, floor(listCards()))
|
|
948
1462
|
if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunk(listCards(), parseSince(url.searchParams.get('since'))))
|
|
949
1463
|
if (req.method === 'GET' && path === '/api/leases') return send(res, 200, { leases: held(listCards()) })
|
|
@@ -980,6 +1494,48 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
980
1494
|
sse.broadcast('removed', forOwner({ card_id: id }))
|
|
981
1495
|
return send(res, 200, { removed: id })
|
|
982
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
|
+
}
|
|
983
1539
|
if (req.method === 'POST' && parts[3]) {
|
|
984
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' }
|
|
985
1541
|
const action = map[parts[3]]
|
|
@@ -1004,21 +1560,28 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
1004
1560
|
}
|
|
1005
1561
|
|
|
1006
1562
|
const onReq = (req, res) => { handle(req, res).catch((err) => { try { send(res, 500, { error: scrub(err.message) }) } catch {} }) }
|
|
1007
|
-
|
|
1563
|
+
// TLS when a certificate pair is configured (leg share on --tls-cert/--tls-key,
|
|
1564
|
+
// or LEG_TLS_CERT/LEG_TLS_KEY). readTls throws rather than fall back to
|
|
1565
|
+
// plaintext: a board told to use TLS and quietly serving http would be the
|
|
1566
|
+
// worst outcome of the three.
|
|
1567
|
+
const tls = readTls(initialShare)
|
|
1568
|
+
const server = tls ? https.createServer({ cert: tls.cert, key: tls.key }, onReq) : http.createServer(onReq)
|
|
1008
1569
|
// When the board is bound to a non-loopback address (share on), also listen on
|
|
1009
1570
|
// 127.0.0.1 so the machine's own browser has a tokenless owner URL — a real
|
|
1010
|
-
// remote peer's address is never loopback, so it still needs a token.
|
|
1571
|
+
// remote peer's address is never loopback, so it still needs a token. That one
|
|
1572
|
+
// stays plain http even under TLS: the certificate is for the shared name, and
|
|
1573
|
+
// loopback traffic never leaves this machine.
|
|
1011
1574
|
const loopbackCompanion = !isLoopback(bind) ? http.createServer(onReq) : null
|
|
1012
1575
|
|
|
1013
1576
|
return {
|
|
1014
1577
|
server,
|
|
1015
|
-
bind, port,
|
|
1578
|
+
bind, port, tls: tls ? { cert_path: tls.cert_path, key_path: tls.key_path } : null,
|
|
1016
1579
|
start() {
|
|
1017
1580
|
return new Promise((resolvePromise, reject) => {
|
|
1018
1581
|
server.once('error', reject)
|
|
1019
1582
|
server.listen(port, bind, () => {
|
|
1020
1583
|
const addr = server.address()
|
|
1021
|
-
log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
|
|
1584
|
+
log(`listening on ${tls ? 'https' : 'http'}://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'}${tls ? `, TLS from ${tls.cert_path}` : ''})`)
|
|
1022
1585
|
// A terminal that crashed instead of exiting left its hand-off in
|
|
1023
1586
|
// .leg/RESUME.md looking live. The board is the thing that starts
|
|
1024
1587
|
// after a crash, so it is where that gets corrected.
|
|
@@ -1048,6 +1611,8 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
1048
1611
|
async stop() {
|
|
1049
1612
|
if (usageTimer) { clearInterval(usageTimer); usageTimer = null }
|
|
1050
1613
|
usageController?.abort()
|
|
1614
|
+
for (const t of enqueueTimers.values()) clearTimeout(t)
|
|
1615
|
+
enqueueTimers.clear()
|
|
1051
1616
|
sse.stop()
|
|
1052
1617
|
if (sched) sched.stop()
|
|
1053
1618
|
if (loopbackCompanion) await new Promise((r) => { loopbackCompanion.closeAllConnections?.(); loopbackCompanion.close(() => r()) })
|