@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.
- package/CHANGELOG.md +2 -0
- package/bin/leftoff.js +543 -527
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +120 -5
- package/package.json +1 -1
- package/ui/app.css +37 -0
- package/ui/app.js +18 -1
- package/ui/hook-fix.js +109 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
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"
|
package/mod/hooks/register.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|