@ucsandman/legcli 0.8.0 → 0.10.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 +121 -0
- package/NOTICE +8 -0
- package/README.md +639 -560
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +43 -12
- package/docs/DECISIONS.md +20 -2
- package/docs/ERRORS.md +205 -0
- package/docs/README.md +5 -1
- package/docs/REUSE.md +1 -1
- package/docs/VOCABULARY.md +22 -0
- package/docs/board-guide.md +33 -1
- package/docs/cli-contracts.md +36 -1
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +23 -1
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/verified.json +1 -1
- package/package.json +7 -3
- package/scripts/build-docs-site.mjs +18 -4
- 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 +6 -4
- package/src/adapters/codex.mjs +1 -1
- package/src/attach.mjs +125 -23
- package/src/auth.mjs +2 -2
- package/src/board/board.css +23 -1
- package/src/board/board.js +17 -5
- package/src/board/history.js +377 -0
- package/src/board/index.html +33 -0
- package/src/board/sessions.js +95 -7
- 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/history/cli.mjs +159 -0
- package/src/history/common.mjs +119 -0
- package/src/history/index.mjs +429 -0
- package/src/history/providers/agy.mjs +91 -0
- package/src/history/providers/claude.mjs +161 -0
- package/src/history/providers/codex.mjs +133 -0
- package/src/history/providers/copilot.mjs +94 -0
- package/src/history/providers/grok.mjs +138 -0
- package/src/history/worktrees.mjs +116 -0
- package/src/hook.mjs +49 -49
- package/src/land.mjs +7 -35
- package/src/launcher.mjs +38 -26
- package/src/ledger.mjs +6 -6
- package/src/license.mjs +10 -9
- package/src/live-capture.mjs +1 -1
- package/src/mergequeue.mjs +5 -5
- package/src/orchestrator.mjs +28 -4
- package/src/preferences.mjs +37 -3
- package/src/redact.mjs +24 -6
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +2 -2
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +224 -18
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +15 -3
- 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 +177 -170
- package/src/taps/codex.mjs +286 -286
- package/src/taps/grok.mjs +2 -2
- package/src/taps/mod.mjs +340 -0
- package/src/trust.mjs +205 -36
- package/src/usage.mjs +5 -1
- package/src/worktree.mjs +6 -5
- 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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Keep packages/leg-agents on the same version as @ucsandman/legcli.
|
|
3
|
+
// Source of truth: the root package.json version and engines.node.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/sync-leg-agents.mjs write version, pin, LICENSE, NOTICE
|
|
6
|
+
// node scripts/sync-leg-agents.mjs --check exit 1 if any of those drifted
|
|
7
|
+
//
|
|
8
|
+
// npm test runs --check. `npm version` runs the writer so a bump cannot
|
|
9
|
+
// leave the alias behind.
|
|
10
|
+
import { copyFileSync, readFileSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { execFileSync } from 'node:child_process'
|
|
12
|
+
import { dirname, join, resolve } from 'node:path'
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
14
|
+
import { resolveCommand } from '../src/commands.mjs'
|
|
15
|
+
|
|
16
|
+
export const ALIAS_NAME = 'leg-agents'
|
|
17
|
+
export const PRIMARY_NAME = '@ucsandman/legcli'
|
|
18
|
+
export const PACKED_FILES = ['package.json', 'bin/leg.mjs', 'LICENSE', 'NOTICE', 'README.md']
|
|
19
|
+
|
|
20
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
21
|
+
|
|
22
|
+
export function inspect (rootDir = root) {
|
|
23
|
+
const alias = join(rootDir, 'packages', 'leg-agents')
|
|
24
|
+
const rootPkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8'))
|
|
25
|
+
const aliasPkg = JSON.parse(readFileSync(join(alias, 'package.json'), 'utf8'))
|
|
26
|
+
const wrapper = readFileSync(join(alias, 'bin', 'leg.mjs'), 'utf8')
|
|
27
|
+
const rootLicense = readFileSync(join(rootDir, 'LICENSE'))
|
|
28
|
+
const rootNotice = readFileSync(join(rootDir, 'NOTICE'))
|
|
29
|
+
const aliasLicense = readFileSync(join(alias, 'LICENSE'))
|
|
30
|
+
const aliasNotice = readFileSync(join(alias, 'NOTICE'))
|
|
31
|
+
const problems = []
|
|
32
|
+
|
|
33
|
+
if (rootPkg.name !== PRIMARY_NAME) {
|
|
34
|
+
problems.push(`root package name is ${rootPkg.name}, expected ${PRIMARY_NAME}`)
|
|
35
|
+
}
|
|
36
|
+
if (aliasPkg.name !== ALIAS_NAME) {
|
|
37
|
+
problems.push(`alias package name is ${aliasPkg.name}, expected ${ALIAS_NAME}`)
|
|
38
|
+
}
|
|
39
|
+
if (aliasPkg.version !== rootPkg.version) {
|
|
40
|
+
problems.push(`alias version ${aliasPkg.version} != ${PRIMARY_NAME} ${rootPkg.version}`)
|
|
41
|
+
}
|
|
42
|
+
const pin = aliasPkg.dependencies?.[PRIMARY_NAME]
|
|
43
|
+
if (pin !== rootPkg.version) {
|
|
44
|
+
problems.push(`alias pins ${PRIMARY_NAME}@${pin ?? '(missing)'}, expected ${rootPkg.version}`)
|
|
45
|
+
}
|
|
46
|
+
if (aliasPkg.engines?.node !== rootPkg.engines?.node) {
|
|
47
|
+
problems.push(`alias engines.node ${aliasPkg.engines?.node} != root ${rootPkg.engines?.node}`)
|
|
48
|
+
}
|
|
49
|
+
if (aliasPkg.bin?.leg !== 'bin/leg.mjs') {
|
|
50
|
+
problems.push(`alias bin.leg is ${aliasPkg.bin?.leg}, expected bin/leg.mjs`)
|
|
51
|
+
}
|
|
52
|
+
if (!Array.isArray(rootPkg.files) || rootPkg.files.includes('packages') || rootPkg.files.some((f) => String(f).startsWith('packages/'))) {
|
|
53
|
+
problems.push('root package.json files must not ship packages/leg-agents')
|
|
54
|
+
}
|
|
55
|
+
if (!rootLicense.equals(aliasLicense)) problems.push('packages/leg-agents/LICENSE does not match root LICENSE')
|
|
56
|
+
if (!rootNotice.equals(aliasNotice)) problems.push('packages/leg-agents/NOTICE does not match root NOTICE')
|
|
57
|
+
if (!wrapper.startsWith('#!/usr/bin/env node')) {
|
|
58
|
+
problems.push('packages/leg-agents/bin/leg.mjs is missing the node shebang')
|
|
59
|
+
}
|
|
60
|
+
if (!wrapper.includes("require.resolve('@ucsandman/legcli/package.json')")) {
|
|
61
|
+
problems.push('alias wrapper does not resolve @ucsandman/legcli/package.json')
|
|
62
|
+
}
|
|
63
|
+
if (!wrapper.includes('pathToFileURL')) {
|
|
64
|
+
problems.push('alias wrapper must import via pathToFileURL so Windows absolute paths work')
|
|
65
|
+
}
|
|
66
|
+
if (!wrapper.includes("join(pkgRoot, 'bin', 'leg.mjs')")) {
|
|
67
|
+
problems.push('alias wrapper does not load bin/leg.mjs from the pinned package')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { rootPkg, aliasPkg, wrapper, problems, aliasDir: alias }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function packedFileList (dir) {
|
|
74
|
+
const { bin, args } = resolveCommand('npm pack --dry-run --json --ignore-scripts')
|
|
75
|
+
const stdout = execFileSync(bin, args, { cwd: dir, encoding: 'utf8', windowsHide: true })
|
|
76
|
+
const startArr = stdout.indexOf('[')
|
|
77
|
+
const startObj = stdout.indexOf('{')
|
|
78
|
+
const start = startArr === -1 ? startObj : startObj === -1 ? startArr : Math.min(startArr, startObj)
|
|
79
|
+
if (start === -1) throw new Error(`npm pack produced no JSON:\n${stdout}`)
|
|
80
|
+
const parsed = JSON.parse(stdout.slice(start))
|
|
81
|
+
const entry = Array.isArray(parsed) ? parsed[0] : parsed
|
|
82
|
+
const files = (entry.files || []).map((f) => f.path || f)
|
|
83
|
+
return files.sort()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function sync (rootDir = root) {
|
|
87
|
+
const { rootPkg, aliasPkg } = inspect(rootDir)
|
|
88
|
+
const alias = join(rootDir, 'packages', 'leg-agents')
|
|
89
|
+
const next = {
|
|
90
|
+
...aliasPkg,
|
|
91
|
+
version: rootPkg.version,
|
|
92
|
+
dependencies: { ...aliasPkg.dependencies, [PRIMARY_NAME]: rootPkg.version },
|
|
93
|
+
engines: { ...aliasPkg.engines, node: rootPkg.engines.node }
|
|
94
|
+
}
|
|
95
|
+
writeFileSync(join(alias, 'package.json'), JSON.stringify(next, null, 2) + '\n')
|
|
96
|
+
copyFileSync(join(rootDir, 'LICENSE'), join(alias, 'LICENSE'))
|
|
97
|
+
copyFileSync(join(rootDir, 'NOTICE'), join(alias, 'NOTICE'))
|
|
98
|
+
return next
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const isMain = Boolean(process.argv[1]) && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
102
|
+
|
|
103
|
+
if (isMain) {
|
|
104
|
+
const check = process.argv.includes('--check')
|
|
105
|
+
if (!check) sync()
|
|
106
|
+
const { rootPkg, problems, aliasDir: dir } = inspect()
|
|
107
|
+
let packed
|
|
108
|
+
try {
|
|
109
|
+
packed = packedFileList(dir)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
problems.push(`npm pack --dry-run failed: ${error.message}`)
|
|
112
|
+
}
|
|
113
|
+
if (packed) {
|
|
114
|
+
const expected = [...PACKED_FILES].sort()
|
|
115
|
+
if (packed.join('\n') !== expected.join('\n')) {
|
|
116
|
+
problems.push(`packed files are [${packed.join(', ')}], expected [${expected.join(', ')}]`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (problems.length) {
|
|
120
|
+
console.error(`leg-agents is out of lockstep with ${PRIMARY_NAME}@${rootPkg.version}:\n`)
|
|
121
|
+
for (const p of problems) console.error(` - ${p}`)
|
|
122
|
+
console.error('\nRun: node scripts/sync-leg-agents.mjs')
|
|
123
|
+
process.exitCode = 1
|
|
124
|
+
} else {
|
|
125
|
+
console.log(`leg-agents: in lockstep with ${PRIMARY_NAME}@${rootPkg.version}`)
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/accounts.mjs
CHANGED
|
@@ -43,13 +43,15 @@ export const LAYOUT = {
|
|
|
43
43
|
export function accountsFile() { return join(home(), 'accounts.json') }
|
|
44
44
|
export function accountDir(agent, name) { return join(home(), 'accounts', agent, name) }
|
|
45
45
|
|
|
46
|
+
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,29}$/i
|
|
47
|
+
|
|
46
48
|
export function readAccounts() {
|
|
47
|
-
const base = { claude: ['default'], codex: ['default'], agy: ['default'] }
|
|
49
|
+
const base = { claude: ['default'], codex: ['default'], agy: ['default'], grok: ['default'] }
|
|
48
50
|
if (!existsSync(accountsFile())) return base
|
|
49
51
|
try {
|
|
50
52
|
const j = JSON.parse(readFileSync(accountsFile(), 'utf8'))
|
|
51
|
-
|
|
52
|
-
if (Array.isArray(j
|
|
53
|
+
// the same shape addAccount accepts: a name is a directory segment, never a path
|
|
54
|
+
for (const k of Object.keys(base)) if (Array.isArray(j[k])) base[k] = ['default', ...j[k].filter((n) => typeof n === 'string' && n !== 'default' && NAME_RE.test(n))]
|
|
53
55
|
} catch {}
|
|
54
56
|
return base
|
|
55
57
|
}
|
|
@@ -75,7 +77,7 @@ function junction(target, link) {
|
|
|
75
77
|
|
|
76
78
|
// Create the account dir, junction the shared harness in, copy the settings.
|
|
77
79
|
export function addAccount(agent, name) {
|
|
78
|
-
if (
|
|
80
|
+
if (!NAME_RE.test(name) || name === 'default') throw new Error(`invalid account name "${name}" (letters, digits, - and _; not "default")`)
|
|
79
81
|
const l = LAYOUT[agent]
|
|
80
82
|
if (!l) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
|
|
81
83
|
if (!l.env) throw new Error(`${agent} has no config-dir override in the installed version; extra accounts are not possible`)
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -19,7 +19,7 @@ const adapter = {
|
|
|
19
19
|
forbiddenFlags: ['danger-full-access', '--dangerously-bypass-approvals-and-sandbox', '--dangerously-bypass-hook-trust', '--full-auto', '--approve-for-me'],
|
|
20
20
|
resolve() {
|
|
21
21
|
if ((process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN)) {
|
|
22
|
-
const bin = process.env.BATON_CODEX_BIN
|
|
22
|
+
const bin = process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN
|
|
23
23
|
return { bin, viaNode: /\.(mjs|cjs|js)$/.test(bin), entry: null }
|
|
24
24
|
}
|
|
25
25
|
// The npm package's bin/codex.js only spawns the platform package's native
|
package/src/attach.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// attach — `
|
|
2
|
-
// this terminal, with
|
|
1
|
+
// attach — `leg claude|codex|agy [args…]`: the normal interactive agent in
|
|
2
|
+
// this terminal, with Leg alongside it. Leg (1) makes sure the board is
|
|
3
3
|
// up and opens it once, (2) registers the session so it shows on the board,
|
|
4
4
|
// (3) taps the agent for usage (claude: hooks + status line via --settings;
|
|
5
5
|
// codex: its rollout file; agy: its log), (4) polls git for the files the
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// same terminal from that bundle. Subscription logins only: API keys are
|
|
9
9
|
// stripped from the child environment (src/env.mjs).
|
|
10
10
|
import http from 'node:http'
|
|
11
|
+
import net from 'node:net'
|
|
11
12
|
import { spawn, spawnSync } from 'node:child_process'
|
|
12
13
|
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
13
14
|
import { join, dirname, resolve, relative } from 'node:path'
|
|
@@ -28,16 +29,27 @@ import { findRollout, createTail, parseLines, readCodexUsage, transcriptTail as
|
|
|
28
29
|
import { scanLog, promptsSince, logSize } from './taps/agy.mjs'
|
|
29
30
|
import { fetchGrokUsage, scanLog as scanGrokLog, promptsSince as grokPromptsSince } from './taps/grok.mjs'
|
|
30
31
|
import { fetchClaudeUsage } from './taps/claude-usage.mjs'
|
|
31
|
-
import { saveSessionBundle, resumePrompt } from './bundle.mjs'
|
|
32
|
+
import { saveSessionBundle, resumePrompt, sessionCommitDelta } from './bundle.mjs'
|
|
32
33
|
import { endSessionPointer } from './resume.mjs'
|
|
33
34
|
import { openBoard, pidfile } from './launcher.mjs'
|
|
34
35
|
import { LAYOUT } from './accounts.mjs'
|
|
35
36
|
import { captureLive } from './live-capture.mjs'
|
|
36
37
|
import { waitForReset, fmtCountdown } from './wait.mjs'
|
|
37
38
|
import { readPreferences, normalizeHandoffOrder, resolveAutoApprove } from './preferences.mjs'
|
|
39
|
+
import { prepareHarnessForHandoff, harnessLine } from './harness/index.mjs'
|
|
40
|
+
import { insideKnownStore } from './history/index.mjs'
|
|
38
41
|
|
|
39
42
|
const SRC = dirname(fileURLToPath(import.meta.url))
|
|
40
|
-
|
|
43
|
+
function resolveServer() {
|
|
44
|
+
const wtMatch = /[\\/]\.(?:leg|baton)-worktrees(?:[\\/].*)?$/.exec(SRC)
|
|
45
|
+
if (wtMatch) {
|
|
46
|
+
const root = SRC.slice(0, wtMatch.index)
|
|
47
|
+
const mainServer = join(root, 'src', 'server.mjs')
|
|
48
|
+
if (existsSync(mainServer)) return mainServer
|
|
49
|
+
}
|
|
50
|
+
return join(SRC, 'server.mjs')
|
|
51
|
+
}
|
|
52
|
+
const SERVER = resolveServer()
|
|
41
53
|
const POLL_MS = Number(process.env.LEG_ATTACH_POLL_MS || process.env.BATON_ATTACH_POLL_MS || 2000)
|
|
42
54
|
const GIT_EVERY = 3 // polls
|
|
43
55
|
const USAGE_MS = Number(process.env.LEG_USAGE_POLL_MS || process.env.BATON_USAGE_POLL_MS || 60000)
|
|
@@ -70,6 +82,19 @@ function health(port, host = '127.0.0.1') {
|
|
|
70
82
|
})
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
// Does anything own this port? A completed TCP connect is the question, so a
|
|
86
|
+
// board too busy to answer /api/health still counts as one. Nothing is sent.
|
|
87
|
+
function portTaken(port, host = '127.0.0.1') {
|
|
88
|
+
return new Promise((res) => {
|
|
89
|
+
const sock = net.connect({ host: host === '0.0.0.0' ? '127.0.0.1' : host, port })
|
|
90
|
+
const done = (v) => { sock.destroy(); res(v) }
|
|
91
|
+
sock.setTimeout(2000)
|
|
92
|
+
sock.on('connect', () => done(true))
|
|
93
|
+
sock.on('error', () => done(false))
|
|
94
|
+
sock.on('timeout', () => done(false))
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
73
98
|
export async function ensureBoard({ open = true } = {}) {
|
|
74
99
|
// with share on the board lives on the shared address, not loopback
|
|
75
100
|
const share = readShare()
|
|
@@ -78,18 +103,31 @@ export async function ensureBoard({ open = true } = {}) {
|
|
|
78
103
|
const host = shared ? share.bind : '127.0.0.1'
|
|
79
104
|
const url = `http://${host}:${port}`
|
|
80
105
|
if ((process.env.LEG_NO_BOARD || process.env.BATON_NO_BOARD) === '1') return { url: null, started: false, skipped: true }
|
|
81
|
-
|
|
106
|
+
// the board is opened whether or not this terminal is the one that started
|
|
107
|
+
// it: `leg claude` in a second terminal still means "show me the board"
|
|
108
|
+
if (await health(port, host)) { if (open) openBoard(url); return { url, started: false } }
|
|
109
|
+
// A board that is merely busy misses the health deadline while still owning
|
|
110
|
+
// the port. Treating that as "no board" spawned a second server that could
|
|
111
|
+
// only die of EADDRINUSE, and the poll below then waited the full fifteen
|
|
112
|
+
// seconds for a child already gone — the whole delay before the agent
|
|
113
|
+
// starts, and the reason no browser ever opened. A listener on the port is
|
|
114
|
+
// a board: attach to it and open it.
|
|
115
|
+
if (await portTaken(port, host)) {
|
|
116
|
+
say(`the board on ${url} is busy; attaching to it`)
|
|
117
|
+
if (open) openBoard(url)
|
|
118
|
+
return { url, started: false, busy: true }
|
|
119
|
+
}
|
|
82
120
|
mkdirSync(home(), { recursive: true })
|
|
83
121
|
const logFd = (await import('node:fs')).openSync(join(home(), 'board.log'), 'a')
|
|
84
|
-
const child = spawn(process.execPath, [SERVER], { detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, BATON_PORT: String(port), BATON_BIND: host, BATON_QUIET: '0' } })
|
|
122
|
+
const child = spawn(process.execPath, [SERVER], { detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, LEG_PORT: String(port), LEG_BIND: host, LEG_QUIET: '0', BATON_PORT: String(port), BATON_BIND: host, BATON_QUIET: '0' } })
|
|
85
123
|
child.unref()
|
|
86
124
|
const t0 = Date.now()
|
|
87
125
|
while (Date.now() - t0 < 15000) {
|
|
88
126
|
const h = await health(port, host)
|
|
89
127
|
if (h) {
|
|
90
128
|
// only claim the pidfile for a child we actually started: under a race,
|
|
91
|
-
// another `
|
|
92
|
-
// dead pid would make `
|
|
129
|
+
// another `leg` won the port and ours died on EADDRINUSE — writing our
|
|
130
|
+
// dead pid would make `leg down` kill nothing and report "not running"
|
|
93
131
|
const ours = h.pid ? h.pid === child.pid : (child.exitCode === null && Boolean(child.pid))
|
|
94
132
|
if (ours) writeFileSync(pidfile(), JSON.stringify({ pid: child.pid, port, bind: host, children: [child.pid], detached: true, started_by: 'attach', started_at: new Date().toISOString() }, null, 2) + '\n')
|
|
95
133
|
if (open) openBoard(url)
|
|
@@ -195,7 +233,7 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
|
|
|
195
233
|
const argv = []
|
|
196
234
|
// viaNode: either an npm entry (codex bin/codex.js) or a BATON_<AGENT>_BIN that names a .mjs (tests)
|
|
197
235
|
if (viaNode) argv.push(entry ?? bin)
|
|
198
|
-
// a leg
|
|
236
|
+
// a leg Leg starts on its own (after a hand-off) takes BATON_<AGENT>_ARGS,
|
|
199
237
|
// e.g. BATON_CODEX_ARGS="-m gpt-5-mini" to keep a test chain on cheap models
|
|
200
238
|
if (prompt) args = [...(process.env[`LEG_${agent.toUpperCase()}_ARGS`] ?? process.env[`BATON_${agent.toUpperCase()}_ARGS`] ?? '').split(/\s+/).filter(Boolean), ...args]
|
|
201
239
|
if (agent === 'claude') {
|
|
@@ -259,6 +297,13 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
|
|
|
259
297
|
|
|
260
298
|
// taps
|
|
261
299
|
let rollout = null; let tail = null
|
|
300
|
+
// a continued codex thread appends to its old rollout, which findRollout
|
|
301
|
+
// (newest file since this leg started) would never pick: bind it up front
|
|
302
|
+
// and read only what the thread writes from here on
|
|
303
|
+
if (agent === 'codex' && !prompt && session.transcript_path && session.agent_session_id && existsSync(session.transcript_path)) {
|
|
304
|
+
rollout = { path: session.transcript_path, meta: { id: session.agent_session_id } }
|
|
305
|
+
tail = createTail(rollout.path, { from: logSize(rollout.path) })
|
|
306
|
+
}
|
|
262
307
|
let polls = 0; let warned = false
|
|
263
308
|
let stop = null
|
|
264
309
|
const done = new Promise((res) => { stop = res })
|
|
@@ -484,14 +529,14 @@ function messagesFor(agent, s) {
|
|
|
484
529
|
// an order save is consumed here, or the editor sees handing_off and refuses.
|
|
485
530
|
// No eligible choice leaves the session unclaimed so all-out waiting can keep
|
|
486
531
|
// accepting order edits.
|
|
487
|
-
export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000) }) {
|
|
532
|
+
export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000), exclude = [] }) {
|
|
488
533
|
let choice = { next: null, out: [] }
|
|
489
534
|
let claimed = false
|
|
490
535
|
const session = updateSession(sid, (current) => {
|
|
491
536
|
const accounts = readAccounts()
|
|
492
537
|
const order = normalizeHandoffOrder(current.handoff_order)
|
|
493
|
-
choice = chooseNext({ agent, account, accounts, installed, order, nowS })
|
|
494
|
-
if (!choice.next && isAvailable(readUsage(agent, account), nowS)) choice = { next: { agent, account }, out: [] }
|
|
538
|
+
choice = chooseNext({ agent, account, accounts, installed, order, nowS, exclude })
|
|
539
|
+
if (!choice.next && isAvailable(readUsage(agent, account), nowS) && !exclude.some((x) => x.agent === agent && x.account === account)) choice = { next: { agent, account }, out: [] }
|
|
495
540
|
if (!choice.next) return {}
|
|
496
541
|
claimed = true
|
|
497
542
|
return {
|
|
@@ -510,15 +555,34 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
|
|
|
510
555
|
return { choice, claimed, session }
|
|
511
556
|
}
|
|
512
557
|
|
|
558
|
+
// The portable harness, decided before a leg starts (src/harness/index.mjs).
|
|
559
|
+
// Off by default: then this records nothing and changes nothing. On, it
|
|
560
|
+
// carries the source agent's working environment to the agent about to run,
|
|
561
|
+
// per the saved policy, and the session keeps the outcome so the board can say
|
|
562
|
+
// what transferred and what did not. Never throws into the session.
|
|
563
|
+
function prepareLegHarness({ sid, from, to }) {
|
|
564
|
+
let outcome
|
|
565
|
+
try { outcome = prepareHarnessForHandoff({ from, to, sessionId: sid }) } catch (err) { outcome = { state: 'error', policy: 'unknown', proceed: true, to, target: to, reason: String(err.message).slice(0, 200), summary: `harness error: ${String(err.message).slice(0, 120)}` } }
|
|
566
|
+
if (outcome.state === 'off') return outcome
|
|
567
|
+
updateSession(sid, { harness: outcome })
|
|
568
|
+
const line = harnessLine(outcome)
|
|
569
|
+
if (line) { say(line); appendEvent(sid, { type: outcome.proceed ? 'harness' : 'harness_blocked', summary: line, body: outcome.dropped?.length || outcome.attention?.length ? [...(outcome.attention ?? []).map((a) => `attention ${a.component}: ${a.reason}`), ...(outcome.dropped ?? []).map((d) => `dropped ${d.component}: ${d.item}: ${d.reason}`)].join('\n') : undefined }) }
|
|
570
|
+
return outcome
|
|
571
|
+
}
|
|
572
|
+
|
|
513
573
|
// ---- the command ----
|
|
514
|
-
|
|
574
|
+
// `cwd` and `continued` are how `leg history continue` starts a leg on a
|
|
575
|
+
// conversation the agent's own store holds (src/history/cli.mjs): the leg runs
|
|
576
|
+
// in that conversation's folder, shares the checkout (its files are already
|
|
577
|
+
// there), and the session record carries the agent's id from the start.
|
|
578
|
+
export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null, continued = null } = {}) {
|
|
515
579
|
if (!SUPERVISED_AGENTS.includes(agent)) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
|
|
516
580
|
// the paid gate: a valid key, or no session (exit 4). The bare agent is never
|
|
517
|
-
// affected; only what
|
|
581
|
+
// affected; only what Leg adds is licensed.
|
|
518
582
|
const ent = entitlement()
|
|
519
583
|
if (!allows(ent, 'run')) { say(describeLicense(ent)); return 4 }
|
|
520
|
-
// --no-worktree is
|
|
521
|
-
const shareCheckout = args.includes('--no-worktree')
|
|
584
|
+
// --no-worktree is Leg's flag, not the agent's: it never passes through
|
|
585
|
+
const shareCheckout = args.includes('--no-worktree') || Boolean(continued)
|
|
522
586
|
args = args.filter((a) => a !== '--no-worktree')
|
|
523
587
|
let autoApproveCli = null
|
|
524
588
|
if (args.includes('--no-auto-approve')) {
|
|
@@ -529,7 +593,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
529
593
|
args = args.filter((a) => a !== '--auto-approve')
|
|
530
594
|
}
|
|
531
595
|
const autoApprove = resolveAutoApprove({ cliFlag: autoApproveCli })
|
|
532
|
-
const cwd = process.cwd()
|
|
596
|
+
const cwd = cwdOpt ? realPath(cwdOpt) : process.cwd()
|
|
533
597
|
const board = await ensureBoard({ open })
|
|
534
598
|
let accounts = readAccounts()
|
|
535
599
|
const installed = await installedAgents()
|
|
@@ -562,6 +626,13 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
562
626
|
// `git worktree add` still leaves a card (with a Remove button), never a
|
|
563
627
|
// silent orphan under .baton-worktrees with no record and no button
|
|
564
628
|
createSession({ id: sid, agent, account, cwd, repo: g.repo, branch: g.branch, argv: args, chain, worktree: null, owner: whoami(), handoffOrder, installed, runtimeCapabilities: [HANDOFF_ORDER_CAPABILITY] })
|
|
629
|
+
if (continued) {
|
|
630
|
+
// the agent's own id and transcript are known before the first turn, so
|
|
631
|
+
// history dedups this leg against the conversation it continues at once
|
|
632
|
+
const safeTranscript = (continued.transcript_path && insideKnownStore(continued.transcript_path)) ? continued.transcript_path : null
|
|
633
|
+
updateSession(sid, { agent_session_id: continued.native_id ?? null, transcript_path: safeTranscript, task: continued.title ?? null, continued_from: { id: continued.id, provider: continued.provider, native_id: continued.native_id ?? null } },
|
|
634
|
+
{ event: { type: 'continued', summary: `continuing ${continued.id}${continued.title ? `: ${String(continued.title).slice(0, 120)}` : ''}` } })
|
|
635
|
+
}
|
|
565
636
|
let iso = null
|
|
566
637
|
if (g.repo && !shareCheckout) {
|
|
567
638
|
try { iso = isolate({ g, cwd, sid }) } catch (err) { say(`could not make a worktree (${String(err.message).split('\n')[0].slice(0, 200)}); sharing the checkout`); try { removeWorktree(g.repo, sid) } catch {} }
|
|
@@ -578,6 +649,9 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
578
649
|
let prompt = null
|
|
579
650
|
let legArgs = args
|
|
580
651
|
let exit = 0
|
|
652
|
+
// the first leg is the agent the human chose: its harness is prepared per
|
|
653
|
+
// policy and recorded, never refused (strict applies to hand-offs)
|
|
654
|
+
prepareLegHarness({ sid, from: null, to: agent })
|
|
581
655
|
// unbounded: the 12-leg cap below stops a runaway chain, and the all-out wait
|
|
582
656
|
// bounds a wait; a normal session runs one leg and exits
|
|
583
657
|
for (let leg = 0; ; leg++) {
|
|
@@ -593,10 +667,14 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
593
667
|
// saveSessionBundle writes the notes file before it shells out to chb, so
|
|
594
668
|
// even when chb is missing and the save throws, the context is on disk
|
|
595
669
|
const notesFile = join(workRoot(cur) ?? cur.cwd, '.leg', `session-${sid}.md`)
|
|
596
|
-
|
|
670
|
+
// destinations the strict harness policy refused during this hand-off
|
|
671
|
+
const excluded = []
|
|
672
|
+
let claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
597
673
|
let choice = claim.choice
|
|
598
674
|
let cancelled = false
|
|
675
|
+
let blocked = false
|
|
599
676
|
while (!choice.next) {
|
|
677
|
+
if (excluded.length && !choice.out.length) { blocked = true; break }
|
|
600
678
|
// every option is out: keep the terminal, count down to the SOONEST reset
|
|
601
679
|
// (the current agent's own wall included — it may be the first back), then
|
|
602
680
|
// start that option from the bundle. Ctrl-C (or End) quits with exit 3.
|
|
@@ -612,7 +690,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
612
690
|
updateSession(sid, { status: 'waiting', all_out: all, waiting: first ? { agent: first.agent, account: first.account, resets_at: first.resets_at, since: new Date().toISOString() } : null }, { event: { type: 'all_out', summary: `every option is out; waiting for ${label} at ${first ? fmtReset(first.resets_at) : 'unknown'}` } })
|
|
613
691
|
const r2 = await waitInTerminal({ sid, label, resetsAt: first?.resets_at ?? null })
|
|
614
692
|
if (r2 === 'cancelled') { cancelled = true; break }
|
|
615
|
-
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason })
|
|
693
|
+
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
616
694
|
choice = claim.choice
|
|
617
695
|
}
|
|
618
696
|
if (cancelled) {
|
|
@@ -620,6 +698,24 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
620
698
|
exit = 3
|
|
621
699
|
break
|
|
622
700
|
}
|
|
701
|
+
// strict harness policy: a destination whose harness cannot be made safe is
|
|
702
|
+
// refused and the next option is tried; when none is left the session ends
|
|
703
|
+
// with exit 5 rather than launching an agent without its environment
|
|
704
|
+
while (!blocked && choice.next) {
|
|
705
|
+
const prepared = prepareLegHarness({ sid, from: agent, to: choice.next.agent })
|
|
706
|
+
if (prepared.proceed) break
|
|
707
|
+
excluded.push(choice.next)
|
|
708
|
+
say(`${choice.next.agent} refused by the strict harness policy: ${prepared.reason ?? prepared.state}`)
|
|
709
|
+
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
710
|
+
choice = claim.choice
|
|
711
|
+
if (!choice.next) blocked = true
|
|
712
|
+
}
|
|
713
|
+
if (blocked) {
|
|
714
|
+
say('every remaining option was refused by the strict harness policy; stopping (exit 5). Fix what needs attention (leg harness status) or relax the policy (leg harness policy sync), then run leg again in this directory.')
|
|
715
|
+
updateSession(sid, { status: 'ended', ended_at: new Date().toISOString(), exit_code: 5, waiting: null }, { event: { type: 'ended', summary: 'strict harness policy refused every destination; stopped (exit 5)' } })
|
|
716
|
+
exit = 5
|
|
717
|
+
break
|
|
718
|
+
}
|
|
623
719
|
const next = choice.next
|
|
624
720
|
// bound the number of hand-offs in one terminal so a chain that limits
|
|
625
721
|
// instantly can never loop forever; stopping is explicit, not a silent exit 0
|
|
@@ -630,9 +726,15 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
630
726
|
break
|
|
631
727
|
}
|
|
632
728
|
appendEvent(sid, { type: 'handoff', summary: `${agent}${account !== 'default' ? '/' + account : ''} → ${next.agent}${next.account !== 'default' ? '/' + next.account : ''}${bundle ? ` (bundle ${bundle.id})` : ''}` })
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
729
|
+
if (bundle) {
|
|
730
|
+
prompt = resumePrompt(cur, bundle, next)
|
|
731
|
+
} else {
|
|
732
|
+
const delta = sessionCommitDelta(workRoot(cur) ?? cur.cwd, cur)
|
|
733
|
+
const fallbackAction = delta.isClean && delta.newCommits.length > 0
|
|
734
|
+
? `The previous agent committed changes (${delta.newCommits.length} commit(s)) and left a clean working tree. Check git log and verify whether the task is already complete before doing redundant work; continue only if work remains.`
|
|
735
|
+
: 'Check git status and git diff, then continue the work.'
|
|
736
|
+
prompt = `You are taking over an interactive coding session from ${agent}.${existsSync(notesFile) ? ` Read ${notesFile} in this directory first (the previous agent's notes: task, last messages, dirty files).` : ''} ${fallbackAction} The task: ${cur.task ?? 'see the recent changes'}`
|
|
737
|
+
}
|
|
636
738
|
say(`starting ${next.agent}${next.account !== 'default' ? '/' + next.account : ''} in this terminal from the bundle`)
|
|
637
739
|
agent = next.agent; account = next.account; legArgs = []
|
|
638
740
|
// the chain is what comes after the agent now taking over, not after the
|
|
@@ -646,7 +748,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
646
748
|
if (fin && fin.status !== 'ended') updateSession(sid, { status: 'ended', ended_at: new Date().toISOString(), exit_code: exit }, { event: { type: 'ended', summary: `session ended (exit ${exit})` } })
|
|
647
749
|
// Every way out of the loop arrives here: a clean exit, a cancelled wait, the
|
|
648
750
|
// 12-leg cap, an agent that never started. The terminal is gone, so RESUME.md
|
|
649
|
-
// must stop describing it as live —
|
|
751
|
+
// must stop describing it as live — Leg owns that file, and leaving the last
|
|
650
752
|
// hand-off sitting there is exactly the lie this rewrite exists to stop.
|
|
651
753
|
try { endSessionPointer(readSession(sid)) } catch (err) { appendEvent(sid, { type: 'error', summary: `resume pointer not rewritten: ${err.message.slice(0, 160)}` }) }
|
|
652
754
|
try { rmSync(join(sessionDir(sid), 'control.json'), { force: true }) } catch {}
|
package/src/auth.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// auth — who is allowed in. Loopback with no token is open; any other bind
|
|
2
2
|
// address refuses to start without a token, and with a token every /api
|
|
3
|
-
// request needs `Authorization: Bearer <token>` (timing-safe). With `
|
|
3
|
+
// request needs `Authorization: Bearer <token>` (timing-safe). With `leg
|
|
4
4
|
// share` on, each human has their own token and the board knows their name
|
|
5
5
|
// and role (src/share.mjs); a loopback request is still the owner, so the
|
|
6
6
|
// machine's own browser needs nothing.
|
|
@@ -75,7 +75,7 @@ export function authorize({ token, req, url, share = null, bind = null, loopback
|
|
|
75
75
|
return { ok: false, subject: null, person: null }
|
|
76
76
|
}
|
|
77
77
|
// checkBind's invariant, re-applied per request: share.json stops reading as
|
|
78
|
-
// on (`
|
|
78
|
+
// on (`leg share off`, a truncated file) while the shared address is still
|
|
79
79
|
// bound, and nobody but this machine may be the local owner in that window
|
|
80
80
|
if (!token && loopbackOwner === false) return { ok: false, subject: null, person: null }
|
|
81
81
|
if (!token && bind && !isLoopback(bind) && !isLoopback(remoteAddress(req))) return { ok: false, subject: null, person: null }
|
package/src/board/board.css
CHANGED
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
--id-codex: #69DBBA; /* oklch(0.815 0.115 172) cool green */
|
|
131
131
|
--id-agy: #CC97F3; /* oklch(0.760 0.140 310) violet, 46 degrees clear of the accent */
|
|
132
132
|
--id-grok: #70B8FF; /* oklch(0.750 0.130 240) azure blue */
|
|
133
|
+
--id-copilot: #F0A3B8; /* oklch(0.790 0.110 5) rose; lists and reads, never runs */
|
|
133
134
|
--id-fake: #A0A6AE; /* oklch(0.720 0.010 258) the scripted adapter, neutral in hue */
|
|
134
135
|
|
|
135
136
|
/* type scale. Fixed rem, range 13 to 52. The old board ran 13 to 21, which
|
|
@@ -222,6 +223,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
222
223
|
.dot.id-codex { background: var(--id-codex); }
|
|
223
224
|
.dot.id-agy { background: var(--id-agy); }
|
|
224
225
|
.dot.id-grok { background: var(--id-grok); }
|
|
226
|
+
.dot.id-copilot { background: var(--id-copilot); }
|
|
225
227
|
|
|
226
228
|
/* ---- logins ------------------------------------------------------------- */
|
|
227
229
|
.logins { display: grid; gap: 20px; }
|
|
@@ -323,6 +325,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
323
325
|
.chip-id-codex { color: var(--id-codex); }
|
|
324
326
|
.chip-id-agy { color: var(--id-agy); }
|
|
325
327
|
.chip-id-grok { color: var(--id-grok); }
|
|
328
|
+
.chip-id-copilot { color: var(--id-copilot); }
|
|
326
329
|
.chip-id-fake { color: var(--id-fake); }
|
|
327
330
|
.chip-state-ok { color: var(--ok-text); }
|
|
328
331
|
.chip-state-warn { color: var(--warn-text); }
|
|
@@ -370,7 +373,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
370
373
|
.drawer-msg-when { font-size: var(--t--1); color: var(--text-3); font-family: var(--mono); }
|
|
371
374
|
|
|
372
375
|
/* ---- ledger: on the ground, unpanelled --------------------------------- */
|
|
373
|
-
.ledger { margin-top: 56px; display: grid; grid-template-columns: repeat(
|
|
376
|
+
.ledger { margin-top: 56px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 40px; padding-top: 32px; border-top: 1px solid var(--edge); }
|
|
374
377
|
.ledger-cell h3 { font-size: var(--t-2); font-weight: var(--w-head); letter-spacing: -.012em; }
|
|
375
378
|
.ledger-cell .region-meta { margin-top: 6px; font-size: var(--t-0); color: var(--text-3); }
|
|
376
379
|
.ledger-actions { margin-top: 18px; display: flex; gap: 10px; flex-wrap: wrap; }
|
|
@@ -384,6 +387,23 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
384
387
|
.line-main { color: var(--text); }
|
|
385
388
|
.line-meta { color: var(--text-2); }
|
|
386
389
|
.line-when { color: var(--text-3); text-align: right; }
|
|
390
|
+
/* history: the conversations every agent keeps, one row each, opening in place */
|
|
391
|
+
.history-filters { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 14px; align-items: center; margin: 4px 0 18px; max-width: 64ch; }
|
|
392
|
+
.history-filters label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
|
|
393
|
+
.history-filters label:has(input[type="checkbox"]) { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; font-weight: var(--w-text); }
|
|
394
|
+
.history-list { display: block; }
|
|
395
|
+
.history-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 20px; align-items: baseline; padding: 12px 0; font-size: var(--t-0); }
|
|
396
|
+
.history-row + .history-row, .history-row + .history-detail, .history-detail + .history-row { border-top: 1px solid var(--line); }
|
|
397
|
+
.history-title { display: block; width: 100%; text-align: left; background: none; border: 0; padding: 0; font: inherit; color: var(--text); cursor: pointer; line-height: 1.4; }
|
|
398
|
+
.history-title:hover { color: var(--accent-hi); }
|
|
399
|
+
.history-title:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: var(--r-control); }
|
|
400
|
+
.history-register { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; font-size: var(--t--1); color: var(--text-3); }
|
|
401
|
+
.history-when { color: var(--text-3); white-space: nowrap; font-size: var(--t--1); }
|
|
402
|
+
.history-detail { padding: 14px 0 20px; }
|
|
403
|
+
.history-detail .kv { margin-bottom: 14px; }
|
|
404
|
+
.history-command { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; font-size: var(--t-0); }
|
|
405
|
+
.history-empty { font-size: var(--t-0); color: var(--text-3); padding: 12px 0; }
|
|
406
|
+
|
|
387
407
|
.group-head { display: flex; align-items: baseline; gap: 10px; margin-top: 24px; margin-bottom: 4px; font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
|
|
388
408
|
.group-head:first-child { margin-top: 0; }
|
|
389
409
|
.group-head span { font-weight: var(--w-text); color: var(--text-3); }
|
|
@@ -556,6 +576,8 @@ dialog::backdrop { background: rgba(0,0,0,.6); }
|
|
|
556
576
|
.kv { grid-template-columns: 1fr; gap: 4px 0; }
|
|
557
577
|
.kv-val + .kv-key { margin-top: 12px; }
|
|
558
578
|
.ledger { grid-template-columns: 1fr; gap: 32px; margin-top: 40px; }
|
|
579
|
+
.history-row { grid-template-columns: 1fr; }
|
|
580
|
+
.history-filters { grid-template-columns: 1fr; }
|
|
559
581
|
.line { grid-template-columns: 1fr auto; gap: 6px 14px; }
|
|
560
582
|
.line-main { grid-column: 1 / -1; }
|
|
561
583
|
.line-meta { grid-column: 1; }
|
package/src/board/board.js
CHANGED
|
@@ -253,10 +253,10 @@
|
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
function formatActor(actor) {
|
|
256
|
-
if (!actor) return '
|
|
256
|
+
if (!actor) return 'leg'
|
|
257
257
|
if (actor.type === 'human') return `human:${actor.id || 'local'}`
|
|
258
258
|
if (actor.type === 'agent') return `agent:${actor.adapter || '?'}`
|
|
259
|
-
return '
|
|
259
|
+
return 'leg'
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
function baseAdapterName(name) { return String(name || '').replace(/^fake-/, '') }
|
|
@@ -292,6 +292,15 @@
|
|
|
292
292
|
: `${s === 'reconnecting' ? 'Reconnecting' : 'Connecting'} to Leg on ${state.bind}.`
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
// sessions.js owns the terminals region and listens for `leg:sessions`.
|
|
296
|
+
// `baton:sessions` is the old name, still emitted for anything outside this
|
|
297
|
+
// page that listens for it; nothing in the board may listen for both, because
|
|
298
|
+
// each listener rebuilds the whole grid.
|
|
299
|
+
function publishSessions(detail) {
|
|
300
|
+
window.dispatchEvent(new CustomEvent('leg:sessions', { detail }))
|
|
301
|
+
window.dispatchEvent(new CustomEvent('baton:sessions', { detail }))
|
|
302
|
+
}
|
|
303
|
+
|
|
295
304
|
function connectSse() {
|
|
296
305
|
if (state.es) { try { state.es.close() } catch { /* ignore */ } }
|
|
297
306
|
const request = ++state.sseRequest
|
|
@@ -312,9 +321,12 @@
|
|
|
312
321
|
// nothing between the drop and this hello was replayed: an open detail
|
|
313
322
|
// region is as old as the gap
|
|
314
323
|
scheduleDrawerRefresh()
|
|
315
|
-
if (data.sessions)
|
|
324
|
+
if (data.sessions) publishSessions(data.sessions)
|
|
316
325
|
})
|
|
317
|
-
|
|
326
|
+
// one parse, one publish, and both inside the staleness guard: the missing
|
|
327
|
+
// braces meant a superseded EventSource still drove a full rebuild, and the
|
|
328
|
+
// payload (a quarter of a megabyte) was parsed twice to do it
|
|
329
|
+
es.addEventListener('sessions', (e) => { if (state.es === es && request === state.sseRequest) publishSessions(JSON.parse(e.data)) })
|
|
318
330
|
es.addEventListener('card', (e) => { if (state.es === es && request === state.sseRequest) upsertCard(JSON.parse(e.data)) })
|
|
319
331
|
es.addEventListener('removed', (e) => { if (state.es === es && request === state.sseRequest) dropCard(JSON.parse(e.data).card_id) })
|
|
320
332
|
es.addEventListener('event', (e) => { if (state.es === es && request === state.sseRequest) onLedgerEvent(JSON.parse(e.data)) })
|
|
@@ -1042,7 +1054,7 @@
|
|
|
1042
1054
|
for (const a of adapters) adapterSelect.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
|
|
1043
1055
|
// Add fallback agent used to default to the first option in the list, which
|
|
1044
1056
|
// is normally the agent already chosen as First agent: the summary line then
|
|
1045
|
-
// read "
|
|
1057
|
+
// read "Leg tries claude, then agy, then claude", a fallback that cannot
|
|
1046
1058
|
// fire. rebuildDefaultFallbacks already applies this filter.
|
|
1047
1059
|
if (!preferred && !first) {
|
|
1048
1060
|
const used = chosenAdapters(ui)
|