@leftoff/cli 0.9.18-beta.4 → 0.9.18-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.18-beta.4",
3
+ "version": "0.9.18-beta.5",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "One Silly App"
@@ -27,9 +27,12 @@ const POST_TIMEOUT_MS = 800
27
27
  const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
29
  const STOP_GRACE_MS = 2500 // 2026-11-03 how long a completed turn waits for its Stop before the mod reports the end itself
30
+ const SKIP_GRACE_MS = 4000 // 2026-11-03 how long a mod event waits for its classic twin before that classic event counts as passed over
31
+ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can be slow)
32
+ const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
30
33
 
31
34
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
32
- const MOD_VERSION = '0.9.18-beta.4' // keep in step with .claude-plugin/plugin.json
35
+ const MOD_VERSION = '0.9.18-beta.5' // keep in step with .claude-plugin/plugin.json
33
36
 
34
37
  const st = {
35
38
  internal: false,
@@ -72,6 +75,13 @@ const st = {
72
75
  paneNeeds: 0, // sessions that need you (the pane's top line)
73
76
  turnStartAt: 0, // 2026-11-03 when this process's current turn started (turn.start)
74
77
  stopAt: 0, // and when a Stop last came (classic.Stop)
78
+ // 2026-11-03 Claude Code 2.1.31x passes every classic.* event of a plugin you
79
+ // installed over when its built-in cc-plugin-sec-default is seated first (a
80
+ // Team or Enterprise plan, or managed settings): see SKIP_GRACE_MS below.
81
+ classicSeen: false, // a classic.* hook of this mod ran in this process
82
+ hooksSkipped: null, // the classic events we saw passed over, as last told to the daemon
83
+ permAt: 0, // when classic.PermissionRequest last ran
84
+ watchAsks: {}, // tool_use_id -> { tool, at, reported, done }: permission prompts tool.check saw
75
85
  }
76
86
 
77
87
  // Millisecond precision: the daemon dedupes events per (sessionId, type, at).
@@ -233,7 +243,7 @@ async function readRepo($, cwd) {
233
243
 
234
244
  async function heartbeat($) {
235
245
  if (st.internal || !st.sessionId) return
236
- const res = await sendOrSpool($, 'heartbeat', { inTurn: !!(st.record && st.record.inTurn) })
246
+ const res = await sendOrSpool($, 'heartbeat', { inTurn: !!(st.record && st.record.inTurn), ...(st.hooksSkipped && st.hooksSkipped.length ? { hooksSkipped: st.hooksSkipped } : {}) })
237
247
  if (res && !st.modHealthSent) await reportModHealth($)
238
248
  if (res && st.daemonTitle) await noteTitle($, st.daemonTitle)
239
249
  else if (res && st.tabTitle && !(st.record && st.record.inTurn)) await applyTabTitle($, null) // the marker came (it never goes: tabMarkerKept), or a rename that had to wait
@@ -256,6 +266,89 @@ async function stopFallback($, completedAt, reason) {
256
266
  await sendOrSpool($, 'turn_completed', { endReason: String(reason || 'end').slice(0, 40), settledBy: 'mod', completedAt: new Date(completedAt).toISOString() })
257
267
  }
258
268
 
269
+ // ---------------------------------------------------------------- passed-over classic hooks (2026-11-03)
270
+ //
271
+ // Claude Code 2.1.31x seats its built-in "cc-plugin-sec-default" outermost on
272
+ // a Team or Enterprise plan or a machine with managed settings, and its hooks
273
+ // module answers every classic.* event with next.to(e, "append"): the event
274
+ // jumps past the user tier, where a plugin you installed (this one) sits, so
275
+ // none of our classic.* hooks run (Claude Code then says "leftoff: its
276
+ // classic.Stop hook was skipped. "cc-plugin-sec-default" runs first and sent
277
+ // the event on past it."). Our other events are untouched: session.start,
278
+ // prompt.submit, turn.start, turn.complete, tool.check and tool.call.
279
+ // So: each of those notes the classic event that should come with it; when it
280
+ // doesn't come within the grace and no classic hook has run in this process at
281
+ // all, the classic event counts as passed over and the daemon hears which.
282
+ function sawClassic($, name) {
283
+ if (name === 'PermissionRequest') st.permAt = Date.now()
284
+ if (st.classicSeen) return
285
+ st.classicSeen = true
286
+ if (st.hooksSkipped && st.hooksSkipped.length) { // a slow SessionStart we counted out too early, a setting changed: they run
287
+ st.hooksSkipped = []
288
+ sendOrSpool($, 'heartbeat', { hooksSkipped: [], fromMod: true }).catch(() => {})
289
+ }
290
+ }
291
+
292
+ function expectClassic($, name, graceMs) {
293
+ if (st.classicSeen || st.internal) return
294
+ $.clock.after(graceMs || SKIP_GRACE_MS, () => { noteSkipped($, name).catch(() => {}) })
295
+ }
296
+
297
+ async function noteSkipped($, name) {
298
+ if (st.classicSeen || st.internal || !st.sessionId) return
299
+ const cur = st.hooksSkipped || []
300
+ if (cur.includes(name)) return
301
+ st.hooksSkipped = cur.concat(name)
302
+ await sendOrSpool($, 'heartbeat', { hooksSkipped: st.hooksSkipped, fromMod: true })
303
+ }
304
+
305
+ // A permission prompt, seen from tool.check: next(e) is the whole check
306
+ // (rules, modes, other plugins' hooks, Claude Code's own), and { decision: "ask" }
307
+ // with approver "person" is the terminal's permission dialog (a host such as
308
+ // headless answers "host"; no approver means it is never asked). We only
309
+ // look: the answer goes back as it came. Reported as Claude Code's own
310
+ // permission_prompt notification would be, unless classic.PermissionRequest
311
+ // ran for it (then permissionHook has put it on the board, answerable there).
312
+ function watchAsk($, e) {
313
+ if (st.internal || st.headless || !st.sessionId) return
314
+ const at = Date.now()
315
+ const id = String(e.tool_use_id || (String(e.tool || '') + '@' + at))
316
+ st.watchAsks[id] = { tool: e.tool, at, reported: false, done: false }
317
+ expectClassic($, 'PermissionRequest')
318
+ $.clock.after(PERM_GRACE_MS, () => { reportAsk($, id, e).catch(() => {}) })
319
+ }
320
+
321
+ async function reportAsk($, id, e) {
322
+ const w = st.watchAsks[id]
323
+ if (!w || w.done) return
324
+ if (st.permAt >= w.at) { delete st.watchAsks[id]; return } // its PermissionRequest hook ran: the board has it
325
+ w.reported = true
326
+ await sendOrSpool($, 'notification', {
327
+ notificationType: 'permission_prompt', message: clip(askMessage(e.tool), 200), tool: e.tool || null, fromMod: true,
328
+ ...(st.hooksSkipped && st.hooksSkipped.length ? { hooksSkipped: st.hooksSkipped } : {}),
329
+ })
330
+ }
331
+
332
+ function askMessage(tool) {
333
+ if (tool === 'AskUserQuestion') return 'Claude has a question for you'
334
+ return 'Claude needs your permission to use ' + (tool || 'a tool')
335
+ }
336
+
337
+ // The prompt went away: the tool runs (tool.call) or the turn is over. Says so
338
+ // only for prompts the mod reported (an activity the board reads as a tool
339
+ // that ran, which ends "Needs you" for a permission prompt).
340
+ async function settleAsks($, toolUseId, tool) {
341
+ let reported = false
342
+ for (const id of Object.keys(st.watchAsks)) {
343
+ const w = st.watchAsks[id]
344
+ if (toolUseId !== undefined && (toolUseId ? id !== String(toolUseId) : w.tool !== tool)) continue
345
+ w.done = true
346
+ if (w.reported) reported = true
347
+ delete st.watchAsks[id]
348
+ }
349
+ if (reported) await sendOrSpool($, 'activity', { kind: 'tool', phase: 'post', tool: tool || null, fromMod: true })
350
+ }
351
+
259
352
  // The commands, in the order they're tried. Claude Code 2.1.295's built-ins
260
353
  // own /checkpoint (an alias of /rewind) and /name (an alias of /rename), so
261
354
  // ours share a /cp prefix; each command lists fallbacks in case a later
@@ -1042,6 +1135,7 @@ export function register(on, options) {
1042
1135
  st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
1043
1136
  st.ready = true
1044
1137
  startReplyLoop($)
1138
+ expectClassic($, 'SessionStart', START_SKIP_GRACE_MS) // 2026-11-03 passed over? (sawClassic)
1045
1139
  } catch {}
1046
1140
  // Commands last, each on its own: a taken name throws, so a
1047
1141
  // clash costs only that command, and registerCommands reports it.
@@ -1050,6 +1144,7 @@ export function register(on, options) {
1050
1144
  })
1051
1145
 
1052
1146
  on('classic.SessionStart', async ($, e, next) => {
1147
+ sawClassic($, 'SessionStart')
1053
1148
  if (!(await internalOnce($)) && !st.internal && e.session_id) {
1054
1149
  let parentSessionId
1055
1150
  if (st.sessionId && e.session_id !== st.sessionId) {
@@ -1082,6 +1177,7 @@ export function register(on, options) {
1082
1177
  })
1083
1178
 
1084
1179
  on('classic.UserPromptSubmit', async ($, e, next) => {
1180
+ sawClassic($, 'UserPromptSubmit')
1085
1181
  await sendOrSpool($, 'activity', { kind: 'prompt', prompt: clip(e.prompt || '', 300), sessionTitle: e.session_title && e.session_title === st.tabTitle ? null : e.session_title, ...moved(e) }) // our own /rename isn't a name you chose
1086
1182
  return next(e)
1087
1183
  })
@@ -1091,6 +1187,7 @@ export function register(on, options) {
1091
1187
  // context entry: the list, its numbers, and `leftoff todo done <list> <n>`.
1092
1188
  // Context goes on the event before next(); on the result it would be too late.
1093
1189
  on('prompt.submit', async ($, e, next) => {
1190
+ expectClassic($, 'UserPromptSubmit')
1094
1191
  const ctx = await listContextFor($)
1095
1192
  return next(ctx ? { ...e, context: [...(Array.isArray(e.context) ? e.context : []), ctx] } : e)
1096
1193
  })
@@ -1104,6 +1201,7 @@ export function register(on, options) {
1104
1201
  on('turn.complete', async ($, e, next) => {
1105
1202
  // classic.Stop reports a normal end with its details; Stop doesn't fire for
1106
1203
  // an interrupted or failed turn, so those are reported here.
1204
+ await settleAsks($, undefined).catch(() => {}) // 2026-11-03 a permission prompt the mod reported is over with the turn
1107
1205
  if (e.reason === 'aborted' || e.reason === 'error' || e.reason === 'refusal') await sendOrSpool($, 'turn_completed', { endReason: e.reason })
1108
1206
  // 2026-11-03 Claude Code doesn't run every Stop for every end of a turn
1109
1207
  // (2.1.317: a turn its loop ends runs Stop without the in-process hooks,
@@ -1111,11 +1209,12 @@ export function register(on, options) {
1111
1209
  // for good. If no Stop came for this turn shortly after it completed, the
1112
1210
  // end is reported from here; a Stop that comes later is the same turn
1113
1211
  // (the daemon doesn't count it twice).
1114
- else { const at = Date.now(); $.clock.after(STOP_GRACE_MS, () => { stopFallback($, at, e.reason).catch(() => {}) }) }
1212
+ else { const at = Date.now(); $.clock.after(STOP_GRACE_MS, () => { stopFallback($, at, e.reason).catch(() => {}) }); expectClassic($, 'Stop') }
1115
1213
  return next(e)
1116
1214
  })
1117
1215
 
1118
1216
  on('classic.Stop', async ($, e, next) => {
1217
+ sawClassic($, 'Stop')
1119
1218
  st.stopAt = Date.now()
1120
1219
  // v0.5.1: a checklist of 3+ in this reply (a cheap regex, no model) -> the
1121
1220
  // board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
@@ -1140,20 +1239,36 @@ export function register(on, options) {
1140
1239
  })
1141
1240
 
1142
1241
  on('classic.Notification', async ($, e, next) => {
1242
+ sawClassic($, 'Notification')
1143
1243
  await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200), ...moved(e) })
1144
1244
  return next(e)
1145
1245
  })
1146
1246
 
1147
- on('classic.PermissionRequest', permissionHook)
1247
+ on('classic.PermissionRequest', async ($, e, next) => { sawClassic($, 'PermissionRequest'); return permissionHook($, e, next) })
1248
+
1249
+ // 2026-11-03 a permission prompt, seen whether or not classic.PermissionRequest
1250
+ // reaches us (see watchAsk): we only look, and hand the answer back as it came.
1251
+ on('tool.check', async ($, e, next) => {
1252
+ const r = await next(e)
1253
+ try { if (r && r.decision === 'ask' && e && e.approver === 'person') watchAsk($, e) } catch {}
1254
+ return r
1255
+ })
1256
+
1257
+ // The tool runs: a prompt the mod reported for it was answered in the terminal.
1258
+ on('tool.call', async ($, e, next) => {
1259
+ if (e && Object.keys(st.watchAsks).length) await settleAsks($, e.tool_use_id || null, e.tool).catch(() => {})
1260
+ return next(e)
1261
+ })
1148
1262
 
1149
1263
  on('classic.PostToolUse', async ($, e, next) => {
1264
+ sawClassic($, 'PostToolUse')
1150
1265
  await toolRan($, e)
1151
1266
  const m = moved(e) // EnterWorktree, or a cd: say so now, not at the end of the turn
1152
1267
  if (m.cwd || m.transcriptPath) await sendOrSpool($, 'activity', { kind: 'cwd', ...m })
1153
1268
  return next(e)
1154
1269
  })
1155
1270
 
1156
- on('classic.PostToolUseFailure', async ($, e, next) => { await toolRan($, e); return next(e) })
1271
+ on('classic.PostToolUseFailure', async ($, e, next) => { sawClassic($, 'PostToolUseFailure'); await toolRan($, e); return next(e) })
1157
1272
 
1158
1273
  on('session.end', async ($, e, next) => {
1159
1274
  // About 1.5 s shared by every SessionEnd hook: post and go. The daemon makes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.18-beta.4",
3
+ "version": "0.9.18-beta.5",
4
4
  "description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
5
5
  "keywords": [
6
6
  "claude",
package/ui/app.css CHANGED
@@ -5396,3 +5396,40 @@ code .file-ref-off { font-size: inherit; }
5396
5396
  .sub .s-hooks-ring { width: 8px; height: 8px; border-radius: 50%; border: 1.5px dashed currentColor; box-sizing: border-box; flex: none; }
5397
5397
  .fs-head .sub > .s-hooks { height: 22px; vertical-align: middle; }
5398
5398
  .fs-side.is-scrolled .fs-head .sub > .s-hooks { display: none; }
5399
+ /* 2026-11-03 which hooks were skipped and why: the same note, its words cut short on a narrow header (the whole in its tip) */
5400
+ .sub .s-hooks-skip { max-width: min(420px, 100%); min-width: 0; }
5401
+ .sub .s-hooks-skip .s-hooks-t { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
5402
+
5403
+ /* 2026-11-03 "Let me answer prompts here" (ui/hook-fix.js): the hooks-skipped note's way out. Centered; a bottom sheet on a phone. */
5404
+ .sub .s-hooks-fix { margin-left: 6px; min-height: 44px; padding: 0 10px; border: 1px solid var(--btn-line); border-radius: 8px; background: transparent; color: var(--accent); font: inherit; font-size: 12px; cursor: pointer; white-space: nowrap; }
5405
+ @media (pointer: fine) { .sub .s-hooks-fix { min-height: 32px; padding: 0 9px; } }
5406
+ .sub .s-hooks-fix:hover { background: var(--btn-hover); }
5407
+ .sub .s-hooks-fix:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
5408
+ .fs-side.is-scrolled .fs-head .sub > .s-hooks-fix { display: none; }
5409
+ .sub .s-hooks-applied .s-hooks-ring { border-style: dotted; }
5410
+ .hf-back { position: fixed; inset: 0; z-index: 400; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; padding: 16px; }
5411
+ .hf-modal { width: min(560px, 100%); max-height: calc(100dvh - 32px); overflow: auto; background: var(--card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--toast-shadow); padding: 16px 18px 14px; color: var(--text2); }
5412
+ .hf-grab { display: none; }
5413
+ .hf-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
5414
+ .hf-head h2 { margin: 0; font-size: 15px; font-weight: 600; color: var(--text); text-transform: none; letter-spacing: normal; }
5415
+ .hf-p { margin: 8px 0; font-size: 13px; line-height: 1.5; }
5416
+ .hf-quiet { color: var(--muted); font-size: 12.5px; }
5417
+ .hf-file code, .hf-p code { font-size: 12px; overflow-wrap: anywhere; }
5418
+ .hf-file { margin: 4px 0; }
5419
+ .hf-code { margin: 6px 0; padding: 10px 12px; background: var(--field); border: 1px solid var(--line); border-radius: 8px; font-size: 12px; line-height: 1.45; overflow: auto; white-space: pre; color: var(--text); user-select: all; }
5420
+ .hf-mark { display: inline-block; width: 9px; height: 9px; margin-right: 7px; vertical-align: 0; box-sizing: border-box; }
5421
+ .hf-mark-ok { border-radius: 50%; background: var(--green); }
5422
+ .hf-mark-admin { border-radius: 2px; border: 1.5px solid var(--yellow); transform: rotate(45deg); }
5423
+ .hf-mark-off { border-radius: 50%; border: 1.5px dashed var(--muted); }
5424
+ .hf-ign { margin-top: 10px; padding-top: 6px; border-top: 1px solid var(--line); }
5425
+ .hf-acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
5426
+ .hf-acts .btn, .hf-x { min-height: 44px; min-width: 44px; justify-content: center; }
5427
+ @media (pointer: fine) { .hf-acts .btn, .hf-x { min-height: 32px; min-width: 32px; } }
5428
+ .hf-modal .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
5429
+ @media (max-width: 640px) {
5430
+ .hf-back { align-items: flex-end; padding: 0; }
5431
+ .hf-modal { width: 100%; max-height: 88dvh; border-radius: var(--radius) var(--radius) 0 0; border-bottom: none; padding: 8px 16px calc(14px + env(safe-area-inset-bottom)); }
5432
+ .hf-grab { display: block; width: 36px; height: 4px; border-radius: 2px; background: var(--line-strong); margin: 2px auto 8px; }
5433
+ .hf-acts { justify-content: stretch; }
5434
+ .hf-acts .btn { flex: 1 1 auto; }
5435
+ }
package/ui/app.js CHANGED
@@ -39,6 +39,7 @@ import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box k
39
39
  import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
40
40
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
41
41
  import { mdInline, renderSection } from './changelog-md.js'
42
+ import { hooksFixButtonHtml, openHookFix } from './hook-fix.js' // 2026-11-03 "Let me answer prompts here"
42
43
  import { crumbHtml, metaLineHtml, detailsHtml, detailsOpen, rememberDetails, branchTileHtml } from './session-head.js' // 0.9.11: the session page's smaller head
43
44
  import { projectHref, threadHref, projectPageHtml, askPageHtml, threadHtml as askThreadHtml, recentAsksHtml, fillAsk, pickupState, paletteProjectItems, scopeOfSession, askHref, sessionAskHtml, sessionAskScope, sessionAskToken, scopeHref, pickListHtml, pickFull, PICK_MAX, ARCHIVE_UNDO_MS } from './ask.js' // Ask about a project (roadmap #4)
44
45
  import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash, prCopyClick } from './prs.js'
@@ -471,7 +472,23 @@ const statusPill = (s) => { if (ownerOffline(s)) return offlinePill(s); const st
471
472
  // 2026-11-03 Claude Code's own hook events never came for this session (only the mod's), so its turns
472
473
  // are read from the transcript: a quiet note, a dashed ring plus words, the why in the tip
473
474
  const HOOKS_QUIET_TIP = 'Claude Code’s hooks aren’t reaching Leftoff for this session, so its prompts and turn ends are read from the transcript instead (a few seconds late). Restarting Claude Code in this folder, or leftoff doctor, usually brings them back.'
474
- const hooksQuietHtml = (s) => (s && s.hooksQuiet && !s.endedAt ? '<span class="s-hooks" data-tip="' + esc(HOOKS_QUIET_TIP) + '"><span class="s-hooks-ring" aria-hidden="true"></span>hooks not reporting</span>' : '')
475
+ // 2026-11-03 and when Leftoff knows which (Claude Code passed them over: a plugin seated ahead of Leftoff's
476
+ // sends them on past it), the note says which and why; the daemon writes the words (src/hook-skips.js)
477
+ const hooksQuietHtml = (s) => {
478
+ if (!s || s.endedAt) return ''
479
+ const k = s.hooksSkipped
480
+ if (k && k.label) return '<span class="s-hooks s-hooks-skip' + (k.applied ? ' s-hooks-applied' : '') + '" data-tip="' + esc(k.tip || k.label) + '"><span class="s-hooks-ring" aria-hidden="true"></span><span class="s-hooks-t">' + esc(k.label) + '</span></span>' + hooksFixButtonHtml(s, { esc }) // 2026-11-03 applied: "restart Claude Code to apply", no button
481
+ return s.hooksQuiet ? '<span class="s-hooks" data-tip="' + esc(HOOKS_QUIET_TIP) + '"><span class="s-hooks-ring" aria-hidden="true"></span>hooks not reporting</span>' : ''
482
+ }
483
+ // the note's button: the sheet with the managed-settings lines (ui/hook-fix.js)
484
+ document.addEventListener('click', (e) => {
485
+ const b = e.target.closest && e.target.closest('[data-hookfix]')
486
+ if (!b) return
487
+ e.preventDefault()
488
+ const sid = b.dataset.hookfix
489
+ const s = (state.session && state.session.id === sid ? state.session : null) || (state.sessions || []).find((x) => x.id === sid) || { id: sid }
490
+ openHookFix({ session: s, from: b, api, esc, toast, thisMachine: state.health && state.health.machine })
491
+ })
475
492
  const offlinePill = (s) => { const w = offlineLine(s); return '<span class="pill s-offline" data-tip="' + esc(offlineTip(s, w, { was: offlineWas(s) })) + '">' + esc(w) + '</span>' }
476
493
 
477
494
  const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
package/ui/hook-fix.js ADDED
@@ -0,0 +1,109 @@
1
+ // 2026-11-03 "Let me answer prompts here": the hooks-skipped note's way out.
2
+ // Claude Code's Security default runs ahead of the plugins you install (Team or
3
+ // Enterprise, managed settings, a managed MCP file) and sends Stop and
4
+ // permission prompts on past Leftoff. Claude Code ignores appendPlugins in
5
+ // your own ~/.claude/settings.json while it's seated (2.1.317), so the sheet
6
+ // shows the managed-settings lines and where they go, with Copy; Leftoff
7
+ // writes none of it. The one change it can make: take Leftoff's own entry
8
+ // back out of your settings when Claude Code ignores it there (an explicit
9
+ // Remove, this Mac's own browser or a trusted paired device, never on a
10
+ // managed Mac). Another Mac's session: run leftoff doctor --fix there.
11
+ // Centered on a desktop, a bottom sheet on a phone.
12
+
13
+ export function hooksFixButtonHtml(s, { esc }) {
14
+ const k = s && s.hooksSkipped
15
+ if (!k || !k.label || k.applied || s.endedAt) return ''
16
+ return '<button type="button" class="s-hooks-fix" data-hookfix="' + esc(s.id) + '" data-tip="Why Leftoff can’t answer this session’s prompts from the board, and the settings line that lets it">Let me answer prompts here</button>'
17
+ }
18
+
19
+ let open = null
20
+
21
+ export function closeHookFix() {
22
+ if (!open) return
23
+ const { back, from, onKey, sid } = open
24
+ open = null
25
+ document.removeEventListener('keydown', onKey, true)
26
+ back.remove()
27
+ document.documentElement.classList.remove('hf-open')
28
+ // the header may have been redrawn while the sheet was up: its button for this session now
29
+ const to = from && from.isConnected ? from : sid ? document.querySelector('[data-hookfix="' + CSS.escape(sid) + '"]') : null
30
+ if (to) try { to.focus({ preventScroll: true }) } catch {}
31
+ }
32
+
33
+ const copyText = async (t) => { try { await navigator.clipboard.writeText(t); return true } catch { return false } }
34
+
35
+ // st: GET /v1/hook-order. -> the sheet's body
36
+ export function hookFixBodyHtml(st, { esc, session = null, thisMachine = null } = {}) {
37
+ if (session && session.isLocal === false) {
38
+ const mac = session.machine || 'that Mac'
39
+ return '<p class="hf-p">This session runs on <b>' + esc(mac) + '</b>, and the settings that decide the plugin order are that Mac’s. Run this there; it says what to add, and why:</p>' +
40
+ '<div class="hf-code-row"><pre class="hf-code hf-cmd">leftoff doctor --fix</pre></div>' +
41
+ '<div class="hf-acts"><button type="button" class="btn hf-copy" data-copy="leftoff doctor --fix">Copy</button><button type="button" class="btn primary hf-close">Close</button></div>'
42
+ }
43
+ if (!st || st.error) return '<p class="hf-p">Couldn’t read this Mac’s Claude Code settings' + (st && st.error ? ' (' + esc(st.error) + ')' : '') + '. <code>leftoff doctor --fix</code> in a terminal says the same.</p><div class="hf-acts"><button type="button" class="btn primary hf-close">Close</button></div>'
44
+ let h = ''
45
+ if (!st.seated) {
46
+ h += '<p class="hf-p"><span class="hf-mark hf-mark-ok" aria-hidden="true"></span>Nothing here puts a plugin ahead of Leftoff’s hooks' + (thisMachine ? ' on ' + esc(thisMachine) : '') + ' right now. A session that started before a change may still show the note; restart Claude Code there.</p>'
47
+ } else if (st.placed) {
48
+ h += '<p class="hf-p"><span class="hf-mark hf-mark-ok" aria-hidden="true"></span><b>Managed settings already seat Leftoff</b> right after Claude Code’s Security default. Restart Claude Code sessions that started before that, and their prompts come here.</p>'
49
+ } else {
50
+ h += '<p class="hf-p">' + esc(st.words) + '</p>'
51
+ h += '<p class="hf-p hf-who"><span class="hf-mark hf-mark-admin" aria-hidden="true"></span>' + (st.managed ? 'Ask whoever manages Claude Code here to ' : 'An admin (on your own Mac, that’s you, with sudo) can ') + (st.snippet.merge ? 'merge this into' : 'put this in') + ':</p>'
52
+ h += '<div class="hf-file"><code>' + esc(st.snippet.file) + '</code></div>'
53
+ h += '<pre class="hf-code">' + esc(st.snippet.json) + '</pre>'
54
+ h += '<p class="hf-p hf-quiet">' + esc(st.snippet.note) + '. Then restart Claude Code. Leftoff never writes managed settings.</p>'
55
+ }
56
+ if (st.ignoredEntry) {
57
+ h += '<div class="hf-ign"><p class="hf-p"><span class="hf-mark hf-mark-off" aria-hidden="true"></span>Your <code>' + esc(st.ignoredEntry.file) + '</code> lists Leftoff in ' + esc(st.ignoredEntry.keys.join(' and ')) + ', which Claude Code ignores here. Removing it changes nothing in Claude Code.</p>' +
58
+ (st.canUnfix ? '<p class="hf-p hf-quiet">Remove takes out only Leftoff’s entry and keeps a backup in ~/.config/checkpoints/backups.</p>' : '<p class="hf-p hf-quiet">This Mac has managed settings, so Leftoff changes nothing in ~/.claude; delete the entry by hand if you like.</p>') + '</div>'
59
+ }
60
+ h += '<div class="hf-acts">' +
61
+ (st.ignoredEntry && st.canUnfix ? '<button type="button" class="btn hf-unfix">Remove the ignored entry</button>' : '') +
62
+ (st.seated && !st.placed ? '<button type="button" class="btn hf-copy" data-copy="' + esc(st.snippet.json) + '">Copy</button>' : '') +
63
+ '<button type="button" class="btn primary hf-close">' + (st.seated && !st.placed ? 'Done' : 'Close') + '</button></div>'
64
+ return h
65
+ }
66
+
67
+ export async function openHookFix({ session, from = null, api, esc, toast = () => {}, thisMachine = null } = {}) {
68
+ closeHookFix()
69
+ const back = document.createElement('div')
70
+ back.className = 'hf-back'
71
+ back.innerHTML = '<div class="hf-modal" role="dialog" aria-modal="true" aria-labelledby="hfTitle"><div class="hf-grab" aria-hidden="true"></div>' +
72
+ '<div class="hf-head"><h2 id="hfTitle">Let Leftoff answer prompts here</h2><button type="button" class="btn x hf-x" aria-label="Close">✕</button></div>' +
73
+ '<div class="hf-body"><p class="hf-p hf-quiet">Reading this Mac’s Claude Code settings…</p></div></div>'
74
+ document.body.appendChild(back)
75
+ document.documentElement.classList.add('hf-open')
76
+ const onKey = (e) => {
77
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeHookFix(); return }
78
+ if (e.key === 'Tab') { // keep focus inside
79
+ const f = [...back.querySelectorAll('button:not([disabled])')]
80
+ if (!f.length) return
81
+ const i = f.indexOf(document.activeElement)
82
+ if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus() } else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus() }
83
+ }
84
+ }
85
+ open = { back, from, onKey, sid: session && session.id }
86
+ document.addEventListener('keydown', onKey, true)
87
+ const body = back.querySelector('.hf-body')
88
+ const paint = (st) => {
89
+ body.innerHTML = hookFixBodyHtml(st, { esc, session, thisMachine })
90
+ const first = back.querySelector('.hf-copy, .hf-close')
91
+ if (first) try { first.focus({ preventScroll: true }) } catch {}
92
+ }
93
+ back.addEventListener('click', async (e) => {
94
+ if (e.target === back || e.target.closest('.hf-x, .hf-close')) { closeHookFix(); return }
95
+ const c = e.target.closest('.hf-copy')
96
+ if (c) { const ok = await copyText(c.dataset.copy || ''); c.textContent = ok ? 'Copied' : 'Select and copy'; setTimeout(() => { if (c.isConnected) c.textContent = 'Copy' }, 1800); return }
97
+ const u = e.target.closest('.hf-unfix')
98
+ if (u) {
99
+ u.disabled = true; u.textContent = 'Removing…'
100
+ try {
101
+ const r = await api('POST', '/v1/hook-order/unfix', { yes: true })
102
+ paint(r.state)
103
+ toast(r.wrote ? 'Removed Leftoff’s entry from ' + r.file + (r.backup ? ' (backup kept)' : '') + '. Restart open Claude Code sessions.' : 'Nothing to remove.', 5000)
104
+ } catch (err) { u.disabled = false; u.textContent = 'Remove the ignored entry'; toast((err && err.message) || 'Couldn’t change it.', 5000) }
105
+ }
106
+ })
107
+ if (session && session.isLocal === false) { paint(null); return }
108
+ try { paint(await api('GET', '/v1/hook-order?fresh=1')) } catch (err) { paint({ error: (err && err.message) || 'no answer' }) }
109
+ }