@leftoff/cli 0.9.6 → 0.9.8-beta.1

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/app.js CHANGED
@@ -4,6 +4,7 @@ import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
4
  import { icon } from './icons.js'
5
5
  import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet } from './picker.js'
6
6
  import { SETTINGS, settingsKeyFor } from './settings-nav.js'
7
+ import { mountWizard, shouldAutoOpen } from './wizard.js'
7
8
  import { changedKeys } from './settings-dirty.js'
8
9
  import { looksMd, stripMd, plainCp } from './cp-text.js'
9
10
  import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
@@ -12,7 +13,8 @@ import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentRepl
12
13
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
13
14
  import { mdInline, renderSection } from './changelog-md.js'
14
15
  import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
15
- import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix } from './viewer.js'
16
+ import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
17
+ import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
16
18
  import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
17
19
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
18
20
  const $app = document.getElementById('app')
@@ -110,6 +112,8 @@ function copiedMark(btn) {
110
112
  btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = 'Copy resume command' }, 1500)
111
113
  }
112
114
  const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
115
+ // 0.9.8 Live link: the session's channel is listening (send it prompts from here)
116
+ const liveBadge = (s) => (s && s.liveLink && s.liveLink.on ? ' <span class="pill live" title="Live link: send this session messages from the board or your phone">Live</span>' : '')
113
117
 
114
118
  const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
115
119
 
@@ -205,7 +209,7 @@ function banners() {
205
209
  const items = inc.slice(0, 2).flatMap((e) => e.sections.flatMap((s) => s.items.map((it) => ({ v: e.version, t: it })))).slice(0, 4)
206
210
  out += '<div class="banner info update" role="status"><div class="grow"><b>Leftoff ' + (u.remoteVersion ? 'v' + esc(u.remoteVersion) : esc(u.remoteSha || 'update')) + ' is available</b> · you have v' + esc(h.version) +
207
211
  (items.length ? '<ul class="incoming">' + items.map((x) => '<li>' + mdInline(firstSentence(x.t)) + '</li>').join('') + '</ul>' : '') + '</div>' +
208
- '<code class="cmd">leftoff upgrade</code><button type="button" class="btn" data-action="copy-cmd" data-cmd="leftoff upgrade">Copy</button></div>'
212
+ '<code class="cmd">' + esc(u.upgradeCommand || 'leftoff upgrade') + '</code><button type="button" class="btn" data-action="copy-cmd" data-cmd="' + esc(u.upgradeCommand || 'leftoff upgrade') + '">Copy</button></div>'
209
213
  }
210
214
  out += syncBanners(h)
211
215
  const mod = h.mod
@@ -310,7 +314,7 @@ function staleNote(machine) {
310
314
  function updateLine() {
311
315
  const h = state.health || {}
312
316
  const u = h.update || {}
313
- if (u.available) return 'v' + esc(u.remoteVersion || '?') + ' is available: run <code>leftoff upgrade</code>.'
317
+ if (u.available) return 'v' + esc(u.remoteVersion || '?') + ' is available: run <code>' + esc(u.upgradeCommand || 'leftoff upgrade') + '</code>.'
314
318
  if (u.error) return 'Last check couldn’t reach the repo (' + esc(u.error) + ').'
315
319
  if (u.checkedAt) return 'Up to date (v' + esc(h.version) + '), checked ' + esc(ago(u.checkedAt)) + '.'
316
320
  return 'Not checked yet.'
@@ -572,7 +576,7 @@ function card(s) {
572
576
  '<div class="row1"><div class="title">' + liveDot(s) +
573
577
  (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' +
574
578
  '<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
575
- '</div>' + pill(s.status) + '</div>' +
579
+ '</div>' + pill(s.status) + liveBadge(s) + '</div>' +
576
580
  '<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' ? ' · no prompt yet' : '') + '</span>' +
577
581
  (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
578
582
  cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
@@ -785,7 +789,7 @@ async function viewBoard() {
785
789
  '<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
786
790
  '<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
787
791
  html += syncDetails() + staleLine(sessions)
788
- html += restoreBanner()
792
+ html += restoreAsksBanner() + restoreBanner()
789
793
  // Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
790
794
  const needs = needsItems(sessions)
791
795
  html += needsStrip(needs, needOpts())
@@ -1033,40 +1037,84 @@ function emptyFold(list) {
1033
1037
  '<div class="grid">' + list.map(card).join('') + '</div></details>'
1034
1038
  }
1035
1039
 
1036
- // Restore (v0.4.7): the sessions that were open before this machine restarted,
1040
+ // Restore (v0.4.7): the sessions that were open before this machine restarted
1041
+ // (0.9.7: or when the terminal quit),
1037
1042
  // with a checkbox each; Restore opens each in a new Ghostty tab, resumed by id.
1038
1043
  // Opening tabs runs on the Mac, so (like Go) only its own browser may press it.
1039
- const restoreUi = { open: false, off: new Set(), result: null }
1044
+ const restoreUi = { open: false, off: new Set(), result: null, ask: null, busy: new Set() }
1040
1045
  function restoreDismissKey(r) { return 'lo.restore.dismissed:' + (r.restartAt || '') }
1041
1046
  function restorePicked() { const r = state.restore; return r ? r.sessions.filter((s) => !restoreUi.off.has(s.id)) : [] }
1047
+ // 0.9.7: this Mac's own board, or the hosted board open on it (the direct path)
1048
+ function restoreGate() {
1049
+ const lr = window.leftoffRelay, r = state.restore || {}
1050
+ return restoreAccess({ local: state.viewerLocal !== false, hosted: !!lr, route: lr ? lr.route : null, routeMac: lr ? lr.mac : null, machine: r.machine || null,
1051
+ phone: isPhoneUa(navigator.userAgent, { maxTouchPoints: navigator.maxTouchPoints || 0 }), canAsk: Array.isArray(r.asks) })
1052
+ }
1053
+ // 0.9.8: Restore asked from a phone or another Mac, waiting on THIS Mac: confirm
1054
+ // or decline here (this Mac's own board, or the hosted board open on it).
1055
+ function restoreAsksBanner() {
1056
+ const r = state.restore
1057
+ const asks = r && Array.isArray(r.asks) ? r.asks : []
1058
+ if (!asks.length) return ''
1059
+ const gate = restoreGate()
1060
+ const here = gate.how === 'local' || gate.how === 'direct'
1061
+ return asks.map((a) => {
1062
+ const what = a.count + ' session' + (a.count === 1 ? '' : 's') + (a.titles && a.titles.length ? ': ' + a.titles.join(', ') + (a.count > a.titles.length ? '…' : '') : '')
1063
+ const busy = restoreUi.busy.has(a.id)
1064
+ return '<div class="banner warn restore-ask" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to restore ' + esc(what) + '</b> on ' + esc(r.machine || 'this Mac') + '. ' +
1065
+ (here ? 'Open them here, each in a new Ghostty tab?' : 'Someone at ' + esc(r.machine || 'that Mac') + ' has to confirm it there.') + ' <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
1066
+ (here ? '<button type="button" class="btn primary" data-action="restore-ask-confirm" data-id="' + esc(a.id) + '"' + (busy ? ' aria-disabled="true"' : '') + '>' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Open ' + a.count + ' tab' + (a.count === 1 ? '' : 's')) + '</button>' +
1067
+ '<button type="button" class="btn" data-action="restore-ask-decline" data-id="' + esc(a.id) + '"' + (busy ? ' aria-disabled="true"' : '') + '>Decline</button>' : '') + '</div>'
1068
+ }).join('')
1069
+ }
1070
+ function restoreAskNote() {
1071
+ const x = askLine(restoreUi.ask, { clock })
1072
+ return x ? '<span class="rs-note rs-ask ' + x.tone + '" id="restoreAskNote" role="status">' + hintHtml(x.text, esc) + '</span>' : ''
1073
+ }
1074
+ // The asker's side: poll the request until it's settled.
1075
+ async function pollRestoreAsk() {
1076
+ const a = restoreUi.ask
1077
+ if (!a || !a.id || !['pending', 'confirming'].includes(a.state)) return
1078
+ try {
1079
+ const r = await api('GET', '/v1/restore/asks/' + encodeURIComponent(a.id) + (a.machine ? '?machine=' + encodeURIComponent(a.machine) : ''))
1080
+ if (restoreUi.ask && restoreUi.ask.id === a.id) {
1081
+ const changed = r.state !== a.state
1082
+ restoreUi.ask = { ...a, ...r, machine: a.machine || r.machine }
1083
+ if (changed) { const el = document.getElementById('restoreBanner'); if (el) el.outerHTML = restoreBanner(); if (r.state === 'confirmed') toast('Restored on ' + (a.machine || 'that Mac')) }
1084
+ }
1085
+ } catch (e) { if (restoreUi.ask && restoreUi.ask.id === a.id && /gone|not_found/.test(String(e.message))) restoreUi.ask = { ...a, state: 'failed', error: e.message } }
1086
+ if (restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)) setTimeout(pollRestoreAsk, 2000)
1087
+ }
1042
1088
  function restoreBanner() {
1043
1089
  const r = state.restore
1044
1090
  if (!r || !r.sessions || !r.sessions.length) return restoreUi.result ? restoreResult() : ''
1045
1091
  try { if (localStorage.getItem(restoreDismissKey(r)) === '1' && !restoreUi.open) return '' } catch {}
1046
1092
  const n = r.sessions.length
1047
- const what = r.restartKind === 'boot' ? (r.ghostty ? 'your Mac' : 'this machine') + ' restarted' : 'Leftoff restarted'
1048
1093
  const when = r.restartAt ? ' (' + clock(r.restartAt) + ')' : ''
1049
- const head = '<b>' + n + ' session' + (n === 1 ? ' was' : 's were') + ' open before ' + what + '</b>' + esc(when)
1094
+ const head = '<b>' + n + ' session' + (n === 1 ? ' was' : 's were') + ' open ' + esc(restoreWhen(r)) + '</b>' + esc(when)
1050
1095
  if (!restoreUi.open) {
1051
1096
  return '<div class="banner info restore" id="restoreBanner" role="status"><span class="grow">' + head + '. Put them back, each in its own Ghostty tab.</span>' +
1052
1097
  '<button type="button" class="btn primary" data-action="restore-open">' + icon('rotate-ccw') + 'Restore…</button>' +
1053
1098
  '<button type="button" class="btn x" data-action="restore-dismiss" aria-label="Not now" title="Not now">✕</button></div>'
1054
1099
  }
1055
- const local = state.viewerLocal !== false
1100
+ const gate = restoreGate()
1056
1101
  const picked = restorePicked().length
1102
+ const asking = restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)
1057
1103
  const rows = r.sessions.map((s) => '<label class="rs-row"><input type="checkbox" name="rs" value="' + esc(s.id) + '"' + (restoreUi.off.has(s.id) ? '' : ' checked') + '>' +
1058
1104
  '<span class="rs-main"><span class="rs-t">' + esc(s.title || '(untitled)') + '</span><span class="rs-m">' + esc([s.repoLabel, s.branch, s.modelLabel, s.lastAliveAt ? 'last seen ' + ago(s.lastAliveAt) : null, 'cp:' + (s.shortId || s.id.slice(0, 8))].filter(Boolean).join(' · ')) + '</span>' + (s.blocked ? '<span class="rs-m err">' + esc(s.error) + '</span>' : '') + '</span></label>').join('')
1059
1105
  return '<div class="banner info restore open" id="restoreBanner" role="region" aria-label="Restore sessions"><div class="rs-head"><span class="grow">' + head + '. Each opens in a new Ghostty tab, in its folder, with <code>claude --resume</code> and its name.</span>' +
1060
1106
  '<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
1061
1107
  '<div class="rs-list">' + rows + '</div>' +
1062
1108
  '<div class="rs-actions">' +
1063
- (local ? '<button type="button" class="btn primary" data-action="restore-go" id="restoreGo"' + (picked ? '' : ' disabled') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>'
1064
- : '<button type="button" class="btn primary" disabled title="Restore opens Ghostty tabs on the Mac running Leftoff, so it only works from that Mac’s own browser (localhost)">Restore ' + picked + '</button>') +
1109
+ '<button type="button" class="btn primary" data-action="restore-go" id="restoreGo" data-how="' + (gate.how || 'off') + '"' + (gate.ok && picked && !asking ? '' : ' aria-disabled="true"') +
1110
+ (gate.ok && gate.how !== 'ask' ? '' : ' aria-describedby="restoreHint"') + (gate.ok ? '' : ' title="' + esc(gate.hint.replace(/`/g, '')) + '"') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>' +
1065
1111
  '<button type="button" class="btn" data-action="restore-all">' + (picked === r.sessions.length ? 'Select none' : 'Select all') + '</button>' +
1066
1112
  '<button type="button" class="btn" data-action="restore-copy">Copy commands</button>' +
1067
- (local ? '' : '<span class="rs-note">This opens tabs on the Mac, so only its own browser can do it (or run <code>leftoff restore</code> there).</span>') +
1113
+ (restoreUi.ask ? restoreAskNote() : gate.ok && gate.how !== 'ask' ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
1068
1114
  '</div>' + (restoreUi.result ? restoreResult() : '') + '</div>'
1069
1115
  }
1116
+ // 0.9.7: the hosted board found (or lost) this Mac's direct path: Restore follows it
1117
+ if (typeof window !== 'undefined') window.addEventListener('leftoff-route', () => { const el = restoreUi.open && document.getElementById('restoreBanner'); if (el) el.outerHTML = restoreBanner() })
1070
1118
  function restoreResult() {
1071
1119
  const x = restoreUi.result
1072
1120
  if (!x) return ''
@@ -1148,7 +1196,7 @@ function needOpts(extra = {}) {
1148
1196
  document.addEventListener('keydown', (ev) => {
1149
1197
  if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.altKey || ev.shiftKey) return
1150
1198
  const box = ev.target && ev.target.closest && ev.target.closest('[data-submit]')
1151
- const need = box && box.closest('.need')
1199
+ const need = box && (box.closest('.need') || box.closest('.live-box')) // 0.9.8: the Live box too
1152
1200
  if (!need) return
1153
1201
  ev.preventDefault()
1154
1202
  if (ev.repeat || ev.isComposing) return
@@ -1224,20 +1272,23 @@ async function viewSession(id) {
1224
1272
  const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
1225
1273
  html += '<div class="pagehead"><div>' + (canRename
1226
1274
  ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + esc(s.title || 'Untitled session') + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
1227
- : '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' &nbsp;' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
1275
+ : '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + liveBadge(s) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' &nbsp;' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
1228
1276
  '<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
1229
1277
  html += '<div class="facts">' +
1230
1278
  fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
1231
1279
  fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
1232
1280
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
1233
1281
  fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
1282
+ if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + await liveSection(s)
1234
1283
  html += parkRow(s)
1235
1284
  html += spacePicker(s, spv, 'session', s.id)
1236
1285
  html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
1237
1286
  // 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
1238
1287
  html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
1239
1288
  html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
1240
- if (s.needsYou) html += '<section class="needs-strip one" aria-label="Needs you"><div class="needs-head"><h2>Needs you</h2></div><div class="needs-list">' + needPanel({ ...s.needsYou, session: s }, needOpts({ compact: true })) + '</div></section>'
1289
+ // 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
1290
+ if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
1291
+ if (!isLive(s)) html += await liveSection(s)
1241
1292
  if (s.statusChanges && s.statusChanges.length) {
1242
1293
  html += '<div class="striplabel">Status history</div><div class="strip">' + s.statusChanges.slice(-24).map((c) => '<span class="s-' + esc(c.status) + '" title="' + esc(clock(c.at)) + '">' + esc(LABEL[c.status] || c.status) + '</span>').join('') + '</div>'
1243
1294
  }
@@ -1266,6 +1317,58 @@ async function viewSession(id) {
1266
1317
  $app.innerHTML = html
1267
1318
  aroundWire(s.id)
1268
1319
  }
1320
+ // ------------------------------------------------------------ Live link (0.9.8 beta)
1321
+
1322
+ const isLive = (s) => !!(s && s.liveLink && s.liveLink.on)
1323
+ function sessionNeedsStrip(s) { return '<section class="needs-strip one" aria-label="Needs you"><div class="needs-head"><h2>Needs you</h2></div><div class="needs-list">' + needPanel({ ...s.needsYou, session: s }, needOpts({ compact: true })) + '</div></section>' }
1324
+ const LIVE_STATE = { sent: 'Sent, waiting for the session', received: 'Received by the session', failed: 'Didn’t arrive' }
1325
+ function liveEntry(m) {
1326
+ const who = m.from === 'claude' ? 'Claude' : m.from === 'event' ? 'Event' + (m.source ? ' · ' + m.source : '') : 'You' + (m.where && !/^on this machine/.test(m.where) ? ' · ' + m.where : '')
1327
+ const st = m.from === 'claude' ? '' : '<span class="lv-state ' + esc(m.state || '') + '">' + esc(m.state === 'failed' ? (m.errorText || LIVE_STATE.failed) : LIVE_STATE[m.state] || '') + '</span>'
1328
+ return '<li class="lv lv-' + esc(m.from) + '"><div class="lv-who">' + esc(who) + ' <span class="faint">' + esc(ago(m.at)) + '</span>' + st + '</div><div class="lv-text">' + esc(m.text) + '</div></li>'
1329
+ }
1330
+ // The how-to for a session that isn't live (fetched once: the claude line has this Mac's path).
1331
+ async function liveHow() {
1332
+ if (state.liveHow === undefined) { try { state.liveHow = await api('GET', '/v1/live') } catch { state.liveHow = null } }
1333
+ return state.liveHow
1334
+ }
1335
+ async function liveSection(s) {
1336
+ if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
1337
+ if (!isLive(s)) {
1338
+ if (s.endedAt || s.live === 'closed') return ''
1339
+ const how = await liveHow()
1340
+ return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
1341
+ '<p class="faint">To send this session messages from here or your phone, and answer its permission prompts, start it with <code>leftoff live</code>' +
1342
+ (how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p></section>'
1343
+ }
1344
+ let v = null
1345
+ try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live') } catch (e) { v = { messages: [], error: e.message } }
1346
+ const msgs = (v && v.messages) || []
1347
+ const may = state.mayAnswer !== false
1348
+ const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
1349
+ return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2><span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
1350
+ (msgs.length ? '<ol class="live-log" aria-live="polite">' + msgs.map(liveEntry).join('') + '</ol>' : '<p class="faint live-empty">Nothing sent yet.</p>') +
1351
+ (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
1352
+ '<div class="reply-row live-row"><textarea class="reply-text live-text" rows="2" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter') + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
1353
+ '<button type="button" class="btn primary" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '"' + (may ? '' : ' aria-disabled="true"') + '>Send</button></div>' +
1354
+ (may ? '' : '<p class="faint">Sending from another device is off. On a Mac, turn on Settings → Privacy &amp; security → Trust my paired devices.</p>') + '</section>'
1355
+ }
1356
+ async function liveSendClick(btn) {
1357
+ const box = btn.closest('.live-box')
1358
+ const ta = box && box.querySelector('.live-text')
1359
+ const text = ta ? ta.value.trim() : ''
1360
+ if (!text) { toast('Type a message first'); return }
1361
+ if (btn.getAttribute('aria-disabled') === 'true') return
1362
+ btn.setAttribute('aria-disabled', 'true'); btn.textContent = 'Sending…'
1363
+ try {
1364
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id) + '/live', { text })
1365
+ if (state.drafts) delete state.drafts['live:' + btn.dataset.id]
1366
+ if (ta) ta.value = ''
1367
+ if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
1368
+ setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
1369
+ } catch (e) { toast(e.message, 4200) } finally { render() }
1370
+ }
1371
+
1269
1372
  // ------------------------------------------------------------ search inside transcripts (0.9.6)
1270
1373
 
1271
1374
  // Plain text + [start, end] ranges -> escaped HTML with <mark>s (ranges that
@@ -2407,6 +2510,7 @@ async function viewSettings() {
2407
2510
  updates: '<div class="section">About</div>' +
2408
2511
  '<label>Version</label><div class="aboutline"><span class="chip mono">v' + esc(h.version || '') + '</span><a href="#/whats-new">What’s new</a><a href="' + NPM_URL + '" target="_blank" rel="noopener">npm ↗</a></div>' +
2409
2512
  '<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
2513
+ '<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a></div>' +
2410
2514
  advanced('updates', '<label for="f-updates">Check daily</label><label class="check"><input id="f-updates" type="checkbox"' + (!(c.updates && c.updates.check === false) ? ' checked' : '') + '><span>Look for a new version daily. Nothing installs on its own.</span></label>' +
2411
2515
  '<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
2412
2516
  '<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
@@ -3148,6 +3252,7 @@ async function render() {
3148
3252
  else if (r.name === 'whats-new') await viewWhatsNew()
3149
3253
  else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
3150
3254
  else if (r.name === 'search') await viewSearch(r.q || '')
3255
+ else if (r.name === 'setup') await viewSetup()
3151
3256
  else await viewBoard()
3152
3257
  } catch (e) {
3153
3258
  if (e.code === 'login_required') return showLogin()
@@ -3155,6 +3260,18 @@ async function render() {
3155
3260
  }
3156
3261
  }
3157
3262
 
3263
+ // First-run setup wizard (0.9.7, ui/wizard.js). Live updates don't redraw it:
3264
+ // it keeps its own step and fetches its own state.
3265
+ async function viewSetup() {
3266
+ if ($app.querySelector('.wz')) return
3267
+ await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/' } })
3268
+ }
3269
+ // Opens #/setup by itself once, on a Mac's own board, when the board opens plain (no deep link).
3270
+ async function firstRunSetup() {
3271
+ if (location.hash.replace(/^#\/?/, '')) return
3272
+ try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) location.replace('#/setup') } catch {}
3273
+ }
3274
+
3158
3275
  // A remote browser (Tailscale) that hasn't signed in yet: the iPhone
3159
3276
  // home-screen app always lands here first (it has its own cookie jar), so the
3160
3277
  // typed code is the main way in; the QR link is the other.
@@ -3702,6 +3819,18 @@ document.addEventListener('click', async (ev) => {
3702
3819
  } catch (e) { cuUi.making = false; cuUi.err = e.message; render() }
3703
3820
  return
3704
3821
  }
3822
+ if (action === 'restore-ask-confirm' || action === 'restore-ask-decline') {
3823
+ const id = btn.dataset.id
3824
+ if (!id || restoreUi.busy.has(id)) return
3825
+ restoreUi.busy.add(id); render()
3826
+ try {
3827
+ const r = await api('POST', '/v1/restore/asks/' + encodeURIComponent(id) + '/' + (action === 'restore-ask-confirm' ? 'confirm' : 'decline'), {}, { timeoutMs: 60000 })
3828
+ toast(r.state === 'confirmed' ? 'Restored ' + ((r.result && r.result.opened) || 0) + ' in Ghostty' : r.state === 'declined' ? 'Declined' : 'That request was ' + r.state)
3829
+ } catch (e) { toast(e.message, 5000) }
3830
+ restoreUi.busy.delete(id)
3831
+ try { state.restore = await api('GET', '/v1/restore') } catch {}
3832
+ return render()
3833
+ }
3705
3834
  if (action === 'restore-open') { restoreUi.open = true; restoreUi.result = null; return render() }
3706
3835
  if (action === 'restore-close') { restoreUi.open = false; return render() }
3707
3836
  if (action === 'restore-dismiss') { try { localStorage.setItem(restoreDismissKey(state.restore || {}), '1') } catch {} restoreUi.open = false; return render() }
@@ -3709,7 +3838,21 @@ document.addEventListener('click', async (ev) => {
3709
3838
  if (action === 'restore-copy') { const p = restorePicked(); if (!p.length) { toast('Pick at least one'); return } await copy(p.map((x) => x.command).join('\n')); return }
3710
3839
  if (action === 'restore-go') {
3711
3840
  const ids = restorePicked().map((x) => x.id)
3712
- if (!ids.length) return
3841
+ if (!ids.length) { toast('Pick at least one'); return }
3842
+ const gate = restoreGate()
3843
+ if (!gate.ok) { toast(gate.hint.replace(/`/g, ''), 6000); return }
3844
+ // 0.9.8: from a phone or another Mac, ask that Mac; a person there confirms
3845
+ if (gate.how === 'ask') {
3846
+ if (restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)) return
3847
+ const machine = (state.restore && state.restore.machine) || null
3848
+ btn.setAttribute('aria-disabled', 'true'); btn.textContent = 'Asking ' + (machine || 'the Mac') + '…'
3849
+ try {
3850
+ const r = await api('POST', '/v1/restore/asks', { machine, ids })
3851
+ restoreUi.ask = { ...r, machine: machine || r.machine }
3852
+ setTimeout(pollRestoreAsk, 2000)
3853
+ } catch (e) { restoreUi.ask = { state: 'error', machine, error: e.message } }
3854
+ return render()
3855
+ }
3713
3856
  btn.disabled = true; btn.textContent = 'Opening ' + ids.length + '…'
3714
3857
  const r = await api('POST', '/v1/restore', { ids }, { timeoutMs: 60000 })
3715
3858
  restoreUi.result = r.how === 'print' || r.failed ? r : null
@@ -3859,6 +4002,7 @@ document.addEventListener('click', async (ev) => {
3859
4002
  }
3860
4003
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
3861
4004
  if (action === 'reply-send') return replyClick(btn)
4005
+ if (action === 'live-send') return liveSendClick(btn)
3862
4006
  if (action === 'quick-reply') return quickReplyClick(btn)
3863
4007
  if (action === 'need-more') return needMoreClick(btn)
3864
4008
  if (action === 'need-dismiss') return needDismissClick(btn)
@@ -4335,10 +4479,10 @@ document.addEventListener('submit', async (ev) => {
4335
4479
 
4336
4480
  // Live updates over SSE; re-render the current view, at most every 800 ms.
4337
4481
  let pending = null
4338
- const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName))
4482
+ const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value)
4339
4483
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
4340
4484
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
4341
- function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && state.route.name === 'settings') && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
4485
+ function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name)) && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
4342
4486
  let stream = null
4343
4487
  function connect() {
4344
4488
  if (stream) { try { stream.close() } catch {} }
@@ -4388,7 +4532,7 @@ document.addEventListener('change', async (ev) => {
4388
4532
  if (ev.target.checked) restoreUi.off.delete(ev.target.value); else restoreUi.off.add(ev.target.value)
4389
4533
  const n = restorePicked().length
4390
4534
  const go = document.getElementById('restoreGo')
4391
- if (go) { go.disabled = !n; go.innerHTML = icon('rotate-ccw') + 'Restore ' + n }
4535
+ if (go) { if (n && restoreGate().ok) go.removeAttribute('aria-disabled'); else go.setAttribute('aria-disabled', 'true'); go.innerHTML = icon('rotate-ccw') + 'Restore ' + n }
4392
4536
  const all = document.querySelector('[data-action="restore-all"]')
4393
4537
  if (all) all.textContent = n === (state.restore ? state.restore.sessions.length : 0) ? 'Select none' : 'Select all'
4394
4538
  return
@@ -4455,7 +4599,7 @@ document.addEventListener('pointerdown', (ev) => {
4455
4599
  if (m && !m.contains(ev.target)) m.open = false
4456
4600
  })
4457
4601
  api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
4458
- refreshHealth().then(render)
4602
+ refreshHealth().then(render).then(firstRunSetup)
4459
4603
  connect()
4460
4604
 
4461
4605
  // The home-screen app's service worker (v0.4.6): it only serves an offline page
package/ui/direct.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // 0.9.3: "direct when on the same Mac". The hosted board (<user>.leftoff.dev)
2
2
  // open on a Mac that runs Leftoff asks that Mac's own daemon over
3
- // http://127.0.0.1:7429 instead of through the relay; the address bar stays.
3
+ // http://127.0.0.1:7429 (0.9.7: or https://leftoff.localhost) instead of
4
+ // through the relay; the address bar stays.
4
5
  //
5
6
  // The request is the very envelope the relay would carry (ui/relay-crypto.js),
6
7
  // posted to /v1/relay/direct (src/direct.js) instead of /_relay/api. So going
@@ -21,7 +22,13 @@ import { LOCAL_PORT, isPhoneUa } from './viewer.js'
21
22
  export const DIRECT_PATH = '/v1/relay/direct'
22
23
  export const PROBE_EVERY_MS = 60000
23
24
  export const FOCUS_GAP_MS = 10000
25
+ // 0.9.7: Leftoff's own https://leftoff.localhost (`leftoff setup`, Caddy in
26
+ // front of 127.0.0.1:7429) is asked too: an https page may always call an
27
+ // https address, where Safari refuses plain http://127.0.0.1. Both go to the
28
+ // same daemon; the first that proves itself is used.
29
+ export const HTTPS_LOCAL = 'https://leftoff.localhost'
24
30
  export function directUrl(port = LOCAL_PORT) { return 'http://127.0.0.1:' + port + DIRECT_PATH }
31
+ export function directUrls(port = LOCAL_PORT) { return [HTTPS_LOCAL + DIRECT_PATH, directUrl(port)] }
25
32
 
26
33
  export class DirectError extends Error { constructor(code, message) { super(message || code); this.code = code } }
27
34
 
@@ -37,14 +44,18 @@ export function directMatch(info, { user, picked } = {}) {
37
44
  export function aborted(signal) { return !!(signal && signal.aborted) }
38
45
 
39
46
  export class DirectRoute {
40
- constructor({ user, port = LOCAL_PORT, fetchImpl, now = () => Date.now(), ua = '', maxTouchPoints = 0, every = PROBE_EVERY_MS, focusGap = FOCUS_GAP_MS } = {}) {
47
+ constructor({ user, port = LOCAL_PORT, urls = null, fetchImpl, now = () => Date.now(), ua = '', maxTouchPoints = 0, every = PROBE_EVERY_MS, focusGap = FOCUS_GAP_MS } = {}) {
41
48
  this.user = user; this.port = port; this.fetchImpl = fetchImpl; this.now = now
49
+ this.urls = urls && urls.length ? urls.slice() : directUrls(port)
50
+ this.base = null // the address that answered last (one of urls)
42
51
  // a phone or tablet never runs Leftoff: don't ask (and don't trip a permission prompt)
43
52
  this.never = isPhoneUa(ua, { maxTouchPoints })
44
53
  this.every = every; this.focusGap = focusGap
45
54
  this.on = false; this.machine = null; this.checkedAt = 0; this.inflight = null; this.lastError = null; this.latencyMs = null
46
55
  }
47
- get url() { return directUrl(this.port) }
56
+ get url() { return this.base || this.urls[this.urls.length - 1] }
57
+ // 'https' (https://leftoff.localhost) or 'http' (127.0.0.1), once one answered
58
+ get via() { return this.base ? (this.base.startsWith('https:') ? 'https' : 'http') : null }
48
59
 
49
60
  // -> the machine (direct confirmed, not yet switched on) or null. Never throws.
50
61
  probe({ picked = null, verify = null, timeoutMs = 1500 } = {}) {
@@ -53,15 +64,20 @@ export class DirectRoute {
53
64
  this.inflight = (async () => {
54
65
  this.checkedAt = this.now()
55
66
  const t0 = this.now()
56
- let info
57
- try {
58
- const r = await this.fetchImpl(this.url, { mode: 'cors', credentials: 'omit', cache: 'no-store', signal: timeoutSignal(timeoutMs) })
59
- if (r.status !== 200) return null
60
- info = await r.json()
61
- } catch { return null }
62
- const m = directMatch(info, { user: this.user, picked })
63
- if (!m) return null
64
- if (verify) { let ok = false; try { ok = await verify(m) } catch { ok = false } if (!ok) return null }
67
+ // every address at once; the first that is this account's Leftoff wins
68
+ const ask = async (url) => {
69
+ const r = await this.fetchImpl(url, { mode: 'cors', credentials: 'omit', cache: 'no-store', signal: timeoutSignal(timeoutMs) })
70
+ if (r.status !== 200) throw new DirectError('http_' + r.status)
71
+ const m = directMatch(await r.json(), { user: this.user, picked })
72
+ if (!m) throw new DirectError('not_ours')
73
+ return { url, m }
74
+ }
75
+ let hit
76
+ try { hit = await firstOf(this.urls.map(ask)) } catch { return null }
77
+ const prev = this.base
78
+ this.base = hit.url
79
+ const m = hit.m
80
+ if (verify) { let ok = false; try { ok = await verify(m) } catch { ok = false } if (!ok) { this.base = prev; return null } }
65
81
  this.latencyMs = Math.max(0, Math.round(this.now() - t0))
66
82
  return m
67
83
  })().finally(() => { this.inflight = null })
@@ -108,6 +124,15 @@ export function toResponse(out) {
108
124
  return new Response(nullBody ? null : out.b, { status: out.s, headers: out.h })
109
125
  }
110
126
 
127
+ // Promise.any, for browsers and runtimes without it.
128
+ function firstOf(ps) {
129
+ return new Promise((resolve, reject) => {
130
+ let left = ps.length
131
+ if (!left) return reject(new DirectError('none'))
132
+ for (const p of ps) Promise.resolve(p).then(resolve, () => { if (--left === 0) reject(new DirectError('none')) })
133
+ })
134
+ }
135
+
111
136
  function timeoutSignal(ms) {
112
137
  if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) return AbortSignal.timeout(ms)
113
138
  const c = new AbortController(); setTimeout(() => c.abort(), ms); return c.signal
package/ui/needs.js CHANGED
@@ -81,7 +81,7 @@ export function needPanel(n, opts = {}) {
81
81
  if (n.more) h += '<div class="need-note">+' + n.more + ' more waiting after this one</div>'
82
82
  h += '<div class="need-actions">' +
83
83
  '<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>' +
84
+ (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
85
  '<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
86
86
  '<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
87
  '<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>'
@@ -0,0 +1,64 @@
1
+ // 0.9.7: may this board press Restore, and if not, what would make it work.
2
+ // Restore opens Ghostty tabs on the Mac the restore card belongs to, so the
3
+ // board must be on that Mac: its own browser on localhost, or your hosted
4
+ // board (<user>.leftoff.dev) open on it and talking to it over the direct path
5
+ // (ui/direct.js: http://127.0.0.1:7429 or https://leftoff.localhost). The
6
+ // daemon checks the same thing again (src/direct.js DIRECT_LOCAL); this only
7
+ // decides how the button looks and what the hint says. Plain logic, no DOM.
8
+ //
9
+ // local the page is that Mac's own board (viewer.local)
10
+ // hosted the page is the hosted board (window.leftoffRelay)
11
+ // route 'direct' | 'relay' (leftoffRelay.route), routeMac: the Mac it is on
12
+ // machine the Mac the restore card belongs to
13
+ // phone a phone or tablet (never runs Leftoff)
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 } = {}) {
18
+ if (local && !hosted) return { ok: true, how: 'local', hint: null }
19
+ if (hosted && !phone && route === 'direct' && machine && routeMac === machine) return { ok: true, how: 'direct', hint: null }
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.' }
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`.' }
23
+ if (hosted) {
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.' }
25
+ }
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.' }
27
+ }
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
+
50
+ // The hint as HTML: escaped, `commands` in <code>.
51
+ export function hintHtml(hint, esc) {
52
+ return String(hint || '').split('`').map((part, i) => (i % 2 ? '<code>' + esc(part) + '</code>' : esc(part))).join('')
53
+ }
54
+
55
+ // 0.9.7: when the restore card's sessions were open, from GET /v1/restore:
56
+ // restartKind 'quit' (the terminal quit while the Mac stayed up; restartTerminal
57
+ // names it when known), 'boot' or 'daemon'. short: the CLI's one-liners.
58
+ // -> "when Ghostty quit" | "before your Mac restarted" | "before Leftoff restarted"
59
+ export function restoreWhen(r = {}, { short = false } = {}) {
60
+ if (r.restartKind === 'quit') return 'when ' + (r.restartTerminal || 'the terminal') + ' quit'
61
+ if (short) return 'before the restart'
62
+ if (r.restartKind === 'boot') return 'before ' + (r.ghostty === false ? 'this machine' : 'your Mac') + ' restarted'
63
+ return 'before Leftoff restarted'
64
+ }