@ucsandman/legcli 0.7.0 → 0.9.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 +57 -0
- package/NOTICE +8 -0
- package/README.md +601 -558
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +64 -34
- package/docs/DECISIONS.md +20 -2
- package/docs/ERRORS.md +71 -0
- package/docs/README.md +2 -0
- package/docs/REUSE.md +1 -1
- package/docs/VOCABULARY.md +21 -0
- package/docs/adapters.md +17 -3
- package/docs/board-guide.md +13 -0
- package/docs/cli-contracts.md +57 -3
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +42 -2
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/fixtures/limits/grok/grok-rate-limit.json +11 -0
- package/fixtures/live/agy/limit-agy-resource-exhausted.json +11 -0
- package/fixtures/verified.json +1 -1
- package/package.json +8 -4
- package/scripts/build-docs-site.mjs +15 -7
- package/scripts/check-branding.mjs +118 -0
- package/scripts/check-claims.mjs +1 -1
- package/scripts/license-sign.mjs +1 -1
- package/scripts/limits-table.mjs +1 -1
- package/scripts/live-limits.mjs +1 -1
- package/scripts/npm-publish-gate.mjs +114 -0
- package/scripts/probe.mjs +4 -3
- package/scripts/seed-fake-cards.mjs +4 -3
- package/scripts/seed-floor-board.mjs +5 -4
- package/scripts/seed-wes-board.mjs +5 -4
- package/scripts/stripe-setup.mjs +1 -1
- package/scripts/sync-harness-engine.mjs +159 -0
- package/scripts/sync-leg-agents.mjs +127 -0
- package/src/accounts.mjs +10 -2
- package/src/adapters/codex.mjs +1 -1
- package/src/adapters/grok.mjs +4 -7
- package/src/attach.mjs +162 -37
- package/src/auth.mjs +2 -2
- package/src/board/board.css +45 -17
- package/src/board/board.js +4 -4
- package/src/board/floor.js +2 -2
- package/src/board/sessions.js +181 -38
- package/src/bundle.mjs +54 -8
- package/src/chain.mjs +1 -1
- package/src/contract.mjs +4 -3
- package/src/fsx.mjs +5 -2
- package/src/handoff.mjs +6 -6
- package/src/harness/cli.mjs +281 -0
- package/src/harness/fingerprint.mjs +68 -0
- package/src/harness/index.mjs +407 -0
- package/src/harness/registry.mjs +124 -0
- package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
- package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
- package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
- package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
- package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
- package/src/hook.mjs +49 -49
- package/src/land.mjs +660 -47
- package/src/launcher.mjs +40 -27
- package/src/ledger.mjs +6 -6
- package/src/license.mjs +10 -9
- package/src/live-capture.mjs +1 -1
- package/src/mergequeue.mjs +6 -6
- package/src/orchestrator.mjs +28 -4
- package/src/preferences.mjs +63 -9
- package/src/redact.mjs +1 -1
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +3 -3
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +69 -20
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +9 -5
- package/src/share.mjs +2 -2
- package/src/stations/agent.mjs +1 -1
- package/src/sync/dashclaw.mjs +4 -4
- package/src/synthesis.mjs +165 -0
- package/src/taps/agy.mjs +2 -2
- package/src/taps/claude-usage.mjs +1 -1
- package/src/taps/claude.mjs +170 -170
- package/src/taps/codex.mjs +286 -286
- package/src/taps/grok.mjs +251 -0
- package/src/trust.mjs +205 -36
- package/src/usage.mjs +5 -1
- package/src/worktree.mjs +5 -4
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +0 -1
- package/fixtures/live/agy/supervisor.log +0 -2
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/out.log +0 -1
- package/fixtures/live/claude/supervisor.log +0 -2
- package/fixtures/live/codex/err.log +0 -1
- package/fixtures/live/codex/out.log +0 -8
- package/fixtures/live/codex/supervisor.log +0 -2
- package/fixtures/live/grok/err.log +0 -32
- package/fixtures/live/grok/out.log +0 -7
- package/fixtures/live/grok/supervisor.log +0 -2
package/src/runner.mjs
CHANGED
|
@@ -129,7 +129,7 @@ function errTail(path, lines = 10) {
|
|
|
129
129
|
|
|
130
130
|
function killTree(pid, log) {
|
|
131
131
|
if ((process.env.LEG_SKIP_KILL || process.env.BATON_SKIP_KILL) === '1') { // test seam: unkillable agent
|
|
132
|
-
log('
|
|
132
|
+
log('LEG_SKIP_KILL=1: killTree skipped')
|
|
133
133
|
return
|
|
134
134
|
}
|
|
135
135
|
if (process.platform === 'win32') {
|
|
@@ -151,14 +151,14 @@ function gitHead(cwd) {
|
|
|
151
151
|
function gitDiff(cwd, headAtStart) {
|
|
152
152
|
const r = spawnSync('git', ['status', '--porcelain'], { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
153
153
|
if (r.status !== 0) return null
|
|
154
|
-
const files = r.stdout.split(/\r?\n/).filter(Boolean).filter((l) => !/\.baton[\\/]/.test(l)).length
|
|
154
|
+
const files = r.stdout.split(/\r?\n/).filter(Boolean).filter((l) => !/\.(baton|leg)[\\/]/.test(l)).length
|
|
155
155
|
const head = gitHead(cwd)
|
|
156
156
|
return { changed: files > 0 || (headAtStart !== null && head !== headAtStart), files, head_at_start: headAtStart, head }
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
// Fallback when the cwd is not a git repo (tests, ad-hoc dirs): a shallow
|
|
160
160
|
// mtime snapshot, so "wrote a file but no DONE" still reads as incomplete.
|
|
161
|
-
const SNAP_SKIP = new Set(['.git', 'node_modules', '.baton', '.baton-worktrees'])
|
|
161
|
+
const SNAP_SKIP = new Set(['.git', 'node_modules', '.baton', '.baton-worktrees', '.leg', '.leg-worktrees'])
|
|
162
162
|
function fsSnapshot(cwd, depth = 3) {
|
|
163
163
|
const out = new Map()
|
|
164
164
|
const walk = (dir, rel, d) => {
|
package/src/scheduler.mjs
CHANGED
|
@@ -46,7 +46,7 @@ export function pickRunnable(cards, { max = MAX_CONCURRENT, landing = new Set()
|
|
|
46
46
|
|
|
47
47
|
export function pidfile() { return join(home(), 'scheduler.pid') }
|
|
48
48
|
|
|
49
|
-
export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: '
|
|
49
|
+
export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: 'leg' } } = {}) {
|
|
50
50
|
const state = { blockedKeys: new Map(), inflight: new Map(), stopped: false, ticks: 0 }
|
|
51
51
|
|
|
52
52
|
async function tick() {
|
package/src/server.mjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// server — the board's HTTP + SSE backend. Ledger-backed: every handler reads
|
|
3
3
|
// card.json / events-*.jsonl on demand (no module-level card store), so a
|
|
4
4
|
// restart shows the same board and a second process sees the same truth.
|
|
5
|
-
//
|
|
6
|
-
// multiplayer seams (src/auth.mjs).
|
|
5
|
+
// LEG_BIND (127.0.0.1) + LEG_PORT (4747) + LEG_TOKEN are the
|
|
6
|
+
// multiplayer seams (src/auth.mjs). BATON_* names still work as fallback.
|
|
7
7
|
import http from 'node:http'
|
|
8
8
|
import { spawnSync } from 'node:child_process'
|
|
9
9
|
import { existsSync, readFileSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
|
|
@@ -26,15 +26,26 @@ import { scrub } from './runner.mjs'
|
|
|
26
26
|
import { resolveChb } from './handoff.mjs'
|
|
27
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
28
|
import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs'
|
|
29
|
+
import { hasRecentSynthesis } from './synthesis.mjs'
|
|
29
30
|
import { refreshPointers } from './resume.mjs'
|
|
30
|
-
import { landSession, landBlocker, landingNow, pruneSessionWorktree } from './land.mjs'
|
|
31
|
+
import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
|
|
31
32
|
import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
|
|
32
33
|
import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
|
|
33
34
|
import { readCodexUsage } from './taps/codex.mjs'
|
|
34
35
|
import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
|
|
35
36
|
|
|
36
37
|
const SELF = fileURLToPath(import.meta.url)
|
|
37
|
-
|
|
38
|
+
export function resolveBoardDir() {
|
|
39
|
+
const dir = join(dirname(SELF), 'board')
|
|
40
|
+
if (existsSync(dir)) return dir
|
|
41
|
+
const wtMatch = /[\\/]\.(?:leg|baton)-worktrees(?:[\\/].*)?$/.exec(dirname(SELF))
|
|
42
|
+
if (wtMatch) {
|
|
43
|
+
const root = dirname(SELF).slice(0, wtMatch.index)
|
|
44
|
+
const fallback = join(root, 'src', 'board')
|
|
45
|
+
if (existsSync(fallback)) return fallback
|
|
46
|
+
}
|
|
47
|
+
return dir
|
|
48
|
+
}
|
|
38
49
|
const VERSION = JSON.parse(readFileSync(join(dirname(SELF), '..', 'package.json'), 'utf8')).version
|
|
39
50
|
const DEFAULT_ORDER = ['plan', 'build', 'review', 'test', 'land']
|
|
40
51
|
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' }
|
|
@@ -191,16 +202,17 @@ export async function detectTools({ refresh = false } = {}) {
|
|
|
191
202
|
return !r.error && r.status === 0
|
|
192
203
|
}
|
|
193
204
|
const agents = {}
|
|
194
|
-
for (const name of ['claude', 'codex', 'agy']) {
|
|
205
|
+
for (const name of ['claude', 'codex', 'agy', 'grok']) {
|
|
195
206
|
try {
|
|
196
|
-
const
|
|
207
|
+
const a = name === 'grok' ? (await import('./adapters/grok.mjs')).default : await getAdapter(name)
|
|
208
|
+
const { bin, viaNode, entry } = a.resolve()
|
|
197
209
|
const target = viaNode ? (entry ?? bin) : bin
|
|
198
210
|
agents[name] = /[\\/]/.test(target) ? existsSync(target) : probe(target)
|
|
199
211
|
} catch { agents[name] = false }
|
|
200
212
|
}
|
|
201
213
|
let chb = false
|
|
202
214
|
try { resolveChb(); chb = true } catch {}
|
|
203
|
-
toolsCache = { ...agents,
|
|
215
|
+
toolsCache = { ...agents, chb, git: probe('git') }
|
|
204
216
|
return toolsCache
|
|
205
217
|
}
|
|
206
218
|
|
|
@@ -257,6 +269,8 @@ function redactSession(s) {
|
|
|
257
269
|
// the branch is already on the worktree chip: naming it again costs nothing
|
|
258
270
|
// and is what the board's land line reads
|
|
259
271
|
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,
|
|
272
|
+
// the chip's word only: paths, dropped items and attention text stay on this machine
|
|
273
|
+
harness: s.harness ? { state: s.harness.state, target: s.harness.target ?? null } : null,
|
|
260
274
|
task: null, cwd: null, files: [], overlap: [], requests: [], hidden: true,
|
|
261
275
|
land_blocker: `read-only: this terminal belongs to ${s.owner ?? 'someone else'}`,
|
|
262
276
|
}
|
|
@@ -279,6 +293,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
279
293
|
const preferredNext = chain[0] ?? null
|
|
280
294
|
const availabilityKnown = Boolean(s.installed)
|
|
281
295
|
const eligibleNext = availabilityKnown ? (chain.find((next) => s.installed[next.agent] !== false && isAvailable(readUsage(next.agent, next.account))) ?? null) : null
|
|
296
|
+
const can = s.worktree ? canLand(s) : { ok: false, blockers: [{ code: 'no_worktree', message: 'this terminal works in the checkout itself: there is no branch of its own to land', fix: null }] }
|
|
282
297
|
return {
|
|
283
298
|
...s,
|
|
284
299
|
handoff_order: handoffOrder,
|
|
@@ -288,6 +303,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
288
303
|
handoff_availability_known: availabilityKnown,
|
|
289
304
|
can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
|
|
290
305
|
active: isActive(s),
|
|
306
|
+
has_synthesis: hasRecentSynthesis(s),
|
|
291
307
|
overlap: ov.get(s.session_id) ?? [],
|
|
292
308
|
elapsed_ms: Date.now() - Date.parse(s.started_at),
|
|
293
309
|
// Older attached Codex processes can retain one parser mistake where an
|
|
@@ -296,7 +312,8 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
296
312
|
files: [...new Set([...(s.files_touched ?? []), ...(s.files_dirty ?? [])])].filter(visibleSessionFile),
|
|
297
313
|
// a 'landing' left behind by a board restart is no longer in flight
|
|
298
314
|
land: land?.state === 'landing' && !landingNow(s.session_id) ? { ...land, state: 'interrupted' } : land,
|
|
299
|
-
|
|
315
|
+
can_land: can,
|
|
316
|
+
land_blocker: s.worktree ? (can.ok ? null : can.blockers[0]?.message) : null,
|
|
300
317
|
}
|
|
301
318
|
})
|
|
302
319
|
const accounts = []
|
|
@@ -349,10 +366,11 @@ function readBody(req) {
|
|
|
349
366
|
}
|
|
350
367
|
|
|
351
368
|
function serveStatic(res, urlPath) {
|
|
352
|
-
const map = { '/': 'index.html', '/floor': 'floor.html' }
|
|
369
|
+
const map = { '/': 'index.html', '/board': 'index.html', '/board/': 'index.html', '/floor': 'floor.html', '/floor/': 'floor.html' }
|
|
353
370
|
const rel = map[urlPath] ?? urlPath.replace(/^\/+/, '')
|
|
354
|
-
const
|
|
355
|
-
|
|
371
|
+
const boardDir = resolveBoardDir()
|
|
372
|
+
const file = resolve(boardDir, rel)
|
|
373
|
+
if (!file.startsWith(boardDir + sep) || !existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found')
|
|
356
374
|
res.writeHead(200, { 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' })
|
|
357
375
|
res.end(readFileSync(file))
|
|
358
376
|
}
|
|
@@ -457,7 +475,7 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
|
|
|
457
475
|
// ---- the server ----
|
|
458
476
|
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
477
|
// 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 `
|
|
478
|
+
// share.json from disk, re-reading it per request (mtime-cached) so `leg
|
|
461
479
|
// share add|rotate|rm` takes effect on a live board — a new link works at
|
|
462
480
|
// once and a removed or rotated one stops at once — without a restart.
|
|
463
481
|
const explicitShare = share !== undefined
|
|
@@ -563,7 +581,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
563
581
|
const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
|
|
564
582
|
if (guest) return send(res, 200, { ok: true, version: VERSION, you })
|
|
565
583
|
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 })
|
|
584
|
+
return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: home(), you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
|
|
567
585
|
}
|
|
568
586
|
if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
|
|
569
587
|
if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
|
|
@@ -596,7 +614,20 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
596
614
|
if (req.method === 'POST' || req.method === 'PATCH') {
|
|
597
615
|
const body = await readBody(req)
|
|
598
616
|
try {
|
|
599
|
-
const
|
|
617
|
+
const patch = {}
|
|
618
|
+
if (body.handoff_order !== undefined) patch.handoff_order = requireHandoffOrder(body.handoff_order)
|
|
619
|
+
if (body.harness !== undefined) {
|
|
620
|
+
// the board may narrow the policy or turn the feature off; turning
|
|
621
|
+
// it on is the first-run consent flow, which shows what will be
|
|
622
|
+
// written before it writes (leg harness enable)
|
|
623
|
+
if (body.harness?.enabled === true) return send(res, 400, { error: 'turn the portable harness on from a terminal: leg harness enable shows what it will write before it writes it' })
|
|
624
|
+
const rank = ['warn', 'sync', 'strict']
|
|
625
|
+
const current = readPreferences().harness
|
|
626
|
+
if (body.harness?.policy !== undefined && rank.indexOf(body.harness.policy) > rank.indexOf(current.policy)) return send(res, 400, { error: `the board may only narrow the harness policy (now ${current.policy}); widen it from a terminal: leg harness policy ${body.harness.policy}` })
|
|
627
|
+
patch.harness = { policy: body.harness?.policy, enabled: body.harness?.enabled === false ? false : undefined }
|
|
628
|
+
}
|
|
629
|
+
if (!Object.keys(patch).length) return send(res, 400, { error: 'nothing to change: send handoff_order or harness' })
|
|
630
|
+
const preferences = writePreferences(patch)
|
|
600
631
|
sse.broadcast('sessions', (v) => viewFor(v))
|
|
601
632
|
return send(res, 200, { preferences })
|
|
602
633
|
} catch (err) {
|
|
@@ -687,9 +718,25 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
687
718
|
}
|
|
688
719
|
}
|
|
689
720
|
if (req.method === 'POST' && parts[3] === 'land') {
|
|
721
|
+
if (parts[4] === 'prepare') {
|
|
722
|
+
const cl = canLand(sess)
|
|
723
|
+
if (!cl.ok) return send(res, 409, { ok: false, error: cl.blockers[0].message, blockers: cl.blockers })
|
|
724
|
+
const prep = await prepareLanding(sess, { by: actor.id })
|
|
725
|
+
return send(res, prep.ok ? 200 : 409, prep)
|
|
726
|
+
}
|
|
727
|
+
if (parts[4] === 'fix') {
|
|
728
|
+
const body = await readBody(req)
|
|
729
|
+
try {
|
|
730
|
+
const r = await applyLandFix(sess.session_id, body?.action, { by: actor.id, message: body?.message })
|
|
731
|
+
try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {}
|
|
732
|
+
return send(res, 200, r)
|
|
733
|
+
} catch (err) {
|
|
734
|
+
return send(res, 400, { ok: false, error: err.message })
|
|
735
|
+
}
|
|
736
|
+
}
|
|
690
737
|
const why = landBlocker(sess)
|
|
691
738
|
if (why) return send(res, 409, { error: why })
|
|
692
|
-
landSession(sess, { by: actor.id })
|
|
739
|
+
landSession(sess, { by: actor.id, autoCommit: true })
|
|
693
740
|
.catch((err) => log(`land ${id}: ${err.message}`))
|
|
694
741
|
.finally(() => { trunkCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
|
|
695
742
|
log(`land requested for ${id} by ${actor.id}`)
|
|
@@ -795,12 +842,14 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
795
842
|
const addr = server.address()
|
|
796
843
|
log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
|
|
797
844
|
// A terminal that crashed instead of exiting left its hand-off in
|
|
798
|
-
// .
|
|
845
|
+
// .leg/RESUME.md looking live. The board is the thing that starts
|
|
799
846
|
// after a crash, so it is where that gets corrected.
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
847
|
+
setImmediate(() => {
|
|
848
|
+
try {
|
|
849
|
+
const touched = refreshPointers()
|
|
850
|
+
if (touched.length) log(`rewrote ${touched.length} stale resume pointer${touched.length === 1 ? '' : 's'}: the terminal each described is gone, or no Leg stamped it`)
|
|
851
|
+
} catch (err) { log(`resume pointers not refreshed: ${err.message}`) }
|
|
852
|
+
})
|
|
804
853
|
if (scheduler) {
|
|
805
854
|
sched = createScheduler()
|
|
806
855
|
sched.run().catch((err) => log(`scheduler crashed: ${err.message}`))
|
package/src/session-detail.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { scrub } from './redact.mjs'
|
|
|
11
11
|
import { transcriptTail as claudeTail } from './taps/claude.mjs'
|
|
12
12
|
import { transcriptTail as codexTail } from './taps/codex.mjs'
|
|
13
13
|
import { resumeVerdict, verdictForBoard } from './resume.mjs'
|
|
14
|
+
import { readHistory } from './harness/index.mjs'
|
|
14
15
|
|
|
15
16
|
export const MESSAGE_LIMIT = 8
|
|
16
17
|
export const DIFF_MAX_LINES = 400
|
|
@@ -41,7 +42,7 @@ export function sessionMessages(session, limit = MESSAGE_LIMIT) {
|
|
|
41
42
|
if (!path) return []
|
|
42
43
|
const tail = session.agent === 'claude' ? claudeTail(path, limit)
|
|
43
44
|
: session.agent === 'codex' ? codexTail(path, limit)
|
|
44
|
-
: [] // agy keeps no transcript
|
|
45
|
+
: [] // agy keeps no transcript Leg can read
|
|
45
46
|
return tail.map((m) => ({ role: m.role === 'user' ? 'user' : 'assistant', text: scrub(m.text), ts: m.ts ?? null }))
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -116,6 +117,18 @@ function resumeFor(session) {
|
|
|
116
117
|
try { return verdictForBoard(resumeVerdict(root)) } catch { return null }
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
// The harness the leg now running was given: what the session recorded when
|
|
121
|
+
// the leg started, plus every capture, sync and decision the harness trail
|
|
122
|
+
// holds for this session. Paths in the attention list are local; a guest never
|
|
123
|
+
// reaches the drawer.
|
|
124
|
+
function harnessFor(session) {
|
|
125
|
+
const h = session.harness ?? null
|
|
126
|
+
let history = []
|
|
127
|
+
try { history = readHistory(200).filter((r) => r.session_id === session.session_id).slice(-20) } catch { /* the drawer renders without the trail */ }
|
|
128
|
+
if (!h && !history.length) return null
|
|
129
|
+
return { ...(h ?? {}), history }
|
|
130
|
+
}
|
|
131
|
+
|
|
119
132
|
export function sessionDetail(session) {
|
|
120
133
|
return {
|
|
121
134
|
session_id: session.session_id,
|
|
@@ -124,6 +137,7 @@ export function sessionDetail(session) {
|
|
|
124
137
|
events: readEvents(session.session_id).slice(-EVENT_LIMIT),
|
|
125
138
|
bundle: session.bundle ?? null,
|
|
126
139
|
resume: resumeFor(session),
|
|
140
|
+
harness: harnessFor(session),
|
|
127
141
|
ts: new Date().toISOString(),
|
|
128
142
|
}
|
|
129
143
|
}
|
package/src/sessions.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// sessions — the store behind `
|
|
1
|
+
// sessions — the store behind `leg claude|codex|agy`. One directory per
|
|
2
2
|
// interactive terminal session under $BATON_HOME/sessions/<id>/:
|
|
3
3
|
// session.json the live record the board renders (atomic writes)
|
|
4
4
|
// events.jsonl timeline (started, turn, warning, limit, handoff, ended)
|
|
@@ -11,9 +11,10 @@ import { randomBytes } from 'node:crypto'
|
|
|
11
11
|
import { home } from './store.mjs'
|
|
12
12
|
import { writeJsonAtomic, withFileLock } from './fsx.mjs'
|
|
13
13
|
import { scrub } from './redact.mjs'
|
|
14
|
-
import { HANDOFF_AGENTS, normalizeHandoffOrder } from './preferences.mjs'
|
|
14
|
+
import { HANDOFF_AGENTS, ALL_HANDOFF_AGENTS, normalizeHandoffOrder } from './preferences.mjs'
|
|
15
15
|
|
|
16
16
|
export const AGENTS = HANDOFF_AGENTS
|
|
17
|
+
export const SUPERVISED_AGENTS = ALL_HANDOFF_AGENTS
|
|
17
18
|
export const HANDOFF_ORDER_CAPABILITY = 'handoff_order_v1'
|
|
18
19
|
export const SESSION_STATUSES = ['starting', 'running', 'warning', 'limit', 'handing_off', 'waiting', 'handed_off', 'ended', 'lost']
|
|
19
20
|
const ACTIVE = ['starting', 'running', 'warning', 'limit', 'handing_off', 'waiting']
|
|
@@ -43,7 +44,7 @@ export function listSessions() {
|
|
|
43
44
|
|
|
44
45
|
export function isActive(s) { return ACTIVE.includes(s?.status) }
|
|
45
46
|
|
|
46
|
-
// The checkout this session's files live in: its own worktree when
|
|
47
|
+
// The checkout this session's files live in: its own worktree when Leg gave
|
|
47
48
|
// it one (repo stays the main checkout, for grouping and landing), else the repo.
|
|
48
49
|
export function workRoot(s) { return s?.worktree?.path ?? s?.repo ?? s?.cwd ?? null }
|
|
49
50
|
|
|
@@ -57,10 +58,13 @@ export function createSession({ id, agent, account = 'default', cwd, repo = null
|
|
|
57
58
|
task: null, turns: 0,
|
|
58
59
|
files_touched: [], files_dirty: [], head: null, head_at_start: null,
|
|
59
60
|
limits: null, limit: null, warning: null,
|
|
60
|
-
bundle: null, handoff: null, chain,
|
|
61
|
+
bundle: null, handoff: null, chain, checkpoints: [],
|
|
61
62
|
handoff_order: normalizeHandoffOrder(handoffOrder), installed,
|
|
62
63
|
runtime_capabilities: [...new Set(runtimeCapabilities)],
|
|
63
64
|
lineage: { from: null, to: null },
|
|
65
|
+
// the portable-harness outcome for the leg now running (src/harness/index.mjs);
|
|
66
|
+
// null until the feature is enabled and a leg has been prepared
|
|
67
|
+
harness: null,
|
|
64
68
|
exit_code: null,
|
|
65
69
|
}
|
|
66
70
|
mkdirSync(sessionDir(id), { recursive: true })
|
|
@@ -160,7 +164,7 @@ export function readLandings() {
|
|
|
160
164
|
return readFileSync(landingsFile(), 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
|
|
161
165
|
}
|
|
162
166
|
|
|
163
|
-
function pidAlive(pid) {
|
|
167
|
+
export function pidAlive(pid) {
|
|
164
168
|
if (!pid) return false
|
|
165
169
|
// EPERM means the process exists but is not ours to signal (e.g. an elevated
|
|
166
170
|
// terminal): it is alive. Only ESRCH ("no such process") means gone.
|
package/src/share.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// share — optional multiplayer for the board, off until `
|
|
1
|
+
// share — optional multiplayer for the board, off until `leg share on`
|
|
2
2
|
// writes $BATON_HOME/share.json. With it on, the board binds the Tailscale or
|
|
3
3
|
// LAN address, every human has a name and their own token (kept as a sha256
|
|
4
|
-
// hash: a token is printed once and `
|
|
4
|
+
// hash: a token is printed once and `leg share rotate` issues a new one),
|
|
5
5
|
// and every session belongs to the human whose terminal started it
|
|
6
6
|
// (`BATON_PERSON`, else the owner). With it off nothing changes: loopback is
|
|
7
7
|
// open and `BATON_TOKEN` is the only token.
|
package/src/stations/agent.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Station kind: agent. One chain leg runs here (or, after `
|
|
1
|
+
// Station kind: agent. One chain leg runs here (or, after `leg down` or a
|
|
2
2
|
// crashed server, the orchestrator re-attaches to the run it left behind),
|
|
3
3
|
// the run is settled, and its verdict is applied to the card. `ops` are the
|
|
4
4
|
// orchestrator's helpers; this module never imports the orchestrator.
|
package/src/sync/dashclaw.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// dashclaw sync — records every ledger event as a DashClaw action over native
|
|
2
|
-
// http/https (LESSONS 07-12: no global fetch). Off unless
|
|
2
|
+
// http/https (LESSONS 07-12: no global fetch). Off unless LEG_SYNC_DASHCLAW=1
|
|
3
3
|
// and DASHCLAW_URL + DASHCLAW_API_KEY are set. Field names come from
|
|
4
4
|
// DashClaw's validator (app/lib/validate.js ACTION_RECORD_SCHEMA): agent_id,
|
|
5
5
|
// action_type and declared_goal are required; status is one of running,
|
|
@@ -26,11 +26,11 @@ const STATUS_BY_TYPE = {
|
|
|
26
26
|
|
|
27
27
|
// One ledger event → one action record (the request body).
|
|
28
28
|
export function actionForEvent(ev, card = null) {
|
|
29
|
-
const actor = ev.actor ?? { type: '
|
|
30
|
-
const agent = actor.type === 'agent' ? `
|
|
29
|
+
const actor = ev.actor ?? { type: 'leg' }
|
|
30
|
+
const agent = actor.type === 'agent' ? `leg/${actor.adapter}` : actor.type === 'human' ? `leg/human:${actor.id}` : 'leg'
|
|
31
31
|
const body = {
|
|
32
32
|
agent_id: agent,
|
|
33
|
-
action_type: `
|
|
33
|
+
action_type: `leg_${ev.type}`,
|
|
34
34
|
declared_goal: String(ev.summary ?? ev.type).slice(0, 2000),
|
|
35
35
|
status: STATUS_BY_TYPE[ev.type] ?? 'completed',
|
|
36
36
|
reversible: true,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// synthesis — the agent-maintained judgment record included in the handoff bundle.
|
|
2
|
+
// Spec: Leg Handoff Synthesis Layer — build spec v1
|
|
3
|
+
// File: .leg/SYNTHESIS-<session-id>.md
|
|
4
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export const MAX_SYNTHESIS_BYTES = 4096
|
|
8
|
+
export const TRUNCATED_MARKER = '[synthesis truncated]'
|
|
9
|
+
export const INVALID_HEADER_PREFIX = '[synthesis header invalid, rendering body as-is]'
|
|
10
|
+
export const SYNTHESIS_POINTER_PARAGRAPH = "Read the Synthesis section first if present. Treat 'Ruled out' as settled: do not retry a ruled-out approach unless you have new evidence it was wrong. Start from the top-ranked Next step unless the repo state contradicts it."
|
|
11
|
+
|
|
12
|
+
export const SYNTHESIS_SECTIONS = [
|
|
13
|
+
'## Ruled out',
|
|
14
|
+
'## Decisions',
|
|
15
|
+
'## Next steps',
|
|
16
|
+
'## Open questions',
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
// The synthesis file path for a session in its checkout/worktree root
|
|
20
|
+
export function synthesisFile(cwd, id) {
|
|
21
|
+
if (!cwd || !id) return null
|
|
22
|
+
const leg = join(cwd, '.leg', `SYNTHESIS-${id}.md`)
|
|
23
|
+
const baton = join(cwd, '.baton', `SYNTHESIS-${id}.md`)
|
|
24
|
+
if (existsSync(leg)) return leg
|
|
25
|
+
if (existsSync(baton)) return baton
|
|
26
|
+
if (existsSync(join(cwd, '.baton')) && !existsSync(join(cwd, '.leg'))) return baton
|
|
27
|
+
return leg
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Safely read the synthesis file; returns null if absent, empty, or unreadable
|
|
31
|
+
export function readSynthesis(cwd, id) {
|
|
32
|
+
if (!cwd || !id) return null
|
|
33
|
+
const file = synthesisFile(cwd, id)
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(file)) return null
|
|
36
|
+
const text = readFileSync(file, 'utf8')
|
|
37
|
+
if (!text || !text.trim()) return null
|
|
38
|
+
return text
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The standing directive for Leg per-session instructions
|
|
45
|
+
export function synthesisDirective(sessionId = '<session-id>') {
|
|
46
|
+
return `Maintain .leg/SYNTHESIS-${sessionId}.md using the schema in section 4. Update it whenever you rule out an approach, make a consequential decision, or change direction. Keep each section to 5 bullets max, one line per bullet.`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Validates the 2-line header per schema v1:
|
|
50
|
+
// synthesis_version: 1
|
|
51
|
+
// session: <session-id> updated: <ISO-8601 UTC>
|
|
52
|
+
export function validateSynthesisHeader(text, { sessionId = null } = {}) {
|
|
53
|
+
if (typeof text !== 'string') return { valid: false, reason: 'content is not a string' }
|
|
54
|
+
const lines = text.split(/\r?\n/)
|
|
55
|
+
if (lines.length < 2) return { valid: false, reason: 'fewer than 2 lines' }
|
|
56
|
+
|
|
57
|
+
const l0 = lines[0].trim()
|
|
58
|
+
if (!/^synthesis_version:\s*1$/.test(l0)) {
|
|
59
|
+
return { valid: false, reason: 'missing or invalid synthesis_version: 1' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const l1 = lines[1].trim()
|
|
63
|
+
const m = /^session:\s*(\S+)\s+updated:\s*(\S+)$/.exec(l1)
|
|
64
|
+
if (!m) {
|
|
65
|
+
return { valid: false, reason: 'missing or invalid session/updated header line' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const [, sessId, updated] = m
|
|
69
|
+
if (sessionId && sessId !== sessionId) {
|
|
70
|
+
return { valid: false, reason: `session mismatch: expected ${sessionId}, got ${sessId}` }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const parsed = Date.parse(updated)
|
|
74
|
+
if (Number.isNaN(parsed) || !/(?:Z|[+-]00:?00)$/i.test(updated)) {
|
|
75
|
+
return { valid: false, reason: 'updated timestamp is not valid ISO-8601 UTC' }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { valid: true, version: 1, session: sessId, updated }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validates the full schema v1: header + optional sections in fixed order with max 5 bullets each
|
|
82
|
+
export function validateSynthesis(text, { sessionId = null } = {}) {
|
|
83
|
+
const header = validateSynthesisHeader(text, { sessionId })
|
|
84
|
+
const errors = []
|
|
85
|
+
if (!header.valid) {
|
|
86
|
+
errors.push(header.reason)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof text !== 'string') {
|
|
90
|
+
return { valid: false, headerValid: false, errors: ['content is not a string'] }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const lines = text.split(/\r?\n/)
|
|
94
|
+
let lastSectionIdx = -1
|
|
95
|
+
let currentSection = null
|
|
96
|
+
let currentBullets = 0
|
|
97
|
+
|
|
98
|
+
for (let i = 2; i < lines.length; i++) {
|
|
99
|
+
const line = lines[i].trim()
|
|
100
|
+
if (line.startsWith('## ')) {
|
|
101
|
+
const idx = SYNTHESIS_SECTIONS.indexOf(line)
|
|
102
|
+
if (idx === -1) {
|
|
103
|
+
errors.push(`unknown section: ${line}`)
|
|
104
|
+
} else if (idx <= lastSectionIdx) {
|
|
105
|
+
errors.push(`section out of order: ${line}`)
|
|
106
|
+
} else {
|
|
107
|
+
lastSectionIdx = idx
|
|
108
|
+
}
|
|
109
|
+
currentSection = line
|
|
110
|
+
currentBullets = 0
|
|
111
|
+
} else if (line.startsWith('- ')) {
|
|
112
|
+
currentBullets++
|
|
113
|
+
if (currentBullets > 5) {
|
|
114
|
+
errors.push(`section ${currentSection || 'unknown'} exceeds 5 bullets`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
valid: errors.length === 0,
|
|
121
|
+
headerValid: header.valid,
|
|
122
|
+
errors,
|
|
123
|
+
session: header.session ?? null,
|
|
124
|
+
updated: header.updated ?? null,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Inlines synthesis into the ## Synthesis section with size cap (4 KB) and malformed header handling
|
|
129
|
+
export function formatSynthesisSection(rawText, { sessionId = null } = {}) {
|
|
130
|
+
if (!rawText || !rawText.trim()) return ''
|
|
131
|
+
|
|
132
|
+
let content = rawText
|
|
133
|
+
const buf = Buffer.from(content, 'utf8')
|
|
134
|
+
if (buf.length > MAX_SYNTHESIS_BYTES) {
|
|
135
|
+
const sliced = buf.subarray(0, MAX_SYNTHESIS_BYTES).toString('utf8')
|
|
136
|
+
const sep = sliced.endsWith('\n') ? '' : '\n'
|
|
137
|
+
content = `${sliced}${sep}${TRUNCATED_MARKER}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const header = validateSynthesisHeader(content, { sessionId })
|
|
141
|
+
if (!header.valid) {
|
|
142
|
+
content = `${INVALID_HEADER_PREFIX}\n${content}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return `## Synthesis\n\n${content.trim()}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// True if .leg/SYNTHESIS-<session-id>.md exists, is non-empty, and was modified within the last 3 checkpoints
|
|
149
|
+
export function hasRecentSynthesis(session) {
|
|
150
|
+
if (!session) return false
|
|
151
|
+
const root = session.worktree?.path ?? session.repo ?? session.cwd ?? null
|
|
152
|
+
if (!root || !session.session_id) return false
|
|
153
|
+
const file = synthesisFile(root, session.session_id)
|
|
154
|
+
try {
|
|
155
|
+
if (!existsSync(file)) return false
|
|
156
|
+
const st = statSync(file)
|
|
157
|
+
if (!st.isFile() || st.size === 0) return false
|
|
158
|
+
const checkpoints = session.checkpoints ?? []
|
|
159
|
+
if (checkpoints.length < 3) return true
|
|
160
|
+
const threshold = new Date(checkpoints[checkpoints.length - 3]).getTime()
|
|
161
|
+
return st.mtimeMs >= threshold
|
|
162
|
+
} catch {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/taps/agy.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// agy tap — Antigravity CLI 1.2.0 is a closed Go binary with no hooks and no
|
|
2
|
-
// usage percentage on any surface
|
|
2
|
+
// usage percentage on any surface Leg can read (its own status line fetches
|
|
3
3
|
// a quota summary from the backend; the value is not written anywhere). What
|
|
4
4
|
// it does give:
|
|
5
|
-
// --log-file <path> one log per
|
|
5
|
+
// --log-file <path> one log per Leg session; the wall shows up as
|
|
6
6
|
// RESOURCE_EXHAUSTED / "it resets in <d>" / "out of quota"
|
|
7
7
|
// (strings present in agy.exe; docs-only until hit live)
|
|
8
8
|
// ~/.gemini/antigravity-cli/history.jsonl
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Why not the status line: Claude Code 2.1.268 renders its built-in status
|
|
4
4
|
// line and does not run a custom `statusLine` command passed via --settings
|
|
5
5
|
// or a project settings file (verified 2026-09-11 with an `echo` command at
|
|
6
|
-
// both levels; hooks from the same --settings file do run). So
|
|
6
|
+
// both levels; hooks from the same --settings file do run). So Leg asks the
|
|
7
7
|
// usage endpoint directly with the OAuth token Claude Code stored at login.
|
|
8
8
|
// The token is read by this process only, sent only to api.anthropic.com,
|
|
9
9
|
// and never written anywhere (the ledger scrubs bearer tokens regardless).
|