@leftoff/cli 0.9.18-beta.1 → 0.9.18-beta.11

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/ask.js CHANGED
@@ -19,7 +19,7 @@ import { glyphState, glyphHtml } from './sglyph.js' // 0.9.14: the status glyph,
19
19
  export const projectHref = (token) => '#/project/' + encodeURIComponent(String(token || ''))
20
20
  export const threadHref = (id, machine = null) => '#/ask/' + encodeURIComponent(String(id || '')) + (machine ? '?m=' + encodeURIComponent(machine) : '')
21
21
 
22
- const TAG = /\[((?:s|cp)\d{1,3}|c:[0-9a-f]{7}|d:\d{4}-\d{2}-\d{2})\]/g
22
+ const TAG = /\[((?:s|cp)\d{1,3}|c:[0-9a-f]{7}|d:\d{4}-\d{2}-\d{2}|dec:D-[0-9a-f]{4,12})\]/g
23
23
 
24
24
  // Where a citation chip goes: a session, a checkpoint on its session page,
25
25
  // a digest day; a commit has no page here (its sha is copied).
@@ -28,15 +28,17 @@ export function chipHref(c) {
28
28
  if (c.kind === 'session' && c.sessionId) return '#/s/' + encodeURIComponent(c.sessionId)
29
29
  if (c.kind === 'checkpoint' && c.sessionId) return '#/s/' + encodeURIComponent(c.sessionId) + (c.cpId ? '?cp=' + encodeURIComponent(c.cpId) : '')
30
30
  if (c.kind === 'digest' && c.date) return '#/digest/' + encodeURIComponent(c.date)
31
+ if (c.kind === 'decision' && c.repo && c.id) return '#/decisions/' + String(c.repo).replace(/[^\w:./@-]/g, encodeURIComponent) + '?d=' + encodeURIComponent(c.id) // 2026-11-03
31
32
  return null
32
33
  }
33
34
  export function chipText(c) {
34
35
  if (!c) return ''
35
36
  if (c.kind === 'commit') return (c.short || String(c.sha || '').slice(0, 7)) + (c.label ? ' ' + c.label : '')
36
37
  if (c.kind === 'digest') return 'Digest ' + (c.date || '')
38
+ if (c.kind === 'decision') return (c.id || 'Decision') + (c.label ? ' ' + c.label : '')
37
39
  return c.label || (c.kind === 'checkpoint' ? 'Checkpoint' : 'Session')
38
40
  }
39
- const KIND_ICON = { session: 'history', checkpoint: 'check', commit: 'copy', digest: 'newspaper' }
41
+ const KIND_ICON = { session: 'history', checkpoint: 'check', commit: 'copy', digest: 'newspaper', decision: 'route' }
40
42
 
41
43
  // One chip. Its words are filled in as text (data-qt).
42
44
  export function chipHtml(c, key, { esc, icon = () => '', n = null } = {}) {
@@ -236,7 +238,7 @@ export function projectHeadHtml(p, { esc, icon = () => '', switcher = false, edi
236
238
  const sc = (p && p.scope) || {}
237
239
  const repos = (sc.repoNames && sc.repoNames.length ? sc.repoNames : sc.repos) || []
238
240
  const keys = { 'p-title': (sc.emoji ? sc.emoji + ' ' : '') + scopeName(sc), 'p-sub': [sc.kind === 'project' ? (repos.length ? repos.join(', ') : null) : (sc.sub ? 'folder ' + sc.sub : null), (sc.machines || []).length ? 'on ' + sc.machines.join(', ') : null, p && p.sessionCount != null ? p.sessionCount + (p.sessionCount === 1 ? ' session' : ' sessions') : null].filter(Boolean).join(' · ') }
239
- return { html: '<div class="pagehead proj-head"><div class="proj-head-main"><div class="proj-kicker">' + icon('folder') + '<span>Project</span></div>' + (switcher ? '<h1 class="proj-title"><button type="button" class="proj-switch" data-action="project-ask-menu" aria-haspopup="menu" aria-expanded="false" data-tip="Ask about another project or repo"><span data-qt="p-title"></span>' + icon('chevron-down') + '</button></h1>' : '<h1 data-qt="p-title"></h1>') + '<div class="sub" data-qt="p-sub"></div></div>' +
241
+ return { html: '<div class="pagehead proj-head"><div class="proj-head-main"><div class="proj-kicker">' + icon('folder') + '<span>Project</span></div>' + (switcher ? '<h1 class="proj-title"><button type="button" class="proj-switch" data-action="project-ask-menu" aria-haspopup="menu" aria-expanded="false" data-tip="Ask about another project or repo"><span data-qt="p-title"></span>' + icon('chevron-down') + '</button></h1>' : '<h1 data-qt="p-title"></h1>') + '<div class="sub" data-qt="p-sub"></div><div class="proj-review-slot" id="projDecReview"></div></div>' + // 1k: decisions to review, filled once the log loads
240
242
  // 0.9.12: Edit (the project's own menu: Rename…, Edit repos…, Delete project) where projects can change
241
243
  (edit && sc.kind === 'project' ? '<button type="button" class="btn proj-edit" data-action="project-manage" data-project="' + esc(edit) + '" aria-haspopup="menu" aria-expanded="false" data-tip="Rename, edit repos, delete">' + icon('pencil') + '<span>Edit</span></button>' : '') + '</div>', keys }
242
244
  }
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,22 @@
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
+ }
@@ -0,0 +1,245 @@
1
+ // Decision memory (2026-11-03): pure builders for the board. A checkpoint's
2
+ // Decided list (the session panel), a repo's or a project's decision log (the
3
+ // timeline), and the decision chips a walkthrough step cites. app.js wires
4
+ // them; unit-tested in test/unit/decisions-ui-1103.test.mjs.
5
+
6
+ const escD = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
7
+ const day = (iso) => String(iso || '').slice(0, 10)
8
+ export const SOURCE_WORDS = { asked: 'You answered', plan: 'Plan approved', correction: 'Your correction', agent: 'Agent chose' }
9
+ // Status as a shape and a word, never color alone.
10
+ export const STATUS_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻' }
11
+ export const STATUS_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded' }
12
+
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).
60
+ // 1k: a proposed one is reviewed right here (Accept / Reject / Edit, the log page's API and gate). trusted: false on a
61
+ // device that may look but not change the log (the buttons show disabled, with the reason). editing: the id being edited.
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 } = {}) {
64
+ const list = Array.isArray(decided) ? decided : []
65
+ if (!list.length) return ''
66
+ const rows = list.map((d) => {
67
+ const hit = d.entry || (log && log[normD(d.text)]) || null
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>' : ''
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>'
70
+ const rp = (hit && (hit.repo || repoId)) || ''
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>'
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>'
83
+ }).join('')
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
86
+ }
87
+
88
+ // 1b: one quiet line in the session panel: the session started with its repo's decisions (shape plus
89
+ // words), or wasn't given them because Claude Code skipped Leftoff's SessionStart (the why in the tip).
90
+ export function decisionsGivenHtml(s, { esc = escD, when = (iso) => String(iso || '').slice(0, 16).replace('T', ' ') } = {}) {
91
+ if (!s) return ''
92
+ const g = s.decisionsGiven
93
+ if (g && g.n > 0) {
94
+ const words = g.via === 'command' ? 'Given ' + g.n + ' decision' + (g.n === 1 ? '' : 's') + ' with /cp-decisions' : 'Started with ' + g.n + ' decision' + (g.n === 1 ? '' : 's')
95
+ const tip = (g.via === 'command' ? 'You ran /cp-decisions' : 'Leftoff added the repo’s accepted decisions when the session started') + (g.at ? ' · ' + when(g.at) : '') + '. Only accepted decisions are given.'
96
+ return '<p class="dec-given" data-tip="' + esc(tip) + '"><span class="dec-given-i" aria-hidden="true">●</span> ' + esc(words) + '</p>'
97
+ }
98
+ const m = s.decisionsMissed
99
+ if (m && m.n > 0) return '<p class="dec-given dec-missed" data-tip="' + esc(m.tip || '') + '"><span class="dec-given-i" aria-hidden="true">◌</span> ' + esc(m.label || ('Not given its repo’s ' + m.n + ' decisions')) + ' <span class="dec-given-how">· type <code>/cp-decisions</code></span></p>'
100
+ return ''
101
+ }
102
+
103
+ // ------------------------------------------------------------ the decision log (timeline)
104
+
105
+ // A route's scope: "#/decisions/gh:acme/web" (a repo) or "#/decisions/project:acme" (a project's repos, merged).
106
+ export function decisionScope(arg) {
107
+ const a = String(arg || '')
108
+ if (a.startsWith('project:')) return { project: a.slice(8), repo: null, api: 'project=' + encodeURIComponent(a.slice(8)) }
109
+ return { project: null, repo: a, api: 'repo=' + encodeURIComponent(a) }
110
+ }
111
+ export const decisionsHref = (scope) => '#/decisions/' + encodeURIComponent(scope).replace(/%3A/gi, ':').replace(/%2F/gi, '/')
112
+
113
+ // What the timeline shows: current decisions (accepted), oldest first, unless you asked for all of them
114
+ // (superseded and rejected too). Proposed ones are always above, to review.
115
+ export function timelineRows(decisions, { all = false } = {}) {
116
+ const list = Array.isArray(decisions) ? decisions : []
117
+ return {
118
+ review: list.filter((d) => d.status === 'proposed'),
119
+ rows: list.filter((d) => d.status === 'accepted' || (all && (d.status === 'superseded' || d.status === 'rejected'))),
120
+ hidden: list.filter((d) => d.status === 'superseded' || d.status === 'rejected').length,
121
+ }
122
+ }
123
+
124
+ function sessionBit(d, esc) {
125
+ // testplan Part 3: proposed from a failed check of a test plan: the plan is where it came from
126
+ const tp = /^testplan:(tp_[0-9a-f]{16})#(c\d+)$/.exec(String(d.checkpointId || ''))
127
+ if (tp) return '<a class="linkish dec-sess" href="#/testplan/' + esc(tp[1]) + '">From a test plan (check ' + esc(tp[2].slice(1)) + ')</a>'
128
+ const s = d.session
129
+ if (!s || !s.id) return ''
130
+ const title = s.title || 'its session'
131
+ return '<a class="linkish dec-sess" href="#/s/' + esc(encodeURIComponent(s.id)) + '">From ' + esc(title) + '</a>' +
132
+ (s.pr && s.pr.url && /^https:\/\//.test(s.pr.url) ? ' · <a class="linkish dec-pr" href="' + esc(s.pr.url) + '" target="_blank" rel="noopener">PR' + (s.pr.number ? ' #' + esc(s.pr.number) : '') + '</a>' : '')
133
+ }
134
+ function chainBit(d, byId, esc) {
135
+ const out = []
136
+ for (const id of d.supersedes || []) { const o = byId.get(id); out.push('<span class="dec-chain">Replaces <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(id) + '">' + esc(id) + '</button>' + (o ? ' (' + esc(day(o.createdAt)) + ')' : '') + '</span>') }
137
+ if (d.supersededBy) out.push('<span class="dec-chain">Superseded by <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(d.supersededBy) + '">' + esc(d.supersededBy) + '</button> on ' + esc(day(d.supersededAt)) + '</span>')
138
+ return out.length ? '<div class="dec-chains">' + out.join('') + '</div>' : ''
139
+ }
140
+ function statusBit(d, esc) {
141
+ return '<span class="dec-st dec-st-' + esc(d.status) + '"><span aria-hidden="true">' + STATUS_SHAPE[d.status] + '</span> ' + esc(STATUS_WORD[d.status]) + '</span>'
142
+ }
143
+ function editForm(d, esc) {
144
+ return '<form class="dec-edit" data-action="dec-save" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">' +
145
+ '<label>Decision<input class="dec-in" name="text" maxlength="240" value="' + esc(d.text) + '" autocomplete="off"></label>' +
146
+ '<label>Why<input class="dec-in" name="why" maxlength="240" value="' + esc(d.why || '') + '" autocomplete="off"></label>' +
147
+ '<div class="dec-acts"><button type="submit" class="btn primary">Save</button><button type="button" class="btn" data-action="dec-cancel" data-id="' + esc(d.id) + '">Cancel</button></div></form>'
148
+ }
149
+
150
+ // 1h: POST /v1/decisions/action is trustedOnly: on a paired device without Trust my paired devices the
151
+ // buttons show, disabled, with this tip, rather than failing on a click.
152
+ export const DEC_TRUST_TIP = 'Changing the decision log from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.'
153
+ const offAttrs = (trusted, esc) => (trusted === false ? ' disabled aria-disabled="true" data-tip="' + esc(DEC_TRUST_TIP) + '"' : '')
154
+
155
+ // One decision. may: you can change it (the board's usual gate); editing: its id is being edited.
156
+ // trusted (1h): false on a device that may look but not change the log. edit: false hides Edit (the viewer).
157
+ // replaces (1g): an id this one may be confirmed to replace (a walkthrough step departs from it).
158
+ export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true, editing = null, multi = false, review = false, trusted = true, edit = true, replaces = null } = {}) {
159
+ const repo = multi && d.repoLabel ? '<span class="dec-repo">' + esc(d.repoLabel) + '</span>' : ''
160
+ const head = '<div class="dec-h"><span class="dec-date">' + esc(day(d.createdAt)) + '</span><span class="dec-id">' + esc(d.id) + '</span>' + repo + statusBit(d, esc) + '</div>'
161
+ if (editing === d.id) return '<li class="dec-item" id="dec-' + esc(d.id) + '">' + head + editForm(d, esc) + '</li>'
162
+ const sug = (d.suggests || []).map((id) => { const o = byId.get(id); return o ? '<div class="dec-sug"><span>Changes <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(id) + '">' + esc(id) + '</button>: ' + esc(o.text) + '</span>' +
163
+ (may ? '<span class="dec-acts"><button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Confirm it replaces ' + esc(id) + '</button><button type="button" class="btn" data-action="dec-act" data-act="dismiss" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Not related</button></span>' : '') + '</div>' : '' }).join('')
164
+ const btn = (act, label, cls = '') => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>' + label + '</button>'
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>' : ''
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>' : ''
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>'
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>'
169
+ return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
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>' +
171
+ (d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
172
+ (d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
173
+ chainBit(d, byId, esc) + sug +
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>'
175
+ }
176
+
177
+ // The storage choice (one per project). Off by default: the log stays in Leftoff's folder.
178
+ export function storageChoiceHtml(project, { esc = escD, may = true, trusted = true } = {}) {
179
+ if (!project) return '<p class="faint dec-local">This repo isn’t in a project, so its log stays in Leftoff only.</p>'
180
+ const on = !!project.inRepo
181
+ const opt = (v, label) => '<button type="button" class="sask-opt" data-action="dec-store" data-project="' + esc(project.id) + '" data-on="' + v + '" aria-pressed="' + (on === v) + '"' + (trusted === false ? offAttrs(false, esc) : may ? '' : ' disabled') + '>' + label + '</button>'
182
+ return '<div class="dec-store"><div class="dec-store-h">Keep decision log in this repo</div>' +
183
+ '<div class="sask-scope dec-store-opts" role="group" aria-label="Keep decision log in this repo">' + opt(false, 'Leftoff only') + opt(true, 'Also in each repo') + '</div>' +
184
+ '<p class="faint">' + (on ? 'Accepted decisions are written to ' + esc(project.file || 'docs/DECISIONS.md') + ' in each repo of ' + esc(project.name) + ', between Leftoff’s markers, on a Mac with a checkout. Never staged or committed: you commit it or not.'
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>'
186
+ }
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
+
194
+ // The whole page. v: GET /v1/decisions. ui: { all, editing, may }
195
+ export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
196
+ const list = (v && v.decisions) || []
197
+ const byId = new Map(list.map((d) => [d.id, d]))
198
+ const multi = ((v && v.repos) || []).length > 1
199
+ const { review, rows, hidden } = timelineRows(list, { all: !!ui.all })
200
+ const sc = (v && v.scope) || {}
201
+ const name = sc.kind === 'project' ? sc.name : sc.label
202
+ const opts = { esc, byId, may: ui.may !== false, editing: ui.editing || null, multi, trusted: ui.trusted !== false }
203
+ let h = '<a class="back" href="' + (sc.kind === 'project' ? '#/project/' + esc(encodeURIComponent(sc.id)) : '#/') + '">← ' + (sc.kind === 'project' ? esc(name) : 'All sessions') + '</a>'
204
+ h += '<header class="dec-head"><h1>Decisions <span class="faint">· ' + esc(name || '') + '</span></h1>' +
205
+ (multi ? '<p class="faint">' + v.repos.map((r) => esc(r.label)).join(' · ') + '</p>' : '') + '</header>'
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)
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>'
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>'
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>' +
210
+ '<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' + testplanLinkHtml(sc, { esc, cls: 'btn' }) + '</div>'
211
+ h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
212
+ (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>'
213
+ return '<div class="proj dec-page">' + h + '</div>'
214
+ }
215
+
216
+ // testplan Part 3: the way in to a test plan of the scope's merged PRs (a project, or a repo's log)
217
+ export function testplanLinkHtml(sc, { esc = escD, cls = 'btn' } = {}) {
218
+ if (!sc) return ''
219
+ const token = sc.kind === 'project' ? sc.id : sc.label || sc.id
220
+ if (!token) return ''
221
+ return '<a class="' + cls + ' tp-open" href="#/testplan/new?scope=' + esc(encodeURIComponent(token)) + '" data-tip="A short manual test plan for the PRs merged lately">Test plan</a>'
222
+ }
223
+
224
+ // 1k: a quiet count of decisions waiting to be reviewed: a shape plus a number (and the words for a screen reader and the tip), never a colored badge alone.
225
+ export function reviewCountHtml(n, { esc = escD, href = null, cls = '' } = {}) {
226
+ if (!(n > 0)) return ''
227
+ const words = n + ' decision' + (n === 1 ? '' : 's') + ' to review'
228
+ const inner = '<span aria-hidden="true">◌</span> ' + n
229
+ return href ? '<a class="dec-rc ' + cls + '" href="' + esc(href) + '" data-tip="' + esc(words) + '" aria-label="' + esc(words) + '">' + inner + '<span class="dec-rc-w"> to review</span></a>'
230
+ : '<span class="dec-rc ' + cls + '" role="img" data-tip="' + esc(words) + '" aria-label="' + esc(words) + '">' + inner + '</span>'
231
+ }
232
+
233
+ // The project page's short Decisions section: counts, the storage choice, the way in.
234
+ // 2026-11-05 a single repo's page (v.scope.kind 'repo') gets it too, without the storage choice: that stays a project's.
235
+ export function projectDecisionsHtml(v, { esc = escD, may = true, trusted = true, scopeArg = '' } = {}) {
236
+ if (!v) return ''
237
+ const isRepo = !!(v.scope && v.scope.kind === 'repo')
238
+ const list = v.decisions || []
239
+ const cur = list.filter((d) => d.status === 'accepted').length, rev = list.filter((d) => d.status === 'proposed').length
240
+ return '<section class="proj-sec dec-proj" id="projDecisions"><h2>Decisions <span class="count">' + cur + '</span></h2>' +
241
+ '<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>' +
242
+ (list.length ? '' : decisionsEmptyHtml({ esc })) +
243
+ '<div class="dec-acts">' + testplanLinkHtml(v.scope, { esc }) + '</div>' +
244
+ (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>'
245
+ }
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
+ }
package/ui/hook-fix.js ADDED
@@ -0,0 +1,109 @@
1
+ // 2026-11-03 "Let me answer prompts here": the hooks-skipped note's way out.
2
+ // Claude Code's Security default runs ahead of the plugins you install (Team or
3
+ // Enterprise, managed settings, a managed MCP file) and sends Stop and
4
+ // permission prompts on past Leftoff. Claude Code ignores appendPlugins in
5
+ // your own ~/.claude/settings.json while it's seated (2.1.317), so the sheet
6
+ // shows the managed-settings lines and where they go, with Copy; Leftoff
7
+ // writes none of it. The one change it can make: take Leftoff's own entry
8
+ // back out of your settings when Claude Code ignores it there (an explicit
9
+ // Remove, this Mac's own browser or a trusted paired device, never on a
10
+ // managed Mac). Another Mac's session: run leftoff doctor --fix there.
11
+ // Centered on a desktop, a bottom sheet on a phone.
12
+
13
+ export function hooksFixButtonHtml(s, { esc }) {
14
+ const k = s && s.hooksSkipped
15
+ if (!k || !k.label || k.applied || s.endedAt) return ''
16
+ return '<button type="button" class="s-hooks-fix" data-hookfix="' + esc(s.id) + '" data-tip="Why Leftoff can’t answer this session’s prompts from the board, and the settings line that lets it">Let me answer prompts here</button>'
17
+ }
18
+
19
+ let open = null
20
+
21
+ export function closeHookFix() {
22
+ if (!open) return
23
+ const { back, from, onKey, sid } = open
24
+ open = null
25
+ document.removeEventListener('keydown', onKey, true)
26
+ back.remove()
27
+ document.documentElement.classList.remove('hf-open')
28
+ // the header may have been redrawn while the sheet was up: its button for this session now
29
+ const to = from && from.isConnected ? from : sid ? document.querySelector('[data-hookfix="' + CSS.escape(sid) + '"]') : null
30
+ if (to) try { to.focus({ preventScroll: true }) } catch {}
31
+ }
32
+
33
+ const copyText = async (t) => { try { await navigator.clipboard.writeText(t); return true } catch { return false } }
34
+
35
+ // st: GET /v1/hook-order. -> the sheet's body
36
+ export function hookFixBodyHtml(st, { esc, session = null, thisMachine = null } = {}) {
37
+ if (session && session.isLocal === false) {
38
+ const mac = session.machine || 'that Mac'
39
+ return '<p class="hf-p">This session runs on <b>' + esc(mac) + '</b>, and the settings that decide the plugin order are that Mac’s. Run this there; it says what to add, and why:</p>' +
40
+ '<div class="hf-code-row"><pre class="hf-code hf-cmd">leftoff doctor --fix</pre></div>' +
41
+ '<div class="hf-acts"><button type="button" class="btn hf-copy" data-copy="leftoff doctor --fix">Copy</button><button type="button" class="btn primary hf-close">Close</button></div>'
42
+ }
43
+ if (!st || st.error) return '<p class="hf-p">Couldn’t read this Mac’s Claude Code settings' + (st && st.error ? ' (' + esc(st.error) + ')' : '') + '. <code>leftoff doctor --fix</code> in a terminal says the same.</p><div class="hf-acts"><button type="button" class="btn primary hf-close">Close</button></div>'
44
+ let h = ''
45
+ if (!st.seated) {
46
+ h += '<p class="hf-p"><span class="hf-mark hf-mark-ok" aria-hidden="true"></span>Nothing here puts a plugin ahead of Leftoff’s hooks' + (thisMachine ? ' on ' + esc(thisMachine) : '') + ' right now. A session that started before a change may still show the note; restart Claude Code there.</p>'
47
+ } else if (st.placed) {
48
+ h += '<p class="hf-p"><span class="hf-mark hf-mark-ok" aria-hidden="true"></span><b>Managed settings already seat Leftoff</b> right after Claude Code’s Security default. Restart Claude Code sessions that started before that, and their prompts come here.</p>'
49
+ } else {
50
+ h += '<p class="hf-p">' + esc(st.words) + '</p>'
51
+ h += '<p class="hf-p hf-who"><span class="hf-mark hf-mark-admin" aria-hidden="true"></span>' + (st.managed ? 'Ask whoever manages Claude Code here to ' : 'An admin (on your own Mac, that’s you, with sudo) can ') + (st.snippet.merge ? 'merge this into' : 'put this in') + ':</p>'
52
+ h += '<div class="hf-file"><code>' + esc(st.snippet.file) + '</code></div>'
53
+ h += '<pre class="hf-code">' + esc(st.snippet.json) + '</pre>'
54
+ h += '<p class="hf-p hf-quiet">' + esc(st.snippet.note) + '. Then restart Claude Code. Leftoff never writes managed settings.</p>'
55
+ }
56
+ if (st.ignoredEntry) {
57
+ h += '<div class="hf-ign"><p class="hf-p"><span class="hf-mark hf-mark-off" aria-hidden="true"></span>Your <code>' + esc(st.ignoredEntry.file) + '</code> lists Leftoff in ' + esc(st.ignoredEntry.keys.join(' and ')) + ', which Claude Code ignores here. Removing it changes nothing in Claude Code.</p>' +
58
+ (st.canUnfix ? '<p class="hf-p hf-quiet">Remove takes out only Leftoff’s entry and keeps a backup in ~/.config/checkpoints/backups.</p>' : '<p class="hf-p hf-quiet">This Mac has managed settings, so Leftoff changes nothing in ~/.claude; delete the entry by hand if you like.</p>') + '</div>'
59
+ }
60
+ h += '<div class="hf-acts">' +
61
+ (st.ignoredEntry && st.canUnfix ? '<button type="button" class="btn hf-unfix">Remove the ignored entry</button>' : '') +
62
+ (st.seated && !st.placed ? '<button type="button" class="btn hf-copy" data-copy="' + esc(st.snippet.json) + '">Copy</button>' : '') +
63
+ '<button type="button" class="btn primary hf-close">' + (st.seated && !st.placed ? 'Done' : 'Close') + '</button></div>'
64
+ return h
65
+ }
66
+
67
+ export async function openHookFix({ session, from = null, api, esc, toast = () => {}, thisMachine = null } = {}) {
68
+ closeHookFix()
69
+ const back = document.createElement('div')
70
+ back.className = 'hf-back'
71
+ back.innerHTML = '<div class="hf-modal" role="dialog" aria-modal="true" aria-labelledby="hfTitle"><div class="hf-grab" aria-hidden="true"></div>' +
72
+ '<div class="hf-head"><h2 id="hfTitle">Let Leftoff answer prompts here</h2><button type="button" class="btn x hf-x" aria-label="Close">✕</button></div>' +
73
+ '<div class="hf-body"><p class="hf-p hf-quiet">Reading this Mac’s Claude Code settings…</p></div></div>'
74
+ document.body.appendChild(back)
75
+ document.documentElement.classList.add('hf-open')
76
+ const onKey = (e) => {
77
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeHookFix(); return }
78
+ if (e.key === 'Tab') { // keep focus inside
79
+ const f = [...back.querySelectorAll('button:not([disabled])')]
80
+ if (!f.length) return
81
+ const i = f.indexOf(document.activeElement)
82
+ if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus() } else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus() }
83
+ }
84
+ }
85
+ open = { back, from, onKey, sid: session && session.id }
86
+ document.addEventListener('keydown', onKey, true)
87
+ const body = back.querySelector('.hf-body')
88
+ const paint = (st) => {
89
+ body.innerHTML = hookFixBodyHtml(st, { esc, session, thisMachine })
90
+ const first = back.querySelector('.hf-copy, .hf-close')
91
+ if (first) try { first.focus({ preventScroll: true }) } catch {}
92
+ }
93
+ back.addEventListener('click', async (e) => {
94
+ if (e.target === back || e.target.closest('.hf-x, .hf-close')) { closeHookFix(); return }
95
+ const c = e.target.closest('.hf-copy')
96
+ if (c) { const ok = await copyText(c.dataset.copy || ''); c.textContent = ok ? 'Copied' : 'Select and copy'; setTimeout(() => { if (c.isConnected) c.textContent = 'Copy' }, 1800); return }
97
+ const u = e.target.closest('.hf-unfix')
98
+ if (u) {
99
+ u.disabled = true; u.textContent = 'Removing…'
100
+ try {
101
+ const r = await api('POST', '/v1/hook-order/unfix', { yes: true })
102
+ paint(r.state)
103
+ toast(r.wrote ? 'Removed Leftoff’s entry from ' + r.file + (r.backup ? ' (backup kept)' : '') + '. Restart open Claude Code sessions.' : 'Nothing to remove.', 5000)
104
+ } catch (err) { u.disabled = false; u.textContent = 'Remove the ignored entry'; toast((err && err.message) || 'Couldn’t change it.', 5000) }
105
+ }
106
+ })
107
+ if (session && session.isLocal === false) { paint(null); return }
108
+ try { paint(await api('GET', '/v1/hook-order?fresh=1')) } catch (err) { paint({ error: (err && err.message) || 'no answer' }) }
109
+ }
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)