@leftoff/cli 0.9.18-beta.8 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/chip-fade.js CHANGED
@@ -18,7 +18,7 @@ export function paintFade(track) {
18
18
  return s
19
19
  }
20
20
 
21
- const SEL = '.repos .track'
21
+ const SEL = '.repos .track, #tabStrip .tab-scroll' // 0.9.18: the tab strip fades where tabs run off too
22
22
  // Keeps every chip row's fade right: on first paint (any re-render adds new
23
23
  // tracks), on its own scroll, and when its width or its chips' widths change.
24
24
  export function watchChipFades(doc = document) {
package/ui/cp-fold.js ADDED
@@ -0,0 +1,42 @@
1
+ // A checkpoint's folded extras, named for what's inside (2026-11-05). The old fixed summary said
2
+ // "Decisions, files, open questions" although that section has been Approach notes since
3
+ // 2026-11-03 (real decisions live under Decided), which made decision memory look broken.
4
+ // Unit-tested in test/unit/cp-fold-1105.test.mjs.
5
+
6
+ // In the order the summary names them. 'decided' counts only where the Decided lines are shown.
7
+ const FOLD_NAMES = [['decided', 'decisions'], ['recap', 'recap'], ['decisions', 'approach notes'], ['files', 'files'], ['questions', 'open questions'], ['didntWork', 'what didn’t work'], ['nextSteps', 'next steps']]
8
+
9
+ const present = (cp, k, decided) => {
10
+ if (!cp) return false
11
+ if (k === 'decided') return !!decided && Array.isArray(cp.decided) && cp.decided.length > 0
12
+ const v = cp[k]
13
+ return Array.isArray(v) ? v.length > 0 : !!v
14
+ }
15
+
16
+ // "Approach notes, files, open questions": the first three sections present, sentence case. '' when none.
17
+ export function cpFoldSummary(cp, { decided = false } = {}) {
18
+ const names = FOLD_NAMES.filter(([k]) => present(cp, k, decided)).map(([, n]) => n).slice(0, 3)
19
+ if (!names.length) return ''
20
+ const t = names.join(', ')
21
+ return t.charAt(0).toUpperCase() + t.slice(1)
22
+ }
23
+
24
+ // 2026-11-06: a checkpoint's "Got done" facts (cp.work: commits, PRs, pushes, the last test run, from the
25
+ // commands' own output) as short lines for its card: [{ kind, text }], at most max, then "+N more".
26
+ // kind is a class name (merged, opened, commit, push, pass, fail); the words say the same, shape and text.
27
+ export function workItems(cp, { max = 5 } = {}) {
28
+ const w = cp && Array.isArray(cp.work) ? cp.work : []
29
+ const out = []
30
+ for (const f of w) {
31
+ if (!f || typeof f !== 'object') continue
32
+ if (f.kind === 'pr' && f.number) out.push({ kind: f.state === 'merged' ? 'merged' : 'opened', text: (f.state === 'merged' ? 'Merged' : f.state === 'auto' ? 'Set to merge' : 'Opened') + ' PR #' + f.number + (f.title ? ': ' + String(f.title) : '') + (f.state === 'opened' ? ' (not merged yet)' : '') })
33
+ else if (f.kind === 'commit' && f.subject) out.push({ kind: 'commit', text: 'Committed ' + (f.sha ? '`' + String(f.sha).slice(0, 7) + '` ' : '') + String(f.subject) })
34
+ else if (f.kind === 'push' && f.branch) out.push({ kind: 'push', text: 'Pushed ' + String(f.branch) })
35
+ else if (f.kind === 'tests') out.push({ kind: f.ok ? 'pass' : 'fail', text: (f.ok ? 'Tests passed' : 'Tests failing') + (f.passed != null ? ': ' + (f.ok ? f.passed + ' passed' : f.failed + ' failed, ' + f.passed + ' passed') : '') + (f.cmd ? ' (`' + String(f.cmd).replace(/`/g, '') + '`)' : '') })
36
+ }
37
+ // the PRs and tests matter most; commits fill what's left
38
+ const top = out.filter((x) => x.kind !== 'commit' && x.kind !== 'push'), rest = out.filter((x) => x.kind === 'commit' || x.kind === 'push')
39
+ const all = [...top, ...rest]
40
+ if (all.length <= max) return all
41
+ return [...all.slice(0, max - 1), { kind: 'more', text: '+' + (all.length - max + 1) + ' more' }]
42
+ }
package/ui/decisions.js CHANGED
@@ -10,29 +10,79 @@ export const SOURCE_WORDS = { asked: 'You answered', plan: 'Plan approved', corr
10
10
  export const STATUS_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻' }
11
11
  export const STATUS_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded' }
12
12
 
13
- // A checkpoint's decisions, under the newest checkpoint in the session panel.
14
- // log: the session's decisions from its repo's log, when loaded ({ <text norm>: { id, status } }).
13
+ // 2026-11-05: a status Leftoff set on its own (statusAuto), in words beside the source, and why in the tip.
14
+ export const AUTO_WORDS = { asked: 'automatically', plan: 'automatically', reversed: 'you changed it later' }
15
+ export const AUTO_TIPS = {
16
+ asked: 'Accepted on its own: you picked this answer in the session. Reject keeps it out of new sessions.',
17
+ plan: 'Accepted on its own: you approved this plan in the session. Reject keeps it out of new sessions.',
18
+ reversed: 'Rejected on its own: later in the session you answered the same question differently.',
19
+ }
20
+ const autoOf = (d) => (d && ((d.status === 'accepted' && (d.statusAuto === 'asked' || d.statusAuto === 'plan')) || (d.status === 'rejected' && d.statusAuto === 'reversed')) ? d.statusAuto : null)
21
+ const autoBit = (d, esc) => { const a = autoOf(d); return a ? '<span class="dec-auto" data-tip="' + esc(AUTO_TIPS[a]) + '">' + esc(AUTO_WORDS[a]) + '</span>' : '' }
22
+ const normD = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
23
+ const byNewest = (a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || ''))
24
+
25
+ // Every decision a session made (2026-11-05), newest first. Each later checkpoint leaves out what an earlier one
26
+ // recorded, so the newest checkpoint alone loses them. entries: the session's rows from its repo's log (GET
27
+ // /v1/decisions, sessionId === this session), or null until the log loads / with no repo. A checkpoint's line the
28
+ // log doesn't have yet (written on another Mac, not folded in) still shows, without a status.
29
+ // -> [{ text, why, alternatives, source, entry }]
30
+ export function sessionDecisions(checkpoints, entries = null) {
31
+ const out = [], seen = new Set()
32
+ const log = Array.isArray(entries) ? [...entries].sort(byNewest) : []
33
+ for (const d of log) {
34
+ out.push({ text: d.text, why: d.why || '', alternatives: d.alternatives || [], source: d.source, entry: d })
35
+ seen.add(normD(d.text)); if (d.original && d.original.text) seen.add(normD(d.original.text))
36
+ }
37
+ const cps = (Array.isArray(checkpoints) ? checkpoints : []).filter((c) => c && !c.doubt).sort(byNewest)
38
+ for (const cp of cps) for (const d of Array.isArray(cp.decided) ? cp.decided : []) {
39
+ const k = normD(d && d.text)
40
+ if (!k || seen.has(k)) continue
41
+ seen.add(k); out.push({ text: d.text, why: d.why || '', alternatives: d.alternatives || [], source: d.source, entry: null })
42
+ }
43
+ return out
44
+ }
45
+ // The fold's count: the total, and how many wait on you.
46
+ export function sessionDecisionsCount(list) {
47
+ const n = (list || []).length, rev = (list || []).filter((d) => d.entry && d.entry.status === 'proposed').length
48
+ return n + (rev ? ' · ' + rev + ' to review' : '')
49
+ }
50
+ // The log's rows for one session, as the decided list reads them ({ <text norm>: row }, the original wording too).
51
+ export function logBySession(decisions, sessionId) {
52
+ const by = {}
53
+ for (const d of decisions || []) if (d && d.sessionId === sessionId) { by[normD(d.text)] = d; if (d.original && d.original.text) by[normD(d.original.text)] = d }
54
+ return by
55
+ }
56
+
57
+ // A session's decisions, in the session panel's Decided fold.
58
+ // decided: sessionDecisions() rows ({ entry }), or a checkpoint's plain decided list read against log
59
+ // ({ <text norm>: { id, status } }, when loaded).
15
60
  // 1k: a proposed one is reviewed right here (Accept / Reject / Edit, the log page's API and gate). trusted: false on a
16
61
  // device that may look but not change the log (the buttons show disabled, with the reason). editing: the id being edited.
17
- export function decidedListHtml(decided, { esc = escD, log = null, repoId = null, trusted = true, editing = null, may = true } = {}) {
62
+ // readOnly: an older checkpoint's lines in the Timeline (status only, no buttons, no link).
63
+ export function decidedListHtml(decided, { esc = escD, log = null, repoId = null, trusted = true, editing = null, may = true, readOnly = false } = {}) {
18
64
  const list = Array.isArray(decided) ? decided : []
19
65
  if (!list.length) return ''
20
- const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
21
66
  const rows = list.map((d) => {
22
- const hit = log && log[norm(d.text)]
67
+ const hit = d.entry || (log && log[normD(d.text)]) || null
23
68
  const st = hit ? '<span class="dec-st dec-st-' + esc(hit.status) + '"><span aria-hidden="true">' + STATUS_SHAPE[hit.status] + '</span> ' + esc(STATUS_WORD[hit.status]) + '</span>' : ''
24
- if (hit && editing === hit.id) return '<li class="dec-row" id="decp-' + esc(hit.id) + '"><span class="dec-id">' + esc(hit.id) + '</span>' + editForm({ ...hit, repo: hit.repo || repoId }, esc) + '</li>'
69
+ if (!readOnly && hit && editing === hit.id) return '<li class="dec-row" id="decp-' + esc(hit.id) + '"><span class="dec-id">' + esc(hit.id) + '</span>' + editForm({ ...hit, repo: hit.repo || repoId }, esc) + '</li>'
25
70
  const rp = (hit && (hit.repo || repoId)) || ''
26
71
  const btn = (act, label, cls) => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>' + label + '</button>'
27
- const acts = hit && hit.status === 'proposed' && may ? '<div class="dec-acts dec-acts-panel">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject', '') +
28
- '<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>' : ''
29
- return '<li class="dec-row"' + (hit ? ' id="decp-' + esc(hit.id) + '"' : '') + '>' + (hit ? '<span class="dec-id">' + esc(hit.id) + '</span> ' : '') + '<span class="dec-text">' + esc(hit && hit.edited ? hit.text : d.text) + '</span>' + st +
30
- (d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
31
- (d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
32
- '<div class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</div>' + acts + '</li>'
72
+ const acts = !readOnly && hit && hit.status === 'proposed' && may ? '<div class="dec-acts dec-acts-panel">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject', '') +
73
+ '<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>'
74
+ : !readOnly && hit && may && autoOf(hit) && hit.status === 'accepted' ? '<div class="dec-acts dec-acts-panel dec-acts-quiet">' + btn('reject', 'Reject', '') + '</div>' : ''
75
+ const why = (hit && hit.why) || d.why, alts = (hit && hit.alternatives && hit.alternatives.length ? hit.alternatives : d.alternatives) || []
76
+ // a quiet line first (status · id · where it came from), then the decision in words, then why / over
77
+ const src = '<span class="dec-src">' + esc(SOURCE_WORDS[(hit && hit.source) || d.source] || SOURCE_WORDS.agent) + '</span>'
78
+ const head = [st, hit ? '<span class="dec-id">' + esc(hit.id) + '</span>' : '', src, autoBit(hit, esc)].filter(Boolean).join('<span class="dec-dot" aria-hidden="true"> · </span>')
79
+ return '<li class="dec-row' + (hit ? ' dec-row-' + esc(hit.status) : '') + '"' + (hit && !readOnly ? ' id="decp-' + esc(hit.id) + '"' : '') + '><div class="dec-row-h">' + head + '</div>' +
80
+ '<div class="dec-text">' + esc(hit && (hit.edited || d.entry) ? hit.text : d.text) + '</div>' +
81
+ (why ? '<div class="dec-why">' + esc(why) + '</div>' : '') +
82
+ (alts.length ? '<div class="dec-alt">Over: ' + alts.map(esc).join(' · ') + '</div>' : '') + acts + '</li>'
33
83
  }).join('')
34
- const link = repoId ? '<a class="linkish dec-open" href="' + esc(decisionsHref(repoId)) + '">Decision log →</a>' : ''
35
- return '<ul class="dec-list">' + rows + '</ul>' + link
84
+ const link = repoId && !readOnly ? '<a class="linkish dec-open" href="' + esc(decisionsHref(repoId)) + '">Decision log →</a>' : ''
85
+ return '<ul class="dec-list' + (readOnly ? ' dec-list-ro' : '') + '">' + rows + '</ul>' + link
36
86
  }
37
87
 
38
88
  // 1b: one quiet line in the session panel: the session started with its repo's decisions (shape plus
@@ -115,13 +165,13 @@ export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true,
115
165
  const editBtn = (cls) => edit ? '<button type="button" class="' + cls + '" data-action="dec-edit" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Edit</button>' : ''
116
166
  const link = replaces && !(d.supersedes || []).includes(replaces) ? '<button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(replaces) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Confirm it replaces ' + esc(replaces) + '</button>' : ''
117
167
  const acts = !may && !link ? '' : !may ? '<div class="dec-acts">' + link + '</div>' : review ? '<div class="dec-acts">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject') + editBtn('btn') + link + '</div>'
118
- : '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : editBtn('linkish')) + link + '</div>'
168
+ : '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : (autoOf(d) && d.status === 'accepted' ? btn('reject', 'Reject') : '') + editBtn('linkish')) + link + '</div>'
119
169
  return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
120
170
  '<div class="dec-text">' + esc(d.text) + (d.edited ? ' <span class="faint" data-tip="' + esc('Edited · was: ' + ((d.original && d.original.text) || '')) + '">(edited)</span>' : '') + '</div>' +
121
171
  (d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
122
172
  (d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
123
173
  chainBit(d, byId, esc) + sug +
124
- '<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
174
+ '<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (autoBit(d, esc) ? ' · ' + autoBit(d, esc) : '') + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
125
175
  }
126
176
 
127
177
  // The storage choice (one per project). Off by default: the log stays in Leftoff's folder.
@@ -135,6 +185,12 @@ export function storageChoiceHtml(project, { esc = escD, may = true, trusted = t
135
185
  : 'The log stays in Leftoff’s folder, shared by your Macs. Turn this on to also write accepted decisions into each repo’s ' + esc(project.file || 'docs/DECISIONS.md') + '.') + '</p></div>'
136
186
  }
137
187
 
188
+ // 2026-11-05 an empty log says where decisions come from, so nobody has to guess whether it's working
189
+ export const DECISIONS_EMPTY = ['Decisions come from checkpoints. A question Claude asked that you answered, a plan you approved, or a correction you gave becomes a proposed decision here at the next checkpoint.', 'Checkpoints written before decision memory aren’t read for it.']
190
+ export function decisionsEmptyHtml({ esc = escD } = {}) {
191
+ return '<div class="dec-empty">' + DECISIONS_EMPTY.map((t) => '<p class="faint">' + esc(t) + '</p>').join('') + '</div>'
192
+ }
193
+
138
194
  // The whole page. v: GET /v1/decisions. ui: { all, editing, may }
139
195
  export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
140
196
  const list = (v && v.decisions) || []
@@ -149,11 +205,12 @@ export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
149
205
  (multi ? '<p class="faint">' + v.repos.map((r) => esc(r.label)).join(' · ') + '</p>' : '') + '</header>'
150
206
  if (ui.trusted === false) h += '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' // 1h: the reason in words too (a phone shows no tips)
151
207
  if (v && (v.project || sc.kind === 'repo')) h += '<section class="proj-sec">' + storageChoiceHtml(v.project, { esc, may: ui.may !== false, trusted: ui.trusted !== false }) + '</section>'
152
- if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
208
+ if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Questions you answered and plans you approved are accepted on their own. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
153
209
  const ctl = '<div class="dec-ctl"><button type="button" class="sask-opt" data-action="dec-all" aria-pressed="' + !!ui.all + '">' + (ui.all ? 'Showing superseded and rejected' : 'Include superseded and rejected' + (hidden ? ' (' + hidden + ')' : '')) + '</button>' +
154
- '<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' + testplanLinkHtml(sc, { esc, cls: 'btn' }) + '</div>'
210
+ '<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' +
211
+ '<button type="button" class="btn save-html" data-action="dec-save-html" data-tip="Save as HTML · one file with nothing linked out: opens offline, on a phone, and prints">Save as HTML</button>' + testplanLinkHtml(sc, { esc, cls: 'btn' }) + '</div>' // 2026-11-06
155
212
  h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
156
- (rows.length ? '<ol class="dec-tl">' + rows.map((d) => decisionItemHtml(d, opts)).join('') + '</ol>' : '<p class="faint">' + (review.length ? 'Nothing accepted yet.' : 'No decisions yet. They’re proposed here when a checkpoint sees one: a question you answered, a plan you approved, a correction.') + '</p>') + '</section>'
213
+ (rows.length ? '<ol class="dec-tl">' + rows.map((d) => decisionItemHtml(d, opts)).join('') + '</ol>' : list.length ? '<p class="faint">Nothing accepted yet.</p>' : decisionsEmptyHtml({ esc })) + '</section>'
157
214
  return '<div class="proj dec-page">' + h + '</div>'
158
215
  }
159
216
 
@@ -175,12 +232,15 @@ export function reviewCountHtml(n, { esc = escD, href = null, cls = '' } = {}) {
175
232
  }
176
233
 
177
234
  // The project page's short Decisions section: counts, the storage choice, the way in.
235
+ // 2026-11-05 a single repo's page (v.scope.kind 'repo') gets it too, without the storage choice: that stays a project's.
178
236
  export function projectDecisionsHtml(v, { esc = escD, may = true, trusted = true, scopeArg = '' } = {}) {
179
237
  if (!v) return ''
238
+ const isRepo = !!(v.scope && v.scope.kind === 'repo')
180
239
  const list = v.decisions || []
181
240
  const cur = list.filter((d) => d.status === 'accepted').length, rev = list.filter((d) => d.status === 'proposed').length
182
241
  return '<section class="proj-sec dec-proj" id="projDecisions"><h2>Decisions <span class="count">' + cur + '</span></h2>' +
183
- '<p>' + (cur ? cur + ' current' : 'None accepted yet') + (rev ? ' · <strong>' + rev + ' to review</strong>' : '') + ' · <a class="linkish" href="' + esc(decisionsHref(scopeArg)) + '">Open the timeline →</a></p>' +
242
+ '<p>' + (!list.length ? 'None yet' : cur ? cur + ' current' : 'None accepted yet') + (rev ? ' · <strong>' + rev + ' to review</strong>' : '') + ' · <a class="linkish" href="' + esc(decisionsHref(scopeArg)) + '">Open the timeline →</a></p>' +
243
+ (list.length ? '' : decisionsEmptyHtml({ esc })) +
184
244
  '<div class="dec-acts">' + testplanLinkHtml(v.scope, { esc }) + '</div>' +
185
- storageChoiceHtml(v.project, { esc, may, trusted }) + (trusted === false ? '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' : '') + '</section>'
245
+ (isRepo ? '<p class="faint dec-local">' + (v.project ? 'Where its log is kept is set on ' + esc(v.project.name) + '’s page.' : 'Keeping the log in the repo too is a project choice: available once this repo is in a project.') + '</p>' : storageChoiceHtml(v.project, { esc, may, trusted })) + (trusted === false ? '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' : '') + '</section>'
186
246
  }
@@ -0,0 +1,57 @@
1
+ // The digest's version note (2026-11-06, src/history.js): a quiet "v3 · changed since 9:00 AM" under the
2
+ // title when a day's digest was made more than once, and a list to open an older version
3
+ // (#/digest/<date>?v=N). "Since" is the version this device last opened, kept in localStorage; with none
4
+ // (or the newest), it just says how many versions there are. An older version says so in words, with a
5
+ // way back. Pure functions plus one paint; the board's renderer is not touched.
6
+
7
+ export const SEEN_KEY = 'lo.digestSeen'
8
+ const hm = (iso) => { const t = new Date(iso); return Number.isNaN(t.getTime()) ? '' : t.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) }
9
+
10
+ // { date: version } this device opened
11
+ export function readSeen(storage) { try { const o = JSON.parse(storage.getItem(SEEN_KEY) || '{}'); return o && typeof o === 'object' ? o : {} } catch { return {} } }
12
+ export function markSeen(storage, date, version) {
13
+ try {
14
+ const o = readSeen(storage)
15
+ if ((o[date] || 0) >= version) return
16
+ o[date] = version
17
+ const keep = Object.keys(o).sort().slice(-30) // a month of days is plenty
18
+ storage.setItem(SEEN_KEY, JSON.stringify(Object.fromEntries(keep.map((k) => [k, o[k]]))))
19
+ } catch {}
20
+ }
21
+
22
+ // d: the GET /v1/digests/<date> answer; seen: the version this device last opened (or null)
23
+ // -> { label, older, latest, items: [{ version, label, href, current, shown }] } | null (one version: nothing to show)
24
+ export function versionNote(d, date, seen = null) {
25
+ const vs = d && d.versions
26
+ if (!vs || !Array.isArray(vs.versions) || vs.count < 2) return null
27
+ const shown = d.version || vs.latest
28
+ const latest = vs.versions.find((v) => v.version === vs.latest) || vs.versions[vs.versions.length - 1]
29
+ const since = seen && seen < vs.latest ? vs.versions.find((v) => v.version === seen) : null
30
+ const label = shown !== vs.latest ? 'v' + shown + ' of ' + vs.count : since ? 'v' + vs.latest + ' · changed since ' + hm(since.generatedAt) : 'v' + vs.latest + ' · ' + vs.count + ' versions'
31
+ const items = vs.versions.slice().reverse().map((v) => ({ version: v.version, label: 'v' + v.version + ' · ' + hm(v.generatedAt) + (v.version === vs.latest ? ' (latest)' : ''), href: '#/digest/' + date + (v.version === vs.latest ? '' : '?v=' + v.version), current: v.version === vs.latest, shown: v.version === shown }))
32
+ return { label, older: shown !== vs.latest, shown, latest: vs.latest, latestAt: latest.generatedAt, shownAt: (vs.versions.find((v) => v.version === shown) || {}).generatedAt, items }
33
+ }
34
+
35
+ export function versionNoteHtml(n, { esc, date }) {
36
+ if (!n) return null
37
+ // a div, not a ul: the digest's repo filter shows and hides every ul in the article
38
+ const menu = '<div class="dv-list" id="dvList" role="list" hidden>' + n.items.map((i) => '<a role="listitem" href="' + esc(i.href) + '"' + (i.shown ? ' aria-current="page"' : '') + '>' + (i.shown ? '● ' : '○ ') + esc(i.label) + '</a>').join('') + '</div>'
39
+ const btn = '<button type="button" class="dv-btn" aria-expanded="false" aria-controls="dvList" data-tip="Earlier versions of this digest">' + esc(n.label) + ' ▾</button>'
40
+ const old = n.older ? '<div class="banner dv-old" role="status"><span class="grow"><b>◷ An earlier version</b>: v' + n.shown + ', made ' + esc(hm(n.shownAt)) + '. The latest is v' + n.latest + ' (' + esc(hm(n.latestAt)) + ').</span><a class="btn" href="#/digest/' + esc(date) + '">Back to latest</a></div>' : ''
41
+ return { old, note: '<span class="dv">' + btn + menu + '</span>' }
42
+ }
43
+
44
+ // After the digest is on the page: the note on its own line under the meta line, the banner above the article.
45
+ export function paintDigestVersion(root, d, date, { esc, storage }) {
46
+ const seen = storage ? readSeen(storage)[date] || null : null
47
+ const n = versionNote(d, date, seen)
48
+ if (storage && d && d.version && d.versions && d.version === d.versions.latest) markSeen(storage, date, d.version)
49
+ if (!n) return null
50
+ const html = versionNoteHtml(n, { esc, date })
51
+ const meta = root.querySelector('article.md .digest-meta')
52
+ if (meta) meta.insertAdjacentHTML('afterend', '<div class="dv-line">' + html.note + '</div>')
53
+ if (html.old) { const art = root.querySelector('article.md'); if (art) art.insertAdjacentHTML('beforebegin', html.old) }
54
+ const btn = root.querySelector('.dv-btn'), list = root.querySelector('#dvList')
55
+ if (btn && list) btn.addEventListener('click', () => { const open = btn.getAttribute('aria-expanded') !== 'true'; btn.setAttribute('aria-expanded', String(open)); list.hidden = !open })
56
+ return n
57
+ }
package/ui/fs-side.js CHANGED
@@ -80,3 +80,32 @@ export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cp
80
80
  return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + (scrolled ? ' is-scrolled' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
81
81
  resizeHandleHtml({ width, viewport }, { esc }) + peek + '<div class="fs-side-in">' + side + '</div>' + foot + railHtml({ cpCount, cu, away }, { esc, icon }) + '</aside>'
82
82
  }
83
+
84
+ // 2026-11-06 The phone's sheet follows a finger: drag its peek up to raise it, and drag the raised
85
+ // sheet down to lower it, from the peek or from its content when that's scrolled to the very top.
86
+ // Everywhere else a finger scrolls the sheet's content natively, both ways (a drag never starts
87
+ // once the content has room above it, nor when the finger first heads the way that scrolls it).
88
+ export const SHEET_DRAG_SLOP = 8 // px a finger moves before it's a drag or a scroll
89
+ export const SHEET_DRAG_FLIP = 64 // px of drag that raises or lowers the sheet on letting go
90
+ // up: the sheet is raised; fromPeek: the finger started on the peek; scrollTop: the content's at the touch's start;
91
+ // dy, dx: the finger's travel so far (down and right positive) -> 'drag' | 'scroll' | null (not decided yet)
92
+ export function sheetDragStart({ up = false, fromPeek = false, scrollTop = 0, dy = 0, dx = 0 } = {}) {
93
+ const ay = Math.abs(dy), ax = Math.abs(dx)
94
+ if (ay < SHEET_DRAG_SLOP && ax < SHEET_DRAG_SLOP) return null
95
+ if (ax > ay) return 'scroll' // sideways: a table or code block, never the sheet
96
+ if (!up) return fromPeek && dy < 0 ? 'drag' : 'scroll' // lowered: only the peek, only upwards
97
+ if (dy <= 0) return 'scroll' // raised, finger heading up: the content scrolls down
98
+ return fromPeek || (Number(scrollTop) || 0) <= 0 ? 'drag' : 'scroll'
99
+ }
100
+ // How far the sheet sits from where it rests while dragged (px, down positive; never past either end).
101
+ // travel: the px between raised and lowered.
102
+ export function sheetDragOffset({ up = false, dy = 0, travel = 0 } = {}) {
103
+ const t = Math.max(0, Number(travel) || 0)
104
+ return up ? Math.round(Math.max(0, Math.min(t, dy))) : Math.round(Math.max(-t, Math.min(0, dy)))
105
+ }
106
+ // Letting go: true when the sheet should change (raise or lower), from the travel or a quick flick.
107
+ export function sheetDragFlips({ up = false, dy = 0, ms = 1000 } = {}) {
108
+ const d = up ? dy : -dy
109
+ if (d >= SHEET_DRAG_FLIP) return true
110
+ return d >= 24 && ms > 0 && d / ms > 0.5 // a flick: 0.5 px/ms
111
+ }
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8">
3
+ <!-- The HTML file viewer's sandbox (ui/html-preview.js). Served with its own CSP
4
+ (sandbox allow-scripts, inline only unless ?remote=1). It takes one page from
5
+ the board by postMessage and becomes that page. -->
6
+ <script>
7
+ addEventListener('message', function (e) {
8
+ if (e.source !== parent || !e.data || e.data.t !== 'lo-hp-html') return
9
+ document.open(); document.write(String(e.data.html)); document.close()
10
+ })
11
+ parent.postMessage({ t: 'lo-hp-ready' }, '*')
12
+ </script>
@@ -0,0 +1,136 @@
1
+ // 2026-11-06 An HTML file in the viewer renders as a page (Preview · Code).
2
+ // The page never runs in the board: it runs in ui/html-preview.html, a shell
3
+ // served with its own CSP (previewCsp) whose `sandbox` gives it an opaque
4
+ // origin, so it can't read the board's storage or cookies, and its calls to
5
+ // the daemon carry Origin: null (refused). The board hands it the file by
6
+ // postMessage. Remote resources (fonts, CDN scripts, images) are blocked until
7
+ // you press Allow, for that view only; the bar lists what the file asks for.
8
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
9
+
10
+ export const PREVIEW_PAGE = '/html-preview.html'
11
+ export const VIEW_KEY = 'lo-html-view-v1'
12
+
13
+ // The shell's CSP. Inline only by default; `remote` adds https: alone (not
14
+ // http:, which would match a local board's own http://127.0.0.1 and pull its
15
+ // files in for the page's ./style.css). Never 'self'. Copied in
16
+ // relay/src/user.js (a unit test keeps them the same).
17
+ export function previewCsp(remote = false) {
18
+ const r = remote ? ' https:' : ''
19
+ return "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'" + r + "; style-src 'unsafe-inline'" + r +
20
+ '; img-src data: blob:' + r + '; font-src data:' + r + '; media-src data: blob:' + r + '; connect-src ' + (remote ? 'https:' : "'none'") +
21
+ "; frame-src 'none'; worker-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts"
22
+ }
23
+
24
+ // An HTML file, from either reader: the session's files (type html) or a path
25
+ // in a message (type code, lang html).
26
+ export function isHtmlDoc(r) {
27
+ if (!r || r.missing || r.tooBig || r.binary || typeof r.text !== 'string') return false
28
+ return r.type === 'html' || (r.type === 'code' && (r.lang === 'html' || /^text\/html\b/.test(r.mime || '')))
29
+ }
30
+
31
+ // What the file loads by itself (not links you'd click): src, srcset, poster,
32
+ // <link href> (not a resource hint), <object data>, CSS url() and @import.
33
+ // Remote ones are blocked until Allow (scripts can fetch more once allowed; the
34
+ // bar says so). Local ones (./style.css, img/x.png, /app.js) never load: the
35
+ // preview has only the file itself, so the bar names them.
36
+ const HINT_RELS = /\b(preconnect|dns-prefetch|prerender)\b/i
37
+ const LINK_TAGS = /^(a|area|base|form)$/i
38
+ const unquote = (u) => String(u || '').trim().replace(/^['"]|['"]$/g, '')
39
+ function remoteUrl(u) {
40
+ const s = unquote(u)
41
+ if (/^https?:\/\//i.test(s)) return s
42
+ if (/^\/\/[^/]/.test(s)) return 'https:' + s
43
+ return ''
44
+ }
45
+ function localUrl(u) {
46
+ const s = unquote(u)
47
+ if (!s || remoteUrl(s) || s.charAt(0) === '#' || /^[a-z][a-z\d+.-]*:/i.test(s)) return '' // data:, blob:, about:, mailto:, any other scheme
48
+ return s
49
+ }
50
+ function cssUrls(css, add) {
51
+ for (const m of String(css).matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi)) add(m[2], 'style')
52
+ for (const m of String(css).matchAll(/@import\s+(['"])([^'"]+)\1/gi)) add(m[2], 'style')
53
+ }
54
+ function scanRefs(html, add) {
55
+ const src = String(html || '').replace(/<!--[\s\S]*?-->/g, '')
56
+ for (const m of src.matchAll(/<([a-z][\w-]*)\b([^>]*)>/gi)) {
57
+ const tag = m[1].toLowerCase(), attrs = m[2]
58
+ const attr = (n) => { const a = attrs.match(new RegExp('(?:^|\\s)' + n + '\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\'|([^\\s>]+))', 'i')); return a ? (a[1] ?? a[2] ?? a[3]) : null }
59
+ if (LINK_TAGS.test(tag)) continue
60
+ const kind = tag === 'script' ? 'script' : tag === 'link' ? (/stylesheet/i.test(attr('rel') || '') ? 'style' : 'link') : tag === 'img' || tag === 'picture' || tag === 'source' ? 'image' : tag
61
+ if (attr('src')) add(attr('src'), kind)
62
+ if (attr('poster')) add(attr('poster'), 'image')
63
+ if (tag === 'object' && attr('data')) add(attr('data'), 'object')
64
+ if (tag === 'link' && attr('href') && !HINT_RELS.test(attr('rel') || '')) add(attr('href'), kind)
65
+ for (const c of String(attr('srcset') || '').matchAll(/(\S+)(?:\s+[^,]*)?(?:,|$)/g)) add(c[1].replace(/,+$/, ''), 'image') // a data: URL keeps its comma
66
+ if (attr('style')) cssUrls(attr('style'), add)
67
+ }
68
+ for (const m of src.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) cssUrls(m[1], add)
69
+ }
70
+ export function remoteRefs(html) {
71
+ const seen = new Map()
72
+ scanRefs(html, (u, kind) => { const url = remoteUrl(u); if (url && !seen.has(url)) { let host = ''; try { host = new URL(url).host } catch { return } seen.set(url, { url, host, kind }) } })
73
+ return [...seen.values()]
74
+ }
75
+ export function localRefs(html) {
76
+ const seen = new Map()
77
+ scanRefs(html, (u, kind) => { const url = localUrl(u); if (url && !seen.has(url)) seen.set(url, { url, kind }) })
78
+ return [...seen.values()]
79
+ }
80
+
81
+ // The hosts, in first-seen order: "fonts.googleapis.com, fonts.gstatic.com".
82
+ export function refHosts(refs) { return [...new Set((refs || []).map((x) => x.host))] }
83
+
84
+ // What the shell writes: the file, plus a first script that keeps a link click
85
+ // inside the preview from leaving it (it says where the link goes instead),
86
+ // passes Esc on, answers the board's ping, and says when the page is leaving.
87
+ // The preview's own CSP can't stop a page sending its frame somewhere (a script
88
+ // setting location); the board's CSP does (frame-src 'self' covers a child's
89
+ // navigations, so another site is refused before any request). Behind that, the
90
+ // board stops a preview that leaves for anywhere it can go (ping unanswered after
91
+ // a load, or pagehide) and says so. A meta refresh, which
92
+ // leaves with no script, is taken out; while blocked, so are resource hints (a
93
+ // preconnect isn't covered by CSP).
94
+ const GUARD = '<script>(function(){var P=parent,D=document;D.addEventListener("click",function(e){var a=e.target&&e.target.closest&&e.target.closest("a[href],area[href]");if(!a)return;var h=a.getAttribute("href")||"";if(h.charAt(0)==="#")return;e.preventDefault();P.postMessage({t:"lo-hp-link",href:h},"*")},true);D.addEventListener("keydown",function(e){if(e.key==="Escape")P.postMessage({t:"lo-hp-esc"},"*")});addEventListener("message",function(e){if(e.source===P&&e.data&&e.data.t==="lo-hp-ping")P.postMessage({t:"lo-hp-pong",n:e.data.n},"*")});addEventListener("pagehide",function(){P.postMessage({t:"lo-hp-left"},"*")})})()</script>'
95
+ const attrOf = (t, n) => (t.match(new RegExp('\\b' + n + '\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\'|([^\\s>]+))', 'i')) || []).slice(1).join(' ')
96
+ export function previewDoc(html, { remote = false } = {}) {
97
+ let s = String(html || '')
98
+ s = s.replace(/<meta\b[^>]*>/gi, (t) => (/^\s*refresh\s*$/i.test(attrOf(t, 'http-equiv')) ? '' : t))
99
+ if (!remote) s = s.replace(/<link\b[^>]*>/gi, (t) => (HINT_RELS.test(attrOf(t, 'rel')) ? '' : t))
100
+ const dt = s.match(/^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype[^>]*>/i)
101
+ return dt ? dt[0] + GUARD + s.slice(dt[0].length) : GUARD + s
102
+ }
103
+
104
+ // The bar above the file: Preview · Code, and in Preview what's blocked.
105
+ export function previewBarHtml({ mode = 'preview', refs = [], remote = false, open = false, locals = [], openLocal = false, icon = () => '' } = {}) {
106
+ const seg = (m, label, tip) => '<button type="button" data-action="hp-mode" data-mode="' + m + '" aria-pressed="' + (mode === m) + '" data-tip="' + esc(tip) + '">' + esc(label) + '</button>'
107
+ let h = '<div class="wt-seg wt-seg-sm hp-seg" role="group" aria-label="Show the file as">' + seg('preview', 'Preview', 'The page, rendered in a sandbox') + seg('code', 'Code', 'The file’s source') + '</div>'
108
+ if (mode === 'preview' && refs.length) {
109
+ const hosts = refHosts(refs)
110
+ const n = refs.length
111
+ const words = remote ? 'Loading ' + n + ' remote ' + (n === 1 ? 'resource' : 'resources') : n + ' remote ' + (n === 1 ? 'resource' : 'resources') + ' blocked'
112
+ h += '<div class="hp-remote' + (remote ? ' on' : '') + '">' + icon(remote ? 'globe' : 'shield', 'ic hp-ric') +
113
+ '<span class="hp-rw"><b>' + esc(words) + '</b><span class="hp-hosts"> from ' + esc(hosts.slice(0, 2).join(', ') + (hosts.length > 2 ? ' and ' + (hosts.length - 2) + ' more' : '')) + '</span></span>' +
114
+ '<button type="button" class="btn sm hp-show" data-action="hp-show" aria-expanded="' + !!open + '" aria-controls="hpList' + (locals.length ? ' hpLocal' : '') + '">' + (open ? 'Hide' : 'Show') + '</button>' +
115
+ (remote ? '<button type="button" class="btn sm" data-action="hp-block" data-tip="Reload the preview with remote resources blocked">Block</button>'
116
+ : '<button type="button" class="btn sm" data-action="hp-allow" data-tip="Load these, and anything they load, for this view only">Allow</button>') + '</div>'
117
+ if (open) {
118
+ h += '<ul class="hp-list" id="hpList">' + refs.map((x) => '<li><span class="hp-kind">' + esc(x.kind) + '</span><code>' + esc(x.url) + '</code></li>').join('') + '</ul>' +
119
+ '<p class="hp-note faint">' + (remote ? '' : 'Allowing also lets the page’s scripts load anything else they ask for. ') + 'Links in the page don’t open from the preview.</p>'
120
+ }
121
+ }
122
+ if (mode === 'preview' && locals.length) {
123
+ // with a remote line above, its Show lists both, so this line is words only
124
+ const n = locals.length, own = !refs.length
125
+ const names = locals.slice(0, 2).map((x) => x.url.split(/[?#]/)[0].split('/').pop() || x.url)
126
+ const shown = own ? openLocal : open
127
+ h += '<div class="hp-remote hp-local' + (own ? '' : ' hp-words') + '">' + icon('file-text', 'ic hp-ric') +
128
+ '<span class="hp-rw"><b>' + n + ' local ' + (n === 1 ? 'file' : 'files') + ' not loaded</b><span class="hp-hosts">: ' + esc(names.join(', ') + (n > 2 ? ' and ' + (n - 2) + ' more' : '')) + '</span></span>' +
129
+ (own ? '<button type="button" class="btn sm hp-show" data-action="hp-show-local" aria-expanded="' + !!openLocal + '" aria-controls="hpLocal">' + (openLocal ? 'Hide' : 'Show') + '</button>' : '') + '</div>'
130
+ if (shown) {
131
+ h += '<ul class="hp-list" id="hpLocal">' + locals.map((x) => '<li><span class="hp-kind">' + esc(x.kind) + '</span><code>' + esc(x.url) + '</code></li>').join('') + '</ul>' +
132
+ '<p class="hp-note faint">The preview has only this file, so files it loads from beside it aren’t there and the page may look unstyled. Open it in a browser from its folder to see it whole.</p>'
133
+ }
134
+ }
135
+ return h
136
+ }
package/ui/icons.js CHANGED
@@ -57,6 +57,7 @@ export const ICONS = {
57
57
  "layers": "<path d=\"M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z\"/> <path d=\"M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12\"/> <path d=\"M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17\"/>",
58
58
  "bot": "<path d=\"M12 8V4H8\"/> <rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/> <path d=\"M2 14h2\"/> <path d=\"M20 14h2\"/> <path d=\"M15 13v2\"/> <path d=\"M9 13v2\"/>",
59
59
  "smartphone": "<rect width=\"14\" height=\"20\" x=\"5\" y=\"2\" rx=\"2\" ry=\"2\"/> <path d=\"M12 18h.01\"/>",
60
+ "globe": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\"/> <path d=\"M2 12h20\"/>",
60
61
  "shield": "<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\"/>",
61
62
  "info": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M12 16v-4\"/> <path d=\"M12 8h.01\"/>",
62
63
  "chevron-right": "<path d=\"m9 18 6-6-6-6\"/>",
package/ui/live-status.js CHANGED
@@ -44,17 +44,83 @@ function resetWords(raw) {
44
44
  const m = raw.match(/\bresets?\s+(?:at\s+)?([^·|\n.]{1,40})/i) // "You've hit your limit · resets 7am (America/Chicago)"
45
45
  return m ? 'resets ' + ago(m[1]).replace(/\s*\(.*$/, '') : ''
46
46
  }
47
+ // Claude Code 2.1.318 prints one stderr line from 10 days before a stored
48
+ // claude.ai sign-in ends (text output only; read from its own code). It is a
49
+ // notice, not why anything failed, and its "claude auth login" would read as
50
+ // "signed out": every classifier drops it first. Its three wordings, exactly:
51
+ // Warning: this sign-in ends between <d> and <d> (in N day(s) at the earliest). Renewing it does not move those dates. Run claude auth login before then, or use claude setup-token for unattended jobs.
52
+ // Warning: this sign-in can end at any time from now until <d>. Renewing it does not move that date. Run claude auth login now, or use claude setup-token for unattended jobs.
53
+ // Warning: this sign-in may have ended between <d> and <d>. If requests are refused, run claude auth login, or use claude setup-token for unattended jobs.
54
+ const SIGNIN_WARNING_RE = /^[ \t]*Warning: this sign-in (?:ends between [^\n]*? at the earliest\)\. Renewing it does not move those dates\.|can end at any time from now until [^\n]*?\. Renewing it does not move that date\.|may have ended between [^\n]*?\. If requests are refused,) run claude auth login[^\n]*? or use claude setup-token for unattended jobs\.[ \t]*(?:\r?\n|$)/gim
55
+ export function dropSignInWarning(text) { return String(text == null ? '' : text).replace(SIGNIN_WARNING_RE, '') }
56
+
57
+ // Claude Code's own "signed out" lines, matched specifically, so an MCP server's
58
+ // or another tool's "failed to authenticate", or a 10-day warning whose words
59
+ // drifted from the three above, never reads as signed out. 2.1.318 (read from
60
+ // its package; test/fixtures/cc-2.1.318/from-package.json signedOut):
61
+ // Failed to authenticate: this login reached its time limit … / OAuth session expired …
62
+ // Login reached its time limit · Please run /login
63
+ // plus the API's own authentication_error. Each line is checked on its own, past
64
+ // a "claude -p exited N:" / "claude -p:" prefix (how Ask and the summarizer
65
+ // report it), and a line starting "Warning: this sign-in" is never one.
66
+ export function signInLines(text) {
67
+ return String(text == null ? '' : text).split('\n').filter((l) => !/^\s*Warning: this sign-in\b/i.test(l)).map((l) => l.replace(/^\s*claude -p\b[^:\n]{0,40}:\s*/i, '')).join('\n')
68
+ }
69
+ // 2026-11-05: a line that names an MCP server or another tool is that tool's failure, never Claude signed
70
+ // out, whatever its words ('MCP server "github": HTTP 401 Unauthorized', 'gh: HTTP 401: Requires authentication').
71
+ export const OTHER_TOOL_RE = /\bMCP\b|\bmcp__|\bgh\b|\bgithub\b|\bgitlab\b|\bbitbucket\b|\bjira\b|\bslack\b|\bnpm\b|\bdocker\b|\bcurl\b|\bgit\b|\bplugin\b/i
72
+ const claudeLines = (text) => signInLines(text).split('\n').filter((l) => l.trim() && !OTHER_TOOL_RE.test(l))
73
+ export function claudeAuthFailed(text) {
74
+ return claudeLines(text).some((l) => /^[ \t]*Failed to authenticate:|login reached its time limit|\bauthentication_error\b/i.test(l))
75
+ }
76
+ // Claude's own API refusing the login: an "API Error: 401 …" / "API Error: 403 …" line, or the API's
77
+ // authentication_error / permission_error JSON, on a line that names no other tool. -> 401 | 403 | null.
78
+ // A bare 401, 403, "unauthorized", "forbidden" or "credentials" anywhere else is someone else's.
79
+ export function claudeApiAuthStatus(text) {
80
+ for (const l of claudeLines(text)) {
81
+ if (/\bAPI Error:?\s*401\b|\bauthentication_error\b/i.test(l)) return 401
82
+ if (/\bAPI Error:?\s*403\b|\bpermission_error\b/i.test(l)) return 403
83
+ }
84
+ return null
85
+ }
86
+ // Claude Code's own signed-out words (not tied to a status), line by line, past other tools' lines.
87
+ const CLAUDE_SIGNED_OUT_RE = /invalid api key|run \/login|please (?:log|sign) ?in|not (?:logged|signed) in|log ?in again|login required|oauth token|token (?:has )?expired/i
88
+ export function claudeSignedOut(text) {
89
+ return claudeAuthFailed(text) || claudeApiAuthStatus(text) === 401 || claudeLines(text).some((l) => CLAUDE_SIGNED_OUT_RE.test(l))
90
+ }
91
+
92
+ // 2.1.318: claude -p refuses to start on an invalid config file (it no longer
93
+ // resets it): "Configuration error in <file>: <why>" / "Claude configuration
94
+ // file at <file> is corrupted" / "Fix the JSON in <file>, or move the file aside".
95
+ // -> the file (home shown as ~ when given), or '' when named nowhere, or null.
96
+ // The path runs to the ".json" that ends it (":", ",", ";", ")", " is corrupted", a
97
+ // sentence's "." , " (" or the line's end), so a folder with a space ("/Users/a b/.claude.json")
98
+ // is kept whole and drifted wording ("….json.", "….json (line 3)", "….json; fix it") still reads.
99
+ const CONFIG_BAD_RE = /(?:Configuration error in|Claude configuration file at|Fix the JSON in) ([^\n]*?\.json)(?=[:,;)]| is corrupted|\.(?:[ \t]|$)|[ \t]+\(|[ \t]*$)/im
100
+ export function claudeConfigError(text, home = '') {
101
+ const m = CONFIG_BAD_RE.exec(String(text == null ? '' : text))
102
+ if (!m) return null
103
+ const f = m[1]
104
+ return home && f.startsWith(home + '/') ? '~' + f.slice(home.length) : f
105
+ }
106
+
47
107
  export function turnErrorWhy(raw0, { subtype = null, code = null, signal = null, endReason = null } = {}) {
48
- const raw = ago(raw0).slice(0, WHY_RAW_MAX)
108
+ const raw = ago(dropSignInWarning(raw0)).slice(0, WHY_RAW_MAX)
49
109
  const w = (label) => ({ label, raw: raw || null })
50
110
  if (raw && /^\[?Request interrupted by user/i.test(raw)) return null
51
111
  if (!raw && endReason === 'aborted') return null
112
+ const cfg = claudeConfigError(raw)
113
+ if (cfg != null) return w('Claude Code’s config file is invalid · fix the JSON in ' + (cfg || '~/.claude.json') + ', or move it aside')
52
114
  if (/usage limit|hit your (usage )?limit|limit reached|out of (extra )?usage|weekly limit|session limit/i.test(raw)) { const r = resetWords(raw); return w('Claude’s usage limit is reached' + (r ? ' · ' + r : '')) }
53
115
  if (/rate[ _-]?limit|\b429\b|too many requests/i.test(raw)) return w('Claude is rate limited right now · try again in a minute')
54
116
  if (/prompt is too long|context (length|window)|too many tokens|maximum context|input is too long/i.test(raw)) return w('The conversation is too long for Claude · run /compact in its terminal, then retry')
55
117
  if (/overloaded|\b529\b/i.test(raw)) return w('Claude’s servers are overloaded · try again in a minute')
56
118
  if (/credit balance|billing|payment required|\b402\b/i.test(raw)) return w('Claude’s account is out of credit')
57
- if (/invalid api key|run \/login|please (log|sign) ?in|not logged in|oauth token|token (has )?expired|authentication|\b401\b|\b403\b|unauthori[sz]ed|forbidden/i.test(raw)) return w(SIGNED_OUT_LABEL)
119
+ // 2.1.318's own signed-out lines (claudeAuthFailed), never a bare "authenticat" or
120
+ // "claude auth login": an MCP server's auth failure or the 10-day warning isn't a sign-out
121
+ const auth = signInLines(dropSignInWarning(raw0)) // by line, before raw's newlines are folded
122
+ // 2026-11-05: a 401 / 403 counts only from Claude's own API error, never another tool's line (claudeApiAuthStatus)
123
+ if (claudeSignedOut(auth) || claudeApiAuthStatus(auth) === 403) return w(SIGNED_OUT_LABEL)
58
124
  if (/timed? ?out|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch failed|network|socket hang up|connection (error|refused|reset)/i.test(raw)) return w('Claude couldn’t reach its servers · check this Mac’s connection')
59
125
  if (/no conversation found|session .*not found/i.test(raw)) return w('Claude Code couldn’t reopen this conversation')
60
126
  const api = raw.match(/API Error:?\s*(\d{3})?/i)