@ucsandman/legcli 0.10.0 → 0.12.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 (70) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +158 -67
  3. package/bin/leg.mjs +168 -18
  4. package/docs/DECISIONS.md +10 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +94 -0
  8. package/docs/ROADMAP-v2.md +69 -11
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/adapters.md +93 -11
  11. package/docs/board-guide.md +401 -66
  12. package/docs/cli-contracts.md +235 -22
  13. package/docs/concepts.md +167 -19
  14. package/docs/configuration.md +113 -5
  15. package/docs/faq.md +21 -5
  16. package/docs/getting-started.md +15 -11
  17. package/docs/redesign-2026-09-17.md +477 -0
  18. package/docs/screenshots/background-1280.png +0 -0
  19. package/docs/screenshots/board-400px.png +0 -0
  20. package/docs/screenshots/board-details-open.png +0 -0
  21. package/docs/screenshots/board-drawer.png +0 -0
  22. package/docs/screenshots/board-handoff.png +0 -0
  23. package/docs/screenshots/board-running.png +0 -0
  24. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  25. package/docs/screenshots/settings-ladder-1280.png +0 -0
  26. package/docs/screenshots/terminals-1280.png +0 -0
  27. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  28. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  31. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/live/grok/cmd.txt +1 -1
  35. package/fixtures/live/grok/parsed.json +6 -3
  36. package/fixtures/live/grok/run.json +22 -10
  37. package/fixtures/verified.json +8 -1
  38. package/package.json +3 -2
  39. package/scripts/build-docs-site.mjs +4 -4
  40. package/scripts/probe.mjs +2 -1
  41. package/scripts/seed-fake-cards.mjs +59 -6
  42. package/scripts/seed-wes-board.mjs +81 -12
  43. package/src/accounts.mjs +6 -1
  44. package/src/adapters/cli.mjs +130 -0
  45. package/src/adapters/custom.mjs +271 -0
  46. package/src/adapters/grok.mjs +51 -10
  47. package/src/adapters/index.mjs +34 -7
  48. package/src/attach.mjs +350 -42
  49. package/src/audit.mjs +118 -0
  50. package/src/board/audit.js +123 -0
  51. package/src/board/board.css +134 -9
  52. package/src/board/board.js +482 -106
  53. package/src/board/index.html +89 -7
  54. package/src/board/sessions.js +1371 -113
  55. package/src/buckets.mjs +101 -0
  56. package/src/cards.mjs +9 -1
  57. package/src/chain.mjs +13 -0
  58. package/src/hook.mjs +7 -1
  59. package/src/ledger.mjs +10 -2
  60. package/src/orchestrator.mjs +13 -4
  61. package/src/preferences.mjs +214 -5
  62. package/src/scheduler.mjs +24 -1
  63. package/src/server.mjs +615 -50
  64. package/src/sessions.mjs +17 -1
  65. package/src/share.mjs +66 -6
  66. package/src/taps/claude-usage.mjs +91 -2
  67. package/src/taps/claude.mjs +144 -5
  68. package/src/taps/codex.mjs +23 -3
  69. package/src/taps/grok.mjs +4 -0
  70. package/src/usage.mjs +424 -13
package/bin/leg.mjs CHANGED
@@ -19,14 +19,17 @@ import { createScheduler, schedulerStatus, pidfile, MAX_CONCURRENT } from '../sr
19
19
  import { availableActions } from '../src/chain.mjs'
20
20
  import { up, down, stopBoard, status, openBoard } from '../src/launcher.mjs'
21
21
  import { attach, ensureBoard } from '../src/attach.mjs'
22
- import { readShare, addPerson, removePerson, rotate as rotateToken, turnOn, turnOff, linkFor, personNamed } from '../src/share.mjs'
22
+ import { readShare, addPerson, removePerson, rotate as rotateToken, turnOn, turnOff, linkFor, personNamed, scheme, tlsConfigured, ROLES } from '../src/share.mjs'
23
+ import { normalizeHandoffOrder, ladderFor, readPreferences, writePreferences } from '../src/preferences.mjs'
24
+ import { MODEL_ALIASES } from '../src/buckets.mjs'
23
25
  import { SUPERVISED_AGENTS, listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, isActive, readLand, sessionDir, appendEvent } from '../src/sessions.mjs'
24
- import { addAccount, removeAccount, listAccountRows, LAYOUT } from '../src/accounts.mjs'
25
- import { listUsage, fmtReset } from '../src/usage.mjs'
26
+ import { addAccount, removeAccount, listAccountRows, readAccounts, LAYOUT } from '../src/accounts.mjs'
27
+ import { listUsage, fmtReset, readUsage, isAvailable, candidates, binding, wallActive, evaluateLadder, rungLabel } from '../src/usage.mjs'
26
28
  import { home } from '../src/store.mjs'
27
29
  import { entitlement, allows, describe as describeLicense, activate as activateLicense, deactivate as deactivateLicense, refresh as refreshLicense, licensePath, BUY_URL } from '../src/license.mjs'
28
30
  import { resumeVerdict, bodyOf, ago } from '../src/resume.mjs'
29
31
  import { harnessCommand } from '../src/harness/cli.mjs'
32
+ import { adapterCommand } from '../src/adapters/cli.mjs'
30
33
  import { historyCommand, worktreesCommand } from '../src/history/cli.mjs'
31
34
 
32
35
  const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src')
@@ -71,13 +74,13 @@ async function cardAdd(args) {
71
74
  // start the next option in the same terminal. The payload is marked
72
75
  // simulated: it is never kept as live evidence, and the wall it records
73
76
  // clears after two minutes. codex has no Leg-owned input, so it is refused.
74
- function simulateLimit(s) {
77
+ function simulateLimit(s, { message = null } = {}) {
75
78
  if (!isActive(s)) die(3, `session ${s.session_id} is not active`)
76
79
  if (['limit', 'handing_off'].includes(s.status)) die(3, `session ${s.session_id} is already ${s.status}`)
77
80
  if (s.agent === 'claude') {
78
81
  const payload = {
79
82
  hook_event_name: 'StopFailure', error: 'rate_limit', session_id: s.agent_session_id ?? undefined, transcript_path: s.transcript_path ?? undefined,
80
- last_assistant_message: 'API Error: Rate limit reached (simulated by leg sessions simulate-limit)', leg_simulated: true, baton_simulated: true,
83
+ last_assistant_message: message ?? 'API Error: Rate limit reached (simulated by leg sessions simulate-limit)', leg_simulated: true, baton_simulated: true,
81
84
  }
82
85
  const r = spawnSync(process.execPath, [join(SRC, 'hook.mjs'), 'claude-hook', '--session', s.session_id], { input: JSON.stringify(payload), windowsHide: true, encoding: 'utf8', timeout: 15000 })
83
86
  if (r.status !== 0) die(1, `hook exited ${r.status}: ${(r.stderr || '').slice(0, 300)}`)
@@ -98,6 +101,96 @@ function simulateLimit(s) {
98
101
  die(2, `simulate-limit drives the claude hook path (and the agy/grok log); codex's wall comes from its own rollout file, which Leg never writes. Use "leg sessions handoff ${s.session_id}" to force the switch.`)
99
102
  }
100
103
 
104
+ // `claude`, `claude/work`, `claude/opus`, `claude/work/opus`. Three parts are
105
+ // unambiguous. Two are not, so the second is read as an account when that
106
+ // account exists and as a model when the agent has one by that name; a word
107
+ // that is neither is refused by name rather than guessed at.
108
+ export function parseTarget(value, { die: fail = (code, msg) => { throw new Error(msg) } } = {}) {
109
+ const parts = String(value).split('/').filter(Boolean)
110
+ const agent = parts[0]
111
+ if (!agent) fail(2, 'usage: --to <agent>[/<account>[/<model>]]')
112
+ const models = MODEL_ALIASES[agent] ?? []
113
+ if (parts.length >= 3) return { agent, account: parts[1], model: parts[2].toLowerCase() }
114
+ if (parts.length === 2) {
115
+ const second = parts[1]
116
+ const accounts = readAccounts()[agent] ?? ['default']
117
+ if (accounts.includes(second)) return { agent, account: second, model: null }
118
+ if (models.includes(second.toLowerCase())) return { agent, account: 'default', model: second.toLowerCase() }
119
+ fail(2, `"${second}" is neither a ${agent} account (${accounts.join(', ')}) nor a ${agent} model (${models.join(', ') || 'none known'})`)
120
+ }
121
+ return { agent, account: 'default', model: null }
122
+ }
123
+
124
+ // What a rung is doing right now, in the words the board uses: the wall and its
125
+ // clock, else the percentage of the bucket that binds it, else "no figure".
126
+ // Never a guess: an agent that publishes no number says so.
127
+ function rungState(rung) {
128
+ const u = readUsage(rung.agent, rung.account)
129
+ const wall = rung.model ? u.walls?.[rung.model] : null
130
+ if (wall && wallActive(wall)) return `${rung.model} out until ${fmtReset(wall.limited_until)}`
131
+ if (!isAvailable(u)) return `at its limit until ${fmtReset(u.limited_until)}`
132
+ const b = binding(u, rung.model ?? null)
133
+ if (b && Number.isFinite(b.percent)) return `${Math.round(b.percent)}% of the ${b.model ? b.model + ' ' : ''}${b.kind === 'session' || b.kind === 'five_hour' ? '5h' : 'week'} window`
134
+ return 'no figure'
135
+ }
136
+
137
+ function printLadder() {
138
+ const prefs = readPreferences()
139
+ const ladder = prefs.handoff_ladder
140
+ const rows = evaluateLadder({ from: null, list: ladder, maySpend: prefs.may_spend, reserve: prefs.reserve, automatic: true, climbBack: prefs.climb_back, ladder })
141
+ out('The ladder a terminal falls down when its login stops. Rung 1 first, every time.')
142
+ ladder.forEach((rung, i) => {
143
+ const r = rows[i]
144
+ const when = rung.when === 'always' ? '' : ` when ${rung.when}`
145
+ out(` ${String(i + 1).padEnd(2)} ${rungLabel(rung).padEnd(20)} ${rungState(rung).padEnd(34)} ${r.ok ? 'ready' : r.reason}${when}`)
146
+ })
147
+ out('')
148
+ out(`spending: ${prefs.may_spend ? 'on (a credits or metered rung may be taken unattended)' : 'off (a credits or metered rung is skipped unattended)'} · leg ladder spend on|off`)
149
+ out(`climb back: ${prefs.climb_back === 'never' ? 'never (stay on the lower rung until you press Back)' : 'at the next hand-off'}`)
150
+ const reserve = Object.entries(prefs.reserve ?? {})
151
+ out(`reserve: ${reserve.length ? reserve.map(([a, p]) => `${a} ${p}%`).join(', ') : 'none'}`)
152
+ out(`order (what older readers see): ${prefs.handoff_order.join(' → ')}`)
153
+ }
154
+
155
+ function ladderCommand(cmd, args) {
156
+ if (!cmd || cmd === 'ls' || cmd === 'show') return printLadder()
157
+ const prefs = readPreferences()
158
+ const ladder = prefs.handoff_ladder.map((r) => ({ ...r }))
159
+ if (cmd === 'set') {
160
+ const [nRaw, target] = args._
161
+ const n = parseInt(nRaw, 10)
162
+ if (!Number.isFinite(n) || n < 1) die(2, 'usage: leg ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]')
163
+ if (!target) die(2, 'usage: leg ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]')
164
+ const want = parseTarget(target, { die })
165
+ const rung = { ...want, when: typeof args.when === 'string' ? args.when : 'always' }
166
+ const at = Math.min(n, ladder.length + 1) - 1
167
+ ladder[at] = rung
168
+ try {
169
+ const saved = writePreferences({ handoff_ladder: ladder })
170
+ out(`rung ${at + 1} is ${rungLabel(saved.handoff_ladder[at])}${rung.when !== 'always' ? `, when ${rung.when}` : ''}`)
171
+ } catch (err) { die(2, err.message) }
172
+ return printLadder()
173
+ }
174
+ if (cmd === 'rm') {
175
+ const n = parseInt(args._[0], 10)
176
+ if (!Number.isFinite(n) || n < 1 || n > ladder.length) die(2, `usage: leg ladder rm <n> (1..${ladder.length})`)
177
+ if (ladder.length === 1) die(2, 'that is the only rung left: a ladder with no rungs has nowhere to hand off to')
178
+ const [gone] = ladder.splice(n - 1, 1)
179
+ try { writePreferences({ handoff_ladder: ladder }) } catch (err) { die(2, err.message) }
180
+ out(`removed rung ${n}: ${rungLabel(gone)}`)
181
+ return printLadder()
182
+ }
183
+ if (cmd === 'spend') {
184
+ const v = args._[0]
185
+ if (!['on', 'off'].includes(v)) die(2, 'usage: leg ladder spend on|off')
186
+ const saved = writePreferences({ may_spend: v === 'on' })
187
+ return out(saved.may_spend
188
+ ? 'spending is ON: an unattended hand-off may take a rung that bills credits.'
189
+ : 'spending is OFF: an unattended hand-off skips any rung that bills credits, and says so in the ledger.')
190
+ }
191
+ die(2, `unknown ladder command "${cmd}" (ls|set <n> <agent>[/<account>[/<model>]]|rm <n>|spend on|off)`)
192
+ }
193
+
101
194
  function fmtCard(c) {
102
195
  const st = c.pipeline?.find((s) => s.name === c.station)
103
196
  const leg = st?.kind === 'agent' ? ` leg ${c.leg}/${st.chain.length} (${st.chain[c.leg]?.adapter ?? '-'})` : ''
@@ -159,11 +252,35 @@ async function main() {
159
252
  for (const s of list) out(`${s.session_id} [${s.status}] ${s.agent}${s.account !== 'default' ? '/' + s.account : ''} ${s.repo_name ?? s.cwd}${s.branch ? '@' + s.branch : ''} turns=${s.turns} ${s.limits ? `5h ${s.limits.five_hour?.pct ?? '-'}% 7d ${s.limits.seven_day?.pct ?? '-'}%` : ''} ${String(s.task ?? '').slice(0, 50)}`)
160
253
  return
161
254
  }
162
- const id = args._[0] || die(2, `usage: leg sessions ${cmd} <session-id>`)
255
+ const id = args._[0] || die(2, `usage: leg sessions ${cmd} <session-id>${cmd === 'handoff' ? ' [--to <agent>[/<account>]]' : ''}`)
163
256
  const s = readSession(id) || die(3, `session not found: ${id}`)
164
257
  if (cmd === 'show') return out(JSON.stringify({ session: s, events: readSessionEvents(id) }, null, 2))
165
258
  if (cmd === 'events') { for (const e of readSessionEvents(id)) out(`${e.ts} ${String(e.type).padEnd(18)} ${e.summary}`); return }
166
- if (cmd === 'handoff') { if (!isActive(s)) die(3, `session ${id} is not active`); requestControl(id, { handoff: true }); return out(`handoff requested for ${id}`) }
259
+ if (cmd === 'handoff') {
260
+ if (!isActive(s)) die(3, `session ${id} is not active`)
261
+ // --to names the destination, the same choice the board's picker makes.
262
+ // Validated here for the same reason it is validated there: a pick that
263
+ // is not a destination, is not installed, or is at its wall must be
264
+ // refused now, not silently turn into "whatever is next".
265
+ if (typeof args.to === 'string') {
266
+ const want = parseTarget(args.to, { die })
267
+ const order = normalizeHandoffOrder(s.handoff_order)
268
+ const ladder = ladderFor(s)
269
+ const chain = candidates({ agent: s.agent, account: s.account, model: s.model ?? null, accounts: readAccounts(), order, ladder })
270
+ const hit = chain.find((c) => c.agent === want.agent && c.account === want.account && (want.model ? (c.model ?? null) === want.model : true))
271
+ const label = rungLabel(want)
272
+ if (!hit) die(2, `${label} is not a destination for this terminal (${chain.map((c) => rungLabel(c)).join(', ') || 'none'})`)
273
+ if (s.installed && s.installed[want.agent] === false) die(3, `${label} is not installed on this machine`)
274
+ const u = readUsage(want.agent, want.account)
275
+ if (!isAvailable(u)) die(3, `${label} is at its usage limit until ${fmtReset(u.limited_until)}; pick another, or drop --to to take the next option in the order`)
276
+ if (hit.model && wallActive(u.walls?.[hit.model])) die(3, `${label} is out until ${fmtReset(u.walls[hit.model].limited_until)}; pick another rung, or drop --to to take the next open one`)
277
+ const target = { agent: hit.agent, account: hit.account, ...(hit.model ? { model: hit.model } : {}) }
278
+ requestControl(id, { handoff: true, target })
279
+ return out(`handoff to ${label} requested for ${id}`)
280
+ }
281
+ requestControl(id, { handoff: true })
282
+ return out(`handoff requested for ${id}`)
283
+ }
167
284
  if (cmd === 'end') { if (!isActive(s)) die(3, `session ${id} is not active`); requestControl(id, { end: true }); return out(`end requested for ${id}`) }
168
285
  if (cmd === 'rm') {
169
286
  if (isActive(s)) die(3, `session ${id} is still active; end it first`)
@@ -182,9 +299,18 @@ async function main() {
182
299
  }
183
300
  removeSession(id); return out(`removed ${id}`)
184
301
  }
185
- if (cmd === 'simulate-limit') return simulateLimit(s)
302
+ // --message drives a particular wording through the real classifier, which
303
+ // is the only way to reach a per-model wall without waiting for one:
304
+ // --message "You've reached your Fable limit." walls fable and leaves the
305
+ // rest of the login open (src/buckets.mjs).
306
+ if (cmd === 'simulate-limit') return simulateLimit(s, { message: typeof args.message === 'string' ? args.message : null })
186
307
  die(2, `unknown sessions command "${cmd}" (ls|show|events|handoff|end|rm|simulate-limit)`)
187
308
  }
309
+ if (group === 'ladder') {
310
+ // The fallback ladder, in the terminal: the same rungs, the same live
311
+ // state and the same skip reasons the board's picker shows.
312
+ return ladderCommand(cmd, args)
313
+ }
188
314
  if (group === 'resume') {
189
315
  // The read side of the pointer. Freshness is never read out of the file:
190
316
  // it is recomputed from git here, now, so a resume file cannot describe a
@@ -226,7 +352,9 @@ async function main() {
226
352
  const showLink = (person, token, s) => {
227
353
  out(`${person.name} is on the board (${person.role}). Their link, shown once:`)
228
354
  out(` ${linkFor(s, token)}`)
229
- out(person.role === 'owner' ? 'Open it on this machine, or any machine that can reach that address.' : 'They see the terminals lane read-only: no prompts, no file names, no logs, no bundles. They can ask for a hand-off; you approve it on the card.')
355
+ out(person.role === 'owner' ? 'Open it on this machine, or any machine that can reach that address.'
356
+ : person.role === 'operator' ? 'They get the pipeline board — cards, the floor, the adapters — and their own terminals. Not this machine’s settings, not its history index, not anyone else’s terminal.'
357
+ : 'They see the terminals lane read-only: no prompts, no file names, no logs, no bundles. They can ask for a hand-off; you approve it on the card.')
230
358
  }
231
359
  if (!cmd || cmd === 'ls' || cmd === 'status') {
232
360
  if (!share.on || !share.people.length) {
@@ -234,9 +362,12 @@ async function main() {
234
362
  out('Turn it on: leg share on (the Tailscale address; --bind lan, or --bind <address>)')
235
363
  return
236
364
  }
237
- out(`share is on: http://${share.bind}:${share.port} (${share.bind_kind})`)
238
- for (const p of share.people) out(` ${p.name.padEnd(16)} ${p.role.padEnd(6)} added ${String(p.created_at).slice(0, 10)}${p.last_seen ? ` last seen ${String(p.last_seen).slice(0, 16).replace('T', ' ')}` : ''}`)
365
+ out(`share is on: ${scheme(share)}://${share.bind}:${share.port} (${share.bind_kind})`)
366
+ for (const p of share.people) out(` ${p.name.padEnd(16)} ${p.role.padEnd(9)} added ${String(p.created_at).slice(0, 10)}${p.last_seen ? ` last seen ${String(p.last_seen).slice(0, 16).replace('T', ' ')}` : ''}`)
239
367
  out('')
368
+ out(tlsConfigured(share)
369
+ ? `TLS: certificate ${share.tls?.cert ?? '(from the environment)'}. The board on 127.0.0.1 stays plain http for this machine's own browser.`
370
+ : 'No TLS: keep this on Tailscale or a network you trust. Add one with leg share on --tls-cert <file> --tls-key <file> (tailscale cert <machine>.<tailnet>.ts.net issues a trusted pair).')
240
371
  out('A token is shown once. Lost one? leg share rotate <name>. Everyone out: leg share off')
241
372
  return
242
373
  }
@@ -246,19 +377,26 @@ async function main() {
246
377
  const ent = entitlement()
247
378
  if (!allows(ent, 'share')) die(2, ent.ok ? `leg share is part of the Team plan (per seat); this machine has a ${ent.plan} license. ${BUY_URL}` : describeLicense(ent))
248
379
  try {
249
- const r = await turnOn({ bind: a.bind ?? 'tailscale', port: a.port ? parseInt(a.port, 10) : undefined, owner: a.owner })
380
+ const r = await turnOn({
381
+ bind: a.bind ?? 'tailscale', port: a.port ? parseInt(a.port, 10) : undefined, owner: a.owner,
382
+ tlsCert: typeof a['tls-cert'] === 'string' ? a['tls-cert'] : null,
383
+ tlsKey: typeof a['tls-key'] === 'string' ? a['tls-key'] : null,
384
+ })
250
385
  await restartBoard()
251
- out(`share is on: the board is at http://${r.share.bind}:${r.share.port} (${r.share.bind_kind})`)
386
+ out(`share is on: the board is at ${scheme(r.share)}://${r.share.bind}:${r.share.port} (${r.share.bind_kind})`)
252
387
  if (r.token) showLink(r.owner, r.token, r.share)
253
- out('Add someone: leg share add <name>')
254
- out('No TLS: keep this on Tailscale or a network you trust. Anyone with a link sees that your terminals exist and how much usage is left.')
388
+ out('Add someone: leg share add <name> [--role operator|guest]')
389
+ out(tlsConfigured(r.share)
390
+ ? `TLS is on, from ${r.share.tls?.cert ?? 'the environment'}. Renew the pair and run leg down && leg up to pick up a new one.`
391
+ : 'No TLS: keep this on Tailscale or a network you trust. Anyone with a link sees that your terminals exist and how much usage is left. leg share on --tls-cert <file> --tls-key <file> turns it on; tailscale cert <machine>.<tailnet>.ts.net issues a trusted pair.')
255
392
  } catch (err) { die(2, err.message) }
256
393
  return
257
394
  }
258
395
  if (cmd === 'add') {
259
- const name = args._[0] || die(2, 'usage: leg share add <name> [--role owner|guest]')
396
+ const name = args._[0] || die(2, `usage: leg share add <name> [--role ${ROLES.join('|')}]`)
260
397
  try {
261
- const r = addPerson(name, { role: args.role === 'owner' ? 'owner' : 'guest', share })
398
+ if (args.role !== undefined && !ROLES.includes(String(args.role))) die(2, `bad role "${args.role}" (${ROLES.join('|')})`)
399
+ const r = addPerson(name, { role: typeof args.role === 'string' ? args.role : 'guest', share })
262
400
  showLink(r.person, r.token, r.share)
263
401
  if (!r.share.on) out('share is still off: leg share on')
264
402
  } catch (err) { die(2, err.message) }
@@ -330,6 +468,12 @@ async function main() {
330
468
  const code = await harnessCommand(cmd, args, { out, die })
331
469
  process.exit(code)
332
470
  }
471
+ if (group === 'adapter' || group === 'adapters') {
472
+ // Custom adapters: any CLI as a card agent, from a JSON spec on disk
473
+ // (src/adapters/custom.mjs). The built-ins need none of this.
474
+ const code = await adapterCommand(cmd, args, { out, die })
475
+ process.exit(code)
476
+ }
333
477
  if (group === 'history' || group === 'worktrees') {
334
478
  // Every conversation on this machine, Leg's own and the ones the agents'
335
479
  // stores hold: a read-only index (src/history/index.mjs). `continue`
@@ -470,13 +614,19 @@ async function main() {
470
614
  out(openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
471
615
  return
472
616
  }
473
- if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|history|worktrees|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
617
+ if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|ladder|history|worktrees|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
474
618
  out(`leg ${VERSION}, your coding agents, with a board alongside and a handoff when one hits its limit
475
619
  claude|codex|agy|grok [args...] the normal interactive agent in this terminal; args pass straight through
476
620
  the board opens once, the session shows as a card, usage is tracked, a limit hands off
477
621
  a second live session in one checkout gets its own worktree (--no-worktree to share)
478
622
  auto-approve mode (--no-auto-approve to opt out)
623
+ --resume-card <id> takes over a background card: this terminal opens in that card's
624
+ worktree, primed from its bundle (Take over on the board pauses it and prints this)
479
625
  sessions ls|show|events|handoff|end|rm|simulate-limit <id>
626
+ handoff --to <agent>[/<account>[/<model>]] names the rung; simulate-limit --message "<text>"
627
+ ladder [ls] the fallback ladder: every rung, what it costs, and what it is doing right now
628
+ ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]
629
+ ladder rm <n> | ladder spend on|off
480
630
  history [ls] [--provider p] [--repo r] [--search q] [--json]
481
631
  every conversation on this machine: Leg's own, and the ones Claude Code, Codex,
482
632
  Grok, Antigravity and Copilot keep in their own stores (read only, nothing moved)
package/docs/DECISIONS.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  Durable product and design decisions that the code does not explain on its own. One entry per decision, newest first.
4
4
 
5
+ ## 2026-09-17: a hand-off destination is a rung of (agent, account, model), and usage is a property of a row
6
+
7
+ - **What.** `preferences.json` keeps `handoff_order` and derives it from a new `handoff_ladder` of rungs, each `{agent, account, model, when, cost}`. `chooseNext` walks the ladder from rung 1 every time. `spawnSpec` pushes the rung's model flag, so the interactive path finally carries `--model` the way every headless adapter already did. A claude downshift with a known session id starts `claude --resume <id> --model <alias>` and skips the bundle; every other rung is primed from the bundle. Usage stops being a region on the board: the binding bucket is one strip token at the top and one phrase on each row, and the four login panels move intact behind a **Capacity and models** disclosure. Live cards become rows in a **Background** panel directly under Terminals; finished cards collapse into one ledger line.
8
+ - **Why a model is part of the destination.** Anthropic's session and weekly limits are shared across every model, and the Opus and Sonnet limits are family scoped: switching outside that family keeps you working (costs doc, line 132). So the cheapest hand-off available is usually not another CLI at all, it is another model on the subscription you already pay for, and until now Leg could not express it. Making the model part of the destination is what lets a Fable wall be a wall on Fable rather than on claude.
9
+ - **Why downshift by `--resume` and upshift by bundle.** A same-login model change can hold the conversation, and holding it is worth more than anything the bundle carries, because the bundle is a summary and the transcript is the thing itself. `--resume` and `--model` are each verified in `claude --help`; composing them was probed and recorded at `fixtures/live/claude/resume-model-probe.json`, which is also where the cost of the other direction is written down: the resumed context is re-read at the new model's rates, so an upshift pays for that re-read at the higher price. Every other rung crosses a CLI boundary where no shared conversation exists, so it takes the bundle, which is the mechanism Leg already had. `codex resume <id> -m <model>` is the same shape and is not probed, so codex rungs take the bundle and do not claim to keep anything.
10
+ - **Why `may_spend` is off by default.** An interactive Fable-on-credits request shows a consent prompt; with nobody at the terminal that prompt is held five minutes and the turn then ends; in `-p` mode, which is how every card runs, Claude Code never shows it and bills without asking (model-config doc, lines 68, 72 and 77). A stalled turn and a silent charge are both worse than a skipped rung, so an automatic hand-off refuses a `credits` or `metered` rung until a human says otherwise, and writes the reason to the ledger. A hand-off a human presses is not automatic and is not gated.
11
+ - **Why usage became a strip and a row property.** On the seeded board the first terminal row sat at 1382px: about a thousand pixels of gauges before the thing the board is for. The gauges are not deleted, they move behind a disclosure, because every notch, degrade path and `aria-valuetext` in them is earned. What replaces them at the top is smaller and says more: the *binding* bucket per login rather than whichever window happened to be stored. The same figure on a row is per model and therefore real; the shared-login caveat is said once, at the region head, where adding three rows' figures together is stopped.
12
+ - **Why cards live under Terminals.** A card is a terminal you are not sitting at: same register, same one sentence, same ladder, same bundle. Putting live cards in a drawer while live terminals are rows made the same object two shapes and taught nobody anything. Liveness, not kind, decides the surface, so ten finished cards are one ledger line and one running card is a row. The honest differences are printed rather than hidden: a `-p` leg is mute until it exits, so its sentence says so.
13
+ - **What this rules out.** A number for agy (it publishes none), dollars for a subscription session (no transcript carries a cost field), and per-terminal attribution of a shared login (nothing publishes it).
14
+
5
15
  ## 2026-09-16: the portable harness is an opt-in subsystem over a vendored, hash-pinned engine
6
16
 
7
17
  - **What.** `leg harness` carries the source agent's working environment (rules, identity, hooks, skills, subagents, commands, MCP servers, permissions) to the agent a hand-off lands on. The capture, neutral bundle and apply engine is the Agnostic AI port engine (MIT), embedded byte for byte under `src/harness/vendor/agnostic-ai/` and driven through its library entry; Leg owns consent, policy, the client registry, state, the fingerprint, the trail and the hand-off decision (`src/harness/*.mjs`).
package/docs/DEMO.md CHANGED
@@ -79,16 +79,19 @@ It prints a preflight table, then
79
79
  address in your browser. The board polls every three seconds, so nothing below
80
80
  needs a reload.
81
81
 
82
- The page is one column, read top to bottom: the instrument head with one row per
83
- login, then **Terminals**, **Landed on main**, **Background tasks**, and
84
- **Settings**. This demo happens entirely in **Background tasks**. On a fresh
85
- board home the head reads `no reading` on every rail, because no agent has
86
- reported usage into this home yet.
82
+ The page is one column, read top to bottom: the verdict, the capacity strip
83
+ (one token per login, with the login panels behind its `Capacity and models`
84
+ disclosure), **Terminals**, **Background** (live cards only, hidden while none
85
+ are running), the ledger (finished terminals, landed commits, conversations
86
+ and finished cards, as four counts), and **Settings**. This demo happens
87
+ entirely in **Background**. On a fresh board home the capacity strip reads
88
+ `no reading` for every login, because no agent has reported usage into this
89
+ home yet.
87
90
 
88
91
  ### 3. Queue the card
89
92
 
90
- Click **New card** in the **Background tasks** head. The dialog opens on **New
91
- background card**.
93
+ Click **New card**, in the ledger's finished-cards cell below Terminals and
94
+ Background. The dialog opens on **New background card**.
92
95
 
93
96
  1. **Repo path**: `C:\baton-demo\toy-demo`.
94
97
  2. **Task**: `Add a file greeting.txt containing 'hello from baton'`.
@@ -112,8 +115,9 @@ The recorded chain is `fake-claude>fake-codex`.
112
115
 
113
116
  ### 4. Leg 1, on fake-claude
114
117
 
115
- The card appears as a row in **Background tasks**. The line beside the region
116
- title reads `1 running`, and the row reads left to right:
118
+ The card appears as a row in **Background**, directly under Terminals. The
119
+ line beside the region title reads `1 running`, and the row reads left to
120
+ right:
117
121
 
118
122
  - `fake-claude`, `build`, `running`
119
123
  - the title, and under it the one sentence
@@ -173,11 +177,13 @@ the bundle Leg wrote. The status word is `running` again, the run is `run=2`,
173
177
  and the chain reads `fake-claude · handed off · fake-codex · running`. This is
174
178
  `demo-4-codex-running.png`.
175
179
 
176
- Twenty seconds later the station finishes. The line beside the region title
177
- reads `1 finished`, the status word is `done`, the sentence is
178
- `done after 2 runs, card done: all 1 station(s) complete`, the chain reads
179
- `fake-claude · handed off · fake-codex · done`, and the buttons are **Rerun**
180
- and **Remove**. This is `demo-5-done.png`.
180
+ Twenty seconds later the station finishes. The card leaves **Background**,
181
+ which goes back to empty and hides itself, and the ledger's finished-cards
182
+ cell reads `1 finished card, 1 done, last <time>`. Clicking **View** there
183
+ opens the one line the drawer keeps for it: `done after 2 runs, card done:
184
+ all 1 station(s) complete`. A finished card carries no buttons; Rerun and
185
+ Remove apply to a live row, and this one no longer is one. This is
186
+ `demo-5-done.png`.
181
187
 
182
188
  The worktree named in **Where** now holds the file the fake agent wrote,
183
189
  `hello-fake.txt`, and `.leg/DONE`.
@@ -180,3 +180,4 @@ tests. Rows for the shape changes a later reader would otherwise wonder about.
180
180
  | 2026-09-14 | src/resume.mjs `refreshPointers()` | the board was to skip any checkout with a live terminal, so a live hand-off is never clobbered | it skips only when the pointer's OWN stamped session is live | the case that started this had a hand-written three-day-old `RESUME.md` and a different terminal live in the same checkout; the first rule would have left it exactly as it was |
181
181
  | 2026-09-14 | src/resume.mjs idle pointer | a checkpoint bundle was described as "the last hand-off", and the per-session file was named unconditionally | `lineage.to` decides hand-off vs checkpoint, and the file is named only when it exists on disk | seen in the live pointer for this repo: it claimed a hand-off that never happened and pointed at a `RESUME-<id>.md` that was never written, which is the same class of lie the module exists to stop |
182
182
  | 2026-09-14 | src/board/sessions.js `renderDrawer()` | the whole panel is rebuilt every 3 s poll so relative timestamps stay honest | still rebuilt, but every scrollable box carries a stable `data-scroll-key` and its offset is carried across | the rebuild reset the task box, each message and the timeline to the top every three seconds, so a long message could not be read at all |
183
+ | 2026-09-17 | src/board/sessions.js `verdictLines()` | spec A.5 lists "a model bucket came back and a terminal is still downshifted" below the two branches that print a standing percentage | the came-back branch is tested before them, right after the model-walled branch | as written it can never fire: "one login carries every live terminal" and "several logins carry work" both return for any login that has a figure at all, so the only state change worth telling a downshifted reader about was unreachable |
package/docs/ERRORS.md CHANGED
@@ -3,6 +3,59 @@
3
3
  What broke, why, and what fixed it. One entry per failure, newest first. A first
4
4
  occurrence has to be written down or a repeat is never countable.
5
5
 
6
+ ## 2026-09-17: a new field on the session view leaked the owner's reset times to a guest, through the guest's own terminal
7
+
8
+ **Fixed in `src/server.mjs` (`sessionsView` decides `guest` before the map and
9
+ blanks `handoff_targets[].resets_at` for one). Caught by
10
+ `test/share-security.test.mjs`, which was already asserting it.**
11
+
12
+ The hand-off picker needed each destination's availability, so `handoff_targets`
13
+ went onto every session in `sessionsView` carrying `resets_at`. The obvious
14
+ mental model was "a guest gets `redactSession`, which lists its fields
15
+ explicitly, so a new field is invisible to them". That is only true of someone
16
+ else's terminal. `mine(s)` is true for a guest's **own** terminal, and that path
17
+ spreads the whole object. So a guest's own card carried the exact reset
18
+ timestamp of every account on the machine, including the owner's, which is
19
+ precisely the usage data the share design keeps off a guest's board.
20
+
21
+ The lesson is about where redaction lives, not about this field. There are two
22
+ paths out of `sessionsView`: `redactSession` (allow-list, safe by default) and
23
+ the `mine(s)` spread (deny-list, unsafe by default). **Any field added to a
24
+ session object is visible to whoever owns that session, and a guest owns one.**
25
+ A field that carries machine-level data has to be blanked where it is built, not
26
+ left to a redactor that never sees it.
27
+
28
+ What made this cheap: the security suite already asserted the whole guest
29
+ response text contains no reset time, so the leak failed a test in the same run
30
+ that introduced it. The test was written against the property ("a guest board
31
+ carries no reset time"), not against the fields that existed when it was
32
+ written, which is why it still caught a field invented months later.
33
+
34
+ Two smaller ones from the same change, both worth the line:
35
+
36
+ - Registering `grok` broke three tests that asserted `unknown adapter "grok"`.
37
+ A test that encodes "not supported yet" as an assertion becomes a tripwire on
38
+ the day support lands. Assert the refusal with a name nothing will ever
39
+ provide (`no-such-agent`), so the test outlives the gap it was describing.
40
+ - `names()` started reading `$LEG_HOME/adapters` from disk, and `/api/health`
41
+ calls it once for the list and once per adapter. That put a readdir, a read
42
+ and a JSON parse per spec on the same event loop the terminals lane is pushed
43
+ from, and `/api/health` went over its 1 s budget in
44
+ `test/board-responsiveness.test.mjs`. Cached against the directory's entry
45
+ list with a one-second floor. The board's hot path is `/api/health` plus the
46
+ sessions view; anything new they call has to be counted, not assumed cheap.
47
+ (This is the same event-loop failure as the entry below, from the other end.)
48
+ - `test/board-responsiveness.test.mjs` failed the ship twice at 1055 ms and
49
+ 1140 ms against a hard `< 1000 ms`, and passed three times out of three when
50
+ run alone. An absolute millisecond budget on a four-way-concurrent runner
51
+ measures the machine, not the code. It now takes an idle baseline in the same
52
+ process and asserts the busy request is not 20x it, with a 3 s ceiling for the
53
+ symptom the test is named for. **It was only trusted after being made to
54
+ fail**: putting the original shape back (no floor, no fingerprint, a 1.2 s
55
+ blocking view on every watcher event) made it report 4,810 ms and fail both
56
+ assertions, which is the "four to fourteen seconds" the entry below describes.
57
+ A perf test that has never been watched failing is a number, not a guard.
58
+
6
59
  ## 2026-09-17: one running terminal saturated the board's event loop, and four separate symptoms came out of it
7
60
 
8
61
  **Fixed in `src/server.mjs` (watcher filter, stat fingerprint, push floor, cached
@@ -529,3 +582,44 @@ it), and check whether a board was listening on 4747 at the time.
529
582
  in the hand-off as in flight, or the next agent ships without it. And a
530
583
  cache keyed on a directory's mtime sees files added and removed, never a
531
584
  file rewritten in place.
585
+
586
+ ## The 0.12.0 redesign review confirmed 51 findings before the fix pass, 23 of them high (2026-09-18)
587
+
588
+ - **What happened.** The eight-step redesign (per-model buckets, the ladder,
589
+ the capacity strip, cards reborn) was reviewed before commit by six finders
590
+ and three refuters per finding: 174 agents, 51 findings confirmed, 23 high.
591
+ The highs were all in code that had passed its own tests: `binding()`
592
+ short-circuited on the first active bucket instead of the one that stops
593
+ the requested model, `summarize()` parsed the ledger once per card per
594
+ refresh, the take-over route handed out a command for a card with no
595
+ checkout, `cardWorkRoot()` fell back to the main checkout, and the guest
596
+ payload leaked buckets, walls and hand-off reasons through the new fields.
597
+ - **Fix.** Three parallel fix passes, one owner per file group, every finding
598
+ closed with a regression test in the same change; the suite went from 783
599
+ to 830 tests. The lockfile's root `repository` field, lost when Playwright
600
+ was installed, was restored so `scripts/npm-publish-gate.mjs` passes.
601
+ - **The lesson that generalises.** A feature's own tests prove the feature's
602
+ own model of itself. The defects a review finds sit where two new pieces
603
+ meet (a new field and an old redaction list, a new route and an old helper),
604
+ and the review has to run on the uncommitted tree, before the commit exists
605
+ to be pushed by someone else. A `npm i` that regenerates the lockfile is a
606
+ release change and gets the publish gate run in the same turn.
607
+
608
+ ## A seeded board's End-as-card button wrote into a real repo (2026-09-17)
609
+
610
+ - **What happened.** Driving the new "End, and keep going as a card" verb in
611
+ a browser against `scripts/seed-wes-board.mjs`'s board, the click on the
612
+ recruiting-tool row hit `POST /api/sessions/:id/end-as-card`, which did
613
+ exactly its job: it wrote a hand-off bundle under
614
+ `C:\Projects\recruiting-tool\.context-handoffs\` and cut a worktree and a
615
+ `leg/card-...` branch there. The seed's rows named real repositories on this
616
+ machine because long real paths were what the layout had to be measured
617
+ against. Nothing ran in the worktree (the server was stopped within a
618
+ minute); the worktree was deregistered and the branch deleted, and the
619
+ directory and bundle were left for a hand delete.
620
+ - **Fix.** The seed's repo paths now live under `C:\Projects-seed\...`, which
621
+ does not exist, so every git-backed action on a seeded row answers 409
622
+ instead of touching a checkout. The row still prints the same length.
623
+ - **The lesson that generalises.** A seeded board is safe to look at and not
624
+ safe to click: any row that names a path that exists is a live control on
625
+ that path. Seed paths must be realistic in shape and impossible in fact.
@@ -1,5 +1,30 @@
1
1
  # Roadmap v2: the software factory
2
2
 
3
+ **0.12.0 (2026-09-17) added the model dimension and made cards a first-class
4
+ surface.** A hand-off destination is now a rung of (agent, login, model), not
5
+ just an agent, so a Fable wall moves the terminal to opus on the same
6
+ subscription before it moves to another CLI, and for claude that move keeps
7
+ the conversation (`claude --resume <id> --model <alias>`). The **Hand off now
8
+ to** picker lists those rungs with their models, whether each keeps the
9
+ conversation, and why a greyed one buys nothing; the same list is a ladder
10
+ editor in Settings, in a terminal's expansion and behind `leg ladder`. Live
11
+ cards left the drawer and became rows in a **Background** panel directly under
12
+ Terminals, with a one-line entry and the thirteen-field dialog demoted to
13
+ **More settings**; `End, and keep going as a card` and `Take over` are the two
14
+ doors between a terminal and a card. The board top became a capacity strip
15
+ over a **Capacity and models** drawer, a row says `waiting on you` with the
16
+ question when Claude Code's `Notification` hook fires, and a burn-rate figure
17
+ prints only with its sample count. That closes the model dimension, the
18
+ picker's second half, and the cards item below.
19
+
20
+ **0.11.0 (2026-09-17) opened the chain and finished the share story.** grok is
21
+ a registered card adapter; any other CLI becomes one from a JSON spec
22
+ (`leg adapter add`, no code); **Hand off now** can name its destination
23
+ instead of taking the next in the order; `leg share` grew TLS from a
24
+ certificate pair you supply, an `operator` role between owner and guest, and
25
+ an audit trail of who did what across every terminal and every card. That
26
+ closes items 1 and 4 below, and the "hand off now to \<adapter>" picker.
27
+
3
28
  **0.3.0 (2026-09-11) shipped item 1 and the first half of continuous landing.**
4
29
  A second live session in one checkout gets its own worktree and branch, and
5
30
  **Land** on its card sends that branch through the merge queue (rebase, tests,
@@ -33,23 +58,56 @@ event names its actor, a board that reads only the ledger, and an auth/bind seam
33
58
 
34
59
  ## Next
35
60
 
36
- 1. **Multi-human network access**, shipped in 0.3.0 as `leg share` (a token
37
- and a name per human, per-human actor ids on every event, rate limits, a
38
- guest's read-only redacted board). What is left: TLS termination, token
39
- scopes finer than owner and guest, and an audit view of who did what across
40
- sessions.
61
+ 1. ~~**Multi-human network access**~~ done. 0.3.0 shipped `leg share`; 0.11.0
62
+ shipped the three that were left: TLS (`leg share on --tls-cert/--tls-key`,
63
+ or `LEG_TLS_CERT`/`LEG_TLS_KEY`, from a pair you supply Leg issues none),
64
+ the `operator` role between owner and guest, and the audit trail
65
+ (`/api/audit`, Settings → Audit trail).
41
66
  2. **Review station with human reviewers**: a `human` station kind that shows
42
67
  the diff, the bundle and the test tail, with Approve / Request changes /
43
68
  Reassign as buttons; reviewer identity from the token.
44
69
  3. **`pr` land mode live**: `gh pr create` argv is built and stub-tested today;
45
70
  run it for real behind an explicit per-card opt-in and a remote allowlist.
46
- 4. **More adapters**: grok (adapter written, unregistered until `grok login` and
47
- a passing probe on the machine) and muse only if a real CLI is verified; a
48
- generic "argv + JSON result" adapter for anything else.
71
+ 4. ~~**More adapters**~~ done. grok is registered (2026-09-17: flags read
72
+ from `grok --help` on 1.0.34, envelope read from the shipped binary, probe
73
+ reached the account and returned a real 402 wall that classified `limit`;
74
+ the success path is still unprobed for want of balance). Anything else,
75
+ muse included, is a custom adapter: a JSON spec in
76
+ `$LEG_HOME/adapters/<name>.json`, `leg adapter add`, no code
77
+ ([adapters.md](adapters.md#custom-adapters)).
49
78
  5. **OpenClaw Workboard mirror** once the bundled plugin is allowed
50
79
  (`plugins.allow`): the verb table in `src/sync/workboard.mjs` is the only
51
80
  thing to check against `openclaw workboard --help`.
52
81
 
53
- Also on the list: per-station prompt templates editable from the board, a
54
- "hand off now to <adapter>" picker, lease suggestions from the diff of the
55
- previous leg, and a floor view that shows lease contention over time.
82
+ 6. **Probe `codex resume <id> -m <model>`.** The `resume` subcommand and the
83
+ `-m` flag are each verified from `codex --help`; composing them is not, so
84
+ a codex rung ships primed from the bundle and only the claude rungs claim
85
+ to keep the conversation. One real run on a live codex session settles it.
86
+ The same probe shape applies to naming the terminal tab: whether codex, agy
87
+ and grok leave an OSC 2 title alone once the child starts drawing is
88
+ assumed, not known, which is why the tab title is the browser's and not the
89
+ terminal's.
90
+
91
+ Also on the list: per-station prompt templates editable from the board, lease
92
+ suggestions from the diff of the previous leg, and a floor view that shows
93
+ lease contention over time. (The "hand off now to \<adapter>" picker landed in
94
+ 0.11.0: Details → **Hand off now to**, or `leg sessions handoff <id> --to`.
95
+ 0.12.0 put models on its rows.)
96
+
97
+ ## Ruled out, with the reason
98
+
99
+ - **Phone or push notifications.** They need a relay, which means a server
100
+ that is not this machine holding a token that can reach you. Leg is
101
+ local-first, so the notice surfaces are the ones the machine already owns:
102
+ the browser tab badge (always on, no permission), a browser toast on the
103
+ board (off by default, gated on a secure context), and a terminal toast
104
+ through Claude Code's `Notification` hook (on by default).
105
+ - **Percentages for agy.** Antigravity CLI publishes no usage figure at all,
106
+ so there is nothing to read. agy's token says `no figure`, and its terminals
107
+ are shown by elapsed time instead. A number here could only be invented.
108
+ - **Dollars for subscription sessions.** No transcript Leg reads carries a
109
+ cost field. codex's `credits.balance` can be printed as a measured fact with
110
+ the word `credits`, and is never summed with an estimate.
111
+ - **Per-terminal attribution of a shared login.** Nothing publishes which
112
+ terminal spent which part of a window. The board says it once, at the
113
+ Terminals head (`4 share the claude login`), rather than guessing per row.