@leftoff/cli 0.9.9-beta.7 → 0.9.9-beta.8

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
@@ -113,7 +113,8 @@ Live link is opt-in, per session: only sessions started with `leftoff live` have
113
113
  - **This Mac's own board** starts it at once. **A phone or another Mac** may start one only when that Mac has *Allow starting sessions from other devices* on (Settings → Privacy & security, off by default) **and** *Trust my paired devices* is on. The switch is kept on that Mac (`~/.config/checkpoints/local/`, not the synced folder, so turning it on one Mac never turns it on another) and only that Mac's own browser can change it (loopback; a phone, the relay or a peer gets 403). Every start from another device shows a notification on the Mac. With the switch off, the request waits up to 5 minutes for a yes on that Mac's board, like Remote Restore. At most 3 wait at once; 3 starts at once, then 1 a minute.
114
114
  - **The repo list is folders that Mac has already run Claude Code in** (from its session records; no typed paths). A headless run loads a repo's hooks and `.mcp.json` without the trust dialog, so a folder Claude Code never ran in is treated as a first time:
115
115
  - **Other folder…** browses that Mac's folders: names only (never a file or what's in one), inside your home by real path (a symlink out of home is refused or left out), never the synced Leftoff folder or anything in it, no hidden folders, `node_modules` or `Library`, at most 500 per listing. Another Mac's are read over the peer link (`fs.dirs`, not an open action, the same as any read), a phone's through the relay. Nothing is written.
116
- - **A first time starts at once only from that Mac's own board** (loopback, or your hosted board open on it): you are the trust prompt. From a phone or another Mac it **always** waits for a yes on that Mac's board, even with *Allow starting sessions from other devices* on, and the ask and the notification say "first time in <folder>; this runs its hooks". After one run it's in the list like the rest. Home itself is refused; a folder that isn't a git repo can't get a new worktree.
116
+ - **A first time starts at once only from that Mac's own board** (loopback, or your hosted board open on it): you are the trust prompt. From a phone or another Mac it waits for a yes on that Mac's board, even with *Allow starting sessions from other devices* on (unless it's inside an approved folder, below), and the ask and the notification say "first time in <folder>; this runs its hooks". After one run it's in the list like the rest. Home itself is refused; a folder that isn't a git repo can't get a new worktree.
117
+ - **Approved folders** (per Mac, 0.9.9): with the switch on, a first time inside a folder you approved on that Mac starts from another device without the ask (and notifies that Mac, like any remote start). They're kept in that Mac's own local file (`~/.config/checkpoints/local/`, not `config.json`, never synced), stored as real paths inside your home; home itself, the synced Leftoff folder (or anything inside it), hidden folders and links out of home are refused. A start counts by the folder's real path, so a link that only looks inside an approved folder doesn't. They're added and removed only at the Mac itself: Settings (locked elsewhere), "Always allow in <folder>" on the first-time ask (the folder above it that holds the repos that Mac knows, or its parent; never home), or `leftoff starts allow|disallow <folder>`.
117
118
  - **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.
118
119
  - **Other Macs** reach it over the signed, encrypted peer link (`session.start`, `session.start.get`, `session.headless`, `fs.dirs`; not open actions, so the target Mac's *Allow other machines* applies too).
119
120
  - **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`).
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.9-beta.7",
3
+ "version": "0.9.9-beta.8",
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"
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
29
 
30
30
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
31
- const MOD_VERSION = '0.9.9-beta.7' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.9-beta.8' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.9-beta.7",
3
+ "version": "0.9.9-beta.8",
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
@@ -2114,6 +2114,16 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2114
2114
  .lv-tick { margin-top: 4px; font-size: 11.5px; color: var(--muted); text-align: right; }
2115
2115
  .lv-tick.got span { color: var(--accent); letter-spacing: -2px; padding-right: 2px; }
2116
2116
  .lv-tick.bad { color: var(--red); text-align: left; }
2117
+ /* 0.9.9 Queued: sent while Claude works, not taken in yet: dimmed, a dashed edge, Queued (not Working) */
2118
+ .lv-you.lv-queued { background: transparent; border-style: dashed; border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); }
2119
+ .lv-you.lv-queued .lv-text, .lv-you.lv-queued .lv-who, .lv-you.lv-queued .att-bubble { opacity: .62; }
2120
+ .lv-q { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px 8px; margin-top: 6px; font-size: 12px; color: var(--muted); }
2121
+ .lv-q-tag { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; letter-spacing: .01em; color: var(--muted); }
2122
+ .lv-q-tag::before { content: ""; width: 7px; height: 7px; border-radius: 50%; border: 1.5px solid currentColor; }
2123
+ .lv-q-hint { color: var(--muted); }
2124
+ .lv-q-acts { display: inline-flex; gap: 6px; }
2125
+ .lv-q-btn { min-height: 44px; min-width: 64px; padding: 0 14px; font-size: 14px; }
2126
+ .lv-q-btn:focus:not(:focus-visible) { outline: none; }
2117
2127
  .lv-working .lvs-line { margin-top: 0; }
2118
2128
  .lv-pending .lv-working { margin-top: 4px; }
2119
2129
  .lvs-sep { color: var(--faint); }
@@ -2888,6 +2898,29 @@ del.gd-x { background: var(--gd-del-word); }
2888
2898
  .headless-bar.running { border-color: var(--green); background: var(--tint-green); }
2889
2899
  .headless-bar.paused { border-color: var(--yellow); background: var(--tint-yellow); }
2890
2900
  .headless-bar .btn { min-height: 44px; }
2901
+ /* 0.9.9 Permissions: the New session choice, the session page's switch and its pill beside Live */
2902
+ .start-mode { grid-template-columns: repeat(3, minmax(0, 1fr)); }
2903
+ .start-mode .start-opt-sub { white-space: normal; }
2904
+ .headless-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: -4px 0 10px; }
2905
+ .headless-mode .hm-l { font-size: 12.5px; font-weight: 600; color: var(--muted); }
2906
+ .hm-seg { display: inline-flex; border: 1px solid var(--line); border-radius: 10px; overflow: visible; background: var(--card); }
2907
+ .hm-opt { position: relative; min-height: 44px; min-width: 44px; padding: 0 14px; border: 0; border-left: 1px solid var(--line); background: transparent; color: var(--text2); font: inherit; font-size: 13.5px; cursor: pointer; }
2908
+ .hm-opt:first-child { border-left: 0; border-radius: 10px 0 0 10px; }
2909
+ .hm-opt:last-child { border-radius: 0 10px 10px 0; }
2910
+ .hm-opt[aria-checked="true"] { background: var(--tint-accent); color: var(--text); font-weight: 600; box-shadow: inset 0 0 0 1px var(--accent); }
2911
+ .hm-opt:disabled { opacity: .55; cursor: default; }
2912
+ .hm-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; z-index: 1; }
2913
+ .hm-opt[data-tip]::after, .mode-pill[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); left: 0; width: max-content; max-width: min(280px, 80vw); 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.35; font-weight: 500; text-align: left; white-space: normal; pointer-events: none; z-index: 20; }
2914
+ @media (hover: hover) { .hm-opt[data-tip]:hover::after, .mode-pill[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
2915
+ .hm-opt[data-tip]:focus-visible::after, .mode-pill[data-tip]:focus-visible::after { display: block; }
2916
+ .hm-hint { flex-basis: 100%; margin: 0; font-size: 12px; color: var(--muted); }
2917
+ .mode-pill { position: relative; display: inline-block; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--bg2); color: var(--text2); font-size: 11.5px; font-weight: 600; line-height: 1.5; white-space: nowrap; }
2918
+ .mode-pill.m-auto { border-color: var(--accent); background: var(--tint-accent); color: var(--text); }
2919
+ .mode-pill.m-acceptEdits { border-color: var(--yellow); background: var(--tint-yellow); color: var(--text); }
2920
+ @media (max-width: 640px) {
2921
+ .hm-seg { display: flex; width: 100%; }
2922
+ .hm-opt { flex: 1 1 0; padding: 0 6px; }
2923
+ }
2891
2924
  .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; }
2892
2925
  @media (max-width: 640px) {
2893
2926
  .start-opts { grid-template-columns: 1fr; }
@@ -2984,3 +3017,76 @@ del.gd-x { background: var(--gd-del-word); }
2984
3017
  .form label.check.locked input { cursor: not-allowed; }
2985
3018
  .form label.check .lock { display: inline-flex; color: var(--muted); }
2986
3019
  .form label.check .lock .ic { width: 14px; height: 14px; }
3020
+ /* 0.9.9 Settings → Approved folders: a list with Remove, and an add field (16px) */
3021
+ .form label[for="f-start-approve"] { align-self: start; padding-top: 12px; }
3022
+ .approved { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
3023
+ .approved-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
3024
+ .approved-list li { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 4px 4px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg2); }
3025
+ .approved-path { flex: 1; min-width: 0; overflow-wrap: anywhere; color: var(--text); font-size: 13px; }
3026
+ .approved .mono, .start-ask-banner .mono { font-family: var(--mono); }
3027
+ .approved-x { flex: none; min-height: 44px; min-width: 44px; justify-content: center; gap: 4px; }
3028
+ .approved-x .ic { width: 14px; height: 14px; }
3029
+ .approved-none { color: var(--faint); font-size: 13px; }
3030
+ .approved-add { display: flex; gap: 8px; align-items: center; }
3031
+ .form .approved-add input { flex: 1; min-width: 0; height: 44px; font-size: 16px; }
3032
+ .approved-add .btn { min-height: 44px; flex: none; }
3033
+ .approved.locked .approved-add input, .approved.locked .approved-add .btn { cursor: not-allowed; }
3034
+ .approved .lock { display: inline-flex; color: var(--muted); }
3035
+ .approved .lock .ic { width: 14px; height: 14px; }
3036
+ .approved-err { color: var(--red); font-size: 12.5px; overflow-wrap: anywhere; }
3037
+ .start-ask-banner .btn.approve { max-width: 100%; }
3038
+ .start-ask-banner .btn.approve .mono { overflow-wrap: anywhere; white-space: normal; text-align: left; }
3039
+ @media (max-width: 640px) {
3040
+ .start-ask-banner > .btn.approve { order: 1; flex: 1 1 100%; }
3041
+ }
3042
+
3043
+ /* 0.9.9 the ask in Live: a waiting permission prompt (or question) takes the
3044
+ composer's place at the bottom of Live, and stays in reach while you read the
3045
+ thread (sticky inside the Live box, above the phone's tab bar and the home
3046
+ indicator); the thread keeps a short row for each ask where it happened. */
3047
+ .ask-dock-wrap { position: sticky; bottom: max(12px, calc(env(safe-area-inset-bottom) + 8px)); z-index: 6; margin-top: 10px; }
3048
+ .need.ask-dock { gap: 10px; padding: 12px 14px; border-radius: 16px; border-color: color-mix(in srgb, var(--red) 50%, var(--line)); background: var(--card); box-shadow: 0 8px 28px rgba(15, 20, 35, .14), 0 1px 3px rgba(15, 20, 35, .08); }
3049
+ [data-theme="dark"] .need.ask-dock { background: var(--card2); box-shadow: 0 8px 28px rgba(0, 0, 0, .45); }
3050
+ .ask-dock .need-head { font-size: 13px; }
3051
+ .ask-count { font-size: 12px; font-weight: 600; color: var(--text2); background: var(--field); border: 1px solid var(--line); border-radius: 10px; padding: 1px 8px; font-variant-numeric: tabular-nums; }
3052
+ .ask-what { display: grid; gap: 6px; min-width: 0; }
3053
+ .ask-verb { font-weight: 600; font-size: 15px; color: var(--text); }
3054
+ .ask-detail { display: block; font-family: var(--mono); font-size: 13px; line-height: 1.5; background: var(--field); border: 1px solid var(--line); border-radius: 8px; padding: 6px 9px; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-height: 40vh; overflow: auto; }
3055
+ .ask-detail.long { max-height: calc(3 * 1.5em + 12px); overflow: hidden; }
3056
+ .ask-detail.long.open { max-height: 40vh; overflow: auto; }
3057
+ .ask-more, .ask-reply-instead { justify-self: start; min-height: 44px; padding: 0 2px; margin: -6px 0 -8px; border: 0; background: none; color: var(--accent); font: inherit; font-size: 14px; font-weight: 500; cursor: pointer; -webkit-tap-highlight-color: transparent; }
3058
+ .ask-reply-instead { color: var(--muted); }
3059
+ .ask-more:hover, .ask-reply-instead:hover { text-decoration: underline; }
3060
+ .ask-more:focus-visible, .ask-reply-instead:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
3061
+ .ask-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: stretch; }
3062
+ .ask-actions .btn, .ask-dock .deny-form .btn { min-height: 44px; font-size: 14px; padding: 8px 16px; justify-content: center; white-space: nowrap; }
3063
+ .ask-actions .btn.ask-allow { min-width: 120px; font-size: 15px; }
3064
+ .ask-dock .deny-form .deny-note { flex: 1 1 220px; min-width: 0; min-height: 44px; box-sizing: border-box; font-size: 16px; }
3065
+ @media (max-width: 699.98px) {
3066
+ .ask-dock-wrap { bottom: calc(var(--bar-h) + 8px + env(safe-area-inset-bottom)); }
3067
+ body.nav-hidden .ask-dock-wrap { bottom: max(10px, calc(env(safe-area-inset-bottom) + 6px)); }
3068
+ .need.ask-dock { padding: 12px; }
3069
+ .ask-actions .btn { flex: 1 1 calc(50% - 4px); font-size: 15px; min-height: 48px; }
3070
+ .ask-actions .btn.ask-allow, .ask-actions .btn.ask-deny { flex-basis: 100%; }
3071
+ .ask-actions .always-wrap { flex: 1 1 calc(50% - 4px); }
3072
+ .ask-actions .always-wrap .btn { flex: 1 1 auto; width: 100%; }
3073
+ .ask-dock .deny-form .btn { flex: 1 1 calc(50% - 4px); min-height: 48px; }
3074
+ .ask-dock .deny-form .deny-note { flex-basis: 100%; }
3075
+ }
3076
+ /* after "Reply instead": one line above the composer, Answer brings the card back */
3077
+ .ask-slim { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 4px 4px 4px 12px; border: 1px solid color-mix(in srgb, var(--red) 40%, var(--line)); border-radius: 12px; background: var(--tint-red); font-size: 13px; min-width: 0; }
3078
+ .ask-slim .need-dot { background: var(--red); }
3079
+ .ask-slim-t { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text2); }
3080
+ .ask-slim .btn { flex: none; min-height: 44px; min-width: 44px; justify-content: center; }
3081
+ /* the row in the thread: what it asked, and how it was answered */
3082
+ .lv-ask { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 8px; row-gap: 2px; margin: 0 0 8px; padding: 6px 10px; border-left: 3px solid var(--line-strong); border-radius: 0 8px 8px 0; background: color-mix(in srgb, var(--muted) 7%, transparent); font-size: 12.5px; color: var(--text2); min-width: 0; }
3083
+ .lv-ask .lva-what { flex: 1 1 200px; min-width: 0; overflow-wrap: anywhere; }
3084
+ .lv-ask .lva-verb { font-weight: 600; }
3085
+ .lv-ask code { font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; }
3086
+ .lv-ask .lva-state { flex: none; max-width: 100%; font-weight: 600; font-size: 12px; overflow-wrap: anywhere; }
3087
+ .lv-ask.t-ok { border-left-color: var(--green); } .lv-ask.t-ok .lva-state { color: var(--green); }
3088
+ .lv-ask.t-auto { border-left-color: var(--accent); } .lv-ask.t-auto .lva-state { color: var(--accent); }
3089
+ .lv-ask.t-no { border-left-color: var(--red); } .lv-ask.t-no .lva-state { color: var(--red); }
3090
+ .lv-ask.t-wait { border-left-color: var(--red); background: var(--tint-red); } .lv-ask.t-wait .lva-state { color: var(--red); }
3091
+ .ask-note { color: var(--muted); font-size: 13px; margin-top: -4px; }
3092
+ .lv-ask .lva-what { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; }
package/ui/app.js CHANGED
@@ -15,13 +15,13 @@ 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, composerBoxHtml } from './composer.js' // 0.9.8 the Live composer
19
- import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath } from './start.js' // 0.9.9 New session
18
+ import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml, keepFocusAfterSend, letGoOfKeyboard } from './composer.js' // 0.9.8 the Live composer
19
+ import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, modePillHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved } from './start.js' // 0.9.9 New session
20
20
  import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
21
21
  import { wireTips } from './tips.js'
22
22
  import { factHtml } from './facts.js' // 0.9.9 the session page's info cards
23
23
  import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
24
- import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
24
+ import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
25
25
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
26
26
  import { mdInline, renderSection } from './changelog-md.js'
27
27
  import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
@@ -1398,6 +1398,7 @@ function paintSession(s, data) {
1398
1398
  liveKeeper.before($app)
1399
1399
  $app.innerHTML = sessionPageHtml(s, data)
1400
1400
  liveAfterRender(s.id)
1401
+ fitAskDetail($app)
1401
1402
  if (kf) {
1402
1403
  const nt = $app.querySelector('.composer-input[data-draft="' + CSS.escape(kf.d || '') + '"]')
1403
1404
  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 {} }
@@ -1495,14 +1496,14 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
1495
1496
  // 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
1496
1497
  '<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>'
1497
1498
  html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
1498
- html += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false }) // 0.9.9: started from the board: Stop / Resume
1499
+ html += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false, busy: modeBusy.get(s.id) || null, local: state.viewerLocal !== false }) // 0.9.9: started from the board: Stop / Resume, Permissions
1499
1500
  if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
1500
1501
  html += '<div class="facts">' +
1501
1502
  fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : ''), '', { copy: (s.repo && s.repo.root) || null, what: 'repo folder' }) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap', { copy: s.worktree.path || null, what: 'worktree folder' }) : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—', '', { copy: s.cwd || null, clip: 34 }) + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
1502
1503
  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, '', { copy: s.id, what: 'session id' }) +
1503
1504
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown', '', { copy: s.live === 'open' && s.liveVia === 'pid' && s.pid != null ? String(s.pid) : null, what: 'process id' }) +
1504
1505
  fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || ''), '', { copy: s.shortId ? 'cp:' + s.shortId : null, what: 'terminal tab id' }) + '</div>'
1505
- if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
1506
+ if (isLive(s)) html += (s.needsYou && !askDocked(s) ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV) // 0.9.9: a permission ask or question docks in Live, in the composer's place
1506
1507
  html += parkRow(s)
1507
1508
  html += spacePicker(s, spv, 'session', s.id)
1508
1509
  html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
@@ -1512,7 +1513,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
1512
1513
  // 0.9.8: the last tools it ran and its folder's changes, folded; loads when you open it
1513
1514
  html += '<section class="wid" id="what-it-did">' + whatItDidSection(s, widOf(s.id)) + '</section>'
1514
1515
  // 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
1515
- if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
1516
+ if (s.needsYou && !isLive(s) && !askDocked(s)) html += sessionNeedsStrip(s)
1516
1517
  if (!isLive(s)) html += liveBoxHtml(s, liveV)
1517
1518
  if (s.statusChanges && s.statusChanges.length) {
1518
1519
  html += '<div class="striplabel">Status history</div><div class="strip">' + s.statusChanges.slice(-24).map((c) => '<span class="s-' + esc(c.status) + '" title="' + esc(clock(c.at)) + '">' + esc(LABEL[c.status] || c.status) + '</span>').join('') + '</div>'
@@ -1547,6 +1548,16 @@ function sessionNeedsStrip(s) { return '<section class="needs-strip one" aria-la
1547
1548
  // 0.9.8 polish: the thread is ui/live-view.js (entries, status, sticking to the
1548
1549
  // bottom); Claude's answer is the session's own (markdown, like the terminal).
1549
1550
  const liveKeeper = createLiveKeeper()
1551
+ // A long command folds to three lines with "Show all"; where it fits anyway (a wide window), no button.
1552
+ function fitAskDetail(root) {
1553
+ for (const c of root.querySelectorAll('.ask-dock .ask-detail.long:not(.open)')) {
1554
+ if (c.scrollHeight > c.clientHeight + 2) continue
1555
+ c.classList.remove('long')
1556
+ const b = c.parentElement && c.parentElement.querySelector('.ask-more'); if (b) b.hidden = true
1557
+ }
1558
+ }
1559
+ // 0.9.9 the docked ask: which ones show their whole command, and which you chose to reply to instead (this page only)
1560
+ const askUi = { open: new Set(), reply: new Set() }
1550
1561
  const liveSeenKey = (id) => 'lo-live-seen:' + id
1551
1562
  function liveSeen(id) { try { return localStorage.getItem(liveSeenKey(id)) } catch { return null } }
1552
1563
  function markLiveSeen(id, at) { try { localStorage.setItem(liveSeenKey(id), at || new Date().toISOString()) } catch {} }
@@ -1593,18 +1604,22 @@ function liveBoxHtml(s, v) {
1593
1604
  // a session Leftoff started that isn't running now: what was said, read-only (Resume here sends again)
1594
1605
  if (kind === 'readonly') {
1595
1606
  if (!msgs.length) return ''
1596
- 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">Not running. Resume it to send more.</span></div>' +
1607
+ return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2>' + modePillHtml(s, { esc }) + '<span class="faint">Not running. Resume it to send more.</span></div>' +
1597
1608
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-label="Messages with this session">' +
1598
- earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
1609
+ earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id), inserts: askRows(s), earlier: (v && v.earlier) || 0, may: false }) + '</ol>' +
1599
1610
  '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div></section>'
1600
1611
  }
1601
- 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>' +
1612
+ // 0.9.9: a waiting permission ask (or question) takes the composer's place; "Reply instead" brings the composer back for that ask
1613
+ const rows = askRows(s)
1614
+ const bottom = askBottom(s, askUi.reply)
1615
+ const dock = bottom === 'dock', docked = bottom !== 'composer'
1616
+ return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2>' + modePillHtml(s, { esc }) + '<span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
1602
1617
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
1603
- earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
1604
- '<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
1618
+ earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id), inserts: rows, earlier: (v && v.earlier) || 0, may }) + '</ol>' +
1619
+ '<p class="faint live-empty"' + (msgs.length || rows.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
1605
1620
  '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
1606
1621
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
1607
- composerHtml(s, draft, may) +
1622
+ (dock ? '<div class="ask-dock-wrap">' + askDockHtml({ ...s.needsYou, session: s }, needOpts({ askOpen: askUi.open })) + '</div>' : (docked ? askSlimHtml(s.needsYou) : '') + composerHtml(s, draft, may)) +
1608
1623
  (may ? '' : '<p class="faint">Sending from another device is off. On a Mac, turn on Settings → Privacy &amp; security → Trust my paired devices.</p>') + '</section>'
1609
1624
  }
1610
1625
  // The Live composer: one rounded box, a textarea that starts at one line and
@@ -2102,6 +2117,38 @@ function onWake(ev) {
2102
2117
  }
2103
2118
  for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
2104
2119
  document.addEventListener('visibilitychange', onWake)
2120
+ // 0.9.9 Cancel / Edit on a Queued message: the daemon takes it back from
2121
+ // Claude Code's queue (only while it still waits; too late says so plainly).
2122
+ // Edit then puts its words (and files) back in the box, after anything typed.
2123
+ async function queuedClick(btn, edit) {
2124
+ const box = btn.closest('.live-box')
2125
+ const sid = box && box.dataset.id
2126
+ const msg = btn.dataset.msg
2127
+ if (!sid || !msg || btn.disabled) return
2128
+ const li = btn.closest('.lv')
2129
+ if (li) li.querySelectorAll('.lv-q-btn').forEach((b) => { b.disabled = true })
2130
+ btn.setAttribute('aria-busy', 'true')
2131
+ try {
2132
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live/' + encodeURIComponent(msg) + '/cancel', {})
2133
+ const s = state.sessions.find((x) => x.id === sid)
2134
+ if (edit) {
2135
+ const typed = ((state.drafts && state.drafts['live:' + sid]) || '').trim()
2136
+ state.drafts['live:' + sid] = typed ? typed + '\n\n' + (r.text || '') : (r.text || '')
2137
+ const atts = (r.attachments || []).filter((a) => a && a.id)
2138
+ if (atts.length && !(s && s.isLocal === false)) attachUi.by.set(sid, [...attachList(sid), ...atts.map((a) => ({ lid: 'a' + (++attachUi.seq), sid, file: null, name: a.name || 'file', size: a.size || 0, type: a.type || '', kind: a.kind || 'file', status: 'done', sent: a.size || 0, total: a.size || 0, id: a.id, preview: null, thumb: null, error: null }))])
2139
+ else if (atts.length) toast('Its files didn’t come back: attach them again.', 4200)
2140
+ }
2141
+ toast(edit ? 'Taken back · edit it and send again' : 'Cancelled · Claude never saw it', 3000)
2142
+ await refreshLive('cancel')
2143
+ await render()
2144
+ if (edit) { const ta = document.querySelector('.live-box .composer-input'); if (ta && !ta.disabled) { ta.value = state.drafts['live:' + sid] || ''; fitComposer(ta); ta.focus({ preventScroll: true }) } }
2145
+ } catch (e) {
2146
+ toast(e.message, 4200)
2147
+ await refreshLive('cancel')
2148
+ await render()
2149
+ }
2150
+ }
2151
+
2105
2152
  async function liveSendClick(btn) {
2106
2153
  const box = btn.closest('.live-box')
2107
2154
  const ta = box && box.querySelector('.live-text')
@@ -2110,6 +2157,7 @@ async function liveSendClick(btn) {
2110
2157
  if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
2111
2158
  if (btn.disabled || btn.classList.contains('sending')) return
2112
2159
  const hadFocus = !!(ta && document.activeElement === ta)
2160
+ let sent = false // 0.9.9: went through: a phone's keyboard closes
2113
2161
  btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
2114
2162
  try {
2115
2163
  // 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
@@ -2123,6 +2171,7 @@ async function liveSendClick(btn) {
2123
2171
  if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
2124
2172
  if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
2125
2173
  if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
2174
+ else sent = true
2126
2175
  const ol = box && box.querySelector('.live-log')
2127
2176
  if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
2128
2177
  setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
@@ -2131,7 +2180,11 @@ async function liveSendClick(btn) {
2131
2180
  const nb = document.querySelector('.live-box [data-action="live-send"]')
2132
2181
  if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
2133
2182
  const nt = document.querySelector('.live-box .composer-input')
2134
- if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
2183
+ if (nt) {
2184
+ fitComposer(nt)
2185
+ if (!keepFocusAfterSend({ ok: sent, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document)
2186
+ else if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true })
2187
+ }
2135
2188
  }
2136
2189
  }
2137
2190
 
@@ -3385,7 +3438,7 @@ async function viewSettings() {
3385
3438
  const panes = {
3386
3439
  general: '<div class="section">Needs you</div>' +
3387
3440
  '<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a notification when a prompt or question has waited ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
3388
- '<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
3441
+ '<div class="hint">They come from “Leftoff” on the Mac the session runs on; a click opens the session.</div>' +
3389
3442
  '<div class="full push-slot" data-push-slot hidden></div>' +
3390
3443
  '<div class="section">Appearance</div>' +
3391
3444
  '<label>Theme</label><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div>' +
@@ -3436,6 +3489,13 @@ async function viewSettings() {
3436
3489
  SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
3437
3490
  '<div class="full set-save" id="setSave"><span class="faint" id="setSaveNote">Saves every section to config.json in your Leftoff folder.</span><button class="btn primary" type="submit" disabled aria-disabled="true">' + icon('check') + 'Save</button></div></form></div></div>'
3438
3491
  showSettingsSection(r)
3492
+ approvedUi.info = starts; approvedUi.err = ''
3493
+ fillApproved($app, starts) // approved folders: paths as text
3494
+ const ap = document.getElementById('f-start-approve')
3495
+ if (ap) {
3496
+ ap.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); approvedAdd() } })
3497
+ ap.addEventListener('input', () => { if (approvedUi.err) { approvedUi.err = ''; fillApproved($app, approvedUi.info) } })
3498
+ }
3439
3499
  paintSettingsDots()
3440
3500
  syncProviderFields()
3441
3501
  // Claim your name (0.6.1): Enter sends the link instead of saving the form; a link already out keeps polling.
@@ -4183,6 +4243,7 @@ async function render() {
4183
4243
 
4184
4244
  // this Mac's own browser, or the hosted board open on it (the direct door)
4185
4245
  function startViewerLocal(v) { return startViewerAt(v, state.viewerLocal !== false) }
4246
+ const modeBusy = new Map() // 0.9.9: a session whose Permissions switch is on its way: id -> mode
4186
4247
  const startUi = { data: null, machine: null, repo: null, where: 'repo', allRepos: false, ask: null, busy: new Set(), sending: false, poll: null, browse: null, custom: null }
4187
4248
  async function viewNewSession() {
4188
4249
  try { startUi.data = await api('GET', '/v1/start') } catch (e) { if (!startUi.data) throw e }
@@ -4241,6 +4302,33 @@ document.addEventListener('click', (ev) => {
4241
4302
  ev.preventDefault()
4242
4303
  pill.classList.toggle('show')
4243
4304
  }, true)
4305
+ // 0.9.9 Settings → Approved folders: add (the Mac checks it: inside home, not
4306
+ // home itself, not the synced folder; an inline error otherwise, and what you
4307
+ // typed stays) and remove. This Mac only (the server checks).
4308
+ const approvedUi = { info: null, err: '', busy: false }
4309
+ async function approvedAdd() {
4310
+ const inp = document.getElementById('f-start-approve')
4311
+ if (!inp || inp.disabled || approvedUi.busy) return
4312
+ const want = typedPath(inp.value)
4313
+ const fail = (m) => { approvedUi.err = m; fillApproved($app, approvedUi.info, m); inp.focus() }
4314
+ if (!want) return fail('Type a folder, like ~/src.')
4315
+ approvedUi.busy = true
4316
+ try {
4317
+ const r = await api('POST', '/v1/starts/approved', { path: want })
4318
+ approvedUi.busy = false
4319
+ toast(r.added ? 'Approved ' + r.folder + (r.allowRemote ? '' : '. Turn on Allow starting sessions from other devices for it to skip the ask') : r.coveredBy ? 'Already inside ' + r.coveredBy + ', which is approved' : r.folder + ' is already approved', r.added && !r.allowRemote ? 6000 : 3000)
4320
+ await viewSettings()
4321
+ const again = document.getElementById('f-start-approve'); if (again) again.focus({ preventScroll: true })
4322
+ } catch (e) { approvedUi.busy = false; fail(e.message) }
4323
+ }
4324
+ async function approvedRemove(i) {
4325
+ const a = approvedUi.info && (approvedUi.info.approved || [])[i]
4326
+ if (!a || approvedUi.busy) return
4327
+ approvedUi.busy = true
4328
+ try { const r = await api('POST', '/v1/starts/approved/remove', { path: a.path }); toast(r.folder + ' is no longer approved') } catch (e) { toast(e.message, 5000) }
4329
+ approvedUi.busy = false
4330
+ await viewSettings()
4331
+ }
4244
4332
  // 0.9.9: a setting that saves the moment you change it (no Save button) says
4245
4333
  // so beside it: "Saved ✓" for a moment, or what went wrong, inline.
4246
4334
  const savedTimers = new Map()
@@ -4275,8 +4363,9 @@ async function startSend(btn) {
4275
4363
  if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
4276
4364
  startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
4277
4365
  try {
4278
- const r = await api('POST', '/v1/sessions/start', { machine: pick.m.machine, cwd: pick.repo.path, prompt: text, where: pick.where }, { timeoutMs: 60000 })
4366
+ const r = await api('POST', '/v1/sessions/start', { machine: pick.m.machine, cwd: pick.repo.path, prompt: text, where: pick.where, mode: pick.mode }, { timeoutMs: 60000 })
4279
4367
  if (state.drafts) delete state.drafts.start
4368
+ if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // 0.9.9: a phone's keyboard closes
4280
4369
  if (r.state === 'started' && r.sessionId) { startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
4281
4370
  startUi.ask = { ...r, machine: r.machine || pick.m.machine }
4282
4371
  pollStartAsk()
@@ -5445,7 +5534,7 @@ document.addEventListener('click', async (ev) => {
5445
5534
  // 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
5446
5535
  if (action === 'start-pick') {
5447
5536
  const k = btn.dataset.k, v = btn.dataset.v
5448
- if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null } else if (k === 'repo') startUi.repo = v; else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'
5537
+ if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null } else if (k === 'repo') startUi.repo = v; else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v
5449
5538
  return render()
5450
5539
  }
5451
5540
  // Other folder…: open the browser at ~, walk it, use a folder
@@ -5491,18 +5580,31 @@ document.addEventListener('click', async (ev) => {
5491
5580
  }
5492
5581
  if (action === 'start-again') { startUi.ask = null; return render() }
5493
5582
  if (action === 'start-send') return startSend(btn)
5494
- if (action === 'start-ask-confirm' || action === 'start-ask-decline') {
5583
+ if (action === 'start-approved-add') return approvedAdd()
5584
+ if (action === 'start-approved-remove') return approvedRemove(Number(btn.dataset.i))
5585
+ if (action === 'start-ask-confirm' || action === 'start-ask-decline' || action === 'start-ask-approve') {
5495
5586
  if (!id || startUi.busy.has(id)) return
5496
5587
  startUi.busy.add(id); render()
5497
5588
  try {
5498
- const r = await api('POST', '/v1/sessions/start/' + encodeURIComponent(id) + '/' + (action === 'start-ask-confirm' ? 'confirm' : 'decline'), {}, { timeoutMs: 60000 })
5499
- 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)
5589
+ // "Always allow in <folder>": approves that folder here, then starts this one
5590
+ const r = await api('POST', '/v1/sessions/start/' + encodeURIComponent(id) + '/' + (action === 'start-ask-decline' ? 'decline' : 'confirm'), action === 'start-ask-approve' ? { approve: true } : {}, { timeoutMs: 60000 })
5591
+ if (r.approved && r.state !== 'failed') toast((r.state === 'started' ? 'Started. ' : '') + 'Folders inside ' + r.approved + ' now start without asking' + (r.allowRemote ? '' : ' once Allow starting sessions from other devices is on'), 5000)
5592
+ else toast(r.state === 'started' ? 'Started' : r.state === 'switched' ? 'Switched to ' + modeLabel(r.mode) : r.state === 'declined' ? 'Declined' : r.state === 'failed' ? 'Didn’t start: ' + (r.error || '') : 'That request was ' + r.state, r.state === 'failed' ? 6000 : 2500)
5500
5593
  startUi.busy.delete(id)
5501
5594
  if (r.state === 'started' && r.sessionId) { location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
5502
5595
  } catch (e) { toast(e.message, 5000) }
5503
5596
  startUi.busy.delete(id)
5504
5597
  return render()
5505
5598
  }
5599
+ // 0.9.9 Permissions of a session Leftoff runs: switch it (or, from another device, ask that Mac)
5600
+ if (action === 'headless-mode') {
5601
+ if (!id || modeBusy.has(id) || btn.getAttribute('aria-checked') === 'true') return
5602
+ modeBusy.set(id, btn.dataset.mode); render()
5603
+ try { modeToast(await switchMode(id, btn.dataset.mode)) } catch (e) { toast(e.message, 6000) }
5604
+ modeBusy.delete(id)
5605
+ return render()
5606
+ }
5607
+ if (action === 'ask-auto') return autoRestClick(btn)
5506
5608
  if (action === 'headless-stop' || action === 'headless-run') {
5507
5609
  btn.disabled = true
5508
5610
  try {
@@ -5694,6 +5796,22 @@ document.addEventListener('click', async (ev) => {
5694
5796
  return paintDigestFilter()
5695
5797
  }
5696
5798
  // ---- Needs you (v0.4.4)
5799
+ // 0.9.9 the docked ask: open a long command in place; reply instead (the composer comes back); back to the card
5800
+ if (action === 'ask-expand') {
5801
+ const code = btn.closest('.ask-dock') && btn.closest('.ask-dock').querySelector('.ask-detail')
5802
+ const open = !askUi.open.has(btn.dataset.ask)
5803
+ if (open) askUi.open.add(btn.dataset.ask); else askUi.open.delete(btn.dataset.ask)
5804
+ if (code) code.classList.toggle('open', open)
5805
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.textContent = open ? 'Show less' : 'Show all'
5806
+ return
5807
+ }
5808
+ if (action === 'ask-reply-instead' || action === 'ask-back') {
5809
+ if (action === 'ask-reply-instead') askUi.reply.add(btn.dataset.ask); else askUi.reply.delete(btn.dataset.ask)
5810
+ await render()
5811
+ if (action === 'ask-reply-instead') { const ta = document.querySelector('.live-box .composer-input'); if (ta && !ta.disabled) ta.focus({ preventScroll: true }) }
5812
+ else { const a = document.querySelector('.ask-dock [data-action="ask-answer"][data-choice="allow"], .ask-dock button:not([disabled])'); if (a) a.focus({ preventScroll: true }) }
5813
+ return
5814
+ }
5697
5815
  if (action === 'ask-deny-open' || action === 'ask-deny-close') {
5698
5816
  const need = btn.closest('.need')
5699
5817
  const form = need && need.querySelector('.deny-form')
@@ -5709,6 +5827,7 @@ document.addEventListener('click', async (ev) => {
5709
5827
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
5710
5828
  if (action === 'reply-send') return replyClick(btn)
5711
5829
  if (action === 'live-send') return liveSendClick(btn)
5830
+ if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
5712
5831
  if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
5713
5832
  if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
5714
5833
  if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
@@ -5843,6 +5962,28 @@ document.addEventListener('click', async (ev) => {
5843
5962
 
5844
5963
  // Answer a permission prompt or a question from the board. The daemon hands it
5845
5964
  // to the waiting hook or mod; the terminal's own prompt may already have won.
5965
+ // 0.9.9 Permissions mid-session (src/session-start.js switchMode): switched, or waiting for a yes at that Mac.
5966
+ function switchMode(id, mode) { return api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/mode', { mode }, { timeoutMs: 30000 }) }
5967
+ function modeToast(r) {
5968
+ if (r && r.state === 'pending') toast('Asked ' + (r.machine || 'that Mac') + ': someone there says yes, then it switches', 5000)
5969
+ else toast('Permissions: ' + modeLabel(r && r.mode) + (r && r.running === false ? ' (when it runs again)' : ''), 3000)
5970
+ }
5971
+ // "Auto-approve the rest" on a permission card: allow this call, and switch the session to Auto.
5972
+ async function autoRestClick(btn) {
5973
+ const need = btn.closest('.need')
5974
+ if (!need || !need.dataset.ask) return
5975
+ const sid = need.dataset.id
5976
+ need.querySelectorAll('button, input, textarea').forEach((el) => { if (!el.classList.contains('go')) el.disabled = true })
5977
+ btn.textContent = 'Switching…'
5978
+ let sw = null
5979
+ try { sw = await switchMode(sid, 'auto') } catch (e) { toast(e.message, 6000); render(); return }
5980
+ try {
5981
+ await api('POST', '/v1/asks/' + encodeURIComponent(need.dataset.ask) + '/answer', { action: 'allow', auto: true }) // 0.9.9: the thread's row says Auto-approved
5982
+ toast(sw && sw.state === 'pending' ? 'Allowed. Auto waits for a yes on ' + (sw.machine || 'that Mac') : 'Allowed; the rest of this session runs in Auto', 4000)
5983
+ } catch (e) { toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200) }
5984
+ render()
5985
+ }
5986
+
5846
5987
  async function answerClick(btn, action) {
5847
5988
  const need = btn.closest('.need, .ql-item') // 0.9.8: Quick look's Allow / Deny are the same buttons
5848
5989
  if (!need) return
@@ -5873,6 +6014,7 @@ async function answerClick(btn, action) {
5873
6014
  try {
5874
6015
  const r = await api('POST', '/v1/asks/' + encodeURIComponent(askId) + '/answer', body)
5875
6016
  toast(r.summary + (r.delivered ? ' · Claude Code has it' : ' · waiting for Claude Code to pick it up'), 3600)
6017
+ if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // a typed answer sent: a phone's keyboard closes
5876
6018
  for (const k of Object.keys(state.drafts)) if (k.includes(askId)) delete state.drafts[k]
5877
6019
  } catch (e) {
5878
6020
  toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
@@ -5896,6 +6038,7 @@ async function replyClick(btn) {
5896
6038
  const r = await api('POST', '/v1/sessions/' + encodeURIComponent(need.dataset.id) + '/reply', { text })
5897
6039
  delete state.drafts['reply:' + need.dataset.id]
5898
6040
  toast(r.queuedBehindTurn ? 'Sent · Claude reads it when the current turn ends' : 'Sent · Claude has it as its next prompt', 3600)
6041
+ if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // a phone's keyboard closes
5899
6042
  } catch (e) { toast(e.message, 4200) } finally { render() }
5900
6043
  }
5901
6044
 
package/ui/composer.js CHANGED
@@ -34,6 +34,18 @@ export function isTouchUi(matchMedia) {
34
34
  } catch { return false }
35
35
  }
36
36
 
37
+ // 0.9.9 After a send that went through, a phone's keyboard closes: the text
38
+ // box lets go of focus. On a Mac (a fine pointer) focus stays, so you can keep
39
+ // typing. A send that failed keeps its text and its focus everywhere.
40
+ export function keepFocusAfterSend({ ok = false, touch = false } = {}) { return !ok || !touch }
41
+ // Let go of the focused text box (the on-screen keyboard closes); true if one had it.
42
+ export function letGoOfKeyboard(doc) {
43
+ const a = doc && doc.activeElement
44
+ if (!a || a === doc.body || !/^(TEXTAREA|INPUT)$/.test(String(a.tagName || '').toUpperCase()) || typeof a.blur !== 'function') return false
45
+ a.blur()
46
+ return true
47
+ }
48
+
37
49
  // The height the box should be for its content: scrollHeight is the
38
50
  // textarea's at height:auto (padding included), lineHeight and padY in px.
39
51
  // -> { height, scroll } where scroll says the content is taller than the cap.
package/ui/live-status.js CHANGED
@@ -16,6 +16,9 @@
16
16
  // folded into that turn (folded: the id of the message that owns the turn):
17
17
  // it has no Working bubble of its own, and when the turn ends it is
18
18
  // "Answered with the reply above".
19
+ // 0.9.9: until Claude takes it in (at its next step, or when the turn ends)
20
+ // such a message is Queued (queued: true; cancellable: Leftoff can still take
21
+ // it back). Anything Claude does with it ends that.
19
22
 
20
23
  export const LIVE_QUIET_MS = 3 * 60 * 1000
21
24
  export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
@@ -27,11 +30,15 @@ export function initStatus(at = 0) { return { phase: 'sent', at, last: at, tool:
27
30
 
28
31
  // One thing that happened to the message. ev: { type, at, tool?, detail?, asked?, text? }
29
32
  // delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended | folded | answered | lost
33
+ const KEEPS_QUEUED = new Set(['delivered', 'queued', 'ask_done', 'noop'])
30
34
  export function stepStatus(st, ev = {}) {
31
35
  const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
32
36
  const at = ev.at || s.last
33
- if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) return s
37
+ if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) { if (s.queued) { delete s.queued; delete s.cancellable } return s }
38
+ if (s.queued && !KEEPS_QUEUED.has(ev.type)) { delete s.queued; delete s.cancellable }
34
39
  switch (ev.type) {
40
+ case 'queued': if (s.phase === 'sent' || s.phase === 'received') { s.queued = true; s.cancellable = !!ev.cancellable } else return s; break
41
+ case 'unqueued': break
35
42
  case 'delivered': if (s.phase === 'sent') s.phase = 'received'; break
36
43
  case 'failed': if (s.phase === 'sent') { s.phase = 'failed'; s.error = ev.error || null; s.endedAt = at } break
37
44
  case 'started': case 'text': if (s.phase !== 'replied') s.phase = 'working'; if (ev.type === 'text') s.waiting = null; break
@@ -57,12 +64,16 @@ export function stepStatus(st, ev = {}) {
57
64
  return s
58
65
  }
59
66
 
67
+ // Waiting behind Claude's turn, not taken in yet.
68
+ export function isQueued(st) { return !!(st && st.queued && !st.folded && (st.phase === 'sent' || st.phase === 'received')) }
69
+
60
70
  // The line under the message: { label, steps, spin, tone, elapsedMs }
61
71
  export function statusView(st, now = Date.now()) {
62
72
  if (!st) return null
63
73
  const quiet = !st.folded && (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
64
74
  const elapsedMs = Math.max(0, (st.endedAt || now) - (st.at || now))
65
75
  let label
76
+ if (isQueued(st)) return { label: 'Queued', steps: [{ name: 'Sent', state: 'done' }, { name: 'Queued', state: 'now' }], spin: false, tone: 'queued', quiet: false, elapsedMs, final: false, queued: true }
66
77
  if (st.phase === 'sent') label = 'Sent, waiting for the session'
67
78
  else if (st.phase === 'received') label = quiet ? 'Received · no word from Claude yet' : 'Received · Claude is starting…'
68
79
  else if (st.phase === 'working' && st.folded) label = 'Taken into the turn above'