@leftoff/cli 0.6.5 → 0.6.9

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.6.5",
3
+ "version": "0.6.9",
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": "Joel"
package/mod/hooks/core.js CHANGED
@@ -285,8 +285,119 @@ const CP_ORDER = ['id', 'session_id', 'session_title', 'created_at', 'trigger',
285
285
  export function resumeCommand(session) {
286
286
  const id = session.id || session.sessionId
287
287
  const cwd = session.cwd
288
- const q = (p) => (/^[A-Za-z0-9_./~@+-]+$/.test(p) ? p : "'" + String(p).replace(/'/g, "'\\''") + "'")
289
- return (cwd ? 'cd ' + q(cwd) + ' && ' : '') + 'claude --resume ' + id
288
+ return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + claudeInvocation(session, ['--resume', id])
289
+ }
290
+
291
+ // POSIX single quotes when needed (also right in fish).
292
+ export function shArg(s) {
293
+ const v = String(s)
294
+ return /^[A-Za-z0-9_./~@%+=:,-]+$/.test(v) ? v : "'" + v.replace(/'/g, "'\\''") + "'"
295
+ }
296
+
297
+ // ---------------------------------------------------------------- backend (0.6.8)
298
+ //
299
+ // Claude Code pointed at another server. `ollama launch claude --model M`
300
+ // (ollama/ollama cmd/launch/claude.go) runs `claude --model M [args after --]`
301
+ // with ANTHROPIC_BASE_URL = the Ollama host (OLLAMA_HOST, default
302
+ // http://127.0.0.1:11434, also for :cloud models: the local server forwards
303
+ // them), ANTHROPIC_AUTH_TOKEN=ollama, ANTHROPIC_API_KEY empty and
304
+ // ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL / CLAUDE_CODE_SUBAGENT_MODEL = M.
305
+ // We keep { kind: 'ollama', model, baseUrl } (the origin only) or, for any other
306
+ // non-Anthropic base URL, { kind: 'custom', host, model }. Never a token or key:
307
+ // the auth token is only compared with the literal "ollama".
308
+
309
+ const OLLAMA_PORT = '11434'
310
+ const LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1', '0.0.0.0'])
311
+
312
+ // "opus", "sonnet[1m]", "claude-opus-4-5", "us.anthropic.claude-…": a Claude model or alias.
313
+ export function isClaudeModel(m) {
314
+ const v = String(m || '').trim().toLowerCase()
315
+ if (!v || v === '<synthetic>') return true
316
+ return /claude/.test(v) || /^(opus|sonnet|haiku|opusplan|default|best)(\[1m\])?$/.test(v)
317
+ }
318
+
319
+ function httpUrl(raw) {
320
+ try { const u = new URL(String(raw).trim()); return /^https?:$/.test(u.protocol) ? u : null } catch { return null }
321
+ }
322
+
323
+ export function isAnthropicHost(host) { return /(^|\.)anthropic\.com$/i.test(String(host || '')) }
324
+
325
+ // The model Claude Code really talks to: the session's model when it isn't a
326
+ // Claude name, else what the alias resolves to (ANTHROPIC_DEFAULT_*_MODEL),
327
+ // else ANTHROPIC_MODEL, else any tier's pin.
328
+ export function realModel(env = {}, model = null) {
329
+ if (model && !isClaudeModel(model)) return String(model)
330
+ const m = String(model || '').toLowerCase()
331
+ const tier = /haiku/.test(m) ? env.ANTHROPIC_DEFAULT_HAIKU_MODEL : /sonnet/.test(m) ? env.ANTHROPIC_DEFAULT_SONNET_MODEL : env.ANTHROPIC_DEFAULT_OPUS_MODEL
332
+ for (const c of [tier, env.ANTHROPIC_MODEL, env.ANTHROPIC_DEFAULT_OPUS_MODEL, env.ANTHROPIC_DEFAULT_SONNET_MODEL, env.ANTHROPIC_DEFAULT_HAIKU_MODEL, env.CLAUDE_CODE_SUBAGENT_MODEL]) {
333
+ if (c && String(c).trim() && !isClaudeModel(c)) return String(c).trim()
334
+ }
335
+ return null
336
+ }
337
+
338
+ // env (Claude Code's own) -> null (Anthropic) | { kind: 'ollama', model, baseUrl } | { kind: 'custom', host, model }
339
+ export function detectBackend(env = {}, { model = null } = {}) {
340
+ const u = env.ANTHROPIC_BASE_URL ? httpUrl(env.ANTHROPIC_BASE_URL) : null
341
+ if (!u || isAnthropicHost(u.hostname)) return null
342
+ const host = u.hostname.toLowerCase()
343
+ const real = realModel(env, model)
344
+ const ollama = u.port === OLLAMA_PORT || /(^|\.)ollama\.com$/.test(host) || env.ANTHROPIC_AUTH_TOKEN === 'ollama'
345
+ if (ollama) return stripNulls({ kind: 'ollama', model: real, baseUrl: u.origin })
346
+ return stripNulls({ kind: 'custom', host: u.host, model: real })
347
+ }
348
+
349
+ // Whatever arrives on the wire or from a file -> a clean backend, or null.
350
+ export function cleanBackend(b) {
351
+ if (!b || typeof b !== 'object') return null
352
+ const model = typeof b.model === 'string' && b.model.trim() ? clip(b.model.trim(), 120) : null
353
+ if (b.kind === 'ollama') {
354
+ const u = b.baseUrl ? httpUrl(b.baseUrl) : null
355
+ return stripNulls({ kind: 'ollama', model, baseUrl: u ? u.origin : null, inferred: b.inferred ? true : null })
356
+ }
357
+ if (b.kind === 'custom') {
358
+ const host = typeof b.host === 'string' && b.host.trim() ? clip(b.host.trim().replace(/^[a-z]+:\/\//i, '').replace(/[/?#@].*$/, ''), 120) : null
359
+ return stripNulls({ kind: 'custom', host, model, inferred: b.inferred ? true : null })
360
+ }
361
+ return null
362
+ }
363
+
364
+ // A transcript's assistant message.model (what the server echoed) -> a backend
365
+ // for a session that has none (sessions from before 0.6.8). Only a model that
366
+ // isn't Claude's; an Ollama-style "name:tag" is taken as Ollama.
367
+ export function backendFromTranscriptModel(model) {
368
+ if (!model || isClaudeModel(model)) return null
369
+ const m = String(model).trim()
370
+ return /^[\w.\-/]+:[\w.\-]+$/.test(m) ? { kind: 'ollama', model: m, inferred: true } : { kind: 'custom', model: m, inferred: true }
371
+ }
372
+
373
+ // "deepseek-v4-flash:cloud · Ollama" | "qwen3 · gw.example:4000" | "opus" | null
374
+ export function modelLabel(s) {
375
+ const b = s && s.backend
376
+ if (!b) return (s && s.model) || null
377
+ const model = b.model || (s.model && !isClaudeModel(s.model) ? s.model : null)
378
+ if (b.kind === 'ollama') return model ? model + ' · Ollama' : 'Ollama'
379
+ const where = b.host || 'custom server'
380
+ return model ? model + ' · ' + where : where
381
+ }
382
+
383
+ // Resuming through Ollama: `ollama launch claude --model M -- <claude args>`
384
+ // (docs.ollama.com/integrations/claude-code: arguments after -- go to Claude
385
+ // Code). OLLAMA_HOST is set only when the session used a non-default Ollama.
386
+ // Straight to ollama.com (no ollama launch): the env form, no key; the shell's
387
+ // own ANTHROPIC_AUTH_TOKEN signs in.
388
+ export function viaOllama(b) { return !!(b && b.kind === 'ollama' && !isOllamaCom(b.baseUrl)) }
389
+ function isOllamaCom(url) { const u = url ? httpUrl(url) : null; return !!(u && /(^|\.)ollama\.com$/i.test(u.hostname)) }
390
+
391
+ export function claudeInvocation(session, args = []) {
392
+ const b = session && session.backend
393
+ const tail = args.map(shArg).join(' ')
394
+ if (b && b.kind === 'ollama') {
395
+ if (isOllamaCom(b.baseUrl)) return 'ANTHROPIC_BASE_URL=' + shArg(b.baseUrl) + ' ANTHROPIC_API_KEY= claude' + (b.model ? ' --model ' + shArg(b.model) : '') + (tail ? ' ' + tail : '')
396
+ const u = b.baseUrl ? httpUrl(b.baseUrl) : null
397
+ const host = u && !(LOCAL_HOSTS.has(u.hostname) && u.port === OLLAMA_PORT && u.protocol === 'http:') ? 'OLLAMA_HOST=' + shArg(u.origin) + ' ' : ''
398
+ return host + 'ollama launch claude' + (b.model ? ' --model ' + shArg(b.model) : '') + ' --' + (tail ? ' ' + tail : '')
399
+ }
400
+ return 'claude' + (tail ? ' ' + tail : '')
290
401
  }
291
402
 
292
403
  export function renderCheckpointMd(cp) {
@@ -328,7 +439,7 @@ export function renderCheckpointMd(cp) {
328
439
  else if (k === 'nextSteps') v.forEach((x, n) => lines.push((n + 1) + '. ' + oneLine(x).replace(/^\*+/, '')))
329
440
  else for (const x of v) lines.push(bullet(x))
330
441
  }
331
- const resume = resumeCommand({ id: cp.sessionId, cwd: cp.cwd })
442
+ const resume = resumeCommand({ id: cp.sessionId, cwd: cp.cwd, backend: cp.backend })
332
443
  return toFrontmatter(fm, CP_ORDER) + lines.join('\n') + '\n\nResume: `' + resume + '`\n'
333
444
  }
334
445
 
@@ -383,7 +494,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
383
494
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
384
495
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
385
496
  'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
386
- 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason']
497
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked']
387
498
 
388
499
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
389
500
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -433,6 +544,7 @@ export function parseSessionMd(text) {
433
544
  s.backgroundTasks = typeof s.backgroundTasks === 'number' ? s.backgroundTasks : 0
434
545
  s.pinned = !!s.pinned
435
546
  s.archived = !!s.archived
547
+ if (s.backend !== undefined) { const b = cleanBackend(s.backend); if (b) s.backend = b; else delete s.backend }
436
548
  return s
437
549
  }
438
550
 
@@ -604,7 +716,10 @@ export function checkpointDue(s, now, cfg = DEFAULT_CONFIG) {
604
716
  const gapOk = isNaN(lastCp) || now - lastCp >= cfg.minGapMin * MIN
605
717
  const activitySinceCp = (s.turnsSinceCheckpoint || 0) > 0 || (!isNaN(lastActivity) && (isNaN(lastCp) ? !!s.lastActivityAt : lastActivity > lastCp))
606
718
  if (status === 'ended' || status === 'stale') {
607
- if (s.finalCheckpointDone || !activitySinceCp || !gapOk) return null
719
+ // v0.6.7: closing always leaves a checkpoint when anything happened since the
720
+ // last one, even minutes after it (no minGap here): the Closed list and
721
+ // Resume read it. Nothing new, or an empty session, still writes none.
722
+ if (s.finalCheckpointDone || !activitySinceCp) return null
608
723
  if (!s.lastActivityAt) return null
609
724
  return s.endReason === 'clear' ? 'clear' : 'session_end'
610
725
  }
@@ -653,6 +768,8 @@ export function applyEvent(s, ev) {
653
768
  if (d.cwd) s.cwd = d.cwd
654
769
  if (d.transcriptPath) s.transcriptPath = d.transcriptPath
655
770
  if (d.model) s.model = d.model
771
+ // 0.6.8: where the model runs. A capture that says null (Anthropic) clears it; no key leaves it.
772
+ if ('backend' in d) { const b = cleanBackend(d.backend); if (b) s.backend = b; else delete s.backend }
656
773
  if (d.surface) s.surface = d.surface
657
774
  if (d.repo && typeof d.repo === 'object') s.repo = { ...(s.repo || {}), ...stripNulls(d.repo) }
658
775
  // A title carrying our own tab marker came from the mod's /rename: it is the
@@ -18,7 +18,7 @@ import {
18
18
  sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
19
19
  shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
21
- listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs,
21
+ listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend,
22
22
  } from './core.js'
23
23
 
24
24
  const PANE_ID = 'checkpoints'
@@ -27,7 +27,7 @@ const END_TIMEOUT_MS = 1000
27
27
  const MODEL_TIMEOUT_MS = 20000
28
28
 
29
29
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
30
- const MOD_VERSION = '0.6.5' // keep in step with .claude-plugin/plugin.json
30
+ const MOD_VERSION = '0.6.9' // keep in step with .claude-plugin/plugin.json
31
31
 
32
32
  const st = {
33
33
  internal: false,
@@ -461,6 +461,24 @@ async function readTermEnv($) {
461
461
  return env
462
462
  }
463
463
 
464
+ // 0.6.8: is this Claude Code talking to Ollama or another server? (literal
465
+ // names only). The auth token is only compared with "ollama" and never kept.
466
+ async function readBackend($, model) {
467
+ try {
468
+ const env = {}
469
+ const put = (k, v) => { if (v) env[k] = String(v) }
470
+ put('ANTHROPIC_BASE_URL', await $.env.get('ANTHROPIC_BASE_URL'))
471
+ if (!env.ANTHROPIC_BASE_URL) return null
472
+ if ((await $.env.get('ANTHROPIC_AUTH_TOKEN')) === 'ollama') env.ANTHROPIC_AUTH_TOKEN = 'ollama'
473
+ put('ANTHROPIC_MODEL', await $.env.get('ANTHROPIC_MODEL'))
474
+ put('ANTHROPIC_DEFAULT_OPUS_MODEL', await $.env.get('ANTHROPIC_DEFAULT_OPUS_MODEL'))
475
+ put('ANTHROPIC_DEFAULT_SONNET_MODEL', await $.env.get('ANTHROPIC_DEFAULT_SONNET_MODEL'))
476
+ put('ANTHROPIC_DEFAULT_HAIKU_MODEL', await $.env.get('ANTHROPIC_DEFAULT_HAIKU_MODEL'))
477
+ put('CLAUDE_CODE_SUBAGENT_MODEL', await $.env.get('CLAUDE_CODE_SUBAGENT_MODEL'))
478
+ return detectBackend(env, { model })
479
+ } catch { return null }
480
+ }
481
+
464
482
  // The tab title: the session's name ("<name> · cp:<id>" only when the daemon
465
483
  // says Go needs the marker, v0.5.5). Claude Code draws the terminal title
466
484
  // itself (✳ / ◐ + the session's name, repainted about once a second while it
@@ -515,9 +533,12 @@ async function callModel($, ctx) {
515
533
  if (!r || r.timedOut) return null
516
534
  // The types say complete() resolves to the reply's text; the spec sketch
517
535
  // mentions { isAnswered }. Accept both.
518
- if (typeof r === 'string') return { text: r, model }
536
+ // 0.6.8: through Ollama the alias resolves to the Ollama model: name that one.
537
+ const used = st.backend && st.backend.model ? st.backend.model : model
538
+ const backend = st.backend ? st.backend.kind : undefined
539
+ if (typeof r === 'string') return { text: r, model: used, backend }
519
540
  if (r.isAnswered === false) return null
520
- return { text: r.text || '', model }
541
+ return { text: r.text || '', model: used, backend }
521
542
  } catch {
522
543
  return null
523
544
  }
@@ -539,7 +560,7 @@ async function writeCheckpointDirect($, cp) {
539
560
  try { taken = await $.fs.exists(file) } catch {}
540
561
  if (taken) continue
541
562
  const rec = { ...cp, id: checkpointId(st.sessionId, cp.createdAt, n) }
542
- await $.fs.write(file, renderCheckpointMd(rec))
563
+ await $.fs.write(file, renderCheckpointMd({ ...rec, backend: st.backend || undefined }))
543
564
  return rec
544
565
  }
545
566
  return null
@@ -557,7 +578,7 @@ async function makeCheckpoint($, trigger, userNote, length) {
557
578
  const ctx = buildCheckpointPrompt({ session: record, previous: st.lastCheckpoint, messages, cfg: st.cfg, userNote, length })
558
579
  const reply = await callModel($, ctx)
559
580
  let parsed = reply ? parseCheckpointReply(reply.text) : null
560
- let generator = reply ? { by: 'mod', model: reply.model } : null
581
+ let generator = reply ? { by: 'mod', model: reply.model, ...(reply.backend ? { backend: reply.backend } : {}) } : null
561
582
  if (!parsed) {
562
583
  // No model answer: let the daemon try its own summarizer, if it's there.
563
584
  const r = await api($, 'POST', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/checkpoints', { trigger, generate: true, userNote: userNote || undefined, length: ctx.length }, MODEL_TIMEOUT_MS * 3)
@@ -826,7 +847,8 @@ export function register(on, options) {
826
847
  const termEnv = await readTermEnv($)
827
848
  st.term = detectTerminal(termEnv)
828
849
  st.surface = surface
829
- st.hello = { cwd, repo, model, surface, proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term }
850
+ st.backend = await readBackend($, model)
851
+ st.hello = { cwd, repo, model, backend: st.backend, surface, proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term }
830
852
  await sendOrSpool($, 'session.opened', st.hello)
831
853
  if (st.heartbeat) st.heartbeat.cancel()
832
854
  st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
@@ -854,6 +876,7 @@ export function register(on, options) {
854
876
  if (!st.sessionId) st.sessionId = e.session_id
855
877
  await sendOrSpool($, 'session.started', {
856
878
  source: e.source, transcriptPath: e.transcript_path, cwd: e.cwd, sessionTitle: e.session_title, model: e.model, parentSessionId,
879
+ backend: e.model ? await readBackend($, e.model) : (st.backend || null),
857
880
  // a new id in the same process (/clear, /resume) runs in the same tab
858
881
  ...(parentSessionId ? { proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term } : {}),
859
882
  })
@@ -984,7 +1007,7 @@ export function register(on, options) {
984
1007
  Button({ key: 'cp', label: 'Checkpoint', hotkey: 'c', onPress: async () => { await makeCheckpoint($, 'manual', null) } }),
985
1008
  Button({ key: 'copy', label: 'Copy resume', hotkey: 'r', onPress: async () => {
986
1009
  const me = st.paneRows.find((r) => r.id === st.sessionId)
987
- const cmd = me ? me.resume : resumeCommand({ id: st.sessionId, cwd: st.record && st.record.cwd })
1010
+ const cmd = me ? me.resume : resumeCommand({ id: st.sessionId, cwd: st.record && st.record.cwd, backend: st.backend || undefined })
988
1011
  try { await $.ui.copy(cmd); $.ui.toast('Copied: ' + cmd) } catch { $.ui.toast(cmd, { timeoutMs: 10000 }) }
989
1012
  } }),
990
1013
  Button({ key: 'ui', label: 'Open board', hotkey: 'b', onPress: async () => { await openBoard($) } }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.6.5",
3
+ "version": "0.6.9",
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
@@ -95,7 +95,7 @@ h2 .count { color: var(--faint); font-weight: 500; }
95
95
  .pagehead .actions { margin-left: auto; display: flex; gap: 8px; }
96
96
 
97
97
  /* summary strip */
98
- .stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin: 18px 0 4px; }
98
+ .stats { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin: 18px 0 4px; }
99
99
  .stat { background: var(--card); box-shadow: var(--card-shadow); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; text-align: left; cursor: pointer; font: inherit; color: inherit; transition: border-color .15s, background .15s; }
100
100
  .stat:hover { border-color: var(--line-strong); }
101
101
  .stat.on { border-color: var(--accent); background: var(--card2); }
@@ -126,7 +126,7 @@ h2 .count { color: var(--faint); font-weight: 500; }
126
126
  .s-background { color: var(--cyan); background: var(--tint-cyan); }
127
127
  .s-needs_input { color: var(--red); background: var(--tint-red); }
128
128
  .s-dormant { color: var(--accent); background: var(--tint-accent); }
129
- .s-stale, .s-ended { color: var(--gray); background: var(--tint-gray); }
129
+ .s-stale, .s-ended, .s-closed { color: var(--gray); background: var(--tint-gray); }
130
130
  .s-active::before { animation: pulse 1.6s ease-in-out infinite; }
131
131
  @keyframes pulse { 50% { opacity: .35; } }
132
132
 
@@ -212,7 +212,7 @@ h2 .count { color: var(--faint); font-weight: 500; }
212
212
 
213
213
  @media (max-width: 900px) {
214
214
  main { padding: 18px 16px 60px; }
215
- .stats { grid-template-columns: repeat(2, 1fr); }
215
+ .stats { grid-template-columns: repeat(3, 1fr); }
216
216
  .pagehead { flex-wrap: wrap; }
217
217
  .pagehead .actions { margin-left: 0; }
218
218
  .facts { grid-template-columns: repeat(2, 1fr); }
@@ -221,6 +221,10 @@ h2 .count { color: var(--faint); font-weight: 500; }
221
221
  }
222
222
 
223
223
  .chip.machine { color: var(--machine); border-color: color-mix(in srgb, var(--accent) 35%, transparent); background: var(--tint-accent); }
224
+ .chip.model { max-width: 100%; white-space: nowrap; color: var(--text2); gap: 4px; }
225
+ .chip.model .m { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
226
+ .chip.model .via { flex: none; color: var(--muted); }
227
+ .fact.wrap .v { white-space: normal; overflow-wrap: anywhere; }
224
228
  .chip.machine.remote { color: var(--machine-remote); border-color: color-mix(in srgb, var(--yellow) 35%, transparent); background: var(--tint-yellow); }
225
229
  .ro { color: var(--faint); font-size: 12px; align-self: center; margin-left: 4px; }
226
230
  .dates a .m { color: var(--faint); font-size: 11px; margin-left: 6px; }
@@ -615,6 +619,7 @@ html { background: var(--bg); }
615
619
  .banner.restore .rs-main { display: flex; flex-direction: column; min-width: 0; }
616
620
  .banner.restore .rs-t { color: var(--text); font-weight: 600; overflow-wrap: anywhere; }
617
621
  .banner.restore .rs-m { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
622
+ .banner.restore .rs-m.err { color: var(--red); }
618
623
  .banner.restore .rs-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
619
624
  .banner.restore .rs-note { color: var(--muted); font-size: 12px; }
620
625
  .banner .rs-cmds { margin: 8px 0 0; padding: 8px 10px; background: var(--bg2); border: 1px solid var(--line); border-radius: 8px; font: 12px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text2); }
@@ -1048,6 +1053,13 @@ lo-combo .lo-combo-input:focus { outline: none; border-color: var(--accent); box
1048
1053
  .settings { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: 28px; align-items: start; }
1049
1054
  .set-nav { position: sticky; top: 74px; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
1050
1055
  .set-navhead h1 { margin: 0 0 2px 4px; }
1056
+ /* Board → Closed (0.6.7) */
1057
+ .closed-head { margin: 14px 0 0; max-width: 420px; }
1058
+ .closed-card .row1 .title a { color: var(--text); }
1059
+ .closed-card .meta .chip.machine { display: inline-flex; }
1060
+ .closed-more { display: flex; justify-content: center; margin: 16px 0 0; }
1061
+ .closed-end { text-align: center; font-size: 12.5px; margin: 14px 0 0; }
1062
+ @media (max-width: 640px) { .closed-head { max-width: none; } }
1051
1063
  .set-search { position: relative; }
1052
1064
  .set-search .ic { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: var(--faint); pointer-events: none; }
1053
1065
  .set-search input { width: 100%; height: 34px; padding: 0 10px 0 32px; background: var(--field); color: var(--text); border: 1px solid var(--line); border-radius: 9px; font: inherit; font-size: 13.5px; }
@@ -1353,3 +1365,79 @@ a.need-pr:hover { border-color: var(--line-strong); }
1353
1365
  .quick-replies .qr:disabled { opacity: .55; cursor: default; }
1354
1366
  @media (hover: none), (max-width: 700px) { .quick-replies .qr { min-height: 40px; font-size: 14px; } .linkbtn { min-height: 40px; } }
1355
1367
  .need-ctx.open .ctx-lines { display: none; }
1368
+
1369
+ /* Settings → Advanced (0.6.6): one closed fold per section, laid out on the form's own grid. */
1370
+ .form .set-adv { grid-column: 1 / -1; border-top: 1px solid var(--line); padding-top: 10px; margin-top: 4px; grid-template-columns: inherit; gap: inherit; align-items: inherit; }
1371
+ .set-pane { grid-template-columns: inherit; gap: inherit; align-items: inherit; }
1372
+ .form .set-adv > summary { list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; font-weight: 600; padding: 6px 0; min-height: 32px; -webkit-tap-highlight-color: transparent; }
1373
+ .form .set-adv > summary::-webkit-details-marker { display: none; }
1374
+ .form .set-adv > summary::before { content: ''; width: 7px; height: 7px; border-right: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor; transform: rotate(-45deg); transition: transform .15s; margin-right: 2px; }
1375
+ .form .set-adv[open] > summary::before { transform: rotate(45deg); }
1376
+ .form .set-adv > summary:hover { color: var(--text); }
1377
+ .form .set-adv > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
1378
+ .form .set-advbody { display: grid; grid-template-columns: inherit; gap: 14px 18px; align-items: center; margin-top: 10px; }
1379
+ @media (max-width: 640px) { .form .set-advbody { gap: 12px 18px; } }
1380
+ .form .set-advbody > .section:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
1381
+ .form .hint.nomt { margin-top: 0; }
1382
+
1383
+ /* PRs (0.6.9): a card's PRs, the session page's list and Link a PR, the PRs tab */
1384
+ .pr-num { font-weight: 700; font-family: var(--mono); }
1385
+ .pr-state { font-weight: 600; color: var(--green); }
1386
+ .pr-state.draft, .pr-state.closed { color: var(--muted); }
1387
+ .pr-state.merged { color: var(--accent); }
1388
+ .pr-checks { color: var(--muted); white-space: nowrap; }
1389
+ .pr-checks.pass { color: var(--green); }
1390
+ .pr-checks.fail { color: var(--red); }
1391
+ .pr-checks.pending { color: var(--yellow); }
1392
+ .card-prs { display: flex; flex-direction: column; gap: 4px; margin: 8px 0 2px; min-width: 0; }
1393
+ .cpr { display: flex; align-items: center; gap: 7px; min-width: 0; font-size: 12.5px; color: var(--text); text-decoration: none; background: var(--card2); border: 1px solid var(--line); border-radius: 8px; padding: 4px 9px; }
1394
+ a.cpr:hover { border-color: var(--line-strong); }
1395
+ .cpr .pr-title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; }
1396
+ .cpr .pr-num, .cpr .pr-state, .cpr .pr-checks { flex: none; }
1397
+ .cpr.more { color: var(--muted); justify-content: center; }
1398
+ .prs-filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 14px; }
1399
+ .prgroup { margin-bottom: 18px; }
1400
+ .prgroup h2 { font-size: 14px; margin: 14px 0 8px; }
1401
+ .prgroup.attention h2 { color: var(--red); }
1402
+ .prrow { display: flex; gap: 10px; align-items: flex-start; background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin-bottom: 8px; min-width: 0; }
1403
+ .prrow.slim { background: var(--card2); }
1404
+ .pr-main { flex: 1 1 auto; min-width: 0; }
1405
+ .pr-line1 { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 9px; font-size: 12.5px; }
1406
+ .pr-att { color: var(--red); font-weight: 600; }
1407
+ .pr-review { color: var(--muted); }
1408
+ .pr-review.approved { color: var(--green); }
1409
+ .pr-review.changes_requested { color: var(--red); }
1410
+ .prrow .pr-title { font-weight: 500; margin-top: 3px; overflow-wrap: anywhere; }
1411
+ .prrow .pr-title a { color: var(--text); text-decoration: none; }
1412
+ .prrow .pr-title a:hover { text-decoration: underline; }
1413
+ .pr-meta { font-size: 12px; color: var(--faint); margin-top: 2px; overflow-wrap: anywhere; }
1414
+ .pr-sessions { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
1415
+ .pr-sess { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; min-width: 0; }
1416
+ .pr-sess .sesslink { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; }
1417
+ .pr-sess .sesslink::before { content: '↳ '; color: var(--faint); }
1418
+ .pr-actions { display: flex; flex-direction: column; gap: 6px; flex: none; align-items: stretch; }
1419
+ .prlist { margin-bottom: 8px; }
1420
+ .prlink { display: flex; gap: 8px; margin: 6px 0 16px; }
1421
+ .prlink input { flex: 1 1 auto; min-width: 0; font-size: 16px; }
1422
+ .prs-gh { margin-top: 6px; }
1423
+ .prs-foot { font-size: 12px; margin-top: 6px; }
1424
+ @media (max-width: 639.98px) {
1425
+ .tabs { grid-template-columns: repeat(6, minmax(0, 1fr)); }
1426
+ .prrow { flex-direction: column; gap: 8px; }
1427
+ .pr-actions { flex-direction: row; flex-wrap: wrap; }
1428
+ }
1429
+
1430
+ /* 0.6.9: "Closed (N) →" under the Board's grid */
1431
+ .closed-link { margin: 18px 0 6px; text-align: center; }
1432
+ .closed-link .linkish { color: var(--muted); font-size: 13.5px; padding: 8px 12px; }
1433
+ .closed-link .linkish:hover { color: var(--text); }
1434
+
1435
+ /* 0.6.9: the Board's stat tiles read as filters you can tap: a lift on hover,
1436
+ a press on touch, and the picked one clearly on (accent ring, tint, number). */
1437
+ .stat { transition: border-color .15s, background .15s, transform .12s, box-shadow .15s; -webkit-tap-highlight-color: transparent; }
1438
+ @media (hover: hover) { .stat:hover { transform: translateY(-1px); border-color: var(--accent); box-shadow: var(--card-shadow), 0 4px 14px rgba(0, 0, 0, .12); } }
1439
+ .stat:active { transform: scale(.97); }
1440
+ .stat:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1441
+ .stat.on { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent), var(--card-shadow); background: color-mix(in srgb, var(--accent) 12%, var(--card)); }
1442
+ .stat.on .n { color: var(--accent); }
1443
+ .stat.on .k { color: var(--text); }