@ucsandman/legcli 0.12.0 → 0.13.1

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.
@@ -63,6 +63,12 @@
63
63
  }
64
64
  const AGENT_IDS = ['claude', 'codex', 'agy', 'grok']
65
65
  const DEFAULT_BIND = '127.0.0.1:4747'
66
+ // The version these page files shipped with. The server answers /api/health
67
+ // with the version of the PROCESS, and the two drift apart the moment a
68
+ // release lands on disk under a board that was started before it: the page
69
+ // then draws controls the process has no routes for (an empty agent select,
70
+ // no buckets). test/files-version.test.mjs pins this to package.json.
71
+ const FILES_VERSION = '0.13.1'
66
72
  const TIMELINE_CAP = 12
67
73
  // mirrors LOOPBACK in src/auth.mjs; state.bind is "<host>:<port>" and an IPv6
68
74
  // host arrives bracketed
@@ -112,6 +118,11 @@
112
118
  // one entry per repo with a live terminal: { repo, repo_name, branch }
113
119
  repoTrunks: [],
114
120
  preferences: null,
121
+ // { claude: [{id, label, default}], codex: [...], agy: [...], grok: [...] }
122
+ // from /api/models. null until it answers; an agent missing from it offers
123
+ // its provider default and nothing else, which is what a bare `leg <agent>`
124
+ // already does.
125
+ models: null,
115
126
  }
116
127
  // a card push while an agent writes its log only moves these two
117
128
  const VOLATILE_CARD_FIELDS = ['last_event', 'elapsed_ms']
@@ -420,6 +431,7 @@
420
431
  state.isOwner = !(data.you && data.you.role && data.you.role !== 'owner')
421
432
  state.healthKnown = true
422
433
  renderBoardFacts(data)
434
+ versionSkew(data.version)
423
435
  renderTokenMeta()
424
436
  // a guest is told who they are by health, which is open to them
425
437
  if (data.you && data.you.role && data.you.role !== 'owner') { guestMode(); return false }
@@ -1214,190 +1226,70 @@
1214
1226
  }
1215
1227
 
1216
1228
  // ---- 6.15 step 6: the one-line background entry (C.2) ----
1217
- // Always visible to the owner, one row above or below the Background panel.
1218
- // Its three nouns (repo, ladder, workflow) are inferred and are buttons that
1219
- // swap for a select in place; nothing here persists past a page reload.
1220
- let entryState = { task: '', repo: null, ladderStart: 0, pipeline: 'build', editing: null }
1221
- const PIPELINE_WORDS = { build: 'build only', 'build-land': 'build and land', factory: 'plan, build, review and land' }
1222
-
1223
- // Only a terminal that carries a `repo` is offered: /api/cards refuses a path
1224
- // that is not a git repository root, so a terminal whose cwd is a plain
1225
- // folder would put a value in this field that Start can only ever reject.
1226
- function knownRepos() {
1227
- const seen = new Map()
1228
- for (const s of state.sessions || []) if (s.repo && !seen.has(s.repo)) seen.set(s.repo, s.repo_name || s.repo)
1229
- for (const c of state.cards.values()) if (c.repo && !seen.has(c.repo)) seen.set(c.repo, c.repo_name || c.repo)
1230
- return [...seen].map(([path, name]) => ({ path, name }))
1231
- }
1232
-
1233
- // C.2: the most recently focused terminal's repo, else the last card's, else
1234
- // the first terminal's.
1235
- function inferRepo() {
1236
- const sessions = (state.sessions || []).filter((s) => s.repo)
1237
- const byFocus = sessions.length ? [...sessions].sort((a, b) => (Date.parse(b.last_activity) || 0) - (Date.parse(a.last_activity) || 0))[0] : null
1238
- if (byFocus) return { path: byFocus.repo, name: byFocus.repo_name || byFocus.repo }
1239
- const cards = [...state.cards.values()].sort((a, b) => (Date.parse(b.updated_at) || 0) - (Date.parse(a.updated_at) || 0))
1240
- if (cards[0] && cards[0].repo) return { path: cards[0].repo, name: cards[0].repo_name || cards[0].repo }
1241
- if (sessions[0]) return { path: sessions[0].repo, name: sessions[0].repo_name || sessions[0].repo }
1242
- return null
1243
- }
1244
-
1245
- function entryRepo() { return entryState.repo || inferRepo() }
1246
-
1247
- // the saved ladder, skipping a credits/metered rung while may_spend is off:
1248
- // a card never starts on a rung that bills without asking (-p mode does)
1249
- function ladderRungs() {
1250
- const prefs = state.preferences
1251
- if (!prefs || !Array.isArray(prefs.handoff_ladder)) return []
1252
- const maySpend = !!prefs.may_spend
1253
- return prefs.handoff_ladder.filter((r) => maySpend || !['credits', 'metered'].includes(r.cost))
1254
- }
1255
-
1256
- function ladderLabel(r) { return r.model ? `${r.agent}/${r.model}` : r.agent }
1257
- function ladderSentence(rungs) { return rungs.length ? rungs.map(ladderLabel).join(' then ') : 'no agent is configured' }
1258
-
1259
- // Exactly what Start posts: one chain entry per rung, carrying that rung's
1260
- // model, from the rung the reader picked downward. De-duplicated by
1261
- // (adapter, model) and not by adapter, because `claude/fable` then
1262
- // `claude/opus` are two real legs and a hand-off between them is the whole
1263
- // point; two rungs that name the same adapter AND the same model are one leg
1264
- // twice, and a hand-off from a leg to its own twin buys nothing.
1265
- function entryChain() {
1266
- const seen = new Set()
1267
- const out = []
1268
- for (const r of ladderRungs().slice(entryState.ladderStart || 0)) {
1269
- const key = `${r.agent}/${r.model || ''}`
1270
- if (seen.has(key)) continue
1271
- seen.add(key)
1272
- out.push(r)
1273
- }
1274
- return out
1275
- }
1276
-
1277
- // The branch a card in this repo should be cut from. The sessions payload
1278
- // carries the repo's own default branch (the server reads origin/HEAD, then
1279
- // main/master/trunk, then the current branch) and each terminal's branch, so
1280
- // neither the sentence nor the body has to assume `main` in a repo whose
1281
- // default is `master` or `develop`: Start could only ever fail there.
1282
- function entryTrunk(repo) {
1283
- if (!repo) return null
1284
- const known = (state.repoTrunks || []).find((t) => t.repo === repo.path || t.repo_name === repo.name)
1285
- if (known && known.branch) return known.branch
1286
- for (const s of state.sessions || []) {
1287
- if (s.repo !== repo.path) continue
1288
- const b = (s.worktree && s.worktree.base) || (!s.worktree && s.branch)
1289
- if (b) return b
1290
- }
1291
- return null
1292
- }
1293
-
1294
- async function submitEntry() {
1295
- const repo = entryRepo()
1296
- const task = (entryState.task || '').trim()
1297
- if (!task || !repo) return
1298
- const rungs = entryChain()
1299
- const trunk = entryTrunk(repo)
1300
- const body = {
1301
- repo: repo.path, task,
1302
- chain: rungs.map((r) => ({ adapter: r.agent, ...(r.model ? { model: r.model } : {}) })),
1303
- ...(trunk ? { trunk } : {}),
1304
- pipeline: entryState.pipeline, queue: true,
1305
- }
1306
- try {
1307
- const data = await api('/api/cards', { method: 'POST', body })
1308
- entryState.task = ''
1309
- entryState.editing = null
1310
- upsertCard(data.card)
1311
- } catch (err) { toast(err.message) }
1312
- }
1313
-
1314
- function nounSelect(options, current, onPick) {
1315
- const select = el('select', { 'aria-label': 'Change' })
1316
- for (const o of options) select.appendChild(el('option', { value: o.value }, [o.label]))
1317
- select.value = current
1318
- select.addEventListener('change', () => { onPick(select.value); entryState.editing = null; renderEntryLine() })
1319
- return select
1320
- }
1321
-
1322
- function nounButton(text, key) {
1323
- return el('button', { type: 'button', class: 'btn btn-text', onclick: () => { entryState.editing = key; renderEntryLine() } }, [text])
1324
- }
1325
-
1326
- function renderEntryLine() {
1327
- const box = document.getElementById('card-entry')
1328
- if (!box) return
1329
- if (isGuest()) { box.hidden = true; return }
1330
- box.hidden = false
1331
- box.textContent = ''
1332
- const repo = entryRepo()
1333
- const task = el('input', { type: 'text', placeholder: 'Describe the task', 'aria-label': 'Task to run in the background', value: entryState.task })
1334
- const reason = el('span', { class: 'field-help' }, ['Describe the task to start it.'])
1335
- reason.hidden = Boolean(entryState.task.trim())
1336
- const start = el('button', { type: 'button', class: 'btn btn-primary', disabled: entryState.task.trim() ? null : '' }, ['Start'])
1337
- task.addEventListener('input', () => { entryState.task = task.value; start.disabled = !task.value.trim(); reason.hidden = Boolean(task.value.trim()) })
1338
- start.addEventListener('click', () => submitEntry())
1339
- // `.confirm-row` is the board's existing sentence-plus-controls strip: a
1340
- // flex row with a gap on the raised surface, which is exactly this line.
1341
- box.appendChild(el('div', { class: 'confirm-row' }, [el('span', {}, ['Run in the background:']), task, start, reason]))
1342
-
1343
- const line = el('p', { class: 'field-help' })
1344
- line.appendChild(document.createTextNode('in '))
1345
- if (entryState.editing === 'repo') {
1346
- line.appendChild(nounSelect(knownRepos().map((r) => ({ value: r.path, label: r.name })), repo ? repo.path : '', (v) => { entryState.repo = knownRepos().find((r) => r.path === v) || null }))
1347
- } else {
1348
- line.appendChild(nounButton(repo ? repo.name : 'no repo', 'repo'))
1349
- }
1350
- line.appendChild(document.createTextNode(` on ${entryTrunk(repo) || 'main'}, with `))
1351
- const rungs = ladderRungs()
1352
- if (entryState.editing === 'ladder') {
1353
- line.appendChild(nounSelect(rungs.map((r, i) => ({ value: String(i), label: ladderLabel(r) })), String(entryState.ladderStart || 0), (v) => { entryState.ladderStart = Number(v) }))
1354
- } else {
1355
- // the sentence names the legs Start posts, models and all
1356
- line.appendChild(nounButton(ladderSentence(entryChain()), 'ladder'))
1357
- }
1358
- line.appendChild(document.createTextNode(', '))
1359
- if (entryState.editing === 'pipeline') {
1360
- line.appendChild(nounSelect(Object.keys(PIPELINE_WORDS).map((v) => ({ value: v, label: PIPELINE_WORDS[v] })), entryState.pipeline, (v) => { entryState.pipeline = v }))
1361
- } else {
1362
- line.appendChild(nounButton(PIPELINE_WORDS[entryState.pipeline] || 'build only', 'pipeline'))
1363
- }
1364
- line.appendChild(document.createTextNode('. '))
1365
- line.appendChild(el('button', { type: 'button', class: 'btn btn-text', onclick: () => openNewCardDialog() }, ['More settings']))
1366
- box.appendChild(line)
1367
- }
1368
-
1369
- // C.2: always visible under the Background panel; when there are no live
1370
- // cards the panel is hidden, so the entry moves to sit under Terminals
1371
- // instead. The fake DOM in the tests has no after(), so this is a no-op
1372
- // there and a real move in the browser.
1373
- function placeEntryLine(hasLive) {
1374
- const entry = document.getElementById('card-entry')
1375
- if (!entry) return
1376
- if (hasLive) {
1377
- const bg = document.getElementById('background')
1378
- if (bg && typeof bg.appendChild === 'function') bg.appendChild(entry)
1379
- } else {
1380
- const terms = document.querySelector('.region-terminals')
1381
- if (terms && typeof terms.after === 'function') terms.after(entry)
1382
- }
1229
+ // src/board/entry.js OWNS THE ROW. It is on this page and on /floor, and one
1230
+ // sentence that starts work has to post one body from both, so the row lives
1231
+ // in a file both pages load and this file keeps only the names its own code
1232
+ // and its test seams call. `state` is handed over live: the row reads
1233
+ // sessions, cards, preferences, adapters and models on every render, never a
1234
+ // copy taken at mount.
1235
+ const entryUi = window.legEntry.create({
1236
+ el,
1237
+ api,
1238
+ toast,
1239
+ host: state,
1240
+ boxId: 'card-entry',
1241
+ isGuest,
1242
+ onCreated: (card) => upsertCard(card),
1243
+ // this page has the dialog markup, so More settings opens it in place
1244
+ onMoreSettings: () => openNewCardDialog(),
1245
+ // C.2: the row sits under the Background panel, and under Terminals when
1246
+ // there are no live cards to sit under
1247
+ moveUnder: { whenLive: 'background', whenEmpty: '.region-terminals' },
1248
+ })
1249
+ const entryState = entryUi.entryState
1250
+ const knownRepos = () => entryUi.knownRepos()
1251
+ const entryRepo = () => entryUi.entryRepo()
1252
+ const realAdapters = () => entryUi.realAdapters()
1253
+ const ladderAgents = () => entryUi.ladderAgents()
1254
+ const asRung = (agent, model) => entryUi.asRung(agent, model)
1255
+ const ladderLabel = (r) => entryUi.ladderLabel(r)
1256
+ const modelSelect = (agent, model, label, onPick) => entryUi.modelSelect(agent, model, label, onPick)
1257
+ const entryChain = () => entryUi.entryChain()
1258
+ const entryTrunk = (repo) => entryUi.entryTrunk(repo)
1259
+ const renderEntryLine = () => entryUi.renderEntryLine()
1260
+ const placeEntryLine = (hasLive) => entryUi.placeEntryLine(hasLive)
1261
+
1262
+ // sessions.js draws the ladder editor in Settings and needs the same model
1263
+ // catalog, but the two files share no module scope (both are plain scripts
1264
+ // served to the browser), so the one fetch is published here and read there.
1265
+ // Narrow on purpose: the catalog and nothing else of this file's state.
1266
+ function publishModels() {
1267
+ if (typeof window === 'undefined') return
1268
+ window.legBoard = { models: state.models }
1383
1269
  }
1384
1270
 
1385
1271
  // ---- new card dialog ----
1272
+ // Two questions, side by side: WHAT the work is (task, repo, branch) and WHO
1273
+ // runs it. "Who runs it" is the same ladder the one-line entry row walks,
1274
+ // one row per rung and a model select on each, prefilled from preferences so
1275
+ // the dialog opens showing exactly what pressing Start on that row would
1276
+ // have done. Every other field the dialog ever had is under Advanced, and
1277
+ // every one of them still posts.
1386
1278
  function newCardDialogEls() {
1387
1279
  return {
1388
1280
  dialog: document.getElementById('new-card-dialog'),
1389
1281
  form: document.getElementById('new-card-form'),
1390
1282
  error: document.getElementById('new-card-error'),
1391
1283
  repo: document.getElementById('nc-repo'),
1284
+ repoKnown: document.getElementById('nc-repo-known'),
1392
1285
  task: document.getElementById('nc-task'),
1393
- firstAgent: document.getElementById('nc-first-agent'),
1394
- firstControls: document.getElementById('nc-first-controls'),
1395
1286
  testAdapter: document.getElementById('nc-test-adapter'),
1396
1287
  fallbackSummary: document.getElementById('nc-fallback-summary'),
1397
1288
  pipeline: document.getElementById('nc-pipeline'),
1398
1289
  customPipeline: document.getElementById('nc-custom-pipeline'),
1399
1290
  chainRows: document.getElementById('nc-chain-rows'),
1400
1291
  addRowBtn: document.getElementById('nc-add-row'),
1292
+ saveLadder: document.getElementById('nc-save-ladder'),
1401
1293
  leases: document.getElementById('nc-leases'),
1402
1294
  trunk: document.getElementById('nc-trunk'),
1403
1295
  landMode: document.getElementById('nc-land-mode'),
@@ -1410,139 +1302,266 @@
1410
1302
 
1411
1303
  function adapterLabel(adapter) { return adapter.fake ? `${adapter.name} (test/demo)` : adapter.name }
1412
1304
 
1413
- // the agents already named in this dialog: the first agent and every fallback
1414
- // row already added. A fallback set to one of these can never fire.
1415
- function chosenAdapters(ui) {
1416
- const used = [ui.testAdapter.value || ui.firstAgent.value]
1417
- for (const row of ui.chainRows.children) if (row.fields) used.push(row.fields.adapterSelect.value)
1418
- return used.filter(Boolean)
1419
- }
1420
-
1421
- function addChainRow(ui, { adapter: preferred = null, first = false } = {}) {
1422
- const adapterSelect = el('select', { 'aria-label': 'Chain adapter' })
1423
- const adapters = [...(state.adapters || [])].sort((a, b) => Number(a.fake) - Number(b.fake))
1424
- for (const a of adapters) adapterSelect.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
1425
- // Add fallback agent used to default to the first option in the list, which
1426
- // is normally the agent already chosen as First agent: the summary line then
1427
- // read "Leg tries claude, then agy, then claude", a fallback that cannot
1428
- // fire. rebuildDefaultFallbacks already applies this filter.
1429
- if (!preferred && !first) {
1430
- const used = chosenAdapters(ui)
1431
- preferred = adapters.filter((a) => !a.fake).map((a) => a.name).find((name) => !used.includes(name)) || null
1432
- }
1433
- if (preferred && adapters.some((a) => a.name === preferred)) adapterSelect.value = preferred
1434
- const modeSelect = el('select', { 'aria-label': 'Chain mode' })
1435
- const approveCheckbox = el('input', { type: 'checkbox', 'aria-label': 'Approve before this leg' })
1436
- const approveLabel = el('label', {}, [approveCheckbox, ' approval before start'])
1437
- const turnsInput = el('input', { type: 'number', min: '0', 'aria-label': 'Max turns', placeholder: 'max turns' })
1438
- const fakeInput = el('input', { type: 'text', 'aria-label': 'Scripted test behavior', placeholder: 'test behavior' })
1439
- const removeBtn = first ? null : el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': 'Remove fallback agent' }, ['Remove'])
1440
- const title = el('span', { class: 'fallback-row-title' }, [first ? `First: ${preferred}` : `Fallback ${ui.chainRows.children.length + 1}`])
1441
- const row = el('div', { class: `chain-row${first ? '' : ' fallback-row'}` }, [title, adapterSelect, modeSelect, approveLabel, turnsInput, fakeInput, removeBtn])
1442
- if (first) adapterSelect.hidden = true
1443
- if (removeBtn) removeBtn.addEventListener('click', () => { row.remove(); refreshFallbackSummary(ui) })
1305
+ // The rows as DATA. The DOM used to be the record: a row's values were read
1306
+ // back off its own inputs, which works until rows can move, because moving a
1307
+ // row means rebuilding it and a rebuilt input is empty. Reorder, remove and
1308
+ // renumber are all list operations here, and the DOM is redrawn from the list.
1309
+ let ncRows = []
1444
1310
 
1445
- function populateModes() {
1446
- modeSelect.textContent = ''
1447
- const adapter = (state.adapters || []).find((a) => a.name === adapterSelect.value)
1448
- const allowed = adapter ? adapter.modes.allowed : []
1449
- for (const m of allowed) modeSelect.appendChild(el('option', { value: m }, [MODE_LABELS[m] ? `${MODE_LABELS[m]} (${m})` : m]))
1450
- if (adapter && adapter.modes.default) modeSelect.value = adapter.modes.default
1451
- fakeInput.hidden = !(adapter && adapter.fake)
1452
- }
1453
- adapterSelect.addEventListener('change', populateModes)
1454
- // the summary sentence names the fallback agents by row, so it has to
1455
- // follow a row whose agent the reader changed after it was added
1456
- if (!first) adapterSelect.addEventListener('change', () => refreshFallbackSummary(ui))
1457
- populateModes()
1311
+ function ncAdapter(name) { return (state.adapters || []).find((a) => a.name === name) || null }
1458
1312
 
1459
- row.fields = { adapterSelect, modeSelect, approveCheckbox, turnsInput, fakeInput }
1460
- ;(first ? ui.firstControls : ui.chainRows).appendChild(row)
1461
- refreshFallbackSummary(ui)
1462
- return row
1313
+ function ncRow(agent, model) {
1314
+ const adapter = ncAdapter(agent)
1315
+ return {
1316
+ agent: adapter ? adapter.name : agent,
1317
+ model: model || '',
1318
+ mode: adapter && adapter.modes ? (adapter.modes.default || '') : '',
1319
+ approve: false, turns: '', fake: '',
1320
+ }
1463
1321
  }
1464
1322
 
1465
- function refreshFallbackSummary(ui) {
1466
- const names = [...ui.chainRows.children].filter((row) => row.fields).map((row) => row.fields.adapterSelect.value)
1467
- ui.fallbackSummary.textContent = names.length
1468
- ? `If the first agent cannot continue, Leg tries ${names.join(', then ')} in this order.`
1469
- : 'No fallback agent is set. Add one under Advanced options if another agent should take over.'
1323
+ // the first installed agent no row already names: a fallback that repeats the
1324
+ // row above it can never fire
1325
+ function ncNextAgent() {
1326
+ const used = ncRows.map((r) => r.agent)
1327
+ const real = realAdapters()
1328
+ return real.find((a) => !used.includes(a)) || real[0] || ((state.adapters || [])[0] || {}).name || 'claude'
1470
1329
  }
1471
1330
 
1472
- function rebuildFirstControls(ui) {
1473
- ui.firstControls.textContent = ''
1474
- addChainRow(ui, { adapter: ui.testAdapter.value || ui.firstAgent.value, first: true })
1475
- }
1331
+ const ncLeg = (r) => (r.model ? `${r.agent}/${r.model}` : r.agent)
1476
1332
 
1477
- function rebuildDefaultFallbacks(ui) {
1333
+ function refreshFallbackSummary(ui) {
1334
+ const legs = ncRows.map(ncLeg)
1335
+ ui.fallbackSummary.textContent = legs.length > 1
1336
+ ? `Leg starts on ${legs[0]}, and tries ${legs.slice(1).join(', then ')} only when the row before it cannot continue.`
1337
+ : legs.length === 1
1338
+ ? `Leg runs ${legs[0]} and stops there. Add a fallback to hand the work on when it cannot continue.`
1339
+ : 'No agent is set. Add a row, or this card has nothing to run it.'
1340
+ }
1341
+
1342
+ // `focusKey` is the control the reader should still be on after the redraw.
1343
+ // Every row is destroyed and rebuilt here, so the button a keyboard user just
1344
+ // pressed Enter on is gone and focus falls to <body>: pressing Up twice meant
1345
+ // tabbing back through every control above it. Same idea as sessions.js's
1346
+ // takeFocus/putFocus pair (data-focus-key), but the intent is passed in rather
1347
+ // than read off document.activeElement, because the moved row's key is known
1348
+ // at the press and the row it lands on is a different index.
1349
+ function renderChainRows(ui, focusKey) {
1478
1350
  ui.chainRows.textContent = ''
1479
- const first = ui.firstAgent.value
1480
- const real = (state.adapters || []).filter((a) => !a.fake).map((a) => a.name)
1481
- const preferred = AGENT_IDS.filter((name) => real.includes(name) && name !== first)
1482
- for (const adapter of preferred) addChainRow(ui, { adapter })
1351
+ const keyed = new Map()
1352
+ ncRows.forEach((row, index) => {
1353
+ // every control on the row is named for the step it belongs to, so a
1354
+ // screen reader hears "Model for fallback 2" and not "Chain adapter"
1355
+ const step = index === 0 ? 'the first agent' : `fallback ${index}`
1356
+ const adapter = ncAdapter(row.agent)
1357
+ const box = el('div', { class: 'chain-row' })
1358
+ box.appendChild(el('span', { class: 'chain-num' }, [`${index + 1}.`]))
1359
+
1360
+ const who = el('select', { 'aria-label': `Provider for ${step}` })
1361
+ for (const a of [...(state.adapters || [])].sort((x, y) => Number(x.fake) - Number(y.fake))) who.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
1362
+ who.value = row.agent
1363
+ who.addEventListener('change', () => {
1364
+ row.agent = who.value
1365
+ // a model belongs to one provider: carrying gpt-5.6-luna over to claude
1366
+ // would post a model that CLI has never heard of
1367
+ row.model = ''
1368
+ const next = ncAdapter(row.agent)
1369
+ row.mode = next && next.modes ? (next.modes.default || '') : ''
1370
+ renderChainRows(ui)
1371
+ })
1372
+ box.appendChild(who)
1373
+
1374
+ box.appendChild(modelSelect(row.agent, row.model, `Model for ${step}`, (v) => { row.model = v; refreshFallbackSummary(ui) }))
1375
+
1376
+ const mode = el('select', { 'aria-label': `Permissions for ${step}` })
1377
+ for (const m of (adapter && adapter.modes ? adapter.modes.allowed : [])) mode.appendChild(el('option', { value: m }, [MODE_LABELS[m] ? `${MODE_LABELS[m]} (${m})` : m]))
1378
+ mode.value = row.mode || (adapter && adapter.modes ? adapter.modes.default : '')
1379
+ mode.addEventListener('change', () => { row.mode = mode.value })
1380
+ box.appendChild(mode)
1381
+
1382
+ const approve = el('input', { type: 'checkbox', 'aria-label': `Ask before ${step} starts` })
1383
+ approve.checked = row.approve
1384
+ approve.addEventListener('change', () => { row.approve = approve.checked })
1385
+ box.appendChild(el('label', { class: 'chain-toggle' }, [approve, ' ask before start']))
1386
+
1387
+ const turns = el('input', { type: 'number', min: '1', class: 'chain-turns', 'aria-label': `Max turns for ${step}`, placeholder: 'max turns', value: row.turns })
1388
+ turns.addEventListener('input', () => { row.turns = turns.value })
1389
+ box.appendChild(turns)
1390
+
1391
+ if (adapter && adapter.fake) {
1392
+ const fake = el('input', { type: 'text', class: 'chain-fake', 'aria-label': `Scripted behaviour for ${step}`, placeholder: 'test behavior', value: row.fake })
1393
+ fake.addEventListener('input', () => { row.fake = fake.value })
1394
+ box.appendChild(fake)
1395
+ }
1396
+
1397
+ const up = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${step} earlier`, 'data-focus-key': `chain:${index}:up`, disabled: index === 0 ? '' : null }, ['Up'])
1398
+ const down = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${step} later`, 'data-focus-key': `chain:${index}:down`, disabled: index === ncRows.length - 1 ? '' : null }, ['Down'])
1399
+ const drop = el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': `Remove ${step}`, 'data-focus-key': `chain:${index}:remove`, disabled: ncRows.length < 2 ? '' : null }, ['Remove'])
1400
+ // the key names where the row LANDS, not where it was pressed
1401
+ up.addEventListener('click', () => { ncRows.splice(index - 1, 0, ncRows.splice(index, 1)[0]); renderChainRows(ui, `chain:${index - 1}:up`) })
1402
+ down.addEventListener('click', () => { ncRows.splice(index + 1, 0, ncRows.splice(index, 1)[0]); renderChainRows(ui, `chain:${index + 1}:down`) })
1403
+ drop.addEventListener('click', () => { ncRows.splice(index, 1); renderChainRows(ui, `chain:${Math.min(index, ncRows.length - 1)}:remove`) })
1404
+ box.append(up, down, drop)
1405
+ keyed.set(`chain:${index}:up`, up)
1406
+ keyed.set(`chain:${index}:down`, down)
1407
+ keyed.set(`chain:${index}:remove`, drop)
1408
+
1409
+ ui.chainRows.appendChild(box)
1410
+ })
1483
1411
  refreshFallbackSummary(ui)
1412
+ if (focusKey) restoreChainFocus(ui, keyed, focusKey)
1413
+ }
1414
+
1415
+ // A row moved to either end loses the button that moved it, and the last row
1416
+ // standing cannot be removed, so the focus goes to the nearest live control on
1417
+ // that row and, when the row itself is gone, to Add a fallback.
1418
+ function restoreChainFocus(ui, keyed, focusKey) {
1419
+ const at = focusKey.split(':')[1]
1420
+ let target = keyed.get(focusKey) || null
1421
+ if (!target || target.disabled) {
1422
+ target = [`chain:${at}:up`, `chain:${at}:down`, `chain:${at}:remove`]
1423
+ .map((k) => keyed.get(k))
1424
+ .find((node) => node && !node.disabled) || ui.addRowBtn || null
1425
+ }
1426
+ if (target && typeof target.focus === 'function') target.focus({ preventScroll: true })
1427
+ }
1428
+
1429
+ // "Save as my default ladder": the rows become preferences.handoff_ladder,
1430
+ // which the entry row and every new terminal read. A scripted test adapter is
1431
+ // never a rung and the server refuses one, so it is dropped here with a
1432
+ // sentence rather than sent and refused; two rows naming the same agent and
1433
+ // model are one rung, for the same reason.
1434
+ async function saveLadderFromRows(rows) {
1435
+ const real = realAdapters()
1436
+ // and of those, the agents a SAVED ladder may name. A custom adapter added
1437
+ // with `leg adapter add` is a real agent and runs a card, but preferences
1438
+ // takes a closed list and refuses the whole array over one rung it does not
1439
+ // know, so the claude rung beside a custom one was never written either.
1440
+ const saveable = ladderAgents()
1441
+ const dropped = []
1442
+ const seen = new Set()
1443
+ const ladder = []
1444
+ for (const r of rows) {
1445
+ if (!real.includes(r.agent)) continue
1446
+ if (!saveable.includes(r.agent)) { if (!dropped.includes(r.agent)) dropped.push(r.agent); continue }
1447
+ const key = `${r.agent}/${r.model || ''}`
1448
+ if (seen.has(key)) continue
1449
+ seen.add(key)
1450
+ ladder.push(asRung(r.agent, r.model || null))
1451
+ }
1452
+ const left = dropped.length ? ` ${dropped.join(', ')} ${dropped.length > 1 ? 'were' : 'was'} left off: the default ladder keeps only the agents Settings can express (${saveable.join(', ')}).` : ''
1453
+ if (!ladder.length) {
1454
+ toast(dropped.length
1455
+ ? `The default ladder was left alone: it would keep no rung at all.${left}`
1456
+ : 'The default ladder was left alone: a scripted test agent cannot be a rung.')
1457
+ return
1458
+ }
1459
+ try {
1460
+ const data = await api('/api/settings', { method: 'PATCH', body: { handoff_ladder: ladder } })
1461
+ state.preferences = data.preferences || state.preferences
1462
+ entryState.ladderStart = 0
1463
+ entryState.model = undefined
1464
+ renderEntryLine()
1465
+ toast(`Saved as your default ladder: ${ladder.map(ladderLabel).join(' then ')}.${left}`)
1466
+ } catch (err) {
1467
+ toast(`The card was created. The default ladder was not saved: ${err.message}`)
1468
+ }
1484
1469
  }
1485
1470
 
1486
1471
  async function openNewCardDialog() {
1487
1472
  if (!state.adapters) {
1488
1473
  try { state.adapters = (await api('/api/adapters')).adapters } catch (err) { toast(err.message); return }
1489
1474
  }
1475
+ // neither of these stops the dialog opening: without a catalog every model
1476
+ // select offers the provider default, and without preferences the rows fall
1477
+ // back to the agents that are installed
1478
+ if (!state.models) { try { state.models = (await api('/api/models')).models; publishModels() } catch { /* provider default only */ } }
1479
+ if (!state.preferences) { try { state.preferences = (await api('/api/settings')).preferences || null } catch { /* installed adapters only */ } }
1480
+
1490
1481
  const ui = newCardDialogEls()
1491
1482
  ui.form.reset()
1492
1483
  ui.error.hidden = true
1493
1484
  ui.error.textContent = ''
1485
+ // More settings is the same sentence with more fields, so the sentence
1486
+ // comes with it: on this page from the row above, and from /floor through
1487
+ // the #new-card hash, which is how that page reaches this dialog without a
1488
+ // second copy of its markup.
1489
+ if (entryState.task && entryState.task.trim()) ui.task.value = entryState.task
1490
+ // the workflow is one of the row's three nouns and it went the same way the
1491
+ // task does. form.reset() above puts the select back to the option marked
1492
+ // selected in the markup (build), so this has to run after it.
1493
+ if (entryState.pipeline) ui.pipeline.value = entryState.pipeline
1494
1494
  ui.customPipeline.hidden = ui.pipeline.value !== 'custom'
1495
- const real = (state.adapters || []).filter((a) => !a.fake)
1496
- ui.firstAgent.textContent = ''
1497
- for (const adapter of real) ui.firstAgent.appendChild(el('option', { value: adapter.name }, [adapter.name]))
1498
- if (real.some((a) => a.name === 'claude')) ui.firstAgent.value = 'claude'
1495
+
1496
+ // the repo and branch the entry row would have used, and every repo this
1497
+ // board has seen, so the commonest case is already filled in
1498
+ const repo = entryRepo()
1499
+ ui.repoKnown.textContent = ''
1500
+ for (const r of knownRepos()) ui.repoKnown.appendChild(el('option', { value: r.path }, [r.name]))
1501
+ ui.repoKnown.appendChild(el('option', { value: '' }, ['Another path, typed below']))
1502
+ ui.repoKnown.value = repo ? repo.path : ''
1503
+ ui.repo.value = repo ? repo.path : ''
1504
+ ui.trunk.value = entryTrunk(repo) || 'main'
1505
+
1499
1506
  ui.testAdapter.textContent = ''
1500
- ui.testAdapter.appendChild(el('option', { value: '' }, ['Use the real first agent above']))
1507
+ ui.testAdapter.appendChild(el('option', { value: '' }, ['Use the real first row above']))
1501
1508
  for (const adapter of (state.adapters || []).filter((a) => a.fake)) ui.testAdapter.appendChild(el('option', { value: adapter.name }, [adapterLabel(adapter)]))
1502
- ui.firstControls.textContent = ''
1503
- ui.chainRows.textContent = ''
1504
- rebuildFirstControls(ui)
1505
- rebuildDefaultFallbacks(ui)
1509
+
1510
+ const rungs = entryChain()
1511
+ ncRows = rungs.length ? rungs.map((r) => ncRow(r.agent, r.model || '')) : []
1512
+ if (!ncRows.length && (state.adapters || []).length) ncRows = [ncRow(ncNextAgent(), '')]
1513
+ ui.saveLadder.checked = false
1514
+ renderChainRows(ui)
1506
1515
  ui.dialog.showModal()
1507
1516
  }
1508
1517
 
1509
1518
  async function submitNewCard(e) {
1510
1519
  e.preventDefault()
1511
1520
  const ui = newCardDialogEls()
1512
- const rows = [...ui.firstControls.children, ...ui.chainRows.children]
1513
- .filter((row) => row.fields)
1514
- .map((row) => ({
1515
- adapter: row.fields.adapterSelect.value,
1516
- mode: row.fields.modeSelect.value,
1517
- approve: row.fields.approveCheckbox.checked,
1518
- turns: row.fields.turnsInput.value.trim(),
1519
- fake: row.fields.fakeInput.hidden ? '' : row.fields.fakeInput.value.trim(),
1520
- }))
1521
- .filter((r) => r.adapter)
1521
+ const fail = (msg) => { ui.error.hidden = false; ui.error.textContent = msg }
1522
+ const rows = ncRows.filter((r) => r.agent)
1523
+ if (!rows.length) return fail('Add at least one row under Who runs it: a card needs an agent to run it.')
1524
+ if (!ui.repo.value.trim()) return fail('Name the repository this card works in.')
1525
+ // One object per row, not a comma list plus four adapter-keyed strings.
1526
+ // The keyed form could only ever carry one mode, one turn limit and one
1527
+ // model PER ADAPTER, so a chain of claude/fable then claude/opus lost the
1528
+ // difference between its own two rows. src/pipeline.mjs normalizeChainEntry
1529
+ // takes every one of these fields per entry.
1530
+ const chain = rows.map((r) => ({
1531
+ adapter: r.agent,
1532
+ ...(r.model ? { model: r.model } : {}),
1533
+ ...(r.mode ? { mode: r.mode } : {}),
1534
+ ...(String(r.turns).trim() ? { maxTurns: Number(String(r.turns).trim()) } : {}),
1535
+ ...(r.approve ? { approve: true } : {}),
1536
+ ...(String(r.fake).trim() ? { fakeMode: String(r.fake).trim() } : {}),
1537
+ }))
1522
1538
 
1523
1539
  const body = {
1524
1540
  repo: ui.repo.value.trim(),
1525
1541
  task: ui.task.value.trim(),
1526
- chain: rows.map((r) => r.adapter).join(','),
1542
+ chain,
1527
1543
  pipeline: ui.pipeline.value === 'custom' ? ui.customPipeline.value.trim() : ui.pipeline.value,
1528
1544
  leases: ui.leases.value.trim(),
1529
1545
  trunk: ui.trunk.value.trim() || 'main',
1530
1546
  land_mode: ui.landMode.value,
1531
1547
  test_command: ui.testCommand.value.trim(),
1532
1548
  title: ui.title.value.trim(),
1533
- mode: rows.filter((r) => r.mode).map((r) => `${r.adapter}=${r.mode}`).join(','),
1534
- approve: rows.filter((r) => r.approve).map((r) => r.adapter).join(','),
1535
- maxTurns: rows.filter((r) => r.turns).map((r) => `${r.adapter}=${r.turns}`).join(','),
1536
- fake_mode: rows.filter((r) => r.fake).map((r) => `${r.adapter}=${r.fake}`).join(','),
1537
1549
  queue: ui.queue.checked,
1538
1550
  }
1539
1551
  try {
1540
1552
  const data = await api('/api/cards', { method: 'POST', body })
1553
+ // the card exists now: a ladder that will not save is a toast, never a
1554
+ // reason to leave the dialog open over a card that was already created
1555
+ if (ui.saveLadder.checked) await saveLadderFromRows(rows)
1541
1556
  ui.dialog.close()
1557
+ // the sentence was sent, so the row that carried it here is spent: a row
1558
+ // left armed makes the next Start post the same card a second time.
1559
+ // upsertCard redraws the row, so there is no render call to add.
1560
+ entryState.task = ''
1561
+ entryState.editing = null
1542
1562
  upsertCard(data.card)
1543
1563
  } catch (err) {
1544
- ui.error.hidden = false
1545
- ui.error.textContent = err.message
1564
+ fail(err.message)
1546
1565
  }
1547
1566
  }
1548
1567
 
@@ -1551,9 +1570,24 @@
1551
1570
  ui.form.addEventListener('submit', submitNewCard)
1552
1571
  ui.cancel.addEventListener('click', () => ui.dialog.close())
1553
1572
  ui.pipeline.addEventListener('change', () => { ui.customPipeline.hidden = ui.pipeline.value !== 'custom' })
1554
- ui.firstAgent.addEventListener('change', () => { ui.testAdapter.value = ''; rebuildFirstControls(ui); rebuildDefaultFallbacks(ui) })
1555
- ui.testAdapter.addEventListener('change', () => rebuildFirstControls(ui))
1556
- ui.addRowBtn.addEventListener('click', () => addChainRow(ui))
1573
+ // the picker fills the path field rather than replacing it: the path is
1574
+ // what gets posted, and a reader who wants a repo the board has never seen
1575
+ // types it in the same box
1576
+ ui.repoKnown.addEventListener('change', () => {
1577
+ if (!ui.repoKnown.value) { ui.repo.focus(); return }
1578
+ ui.repo.value = ui.repoKnown.value
1579
+ const known = knownRepos().find((r) => r.path === ui.repoKnown.value) || { path: ui.repoKnown.value, name: ui.repoKnown.value }
1580
+ ui.trunk.value = entryTrunk(known) || 'main'
1581
+ })
1582
+ // unchanged meaning: the scripted adapter replaces the agent on the first
1583
+ // row, and clearing it puts the first real agent back
1584
+ ui.testAdapter.addEventListener('change', () => {
1585
+ if (!ncRows.length) ncRows = [ncRow(ncNextAgent(), '')]
1586
+ const real = realAdapters()
1587
+ ncRows[0] = ncRow(ui.testAdapter.value || real[0] || ncRows[0].agent, '')
1588
+ renderChainRows(ui)
1589
+ })
1590
+ ui.addRowBtn.addEventListener('click', () => { ncRows.push(ncRow(ncNextAgent(), '')); renderChainRows(ui) })
1557
1591
  }
1558
1592
 
1559
1593
  // ---- 6.10 settings: the last region of the page, in flow ----
@@ -1605,6 +1639,15 @@
1605
1639
  if (meta) meta.textContent = panel.meta
1606
1640
  }
1607
1641
 
1642
+ // The process behind the page is older or newer than the page itself. Said
1643
+ // once, as an error that stays until dismissed, because everything the reader
1644
+ // sees from here on is drawn by files the process does not know about.
1645
+ function versionSkew(processVersion) {
1646
+ if (!processVersion || processVersion === FILES_VERSION) return false
1647
+ toast(`This board process runs leg ${processVersion} and the page files are ${FILES_VERSION}. Restart it to match: leg down && leg up`)
1648
+ return true
1649
+ }
1650
+
1608
1651
  function renderBoardFacts(health) {
1609
1652
  const box = document.querySelector('.region-settings .board-facts')
1610
1653
  if (!box) return
@@ -1612,8 +1655,9 @@
1612
1655
  const you = health.you || {}
1613
1656
  const share = you.share || { on: false, people: 0 }
1614
1657
  const sched = health.scheduler
1658
+ const skew = health.version && health.version !== FILES_VERSION ? `, page files ${FILES_VERSION}` : ''
1615
1659
  const lines = [
1616
- `leg ${health.version}, bound to ${state.bind}`,
1660
+ `leg ${health.version}${skew}, bound to ${state.bind}`,
1617
1661
  `signed in as ${you.name || 'local'}, ${you.role || 'owner'}`,
1618
1662
  share.on ? `share on, ${share.people === 1 ? '1 person' : `${share.people} people`}` : 'share off, nobody invited',
1619
1663
  ]
@@ -1662,6 +1706,39 @@
1662
1706
  renderTokenMeta()
1663
1707
  }
1664
1708
 
1709
+ // ---- what /floor sends over in the address bar ----
1710
+ // The floor starts cards from its own copy of the entry row, but the New card
1711
+ // dialog's markup exists once, on this page, so the floor's More settings and
1712
+ // its card titles are links here. `#new-card` opens the dialog, `#new-card=<task>`
1713
+ // opens it with the sentence the reader had already typed, and `#card=<id>`
1714
+ // expands that card's detail region. The hash is cleared once it is acted on:
1715
+ // a reload should not reopen a dialog the reader closed.
1716
+ async function openFromHash() {
1717
+ const hash = decodeURIComponent(String((location && location.hash) || '').replace(/^#/, ''))
1718
+ if (!hash) return
1719
+ const clear = () => { try { history.replaceState(null, '', location.pathname + location.search) } catch { /* a browser that refuses is still on the right page */ } }
1720
+ if (hash.startsWith('new-card')) {
1721
+ // `#new-card=<task>&pipeline=<p>` or `#new-card?pipeline=<p>`: the floor's
1722
+ // entry row sends both, and the dialog opens on what the reader chose
1723
+ const rest = hash.slice('new-card'.length)
1724
+ const m = rest.match(/[&?]pipeline=([a-z_-]+)$/)
1725
+ const task = rest.replace(/[&?]pipeline=[a-z_-]+$/, '').replace(/^=/, '')
1726
+ if (task) entryState.task = task
1727
+ // an unknown word is harmless: the dialog's select ignores a value it has no option for
1728
+ if (m) entryState.pipeline = m[1]
1729
+ if (task || m) renderEntryLine()
1730
+ clear()
1731
+ await openNewCardDialog()
1732
+ return
1733
+ }
1734
+ if (hash.startsWith('card=')) {
1735
+ const id = hash.slice('card='.length)
1736
+ clear()
1737
+ if (!state.cards.has(id)) await fetchCards()
1738
+ if (state.cards.has(id)) expandRow(id)
1739
+ }
1740
+ }
1741
+
1665
1742
  // ---- init ----
1666
1743
  async function init() {
1667
1744
  initSettings()
@@ -1682,8 +1759,19 @@
1682
1759
  // C.2: the ladder sentence on the entry line reads this; a guest never
1683
1760
  // sees the entry line, so there is nothing to fetch it for
1684
1761
  if (owner) {
1685
- try { state.preferences = (await api('/api/settings')).preferences || null } catch { /* entry line falls back to "no agent is configured" */ }
1762
+ // three independent reads, so they go out together: the ladder the entry
1763
+ // line names, the adapters that are actually installed (the fallback when
1764
+ // there is no ladder at all), and the model catalog both selects use
1765
+ await Promise.all([
1766
+ api('/api/settings').then((d) => { state.preferences = d.preferences || null }).catch(() => {}),
1767
+ api('/api/adapters').then((d) => { state.adapters = d.adapters || null }).catch(() => {}),
1768
+ api('/api/models').then((d) => { state.models = d.models || null }).catch(() => {}),
1769
+ ])
1770
+ publishModels()
1686
1771
  renderEntryLine()
1772
+ // last, so the dialog opens over a page that already knows its ladder,
1773
+ // its repos and its cards
1774
+ await openFromHash()
1687
1775
  }
1688
1776
  }
1689
1777