@leftoff/cli 0.9.18-beta.7 → 0.9.18-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.18-beta.7",
3
+ "version": "0.9.18-beta.9",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "One Silly App"
@@ -32,7 +32,7 @@ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can
32
32
  const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
33
33
 
34
34
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
35
- const MOD_VERSION = '0.9.18-beta.7' // keep in step with .claude-plugin/plugin.json
35
+ const MOD_VERSION = '0.9.18-beta.9' // keep in step with .claude-plugin/plugin.json
36
36
 
37
37
  const st = {
38
38
  internal: false,
@@ -495,8 +495,8 @@ async function decisionContext($, e) {
495
495
 
496
496
  // 1b /cp-decisions: the same text SessionStart adds (src/decisions.js decisionContextText, from the daemon),
497
497
  // shown to you and handed to Claude as the command's context. The daemon notes that this session was given them.
498
- // Runs where the classic hooks are skipped: a command is not a classic event (Claude Code 2.1.318's Security
499
- // default hooks classic.* but not command.run).
498
+ // Runs where the classic hooks are skipped: a command is not a classic event (Claude Code's Security default
499
+ // hooks classic.* but not command.run; checked on 2.1.317 and 2.1.318).
500
500
  async function decisionsCommand($) {
501
501
  if (st.internal) return { text: 'Leftoff: not in Leftoff\'s own runs.' }
502
502
  let cwd = null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.18-beta.7",
3
+ "version": "0.9.18-beta.9",
4
4
  "description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
5
5
  "keywords": [
6
6
  "claude",
package/ui/app.css CHANGED
@@ -2495,6 +2495,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2495
2495
  .composer .att-chips[hidden] { display: none; }
2496
2496
  .composer.att-drop { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); background: color-mix(in srgb, var(--accent) 7%, var(--card2)); }
2497
2497
  .att-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; overflow: hidden; clip: rect(0 0 0 0); }
2498
+ .att-picker { position: fixed; left: 0; bottom: 0; } /* 0.9.18: the paperclip's picker, outside the page so a redraw can't drop what you picked */
2498
2499
  .composer-attach { flex: none; width: 44px; height: 44px; margin: 0; padding: 0; border: 0; background: transparent; display: grid; place-items: center; cursor: pointer; color: var(--muted); -webkit-tap-highlight-color: transparent; }
2499
2500
  .composer-clip { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; transition: background-color .15s, color .15s; }
2500
2501
  .composer-clip .ic { width: 19px; height: 19px; }
package/ui/app.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // /v1 API on the same origin (auth rides the cookie set at GET /).
3
3
  import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
4
  import { icon } from './icons.js'
5
- import { fullViewportFit, isTypingTarget, SETTLE_AFTER_BLUR_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes
5
+ import { fullViewportFit, isTypingTarget, outOfView, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
6
6
  import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet, openProjectSheet, refreshActionMenu } from './picker.js'
7
7
  import { sheetRepos, sheetPlan, runSheetPlan, askScopeItems } from './project-sheet.js' // 0.9.12: New project with several repos
8
8
  import { watchChipFades } from './chip-fade.js' // 0.9.12: chip rows fade only where chips run off
@@ -3451,14 +3451,48 @@ function fitFullViewport() {
3451
3451
  html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
3452
3452
  // the keyboard's gone: the page under full screen back where it was before the text box took focus
3453
3453
  if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
3454
+ if (typing) keepTypingInView()
3455
+ }
3456
+ // 2026-11-04 (switching to another keyboard mid-typing hid the composer, and switching back didn't
3457
+ // bring it back): while you type, check where the composer really is against what you can see, from
3458
+ // this moment's numbers only, and if it isn't in view move full screen by the difference.
3459
+ // (Since 0.9.16 a session always opens in full screen, so that's the Live composer's only home.)
3460
+ function keepTypingInView() {
3461
+ const vv = window.visualViewport, el = document.activeElement, html = document.documentElement
3462
+ if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
3463
+ const box = el.closest('.live-box.full')
3464
+ if (!box) return
3465
+ const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv)
3466
+ if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
3467
+ }
3468
+ // Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
3469
+ // page under it) fits full screen then and looks again a few beats after: a swap's last change can
3470
+ // come with no report of its own, and a passing bogus one is put right by the next look.
3471
+ function onViewportChange(ev) {
3472
+ fitFullViewport()
3473
+ if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) settleWhileTyping(1)
3474
+ }
3475
+ function settleWhileTyping(from = 0) {
3476
+ clearTimeout(lfKb.settle)
3477
+ if (from >= SETTLE_WHILE_TYPING_MS.length) return
3478
+ let i = from
3479
+ const step = () => {
3480
+ if (!isTypingTarget(document.activeElement)) return
3481
+ fitFullViewport()
3482
+ if (++i < SETTLE_WHILE_TYPING_MS.length) lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - SETTLE_WHILE_TYPING_MS[i - 1])
3483
+ }
3484
+ lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - (i ? SETTLE_WHILE_TYPING_MS[i - 1] : 0))
3454
3485
  }
3455
- if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, fitFullViewport)
3456
- for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, fitFullViewport)
3486
+ if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, onViewportChange)
3487
+ window.addEventListener('scroll', (ev) => { if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) onViewportChange(ev) }, { passive: true })
3488
+ for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, onViewportChange)
3457
3489
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') fitFullViewport() })
3458
3490
  document.addEventListener('focusin', (e) => {
3459
3491
  if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3460
- clearTimeout(lfKb.settle)
3461
3492
  if (lfKb.scroll == null) lfKb.scroll = window.scrollY
3493
+ // 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
3494
+ // visual viewport without a resize event of its own: measure again as it opens, as on the way down
3495
+ settleWhileTyping(0)
3462
3496
  })
3463
3497
  // A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
3464
3498
  // goes down, whatever iOS reports or doesn't on the way.
@@ -3718,11 +3752,40 @@ function composerFor(el) {
3718
3752
  const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
3719
3753
  return box && !box.classList.contains('off') ? box : null
3720
3754
  }
3755
+ // The picker the paperclip opens lives outside the page (0.9.18). A phone's
3756
+ // photo picker covers the board for seconds; coming back is a wake-up (focus
3757
+ // after more than AWAY_MS away) that redraws the page, and iOS hands the
3758
+ // picked photo over only after that, to the <input> it opened from. A picker
3759
+ // inside the composer was gone by then (the redraw made a new one), so its
3760
+ // change event went nowhere and the photo never showed. This one is never
3761
+ // redrawn; the composer's own hidden input stays for drops and tests.
3762
+ let attPicker = null
3763
+ function attachPicker() {
3764
+ if (attPicker && attPicker.isConnected) return attPicker
3765
+ attPicker = document.createElement('input')
3766
+ attPicker.type = 'file'; attPicker.multiple = true; attPicker.tabIndex = -1
3767
+ attPicker.className = 'att-input att-picker'; attPicker.id = 'att-picker'
3768
+ attPicker.setAttribute('aria-hidden', 'true')
3769
+ document.body.appendChild(attPicker)
3770
+ return attPicker
3771
+ }
3772
+ function openPicker(sid) {
3773
+ if (!sid) return
3774
+ const pk = attachPicker()
3775
+ pk.dataset.sid = sid
3776
+ pk.value = ''
3777
+ pk.click() // still inside the tap: iOS opens a picker only from a user gesture
3778
+ }
3779
+ function takePicked(inp) {
3780
+ const files = Array.from(inp.files || [])
3781
+ if (!files.length) return
3782
+ inp.value = '' // the same file can be picked again
3783
+ try { addFiles(inp.dataset.sid, files) } catch (e) { toast('Couldn’t attach ' + (files.length === 1 ? files[0].name || 'that file' : files.length + ' files') + ': ' + (e.message || e), 6000) }
3784
+ }
3721
3785
  document.addEventListener('change', (ev) => {
3722
3786
  const inp = ev.target
3723
3787
  if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
3724
- addFiles(inp.dataset.sid, inp.files)
3725
- inp.value = '' // the same file can be picked again
3788
+ takePicked(inp)
3726
3789
  })
3727
3790
  document.addEventListener('paste', (ev) => {
3728
3791
  const t = ev.target
@@ -9481,7 +9544,7 @@ document.addEventListener('click', async (ev) => {
9481
9544
  if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
9482
9545
  if (action === 'lv-queued-now') return sendNowClick(btn)
9483
9546
  if (action === 'lv-queued-more') { if (ev.target.closest('a[href]')) return; const open = !btn.classList.contains('open'); btn.classList.toggle('open', open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); return }
9484
- if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
9547
+ if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) openPicker(inp.dataset.sid); return }
9485
9548
  if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
9486
9549
  if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
9487
9550
  if (action === 'att-open') return openAttachment(btn)
package/ui/live-status.js CHANGED
@@ -44,17 +44,42 @@ 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
+ // 2.1.318: claude -p refuses to start on an invalid config file (it no longer
58
+ // resets it): "Configuration error in <file>: <why>" / "Claude configuration
59
+ // file at <file> is corrupted" / "Fix the JSON in <file>, or move the file aside".
60
+ // -> the file (home shown as ~ when given), or '' when named nowhere, or null.
61
+ const CONFIG_BAD_RE = /(?:Configuration error in|Claude configuration file at|Fix the JSON in) (\S+?\.json)\b/i
62
+ export function claudeConfigError(text, home = '') {
63
+ const m = CONFIG_BAD_RE.exec(String(text == null ? '' : text))
64
+ if (!m) return null
65
+ const f = m[1]
66
+ return home && f.startsWith(home + '/') ? '~' + f.slice(home.length) : f
67
+ }
68
+
47
69
  export function turnErrorWhy(raw0, { subtype = null, code = null, signal = null, endReason = null } = {}) {
48
- const raw = ago(raw0).slice(0, WHY_RAW_MAX)
70
+ const raw = ago(dropSignInWarning(raw0)).slice(0, WHY_RAW_MAX)
49
71
  const w = (label) => ({ label, raw: raw || null })
50
72
  if (raw && /^\[?Request interrupted by user/i.test(raw)) return null
51
73
  if (!raw && endReason === 'aborted') return null
74
+ const cfg = claudeConfigError(raw)
75
+ if (cfg != null) return w('Claude Code’s config file is invalid · fix the JSON in ' + (cfg || '~/.claude.json') + ', or move it aside')
52
76
  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
77
  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
78
  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
79
  if (/overloaded|\b529\b/i.test(raw)) return w('Claude’s servers are overloaded · try again in a minute')
56
80
  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)
81
+ // 2.1.318's "Failed to authenticate: this login reached its time limit … Run `claude auth login` …" too
82
+ if (/invalid api key|run \/login|please (log|sign) ?in|not logged in|oauth token|token (has )?expired|authenticat|claude auth login|\b401\b|\b403\b|unauthori[sz]ed|forbidden/i.test(raw)) return w(SIGNED_OUT_LABEL)
58
83
  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
84
  if (/no conversation found|session .*not found/i.test(raw)) return w('Claude Code couldn’t reopen this conversation')
60
85
  const api = raw.match(/API Error:?\s*(\d{3})?/i)
@@ -23,18 +23,37 @@ export const KEYBOARD_MIN_PX = 120 // less than this is the browser's own bars,
23
23
 
24
24
  // innerHeight: the layout viewport's height; vv: { height, offsetTop, scale }; typing: a text box has focus.
25
25
  // -> { h, top, kb } in CSS px (null without a visualViewport: the CSS falls back to 100dvh).
26
+ // 2026-11-04 (switching keyboards mid-typing hid the composer): while a text box has focus, full
27
+ // screen is exactly the visual viewport as iOS reports it right now, whatever the keyboard's height
28
+ // (a keyboard swap reports a passing full-height or tiny keyboard on the way; following each report
29
+ // means the next one puts it right). Nothing is remembered from when the box took focus. `kb` (the
30
+ // keyboard's own look: no home-indicator gap) still needs a keyboard's worth of height.
26
31
  export function fullViewportFit({ innerHeight, vv, typing = false } = {}) {
27
32
  if (!vv || !(Number(innerHeight) > 0)) return null
28
33
  const ih = Number(innerHeight)
29
34
  const vh = Number(vv.height) > 0 ? Number(vv.height) : ih
30
35
  const zoomed = Number(vv.scale) > 1.01 // pinch-zoomed: follow the visual viewport as before
31
36
  const kb = !!typing && ih - vh > KEYBOARD_MIN_PX
32
- if (!kb && !zoomed) return { h: Math.round(ih), top: 0, kb: false } // no keyboard: the whole window, from its top
33
- const room = Math.max(0, ih - vh)
37
+ if (!typing && !zoomed) return { h: Math.round(ih), top: 0, kb: false } // no keyboard: the whole window, from its top
38
+ const room = Math.max(0, ih - vh) // a visual viewport as tall as the window can't be panned (a stale pan)
34
39
  const top = Math.min(Math.max(0, Number(vv.offsetTop) || 0), room)
35
- return { h: Math.round(vh), top: Math.round(top), kb }
40
+ return { h: Math.round(Math.min(vh, ih)), top: Math.round(top), kb }
41
+ }
42
+
43
+ // How far full screen's composer sits outside what you can see (the visual viewport, in the same
44
+ // page coordinates as getBoundingClientRect): > 0 means move it up by that much, < 0 down, 0 it's in view.
45
+ // rect: the composer's { top, bottom }; vv: { height, offsetTop }.
46
+ export function outOfView(rect, vv, slack = 1) {
47
+ if (!rect || !vv || !(Number(vv.height) > 0)) return 0
48
+ const top = Number(vv.offsetTop) || 0, bottom = top + Number(vv.height)
49
+ if (rect.bottom > bottom + slack) return Math.round(Math.min(rect.bottom - bottom, rect.top - top)) // below the keyboard's edge (never past its own top)
50
+ if (rect.top < top - slack) return -Math.round(top - rect.top) // above the top of what you can see
51
+ return 0
36
52
  }
37
53
 
38
54
  // When to look again after a text box lets go (the keyboard takes ~0.3–0.5 s to
39
55
  // close, and iOS's reports during it can be stale or missing), in ms.
40
56
  export const SETTLE_AFTER_BLUR_MS = [0, 120, 350, 700, 1100]
57
+ // The same beats after every report while a text box has focus: a keyboard swap's last change can
58
+ // come with no event of its own, so the board looks again a few times after each one.
59
+ export const SETTLE_WHILE_TYPING_MS = SETTLE_AFTER_BLUR_MS