@leftoff/cli 0.9.6 → 0.9.7

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
@@ -94,6 +94,8 @@ A relayed request is replayed against the Mac's own board from inside the daemon
94
94
 
95
95
  When your hosted board is open on a Mac that runs Leftoff, the page sends the same sealed requests straight to that Mac's daemon at `http://127.0.0.1:7429/v1/relay/direct` instead of to the relay. Nothing new is trusted: the daemon opens them with the same per-device key (and the same replay and time checks), runs them the same way (a remote device, the rules above), and seals the answer. The page only switches after the daemon says it's on your account and a sealed round trip through it works, so another Leftoff on that port (someone else's account) can't serve your board: it can't open the request or forge the answer. The door is loopback only, never through a proxy, off when Use from anywhere is off, and answers CORS for exactly one origin, `https://<your name>.leftoff.dev` (with Chrome's Private Network Access header); any other page gets a 403 it can't read. If the direct path fails, the request goes through the relay once more and the board stays on the relay until a later check finds the direct path again.
96
96
 
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
+
97
99
  ### Logging
98
100
 
99
101
  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.6",
3
+ "version": "0.9.7",
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"
@@ -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.6' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.7' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
@@ -103,6 +103,7 @@ async function api($, method, path, body, timeoutMs) {
103
103
  // Every event goes through here: post, or spool a unique file (fs.write isn't atomic).
104
104
  async function sendOrSpool($, type, data, timeoutMs) {
105
105
  if (st.internal || !st.sessionId) return null
106
+ try { await ensureSetup($) } catch {} // a setup that failed leaves the fallback below to name the spool file's folder
106
107
  const ev = { v: 1, type, sessionId: st.sessionId, at: nowIso(), capture: 'mod', machine: st.machine, data: data || {} }
107
108
  st.record = applyEvent(st.record || { id: st.sessionId, turns: 0, turnsSinceCheckpoint: 0, backgroundTasks: 0 }, ev)
108
109
  const r = await api($, 'POST', '/v1/events', { events: [ev] }, timeoutMs)
@@ -115,7 +116,17 @@ async function sendOrSpool($, type, data, timeoutMs) {
115
116
  return j
116
117
  }
117
118
  try {
118
- await $.fs.write(st.root + '/.spool/' + st.machine + '/' + st.sessionId + '/' + Date.now() + '-' + rand() + '.json', JSON.stringify({ events: [ev] }) + '\n')
119
+ // An absolute root even when setup never came: without one the concat
120
+ // makes st.root null a relative "null/.spool" beside the session's cwd.
121
+ let root = st.root
122
+ if (!root) {
123
+ const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
124
+ if (home) root = home.replace(/[\\/]+$/, '') + '/checkpoints' // never a bare "/checkpoints"
125
+ }
126
+ // Only an absolute folder; otherwise skip the file but still keep the registry below.
127
+ if (root && /^([A-Za-z]:[\\/]|\/)/.test(String(root))) {
128
+ await $.fs.write(String(root) + '/.spool/' + (st.machine || 'unknown') + '/' + st.sessionId + '/' + Date.now() + '-' + rand() + '.json', JSON.stringify({ events: [ev] }) + '\n')
129
+ }
119
130
  } catch {}
120
131
  try {
121
132
  await $.store.set('session:' + st.sessionId, {
@@ -160,6 +171,20 @@ async function loadSetup($) {
160
171
  st.machine = resolveMachine({ env: await $.env.get('CHECKPOINTS_MACHINE'), hostname, cfg: st.cfg })
161
172
  }
162
173
 
174
+ // loadSetup runs once per process and every sender awaits the same attempt:
175
+ // a hook that fires before session.start (classic.SessionStart on a cold
176
+ // boot, the old null/.spool race) waits for these values instead of reading
177
+ // st at its initial state.
178
+ let setupPromise = null
179
+ function ensureSetup($) {
180
+ if (!setupPromise) {
181
+ const p = loadSetup($)
182
+ setupPromise = p
183
+ p.catch(() => { if (setupPromise === p) setupPromise = null }) // a failed setup tries again on the next event
184
+ }
185
+ return setupPromise
186
+ }
187
+
163
188
  async function readRepo($, cwd) {
164
189
  let repo = null
165
190
  try {
@@ -931,7 +956,7 @@ export function register(on, options) {
931
956
  on('session.start', async ($, e, next) => {
932
957
  try {
933
958
  if (await isInternal($)) { st.internal = true; return next(e) }
934
- await loadSetup($)
959
+ try { await ensureSetup($) } catch {} // the same attempt an early hook may have started; a failure still says hello
935
960
  st.sessionId = await $.session.id()
936
961
  const cwd = await $.session.cwd()
937
962
  const repo = await readRepo($, cwd)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
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
@@ -35,6 +35,12 @@
35
35
  --toast-shadow: 0 10px 30px rgba(0,0,0,.4);
36
36
  --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%238b93a7' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
37
37
  --radius: 14px;
38
+ --btn-bg: #1d2433;
39
+ --btn-hover: #252d40;
40
+ --btn-active: #161b26;
41
+ --btn-line: #3a4560;
42
+ --btn-line-hover: #56637f;
43
+ --btn-shadow: none;
38
44
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
39
45
  }
40
46
  /* Light: a paper-white page with cool-gray structure, not an inverted dark.
@@ -75,6 +81,12 @@
75
81
  --card-shadow: 0 1px 2px rgba(22,26,36,.04), 0 2px 8px rgba(22,26,36,.04);
76
82
  --toast-shadow: 0 10px 30px rgba(22,26,36,.16);
77
83
  --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%235b6478' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
84
+ --btn-bg: #ffffff;
85
+ --btn-hover: #f4f6fa;
86
+ --btn-active: #e9ecf2;
87
+ --btn-line: #c7cedb;
88
+ --btn-line-hover: #9aa3b5;
89
+ --btn-shadow: 0 1px 1px rgba(22,26,36,.06);
78
90
  }
79
91
  * { box-sizing: border-box; }
80
92
  html, body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
@@ -147,11 +159,18 @@ h2 .count { color: var(--faint); font-weight: 500; }
147
159
  .nocp { color: var(--faint); font-size: 13px; font-style: italic; padding: 4px 0; }
148
160
 
149
161
  .actions-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; padding-top: 2px; }
150
- .btn { background: var(--bg2); border: 1px solid var(--line); border-radius: 8px; padding: 5px 10px; font-size: 12.5px; color: var(--muted); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
151
- .btn:hover { color: var(--text); border-color: var(--line-strong); }
162
+ /* 0.9.7: a secondary button reads as a button on any surface (page, card, the
163
+ tinted banners): solid text, its own fill and a visible edge. Only a real
164
+ disabled state (disabled or aria-disabled) is dimmed, flat and dashed. */
165
+ .btn { background: var(--btn-bg); border: 1px solid var(--btn-line); border-radius: 8px; padding: 5px 10px; font-size: 12.5px; font-weight: 500; color: var(--text2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; box-shadow: var(--btn-shadow); transition: background-color .12s, border-color .12s, color .12s; }
166
+ .btn:hover { background: var(--btn-hover); border-color: var(--btn-line-hover); color: var(--text); }
167
+ .btn:active { background: var(--btn-active); box-shadow: none; }
168
+ .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
152
169
  .btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; }
153
170
  .btn.primary:hover { filter: brightness(1.08); color: var(--on-accent); }
154
- .btn:disabled { opacity: .55; cursor: progress; }
171
+ .btn.primary:active { filter: brightness(.94); }
172
+ .btn:disabled, .btn[aria-disabled="true"], .btn:disabled:hover, .btn[aria-disabled="true"]:hover, .btn:disabled:active, .btn[aria-disabled="true"]:active { opacity: .5; cursor: not-allowed; box-shadow: none; filter: none; border-style: dashed; background: var(--btn-bg); border-color: var(--btn-line-hover); color: var(--muted); }
173
+ @media (prefers-reduced-motion: reduce) { .btn { transition: none; } }
155
174
 
156
175
  .empty { border: 1px dashed var(--line); border-radius: var(--radius); padding: 38px; text-align: center; color: var(--muted); }
157
176
  .empty code { background: var(--bg2); padding: 2px 6px; border-radius: 6px; }
@@ -488,7 +507,6 @@ h2 .count { color: var(--faint); font-weight: 500; }
488
507
  .btn.allow:hover { filter: brightness(1.08); }
489
508
  .btn.deny { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); }
490
509
  .btn.deny:hover { background: var(--tint-red); }
491
- .btn[disabled] { opacity: .5; cursor: not-allowed; }
492
510
  .deny-form { display: flex; gap: 6px; flex-wrap: wrap; }
493
511
  .deny-form[hidden] { display: none; }
494
512
  .deny-form input, .need .qother, .reply-text { flex: 1 1 220px; min-width: 0; background: var(--field); border: 1px solid var(--line); border-radius: 8px; color: var(--text); padding: 7px 9px; font: inherit; font-size: 13px; }
@@ -635,7 +653,8 @@ html { background: var(--bg); }
635
653
  .banner.restore .rs-m { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
636
654
  .banner.restore .rs-m.err { color: var(--red); }
637
655
  .banner.restore .rs-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
638
- .banner.restore .rs-note { color: var(--muted); font-size: 12px; }
656
+ .banner.restore .rs-note { color: var(--text2); font-size: 12.5px; flex: 1 1 280px; min-width: 0; }
657
+ @media (pointer: coarse), (max-width: 600px) { .banner.restore .rs-actions .btn { min-height: 44px; } }
639
658
  .banner .rs-cmds { margin: 8px 0 0; padding: 8px 10px; background: var(--bg2); border: 1px solid var(--line); border-radius: 8px; font: 12px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text2); }
640
659
  .banner.restore .rs-result { margin: 10px 0 0; }
641
660
 
@@ -1931,3 +1950,68 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
1931
1950
  .board-search .set-search input, .board-search input { height: 44px; }
1932
1951
  .tx-board-link { min-height: 44px; }
1933
1952
  }
1953
+
1954
+ /* ------------------------------------------------ first-run setup wizard (0.9.7, ui/wizard.js) */
1955
+ .wz { max-width: 860px; margin: 8px auto 40px; }
1956
+ .wz .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
1957
+ .wz-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
1958
+ .wz-h1 { font-size: 22px; line-height: 1.25; margin: 0; font-weight: 700; color: var(--text); }
1959
+ .wz-count { color: var(--muted); font-size: 13px; margin-top: 2px; }
1960
+ .wz-bar { list-style: none; margin: 0 0 14px; padding: 0; display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }
1961
+ .wz-bar::-webkit-scrollbar { display: none; }
1962
+ .wz-bar li { flex: 1 0 auto; }
1963
+ .wz-bar button { width: 100%; display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--muted); font: inherit; font-size: 13px; cursor: pointer; white-space: nowrap; min-height: 40px; }
1964
+ .wz-bar button:hover { border-color: var(--line-strong); color: var(--text); }
1965
+ .wz-num { flex: none; width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; background: var(--bg2); color: var(--muted); border: 1px solid var(--line); }
1966
+ .wz-bar li.done .wz-num { background: var(--tint-green); color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, var(--line)); }
1967
+ .wz-bar li.on button { border-color: var(--accent); color: var(--text); background: var(--tint-accent); }
1968
+ .wz-bar li.on .wz-num { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
1969
+ .wz-card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--card-shadow); padding: 18px; outline: none; }
1970
+ .wz-card:focus-visible { box-shadow: 0 0 0 2px var(--focus); }
1971
+ .wz .wz-step { margin: 0 0 12px; }
1972
+ .wz-status { display: flex; gap: 12px; align-items: flex-start; }
1973
+ .wz-mark { flex: none; width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; background: var(--tint-gray); color: var(--muted); }
1974
+ .wz-status.s-done .wz-mark, .wz-sumlist .wz-done .wz-mark { background: var(--tint-green); color: var(--green); }
1975
+ .wz-status.s-todo .wz-mark, .wz-sumlist .wz-todo .wz-mark { background: var(--tint-yellow); color: var(--yellow); }
1976
+ .wz-title { font-size: 16px; font-weight: 600; color: var(--text); line-height: 1.35; }
1977
+ .wz-detail { color: var(--text2); margin-top: 3px; }
1978
+ .wz-p { color: var(--text2); margin: 12px 0 0; }
1979
+ .wz-p a, .wz-detail a { color: var(--accent); }
1980
+ .wz-hint { color: var(--muted); font-size: 12.5px; }
1981
+ .wz-list { list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 6px; }
1982
+ .wz-list li { display: flex; align-items: center; gap: 8px; color: var(--text2); flex-wrap: wrap; }
1983
+ .wz-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--gray); flex: none; }
1984
+ .wz-dot.up { background: var(--green); } .wz-dot.down { background: var(--red); }
1985
+ .wz-chip { font-size: 11.5px; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: var(--tint-gray); color: var(--muted); }
1986
+ .wz-chip.on { background: var(--tint-green); color: var(--green); }
1987
+ .wz-actions { display: flex; align-items: center; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
1988
+ .wz-result { margin-top: 12px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); display: flex; flex-direction: column; gap: 4px; color: var(--text2); }
1989
+ .wz-result.ok { border-color: color-mix(in srgb, var(--green) 40%, var(--line)); }
1990
+ .wz-result.bad { border-color: color-mix(in srgb, var(--red) 45%, var(--line)); color: var(--text); }
1991
+ .wz-fix { margin-top: 14px; padding: 10px 12px; border-radius: 10px; background: var(--bg2); border: 1px solid var(--line); }
1992
+ .wz-fix-what { font-size: 13px; color: var(--text2); margin-bottom: 6px; }
1993
+ .wz-cmdrow { display: flex; align-items: center; gap: 8px; }
1994
+ .wz-cmd { flex: 1; min-width: 0; overflow-wrap: anywhere; color: var(--text); font-size: 13px; }
1995
+ .wz-sumlist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
1996
+ .wz-sumlist li { display: flex; gap: 10px; align-items: flex-start; color: var(--text2); border: 0; padding: 0; }
1997
+ .wz-sumlist b { color: var(--text); margin-right: 4px; }
1998
+ .wz-h3 { font-size: 15px; margin: 18px 0 0; color: var(--text); }
1999
+ .wz-allset { font-weight: 600; color: var(--green); }
2000
+ .wz-nav { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 14px; }
2001
+ .wz-nav-r { display: flex; gap: 8px; }
2002
+ .wz .btn { font-size: 14px; padding: 8px 14px; min-height: 38px; }
2003
+ .wz-remote { margin-top: 10px; }
2004
+ @media (pointer: coarse), (max-width: 600px) {
2005
+ .wz .btn, .wz-bar button { min-height: 44px; }
2006
+ .wz-copy { min-width: 64px; justify-content: center; }
2007
+ }
2008
+ @media (max-width: 600px) {
2009
+ .wz { margin-top: 0; }
2010
+ .wz-card { padding: 14px; }
2011
+ .wz-bar li { flex: none; }
2012
+ .wz-bar .wz-lbl { display: none; }
2013
+ .wz-bar li.on .wz-lbl { display: inline; }
2014
+ .wz-cmdrow { flex-direction: column; align-items: flex-start; }
2015
+ .wz-cmd { width: 100%; }
2016
+ .wz-nav .btn { padding: 8px 16px; }
2017
+ }
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 } 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')
@@ -205,7 +207,7 @@ function banners() {
205
207
  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
208
  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
209
  (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>'
210
+ '<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
211
  }
210
212
  out += syncBanners(h)
211
213
  const mod = h.mod
@@ -310,7 +312,7 @@ function staleNote(machine) {
310
312
  function updateLine() {
311
313
  const h = state.health || {}
312
314
  const u = h.update || {}
313
- if (u.available) return 'v' + esc(u.remoteVersion || '?') + ' is available: run <code>leftoff upgrade</code>.'
315
+ if (u.available) return 'v' + esc(u.remoteVersion || '?') + ' is available: run <code>' + esc(u.upgradeCommand || 'leftoff upgrade') + '</code>.'
314
316
  if (u.error) return 'Last check couldn’t reach the repo (' + esc(u.error) + ').'
315
317
  if (u.checkedAt) return 'Up to date (v' + esc(h.version) + '), checked ' + esc(ago(u.checkedAt)) + '.'
316
318
  return 'Not checked yet.'
@@ -1033,26 +1035,32 @@ function emptyFold(list) {
1033
1035
  '<div class="grid">' + list.map(card).join('') + '</div></details>'
1034
1036
  }
1035
1037
 
1036
- // Restore (v0.4.7): the sessions that were open before this machine restarted,
1038
+ // Restore (v0.4.7): the sessions that were open before this machine restarted
1039
+ // (0.9.7: or when the terminal quit),
1037
1040
  // with a checkbox each; Restore opens each in a new Ghostty tab, resumed by id.
1038
1041
  // Opening tabs runs on the Mac, so (like Go) only its own browser may press it.
1039
1042
  const restoreUi = { open: false, off: new Set(), result: null }
1040
1043
  function restoreDismissKey(r) { return 'lo.restore.dismissed:' + (r.restartAt || '') }
1041
1044
  function restorePicked() { const r = state.restore; return r ? r.sessions.filter((s) => !restoreUi.off.has(s.id)) : [] }
1045
+ // 0.9.7: this Mac's own board, or the hosted board open on it (the direct path)
1046
+ function restoreGate() {
1047
+ const lr = window.leftoffRelay, r = state.restore || {}
1048
+ 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 }) })
1050
+ }
1042
1051
  function restoreBanner() {
1043
1052
  const r = state.restore
1044
1053
  if (!r || !r.sessions || !r.sessions.length) return restoreUi.result ? restoreResult() : ''
1045
1054
  try { if (localStorage.getItem(restoreDismissKey(r)) === '1' && !restoreUi.open) return '' } catch {}
1046
1055
  const n = r.sessions.length
1047
- const what = r.restartKind === 'boot' ? (r.ghostty ? 'your Mac' : 'this machine') + ' restarted' : 'Leftoff restarted'
1048
1056
  const when = r.restartAt ? ' (' + clock(r.restartAt) + ')' : ''
1049
- const head = '<b>' + n + ' session' + (n === 1 ? ' was' : 's were') + ' open before ' + what + '</b>' + esc(when)
1057
+ const head = '<b>' + n + ' session' + (n === 1 ? ' was' : 's were') + ' open ' + esc(restoreWhen(r)) + '</b>' + esc(when)
1050
1058
  if (!restoreUi.open) {
1051
1059
  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
1060
  '<button type="button" class="btn primary" data-action="restore-open">' + icon('rotate-ccw') + 'Restore…</button>' +
1053
1061
  '<button type="button" class="btn x" data-action="restore-dismiss" aria-label="Not now" title="Not now">✕</button></div>'
1054
1062
  }
1055
- const local = state.viewerLocal !== false
1063
+ const gate = restoreGate()
1056
1064
  const picked = restorePicked().length
1057
1065
  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
1066
  '<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('')
@@ -1060,13 +1068,15 @@ function restoreBanner() {
1060
1068
  '<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
1061
1069
  '<div class="rs-list">' + rows + '</div>' +
1062
1070
  '<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>') +
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>' +
1065
1073
  '<button type="button" class="btn" data-action="restore-all">' + (picked === r.sessions.length ? 'Select none' : 'Select all') + '</button>' +
1066
1074
  '<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>') +
1075
+ (gate.ok ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
1068
1076
  '</div>' + (restoreUi.result ? restoreResult() : '') + '</div>'
1069
1077
  }
1078
+ // 0.9.7: the hosted board found (or lost) this Mac's direct path: Restore follows it
1079
+ if (typeof window !== 'undefined') window.addEventListener('leftoff-route', () => { const el = restoreUi.open && document.getElementById('restoreBanner'); if (el) el.outerHTML = restoreBanner() })
1070
1080
  function restoreResult() {
1071
1081
  const x = restoreUi.result
1072
1082
  if (!x) return ''
@@ -2407,6 +2417,7 @@ async function viewSettings() {
2407
2417
  updates: '<div class="section">About</div>' +
2408
2418
  '<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
2419
  '<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
2420
+ '<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a></div>' +
2410
2421
  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
2422
  '<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
2412
2423
  '<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
@@ -3148,6 +3159,7 @@ async function render() {
3148
3159
  else if (r.name === 'whats-new') await viewWhatsNew()
3149
3160
  else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
3150
3161
  else if (r.name === 'search') await viewSearch(r.q || '')
3162
+ else if (r.name === 'setup') await viewSetup()
3151
3163
  else await viewBoard()
3152
3164
  } catch (e) {
3153
3165
  if (e.code === 'login_required') return showLogin()
@@ -3155,6 +3167,18 @@ async function render() {
3155
3167
  }
3156
3168
  }
3157
3169
 
3170
+ // First-run setup wizard (0.9.7, ui/wizard.js). Live updates don't redraw it:
3171
+ // it keeps its own step and fetches its own state.
3172
+ async function viewSetup() {
3173
+ if ($app.querySelector('.wz')) return
3174
+ await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/' } })
3175
+ }
3176
+ // Opens #/setup by itself once, on a Mac's own board, when the board opens plain (no deep link).
3177
+ async function firstRunSetup() {
3178
+ if (location.hash.replace(/^#\/?/, '')) return
3179
+ try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) location.replace('#/setup') } catch {}
3180
+ }
3181
+
3158
3182
  // A remote browser (Tailscale) that hasn't signed in yet: the iPhone
3159
3183
  // home-screen app always lands here first (it has its own cookie jar), so the
3160
3184
  // typed code is the main way in; the QR link is the other.
@@ -3709,7 +3733,9 @@ document.addEventListener('click', async (ev) => {
3709
3733
  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
3734
  if (action === 'restore-go') {
3711
3735
  const ids = restorePicked().map((x) => x.id)
3712
- if (!ids.length) return
3736
+ if (!ids.length) { toast('Pick at least one'); return }
3737
+ const gate = restoreGate()
3738
+ if (!gate.ok) { toast(gate.hint.replace(/`/g, ''), 6000); return }
3713
3739
  btn.disabled = true; btn.textContent = 'Opening ' + ids.length + '…'
3714
3740
  const r = await api('POST', '/v1/restore', { ids }, { timeoutMs: 60000 })
3715
3741
  restoreUi.result = r.how === 'print' || r.failed ? r : null
@@ -4338,7 +4364,7 @@ let pending = null
4338
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))
4339
4365
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
4340
4366
  // 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) }
4367
+ 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
4368
  let stream = null
4343
4369
  function connect() {
4344
4370
  if (stream) { try { stream.close() } catch {} }
@@ -4388,7 +4414,7 @@ document.addEventListener('change', async (ev) => {
4388
4414
  if (ev.target.checked) restoreUi.off.delete(ev.target.value); else restoreUi.off.add(ev.target.value)
4389
4415
  const n = restorePicked().length
4390
4416
  const go = document.getElementById('restoreGo')
4391
- if (go) { go.disabled = !n; go.innerHTML = icon('rotate-ccw') + 'Restore ' + n }
4417
+ if (go) { if (n && restoreGate().ok) go.removeAttribute('aria-disabled'); else go.setAttribute('aria-disabled', 'true'); go.innerHTML = icon('rotate-ccw') + 'Restore ' + n }
4392
4418
  const all = document.querySelector('[data-action="restore-all"]')
4393
4419
  if (all) all.textContent = n === (state.restore ? state.restore.sessions.length : 0) ? 'Select none' : 'Select all'
4394
4420
  return
@@ -4455,7 +4481,7 @@ document.addEventListener('pointerdown', (ev) => {
4455
4481
  if (m && !m.contains(ev.target)) m.open = false
4456
4482
  })
4457
4483
  api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
4458
- refreshHealth().then(render)
4484
+ refreshHealth().then(render).then(firstRunSetup)
4459
4485
  connect()
4460
4486
 
4461
4487
  // 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
@@ -0,0 +1,40 @@
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
+ // -> { 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 } = {}) {
16
+ if (local && !hosted) return { ok: true, how: 'local', hint: null }
17
+ if (hosted && !phone && route === 'direct' && machine && routeMac === machine) return { ok: true, how: 'direct', hint: null }
18
+ const m = machine || 'the Mac'
19
+ 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
+ if (hosted) {
21
+ 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.' }
22
+ }
23
+ 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
+ }
25
+
26
+ // The hint as HTML: escaped, `commands` in <code>.
27
+ export function hintHtml(hint, esc) {
28
+ return String(hint || '').split('`').map((part, i) => (i % 2 ? '<code>' + esc(part) + '</code>' : esc(part))).join('')
29
+ }
30
+
31
+ // 0.9.7: when the restore card's sessions were open, from GET /v1/restore:
32
+ // restartKind 'quit' (the terminal quit while the Mac stayed up; restartTerminal
33
+ // names it when known), 'boot' or 'daemon'. short: the CLI's one-liners.
34
+ // -> "when Ghostty quit" | "before your Mac restarted" | "before Leftoff restarted"
35
+ export function restoreWhen(r = {}, { short = false } = {}) {
36
+ if (r.restartKind === 'quit') return 'when ' + (r.restartTerminal || 'the terminal') + ' quit'
37
+ if (short) return 'before the restart'
38
+ if (r.restartKind === 'boot') return 'before ' + (r.ghostty === false ? 'this machine' : 'your Mac') + ' restarted'
39
+ return 'before Leftoff restarted'
40
+ }