@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.
Files changed (116) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/NOTICE +8 -0
  3. package/README.md +601 -558
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +64 -34
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +71 -0
  8. package/docs/README.md +2 -0
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +21 -0
  11. package/docs/adapters.md +17 -3
  12. package/docs/board-guide.md +13 -0
  13. package/docs/cli-contracts.md +57 -3
  14. package/docs/concepts.md +42 -3
  15. package/docs/configuration.md +42 -2
  16. package/docs/faq.md +19 -0
  17. package/docs/getting-started.md +272 -251
  18. package/docs/harness.md +319 -0
  19. package/fixtures/limits/grok/grok-rate-limit.json +11 -0
  20. package/fixtures/live/agy/limit-agy-resource-exhausted.json +11 -0
  21. package/fixtures/verified.json +1 -1
  22. package/package.json +8 -4
  23. package/scripts/build-docs-site.mjs +15 -7
  24. package/scripts/check-branding.mjs +118 -0
  25. package/scripts/check-claims.mjs +1 -1
  26. package/scripts/license-sign.mjs +1 -1
  27. package/scripts/limits-table.mjs +1 -1
  28. package/scripts/live-limits.mjs +1 -1
  29. package/scripts/npm-publish-gate.mjs +114 -0
  30. package/scripts/probe.mjs +4 -3
  31. package/scripts/seed-fake-cards.mjs +4 -3
  32. package/scripts/seed-floor-board.mjs +5 -4
  33. package/scripts/seed-wes-board.mjs +5 -4
  34. package/scripts/stripe-setup.mjs +1 -1
  35. package/scripts/sync-harness-engine.mjs +159 -0
  36. package/scripts/sync-leg-agents.mjs +127 -0
  37. package/src/accounts.mjs +10 -2
  38. package/src/adapters/codex.mjs +1 -1
  39. package/src/adapters/grok.mjs +4 -7
  40. package/src/attach.mjs +162 -37
  41. package/src/auth.mjs +2 -2
  42. package/src/board/board.css +45 -17
  43. package/src/board/board.js +4 -4
  44. package/src/board/floor.js +2 -2
  45. package/src/board/sessions.js +181 -38
  46. package/src/bundle.mjs +54 -8
  47. package/src/chain.mjs +1 -1
  48. package/src/contract.mjs +4 -3
  49. package/src/fsx.mjs +5 -2
  50. package/src/handoff.mjs +6 -6
  51. package/src/harness/cli.mjs +281 -0
  52. package/src/harness/fingerprint.mjs +68 -0
  53. package/src/harness/index.mjs +407 -0
  54. package/src/harness/registry.mjs +124 -0
  55. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  56. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  57. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  58. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  69. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  70. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  71. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  72. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  73. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  74. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  75. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  76. package/src/hook.mjs +49 -49
  77. package/src/land.mjs +660 -47
  78. package/src/launcher.mjs +40 -27
  79. package/src/ledger.mjs +6 -6
  80. package/src/license.mjs +10 -9
  81. package/src/live-capture.mjs +1 -1
  82. package/src/mergequeue.mjs +6 -6
  83. package/src/orchestrator.mjs +28 -4
  84. package/src/preferences.mjs +63 -9
  85. package/src/redact.mjs +1 -1
  86. package/src/resume.mjs +17 -15
  87. package/src/runner.mjs +3 -3
  88. package/src/scheduler.mjs +1 -1
  89. package/src/server.mjs +69 -20
  90. package/src/session-detail.mjs +15 -1
  91. package/src/sessions.mjs +9 -5
  92. package/src/share.mjs +2 -2
  93. package/src/stations/agent.mjs +1 -1
  94. package/src/sync/dashclaw.mjs +4 -4
  95. package/src/synthesis.mjs +165 -0
  96. package/src/taps/agy.mjs +2 -2
  97. package/src/taps/claude-usage.mjs +1 -1
  98. package/src/taps/claude.mjs +170 -170
  99. package/src/taps/codex.mjs +286 -286
  100. package/src/taps/grok.mjs +251 -0
  101. package/src/trust.mjs +205 -36
  102. package/src/usage.mjs +5 -1
  103. package/src/worktree.mjs +5 -4
  104. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  105. package/fixtures/live/agy/err.log +0 -0
  106. package/fixtures/live/agy/out.log +0 -1
  107. package/fixtures/live/agy/supervisor.log +0 -2
  108. package/fixtures/live/claude/err.log +0 -0
  109. package/fixtures/live/claude/out.log +0 -1
  110. package/fixtures/live/claude/supervisor.log +0 -2
  111. package/fixtures/live/codex/err.log +0 -1
  112. package/fixtures/live/codex/out.log +0 -8
  113. package/fixtures/live/codex/supervisor.log +0 -2
  114. package/fixtures/live/grok/err.log +0 -32
  115. package/fixtures/live/grok/out.log +0 -7
  116. package/fixtures/live/grok/supervisor.log +0 -2
package/src/attach.mjs CHANGED
@@ -1,5 +1,5 @@
1
- // attach — `baton claude|codex|agy [args…]`: the normal interactive agent in
2
- // this terminal, with Baton alongside it. Baton (1) makes sure the board is
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 { AGENTS, HANDOFF_ORDER_CAPABILITY, newSessionId, createSession, readSession, updateSession, appendEvent, takeControl, sessionDir, listSessions, reapLost, isActive, workRoot } from './sessions.mjs'
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
- const SERVER = join(SRC, 'server.mjs')
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: 1500 }, (r) => {
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
- if (await health(port, host)) {
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 `baton` won the port and ours died on EADDRINUSE — writing our
90
- // dead pid would make `baton down` kill nothing and report "not running"
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 AGENTS) {
162
+ for (const name of SUPERVISED_AGENTS) {
146
163
  try {
147
- const { bin, viaNode, entry } = (await getAdapter(name)).resolve()
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 getAdapter(agent)
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 Baton starts on its own (after a hand-off) takes BATON_<AGENT>_ARGS,
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
- argv.push(...args, '--settings', settings)
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
- argv.push(...args)
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
- argv.push(...args, '--log-file', log)
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 (!AGENTS.includes(agent)) throw new Error(`unknown agent "${agent}" (claude|codex|agy)`)
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 Baton adds is licensed.
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 Baton's flag, not the agent's: it never passes through
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
- let claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason })
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
- prompt = bundle
565
- ? resumePrompt(cur, bundle, next)
566
- : `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).` : ''} Check git status and git diff, then continue the work. The task: ${cur.task ?? 'see the recent changes'}`
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 — Baton owns that file, and leaving the last
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 `baton
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 (`baton share off`, a truncated file) while the shared address is still
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 }
@@ -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: baseline; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding-block: 28px 0; }
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: baseline; gap: 18px; flex-wrap: wrap; font-size: var(--t-0); color: var(--text-3); }
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-2); }
349
- .drawer-msg p { max-height: 18rem; overflow-y: auto; font-size: var(--t-0); color: var(--text-2); }
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-role { font-size: var(--t--1); color: var(--text-3); font-weight: var(--w-head); }
355
- .drawer-msg-when { font-size: var(--t--1); color: var(--text-3); }
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-heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; font-size: var(--t-1); font-weight: var(--w-head); margin-bottom: 12px; }
424
- .detail-section { margin-top: 20px; }
425
- .detail-sub { font-size: var(--t-0); color: var(--text-3); margin-bottom: 8px; }
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
- .well { background: var(--e0); border-radius: var(--r-control); padding: 14px; }
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-row { display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 8px; text-align: left; }
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: baseline; gap: 18px; flex-wrap: wrap; }
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; }
@@ -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 'baton'
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 'baton'
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 "Baton tries claude, then agy, then claude", a fallback that cannot
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)
@@ -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 Baton on ${state.bind}.`
603
+ banner.textContent = `Connecting to Leg on ${state.bind}.`
604
604
  }
605
605
  }
606
606