@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/CHANGELOG.md +23 -0
- package/README.md +1 -1
- package/bin/leftoff.js +394 -345
- package/docs/SECURITY.md +12 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +1 -1
- package/mod/hooks/register.js +28 -3
- package/package.json +1 -1
- package/ui/app.css +121 -5
- package/ui/app.js +165 -21
- package/ui/direct.js +37 -12
- package/ui/needs.js +1 -1
- package/ui/restore-gate.js +64 -0
- package/ui/wizard.js +158 -0
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
|
|
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
|
|
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
|
-
|
|
1064
|
-
|
|
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
|
-
(
|
|
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>' : '') + ' ' + 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>' : '') + ' ' + 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
|
-
|
|
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 & 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
const r = await this.fetchImpl(
|
|
59
|
-
if (r.status !== 200)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|