@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/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
|
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
15
15
|
import { sanitizeEnv } from './env.mjs'
|
|
16
16
|
import { home } from './store.mjs'
|
|
17
17
|
import { get as getAdapter } from './adapters/index.mjs'
|
|
18
|
-
import {
|
|
18
|
+
import { SUPERVISED_AGENTS, HANDOFF_ORDER_CAPABILITY, newSessionId, createSession, readSession, updateSession, appendEvent, takeControl, sessionDir, listSessions, reapLost, isActive, workRoot } from './sessions.mjs'
|
|
19
19
|
import { ensure as ensureWorktree, remove as removeWorktree } from './worktree.mjs'
|
|
20
20
|
import { canonPath, realPath } from './fsx.mjs'
|
|
21
21
|
import { whoami, readShare, isOn as shareIsOn } from './share.mjs'
|
|
@@ -26,17 +26,28 @@ import { writeSettings, userStatusLine, transcriptTail as claudeTail } from './t
|
|
|
26
26
|
import { ensureTrust, trustLine } from './trust.mjs'
|
|
27
27
|
import { findRollout, createTail, parseLines, readCodexUsage, transcriptTail as codexTail } from './taps/codex.mjs'
|
|
28
28
|
import { scanLog, promptsSince, logSize } from './taps/agy.mjs'
|
|
29
|
+
import { fetchGrokUsage, scanLog as scanGrokLog, promptsSince as grokPromptsSince } from './taps/grok.mjs'
|
|
29
30
|
import { fetchClaudeUsage } from './taps/claude-usage.mjs'
|
|
30
|
-
import { saveSessionBundle, resumePrompt } from './bundle.mjs'
|
|
31
|
+
import { saveSessionBundle, resumePrompt, sessionCommitDelta } from './bundle.mjs'
|
|
31
32
|
import { endSessionPointer } from './resume.mjs'
|
|
32
33
|
import { openBoard, pidfile } from './launcher.mjs'
|
|
33
34
|
import { LAYOUT } from './accounts.mjs'
|
|
34
35
|
import { captureLive } from './live-capture.mjs'
|
|
35
36
|
import { waitForReset, fmtCountdown } from './wait.mjs'
|
|
36
|
-
import { readPreferences, normalizeHandoffOrder } from './preferences.mjs'
|
|
37
|
+
import { readPreferences, normalizeHandoffOrder, resolveAutoApprove } from './preferences.mjs'
|
|
38
|
+
import { prepareHarnessForHandoff, harnessLine } from './harness/index.mjs'
|
|
37
39
|
|
|
38
40
|
const SRC = dirname(fileURLToPath(import.meta.url))
|
|
39
|
-
|
|
41
|
+
function resolveServer() {
|
|
42
|
+
const wtMatch = /[\\/]\.(?:leg|baton)-worktrees(?:[\\/].*)?$/.exec(SRC)
|
|
43
|
+
if (wtMatch) {
|
|
44
|
+
const root = SRC.slice(0, wtMatch.index)
|
|
45
|
+
const mainServer = join(root, 'src', 'server.mjs')
|
|
46
|
+
if (existsSync(mainServer)) return mainServer
|
|
47
|
+
}
|
|
48
|
+
return join(SRC, 'server.mjs')
|
|
49
|
+
}
|
|
50
|
+
const SERVER = resolveServer()
|
|
40
51
|
const POLL_MS = Number(process.env.LEG_ATTACH_POLL_MS || process.env.BATON_ATTACH_POLL_MS || 2000)
|
|
41
52
|
const GIT_EVERY = 3 // polls
|
|
42
53
|
const USAGE_MS = Number(process.env.LEG_USAGE_POLL_MS || process.env.BATON_USAGE_POLL_MS || 60000)
|
|
@@ -56,7 +67,7 @@ export function isCurrentLeg(session, { pid, agent, account }) {
|
|
|
56
67
|
// ---- board ----
|
|
57
68
|
function health(port, host = '127.0.0.1') {
|
|
58
69
|
return new Promise((res) => {
|
|
59
|
-
const req = http.get({ host, port, path: '/api/health', timeout:
|
|
70
|
+
const req = http.get({ host, port, path: '/api/health', timeout: 4000 }, (r) => {
|
|
60
71
|
let d = ''
|
|
61
72
|
r.on('data', (c) => { d += c })
|
|
62
73
|
r.on('end', () => {
|
|
@@ -80,15 +91,16 @@ export async function ensureBoard({ open = true } = {}) {
|
|
|
80
91
|
if (await health(port, host)) return { url, started: false }
|
|
81
92
|
mkdirSync(home(), { recursive: true })
|
|
82
93
|
const logFd = (await import('node:fs')).openSync(join(home(), 'board.log'), 'a')
|
|
83
|
-
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' } })
|
|
94
|
+
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' } })
|
|
84
95
|
child.unref()
|
|
85
96
|
const t0 = Date.now()
|
|
86
97
|
while (Date.now() - t0 < 15000) {
|
|
87
|
-
|
|
98
|
+
const h = await health(port, host)
|
|
99
|
+
if (h) {
|
|
88
100
|
// only claim the pidfile for a child we actually started: under a race,
|
|
89
|
-
// another `
|
|
90
|
-
// dead pid would make `
|
|
91
|
-
const ours = child.exitCode === null && Boolean(child.pid)
|
|
101
|
+
// another `leg` won the port and ours died on EADDRINUSE — writing our
|
|
102
|
+
// dead pid would make `leg down` kill nothing and report "not running"
|
|
103
|
+
const ours = h.pid ? h.pid === child.pid : (child.exitCode === null && Boolean(child.pid))
|
|
92
104
|
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')
|
|
93
105
|
if (open) openBoard(url)
|
|
94
106
|
return { url, started: ours }
|
|
@@ -138,13 +150,18 @@ export function isolate({ g, cwd, sid, sessions = reapLost(listSessions()) }) {
|
|
|
138
150
|
// a binary that is not installed (that spawned ENOENT and killed the session
|
|
139
151
|
// with exit 127 instead of waiting for a reset). Resolves each adapter the way
|
|
140
152
|
// the runner would spawn it (native exe, npm entry, or BATON_<AGENT>_BIN).
|
|
153
|
+
async function loadAdapter(name) {
|
|
154
|
+
if (name === 'grok') return (await import('./adapters/grok.mjs')).default
|
|
155
|
+
return getAdapter(name)
|
|
156
|
+
}
|
|
157
|
+
|
|
141
158
|
let installedCache = null
|
|
142
159
|
async function installedAgents() {
|
|
143
160
|
if (installedCache) return installedCache
|
|
144
161
|
const out = {}
|
|
145
|
-
for (const name of
|
|
162
|
+
for (const name of SUPERVISED_AGENTS) {
|
|
146
163
|
try {
|
|
147
|
-
const { bin, viaNode, entry } = (await
|
|
164
|
+
const { bin, viaNode, entry } = (await loadAdapter(name)).resolve()
|
|
148
165
|
const target = viaNode ? (entry ?? bin) : bin
|
|
149
166
|
if (/[\\/]/.test(target)) out[name] = existsSync(target)
|
|
150
167
|
else { const r = spawnSync(target, ['--version'], { windowsHide: true, encoding: 'utf8', timeout: 8000 }); out[name] = !r.error && r.status === 0 }
|
|
@@ -182,26 +199,36 @@ function restoreTerminal() {
|
|
|
182
199
|
}
|
|
183
200
|
|
|
184
201
|
// ---- spawn spec per agent ----
|
|
185
|
-
export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd }) {
|
|
186
|
-
const adapter = await
|
|
202
|
+
export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd, autoApprove = resolveAutoApprove() }) {
|
|
203
|
+
const adapter = await loadAdapter(agent)
|
|
187
204
|
const { bin, viaNode, entry } = adapter.resolve()
|
|
188
205
|
const argv = []
|
|
189
206
|
// viaNode: either an npm entry (codex bin/codex.js) or a BATON_<AGENT>_BIN that names a .mjs (tests)
|
|
190
207
|
if (viaNode) argv.push(entry ?? bin)
|
|
191
|
-
// a leg
|
|
208
|
+
// a leg Leg starts on its own (after a hand-off) takes BATON_<AGENT>_ARGS,
|
|
192
209
|
// e.g. BATON_CODEX_ARGS="-m gpt-5-mini" to keep a test chain on cheap models
|
|
193
|
-
if (prompt) args = [...(process.env[`BATON_${agent.toUpperCase()}_ARGS`] ?? '').split(/\s+/).filter(Boolean), ...args]
|
|
210
|
+
if (prompt) args = [...(process.env[`LEG_${agent.toUpperCase()}_ARGS`] ?? process.env[`BATON_${agent.toUpperCase()}_ARGS`] ?? '').split(/\s+/).filter(Boolean), ...args]
|
|
194
211
|
if (agent === 'claude') {
|
|
195
212
|
const settings = writeSettings(sessionId, { statusLine: userStatusLine(process.env.CLAUDE_CONFIG_DIR || (account !== 'default' ? envFor('claude', account).CLAUDE_CONFIG_DIR : undefined)) })
|
|
196
|
-
|
|
213
|
+
const autoFlags = autoApprove && !args.includes('--dangerously-skip-permissions') ? ['--dangerously-skip-permissions'] : [] // auto-approve: not forbidden for interactive sessions
|
|
214
|
+
argv.push(...args, ...autoFlags, '--settings', settings)
|
|
197
215
|
if (prompt) argv.push(prompt)
|
|
198
216
|
} else if (agent === 'codex') {
|
|
199
|
-
|
|
217
|
+
const hasApproval = args.includes('--ask-for-approval') || args.includes('-a') || args.some((x) => typeof x === 'string' && x.startsWith('--ask-for-approval='))
|
|
218
|
+
const autoFlags = autoApprove && !hasApproval ? ['--ask-for-approval', 'never'] : []
|
|
219
|
+
argv.push(...args, ...autoFlags)
|
|
200
220
|
if (prompt) argv.push(prompt)
|
|
201
221
|
} else if (agent === 'agy') {
|
|
202
222
|
const log = join(sessionDir(sessionId), 'agy.log')
|
|
203
|
-
|
|
223
|
+
const autoFlags = autoApprove && !args.includes('--dangerously-skip-permissions') ? ['--dangerously-skip-permissions'] : [] // auto-approve: not forbidden for interactive sessions
|
|
224
|
+
argv.push(...args, ...autoFlags, '--log-file', log)
|
|
204
225
|
if (prompt) argv.push('-i', prompt)
|
|
226
|
+
} else if (agent === 'grok') {
|
|
227
|
+
const log = join(sessionDir(sessionId), 'grok.log')
|
|
228
|
+
const hasApprove = args.includes('--always-approve') || args.includes('--yolo') || args.includes('--approval-mode=yolo') // auto-approve check: not forbidden for interactive sessions
|
|
229
|
+
const autoFlags = autoApprove && !hasApprove ? ['--always-approve'] : [] // auto-approve: not forbidden for interactive sessions
|
|
230
|
+
argv.push(...args, ...autoFlags, '--debug-file', log)
|
|
231
|
+
if (prompt) argv.push(prompt)
|
|
205
232
|
}
|
|
206
233
|
const env = { ...sanitizeEnv(process.env, { interactive: true }), ...envFor(agent, account), LEG_SESSION: sessionId, BATON_SESSION: sessionId }
|
|
207
234
|
return { bin: viaNode ? process.execPath : bin, args: argv, env, cwd }
|
|
@@ -209,7 +236,7 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd }
|
|
|
209
236
|
|
|
210
237
|
// ---- one agent leg ----
|
|
211
238
|
// Returns { reason: 'exit'|'limit'|'handoff', code }
|
|
212
|
-
async function runLeg({ agent, account, args, session, prompt, boardUrl }) {
|
|
239
|
+
async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApprove = resolveAutoApprove() }) {
|
|
213
240
|
const sid = session.session_id
|
|
214
241
|
refreshAccount(agent, account)
|
|
215
242
|
// A handoff happens when the limit hits, which is usually when nobody is
|
|
@@ -219,7 +246,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl }) {
|
|
|
219
246
|
const trust = ensureTrust(agent, session.cwd, { cwd: session.cwd })
|
|
220
247
|
const trusted = trustLine(trust)
|
|
221
248
|
if (trusted) { say(trusted); appendEvent(sid, { type: 'trust', summary: trusted }) }
|
|
222
|
-
const spec = await spawnSpec(agent, { account, args, sessionId: sid, prompt, cwd: session.cwd })
|
|
249
|
+
const spec = await spawnSpec(agent, { account, args, sessionId: sid, prompt, cwd: session.cwd, autoApprove })
|
|
223
250
|
appendEvent(sid, { type: 'leg', summary: `${agent} (${account}) starting${prompt ? ' from the handoff bundle' : ''}` })
|
|
224
251
|
const startedMs = Date.now()
|
|
225
252
|
const turnsAtLegStart = session.turns ?? 0
|
|
@@ -227,6 +254,8 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl }) {
|
|
|
227
254
|
// the end of what the first one wrote, or it walls itself on that leg's line
|
|
228
255
|
const agyLog = agent === 'agy' ? join(sessionDir(sid), 'agy.log') : null
|
|
229
256
|
const agyTail = agyLog ? createTail(agyLog, { from: logSize(agyLog) }) : null
|
|
257
|
+
const grokLog = agent === 'grok' ? join(sessionDir(sid), 'grok.log') : null
|
|
258
|
+
const grokTail = grokLog ? createTail(grokLog, { from: logSize(grokLog) }) : null
|
|
230
259
|
let child
|
|
231
260
|
try {
|
|
232
261
|
child = spawn(spec.bin, spec.args, { cwd: spec.cwd, env: spec.env, stdio: 'inherit', windowsHide: false })
|
|
@@ -289,6 +318,24 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl }) {
|
|
|
289
318
|
pollUsage().catch(() => {})
|
|
290
319
|
usageTimer = setInterval(() => pollUsage().catch(() => {}), USAGE_MS)
|
|
291
320
|
usageTimer.unref?.()
|
|
321
|
+
} else if (agent === 'grok') {
|
|
322
|
+
const pollUsage = async () => {
|
|
323
|
+
const configDir = spec.env.GROK_HOME || LAYOUT.grok.home()
|
|
324
|
+
const r = await fetchGrokUsage({ configDir })
|
|
325
|
+
const s = readSession(sid)
|
|
326
|
+
if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
|
|
327
|
+
const usable = r.ok && r.limits && (r.limits.five_hour || r.limits.seven_day)
|
|
328
|
+
if (usable) {
|
|
329
|
+
recordUsage('grok', account, r.limits, 'grok billing proxy')
|
|
330
|
+
updateSession(sid, { limits: r.limits, usage_source: 'grok billing proxy', usage_error: null })
|
|
331
|
+
} else if (!s.usage_error) {
|
|
332
|
+
const why = r.error ?? 'the usage endpoint answered with no window'
|
|
333
|
+
updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `grok usage unavailable: ${why}` } })
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
pollUsage().catch(() => {})
|
|
337
|
+
usageTimer = setInterval(() => pollUsage().catch(() => {}), USAGE_MS)
|
|
338
|
+
usageTimer.unref?.()
|
|
292
339
|
}
|
|
293
340
|
|
|
294
341
|
const timer = setInterval(() => {
|
|
@@ -351,6 +398,25 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl }) {
|
|
|
351
398
|
if (s.status === 'starting') patch.status = 'running'
|
|
352
399
|
}
|
|
353
400
|
}
|
|
401
|
+
// grok: log text + prompts
|
|
402
|
+
if (agent === 'grok') {
|
|
403
|
+
const text = grokTail ? grokTail.read().join('\n') : ''
|
|
404
|
+
const hit = text ? scanGrokLog(text) : null
|
|
405
|
+
if (hit && s.status !== 'limit') {
|
|
406
|
+
const u = markLimited('grok', account, { resets_at: hit.resets_at, reason: hit.signal, source: 'grok log' })
|
|
407
|
+
patch.status = 'limit'; patch.limit = { reason: hit.signal, detail: hit.detail, resets_at: hit.resets_at ?? u.limited_until, at: new Date().toISOString() }
|
|
408
|
+
appendEvent(sid, { type: 'limit', summary: `grok limit (${hit.signal}): ${hit.detail.slice(0, 160)}` })
|
|
409
|
+
try { captureLive('grok', hit.signal, { log_excerpt: hit.detail, resets_at: hit.resets_at }, { sessionId: sid }) } catch {}
|
|
410
|
+
}
|
|
411
|
+
const prompts = grokPromptsSince({ grokHome: spec.env.GROK_HOME || LAYOUT.grok.home(), cwd: s.cwd, sinceMs: startedMs })
|
|
412
|
+
const turns = turnsAtLegStart + prompts.length
|
|
413
|
+
if (prompts.length && turns !== (s.turns ?? 0)) {
|
|
414
|
+
patch.turns = turns; patch.last_activity = new Date().toISOString()
|
|
415
|
+
if (!s.task) patch.task = prompts[0].text.slice(0, 500)
|
|
416
|
+
if (!s.agent_session_id && prompts[0].sessionId) patch.agent_session_id = prompts[0].sessionId
|
|
417
|
+
if (s.status === 'starting') patch.status = 'running'
|
|
418
|
+
}
|
|
419
|
+
}
|
|
354
420
|
// warning threshold (every agent; claude's limits arrive via the usage poller)
|
|
355
421
|
{
|
|
356
422
|
const lim = patch.limits ?? s.limits
|
|
@@ -428,14 +494,14 @@ function messagesFor(agent, s) {
|
|
|
428
494
|
// an order save is consumed here, or the editor sees handing_off and refuses.
|
|
429
495
|
// No eligible choice leaves the session unclaimed so all-out waiting can keep
|
|
430
496
|
// accepting order edits.
|
|
431
|
-
export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000) }) {
|
|
497
|
+
export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000), exclude = [] }) {
|
|
432
498
|
let choice = { next: null, out: [] }
|
|
433
499
|
let claimed = false
|
|
434
500
|
const session = updateSession(sid, (current) => {
|
|
435
501
|
const accounts = readAccounts()
|
|
436
502
|
const order = normalizeHandoffOrder(current.handoff_order)
|
|
437
|
-
choice = chooseNext({ agent, account, accounts, installed, order, nowS })
|
|
438
|
-
if (!choice.next && isAvailable(readUsage(agent, account), nowS)) choice = { next: { agent, account }, out: [] }
|
|
503
|
+
choice = chooseNext({ agent, account, accounts, installed, order, nowS, exclude })
|
|
504
|
+
if (!choice.next && isAvailable(readUsage(agent, account), nowS) && !exclude.some((x) => x.agent === agent && x.account === account)) choice = { next: { agent, account }, out: [] }
|
|
439
505
|
if (!choice.next) return {}
|
|
440
506
|
claimed = true
|
|
441
507
|
return {
|
|
@@ -454,23 +520,47 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
|
|
|
454
520
|
return { choice, claimed, session }
|
|
455
521
|
}
|
|
456
522
|
|
|
523
|
+
// The portable harness, decided before a leg starts (src/harness/index.mjs).
|
|
524
|
+
// Off by default: then this records nothing and changes nothing. On, it
|
|
525
|
+
// carries the source agent's working environment to the agent about to run,
|
|
526
|
+
// per the saved policy, and the session keeps the outcome so the board can say
|
|
527
|
+
// what transferred and what did not. Never throws into the session.
|
|
528
|
+
function prepareLegHarness({ sid, from, to }) {
|
|
529
|
+
let outcome
|
|
530
|
+
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)}` } }
|
|
531
|
+
if (outcome.state === 'off') return outcome
|
|
532
|
+
updateSession(sid, { harness: outcome })
|
|
533
|
+
const line = harnessLine(outcome)
|
|
534
|
+
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 }) }
|
|
535
|
+
return outcome
|
|
536
|
+
}
|
|
537
|
+
|
|
457
538
|
// ---- the command ----
|
|
458
539
|
export async function attach(agent, args = [], { open = true } = {}) {
|
|
459
|
-
if (!
|
|
540
|
+
if (!SUPERVISED_AGENTS.includes(agent)) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
|
|
460
541
|
// the paid gate: a valid key, or no session (exit 4). The bare agent is never
|
|
461
|
-
// affected; only what
|
|
542
|
+
// affected; only what Leg adds is licensed.
|
|
462
543
|
const ent = entitlement()
|
|
463
544
|
if (!allows(ent, 'run')) { say(describeLicense(ent)); return 4 }
|
|
464
|
-
// --no-worktree is
|
|
545
|
+
// --no-worktree is Leg's flag, not the agent's: it never passes through
|
|
465
546
|
const shareCheckout = args.includes('--no-worktree')
|
|
466
547
|
args = args.filter((a) => a !== '--no-worktree')
|
|
548
|
+
let autoApproveCli = null
|
|
549
|
+
if (args.includes('--no-auto-approve')) {
|
|
550
|
+
autoApproveCli = false
|
|
551
|
+
args = args.filter((a) => a !== '--no-auto-approve')
|
|
552
|
+
} else if (args.includes('--auto-approve')) {
|
|
553
|
+
autoApproveCli = true
|
|
554
|
+
args = args.filter((a) => a !== '--auto-approve')
|
|
555
|
+
}
|
|
556
|
+
const autoApprove = resolveAutoApprove({ cliFlag: autoApproveCli })
|
|
467
557
|
const cwd = process.cwd()
|
|
468
558
|
const board = await ensureBoard({ open })
|
|
469
559
|
let accounts = readAccounts()
|
|
470
560
|
const installed = await installedAgents()
|
|
471
561
|
const handoffOrder = readPreferences().handoff_order
|
|
472
562
|
let account = process.env.LEG_ACCOUNT || process.env.BATON_ACCOUNT || 'default'
|
|
473
|
-
if (!accounts[agent].includes(account)) { say(`no ${agent} account "${account}"; using default`); account = 'default' }
|
|
563
|
+
if (!(accounts[agent] ?? ['default']).includes(account)) { say(`no ${agent} account "${account}"; using default`); account = 'default' }
|
|
474
564
|
// A persisted wall is only a cache. Ask Codex's read-only account endpoint
|
|
475
565
|
// before using it to skip this login; an explicit true can clear an older
|
|
476
566
|
// wall, false refreshes it, and unknown preserves it.
|
|
@@ -478,6 +568,10 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
478
568
|
const codexHome = envFor('codex', account).CODEX_HOME || LAYOUT.codex.home()
|
|
479
569
|
await refreshCodexUsage(account, codexHome).catch(() => {})
|
|
480
570
|
}
|
|
571
|
+
if (agent === 'grok' && installed.grok && !process.env.BATON_GROK_BIN) {
|
|
572
|
+
const grokHome = envFor('grok', account).GROK_HOME || LAYOUT.grok.home()
|
|
573
|
+
await fetchGrokUsage({ configDir: grokHome }).catch(() => {})
|
|
574
|
+
}
|
|
481
575
|
// Start on an account that is not at its wall, if we already know one is.
|
|
482
576
|
const nowS = Math.floor(Date.now() / 1000)
|
|
483
577
|
const u0 = readUsage(agent, account)
|
|
@@ -509,11 +603,14 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
509
603
|
let prompt = null
|
|
510
604
|
let legArgs = args
|
|
511
605
|
let exit = 0
|
|
606
|
+
// the first leg is the agent the human chose: its harness is prepared per
|
|
607
|
+
// policy and recorded, never refused (strict applies to hand-offs)
|
|
608
|
+
prepareLegHarness({ sid, from: null, to: agent })
|
|
512
609
|
// unbounded: the 12-leg cap below stops a runaway chain, and the all-out wait
|
|
513
610
|
// bounds a wait; a normal session runs one leg and exits
|
|
514
611
|
for (let leg = 0; ; leg++) {
|
|
515
612
|
const s = readSession(sid)
|
|
516
|
-
const r = await runLeg({ agent, account, args: legArgs, session: s, prompt, boardUrl: board.url })
|
|
613
|
+
const r = await runLeg({ agent, account, args: legArgs, session: s, prompt, boardUrl: board.url, autoApprove })
|
|
517
614
|
if (r.reason === 'exit') { exit = r.code ?? 0; break }
|
|
518
615
|
// limit or handoff: bundle, choose next, go again in this terminal
|
|
519
616
|
const cur = readSession(sid)
|
|
@@ -524,10 +621,14 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
524
621
|
// saveSessionBundle writes the notes file before it shells out to chb, so
|
|
525
622
|
// even when chb is missing and the save throws, the context is on disk
|
|
526
623
|
const notesFile = join(workRoot(cur) ?? cur.cwd, '.leg', `session-${sid}.md`)
|
|
527
|
-
|
|
624
|
+
// destinations the strict harness policy refused during this hand-off
|
|
625
|
+
const excluded = []
|
|
626
|
+
let claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
528
627
|
let choice = claim.choice
|
|
529
628
|
let cancelled = false
|
|
629
|
+
let blocked = false
|
|
530
630
|
while (!choice.next) {
|
|
631
|
+
if (excluded.length && !choice.out.length) { blocked = true; break }
|
|
531
632
|
// every option is out: keep the terminal, count down to the SOONEST reset
|
|
532
633
|
// (the current agent's own wall included — it may be the first back), then
|
|
533
634
|
// start that option from the bundle. Ctrl-C (or End) quits with exit 3.
|
|
@@ -543,7 +644,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
543
644
|
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'}` } })
|
|
544
645
|
const r2 = await waitInTerminal({ sid, label, resetsAt: first?.resets_at ?? null })
|
|
545
646
|
if (r2 === 'cancelled') { cancelled = true; break }
|
|
546
|
-
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason })
|
|
647
|
+
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
547
648
|
choice = claim.choice
|
|
548
649
|
}
|
|
549
650
|
if (cancelled) {
|
|
@@ -551,6 +652,24 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
551
652
|
exit = 3
|
|
552
653
|
break
|
|
553
654
|
}
|
|
655
|
+
// strict harness policy: a destination whose harness cannot be made safe is
|
|
656
|
+
// refused and the next option is tried; when none is left the session ends
|
|
657
|
+
// with exit 5 rather than launching an agent without its environment
|
|
658
|
+
while (!blocked && choice.next) {
|
|
659
|
+
const prepared = prepareLegHarness({ sid, from: agent, to: choice.next.agent })
|
|
660
|
+
if (prepared.proceed) break
|
|
661
|
+
excluded.push(choice.next)
|
|
662
|
+
say(`${choice.next.agent} refused by the strict harness policy: ${prepared.reason ?? prepared.state}`)
|
|
663
|
+
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
664
|
+
choice = claim.choice
|
|
665
|
+
if (!choice.next) blocked = true
|
|
666
|
+
}
|
|
667
|
+
if (blocked) {
|
|
668
|
+
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.')
|
|
669
|
+
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)' } })
|
|
670
|
+
exit = 5
|
|
671
|
+
break
|
|
672
|
+
}
|
|
554
673
|
const next = choice.next
|
|
555
674
|
// bound the number of hand-offs in one terminal so a chain that limits
|
|
556
675
|
// instantly can never loop forever; stopping is explicit, not a silent exit 0
|
|
@@ -561,9 +680,15 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
561
680
|
break
|
|
562
681
|
}
|
|
563
682
|
appendEvent(sid, { type: 'handoff', summary: `${agent}${account !== 'default' ? '/' + account : ''} → ${next.agent}${next.account !== 'default' ? '/' + next.account : ''}${bundle ? ` (bundle ${bundle.id})` : ''}` })
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
683
|
+
if (bundle) {
|
|
684
|
+
prompt = resumePrompt(cur, bundle, next)
|
|
685
|
+
} else {
|
|
686
|
+
const delta = sessionCommitDelta(workRoot(cur) ?? cur.cwd, cur)
|
|
687
|
+
const fallbackAction = delta.isClean && delta.newCommits.length > 0
|
|
688
|
+
? `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.`
|
|
689
|
+
: 'Check git status and git diff, then continue the work.'
|
|
690
|
+
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'}`
|
|
691
|
+
}
|
|
567
692
|
say(`starting ${next.agent}${next.account !== 'default' ? '/' + next.account : ''} in this terminal from the bundle`)
|
|
568
693
|
agent = next.agent; account = next.account; legArgs = []
|
|
569
694
|
// the chain is what comes after the agent now taking over, not after the
|
|
@@ -577,7 +702,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
577
702
|
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})` } })
|
|
578
703
|
// Every way out of the loop arrives here: a clean exit, a cancelled wait, the
|
|
579
704
|
// 12-leg cap, an agent that never started. The terminal is gone, so RESUME.md
|
|
580
|
-
// must stop describing it as live —
|
|
705
|
+
// must stop describing it as live — Leg owns that file, and leaving the last
|
|
581
706
|
// hand-off sitting there is exactly the lie this rewrite exists to stop.
|
|
582
707
|
try { endSessionPointer(readSession(sid)) } catch (err) { appendEvent(sid, { type: 'error', summary: `resume pointer not rewritten: ${err.message.slice(0, 160)}` }) }
|
|
583
708
|
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
|
@@ -129,6 +129,7 @@
|
|
|
129
129
|
--id-claude: #FCA169; /* oklch(0.790 0.130 52) warm */
|
|
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
|
+
--id-grok: #70B8FF; /* oklch(0.750 0.130 240) azure blue */
|
|
132
133
|
--id-fake: #A0A6AE; /* oklch(0.720 0.010 258) the scripted adapter, neutral in hue */
|
|
133
134
|
|
|
134
135
|
/* type scale. Fixed rem, range 13 to 52. The old board ran 13 to 21, which
|
|
@@ -188,10 +189,10 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
188
189
|
|
|
189
190
|
.banner { background: var(--e1); border-bottom: 1px solid var(--edge); color: var(--text-2); font-size: var(--t-0); padding: 10px clamp(20px, 4vw, 40px); padding-top: calc(10px + env(safe-area-inset-top, 0px)); }
|
|
190
191
|
|
|
191
|
-
.masthead { display: flex; align-items:
|
|
192
|
+
.masthead { position: relative; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding-block: 28px 24px; }
|
|
192
193
|
.brand { font-size: var(--t-1); font-weight: var(--w-head); letter-spacing: -.01em; }
|
|
193
194
|
.brand-glyph { display: inline-block; margin-right: 4px; }
|
|
194
|
-
.masthead-right { display: flex; align-items:
|
|
195
|
+
.masthead-right { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; font-size: var(--t-0); color: var(--text-3); }
|
|
195
196
|
.floor-link { color: var(--text-2); text-decoration: none; border-bottom: 1px solid var(--line-strong); padding-bottom: 1px; }
|
|
196
197
|
.floor-link:hover { color: var(--text); border-bottom-color: var(--text-2); }
|
|
197
198
|
|
|
@@ -220,6 +221,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
220
221
|
.dot.id-claude { background: var(--id-claude); }
|
|
221
222
|
.dot.id-codex { background: var(--id-codex); }
|
|
222
223
|
.dot.id-agy { background: var(--id-agy); }
|
|
224
|
+
.dot.id-grok { background: var(--id-grok); }
|
|
223
225
|
|
|
224
226
|
/* ---- logins ------------------------------------------------------------- */
|
|
225
227
|
.logins { display: grid; gap: 20px; }
|
|
@@ -304,6 +306,14 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
304
306
|
.file.is-overlap { color: var(--warn-text); }
|
|
305
307
|
.blocker { margin-top: 10px; font-size: var(--t-0); color: var(--text-3); max-width: 64ch; }
|
|
306
308
|
.blocker.is-hoisted { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
309
|
+
.why-cant-land { margin-top: 10px; font-size: var(--t-0); color: var(--text-3); max-width: 64ch; }
|
|
310
|
+
.why-cant-land-summary { cursor: pointer; color: var(--text-2); font-weight: var(--w-head); user-select: none; }
|
|
311
|
+
.why-cant-land-summary:hover { color: var(--text-1); }
|
|
312
|
+
.blocker-list { list-style: none; margin: 8px 0 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
313
|
+
.blocker-item { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 10px; background: var(--surface-2); border-radius: var(--r-control); font-size: var(--t--1); }
|
|
314
|
+
.blocker-msg { color: var(--text-2); flex: 1 1 auto; }
|
|
315
|
+
.blocker-fixes { display: inline-flex; gap: 6px; }
|
|
316
|
+
.btn-sm { min-height: 26px; padding: 0 10px; font-size: var(--t--1); }
|
|
307
317
|
|
|
308
318
|
/* the flat inline data token. Not a capsule: a chip here is a word set in the
|
|
309
319
|
meta colour beside the thing it qualifies */
|
|
@@ -312,6 +322,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
312
322
|
.chip-id-claude { color: var(--id-claude); }
|
|
313
323
|
.chip-id-codex { color: var(--id-codex); }
|
|
314
324
|
.chip-id-agy { color: var(--id-agy); }
|
|
325
|
+
.chip-id-grok { color: var(--id-grok); }
|
|
315
326
|
.chip-id-fake { color: var(--id-fake); }
|
|
316
327
|
.chip-state-ok { color: var(--ok-text); }
|
|
317
328
|
.chip-state-warn { color: var(--warn-text); }
|
|
@@ -320,6 +331,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
320
331
|
.acct-name.id-claude { color: var(--id-claude); }
|
|
321
332
|
.acct-name.id-codex { color: var(--id-codex); }
|
|
322
333
|
.acct-name.id-agy { color: var(--id-agy); }
|
|
334
|
+
.acct-name.id-grok { color: var(--id-grok); }
|
|
323
335
|
.acct-name.id-fake { color: var(--id-fake); }
|
|
324
336
|
|
|
325
337
|
/* ---- controls ----------------------------------------------------------- */
|
|
@@ -345,14 +357,17 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
345
357
|
/* the four scroll boxes board-drawer.test.mjs scans for by line prefix. Each
|
|
346
358
|
class is the first token on its line, in this order, and no earlier line in
|
|
347
359
|
this file begins with any of the four */
|
|
348
|
-
.drawer-task { max-height: 22rem; overflow: auto; font-size: var(--t-0); color: var(--text-
|
|
349
|
-
.drawer-msg p { max-height: 18rem; overflow-y: auto; font-size: var(--t-0); color: var(--text
|
|
350
|
-
.drawer-timeline { max-height: 22rem; overflow: auto; font-size: var(--t-0); }
|
|
351
|
-
.drawer-diff { max-height: 26rem; overflow: auto; font-family: var(--mono); font-size: var(--t--1); }
|
|
352
|
-
|
|
353
|
-
.drawer-msg { margin-top: 14px; }
|
|
354
|
-
.drawer-msg-
|
|
355
|
-
.drawer-msg-
|
|
360
|
+
.drawer-task { max-height: 22rem; overflow: auto; font-size: var(--t-0); color: var(--text); background: var(--e0); border: 1px solid var(--edge); border-radius: var(--r-control); padding: 14px 18px; line-height: 1.55; }
|
|
361
|
+
.drawer-msg p { max-height: 18rem; overflow-y: auto; font-size: var(--t-0); color: var(--text); line-height: 1.55; margin: 0; }
|
|
362
|
+
.drawer-timeline { max-height: 22rem; overflow: auto; font-size: var(--t-0); background: var(--e0); border: 1px solid var(--edge); border-radius: var(--r-control); padding: 8px 16px; }
|
|
363
|
+
.drawer-diff { max-height: 26rem; overflow: auto; font-family: var(--mono); font-size: var(--t--1); background: var(--e0); border-top: 1px solid var(--line); margin-top: 10px; padding-top: 10px; }
|
|
364
|
+
|
|
365
|
+
.drawer-msg { margin-top: 12px; background: var(--e0); border: 1px solid var(--edge); border-radius: var(--r-control); padding: 14px 18px; }
|
|
366
|
+
.drawer-msg.is-human { border-left: 3px solid var(--accent); }
|
|
367
|
+
.drawer-msg.is-agent { border-left: 3px solid var(--ok-text); }
|
|
368
|
+
.drawer-msg-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
|
369
|
+
.drawer-msg-role { font-size: var(--t--1); color: var(--text-2); font-weight: var(--w-head); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
370
|
+
.drawer-msg-when { font-size: var(--t--1); color: var(--text-3); font-family: var(--mono); }
|
|
356
371
|
|
|
357
372
|
/* ---- ledger: on the ground, unpanelled --------------------------------- */
|
|
358
373
|
.ledger { margin-top: 56px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 40px; padding-top: 32px; border-top: 1px solid var(--edge); }
|
|
@@ -418,20 +433,32 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
418
433
|
.worktree { color: var(--text-3); }
|
|
419
434
|
|
|
420
435
|
/* ---- detail panes ------------------------------------------------------- */
|
|
421
|
-
.detail { margin-top: 22px; background: var(--e1); border-radius: var(--r-well); box-shadow: var(--sink); }
|
|
436
|
+
.detail { margin-top: 22px; background: var(--e1); border-radius: var(--r-well); box-shadow: var(--sink); border: 1px solid var(--edge); }
|
|
422
437
|
.detail-inner { padding: 24px; }
|
|
423
|
-
.detail-
|
|
424
|
-
.detail-
|
|
425
|
-
.detail-
|
|
438
|
+
.detail-masthead { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding-bottom: 18px; border-bottom: 1px solid var(--line); margin-bottom: 22px; }
|
|
439
|
+
.detail-brand { display: flex; align-items: center; gap: 10px; font-size: var(--t-1); }
|
|
440
|
+
.detail-ctrls { display: flex; align-items: center; gap: 10px; }
|
|
441
|
+
.detail-heading { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 12px; font-size: var(--t-1); font-weight: var(--w-head); margin-bottom: 14px; letter-spacing: -.01em; }
|
|
442
|
+
.detail-section { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--line); }
|
|
443
|
+
.detail-section:first-of-type { margin-top: 0; padding-top: 0; border-top: 0; }
|
|
444
|
+
.detail-sub { font-size: var(--t-0); color: var(--text-3); font-weight: var(--w-text); }
|
|
426
445
|
.kv { display: grid; grid-template-columns: 148px 1fr; gap: 10px 20px; font-size: var(--t-0); }
|
|
427
446
|
.kv-key { color: var(--text-3); }
|
|
428
447
|
.kv-val { color: var(--text); }
|
|
429
|
-
.
|
|
448
|
+
.detail .kv { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
|
|
449
|
+
.well { background: var(--e0); border-radius: var(--r-control); padding: 14px 18px; border: 1px solid var(--edge); }
|
|
430
450
|
.log-pre { font-family: var(--mono); font-size: var(--t--1); white-space: pre-wrap; color: var(--text-2); max-height: 26rem; overflow: auto; }
|
|
431
451
|
.hunk { font-family: var(--mono); font-size: var(--t--1); white-space: pre-wrap; }
|
|
432
|
-
.file-
|
|
452
|
+
.drawer-file-item { background: var(--e0); border: 1px solid var(--edge); border-radius: var(--r-control); padding: 10px 14px; margin-bottom: 8px; }
|
|
453
|
+
.drawer-file-item:hover { border-color: var(--line-strong); }
|
|
454
|
+
.file-row { display: flex; align-items: center; justify-content: space-between; width: 100%; gap: 10px; text-align: left; }
|
|
433
455
|
.turn { padding: 12px 0; }
|
|
434
456
|
.turn + .turn { border-top: 1px solid var(--line); }
|
|
457
|
+
.timeline-item { padding: 10px 0; display: grid; grid-template-columns: 80px 140px 1fr; gap: 12px; align-items: baseline; }
|
|
458
|
+
.timeline-item + .timeline-item { border-top: 1px solid var(--line); }
|
|
459
|
+
.timeline-item .turn-when { color: var(--text-3); font-size: var(--t--1); }
|
|
460
|
+
.timeline-item .turn-role { color: var(--accent); font-size: var(--t--1); font-weight: var(--w-head); }
|
|
461
|
+
.timeline-summary { color: var(--text-2); margin: 0; font-size: var(--t-0); }
|
|
435
462
|
.turn-role { font-size: var(--t--1); color: var(--text-3); font-weight: var(--w-head); }
|
|
436
463
|
.turn-when { font-size: var(--t--1); color: var(--text-3); }
|
|
437
464
|
/* the kind word sits beside the clock, not welded to it: `10:40 AMstarted` */
|
|
@@ -448,9 +475,10 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
448
475
|
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
449
476
|
|
|
450
477
|
/* ---- the floor: the scheduler's view, tables on the same ground --------- */
|
|
451
|
-
.masthead-left { display: flex; align-items:
|
|
478
|
+
.masthead-left { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
|
|
452
479
|
.repos-status, .counts-status { color: var(--text-3); }
|
|
453
480
|
.counts-status span { color: var(--text-2); font-weight: var(--w-head); margin-left: 2px; }
|
|
481
|
+
#floor-accounts { margin-top: 16px; }
|
|
454
482
|
.region-floor { margin-top: 12px; }
|
|
455
483
|
.ftable { width: 100%; border-collapse: collapse; background: var(--e2); border: 1px solid var(--edge); border-radius: var(--r-panel); box-shadow: var(--lift); overflow: hidden; font-size: var(--t-0); }
|
|
456
484
|
.ftable th, .ftable td { text-align: left; vertical-align: top; padding: 14px 18px; }
|
package/src/board/board.js
CHANGED
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
'read-only': 'read only',
|
|
57
57
|
plan: 'plan only',
|
|
58
58
|
}
|
|
59
|
-
const AGENT_IDS = ['claude', 'codex', 'agy']
|
|
59
|
+
const AGENT_IDS = ['claude', 'codex', 'agy', 'grok']
|
|
60
60
|
const DEFAULT_BIND = '127.0.0.1:4747'
|
|
61
61
|
const TIMELINE_CAP = 12
|
|
62
62
|
// mirrors LOOPBACK in src/auth.mjs; state.bind is "<host>:<port>" and an IPv6
|
|
@@ -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-/, '') }
|
|
@@ -1042,7 +1042,7 @@
|
|
|
1042
1042
|
for (const a of adapters) adapterSelect.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
|
|
1043
1043
|
// Add fallback agent used to default to the first option in the list, which
|
|
1044
1044
|
// is normally the agent already chosen as First agent: the summary line then
|
|
1045
|
-
// read "
|
|
1045
|
+
// read "Leg tries claude, then agy, then claude", a fallback that cannot
|
|
1046
1046
|
// fire. rebuildDefaultFallbacks already applies this filter.
|
|
1047
1047
|
if (!preferred && !first) {
|
|
1048
1048
|
const used = chosenAdapters(ui)
|
package/src/board/floor.js
CHANGED
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
// the same commit.
|
|
131
131
|
|
|
132
132
|
const WIN_WORDS = { '5h': '5 hour', '7d': '7 day' }
|
|
133
|
-
const IDS = ['claude', 'codex', 'agy', 'fake']
|
|
133
|
+
const IDS = ['claude', 'codex', 'agy', 'grok', 'fake']
|
|
134
134
|
|
|
135
135
|
// ---- times. The head prints `Times are local.` once, so no row repeats it ----
|
|
136
136
|
// THIS FILE OWNS THE TIME GRAMMAR FOR THE WHOLE BOARD. ago(), clockAt(),
|
|
@@ -600,7 +600,7 @@
|
|
|
600
600
|
if (s === 'reconnecting') {
|
|
601
601
|
banner.textContent = state.lastReadingAt ? `Reconnecting to Leg. Last reading ${clockAt(state.lastReadingAt.getTime())}.` : `Reconnecting to Leg on ${state.bind}.`
|
|
602
602
|
} else if (s === 'connecting') {
|
|
603
|
-
banner.textContent = `Connecting to
|
|
603
|
+
banner.textContent = `Connecting to Leg on ${state.bind}.`
|
|
604
604
|
}
|
|
605
605
|
}
|
|
606
606
|
|