@leftoff/cli 0.9.9-beta.4 → 0.9.9-beta.6

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
@@ -111,9 +111,11 @@ Live link is opt-in, per session: only sessions started with `leftoff live` have
111
111
  **New session** starts Claude Code on a Mac you pick, headless (`claude -p` with stream-json in and out, run by that Mac's daemon). That is more than answering a prompt (a new agent in your repo, on your Claude plan), so:
112
112
 
113
113
  - **This Mac's own board** starts it at once. **A phone or another Mac** may start one only when that Mac has *Allow starting sessions from other devices* on (Settings → Privacy & security, off by default) **and** *Trust my paired devices* is on. The switch is kept on that Mac (`~/.config/checkpoints/local/`, not the synced folder, so turning it on one Mac never turns it on another) and only that Mac's own browser can change it (loopback; a phone, the relay or a peer gets 403). Every start from another device shows a notification on the Mac. With the switch off, the request waits up to 5 minutes for a yes on that Mac's board, like Remote Restore. At most 3 wait at once; 3 starts at once, then 1 a minute.
114
- - **Only repos that Mac has already run Claude Code in** (from its session records; no typed paths). A headless run loads a repo's hooks and `.mcp.json` without the trust dialog, so a folder nobody opened in Claude Code can't be started.
114
+ - **The repo list is folders that Mac has already run Claude Code in** (from its session records; no typed paths). A headless run loads a repo's hooks and `.mcp.json` without the trust dialog, so a folder Claude Code never ran in is treated as a first time:
115
+ - **Other folder…** browses that Mac's folders: names only (never a file or what's in one), inside your home by real path (a symlink out of home is refused or left out), never the synced Leftoff folder or anything in it, no hidden folders, `node_modules` or `Library`, at most 500 per listing. Another Mac's are read over the peer link (`fs.dirs`, not an open action, the same as any read), a phone's through the relay. Nothing is written.
116
+ - **A first time starts at once only from that Mac's own board** (loopback, or your hosted board open on it): you are the trust prompt. From a phone or another Mac it **always** waits for a yes on that Mac's board, even with *Allow starting sessions from other devices* on, and the ask and the notification say "first time in <folder>; this runs its hooks". After one run it's in the list like the rest. Home itself is refused; a folder that isn't a git repo can't get a new worktree.
115
117
  - **Permission mode `default`, always passed.** Reads in the repo run; every edit and command becomes a Needs-you request (here, the phone, any board; first answer wins) and waits for it, with no 9.5-minute cap since nobody is at a terminal. A request settled any other way is a no. Never `bypassPermissions` or `auto`. AskUserQuestion is answered "ask in words", so Claude asks in plain text and you reply in Live.
116
- - **Other Macs** reach it over the signed, encrypted peer link (`session.start`, `session.start.get`, `session.headless`; not open actions, so the target Mac's *Allow other machines* applies too).
118
+ - **Other Macs** reach it over the signed, encrypted peer link (`session.start`, `session.start.get`, `session.headless`, `fs.dirs`; not open actions, so the target Mac's *Allow other machines* applies too).
117
119
  - **Nothing new in the folder.** The card, its title and checkpoints as for any session, plus `startedFrom` (device and how it came, not the prompt) and the run's state. Stream output, stdin and stderr stay in the daemon's memory; Claude's transcript stays in `~/.claude` on that Mac; the Live thread and attachments stay where Live keeps them (`~/.config/checkpoints`).
118
120
  - **Keeping the Mac awake.** While a turn runs, the daemon holds `caffeinate -i -w <pid>` (idle sleep waits; a closed lid on battery still sleeps). Stop interrupts the turn, closes stdin, then SIGINT, then SIGTERM. A daemon that stops pauses its sessions.
119
121
  - **Logged:** one daemon-log line per start, stop and permission request (session id and tool, never prompt text).
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.9-beta.4",
3
+ "version": "0.9.9-beta.6",
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
@@ -914,8 +914,8 @@ export function applyEvent(s, ev) {
914
914
  s.inTurn = false
915
915
  s.activeSince = null
916
916
  s.needsInput = false
917
- if (d.countTurn !== false) s.turns = (s.turns || 0) + 1 // false: Codex already counted it at the start
918
- s.turnsSinceCheckpoint = (s.turnsSinceCheckpoint || 0) + 1
917
+ if (d.countTurn !== false && !d.again) s.turns = (s.turns || 0) + 1 // false: Codex already counted it at the start
918
+ if (!d.again) s.turnsSinceCheckpoint = (s.turnsSinceCheckpoint || 0) + 1 // again (0.9.9): a late report of a turn already ended and counted
919
919
  s.backgroundTasks = Number(d.backgroundTasks || 0) + Number(d.sessionCrons || 0)
920
920
  if (d.lastAssistantPreview) s.lastPreview = clip(d.lastAssistantPreview, 280)
921
921
  // Claude ended the turn on a question to you (v0.4.4): the board offers a reply.
@@ -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.9-beta.4' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.9-beta.6' // 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.9-beta.4",
3
+ "version": "0.9.9-beta.6",
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
@@ -2869,7 +2869,6 @@ del.gd-x { background: var(--gd-del-word); }
2869
2869
  .start-ask { padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); font-size: 13.5px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
2870
2870
  .start-ask.wait { border-color: var(--yellow); background: var(--tint-yellow); }
2871
2871
  .start-ask.bad { border-color: var(--red); background: var(--tint-red); }
2872
- .btn.start-new { min-height: 36px; }
2873
2872
  .headless-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 4px 0 10px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); font-size: 13px; color: var(--text2); }
2874
2873
  .headless-bar .grow { flex: 1 1 200px; display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
2875
2874
  .headless-bar.running { border-color: var(--green); background: var(--tint-green); }
@@ -2880,6 +2879,75 @@ del.gd-x { background: var(--gd-del-word); }
2880
2879
  .start-opts { grid-template-columns: 1fr; }
2881
2880
  .start-where { grid-template-columns: 1fr 1fr; }
2882
2881
  .start-opt { min-height: 48px; }
2883
- .btn.start-new { min-height: 44px; }
2884
2882
  .start-ask-banner .btn, .start-ask .btn { min-height: 44px; }
2885
2883
  }
2884
+
2885
+ /* 0.9.9 New session: a + in the top bar on a phone (next to search); on a
2886
+ desktop the button in the board's header row, like the buttons beside it */
2887
+ .topright .newbtn { display: none; }
2888
+ .newbtn .lbl { display: none; }
2889
+ .newbtn[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); right: 0; width: max-content; padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font-size: 12px; line-height: 1.3; font-weight: 500; white-space: nowrap; pointer-events: none; z-index: 20; }
2890
+ @media (hover: hover) { .newbtn[data-tip]:hover::after { display: block; } }
2891
+ @media (max-width: 640px) {
2892
+ .topright .newbtn { display: grid; place-items: center; width: 44px; height: 44px; }
2893
+ .pagehead .actions .start-new { display: none; }
2894
+ }
2895
+ /* A request banner on a phone (start or Restore asked from another device,
2896
+ the restore banner): the words use the whole width, the buttons a row of
2897
+ their own below, 44px tall */
2898
+ @media (max-width: 640px) {
2899
+ .start-ask-banner > .grow, .restore-ask > .grow, .banner.restore:not(.open) > .grow, .pair-ask > .grow { flex: 1 1 100%; }
2900
+ .start-ask-banner > .btn, .restore-ask > .btn, .banner.restore:not(.open) > .btn:not(.x), .pair-ask > .btn { flex: 1 1 0; justify-content: center; min-height: 44px; }
2901
+ .restore-ask > .rs-m { flex: 1 1 100%; }
2902
+ }
2903
+
2904
+ /* 0.9.9 Other folder…: the inline folder browser (full width; no popover) */
2905
+ .start-other .start-opt-main { display: inline-flex; align-items: center; gap: 6px; }
2906
+ .start-other .ic { width: 16px; height: 16px; color: var(--muted); }
2907
+ .start-other { border-style: dashed; }
2908
+ .start-badge { display: inline-block; margin-left: 4px; padding: 0 6px; border-radius: 6px; border: 1px solid var(--line); font-size: 11px; font-weight: 600; line-height: 18px; color: var(--muted); background: var(--bg2); vertical-align: 1px; }
2909
+ .start-badge.git { color: var(--green); border-color: color-mix(in srgb, var(--green) 40%, var(--line)); }
2910
+ .start-badge.new { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 50%, var(--line)); }
2911
+ .start-first { color: var(--text); }
2912
+ .start-browse { display: flex; flex-direction: column; gap: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg2); min-width: 0; }
2913
+ .sb-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-width: 0; }
2914
+ .sb-crumb { min-height: 44px; min-width: 44px; padding: 0 8px; border: 0; border-radius: 8px; background: none; color: var(--accent); font: inherit; font-size: 14px; cursor: pointer; overflow-wrap: anywhere; text-align: left; }
2915
+ .sb-crumb[aria-current] { color: var(--text); font-weight: 600; cursor: default; }
2916
+ .sb-crumb:hover:not([aria-current]) { background: var(--card); }
2917
+ .sb-sep { color: var(--faint); }
2918
+ .sb-here { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
2919
+ .sb-here .btn { min-height: 44px; }
2920
+ .sb-cur { flex: 1 1 160px; min-width: 0; font-size: 13px; color: var(--text2); overflow-wrap: anywhere; }
2921
+ .sb-cur-name { font-family: var(--mono); font-size: 12.5px; }
2922
+ .sb-cuts { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
2923
+ .sb-cuts-l { font-size: 12px; color: var(--muted); }
2924
+ .sb-cut { min-height: 44px; min-width: 44px; padding: 0 12px; border: 1px solid var(--line); border-radius: 22px; background: var(--card); color: var(--text2); font: inherit; font-family: var(--mono); font-size: 12.5px; cursor: pointer; }
2925
+ .sb-cut:hover { border-color: var(--line-strong); }
2926
+ .sb-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 6px; max-height: 360px; overflow: auto; }
2927
+ .sb-dir { width: 100%; min-height: 44px; display: flex; align-items: center; gap: 8px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--card); color: var(--text); font: inherit; font-size: 14px; text-align: left; cursor: pointer; min-width: 0; }
2928
+ .sb-dir:hover { border-color: var(--line-strong); }
2929
+ .sb-dir .ic { width: 16px; height: 16px; flex: none; color: var(--muted); }
2930
+ .sb-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2931
+ .sb-dir .sb-go { color: var(--faint); }
2932
+ .sb-foot { display: flex; justify-content: flex-end; }
2933
+ .sb-foot .btn { min-height: 44px; }
2934
+ .sb-crumb:focus-visible, .sb-cut:focus-visible, .sb-dir:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2935
+ /* Go to a folder: type a path you know */
2936
+ .sb-path { display: flex; flex-direction: column; gap: 4px; margin: 0; }
2937
+ .sb-path-l { font-size: 12px; color: var(--muted); }
2938
+ .sb-path-row { display: flex; gap: 8px; min-width: 0; }
2939
+ .sb-path-in { flex: 1 1 auto; min-width: 0; min-height: 44px; padding: 0 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--card); color: var(--text); font: inherit; font-family: var(--mono); font-size: 16px; }
2940
+ .sb-path-in:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2941
+ .sb-path-in[aria-invalid="true"] { border-color: var(--red); }
2942
+ .sb-path-row .btn { min-height: 44px; min-width: 44px; justify-content: center; }
2943
+ .sb-path-err { margin: 0; }
2944
+ .start-custom .start-opt-main { white-space: normal; }
2945
+ @media (max-width: 640px) {
2946
+ .start-browse { padding: 10px; margin-left: -4px; margin-right: -4px; }
2947
+ .sb-list { grid-template-columns: 1fr; max-height: none; overflow: visible; }
2948
+ .sb-here .sb-use { flex: 1 1 100%; justify-content: center; }
2949
+ }
2950
+
2951
+ /* 0.9.9 asked at pairing time: may other devices start sessions here? */
2952
+ .pair-ask .grow { min-width: 0; }
2953
+ @media (pointer: coarse) { .pair-ask > .btn { min-height: 44px; } }
package/ui/app.js CHANGED
@@ -16,7 +16,7 @@ import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
16
16
  import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
17
17
  import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
18
18
  import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml } from './composer.js' // 0.9.8 the Live composer
19
- import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, startViewerLocal as startViewerAt } from './start.js' // 0.9.9 New session
19
+ import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath } from './start.js' // 0.9.9 New session
20
20
  import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
21
21
  import { wireTips } from './tips.js'
22
22
  import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
@@ -841,6 +841,7 @@ async function viewBoard() {
841
841
  '<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
842
842
  '<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
843
843
  html += syncDetails() + staleLine(sessions)
844
+ html += startPairAskHtml(state.starts, { esc, local: startViewerLocal(state.starts && state.starts.viewer), busy: startUi.pairBusy }) // 0.9.9: asked once, at pairing time
844
845
  html += restoreAsksBanner() + startAsksBanner(state.starts, { esc, icon, local: startViewerLocal(state.starts && state.starts.viewer), clock, busy: startUi.busy }) + restoreBanner()
845
846
  // Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
846
847
  const needs = needsItems(sessions)
@@ -945,6 +946,7 @@ function boardSearchWire() {
945
946
  try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
946
947
  state.route = parseRoute()
947
948
  await viewBoard() // the box isn't rebuilt, so the caret stays wherever you've typed to since
949
+ fillStartTexts($app, startTexts(state.starts, {}))
948
950
  }, 160)
949
951
  })
950
952
  q.addEventListener('keydown', (ev) => {
@@ -4155,6 +4157,7 @@ async function render() {
4155
4157
  else await viewBoard()
4156
4158
  tabsAfterRender()
4157
4159
  fillHeadlessErrors()
4160
+ if (r.name !== 'new') fillStartTexts($app, startTexts(state.starts, {})) // 0.9.9: a waiting start's folder, as text
4158
4161
  // a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
4159
4162
  for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
4160
4163
  } catch (e) {
@@ -4167,15 +4170,55 @@ async function render() {
4167
4170
 
4168
4171
  // this Mac's own browser, or the hosted board open on it (the direct door)
4169
4172
  function startViewerLocal(v) { return startViewerAt(v, state.viewerLocal !== false) }
4170
- const startUi = { data: null, machine: null, repo: null, where: 'repo', allRepos: false, ask: null, busy: new Set(), sending: false, poll: null }
4173
+ const startUi = { data: null, machine: null, repo: null, where: 'repo', allRepos: false, ask: null, busy: new Set(), sending: false, poll: null, browse: null, custom: null }
4171
4174
  async function viewNewSession() {
4172
4175
  try { startUi.data = await api('GET', '/v1/start') } catch (e) { if (!startUi.data) throw e }
4173
4176
  const fa = document.activeElement
4174
4177
  const kf = fa && fa.classList && fa.classList.contains('start-text') ? { a: fa.selectionStart, b: fa.selectionEnd } : null
4175
4178
  $app.innerHTML = startPageHtml(startUi.data, startUi, { esc, icon, emoji: mEmoji, local: startViewerLocal(startUi.data.viewer), clock, draft: (state.drafts && state.drafts.start) || '', busy: startUi.sending })
4179
+ fillStartTexts($app, startTexts(startUi.data, startUi)) // folder names and paths: text, never markup
4180
+ { const pi = $app.querySelector('.sb-path-in'); if (pi && startUi.browse && startUi.browse.typed && pi.value !== startUi.browse.typed) pi.value = startUi.browse.typed } // what you were typing survives a redraw
4176
4181
  const ta = $app.querySelector('.start-text')
4177
4182
  if (ta) { fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
4178
4183
  }
4184
+ // Other folder…: list one folder on the picked Mac (that Mac's own disk, over the peer link for another Mac)
4185
+ async function startBrowse(machine, p, { typed = false } = {}) {
4186
+ const seq = (startUi.browseSeq = (startUi.browseSeq || 0) + 1)
4187
+ const prev = startUi.browse && startUi.browse.machine === machine ? startUi.browse : {}
4188
+ startUi.browse = { ...prev, machine, loading: true, error: null, pathError: null }
4189
+ if (state.route && state.route.name === 'new') render()
4190
+ try {
4191
+ const d = await api('GET', '/v1/start/dirs?machine=' + encodeURIComponent(machine) + (p ? '&path=' + encodeURIComponent(p) : ''), undefined, { timeoutMs: 20000 })
4192
+ if (seq !== startUi.browseSeq || !startUi.browse) return
4193
+ startUi.browse = { machine, loading: false, error: null, pathError: null, typed: typed ? '' : startUi.browse.typed, data: d }
4194
+ } catch (e) {
4195
+ if (seq !== startUi.browseSeq || !startUi.browse) return
4196
+ // a typed path that isn't one you can open: said under the box, and the folder you were in stays
4197
+ startUi.browse = typed ? { ...startUi.browse, loading: false, pathError: e.message } : { ...startUi.browse, loading: false, error: e.message }
4198
+ }
4199
+ if (state.route && state.route.name === 'new') {
4200
+ await render() // the page re-reads before it redraws: focus after
4201
+ const pi = typed && startUi.browse.pathError ? $app.querySelector('.sb-path-in') : null
4202
+ const f = pi || (startUi.browseFocus ? $app.querySelector('.sb-crumb[aria-current]') : null)
4203
+ if (f) f.focus({ preventScroll: true })
4204
+ }
4205
+ }
4206
+ // "Go to a folder": Enter or Go takes the browser there (the Mac checks it like any other step)
4207
+ document.addEventListener('submit', (ev) => {
4208
+ const f = ev.target && ev.target.closest && ev.target.closest('form[data-form="start-browse-path"]')
4209
+ if (!f) return
4210
+ ev.preventDefault()
4211
+ const b = startUi.browse, inp = f.querySelector('.sb-path-in')
4212
+ const want = typedPath(inp ? inp.value : '')
4213
+ if (!b) return
4214
+ if (!want) { startUi.browse = { ...b, pathError: 'Type a folder, like ~/code/web.' }; render().then(() => { const pi = $app.querySelector('.sb-path-in'); if (pi) pi.focus() }); return }
4215
+ startUi.browseFocus = true
4216
+ startBrowse(b.machine, want, { typed: true })
4217
+ })
4218
+ document.addEventListener('input', (ev) => {
4219
+ if (!ev.target || !ev.target.classList || !ev.target.classList.contains('sb-path-in') || !startUi.browse) return
4220
+ startUi.browse.typed = ev.target.value // kept across the board's redraws
4221
+ })
4179
4222
  // Claude Code's own words when a session it ran stopped with an error: text, never markup.
4180
4223
  const headlessErrors = new Map()
4181
4224
  function fillHeadlessErrors() {
@@ -5356,10 +5399,50 @@ document.addEventListener('click', async (ev) => {
5356
5399
  // 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
5357
5400
  if (action === 'start-pick') {
5358
5401
  const k = btn.dataset.k, v = btn.dataset.v
5359
- if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false } else if (k === 'repo') startUi.repo = v; else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'
5402
+ if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null } else if (k === 'repo') startUi.repo = v; else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'
5360
5403
  return render()
5361
5404
  }
5405
+ // Other folder…: open the browser at ~, walk it, use a folder
5406
+ if (action === 'start-browse') {
5407
+ const pick = startPick(startUi.data, startUi)
5408
+ if (!pick.m) return
5409
+ if (startUi.browse && startUi.browse.machine === pick.m.machine) { startUi.browse = null; return render() }
5410
+ startUi.browseFocus = false
5411
+ return startBrowse(pick.m.machine, null)
5412
+ }
5413
+ if (action === 'start-browse-go' || action === 'start-browse-up') {
5414
+ const b = startUi.browse
5415
+ const to = action === 'start-browse-up' ? (b && b.data && b.data.parent ? { path: b.data.parent } : null) : browseTarget(b, btn)
5416
+ if (!b || !to) return
5417
+ startUi.browseFocus = true
5418
+ return startBrowse(b.machine, to.path)
5419
+ }
5420
+ if (action === 'start-browse-close') { startUi.browse = null; return render() }
5421
+ if (action === 'start-browse-use') {
5422
+ const b = startUi.browse, d = b && b.data
5423
+ if (!d || !d.parent) return
5424
+ const known = startPick(startUi.data, { ...startUi, machine: b.machine }).m
5425
+ if (d.ran || (known && known.repos.some((r) => r.path === d.path))) startUi.custom = null
5426
+ else startUi.custom = { machine: b.machine, path: d.path, name: d.crumbs[d.crumbs.length - 1].name, label: d.label, git: !!d.git }
5427
+ startUi.repo = d.path
5428
+ if (!d.git) startUi.where = 'repo'
5429
+ startUi.browse = null
5430
+ render()
5431
+ const ta = $app.querySelector('.start-text'); if (ta && !ta.disabled) ta.focus({ preventScroll: true })
5432
+ return
5433
+ }
5362
5434
  if (action === 'start-all-repos') { startUi.allRepos = true; return render() }
5435
+ if (action === 'pair-ask-yes' || action === 'pair-ask-no') {
5436
+ if (startUi.pairBusy) return
5437
+ startUi.pairBusy = true
5438
+ try {
5439
+ const r = await api('POST', '/v1/starts/pair-ask', { yes: action === 'pair-ask-yes' })
5440
+ toast(r.answered === 'yes' ? 'Other devices can start sessions here' + (r.trustTurnedOn ? ' (Trust my paired devices is on too)' : '') : 'OK, they’ll ask here first', 4000)
5441
+ } catch (e) { toast(e.message, 5000) }
5442
+ startUi.pairBusy = false
5443
+ try { state.starts = await api('GET', '/v1/starts') } catch {}
5444
+ return render()
5445
+ }
5363
5446
  if (action === 'start-again') { startUi.ask = null; return render() }
5364
5447
  if (action === 'start-send') return startSend(btn)
5365
5448
  if (action === 'start-ask-confirm' || action === 'start-ask-decline') {
@@ -6107,7 +6190,7 @@ document.addEventListener('submit', async (ev) => {
6107
6190
 
6108
6191
  // Live updates over SSE; re-render the current view, at most every 800 ms.
6109
6192
  let pending = null
6110
- const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.start-box') && document.activeElement.value)
6193
+ const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.start-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('sb-path-in') && document.activeElement.value)
6111
6194
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
6112
6195
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
6113
6196
  function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name)) && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
package/ui/index.html CHANGED
@@ -36,6 +36,7 @@
36
36
  </nav>
37
37
  <div class="topright">
38
38
  <button type="button" class="searchbtn" id="searchBtn" title="Search (⌘K)" aria-label="Search" aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K /"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8"/> <path d="m21 21-4.3-4.3"/></svg><span class="sb-lbl">Search</span><kbd class="sb-kbd" id="searchKbd">⌘K</kbd></button>
39
+ <a href="#/new" data-route="new" id="newTab" class="iconbtn newbtn" data-tip="New session" aria-label="New session"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14"/> <path d="M12 5v14"/></svg><span class="lbl">New session</span></a>
39
40
  <button type="button" class="health" id="health" title="Status" aria-haspopup="dialog" aria-expanded="false" aria-controls="statusPop"><span class="dot"></span><span class="label">connecting…</span></button>
40
41
  <a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" title="What’s new" aria-label="What’s new"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span class="lbl">What’s new</span><span class="newdot" hidden></span></a>
41
42
  <a href="#/settings" data-route="settings" id="setTab" class="iconbtn" title="Settings" aria-label="Settings"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span class="lbl">Settings</span><span class="topdot" hidden></span></a>
package/ui/start.js CHANGED
@@ -21,8 +21,11 @@ export function startPick(data, ui = {}) {
21
21
  const ms = (data && data.machines) || []
22
22
  const m = ms.find((x) => x.machine === ui.machine && x.online) || ms.find((x) => x.here && x.online && x.repos.length) || ms.find((x) => x.online && x.repos.length) || ms.find((x) => x.here) || ms[0] || null
23
23
  const repos = m ? m.repos : []
24
- const repo = repos.find((r) => r.path === ui.repo) || repos[0] || null
25
- return { m, repo, where: ui.where === 'worktree' ? 'worktree' : 'repo' }
24
+ // Other folder…: a folder that Mac hasn't run Claude Code in (ui.custom), picked in the browser
25
+ const custom = m && ui.custom && ui.custom.machine === m.machine && ui.custom.path === ui.repo && !repos.some((r) => r.path === ui.custom.path) ? { ...ui.custom, isNew: true } : null
26
+ const repo = repos.find((r) => r.path === ui.repo) || custom || repos[0] || null
27
+ const where = ui.where === 'worktree' && !(repo && repo.git === false) ? 'worktree' : 'repo'
28
+ return { m, repo, where }
26
29
  }
27
30
 
28
31
  // What happens when you press ↑, in one sentence (or why you can't).
@@ -31,7 +34,10 @@ export function startRule(data, pick, { local = true } = {}) {
31
34
  if (!m) return { ok: false, text: 'No Mac to start on.' }
32
35
  if (!local && !data.trust) return { ok: false, text: 'Turn on Trust my paired devices on a Mac first (Settings → Privacy & security), so you can answer its prompts from here.' }
33
36
  if (!m.online) return { ok: false, text: m.machine + ' is offline right now.' }
34
- if (!pick.repo) return { ok: false, text: m.machine + ' hasn’t run Claude Code in a repo yet. Open Claude Code there once and it shows up here.' }
37
+ if (!pick.repo) return { ok: false, text: m.machine + ' hasn’t run Claude Code in a repo yet. Pick Other folder…, or open Claude Code there once and it shows up here.' }
38
+ // a folder that Mac hasn't run Claude Code in: its hooks run, so only a person at that Mac says yes
39
+ if (pick.repo.isNew && m.here && local) return { ok: true, text: 'First time Claude Code runs in this folder on this Mac: it starts right away and runs the folder’s hooks and MCP servers.' }
40
+ if (pick.repo.isNew) return { ok: true, text: 'First time in this folder on ' + m.machine + ', so someone there confirms it first (it runs the folder’s hooks), even with “Allow starting sessions from other devices” on.' }
35
41
  if (m.here && local) return { ok: true, text: 'It starts right away on this Mac.' }
36
42
  if (m.here && m.allowRemote === true) return { ok: true, text: 'It starts right away, and ' + m.machine + ' shows a notification.' }
37
43
  if (m.here) return { ok: true, text: 'Someone at ' + m.machine + ' confirms it first (up to 5 minutes). To skip that, turn on “Allow starting sessions from other devices” in its Settings.' }
@@ -54,7 +60,7 @@ function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
54
60
  // The waiting line after a start that has to be confirmed at the Mac.
55
61
  export function startAskLine(ask, { clock = (x) => x } = {}) {
56
62
  if (!ask) return null
57
- if (ask.state === 'pending') return { tone: 'wait', text: 'Waiting for a yes on ' + ask.machine + ' (until ' + clock(ask.expiresAt) + '). It shows on that Mac’s board and as a notification.' }
63
+ if (ask.state === 'pending') return { tone: 'wait', text: 'Waiting for a yes on ' + ask.machine + ' (until ' + clock(ask.expiresAt) + '). It shows on that Mac’s board and as a notification.' + (ask.firstTime ? ' A first time in a folder always asks there.' : '') }
58
64
  if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
59
65
  if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
60
66
  if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
@@ -78,19 +84,25 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
78
84
  const repos = pick.m ? pick.m.repos : []
79
85
  const all = !!ui.allRepos || repos.length <= REPOS_SHOWN + 1
80
86
  const shown = all ? repos : repos.slice(0, REPOS_SHOWN).concat(pick.repo && repos.indexOf(pick.repo) >= REPOS_SHOWN ? [pick.repo] : [])
87
+ const custom = pick.repo && pick.repo.isNew ? pick.repo : null
88
+ const browsing = !!(pick.m && ui.browse && ui.browse.machine === pick.m.machine)
81
89
  h += '<section class="start-sec"><h2 class="start-h">Repo</h2>'
82
- if (!repos.length) h += '<p class="start-empty">' + esc(pick.m ? pick.m.machine + ' hasn’t run Claude Code in a repo yet.' : 'No Mac yet.') + '</p>'
83
- else {
90
+ if (!repos.length && !custom) h += '<p class="start-empty">' + esc(pick.m ? pick.m.machine + ' hasn’t run Claude Code in a repo yet.' : 'No Mac yet.') + '</p>'
91
+ if (pick.m) {
84
92
  h += '<div class="start-opts" role="radiogroup" aria-label="Repo">' + shown.map((r) => opt({ k: 'repo', v: r.path, on: !!pick.repo && r.path === pick.repo.path, esc,
85
- label: esc(r.name) + (r.branch ? ' <span class="start-branch">' + esc(r.branch) + '</span>' : ''), sub: esc(r.path.replace(/^\/Users\/[^/]+/, '~')) + (r.lastAt ? ' · ' + esc(ago(r.lastAt, now)) : '') })).join('') + '</div>'
93
+ label: esc(r.name) + (r.branch ? ' <span class="start-branch">' + esc(r.branch) + '</span>' : ''), sub: esc(r.path.replace(/^\/Users\/[^/]+/, '~')) + (r.lastAt ? ' · ' + esc(ago(r.lastAt, now)) : '') + (r.git === false ? ' · not a git repo' : '') })).join('') +
94
+ // the folder picked with Other folder… (its name and path go in as text: [data-st], startTexts)
95
+ (custom ? '<button type="button" class="start-opt start-custom" role="radio" aria-checked="true" data-action="start-pick" data-k="repo" data-v="' + esc(custom.path) + '"><span class="start-opt-main"><span data-st="custom-name"></span> <span class="start-badge new">first time here</span>' + (custom.git ? '' : ' <span class="start-badge">not a git repo</span>') + '</span><span class="start-opt-sub" data-st="custom-path"></span></button>' : '') +
96
+ (pick.m.online ? '<button type="button" class="start-opt start-other" data-action="start-browse" aria-expanded="' + browsing + '"' + (browsing ? ' aria-controls="start-browse"' : '') + '><span class="start-opt-main">' + icon('folder') + 'Other folder…</span><span class="start-opt-sub">Browse ' + esc(pick.m.machine) + '’s folders</span></button>' : '') + '</div>'
86
97
  if (!all) h += '<button type="button" class="btn start-more" data-action="start-all-repos">Show all ' + repos.length + '</button>'
87
- h += '<p class="start-hint">Only repos ' + esc(pick.m.machine) + ' has already run Claude Code in.</p>'
98
+ if (browsing) h += startBrowseHtml(ui.browse, { esc, icon, machine: pick.m.machine })
99
+ if (repos.length) h += '<p class="start-hint">Listed: repos ' + esc(pick.m.machine) + ' has already run Claude Code in. Other folder… picks any folder in your home there; the first time, ' + (pick.m.here && local ? 'starting it is your yes to its hooks.' : 'someone at ' + esc(pick.m.machine) + ' says yes first.') + '</p>'
88
100
  }
89
101
  h += '</section>'
90
102
  // Where
91
103
  h += '<section class="start-sec"><h2 class="start-h">Where</h2><div class="start-opts start-where" role="radiogroup" aria-label="Where">' +
92
104
  opt({ k: 'where', v: 'repo', on: pick.where === 'repo', esc, label: 'In the repo', sub: 'on its current branch' }) +
93
- opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: 'its own branch and folder' }) +
105
+ opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: pick.repo && pick.repo.git === false ? 'needs a git repo' : 'its own branch and folder', disabled: !!(pick.repo && pick.repo.git === false) }) +
94
106
  '</div></section>'
95
107
  // First message
96
108
  h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
@@ -103,6 +115,84 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
103
115
  return h
104
116
  }
105
117
 
118
+ // Other folder…: the inline folder browser for one Mac (no popover; full width
119
+ // on a phone). Folder names and paths come from that Mac's disk, so they go in
120
+ // as text afterwards ([data-st], startTexts / fillStartTexts), never as markup.
121
+ // b: { machine, loading, error, data: the GET /v1/start/dirs answer }
122
+ export function startBrowseHtml(b, { esc, icon = () => '', machine = '' } = {}) {
123
+ let h = '<div class="start-browse" id="start-browse" role="region" aria-label="Folders on ' + esc(machine) + '">'
124
+ const d = b && b.data
125
+ // or type one you know (~/src/project, or a full path): the browser goes there; the same rules as walking to it
126
+ const goTo = '<form class="sb-path" data-form="start-browse-path" novalidate><label class="sb-path-l" for="sb-path-in">Go to a folder</label><div class="sb-path-row">' +
127
+ '<input type="text" id="sb-path-in" class="sb-path-in" name="path" placeholder="~/code/web" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go"' + (b && b.pathError ? ' aria-invalid="true" aria-describedby="sb-path-err"' : '') + '>' +
128
+ '<button type="submit" class="btn">Go</button></div>' +
129
+ (b && b.pathError ? '<p class="start-rule bad sb-path-err" id="sb-path-err" role="alert" data-st="path-err"></p>' : '') + '</form>'
130
+ if (!d) h += goTo
131
+ if (d) {
132
+ h += '<nav class="sb-crumbs" aria-label="Folder">' + d.crumbs.map((c, i) => (i ? '<span class="sb-sep" aria-hidden="true">/</span>' : '') +
133
+ '<button type="button" class="sb-crumb" data-action="start-browse-go" data-crumb="' + i + '"' + (i === d.crumbs.length - 1 ? ' aria-current="location"' : '') + ' data-st="crumb-' + i + '"></button>').join('') + '</nav>'
134
+ h += '<div class="sb-here">' +
135
+ (d.parent ? '<button type="button" class="btn sb-up" data-action="start-browse-up" aria-label="Up one folder">' + icon('chevron-left') + 'Up</button>' : '') +
136
+ '<span class="sb-cur"><span class="sb-cur-name" data-st="cur"></span>' + (d.git ? ' <span class="start-badge git">git</span>' : ' <span class="start-badge">not a git repo</span>') + '</span>' +
137
+ '<button type="button" class="btn primary sb-use" data-action="start-browse-use"' + (d.parent ? '' : ' disabled') + '>Use this folder</button></div>'
138
+ h += goTo
139
+ if (d.shortcuts && d.shortcuts.length > 1) h += '<div class="sb-cuts"><span class="sb-cuts-l">Jump to</span>' + d.shortcuts.map((c, i) => '<button type="button" class="sb-cut" data-action="start-browse-go" data-cut="' + i + '" data-st="cut-' + i + '"></button>').join('') + '</div>'
140
+ if (!d.entries.length) h += '<p class="start-empty">No folders in here.</p>'
141
+ else h += '<ul class="sb-list">' + d.entries.map((e, i) => '<li><button type="button" class="sb-dir" data-action="start-browse-go" data-entry="' + i + '">' + icon('folder') + '<span class="sb-name" data-st="entry-' + i + '"></span>' + (e.git ? '<span class="start-badge git">git</span>' : '') + icon('chevron-right', 'ic sb-go') + '</button></li>').join('') + '</ul>'
142
+ if (d.capped) h += '<p class="start-hint">Showing the first ' + esc(String(d.entries.length)) + ' of ' + esc(String(d.total)) + ' folders.</p>'
143
+ }
144
+ if (b && b.loading) h += '<p class="start-hint" role="status">Loading ' + esc(machine) + '’s folders…</p>'
145
+ if (b && b.error) h += '<p class="start-rule bad" role="alert">' + esc(b.error) + '</p>'
146
+ h += '<p class="start-hint">Folders only, by name: hidden ones, node_modules and Library are left out, and so is the synced Leftoff folder.</p>' +
147
+ '<div class="sb-foot"><button type="button" class="btn" data-action="start-browse-close">Cancel</button></div></div>'
148
+ return h
149
+ }
150
+
151
+ // What you typed in "Go to a folder", as a path to ask for: no spaces around
152
+ // it, no quotes a copy brought along, a shell's "\ " back to a space, no
153
+ // trailing slash. "~" stays "~"; the Mac resolves it (src/dirs.js checkFolder).
154
+ export function typedPath(v) {
155
+ let t = String(v == null ? '' : v).trim()
156
+ if (/^(['"]).*\1$/.test(t) && t.length >= 2) t = t.slice(1, -1).trim()
157
+ t = t.replace(/\\ /g, ' ')
158
+ if (t.length > 1) t = t.replace(/\/+$/, '') || '/'
159
+ return t
160
+ }
161
+
162
+ // The text for every [data-st] on the page: names and paths from a Mac's disk.
163
+ export function startTexts(data, ui = {}) {
164
+ const t = {}
165
+ const pick = startPick(data, ui)
166
+ if (pick.repo && pick.repo.isNew) { t['custom-name'] = pick.repo.name || ''; t['custom-path'] = pick.repo.label || pick.repo.path || '' }
167
+ const d = ui.browse && ui.browse.data
168
+ if (d) {
169
+ t.cur = d.label || d.path || ''
170
+ d.crumbs.forEach((c, i) => { t['crumb-' + i] = c.name })
171
+ ;(d.shortcuts || []).forEach((c, i) => { t['cut-' + i] = c.label })
172
+ d.entries.forEach((e, i) => { t['entry-' + i] = e.name })
173
+ }
174
+ if (ui.browse && ui.browse.pathError) t['path-err'] = ui.browse.pathError
175
+ for (const a of (data && Array.isArray(data.pending) ? data.pending : [])) if (a.firstTime) t['ask-folder-' + a.id] = a.folder || ''
176
+ return t
177
+ }
178
+
179
+ export function fillStartTexts(root, texts) {
180
+ if (!root || !root.querySelectorAll) return 0
181
+ let n = 0
182
+ for (const el of root.querySelectorAll('[data-st]')) { el.textContent = texts[el.getAttribute('data-st')] || ''; n++ }
183
+ return n
184
+ }
185
+
186
+ // The folder a browser click means: { path } from a crumb, a shortcut or an entry.
187
+ export function browseTarget(b, el) {
188
+ const d = b && b.data
189
+ if (!d || !el || !el.dataset) return null
190
+ if (el.dataset.crumb != null) return d.crumbs[Number(el.dataset.crumb)] || null
191
+ if (el.dataset.cut != null) return (d.shortcuts || [])[Number(el.dataset.cut)] || null
192
+ if (el.dataset.entry != null) return d.entries[Number(el.dataset.entry)] || null
193
+ return null
194
+ }
195
+
106
196
  // A request waiting on THIS Mac: its board shows it with Start and Decline
107
197
  // (only this Mac's own browser may answer).
108
198
  export function startAsksBanner(info, { esc, icon, local = true, clock = (x) => x, busy = new Set() } = {}) {
@@ -110,12 +200,30 @@ export function startAsksBanner(info, { esc, icon, local = true, clock = (x) =>
110
200
  if (!asks.length) return ''
111
201
  return asks.map((a) => {
112
202
  const b = busy.has(a.id)
113
- return '<div class="banner warn start-ask-banner" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to start a session</b> in ' + esc(a.repo) + (a.where === 'worktree' ? ' (new worktree)' : '') + ' on ' + esc(info.machine || 'this Mac') + ': “' + esc(a.prompt) + '” <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
203
+ const where = a.firstTime ? '<span data-st="ask-folder-' + esc(a.id) + '"></span>' : esc(a.repo)
204
+ return '<div class="banner warn start-ask-banner' + (a.firstTime ? ' first' : '') + '" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to start a session</b> in ' + where + (a.where === 'worktree' ? ' (new worktree)' : '') + ' on ' + esc(info.machine || 'this Mac') + ': “' + esc(a.prompt) + '”' +
205
+ (a.firstTime ? ' <b class="start-first">First time in this folder; this runs its hooks.</b>' : '') + ' <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
114
206
  (local ? '<button type="button" class="btn primary" data-action="start-ask-confirm" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>' + icon('play') + (b ? 'Starting…' : 'Start it') + '</button>' +
115
207
  '<button type="button" class="btn" data-action="start-ask-decline" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>Decline</button>' : '<span class="rs-m">Someone at that Mac confirms it there.</span>') + '</div>'
116
208
  }).join('')
117
209
  }
118
210
 
211
+ // Asked once on THIS Mac's board when it pairs a phone or sees a new Mac:
212
+ // may it start sessions here? No is the default (first, and what doing
213
+ // nothing means); Yes turns on the switch, and Trust my paired devices if it's
214
+ // off (the words say so). Never shown off this Mac.
215
+ export function startPairAskHtml(info, { esc, local = true, busy = false } = {}) {
216
+ const a = info && info.pairAsk
217
+ if (!a || !local) return ''
218
+ const mac = info.machine || 'this Mac'
219
+ const who = a.kind === 'mac' ? (a.name || 'your other Mac') : 'your phone'
220
+ const what = a.kind === 'mac' ? 'Let ' + who + ' start sessions here?' : 'Let your phone start sessions here?'
221
+ const yes = 'Yes turns on “Allow starting sessions from other devices” for ' + mac + (info.trust ? '' : ', and Trust my paired devices so you can answer its prompts there') + '. It applies to every paired phone and Mac; change it any time in Settings → Privacy & security.'
222
+ return '<div class="banner info pair-ask" role="region" aria-label="' + esc(what) + '"><span class="grow"><b>' + esc(what) + '</b> ' + esc(a.kind === 'mac' ? who + ' could start Claude Code on ' + mac + ' with nobody here;' : 'It could start Claude Code on ' + mac + ' with nobody here;') + ' its edits and commands still wait for you in Needs you. ' + esc(yes) + '</span>' +
223
+ '<button type="button" class="btn" data-action="pair-ask-no"' + (busy ? ' aria-disabled="true"' : '') + '>No</button>' +
224
+ '<button type="button" class="btn" data-action="pair-ask-yes"' + (busy ? ' aria-disabled="true"' : '') + '>Yes, allow</button></div>'
225
+ }
226
+
119
227
  // The session page's line for a session Leftoff runs: where it runs, who
120
228
  // started it, and Stop / Resume. The error (Claude Code's own words) is put
121
229
  // in with textContent by app.js ([data-headless-err]).