@leftoff/cli 0.9.1 → 0.9.4

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
@@ -89,6 +89,10 @@ None of this touches the board's data: everything under [What the relay can see]
89
89
 
90
90
  A relayed request is replayed against the Mac's own board from inside the daemon (proved with a secret that never leaves memory) and counts as a **remote device**, with the same rules as a phone over Tailscale: it can read the board; answering prompts and pairing new devices need Settings → Privacy & security → *Trust my paired devices* on that Mac (`remote.allowAnswers`; 403 `trust_off` while it's off); revoking a paired device works from any paired device, itself included, since it only cuts access; Go, moving the folder, Syncthing, turning the relay on or off and the trust switch itself are refused (this Mac's business, loopback only). The daemon still listens only on 127.0.0.1: the relay connection is outbound.
91
91
 
92
+ ### Direct when on the same Mac (0.9.3)
93
+
94
+ 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.
95
+
92
96
  ### Logging
93
97
 
94
98
  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.
@@ -55,6 +55,9 @@ Each command is one envelope. It's JSON and travels the same way over every tran
55
55
  | `ask.answer` | `askId`, `body` | `engine.answerAsk()`: the board's Allow / Deny / answer code path |
56
56
  | `session.reply` | `sessionId`, `text` | `engine.postReply()`: the board's reply box |
57
57
  | `session.go` | `sessionId` | `engine.jump()`: the board's Go |
58
+ | `session.checkpoint` | `sessionId`, `length?`, `userNote?`, `next?` | `engine.generateCheckpoint()`: Checkpoint now (0.9.2); returns the checkpoint, which the asking board lists at once |
59
+ | `session.patch` | `sessionId`, `title?`, `pinned?`, `archived?` | `engine.patchSession()`: rename, Pin, Archive (0.9.2) |
60
+ | `session.title.auto` | `sessionId` | `engine.regenerateTitle()`: Regenerate title (0.9.2) |
58
61
 
59
62
  Answers are recorded in the session's `answers.md` as *from studio's board (direct)* or *(through the relay)*.
60
63
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.1",
3
+ "version": "0.9.4",
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
@@ -1354,6 +1354,13 @@ export function parseCheckpointReply(text) {
1354
1354
  return { ...out, ...parseSections(rest) }
1355
1355
  }
1356
1356
 
1357
+ // 0.9.2: a checkpoint written with nothing to go on: no prompt and no reply were
1358
+ // found (the heuristic's placeholder lines). Never saved; old ones are set aside.
1359
+ export function isEmptyCheckpoint(cp) {
1360
+ return !!cp && /^No prompt captured yet\.?$/.test(String(cp.doing || '').trim()) && /^No reply captured yet\.?$/.test(String(cp.stoppedAt || '').trim())
1361
+ }
1362
+ export const NOTHING_TO_CHECKPOINT = 'Nothing to checkpoint yet — no messages found for this session'
1363
+
1357
1364
  // No model available: build a checkpoint from the last prompt and the last reply.
1358
1365
  // Informative / long add what can be read off the transcript without a model:
1359
1366
  // the files edited (with the tool), the failed tool calls, the questions asked.
@@ -14,7 +14,7 @@
14
14
  // each event is registered once per matcher.
15
15
  import {
16
16
  DEFAULT_CONFIG, DEFAULT_PORT, mergeConfig, resolveMachine, applyEvent, checkpointDue, buildCheckpointPrompt,
17
- parseCheckpointReply, heuristicCheckpoint, renderCheckpointMd, checkpointId, checkpointFileName,
17
+ parseCheckpointReply, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
18
18
  sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
19
19
  shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
@@ -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.1' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.4' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
@@ -652,6 +652,8 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
652
652
  parsed = heuristicCheckpoint(messages, record, { length: ctx.length })
653
653
  generator = { by: 'heuristic' }
654
654
  }
655
+ // 0.9.2: nothing to go on (no prompt, no reply): don't save a placeholder over the last real one
656
+ if (isEmptyCheckpoint(parsed)) { if (trigger === 'manual') $.ui.toast(NOTHING_TO_CHECKPOINT, { timeoutMs: 6000 }); return null }
655
657
  if (opts.next) parsed = { ...parsed, next: opts.next }
656
658
  const cp = {
657
659
  sessionId: st.sessionId, createdAt: nowIso(), trigger, machine: st.machine,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.1",
3
+ "version": "0.9.4",
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
@@ -1775,3 +1775,61 @@ html.dv-open .lo-menu { z-index: 62; }
1775
1775
  .pr-more-sess > summary { list-style: none; cursor: pointer; display: inline; }
1776
1776
  .pr-more-sess > summary::-webkit-details-marker { display: none; }
1777
1777
  .pr-more-sess[open] > summary { display: none; }
1778
+
1779
+ /* 0.9.2: the ⋯ menu's sub lines, its plain note (a Mac that can't be reached),
1780
+ the row of other checkpoint lengths, and the split "Checkpoint now" button. */
1781
+ .lo-act.has-sub { align-items: flex-start; white-space: normal; }
1782
+ .lo-act.has-sub .ic { margin-top: 2px; }
1783
+ .lo-act-txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
1784
+ .lo-act-sub { font-size: 12.5px; color: var(--muted); font-weight: 400; line-height: 1.35; }
1785
+ .lo-act-note { display: flex; align-items: flex-start; gap: 10px; padding: 8px 10px 10px; margin-bottom: 4px; border-bottom: 1px solid var(--line); color: var(--text2); font-size: 13.5px; line-height: 1.4; white-space: normal; }
1786
+ .lo-act-note .ic { width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--muted); }
1787
+ .lo-menu.sheet .lo-act-note { font-size: 15px; padding: 10px 10px 12px; }
1788
+ .lo-menu.sheet .lo-act-sub { font-size: 14px; }
1789
+ /* 0.9.4: a row with a second page (Checkpoint now ›), the iOS way: the row
1790
+ does its action, the › at its right edge (its own target, a hairline before
1791
+ it) slides the menu to the page; ‹ title goes back. Fixed width on a
1792
+ desktop so the page change only eases the height. */
1793
+ .lo-actmenu.has-pages { overflow-x: hidden; }
1794
+ .lo-menu.lo-actmenu.has-pages:not(.sheet) { width: min(312px, calc(100vw - 16px)); max-width: none; }
1795
+ .lo-act-split { display: flex; align-items: stretch; position: relative; }
1796
+ .lo-act-split .lo-act-main { flex: 1 1 auto; min-width: 0; }
1797
+ .lo-act-next { flex: none; display: flex; align-items: center; justify-content: center; width: 40px; margin-left: 2px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--muted); cursor: pointer; position: relative; }
1798
+ .lo-act-next::before { content: ""; position: absolute; left: -1px; top: 9px; bottom: 9px; width: 1px; background: var(--line); }
1799
+ .lo-act-next .ic { width: 16px; height: 16px; }
1800
+ .lo-act-next:hover, .lo-act-next:focus { outline: none; background: var(--bg2); color: var(--text); }
1801
+ [data-theme="dark"] .lo-act-next:hover, [data-theme="dark"] .lo-act-next:focus, :root:not([data-theme="light"]) .lo-act-next:hover, :root:not([data-theme="light"]) .lo-act-next:focus { background: var(--line); }
1802
+ .lo-act-next:focus-visible { box-shadow: inset 0 0 0 2px var(--focus); }
1803
+ .lo-act-split:hover .lo-act-next::before, .lo-act-split:focus-within .lo-act-next::before { opacity: 0; }
1804
+ .lo-act-back { color: var(--text); font-weight: 600; border-bottom: 1px solid var(--line); border-radius: 8px 8px 0 0; margin-bottom: 4px; }
1805
+ .lo-act-back .ic { color: var(--accent); }
1806
+ .lo-act-page .lo-act.has-sub { padding-top: 9px; padding-bottom: 9px; }
1807
+ .lo-act-page .lo-act.on { font-weight: 600; }
1808
+ .lo-act .lo-act-check { color: var(--accent); }
1809
+ .lo-act .lo-act-sub { white-space: normal; }
1810
+ .lo-vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
1811
+ .lo-menu.lo-act-sizing { transition: height .2s cubic-bezier(.2, .8, .2, 1), top .2s cubic-bezier(.2, .8, .2, 1); overflow-y: hidden; }
1812
+ @keyframes lo-page-fwd { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: none; } }
1813
+ @keyframes lo-page-back { from { opacity: 0; transform: translateX(-18px); } to { opacity: 1; transform: none; } }
1814
+ .lo-act-page.lo-in-fwd { animation: lo-page-fwd .2s cubic-bezier(.2, .8, .2, 1); }
1815
+ .lo-act-page.lo-in-back { animation: lo-page-back .2s cubic-bezier(.2, .8, .2, 1); }
1816
+ .lo-menu.sheet .lo-act-next { width: 52px; min-height: 44px; }
1817
+ .lo-menu.sheet .lo-act-next .ic { width: 20px; height: 20px; }
1818
+ .lo-menu.sheet .lo-act-next::before { top: 12px; bottom: 12px; }
1819
+ .lo-menu.sheet .lo-act-page .lo-act.has-sub { padding-top: 12px; padding-bottom: 12px; }
1820
+ /* a phone keeps :hover on the last row tapped; only focus highlights there */
1821
+ @media (hover: none) { :root .lo-act:hover:not(:focus), :root .lo-act-next:hover:not(:focus) { background: none; } }
1822
+ @media (prefers-reduced-motion: reduce) { .lo-act-page.lo-in-fwd, .lo-act-page.lo-in-back { animation: none; } .lo-menu.lo-act-sizing { transition: none; } }
1823
+ .split { display: inline-flex; align-items: stretch; }
1824
+ .split .btn.split-main { border-top-right-radius: 0; border-bottom-right-radius: 0; }
1825
+ .split .btn.split-more { border-top-left-radius: 0; border-bottom-left-radius: 0; padding: 0 8px; margin-left: 1px; display: inline-flex; align-items: center; }
1826
+ .split .btn.split-more .ic { width: 15px; height: 15px; margin: 0; }
1827
+ @media (pointer: coarse) { .split .btn.split-more { min-width: 40px; justify-content: center; } }
1828
+ .more-wrap { display: inline-flex; }
1829
+ /* 0.9.2: the popover's Theme control never wraps: it gets its own row under
1830
+ the label, three equal segments, whatever the popover's width. */
1831
+ .statuspop .sp-theme { flex-wrap: wrap; row-gap: 6px; }
1832
+ .statuspop .sp-theme .sp-k { width: 100%; }
1833
+ .statuspop .sp-theme .theme { flex: 1 1 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); flex-wrap: nowrap; }
1834
+ .statuspop .sp-theme .theme button { justify-content: center; white-space: nowrap; min-width: 0; height: 30px; padding: 0 6px; }
1835
+ @media (pointer: coarse) { .statuspop .sp-theme .theme button { height: 36px; font-size: 13px; } }
package/ui/app.js CHANGED
@@ -370,11 +370,12 @@ function goButton(s) {
370
370
  // Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
371
371
  if (!s.isLocal) {
372
372
  if (!j.peer) return ''
373
- const label = 'Go on ' + s.machine + ' ↗'
374
- if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled title="Go only works from a Mac’s own browser">' + esc(label) + '</button>'
375
- if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled title="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
373
+ // 0.9.2: "Go ↗" like a local card (a phone cut "Go on <machine>" short); the machine is in its title and label
374
+ const label = 'Go ↗', on = 'Go on ' + s.machine
375
+ if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(on + ': Go only works from a Mac’s own browser') + '">' + esc(label) + '</button>'
376
+ if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
376
377
  const st = goState.get(s.id)
377
- return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" title="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
378
+ return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '" title="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
378
379
  }
379
380
  if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled title="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
380
381
  if (!j.supported) return '<button type="button" class="btn go" disabled title="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
@@ -419,35 +420,108 @@ function copyResumeIcon(s) {
419
420
  return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="Copy resume command">' + icon('copy') + '</button>'
420
421
  }
421
422
  // items: [{ action, label, icon, title, attrs }]
422
- function moreMenu(s, items) {
423
- items = items.concat([projectMenuItem(s)]).filter(Boolean) // 0.9.0: Add to project… on every card with a repo
423
+ // 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
424
+ // 0.9.4: or { …item, more: { title, items } }: the row does its action, and a ›
425
+ // at its right edge opens a second page of items (e.g. the checkpoint lengths,
426
+ // each with { sub, on }).
427
+ function moreMenu(s, items, { project = true, title = '' } = {}) {
428
+ items = items.concat([project ? projectMenuItem(s) : null]).filter(Boolean) // 0.9.0: Add to project… on every card with a repo
424
429
  if (!items.length) return ''
425
- return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '" aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
426
- '<template class="more-items">' + items.map((it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
427
- Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + '>' +
428
- (it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>').join('') + '</template>'
430
+ const btn = (it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
431
+ Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + (it.sub ? ' data-sub="' + esc(it.sub) + '"' : '') + (it.on ? ' data-on="' + esc(it.onLabel || 'on') + '"' : '') + '>' +
432
+ (it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>'
433
+ return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '"' + (title ? ' data-menu-title="' + esc(title) + '"' : '') + ' aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
434
+ '<template class="more-items">' + items.map((it) => it.note ? '<div class="more-note">' + (it.icon ? icon(it.icon) : '') + esc(it.note) + '</div>'
435
+ : it.more ? '<div class="more-split" data-more-title="' + esc(it.more.title) + '">' + btn(it) + '<div class="more-sub">' + it.more.items.map(btn).join('') + '</div></div>' : btn(it)).join('') + '</template>'
429
436
  }
430
437
  function openCardMenu(btn) {
431
438
  const tpl = btn.parentElement && btn.parentElement.querySelector('template.more-items')
432
439
  if (!tpl) return
433
440
  const card = btn.closest('.card')
434
- const title = card && card.querySelector('.title a') ? card.querySelector('.title a').textContent.trim() : ''
435
- const items = [...tpl.content.querySelectorAll('button')].map((b) => {
441
+ const title = btn.dataset.menuTitle || (card && card.querySelector('.title a') ? card.querySelector('.title a').textContent.trim() : '')
442
+ const toItem = (b) => {
436
443
  const svg = b.querySelector('svg')
437
444
  const attrs = {}
438
- for (const a of b.attributes) if (a.name.startsWith('data-') || a.name === 'title') attrs[a.name] = a.value
439
- return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs }
440
- })
445
+ for (const a of b.attributes) if ((a.name.startsWith('data-') && a.name !== 'data-sub' && a.name !== 'data-on') || a.name === 'title') attrs[a.name] = a.value
446
+ return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs, ...(b.dataset.sub ? { sub: b.dataset.sub } : {}), ...(b.dataset.on ? { on: true, onLabel: b.dataset.on } : {}) }
447
+ }
448
+ const items = [...tpl.content.children].map((el) => {
449
+ if (el.classList.contains('more-note')) { const svg = el.querySelector('svg'); return { note: el.textContent.trim(), icon: svg ? svg.outerHTML : '' } }
450
+ if (el.classList.contains('more-split')) return { ...toItem(el.querySelector(':scope > button')), more: { title: el.dataset.moreTitle || '', items: [...el.querySelectorAll('.more-sub > button')].map(toItem) } }
451
+ return el.tagName === 'BUTTON' ? toItem(el) : null
452
+ }).filter(Boolean)
441
453
  lastMenuAnchor = btn
442
454
  openActionMenu(btn, items, { title })
443
455
  }
444
- const sessionMenuItems = (s) => s.isLocal ? [
445
- { action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus' },
456
+ // 0.9.2: what this board can do with a session: 'local'; 'peer', through the
457
+ // Mac it lives on (the same path as answering and Go); 'away', when that Mac
458
+ // can't be reached from here. Agent records are read-only (null).
459
+ function reachOf(s) {
460
+ if (!s || s.agent || /^agent:/.test(String(s.id || ''))) return null
461
+ if (s.isLocal) return 'local'
462
+ const p = s.peer || {}
463
+ return p.ok || p.code === 'checking' ? 'peer' : 'away'
464
+ }
465
+ // The length "Checkpoint now" writes: yours from Settings → Summaries.
466
+ function defaultLength() { const k = state.cfg && state.cfg.summary && state.cfg.summary.length; return LENGTH_CHOICES.some((o) => o.key === k) ? k : 'informative' }
467
+ const lengthLabel = (k) => (LENGTH_CHOICES.find((o) => o.key === k) || LENGTH_CHOICES[1]).label
468
+ // 0.9.4: one row, "Checkpoint now" at your length; its › opens Short /
469
+ // Informative / Long, each with its line, your default ticked.
470
+ function checkpointItems() {
471
+ const def = defaultLength()
472
+ return [
473
+ { action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus', title: 'Checkpoint now · ' + lengthLabel(def), attrs: { 'data-length': def },
474
+ more: { title: 'Checkpoint length', items: LENGTH_CHOICES.map((o) => ({ action: 'checkpoint', label: o.label, sub: o.blurb, on: o.key === def, onLabel: 'your default', attrs: { 'data-length': o.key } })) } },
475
+ ]
476
+ }
477
+ // The ⋯ menu when the Mac a session lives on can't be reached: why, in plain
478
+ // words, and what you can do (switch the board to that Mac, or check again).
479
+ function awayNote(s) {
480
+ const m = s.machine || 'another Mac', c = s.peer && s.peer.code
481
+ return c === 'not_allowed' ? 'Lives on ' + m + ', which doesn’t take actions from other Macs (Settings → Privacy & security on ' + m + ')'
482
+ : c === 'key_changed' ? 'Lives on ' + m + ', whose key changed; confirm it in Settings → Privacy & security'
483
+ : c === 'no_key' ? 'Lives on ' + m + ', which needs a newer Leftoff for this'
484
+ : c === 'off' ? 'Lives on ' + m + '; actions on other Macs are off here'
485
+ : asleepOnRelay(m) ? 'Lives on ' + m + ', which is asleep right now'
486
+ : 'Lives on ' + m + ', which isn’t reachable right now'
487
+ }
488
+ function asleepOnRelay(m) { const st = window.leftoffRelay && state.relayStatus; const x = st && (st.macs || []).find((y) => y.machine === m); return !!(x && !x.online) }
489
+ function awayMenuItems(s) {
490
+ const m = s.machine
491
+ const canSwitch = !!window.leftoffRelay && m && m !== window.leftoffRelay.mac && !asleepOnRelay(m)
492
+ return [
493
+ { note: awayNote(s), icon: 'wifi-off' },
494
+ canSwitch ? { action: 'relay-mac', label: 'Switch to ' + m + '’s board', icon: 'monitor', attrs: { 'data-mac': m } } : null,
495
+ { action: 'peer-recheck', label: 'Check again', icon: 'refresh-cw', attrs: { 'data-machine': m || '' } },
496
+ ].filter(Boolean)
497
+ }
498
+ const sessionMenuItems = (s) => { const r = reachOf(s); return r === 'local' || r === 'peer' ? [
499
+ ...checkpointItems(),
446
500
  { action: 'pin', label: s.pinned ? 'Unpin' : 'Pin', icon: s.pinned ? 'pin-off' : 'pin' },
447
501
  { action: 'title-auto', label: 'Regenerate title', icon: 'refresh-cw', title: 'A new title from the latest messages (like /cp-name --auto)' },
448
502
  { action: 'archive', label: 'Archive', icon: 'archive' },
449
- ] : []
450
- const roNote = (t) => '<span class="ro" title="' + esc(t) + '">' + esc(t) + '</span>'
503
+ ] : r === 'away' ? awayMenuItems(s) : [] }
504
+ // 0.9.2: "Checkpoint now" on a session's page: the main part writes your
505
+ // length; the chevron opens Short / Informative / Long (our menu, a sheet on a phone).
506
+ function checkpointSplit(s) {
507
+ const def = defaultLength()
508
+ return '<span class="split" role="group" aria-label="Checkpoint now">' +
509
+ '<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '">Checkpoint now</button>' +
510
+ '<button type="button" class="btn primary split-more" data-action="cp-lengths" data-id="' + esc(s.id) + '" aria-label="Checkpoint length" title="Choose the length" aria-haspopup="menu" aria-expanded="false">' + icon('chevron-down') + '</button></span>'
511
+ }
512
+ function openLengthMenu(btn, id) {
513
+ const def = defaultLength()
514
+ lastMenuAnchor = btn
515
+ openActionMenu(btn, LENGTH_CHOICES.map((o) => ({ label: o.label + (o.key === def ? ' · your default' : ''), sub: o.blurb, icon: o.key === def ? icon('check') : icon('bookmark-plus'), cls: o.key === def ? 'on' : '',
516
+ attrs: { 'data-action': 'checkpoint', 'data-id': id, 'data-length': o.key } })), { title: 'Checkpoint now' })
517
+ }
518
+ const roNote = (t, tip) => '<span class="ro" title="' + esc(tip || t) + '">' + esc(t) + '</span>'
519
+ // 0.9.2: a short line on another Mac's card (the full story is its title and the ⋯ menu)
520
+ function remoteNote(s) {
521
+ if (reachOf(s) === 'peer') return roNote('via ' + s.machine, 'Lives on ' + s.machine + ' · checkpoints, answering and Go work through it')
522
+ if (/class="go-off"/.test(goButton(s))) return '' // the grey Go already says why
523
+ return roNote(s.machine + ' unreachable', awayNote(s) + ' · read-only here')
524
+ }
451
525
 
452
526
  function card(s) {
453
527
  if (s.agent) return agentCard(s)
@@ -465,7 +539,7 @@ function card(s) {
465
539
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
466
540
  '<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
467
541
  copyResumeIcon(s) +
468
- (s.isLocal ? moreMenu(s, sessionMenuItems(s)) : roNote(s.peer && s.peer.ok ? 'lives on ' + s.machine + ' · answering and Go work through it' : 'read-only here · lives on ' + s.machine)) +
542
+ (s.isLocal ? '' : remoteNote(s)) + moreMenu(s, sessionMenuItems(s)) +
469
543
  '</div></article>'
470
544
  }
471
545
 
@@ -1102,11 +1176,11 @@ async function viewSession(id) {
1102
1176
  const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
1103
1177
  let html = '<a class="back" href="#/">← All sessions</a>'
1104
1178
  // 0.8.9: rename in place: tap the title or the pencil (this Mac's sessions; like /cp-name, your name wins over automatic ones)
1105
- const canRename = s.isLocal !== false && !s.agent && !/^agent:/.test(String(s.id || ''))
1179
+ const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
1106
1180
  html += '<div class="pagehead"><div>' + (canRename
1107
1181
  ? '<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>'
1108
1182
  : '<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>' +
1109
- '<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>' + (s.isLocal ? '<button type="button" class="btn primary" data-action="checkpoint" data-id="' + esc(s.id) + '">Checkpoint now</button>' : '') + '</div></div>'
1183
+ '<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>'
1110
1184
  html += '<div class="facts">' +
1111
1185
  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)) +
1112
1186
  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) +
@@ -1135,8 +1209,10 @@ async function viewSession(id) {
1135
1209
  if (!s.checkpoints.length) html += '<div class="empty">No checkpoints yet.</div>'
1136
1210
  else html += '<div class="timeline">' + cpTimeline(s.checkpoints.filter((cp) => !cp.doubt), newestCp) + '</div>'
1137
1211
  // 0.9.0: checkpoints written while Leftoff had another session's branch for this one: kept, folded away
1138
- const doubted = s.checkpoints.filter((cp) => cp.doubt)
1212
+ const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty')
1139
1213
  if (doubted.length) html += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
1214
+ const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
1215
+ if (empties.length) html += '<details class="tl-doubt"><summary>' + empties.length + ' empty checkpoint' + (empties.length === 1 ? '' : 's') + ' set aside: no messages were found when ' + (empties.length === 1 ? 'it was' : 'they were') + ' written</summary><div class="timeline">' + cpTimeline(empties, null) + '</div></details>'
1140
1216
  html += answeredList(s)
1141
1217
  $app.innerHTML = html
1142
1218
  }
@@ -2960,19 +3036,27 @@ function paintStatus() {
2960
3036
  const pref = window.cpTheme ? window.cpTheme.pref() : 'system'
2961
3037
  const row = (k, v, cls) => '<div class="sp-row"><span class="sp-k">' + esc(k) + '</span><span class="sp-v' + (cls ? ' ' + cls : '') + '">' + v + '</span></div>'
2962
3038
  const syncWarn = !!settingsDots().sync
2963
- pop.innerHTML = '<div class="sp-head"><span class="dot ' + (h ? 'up' : 'down') + '"></span><b>' + esc(h ? h.machine : 'Leftoff') + '</b><span class="faint">' + (h ? (window.leftoffRelay ? 'through the relay' : 'this Mac') : 'offline') + '</span></div>' +
3039
+ pop.innerHTML = '<div class="sp-head"><span class="dot ' + (h ? 'up' : 'down') + '"></span><b>' + esc(h ? h.machine : 'Leftoff') + '</b><span class="faint sp-route">' + (h ? routeLabel() : 'offline') + '</span></div>' +
2964
3040
  (h ? row('Sessions', esc(h.sessionsOpen + ' open') + ' <span class="faint">of ' + esc(h.sessionsTotal) + '</span>') +
2965
3041
  (h.needsYou ? row('Needs you', '<a href="#/waiting">' + esc(h.needsYou) + ' waiting</a>') : '') +
2966
3042
  row('Summaries', esc(h.summarizer + (h.summarizerDetail && h.summarizerDetail.model ? ' · ' + h.summarizerDetail.model : '')) + ' <a href="#/settings/models">Change</a>') +
2967
3043
  relayRows(row) +
2968
3044
  ((h.machines || []).length > 1 && !window.leftoffRelay ? row('Macs', esc(h.machines.join(', '))) : '') +
2969
3045
  row('Sync', '<a href="#/settings/sync">' + esc(syncLine()) + '</a>', syncWarn ? 'warnish' : '') +
2970
- row('Version', esc('v' + h.version) + (h.update && h.update.available ? ' · <a href="#/settings/updates">v' + esc(h.update.remoteVersion || '?') + ' available</a>' : ' · <a href="#/whats-new">What’s new</a>'))
3046
+ row('Version', esc('v' + h.version) + (h.update && h.update.available ? ' · <a href="#/settings/updates">v' + esc(h.update.remoteVersion || '?') + ' available</a>' : '')) // 0.9.2: What’s new is the button below
2971
3047
  : '<p class="faint">The daemon isn’t answering.</p>') +
2972
3048
  '<div class="sp-row sp-theme"><span class="sp-k">Theme</span><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div></div>' +
2973
3049
  '<div class="sp-links"><a href="#/settings">' + icon('settings') + 'Settings</a><a href="#/whats-new">' + icon('sparkles') + 'What’s new</a></div>'
2974
3050
  placeStatus()
2975
3051
  }
3052
+ // 0.9.3: the quiet marker for how this board reaches its Mac. On your hosted
3053
+ // board: "direct · this Mac" (its own daemon over 127.0.0.1) or "via relay".
3054
+ function routeLabel() {
3055
+ const lr = window.leftoffRelay
3056
+ if (!lr) return 'this Mac'
3057
+ return lr.route === 'direct' ? 'direct · this Mac' : 'via relay'
3058
+ }
3059
+ window.addEventListener('leftoff-route', () => { if (statusOpen()) paintStatus() })
2976
3060
  // Through the relay (v0.6.0): which Mac this board comes from, and a switcher.
2977
3061
  function relayRows(row) {
2978
3062
  const lr = window.leftoffRelay
@@ -3055,7 +3139,11 @@ function paletteActions() {
3055
3139
  const sid = state.route && state.route.name === 's' ? state.route.arg : null
3056
3140
  const s = sid ? (state.sessions || []).find((x) => x.id === sid) : null
3057
3141
  if (s) {
3058
- if (s.isLocal && !s.agent) out.push({ action: 'checkpoint', id: s.id, title: 'Checkpoint now', sub: s.title || 'this session', icon: 'bookmark-plus' })
3142
+ if (reachOf(s) === 'local' || reachOf(s) === 'peer') { // 0.9.2: any length, and through the Mac it lives on
3143
+ const def = defaultLength()
3144
+ out.push({ action: 'checkpoint', id: s.id, length: def, title: 'Checkpoint now', sub: (s.title || 'this session') + ' · ' + lengthLabel(def), icon: 'bookmark-plus' })
3145
+ for (const o of LENGTH_CHOICES) if (o.key !== def) out.push({ action: 'checkpoint', id: s.id, length: o.key, title: 'Checkpoint now (' + o.label.toLowerCase() + ')', sub: o.blurb, icon: 'bookmark-plus' })
3146
+ }
3059
3147
  if (!s.agent) out.push({ action: 'resume', id: s.id, title: 'Copy resume command', sub: s.title || 'this session', icon: 'copy' })
3060
3148
  if (canProject(s)) out.push({ action: 'project-menu', key: s.repoKey, repo: s.repoLabel || '', title: s.repoProject ? 'Change project…' : 'Add to project…', sub: s.repoLabel || '', icon: 'folder' })
3061
3149
  }
@@ -3097,6 +3185,7 @@ function runPaletteItem(it) {
3097
3185
  if (it.id) b.dataset.id = it.id
3098
3186
  if (it.key) b.dataset.key = it.key
3099
3187
  if (it.repo) b.dataset.repo = it.repo
3188
+ if (it.length) b.dataset.length = it.length
3100
3189
  b.className = 'palette-proxy'
3101
3190
  const anchor = document.getElementById('searchBtn')
3102
3191
  if (anchor) { lastMenuAnchor = anchor }
@@ -3542,10 +3631,27 @@ document.addEventListener('click', async (ev) => {
3542
3631
  const cmd = s ? s.resumeCommand : (await api('GET', '/v1/sessions/' + encodeURIComponent(id))).resumeCommand
3543
3632
  if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
3544
3633
  } else if (action === 'checkpoint') {
3634
+ // 0.9.2: any length (data-length), and another Mac's session too: its Leftoff writes it
3635
+ const length = btn.dataset.length || null
3636
+ const away = s && !s.isLocal ? s.machine : null
3637
+ const was = btn.innerHTML
3545
3638
  btn.disabled = true; btn.textContent = 'Writing…'
3546
- if (!btn.isConnected || btn.classList.contains('palette-proxy')) toast('Writing a checkpoint…', 20000) // from the ⋯ menu (closed) or ⌘K
3547
- const cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true })
3548
- toast('Checkpoint saved · Next: ' + cp.next)
3639
+ if (!btn.isConnected || btn.classList.contains('palette-proxy') || !btn.closest('.split')) toast('Writing ' + (length ? lengthLabel(length).toLowerCase() + ' ' : 'a ') + 'checkpoint' + (away ? ' on ' + away : '') + '…', 40000) // from a menu (closed) or ⌘K
3640
+ let cp
3641
+ try { cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true, ...(length ? { length } : {}) }) }
3642
+ catch (e) { toast(e.message, 6000); if (btn.isConnected) { btn.disabled = false; btn.innerHTML = was } return }
3643
+ toast('Checkpoint saved' + (cp.peer ? ' on ' + cp.peer : '') + ' · Next: ' + cp.next)
3644
+ render()
3645
+ } else if (action === 'cp-lengths') {
3646
+ ev.preventDefault(); return openLengthMenu(btn, id)
3647
+ } else if (action === 'peer-recheck') {
3648
+ const m = btn.dataset.machine
3649
+ toast('Checking ' + (m || 'the other Mac') + '…', 8000)
3650
+ try {
3651
+ const o = await api('POST', '/v1/peers/check', {})
3652
+ const p = (o.peers || []).find((x) => x.name === m || x.machine === m)
3653
+ toast(p && p.usable && p.usable.ok ? m + ' is reachable again' : 'Still can’t reach ' + (m || 'it') + (p && p.usable && p.usable.reason ? ': ' + p.usable.reason : ''), 6000)
3654
+ } catch (e) { toast(e.message, 5000) }
3549
3655
  render()
3550
3656
  } else if (action === 'pin') {
3551
3657
  await api('PATCH', '/v1/sessions/' + encodeURIComponent(id), { pinned: !(s && s.pinned) }); render()
package/ui/direct.js ADDED
@@ -0,0 +1,114 @@
1
+ // 0.9.3: "direct when on the same Mac". The hosted board (<user>.leftoff.dev)
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.
4
+ //
5
+ // The request is the very envelope the relay would carry (ui/relay-crypto.js),
6
+ // posted to /v1/relay/direct (src/direct.js) instead of /_relay/api. So going
7
+ // direct needs no new trust: the daemon opens it with this browser's paired
8
+ // key, and the board only believes an answer that opens with the same key.
9
+ // Before it switches, the board checks: the daemon says it is on this account
10
+ // (user) and a Mac you didn't steer away from (picked), and a sealed round
11
+ // trip through it works (verify). A stranger's Leftoff on the port fails all
12
+ // of it, and the browser refuses it anyway (CORS is for this Mac's own name).
13
+ //
14
+ // When anything about the direct path fails (the daemon stopped, Safari
15
+ // refusing http://127.0.0.1 from an https page, a Local Network Access
16
+ // "Block"), the call goes again through the relay once, quietly, and the board
17
+ // stays on the relay until a later probe (every minute, or when the tab comes
18
+ // back) finds the direct path again. Plain logic, no DOM: unit tested.
19
+ import { LOCAL_PORT, isPhoneUa } from './viewer.js'
20
+
21
+ export const DIRECT_PATH = '/v1/relay/direct'
22
+ export const PROBE_EVERY_MS = 60000
23
+ export const FOCUS_GAP_MS = 10000
24
+ export function directUrl(port = LOCAL_PORT) { return 'http://127.0.0.1:' + port + DIRECT_PATH }
25
+
26
+ export class DirectError extends Error { constructor(code, message) { super(message || code); this.code = code } }
27
+
28
+ // What the local daemon said -> its machine when it may serve this board, else null.
29
+ export function directMatch(info, { user, picked } = {}) {
30
+ if (!info || info.app !== 'leftoff' || !info.direct || typeof info.machine !== 'string' || !info.machine) return null
31
+ if (!user || String(info.user || '').toLowerCase() !== String(user).toLowerCase()) return null
32
+ if (picked && picked !== info.machine) return null
33
+ return info.machine
34
+ }
35
+
36
+ // A sealed request's body may only be read once; a fallback needs it again.
37
+ export function aborted(signal) { return !!(signal && signal.aborted) }
38
+
39
+ export class DirectRoute {
40
+ constructor({ user, port = LOCAL_PORT, fetchImpl, now = () => Date.now(), ua = '', maxTouchPoints = 0, every = PROBE_EVERY_MS, focusGap = FOCUS_GAP_MS } = {}) {
41
+ this.user = user; this.port = port; this.fetchImpl = fetchImpl; this.now = now
42
+ // a phone or tablet never runs Leftoff: don't ask (and don't trip a permission prompt)
43
+ this.never = isPhoneUa(ua, { maxTouchPoints })
44
+ this.every = every; this.focusGap = focusGap
45
+ this.on = false; this.machine = null; this.checkedAt = 0; this.inflight = null; this.lastError = null; this.latencyMs = null
46
+ }
47
+ get url() { return directUrl(this.port) }
48
+
49
+ // -> the machine (direct confirmed, not yet switched on) or null. Never throws.
50
+ probe({ picked = null, verify = null, timeoutMs = 1500 } = {}) {
51
+ if (this.never) return Promise.resolve(null)
52
+ if (this.inflight) return this.inflight
53
+ this.inflight = (async () => {
54
+ this.checkedAt = this.now()
55
+ 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 }
65
+ this.latencyMs = Math.max(0, Math.round(this.now() - t0))
66
+ return m
67
+ })().finally(() => { this.inflight = null })
68
+ return this.inflight
69
+ }
70
+ use(machine) { this.on = true; this.machine = machine; this.lastError = null }
71
+ drop(err) { this.on = false; this.lastError = err ? (err.code || err.message || String(err)) : null; this.checkedAt = this.now() }
72
+ // time for another look? (focus: sooner, but not on every click into the window)
73
+ due({ focus = false } = {}) {
74
+ if (this.never || this.on || this.inflight) return false
75
+ return this.now() - this.checkedAt >= (focus ? this.focusGap : this.every)
76
+ }
77
+
78
+ // POST the sealed envelope; -> the sealed response envelope. Throws DirectError.
79
+ async send(env, { signal } = {}) {
80
+ let r
81
+ try {
82
+ r = await this.fetchImpl(this.url, { method: 'POST', mode: 'cors', credentials: 'omit', cache: 'no-store', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ env }), signal })
83
+ } catch (e) { if (aborted(signal)) throw e; throw new DirectError('unreachable', e && e.message) }
84
+ if (r.status !== 200) throw new DirectError('http_' + r.status)
85
+ let j
86
+ try { j = await r.json() } catch { throw new DirectError('bad_response') }
87
+ if (!j || !j.env) throw new DirectError('bad_response')
88
+ return j.env
89
+ }
90
+ }
91
+
92
+ // One call: direct when it's on, else (or when direct fails) the relay, once.
93
+ // A call the page itself aborted is not retried.
94
+ export async function directOrRelay({ route, direct, relay, signal, onDrop = () => {} }) {
95
+ if (route && route.on) {
96
+ try { return await direct() } catch (e) {
97
+ if (aborted(signal)) throw e
98
+ route.drop(e)
99
+ try { onDrop(e) } catch {}
100
+ }
101
+ }
102
+ return relay()
103
+ }
104
+
105
+ // A sealed response -> a fetch Response (204/304 carry no body).
106
+ export function toResponse(out) {
107
+ const nullBody = out.s === 204 || out.s === 205 || out.s === 304
108
+ return new Response(nullBody ? null : out.b, { status: out.s, headers: out.h })
109
+ }
110
+
111
+ function timeoutSignal(ms) {
112
+ if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) return AbortSignal.timeout(ms)
113
+ const c = new AbortController(); setTimeout(() => c.abort(), ms); return c.signal
114
+ }
package/ui/icons.js CHANGED
@@ -31,6 +31,9 @@ export const ICONS = {
31
31
  "shield": "<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\"/>",
32
32
  "info": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M12 16v-4\"/> <path d=\"M12 8h.01\"/>",
33
33
  "chevron-right": "<path d=\"m9 18 6-6-6-6\"/>",
34
+ "chevron-down": "<path d=\"m6 9 6 6 6-6\"/>",
35
+ "wifi-off": "<path d=\"M12 20h.01\"/> <path d=\"M8.5 16.429a5 5 0 0 1 7 0\"/> <path d=\"M5 12.859a10 10 0 0 1 5.17-2.69\"/> <path d=\"M19 12.859a10 10 0 0 0-2.007-1.523\"/> <path d=\"M2 8.82a15 15 0 0 1 4.177-2.643\"/> <path d=\"M22 8.82a15 15 0 0 0-11.288-3.764\"/> <path d=\"m2 2 20 20\"/>",
36
+ "monitor": "<rect width=\"20\" height=\"14\" x=\"2\" y=\"3\" rx=\"2\"/> <line x1=\"8\" x2=\"16\" y1=\"21\" y2=\"21\"/> <line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"21\"/>",
34
37
  "chevron-left": "<path d=\"m15 18-6-6 6-6\"/>",
35
38
  "mail": "<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/> <path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>",
36
39
  "search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>",