@ucsandman/legcli 0.7.0 → 0.8.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/src/attach.mjs CHANGED
@@ -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,6 +26,7 @@ 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
31
  import { saveSessionBundle, resumePrompt } from './bundle.mjs'
31
32
  import { endSessionPointer } from './resume.mjs'
@@ -33,7 +34,7 @@ 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'
37
38
 
38
39
  const SRC = dirname(fileURLToPath(import.meta.url))
39
40
  const SERVER = join(SRC, 'server.mjs')
@@ -56,7 +57,7 @@ export function isCurrentLeg(session, { pid, agent, account }) {
56
57
  // ---- board ----
57
58
  function health(port, host = '127.0.0.1') {
58
59
  return new Promise((res) => {
59
- const req = http.get({ host, port, path: '/api/health', timeout: 1500 }, (r) => {
60
+ const req = http.get({ host, port, path: '/api/health', timeout: 4000 }, (r) => {
60
61
  let d = ''
61
62
  r.on('data', (c) => { d += c })
62
63
  r.on('end', () => {
@@ -84,11 +85,12 @@ export async function ensureBoard({ open = true } = {}) {
84
85
  child.unref()
85
86
  const t0 = Date.now()
86
87
  while (Date.now() - t0 < 15000) {
87
- if (await health(port, host)) {
88
+ const h = await health(port, host)
89
+ if (h) {
88
90
  // only claim the pidfile for a child we actually started: under a race,
89
91
  // another `baton` won the port and ours died on EADDRINUSE — writing our
90
92
  // dead pid would make `baton down` kill nothing and report "not running"
91
- const ours = child.exitCode === null && Boolean(child.pid)
93
+ const ours = h.pid ? h.pid === child.pid : (child.exitCode === null && Boolean(child.pid))
92
94
  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
95
  if (open) openBoard(url)
94
96
  return { url, started: ours }
@@ -138,13 +140,18 @@ export function isolate({ g, cwd, sid, sessions = reapLost(listSessions()) }) {
138
140
  // a binary that is not installed (that spawned ENOENT and killed the session
139
141
  // with exit 127 instead of waiting for a reset). Resolves each adapter the way
140
142
  // the runner would spawn it (native exe, npm entry, or BATON_<AGENT>_BIN).
143
+ async function loadAdapter(name) {
144
+ if (name === 'grok') return (await import('./adapters/grok.mjs')).default
145
+ return getAdapter(name)
146
+ }
147
+
141
148
  let installedCache = null
142
149
  async function installedAgents() {
143
150
  if (installedCache) return installedCache
144
151
  const out = {}
145
- for (const name of AGENTS) {
152
+ for (const name of SUPERVISED_AGENTS) {
146
153
  try {
147
- const { bin, viaNode, entry } = (await getAdapter(name)).resolve()
154
+ const { bin, viaNode, entry } = (await loadAdapter(name)).resolve()
148
155
  const target = viaNode ? (entry ?? bin) : bin
149
156
  if (/[\\/]/.test(target)) out[name] = existsSync(target)
150
157
  else { const r = spawnSync(target, ['--version'], { windowsHide: true, encoding: 'utf8', timeout: 8000 }); out[name] = !r.error && r.status === 0 }
@@ -182,26 +189,36 @@ function restoreTerminal() {
182
189
  }
183
190
 
184
191
  // ---- spawn spec per agent ----
185
- export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd }) {
186
- const adapter = await getAdapter(agent)
192
+ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd, autoApprove = resolveAutoApprove() }) {
193
+ const adapter = await loadAdapter(agent)
187
194
  const { bin, viaNode, entry } = adapter.resolve()
188
195
  const argv = []
189
196
  // viaNode: either an npm entry (codex bin/codex.js) or a BATON_<AGENT>_BIN that names a .mjs (tests)
190
197
  if (viaNode) argv.push(entry ?? bin)
191
198
  // a leg Baton starts on its own (after a hand-off) takes BATON_<AGENT>_ARGS,
192
199
  // 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]
200
+ if (prompt) args = [...(process.env[`LEG_${agent.toUpperCase()}_ARGS`] ?? process.env[`BATON_${agent.toUpperCase()}_ARGS`] ?? '').split(/\s+/).filter(Boolean), ...args]
194
201
  if (agent === 'claude') {
195
202
  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)
203
+ const autoFlags = autoApprove && !args.includes('--dangerously-skip-permissions') ? ['--dangerously-skip-permissions'] : [] // auto-approve: not forbidden for interactive sessions
204
+ argv.push(...args, ...autoFlags, '--settings', settings)
197
205
  if (prompt) argv.push(prompt)
198
206
  } else if (agent === 'codex') {
199
- argv.push(...args)
207
+ const hasApproval = args.includes('--ask-for-approval') || args.includes('-a') || args.some((x) => typeof x === 'string' && x.startsWith('--ask-for-approval='))
208
+ const autoFlags = autoApprove && !hasApproval ? ['--ask-for-approval', 'never'] : []
209
+ argv.push(...args, ...autoFlags)
200
210
  if (prompt) argv.push(prompt)
201
211
  } else if (agent === 'agy') {
202
212
  const log = join(sessionDir(sessionId), 'agy.log')
203
- argv.push(...args, '--log-file', log)
213
+ const autoFlags = autoApprove && !args.includes('--dangerously-skip-permissions') ? ['--dangerously-skip-permissions'] : [] // auto-approve: not forbidden for interactive sessions
214
+ argv.push(...args, ...autoFlags, '--log-file', log)
204
215
  if (prompt) argv.push('-i', prompt)
216
+ } else if (agent === 'grok') {
217
+ const log = join(sessionDir(sessionId), 'grok.log')
218
+ const hasApprove = args.includes('--always-approve') || args.includes('--yolo') || args.includes('--approval-mode=yolo') // auto-approve check: not forbidden for interactive sessions
219
+ const autoFlags = autoApprove && !hasApprove ? ['--always-approve'] : [] // auto-approve: not forbidden for interactive sessions
220
+ argv.push(...args, ...autoFlags, '--debug-file', log)
221
+ if (prompt) argv.push(prompt)
205
222
  }
206
223
  const env = { ...sanitizeEnv(process.env, { interactive: true }), ...envFor(agent, account), LEG_SESSION: sessionId, BATON_SESSION: sessionId }
207
224
  return { bin: viaNode ? process.execPath : bin, args: argv, env, cwd }
@@ -209,7 +226,7 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd }
209
226
 
210
227
  // ---- one agent leg ----
211
228
  // Returns { reason: 'exit'|'limit'|'handoff', code }
212
- async function runLeg({ agent, account, args, session, prompt, boardUrl }) {
229
+ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApprove = resolveAutoApprove() }) {
213
230
  const sid = session.session_id
214
231
  refreshAccount(agent, account)
215
232
  // A handoff happens when the limit hits, which is usually when nobody is
@@ -219,7 +236,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl }) {
219
236
  const trust = ensureTrust(agent, session.cwd, { cwd: session.cwd })
220
237
  const trusted = trustLine(trust)
221
238
  if (trusted) { say(trusted); appendEvent(sid, { type: 'trust', summary: trusted }) }
222
- const spec = await spawnSpec(agent, { account, args, sessionId: sid, prompt, cwd: session.cwd })
239
+ const spec = await spawnSpec(agent, { account, args, sessionId: sid, prompt, cwd: session.cwd, autoApprove })
223
240
  appendEvent(sid, { type: 'leg', summary: `${agent} (${account}) starting${prompt ? ' from the handoff bundle' : ''}` })
224
241
  const startedMs = Date.now()
225
242
  const turnsAtLegStart = session.turns ?? 0
@@ -227,6 +244,8 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl }) {
227
244
  // the end of what the first one wrote, or it walls itself on that leg's line
228
245
  const agyLog = agent === 'agy' ? join(sessionDir(sid), 'agy.log') : null
229
246
  const agyTail = agyLog ? createTail(agyLog, { from: logSize(agyLog) }) : null
247
+ const grokLog = agent === 'grok' ? join(sessionDir(sid), 'grok.log') : null
248
+ const grokTail = grokLog ? createTail(grokLog, { from: logSize(grokLog) }) : null
230
249
  let child
231
250
  try {
232
251
  child = spawn(spec.bin, spec.args, { cwd: spec.cwd, env: spec.env, stdio: 'inherit', windowsHide: false })
@@ -289,6 +308,24 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl }) {
289
308
  pollUsage().catch(() => {})
290
309
  usageTimer = setInterval(() => pollUsage().catch(() => {}), USAGE_MS)
291
310
  usageTimer.unref?.()
311
+ } else if (agent === 'grok') {
312
+ const pollUsage = async () => {
313
+ const configDir = spec.env.GROK_HOME || LAYOUT.grok.home()
314
+ const r = await fetchGrokUsage({ configDir })
315
+ const s = readSession(sid)
316
+ if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
317
+ const usable = r.ok && r.limits && (r.limits.five_hour || r.limits.seven_day)
318
+ if (usable) {
319
+ recordUsage('grok', account, r.limits, 'grok billing proxy')
320
+ updateSession(sid, { limits: r.limits, usage_source: 'grok billing proxy', usage_error: null })
321
+ } else if (!s.usage_error) {
322
+ const why = r.error ?? 'the usage endpoint answered with no window'
323
+ updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `grok usage unavailable: ${why}` } })
324
+ }
325
+ }
326
+ pollUsage().catch(() => {})
327
+ usageTimer = setInterval(() => pollUsage().catch(() => {}), USAGE_MS)
328
+ usageTimer.unref?.()
292
329
  }
293
330
 
294
331
  const timer = setInterval(() => {
@@ -351,6 +388,25 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl }) {
351
388
  if (s.status === 'starting') patch.status = 'running'
352
389
  }
353
390
  }
391
+ // grok: log text + prompts
392
+ if (agent === 'grok') {
393
+ const text = grokTail ? grokTail.read().join('\n') : ''
394
+ const hit = text ? scanGrokLog(text) : null
395
+ if (hit && s.status !== 'limit') {
396
+ const u = markLimited('grok', account, { resets_at: hit.resets_at, reason: hit.signal, source: 'grok log' })
397
+ patch.status = 'limit'; patch.limit = { reason: hit.signal, detail: hit.detail, resets_at: hit.resets_at ?? u.limited_until, at: new Date().toISOString() }
398
+ appendEvent(sid, { type: 'limit', summary: `grok limit (${hit.signal}): ${hit.detail.slice(0, 160)}` })
399
+ try { captureLive('grok', hit.signal, { log_excerpt: hit.detail, resets_at: hit.resets_at }, { sessionId: sid }) } catch {}
400
+ }
401
+ const prompts = grokPromptsSince({ grokHome: spec.env.GROK_HOME || LAYOUT.grok.home(), cwd: s.cwd, sinceMs: startedMs })
402
+ const turns = turnsAtLegStart + prompts.length
403
+ if (prompts.length && turns !== (s.turns ?? 0)) {
404
+ patch.turns = turns; patch.last_activity = new Date().toISOString()
405
+ if (!s.task) patch.task = prompts[0].text.slice(0, 500)
406
+ if (!s.agent_session_id && prompts[0].sessionId) patch.agent_session_id = prompts[0].sessionId
407
+ if (s.status === 'starting') patch.status = 'running'
408
+ }
409
+ }
354
410
  // warning threshold (every agent; claude's limits arrive via the usage poller)
355
411
  {
356
412
  const lim = patch.limits ?? s.limits
@@ -456,7 +512,7 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
456
512
 
457
513
  // ---- the command ----
458
514
  export async function attach(agent, args = [], { open = true } = {}) {
459
- if (!AGENTS.includes(agent)) throw new Error(`unknown agent "${agent}" (claude|codex|agy)`)
515
+ if (!SUPERVISED_AGENTS.includes(agent)) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
460
516
  // the paid gate: a valid key, or no session (exit 4). The bare agent is never
461
517
  // affected; only what Baton adds is licensed.
462
518
  const ent = entitlement()
@@ -464,13 +520,22 @@ export async function attach(agent, args = [], { open = true } = {}) {
464
520
  // --no-worktree is Baton's flag, not the agent's: it never passes through
465
521
  const shareCheckout = args.includes('--no-worktree')
466
522
  args = args.filter((a) => a !== '--no-worktree')
523
+ let autoApproveCli = null
524
+ if (args.includes('--no-auto-approve')) {
525
+ autoApproveCli = false
526
+ args = args.filter((a) => a !== '--no-auto-approve')
527
+ } else if (args.includes('--auto-approve')) {
528
+ autoApproveCli = true
529
+ args = args.filter((a) => a !== '--auto-approve')
530
+ }
531
+ const autoApprove = resolveAutoApprove({ cliFlag: autoApproveCli })
467
532
  const cwd = process.cwd()
468
533
  const board = await ensureBoard({ open })
469
534
  let accounts = readAccounts()
470
535
  const installed = await installedAgents()
471
536
  const handoffOrder = readPreferences().handoff_order
472
537
  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' }
538
+ if (!(accounts[agent] ?? ['default']).includes(account)) { say(`no ${agent} account "${account}"; using default`); account = 'default' }
474
539
  // A persisted wall is only a cache. Ask Codex's read-only account endpoint
475
540
  // before using it to skip this login; an explicit true can clear an older
476
541
  // wall, false refreshes it, and unknown preserves it.
@@ -478,6 +543,10 @@ export async function attach(agent, args = [], { open = true } = {}) {
478
543
  const codexHome = envFor('codex', account).CODEX_HOME || LAYOUT.codex.home()
479
544
  await refreshCodexUsage(account, codexHome).catch(() => {})
480
545
  }
546
+ if (agent === 'grok' && installed.grok && !process.env.BATON_GROK_BIN) {
547
+ const grokHome = envFor('grok', account).GROK_HOME || LAYOUT.grok.home()
548
+ await fetchGrokUsage({ configDir: grokHome }).catch(() => {})
549
+ }
481
550
  // Start on an account that is not at its wall, if we already know one is.
482
551
  const nowS = Math.floor(Date.now() / 1000)
483
552
  const u0 = readUsage(agent, account)
@@ -513,7 +582,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
513
582
  // bounds a wait; a normal session runs one leg and exits
514
583
  for (let leg = 0; ; leg++) {
515
584
  const s = readSession(sid)
516
- const r = await runLeg({ agent, account, args: legArgs, session: s, prompt, boardUrl: board.url })
585
+ const r = await runLeg({ agent, account, args: legArgs, session: s, prompt, boardUrl: board.url, autoApprove })
517
586
  if (r.reason === 'exit') { exit = r.code ?? 0; break }
518
587
  // limit or handoff: bundle, choose next, go again in this terminal
519
588
  const cur = readSession(sid)
@@ -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
@@ -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
 
@@ -22,7 +22,7 @@
22
22
  handing_off: ['handing off', 'warn'], waiting: ['waiting for reset', 'warn'], handed_off: ['handed off', 'idle'], ended: ['ended', 'idle'], lost: ['lost', 'danger'],
23
23
  }
24
24
  const WIN_WORDS = { '5h': '5 hour', '7d': '7 day' }
25
- const IDS = ['claude', 'codex', 'agy', 'fake']
25
+ const IDS = ['claude', 'codex', 'agy', 'grok', 'fake']
26
26
 
27
27
  let view = null
28
28
  const NO_BRANCH_BLOCKER = 'this terminal works in the checkout itself: there is no branch of its own to land'
@@ -643,15 +643,15 @@
643
643
  // A result that belongs to a terminal is written into that terminal's sentence
644
644
  // slot, where the reader is already looking. Only a result that belongs to no
645
645
  // object on this page goes to the one system message.
646
- async function act(id, action, btn) {
646
+ async function act(id, action, btn, body = null) {
647
647
  btn.disabled = true
648
648
  actionNotes.delete(id)
649
649
  try {
650
650
  if (action.startsWith('requests/')) {
651
- await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
651
+ await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST', body })
652
652
  actionNotes.set(id, { at: Date.now(), tone: 'ok', text: action.endsWith('approve') ? 'approved; this terminal hands off in a few seconds' : 'the request was dismissed' })
653
653
  } else if (action === 'request-handoff') {
654
- await api(`/api/sessions/${encodeURIComponent(id)}/request-handoff`, { method: 'POST' })
654
+ await api(`/api/sessions/${encodeURIComponent(id)}/request-handoff`, { method: 'POST', body })
655
655
  sysMessage('asked; the owner of that terminal decides', 'ok')
656
656
  } else if (action === 'remove') {
657
657
  const r = await api(`/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' })
@@ -660,9 +660,10 @@
660
660
  await api(`/api/sessions/${encodeURIComponent(id)}?force=1&keep_worktree=1`, { method: 'DELETE' })
661
661
  sysMessage('removed the Leg record; the worktree and the branch are kept', 'ok')
662
662
  } else {
663
- await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
663
+ await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST', body })
664
664
  if (action === 'handoff') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'hand-off requested; this terminal switches agents in a few seconds' })
665
665
  else if (action === 'end') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'end requested; the agent stops after its current turn' })
666
+ else if (action === 'land/fix') actionNotes.set(id, { at: Date.now(), tone: 'ok', text: 'applied fix' })
666
667
  }
667
668
  refresh()
668
669
  } catch (err) {
@@ -849,25 +850,63 @@
849
850
  // G10: the order is Land, Hand off now, Details, End, and it never reflows
850
851
  // by availability. A button that does not apply is omitted, never moved.
851
852
  const landing = Boolean(s.land && s.land.state === 'landing')
852
- const blocker = s.worktree ? s.land_blocker : NO_BRANCH_BLOCKER
853
- // G10 is disabled-with-its-reason, so the reason is attached to the control
854
- // as well as printed: the title used to be on the inverse condition, giving
855
- // the tooltip to the button that explains itself and none to the one that
856
- // needs it, and nothing connected the sentence below to the button above.
853
+ const cl = s.can_land || (s.worktree ? (s.land_blocker ? { ok: false, blockers: [{ code: 'legacy', message: s.land_blocker }] } : { ok: true, blockers: [] }) : { ok: false, blockers: [{ code: 'no_worktree', message: NO_BRANCH_BLOCKER }] })
854
+ const isOnTarget = cl.blockers && cl.blockers.some((b) => b.code === 'on_target_branch')
855
+ const targetLocked = cl.blockers && cl.blockers.find((b) => b.code === 'target_locked')
856
+ const blocker = !cl.ok ? (cl.blockers[0]?.message || NO_BRANCH_BLOCKER) : null
857
857
  const blockerId = blocker ? `land-blocker-${s.session_id}` : null
858
- // Land is the primary action only when it can actually run. A disabled
859
- // button painted in the one accent colour spends the loudest thing in the
860
- // design on something the reader cannot do.
861
- const land = el('button', {
862
- type: 'button',
863
- class: `btn ${blocker ? 'btn-secondary' : 'btn-primary'}${landing ? ' is-loading' : ''}`,
864
- disabled: blocker || landing ? '' : null,
865
- 'data-focus-key': `land:${s.session_id}`,
866
- 'aria-describedby': blockerId,
867
- title: blocker || `commit this terminal's work on ${s.worktree.branch}, rebase it onto ${s.worktree.base}, run the tests, fast-forward ${s.worktree.base}; a bounce says why`,
868
- }, [landing ? 'Landing…' : 'Land'])
869
- land.addEventListener('click', () => act(s.session_id, 'land', land))
870
- actions.appendChild(land)
858
+
859
+ if (isOnTarget) {
860
+ const commitDirect = el('button', {
861
+ type: 'button',
862
+ class: 'btn btn-secondary',
863
+ 'data-focus-key': `commit-direct:${s.session_id}`,
864
+ title: `Commit directly on ${s.branch || s.worktree?.base || 'main'}`,
865
+ }, ['Commit directly'])
866
+ commitDirect.addEventListener('click', () => act(s.session_id, 'land/fix', commitDirect, { action: 'commit_directly' }))
867
+ actions.appendChild(commitDirect)
868
+ } else {
869
+ const landLabel = landing ? 'Landing…' : targetLocked ? targetLocked.message : 'Land'
870
+ const landDisabled = !cl.ok || landing
871
+ const land = el('button', {
872
+ type: 'button',
873
+ class: `btn ${landDisabled ? 'btn-secondary' : 'btn-primary'}${landing ? ' is-loading' : ''}`,
874
+ disabled: landDisabled ? '' : null,
875
+ 'data-focus-key': `land:${s.session_id}`,
876
+ 'aria-describedby': blockerId,
877
+ title: blocker || (s.worktree ? `commit work on ${s.worktree.branch}, rebase onto ${s.worktree.base}, test, and fast-forward ${s.worktree.base}` : 'Land'),
878
+ }, [landLabel])
879
+ if (!landDisabled) {
880
+ land.addEventListener('click', async () => {
881
+ land.disabled = true
882
+ land.classList.add('is-loading')
883
+ land.textContent = 'Preparing…'
884
+ try {
885
+ const res = await api(`/api/sessions/${encodeURIComponent(s.session_id)}/land/prepare`, { method: 'POST' })
886
+ land.classList.remove('is-loading')
887
+ if (!res.ok) {
888
+ actionNotes.set(s.session_id, { at: Date.now(), tone: 'danger', text: res.error || 'Prepare failed' })
889
+ refresh()
890
+ return
891
+ }
892
+ const statText = res.diff_stat || `${res.files?.length || 0} files changed`
893
+ const question = `Prepared: ${statText} · tests green. Land onto ${s.worktree?.base || 'main'} and ship to GitHub?`
894
+ pendingConfirm = {
895
+ id: s.session_id,
896
+ question,
897
+ verb: 'Land',
898
+ action: 'land',
899
+ }
900
+ renderSessions(view)
901
+ } catch (err) {
902
+ land.classList.remove('is-loading')
903
+ actionNotes.set(s.session_id, { at: Date.now(), tone: 'danger', text: err.message })
904
+ refresh()
905
+ }
906
+ })
907
+ }
908
+ actions.appendChild(land)
909
+ }
871
910
  if (s.active) {
872
911
  const h = el('button', { type: 'button', class: `btn ${blocker ? 'btn-primary' : 'btn-secondary'}`, title: 'save the bundle, stop this agent, start the next option in the same terminal', 'data-focus-key': `handoff:${s.session_id}` }, ['Hand off now'])
873
912
  h.addEventListener('click', () => act(s.session_id, 'handoff', h))
@@ -901,8 +940,33 @@
901
940
  }
902
941
  row.appendChild(actions)
903
942
  term.appendChild(row)
904
- // the reason a disabled control is disabled is printed, never left in a title
905
- if (blocker) term.appendChild(el('p', { class: 'blocker', id: blockerId }, [blocker]))
943
+
944
+ // Why can't I land expander or fallback blocker message
945
+ if (!cl.ok && !isOnTarget && s.worktree) {
946
+ const list = el('ul', { class: 'blocker-list' })
947
+ for (const b of cl.blockers) {
948
+ const item = el('li', { class: 'blocker-item' })
949
+ item.appendChild(el('span', { class: 'blocker-msg' }, [b.message]))
950
+ const fixes = b.fixes || (b.fix ? [b.fix] : [])
951
+ if (fixes.length) {
952
+ const grp = el('span', { class: 'blocker-fixes' })
953
+ for (const f of fixes) {
954
+ const fBtn = el('button', { type: 'button', class: 'btn btn-sm btn-secondary', title: f.label }, [f.label])
955
+ fBtn.addEventListener('click', () => act(s.session_id, 'land/fix', fBtn, { action: f.action, target_session: f.target_session }))
956
+ grp.appendChild(fBtn)
957
+ }
958
+ item.appendChild(grp)
959
+ }
960
+ list.appendChild(item)
961
+ }
962
+ const expander = el('details', { class: 'why-cant-land', id: blockerId }, [
963
+ el('summary', { class: 'why-cant-land-summary' }, ["Why can't I land?"]),
964
+ list,
965
+ ])
966
+ term.appendChild(expander)
967
+ } else if (blocker) {
968
+ term.appendChild(el('p', { class: 'blocker', id: blockerId }, [blocker]))
969
+ }
906
970
  return term
907
971
  }
908
972
 
@@ -1014,9 +1078,12 @@
1014
1078
  }
1015
1079
 
1016
1080
  function messageRow(m, key) {
1017
- return el('div', { class: 'turn drawer-msg' }, [
1018
- el('span', { class: 'turn-role drawer-msg-role' }, [m.role === 'user' ? 'human' : 'agent']),
1019
- m.ts ? el('span', { class: 'turn-when drawer-msg-when' }, [whenAgo(m.ts)]) : null,
1081
+ const isUser = m.role === 'user'
1082
+ return el('div', { class: `turn drawer-msg ${isUser ? 'is-human' : 'is-agent'}` }, [
1083
+ el('div', { class: 'drawer-msg-head' }, [
1084
+ el('span', { class: `turn-role drawer-msg-role ${isUser ? '' : 'chip-state-ok'}` }, [isUser ? 'human' : 'agent']),
1085
+ m.ts ? el('span', { class: 'turn-when drawer-msg-when' }, [whenAgo(m.ts)]) : null,
1086
+ ]),
1020
1087
  // every box that can scroll carries a key, so where the reader had
1021
1088
  // scrolled to survives the rebuild three seconds later
1022
1089
  el('p', { 'data-scroll-key': `msg:${key}` }, [m.text]),
@@ -1024,7 +1091,7 @@
1024
1091
  }
1025
1092
 
1026
1093
  function fileRow(f) {
1027
- const wrap = el('div', {})
1094
+ const wrap = el('div', { class: 'drawer-file-item' })
1028
1095
  const pre = el('pre', { class: 'drawer-diff', hidden: '', 'data-scroll-key': `diff:${f.path}` })
1029
1096
  const row = el('button', { type: 'button', class: 'btn btn-text file-row', 'aria-expanded': 'false', 'data-focus-key': `file:${f.path}` }, [
1030
1097
  el('span', { class: 'mono', title: f.path }, [f.path]),
@@ -1115,16 +1182,18 @@
1115
1182
  return
1116
1183
  }
1117
1184
  const [label] = STATUS[s.status] || [s.status]
1118
- const controls = el('div', { class: 'cap-line' }, [
1185
+ const left = el('div', { class: 'detail-brand' }, [
1186
+ el('span', { class: `dot id-${idOf(s.agent)}` }),
1119
1187
  el('span', { class: `acct-name chip-id-${idOf(s.agent)}` }, [s.agent]),
1120
- el('span', { class: 'chip' }, [tail(s.session_id)]),
1188
+ el('span', { class: 'chip mono' }, [tail(s.session_id)]),
1189
+ el('span', { class: s.active ? 'chip chip-state-ok' : 'chip is-stale' }, [s.active ? (label || 'running') : (label || s.status)]),
1121
1190
  ])
1122
1191
  const pause = el('button', { type: 'button', class: 'btn btn-secondary', id: 'session-drawer-pause', 'data-focus-key': 'drawer-pause' }, [drawer.paused ? 'Resume updates' : 'Pause updates'])
1123
1192
  pause.addEventListener('click', () => { drawer.paused = !drawer.paused; if (!drawer.paused) loadDrawer(); else renderDrawer() })
1124
1193
  const close = el('button', { type: 'button', class: 'btn btn-secondary', id: 'session-drawer-close', 'data-focus-key': 'drawer-close' }, ['Close'])
1125
1194
  close.addEventListener('click', closeSessionDrawer)
1126
- controls.append(pause, close)
1127
- box.appendChild(controls)
1195
+ const header = el('div', { class: 'detail-masthead' }, [left, el('div', { class: 'detail-ctrls' }, [pause, close])])
1196
+ box.appendChild(header)
1128
1197
  if (drawer.error) box.appendChild(el('p', { class: 'sentence tone-danger' }, [drawer.error]))
1129
1198
 
1130
1199
  const last = d && d.messages ? [...d.messages].reverse().find((m) => m.role === 'assistant') : null
@@ -1171,10 +1240,10 @@
1171
1240
  // header on the same panel printing the same instant as 11:04 PM. The
1172
1241
  // summary is a block, as board.js:754 builds the same row, or the kind
1173
1242
  // word and the sentence render glued: `lostrunner pid 999002 is gone`.
1174
- timeline.appendChild(el('div', { class: 'turn' }, [
1243
+ timeline.appendChild(el('div', { class: 'turn timeline-item' }, [
1175
1244
  el('span', { class: 'mono turn-when' }, [clockAt(Date.parse(e.ts))]),
1176
1245
  el('span', { class: 'turn-role' }, [e.type]),
1177
- el('p', {}, [e.summary || '']),
1246
+ el('p', { class: 'timeline-summary' }, [e.summary || '']),
1178
1247
  ]))
1179
1248
  }
1180
1249
  if (!events.length) timeline.appendChild(el('p', { class: 'sentence tone-muted' }, [d ? 'nothing recorded yet' : 'reading the timeline']))