@leftoff/cli 0.9.7 → 0.9.8-beta.10

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.
@@ -0,0 +1,96 @@
1
+ // 0.9.8: the board stays live after a phone wakes up.
2
+ //
3
+ // iOS suspends a Home Screen app (and its live-update long-poll or event
4
+ // stream) when you switch away or lock the screen. A request that was in
5
+ // flight then may never settle, so the stream can go quiet forever while the
6
+ // page still shows a confident "Working · 5m" timer. The pieces here are
7
+ // pure (unit tested in test/unit/livewake-098.test.mjs); ui/app.js wires them:
8
+ // - the daemon sends a ping on the stream every HEARTBEAT_MS (a long-poll
9
+ // answers at least that often), and a stream with nothing for STREAM_DEAD_MS
10
+ // is closed and opened again (with backoff), then the view refetches
11
+ // - coming back (visible, pageshow, focus, online) refetches at once
12
+ // - while a Live message on screen is still going, its thread is polled every
13
+ // POLL_MS, so a dead stream can't leave a Working bubble behind
14
+ // - a pending bubble whose data is older than STALE_MS says "Reconnecting…"
15
+
16
+ export const HEARTBEAT_MS = 15000 // the daemon's stream ping (src/server.js)
17
+ export const STREAM_DEAD_MS = 2 * HEARTBEAT_MS + 5000
18
+ export const POLL_MS = 5000
19
+ export const STALE_MS = 15000
20
+ export const AWAY_MS = 2000 // focus or visible after less than this in the background isn't a wake-up (unless the stream died)
21
+
22
+ const PENDING = new Set(['sent', 'received', 'working'])
23
+
24
+ // A board message whose turn is still going (its bubble spins or waits).
25
+ export function isPending(m) {
26
+ const st = m && m.status
27
+ return !!(st && PENDING.has(st.phase))
28
+ }
29
+ export function anyPending(msgs) { return (msgs || []).some(isPending) }
30
+
31
+ // Should the Live thread be polled right now?
32
+ export function shouldPoll({ visible = true, onLive = false, pending = false } = {}) {
33
+ return !!(visible && onLive && pending)
34
+ }
35
+
36
+ // Has the stream been silent longer than a ping allows?
37
+ export function streamDead(lastAt, now = Date.now(), limit = STREAM_DEAD_MS) {
38
+ if (!lastAt) return false
39
+ return now - lastAt > limit
40
+ }
41
+
42
+ // Is what the page shows too old to trust a running timer?
43
+ export function isStale(okAt, now = Date.now(), limit = STALE_MS) {
44
+ if (!okAt) return false
45
+ return now - okAt > limit
46
+ }
47
+
48
+ // Reconnect delays: 1 s, 2 s, 4 s … at most 30 s.
49
+ export function backoffMs(attempt) {
50
+ const n = Math.max(0, Math.floor(Number(attempt) || 0))
51
+ return Math.min(30000, 1000 * 2 ** Math.min(n, 5))
52
+ }
53
+
54
+ // What a refetch compares: has anything in the thread changed?
55
+ export function liveSignature(msgs) {
56
+ return (msgs || []).map((m) => {
57
+ const st = m.status || {}
58
+ return [m.id || m.key || '', m.from || '', st.phase || '', st.tool ? st.tool.name : '', st.waiting ? 'w' : '', st.endedAt || '', m.text ? String(m.text).length : 0].join(':')
59
+ }).join('|')
60
+ }
61
+
62
+ // Which reasons to refetch: coming back to the page or the network.
63
+ export function wakeReason(type, { visibilityState = 'visible', persisted = false } = {}) {
64
+ if (type === 'visibilitychange') return visibilityState === 'visible' ? 'visible' : null
65
+ if (type === 'pageshow') return persisted ? 'pageshow-bfcache' : null // a plain pageshow is the first load, already fetching
66
+ if (type === 'focus' || type === 'online') return type
67
+ return null
68
+ }
69
+
70
+ // Coalesce wake-ups (visible + focus + pageshow all fire together on iOS).
71
+ export function createWake({ now = () => Date.now(), minGapMs = 1500 } = {}) {
72
+ let last = 0
73
+ return {
74
+ due() { const t = now(); if (t - last < minGapMs) return false; last = t; return true },
75
+ reset() { last = 0 },
76
+ }
77
+ }
78
+
79
+ // The stream watchdog: call seen() on every event or ping, check() on a timer.
80
+ // check() answers 'ok', or 'dead' once (then waits for the reconnect's delay).
81
+ export function createWatchdog({ now = () => Date.now(), limit = STREAM_DEAD_MS } = {}) {
82
+ let lastAt = now(), attempt = 0, nextAt = 0
83
+ return {
84
+ seen() { lastAt = now(); attempt = 0; nextAt = 0 },
85
+ opened() { lastAt = now() },
86
+ check() {
87
+ const t = now()
88
+ if (!streamDead(lastAt, t, limit)) return 'ok'
89
+ if (t < nextAt) return 'wait'
90
+ nextAt = t + backoffMs(attempt++)
91
+ return 'dead'
92
+ },
93
+ get lastAt() { return lastAt },
94
+ get attempt() { return attempt },
95
+ }
96
+ }
package/ui/nav.js ADDED
@@ -0,0 +1,88 @@
1
+ // The sidebar rail, the phone's bottom bar and Quick look (0.9.8). Pure functions
2
+ // over the daemon's sessions (shared with the unit tests); app.js wires them.
3
+ //
4
+ // Quick look: hover (or long-press on a phone) the Needs you item for the count
5
+ // and the five longest waits, with Allow once / Always / Deny on permission prompts
6
+ // (Always only when the Needs you page offers "don't ask again"). Those
7
+ // buttons are the Needs you page's own (data-action="ask-answer"): same request,
8
+ // same checks, and nothing is ever answered without a click.
9
+
10
+ import { esc, needsItems, needsLine, waitLabel, alwaysText, alwaysButton } from './needs.js'
11
+
12
+ // The nav item a route lights up. A session page belongs to the board, a
13
+ // catch-up to the digest.
14
+ export function navKey(name) {
15
+ if (name === 's' || name === 'board' || !name) return 'board'
16
+ if (name === 'catchup') return 'digest'
17
+ return name
18
+ }
19
+
20
+ // On a phone the bar holds four sections and More; these live under More.
21
+ export const MORE_KEYS = ['timeline', 'digest', 'whats-new', 'settings']
22
+ export const moreActive = (key) => MORE_KEYS.includes(key)
23
+
24
+ // The red count on Needs you: nothing at zero, 99+ past 99.
25
+ export function badgeText(n) {
26
+ const v = Math.max(0, Math.floor(Number(n) || 0))
27
+ if (!v) return ''
28
+ return v > 99 ? '99+' : String(v)
29
+ }
30
+
31
+ export function qlTitle(total) {
32
+ if (!total) return 'Nothing needs you'
33
+ return total + (total === 1 ? ' thing needs' : ' things need') + ' you'
34
+ }
35
+
36
+ // "6m" from the need's wait, or from when it was asked when there's no wait clock.
37
+ export function qlAge(n, now = Date.now()) {
38
+ if (!n) return ''
39
+ if (n.waitSec != null) return waitLabel(n.waitSec).replace('waiting ', '')
40
+ const t = Date.parse(n.since || n.at || '')
41
+ if (!Number.isFinite(t)) return ''
42
+ return waitLabel(Math.max(1, (now - t) / 1000)).replace('waiting ', '')
43
+ }
44
+
45
+ // The popover's items: the same order as the Needs you page (blocked first,
46
+ // longest wait first), the first `limit` of them.
47
+ export function quickLookItems(sessions = [], limit = 5) {
48
+ const all = needsItems(sessions)
49
+ return { total: all.length, items: all.slice(0, limit), more: Math.max(0, all.length - limit) }
50
+ }
51
+
52
+ // A permission prompt with an id is the one kind answered inline.
53
+ export const qlAnswerable = (n) => !!(n && n.kind === 'permission' && n.ask && n.ask.id)
54
+
55
+ // opts: { mayAnswer, pending: { [askId]: label }, now }
56
+ export function qlRow(n, opts = {}) {
57
+ const s = n.session || {}
58
+ const askId = n.ask && n.ask.id ? n.ask.id : ''
59
+ const key = askId || 'reply:' + s.id
60
+ const mac = n.peer || s.machine || ''
61
+ const meta = [s.repoLabel, mac, qlAge(n, opts.now)].filter(Boolean).join(' · ')
62
+ const line = needsLine(n)
63
+ const always = qlAnswerable(n) ? alwaysText(n.ask) : ''
64
+ let h = '<li class="ql-item k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + (always ? ' has-always' : '') + '" data-id="' + esc(s.id) + '"' + (askId ? ' data-ask="' + esc(askId) + '"' : '') + ' data-key="' + esc(key) + '">' +
65
+ '<a class="ql-main" href="#/s/' + encodeURIComponent(s.id || '') + '">' +
66
+ '<span class="ql-t">' + esc(s.title || 'Untitled session') + '</span>' +
67
+ (line ? '<span class="ql-what" title="' + esc(line) + '">' + esc(line) + '</span>' : '') +
68
+ (meta ? '<span class="ql-meta mono">' + esc(meta) + '</span>' : '') + '</a>'
69
+ if (qlAnswerable(n)) {
70
+ const busy = opts.pending && opts.pending[key]
71
+ const dis = opts.mayAnswer === false || busy ? ' disabled' : ''
72
+ const off = opts.mayAnswer === false ? ' title="Answering from this device is off"' : ''
73
+ h += '<div class="ql-actions">' +
74
+ '<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + off + ' aria-label="Allow once: ' + esc(line) + '">' + (busy ? esc(busy) : 'Allow once') + '</button>' +
75
+ (always ? alwaysButton(n.ask, dis, { label: 'Always', scope: 'ql', session: s, title: off }) : '') +
76
+ '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + off + ' aria-label="Deny: ' + esc(line) + '">Deny</button></div>'
77
+ }
78
+ return h + '</li>'
79
+ }
80
+
81
+ export function quickLookHtml(model = {}, opts = {}) {
82
+ const total = model.total || 0
83
+ const items = model.items || []
84
+ let h = '<div class="ql-head"><h2 id="qlTitle">' + esc(qlTitle(total)) + '</h2></div>'
85
+ if (!items.length) h += '<p class="ql-empty">When a session asks for permission or a question, it shows up here.</p>'
86
+ else h += '<ul class="ql-list">' + items.map((n) => qlRow(n, opts)).join('') + '</ul>' + (model.more ? '<p class="ql-more">+' + model.more + ' more</p>' : '')
87
+ return h + '<a class="ql-foot" href="#/waiting">Open Needs you <span aria-hidden="true">→</span></a>'
88
+ }
package/ui/navhide.js ADDED
@@ -0,0 +1,35 @@
1
+ // Hide the nav on scroll (phones, 0.9.8). Under 700px wide the top row and the
2
+ // bottom tab bar slide away while you scroll down a page and come back when
3
+ // you scroll up, reach the top or the very bottom, Safari style. Never while
4
+ // you're typing, a menu or sheet is open, or the page barely scrolls. Pure
5
+ // functions here (shared with the unit tests); app.js wires the scroll.
6
+
7
+ export const NAVHIDE_DOWN = 10 // px scrolled down in one go before the bars go
8
+ export const NAVHIDE_UP = 10 // px scrolled up in one go before they come back
9
+ export const NAVHIDE_TOP = 56 // this near the top they always show
10
+ export const NAVHIDE_BOTTOM = 8 // this near the very bottom they always show
11
+
12
+ // What counts as typing: a focused field the keyboard is up for.
13
+ export const NAVHIDE_TYPING = 'textarea, [contenteditable=""], [contenteditable="true"], input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="range"]):not([type="color"]):not([type="file"]):not([type="hidden"])'
14
+
15
+ // One scroll reading → the next state. state: { hidden, anchor } where anchor
16
+ // is where the current run of scrolling in one direction started. reading:
17
+ // { y: scrollY, max: how far the page can scroll, blocked: typing / a menu
18
+ // open / the bars held (a route change) }.
19
+ export function navHideStep(state, { y = 0, max = 0, blocked = false } = {}) {
20
+ const at = Number(y) || 0
21
+ const prev = state && typeof state === 'object' ? state : { hidden: false, anchor: at } // no state yet: resting here
22
+ const room = Number(max) || 0
23
+ const show = { hidden: false, anchor: at }
24
+ if (blocked) return show
25
+ if (room < NAVHIDE_TOP + NAVHIDE_DOWN + NAVHIDE_BOTTOM) return show // too short to bother
26
+ if (at <= NAVHIDE_TOP) return show
27
+ if (at >= room - NAVHIDE_BOTTOM) return show
28
+ const anchor = Number.isFinite(prev.anchor) ? prev.anchor : at
29
+ if (prev.hidden) {
30
+ if (at > anchor) return { hidden: true, anchor: at } // still going down: follow it
31
+ return anchor - at >= NAVHIDE_UP ? show : { hidden: true, anchor }
32
+ }
33
+ if (at < anchor) return { hidden: false, anchor: at } // going up while shown: follow it
34
+ return at - anchor >= NAVHIDE_DOWN ? { hidden: true, anchor: at } : { hidden: false, anchor }
35
+ }
package/ui/needs.js CHANGED
@@ -2,12 +2,13 @@
2
2
  // the panel on a session's page. Pure functions over the daemon's needsYou items
3
3
  // (shared with the unit tests); app.js wires the clicks.
4
4
  //
5
- // Kinds: permission (Allow once / Allow for this session / Deny with a note),
5
+ // Kinds: permission (Allow once / Always allow here ⓘ / Allow for this session / Deny with a note),
6
6
  // question (AskUserQuestion's options as buttons, plus Other…), terminal (only
7
7
  // the terminal can answer: Go), reply (the turn ended on a question: a reply box
8
8
  // when the mod can type it in). Nothing is ever answered without a click.
9
9
 
10
10
  import { mdInline } from './changelog-md.js'
11
+ import { tipHtml, tipId } from './tips.js'
11
12
 
12
13
  const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
13
14
  export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
@@ -56,6 +57,35 @@ export function cardBadge(n) {
56
57
  export const KBD_SUBMIT = 'Meta+Enter Control+Enter'
57
58
  const kbdHint = (opts) => '<span class="kbd-hint" aria-hidden="true">' + esc(opts.kbd || '⌘↵') + '</span>'
58
59
 
60
+ // 0.9.8 "Always allow here" (Claude Code's "don't ask again"): offered only
61
+ // when Leftoff can name the exact rule(s). One line on the button; the rule(s),
62
+ // the file and the project are in its ⓘ tooltip and in its aria-label.
63
+ export function alwaysText(ask) {
64
+ const a = ask && ask.always
65
+ if (!a || !Array.isArray(a.rules) || !a.rules.length) return ''
66
+ return 'adds ' + a.rules.join(', ') + ' to ' + (a.file || '.claude/settings.local.json')
67
+ }
68
+ // The tooltip's words: "Adds `Bash(x)` to .claude/settings.local.json in api, so
69
+ // Claude won't ask for this again here." (markup; the rules in mono)
70
+ export function alwaysTipHtml(ask, session = null) {
71
+ const a = ask && ask.always
72
+ if (!alwaysText(ask)) return ''
73
+ const where = a.project || (session && session.repoLabel) || ''
74
+ return 'Adds ' + a.rules.map((r) => '<code>' + esc(r) + '</code>').join(', ') + ' to <code>' + esc(a.file || '.claude/settings.local.json') + '</code>' +
75
+ (where ? ' in <b>' + esc(where) + '</b>' : ' in this project') + ', so Claude won’t ask for ' + (a.rules.length > 1 ? 'these' : 'this') + ' again here.'
76
+ }
77
+ // opts: { label ('Always allow here'; Quick look says 'Always'), scope (keeps the
78
+ // tooltip's id unique when Quick look and the page show the same ask), session, title }
79
+ export function alwaysButton(ask, dis = '', opts = {}) {
80
+ const t = alwaysText(ask)
81
+ if (!t) return ''
82
+ const label = opts.label || 'Always allow here'
83
+ const id = tipId(opts.scope || 'always', ask.id)
84
+ return '<span class="always-wrap">' +
85
+ '<button type="button" class="btn always" data-action="ask-answer" data-choice="allow_always"' + dis + (opts.title || '') + ' aria-label="' + esc(label + ': ' + t) + '" aria-describedby="' + esc(id) + '">' + esc(label) + '</button>' +
86
+ tipHtml({ id, label: 'What “' + label + '” does', html: alwaysTipHtml(ask, opts.session) }) + '</span>'
87
+ }
88
+
59
89
  // opts: { mayAnswer, goButton(session) -> html, drafts: { [key]: text }, pending: { [key]: label }, compact, kbd }
60
90
  export function needPanel(n, opts = {}) {
61
91
  const s = n.session || {}
@@ -81,7 +111,8 @@ export function needPanel(n, opts = {}) {
81
111
  if (n.more) h += '<div class="need-note">+' + n.more + ' more waiting after this one</div>'
82
112
  h += '<div class="need-actions">' +
83
113
  '<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + '>Allow once</button>' +
84
- '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' title="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>' +
114
+ alwaysButton(n.ask, dis, { session: s }) +
115
+ (n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' title="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>') + // 0.9.8: Live link allows one call at a time
85
116
  '<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
86
117
  '<div class="deny-form" hidden><input type="text" class="deny-note" maxlength="500" placeholder="Tell Claude why or what to do instead (optional)" value="' + esc(drafts['deny:' + askId] || '') + '" data-draft="deny:' + esc(askId) + '" data-submit="deny" aria-keyshortcuts="' + KBD_SUBMIT + '">' +
87
118
  '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny" data-submit-for="deny"' + dis + '>Deny' + kbdHint(opts) + '</button><button type="button" class="btn" data-action="ask-deny-close">Cancel</button></div>'
package/ui/pull.js ADDED
@@ -0,0 +1,31 @@
1
+ // Pull to refresh on phones (0.9.8). Pull down from the top of any page and let
2
+ // go past the line: the page reads its data again (no full reload). Pure
3
+ // functions here (shared with the unit tests); app.js wires the touches.
4
+
5
+ export const PULL_TRIGGER = 70 // px of finger travel that arms a refresh
6
+ export const PULL_MAX = 110 // how far the spinner travels at most
7
+
8
+ // Where a pull never starts: text fields, the Live box, menus and sheets,
9
+ // sideways scrollers, and anything open on top of the page.
10
+ export const PULL_SKIP = 'input, textarea, [contenteditable=""], [contenteditable="true"], .live-box, lo-select, lo-combo, .lo-menu, .lo-backdrop, .cmdk, .quicklook, .moremenu, .statuspop, .doc-viewer, .emoji-sheet-wrap, .relay-screen, .tabs, pre, .scroll-x, [data-no-pull]'
11
+
12
+ // The phone layout on a touch screen, at the very top of the page, from a
13
+ // place that isn't a field or an overlay, and with no overlay open.
14
+ export function canStartPull({ coarse, narrow, scrollY, target, overlayOpen } = {}) {
15
+ if (!coarse || !narrow || overlayOpen) return false
16
+ if (scrollY > 0) return false
17
+ if (target && typeof target.closest === 'function' && target.closest(PULL_SKIP)) return false
18
+ return true
19
+ }
20
+
21
+ // One finger movement: how far the spinner sits (with some resistance), how
22
+ // far round it is, and whether letting go now refreshes. Sideways first means
23
+ // it isn't a pull.
24
+ export function pullStep(dy, dx = 0, { trigger = PULL_TRIGGER, max = PULL_MAX } = {}) {
25
+ if (!(dy > 0) || Math.abs(dx) > dy) return { pulling: false, dist: 0, progress: 0, armed: false }
26
+ const dist = Math.min(max, dy < trigger ? dy * 0.8 : trigger * 0.8 + (dy - trigger) * 0.35)
27
+ return { pulling: true, dist: Math.round(dist), progress: Math.min(1, dy / trigger), armed: dy >= trigger }
28
+ }
29
+
30
+ // What letting go does.
31
+ export const pullRelease = (step) => (step && step.pulling && step.armed ? 'refresh' : 'cancel')
package/ui/push.js ADDED
@@ -0,0 +1,140 @@
1
+ // Phone notifications (0.9.8): the one per-device button. No setting: the
2
+ // button and the browser's own permission prompt are the opt-in, and the same
3
+ // button turns them off for this device. The Mac that owns a session sends
4
+ // the push (src/push.js); this file only subscribes this browser.
5
+ //
6
+ // iPhone: Web Push works for a board added to the Home Screen (iOS 16.4+), so
7
+ // Safari in a tab gets one line saying how, and the button appears in the
8
+ // Home Screen app. Pure parts (pushSupport, pushUi) are shared with the tests.
9
+
10
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';')
11
+ const BELL = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10.268 21a2 2 0 0 0 3.464 0"/> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>'
12
+
13
+ export const isIos = (ua = '', touch = 0) => /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && touch > 1)
14
+
15
+ // What this browser can do -> { state } : insecure | add_to_home | unsupported | denied | off
16
+ export function pushSupport({ ua = '', touch = 0, standalone = false, hasPush = false, hasSW = false, secure = true, permission = 'default' } = {}) {
17
+ const ios = isIos(ua, touch)
18
+ if (!secure) return { state: 'insecure', ios }
19
+ if (ios && !standalone) return { state: 'add_to_home', ios }
20
+ if (!hasPush || !hasSW) return { state: 'unsupported', ios }
21
+ if (permission === 'denied') return { state: 'denied', ios }
22
+ return { state: 'off', ios }
23
+ }
24
+
25
+ // The block for a slot. st: pushSupport() plus { on, busy, note, noteBad }
26
+ export function pushUi(st = {}) {
27
+ const note = st.note ? '<p class="push-note' + (st.noteBad ? ' bad' : '') + '" role="status">' + esc(st.note) + '</p>' : ''
28
+ const busy = st.busy ? ' disabled aria-busy="true"' : ''
29
+ const wrap = (inner) => '<div class="push-box">' + BELL + '<div class="push-main">' + inner + note + '</div></div>'
30
+ switch (st.on ? 'on' : st.state) {
31
+ case 'add_to_home': return wrap('<p class="push-line">Add Leftoff to your Home Screen first (Share → Add to Home Screen), then turn on notifications here.</p>')
32
+ case 'denied': return wrap('<p class="push-line">Notifications are blocked for Leftoff on this device. Allow them in ' + (st.ios ? 'Settings → Notifications → Leftoff' : 'the browser’s site settings') + ', then come back.</p>')
33
+ case 'on': return wrap('<p class="push-line"><strong>Notifications on for this device</strong></p><div class="push-acts"><button type="button" class="btn" data-push="test"' + busy + '>Send a test</button><button type="button" class="btn ghost" data-push="off"' + busy + '>Turn off</button></div>')
34
+ case 'off': return wrap('<p class="push-line">Get a notification when Claude needs you or a Live reply lands.</p><div class="push-acts"><button type="button" class="btn primary" data-push="on"' + busy + '>' + (st.busy ? 'Turning on…' : 'Turn on notifications') + '</button></div>')
35
+ default: return '' // no Push here (or not https): nothing to offer
36
+ }
37
+ }
38
+
39
+ const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
40
+ const unb64u = (s) => Uint8Array.from(atob(String(s).replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((String(s).length + 3) % 4)), (c) => c.charCodeAt(0))
41
+
42
+ const ui = { st: null, on: false, sub: null, busy: false, note: '', noteBad: false, api: null, refreshed: false }
43
+
44
+ function env() {
45
+ return {
46
+ ua: navigator.userAgent, touch: navigator.maxTouchPoints || 0,
47
+ standalone: !!(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || navigator.standalone === true,
48
+ hasPush: 'PushManager' in window && 'Notification' in window, hasSW: 'serviceWorker' in navigator,
49
+ secure: !!window.isSecureContext, permission: 'Notification' in window ? Notification.permission : 'default',
50
+ }
51
+ }
52
+
53
+ async function registration() {
54
+ if (!('serviceWorker' in navigator)) return null
55
+ return (await navigator.serviceWorker.getRegistration('/')) || (await navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => null))
56
+ }
57
+
58
+ const keyOf = (sub) => { try { const k = sub.options && sub.options.applicationServerKey; return k ? b64u(k) : null } catch { return null } }
59
+
60
+ async function load() {
61
+ const e = env()
62
+ ui.st = pushSupport(e)
63
+ ui.sub = null; ui.on = false
64
+ if (ui.st.state !== 'off') return
65
+ try { const reg = await registration(); ui.sub = reg && reg.pushManager ? await reg.pushManager.getSubscription() : null } catch { ui.sub = null }
66
+ ui.on = !!ui.sub && e.permission === 'granted'
67
+ // Once a page load: tell this Mac again (it shares with your other Macs), so a
68
+ // Mac that lost track, or a new one, knows this device.
69
+ if (ui.on && !ui.refreshed && ui.api) { ui.refreshed = true; ui.api('POST', '/v1/push/subscribe', { subscription: ui.sub.toJSON(), serverKey: keyOf(ui.sub), ua: navigator.userAgent }).catch(() => {}) }
70
+ }
71
+
72
+ export function paintPush() {
73
+ const slots = document.querySelectorAll('[data-push-slot]')
74
+ if (!slots.length) return
75
+ const html = ui.st ? pushUi({ ...ui.st, on: ui.on, busy: ui.busy, note: ui.note, noteBad: ui.noteBad }) : ''
76
+ for (const el of slots) { el.innerHTML = html; el.hidden = !html; el.setAttribute('data-pp', '1') }
77
+ }
78
+
79
+ async function turnOn() {
80
+ // The permission prompt first, straight from the tap (iOS asks only then).
81
+ const perm = await Notification.requestPermission()
82
+ if (perm !== 'granted') { ui.st = pushSupport(env()); ui.note = perm === 'denied' ? '' : 'Notifications weren’t allowed. Tap again to try.'; return }
83
+ await registration()
84
+ const reg = await navigator.serviceWorker.ready
85
+ const info = await ui.api('GET', '/v1/push')
86
+ let sub = await reg.pushManager.getSubscription()
87
+ // An existing subscription stays (the Mac that made it shares its key); a new one uses this Mac's key.
88
+ if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: unb64u(info.publicKey) })
89
+ await ui.api('POST', '/v1/push/subscribe', { subscription: sub.toJSON(), serverKey: keyOf(sub) || info.publicKey, ua: navigator.userAgent })
90
+ ui.sub = sub; ui.on = true; ui.refreshed = true
91
+ ui.note = 'You’ll get a notification when Claude needs you or a Live reply lands.'
92
+ }
93
+
94
+ async function turnOff() {
95
+ const sub = ui.sub
96
+ if (sub) {
97
+ const endpoint = sub.endpoint
98
+ try { await sub.unsubscribe() } catch {}
99
+ await ui.api('POST', '/v1/push/unsubscribe', { endpoint }).catch(() => {})
100
+ }
101
+ ui.sub = null; ui.on = false
102
+ ui.note = 'Notifications off for this device.'
103
+ }
104
+
105
+ async function sendTest() {
106
+ const r = await ui.api('POST', '/v1/push/test', { endpoint: ui.sub && ui.sub.endpoint })
107
+ if (r && r.ok) { ui.note = 'Sent from ' + (r.machine || 'your Mac') + '. It should show up in a few seconds.'; return }
108
+ if (r && r.error === 'not_subscribed') { ui.on = false; ui.sub = null; throw new Error('This Mac didn’t know this device. Turn notifications on again.') }
109
+ throw new Error('The push service didn’t take it' + (r && r.error ? ' (' + r.error + ')' : '') + '.')
110
+ }
111
+
112
+ // Wire it once: api is app.js's api(method, path, body).
113
+ export function mountPush({ api }) {
114
+ ui.api = api
115
+ document.addEventListener('click', async (ev) => {
116
+ const b = ev.target.closest && ev.target.closest('[data-push]')
117
+ if (!b || ui.busy) return
118
+ ev.preventDefault(); ev.stopPropagation()
119
+ const what = b.getAttribute('data-push')
120
+ ui.busy = true; ui.note = ''; ui.noteBad = false; paintPush()
121
+ try {
122
+ if (what === 'on') await turnOn()
123
+ else if (what === 'off') await turnOff()
124
+ else if (what === 'test') await sendTest()
125
+ } catch (e) { ui.note = e && e.message ? e.message : String(e); ui.noteBad = true }
126
+ ui.busy = false
127
+ paintPush()
128
+ })
129
+ load().then(paintPush, paintPush)
130
+ // Settings is drawn on demand: paint a slot the moment one appears.
131
+ const app = document.getElementById('app')
132
+ if (app && window.MutationObserver) new MutationObserver(() => { if (document.querySelector('[data-push-slot]:not([data-pp])')) paintPush() }).observe(app, { childList: true, subtree: true })
133
+ // A tapped notification: the service worker asks the open board to show that session.
134
+ if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('message', (ev) => {
135
+ const d = ev.data || {}
136
+ if (d.type !== 'leftoff-open' || typeof d.url !== 'string') return
137
+ try { const u = new URL(d.url, location.origin); if (u.origin === location.origin && location.hash !== u.hash) location.hash = u.hash } catch {}
138
+ })
139
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && !ui.busy) load().then(paintPush, paintPush) })
140
+ }
@@ -44,7 +44,7 @@ export async function forgetDeviceKey() {
44
44
 
45
45
  // ------------------------------------------------------------ state
46
46
 
47
- const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null }
47
+ const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null, owners: new Map() }
48
48
  // ui/direct.js loads only on the board itself (never on pair.html, which the
49
49
  // relay serves before a browser is paired and where this file is imported too).
50
50
  let route = null, directOrRelay = null
@@ -140,6 +140,39 @@ function watchDirect() {
140
140
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') again(true) })
141
141
  }
142
142
 
143
+ // ------------------------------------------------------------ auto-switch host (0.9.8)
144
+ // On your leftoff.dev board, a session that lives on another Mac loads its own
145
+ // data (Live, its transcript, Find lists, files...) straight from that Mac
146
+ // through the relay, instead of from the Mac serving the board. The board says
147
+ // which Mac each session lives on (leftoffRelay.owner); these decide where one
148
+ // call goes. Pure and exported, so they are unit-tested.
149
+
150
+ // /v1/sessions/<id>/<anything> -> <id>; the record itself (/v1/sessions/<id>) stays on
151
+ // the board's Mac (it's the synced copy the board lists), and so does everything else.
152
+ const SESSION_SUB = /^\/v1\/sessions\/([^/?#]+)\/[^?#]+$/
153
+ export function sessionOfPath(pathname) {
154
+ const m = SESSION_SUB.exec(String(pathname || ''))
155
+ if (!m) return null
156
+ try { return decodeURIComponent(m[1]) } catch { return null }
157
+ }
158
+
159
+ // Which Mac a call should go to, or null for the board's own Mac.
160
+ // owners: Map(sessionId -> machine) the board filled in; current: the Mac serving the board;
161
+ // online: the Macs the relay says are connected now.
162
+ export function ownerRoute(pathname, owners, { current = null, online = [] } = {}) {
163
+ const id = sessionOfPath(pathname)
164
+ if (!id || !owners || typeof owners.get !== 'function') return null
165
+ const m = owners.get(id)
166
+ if (!m || m === current) return null
167
+ return online.includes(m) ? m : null
168
+ }
169
+
170
+ // A failed hop to the owner falls back to the board's Mac (which reaches the
171
+ // owner over the peer link, or says plainly that it's offline).
172
+ export function fallBack({ status = 0, code = '' } = {}) {
173
+ return status === 0 || status === 401 || status === 403 && code === 'unknown_device' || status === 502 || status === 503 || status === 504 || code === 'mac_offline' || code === 'timeout'
174
+ }
175
+
143
176
  // ------------------------------------------------------------ the tunnel
144
177
 
145
178
  async function tunnel(url, init = {}, retried = false) {
@@ -151,6 +184,9 @@ async function tunnel(url, init = {}, retried = false) {
151
184
  const hdrs = new Headers(init.headers || {})
152
185
  if (hdrs.get('content-type')) h['content-type'] = hdrs.get('content-type')
153
186
  const body = { m: method, p: url.pathname + url.search, b, h }
187
+ // 0.9.8: a session that lives on another Mac answers for itself (ui/owner-route.js)
188
+ const owner = ownerRoute(url.pathname, relay.owners, { current: relay.mac, online: onlineMacs() })
189
+ if (owner) { const r = await viaOwner(owner, body, init).catch(() => null); if (r) return r }
154
190
  if (!onDirect()) return viaRelay(body, init, retried)
155
191
  return directOrRelay({
156
192
  route,
@@ -164,6 +200,22 @@ async function tunnel(url, init = {}, retried = false) {
164
200
  })
165
201
  }
166
202
 
203
+ function onlineMacs() { return ((relay.status && relay.status.macs) || []).filter((m) => m.online).map((m) => m.machine) }
204
+ // Straight to the session's own Mac through the relay; null sends the call to the
205
+ // board's Mac instead (the owner went away, or doesn't know this browser yet).
206
+ async function viaOwner(machine, body, init) {
207
+ const env = await sealRequest(relay.key, { device: relay.device, machine, body })
208
+ const res = await realFetch('/_relay/api', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(env), signal: init.signal, cache: 'no-store' })
209
+ if (res.status !== 200) {
210
+ const j = await res.json().catch(() => ({}))
211
+ if (j.code === 'mac_offline') refreshStatus().catch(() => {})
212
+ return null
213
+ }
214
+ const out = toResponse(await openResponse(relay.key, env, await res.json()))
215
+ if (out.status === 401 || out.status === 403) { const j = await out.clone().json().catch(() => ({})); if (fallBack({ status: out.status, code: j.code || '' }) || /device/.test(j.code || '')) return null }
216
+ return out
217
+ }
218
+
167
219
  async function viaRelay(body, init, retried) {
168
220
  const gone = relay.mac
169
221
  const env = await sealRequest(relay.key, { device: relay.device, machine: gone, body })
@@ -196,6 +248,7 @@ function patchedFetch(input, init) {
196
248
  }
197
249
 
198
250
  // EventSource('/v1/stream') -> long-polls of GET /v1/events through the tunnel.
251
+ export const POLL_WAIT_MS = 20000, POLL_MARGIN_MS = 12000
199
252
  class RelayEventSource extends EventTarget {
200
253
  constructor(url) {
201
254
  super()
@@ -207,11 +260,20 @@ class RelayEventSource extends EventTarget {
207
260
  let fails = 0
208
261
  while (this.readyState !== 2) {
209
262
  try {
210
- this.ctrl = new AbortController()
211
- const res = await patchedFetch('/v1/events?after=' + (this.seq == null ? -1 : this.seq) + '&ms=20000', { signal: this.ctrl.signal })
212
- if (!res.ok) throw new Error('HTTP ' + res.status)
213
- const j = await res.json()
263
+ // 0.9.8: a poll that outlives its wait plus a margin is dead (iOS
264
+ // suspended the page mid-request, or the relay dropped it silently):
265
+ // abort it and poll again instead of waiting forever.
266
+ const ctrl = this.ctrl = new AbortController()
267
+ const timer = setTimeout(() => ctrl.abort(), POLL_WAIT_MS + POLL_MARGIN_MS)
268
+ let j
269
+ try {
270
+ const res = await patchedFetch('/v1/events?after=' + (this.seq == null ? -1 : this.seq) + '&ms=' + POLL_WAIT_MS, { signal: ctrl.signal })
271
+ if (!res.ok) throw new Error('HTTP ' + res.status)
272
+ j = await res.json()
273
+ } finally { clearTimeout(timer) }
274
+ if (this.readyState === 2) return
214
275
  if (this.readyState === 0) { this.readyState = 1; if (this.onopen) this.onopen(new Event('open')) }
276
+ this.dispatchEvent(new MessageEvent('ping', { data: '{}' })) // a poll that answered is the stream's heartbeat
215
277
  const first = this.seq == null
216
278
  this.seq = j.seq
217
279
  if (!first) for (const e of j.events || []) {
@@ -272,6 +334,9 @@ export const leftoffRelay = {
272
334
  get route() { return onDirect() ? 'direct' : 'relay' },
273
335
  get directLatencyMs() { return route ? route.latencyMs : null },
274
336
  status: () => refreshStatus(),
337
+ // 0.9.8: which Mac a session lives on (null: the board's own), so its calls go there
338
+ owner(id, machine) { if (!id) return; if (machine) relay.owners.set(String(id), String(machine)); else relay.owners.delete(String(id)) },
339
+ ownerOf(id) { const m = relay.owners.get(String(id)); return m && m !== relay.mac && onlineMacs().includes(m) ? m : null },
275
340
  // Switch Macs: the board reloads from the other one.
276
341
  pick(mac) { remember(mac); cookieMac(mac); location.reload() },
277
342
  // This browser revoked itself (0.6.6).
@@ -11,11 +11,14 @@
11
11
  // route 'direct' | 'relay' (leftoffRelay.route), routeMac: the Mac it is on
12
12
  // machine the Mac the restore card belongs to
13
13
  // phone a phone or tablet (never runs Leftoff)
14
- // -> { ok, how: 'local' | 'direct' | null, hint: text with `commands` or null }
15
- export function restoreAccess({ local = false, hosted = false, route = null, routeMac = null, machine = null, phone = false } = {}) {
14
+ // canAsk 0.9.8: that Mac takes Restore requests from other devices (GET /v1/restore has asks)
15
+ // -> { ok, how: 'local' | 'direct' | 'ask' | null, hint: text with `commands` or null }
16
+ // 'ask': the request goes to that Mac, where a person confirms before any tab opens.
17
+ export function restoreAccess({ local = false, hosted = false, route = null, routeMac = null, machine = null, phone = false, canAsk = false } = {}) {
16
18
  if (local && !hosted) return { ok: true, how: 'local', hint: null }
17
19
  if (hosted && !phone && route === 'direct' && machine && routeMac === machine) return { ok: true, how: 'direct', hint: null }
18
20
  const m = machine || 'the Mac'
21
+ if (canAsk && machine) return { ok: true, how: 'ask', hint: 'Restore asks ' + m + ' first: someone at ' + m + ' confirms before the tabs open.' }
19
22
  if (phone) return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + ', so it can’t run from a phone yet. On ' + m + ', run `leftoff restore`.' }
20
23
  if (hosted) {
21
24
  return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + '. Open this board on ' + m + ', or run `leftoff restore` there. Already on ' + m + '? Run `leftoff setup` and turn on https://leftoff.localhost so this page can reach it.' }
@@ -23,6 +26,27 @@ export function restoreAccess({ local = false, hosted = false, route = null, rou
23
26
  return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + ', so it works from ' + m + '’s own browser. Or run `leftoff restore` there.' }
24
27
  }
25
28
 
29
+ // 0.9.8: what the card says about a Restore it asked another Mac for.
30
+ // ask: { state, machine, count, expiresAt, result, error } -> { text, tone: 'info' | 'ok' | 'warn' }
31
+ export function askLine(ask, { clock = (iso) => iso } = {}) {
32
+ if (!ask) return null
33
+ const m = ask.machine || 'the Mac', n = ask.count || 0
34
+ const tabs = n + ' tab' + (n === 1 ? '' : 's')
35
+ switch (ask.state) {
36
+ case 'pending': return { text: 'Waiting for someone at ' + m + ' to confirm' + (ask.expiresAt ? ' (until ' + clock(ask.expiresAt) + ')' : '') + '…', tone: 'info' }
37
+ case 'confirming': return { text: 'Confirmed on ' + m + ': opening ' + tabs + '…', tone: 'info' }
38
+ case 'confirmed': {
39
+ const r = ask.result || {}
40
+ if (r.how === 'print') return { text: 'Confirmed on ' + m + ', but Ghostty didn’t open there: run `leftoff restore` on ' + m + '.', tone: 'warn' }
41
+ return { text: 'Confirmed on ' + m + ': ' + (r.opened || 0) + ' of ' + tabs + ' opened' + (r.failed ? ', ' + r.failed + ' didn’t' : '') + '.', tone: r.failed ? 'warn' : 'ok' }
42
+ }
43
+ case 'declined': return { text: 'Declined on ' + m + '. Nothing was opened.', tone: 'warn' }
44
+ case 'expired': return { text: 'Nobody confirmed on ' + m + ' in time. Nothing was opened.', tone: 'warn' }
45
+ case 'failed': return { text: 'It didn’t work on ' + m + ': ' + (ask.error || 'an error') + '.', tone: 'warn' }
46
+ default: return { text: ask.error || 'Couldn’t ask ' + m + '.', tone: 'warn' }
47
+ }
48
+ }
49
+
26
50
  // The hint as HTML: escaped, `commands` in <code>.
27
51
  export function hintHtml(hint, esc) {
28
52
  return String(hint || '').split('`').map((part, i) => (i % 2 ? '<code>' + esc(part) + '</code>' : esc(part))).join('')