@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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// What one terminal card's drawer shows: the agent's last messages, the files
|
|
2
|
+
// this session changed, and the diff for one of them. Read-only, and every
|
|
3
|
+
// string is scrubbed on the way out: a transcript and a diff can both carry a
|
|
4
|
+
// key the agent printed, and neither has ever left this machine before.
|
|
5
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
6
|
+
import { execFileSync } from 'node:child_process'
|
|
7
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
8
|
+
import { readEvents, workRoot } from './sessions.mjs'
|
|
9
|
+
import { realPath } from './fsx.mjs'
|
|
10
|
+
import { scrub } from './redact.mjs'
|
|
11
|
+
import { transcriptTail as claudeTail } from './taps/claude.mjs'
|
|
12
|
+
import { transcriptTail as codexTail } from './taps/codex.mjs'
|
|
13
|
+
import { resumeVerdict, verdictForBoard } from './resume.mjs'
|
|
14
|
+
|
|
15
|
+
export const MESSAGE_LIMIT = 8
|
|
16
|
+
export const DIFF_MAX_LINES = 400
|
|
17
|
+
export const EVENT_LIMIT = 200
|
|
18
|
+
|
|
19
|
+
export class DiffInputError extends Error {}
|
|
20
|
+
|
|
21
|
+
function git(root, args) {
|
|
22
|
+
try {
|
|
23
|
+
return execFileSync('git', ['-C', root, ...args], { windowsHide: true, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 5000, env: { ...process.env, MSYS_NO_PATHCONV: '1' }, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
24
|
+
} catch { return null }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A path the session recorded (relative, or absolute in another form: a short
|
|
28
|
+
// 8.3 prefix, a symlinked temp dir) → its path inside this session's own tree,
|
|
29
|
+
// or null when it is not in that tree at all.
|
|
30
|
+
function insideRoot(root, file) {
|
|
31
|
+
if (!root || !file) return null
|
|
32
|
+
const rootR = realPath(root)
|
|
33
|
+
const absR = realPath(isAbsolute(file) ? file : resolve(rootR, file))
|
|
34
|
+
const rel = relative(rootR, absR)
|
|
35
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) return null
|
|
36
|
+
return { rel: rel.split(sep).join('/'), abs: absR }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function sessionMessages(session, limit = MESSAGE_LIMIT) {
|
|
40
|
+
const path = session?.transcript_path
|
|
41
|
+
if (!path) return []
|
|
42
|
+
const tail = session.agent === 'claude' ? claudeTail(path, limit)
|
|
43
|
+
: session.agent === 'codex' ? codexTail(path, limit)
|
|
44
|
+
: [] // agy keeps no transcript Baton can read
|
|
45
|
+
return tail.map((m) => ({ role: m.role === 'user' ? 'user' : 'assistant', text: scrub(m.text), ts: m.ts ?? null }))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// path → {adds, dels} for everything git can compare against HEAD. A file the
|
|
49
|
+
// agent created is not in here; it is still listed, with no counts.
|
|
50
|
+
function numstat(root) {
|
|
51
|
+
const out = git(root, ['diff', '--numstat', 'HEAD'])
|
|
52
|
+
const counts = new Map()
|
|
53
|
+
for (const line of (out ?? '').split('\n')) {
|
|
54
|
+
const [adds, dels, path] = line.split('\t')
|
|
55
|
+
if (!path) continue
|
|
56
|
+
counts.set(path.trim(), { adds: Number(adds) || 0, dels: Number(dels) || 0 })
|
|
57
|
+
}
|
|
58
|
+
return counts
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Is this a path git already has? A file with no counts against HEAD is either
|
|
62
|
+
// one the agent created (nobody tracks it, ignored files included) or one that
|
|
63
|
+
// already landed in a commit, and the drawer must not call those the same.
|
|
64
|
+
function isTracked(root, rel) {
|
|
65
|
+
const out = git(root, ['ls-files', '--error-unmatch', '--', rel])
|
|
66
|
+
return typeof out === 'string' && out.trim().length > 0
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function sessionFiles(session) {
|
|
70
|
+
const root = workRoot(session)
|
|
71
|
+
if (!root) return []
|
|
72
|
+
const counts = numstat(root)
|
|
73
|
+
const dirty = new Set()
|
|
74
|
+
for (const f of session.files_dirty ?? []) { const i = insideRoot(root, f); if (i) dirty.add(i.rel) }
|
|
75
|
+
const files = new Map()
|
|
76
|
+
for (const f of [...(session.files_touched ?? []), ...(session.files_dirty ?? [])]) {
|
|
77
|
+
const i = insideRoot(root, f)
|
|
78
|
+
if (!i || files.has(i.rel)) continue
|
|
79
|
+
const c = counts.get(i.rel) ?? null
|
|
80
|
+
const state = c ? 'modified' : (isTracked(root, i.rel) ? 'committed' : 'new')
|
|
81
|
+
files.set(i.rel, { path: i.rel, adds: c?.adds ?? null, dels: c?.dels ?? null, dirty: dirty.has(i.rel), state })
|
|
82
|
+
}
|
|
83
|
+
return [...files.values()].sort((a, b) => a.path.localeCompare(b.path))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function sessionDiff(session, file) {
|
|
87
|
+
const root = workRoot(session)
|
|
88
|
+
if (!root) throw new DiffInputError('this terminal has no repository to diff')
|
|
89
|
+
const inside = insideRoot(root, String(file ?? ''))
|
|
90
|
+
if (!inside) throw new DiffInputError('that file is not in this terminal\'s tree')
|
|
91
|
+
let out = git(root, ['diff', 'HEAD', '--', inside.rel]) ?? ''
|
|
92
|
+
let state = 'modified'
|
|
93
|
+
if (!out.trim()) {
|
|
94
|
+
// nothing to compare against is not one situation: the agent created this
|
|
95
|
+
// file, or it already landed in a commit, or it is gone. Say which.
|
|
96
|
+
const isFile = existsSync(inside.abs) && statSync(inside.abs).isFile()
|
|
97
|
+
if (isFile && !isTracked(root, inside.rel)) {
|
|
98
|
+
let body = ''
|
|
99
|
+
try { body = readFileSync(inside.abs, 'utf8') } catch { body = '' }
|
|
100
|
+
if (body) out = `--- /dev/null\n+++ b/${inside.rel}\n` + body.split('\n').map((l) => '+' + l).join('\n')
|
|
101
|
+
state = 'new'
|
|
102
|
+
} else state = existsSync(inside.abs) ? 'committed' : 'gone'
|
|
103
|
+
}
|
|
104
|
+
const lines = scrub(out).split('\n')
|
|
105
|
+
const truncated = lines.length > DIFF_MAX_LINES
|
|
106
|
+
return { file: inside.rel, diff: lines.slice(0, DIFF_MAX_LINES).join('\n'), truncated, state }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Is the resume pointer in this terminal's checkout still describing the repo
|
|
110
|
+
// the reader will find? Recomputed from git on every drawer poll, and handed
|
|
111
|
+
// over without its local paths: a screenshot of the drawer travels further
|
|
112
|
+
// than this machine.
|
|
113
|
+
function resumeFor(session) {
|
|
114
|
+
const root = workRoot(session)
|
|
115
|
+
if (!root) return null
|
|
116
|
+
try { return verdictForBoard(resumeVerdict(root)) } catch { return null }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function sessionDetail(session) {
|
|
120
|
+
return {
|
|
121
|
+
session_id: session.session_id,
|
|
122
|
+
messages: sessionMessages(session),
|
|
123
|
+
files: sessionFiles(session),
|
|
124
|
+
events: readEvents(session.session_id).slice(-EVENT_LIMIT),
|
|
125
|
+
bundle: session.bundle ?? null,
|
|
126
|
+
resume: resumeFor(session),
|
|
127
|
+
ts: new Date().toISOString(),
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/sessions.mjs
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// sessions — the store behind `baton claude|codex|agy`. One directory per
|
|
2
|
+
// interactive terminal session under $BATON_HOME/sessions/<id>/:
|
|
3
|
+
// session.json the live record the board renders (atomic writes)
|
|
4
|
+
// events.jsonl timeline (started, turn, warning, limit, handoff, ended)
|
|
5
|
+
// control.json board → runner requests ({ handoff: true })
|
|
6
|
+
// The runner (src/attach.mjs) is the only writer of session.json; hooks and
|
|
7
|
+
// taps go through recordFromTap() so every write is one atomic replace.
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, appendFileSync, rmSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { randomBytes } from 'node:crypto'
|
|
11
|
+
import { home } from './store.mjs'
|
|
12
|
+
import { writeJsonAtomic, withFileLock } from './fsx.mjs'
|
|
13
|
+
import { scrub } from './redact.mjs'
|
|
14
|
+
import { HANDOFF_AGENTS, normalizeHandoffOrder } from './preferences.mjs'
|
|
15
|
+
|
|
16
|
+
export const AGENTS = HANDOFF_AGENTS
|
|
17
|
+
export const HANDOFF_ORDER_CAPABILITY = 'handoff_order_v1'
|
|
18
|
+
export const SESSION_STATUSES = ['starting', 'running', 'warning', 'limit', 'handing_off', 'waiting', 'handed_off', 'ended', 'lost']
|
|
19
|
+
const ACTIVE = ['starting', 'running', 'warning', 'limit', 'handing_off', 'waiting']
|
|
20
|
+
|
|
21
|
+
const now = () => new Date().toISOString()
|
|
22
|
+
|
|
23
|
+
export function sessionsRoot() { return join(home(), 'sessions') }
|
|
24
|
+
export function sessionDir(id) { return join(sessionsRoot(), id) }
|
|
25
|
+
|
|
26
|
+
export function newSessionId(agent) {
|
|
27
|
+
const d = new Date()
|
|
28
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
29
|
+
return `s-${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}-${agent}-${randomBytes(2).toString('hex')}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readSession(id) {
|
|
33
|
+
const f = join(sessionDir(id), 'session.json')
|
|
34
|
+
if (!existsSync(f)) return null
|
|
35
|
+
try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function listSessions() {
|
|
39
|
+
const root = sessionsRoot()
|
|
40
|
+
if (!existsSync(root)) return []
|
|
41
|
+
return readdirSync(root).filter((n) => n.startsWith('s-')).sort().map(readSession).filter(Boolean)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isActive(s) { return ACTIVE.includes(s?.status) }
|
|
45
|
+
|
|
46
|
+
// The checkout this session's files live in: its own worktree when Baton gave
|
|
47
|
+
// it one (repo stays the main checkout, for grouping and landing), else the repo.
|
|
48
|
+
export function workRoot(s) { return s?.worktree?.path ?? s?.repo ?? s?.cwd ?? null }
|
|
49
|
+
|
|
50
|
+
export function createSession({ id, agent, account = 'default', cwd, repo = null, branch = null, argv = [], runner_pid = process.pid, chain = [], worktree = null, owner = null, handoffOrder = AGENTS, installed = null, runtimeCapabilities = [] }) {
|
|
51
|
+
const session = {
|
|
52
|
+
session_id: id, agent, account, cwd, repo, branch, argv, worktree, owner,
|
|
53
|
+
repo_name: repo ? repo.split(/[\\/]/).filter(Boolean).pop() : null,
|
|
54
|
+
status: 'starting', runner_pid, pid: null,
|
|
55
|
+
started_at: now(), updated_at: now(), ended_at: null, last_activity: now(),
|
|
56
|
+
agent_session_id: null, transcript_path: null,
|
|
57
|
+
task: null, turns: 0,
|
|
58
|
+
files_touched: [], files_dirty: [], head: null, head_at_start: null,
|
|
59
|
+
limits: null, limit: null, warning: null,
|
|
60
|
+
bundle: null, handoff: null, chain,
|
|
61
|
+
handoff_order: normalizeHandoffOrder(handoffOrder), installed,
|
|
62
|
+
runtime_capabilities: [...new Set(runtimeCapabilities)],
|
|
63
|
+
lineage: { from: null, to: null },
|
|
64
|
+
exit_code: null,
|
|
65
|
+
}
|
|
66
|
+
mkdirSync(sessionDir(id), { recursive: true })
|
|
67
|
+
writeJsonAtomic(join(sessionDir(id), 'session.json'), session)
|
|
68
|
+
appendEvent(id, { type: 'started', summary: `${agent} (${account}) started in ${cwd}` })
|
|
69
|
+
return session
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Patch the record; arrays replace, `merge` deep-merges one level (limits).
|
|
73
|
+
// `patch` may be a function (cur) => delta: the read, the compute and the write
|
|
74
|
+
// then happen inside one cross-process lock, so concurrent hook/tap/poller
|
|
75
|
+
// processes cannot lose an accumulated field (files_touched, turns) to a
|
|
76
|
+
// last-writer-wins race. Callers that only set fixed values pass a plain object.
|
|
77
|
+
export function updateSession(id, patch, { event } = {}) {
|
|
78
|
+
return withFileLock(join(sessionDir(id), '.session.lock'), () => {
|
|
79
|
+
const cur = readSession(id)
|
|
80
|
+
if (!cur) return null
|
|
81
|
+
const delta = typeof patch === 'function' ? patch(cur) : patch
|
|
82
|
+
const next = { ...cur, ...delta, updated_at: now() }
|
|
83
|
+
if (delta.limits && cur.limits) next.limits = { ...cur.limits, ...delta.limits }
|
|
84
|
+
writeJsonAtomic(join(sessionDir(id), 'session.json'), next)
|
|
85
|
+
if (event) appendEvent(id, event)
|
|
86
|
+
return next
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function appendEvent(id, ev) {
|
|
91
|
+
const dir = sessionDir(id)
|
|
92
|
+
if (!existsSync(dir)) return
|
|
93
|
+
const line = { ts: now(), session_id: id, ...ev, summary: scrub(String(ev.summary ?? '')) }
|
|
94
|
+
if (line.body) line.body = scrub(String(line.body)).slice(0, 4000)
|
|
95
|
+
appendFileSync(join(dir, 'events.jsonl'), JSON.stringify(line) + '\n')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function readEvents(id) {
|
|
99
|
+
const f = join(sessionDir(id), 'events.jsonl')
|
|
100
|
+
if (!existsSync(f)) return []
|
|
101
|
+
return readFileSync(f, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Board → runner. The runner polls this file; a consumed request is deleted.
|
|
105
|
+
// Merge, never replace: an `end` and a later `handoff` (or a second board
|
|
106
|
+
// command in the same poll window) both survive to be seen by takeControl,
|
|
107
|
+
// instead of the second write silently dropping the first.
|
|
108
|
+
export function requestControl(id, req) {
|
|
109
|
+
const f = join(sessionDir(id), 'control.json')
|
|
110
|
+
return withFileLock(join(sessionDir(id), '.control.lock'), () => {
|
|
111
|
+
let existing = {}
|
|
112
|
+
if (existsSync(f)) { try { existing = JSON.parse(readFileSync(f, 'utf8')) } catch {} }
|
|
113
|
+
writeJsonAtomic(f, { ...existing, ...req, requested_at: now() })
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
export function takeControl(id) {
|
|
117
|
+
const f = join(sessionDir(id), 'control.json')
|
|
118
|
+
return withFileLock(join(sessionDir(id), '.control.lock'), () => {
|
|
119
|
+
if (!existsSync(f)) return null
|
|
120
|
+
let req = null
|
|
121
|
+
try { req = JSON.parse(readFileSync(f, 'utf8')) } catch {}
|
|
122
|
+
rmSync(f, { force: true })
|
|
123
|
+
return req
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function removeSession(id) { rmSync(sessionDir(id), { recursive: true, force: true }) }
|
|
128
|
+
|
|
129
|
+
// Land state for one session: land.json is written by the board server only
|
|
130
|
+
// (the runner owns session.json), so a land result never races a tap write.
|
|
131
|
+
export function readLand(id) {
|
|
132
|
+
const f = join(sessionDir(id), 'land.json')
|
|
133
|
+
if (!existsSync(f)) return null
|
|
134
|
+
try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null }
|
|
135
|
+
}
|
|
136
|
+
export function writeLand(id, land) {
|
|
137
|
+
if (!existsSync(sessionDir(id))) return
|
|
138
|
+
writeJsonAtomic(join(sessionDir(id), 'land.json'), land)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Hand-off requests from another human (share mode). Server-owned, like land.json.
|
|
142
|
+
export function readRequests(id) {
|
|
143
|
+
const f = join(sessionDir(id), 'requests.json')
|
|
144
|
+
if (!existsSync(f)) return []
|
|
145
|
+
try { const j = JSON.parse(readFileSync(f, 'utf8')); return Array.isArray(j) ? j : [] } catch { return [] }
|
|
146
|
+
}
|
|
147
|
+
export function writeRequests(id, list) {
|
|
148
|
+
if (!existsSync(sessionDir(id))) return
|
|
149
|
+
writeJsonAtomic(join(sessionDir(id), 'requests.json'), list.slice(-20))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Who landed what: one line per landing, kept after the session is removed.
|
|
153
|
+
const landingsFile = () => join(home(), 'landings.jsonl')
|
|
154
|
+
export function appendLanding(entry) {
|
|
155
|
+
mkdirSync(home(), { recursive: true })
|
|
156
|
+
appendFileSync(landingsFile(), JSON.stringify({ ts: now(), ...entry }) + '\n')
|
|
157
|
+
}
|
|
158
|
+
export function readLandings() {
|
|
159
|
+
if (!existsSync(landingsFile())) return []
|
|
160
|
+
return readFileSync(landingsFile(), 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function pidAlive(pid) {
|
|
164
|
+
if (!pid) return false
|
|
165
|
+
// EPERM means the process exists but is not ours to signal (e.g. an elevated
|
|
166
|
+
// terminal): it is alive. Only ESRCH ("no such process") means gone.
|
|
167
|
+
try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A session whose runner process is gone (terminal closed, crash) is marked
|
|
171
|
+
// lost so the board never shows a dead terminal as live.
|
|
172
|
+
export function reapLost(sessions = listSessions()) {
|
|
173
|
+
const out = []
|
|
174
|
+
for (const s of sessions) {
|
|
175
|
+
if (isActive(s) && !pidAlive(s.runner_pid)) {
|
|
176
|
+
out.push(updateSession(s.session_id, { status: 'lost', ended_at: now() }, { event: { type: 'lost', summary: `runner pid ${s.runner_pid} is gone; session marked lost` } }) ?? s)
|
|
177
|
+
} else out.push(s)
|
|
178
|
+
}
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Two live sessions on one repo touching the same file: both get flagged.
|
|
183
|
+
export function overlaps(sessions) {
|
|
184
|
+
const live = sessions.filter(isActive).filter((s) => s.repo)
|
|
185
|
+
const out = new Map()
|
|
186
|
+
for (let i = 0; i < live.length; i++) {
|
|
187
|
+
for (let j = i + 1; j < live.length; j++) {
|
|
188
|
+
const a = live[i]; const b = live[j]
|
|
189
|
+
if (a.repo.toLowerCase() !== b.repo.toLowerCase()) continue
|
|
190
|
+
const setB = new Set([...(b.files_touched ?? []), ...(b.files_dirty ?? [])])
|
|
191
|
+
const shared = [...new Set([...(a.files_touched ?? []), ...(a.files_dirty ?? [])])].filter((f) => setB.has(f))
|
|
192
|
+
if (!shared.length) continue
|
|
193
|
+
// separate: each has its own checkout, so the clash waits for the second landing
|
|
194
|
+
const separate = String(workRoot(a)).toLowerCase() !== String(workRoot(b)).toLowerCase()
|
|
195
|
+
for (const [x, y] of [[a, b], [b, a]]) {
|
|
196
|
+
if (!out.has(x.session_id)) out.set(x.session_id, [])
|
|
197
|
+
out.get(x.session_id).push({ session_id: y.session_id, agent: y.agent, files: shared, separate })
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return out
|
|
202
|
+
}
|
package/src/share.mjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// share — optional multiplayer for the board, off until `baton share on`
|
|
2
|
+
// writes $BATON_HOME/share.json. With it on, the board binds the Tailscale or
|
|
3
|
+
// LAN address, every human has a name and their own token (kept as a sha256
|
|
4
|
+
// hash: a token is printed once and `baton share rotate` issues a new one),
|
|
5
|
+
// and every session belongs to the human whose terminal started it
|
|
6
|
+
// (`BATON_PERSON`, else the owner). With it off nothing changes: loopback is
|
|
7
|
+
// open and `BATON_TOKEN` is the only token.
|
|
8
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
9
|
+
import { existsSync, readFileSync, mkdirSync } from 'node:fs'
|
|
10
|
+
import { networkInterfaces, userInfo } from 'node:os'
|
|
11
|
+
import { createSocket } from 'node:dgram'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { home } from './store.mjs'
|
|
14
|
+
import { writeJsonAtomic } from './fsx.mjs'
|
|
15
|
+
|
|
16
|
+
export const OFF = { version: 1, on: false, bind: null, bind_kind: null, port: null, owner: null, people: [] }
|
|
17
|
+
export const ROLES = ['owner', 'guest']
|
|
18
|
+
|
|
19
|
+
export function sharePath() { return join(home(), 'share.json') }
|
|
20
|
+
|
|
21
|
+
export function readShare() {
|
|
22
|
+
const f = sharePath()
|
|
23
|
+
if (!existsSync(f)) return { ...OFF }
|
|
24
|
+
try {
|
|
25
|
+
const s = JSON.parse(readFileSync(f, 'utf8'))
|
|
26
|
+
return { ...OFF, ...s, people: Array.isArray(s.people) ? s.people : [] }
|
|
27
|
+
} catch { return { ...OFF } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writeShare(share) {
|
|
31
|
+
mkdirSync(home(), { recursive: true })
|
|
32
|
+
writeJsonAtomic(sharePath(), share)
|
|
33
|
+
return share
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// On means: turned on, bound somewhere, and at least one person to let in.
|
|
37
|
+
export function isOn(share = readShare()) { return Boolean(share.on && share.people.length && share.bind) }
|
|
38
|
+
|
|
39
|
+
export function hashToken(token) { return createHash('sha256').update(String(token)).digest('hex') }
|
|
40
|
+
export function newToken() { return randomBytes(24).toString('base64url') }
|
|
41
|
+
export function validName(name) { return /^[a-z0-9][a-z0-9_-]{0,31}$/i.test(String(name ?? '')) }
|
|
42
|
+
|
|
43
|
+
// Which person presented this token, in constant time. null for no match.
|
|
44
|
+
export function identify(share, presented) {
|
|
45
|
+
if (!presented || !share?.people?.length) return null
|
|
46
|
+
const h = Buffer.from(hashToken(presented))
|
|
47
|
+
let found = null
|
|
48
|
+
for (const p of share.people) {
|
|
49
|
+
const q = Buffer.from(String(p.token_sha256 ?? '').padEnd(h.length, '0').slice(0, h.length))
|
|
50
|
+
if (timingSafeEqual(h, q) && p.token_sha256) found = found ?? p
|
|
51
|
+
}
|
|
52
|
+
return found
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function personNamed(share, name) { return share.people.find((p) => p.name.toLowerCase() === String(name ?? '').toLowerCase()) ?? null }
|
|
56
|
+
export function isOwner(person) { return person?.role === 'owner' }
|
|
57
|
+
|
|
58
|
+
export function addPerson(name, { role = 'guest', share = readShare() } = {}) {
|
|
59
|
+
if (!validName(name)) throw new Error(`bad name "${name}": letters, digits, dash and underscore, up to 32 characters`)
|
|
60
|
+
if (!ROLES.includes(role)) throw new Error(`bad role "${role}" (owner|guest)`)
|
|
61
|
+
if (personNamed(share, name)) throw new Error(`"${name}" is already on the board; baton share rotate ${name} issues a new link`)
|
|
62
|
+
const token = newToken()
|
|
63
|
+
const person = { name, role, token_sha256: hashToken(token), created_at: new Date().toISOString(), last_seen: null }
|
|
64
|
+
share.people.push(person)
|
|
65
|
+
writeShare(share)
|
|
66
|
+
return { person, token, share }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function removePerson(name, share = readShare()) {
|
|
70
|
+
const person = personNamed(share, name)
|
|
71
|
+
if (!person) throw new Error(`no one called "${name}" on this board`)
|
|
72
|
+
// a shared board with no owner locks this machine's own browser out of it
|
|
73
|
+
if (isOwner(person) && isOn(share) && !share.people.some((p) => isOwner(p) && p !== person)) {
|
|
74
|
+
throw new Error(`"${name}" is the only owner of this board: leg share add <someone> --role owner first, or leg share off`)
|
|
75
|
+
}
|
|
76
|
+
share.people = share.people.filter((p) => p.name.toLowerCase() !== String(name).toLowerCase())
|
|
77
|
+
if (share.owner && share.owner.toLowerCase() === String(name).toLowerCase()) share.owner = share.people.find((p) => p.role === 'owner')?.name ?? null
|
|
78
|
+
writeShare(share)
|
|
79
|
+
return share
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function rotate(name, share = readShare()) {
|
|
83
|
+
const person = personNamed(share, name)
|
|
84
|
+
if (!person) throw new Error(`no one called "${name}" on this board`)
|
|
85
|
+
const token = newToken()
|
|
86
|
+
person.token_sha256 = hashToken(token)
|
|
87
|
+
person.rotated_at = new Date().toISOString()
|
|
88
|
+
writeShare(share)
|
|
89
|
+
return { person, token, share }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ipv4s() {
|
|
93
|
+
const out = []
|
|
94
|
+
for (const [name, list] of Object.entries(networkInterfaces())) for (const a of list ?? []) if (a.family === 'IPv4' && !a.internal) out.push({ name, address: a.address })
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The address of the interface the default route uses, without sending a byte.
|
|
99
|
+
function defaultRouteAddress() {
|
|
100
|
+
return new Promise((resolvePromise) => {
|
|
101
|
+
const s = createSocket('udp4')
|
|
102
|
+
const done = (v) => { try { s.close() } catch {} resolvePromise(v) }
|
|
103
|
+
s.on('error', () => done(null))
|
|
104
|
+
try { s.connect(53, '8.8.8.8', () => { try { done(s.address().address) } catch { done(null) } }) } catch { done(null) }
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 'tailscale' (default), 'lan', or an address to use as given.
|
|
109
|
+
export async function resolveBind(kind = 'tailscale') {
|
|
110
|
+
const want = String(kind).toLowerCase()
|
|
111
|
+
if (!['tailscale', 'lan'].includes(want)) return want
|
|
112
|
+
const addrs = ipv4s()
|
|
113
|
+
if (want === 'tailscale') {
|
|
114
|
+
const ts = addrs.find((a) => /tailscale/i.test(a.name) || /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a.address))
|
|
115
|
+
if (!ts) throw new Error('no Tailscale address on this machine: use --bind lan, or --bind <address>')
|
|
116
|
+
return ts.address
|
|
117
|
+
}
|
|
118
|
+
const viaRoute = await defaultRouteAddress()
|
|
119
|
+
if (viaRoute && addrs.some((a) => a.address === viaRoute)) return viaRoute
|
|
120
|
+
const lan = addrs.find((a) => /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a.address) && !/tailscale|virtual|default switch|wsl/i.test(a.name))
|
|
121
|
+
if (!lan) throw new Error('no LAN address found: pass --bind <address>')
|
|
122
|
+
return lan.address
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function linkFor(share, token) { return `http://${share.bind}:${share.port}/?token=${token}` }
|
|
126
|
+
|
|
127
|
+
// Whose terminal this is: BATON_PERSON, else the board's owner, else 'local'.
|
|
128
|
+
export function whoami(share = readShare()) {
|
|
129
|
+
const named = String(process.env.LEG_PERSON ?? process.env.BATON_PERSON ?? '').trim()
|
|
130
|
+
if (named && validName(named)) return named
|
|
131
|
+
return share.owner || 'local'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function turnOn({ bind = 'tailscale', port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), owner } = {}) {
|
|
135
|
+
const share = readShare()
|
|
136
|
+
const address = await resolveBind(bind)
|
|
137
|
+
share.on = true
|
|
138
|
+
share.bind = address
|
|
139
|
+
share.bind_kind = ['tailscale', 'lan'].includes(String(bind).toLowerCase()) ? String(bind).toLowerCase() : 'address'
|
|
140
|
+
share.port = port
|
|
141
|
+
// being first in the roster is not being the owner: a board with nobody in
|
|
142
|
+
// the owner role gets one of its own, so the machine that shares it keeps it
|
|
143
|
+
const named = personNamed(share, share.owner)
|
|
144
|
+
const existing = isOwner(named) ? named : share.people.find((p) => isOwner(p)) ?? null
|
|
145
|
+
if (!existing) {
|
|
146
|
+
const name = validName(owner) ? owner : (validName(userInfo().username) ? userInfo().username.toLowerCase() : 'owner')
|
|
147
|
+
if (personNamed(share, name)) throw new Error(`no one on this board is an owner: leg share add <you> --role owner`)
|
|
148
|
+
share.owner = name
|
|
149
|
+
writeShare(share)
|
|
150
|
+
const added = addPerson(name, { role: 'owner', share })
|
|
151
|
+
return { share: added.share, owner: added.person, token: added.token }
|
|
152
|
+
}
|
|
153
|
+
share.owner = existing.name
|
|
154
|
+
writeShare(share)
|
|
155
|
+
return { share, owner: existing, token: null }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function turnOff() {
|
|
159
|
+
const share = readShare()
|
|
160
|
+
share.on = false
|
|
161
|
+
writeShare(share)
|
|
162
|
+
return share
|
|
163
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Station kind: agent. One chain leg runs here (or, after `baton down` or a
|
|
2
|
+
// crashed server, the orchestrator re-attaches to the run it left behind),
|
|
3
|
+
// the run is settled, and its verdict is applied to the card. `ops` are the
|
|
4
|
+
// orchestrator's helpers; this module never imports the orchestrator.
|
|
5
|
+
export async function run({ id, card, station, worktree, actor, ops }) {
|
|
6
|
+
const { unsettledRun, pidAlive, ledgerAppend, patchRun, waitForRun, runLeg, settleRun, readCard, handoffOn, transition, apply, step, log } = ops
|
|
7
|
+
const pending = unsettledRun(id)
|
|
8
|
+
if (pending?.driver_pid && pending.driver_pid !== process.pid && pidAlive(pending.driver_pid)) {
|
|
9
|
+
log(`card ${id}: run ${pending.run} is driven by pid ${pending.driver_pid}; not attaching`)
|
|
10
|
+
return { card, done: true }
|
|
11
|
+
}
|
|
12
|
+
if (pending) {
|
|
13
|
+
ledgerAppend(id, { type: 'status', station: station.name, leg: card.leg, summary: `re-attached to run ${pending.run} (${pending.status}); no new leg launched` })
|
|
14
|
+
patchRun(id, pending.run, { driver_pid: process.pid })
|
|
15
|
+
}
|
|
16
|
+
const runRecord = pending ? await waitForRun(id, pending.run) : await runLeg(card, station, worktree)
|
|
17
|
+
settleRun(id, runRecord)
|
|
18
|
+
const fresh = readCard(id)
|
|
19
|
+
if (fresh.status !== 'running') {
|
|
20
|
+
// A human acted while the leg ran; the machine already moved the card.
|
|
21
|
+
// paused: keep the context for Resume. handing_off: the human asked for
|
|
22
|
+
// the bundle, so write it and queue the next leg. queued: reassigned, and
|
|
23
|
+
// the loop starts the new adapter. killed: nothing more to do.
|
|
24
|
+
if (fresh.status === 'paused') handoffOn(fresh, station, runRecord, worktree, ['paused by a human; resume continues from this bundle'])
|
|
25
|
+
if (fresh.status === 'handing_off') {
|
|
26
|
+
handoffOn(fresh, station, runRecord, worktree, ['hand-off requested by a human; the running leg was stopped'])
|
|
27
|
+
return { card: step(id, 'bundle_written', {}, actor) }
|
|
28
|
+
}
|
|
29
|
+
return { card: fresh, done: fresh.status !== 'queued' }
|
|
30
|
+
}
|
|
31
|
+
const before = fresh
|
|
32
|
+
const result = transition(before, 'leg_result', {
|
|
33
|
+
outcome: runRecord.outcome, handoff: runRecord.handoff, signal: runRecord.signal,
|
|
34
|
+
adapter: station.chain[before.leg]?.adapter, run: runRecord.run,
|
|
35
|
+
})
|
|
36
|
+
let next = apply(id, before, result, actor)
|
|
37
|
+
if (next.status === 'handing_off') {
|
|
38
|
+
handoffOn(next, station, runRecord, worktree)
|
|
39
|
+
next = step(id, 'bundle_written', {}, actor)
|
|
40
|
+
}
|
|
41
|
+
return { card: next }
|
|
42
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Station prompt template: build. The agent implements the task.
|
|
2
|
+
export default {
|
|
3
|
+
goal: 'Implement the task in this working directory.',
|
|
4
|
+
deliverables: [
|
|
5
|
+
'Make the code change the task describes, with tests where the repository has a test suite.',
|
|
6
|
+
'Run the repository\'s own checks (tests, lint) when they exist and fix what you broke.',
|
|
7
|
+
'If PLAN.md exists, follow it; note any deviation in .leg/PROGRESS.md.',
|
|
8
|
+
],
|
|
9
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Station template: land. No agent runs here; the merge queue does the work
|
|
2
|
+
// (src/mergequeue.mjs). Kept so every station kind has a template entry.
|
|
3
|
+
export default {
|
|
4
|
+
goal: 'Rebase onto trunk, run the repository tests, fast-forward trunk.',
|
|
5
|
+
deliverables: [],
|
|
6
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Station prompt template: plan. The agent turns the task into a plan file.
|
|
2
|
+
export default {
|
|
3
|
+
goal: 'Turn the task into a concrete plan before any code changes.',
|
|
4
|
+
deliverables: [
|
|
5
|
+
'Write PLAN.md at the repository root: goal, the files you expect to touch, the steps in order, how each step is verified, and open questions.',
|
|
6
|
+
'Do not implement anything in this station; planning only.',
|
|
7
|
+
],
|
|
8
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// pr — the opt-in land mode. Instead of fast-forwarding trunk, build the
|
|
2
|
+
// `gh pr create` argv and run it through BATON_GH_BIN. This run never
|
|
3
|
+
// executes a real gh (hard stop: no remotes); tests point BATON_GH_BIN at a
|
|
4
|
+
// stub that logs its argv and prints a URL.
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { branchName } from '../worktree.mjs'
|
|
9
|
+
import { scrub } from '../runner.mjs'
|
|
10
|
+
|
|
11
|
+
export function prArgv({ card, bodyFile }) {
|
|
12
|
+
return ['pr', 'create', '--base', card.trunk || 'main', '--head', branchName(card.card_id), '--title', card.title || card.card_id, '--body-file', bodyFile]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function prBody(card, bundleSummary = '') {
|
|
16
|
+
return `## Task\n\n${card.task}\n\n## Leg\n\ncard ${card.card_id}, pipeline ${card.pipeline.map((s) => s.name).join(' → ')}\n\n${bundleSummary ? `## Handoff summary\n\n${bundleSummary}\n` : ''}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function openPr({ card, runDir, bundleSummary = '' }) {
|
|
20
|
+
const bin = (process.env.LEG_GH_BIN || process.env.BATON_GH_BIN)
|
|
21
|
+
if (!bin) return { ok: false, error: 'LEG_GH_BIN or BATON_GH_BIN is not set; pr land mode is stub-only in this build (no live gh)' }
|
|
22
|
+
mkdirSync(runDir, { recursive: true })
|
|
23
|
+
const bodyFile = join(runDir, 'pr-body.md')
|
|
24
|
+
writeFileSync(bodyFile, prBody(card, bundleSummary))
|
|
25
|
+
const argv = prArgv({ card, bodyFile })
|
|
26
|
+
const viaNode = /\.(mjs|cjs|js)$/.test(bin)
|
|
27
|
+
const r = spawnSync(viaNode ? process.execPath : bin, viaNode ? [bin, ...argv] : argv, { cwd: card.worktree ?? card.repo, windowsHide: true, encoding: 'utf8', timeout: 60000, env: process.env })
|
|
28
|
+
if (r.error || r.status !== 0) return { ok: false, error: scrub(r.error?.message || r.stderr || r.stdout).slice(0, 300), argv }
|
|
29
|
+
const url = (r.stdout.match(/https?:\/\/\S+/) || [null])[0]
|
|
30
|
+
return { ok: true, url, argv, stdout: r.stdout.trim() }
|
|
31
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Station prompt template: review. The agent reviews the branch and fixes blockers.
|
|
2
|
+
export default {
|
|
3
|
+
goal: 'Review the changes on this branch against the trunk and fix anything blocking.',
|
|
4
|
+
deliverables: [
|
|
5
|
+
'Write REVIEW.md at the repository root: findings ordered by severity, each with file:line and a one-line fix.',
|
|
6
|
+
'Fix every blocking finding (bugs, broken tests, security) in place; leave non-blocking ones listed.',
|
|
7
|
+
],
|
|
8
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Station kind: test. Runs the repo's test command in the worktree; green
|
|
2
|
+
// advances the card, red bounces it to the build station with the tail in
|
|
3
|
+
// the handoff bundle (the attempt cap is shared with the land station).
|
|
4
|
+
export async function run({ id, card, station, worktree, actor, ops }) {
|
|
5
|
+
const { resolveTestCommand, runTestCommand, ledgerAppend, step, transition, handoffOn, apply, readCard } = ops
|
|
6
|
+
const cmd = station.command ?? resolveTestCommand(card, worktree).command
|
|
7
|
+
if (!cmd) {
|
|
8
|
+
ledgerAppend(id, { type: 'status', station: station.name, leg: 0, summary: 'test station: no test command configured; treating as green' })
|
|
9
|
+
return { card: step(id, 'test_result', { green: true }, actor) }
|
|
10
|
+
}
|
|
11
|
+
const t = await runTestCommand(cmd, worktree)
|
|
12
|
+
ledgerAppend(id, { type: 'status', station: station.name, leg: 0, summary: `test ${t.green ? 'green' : 'red'}: ${t.command}${t.timedOut ? ' (timed out)' : ''}`, body: t.tail })
|
|
13
|
+
// the suite ran for a while: a human may have killed or paused the card
|
|
14
|
+
// meanwhile, and a result applied to that stale status would undo them
|
|
15
|
+
const fresh = readCard(id)
|
|
16
|
+
if (fresh.status !== 'running') {
|
|
17
|
+
ledgerAppend(id, { type: 'status', station: station.name, leg: 0, summary: `test result not applied: the card was ${fresh.status} by then` })
|
|
18
|
+
return { card: fresh, done: fresh.status !== 'queued' }
|
|
19
|
+
}
|
|
20
|
+
if (!t.green) {
|
|
21
|
+
const bounced = transition(fresh, 'test_result', { green: false, reason: `test red (${t.command}, exit ${t.status}): ${t.tail.split('\n').slice(-5).join(' | ')}` })
|
|
22
|
+
// Attach the failure to a bundle so the next build leg starts from it.
|
|
23
|
+
handoffOn(fresh, { name: station.name, chain: [] }, { outcome: 'test_red', reason: t.tail, exit_code: t.status, adapter: 'test' }, worktree, [`test red: ${t.command}`, t.tail.slice(0, 1500)])
|
|
24
|
+
return { card: apply(id, fresh, bounced, actor) }
|
|
25
|
+
}
|
|
26
|
+
return { card: step(id, 'test_result', { green: true }, actor) }
|
|
27
|
+
}
|