@leftoff/cli 0.9.8 → 0.9.9-beta.2

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.8",
3
+ "version": "0.9.9-beta.2",
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.8' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.9-beta.2' // 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.8",
3
+ "version": "0.9.9-beta.2",
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
@@ -525,8 +525,8 @@ h2 .count { color: var(--faint); font-weight: 500; }
525
525
  .need.k-permission .need-actions .btn, .need.k-permission .deny-form .btn { min-height: 44px; }
526
526
  .deny-form { display: flex; gap: 6px; flex-wrap: wrap; }
527
527
  .deny-form[hidden] { display: none; }
528
- .deny-form input, .need .qother, .reply-text { flex: 1 1 220px; min-width: 0; background: var(--field); border: 1px solid var(--line); border-radius: 8px; color: var(--text); padding: 7px 9px; font: inherit; font-size: 13px; }
529
- .deny-form input:focus, .need .qother:focus, .reply-text:focus { outline: 2px solid var(--focus); border-color: var(--accent); }
528
+ .deny-form input, .need .qother { flex: 1 1 220px; min-width: 0; background: var(--field); border: 1px solid var(--line); border-radius: 8px; color: var(--text); padding: 7px 9px; font: inherit; font-size: 13px; }
529
+ .deny-form input:focus, .need .qother:focus { outline: 2px solid var(--focus); border-color: var(--accent); }
530
530
  .need-q { display: grid; gap: 6px; }
531
531
  .need-q .qtext { font-size: 13.5px; font-weight: 500; }
532
532
  .need-q .qhead { display: inline-block; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-right: 8px; }
@@ -535,8 +535,8 @@ h2 .count { color: var(--faint); font-weight: 500; }
535
535
  .btn.opt small { color: var(--muted); font-size: 11.5px; font-weight: 400; }
536
536
  .btn.opt[aria-pressed="true"] { border-color: var(--accent); background: var(--tint-accent); }
537
537
  .need-question { margin: 0; padding: 6px 10px; border-left: 3px solid var(--yellow); background: var(--subtle); color: var(--text2); font-size: 13.5px; white-space: pre-wrap; }
538
- .reply-row { display: flex; gap: 6px; align-items: flex-end; flex-wrap: wrap; }
539
- .reply-text { resize: vertical; min-height: 38px; }
538
+ /* 0.9.8 the Needs-you Reply box is the Live composer (.composer below) */
539
+ .need .reply-composer { margin-top: 8px; }
540
540
  .need-badge { display: flex; gap: 8px; align-items: baseline; font-size: 12.5px; padding: 6px 9px; border-radius: 8px; background: var(--tint-yellow); min-width: 0; }
541
541
  .need-badge.blocking { background: var(--tint-red); }
542
542
  .need-badge .nb-k { font-weight: 700; color: var(--yellow); flex: none; }
@@ -1365,7 +1365,7 @@ body.pairpage { background: var(--bg); color: var(--text); margin: 0; }
1365
1365
  .empty.login .codeform input { font-size: 22px !important; }
1366
1366
  .cmdk-field input, .pairbox input { font-size: 17px !important; }
1367
1367
  .addform input { padding: 5px 10px; }
1368
- .deny-form input, .need .qother, .reply-text { padding: 6px 9px; }
1368
+ .deny-form input, .need .qother { padding: 6px 9px; }
1369
1369
  .card .title input { padding: 2px 6px; }
1370
1370
  .standup-out textarea { line-height: 1.4; }
1371
1371
  }
@@ -2163,6 +2163,18 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2163
2163
  .composer-attach:focus { outline: none; }
2164
2164
  .composer-attach:focus-visible .composer-clip { outline: 2px solid var(--accent); outline-offset: 2px; }
2165
2165
  .composer-attach:disabled { cursor: default; opacity: .5; }
2166
+ /* 0.9.8 styled tooltips (data-tip, never title=) for the composer's 📎 and ↑
2167
+ (Live and the Needs-you Reply box), the Needs-you card's controls and Go ↗.
2168
+ Hover with a mouse, or keyboard focus; display:none otherwise, so a tip
2169
+ never widens a phone page. aria-label carries the words for a screen reader. */
2170
+ :where(.composer-attach[data-tip], .composer-send[data-tip], .need [data-tip], .btn.go[data-tip]) { position: relative; }
2171
+ .composer-attach[data-tip]::after, .composer-send[data-tip]::after, .need [data-tip]::after, .btn.go[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); right: 0; width: max-content; max-width: min(260px, 70vw); 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; font-style: normal; text-align: left; white-space: normal; letter-spacing: normal; pointer-events: none; z-index: 20; }
2172
+ .composer-attach[data-tip]::after, .composer-send[data-tip]::after { top: auto; bottom: calc(100% + 4px); }
2173
+ .composer-attach[data-tip]::after { right: auto; left: 0; }
2174
+ @media (hover: hover) { .composer-attach[data-tip]:hover::after, .composer-send[data-tip]:hover::after, .need [data-tip]:hover::after, .btn.go[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
2175
+ .composer-attach[data-tip]:focus-visible::after, .composer-send[data-tip]:focus-visible::after, .need [data-tip]:focus-visible::after, .btn.go[data-tip]:focus-visible::after { display: block; animation: tip-in .12s ease-out both; }
2176
+ @keyframes tip-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
2177
+ @media (prefers-reduced-motion: reduce) { .composer-attach[data-tip]::after, .composer-send[data-tip]::after, .need [data-tip]::after, .btn.go[data-tip]::after { animation: none !important; } }
2166
2178
  .att-chip { position: relative; display: flex; align-items: center; gap: 8px; max-width: 100%; min-width: 0; width: auto; flex: 0 1 auto; height: 52px; padding: 4px 0 4px 4px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); overflow: hidden; cursor: default; }
2167
2179
  [data-theme="light"] .att-chip { background: #f6f7f9; }
2168
2180
  .att-chip.failed { border-color: color-mix(in srgb, var(--red) 60%, var(--line)); }
@@ -2834,3 +2846,40 @@ del.gd-x { background: var(--gd-del-word); }
2834
2846
  .lv-tools .lv-tool-open::after { content: '›'; position: absolute; right: 8px; top: 0; color: var(--faint); font-size: 14px; }
2835
2847
  .lv-tools .lv-tool-open:hover { background: var(--bg2); color: var(--text); }
2836
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
@@ -15,7 +15,8 @@ import { canStartPull, pullStep, pullRelease } from './pull.js'
15
15
  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
- import { isBlank, composerKey, isTouchUi, composerHeight } from './composer.js' // 0.9.8 the Live composer
18
+ import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml } from './composer.js' // 0.9.8 the Live composer
19
+ import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml } 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"
@@ -426,15 +427,15 @@ function goButton(s, { page = false } = {}) {
426
427
  if (!j.peer) return ''
427
428
  // 0.9.2: "Go ↗" like a local card (a phone cut "Go on <machine>" short); the machine is in its title and label
428
429
  const label = 'Go ↗', on = 'Go on ' + s.machine
429
- if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(on + ': Go only works from a Mac’s own browser') + '">' + esc(label) + '</button>'
430
- if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
430
+ if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '" data-tip="' + esc(on + ': Go only works from a Mac’s own browser') + '">' + esc(label) + '</button>'
431
+ if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '" data-tip="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
431
432
  const st = goState.get(s.id)
432
- return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '" title="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
433
+ return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '" data-tip="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
433
434
  }
434
- if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled title="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
435
- if (!j.supported) return '<button type="button" class="btn go" disabled title="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
435
+ if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled data-tip="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
436
+ if (!j.supported) return '<button type="button" class="btn go" disabled data-tip="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
436
437
  const st = goState.get(s.id)
437
- return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="Go ↗" title="' + esc('Bring this session’s ' + (s.terminal ? s.terminal.label + ' ' : '') + 'tab to the front') + '">' + esc(st ? st.label : 'Go ↗') + '</button>'
438
+ return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="Go ↗" data-tip="' + esc('Bring this session’s ' + (s.terminal ? s.terminal.label + ' ' : '') + 'tab to the front') + '">' + esc(st ? st.label : 'Go ↗') + '</button>'
438
439
  }
439
440
 
440
441
  // The short reason a cross-machine button is grey (the full one is its tooltip).
@@ -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: !!(state.starts && state.starts.viewer && state.starts.viewer.local), 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())
@@ -1264,7 +1267,7 @@ function paintNeedsCount(n) {
1264
1267
 
1265
1268
  const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
1266
1269
  function needOpts(extra = {}) {
1267
- return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, expanded: state.needOpen, turns: state.needTurns, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', ...extra }
1270
+ return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, expanded: state.needOpen, turns: state.needTurns, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', composerKeys: isTouchUi(window.matchMedia) ? 'Meta+Enter Control+Enter' : 'Enter Meta+Enter Control+Enter', ...extra }
1268
1271
  }
1269
1272
 
1270
1273
  // ⌘↵ (Ctrl+↵ off a Mac) in a Needs-you text box (the Deny note, a question's
@@ -1393,7 +1396,7 @@ function paintSession(s, data) {
1393
1396
  $app.innerHTML = sessionPageHtml(s, data)
1394
1397
  liveAfterRender(s.id)
1395
1398
  if (kf) {
1396
- const nt = $app.querySelector('.composer-input')
1399
+ const nt = $app.querySelector('.composer-input[data-draft="' + CSS.escape(kf.d || '') + '"]')
1397
1400
  if (nt && nt.dataset.draft === kf.d && !nt.disabled) { nt.focus({ preventScroll: true }); try { const n = nt.value.length; nt.setSelectionRange(Math.min(kf.a, n), Math.min(kf.b, n)) } catch {} }
1398
1401
  } else if (kb && !$app.contains(document.activeElement)) {
1399
1402
  const hits = $app.querySelectorAll(kb)
@@ -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) +
@@ -1594,20 +1599,12 @@ function liveBoxHtml(s, v) {
1594
1599
  // grows, and a round ↑ in its corner (dim until there's something to send).
1595
1600
  // No interrupt reaches Claude Code from the board, so while it works the
1596
1601
  // button still sends: the message waits for its next turn, as before.
1597
- const SEND_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>'
1598
- const CLIP_IC = '<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="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg>'
1599
1602
  const FILE_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>'
1600
1603
  function composerHtml(s, draft, may) {
1601
1604
  const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
1602
- const atts = attachList(s.id)
1603
- const off = !may || (isBlank(draft) && !atts.length)
1604
- return '<div class="composer live-row' + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '" data-sid="' + esc(s.id) + '">' +
1605
- '<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(chipHtml).join('') + '</div>' +
1606
- '<textarea class="composer-input live-text" rows="1" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" autocomplete="off" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd) + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
1607
- // the phone's own picker (Photo Library, Take Photo, Choose File): any file
1608
- '<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + esc(s.id) + '"' + (may ? '' : ' disabled') + '>' +
1609
- '<button type="button" class="composer-attach" data-action="attach" data-id="' + esc(s.id) + '" aria-label="Attach" title="Attach photos or files"' + (may ? '' : ' disabled') + '><span class="composer-clip">' + CLIP_IC + '</span></button>' +
1610
- '<button type="button" class="composer-send" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '" aria-label="Send" title="Send"' + (off ? ' disabled' : '') + '><span class="composer-dot">' + SEND_IC + '</span></button></div>'
1605
+ return composerBoxHtml({ sid: s.id, submit: 'live', sendAction: 'live-send', draftKey: 'live:' + s.id, draft, may,
1606
+ placeholder: 'Message this session…', label: 'Message this session', boxClass: 'live-row', inputClass: 'live-text',
1607
+ attach: true, atts: attachList(s.id), chipHtml, keys: isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd })
1611
1608
  }
1612
1609
 
1613
1610
  // ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
@@ -1895,6 +1892,7 @@ function syncComposer(ta) {
1895
1892
  }
1896
1893
  document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
1897
1894
  // Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
1895
+ // The same for Live and the Needs-you Reply composer: the key presses its own box's ↑.
1898
1896
  document.addEventListener('keydown', (ev) => {
1899
1897
  const ta = ev.target
1900
1898
  if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
@@ -1903,8 +1901,8 @@ document.addEventListener('keydown', (ev) => {
1903
1901
  ev.preventDefault()
1904
1902
  const cb = ta.closest('.composer')
1905
1903
  if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
1906
- const box = ta.closest('.live-box')
1907
- const btn = box && box.querySelector('[data-submit-for="live"]')
1904
+ const box = ta.closest('.live-box') || ta.closest('.need') || ta.closest('.start-box')
1905
+ const btn = box && box.querySelector('[data-submit-for="' + CSS.escape(ta.dataset.submit || 'live') + '"]')
1908
1906
  if (!btn || btn.disabled) return
1909
1907
  const last = Number(box.dataset.kbdAt || 0)
1910
1908
  if (Date.now() - last < 1500) return
@@ -3360,6 +3358,7 @@ function queueSave() { clearTimeout(saveUi.timer); saveUi.timer = setTimeout(pai
3360
3358
 
3361
3359
  async function viewSettings() {
3362
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
3363
3362
  state.relay = relay
3364
3363
  state.gh = gh
3365
3364
  syncUi.data = sync; syncUi.peers = peers; if (st) stUi.data = st
@@ -3398,6 +3397,7 @@ async function viewSettings() {
3398
3397
  phone: phonePane(relay, remote, unavailable),
3399
3398
  security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
3400
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: !!(starts && starts.viewer && starts.viewer.local) }) +
3401
3401
  // a changed machine key needs a click here, so Other machines stays up top then
3402
3402
  (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
3403
3403
  advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
@@ -4149,16 +4149,76 @@ async function render() {
4149
4149
  else if (r.name === 'whats-new') await viewWhatsNew()
4150
4150
  else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
4151
4151
  else if (r.name === 'search') await viewSearch(r.q || '')
4152
+ else if (r.name === 'new') await viewNewSession()
4152
4153
  else if (r.name === 'setup') await viewSetup()
4153
4154
  else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
4154
4155
  else await viewBoard()
4155
4156
  tabsAfterRender()
4157
+ fillHeadlessErrors()
4158
+ // a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
4159
+ for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
4156
4160
  } catch (e) {
4157
4161
  if (e.code === 'login_required') return showLogin()
4158
4162
  $app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
4159
4163
  }
4160
4164
  }
4161
4165
 
4166
+ // ------------------------------------------------------------ New session (0.9.9, ui/start.js)
4167
+
4168
+ const startUi = { data: null, machine: null, repo: null, where: 'repo', allRepos: false, ask: null, busy: new Set(), sending: false, poll: null }
4169
+ async function viewNewSession() {
4170
+ try { startUi.data = await api('GET', '/v1/start') } catch (e) { if (!startUi.data) throw e }
4171
+ const fa = document.activeElement
4172
+ const kf = fa && fa.classList && fa.classList.contains('start-text') ? { a: fa.selectionStart, b: fa.selectionEnd } : null
4173
+ $app.innerHTML = startPageHtml(startUi.data, startUi, { esc, icon, emoji: mEmoji, local: state.viewerLocal !== false && !!(startUi.data.viewer && startUi.data.viewer.local), clock, draft: (state.drafts && state.drafts.start) || '', busy: startUi.sending })
4174
+ const ta = $app.querySelector('.start-text')
4175
+ if (ta) { fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
4176
+ }
4177
+ // Claude Code's own words when a session it ran stopped with an error: text, never markup.
4178
+ const headlessErrors = new Map()
4179
+ function fillHeadlessErrors() {
4180
+ for (const el of $app.querySelectorAll('[data-headless-err]')) {
4181
+ const t = headlessErrors.get(el.dataset.headlessErr) || ''
4182
+ el.textContent = t
4183
+ el.hidden = !t
4184
+ }
4185
+ }
4186
+ async function startSend(btn) {
4187
+ const box = btn.closest('.start-box')
4188
+ const ta = box && box.querySelector('.start-text')
4189
+ const text = ta ? ta.value.trim() : ''
4190
+ if (!text) { if (ta) ta.focus(); return }
4191
+ if (startUi.sending) return
4192
+ const pick = startPick(startUi.data, startUi)
4193
+ if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
4194
+ startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
4195
+ try {
4196
+ const r = await api('POST', '/v1/sessions/start', { machine: pick.m.machine, cwd: pick.repo.path, prompt: text, where: pick.where }, { timeoutMs: 60000 })
4197
+ if (state.drafts) delete state.drafts.start
4198
+ if (r.state === 'started' && r.sessionId) { startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
4199
+ startUi.ask = { ...r, machine: r.machine || pick.m.machine }
4200
+ pollStartAsk()
4201
+ } catch (e) { toast(e.message, 6000) } finally { startUi.sending = false }
4202
+ if (state.route && state.route.name === 'new') render()
4203
+ }
4204
+ // The asker's side of a start that waits for a yes at the Mac.
4205
+ function pollStartAsk() {
4206
+ clearTimeout(startUi.poll)
4207
+ const a = startUi.ask
4208
+ if (!a || !a.id || !['pending', 'starting'].includes(a.state)) return
4209
+ startUi.poll = setTimeout(async () => {
4210
+ try {
4211
+ const r = await api('GET', '/v1/sessions/start/' + encodeURIComponent(a.id) + '?machine=' + encodeURIComponent(a.machine))
4212
+ if (startUi.ask && startUi.ask.id === a.id) {
4213
+ startUi.ask = { ...a, ...r }
4214
+ if (r.state === 'started' && r.sessionId) { startUi.ask = null; location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
4215
+ }
4216
+ } catch (e) { if (e.status === 404) { startUi.ask = { ...a, state: 'failed', error: e.message } } }
4217
+ if (state.route && state.route.name === 'new') render()
4218
+ pollStartAsk()
4219
+ }, 2000)
4220
+ }
4221
+
4162
4222
  // First-run setup wizard (0.9.7, ui/wizard.js). Live updates don't redraw it:
4163
4223
  // it keeps its own step and fetches its own state.
4164
4224
  async function viewSetup() {
@@ -5291,6 +5351,35 @@ document.addEventListener('click', async (ev) => {
5291
5351
  } catch (e) { cuUi.making = false; cuUi.err = e.message; render() }
5292
5352
  return
5293
5353
  }
5354
+ // 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
5355
+ if (action === 'start-pick') {
5356
+ const k = btn.dataset.k, v = btn.dataset.v
5357
+ 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'
5358
+ return render()
5359
+ }
5360
+ if (action === 'start-all-repos') { startUi.allRepos = true; return render() }
5361
+ if (action === 'start-again') { startUi.ask = null; return render() }
5362
+ if (action === 'start-send') return startSend(btn)
5363
+ if (action === 'start-ask-confirm' || action === 'start-ask-decline') {
5364
+ if (!id || startUi.busy.has(id)) return
5365
+ startUi.busy.add(id); render()
5366
+ try {
5367
+ const r = await api('POST', '/v1/sessions/start/' + encodeURIComponent(id) + '/' + (action === 'start-ask-confirm' ? 'confirm' : 'decline'), {}, { timeoutMs: 60000 })
5368
+ 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)
5369
+ startUi.busy.delete(id)
5370
+ if (r.state === 'started' && r.sessionId) { location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
5371
+ } catch (e) { toast(e.message, 5000) }
5372
+ startUi.busy.delete(id)
5373
+ return render()
5374
+ }
5375
+ if (action === 'headless-stop' || action === 'headless-run') {
5376
+ btn.disabled = true
5377
+ try {
5378
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/' + (action === 'headless-stop' ? 'stop' : 'run'), {}, { timeoutMs: 30000 })
5379
+ toast(action === 'headless-stop' ? 'Stopping' : r.already ? 'Already running' : 'Running again')
5380
+ } catch (e) { toast(e.message, 6000) }
5381
+ return render()
5382
+ }
5294
5383
  if (action === 'restore-ask-confirm' || action === 'restore-ask-decline') {
5295
5384
  const id = btn.dataset.id
5296
5385
  if (!id || restoreUi.busy.has(id)) return
@@ -5667,9 +5756,11 @@ async function replyClick(btn) {
5667
5756
  const need = btn.closest('.need')
5668
5757
  const ta = need && need.querySelector('.reply-text')
5669
5758
  const text = ta ? ta.value.trim() : ''
5670
- if (!text) { toast('Type a reply first'); return }
5759
+ if (!text) { toast('Type a reply first'); if (ta && !ta.disabled) ta.focus(); return }
5671
5760
  if (text.startsWith('/')) { toast('Replies can’t start with “/”'); return }
5672
- btn.disabled = true; btn.textContent = 'Sending…'
5761
+ if (btn.classList.contains('sending')) return
5762
+ // the composer's ↑ (0.9.8): it pulses while the reply goes, like Live's
5763
+ btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
5673
5764
  try {
5674
5765
  const r = await api('POST', '/v1/sessions/' + encodeURIComponent(need.dataset.id) + '/reply', { text })
5675
5766
  delete state.drafts['reply:' + need.dataset.id]
@@ -6014,7 +6105,7 @@ document.addEventListener('submit', async (ev) => {
6014
6105
 
6015
6106
  // Live updates over SSE; re-render the current view, at most every 800 ms.
6016
6107
  let pending = null
6017
- 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)
6108
+ 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)
6018
6109
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
6019
6110
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
6020
6111
  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) }
@@ -6028,7 +6119,7 @@ function connect() {
6028
6119
  es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
6029
6120
  es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
6030
6121
  es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
6031
- 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() })
6122
+ 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() })
6032
6123
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
6033
6124
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
6034
6125
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
@@ -6052,6 +6143,11 @@ document.addEventListener('change', async (ev) => {
6052
6143
  } catch (e) { toast(e.message, 5000) }
6053
6144
  return sel.closest('#spacesBox') ? spacesRepaint() : render()
6054
6145
  }
6146
+ if (ev.target.id === 'f-start-allow') { // 0.9.9: this Mac only
6147
+ const cb = ev.target
6148
+ 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) }
6149
+ return viewSettings()
6150
+ }
6055
6151
  if (ev.target.id === 'f-peer-allow') {
6056
6152
  const cb = ev.target
6057
6153
  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) }