@leftoff/cli 0.9.9-beta.4 → 0.9.9-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.
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.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
@@ -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.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.9-beta.4",
3
+ "version": "0.9.9-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
@@ -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,62 @@ 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 { flex: 1 1 100%; }
2900
+ .start-ask-banner > .btn, .restore-ask > .btn, .banner.restore:not(.open) > .btn:not(.x) { 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
+ .start-custom .start-opt-main { white-space: normal; }
2936
+ @media (max-width: 640px) {
2937
+ .start-browse { padding: 10px; margin-left: -4px; margin-right: -4px; }
2938
+ .sb-list { grid-template-columns: 1fr; max-height: none; overflow: visible; }
2939
+ .sb-here .sb-use { flex: 1 1 100%; justify-content: center; }
2940
+ }
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 } 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"
@@ -945,6 +945,7 @@ function boardSearchWire() {
945
945
  try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
946
946
  state.route = parseRoute()
947
947
  await viewBoard() // the box isn't rebuilt, so the caret stays wherever you've typed to since
948
+ fillStartTexts($app, startTexts(state.starts, {}))
948
949
  }, 160)
949
950
  })
950
951
  q.addEventListener('keydown', (ev) => {
@@ -4155,6 +4156,7 @@ async function render() {
4155
4156
  else await viewBoard()
4156
4157
  tabsAfterRender()
4157
4158
  fillHeadlessErrors()
4159
+ if (r.name !== 'new') fillStartTexts($app, startTexts(state.starts, {})) // 0.9.9: a waiting start's folder, as text
4158
4160
  // a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
4159
4161
  for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
4160
4162
  } catch (e) {
@@ -4167,15 +4169,31 @@ async function render() {
4167
4169
 
4168
4170
  // this Mac's own browser, or the hosted board open on it (the direct door)
4169
4171
  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 }
4172
+ 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
4173
  async function viewNewSession() {
4172
4174
  try { startUi.data = await api('GET', '/v1/start') } catch (e) { if (!startUi.data) throw e }
4173
4175
  const fa = document.activeElement
4174
4176
  const kf = fa && fa.classList && fa.classList.contains('start-text') ? { a: fa.selectionStart, b: fa.selectionEnd } : null
4175
4177
  $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 })
4178
+ fillStartTexts($app, startTexts(startUi.data, startUi)) // folder names and paths: text, never markup
4176
4179
  const ta = $app.querySelector('.start-text')
4177
4180
  if (ta) { fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
4178
4181
  }
4182
+ // Other folder…: list one folder on the picked Mac (that Mac's own disk, over the peer link for another Mac)
4183
+ async function startBrowse(machine, p) {
4184
+ const seq = (startUi.browseSeq = (startUi.browseSeq || 0) + 1)
4185
+ startUi.browse = { ...(startUi.browse && startUi.browse.machine === machine ? startUi.browse : {}), machine, loading: true, error: null }
4186
+ if (state.route && state.route.name === 'new') render()
4187
+ try {
4188
+ const d = await api('GET', '/v1/start/dirs?machine=' + encodeURIComponent(machine) + (p ? '&path=' + encodeURIComponent(p) : ''), undefined, { timeoutMs: 20000 })
4189
+ if (seq !== startUi.browseSeq || !startUi.browse) return
4190
+ startUi.browse = { machine, loading: false, error: null, data: d }
4191
+ } catch (e) {
4192
+ if (seq !== startUi.browseSeq || !startUi.browse) return
4193
+ startUi.browse = { ...startUi.browse, loading: false, error: e.message }
4194
+ }
4195
+ if (state.route && state.route.name === 'new') { render(); const f = $app.querySelector('.sb-crumb[aria-current]'); if (f && startUi.browseFocus) f.focus({ preventScroll: true }) }
4196
+ }
4179
4197
  // Claude Code's own words when a session it ran stopped with an error: text, never markup.
4180
4198
  const headlessErrors = new Map()
4181
4199
  function fillHeadlessErrors() {
@@ -5356,9 +5374,38 @@ document.addEventListener('click', async (ev) => {
5356
5374
  // 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
5357
5375
  if (action === 'start-pick') {
5358
5376
  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'
5377
+ 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
5378
  return render()
5361
5379
  }
5380
+ // Other folder…: open the browser at ~, walk it, use a folder
5381
+ if (action === 'start-browse') {
5382
+ const pick = startPick(startUi.data, startUi)
5383
+ if (!pick.m) return
5384
+ if (startUi.browse && startUi.browse.machine === pick.m.machine) { startUi.browse = null; return render() }
5385
+ startUi.browseFocus = false
5386
+ return startBrowse(pick.m.machine, null)
5387
+ }
5388
+ if (action === 'start-browse-go' || action === 'start-browse-up') {
5389
+ const b = startUi.browse
5390
+ const to = action === 'start-browse-up' ? (b && b.data && b.data.parent ? { path: b.data.parent } : null) : browseTarget(b, btn)
5391
+ if (!b || !to) return
5392
+ startUi.browseFocus = true
5393
+ return startBrowse(b.machine, to.path)
5394
+ }
5395
+ if (action === 'start-browse-close') { startUi.browse = null; return render() }
5396
+ if (action === 'start-browse-use') {
5397
+ const b = startUi.browse, d = b && b.data
5398
+ if (!d || !d.parent) return
5399
+ const known = startPick(startUi.data, { ...startUi, machine: b.machine }).m
5400
+ if (d.ran || (known && known.repos.some((r) => r.path === d.path))) startUi.custom = null
5401
+ else startUi.custom = { machine: b.machine, path: d.path, name: d.crumbs[d.crumbs.length - 1].name, label: d.label, git: !!d.git }
5402
+ startUi.repo = d.path
5403
+ if (!d.git) startUi.where = 'repo'
5404
+ startUi.browse = null
5405
+ render()
5406
+ const ta = $app.querySelector('.start-text'); if (ta && !ta.disabled) ta.focus({ preventScroll: true })
5407
+ return
5408
+ }
5362
5409
  if (action === 'start-all-repos') { startUi.allRepos = true; return render() }
5363
5410
  if (action === 'start-again') { startUi.ask = null; return render() }
5364
5411
  if (action === 'start-send') return startSend(btn)
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,65 @@ 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
+ if (d) {
126
+ h += '<nav class="sb-crumbs" aria-label="Folder">' + d.crumbs.map((c, i) => (i ? '<span class="sb-sep" aria-hidden="true">/</span>' : '') +
127
+ '<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>'
128
+ h += '<div class="sb-here">' +
129
+ (d.parent ? '<button type="button" class="btn sb-up" data-action="start-browse-up" aria-label="Up one folder">' + icon('chevron-left') + 'Up</button>' : '') +
130
+ '<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>' +
131
+ '<button type="button" class="btn primary sb-use" data-action="start-browse-use"' + (d.parent ? '' : ' disabled') + '>Use this folder</button></div>'
132
+ 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>'
133
+ if (!d.entries.length) h += '<p class="start-empty">No folders in here.</p>'
134
+ 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>'
135
+ if (d.capped) h += '<p class="start-hint">Showing the first ' + esc(String(d.entries.length)) + ' of ' + esc(String(d.total)) + ' folders.</p>'
136
+ }
137
+ if (b && b.loading) h += '<p class="start-hint" role="status">Loading ' + esc(machine) + '’s folders…</p>'
138
+ if (b && b.error) h += '<p class="start-rule bad" role="alert">' + esc(b.error) + '</p>'
139
+ 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>' +
140
+ '<div class="sb-foot"><button type="button" class="btn" data-action="start-browse-close">Cancel</button></div></div>'
141
+ return h
142
+ }
143
+
144
+ // The text for every [data-st] on the page: names and paths from a Mac's disk.
145
+ export function startTexts(data, ui = {}) {
146
+ const t = {}
147
+ const pick = startPick(data, ui)
148
+ if (pick.repo && pick.repo.isNew) { t['custom-name'] = pick.repo.name || ''; t['custom-path'] = pick.repo.label || pick.repo.path || '' }
149
+ const d = ui.browse && ui.browse.data
150
+ if (d) {
151
+ t.cur = d.label || d.path || ''
152
+ d.crumbs.forEach((c, i) => { t['crumb-' + i] = c.name })
153
+ ;(d.shortcuts || []).forEach((c, i) => { t['cut-' + i] = c.label })
154
+ d.entries.forEach((e, i) => { t['entry-' + i] = e.name })
155
+ }
156
+ for (const a of (data && Array.isArray(data.pending) ? data.pending : [])) if (a.firstTime) t['ask-folder-' + a.id] = a.folder || ''
157
+ return t
158
+ }
159
+
160
+ export function fillStartTexts(root, texts) {
161
+ if (!root || !root.querySelectorAll) return 0
162
+ let n = 0
163
+ for (const el of root.querySelectorAll('[data-st]')) { el.textContent = texts[el.getAttribute('data-st')] || ''; n++ }
164
+ return n
165
+ }
166
+
167
+ // The folder a browser click means: { path } from a crumb, a shortcut or an entry.
168
+ export function browseTarget(b, el) {
169
+ const d = b && b.data
170
+ if (!d || !el || !el.dataset) return null
171
+ if (el.dataset.crumb != null) return d.crumbs[Number(el.dataset.crumb)] || null
172
+ if (el.dataset.cut != null) return (d.shortcuts || [])[Number(el.dataset.cut)] || null
173
+ if (el.dataset.entry != null) return d.entries[Number(el.dataset.entry)] || null
174
+ return null
175
+ }
176
+
106
177
  // A request waiting on THIS Mac: its board shows it with Start and Decline
107
178
  // (only this Mac's own browser may answer).
108
179
  export function startAsksBanner(info, { esc, icon, local = true, clock = (x) => x, busy = new Set() } = {}) {
@@ -110,7 +181,9 @@ export function startAsksBanner(info, { esc, icon, local = true, clock = (x) =>
110
181
  if (!asks.length) return ''
111
182
  return asks.map((a) => {
112
183
  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>' +
184
+ const where = a.firstTime ? '<span data-st="ask-folder-' + esc(a.id) + '"></span>' : esc(a.repo)
185
+ 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) + '”' +
186
+ (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
187
  (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
188
  '<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
189
  }).join('')