@leftoff/cli 0.9.18-beta.12 → 0.9.18-beta.14

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.18-beta.12",
3
+ "version": "0.9.18-beta.14",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "One Silly App"
package/mod/hooks/core.js CHANGED
@@ -508,6 +508,10 @@ export function renderCheckpointMd(cp) {
508
508
  const bullet = (x) => '- ' + oneLine(x).replace(/^\*+/, '')
509
509
  // 2026-11-03 decision memory: the decisions this checkpoint saw, first of the
510
510
  // sections, so an older reader (no section open yet) skips the heading and its lines.
511
+ // 2026-11-06: what the session's commands show got done (workFacts), first of all, so the
512
+ // digest can say what shipped in past tense; an older reader skips the heading and its lines
513
+ const work = (Array.isArray(cp.work) ? cp.work : []).map(renderWorkLine).filter(Boolean)
514
+ if (work.length) { lines.push('', '### ' + WORK_TITLE); for (const w of work) lines.push('- ' + w) }
511
515
  const decided = cleanDecided(cp.decided)
512
516
  if (decided.length) { lines.push('', '### ' + DECIDED_TITLE); for (const d of decided) lines.push(renderDecidedLine(d)) }
513
517
  for (const k of SECTION_ORDER) {
@@ -566,7 +570,15 @@ export function parseCheckpointMd(text) {
566
570
  if (cp.format === 'markdown') for (const f of ['doing', 'stoppedAt', 'next']) cp[f] = cp[f].replace(/\n{3,}/g, '\n\n').trim()
567
571
  const sec = []
568
572
  for (; k < lines.length; k++) { if (/^Resume: `/.test(lines[k])) break; sec.push(lines[k]) }
573
+ // 2026-11-06: "### Got done" lines are facts in a fixed shape (renderWorkLine); read them here, not as model prose
574
+ const work = []
575
+ let inWork = false
576
+ for (const l of sec) {
577
+ if (/^###\s/.test(l)) { inWork = l.replace(/^###\s+/, '').trim() === WORK_TITLE; continue }
578
+ if (inWork) { const f = parseWorkLine(l); if (f) work.push(f) }
579
+ }
569
580
  Object.assign(cp, parseSections(sec))
581
+ if (work.length) cp.work = work
570
582
  return cp
571
583
  }
572
584
 
@@ -904,10 +916,11 @@ export function computeStatus(s, now, cfg = DEFAULT_CONFIG) {
904
916
  // fire), so a turn that has been silent for twice the dormancy window is
905
917
  // treated as over. The mod sees turn.complete for aborted turns too.
906
918
  // 0.8.0: a Codex rollout shows no end for a turn Codex was killed in, either.
907
- if ((s.capture === 'hooks' || s.capture === 'codex') && idle >= 2 * dormantAfter) return 'dormant'
919
+ // 2026-11-06: a subagent working is the turn going on, however quiet the session's own hooks are
920
+ if ((s.capture === 'hooks' || s.capture === 'codex') && idle >= 2 * dormantAfter && !(s.subagents && s.subagents.n > 0)) return 'dormant'
908
921
  return 'active'
909
922
  }
910
- if (s.backgroundTasks > 0) return 'background'
923
+ if (s.backgroundTasks > 0 || (s.subagents && s.subagents.n > 0)) return 'background' // 2026-11-06: subagents working (the daemon reads them, src/subagents.js), a turn's end missed or not
911
924
  return idle >= dormantAfter ? 'dormant' : 'waiting'
912
925
  }
913
926
 
@@ -1335,6 +1348,189 @@ export function transcriptSlice(messages, { maxInputChars = 24000, cwd } = {}) {
1335
1348
  return blocks.reverse().join('\n')
1336
1349
  }
1337
1350
 
1351
+ // ---------------------------------------------------------------- work facts (2026-11-06)
1352
+ //
1353
+ // What a session got done, read from the commands it ran and what they printed,
1354
+ // never from a model: commits ("[branch abc1234] subject"), PRs opened (gh pr
1355
+ // create's URL) and merged (gh pr merge, or GitHub's MCP merge tool), branches
1356
+ // pushed, and the last test run with its counts. A checkpoint keeps them under
1357
+ // "### Got done", one line each, so the digest can say "Merged PR #53: …" in
1358
+ // past tense instead of the last status line ("PR 53 was ready to merge").
1359
+ export const WORK_TITLE = 'Got done'
1360
+ export const WORK_MAX = { prs: 6, commits: 10, pushes: 3 }
1361
+
1362
+ const quoted = (s) => s == null ? '' : String(s)
1363
+ function argOf(cmd, names) {
1364
+ for (const n of names) {
1365
+ const re = new RegExp('(?:^|\\s)' + n.replace(/-/g, '\\-') + '(?:=|\\s+)(?:"((?:[^"\\\\]|\\\\.)*)"|\'([^\']*)\'|(\\S+))')
1366
+ const m = String(cmd).match(re)
1367
+ if (m) return quoted(m[1] != null ? m[1].replace(/\\(.)/g, '$1') : m[2] != null ? m[2] : m[3])
1368
+ }
1369
+ return ''
1370
+ }
1371
+ const firstLineOf = (t) => String(t || '').split('\n').map((x) => x.trim()).find(Boolean) || ''
1372
+ const TEST_CMD = /(?:^|[\s;&|(])((?:(?:env\s+(?:-u\s+\S+\s+)*)?(?:timeout\s+(?:-k\s+\S+\s+)?\S+\s+)?)?(?:(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?test(?::[\w:.-]+)?|node\s+(?:--?[\w-]+(?:[= ]\S+)?\s+)*--test\b|npx\s+(?:vitest|jest|mocha)|vitest|jest|pytest|python3?\s+-m\s+pytest|go\s+test|cargo\s+test|mocha|rspec|swift\s+test|make\s+test|deno\s+test))/
1373
+ function testCounts(text) {
1374
+ const t = String(text || '')
1375
+ let m, passed = null, failed = null
1376
+ if ((m = t.match(/test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed/))) return { passed: +m[1], failed: +m[2] }
1377
+ const np = [...t.matchAll(/^\s*(?:#|ℹ)\s*pass\s+(\d+)\s*$/gm)].pop(), nf = [...t.matchAll(/^\s*(?:#|ℹ)\s*fail\s+(\d+)\s*$/gm)].pop()
1378
+ if (np || nf) return { passed: np ? +np[1] : 0, failed: nf ? +nf[1] : 0 }
1379
+ if ((m = t.match(/^\s*Tests:?\s+(.*)$/m)) && /\d+ (?:passed|failed)/.test(m[1])) {
1380
+ const p = m[1].match(/(\d+) passed/), f = m[1].match(/(\d+) failed/)
1381
+ return { passed: p ? +p[1] : 0, failed: f ? +f[1] : 0 }
1382
+ }
1383
+ const lines = t.split('\n').filter((l) => /\d+ (?:passed|failed)\b/.test(l))
1384
+ if (lines.length) {
1385
+ const l = lines[lines.length - 1], p = l.match(/(\d+) passed/), f = l.match(/(\d+) failed/)
1386
+ passed = p ? +p[1] : 0; failed = f ? +f[1] : 0
1387
+ return { passed, failed }
1388
+ }
1389
+ return null
1390
+ }
1391
+ const PR_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/
1392
+
1393
+ export function workFacts(messages) {
1394
+ const prs = new Map() // number -> { kind: 'pr', number, title, state, repo }
1395
+ const commits = [], pushes = []
1396
+ let tests = null
1397
+ const results = new Map()
1398
+ for (const m of messages || []) for (const r of m.toolResults || []) if (r && r.tool_use_id) results.set(r.tool_use_id, r)
1399
+ const pr = (number, patch) => {
1400
+ const n = Number(number)
1401
+ if (!n) return
1402
+ const cur = prs.get(n) || { kind: 'pr', number: n, title: '', state: 'opened', repo: null }
1403
+ // merged is final: a later "opened" (a re-run of create) doesn't take it back
1404
+ const state = cur.state === 'merged' ? 'merged' : (patch.state || cur.state)
1405
+ prs.delete(n) // newest last
1406
+ prs.set(n, { ...cur, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v)), state })
1407
+ }
1408
+ for (const m of messages || []) {
1409
+ if (m.role !== 'assistant') continue
1410
+ for (const t of m.toolUses || []) {
1411
+ const name = String(t.tool || t.name || '')
1412
+ const res = (t.tool_use_id && results.get(t.tool_use_id)) || null
1413
+ const text = String(t.text != null ? t.text : (res && res.text) || '')
1414
+ const isError = !!(t.isError || (res && res.isError))
1415
+ const input = t.input || {}
1416
+ // GitHub's MCP tools (mcp__github__create_pull_request / merge_pull_request)
1417
+ if (/(?:^|__)create_pull_request$/.test(name)) {
1418
+ if (isError) continue
1419
+ const u = text.match(PR_URL), n = (u && u[2]) || ((text.match(/"number"\s*:\s*(\d+)/) || [])[1])
1420
+ pr(n, { title: clip(String(input.title || ''), 120), state: 'opened', repo: u ? u[1] : (input.owner && input.repo ? input.owner + '/' + input.repo : null) })
1421
+ continue
1422
+ }
1423
+ if (/(?:^|__)merge_pull_request$/.test(name)) {
1424
+ if (isError || /not mergeable|failed|error/i.test(firstLineOf(text))) continue
1425
+ pr(input.pull_number || input.pullNumber || input.number, { state: 'merged', title: clip(String(input.commit_title || ''), 120).replace(/\s*\(#\d+\)$/, ''), repo: input.owner && input.repo ? input.owner + '/' + input.repo : null })
1426
+ continue
1427
+ }
1428
+ if (name !== 'Bash' || !input.command) continue
1429
+ const cmd = String(input.command)
1430
+ if (/\bgit\b[^\n|;&]*\bcommit\b/.test(cmd) && !/--dry-run/.test(cmd)) {
1431
+ for (const c of text.matchAll(/^\[([^\]\s]+)(?: \([^)]*\))? ([0-9a-f]{7,40})\] (.+)$/gm)) {
1432
+ const subject = clip(c[3].trim(), 120)
1433
+ const i = commits.findIndex((x) => x.subject === subject) // an --amend: the new sha, one line
1434
+ if (i >= 0) commits.splice(i, 1)
1435
+ commits.push({ kind: 'commit', sha: c[2].slice(0, 7), subject, branch: c[1] })
1436
+ }
1437
+ }
1438
+ if (/\bgh\s+pr\s+create\b/.test(cmd) && !isError) {
1439
+ const u = text.match(PR_URL)
1440
+ if (u) pr(u[2], { title: clip(argOf(cmd, ['--title', '-t']), 120), state: 'opened', repo: u[1], branch: argOf(cmd, ['--head', '-H']).replace(/^[\w.-]+:/, '') || pushes[pushes.length - 1] || (commits.length ? commits[commits.length - 1].branch : '') })
1441
+ }
1442
+ if (/\bgh\s+pr\s+merge\b/.test(cmd) && !isError) {
1443
+ const n = (cmd.match(/\bgh\s+pr\s+merge\s+(?:[^\s-]\S*\/pull\/)?#?(\d+)\b/) || [])[1] || (text.match(/pull request (?:[\w.-]+\/[\w.-]+)?#(\d+)/i) || [])[1] || ((text.match(PR_URL) || [])[2])
1444
+ const title = (text.match(/pull request (?:[\w.-]+\/[\w.-]+)?#\d+ \((.+)\)\s*$/im) || [])[1] || ''
1445
+ if (/automatically merged|auto-?merge|merge queue|will be merged/i.test(text) || /--auto\b/.test(cmd)) pr(n, { state: 'auto', title: clip(title, 120) })
1446
+ else if (!/not mergeable|failed|error:/i.test(text)) pr(n, { state: 'merged', title: clip(title, 120) })
1447
+ }
1448
+ if (/\bgit\s+push\b/.test(cmd) && !isError) {
1449
+ const b = [...text.matchAll(/(?:\[new branch\]|\.\.|\+)\s*\S*\s+(\S+)\s+->\s+(\S+)/g)].map((x) => x[2]).pop() || [...text.matchAll(/\s(\S+)\s+->\s+(\S+)/g)].map((x) => x[2]).pop()
1450
+ if (b) { const i = pushes.indexOf(b); if (i >= 0) pushes.splice(i, 1); pushes.push(b) }
1451
+ }
1452
+ const tc = cmd.match(TEST_CMD)
1453
+ if (tc) {
1454
+ const counts = testCounts(text)
1455
+ if (counts || isError) tests = { kind: 'tests', ok: !isError && (!counts || counts.failed === 0), passed: counts ? counts.passed : null, failed: counts ? counts.failed : null, cmd: clip(tc[1].replace(/^env\s+(?:-u\s+\S+\s+)*/, '').replace(/^timeout\s+(?:-k\s+\S+\s+)?\S+\s+/, '').replace(/\s--?(?:import|require|loader|r)[= ]\S+/g, '').trim(), 60) }
1456
+ }
1457
+ }
1458
+ }
1459
+ const out = []
1460
+ const prList = [...prs.values()].slice(-WORK_MAX.prs)
1461
+ out.push(...prList.filter((p) => p.state === 'merged'), ...prList.filter((p) => p.state !== 'merged'))
1462
+ out.push(...commits.slice(-WORK_MAX.commits))
1463
+ out.push(...pushes.slice(-WORK_MAX.pushes).map((branch) => ({ kind: 'push', branch })))
1464
+ if (tests) out.push(tests)
1465
+ return out
1466
+ }
1467
+
1468
+ const oneL = (s) => String(s == null ? '' : s).replace(/\s*\n\s*/g, ' ').trim()
1469
+ // One fact, one line; parseWorkLine reads it back.
1470
+ export function renderWorkLine(f) {
1471
+ if (!f) return ''
1472
+ if (f.kind === 'pr') {
1473
+ const head = (f.state === 'merged' ? 'Merged' : f.state === 'auto' ? 'Set to merge' : 'Opened') + ' PR #' + f.number + (f.repo ? ' (' + f.repo + ')' : '') + (f.branch ? ' from ' + f.branch : '')
1474
+ return head + (f.title ? ': ' + oneL(f.title) : '')
1475
+ }
1476
+ if (f.kind === 'commit') return 'Committed ' + f.sha + (f.branch ? ' on ' + f.branch : '') + ': ' + oneL(f.subject)
1477
+ if (f.kind === 'push') return 'Pushed ' + f.branch
1478
+ if (f.kind === 'tests') {
1479
+ const n = f.passed != null ? f.passed + ' passed' + (f.failed ? ', ' + f.failed + ' failed' : '') : ''
1480
+ return 'Tests ' + (f.ok ? 'passed' : 'failed') + (n ? ': ' + n : '') + (f.cmd ? ' (' + oneL(f.cmd).replace(/[()]/g, '') + ')' : '')
1481
+ }
1482
+ return ''
1483
+ }
1484
+ export function parseWorkLine(line) {
1485
+ const t = String(line || '').replace(/^\s*[-*•]\s*/, '').trim()
1486
+ let m
1487
+ if ((m = t.match(/^(Merged|Opened|Set to merge) PR #(\d+)(?: \(([\w.-]+\/[\w.-]+)\))?(?: from (\S+?))?(?::\s*(.*))?$/))) return { kind: 'pr', number: +m[2], state: m[1] === 'Merged' ? 'merged' : m[1] === 'Opened' ? 'opened' : 'auto', repo: m[3] || null, ...(m[4] ? { branch: m[4] } : {}), title: (m[5] || '').trim() }
1488
+ if ((m = t.match(/^Committed ([0-9a-f]{7,40})(?: on (\S+))?:\s*(.*)$/))) return { kind: 'commit', sha: m[1], branch: m[2] || null, subject: m[3].trim() }
1489
+ if ((m = t.match(/^Pushed (\S+)$/))) return { kind: 'push', branch: m[1] }
1490
+ if ((m = t.match(/^Tests (passed|failed)(?::\s*(\d+) passed(?:, (\d+) failed)?)?(?: \((.*)\))?$/))) return { kind: 'tests', ok: m[1] === 'passed', passed: m[2] != null ? +m[2] : null, failed: m[2] != null ? +(m[3] || 0) : null, cmd: m[4] || '' }
1491
+ return null
1492
+ }
1493
+ // "PR 53 is ready to merge" / "Merge PR 53" about a PR that has merged: a status line gone stale.
1494
+ export const STATUSY_PR = /\bPR\s*#?(\d+)\b[^.;]{0,60}?\b(?:ready to merge|ready for review|ready to land|awaiting (?:review|merge|approval)|waiting (?:on|for) (?:review|merge|approval|checks|CI)|open(?:ed)?(?: and)? (?:for|awaiting)|in review|pending (?:review|merge))/i
1495
+ export const STALE_NEXT_PR = /\b(?:merge|land|ship|review|approve|squash)\b[^.;]{0,40}?\bPR\s*#?(\d+)/i
1496
+ // A checkpoint whose Stopped at / Next calls a PR its own facts show merged ready or next to merge:
1497
+ // Stopped at becomes what the facts say, Next is dropped (the digest's next step comes from elsewhere).
1498
+ // -> { stoppedAt, next, corrected: [fields] }; nothing changed when nothing is contradicted.
1499
+ export function reconcileWithWork(cp, work) {
1500
+ const merged = new Map((work || []).filter((f) => f && f.kind === 'pr' && f.state === 'merged').map((f) => [f.number, f]))
1501
+ const out = { stoppedAt: cp.stoppedAt, next: cp.next, corrected: [] }
1502
+ if (!merged.size) return out
1503
+ const one = (t) => String(t || '').replace(/\s+/g, ' ')
1504
+ const m = one(cp.stoppedAt).match(STATUSY_PR)
1505
+ if (m && merged.has(Number(m[1]))) {
1506
+ const pr = merged.get(Number(m[1]))
1507
+ const tests = (work || []).filter((f) => f.kind === 'tests').pop()
1508
+ out.stoppedAt = 'PR #' + pr.number + (pr.title ? ' (' + clip(pr.title, 80) + ')' : '') + ' is merged' + (tests ? '; tests ' + (tests.ok ? 'pass' : 'fail') + (tests.passed != null ? ' (' + (tests.ok ? tests.passed + ' passed' : tests.failed + ' failed') + ')' : '') : '') + '.'
1509
+ out.corrected.push('stoppedAt')
1510
+ }
1511
+ const n = one(cp.next).match(STALE_NEXT_PR)
1512
+ if (n && merged.has(Number(n[1]))) { out.next = ''; out.corrected.push('next') }
1513
+ return out
1514
+ }
1515
+ // A work list from anywhere (the mod's POST, a file) made safe: only what renders and reads back.
1516
+ export function cleanWork(list) {
1517
+ const out = []
1518
+ for (const f of Array.isArray(list) ? list : []) {
1519
+ const w = typeof f === 'string' ? parseWorkLine(f) : parseWorkLine(renderWorkLine(f))
1520
+ if (w) out.push(w)
1521
+ }
1522
+ return out.slice(0, WORK_MAX.prs + WORK_MAX.commits + WORK_MAX.pushes + 1)
1523
+ }
1524
+ // The same fact in two checkpoints is one fact (a commit by its subject, since an amend changes the sha).
1525
+ export function workKey(f) {
1526
+ if (!f) return ''
1527
+ if (f.kind === 'pr') return 'pr:#' + f.number
1528
+ if (f.kind === 'commit') return 'commit:' + String(f.subject || '').toLowerCase()
1529
+ if (f.kind === 'push') return 'push:' + f.branch
1530
+ if (f.kind === 'tests') return 'tests'
1531
+ return ''
1532
+ }
1533
+
1338
1534
  // ---------------------------------------------------------------- checkpoint length (v0.4.6)
1339
1535
  //
1340
1536
  // config summary.length picks how much a checkpoint says. Every length keeps the
@@ -1387,10 +1583,11 @@ const CHECKPOINT_BASE = [
1387
1583
  'You write checkpoints for a developer juggling many coding-agent sessions, read when they come back after half an hour, a day or a weekend. Given the recent transcript of ONE session, start with exactly these five lines:',
1388
1584
  'Goal: <the outcome the developer is after, from what they asked for, in a few plain words; not the latest step. Keep the previous goal unless they changed course or it is done and they moved on>',
1389
1585
  'Progress: <one of Just started, Underway, Mostly done, Done, Stuck> - <what is left between here and the goal, a few words>',
1390
- 'Doing: <what the session was working on, one sentence, concrete nouns: files, features, errors>',
1391
- 'Stopped at: <the exact state when it went quiet: what passed, what failed, what is half-done; say what was actually run or checked, and what is only assumed>',
1586
+ 'Doing: <what the session got done or was working on, one sentence, concrete nouns: files, features, errors; past tense for what is finished, e.g. "Merged PR #53, which keeps the reply box above the keyboard">',
1587
+ 'Stopped at: <the exact state when it went quiet, as outcomes: what passed, what failed, what is half-done, what is merged and what is not; say what was actually run or checked, and what is only assumed>',
1392
1588
  'Next: <the single next action a human or the agent should take>',
1393
1589
  'If the session is blocked on the human (a question, a permission, a decision), start Next with "YOU:".',
1590
+ 'Say outcomes, not statuses: "Merged PR #53" or "Opened PR #53, not merged yet", never "PR 53 was ready to merge". The "Verified from the commands\' output" line is the record of commits, PRs, pushes and tests: never contradict it, and never claim a merge, commit or passing test it does not show.',
1394
1591
  'Describe only what the transcript shows; the lines above it (repo, branch, title, what was asked, previous checkpoint) are background and may be out of date.',
1395
1592
  ]
1396
1593
  // 2026-11-03 decision memory: only when the transcript shows one, quoted, never invented.
@@ -1477,12 +1674,15 @@ export function buildCheckpointPrompt({ session, previous, messages, cfg = DEFAU
1477
1674
  const red = (t) => redact(String(t || ''), (cfg.redact && cfg.redact.patterns) || [])
1478
1675
  const found = decisionsFromMessages(messages).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) }))
1479
1676
  if (found.length) head.push('Decisions already recorded from the session\'s questions and plans (don\'t repeat them): ' + found.map((d) => '“' + clip(d.text, 120) + '”').join('; '))
1677
+ // 2026-11-06: what the commands' own output shows got done, so the model says "Merged PR #53" and not "PR 53 was ready to merge"
1678
+ const work = workFacts(messages).map((f) => (f.kind === 'pr' || f.kind === 'commit' ? { ...f, title: f.title && red(f.title), subject: f.subject && red(f.subject) } : f)).map((f) => Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined)))
1679
+ head.push('Verified from the commands\' output (git, gh, the test runner) this session: ' + (work.length ? work.map(renderWorkLine).join('; ') : 'no commit, PR, push or test run'))
1480
1680
  if (session.needsInput) head.push('State: the session is waiting on the developer (' + (session.lastNotification || 'a prompt') + ').')
1481
1681
  if (wantTitle) head.push('Also write the line: Title: <max 6 words>.')
1482
1682
  const slice = transcriptSlice(messages, { maxInputChars, cwd })
1483
1683
  const patterns = (cfg.redact && cfg.redact.patterns) || []
1484
1684
  const prompt = redact(head.join('\n') + '\nTranscript (oldest→newest, trimmed):\n' + (slice || '(empty)'), patterns)
1485
- return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens, found }
1685
+ return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens, found, work }
1486
1686
  }
1487
1687
 
1488
1688
  // ---------------------------------------------------------------- decisions (2026-11-03)
@@ -1671,6 +1871,9 @@ const SECTION_KEYS = {
1671
1871
  'didn\'t work': 'didntWork', 'didnt work': 'didntWork', 'did not work': 'didntWork', 'what didn\'t work': 'didntWork', 'what didnt work': 'didntWork', 'tried': 'didntWork',
1672
1872
  'recap': 'recap',
1673
1873
  'next steps': 'nextSteps', 'next 3 steps': 'nextSteps',
1874
+ // 2026-11-06: "Got done" is written in code from the commands' output (workFacts); a model
1875
+ // that writes the heading itself is not a source for it, so its lines are dropped
1876
+ 'got done': 'gotDone',
1674
1877
  'decided': 'decided', // 2026-11-03 decision memory
1675
1878
  }
1676
1879
  export const SECTION_TITLES = { decisions: 'Approach notes', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
@@ -1700,6 +1903,7 @@ function parseSections(lines) {
1700
1903
  const push = (k, v) => {
1701
1904
  v = String(v || '').replace(/\*\*/g, '').trim()
1702
1905
  if (!v || NONE_RE.test(v)) return
1906
+ if (k === 'gotDone') return
1703
1907
  if (k === 'recap') { ex.recap = ex.recap ? ex.recap + ' ' + v : v; return }
1704
1908
  if (k === 'files') { const f = parseFileNote(v); if (f.path || f.why) ex.files.push(f); return }
1705
1909
  if (k === 'decided') { const d = parseDecidedLine(v); if (d) ex.decided.push(d); return }
@@ -19,7 +19,7 @@ import {
19
19
  shortId, formatTabTitle, parseTabTitle, tabRenameWaits, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
21
21
  listHintFrom, backgroundListFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend, parseParkArgs,
22
- boardLinkLines, isNonInteractive, isLeftoffHeadless, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText,
22
+ boardLinkLines, isNonInteractive, isLeftoffHeadless, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText, reconcileWithWork,
23
23
  } from './core.js'
24
24
 
25
25
  const PANE_ID = 'checkpoints'
@@ -32,7 +32,7 @@ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can
32
32
  const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
33
33
 
34
34
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
35
- const MOD_VERSION = '0.9.18-beta.12' // keep in step with .claude-plugin/plugin.json
35
+ const MOD_VERSION = '0.9.18-beta.14' // keep in step with .claude-plugin/plugin.json
36
36
 
37
37
  const st = {
38
38
  internal: false,
@@ -866,6 +866,11 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
866
866
  }
867
867
  // 0.9.2: nothing to go on (no prompt, no reply): don't save a placeholder over the last real one
868
868
  if (isEmptyCheckpoint(parsed)) { if (trigger === 'manual') $.ui.toast(NOTHING_TO_CHECKPOINT, { timeoutMs: 6000 }); return null }
869
+ // 2026-11-06: a Stopped at / Next that calls a PR this session merged "ready to merge": the facts win
870
+ if (parsed.format !== 'markdown') {
871
+ const fix = reconcileWithWork(parsed, ctx.work)
872
+ if (fix.corrected.length) { parsed = { ...parsed, stoppedAt: fix.stoppedAt, next: fix.next }; generator = { ...(generator || {}), corrected: fix.corrected.join(' ') } }
873
+ }
869
874
  if (opts.next) parsed = { ...parsed, next: opts.next }
870
875
  const cp = {
871
876
  sessionId: st.sessionId, createdAt: nowIso(), trigger, machine: st.machine,
@@ -874,6 +879,7 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
874
879
  doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
875
880
  userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
876
881
  parseOk: parsed.parseOk, ...(parsed.format === 'markdown' ? { format: 'markdown' } : {}), generator, length: ctx.length,
882
+ work: ctx.work || [], // 2026-11-06: what the commands' output shows got done
877
883
  decided: mergeDecided(ctx.found, generator && generator.by === 'heuristic' ? [] : parsed.decided, { earlier: st.lastCheckpoint && st.lastCheckpoint.decided }), // 2026-11-03 decision memory
878
884
  decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
879
885
  didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.18-beta.12",
3
+ "version": "0.9.18-beta.14",
4
4
  "description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
5
5
  "keywords": [
6
6
  "claude",
package/ui/app.css CHANGED
@@ -435,6 +435,29 @@ h2 .count { color: var(--faint); font-weight: 500; }
435
435
  .pagehead .actions { flex-wrap: wrap; }
436
436
  }
437
437
  .digest-meta { color: var(--faint); font-size: 12.5px; margin: -2px 0 12px; }
438
+ /* 2026-11-06: the digest's versions (ui/digest-versions.js): quiet, one line, a list on tap */
439
+ .dv-line { margin: -10px 0 8px; }
440
+ .dv { position: relative; display: inline-block; }
441
+ .dv-btn { min-height: 44px; padding: 0 10px; margin: 0 0 0 -10px; border: 0; background: transparent; color: var(--muted); font: inherit; font-size: 12.5px; cursor: pointer; border-radius: 8px; -webkit-tap-highlight-color: transparent; }
442
+ .dv-btn:hover, .dv-btn[aria-expanded="true"] { color: var(--text2); background: var(--bg2); }
443
+ .dv-btn:focus-visible { outline: 2px solid var(--accent, currentColor); outline-offset: 1px; }
444
+ .dv-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 190px; margin: 0; padding: 4px; list-style: none; background: var(--card); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--card-shadow); }
445
+ .dv-list[hidden] { display: none; }
446
+ .dv-list a { display: flex; align-items: center; min-height: 44px; padding: 0 10px; border-radius: 7px; color: var(--text2); text-decoration: none; font-size: 13px; white-space: nowrap; }
447
+ .dv-list a:hover { background: var(--bg2); }
448
+ .dv-list a[aria-current="page"] { color: var(--text); font-weight: 600; }
449
+ .dv-old .btn { min-height: 44px; }
450
+ /* Settings → Digest → Let Claude search your notes (ui/mcp-setup.js) */
451
+ .form .full.mcp-box { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; justify-content: stretch; min-width: 0; }
452
+ .mcp-row { position: relative; display: flex; align-items: center; gap: 6px; padding: 6px 4px 6px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg2); }
453
+ .mcp-row code { flex: 1; min-width: 0; white-space: normal; overflow-wrap: anywhere; font-size: 12.5px; border: 0; background: transparent; padding: 0; }
454
+ .mcp-row .copy-float { flex: none; width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; }
455
+ .mcp-row .copy-float:hover, .mcp-row .copy-float.copied { color: var(--text); background: var(--card); }
456
+ .mcp-row .copy-float svg { width: 16px; height: 16px; }
457
+ .mcp-codex summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; color: var(--text2); font-size: 13px; }
458
+ .mcp-codex pre { margin: 6px 0 0; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg2); font-size: 12px; overflow-x: auto; }
459
+ .mcp-states { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12.5px; color: var(--text2); }
460
+ .mcp-state.ok { color: var(--text); }
438
461
  @media (max-width: 640px) {
439
462
  /* the five status counts as one compact row that scrolls, not a 3-row grid */
440
463
  .stats { display: flex; overflow-x: auto; scrollbar-width: none; gap: 8px; margin: 14px -14px 0; padding: 2px 14px; }
@@ -1755,6 +1778,33 @@ html.dv-open, html.dv-open body { overflow: hidden; }
1755
1778
  .dv-sub { padding: 6px 14px 0; }
1756
1779
  .dv-dl .dl-word { display: none; }
1757
1780
  }
1781
+ /* 2026-11-06 An HTML file: Preview · Code (ui/html-preview.js). Preview fills the sheet with the
1782
+ sandboxed page on white (a page with no background of its own is drawn on white, as in a browser);
1783
+ a line under the switch says what remote resources were blocked, with Show and Allow. */
1784
+ .hp-bar { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding-bottom: 10px; }
1785
+ .hp-remote { display: flex; align-items: center; gap: 8px; flex: 1 1 320px; min-width: 0; font-size: 12.5px; color: var(--text2); }
1786
+ .hp-ric { flex: none; width: 15px; height: 15px; color: var(--muted); }
1787
+ .hp-remote.on .hp-ric { color: var(--accent); }
1788
+ .hp-rw { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1789
+ .hp-rw b { font-weight: 600; color: var(--text); }
1790
+ .hp-remote .btn { flex: none; }
1791
+ .hp-list { flex-basis: 100%; margin: 0; padding: 8px 10px; list-style: none; border: 1px solid var(--line); border-radius: 10px; background: var(--bg2); max-height: 160px; overflow: auto; display: grid; gap: 4px; }
1792
+ .hp-list li { display: flex; gap: 8px; align-items: baseline; min-width: 0; font-size: 12px; }
1793
+ .hp-list code { font: 12px/1.4 var(--mono); color: var(--text); overflow-wrap: anywhere; }
1794
+ .hp-kind { flex: none; min-width: 42px; font-size: 11px; color: var(--muted); }
1795
+ .hp-note { flex-basis: 100%; margin: 0; font-size: 12px; }
1796
+ .dv-body.dv-hpv { display: flex; flex-direction: column; overflow: hidden; }
1797
+ .dv-hpv .hp-main { flex: 1; min-height: 0; display: flex; }
1798
+ .dv-hpv .hp-stopped { flex: 1; justify-content: center; }
1799
+ .hp-stopped .btn { margin-top: 10px; }
1800
+ .hp-frame { flex: 1; width: 100%; min-height: 0; border: 1px solid var(--line); border-radius: 10px; background: #fff; color-scheme: light; }
1801
+ @media (max-width: 599.98px) {
1802
+ .hp-seg { flex: 1 1 100%; display: flex; }
1803
+ .hp-seg button { flex: 1 1 0; height: 44px; }
1804
+ .hp-remote { flex-basis: 100%; }
1805
+ .hp-remote .btn { min-height: 44px; }
1806
+ .hp-hosts { display: none; }
1807
+ }
1758
1808
  html.dv-open .lo-backdrop { z-index: 61; }
1759
1809
  html.dv-open .lo-menu { z-index: 62; }
1760
1810
 
@@ -3104,6 +3154,26 @@ html.pv-open, html.pv-open body { overflow: hidden; }
3104
3154
  .md .bi-title:hover { text-decoration: underline; text-underline-offset: 2px; }
3105
3155
  .md .bi-tag { color: var(--faint); font-size: 12px; }
3106
3156
  .md section.start-sec + .digest-hint, .md p.lede + .digest-hint { margin-top: 14px; }
3157
+ /* 2026-11-06: What you got done: per project, one fact a line, each led by a shape and its word (never colour alone) */
3158
+ .md section.done-sec { margin: 16px 0 4px; padding: 10px 14px 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg2); }
3159
+ .md section.done-sec h2 { margin: 2px 0 2px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
3160
+ .md section.done-sec h3 { margin: 12px 0 4px; font-size: 14px; color: var(--text); }
3161
+ .md ul.done, .cp ul:has(> li.dn) { list-style: none; margin: 0; padding: 0; }
3162
+ .md li.dn, .cp li.dn { position: relative; padding: 3px 0 3px 22px; line-height: 1.45; color: var(--text2); overflow-wrap: anywhere; }
3163
+ .md li.dn b { color: var(--text); font-weight: 600; }
3164
+ .md li.dn a { color: var(--text); text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 2px; }
3165
+ .md li.dn::before, .cp li.dn::before { position: absolute; left: 2px; top: 3px; width: 16px; text-align: center; font-weight: 700; color: var(--muted); }
3166
+ li.dn-merged::before { content: '✓'; }
3167
+ li.dn-opened::before { content: '○'; }
3168
+ li.dn-commit::before, li.dn-push::before { content: '•'; }
3169
+ li.dn-pass::before { content: '✓'; }
3170
+ li.dn-fail::before { content: '✗'; }
3171
+ li.dn-decided::before { content: '◆'; font-size: 11px; }
3172
+ li.dn-you::before { content: '!'; }
3173
+ li.dn-open::before { content: '→'; }
3174
+ li.dn-more::before { content: '+'; }
3175
+ .cp dd.cp-done { margin-bottom: 6px; }
3176
+ @media (max-width: 600px) { .md section.done-sec { padding: 8px 12px 4px; margin-left: -4px; margin-right: -4px; } }
3107
3177
  .md section.start-sec ~ #digestSpaces { margin-top: 16px; }
3108
3178
  .md .bi-say { display: block; color: var(--text2); line-height: 1.45; margin-top: 1px; }
3109
3179
  .md details.fin { margin: 10px 0 4px; }
@@ -3602,7 +3672,7 @@ del.gd-x { background: var(--gd-del-word); }
3602
3672
 
3603
3673
  html.live-full, html.live-full body { overflow: hidden; overscroll-behavior: none; }
3604
3674
  .live-box.full { --lf-pl: max(12px, env(safe-area-inset-left)); --lf-pr: max(12px, env(safe-area-inset-right)); position: fixed; z-index: 32; left: 0; right: 0; top: var(--lf-top, 0); height: var(--lf-h, 100vh); height: var(--lf-h, 100dvh); margin: 0; border: 0; border-radius: 0; padding: 0 var(--lf-pr) max(10px, env(safe-area-inset-bottom)) var(--lf-pl); display: flex; flex-direction: column; background: var(--bg); box-sizing: border-box; }
3605
- html.lf-kb .live-box.full { padding-bottom: 8px; }
3675
+ html.lf-kb .live-box.full { padding-bottom: calc(8px + var(--lf-acc, 0px)); } /* --lf-acc: iOS 26's keyboard bar floats over the page (app.js KB_ACCESSORY) */
3606
3676
  .live-box.full > * { flex: none; width: 100%; max-width: 860px; margin-left: auto; margin-right: auto; box-sizing: border-box; }
3607
3677
  .live-fbar { display: flex; align-items: center; gap: 6px; min-height: 52px; padding-top: env(safe-area-inset-top); border-bottom: 1px solid var(--line); }
3608
3678
  .live-box.full > .live-fbar { max-width: none; margin: 0 calc(-1 * var(--lf-pr)) 0 calc(-1 * var(--lf-pl)); width: auto; padding-left: max(4px, env(safe-area-inset-left)); padding-right: max(8px, env(safe-area-inset-right)); background: var(--bg); }
@@ -4546,7 +4616,7 @@ html:not(.live-full) .fs-side { display: none; }
4546
4616
  .fs-side.collapsed .fs-rail { display: none; }
4547
4617
  html.live-full.fs-on .live-box.full { height: calc(var(--lf-h, 100vh) - var(--fs-peek, 56px)); height: calc(var(--lf-h, 100dvh) - 56px - env(safe-area-inset-bottom)); padding-bottom: 6px; }
4548
4618
  html.live-full.lf-kb .fs-side { display: none; }
4549
- html.live-full.lf-kb.fs-on .live-box.full { height: var(--lf-h, 100dvh); }
4619
+ html.live-full.lf-kb.fs-on .live-box.full { height: var(--lf-h, 100dvh); padding-bottom: calc(6px + var(--lf-acc, 0px)); }
4550
4620
  /* 2026-11-06 typing outside the sheet: it steps aside whether or not a keyboard's height was measured (app.js fitSheetAside) */
4551
4621
  html.live-full.lf-typing .fs-side { display: none; }
4552
4622
  html.live-full.lf-typing.fs-on .live-box.full { height: var(--lf-h, 100dvh); }
@@ -4809,6 +4879,13 @@ details[open] > summary .wt-g-chev { transform: rotate(90deg); }
4809
4879
  .wt-ask:has(.wt-ask-in:focus-visible) { border-color: var(--text2); }
4810
4880
  .wt-ask-b { flex: none; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 10px; background: var(--card2); color: var(--text2); cursor: pointer; }
4811
4881
  .wt-pnav { display: none; }
4882
+ /* 2026-11-06 the long page has a bar too when there's a spot to jump to (Next spot stays on screen) */
4883
+ .wt-pnav:has(.wt-spot) { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 14px; border-top: 1px solid var(--line); background: var(--bg); }
4884
+ .wt-pnav:has(.wt-spot) .btn { min-height: 32px; padding: 0 12px; display: inline-flex; align-items: center; gap: 4px; }
4885
+ /* the finish: every step reviewed, said on the overview */
4886
+ .wt-alldone { display: flex; align-items: center; gap: 8px; margin: 14px 0 0; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); color: var(--text); font-size: 13.5px; font-weight: 600; }
4887
+ .wt-alldone[hidden] { display: none; }
4888
+ .wt-alldone .ic { width: 16px; height: 16px; color: var(--green); }
4812
4889
  .wt :is(button, a, summary):focus-visible, .fs-wt:focus-visible, .wt-since:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
4813
4890
  @media (prefers-reduced-motion: no-preference) { .wt-st { transition: border-color .15s, box-shadow .15s; } details[open] > summary .wt-g-chev { transition: transform .12s; } }
4814
4891
  @media (max-width: 1100px) { .wt.wt-big .wt-hw { grid-template-columns: minmax(0, 1fr); } .wt.wt-big .wt-fm { display: none; } .wt-big .wt-in { padding-left: 28px; } }
@@ -5405,8 +5482,23 @@ code .file-ref-off { font-size: inherit; }
5405
5482
  .wt-spot .ic { width: 15px; height: 15px; }
5406
5483
  .wt-spot-n { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
5407
5484
  .wt-gd .wt-spot-on { position: relative; box-shadow: inset 4px 0 0 var(--text), inset 0 0 0 1px var(--line-strong); }
5408
- .wt-spot-tag { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); z-index: 1; display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 1px 7px 0; box-sizing: border-box; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--card); color: var(--text); font-family: var(--sans, inherit); font-size: 11px; font-weight: 600; line-height: 1; white-space: nowrap; pointer-events: none; }
5409
- .wt-spot-tag .ic { width: 11px; height: 11px; }
5485
+ /* 2026-11-06 every spot's chip at its line, all the time; the one Next spot is on is solid. The chip
5486
+ is a .tip-btn (ui/tips.js): hover, focus or a tap explains why it's a spot. A 20px pill in a hit
5487
+ area as tall as the row (44px on touch, by its padding) */
5488
+ .wt-spot-tag { position: absolute; right: 6px; top: 0; bottom: 0; z-index: 1; display: flex; align-items: center; pointer-events: none; }
5489
+ .wt-gd [data-ri]:has(> .wt-spot-tag) { position: relative; }
5490
+ .wt-spot-tag .tip-btn.wt-spot-chip { position: static; width: auto; height: 20px; display: inline-flex; align-items: center; gap: 4px; padding: 1px 7px 0; box-sizing: border-box; border: 1px dashed var(--line-strong); border-radius: 10px; background: var(--card); color: var(--muted); font-family: var(--sans, inherit); font-size: 11px; font-weight: 600; line-height: 1; white-space: nowrap; cursor: help; pointer-events: auto; }
5491
+ .wt-spot-tag.cur .tip-btn.wt-spot-chip { border-style: solid; color: var(--text); }
5492
+ .wt-spot-tag .tip-btn.wt-spot-chip:hover, .wt-spot-tag .tip-btn.wt-spot-chip[aria-expanded="true"] { color: var(--text); background: var(--card2); }
5493
+ .wt-spot-tag .tip-btn.wt-spot-chip .ic { width: 11px; height: 11px; }
5494
+ .wt-spot-tag .tip { font-family: var(--sans, system-ui, sans-serif); font-weight: 400; white-space: normal; pointer-events: auto; }
5495
+ @media (pointer: coarse) { .wt-spot-tag .tip-btn.wt-spot-chip { height: 28px; padding: 1px 9px 0; border-radius: 14px; } .wt-spot-tag .tip-btn.wt-spot-chip::after { content: ''; position: absolute; inset: -8px -4px; } .wt-spot-tag .tip-btn.wt-spot-chip { position: relative; } }
5496
+ /* Next spot in the bar under the reading (slides, and the long page) */
5497
+ .wt-dnav .btn.wt-spot { height: 44px; }
5498
+ @media (pointer: fine) { .wt-dnav .btn.wt-spot { height: 30px; min-height: 30px; } }
5499
+ @media (max-width: 699.98px) { .wt-dnav .btn.wt-spot { width: auto; padding: 0 10px; gap: 4px; } .wt-dnav .wt-spot-l { display: none; } }
5500
+ .wt-pnav .wt-spot { margin-left: auto; }
5501
+ .wt-pnav .wt-spot + .btn { margin-left: 0; }
5410
5502
  /* the file list: folds to a slim rail, its edge drags */
5411
5503
  .wt-railt { flex: none; display: grid; place-items: center; width: 32px; height: 32px; margin-left: auto; border: 0; border-radius: 8px; background: none; color: var(--muted); cursor: pointer; }
5412
5504
  .wt-railt .ic { width: 16px; height: 16px; }
@@ -5645,3 +5737,30 @@ a.tp-cite::after { content: ''; position: absolute; inset: -10px -4px; }
5645
5737
  .prs-picks { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 8px 12px; margin: 0 0 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); }
5646
5738
  .prs-picks .btn, .prs-picks .linkish { min-height: 44px; }
5647
5739
  .wt-dec .wt-dec-id { white-space: nowrap; flex: none; }
5740
+
5741
+ /* 2026-11-06 the quiet "Updated" after the board catches up on return (ui/app.js caughtUpCue): a check
5742
+ and a word, out of the way under the top row, no spinner; no slide under reduced motion */
5743
+ .caught-up { position: fixed; top: calc(var(--lf-top, 0px) + env(safe-area-inset-top, 0px) + 56px); left: 50%; transform: translate(-50%, -4px); z-index: 60; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line, rgba(127,127,127,.3)); background: var(--card, var(--bg, #fff)); color: var(--muted, inherit); font-size: 12.5px; pointer-events: none; opacity: 0; transition: opacity .2s ease, transform .2s ease; }
5744
+ .caught-up[hidden] { display: none; }
5745
+ .caught-up .ic { width: 14px; height: 14px; }
5746
+ .caught-up.in { opacity: 1; transform: translate(-50%, 0); }
5747
+ .caught-up.out { opacity: 0; }
5748
+ @media (prefers-reduced-motion: reduce) { .caught-up { transition: none; transform: translate(-50%, 0); } }
5749
+
5750
+ /* 2026-11-06 subagents working (ui/subagents-view.js): a spinner shape and words, in the status pill
5751
+ and as Live's line under the thread; the spinner stands still under reduced motion */
5752
+ .sub-spin { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 2px solid currentColor; border-right-color: transparent; margin-right: 5px; vertical-align: -1px; animation: sub-spin 1s linear infinite; flex: none; }
5753
+ @keyframes sub-spin { to { transform: rotate(360deg); } }
5754
+ .lv-subs { display: flex; align-items: center; gap: 8px; min-height: 44px; margin: 6px 0 2px; padding: 6px 10px; border: 1px dashed var(--line-strong, var(--line)); border-radius: 10px; color: var(--text); font-size: 13px; }
5755
+ .lv-subs-what { display: flex; flex-direction: column; min-width: 0; flex: 1; }
5756
+ .lv-subs-step { color: var(--muted); font-family: var(--mono); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
5757
+ .lv-subs-when { font-size: 12px; flex: none; }
5758
+ @media (prefers-reduced-motion: reduce) { .sub-spin { animation: none; border-right-color: currentColor; border-style: dotted; } }
5759
+
5760
+ /* 2026-11-06 Save as HTML (digest, decision log; the walkthrough's is in its header): a quiet button,
5761
+ 44px to a finger. The file is one self-contained .html (ui/render-export.js). */
5762
+ .digest-save { display: flex; justify-content: flex-end; margin: -8px 0 8px; }
5763
+ .btn.save-html { background: none; box-shadow: none; color: var(--muted); }
5764
+ .btn.save-html:hover { color: var(--text); }
5765
+ @media (pointer: coarse) { .btn.save-html { min-height: 44px; padding: 0 12px; } }
5766
+ @media (max-width: 600px) { .digest-save { justify-content: flex-start; margin: -6px 0 10px; } .btn.save-html { min-height: 44px; } }