@leftoff/cli 0.9.8-beta.3 → 0.9.8-beta.5

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.8-beta.3",
3
+ "version": "0.9.8-beta.5",
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
@@ -299,9 +299,19 @@ export function resumeCommand(session) {
299
299
  const id = session.id || session.sessionId
300
300
  const cwd = resumeDir(session)
301
301
  const wt = resumeWorktreeName(session)
302
- return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + (isCodex(session) ? codexInvocation(id) : claudeInvocation(session, [...(wt ? ['--worktree', wt] : []), '--resume', id]))
302
+ const args = [...(wt ? ['--worktree', wt] : []), '--resume', id]
303
+ return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + (isCodex(session) ? codexInvocation(id) : wasLiveSession(session) ? liveInvocation(args) : claudeInvocation(session, args))
303
304
  }
304
305
 
306
+ // 0.9.8: a session started with `leftoff live` (it had Leftoff's Live channel)
307
+ // resumes as one, `leftoff live -- --resume <id>`, so it shows up Live again.
308
+ // Codex and Ollama sessions resume their own way (Live is Claude Code only).
309
+ export function wasLiveSession(session) {
310
+ if (!session || isCodex(session) || (session.backend && session.backend.kind === 'ollama')) return false
311
+ return !!(session.wasLive || (session.liveLink && session.liveLink.on))
312
+ }
313
+ export function liveInvocation(args = []) { return 'leftoff live --' + (args.length ? ' ' + args.map(shArg).join(' ') : '') }
314
+
305
315
  // 0.8.0: a Codex session (its rollout under ~/.codex/sessions) resumes with `codex resume <id>`.
306
316
  export function isCodex(session) { return !!session && session.codingAgent === 'codex' }
307
317
  export function codexInvocation(id) { return 'codex resume ' + shArg(id) }
@@ -547,7 +557,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
547
557
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
548
558
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
549
559
  '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', 'liveLink']
560
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive']
551
561
 
552
562
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
553
563
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -800,7 +810,7 @@ export function checkpointDue(s, now, cfg = DEFAULT_CONFIG) {
800
810
  export function isEmptySession(s, checkpointCount = 0) {
801
811
  if (!s) return true
802
812
  // 0.7.1: a prompt counts. A long first turn (a worktree bug fix) has no completed turn yet.
803
- return !(Number(s.turns) > 0) && !s.title && !s.firstPrompt && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
813
+ return !(Number(s.turns) > 0) && !(Number(s.transcriptTurns) > 0) && !s.title && !s.firstPrompt && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
804
814
  !(Number(s.historyTurns) > 0) && s.startSource !== 'resume'
805
815
  }
806
816
 
@@ -876,7 +886,7 @@ export function applyEvent(s, ev) {
876
886
  s.needsInput = false
877
887
  s.question = null
878
888
  s.lastNotification = null
879
- if (d.prompt && !s.firstPrompt) s.firstPrompt = clip(String(d.prompt).replace(/^\s*<channel\b[^>]*>([\s\S]*?)<\/channel>\s*$/, '$1').trim(), 300) // 0.9.8: a Live link message, without its wrapper
889
+ if (d.prompt && (!s.firstPrompt || /^\s*<channel\b/.test(s.firstPrompt))) s.firstPrompt = clip(String(d.prompt).replace(/^\s*<channel\b[^>]*>/, '').replace(/<\/channel>\s*$/, '').trim(), 300) || s.firstPrompt || null // 0.9.8: a Live link message, without its wrapper (even one the 300-character clip cut short)
880
890
  if (d.countTurn === true) s.turns = (s.turns || 0) + 1 // 0.8.1: Codex counts a turn at its prompt when no task_started came first
881
891
  if (s.capture === 'hooks') { s.inTurn = true; s.activeSince = s.activeSince || at }
882
892
  }
@@ -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.8-beta.3' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.8-beta.5' // 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.8-beta.3",
3
+ "version": "0.9.8-beta.5",
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
@@ -120,7 +120,7 @@ h2 .count { color: var(--faint); font-weight: 500; }
120
120
  .card:hover { border-color: var(--line-strong); }
121
121
  .card.needs_input { border-color: color-mix(in srgb, var(--red) 45%, transparent); box-shadow: 0 0 0 1px var(--tint-red) inset, var(--card-shadow); }
122
122
  .card .row1 { display: flex; align-items: flex-start; gap: 10px; }
123
- .card .title { font-size: 15px; font-weight: 600; line-height: 1.35; flex: 1; }
123
+ .card .title { font-size: 15px; font-weight: 600; line-height: 1.35; flex: 1; min-width: 0; overflow-wrap: break-word; } /* breaks at the <wbr> after a "/" and at spaces first; inside a word only when one word is wider than the card */
124
124
  .card .title a:hover { text-decoration: underline; text-decoration-color: var(--faint); }
125
125
  .s-title { display: flex; align-items: center; gap: 6px; min-width: 0; }
126
126
  .s-title h1 { min-width: 0; }
@@ -2114,6 +2114,9 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2114
2114
  .live-jump-n { font-weight: 500; color: var(--muted); }
2115
2115
  .live-empty { margin: 6px 0 10px; font-size: 13px; }
2116
2116
  .live-err { color: var(--red); font-size: 13px; }
2117
+ /* 0.9.8: another Mac's session whose Mac is offline */
2118
+ .owner-off { display: flex; gap: 10px; align-items: flex-start; margin: 12px 0; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--text); font-size: 14px; line-height: 1.45; }
2119
+ .owner-off .ic { flex: none; width: 18px; height: 18px; margin-top: 1px; color: var(--muted); }
2117
2120
  .live-row .live-text { min-height: 44px; }
2118
2121
  /* Chat on a live card: straight to the Live box */
2119
2122
  .btn.chat-btn { position: relative; color: var(--green); border-color: color-mix(in srgb, var(--green) 40%, var(--btn-line)); font-weight: 600; }
@@ -2260,3 +2263,46 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2260
2263
  @media (pointer: coarse) and (max-width: 819.98px) { html, body { overscroll-behavior-y: contain; } }
2261
2264
  @media (prefers-reduced-motion: reduce) { .ptr-ring { transform: none; } .ptr.busy .ptr-ring { animation: lo-pulse 1.2s ease-in-out infinite; } }
2262
2265
  @keyframes lo-pulse { 50% { opacity: .35; } }
2266
+
2267
+ /* 0.9.8: the session page's header. The title takes the room that's left and
2268
+ breaks at " · " and after "/" (titleHtml's <wbr>), never mid-word unless one
2269
+ word is wider than the page; the buttons are one group that drops under the
2270
+ title as a whole when it doesn't fit beside it. Every button: one line, 44px,
2271
+ an icon and a short label; the primary ones (Checkpoint, Resume) are tinted,
2272
+ not filled, so they don't shout over the rest. */
2273
+ .pagehead.s-head { flex-wrap: wrap; align-items: flex-start; column-gap: 20px; row-gap: 12px; }
2274
+ .pagehead.s-head > .s-head-main { flex: 1 1 28rem; min-width: 0; } /* under ~28rem beside the buttons, the buttons move down instead */
2275
+ .s-head h1 { overflow-wrap: break-word; line-height: 1.3; }
2276
+ .pagehead.s-head > .s-actions { margin-left: 0; flex: 0 1 auto; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; max-width: 100%; }
2277
+ .s-actions .btn { height: 44px; min-height: 44px; padding: 0 14px; gap: 7px; font-size: 13.5px; line-height: 1; white-space: nowrap; flex: none; justify-content: center; }
2278
+ .s-actions .btn .ic { width: 16px; height: 16px; flex: none; margin: 0; }
2279
+ .s-actions .btn.icon-btn { width: 44px; padding: 0; }
2280
+ .s-actions .more-wrap { display: inline-flex; }
2281
+ .s-actions .btn.primary { background: var(--tint-accent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--btn-line)); }
2282
+ .s-actions .btn.primary:hover { filter: none; background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); border-color: var(--accent); }
2283
+ .s-actions .btn.primary:active { filter: none; background: color-mix(in srgb, var(--accent) 22%, transparent); }
2284
+ .s-actions .split { flex: none; display: inline-flex; align-items: stretch; height: 44px; }
2285
+ .s-actions .split .btn { height: 44px; }
2286
+ .s-actions .split .btn.split-more { width: 38px; min-width: 38px; padding: 0; margin-left: 0; border-left: 1px solid color-mix(in srgb, var(--accent) 30%, var(--btn-line)); }
2287
+ .s-actions .split .btn.split-main { border-right-width: 0; }
2288
+ .s-actions .split.is-writing .btn:disabled { opacity: .85; border-style: solid; background: var(--tint-accent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, var(--btn-line)); }
2289
+ .s-actions .split.is-writing .spin { border-color: color-mix(in srgb, var(--accent) 30%, transparent); border-top-color: var(--accent); }
2290
+ @media (max-width: 640px) {
2291
+ /* a phone: the buttons are a row under the title that wraps, each part of a row sharing its width */
2292
+ .pagehead.s-head > .s-actions { flex: 1 1 100%; }
2293
+ .s-actions > .btn, .s-actions > .split { flex: 1 1 auto; }
2294
+ .s-actions .split .split-main { flex: 1 1 auto; }
2295
+ .s-actions > .more-wrap { flex: none; }
2296
+ }
2297
+
2298
+ /* 0.9.8: a card's row (Go, Chat, copy, ⋯) and Catch me up's row: one line each,
2299
+ all the same height (44px on touch). */
2300
+ .card > .actions-row > .btn, .card > .actions-row > .go-off > .btn, .cu-s .actions-row > .btn { white-space: nowrap; height: 30px; min-height: 30px; padding-top: 0; padding-bottom: 0; line-height: 28px; }
2301
+ .card > .actions-row > .btn.icon-btn { width: 32px; line-height: 1; }
2302
+ .card > .actions-row > .btn.chat-btn { min-width: 0; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; padding-top: 0; padding-bottom: 0; overflow: visible; }
2303
+ .cu-s .actions-row > .btn { display: inline-flex; align-items: center; }
2304
+ @media (pointer: coarse), (max-width: 600px) {
2305
+ .card > .actions-row > .btn, .card > .actions-row > .go-off > .btn, .cu-s .actions-row > .btn { height: 44px; min-height: 44px; line-height: 42px; }
2306
+ .card > .actions-row > .btn.icon-btn { width: 44px; }
2307
+ .card > .actions-row > .btn:not(.icon-btn) { min-width: 44px; }
2308
+ }
package/ui/app.js CHANGED
@@ -19,7 +19,7 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
19
19
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
20
20
  import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
21
21
  import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
22
- import { sessionTitle, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom } from './live-view.js' // 0.9.8 Live link polish
22
+ import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView } from './live-view.js' // 0.9.8 Live link polish
23
23
  import { statusView, elapsedText } from './live-status.js'
24
24
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
25
25
  const $app = document.getElementById('app')
@@ -113,8 +113,9 @@ async function copy(text) {
113
113
  // 0.8.7: the copy icon says it worked for a moment (a check, "Copied"), then goes back.
114
114
  function copiedMark(btn) {
115
115
  clearTimeout(btn._copied)
116
+ const tip = btn._tip || (btn._tip = btn.title || 'Copy resume command')
116
117
  btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.title = 'Copied'
117
- btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = 'Copy resume command' }, 1500)
118
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
118
119
  }
119
120
  const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
120
121
  // 0.9.8 Live link: the session's channel is listening (send it prompts from here)
@@ -184,11 +185,11 @@ function wtChips(s) {
184
185
  return h
185
186
  }
186
187
 
187
- function where(s) {
188
+ function where(s, { repo = true } = {}) {
188
189
  if (s.agent) return (s.project ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(s.repoLabel) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc(s.repoLabel + ' · show only this project') + '">' + esc(s.repoLabel) + '</button>' : '') + agentChip(s.agent)
189
190
  const branch = s.repo && s.repo.branch
190
191
  const label = s.repoLabel || s.repoName || 'no repo'
191
- return projChip(s) + '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
192
+ return projChip(s) + (repo ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' : '') + wtChips(s) +
192
193
  (multi() || !s.isLocal ? (() => { const sn = s.isLocal ? '' : staleNote(s.machine); return '<button type="button" class="chip machine' + (s.isLocal ? '' : ' remote') + (sn ? ' stale' : '') + '" title="' + (s.isLocal ? 'this machine' : sn ? 'showing data as of ' + esc(sn.replace(/^as of /, '')) + ': ' + esc(s.machine) + ' hasn’t synced since' : 'synced from another machine') + '" data-action="machine-emoji" data-machine="' + esc(s.machine) + '"><span class="memo">' + esc(mEmoji(s.machine)) + '</span> ' + esc(s.machine) + (sn ? ' · ' + esc(sn) : '') + '</button>' })() : '') + modelChip(s)
193
194
  }
194
195
 
@@ -387,9 +388,13 @@ function liveNote(s) {
387
388
  if (s.live === 'open' && s.liveVia === 'heartbeat' && !s.isLocal) return 'open (heartbeat)'
388
389
  return s.live === 'open' ? 'open' : ''
389
390
  }
390
- function goButton(s) {
391
+ // 0.9.8: { page: true } is the session page's header, where a Go that can't run
392
+ // is left out (its tooltip was the only place the reason showed, and the grey
393
+ // box read as broken); a card keeps the dimmed Go and its short why.
394
+ function goButton(s, { page = false } = {}) {
391
395
  if (s.live === 'closed') return ''
392
396
  const j = s.jump || {}
397
+ if (page && (state.viewerLocal === false || !j.supported)) return ''
393
398
  // Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
394
399
  if (!s.isLocal) {
395
400
  if (!j.peer) return ''
@@ -439,8 +444,12 @@ function agentCard(s) {
439
444
  // closed), a small copy icon for the resume command, and "⋯" for the rest. The
440
445
  // ⋯ menu's items ride in the card as an inert <template> (real buttons with the
441
446
  // same data-action as before), so the one click handler below runs them unchanged.
447
+ // What Resume runs: codex resume, claude --resume, or for a Live session (0.9.8)
448
+ // leftoff live -- --resume, so it comes back Live.
449
+ function resumeVerb(s) { return s.codingAgent === 'codex' ? 'codex resume' : s.resumeLive ? 'leftoff live -- --resume' : 'claude --resume' }
450
+ function resumeTip(s, lead) { return lead + ': ' + resumeVerb(s) + (s.resumeLive ? ' (it comes back Live)' : '') }
442
451
  function copyResumeIcon(s) {
443
- 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>'
452
+ return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + '</button>'
444
453
  }
445
454
  // items: [{ action, label, icon, title, attrs }]
446
455
  // 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
@@ -557,7 +566,7 @@ function checkpointSplit(s) {
557
566
  '<button type="button" class="btn primary split-more" disabled aria-disabled="true" aria-label="Checkpoint length">' + icon('chevron-down') + '</button></span>'
558
567
  }
559
568
  return '<span class="split" role="group" aria-label="Checkpoint now">' +
560
- '<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>' +
569
+ '<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)) + '" aria-label="Checkpoint now">' + icon('bookmark-plus') + 'Checkpoint</button>' +
561
570
  '<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>'
562
571
  }
563
572
  function openLengthMenu(btn, id) {
@@ -581,10 +590,10 @@ function card(s) {
581
590
  const nb = s.needsYou ? cardBadge(s.needsYou) : ''
582
591
  return '<article class="card ' + esc(s.status) + (s.live === 'closed' ? ' is-closed' : '') + (s.needsYou && s.needsYou.blocking ? ' needs-you' : '') + '" data-id="' + esc(s.id) + '" data-href="#/s/' + esc(encodeURIComponent(s.id)) + '">' +
583
592
  '<div class="row1"><div class="title">' + liveDot(s) +
584
- (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + esc(sessionTitle(s)) + '</a>' +
593
+ (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
585
594
  '<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
586
595
  '</div>' + statusPill(s) + liveBadge(s) + '</div>' +
587
- '<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' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
596
+ '<div class="meta">' + spaceChip(s) + where(s, { repo: !titleIsWhere(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' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
588
597
  (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
589
598
  cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
590
599
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
@@ -928,10 +937,10 @@ function resumeButton(s, viewer = {}) {
928
937
  const busy = closedUi.busy.has(s.id)
929
938
  if (s.isLocal) {
930
939
  if (!viewer.ghostty) return ''
931
- return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="Open it in a new Ghostty tab: ' + (s.codingAgent === 'codex' ? 'codex resume' : 'claude --resume') + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume') + '</button>'
940
+ return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc(resumeTip(s, 'Open it in a new Ghostty tab')) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume') + '</button>'
932
941
  }
933
942
  if (!(s.peer && s.peer.ok)) return '' // that Mac can't be reached from here: Copy resume
934
- return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc('Open it in a new Ghostty tab on ' + s.machine) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume on ' + esc(s.machine)) + '</button>'
943
+ return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc(resumeTip(s, 'Open it in a new Ghostty tab on ' + s.machine)) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume on ' + esc(s.machine)) + '</button>'
935
944
  }
936
945
  function closedCard(s, viewer) {
937
946
  const href = '#/s/' + encodeURIComponent(s.id)
@@ -1248,8 +1257,8 @@ async function viewAgentSession(s) {
1248
1257
  const name = esc(a.display || a.name)
1249
1258
  const hint = typeof a.replyHint === 'string' && a.replyHint.trim() ? a.replyHint.trim() : ''
1250
1259
  let html = '<a class="back" href="#/">← All sessions</a>'
1251
- html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + where(s) + ' <span class="faint">' + esc(s.inFeed === false ? 'no longer in the feed' : AGENT_STATUS[s.feedStatus] || s.feedStatus) + ' · updated ' + esc(ago(s.lastActivityAt)) + '</span></div></div>' +
1252
- '<div class="actions">' + agentReplyButton(a) + '</div></div>'
1260
+ html += '<div class="pagehead s-head"><div class="s-head-main"><h1>' + esc(s.title || 'Untitled') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + where(s) + ' <span class="faint">' + esc(s.inFeed === false ? 'no longer in the feed' : AGENT_STATUS[s.feedStatus] || s.feedStatus) + ' · updated ' + esc(ago(s.lastActivityAt)) + '</span></div></div>' +
1261
+ '<div class="actions s-actions">' + agentReplyButton(a) + '</div></div>'
1253
1262
  // v0.5.6: agent.url only links the name; the reply affordance and its
1254
1263
  // words come from the feed (agent.reply, agent.replyHint), or nothing.
1255
1264
  html += '<div class="banner info"><span>Read-only. This is ' + (home ? '<a href="' + esc(home) + '" target="_blank" rel="noopener noreferrer">' + name + '</a>' : name) + '’s work, from its feed' + (a.pullsHere ? ', pulled by this Mac' : a.puller ? ', pulled by ' + esc(a.puller) : '') + (a.lastSuccessAt ? ' (last pull ' + esc(ago(a.lastSuccessAt)) + ')' : '') + '.' + (hint ? ' ' + esc(hint.charAt(0).toUpperCase() + hint.slice(1)) + (/[.!?]$/.test(hint) ? '' : '.') : '') + '</span></div>'
@@ -1270,19 +1279,37 @@ async function viewAgentSession(s) {
1270
1279
  $app.innerHTML = banners() + html
1271
1280
  }
1272
1281
 
1282
+ // 0.9.8: another Mac's session whose Mac is offline says so plainly, once, near the top
1283
+ function ownerOffline(s) {
1284
+ if (!s || s.isLocal !== false || s.agent || !s.machine) return false
1285
+ const st = window.leftoffRelay && state.relayStatus
1286
+ const x = st && (st.macs || []).find((y) => y.machine === s.machine)
1287
+ if (x) return !x.online
1288
+ return !!(s.peer && s.peer.code === 'unreachable')
1289
+ }
1290
+ function ownerOfflineNote(s) {
1291
+ if (!ownerOffline(s)) return ''
1292
+ const m = esc(s.machine), sn = staleNote(s.machine)
1293
+ return '<p class="owner-off" role="status">' + icon('wifi-off') + '<span>This session lives on <b>' + m + '</b>, which is offline right now. You’re seeing what it last synced' + (sn ? ' (' + esc(sn) + ')' : '') + '. Live, its transcript and Find lists come back when ' + m + ' is.</span></p>'
1294
+ }
1273
1295
  async function viewSession(id) {
1274
1296
  if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
1275
1297
  const s = await api('GET', '/v1/sessions/' + encodeURIComponent(id))
1276
1298
  state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
1299
+ // 0.9.8 auto-switch host: on leftoff.dev, another Mac's session loads its own data from that Mac
1300
+ if (window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(s.id, s.isLocal === false && !s.agent ? s.machine : null)
1277
1301
  const branch = s.repo && s.repo.branch
1278
- const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
1302
+ const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects(),
1303
+ s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null])
1279
1304
  let html = '<a class="back" href="#/">← All sessions</a>'
1280
1305
  // 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)
1281
1306
  const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
1282
- html += '<div class="pagehead"><div>' + (canRename
1283
- ? '<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(sessionTitle(s)) + '</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>'
1284
- : '<h1>' + esc(sessionTitle(s)) + '</h1>') + '<div class="sub">' + statusPill(s) + 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 === 'prompt' ? 'named from the first prompt' : s.titleSource + ' title') : '') + '</div></div>' +
1285
- '<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>'
1307
+ html += '<div class="pagehead s-head"><div class="s-head-main">' + (canRename
1308
+ ? '<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">' + titleHtml(sessionTitle(s), esc) + '</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>'
1309
+ : '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1>') + '<div class="sub">' + statusPill(s) + 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 === 'prompt' ? 'named from the first prompt' : s.titleSource + ' title') : '') + '</div></div>' +
1310
+ // 0.9.8: one line each, 44px, an icon and a short label; the group drops under the title as a whole when it doesn't fit beside it
1311
+ '<div class="actions s-actions">' + goButton(s, { page: true }) + (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) + '" title="Open in Timeline" aria-label="Open in Timeline">' + icon('history') + 'Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + '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>'
1312
+ html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
1286
1313
  html += '<div class="facts">' +
1287
1314
  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)) +
1288
1315
  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) +
@@ -1387,8 +1414,16 @@ function liveAfterRender(sid) {
1387
1414
  liveJumpButton(r.thread, liveKeeper.unseen)
1388
1415
  if (ol && !ol.dataset.wired) {
1389
1416
  ol.dataset.wired = '1'
1390
- ol.addEventListener('scroll', () => { if (atBottom(ol)) { liveKeeper.seen(); liveJumpButton(r.thread, 0) } }, { passive: true })
1417
+ ol._stick = atBottom(ol)
1418
+ // the button follows the scroll: there whenever you're away from the bottom, gone once you're back
1419
+ ol.addEventListener('scroll', () => { ol._stick = atBottom(ol); liveJumpButton(r.thread, liveKeeper.unseen) }, { passive: true })
1420
+ // at the bottom, the thread stays there while an entry grows (a streamed reply, a code block, late fonts)
1421
+ if (typeof ResizeObserver === 'function') {
1422
+ ol._ro = new ResizeObserver(() => { if (ol._stick && !atBottom(ol, 2)) ol.scrollTop = ol.scrollHeight; liveJumpButton(r.thread, liveKeeper.unseen) })
1423
+ }
1391
1424
  }
1425
+ if (ol && ol._ro) { ol._ro.disconnect(); ol._ro.observe(ol); for (const li of ol.children) ol._ro.observe(li) }
1426
+ if (ol && r.follow) ol._stick = true
1392
1427
  // #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
1393
1428
  const want = parseLiveRoute(location.hash)
1394
1429
  if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
@@ -1401,18 +1436,32 @@ function liveAfterRender(sid) {
1401
1436
  }
1402
1437
  liveTick()
1403
1438
  }
1439
+ // "Jump to latest ↓" shows whenever the thread is scrolled away from its bottom (with the
1440
+ // count of what came in since, when there is any), and hides once you're back at the bottom.
1404
1441
  function liveJumpButton(thread, n) {
1405
1442
  const b = thread && thread.querySelector('.live-jump')
1406
1443
  if (!b) return
1407
- b.hidden = !(n > 0)
1444
+ const ol = thread.querySelector('.live-log')
1445
+ const v = jumpView(ol, n)
1446
+ if (!v.show && n > 0) liveKeeper.seen()
1447
+ // on its way down after a tap: don't flash back up mid-scroll
1448
+ if (v.show && thread.dataset.jumping && Date.now() < Number(thread.dataset.jumping)) return
1449
+ b.hidden = !v.show
1408
1450
  const c = b.querySelector('.live-jump-n')
1409
- if (c) c.textContent = n > 0 ? ' · ' + n + ' new' : ''
1451
+ if (c) c.textContent = v.count
1452
+ b.setAttribute('aria-label', v.label)
1410
1453
  }
1411
1454
  function liveJumpClick(btn) {
1412
1455
  const thread = btn.closest('.live-thread')
1413
1456
  const ol = thread && thread.querySelector('.live-log')
1414
- if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
1415
- liveKeeper.seen(); liveJumpButton(thread, 0)
1457
+ const smooth = !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
1458
+ if (thread) thread.dataset.jumping = String(Date.now() + (smooth ? 900 : 0))
1459
+ if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
1460
+ liveKeeper.seen()
1461
+ btn.hidden = true
1462
+ const c = btn.querySelector('.live-jump-n'); if (c) c.textContent = ''
1463
+ // land exactly at the bottom even if something grew mid-scroll, then the button follows the scroll again
1464
+ setTimeout(() => { if (!thread || !thread.isConnected) return; delete thread.dataset.jumping; if (ol && !atBottom(ol, 4)) ol.scrollTop = ol.scrollHeight; liveJumpButton(thread, liveKeeper.unseen) }, smooth ? 950 : 0)
1416
1465
  }
1417
1466
  // "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
1418
1467
  async function liveEarlierClick(btn) {
package/ui/live-view.js CHANGED
@@ -13,7 +13,12 @@ export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.en
13
13
  // its repo and branch.
14
14
  export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
15
15
  if (!s) return untitled
16
- if (s.title) return s.title
16
+ const wrapped = isWrapped(s.title)
17
+ if (s.title && !wrapped) return s.title
18
+ // No name yet (or a raw <channel …> one an earlier beta kept): the first
19
+ // prompt's words, which another Mac's older Leftoff may have sent without a title.
20
+ const first = promptLine(s.firstPrompt) || (wrapped ? promptLine(s.title) : '')
21
+ if (first && (isLiveSession(s) || wrapped)) return first
17
22
  if (isLiveSession(s)) {
18
23
  const repo = s.repoLabel || s.repoName || (s.repo && s.repo.name) || null
19
24
  const branch = s.branch || (s.repo && s.repo.branch) || null
@@ -24,6 +29,33 @@ export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
24
29
  return untitled
25
30
  }
26
31
 
32
+ // Is the card's name just where it runs ("repo · branch")? Then the repo chip
33
+ // under it would say the same thing twice.
34
+ export function titleIsWhere(s) {
35
+ if (!s || !isLiveSession(s)) return false
36
+ const t = sessionTitle(s)
37
+ const repo = s.repoLabel || s.repoName || (s.repo && s.repo.name) || ''
38
+ const branch = s.branch || (s.repo && s.repo.branch) || ''
39
+ return !!repo && (t === repo || t === repo + ' · ' + branch)
40
+ }
41
+
42
+ const isWrapped = (t) => /^\s*<channel\b/.test(String(t || ''))
43
+ const NOISE_LINE = /^\s*<\/?(local-command-\w+|command-\w+|system-reminder|pasted_content)\b/
44
+ // The first line of a prompt in its own words: no channel wrapper (even one
45
+ // cut short), no system reminders; 60 characters at most.
46
+ export function promptLine(text) {
47
+ let t = String(text || '').replace(/^\s*<channel\b[^>]*>?/, '').replace(/<\/channel>\s*$/, '').replace(/<system-reminder>[\s\S]*?(<\/system-reminder>|$)/g, '')
48
+ t = t.split('\n').map((l) => l.trim()).find((l) => l && !NOISE_LINE.test(l)) || ''
49
+ t = t.replace(/\s+/g, ' ')
50
+ return t.length > 60 ? t.slice(0, 59) + '…' : t
51
+ }
52
+
53
+ // A title as HTML that wraps where it reads: after a "/" in a repo or branch
54
+ // (and at the spaces around " · "), not in the middle of a word.
55
+ export function titleHtml(text, esc) {
56
+ return esc(String(text == null ? '' : text)).split('/').join('/<wbr>')
57
+ }
58
+
27
59
  // Which pills a card shows: a live session that is just waiting for you shows
28
60
  // one Live pill (never "dormant"); one that is working or needs you shows that too.
29
61
  export function cardPills(s) {
@@ -67,6 +99,14 @@ export function senderTitle(m) {
67
99
  export function atBottom({ scrollTop = 0, scrollHeight = 0, clientHeight = 0 } = {}, slack = 80) {
68
100
  return scrollHeight - clientHeight - scrollTop <= slack
69
101
  }
102
+ // "Jump to latest ↓": shown whenever a thread that scrolls is away from its bottom, with the
103
+ // count of what came in since when there is any; at the bottom it's hidden and nothing is unseen.
104
+ export function jumpView(box = {}, unseen = 0) {
105
+ const { scrollHeight = 0, clientHeight = 0 } = box || {}
106
+ const away = scrollHeight > clientHeight + 1 && !atBottom(box)
107
+ const n = away && unseen > 0 ? unseen : 0
108
+ return { show: away, unseen: n, count: n > 0 ? ' · ' + n + ' new' : '', label: 'Jump to latest message' + (n > 0 ? ' (' + n + ' new)' : '') }
109
+ }
70
110
  // After new content: follow it, or leave the view where it is and count what is new.
71
111
  export function scrollDecision({ wasAtBottom, newCount = 0, unseen = 0 }) {
72
112
  if (wasAtBottom) return { follow: true, unseen: 0 }
@@ -44,7 +44,7 @@ export async function forgetDeviceKey() {
44
44
 
45
45
  // ------------------------------------------------------------ state
46
46
 
47
- const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null }
47
+ const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null, owners: new Map() }
48
48
  // ui/direct.js loads only on the board itself (never on pair.html, which the
49
49
  // relay serves before a browser is paired and where this file is imported too).
50
50
  let route = null, directOrRelay = null
@@ -140,6 +140,39 @@ function watchDirect() {
140
140
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') again(true) })
141
141
  }
142
142
 
143
+ // ------------------------------------------------------------ auto-switch host (0.9.8)
144
+ // On your leftoff.dev board, a session that lives on another Mac loads its own
145
+ // data (Live, its transcript, Find lists, files...) straight from that Mac
146
+ // through the relay, instead of from the Mac serving the board. The board says
147
+ // which Mac each session lives on (leftoffRelay.owner); these decide where one
148
+ // call goes. Pure and exported, so they are unit-tested.
149
+
150
+ // /v1/sessions/<id>/<anything> -> <id>; the record itself (/v1/sessions/<id>) stays on
151
+ // the board's Mac (it's the synced copy the board lists), and so does everything else.
152
+ const SESSION_SUB = /^\/v1\/sessions\/([^/?#]+)\/[^?#]+$/
153
+ export function sessionOfPath(pathname) {
154
+ const m = SESSION_SUB.exec(String(pathname || ''))
155
+ if (!m) return null
156
+ try { return decodeURIComponent(m[1]) } catch { return null }
157
+ }
158
+
159
+ // Which Mac a call should go to, or null for the board's own Mac.
160
+ // owners: Map(sessionId -> machine) the board filled in; current: the Mac serving the board;
161
+ // online: the Macs the relay says are connected now.
162
+ export function ownerRoute(pathname, owners, { current = null, online = [] } = {}) {
163
+ const id = sessionOfPath(pathname)
164
+ if (!id || !owners || typeof owners.get !== 'function') return null
165
+ const m = owners.get(id)
166
+ if (!m || m === current) return null
167
+ return online.includes(m) ? m : null
168
+ }
169
+
170
+ // A failed hop to the owner falls back to the board's Mac (which reaches the
171
+ // owner over the peer link, or says plainly that it's offline).
172
+ export function fallBack({ status = 0, code = '' } = {}) {
173
+ return status === 0 || status === 401 || status === 403 && code === 'unknown_device' || status === 502 || status === 503 || status === 504 || code === 'mac_offline' || code === 'timeout'
174
+ }
175
+
143
176
  // ------------------------------------------------------------ the tunnel
144
177
 
145
178
  async function tunnel(url, init = {}, retried = false) {
@@ -151,6 +184,9 @@ async function tunnel(url, init = {}, retried = false) {
151
184
  const hdrs = new Headers(init.headers || {})
152
185
  if (hdrs.get('content-type')) h['content-type'] = hdrs.get('content-type')
153
186
  const body = { m: method, p: url.pathname + url.search, b, h }
187
+ // 0.9.8: a session that lives on another Mac answers for itself (ui/owner-route.js)
188
+ const owner = ownerRoute(url.pathname, relay.owners, { current: relay.mac, online: onlineMacs() })
189
+ if (owner) { const r = await viaOwner(owner, body, init).catch(() => null); if (r) return r }
154
190
  if (!onDirect()) return viaRelay(body, init, retried)
155
191
  return directOrRelay({
156
192
  route,
@@ -164,6 +200,22 @@ async function tunnel(url, init = {}, retried = false) {
164
200
  })
165
201
  }
166
202
 
203
+ function onlineMacs() { return ((relay.status && relay.status.macs) || []).filter((m) => m.online).map((m) => m.machine) }
204
+ // Straight to the session's own Mac through the relay; null sends the call to the
205
+ // board's Mac instead (the owner went away, or doesn't know this browser yet).
206
+ async function viaOwner(machine, body, init) {
207
+ const env = await sealRequest(relay.key, { device: relay.device, machine, body })
208
+ const res = await realFetch('/_relay/api', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(env), signal: init.signal, cache: 'no-store' })
209
+ if (res.status !== 200) {
210
+ const j = await res.json().catch(() => ({}))
211
+ if (j.code === 'mac_offline') refreshStatus().catch(() => {})
212
+ return null
213
+ }
214
+ const out = toResponse(await openResponse(relay.key, env, await res.json()))
215
+ if (out.status === 401 || out.status === 403) { const j = await out.clone().json().catch(() => ({})); if (fallBack({ status: out.status, code: j.code || '' }) || /device/.test(j.code || '')) return null }
216
+ return out
217
+ }
218
+
167
219
  async function viaRelay(body, init, retried) {
168
220
  const gone = relay.mac
169
221
  const env = await sealRequest(relay.key, { device: relay.device, machine: gone, body })
@@ -272,6 +324,9 @@ export const leftoffRelay = {
272
324
  get route() { return onDirect() ? 'direct' : 'relay' },
273
325
  get directLatencyMs() { return route ? route.latencyMs : null },
274
326
  status: () => refreshStatus(),
327
+ // 0.9.8: which Mac a session lives on (null: the board's own), so its calls go there
328
+ owner(id, machine) { if (!id) return; if (machine) relay.owners.set(String(id), String(machine)); else relay.owners.delete(String(id)) },
329
+ ownerOf(id) { const m = relay.owners.get(String(id)); return m && m !== relay.mac && onlineMacs().includes(m) ? m : null },
275
330
  // Switch Macs: the board reloads from the other one.
276
331
  pick(mac) { remember(mac); cookieMac(mac); location.reload() },
277
332
  // This browser revoked itself (0.6.6).