@leftoff/cli 0.9.9-beta.6 → 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/ui/start.js CHANGED
@@ -25,7 +25,35 @@ export function startPick(data, ui = {}) {
25
25
  const custom = m && ui.custom && ui.custom.machine === m.machine && ui.custom.path === ui.repo && !repos.some((r) => r.path === ui.custom.path) ? { ...ui.custom, isNew: true } : null
26
26
  const repo = repos.find((r) => r.path === ui.repo) || custom || repos[0] || null
27
27
  const where = ui.where === 'worktree' && !(repo && repo.git === false) ? 'worktree' : 'repo'
28
- return { m, repo, where }
28
+ // Permissions: what you picked, else what this board last used for that Mac (kept on the Mac you're connected to), else Ask me
29
+ const mode = START_MODES.some((x) => x.v === ui.mode) ? ui.mode : m && START_MODES.some((x) => x.v === m.lastMode) ? m.lastMode : 'default'
30
+ return { m, repo, where, mode }
31
+ }
32
+
33
+ // 0.9.9 Permissions for a session Leftoff starts (src/claude-headless.js
34
+ // ALLOWED_MODES, checked against Claude Code 2.1.300). Never "skip all asks"
35
+ // (bypassPermissions): too much to hand a start from a phone. A session in
36
+ // your own terminal keeps its own mode (Shift+Tab there).
37
+ export const START_MODES = Object.freeze([
38
+ { v: 'default', label: 'Ask me', sub: 'every edit and command', text: 'Reads run freely; every edit and command waits for you in Needs you (here, your phone, any board).' },
39
+ { v: 'acceptEdits', label: 'Accept edits', sub: 'edits go through, commands ask', text: 'File edits in the folder go through without asking (so do simple file commands like mkdir); other commands still wait for you in Needs you.' },
40
+ { v: 'auto', label: 'Auto', sub: 'a safety check decides', text: 'Claude Code’s auto mode: routine steps just run. Riskier ones get a separate safety check (a little extra usage) that lets them run or blocks them; a blocked step is skipped and Claude says why. Few or no asks.' },
41
+ ])
42
+ export const TERMINAL_MODE_HINT = 'Sessions you run in your own terminal aren’t changed: press Shift+Tab in Claude Code there.'
43
+ export function modeLabel(mode) { const x = START_MODES.find((y) => y.v === (mode === 'manual' ? 'default' : mode)); return x ? x.label : mode === 'plan' ? 'Plan' : String(mode || 'Ask me') }
44
+
45
+ // The session page's small pill beside Live: the mode Claude Code reports.
46
+ export function modePillHtml(s, { esc }) {
47
+ const h = s && s.headless
48
+ if (!h || !h.mode) return ''
49
+ const m = h.mode
50
+ return ' <span class="mode-pill m-' + esc(m) + '" data-tip="' + esc('Permissions: ' + modeLabel(m) + (h.state === 'running' ? '' : ' (when it runs again)')) + '">' + esc(modeLabel(m)) + '</span>'
51
+ }
52
+
53
+ // Is a path inside one of a Mac's approved folders (its real paths)?
54
+ export function underApproved(p, approved) {
55
+ const x = String(p || '')
56
+ return !!x && (Array.isArray(approved) ? approved : []).some((a) => typeof a === 'string' && a && (x === a || x.startsWith(a.endsWith('/') ? a : a + '/')))
29
57
  }
30
58
 
31
59
  // What happens when you press ↑, in one sentence (or why you can't).
@@ -37,7 +65,10 @@ export function startRule(data, pick, { local = true } = {}) {
37
65
  if (!pick.repo) return { ok: false, text: m.machine + ' hasn’t run Claude Code in a repo yet. Pick Other folder…, or open Claude Code there once and it shows up here.' }
38
66
  // a folder that Mac hasn't run Claude Code in: its hooks run, so only a person at that Mac says yes
39
67
  if (pick.repo.isNew && m.here && local) return { ok: true, text: 'First time Claude Code runs in this folder on this Mac: it starts right away and runs the folder’s hooks and MCP servers.' }
40
- if (pick.repo.isNew) return { ok: true, text: 'First time in this folder on ' + m.machine + ', so someone there confirms it first (it runs the folder’s hooks), even with “Allow starting sessions from other devices” on.' }
68
+ // …unless it's inside a folder approved on that Mac and its switch is on (that Mac decides; this is what it told us)
69
+ if (pick.repo.isNew && m.here && m.allowRemote === true && underApproved(pick.repo.path, m.approved)) return { ok: true, text: 'First time in this folder, but it’s inside a folder approved on ' + m.machine + ': it starts right away, and ' + m.machine + ' shows a notification.' }
70
+ if (pick.repo.isNew && m.here) return { ok: true, text: 'First time in this folder on ' + m.machine + ', so someone there confirms it first (it runs the folder’s hooks). They can approve the folder above it there, so the next ones start right away.' }
71
+ if (pick.repo.isNew) return { ok: true, text: 'First time in this folder on ' + m.machine + ', so someone there confirms it first (it runs the folder’s hooks), unless it’s inside a folder approved there.' }
41
72
  if (m.here && local) return { ok: true, text: 'It starts right away on this Mac.' }
42
73
  if (m.here && m.allowRemote === true) return { ok: true, text: 'It starts right away, and ' + m.machine + ' shows a notification.' }
43
74
  if (m.here) return { ok: true, text: 'Someone at ' + m.machine + ' confirms it first (up to 5 minutes). To skip that, turn on “Allow starting sessions from other devices” in its Settings.' }
@@ -60,7 +91,7 @@ function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
60
91
  // The waiting line after a start that has to be confirmed at the Mac.
61
92
  export function startAskLine(ask, { clock = (x) => x } = {}) {
62
93
  if (!ask) return null
63
- if (ask.state === 'pending') return { tone: 'wait', text: 'Waiting for a yes on ' + ask.machine + ' (until ' + clock(ask.expiresAt) + '). It shows on that Mac’s board and as a notification.' + (ask.firstTime ? ' A first time in a folder always asks there.' : '') }
94
+ if (ask.state === 'pending') return { tone: 'wait', text: 'Waiting for a yes on ' + ask.machine + ' (until ' + clock(ask.expiresAt) + '). It shows on that Mac’s board and as a notification.' + (ask.firstTime ? ' A first time in a folder asks there, unless it’s inside a folder approved there.' : '') }
64
95
  if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
65
96
  if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
66
97
  if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
@@ -104,11 +135,15 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
104
135
  opt({ k: 'where', v: 'repo', on: pick.where === 'repo', esc, label: 'In the repo', sub: 'on its current branch' }) +
105
136
  opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: pick.repo && pick.repo.git === false ? 'needs a git repo' : 'its own branch and folder', disabled: !!(pick.repo && pick.repo.git === false) }) +
106
137
  '</div></section>'
138
+ // Permissions (0.9.9)
139
+ const mo = START_MODES.find((x) => x.v === pick.mode) || START_MODES[0]
140
+ h += '<section class="start-sec"><h2 class="start-h">Permissions</h2><div class="start-opts start-mode" role="radiogroup" aria-label="Permissions">' +
141
+ START_MODES.map((x) => opt({ k: 'mode', v: x.v, on: x.v === mo.v, esc, label: esc(x.label), sub: esc(x.sub) })).join('') +
142
+ '</div><p class="start-hint start-mode-hint">' + esc(mo.text) + ' ' + esc(TERMINAL_MODE_HINT) + '</p></section>'
107
143
  // First message
108
144
  h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
109
145
  composerBoxHtml({ submit: 'start', sendAction: 'start-send', draftKey: 'start', draft, may: rule.ok && !ui.ask, busy, placeholder: 'What should Claude do?', label: 'First message', inputClass: 'start-text', keys: 'Enter Meta+Enter Control+Enter' }) +
110
- '</div><p class="start-rule' + (rule.ok ? '' : ' bad') + '" role="status">' + esc(rule.text) + '</p>' +
111
- '<p class="start-hint">Reads run freely; every edit and command waits for you in Needs you (here, your phone, any board).</p>'
146
+ '</div><p class="start-rule' + (rule.ok ? '' : ' bad') + '" role="status">' + esc(rule.text) + '</p>'
112
147
  const line = startAskLine(ui.ask, { clock })
113
148
  if (line) h += '<div class="start-ask ' + line.tone + '" role="status">' + esc(line.text) + (ui.ask && ['declined', 'expired', 'failed'].includes(ui.ask.state) ? ' <button type="button" class="btn" data-action="start-again">Try again</button>' : '') + '</div>'
114
149
  h += '</section></div>'
@@ -172,7 +207,10 @@ export function startTexts(data, ui = {}) {
172
207
  d.entries.forEach((e, i) => { t['entry-' + i] = e.name })
173
208
  }
174
209
  if (ui.browse && ui.browse.pathError) t['path-err'] = ui.browse.pathError
175
- for (const a of (data && Array.isArray(data.pending) ? data.pending : [])) if (a.firstTime) t['ask-folder-' + a.id] = a.folder || ''
210
+ for (const a of (data && Array.isArray(data.pending) ? data.pending : [])) {
211
+ if (a.firstTime) t['ask-folder-' + a.id] = a.folder || ''
212
+ if (a.firstTime && a.approve) t['ask-approve-' + a.id] = a.approve.label || ''
213
+ }
176
214
  return t
177
215
  }
178
216
 
@@ -199,15 +237,29 @@ export function startAsksBanner(info, { esc, icon, local = true, clock = (x) =>
199
237
  const asks = info && Array.isArray(info.pending) ? info.pending : []
200
238
  if (!asks.length) return ''
201
239
  return asks.map((a) => {
240
+ if (a.kind === 'mode') return modeAskBannerHtml(a, info, { esc, local, clock, busy })
202
241
  const b = busy.has(a.id)
203
242
  const where = a.firstTime ? '<span data-st="ask-folder-' + esc(a.id) + '"></span>' : esc(a.repo)
204
- return '<div class="banner warn start-ask-banner' + (a.firstTime ? ' first' : '') + '" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to start a session</b> in ' + where + (a.where === 'worktree' ? ' (new worktree)' : '') + ' on ' + esc(info.machine || 'this Mac') + ': “' + esc(a.prompt) + '”' +
243
+ return '<div class="banner warn start-ask-banner' + (a.firstTime ? ' first' : '') + '" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to start a session</b> in ' + where + (a.where === 'worktree' ? ' (new worktree)' : '') + (a.mode && a.mode !== 'default' ? ' in <b>' + esc(modeLabel(a.mode)) + '</b>' : '') + ' on ' + esc(info.machine || 'this Mac') + ': “' + esc(a.prompt) + '”' +
205
244
  (a.firstTime ? ' <b class="start-first">First time in this folder; this runs its hooks.</b>' : '') + ' <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
206
245
  (local ? '<button type="button" class="btn primary" data-action="start-ask-confirm" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>' + icon('play') + (b ? 'Starting…' : 'Start it') + '</button>' +
246
+ // a first time: approve the folder above it here, and start this one (the folder is text, put in by fillStartTexts)
247
+ (a.firstTime && a.approve ? '<button type="button" class="btn approve" data-action="start-ask-approve" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>Always allow in <span class="mono" data-st="ask-approve-' + esc(a.id) + '"></span></button>' : '') +
207
248
  '<button type="button" class="btn" data-action="start-ask-decline" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>Decline</button>' : '<span class="rs-m">Someone at that Mac confirms it there.</span>') + '</div>'
208
249
  }).join('')
209
250
  }
210
251
 
252
+ // A phone or another Mac asks to switch a running session to Accept edits or
253
+ // Auto while "Allow starting sessions from other devices" is off: the same
254
+ // yes at this Mac a start needs (src/session-start.js switchMode).
255
+ export function modeAskBannerHtml(a, info, { esc, local = true, clock = (x) => x, busy = new Set() } = {}) {
256
+ const b = busy.has(a.id)
257
+ const mo = START_MODES.find((x) => x.v === a.mode)
258
+ return '<div class="banner warn start-ask-banner mode-ask" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to switch ' + esc(a.repo || 'a session') + ' to ' + esc(modeLabel(a.mode)) + '</b> on ' + esc(info.machine || 'this Mac') + '. ' + esc(mo ? mo.text : '') + ' <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
259
+ (local ? '<button type="button" class="btn primary" data-action="start-ask-confirm" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>' + (b ? 'Switching…' : 'Switch it') + '</button>' +
260
+ '<button type="button" class="btn" data-action="start-ask-decline" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>Decline</button>' : '<span class="rs-m">Someone at that Mac confirms it there.</span>') + '</div>'
261
+ }
262
+
211
263
  // Asked once on THIS Mac's board when it pairs a phone or sees a new Mac:
212
264
  // may it start sessions here? No is the default (first, and what doing
213
265
  // nothing means); Yes turns on the switch, and Trust my paired devices if it's
@@ -227,7 +279,7 @@ export function startPairAskHtml(info, { esc, local = true, busy = false } = {})
227
279
  // The session page's line for a session Leftoff runs: where it runs, who
228
280
  // started it, and Stop / Resume. The error (Claude Code's own words) is put
229
281
  // in with textContent by app.js ([data-headless-err]).
230
- export function headlessBarHtml(s, { esc, icon, may = true } = {}) {
282
+ export function headlessBarHtml(s, { esc, icon, may = true, busy = null, local = true } = {}) {
231
283
  const h = s && s.headless
232
284
  if (!h) return ''
233
285
  const from = s.startedFrom && s.startedFrom.device ? ' from ' + s.startedFrom.device : ''
@@ -240,12 +292,76 @@ export function headlessBarHtml(s, { esc, icon, may = true } = {}) {
240
292
  if (may) btn = '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
241
293
  }
242
294
  return '<div class="headless-bar ' + esc(h.state) + '" data-sid="' + esc(s.id) + '"><span class="grow">' + icon('play') + esc(text) + '</span>' + btn + '</div>' +
243
- (h.error && h.state !== 'running' ? '<pre class="headless-err" data-headless-err="' + esc(s.id) + '"></pre>' : '')
295
+ (h.error && h.state !== 'running' ? '<pre class="headless-err" data-headless-err="' + esc(s.id) + '"></pre>' : '') +
296
+ (h.state !== 'stopping' ? headlessModeHtml(s, { esc, may, busy, local }) : '')
297
+ }
298
+
299
+ // The session page's Permissions switch for a session Leftoff runs: the same
300
+ // three as New session. Running, it switches Claude Code mid-session; stopped,
301
+ // it's the mode Resume starts it in.
302
+ export function headlessModeHtml(s, { esc, may = true, busy = null, local = true } = {}) {
303
+ const h = s && s.headless
304
+ if (!h || !may) return '' // can't change it from here (no Trust): the pill beside Live still says which
305
+ const cur = h.mode === 'manual' ? 'default' : h.mode || 'default'
306
+ const mo = START_MODES.find((x) => x.v === cur)
307
+ const remote = !local || s.isLocal === false
308
+ return '<div class="headless-mode" role="group" aria-label="Permissions"><span class="hm-l">Permissions</span><div class="hm-seg" role="radiogroup" aria-label="Permissions">' +
309
+ START_MODES.map((x) => '<button type="button" class="hm-opt" role="radio" aria-checked="' + (x.v === cur) + '" data-action="headless-mode" data-id="' + esc(s.id) + '" data-mode="' + esc(x.v) + '"' + (busy ? ' disabled' : '') + ' data-tip="' + esc(x.text) + '">' + esc(x.label) + (busy === x.v ? '…' : '') + '</button>').join('') +
310
+ '</div><p class="hint hm-hint">' + esc(mo ? mo.text : 'Claude Code is in ' + modeLabel(cur) + ' mode.') + (h.state === 'running' ? '' : ' Applies when you Resume it.') + ' ' + (remote ? 'From another device, Accept edits and Auto follow that Mac’s “Allow starting sessions from other devices” (else someone there says yes). ' : '') + esc(TERMINAL_MODE_HINT) + '</p></div>'
244
311
  }
245
312
 
246
313
  // Settings → Privacy & security: the one switch, on this Mac only.
247
- export function startsSettingHtml(info, { esc, local = true } = {}) {
314
+ // 0.9.9: a setting kept only on one Mac (not in the synced config.json) wears a
315
+ // pill beside its label: "🖥️ studio only", with a styled tip. Synced settings
316
+ // get none, so the pill alone tells you which kind it is.
317
+ export const LOCAL_TIP = 'Kept on this Mac; doesn’t sync.'
318
+ export function localPillHtml({ machine, emoji = '', esc }) {
319
+ return ' <span class="local-pill" data-tip="' + esc(LOCAL_TIP) + '">' + (emoji ? '<span aria-hidden="true">' + esc(emoji) + '</span> ' : '') + esc(machine || 'this Mac') + ' only<span class="sr-only">: ' + esc(LOCAL_TIP) + '</span></span>'
320
+ }
321
+ // Away from that Mac the control is locked, and looks it: a lock, not just grey.
322
+ export function lockedAttrs(local) { return local ? '' : ' disabled aria-disabled="true"' }
323
+ export function lockHtml(local, icon) { return local ? '' : '<span class="lock" aria-hidden="true">' + (icon ? icon('lock') : '🔒') + '</span>' }
324
+
325
+ export function startsSettingHtml(info, { esc, local = true, emoji = '', icon = null } = {}) {
326
+ if (!info) return ''
327
+ const m = esc(info.machine || 'that Mac')
328
+ return '<label for="f-start-allow">Start sessions' + localPillHtml({ machine: info.machine, emoji, esc }) + '</label>' +
329
+ '<label class="check' + (local ? '' : ' locked') + '"><input id="f-start-allow" type="checkbox"' + (info.allowRemote ? ' checked' : '') + lockedAttrs(local) + '>' + lockHtml(local, icon) + '<span>Allow starting sessions from other devices</span></label>' +
330
+ '<div class="hint">Off: a session your phone or another Mac starts here waits until someone at this Mac confirms it. On: it starts right away, and this Mac shows a notification each time. A folder this Mac hasn’t run Claude Code in still asks here first, unless it’s inside an approved folder. Needs Trust my paired devices.</div>' +
331
+ '<div class="hint local-hint">' + (local ? 'Or run <code>leftoff starts on</code> on this Mac.' : 'Change on ' + m + ', or run <code>leftoff starts on</code> there.') + '</div>' +
332
+ approvedSettingHtml(info, { esc, local, emoji, icon })
333
+ }
334
+
335
+ // Approved folders (0.9.9): with the switch on, a first-time folder inside one
336
+ // starts from your other devices without asking here. Kept on this Mac; added
337
+ // and removed only at it. Every path is put in as text by fillApproved.
338
+ export function approvedSettingHtml(info, { esc, local = true, emoji = '', icon = null } = {}) {
248
339
  if (!info) return ''
249
- return '<label for="f-start-allow">Start sessions</label><label class="check"><input id="f-start-allow" type="checkbox"' + (info.allowRemote ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Allow starting sessions from other devices</span></label>' +
250
- '<div class="hint">Off: a session your phone or another Mac starts here waits until someone at this Mac confirms it. On: it starts right away, and this Mac shows a notification each time. Needs Trust my paired devices. Kept on this Mac only (it doesn’t sync)' + (local ? '.' : '; change it on ' + esc(info.machine || 'that Mac') + '.') + '</div>'
340
+ const list = Array.isArray(info.approved) ? info.approved : []
341
+ const m = esc(info.machine || 'that Mac')
342
+ return '<label for="f-start-approve">Approved folders' + localPillHtml({ machine: info.machine, emoji, esc }) + '</label>' +
343
+ '<div class="approved' + (local ? '' : ' locked') + '" data-approved>' +
344
+ (list.length ? '<ul class="approved-list">' + list.map((a, i) => '<li><span class="approved-path mono" data-apt="row-' + i + '"></span>' +
345
+ (local ? '<button type="button" class="btn approved-x" data-action="start-approved-remove" data-i="' + i + '" data-apt-label="row-' + i + '">' + (icon ? icon('x') : '✕') + '<span>Remove</span></button>' : '') + '</li>').join('') + '</ul>'
346
+ : '<div class="approved-none">None yet.</div>') +
347
+ '<div class="approved-add">' + lockHtml(local, icon) + '<input id="f-start-approve" type="text" inputmode="url" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="~/src" aria-describedby="f-start-approve-err"' + lockedAttrs(local) + '>' +
348
+ '<button type="button" class="btn" data-action="start-approved-add"' + lockedAttrs(local) + '>Add</button></div>' +
349
+ '<div class="approved-err" id="f-start-approve-err" role="alert" data-apt="err" hidden></div>' +
350
+ '</div>' +
351
+ '<div class="hint">With the switch on, a folder this Mac hasn’t run Claude Code in starts right away from your other devices when it’s inside one of these. Folders in your home, not home itself.' + (local ? '' : ' Change on ' + m + ', or run <code>leftoff starts allow ~/src</code> there.') + '</div>'
352
+ }
353
+
354
+ // The paths, as text (never markup), and each Remove's name for a screen reader.
355
+ export function fillApproved(root, info, err = '') {
356
+ if (!root || !root.querySelectorAll) return 0
357
+ const list = info && Array.isArray(info.approved) ? info.approved : []
358
+ let n = 0
359
+ for (const el of root.querySelectorAll('[data-apt]')) {
360
+ const k = el.getAttribute('data-apt')
361
+ if (k === 'err') { el.textContent = err || ''; el.hidden = !err; n++; continue }
362
+ const a = list[Number(k.replace('row-', ''))]
363
+ el.textContent = a ? a.label || a.path : ''; n++
364
+ }
365
+ for (const b of root.querySelectorAll('[data-apt-label]')) { const a = list[Number(b.getAttribute('data-apt-label').replace('row-', ''))]; if (a) b.setAttribute('aria-label', 'Remove ' + (a.label || a.path)) }
366
+ return n
251
367
  }