@ucsandman/legcli 0.7.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 +560 -0
- package/LICENSE +58 -0
- package/NOTICE +4 -0
- package/README.md +558 -0
- package/bin/fake-agent.mjs +143 -0
- package/bin/leg.mjs +449 -0
- package/docs/DECISIONS.md +119 -0
- package/docs/DEMO.md +214 -0
- package/docs/DEVIATIONS.md +182 -0
- package/docs/ERRORS.md +326 -0
- package/docs/README.md +40 -0
- package/docs/REUSE.md +289 -0
- package/docs/ROADMAP-v2.md +55 -0
- package/docs/VOCABULARY.md +243 -0
- package/docs/adapters.md +300 -0
- package/docs/board-guide.md +579 -0
- package/docs/cli-contracts.md +401 -0
- package/docs/concepts.md +426 -0
- package/docs/configuration.md +220 -0
- package/docs/faq.md +206 -0
- package/docs/getting-started.md +251 -0
- package/docs/real-run.md +80 -0
- package/docs/review-2026-09-14.md +72 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-done.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/demo-1-claude-running.png +0 -0
- package/docs/screenshots/demo-2-limit-hit.png +0 -0
- package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
- package/docs/screenshots/demo-4-codex-running.png +0 -0
- package/docs/screenshots/demo-5-done.png +0 -0
- package/docs/screenshots/floor-final-1280.png +0 -0
- package/docs/screenshots/floor-final-400.png +0 -0
- package/docs/screenshots/floor-landing.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/share-guest-1280.png +0 -0
- package/docs/screenshots/share-owner-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/docs/screenshots/thumb-done.jpg +0 -0
- package/docs/screenshots/thumb-handoff-details.jpg +0 -0
- package/docs/screenshots/thumb-limit-hit.jpg +0 -0
- package/docs/screenshots/youtube-thumbnail.jpg +0 -0
- package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
- package/fixtures/limits/claude/claude-budget-limit.json +11 -0
- package/fixtures/limits/claude/claude-max-turns.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +11 -0
- package/fixtures/limits/claude/claude-session-limit.json +11 -0
- package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
- package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-skills-notice.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit.json +11 -0
- package/fixtures/limits/generic/generic-429.json +11 -0
- package/fixtures/limits/generic/generic-overloaded.json +11 -0
- package/fixtures/limits/generic/generic-quota.json +11 -0
- package/fixtures/limits/generic/generic-rate-limit.json +11 -0
- package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
- package/fixtures/limits/generic/generic-usage-limit.json +11 -0
- package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
- package/fixtures/limits/negative/auth-source-set.json +11 -0
- package/fixtures/limits/negative/compile-error.json +11 -0
- package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
- package/fixtures/live/agy/cmd.txt +1 -0
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +1 -0
- package/fixtures/live/agy/parsed.json +5 -0
- package/fixtures/live/agy/run.json +18 -0
- package/fixtures/live/agy/supervisor.log +2 -0
- package/fixtures/live/claude/cmd.txt +1 -0
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/limit-rate_limit.json +21 -0
- package/fixtures/live/claude/out.log +1 -0
- package/fixtures/live/claude/parsed.json +11 -0
- package/fixtures/live/claude/run.json +18 -0
- package/fixtures/live/claude/supervisor.log +2 -0
- package/fixtures/live/codex/cmd.txt +1 -0
- package/fixtures/live/codex/err.log +1 -0
- package/fixtures/live/codex/last.md +2 -0
- package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
- package/fixtures/live/codex/out.log +8 -0
- package/fixtures/live/codex/parsed.json +15 -0
- package/fixtures/live/codex/run.json +18 -0
- package/fixtures/live/codex/supervisor.log +2 -0
- package/fixtures/live/grok/cmd.txt +1 -0
- package/fixtures/live/grok/err.log +32 -0
- package/fixtures/live/grok/out.log +7 -0
- package/fixtures/live/grok/parsed.json +5 -0
- package/fixtures/live/grok/run.json +18 -0
- package/fixtures/live/grok/supervisor.log +2 -0
- package/fixtures/verified.json +34 -0
- package/package.json +68 -0
- package/scripts/board-shots.mjs +69 -0
- package/scripts/build-docs-site.mjs +308 -0
- package/scripts/check-claims.mjs +150 -0
- package/scripts/clean-clone-check.sh +42 -0
- package/scripts/license-sign.mjs +23 -0
- package/scripts/limits-table.mjs +38 -0
- package/scripts/live-limits.mjs +36 -0
- package/scripts/privacy-check.mjs +63 -0
- package/scripts/probe.mjs +92 -0
- package/scripts/seed-fake-cards.mjs +34 -0
- package/scripts/seed-floor-board.mjs +69 -0
- package/scripts/seed-wes-board.mjs +91 -0
- package/scripts/stripe-setup.mjs +99 -0
- package/scripts/vercel-env.mjs +40 -0
- package/src/accounts.mjs +119 -0
- package/src/adapters/agy.mjs +56 -0
- package/src/adapters/claude.mjs +54 -0
- package/src/adapters/codex.mjs +73 -0
- package/src/adapters/common.mjs +32 -0
- package/src/adapters/fake.mjs +48 -0
- package/src/adapters/grok.mjs +51 -0
- package/src/adapters/index.mjs +27 -0
- package/src/adapters/resolve.mjs +40 -0
- package/src/attach.mjs +592 -0
- package/src/auth.mjs +85 -0
- package/src/board/board.css +543 -0
- package/src/board/board.js +1307 -0
- package/src/board/favicon.svg +1 -0
- package/src/board/floor.html +127 -0
- package/src/board/floor.js +670 -0
- package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- package/src/board/fonts/OFL-azeret-mono.txt +94 -0
- package/src/board/fonts/atkinson-board.woff2 +0 -0
- package/src/board/fonts/azeret-board.woff2 +0 -0
- package/src/board/index.html +251 -0
- package/src/board/sessions.js +1451 -0
- package/src/bundle.mjs +103 -0
- package/src/cards.mjs +114 -0
- package/src/chain.mjs +264 -0
- package/src/commands.mjs +79 -0
- package/src/contract.mjs +73 -0
- package/src/env.mjs +19 -0
- package/src/fsx.mjs +72 -0
- package/src/git-snapshot.mjs +152 -0
- package/src/handoff.mjs +171 -0
- package/src/hook.mjs +49 -0
- package/src/land.mjs +106 -0
- package/src/launcher.mjs +272 -0
- package/src/leases.mjs +86 -0
- package/src/ledger.mjs +369 -0
- package/src/license.mjs +168 -0
- package/src/limits.mjs +97 -0
- package/src/live-capture.mjs +69 -0
- package/src/mergequeue.mjs +225 -0
- package/src/orchestrator.mjs +365 -0
- package/src/pipeline.mjs +100 -0
- package/src/preferences.mjs +47 -0
- package/src/presets.mjs +23 -0
- package/src/ratelimit.mjs +49 -0
- package/src/redact.mjs +47 -0
- package/src/resume.mjs +354 -0
- package/src/runner.mjs +438 -0
- package/src/scheduler.mjs +118 -0
- package/src/server.mjs +845 -0
- package/src/session-detail.mjs +129 -0
- package/src/sessions.mjs +202 -0
- package/src/share.mjs +163 -0
- package/src/stations/agent.mjs +42 -0
- package/src/stations/build.mjs +9 -0
- package/src/stations/human.mjs +5 -0
- package/src/stations/land.mjs +6 -0
- package/src/stations/plan.mjs +8 -0
- package/src/stations/pr.mjs +31 -0
- package/src/stations/review.mjs +8 -0
- package/src/stations/test.mjs +27 -0
- package/src/store.mjs +90 -0
- package/src/sync/dashclaw.mjs +70 -0
- package/src/sync/index.mjs +54 -0
- package/src/sync/workboard.mjs +54 -0
- package/src/taps/agy.mjs +64 -0
- package/src/taps/claude-usage.mjs +67 -0
- package/src/taps/claude.mjs +170 -0
- package/src/taps/codex.mjs +286 -0
- package/src/trust.mjs +323 -0
- package/src/usage.mjs +179 -0
- package/src/wait.mjs +30 -0
- package/src/worktree.mjs +207 -0
package/src/server.mjs
ADDED
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// server — the board's HTTP + SSE backend. Ledger-backed: every handler reads
|
|
3
|
+
// card.json / events-*.jsonl on demand (no module-level card store), so a
|
|
4
|
+
// restart shows the same board and a second process sees the same truth.
|
|
5
|
+
// BATON_BIND (127.0.0.1) + BATON_PORT (4747) + BATON_TOKEN are the
|
|
6
|
+
// multiplayer seams (src/auth.mjs).
|
|
7
|
+
import http from 'node:http'
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import { existsSync, readFileSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
|
|
10
|
+
import { join, dirname, resolve, extname, sep } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
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 { createLimiter } from './ratelimit.mjs'
|
|
15
|
+
import { realPath, canonPath } from './fsx.mjs'
|
|
16
|
+
import { listCards, readCard, readRuns, readEvents, cardDir, home } from './store.mjs'
|
|
17
|
+
import { humanAction } from './orchestrator.mjs'
|
|
18
|
+
import { createCard, CardInputError } from './cards.mjs'
|
|
19
|
+
import { IllegalTransition, availableActions } from './chain.mjs'
|
|
20
|
+
import { held } from './leases.mjs'
|
|
21
|
+
import { PRESETS } from './presets.mjs'
|
|
22
|
+
import { names as adapterNames, get as getAdapter, isFake } from './adapters/index.mjs'
|
|
23
|
+
import { createScheduler, schedulerStatus, MAX_CONCURRENT } from './scheduler.mjs'
|
|
24
|
+
import { remove as removeWorktree, worktreeDirty } from './worktree.mjs'
|
|
25
|
+
import { scrub } from './runner.mjs'
|
|
26
|
+
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'
|
|
28
|
+
import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs'
|
|
29
|
+
import { refreshPointers } from './resume.mjs'
|
|
30
|
+
import { landSession, landBlocker, landingNow, pruneSessionWorktree } from './land.mjs'
|
|
31
|
+
import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
|
|
32
|
+
import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
|
|
33
|
+
import { readCodexUsage } from './taps/codex.mjs'
|
|
34
|
+
import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
|
|
35
|
+
|
|
36
|
+
const SELF = fileURLToPath(import.meta.url)
|
|
37
|
+
const BOARD_DIR = join(dirname(SELF), 'board')
|
|
38
|
+
const VERSION = JSON.parse(readFileSync(join(dirname(SELF), '..', 'package.json'), 'utf8')).version
|
|
39
|
+
const DEFAULT_ORDER = ['plan', 'build', 'review', 'test', 'land']
|
|
40
|
+
const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json; charset=utf-8', '.woff2': 'font/woff2' }
|
|
41
|
+
|
|
42
|
+
const log = (msg) => { const q = process.env.LEG_QUIET ?? process.env.BATON_QUIET; if (q !== '1') process.stdout.write(`[board] ${new Date().toISOString()} ${msg}\n`) }
|
|
43
|
+
|
|
44
|
+
// ---- read models ----
|
|
45
|
+
function lastEventOf(id) {
|
|
46
|
+
const evs = readEvents(id)
|
|
47
|
+
return evs.length ? evs[evs.length - 1] : null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function columnsFor(cards) {
|
|
51
|
+
const names = new Set()
|
|
52
|
+
for (const c of cards) for (const s of c.pipeline ?? []) names.add(s.name)
|
|
53
|
+
const ordered = [...DEFAULT_ORDER.filter((n) => names.has(n)), ...[...names].filter((n) => !DEFAULT_ORDER.includes(n)).sort()]
|
|
54
|
+
return ['backlog', 'queued', ...ordered, 'done', 'failed']
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function columnOf(card) {
|
|
58
|
+
if (['backlog', 'queued', 'done'].includes(card.status)) return card.status
|
|
59
|
+
if (['failed', 'killed'].includes(card.status)) return 'failed'
|
|
60
|
+
return card.station
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function summarize(card) {
|
|
64
|
+
const st = (card.pipeline ?? []).find((s) => s.name === card.station) ?? null
|
|
65
|
+
const last = lastEventOf(card.card_id)
|
|
66
|
+
const runs = readRuns(card.card_id)
|
|
67
|
+
const activeRun = runs.find((r) => ['launching', 'running'].includes(r.status)) ?? null
|
|
68
|
+
// Once a station is over (done/failed) card.leg is reset, so the rail is
|
|
69
|
+
// rebuilt from the legs that actually started at this station.
|
|
70
|
+
const terminal = ['done', 'failed', 'killed'].includes(card.status)
|
|
71
|
+
const startedLegs = terminal && st?.kind === 'agent'
|
|
72
|
+
? readEvents(card.card_id).filter((ev) => ev.type === 'leg_started' && ev.station === card.station).map((ev) => ev.leg)
|
|
73
|
+
: []
|
|
74
|
+
const lastLeg = startedLegs.length ? Math.max(...startedLegs) : card.leg
|
|
75
|
+
// card.leg is reset when the station ends, so a finished card read its adapter
|
|
76
|
+
// off chain[0] and reported the agent that STARTED the work as the one that
|
|
77
|
+
// did it. On a handoff card that is the wrong name on the finished row.
|
|
78
|
+
const entry = st?.kind === 'agent' ? st.chain[terminal ? lastLeg : card.leg] ?? null : null
|
|
79
|
+
const legState = (i) => {
|
|
80
|
+
if (terminal) {
|
|
81
|
+
if (!startedLegs.includes(i)) return 'pending'
|
|
82
|
+
if (i < lastLeg) return 'handed'
|
|
83
|
+
return card.status === 'done' ? 'done' : 'failed'
|
|
84
|
+
}
|
|
85
|
+
if (i < card.leg) return 'handed'
|
|
86
|
+
if (i > card.leg) return 'pending'
|
|
87
|
+
return ['running', 'handing_off'].includes(card.status) ? 'active' : 'pending'
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
...card,
|
|
91
|
+
column: columnOf(card),
|
|
92
|
+
station_kind: st?.kind ?? null,
|
|
93
|
+
active_adapter: entry?.adapter ?? null,
|
|
94
|
+
active_mode: entry?.mode ?? null,
|
|
95
|
+
chain_view: st?.kind === 'agent' ? st.chain.map((e, i) => ({
|
|
96
|
+
adapter: e.adapter, mode: e.mode ?? null, approve: Boolean(e.approve),
|
|
97
|
+
// a leg before the last one ended in a handoff (only the last leg can complete a station)
|
|
98
|
+
state: legState(i),
|
|
99
|
+
})) : [],
|
|
100
|
+
actions: availableActions(card),
|
|
101
|
+
last_event: last ? { ts: last.ts, type: last.type, summary: last.summary, actor: last.actor } : null,
|
|
102
|
+
runs_count: runs.length,
|
|
103
|
+
active_run: activeRun ? { run: activeRun.run, adapter: activeRun.adapter, started_at: activeRun.started_at } : null,
|
|
104
|
+
elapsed_ms: activeRun ? Date.now() - Date.parse(activeRun.started_at) : null,
|
|
105
|
+
repo_name: card.repo ? card.repo.split(/[\\/]/).filter(Boolean).pop() : null,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const LOG_TAIL_BYTES = 65536
|
|
110
|
+
|
|
111
|
+
// Read at most the last `maxBytes` of a file without loading the whole thing
|
|
112
|
+
// (run logs can grow large; the board only ever shows a tail of them).
|
|
113
|
+
function readFileTail(path, maxBytes = LOG_TAIL_BYTES) {
|
|
114
|
+
const fd = openSync(path, 'r')
|
|
115
|
+
try {
|
|
116
|
+
const size = fstatSync(fd).size
|
|
117
|
+
const start = Math.max(0, size - maxBytes)
|
|
118
|
+
const len = size - start
|
|
119
|
+
if (!len) return ''
|
|
120
|
+
const buf = Buffer.alloc(len)
|
|
121
|
+
readSync(fd, buf, 0, len, start)
|
|
122
|
+
return buf.toString('utf8')
|
|
123
|
+
} finally {
|
|
124
|
+
closeSync(fd)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function logTail(id, run, tail = 200) {
|
|
129
|
+
const runs = readRuns(id)
|
|
130
|
+
const r = run ? runs.find((x) => x.run === run) : runs[runs.length - 1]
|
|
131
|
+
if (!r) return { run: null, lines: [] }
|
|
132
|
+
const dir = join(cardDir(id), 'runs', String(r.run))
|
|
133
|
+
const lines = []
|
|
134
|
+
for (const f of ['out.log', 'err.log']) {
|
|
135
|
+
const p = join(dir, f)
|
|
136
|
+
if (!existsSync(p)) continue
|
|
137
|
+
const text = readFileTail(p).trim()
|
|
138
|
+
if (text) lines.push(...text.split('\n').map((l) => `${f === 'err.log' ? 'stderr ' : ''}${l}`))
|
|
139
|
+
}
|
|
140
|
+
return { run: r.run, adapter: r.adapter, status: r.status, outcome: r.outcome ?? null, lines: scrub(lines.slice(-tail).join('\n')).split('\n') }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function floor(cards) {
|
|
144
|
+
const running = cards.filter((c) => ['running', 'handing_off'].includes(c.status)).map((c) => {
|
|
145
|
+
const s = summarize(c)
|
|
146
|
+
return { card_id: c.card_id, title: c.title, station: c.station, status: c.status, adapter: s.active_adapter, leg: c.leg, leases: c.leases?.length ? c.leases : ['**'], last_event: s.last_event, since: s.active_run?.started_at ?? c.updated_at, elapsed_ms: s.elapsed_ms, repo_name: s.repo_name }
|
|
147
|
+
})
|
|
148
|
+
const waiting = cards.filter((c) => ['waiting_human', 'needs_approval', 'paused'].includes(c.status)).map((c) => ({ card_id: c.card_id, title: c.title, station: c.station, status: c.status, since: c.updated_at, actions: availableActions(c) }))
|
|
149
|
+
const queued = cards.filter((c) => c.status === 'queued').map((c) => {
|
|
150
|
+
const last = lastEventOf(c.card_id)
|
|
151
|
+
return { card_id: c.card_id, title: c.title, station: c.station, leases: c.leases?.length ? c.leases : ['**'], blocked_by: last?.type === 'blocked_by' ? last.summary : null }
|
|
152
|
+
})
|
|
153
|
+
return {
|
|
154
|
+
running, waiting, queued,
|
|
155
|
+
leases: held(cards),
|
|
156
|
+
scheduler: { ...schedulerStatus(), max_concurrent: MAX_CONCURRENT },
|
|
157
|
+
repos: [...new Set(cards.map((c) => c.repo))],
|
|
158
|
+
counts: { running: running.length, queued: queued.length, waiting: waiting.length, done: cards.filter((c) => c.status === 'done').length },
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function trunk(cards, sinceMs) {
|
|
163
|
+
const cutoff = Date.now() - sinceMs
|
|
164
|
+
const landed = []
|
|
165
|
+
for (const c of cards) {
|
|
166
|
+
for (const e of readEvents(c.card_id)) {
|
|
167
|
+
if (e.type !== 'landed') continue
|
|
168
|
+
if (Date.parse(e.ts) < cutoff) continue
|
|
169
|
+
landed.push({ ts: e.ts, card_id: c.card_id, title: c.title, summary: e.summary, body: e.body ?? null, actor: e.actor, station: e.station })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
landed.sort((a, b) => (a.ts < b.ts ? 1 : -1))
|
|
173
|
+
return { since_ms: sinceMs, landed }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseSince(s) {
|
|
177
|
+
const m = /^(\d+)(m|h|d)$/.exec(String(s ?? '1h'))
|
|
178
|
+
if (!m) return 3600000
|
|
179
|
+
return parseInt(m[1], 10) * { m: 60000, h: 3600000, d: 86400000 }[m[2]]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let toolsCache = null
|
|
183
|
+
// Is this agent actually here? Ask its adapter to resolve the binary the
|
|
184
|
+
// runner would spawn (codex's native exe, an npm entry, a BATON_<AGENT>_BIN
|
|
185
|
+
// override) instead of running the bare name, which on Windows needs a shell
|
|
186
|
+
// and told the board "no codex" while the runner could start it fine.
|
|
187
|
+
export async function detectTools({ refresh = false } = {}) {
|
|
188
|
+
if (toolsCache && !refresh) return toolsCache
|
|
189
|
+
const probe = (bin, args = ['--version']) => {
|
|
190
|
+
const r = spawnSync(bin, args, { windowsHide: true, encoding: 'utf8', timeout: 8000 })
|
|
191
|
+
return !r.error && r.status === 0
|
|
192
|
+
}
|
|
193
|
+
const agents = {}
|
|
194
|
+
for (const name of ['claude', 'codex', 'agy']) {
|
|
195
|
+
try {
|
|
196
|
+
const { bin, viaNode, entry } = (await getAdapter(name)).resolve()
|
|
197
|
+
const target = viaNode ? (entry ?? bin) : bin
|
|
198
|
+
agents[name] = /[\\/]/.test(target) ? existsSync(target) : probe(target)
|
|
199
|
+
} catch { agents[name] = false }
|
|
200
|
+
}
|
|
201
|
+
let chb = false
|
|
202
|
+
try { resolveChb(); chb = true } catch {}
|
|
203
|
+
toolsCache = { ...agents, grok: probe('grok'), chb, git: probe('git') }
|
|
204
|
+
return toolsCache
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function adaptersInfo() {
|
|
208
|
+
const out = []
|
|
209
|
+
for (const n of adapterNames()) {
|
|
210
|
+
const a = await getAdapter(n)
|
|
211
|
+
out.push({ name: n, modes: a.modes, fake: isFake(n), stdin: a.stdin })
|
|
212
|
+
}
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- sessions (baton claude|codex|agy) ----
|
|
217
|
+
const trunkCache = new Map()
|
|
218
|
+
function trunkFor(repo) {
|
|
219
|
+
const hit = trunkCache.get(repo)
|
|
220
|
+
if (hit && Date.now() - hit.at < 15000) return hit.data
|
|
221
|
+
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 }
|
|
222
|
+
let branch = null
|
|
223
|
+
for (const b of ['main', 'master', 'trunk']) if (g(['rev-parse', '--verify', '--quiet', b]) !== null) { branch = b; break }
|
|
224
|
+
const log = branch ? g(['log', '--format=%h%x1f%s%x1f%cr%x1f%an', '-6', branch]) : null
|
|
225
|
+
const data = {
|
|
226
|
+
repo, repo_name: repo.split(/[\\/]/).filter(Boolean).pop(), branch,
|
|
227
|
+
commits: (log ?? '').split('\n').filter(Boolean).map((l) => { const [sha, subject, when, author] = l.split('\x1f'); return { sha, subject, when, author } }),
|
|
228
|
+
}
|
|
229
|
+
trunkCache.set(repo, { at: Date.now(), data })
|
|
230
|
+
return data
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Each trunk commit a Land put there says who landed it (landings.jsonl).
|
|
234
|
+
function withLandings(t, landings) {
|
|
235
|
+
if (!landings.length) return t
|
|
236
|
+
return {
|
|
237
|
+
...t,
|
|
238
|
+
commits: t.commits.map((c) => {
|
|
239
|
+
const l = landings.findLast((x) => (x.commits?.length ? x.commits : [x.sha]).some((sha) => String(sha).startsWith(c.sha)))
|
|
240
|
+
return l ? { ...c, landed_by: { agent: l.agent, account: l.account, session_id: l.session_id, by: l.by, at: l.ts } } : c
|
|
241
|
+
}),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// What another human sees of a terminal that is not theirs: that it is there,
|
|
246
|
+
// nothing it has said, read or written. No task, no paths, no file names, no
|
|
247
|
+
// limit text, no bundle, no events.
|
|
248
|
+
function redactSession(s) {
|
|
249
|
+
return {
|
|
250
|
+
session_id: s.session_id, agent: s.agent, account: s.account, status: s.status, active: s.active,
|
|
251
|
+
started_at: s.started_at, elapsed_ms: s.elapsed_ms, turns: s.turns, repo_name: s.repo_name, branch: s.branch,
|
|
252
|
+
owner: s.owner ?? null, limits: s.limits ?? null, lineage: s.lineage ?? null,
|
|
253
|
+
warning: s.warning ? { window: s.warning.window, pct: s.warning.pct, resets_at: s.warning.resets_at } : null,
|
|
254
|
+
limit: s.limit ? { reason: s.limit.reason, resets_at: s.limit.resets_at ?? null } : null,
|
|
255
|
+
waiting: s.waiting ?? null,
|
|
256
|
+
worktree: s.worktree ? { branch: s.worktree.branch, base: s.worktree.base } : null,
|
|
257
|
+
// the branch is already on the worktree chip: naming it again costs nothing
|
|
258
|
+
// and is what the board's land line reads
|
|
259
|
+
land: s.land ? { state: s.land.state, branch: s.land.branch ?? null, base: s.land.base ?? null, sha: s.land.sha ?? null, reason: s.land.reason ?? null } : null,
|
|
260
|
+
task: null, cwd: null, files: [], overlap: [], requests: [], hidden: true,
|
|
261
|
+
land_blocker: `read-only: this terminal belongs to ${s.owner ?? 'someone else'}`,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function visibleSessionFile(file) {
|
|
266
|
+
const value = String(file ?? '')
|
|
267
|
+
return !value.includes('*** Begin Patch') && !value.includes('*** End Patch')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function sessionsView({ viewer = null, share = null } = {}) {
|
|
271
|
+
const shared = Boolean(share && shareIsOn(share))
|
|
272
|
+
const list = reapLost(listSessions())
|
|
273
|
+
const ov = overlaps(list)
|
|
274
|
+
const configuredAccounts = readAccounts()
|
|
275
|
+
const sessions = list.map((s) => {
|
|
276
|
+
const land = readLand(s.session_id)
|
|
277
|
+
const handoffOrder = normalizeHandoffOrder(s.handoff_order)
|
|
278
|
+
const chain = candidates({ agent: s.agent, account: s.account, accounts: configuredAccounts, order: handoffOrder })
|
|
279
|
+
const preferredNext = chain[0] ?? null
|
|
280
|
+
const availabilityKnown = Boolean(s.installed)
|
|
281
|
+
const eligibleNext = availabilityKnown ? (chain.find((next) => s.installed[next.agent] !== false && isAvailable(readUsage(next.agent, next.account))) ?? null) : null
|
|
282
|
+
return {
|
|
283
|
+
...s,
|
|
284
|
+
handoff_order: handoffOrder,
|
|
285
|
+
chain,
|
|
286
|
+
preferred_next: preferredNext,
|
|
287
|
+
eligible_next: eligibleNext,
|
|
288
|
+
handoff_availability_known: availabilityKnown,
|
|
289
|
+
can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
|
|
290
|
+
active: isActive(s),
|
|
291
|
+
overlap: ov.get(s.session_id) ?? [],
|
|
292
|
+
elapsed_ms: Date.now() - Date.parse(s.started_at),
|
|
293
|
+
// Older attached Codex processes can retain one parser mistake where an
|
|
294
|
+
// apply_patch source block was stored as a file name. Keep history on
|
|
295
|
+
// disk and hide only unmistakable patch envelopes in the board view.
|
|
296
|
+
files: [...new Set([...(s.files_touched ?? []), ...(s.files_dirty ?? [])])].filter(visibleSessionFile),
|
|
297
|
+
// a 'landing' left behind by a board restart is no longer in flight
|
|
298
|
+
land: land?.state === 'landing' && !landingNow(s.session_id) ? { ...land, state: 'interrupted' } : land,
|
|
299
|
+
land_blocker: s.worktree ? landBlocker(s) : null,
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
const accounts = []
|
|
303
|
+
for (const agent of Object.keys(configuredAccounts)) for (const account of configuredAccounts[agent]) {
|
|
304
|
+
const u = readUsage(agent, account)
|
|
305
|
+
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 })
|
|
306
|
+
}
|
|
307
|
+
const repos = new Map()
|
|
308
|
+
for (const s of sessions) if (s.repo && (s.active || s.worktree) && !repos.has(canonPath(s.repo))) repos.set(canonPath(s.repo), s.repo)
|
|
309
|
+
const landings = readLandings()
|
|
310
|
+
const canon = new Map()
|
|
311
|
+
const landingsFor = (key) => landings.filter((l) => { if (!canon.has(l.repo)) canon.set(l.repo, canonPath(l.repo)); return canon.get(l.repo) === key })
|
|
312
|
+
const trunk = [...repos].map(([key, r]) => { try { return withLandings(trunkFor(r), landingsFor(key)) } catch { return { repo: r, commits: [] } } })
|
|
313
|
+
const guest = shared && viewer && viewer.role !== 'owner'
|
|
314
|
+
const mine = (s) => !shared || !viewer || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
|
|
315
|
+
const shown = sessions.map((s) => (mine(s) ? { ...s, requests: readRequests(s.session_id).filter((r) => r.state === 'pending') } : redactSession(s)))
|
|
316
|
+
return {
|
|
317
|
+
sessions: shown,
|
|
318
|
+
// a guest sees which accounts exist and which are busy, never how much of
|
|
319
|
+
// them is used: the head prints "not shared" in these slots, and the
|
|
320
|
+
// percentages, the reset times and the reading source stay on this machine
|
|
321
|
+
// (.design/BOARD-DESIGN.md 6.13)
|
|
322
|
+
accounts: guest ? accounts.map((a) => ({ agent: a.agent, account: a.account, live: a.live, shared: false })) : accounts,
|
|
323
|
+
// a guest sees what landed, not where the repo lives on this machine
|
|
324
|
+
trunk: guest ? trunk.map((t) => ({ repo_name: t.repo_name, branch: t.branch, commits: t.commits ?? [] })) : trunk,
|
|
325
|
+
you: viewer,
|
|
326
|
+
share: { on: shared, bind: shared ? share.bind : null, people: shared ? share.people.length : 0 },
|
|
327
|
+
preferences: guest ? null : readPreferences(),
|
|
328
|
+
ts: new Date().toISOString(),
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---- http helpers ----
|
|
333
|
+
function send(res, status, body, headers = {}) {
|
|
334
|
+
const data = typeof body === 'string' ? body : JSON.stringify(body)
|
|
335
|
+
res.writeHead(status, { 'Content-Type': typeof body === 'string' ? 'text/plain; charset=utf-8' : 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers })
|
|
336
|
+
res.end(data)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function readBody(req) {
|
|
340
|
+
return new Promise((resolvePromise, reject) => {
|
|
341
|
+
let data = ''
|
|
342
|
+
req.on('data', (c) => { data += c; if (data.length > 1e6) reject(new Error('body too large')) })
|
|
343
|
+
req.on('end', () => {
|
|
344
|
+
if (!data) return resolvePromise({})
|
|
345
|
+
try { resolvePromise(JSON.parse(data)) } catch { reject(new Error('invalid JSON body')) }
|
|
346
|
+
})
|
|
347
|
+
req.on('error', reject)
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function serveStatic(res, urlPath) {
|
|
352
|
+
const map = { '/': 'index.html', '/floor': 'floor.html' }
|
|
353
|
+
const rel = map[urlPath] ?? urlPath.replace(/^\/+/, '')
|
|
354
|
+
const file = resolve(BOARD_DIR, rel)
|
|
355
|
+
if (!file.startsWith(BOARD_DIR + sep) || !existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found')
|
|
356
|
+
res.writeHead(200, { 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' })
|
|
357
|
+
res.end(readFileSync(file))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---- SSE: watch $BATON_HOME/cards for fs events and push only what changed ----
|
|
361
|
+
function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () => sessionsView(), reauth = (c) => c.viewer } = {}) {
|
|
362
|
+
const clients = new Set() // { res, viewer, token, loopback, sig }
|
|
363
|
+
let watcher = null
|
|
364
|
+
let healthTimer = null
|
|
365
|
+
const pending = new Set()
|
|
366
|
+
let flushTimer = null
|
|
367
|
+
// data may be a function of the client's viewer: each human gets their own
|
|
368
|
+
// payload, so a guest's stream never carries someone else's terminal. The
|
|
369
|
+
// viewer is re-resolved from the live roster on every push, so a link that
|
|
370
|
+
// `share rm`/`rotate` invalidated loses its stream at once.
|
|
371
|
+
const broadcast = (event, data) => {
|
|
372
|
+
for (const c of [...clients]) {
|
|
373
|
+
const viewer = reauth(c)
|
|
374
|
+
if (viewer === null) { try { c.res.end() } catch {} clients.delete(c); if (!clients.size) stopWatch(); continue }
|
|
375
|
+
c.viewer = viewer
|
|
376
|
+
const payload = typeof data === 'function' ? data(viewer) : data
|
|
377
|
+
if (payload === null || payload === undefined) continue
|
|
378
|
+
try { c.res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`) } catch {}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// Re-read and re-emit exactly one card's files — never the whole ledger. Each
|
|
382
|
+
// client carries its own high-water mark, so a second client connecting never
|
|
383
|
+
// resets the count the first is reading from.
|
|
384
|
+
const refreshCard = (id) => {
|
|
385
|
+
const card = readCard(id)
|
|
386
|
+
// pipeline cards and their events are the owner's: a guest never gets them
|
|
387
|
+
const forOwner = (payload) => (viewer) => (viewer && viewer.role !== 'owner' ? null : payload)
|
|
388
|
+
if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
|
|
389
|
+
const events = readEvents(id)
|
|
390
|
+
// broadcast() refreshes each client's viewer (and drops revoked ones) first
|
|
391
|
+
broadcast('card', forOwner(summarize(card)))
|
|
392
|
+
for (const c of [...clients]) {
|
|
393
|
+
if (!c.viewer || c.viewer.role !== 'owner') { c.sig.set(id, events.length); continue }
|
|
394
|
+
const from = c.sig.get(id) ?? 0
|
|
395
|
+
c.sig.set(id, events.length)
|
|
396
|
+
for (const e of events.slice(from)) { try { c.res.write(`event: event\ndata: ${JSON.stringify(e)}\n\n`) } catch {} }
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const flushPending = () => {
|
|
400
|
+
flushTimer = null
|
|
401
|
+
const ids = [...pending]
|
|
402
|
+
pending.clear()
|
|
403
|
+
for (const id of ids) refreshCard(id)
|
|
404
|
+
}
|
|
405
|
+
const scheduleRefresh = (id) => {
|
|
406
|
+
pending.add(id)
|
|
407
|
+
if (!flushTimer) flushTimer = setTimeout(flushPending, debounceMs)
|
|
408
|
+
}
|
|
409
|
+
let sessionsWatcher = null
|
|
410
|
+
let sessionsTimer = null
|
|
411
|
+
const pushSessions = () => { sessionsTimer = null; try { broadcast('sessions', (viewer) => viewFor(viewer)) } catch (err) { log(`sessions view: ${err.message}`) } }
|
|
412
|
+
const startWatch = () => {
|
|
413
|
+
if (watcher) return
|
|
414
|
+
try {
|
|
415
|
+
const sdir = sessionsRoot()
|
|
416
|
+
mkdirSync(sdir, { recursive: true })
|
|
417
|
+
sessionsWatcher = fsWatch(realPath(sdir), { recursive: true }, () => { if (!sessionsTimer) sessionsTimer = setTimeout(pushSessions, 300) })
|
|
418
|
+
} catch (err) { log(`sessions watch: ${err.message}`); sessionsWatcher = null }
|
|
419
|
+
// watch the real long path: libuv's recursive watcher asserts when the
|
|
420
|
+
// watched dir is an 8.3 short path (fs-event.c, seen on a GitHub runner)
|
|
421
|
+
const dir = join(home(), 'cards')
|
|
422
|
+
try {
|
|
423
|
+
mkdirSync(dir, { recursive: true })
|
|
424
|
+
watcher = fsWatch(realPath(dir), { recursive: true }, (_event, filename) => {
|
|
425
|
+
if (!filename) return
|
|
426
|
+
const id = String(filename).split(/[\\/]/)[0]
|
|
427
|
+
if (id.startsWith('card-')) scheduleRefresh(id)
|
|
428
|
+
})
|
|
429
|
+
} catch (err) { log(`sse watch: ${err.message}`); watcher = null }
|
|
430
|
+
// the scheduler is the pipeline's, like /api/health: a guest's topbar
|
|
431
|
+
// gets the tick without it
|
|
432
|
+
healthTimer = setInterval(() => {
|
|
433
|
+
const ts = new Date().toISOString()
|
|
434
|
+
broadcast('health', (viewer) => (viewer && viewer.role !== 'owner' ? { ok: true, ts } : { ok: true, scheduler: { ...schedulerStatus(), max_concurrent: MAX_CONCURRENT }, ts }))
|
|
435
|
+
pushSessions()
|
|
436
|
+
}, healthIntervalMs)
|
|
437
|
+
}
|
|
438
|
+
const stopWatch = () => {
|
|
439
|
+
if (watcher) { watcher.close(); watcher = null }
|
|
440
|
+
if (sessionsWatcher) { sessionsWatcher.close(); sessionsWatcher = null }
|
|
441
|
+
if (sessionsTimer) { clearTimeout(sessionsTimer); sessionsTimer = null }
|
|
442
|
+
if (healthTimer) { clearInterval(healthTimer); healthTimer = null }
|
|
443
|
+
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
|
|
444
|
+
pending.clear()
|
|
445
|
+
}
|
|
446
|
+
const add = (res, cards, viewer = null, meta = {}) => {
|
|
447
|
+
const client = { res, viewer, token: meta.token ?? null, loopback: Boolean(meta.loopback), sig: new Map() }
|
|
448
|
+
clients.add(client)
|
|
449
|
+
for (const c of cards) client.sig.set(c.card_id, readEvents(c.card_id).length)
|
|
450
|
+
startWatch()
|
|
451
|
+
res.on('close', () => { clients.delete(client); if (!clients.size) stopWatch() })
|
|
452
|
+
}
|
|
453
|
+
const stop = () => { stopWatch(); for (const c of clients) { try { c.res.end() } catch {} } clients.clear() }
|
|
454
|
+
return { add, stop, broadcast, clients }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---- the server ----
|
|
458
|
+
export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN || process.env.BATON_TOKEN || '', scheduler = (process.env.LEG_NO_SCHEDULER || process.env.BATON_NO_SCHEDULER) !== '1', share, usagePolling = false, usageReader = readCodexUsage } = {}) {
|
|
459
|
+
// An explicit `share` (tests) is fixed; the real server passes none and reads
|
|
460
|
+
// share.json from disk, re-reading it per request (mtime-cached) so `baton
|
|
461
|
+
// share add|rotate|rm` takes effect on a live board — a new link works at
|
|
462
|
+
// once and a removed or rotated one stops at once — without a restart.
|
|
463
|
+
const explicitShare = share !== undefined
|
|
464
|
+
const initialShare = explicitShare ? share : readShare()
|
|
465
|
+
const shared0 = shareIsOn(initialShare)
|
|
466
|
+
// with share on, the board's address and port come from share.json
|
|
467
|
+
bind = bind ?? (shared0 ? initialShare.bind : (process.env.LEG_BIND || process.env.BATON_BIND || '127.0.0.1'))
|
|
468
|
+
port = port ?? (shared0 ? initialShare.port : Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747))
|
|
469
|
+
checkBind({ bind, token, share: initialShare })
|
|
470
|
+
let shareSnapshot = initialShare
|
|
471
|
+
let shareMtime = -1
|
|
472
|
+
const currentShare = explicitShare ? () => initialShare : () => {
|
|
473
|
+
try {
|
|
474
|
+
const st = statSync(sharePath())
|
|
475
|
+
if (st.mtimeMs !== shareMtime) { shareMtime = st.mtimeMs; shareSnapshot = readShare() }
|
|
476
|
+
} catch { if (shareMtime !== -1) { shareMtime = -1; shareSnapshot = readShare() } }
|
|
477
|
+
return shareSnapshot
|
|
478
|
+
}
|
|
479
|
+
const limiter = createLimiter()
|
|
480
|
+
const viewFor = (viewer, sh) => sessionsView({ viewer, share: sh ?? currentShare() })
|
|
481
|
+
const forOwner = (payload) => (viewer) => (viewer && viewer.role !== 'owner' ? null : payload)
|
|
482
|
+
// SSE re-identifies each client from the live roster on every push
|
|
483
|
+
const reauthClient = (c) => {
|
|
484
|
+
const sh = currentShare()
|
|
485
|
+
// share stopping is a revocation, never a promotion: a guest holding this
|
|
486
|
+
// stream through their own token loses it, and only the machine's own
|
|
487
|
+
// tokenless page (or this board's BATON_TOKEN) is the single-player owner
|
|
488
|
+
if (!shareIsOn(sh)) {
|
|
489
|
+
if (c.token) return tokenMatches(token, c.token) ? { name: 'token', role: 'owner' } : null
|
|
490
|
+
return c.loopback ? { name: 'local', role: 'owner' } : null
|
|
491
|
+
}
|
|
492
|
+
const person = identify(sh, c.token)
|
|
493
|
+
if (person) return { name: person.name, role: person.role }
|
|
494
|
+
if (!c.token && sh.loopback_owner !== false && c.loopback) {
|
|
495
|
+
const owner = personNamed(sh, sh.owner) ?? sh.people.find((p) => p.role === 'owner') ?? null
|
|
496
|
+
if (owner) return { name: owner.name, role: owner.role }
|
|
497
|
+
}
|
|
498
|
+
return null
|
|
499
|
+
}
|
|
500
|
+
const sse = createSse({ viewFor, reauth: reauthClient })
|
|
501
|
+
let sched = null
|
|
502
|
+
let usageTimer = null
|
|
503
|
+
let usageController = null
|
|
504
|
+
let usageInFlight = null
|
|
505
|
+
const refreshCodexAccounts = () => {
|
|
506
|
+
if (!usagePolling || ((process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN) && usageReader === readCodexUsage) || usageInFlight) return usageInFlight
|
|
507
|
+
usageController = new AbortController()
|
|
508
|
+
const signal = usageController.signal
|
|
509
|
+
usageInFlight = Promise.all((readAccounts().codex ?? ['default']).map(async (account) => {
|
|
510
|
+
const codexHome = envFor('codex', account).CODEX_HOME || LAYOUT.codex.home()
|
|
511
|
+
const r = await usageReader({ codexHome, timeoutMs: 8000, signal })
|
|
512
|
+
if (!r.ok) return false
|
|
513
|
+
recordUsage('codex', account, r.limits, 'codex app-server account/rateLimits/read', { observed_at: r.observed_at, available: r.available })
|
|
514
|
+
return true
|
|
515
|
+
})).then((changed) => {
|
|
516
|
+
if (changed.some(Boolean)) sse.broadcast('sessions', (viewer) => viewFor(viewer))
|
|
517
|
+
}).catch((err) => log(`codex usage refresh: ${err.message}`)).finally(() => {
|
|
518
|
+
usageInFlight = null
|
|
519
|
+
usageController = null
|
|
520
|
+
})
|
|
521
|
+
return usageInFlight
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function handle(req, res) {
|
|
525
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
|
|
526
|
+
const path = url.pathname
|
|
527
|
+
if (!path.startsWith('/api/')) return serveStatic(res, path)
|
|
528
|
+
// reject a state-changing request whose Origin is a different site: a page
|
|
529
|
+
// the operator has open in the same browser cannot drive the loopback board
|
|
530
|
+
if (req.method !== 'GET' && req.headers.origin) {
|
|
531
|
+
let ok = false
|
|
532
|
+
try { ok = new URL(req.headers.origin).host === req.headers.host } catch {}
|
|
533
|
+
if (!ok) return send(res, 403, { error: 'cross-origin request refused' })
|
|
534
|
+
}
|
|
535
|
+
// the roster, resolved fresh per request from share.json (mtime-cached)
|
|
536
|
+
const share = currentShare()
|
|
537
|
+
const shared = shareIsOn(share)
|
|
538
|
+
const ip = remoteAddress(req) || 'unknown'
|
|
539
|
+
// a token cannot be guessed at speed, and no one client can flood the board
|
|
540
|
+
if (limiter.lockedOut(ip)) return send(res, 429, { error: 'too many bad tokens from here; wait a minute' }, { 'Retry-After': String(limiter.retryAfter(ip)) })
|
|
541
|
+
const auth = authorize({ token, req, url, share, bind, loopbackOwner: isLoopbackRequest(req) })
|
|
542
|
+
if (!auth.ok) {
|
|
543
|
+
// only a token that was presented and did not match counts as a guess;
|
|
544
|
+
// a board page that has not been given a token yet is not an attacker,
|
|
545
|
+
// and neither is a stale tab polling the one token it was given
|
|
546
|
+
const presented = presentedToken(req, url)
|
|
547
|
+
if (presented) limiter.failure(ip, presented)
|
|
548
|
+
return send(res, 401, { error: shared ? 'unauthorized: open the board with your own link (leg share)' : 'unauthorized: set Authorization: Bearer <LEG_TOKEN>' })
|
|
549
|
+
}
|
|
550
|
+
const viewer = auth.person ? { name: auth.person.name, role: auth.person.role } : { name: auth.subject ?? 'local', role: 'owner' }
|
|
551
|
+
// a name is a bucket only when it names a human: 'local' and 'token' are
|
|
552
|
+
// every client at once, so those are counted per address
|
|
553
|
+
const rl = limiter.request(auth.person ? viewer.name : ip)
|
|
554
|
+
if (!rl.ok) return send(res, 429, { error: `rate limit: more than ${limiter.max} requests a minute` }, { 'Retry-After': String(rl.retry_after) })
|
|
555
|
+
const actor = { type: 'human', id: viewer.name }
|
|
556
|
+
// a guest sees the terminals lane, read-only; the pipeline side is the owner's
|
|
557
|
+
const guest = shared && viewer.role !== 'owner'
|
|
558
|
+
const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
|
|
559
|
+
const parts = path.split('/').filter(Boolean) // ['api', ...]
|
|
560
|
+
if (guest && ['cards', 'floor', 'presets', 'adapters', 'leases', 'trunk'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner of this machine' })
|
|
561
|
+
try {
|
|
562
|
+
if (req.method === 'GET' && path === '/api/health') {
|
|
563
|
+
const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
|
|
564
|
+
if (guest) return send(res, 200, { ok: true, version: VERSION, you })
|
|
565
|
+
const cards = listCards()
|
|
566
|
+
return send(res, 200, { ok: true, 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 })
|
|
567
|
+
}
|
|
568
|
+
if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
|
|
569
|
+
if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
|
|
570
|
+
if (req.method === 'GET' && path === '/api/cards') {
|
|
571
|
+
const cards = listCards()
|
|
572
|
+
return send(res, 200, { columns: columnsFor(cards), cards: cards.map(summarize) })
|
|
573
|
+
}
|
|
574
|
+
if (req.method === 'POST' && path === '/api/cards') {
|
|
575
|
+
const body = await readBody(req)
|
|
576
|
+
try {
|
|
577
|
+
const card = await createCard(body, actor)
|
|
578
|
+
sse.broadcast('card', forOwner(summarize(card)))
|
|
579
|
+
return send(res, 201, { card: summarize(card) })
|
|
580
|
+
} catch (err) {
|
|
581
|
+
if (err instanceof CardInputError) return send(res, 400, { error: err.message })
|
|
582
|
+
throw err
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (req.method === 'GET' && path === '/api/events') {
|
|
586
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
|
|
587
|
+
const cards = guest ? [] : listCards()
|
|
588
|
+
res.write(`event: hello\ndata: ${JSON.stringify({ columns: columnsFor(cards), cards: cards.map(summarize), sessions: viewFor(viewer), ts: new Date().toISOString() })}\n\n`)
|
|
589
|
+
sse.add(res, cards, viewer, { token: presentedToken(req, url), loopback: isLoopbackRequest(req) })
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
if (req.method === 'GET' && path === '/api/sessions') return send(res, 200, viewFor(viewer))
|
|
593
|
+
if (path === '/api/settings') {
|
|
594
|
+
if (guest) return send(res, 403, { error: 'the machine settings belong to the owner of this board' })
|
|
595
|
+
if (req.method === 'GET') return send(res, 200, { preferences: readPreferences() })
|
|
596
|
+
if (req.method === 'POST' || req.method === 'PATCH') {
|
|
597
|
+
const body = await readBody(req)
|
|
598
|
+
try {
|
|
599
|
+
const preferences = writePreferences({ handoff_order: requireHandoffOrder(body.handoff_order) })
|
|
600
|
+
sse.broadcast('sessions', (v) => viewFor(v))
|
|
601
|
+
return send(res, 200, { preferences })
|
|
602
|
+
} catch (err) {
|
|
603
|
+
if (err instanceof TypeError) return send(res, 400, { error: err.message })
|
|
604
|
+
throw err
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (parts[1] === 'sessions' && parts[2]) {
|
|
609
|
+
const id = parts[2]
|
|
610
|
+
const sess = readSession(id)
|
|
611
|
+
if (!sess) return send(res, 404, { error: `session not found: ${id}` })
|
|
612
|
+
const mine = ownsSession(sess)
|
|
613
|
+
// another human's terminal: its prompts, files and events are not theirs to read
|
|
614
|
+
if (!mine && !(req.method === 'POST' && parts[3] === 'request-handoff')) {
|
|
615
|
+
return send(res, 403, { error: `read-only: this terminal belongs to ${sess.owner ?? 'someone else'}; ask for a hand-off instead` })
|
|
616
|
+
}
|
|
617
|
+
if (req.method === 'POST' && parts[3] === 'request-handoff') {
|
|
618
|
+
if (!shared) return send(res, 409, { error: 'share is off: use Hand off now' })
|
|
619
|
+
if (mine) return send(res, 409, { error: 'this terminal is yours: use Hand off now' })
|
|
620
|
+
if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
|
|
621
|
+
const list = readRequests(id).filter((r) => !(r.by === viewer.name && r.state === 'pending'))
|
|
622
|
+
list.push({ by: viewer.name, at: new Date().toISOString(), state: 'pending' })
|
|
623
|
+
writeRequests(id, list)
|
|
624
|
+
appendSessionEvent(id, { type: 'handoff_requested', by: viewer.name, summary: `${viewer.name} asked ${sess.owner ?? 'the owner'} to hand this terminal off` })
|
|
625
|
+
log(`hand-off requested for ${id} by ${viewer.name}`)
|
|
626
|
+
sse.broadcast('sessions', (v) => viewFor(v))
|
|
627
|
+
return send(res, 202, { ok: true, requested: 'handoff', by: viewer.name })
|
|
628
|
+
}
|
|
629
|
+
if (req.method === 'POST' && parts[3] === 'requests' && parts[4] && ['approve', 'dismiss'].includes(parts[5])) {
|
|
630
|
+
const who = decodeURIComponent(parts[4])
|
|
631
|
+
const list = readRequests(id)
|
|
632
|
+
const hit = list.find((r) => r.by === who && r.state === 'pending')
|
|
633
|
+
if (!hit) return send(res, 404, { error: `no pending hand-off request from ${who}` })
|
|
634
|
+
const approve = parts[5] === 'approve'
|
|
635
|
+
// check liveness before mutating the request, so a session that ended
|
|
636
|
+
// just before Approve does not leave the request stuck in 'approved'
|
|
637
|
+
if (approve && !isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
|
|
638
|
+
hit.state = approve ? 'approved' : 'dismissed'
|
|
639
|
+
hit.answered_at = new Date().toISOString()
|
|
640
|
+
hit.answered_by = viewer.name
|
|
641
|
+
writeRequests(id, list)
|
|
642
|
+
if (approve) requestControl(id, { handoff: true, by: `${viewer.name} for ${who}` })
|
|
643
|
+
appendSessionEvent(id, { type: approve ? 'handoff_requested' : 'status', by: viewer.name, summary: `${viewer.name} ${approve ? 'approved' : 'dismissed'} ${who}'s hand-off request` })
|
|
644
|
+
sse.broadcast('sessions', (v) => viewFor(v))
|
|
645
|
+
return send(res, 200, { ok: true, request: hit })
|
|
646
|
+
}
|
|
647
|
+
if (req.method === 'GET' && parts.length === 3) return send(res, 200, { session: sess, events: readSessionEvents(id), requests: readRequests(id) })
|
|
648
|
+
// the card's drawer: what the agent last said, what it changed, what it
|
|
649
|
+
// has done. Only ever this viewer's own terminal; the guard above sent
|
|
650
|
+
// anyone else away before we read a transcript.
|
|
651
|
+
if (req.method === 'GET' && parts[3] === 'detail') return send(res, 200, sessionDetail(sess))
|
|
652
|
+
if (req.method === 'GET' && parts[3] === 'diff') {
|
|
653
|
+
try {
|
|
654
|
+
return send(res, 200, sessionDiff(sess, url.searchParams.get('file') ?? ''))
|
|
655
|
+
} catch (err) {
|
|
656
|
+
if (err instanceof DiffInputError) return send(res, 400, { error: err.message })
|
|
657
|
+
throw err
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (req.method === 'POST' && parts[3] === 'handoff-order') {
|
|
661
|
+
if (!sess.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY)) {
|
|
662
|
+
return send(res, 409, { error: 'this terminal started before order changes were available; save the order as the default, then restart the terminal when ready' })
|
|
663
|
+
}
|
|
664
|
+
if (!['starting', 'running', 'warning', 'limit', 'waiting'].includes(sess.status)) {
|
|
665
|
+
return send(res, 409, { error: `handoff order cannot change while this terminal is ${sess.status}` })
|
|
666
|
+
}
|
|
667
|
+
const body = await readBody(req)
|
|
668
|
+
try {
|
|
669
|
+
const order = requireHandoffOrder(body.handoff_order)
|
|
670
|
+
const next = updateSession(id, (current) => {
|
|
671
|
+
if (!['starting', 'running', 'warning', 'limit', 'waiting'].includes(current.status)) {
|
|
672
|
+
const conflict = new Error(`handoff order cannot change while this terminal is ${current.status}`)
|
|
673
|
+
conflict.statusCode = 409
|
|
674
|
+
throw conflict
|
|
675
|
+
}
|
|
676
|
+
return {
|
|
677
|
+
handoff_order: order,
|
|
678
|
+
chain: candidates({ agent: current.agent, account: current.account, accounts: readAccounts(), order }),
|
|
679
|
+
}
|
|
680
|
+
}, { event: { type: 'status', by: actor.id, summary: `handoff order changed to ${order.join(' → ')}` } })
|
|
681
|
+
sse.broadcast('sessions', (v) => viewFor(v))
|
|
682
|
+
return send(res, 200, { session: next })
|
|
683
|
+
} catch (err) {
|
|
684
|
+
if (err instanceof TypeError) return send(res, 400, { error: err.message })
|
|
685
|
+
if (err.statusCode === 409) return send(res, 409, { error: err.message })
|
|
686
|
+
throw err
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (req.method === 'POST' && parts[3] === 'land') {
|
|
690
|
+
const why = landBlocker(sess)
|
|
691
|
+
if (why) return send(res, 409, { error: why })
|
|
692
|
+
landSession(sess, { by: actor.id })
|
|
693
|
+
.catch((err) => log(`land ${id}: ${err.message}`))
|
|
694
|
+
.finally(() => { trunkCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
|
|
695
|
+
log(`land requested for ${id} by ${actor.id}`)
|
|
696
|
+
return send(res, 202, { ok: true, requested: 'land' })
|
|
697
|
+
}
|
|
698
|
+
if (req.method === 'POST' && (parts[3] === 'handoff' || parts[3] === 'end')) {
|
|
699
|
+
if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
|
|
700
|
+
requestControl(id, parts[3] === 'handoff' ? { handoff: true, by: actor.id } : { end: true, by: actor.id })
|
|
701
|
+
log(`${parts[3]} requested for ${id} by ${actor.id}`)
|
|
702
|
+
return send(res, 200, { ok: true, requested: parts[3] })
|
|
703
|
+
}
|
|
704
|
+
if (req.method === 'DELETE' && parts.length === 3) {
|
|
705
|
+
if (isActive(sess)) return send(res, 409, { error: 'end the session before removing it' })
|
|
706
|
+
if (landingNow(id)) return send(res, 409, { error: 'wait for the landing to finish before removing it' })
|
|
707
|
+
const force = url.searchParams.get('force') === '1'
|
|
708
|
+
const keepWorktree = url.searchParams.get('keep_worktree') === '1'
|
|
709
|
+
if (keepWorktree && !force) return send(res, 400, { error: 'keep_worktree requires force=1' })
|
|
710
|
+
let worktree = null
|
|
711
|
+
if (keepWorktree && sess.worktree) {
|
|
712
|
+
worktree = { removed: false, preserved: true, path: sess.worktree.path, branch: sess.worktree.branch, reason: 'preserved by request' }
|
|
713
|
+
} else if (sess.worktree) { try { worktree = pruneSessionWorktree(sess) } catch (err) { worktree = { removed: false, reason: scrub(err.message).slice(0, 200) } } }
|
|
714
|
+
// Remove must not orphan unlanded work: if the worktree could not be
|
|
715
|
+
// pruned (uncommitted or unmerged), keep the record unless forced
|
|
716
|
+
if (worktree && !worktree.removed && !force) return send(res, 409, { error: `not removing ${id}: ${worktree.reason}. Land it first, or retry with ?force=1 to drop the record and leave the worktree in place.`, worktree })
|
|
717
|
+
removeSession(id)
|
|
718
|
+
sse.broadcast('sessions', (v) => viewFor(v))
|
|
719
|
+
return send(res, 200, { removed: id, worktree })
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (req.method === 'GET' && path === '/api/floor') return send(res, 200, floor(listCards()))
|
|
723
|
+
if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunk(listCards(), parseSince(url.searchParams.get('since'))))
|
|
724
|
+
if (req.method === 'GET' && path === '/api/leases') return send(res, 200, { leases: held(listCards()) })
|
|
725
|
+
if (parts[1] === 'cards' && parts[2]) {
|
|
726
|
+
const id = parts[2]
|
|
727
|
+
const card = readCard(id)
|
|
728
|
+
if (!card) return send(res, 404, { error: `card not found: ${id}` })
|
|
729
|
+
if (req.method === 'GET' && parts.length === 3) {
|
|
730
|
+
return send(res, 200, { card: summarize(card), runs: readRuns(id), events: readEvents(id), bundle: card.last_bundle ? { id: card.last_bundle, path: card.worktree ? join(card.worktree, '.context-handoffs', card.last_bundle) : null } : null })
|
|
731
|
+
}
|
|
732
|
+
if (req.method === 'GET' && parts[3] === 'events') return send(res, 200, { events: readEvents(id) })
|
|
733
|
+
if (req.method === 'GET' && parts[3] === 'log') {
|
|
734
|
+
const run = url.searchParams.get('run') ? parseInt(url.searchParams.get('run'), 10) : null
|
|
735
|
+
const tail = Math.min(2000, parseInt(url.searchParams.get('tail') ?? '200', 10) || 200)
|
|
736
|
+
return send(res, 200, logTail(id, run, tail))
|
|
737
|
+
}
|
|
738
|
+
if (req.method === 'DELETE' && parts.length === 3) {
|
|
739
|
+
if (['running', 'handing_off'].includes(card.status)) return send(res, 409, { error: 'kill the card before removing it' })
|
|
740
|
+
// don't discard uncommitted agent work without an explicit force
|
|
741
|
+
if (url.searchParams.get('force') !== '1') {
|
|
742
|
+
let dirty
|
|
743
|
+
try { dirty = worktreeDirty(card.repo, id) } catch (err) {
|
|
744
|
+
// a status check that fails must not read as clean
|
|
745
|
+
return send(res, 409, { error: `cannot verify the card's worktree (git status failed: ${scrub(err.message).slice(0, 200)}). Retry with ?force=1 to discard it.` })
|
|
746
|
+
}
|
|
747
|
+
if (dirty.length) return send(res, 409, { error: `the card's worktree has ${dirty.length} uncommitted file(s): ${dirty.slice(0, 10).join(', ')}. Retry with ?force=1 to discard them.`, dirty })
|
|
748
|
+
}
|
|
749
|
+
// `branch=delete` on its own is `git branch -d`: commits the branch is
|
|
750
|
+
// the only copy of are lost only when the caller says force
|
|
751
|
+
let wt = null
|
|
752
|
+
try { wt = removeWorktree(card.repo, id, { deleteBranch: url.searchParams.get('branch') === 'delete', force: url.searchParams.get('force') === '1' }) } catch (err) { log(`worktree remove: ${err.message}`) }
|
|
753
|
+
if (wt && wt.branchUnmerged && !wt.branchDeleted) return send(res, 409, { error: `not removing ${id}: its branch has commits that are not on its base. Land it first, or retry with ?force=1 to delete the branch and discard them.`, worktree: wt })
|
|
754
|
+
rmSync(cardDir(id), { recursive: true, force: true })
|
|
755
|
+
sse.broadcast('removed', forOwner({ card_id: id }))
|
|
756
|
+
return send(res, 200, { removed: id })
|
|
757
|
+
}
|
|
758
|
+
if (req.method === 'POST' && parts[3]) {
|
|
759
|
+
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' }
|
|
760
|
+
const action = map[parts[3]]
|
|
761
|
+
if (!action) return send(res, 404, { error: `unknown action ${parts[3]}` })
|
|
762
|
+
const body = await readBody(req)
|
|
763
|
+
try {
|
|
764
|
+
const next = humanAction(id, action, body, actor)
|
|
765
|
+
sse.broadcast('card', forOwner(summarize(next)))
|
|
766
|
+
return send(res, 200, { card: summarize(next) })
|
|
767
|
+
} catch (err) {
|
|
768
|
+
if (err instanceof IllegalTransition) return send(res, 409, { error: err.message })
|
|
769
|
+
if (/needs an adapter/.test(err.message)) return send(res, 400, { error: err.message })
|
|
770
|
+
throw err
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return send(res, 404, { error: 'not found' })
|
|
775
|
+
} catch (err) {
|
|
776
|
+
log(`error ${req.method} ${path}: ${err.message}`)
|
|
777
|
+
return send(res, 500, { error: scrub(err.message) })
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const onReq = (req, res) => { handle(req, res).catch((err) => { try { send(res, 500, { error: scrub(err.message) }) } catch {} }) }
|
|
782
|
+
const server = http.createServer(onReq)
|
|
783
|
+
// When the board is bound to a non-loopback address (share on), also listen on
|
|
784
|
+
// 127.0.0.1 so the machine's own browser has a tokenless owner URL — a real
|
|
785
|
+
// remote peer's address is never loopback, so it still needs a token.
|
|
786
|
+
const loopbackCompanion = !isLoopback(bind) ? http.createServer(onReq) : null
|
|
787
|
+
|
|
788
|
+
return {
|
|
789
|
+
server,
|
|
790
|
+
bind, port,
|
|
791
|
+
start() {
|
|
792
|
+
return new Promise((resolvePromise, reject) => {
|
|
793
|
+
server.once('error', reject)
|
|
794
|
+
server.listen(port, bind, () => {
|
|
795
|
+
const addr = server.address()
|
|
796
|
+
log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
|
|
797
|
+
// A terminal that crashed instead of exiting left its hand-off in
|
|
798
|
+
// .baton/RESUME.md looking live. The board is the thing that starts
|
|
799
|
+
// after a crash, so it is where that gets corrected.
|
|
800
|
+
try {
|
|
801
|
+
const touched = refreshPointers()
|
|
802
|
+
if (touched.length) log(`rewrote ${touched.length} stale resume pointer${touched.length === 1 ? '' : 's'}: the terminal each described is gone, or no Baton stamped it`)
|
|
803
|
+
} catch (err) { log(`resume pointers not refreshed: ${err.message}`) }
|
|
804
|
+
if (scheduler) {
|
|
805
|
+
sched = createScheduler()
|
|
806
|
+
sched.run().catch((err) => log(`scheduler crashed: ${err.message}`))
|
|
807
|
+
}
|
|
808
|
+
if (usagePolling) {
|
|
809
|
+
refreshCodexAccounts()
|
|
810
|
+
usageTimer = setInterval(refreshCodexAccounts, 60000)
|
|
811
|
+
usageTimer.unref?.()
|
|
812
|
+
}
|
|
813
|
+
if (loopbackCompanion) {
|
|
814
|
+
loopbackCompanion.on('error', (err) => log(`loopback companion: ${err.message}`))
|
|
815
|
+
loopbackCompanion.listen(addr.port, '127.0.0.1', () => log(`also on http://127.0.0.1:${addr.port} (this machine, tokenless owner)`))
|
|
816
|
+
}
|
|
817
|
+
resolvePromise({ port: addr.port, bind })
|
|
818
|
+
})
|
|
819
|
+
})
|
|
820
|
+
},
|
|
821
|
+
async stop() {
|
|
822
|
+
if (usageTimer) { clearInterval(usageTimer); usageTimer = null }
|
|
823
|
+
usageController?.abort()
|
|
824
|
+
sse.stop()
|
|
825
|
+
if (sched) sched.stop()
|
|
826
|
+
if (loopbackCompanion) await new Promise((r) => { loopbackCompanion.closeAllConnections?.(); loopbackCompanion.close(() => r()) })
|
|
827
|
+
await new Promise((r) => { server.closeAllConnections?.(); server.close(r) })
|
|
828
|
+
},
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const isMain = process.argv[1] && resolve(process.argv[1]).toLowerCase() === SELF.toLowerCase()
|
|
833
|
+
if (isMain) {
|
|
834
|
+
let srv
|
|
835
|
+
try {
|
|
836
|
+
srv = createBoardServer({ usagePolling: true })
|
|
837
|
+
} catch (err) {
|
|
838
|
+
process.stderr.write(err.message + '\n')
|
|
839
|
+
process.exit(err.exitCode ?? 1)
|
|
840
|
+
}
|
|
841
|
+
await srv.start()
|
|
842
|
+
const bye = () => { srv.stop().then(() => process.exit(0)) }
|
|
843
|
+
process.on('SIGINT', bye)
|
|
844
|
+
process.on('SIGTERM', bye)
|
|
845
|
+
}
|