@leftoff/cli 0.9.7 → 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/docs/SECURITY.md CHANGED
@@ -96,6 +96,16 @@ When your hosted board is open on a Mac that runs Leftoff, the page sends the sa
96
96
 
97
97
  Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
98
98
 
99
+ ### Live link (0.9.8 beta)
100
+
101
+ Live link is opt-in, per session: only sessions started with `leftoff live` have a channel. It adds:
102
+
103
+ - **The channel** is a stdio MCP server that Claude Code starts. It opens no port. It talks to the daemon on 127.0.0.1 with the board's token, and the daemon accepts its calls (hello, next message, ack, reply, permission) from loopback only. A channel is tied to the session whose process started it, so the daemon only writes into sessions on its own Mac. Other Macs go through the peer link.
104
+ - **Prompts from the board or a phone** reach Claude exactly like typed input, so they follow the same rule as answering a permission: a phone needs *Trust my paired devices*, and they're rate-limited and written to the session's audit trail. Anyone who can send prompts can make Claude act, so treat trusting your paired devices as giving them your keyboard.
105
+ - **Permission relay** never allows anything on its own. A prompt waits for Allow once or Deny. With no answer the terminal dialog stays and decides, and an answer in the terminal wins. Live link answers one call at a time and never adds session-wide rules.
106
+ - **Outside events** need this Mac's event key (`~/.config/checkpoints/secrets/live-events`, mode 600, made by `leftoff live key`, replaced with `--new`). Locally that's a Bearer key. Through the relay only an HMAC signature is accepted (5-minute window, replays refused), so the relay can pass an event along but can't read the key, forge an event or change one. Both the relay and the Mac rate-limit, and bodies are capped at 16 KB. Claude's instructions call an event information, not a command, but it is still text Claude reads: anyone with the key can put words in front of your session. Keep the key in your CI's secrets.
107
+ - **Remote Restore** opens Ghostty tabs, so a phone or another Mac can only ask. The Mac shows a notification and a card, and only that Mac's own browser can confirm. Requests expire after 5 minutes, at most 3 wait at once, and they're rate-limited.
108
+
99
109
  ### Logging
100
110
 
101
111
  The Worker never logs request or response bodies (it can't read them anyway) and stores only what's listed above, in your Durable Object. `wrangler.toml` turns Workers observability (request logs) off. Cloudflare's edge keeps its usual metadata for any site (IP, hostname, path such as `/_relay/api`, status), under its own policies.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.7",
3
+ "version": "0.9.8-beta.1",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "Joel"
package/mod/hooks/core.js CHANGED
@@ -547,7 +547,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
547
547
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
548
548
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
549
549
  'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
550
- 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints']
550
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink']
551
551
 
552
552
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
553
553
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
29
 
30
30
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
31
- const MOD_VERSION = '0.9.7' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.8-beta.1' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.7",
3
+ "version": "0.9.8-beta.1",
4
4
  "description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
5
5
  "keywords": [
6
6
  "claude",
package/ui/app.css CHANGED
@@ -2015,3 +2015,35 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2015
2015
  .wz-cmd { width: 100%; }
2016
2016
  .wz-nav .btn { padding: 8px 16px; }
2017
2017
  }
2018
+
2019
+ /* 0.9.8 Live link: the Live badge, and the session page's message box with its delivery states */
2020
+ .pill.live { color: var(--green); background: var(--tint-green); }
2021
+ .live-box { margin: 16px 0 18px; border: 1px solid color-mix(in srgb, var(--green) 35%, var(--line)); background: linear-gradient(180deg, var(--tint-green), transparent 70%), var(--card); border-radius: var(--radius); padding: 12px 14px 14px; }
2022
+ .live-box.off { border-color: var(--line); background: var(--card); }
2023
+ .live-box.off p { margin: 4px 0 0; font-size: 13px; line-height: 1.5; }
2024
+ .live-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
2025
+ .live-head h2 { margin: 0; font-size: 15px; display: inline-flex; align-items: center; gap: 8px; }
2026
+ .live-head .faint { font-size: 12.5px; }
2027
+ .live-log { list-style: none; margin: 8px 0 10px; padding: 0; display: flex; flex-direction: column; gap: 8px; max-height: 420px; overflow-y: auto; }
2028
+ .lv { border: 1px solid var(--line); border-radius: 10px; padding: 7px 10px; background: var(--card); max-width: 88%; }
2029
+ .lv-you { align-self: flex-end; background: var(--tint-accent); border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
2030
+ .lv-event { align-self: flex-end; background: var(--tint-yellow); border-color: color-mix(in srgb, var(--yellow) 35%, var(--line)); }
2031
+ .lv-claude { align-self: flex-start; }
2032
+ .lv-who { font-size: 11.5px; color: var(--muted); display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; }
2033
+ .lv-text { white-space: pre-wrap; word-break: break-word; font-size: 13.5px; color: var(--text); margin-top: 2px; }
2034
+ .lv-state { margin-left: auto; font-weight: 600; }
2035
+ .lv-state.sent { color: var(--muted); }
2036
+ .lv-state.received { color: var(--green); }
2037
+ .lv-state.failed { color: var(--red); }
2038
+ .live-empty { margin: 6px 0 10px; font-size: 13px; }
2039
+ .live-err { color: var(--red); font-size: 13px; }
2040
+ .live-row .live-text { min-height: 44px; }
2041
+ .restore-ask .rs-m { color: var(--muted); font-size: 12.5px; }
2042
+ .rs-note.rs-ask.ok { color: var(--green); }
2043
+ .rs-note.rs-ask.warn { color: var(--red); }
2044
+ @media (pointer: coarse), (max-width: 600px) {
2045
+ .live-row .btn, .restore-ask .btn { min-height: 44px; min-width: 44px; }
2046
+ .need-actions .btn, .deny-form .btn { min-height: 44px; } /* Allow / Deny from the phone */
2047
+ .live-box { padding: 10px; margin-left: -4px; margin-right: -4px; }
2048
+ .lv { max-width: 100%; }
2049
+ }
package/ui/app.js CHANGED
@@ -14,7 +14,7 @@ import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
14
14
  import { mdInline, renderSection } from './changelog-md.js'
15
15
  import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
16
16
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
17
- import { restoreAccess, hintHtml, restoreWhen } from './restore-gate.js'
17
+ import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
18
18
  import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
19
19
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
20
20
  const $app = document.getElementById('app')
@@ -112,6 +112,8 @@ function copiedMark(btn) {
112
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)
113
113
  }
114
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>' : '')
115
117
 
116
118
  const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
117
119
 
@@ -574,7 +576,7 @@ function card(s) {
574
576
  '<div class="row1"><div class="title">' + liveDot(s) +
575
577
  (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' +
576
578
  '<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
577
- '</div>' + pill(s.status) + '</div>' +
579
+ '</div>' + pill(s.status) + liveBadge(s) + '</div>' +
578
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>' +
579
581
  (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
580
582
  cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
@@ -787,7 +789,7 @@ async function viewBoard() {
787
789
  '<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
788
790
  '<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
789
791
  html += syncDetails() + staleLine(sessions)
790
- html += restoreBanner()
792
+ html += restoreAsksBanner() + restoreBanner()
791
793
  // Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
792
794
  const needs = needsItems(sessions)
793
795
  html += needsStrip(needs, needOpts())
@@ -1039,14 +1041,49 @@ function emptyFold(list) {
1039
1041
  // (0.9.7: or when the terminal quit),
1040
1042
  // with a checkbox each; Restore opens each in a new Ghostty tab, resumed by id.
1041
1043
  // Opening tabs runs on the Mac, so (like Go) only its own browser may press it.
1042
- const restoreUi = { open: false, off: new Set(), result: null }
1044
+ const restoreUi = { open: false, off: new Set(), result: null, ask: null, busy: new Set() }
1043
1045
  function restoreDismissKey(r) { return 'lo.restore.dismissed:' + (r.restartAt || '') }
1044
1046
  function restorePicked() { const r = state.restore; return r ? r.sessions.filter((s) => !restoreUi.off.has(s.id)) : [] }
1045
1047
  // 0.9.7: this Mac's own board, or the hosted board open on it (the direct path)
1046
1048
  function restoreGate() {
1047
1049
  const lr = window.leftoffRelay, r = state.restore || {}
1048
1050
  return restoreAccess({ local: state.viewerLocal !== false, hosted: !!lr, route: lr ? lr.route : null, routeMac: lr ? lr.mac : null, machine: r.machine || null,
1049
- phone: isPhoneUa(navigator.userAgent, { maxTouchPoints: navigator.maxTouchPoints || 0 }) })
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)
1050
1087
  }
1051
1088
  function restoreBanner() {
1052
1089
  const r = state.restore
@@ -1062,17 +1099,18 @@ function restoreBanner() {
1062
1099
  }
1063
1100
  const gate = restoreGate()
1064
1101
  const picked = restorePicked().length
1102
+ const asking = restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)
1065
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') + '>' +
1066
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('')
1067
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>' +
1068
1106
  '<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
1069
1107
  '<div class="rs-list">' + rows + '</div>' +
1070
1108
  '<div class="rs-actions">' +
1071
- '<button type="button" class="btn primary" data-action="restore-go" id="restoreGo" data-how="' + (gate.how || 'off') + '"' + (gate.ok && picked ? '' : ' aria-disabled="true"') +
1072
- (gate.ok ? '' : ' aria-describedby="restoreHint" title="' + esc(gate.hint.replace(/`/g, '')) + '"') + '>' + icon('rotate-ccw') + '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>' +
1073
1111
  '<button type="button" class="btn" data-action="restore-all">' + (picked === r.sessions.length ? 'Select none' : 'Select all') + '</button>' +
1074
1112
  '<button type="button" class="btn" data-action="restore-copy">Copy commands</button>' +
1075
- (gate.ok ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
1113
+ (restoreUi.ask ? restoreAskNote() : gate.ok && gate.how !== 'ask' ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
1076
1114
  '</div>' + (restoreUi.result ? restoreResult() : '') + '</div>'
1077
1115
  }
1078
1116
  // 0.9.7: the hosted board found (or lost) this Mac's direct path: Restore follows it
@@ -1158,7 +1196,7 @@ function needOpts(extra = {}) {
1158
1196
  document.addEventListener('keydown', (ev) => {
1159
1197
  if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.altKey || ev.shiftKey) return
1160
1198
  const box = ev.target && ev.target.closest && ev.target.closest('[data-submit]')
1161
- const need = box && box.closest('.need')
1199
+ const need = box && (box.closest('.need') || box.closest('.live-box')) // 0.9.8: the Live box too
1162
1200
  if (!need) return
1163
1201
  ev.preventDefault()
1164
1202
  if (ev.repeat || ev.isComposing) return
@@ -1234,20 +1272,23 @@ async function viewSession(id) {
1234
1272
  const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
1235
1273
  html += '<div class="pagehead"><div>' + (canRename
1236
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>'
1237
- : '<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>' +
1238
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>'
1239
1277
  html += '<div class="facts">' +
1240
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)) +
1241
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) +
1242
1280
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
1243
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)
1244
1283
  html += parkRow(s)
1245
1284
  html += spacePicker(s, spv, 'session', s.id)
1246
1285
  html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
1247
1286
  // 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
1248
1287
  html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
1249
1288
  html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
1250
- 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)
1251
1292
  if (s.statusChanges && s.statusChanges.length) {
1252
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>'
1253
1294
  }
@@ -1276,6 +1317,58 @@ async function viewSession(id) {
1276
1317
  $app.innerHTML = html
1277
1318
  aroundWire(s.id)
1278
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
+
1279
1372
  // ------------------------------------------------------------ search inside transcripts (0.9.6)
1280
1373
 
1281
1374
  // Plain text + [start, end] ranges -> escaped HTML with <mark>s (ranges that
@@ -3726,6 +3819,18 @@ document.addEventListener('click', async (ev) => {
3726
3819
  } catch (e) { cuUi.making = false; cuUi.err = e.message; render() }
3727
3820
  return
3728
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
+ }
3729
3834
  if (action === 'restore-open') { restoreUi.open = true; restoreUi.result = null; return render() }
3730
3835
  if (action === 'restore-close') { restoreUi.open = false; return render() }
3731
3836
  if (action === 'restore-dismiss') { try { localStorage.setItem(restoreDismissKey(state.restore || {}), '1') } catch {} restoreUi.open = false; return render() }
@@ -3736,6 +3841,18 @@ document.addEventListener('click', async (ev) => {
3736
3841
  if (!ids.length) { toast('Pick at least one'); return }
3737
3842
  const gate = restoreGate()
3738
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
+ }
3739
3856
  btn.disabled = true; btn.textContent = 'Opening ' + ids.length + '…'
3740
3857
  const r = await api('POST', '/v1/restore', { ids }, { timeoutMs: 60000 })
3741
3858
  restoreUi.result = r.how === 'print' || r.failed ? r : null
@@ -3885,6 +4002,7 @@ document.addEventListener('click', async (ev) => {
3885
4002
  }
3886
4003
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
3887
4004
  if (action === 'reply-send') return replyClick(btn)
4005
+ if (action === 'live-send') return liveSendClick(btn)
3888
4006
  if (action === 'quick-reply') return quickReplyClick(btn)
3889
4007
  if (action === 'need-more') return needMoreClick(btn)
3890
4008
  if (action === 'need-dismiss') return needDismissClick(btn)
@@ -4361,7 +4479,7 @@ document.addEventListener('submit', async (ev) => {
4361
4479
 
4362
4480
  // Live updates over SSE; re-render the current view, at most every 800 ms.
4363
4481
  let pending = null
4364
- 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)
4365
4483
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
4366
4484
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
4367
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) }
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>'
@@ -11,11 +11,14 @@
11
11
  // route 'direct' | 'relay' (leftoffRelay.route), routeMac: the Mac it is on
12
12
  // machine the Mac the restore card belongs to
13
13
  // phone a phone or tablet (never runs Leftoff)
14
- // -> { ok, how: 'local' | 'direct' | null, hint: text with `commands` or null }
15
- export function restoreAccess({ local = false, hosted = false, route = null, routeMac = null, machine = null, phone = false } = {}) {
14
+ // canAsk 0.9.8: that Mac takes Restore requests from other devices (GET /v1/restore has asks)
15
+ // -> { ok, how: 'local' | 'direct' | 'ask' | null, hint: text with `commands` or null }
16
+ // 'ask': the request goes to that Mac, where a person confirms before any tab opens.
17
+ export function restoreAccess({ local = false, hosted = false, route = null, routeMac = null, machine = null, phone = false, canAsk = false } = {}) {
16
18
  if (local && !hosted) return { ok: true, how: 'local', hint: null }
17
19
  if (hosted && !phone && route === 'direct' && machine && routeMac === machine) return { ok: true, how: 'direct', hint: null }
18
20
  const m = machine || 'the Mac'
21
+ if (canAsk && machine) return { ok: true, how: 'ask', hint: 'Restore asks ' + m + ' first: someone at ' + m + ' confirms before the tabs open.' }
19
22
  if (phone) return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + ', so it can’t run from a phone yet. On ' + m + ', run `leftoff restore`.' }
20
23
  if (hosted) {
21
24
  return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + '. Open this board on ' + m + ', or run `leftoff restore` there. Already on ' + m + '? Run `leftoff setup` and turn on https://leftoff.localhost so this page can reach it.' }
@@ -23,6 +26,27 @@ export function restoreAccess({ local = false, hosted = false, route = null, rou
23
26
  return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + ', so it works from ' + m + '’s own browser. Or run `leftoff restore` there.' }
24
27
  }
25
28
 
29
+ // 0.9.8: what the card says about a Restore it asked another Mac for.
30
+ // ask: { state, machine, count, expiresAt, result, error } -> { text, tone: 'info' | 'ok' | 'warn' }
31
+ export function askLine(ask, { clock = (iso) => iso } = {}) {
32
+ if (!ask) return null
33
+ const m = ask.machine || 'the Mac', n = ask.count || 0
34
+ const tabs = n + ' tab' + (n === 1 ? '' : 's')
35
+ switch (ask.state) {
36
+ case 'pending': return { text: 'Waiting for someone at ' + m + ' to confirm' + (ask.expiresAt ? ' (until ' + clock(ask.expiresAt) + ')' : '') + '…', tone: 'info' }
37
+ case 'confirming': return { text: 'Confirmed on ' + m + ': opening ' + tabs + '…', tone: 'info' }
38
+ case 'confirmed': {
39
+ const r = ask.result || {}
40
+ if (r.how === 'print') return { text: 'Confirmed on ' + m + ', but Ghostty didn’t open there: run `leftoff restore` on ' + m + '.', tone: 'warn' }
41
+ return { text: 'Confirmed on ' + m + ': ' + (r.opened || 0) + ' of ' + tabs + ' opened' + (r.failed ? ', ' + r.failed + ' didn’t' : '') + '.', tone: r.failed ? 'warn' : 'ok' }
42
+ }
43
+ case 'declined': return { text: 'Declined on ' + m + '. Nothing was opened.', tone: 'warn' }
44
+ case 'expired': return { text: 'Nobody confirmed on ' + m + ' in time. Nothing was opened.', tone: 'warn' }
45
+ case 'failed': return { text: 'It didn’t work on ' + m + ': ' + (ask.error || 'an error') + '.', tone: 'warn' }
46
+ default: return { text: ask.error || 'Couldn’t ask ' + m + '.', tone: 'warn' }
47
+ }
48
+ }
49
+
26
50
  // The hint as HTML: escaped, `commands` in <code>.
27
51
  export function hintHtml(hint, esc) {
28
52
  return String(hint || '').split('`').map((part, i) => (i % 2 ? '<code>' + esc(part) + '</code>' : esc(part))).join('')