@leftoff/cli 0.9.9-beta.1 → 0.9.9-beta.3

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
@@ -106,6 +106,18 @@ Live link is opt-in, per session: only sessions started with `leftoff live` have
106
106
  - **Outside events** need this Mac's event key (`~/.config/checkpoints/secrets/live-events`, mode 600, made by `leftoff live key`, replaced with `--new`). Locally that's a Bearer key. Through the relay only an HMAC signature is accepted (5-minute window, replays refused), so the relay can pass an event along but can't read the key, forge an event or change one. Both the relay and the Mac rate-limit, and bodies are capped at 16 KB. Claude's instructions call an event information, not a command, but it is still text Claude reads: anyone with the key can put words in front of your session. Keep the key in your CI's secrets.
107
107
  - **Remote Restore** opens Ghostty tabs, so a phone or another Mac can only ask. The Mac shows a notification and a card, and only that Mac's own browser can confirm. Requests expire after 5 minutes, at most 3 wait at once, and they're rate-limited.
108
108
 
109
+ ### Start a session from the board (0.9.9 beta)
110
+
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
+
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.
115
+ - **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).
117
+ - **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
+ - **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
+ - **Logged:** one daemon-log line per start, stop and permission request (session id and tool, never prompt text).
120
+
109
121
  ### What it did: tool output and the diff (0.9.8 beta)
110
122
 
111
123
  The session page's **What it did** shows the last tools a session ran with what they printed, and **This folder's changes** (the session's folder's uncommitted changes). This puts command output and file contents on the board and on your phone, so:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.9-beta.1",
3
+ "version": "0.9.9-beta.3",
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
@@ -567,7 +567,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
567
567
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
568
568
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
569
569
  'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
570
- 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive']
570
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom']
571
571
 
572
572
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
573
573
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -1840,6 +1840,14 @@ export function isNonInteractive({ surface = null, entrypoint = null } = {}) {
1840
1840
  return surface === 'headless' || surface === 'codex-exec'
1841
1841
  }
1842
1842
 
1843
+ // 0.9.9: a session Leftoff itself started from the board (src/headless.js).
1844
+ // It runs as `claude -p` (entrypoint sdk-cli) but it is your work: the daemon
1845
+ // spawned it with LEFTOFF_HEADLESS=<its session id>. Anything else in that
1846
+ // variable (empty, a stray value) doesn't count.
1847
+ export function isLeftoffHeadless(value) {
1848
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '').trim())
1849
+ }
1850
+
1843
1851
  // Our own summarizer's and titler's prompts (buildCheckpointPrompt, TITLE
1844
1852
  // prompt): a session that starts with one is Leftoff talking to a model.
1845
1853
  export function isOwnSummarizerPrompt(p) {
@@ -19,7 +19,7 @@ import {
19
19
  shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
21
21
  listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend, parseParkArgs,
22
- boardLinkLines, isNonInteractive, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText,
22
+ boardLinkLines, isNonInteractive, isLeftoffHeadless, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText,
23
23
  } from './core.js'
24
24
 
25
25
  const PANE_ID = 'checkpoints'
@@ -28,10 +28,11 @@ 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.1' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.9-beta.3' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
35
+ headless: false, // 0.9.9: started from the board (LEFTOFF_HEADLESS)
35
36
  ready: false,
36
37
  sessionId: null,
37
38
  root: null,
@@ -150,10 +151,19 @@ async function sayHelloAgain($) {
150
151
  async function isInternal($) {
151
152
  // Leftoff's own claude runs (the summarizer's claude -p, claude plugin …) carry this.
152
153
  if ((await $.env.get('LEFTOFF_INTERNAL')) || (await $.env.get('CHECKPOINTS_INTERNAL'))) return true
154
+ // 0.9.9: a session Leftoff started from the board runs as claude -p, but it is yours.
155
+ if (await isHeadless($)) return false
153
156
  // 0.8.6: `claude -p` / the Agent SDK (CLAUDE_CODE_ENTRYPOINT sdk-*): not a work session.
154
157
  try { return isNonInteractive({ entrypoint: await $.env.get('CLAUDE_CODE_ENTRYPOINT') }) } catch { return false }
155
158
  }
156
159
 
160
+ // 0.9.9: started by Leftoff from the board (src/headless.js): no terminal, no
161
+ // tab to name, the daemon writes the board's messages on its stdin and answers
162
+ // its permission prompts itself, so the mod only reports.
163
+ async function isHeadless($) {
164
+ try { return isLeftoffHeadless(await $.env.get('LEFTOFF_HEADLESS')) } catch { return false }
165
+ }
166
+
157
167
  async function loadSetup($) {
158
168
  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
159
169
  st.root = ((await $.env.get('LEFTOFF_DIR')) || (await $.env.get('CHECKPOINTS_DIR')) || home + '/checkpoints').replace(/\/+$/, '')
@@ -577,7 +587,8 @@ async function readBackend($, model) {
577
587
  // tab titled. (Claude Code's terminalTitleFromRename setting has to stay on,
578
588
  // which it is by default.) With CLAUDE_CODE_DISABLE_TERMINAL_TITLE set, Claude
579
589
  // Code draws nothing, so the mod writes OSC 2 to the session's tty instead.
580
- function tabTitleOn() { return st.cfg.tabTitle !== false && !st.internal && st.surface !== 'headless' }
590
+ const noTab = () => st.surface === 'headless' || st.surface === 'leftoff' // no terminal tab (claude -p, or started from the board)
591
+ function tabTitleOn() { return st.cfg.tabTitle !== false && !st.internal && !noTab() }
581
592
 
582
593
  function desiredTabTitle(name) {
583
594
  const repo = st.record ? repoName(st.record) : null
@@ -865,7 +876,7 @@ const ASK_MAX_MS = 9.5 * 60 * 1000
865
876
 
866
877
  async function permissionHook($, e, next) {
867
878
  const base = await next(e)
868
- if (st.internal || !st.sessionId || (base && base.decision)) return base
879
+ if (st.internal || st.headless || !st.sessionId || (base && base.decision)) return base // headless: the daemon answers on its pipe
869
880
  const opened = await api($, 'POST', '/v1/asks', {
870
881
  sessionId: e.session_id || st.sessionId, tool: e.tool_name, input: e.tool_input, suggestions: e.permission_suggestions,
871
882
  capture: 'mod', cwd: e.cwd, permissionMode: e.permission_mode,
@@ -913,7 +924,7 @@ async function toolRan($, e) {
913
924
  // typed in with $.prompt.submit, which Claude Code runs once the session is idle
914
925
  // (checked on 2.1.295: a submit during a turn waits for it to end).
915
926
  function startReplyLoop($) {
916
- if (st.replyLoop || st.internal || st.surface === 'headless') return
927
+ if (st.replyLoop || st.internal || noTab()) return
917
928
  st.replyLoop = true
918
929
  const loop = async () => {
919
930
  let backoff = 0
@@ -964,6 +975,8 @@ export function register(on, options) {
964
975
  try { model = await $.session.model() } catch {}
965
976
  let surface = null
966
977
  try { const surfaces = await $.session.surfaces(); surface = surfaces && surfaces.length ? surfaces[0] : 'headless' } catch {}
978
+ st.headless = await isHeadless($)
979
+ if (st.headless) surface = 'leftoff' // 0.9.9: started from the board, a work session (not 'headless')
967
980
  st.record = null
968
981
  // Where this session runs: Claude Code's pid + tty (for open/closed and
969
982
  // Terminal.app) and the terminal's own ids (for Go).
@@ -1006,7 +1019,7 @@ export function register(on, options) {
1006
1019
  // a new id in the same process (/clear, /resume) runs in the same tab
1007
1020
  ...(parentSessionId ? { proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term } : {}),
1008
1021
  })
1009
- if (st.surface !== 'headless') {
1022
+ if (!noTab()) {
1010
1023
  const own = e.session_title ? parseTabTitle(e.session_title) : null
1011
1024
  const name = own && !own.id ? own.name : (own && own.name) || st.daemonTitle || null
1012
1025
  await applyTabTitle($, name, { force: !!parentSessionId, current: e.session_title || null })
@@ -1055,8 +1068,8 @@ export function register(on, options) {
1055
1068
  })
1056
1069
  // The daemon titles a session after its first turn (a few seconds of
1057
1070
  // summarizer); name the tab once it has, without waiting for the minute heartbeat.
1058
- if (st.daemonTitle && st.surface !== 'headless') await noteTitle($, st.daemonTitle)
1059
- else if (!st.titleSoon && st.surface !== 'headless' && tabTitleOn()) {
1071
+ if (st.daemonTitle && !noTab()) await noteTitle($, st.daemonTitle)
1072
+ else if (!st.titleSoon && !noTab() && tabTitleOn()) {
1060
1073
  st.titleSoon = true
1061
1074
  $.clock.after(12000, () => { heartbeat($).catch(() => {}) })
1062
1075
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.9-beta.1",
3
+ "version": "0.9.9-beta.3",
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
@@ -2846,3 +2846,40 @@ del.gd-x { background: var(--gd-del-word); }
2846
2846
  .lv-tools .lv-tool-open::after { content: '›'; position: absolute; right: 8px; top: 0; color: var(--faint); font-size: 14px; }
2847
2847
  .lv-tools .lv-tool-open:hover { background: var(--bg2); color: var(--text); }
2848
2848
  .lv-tools .lv-tool-open:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
2849
+
2850
+ /* 0.9.9 New session (ui/start.js): plain lists of big choices, inline on a phone */
2851
+ .start { display: flex; flex-direction: column; gap: 18px; max-width: 760px; margin-top: 16px; }
2852
+ .start-where .start-opt-sub { white-space: normal; }
2853
+ .start-sec { display: flex; flex-direction: column; gap: 8px; }
2854
+ .start-h { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; margin: 0; }
2855
+ .start-opts { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
2856
+ .start-where { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2857
+ .start-opt { min-height: 44px; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px; text-align: left; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--text); font: inherit; font-size: 14px; cursor: pointer; min-width: 0; }
2858
+ .start-opt:hover:not(:disabled) { border-color: var(--line-strong); }
2859
+ .start-opt[aria-checked="true"] { border-color: var(--accent); background: var(--tint-accent); box-shadow: inset 0 0 0 1px var(--accent); }
2860
+ .start-opt:disabled { opacity: .5; cursor: default; }
2861
+ .start-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2862
+ .start-opt-main { font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2863
+ .start-opt-sub { font-size: 12px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2864
+ .start-branch { font-weight: 400; color: var(--muted); font-size: 12px; }
2865
+ .start-more { align-self: flex-start; min-height: 44px; }
2866
+ .start-hint, .start-empty { margin: 0; font-size: 12.5px; color: var(--muted); }
2867
+ .start-rule { margin: 0; font-size: 13px; color: var(--text2); }
2868
+ .start-rule.bad { color: var(--red); }
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
+ .start-ask.wait { border-color: var(--yellow); background: var(--tint-yellow); }
2871
+ .start-ask.bad { border-color: var(--red); background: var(--tint-red); }
2872
+ .btn.start-new { min-height: 36px; }
2873
+ .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
+ .headless-bar .grow { flex: 1 1 200px; display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
2875
+ .headless-bar.running { border-color: var(--green); background: var(--tint-green); }
2876
+ .headless-bar.paused { border-color: var(--yellow); background: var(--tint-yellow); }
2877
+ .headless-bar .btn { min-height: 44px; }
2878
+ .headless-err { margin: -4px 0 10px; padding: 8px 12px; border-radius: 8px; background: var(--tint-red); color: var(--text2); font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 9em; overflow: auto; }
2879
+ @media (max-width: 640px) {
2880
+ .start-opts { grid-template-columns: 1fr; }
2881
+ .start-where { grid-template-columns: 1fr 1fr; }
2882
+ .start-opt { min-height: 48px; }
2883
+ .btn.start-new { min-height: 44px; }
2884
+ .start-ask-banner .btn, .start-ask .btn { min-height: 44px; }
2885
+ }
package/ui/app.js CHANGED
@@ -16,6 +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
20
  import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
20
21
  import { wireTips } from './tips.js'
21
22
  import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
@@ -796,6 +797,7 @@ async function viewBoard() {
796
797
  state.ghostty = viewer ? viewer.ghostty === true : false
797
798
  state.mayAnswer = viewer ? viewer.mayAnswer !== false : true
798
799
  try { state.restore = await api('GET', '/v1/restore') } catch { state.restore = null }
800
+ try { state.starts = await api('GET', '/v1/starts') } catch { state.starts = null } // 0.9.9: starts waiting for a yes here
799
801
  const today = new Date(); today.setHours(0, 0, 0, 0)
800
802
  const recent = sessions.filter((s) => !(s.status === 'ended' && Date.parse(s.endedAt || 0) < today.getTime()))
801
803
  // v0.4.8: sessions with nothing in them yet (no prompt, name or checkpoint,
@@ -835,10 +837,11 @@ async function viewBoard() {
835
837
  let html = '<div class="pagehead"><div><h1>Sessions</h1><div class="sub">Every Claude Code session on ' + (multi() ? 'your machines' : 'this machine') + (agentNames().length ? ', plus ' + esc(agentNames().join(' and ')) : '') + ', grouped by what needs you.</div>' + syncChip() + '</div>' +
836
838
  '<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
837
839
  options: [['', 'All machines'], ...state.health.machines.map((m) => [m, mEmoji(m) + ' ' + m + thisOneSuffix(m, state.health)]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
840
+ '<a class="btn start-new" href="#/new">' + icon('plus') + 'New session</a>' +
838
841
  '<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
839
842
  '<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
840
843
  html += syncDetails() + staleLine(sessions)
841
- html += restoreAsksBanner() + restoreBanner()
844
+ html += restoreAsksBanner() + startAsksBanner(state.starts, { esc, icon, local: startViewerLocal(state.starts && state.starts.viewer), clock, busy: startUi.busy }) + restoreBanner()
842
845
  // Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
843
846
  const needs = needsItems(sessions)
844
847
  html += needsStrip(needs, needOpts())
@@ -1489,6 +1492,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
1489
1492
  // 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
1490
1493
  '<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>' + (s.liveResumeCommand ? '<button type="button" class="btn" data-action="copy-cmd" data-cmd="' + esc(s.liveResumeCommand) + '" title="Exit the session in its terminal, then run this to bring it back Live">' + icon('copy') + 'Copy Live 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>'
1491
1494
  html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
1495
+ html += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false }) // 0.9.9: started from the board: Stop / Resume
1496
+ if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
1492
1497
  html += '<div class="facts">' +
1493
1498
  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)) +
1494
1499
  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) +
@@ -1566,7 +1571,7 @@ async function fetchLive(id, { timeoutMs } = {}) {
1566
1571
  } catch (e) { return { messages: [], error: e.message } }
1567
1572
  }
1568
1573
  function liveBoxHtml(s, v) {
1569
- if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
1574
+ if (s.agent || s.codingAgent === 'codex' || (s.capture === 'hooks' && !s.headless)) return '' // 0.9.9: one Leftoff started talks Live whatever reports it
1570
1575
  if (!isLive(s)) {
1571
1576
  if (s.endedAt || s.live === 'closed') return ''
1572
1577
  const how = state.liveHow
@@ -1584,7 +1589,7 @@ function liveBoxHtml(s, v) {
1584
1589
  return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2><span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
1585
1590
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
1586
1591
  earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
1587
- '<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
1592
+ '<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
1588
1593
  '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
1589
1594
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
1590
1595
  composerHtml(s, draft, may) +
@@ -1896,7 +1901,7 @@ document.addEventListener('keydown', (ev) => {
1896
1901
  ev.preventDefault()
1897
1902
  const cb = ta.closest('.composer')
1898
1903
  if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
1899
- const box = ta.closest('.live-box') || ta.closest('.need')
1904
+ const box = ta.closest('.live-box') || ta.closest('.need') || ta.closest('.start-box')
1900
1905
  const btn = box && box.querySelector('[data-submit-for="' + CSS.escape(ta.dataset.submit || 'live') + '"]')
1901
1906
  if (!btn || btn.disabled) return
1902
1907
  const last = Number(box.dataset.kbdAt || 0)
@@ -3353,6 +3358,7 @@ function queueSave() { clearTimeout(saveUi.timer); saveUi.timer = setTimeout(pai
3353
3358
 
3354
3359
  async function viewSettings() {
3355
3360
  const [c, , remote, sync, peers, st, , , relay, gh] = await Promise.all([api('GET', '/v1/config'), spacesLoad(), api('GET', '/v1/remote').catch(() => null), api('GET', '/v1/sync').catch(() => null), api('GET', '/v1/peers').catch(() => null), api('GET', '/v1/syncthing').catch(() => null), refreshHealth().catch(() => null), agentsLoad(), api('GET', '/v1/relay').catch(() => null), api('GET', '/v1/gh').catch(() => null)])
3361
+ const starts = await api('GET', '/v1/starts').catch(() => null) // 0.9.9: this Mac's own switch
3356
3362
  state.relay = relay
3357
3363
  state.gh = gh
3358
3364
  syncUi.data = sync; syncUi.peers = peers; if (st) stUi.data = st
@@ -3391,6 +3397,7 @@ async function viewSettings() {
3391
3397
  phone: phonePane(relay, remote, unavailable),
3392
3398
  security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
3393
3399
  '<div class="full hint">Nothing reaches this board from elsewhere until <a href="#/settings/phone">Phone &amp; remote</a> is on.</div>') +
3400
+ startsSettingHtml(starts, { esc, local: startViewerLocal(starts && starts.viewer) }) +
3394
3401
  // a changed machine key needs a click here, so Other machines stays up top then
3395
3402
  (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
3396
3403
  advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
@@ -4142,10 +4149,12 @@ async function render() {
4142
4149
  else if (r.name === 'whats-new') await viewWhatsNew()
4143
4150
  else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
4144
4151
  else if (r.name === 'search') await viewSearch(r.q || '')
4152
+ else if (r.name === 'new') await viewNewSession()
4145
4153
  else if (r.name === 'setup') await viewSetup()
4146
4154
  else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
4147
4155
  else await viewBoard()
4148
4156
  tabsAfterRender()
4157
+ fillHeadlessErrors()
4149
4158
  // a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
4150
4159
  for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
4151
4160
  } catch (e) {
@@ -4154,6 +4163,64 @@ async function render() {
4154
4163
  }
4155
4164
  }
4156
4165
 
4166
+ // ------------------------------------------------------------ New session (0.9.9, ui/start.js)
4167
+
4168
+ // this Mac's own browser, or the hosted board open on it (the direct door)
4169
+ 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 }
4171
+ async function viewNewSession() {
4172
+ try { startUi.data = await api('GET', '/v1/start') } catch (e) { if (!startUi.data) throw e }
4173
+ const fa = document.activeElement
4174
+ const kf = fa && fa.classList && fa.classList.contains('start-text') ? { a: fa.selectionStart, b: fa.selectionEnd } : null
4175
+ $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 })
4176
+ const ta = $app.querySelector('.start-text')
4177
+ if (ta) { fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
4178
+ }
4179
+ // Claude Code's own words when a session it ran stopped with an error: text, never markup.
4180
+ const headlessErrors = new Map()
4181
+ function fillHeadlessErrors() {
4182
+ for (const el of $app.querySelectorAll('[data-headless-err]')) {
4183
+ const t = headlessErrors.get(el.dataset.headlessErr) || ''
4184
+ el.textContent = t
4185
+ el.hidden = !t
4186
+ }
4187
+ }
4188
+ async function startSend(btn) {
4189
+ const box = btn.closest('.start-box')
4190
+ const ta = box && box.querySelector('.start-text')
4191
+ const text = ta ? ta.value.trim() : ''
4192
+ if (!text) { if (ta) ta.focus(); return }
4193
+ if (startUi.sending) return
4194
+ const pick = startPick(startUi.data, startUi)
4195
+ if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
4196
+ startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
4197
+ try {
4198
+ const r = await api('POST', '/v1/sessions/start', { machine: pick.m.machine, cwd: pick.repo.path, prompt: text, where: pick.where }, { timeoutMs: 60000 })
4199
+ if (state.drafts) delete state.drafts.start
4200
+ if (r.state === 'started' && r.sessionId) { startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
4201
+ startUi.ask = { ...r, machine: r.machine || pick.m.machine }
4202
+ pollStartAsk()
4203
+ } catch (e) { toast(e.message, 6000) } finally { startUi.sending = false }
4204
+ if (state.route && state.route.name === 'new') render()
4205
+ }
4206
+ // The asker's side of a start that waits for a yes at the Mac.
4207
+ function pollStartAsk() {
4208
+ clearTimeout(startUi.poll)
4209
+ const a = startUi.ask
4210
+ if (!a || !a.id || !['pending', 'starting'].includes(a.state)) return
4211
+ startUi.poll = setTimeout(async () => {
4212
+ try {
4213
+ const r = await api('GET', '/v1/sessions/start/' + encodeURIComponent(a.id) + '?machine=' + encodeURIComponent(a.machine))
4214
+ if (startUi.ask && startUi.ask.id === a.id) {
4215
+ startUi.ask = { ...a, ...r }
4216
+ if (r.state === 'started' && r.sessionId) { startUi.ask = null; location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
4217
+ }
4218
+ } catch (e) { if (e.status === 404) { startUi.ask = { ...a, state: 'failed', error: e.message } } }
4219
+ if (state.route && state.route.name === 'new') render()
4220
+ pollStartAsk()
4221
+ }, 2000)
4222
+ }
4223
+
4157
4224
  // First-run setup wizard (0.9.7, ui/wizard.js). Live updates don't redraw it:
4158
4225
  // it keeps its own step and fetches its own state.
4159
4226
  async function viewSetup() {
@@ -5286,6 +5353,35 @@ document.addEventListener('click', async (ev) => {
5286
5353
  } catch (e) { cuUi.making = false; cuUi.err = e.message; render() }
5287
5354
  return
5288
5355
  }
5356
+ // 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
5357
+ if (action === 'start-pick') {
5358
+ 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'
5360
+ return render()
5361
+ }
5362
+ if (action === 'start-all-repos') { startUi.allRepos = true; return render() }
5363
+ if (action === 'start-again') { startUi.ask = null; return render() }
5364
+ if (action === 'start-send') return startSend(btn)
5365
+ if (action === 'start-ask-confirm' || action === 'start-ask-decline') {
5366
+ if (!id || startUi.busy.has(id)) return
5367
+ startUi.busy.add(id); render()
5368
+ try {
5369
+ const r = await api('POST', '/v1/sessions/start/' + encodeURIComponent(id) + '/' + (action === 'start-ask-confirm' ? 'confirm' : 'decline'), {}, { timeoutMs: 60000 })
5370
+ toast(r.state === 'started' ? 'Started' : r.state === 'declined' ? 'Declined' : r.state === 'failed' ? 'Didn’t start: ' + (r.error || '') : 'That request was ' + r.state, r.state === 'failed' ? 6000 : 2500)
5371
+ startUi.busy.delete(id)
5372
+ if (r.state === 'started' && r.sessionId) { location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
5373
+ } catch (e) { toast(e.message, 5000) }
5374
+ startUi.busy.delete(id)
5375
+ return render()
5376
+ }
5377
+ if (action === 'headless-stop' || action === 'headless-run') {
5378
+ btn.disabled = true
5379
+ try {
5380
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/' + (action === 'headless-stop' ? 'stop' : 'run'), {}, { timeoutMs: 30000 })
5381
+ toast(action === 'headless-stop' ? 'Stopping' : r.already ? 'Already running' : 'Running again')
5382
+ } catch (e) { toast(e.message, 6000) }
5383
+ return render()
5384
+ }
5289
5385
  if (action === 'restore-ask-confirm' || action === 'restore-ask-decline') {
5290
5386
  const id = btn.dataset.id
5291
5387
  if (!id || restoreUi.busy.has(id)) return
@@ -6011,7 +6107,7 @@ document.addEventListener('submit', async (ev) => {
6011
6107
 
6012
6108
  // Live updates over SSE; re-render the current view, at most every 800 ms.
6013
6109
  let pending = null
6014
- 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)
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)
6015
6111
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
6016
6112
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
6017
6113
  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) }
@@ -6025,7 +6121,7 @@ function connect() {
6025
6121
  es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
6026
6122
  es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
6027
6123
  es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
6028
- for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'projects.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, () => { if (stream === es) streamDog.seen(); soon() })
6124
+ for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'projects.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated', 'starts.updated']) es.addEventListener(t, () => { if (stream === es) streamDog.seen(); soon() })
6029
6125
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
6030
6126
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
6031
6127
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
@@ -6049,6 +6145,11 @@ document.addEventListener('change', async (ev) => {
6049
6145
  } catch (e) { toast(e.message, 5000) }
6050
6146
  return sel.closest('#spacesBox') ? spacesRepaint() : render()
6051
6147
  }
6148
+ if (ev.target.id === 'f-start-allow') { // 0.9.9: this Mac only
6149
+ const cb = ev.target
6150
+ try { const r = await api('POST', '/v1/starts/allow', { allow: cb.checked }); toast(r.allowRemote ? 'Your phone and other Macs can now start sessions here' : 'Sessions started from other devices wait for a yes here') } catch (e) { cb.checked = !cb.checked; toast(e.message, 6000) }
6151
+ return viewSettings()
6152
+ }
6052
6153
  if (ev.target.id === 'f-peer-allow') {
6053
6154
  const cb = ev.target
6054
6155
  try { const r = await api('POST', '/v1/peers/allow', { allow: cb.checked }); toast(r.allowIncoming ? 'Other machines may now answer and Go here' : 'Other machines can no longer answer or Go here') } catch (e) { cb.checked = !cb.checked; toast(e.message, 6000) }
package/ui/icons.js CHANGED
@@ -46,6 +46,7 @@ export const ICONS = {
46
46
  "archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\"/> <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\"/> <path d=\"M10 12h4\"/>",
47
47
  "bookmark-plus": "<path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\"/> <path d=\"M12 7v6\"/> <path d=\"M15 10H9\"/>",
48
48
  "circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>",
49
+ "square": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/>",
49
50
  "message-square": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>"
50
51
  }
51
52
 
package/ui/start.js ADDED
@@ -0,0 +1,143 @@
1
+ // 0.9.9 New session (docs/plans/start-from-board.md): pick a Mac, a repo it
2
+ // has run Claude Code in, where (the repo, or a new worktree), and the first
3
+ // message (the Live composer, ui/composer.js); Leftoff starts Claude Code there
4
+ // and the session shows on every board right away. Inline on a phone: plain
5
+ // lists of big buttons, no menus or popovers. Pure HTML builders, shared with
6
+ // the unit tests; app.js wires them.
7
+ import { composerBoxHtml } from './composer.js'
8
+
9
+ const REPOS_SHOWN = 6
10
+
11
+ // At this Mac: its own browser (the daemon says local), or your hosted board
12
+ // open on it through the direct door (local and direct). boardLocal is the
13
+ // board's own read of where it runs; the direct door overrides it.
14
+ export function startViewerLocal(viewer, boardLocal = true) {
15
+ return !!(viewer && viewer.local) && (boardLocal || viewer.direct === true)
16
+ }
17
+
18
+ // The choice the page shows: what you picked, else this Mac (or the first
19
+ // online one), its newest repo, and "In the repo".
20
+ export function startPick(data, ui = {}) {
21
+ const ms = (data && data.machines) || []
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
+ 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' }
26
+ }
27
+
28
+ // What happens when you press ↑, in one sentence (or why you can't).
29
+ export function startRule(data, pick, { local = true } = {}) {
30
+ const m = pick.m
31
+ if (!m) return { ok: false, text: 'No Mac to start on.' }
32
+ 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
+ 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.' }
35
+ if (m.here && local) return { ok: true, text: 'It starts right away on this Mac.' }
36
+ if (m.here && m.allowRemote === true) return { ok: true, text: 'It starts right away, and ' + m.machine + ' shows a notification.' }
37
+ 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.' }
38
+ return { ok: true, text: 'It starts right away if ' + m.machine + ' allows starting sessions from other devices; otherwise someone there confirms it first.' }
39
+ }
40
+
41
+ const ago = (iso, now) => {
42
+ const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return ''
43
+ const s = Math.max(0, Math.round((now - t) / 1000))
44
+ if (s < 3600) return Math.max(1, Math.round(s / 60)) + 'm ago'
45
+ if (s < 86400) return Math.round(s / 3600) + 'h ago'
46
+ return Math.round(s / 86400) + 'd ago'
47
+ }
48
+
49
+ function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
50
+ return '<button type="button" class="start-opt" role="radio" aria-checked="' + on + '" data-action="start-pick" data-k="' + esc(k) + '" data-v="' + esc(v) + '"' + (disabled ? ' disabled' : '') + '>' +
51
+ '<span class="start-opt-main">' + label + '</span>' + (sub ? '<span class="start-opt-sub">' + sub + '</span>' : '') + '</button>'
52
+ }
53
+
54
+ // The waiting line after a start that has to be confirmed at the Mac.
55
+ export function startAskLine(ask, { clock = (x) => x } = {}) {
56
+ 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.' }
58
+ if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
59
+ if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
60
+ if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
61
+ if (ask.state === 'failed') return { tone: 'bad', text: 'Didn’t start on ' + ask.machine + ': ' + (ask.error || 'unknown error') }
62
+ return null
63
+ }
64
+
65
+ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', local = true, now = Date.now(), clock = (x) => x, draft = '', busy = false } = {}) {
66
+ const pick = startPick(data, ui)
67
+ const rule = startRule(data, pick, { local })
68
+ let h = '<a class="back" href="#/">← All sessions</a>' +
69
+ '<div class="pagehead"><div><h1>New session</h1><div class="sub">Leftoff starts Claude Code on the Mac you pick. It shows on every board right away: talk to it in Live, approve its edits and commands in Needs you.</div></div></div>'
70
+ h += '<div class="start" data-start>'
71
+ // Mac
72
+ const ms = (data && data.machines) || []
73
+ h += '<section class="start-sec"><h2 class="start-h">Mac</h2><div class="start-opts" role="radiogroup" aria-label="Mac">' +
74
+ ms.map((x) => opt({ k: 'machine', v: x.machine, on: !!pick.m && x.machine === pick.m.machine, disabled: !x.online, esc,
75
+ label: esc((emoji(x.machine) ? emoji(x.machine) + ' ' : '') + x.machine), sub: esc(x.here ? (local ? 'this Mac' : 'the Mac you’re connected to') : x.online ? 'online' : 'offline') })).join('') +
76
+ '</div></section>'
77
+ // Repo
78
+ const repos = pick.m ? pick.m.repos : []
79
+ const all = !!ui.allRepos || repos.length <= REPOS_SHOWN + 1
80
+ const shown = all ? repos : repos.slice(0, REPOS_SHOWN).concat(pick.repo && repos.indexOf(pick.repo) >= REPOS_SHOWN ? [pick.repo] : [])
81
+ 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 {
84
+ 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>'
86
+ 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>'
88
+ }
89
+ h += '</section>'
90
+ // Where
91
+ h += '<section class="start-sec"><h2 class="start-h">Where</h2><div class="start-opts start-where" role="radiogroup" aria-label="Where">' +
92
+ 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' }) +
94
+ '</div></section>'
95
+ // First message
96
+ h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
97
+ composerBoxHtml({ submit: 'start', sendAction: 'start-send', draftKey: 'start', draft, may: rule.ok && !ui.ask, busy, placeholder: 'What should Claude do?', label: 'First message', inputClass: 'start-text', keys: 'Enter Meta+Enter Control+Enter' }) +
98
+ '</div><p class="start-rule' + (rule.ok ? '' : ' bad') + '" role="status">' + esc(rule.text) + '</p>' +
99
+ '<p class="start-hint">Reads run freely; every edit and command waits for you in Needs you (here, your phone, any board).</p>'
100
+ const line = startAskLine(ui.ask, { clock })
101
+ if (line) h += '<div class="start-ask ' + line.tone + '" role="status">' + esc(line.text) + (ui.ask && ['declined', 'expired', 'failed'].includes(ui.ask.state) ? ' <button type="button" class="btn" data-action="start-again">Try again</button>' : '') + '</div>'
102
+ h += '</section></div>'
103
+ return h
104
+ }
105
+
106
+ // A request waiting on THIS Mac: its board shows it with Start and Decline
107
+ // (only this Mac's own browser may answer).
108
+ export function startAsksBanner(info, { esc, icon, local = true, clock = (x) => x, busy = new Set() } = {}) {
109
+ const asks = info && Array.isArray(info.pending) ? info.pending : []
110
+ if (!asks.length) return ''
111
+ return asks.map((a) => {
112
+ 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>' +
114
+ (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
+ '<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
+ }).join('')
117
+ }
118
+
119
+ // The session page's line for a session Leftoff runs: where it runs, who
120
+ // started it, and Stop / Resume. The error (Claude Code's own words) is put
121
+ // in with textContent by app.js ([data-headless-err]).
122
+ export function headlessBarHtml(s, { esc, icon, may = true } = {}) {
123
+ const h = s && s.headless
124
+ if (!h) return ''
125
+ const from = s.startedFrom && s.startedFrom.device ? ' from ' + s.startedFrom.device : ''
126
+ const where = s.machine ? ' on ' + s.machine : ''
127
+ let text, btn = ''
128
+ if (h.state === 'running') { text = 'Running' + where + ', started' + from + ' by Leftoff' + (h.awake ? ' · keeping it awake' : ''); if (may) btn = '<button type="button" class="btn" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' }
129
+ else if (h.state === 'stopping') text = 'Stopping' + where + '…'
130
+ else {
131
+ text = (h.state === 'paused' ? 'Paused' + where + ' (Leftoff restarted)' : h.error ? 'Claude Code stopped' + where : 'Stopped' + where) + (from ? ', started' + from : '')
132
+ if (may) btn = '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
133
+ }
134
+ return '<div class="headless-bar ' + esc(h.state) + '" data-sid="' + esc(s.id) + '"><span class="grow">' + icon('play') + esc(text) + '</span>' + btn + '</div>' +
135
+ (h.error && h.state !== 'running' ? '<pre class="headless-err" data-headless-err="' + esc(s.id) + '"></pre>' : '')
136
+ }
137
+
138
+ // Settings → Privacy & security: the one switch, on this Mac only.
139
+ export function startsSettingHtml(info, { esc, local = true } = {}) {
140
+ if (!info) return ''
141
+ return '<label for="f-start-allow">Start sessions</label><label class="check"><input id="f-start-allow" type="checkbox"' + (info.allowRemote ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Allow starting sessions from other devices</span></label>' +
142
+ '<div class="hint">Off: a session your phone or another Mac starts here waits until someone at this Mac confirms it. On: it starts right away, and this Mac shows a notification each time. Needs Trust my paired devices. Kept on this Mac only (it doesn’t sync)' + (local ? '.' : '; change it on ' + esc(info.machine || 'that Mac') + '.') + '</div>'
143
+ }