@leftoff/cli 0.9.18-beta.3 → 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 +5 -0
- package/bin/leftoff.js +548 -531
- package/bin/pty-host-main.js +10 -10
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +141 -4
- package/package.json +1 -1
- package/ui/app.css +55 -0
- package/ui/app.js +32 -6
- package/ui/hook-fix.js +109 -0
- package/ui/semdiff.js +77 -12
package/bin/pty-host-main.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import y from"node:fs";import it from"node:net";import W from"node:path";import
|
|
2
|
+
import y from"node:fs";import it from"node:net";import W from"node:path";import C from"node:fs";import P from"node:path";import{spawn as Z,execFile as ut}from"node:child_process";import{createRequire as q}from"node:module";import{StringDecoder as Q}from"node:string_decoder";var wt=2*1024*1024;var gt=192*1024;var yt=64*1024;var tt=String.raw`
|
|
3
3
|
import os, sys, pty, select, struct, fcntl, termios, signal
|
|
4
4
|
cols, rows = int(sys.argv[1]), int(sys.argv[2])
|
|
5
5
|
argv = sys.argv[3:]
|
|
@@ -51,16 +51,16 @@ while True:
|
|
|
51
51
|
st = os.waitpid(pid, 0)[1]
|
|
52
52
|
code = os.waitstatus_to_exitcode(st)
|
|
53
53
|
sys.exit(code if code >= 0 else 128 - code)
|
|
54
|
-
`;function et({platform:t=process.platform,exists:n=c=>{try{return
|
|
54
|
+
`;function et({platform:t=process.platform,exists:n=c=>{try{return C.statSync(c).isFile()}catch{return!1}},isDir:o=c=>{try{return C.statSync(c).isDirectory()}catch{return!1}}}={}){return(t==="darwin"?["/opt/homebrew/bin/python3","/usr/local/bin/python3",...o("/Library/Developer/CommandLineTools")||o("/Applications/Xcode.app")?["/usr/bin/python3"]:[]]:["/usr/bin/python3","/usr/local/bin/python3","/bin/python3"]).find(n)||null}function st(t){return{kind:"node-pty",spawn({file:n,args:o,cwd:c,env:a,cols:d,rows:e}){let r=t.spawn(n,o,{name:"xterm-256color",cols:d,rows:e,cwd:c,env:a});return{pid:r.pid,onData:s=>r.onData(s),onExit:s=>r.onExit(({exitCode:p,signal:u})=>s({code:p,signal:u||null})),write:s=>r.write(s),resize:(s,p)=>{try{r.resize(s,p)}catch{}},kill:(s="SIGHUP")=>{try{r.kill(s)}catch{}}}}}}function U(t,{spawnImpl:n=Z}={}){return{kind:"python",spawn({file:o,args:c,cwd:a,env:d,cols:e,rows:r}){let s=n(t,["-c",tt,String(e),String(r),o,...c],{cwd:a,env:d,stdio:["pipe","pipe","pipe","pipe"]}),p=new Q("utf8"),u=[],H=[],j=!1;s.stdout.on("data",i=>{let h=p.write(i);if(h)for(let b of u)b(h)}),s.stderr.on("data",i=>{let h=String(i);for(let b of u)b(h)});let A=s.stdio[3];for(let i of[s.stdin,A])i&&i.on("error",()=>{});let G=(i,h)=>{if(j)return;j=!0;let b=p.end();if(b)for(let F of u)F(b);for(let F of H)F({code:i??null,signal:h||null})};s.on("exit",(i,h)=>setTimeout(()=>G(i,h),20)),s.on("error",i=>{for(let h of u)h(`\r
|
|
55
55
|
[terminal] `+i.message+`\r
|
|
56
|
-
`);G(127,null)});let V={SIGHUP:1,SIGINT:2,SIGKILL:9,SIGTERM:15};return{pid:s.pid,onData:i=>u.push(i),onExit:i=>H.push(i),write:i=>{try{s.stdin.write(i)}catch{}},resize:(i,h)=>{try{
|
|
57
|
-
`)}catch{}},kill:(i="SIGHUP")=>{try{
|
|
58
|
-
`)}catch{}if(i==="SIGKILL")try{s.kill("SIGKILL")}catch{}}}}}}function nt(t,{platform:n=process.platform,arch:o=process.arch,stat:c=d=>
|
|
56
|
+
`);G(127,null)});let V={SIGHUP:1,SIGINT:2,SIGKILL:9,SIGTERM:15};return{pid:s.pid,onData:i=>u.push(i),onExit:i=>H.push(i),write:i=>{try{s.stdin.write(i)}catch{}},resize:(i,h)=>{try{A.write("R "+i+" "+h+`
|
|
57
|
+
`)}catch{}},kill:(i="SIGHUP")=>{try{A.write("K "+(V[i]||1)+`
|
|
58
|
+
`)}catch{}if(i==="SIGKILL")try{s.kill("SIGKILL")}catch{}}}}}}function nt(t,{platform:n=process.platform,arch:o=process.arch,stat:c=d=>C.statSync(d),chmod:a=(d,e)=>C.chmodSync(d,e)}={}){let d=[];for(let e of[P.join(t,"prebuilds",n+"-"+o,"spawn-helper"),P.join(t,"build","Release","spawn-helper")]){let r;try{r=c(e)}catch{continue}if(!(!r.isFile()||(r.mode&73)===73))try{a(e,r.mode&511|493),d.push(e)}catch{}}return d}function rt(t,n){if(!n)return t;let o=t;return{get kind(){return o.kind},spawn(c){try{return o.spawn(c)}catch(a){if(o!==t)throw a;return o=n(),o.spawn(c)}}}}function K({requireImpl:t=q(import.meta.url),python:n=et(),env:o=process.env}={}){let c=null;if(o.LEFTOFF_TERM_BACKEND!=="python")try{let a=t("node-pty");try{nt(P.dirname(t.resolve("node-pty/package.json")))}catch{}return rt(st(a),n?()=>U(n):null)}catch(a){c=a}return n?U(n):{kind:null,why:"No terminal backend: node-pty isn’t installed"+(c&&!/Cannot find module/.test(c.message)?" ("+String(c.message).split(`
|
|
59
59
|
`)[0]+")":"")+" and there’s no python3. Run `leftoff upgrade`, or install python3."}}var X=process.argv[2],l;try{l=JSON.parse(y.readFileSync(X,"utf8"))}catch(t){process.stderr.write("pty-host: no spec: "+t.message+`
|
|
60
|
-
`),process.exit(2)}try{y.rmSync(X,{force:!0})}catch{}var{id:_,dir:B}=l,v=W.join(B,_+".sock"),
|
|
61
|
-
`);process.on("SIGHUP",()=>{});process.on("SIGPIPE",()=>{});var
|
|
62
|
-
`;for(let o of
|
|
63
|
-
`))>=0;){let d=n.slice(0,a);n=n.slice(a+1);let e;try{e=JSON.parse(d)}catch{continue}if(e.t==="hello"){t.role=e.role==="attach"?"attach":"daemon",t.info=e.role==="attach"?{pid:Number(e.pid)||null,tty:e.tty?String(e.tty).slice(0,80):null,term:e.term&&typeof e.term=="object"?e.term:null}:{},t.ready=!0;let r=k.join("");k=r?[r]:[];let s=x-
|
|
60
|
+
`),process.exit(2)}try{y.rmSync(X,{force:!0})}catch{}var{id:_,dir:B}=l,v=W.join(B,_+".sock"),N=W.join(B,_+".json"),ot=l.bufferMax||2*1024*1024,E=t=>process.stderr.write(new Date().toISOString()+" "+_+" "+t+`
|
|
61
|
+
`);process.on("SIGHUP",()=>{});process.on("SIGPIPE",()=>{});var I=K({env:l.env||process.env});I.kind||(E("no backend: "+I.why),process.exit(3));var f;try{f=I.spawn({file:l.file,args:l.args||[],cwd:l.cwd,env:l.env||process.env,cols:l.cols||80,rows:l.rows||24})}catch(t){E("spawn failed: "+t.message),process.exit(4)}var k=[],M=0,x=0,m=!1,O=null,S=null,J=!1,w=l.cols||80,g=l.rows||24,L={v:1,id:_,hostPid:process.pid,pid:f.pid||null,cwd:l.cwd,createdAt:new Date().toISOString(),cols:w,rows:g,backend:I.kind,...l.meta||{}},D=()=>{try{let t=N+".tmp";y.writeFileSync(t,JSON.stringify({...L,cols:w,rows:g,exited:m,code:O}),{mode:384}),y.renameSync(t,N)}catch(t){E("meta: "+t.message)}},T=new Set,R=t=>{let n=JSON.stringify(t)+`
|
|
62
|
+
`;for(let o of T)if(o.ready)try{o.write(n)}catch{}},$=()=>[...T].filter(t=>t.ready).map(t=>({role:t.role,...t.info||{}}));f.onData(t=>{for(k.push(t),M+=t.length,x+=t.length;M>ot&&k.length>1;)M-=k[0].length,k.shift();R({t:"out",d:t,end:x})});var ct=null;f.onExit(({code:t,signal:n})=>{m=!0,O=t??null,S=n||null,E("program exited ("+O+(S?", "+S:"")+")"),D(),R({t:"exit",code:O,signal:S}),ct=setTimeout(Y,J?300:l.linger==null?3e4:l.linger)});function Y(){for(let t of T)try{t.end()}catch{}try{z.close()}catch{}for(let t of[v,N])try{y.rmSync(t,{force:!0})}catch{}setTimeout(()=>process.exit(0),50)}try{y.rmSync(v,{force:!0})}catch{}var z=it.createServer(t=>{t.setEncoding("utf8");let n="";T.add(t),t.on("data",c=>{n+=c;let a;for(;(a=n.indexOf(`
|
|
63
|
+
`))>=0;){let d=n.slice(0,a);n=n.slice(a+1);let e;try{e=JSON.parse(d)}catch{continue}if(e.t==="hello"){t.role=e.role==="attach"?"attach":"daemon",t.info=e.role==="attach"?{pid:Number(e.pid)||null,tty:e.tty?String(e.tty).slice(0,80):null,term:e.term&&typeof e.term=="object"?e.term:null}:{},t.ready=!0;let r=k.join("");k=r?[r]:[];let s=x-M,p=typeof e.since=="number"&&e.since>=s&&e.since<=x?e.since:s;t.write(JSON.stringify({t:"hello",id:_,pid:f.pid||null,start:s,end:x,exited:m,code:O,signal:S,cols:w,rows:g,meta:L})+`
|
|
64
64
|
`);let u=r.slice(p-s);u&&t.write(JSON.stringify({t:"out",d:u,end:x})+`
|
|
65
65
|
`),m&&t.write(JSON.stringify({t:"exit",code:O,signal:S})+`
|
|
66
|
-
`),
|
|
66
|
+
`),R({t:"clients",list:$()})}else if(e.t==="in"){if(!m)try{f.write(String(e.d||""))}catch{}}else if(e.t==="resize"){let r=Math.max(2,Math.min(1e3,Number(e.c)||w)),s=Math.max(1,Math.min(500,Number(e.r)||g));if(r!==w||s!==g){w=r,g=s;try{f.resize(w,g)}catch{}}}else if(e.t==="kill")if(J=!0,m)Y();else{try{f.kill(e.sig||"SIGHUP")}catch{}setTimeout(()=>{if(!m)try{f.kill("SIGKILL")}catch{}},3e3).unref()}else if(e.t==="meta"&&e.patch&&typeof e.patch=="object"){for(let[r,s]of Object.entries(e.patch))/^(title|kind|sessionId|run|startCwd|renamed)$/.test(r)&&(L[r]=s);D()}}});let o=()=>{T.delete(t)&&R({t:"clients",list:$()})};t.on("close",o),t.on("error",o)}),lt=process.umask(127);z.listen(v,()=>{process.umask(lt);try{y.chmodSync(v,384)}catch{}D(),E("listening ("+I.kind+", pid "+f.pid+")")});z.on("error",t=>{E("socket: "+t.message);try{f.kill("SIGHUP")}catch{}process.exit(5)});
|
|
@@ -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
|
@@ -26,9 +26,13 @@ const PANE_ID = 'checkpoints'
|
|
|
26
26
|
const POST_TIMEOUT_MS = 800
|
|
27
27
|
const END_TIMEOUT_MS = 1000
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
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
|
|
29
33
|
|
|
30
34
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.18-beta.
|
|
35
|
+
const MOD_VERSION = '0.9.18-beta.5' // keep in step with .claude-plugin/plugin.json
|
|
32
36
|
|
|
33
37
|
const st = {
|
|
34
38
|
internal: false,
|
|
@@ -69,6 +73,15 @@ const st = {
|
|
|
69
73
|
listSig: null, // v0.5.1: the signature of this session's lists Claude last heard about
|
|
70
74
|
listPending: false, // a list was saved or ticked here: tell Claude with the next prompt
|
|
71
75
|
paneNeeds: 0, // sessions that need you (the pane's top line)
|
|
76
|
+
turnStartAt: 0, // 2026-11-03 when this process's current turn started (turn.start)
|
|
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
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
// Millisecond precision: the daemon dedupes events per (sessionId, type, at).
|
|
@@ -230,7 +243,7 @@ async function readRepo($, cwd) {
|
|
|
230
243
|
|
|
231
244
|
async function heartbeat($) {
|
|
232
245
|
if (st.internal || !st.sessionId) return
|
|
233
|
-
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 } : {}) })
|
|
234
247
|
if (res && !st.modHealthSent) await reportModHealth($)
|
|
235
248
|
if (res && st.daemonTitle) await noteTitle($, st.daemonTitle)
|
|
236
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
|
|
@@ -243,6 +256,99 @@ async function heartbeat($) {
|
|
|
243
256
|
}
|
|
244
257
|
}
|
|
245
258
|
|
|
259
|
+
// 2026-11-03 a turn completed and no Stop came for it within STOP_GRACE_MS:
|
|
260
|
+
// report its end (once; nothing when this process doesn't hold a turn open).
|
|
261
|
+
async function stopFallback($, completedAt, reason) {
|
|
262
|
+
if (st.internal || !st.sessionId) return
|
|
263
|
+
if (st.stopAt && st.stopAt >= (st.turnStartAt || 0)) return // its Stop came
|
|
264
|
+
if (!(st.record && st.record.inTurn)) return
|
|
265
|
+
st.stopAt = Date.now()
|
|
266
|
+
await sendOrSpool($, 'turn_completed', { endReason: String(reason || 'end').slice(0, 40), settledBy: 'mod', completedAt: new Date(completedAt).toISOString() })
|
|
267
|
+
}
|
|
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
|
+
|
|
246
352
|
// The commands, in the order they're tried. Claude Code 2.1.295's built-ins
|
|
247
353
|
// own /checkpoint (an alias of /rewind) and /name (an alias of /rename), so
|
|
248
354
|
// ours share a /cp prefix; each command lists fallbacks in case a later
|
|
@@ -1029,6 +1135,7 @@ export function register(on, options) {
|
|
|
1029
1135
|
st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
|
|
1030
1136
|
st.ready = true
|
|
1031
1137
|
startReplyLoop($)
|
|
1138
|
+
expectClassic($, 'SessionStart', START_SKIP_GRACE_MS) // 2026-11-03 passed over? (sawClassic)
|
|
1032
1139
|
} catch {}
|
|
1033
1140
|
// Commands last, each on its own: a taken name throws, so a
|
|
1034
1141
|
// clash costs only that command, and registerCommands reports it.
|
|
@@ -1037,6 +1144,7 @@ export function register(on, options) {
|
|
|
1037
1144
|
})
|
|
1038
1145
|
|
|
1039
1146
|
on('classic.SessionStart', async ($, e, next) => {
|
|
1147
|
+
sawClassic($, 'SessionStart')
|
|
1040
1148
|
if (!(await internalOnce($)) && !st.internal && e.session_id) {
|
|
1041
1149
|
let parentSessionId
|
|
1042
1150
|
if (st.sessionId && e.session_id !== st.sessionId) {
|
|
@@ -1069,6 +1177,7 @@ export function register(on, options) {
|
|
|
1069
1177
|
})
|
|
1070
1178
|
|
|
1071
1179
|
on('classic.UserPromptSubmit', async ($, e, next) => {
|
|
1180
|
+
sawClassic($, 'UserPromptSubmit')
|
|
1072
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
|
|
1073
1182
|
return next(e)
|
|
1074
1183
|
})
|
|
@@ -1078,11 +1187,13 @@ export function register(on, options) {
|
|
|
1078
1187
|
// context entry: the list, its numbers, and `leftoff todo done <list> <n>`.
|
|
1079
1188
|
// Context goes on the event before next(); on the result it would be too late.
|
|
1080
1189
|
on('prompt.submit', async ($, e, next) => {
|
|
1190
|
+
expectClassic($, 'UserPromptSubmit')
|
|
1081
1191
|
const ctx = await listContextFor($)
|
|
1082
1192
|
return next(ctx ? { ...e, context: [...(Array.isArray(e.context) ? e.context : []), ctx] } : e)
|
|
1083
1193
|
})
|
|
1084
1194
|
|
|
1085
1195
|
on('turn.start', async ($, e, next) => {
|
|
1196
|
+
st.turnStartAt = Date.now()
|
|
1086
1197
|
await sendOrSpool($, 'turn_started', {})
|
|
1087
1198
|
return next(e)
|
|
1088
1199
|
})
|
|
@@ -1090,11 +1201,21 @@ export function register(on, options) {
|
|
|
1090
1201
|
on('turn.complete', async ($, e, next) => {
|
|
1091
1202
|
// classic.Stop reports a normal end with its details; Stop doesn't fire for
|
|
1092
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
|
|
1093
1205
|
if (e.reason === 'aborted' || e.reason === 'error' || e.reason === 'refusal') await sendOrSpool($, 'turn_completed', { endReason: e.reason })
|
|
1206
|
+
// 2026-11-03 Claude Code doesn't run every Stop for every end of a turn
|
|
1207
|
+
// (2.1.317: a turn its loop ends runs Stop without the in-process hooks,
|
|
1208
|
+
// and a tool can end a turn), and then this turn stayed open on the board
|
|
1209
|
+
// for good. If no Stop came for this turn shortly after it completed, the
|
|
1210
|
+
// end is reported from here; a Stop that comes later is the same turn
|
|
1211
|
+
// (the daemon doesn't count it twice).
|
|
1212
|
+
else { const at = Date.now(); $.clock.after(STOP_GRACE_MS, () => { stopFallback($, at, e.reason).catch(() => {}) }); expectClassic($, 'Stop') }
|
|
1094
1213
|
return next(e)
|
|
1095
1214
|
})
|
|
1096
1215
|
|
|
1097
1216
|
on('classic.Stop', async ($, e, next) => {
|
|
1217
|
+
sawClassic($, 'Stop')
|
|
1218
|
+
st.stopAt = Date.now()
|
|
1098
1219
|
// v0.5.1: a checklist of 3+ in this reply (a cheap regex, no model) -> the
|
|
1099
1220
|
// board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
|
|
1100
1221
|
const listHint = listHintFrom(e.last_assistant_message || '', { fallback: st.daemonTitle || null })
|
|
@@ -1118,20 +1239,36 @@ export function register(on, options) {
|
|
|
1118
1239
|
})
|
|
1119
1240
|
|
|
1120
1241
|
on('classic.Notification', async ($, e, next) => {
|
|
1242
|
+
sawClassic($, 'Notification')
|
|
1121
1243
|
await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200), ...moved(e) })
|
|
1122
1244
|
return next(e)
|
|
1123
1245
|
})
|
|
1124
1246
|
|
|
1125
|
-
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
|
+
})
|
|
1126
1262
|
|
|
1127
1263
|
on('classic.PostToolUse', async ($, e, next) => {
|
|
1264
|
+
sawClassic($, 'PostToolUse')
|
|
1128
1265
|
await toolRan($, e)
|
|
1129
1266
|
const m = moved(e) // EnterWorktree, or a cd: say so now, not at the end of the turn
|
|
1130
1267
|
if (m.cwd || m.transcriptPath) await sendOrSpool($, 'activity', { kind: 'cwd', ...m })
|
|
1131
1268
|
return next(e)
|
|
1132
1269
|
})
|
|
1133
1270
|
|
|
1134
|
-
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) })
|
|
1135
1272
|
|
|
1136
1273
|
on('session.end', async ($, e, next) => {
|
|
1137
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
|
@@ -5274,6 +5274,18 @@ code .file-ref-off { font-size: inherit; }
|
|
|
5274
5274
|
.wt-sf .wt-snip + .wt-snip { border-top: 1px dashed var(--line); }
|
|
5275
5275
|
.wt-sf .wt-snip-h { margin: 0; padding: 4px 10px; }
|
|
5276
5276
|
@media (pointer: fine) { .wt-sf > summary { min-height: 34px; } .wt-sfa-b { min-height: 32px; } }
|
|
5277
|
+
/* 2026-11-03 a file a step shares with an earlier one: folded, with a quiet way back to where it's open */
|
|
5278
|
+
.wt-also { display: inline-flex; align-items: center; gap: 4px; align-self: stretch; padding: 0 6px; font-size: 12px; color: var(--text2); text-decoration: none; white-space: nowrap; }
|
|
5279
|
+
.wt-also .ic { width: 13px; height: 13px; color: var(--muted); }
|
|
5280
|
+
.wt-also:hover { color: var(--text); text-decoration: underline; text-underline-offset: 2px; }
|
|
5281
|
+
.wt-also:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 6px; }
|
|
5282
|
+
.wt-sf-also > summary .wt-sf-p { color: var(--text2); }
|
|
5283
|
+
/* the lines the step's refs name, when it shows a hunk whole: the rest steps back */
|
|
5284
|
+
.wt-gd-focus .gd-row:not(.wt-focus), .wt-gd-focus .gd-half:not(.wt-focus):not(.gd-empty) { opacity: .55; }
|
|
5285
|
+
.wt-gd-focus .gd-row.wt-focus .gd-n, .wt-gd-focus .gd-half.wt-focus .gd-gut { box-shadow: inset 2px 0 0 var(--muted); }
|
|
5286
|
+
.wt-focus-w { display: inline-flex; align-items: center; gap: 6px; }
|
|
5287
|
+
.wt-focus-g { width: 2px; height: 12px; border-radius: 1px; background: var(--muted); }
|
|
5288
|
+
.wt-snip-none { margin: 0; padding: 10px; font-size: 12.5px; }
|
|
5277
5289
|
/* "changed since": a half-filled circle and the words (the stale bar uses the same glyph) */
|
|
5278
5290
|
.wt-chg { flex: none; display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 1px 7px 0; box-sizing: border-box; border: 1px dashed var(--line-strong); border-radius: 6px; font-size: 11.5px; font-weight: 600; line-height: 1; color: var(--text2); }
|
|
5279
5291
|
.wt-chg-g { flex: none; width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid currentColor; background: linear-gradient(90deg, currentColor 50%, transparent 50%); box-sizing: border-box; }
|
|
@@ -5378,3 +5390,46 @@ code .file-ref-off { font-size: inherit; }
|
|
|
5378
5390
|
.wid-path .wt-nots, .wid-path .wt-pre { font-family: var(--sans, inherit); }
|
|
5379
5391
|
.wid-scope-n { display: flex; gap: 6px; align-items: flex-start; }
|
|
5380
5392
|
.wid-scope-n .ic { width: 13px; height: 13px; flex: none; margin-top: 2px; }
|
|
5393
|
+
|
|
5394
|
+
/* 2026-11-03 "hooks not reporting": a quiet note beside the session's pills (a dashed ring plus words) */
|
|
5395
|
+
.sub .s-hooks { display: inline-flex; align-items: center; gap: 5px; margin-left: 4px; font-size: 12px; color: var(--muted); white-space: nowrap; }
|
|
5396
|
+
.sub .s-hooks-ring { width: 8px; height: 8px; border-radius: 50%; border: 1.5px dashed currentColor; box-sizing: border-box; flex: none; }
|
|
5397
|
+
.fs-head .sub > .s-hooks { height: 22px; vertical-align: middle; }
|
|
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'
|
|
@@ -54,7 +55,7 @@ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember
|
|
|
54
55
|
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
|
|
55
56
|
import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
|
|
56
57
|
import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
|
|
57
|
-
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine } from './semdiff.js' // 0.9.17 Walk me through it
|
|
58
|
+
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine } from './semdiff.js' // 0.9.17 Walk me through it
|
|
58
59
|
import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
|
|
59
60
|
import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
|
|
60
61
|
import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
|
|
@@ -468,6 +469,26 @@ const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an
|
|
|
468
469
|
// The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
|
|
469
470
|
// 0.9.14: another Mac that's asleep or unreachable: "offline · last seen 12m ago" instead (never "needs you").
|
|
470
471
|
const statusPill = (s) => { if (ownerOffline(s)) return offlinePill(s); const st = cardPills(s).status; return st ? pill(st, s) : '' }
|
|
472
|
+
// 2026-11-03 Claude Code's own hook events never came for this session (only the mod's), so its turns
|
|
473
|
+
// are read from the transcript: a quiet note, a dashed ring plus words, the why in the tip
|
|
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.'
|
|
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
|
+
})
|
|
471
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>' }
|
|
472
493
|
|
|
473
494
|
const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
@@ -1142,6 +1163,7 @@ async function wtCheck(sid) {
|
|
|
1142
1163
|
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { stale: true, peek: true }), { timeoutMs: 60000 })
|
|
1143
1164
|
const had = wtUi.data.get(k)
|
|
1144
1165
|
if (r && r.status === 'ok' && had && !wtUi.loading.has(k) && (r.fingerprint !== had.fingerprint || JSON.stringify(r.stale || null) !== JSON.stringify(had.stale || null))) {
|
|
1166
|
+
if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
|
|
1145
1167
|
wtUi.data.set(k, r)
|
|
1146
1168
|
if (wtUi.sid === sid) { wtUi.sig = ''; wtFill(wtSession(sid)) }
|
|
1147
1169
|
}
|
|
@@ -1215,6 +1237,7 @@ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {})
|
|
|
1215
1237
|
wtFill(wtSession(sid))
|
|
1216
1238
|
try {
|
|
1217
1239
|
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { ...(fresh ? { fresh: true } : {}), ...(fresh || update ? {} : { stale: true }), ...(peek && !fresh && !update ? { peek: true } : {}) }), { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
|
|
1240
|
+
if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
|
|
1218
1241
|
wtUi.data.set(k, r)
|
|
1219
1242
|
if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
|
|
1220
1243
|
// 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
|
|
@@ -1339,18 +1362,21 @@ function wtRevNext() {
|
|
|
1339
1362
|
if (!done) wtReviewed(sid, step, { force: true, advance: false })
|
|
1340
1363
|
wtSlideGo(st.to)
|
|
1341
1364
|
}
|
|
1342
|
-
function wtGo(sid, step, { group = null } = {}) {
|
|
1365
|
+
function wtGo(sid, step, { group = null, file = null } = {}) {
|
|
1343
1366
|
const node = wtUi.node; if (!node) return
|
|
1344
1367
|
if (group && node.querySelector('#wtDeck')) { const d = node.querySelector('#wtDeck'), sk = d.lastElementChild; if (sk && !sk.dataset.step && sk.dataset.slide !== '0') wtSlideGo(Number(sk.dataset.slide)) }
|
|
1345
1368
|
if (group) { const g = node.querySelector('#wt-g-' + CSS.escape(group)); if (g) { g.open = true; g.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }) } return }
|
|
1346
1369
|
const k = wtKey(sid)
|
|
1347
1370
|
const deck = node.querySelector('#wtDeck')
|
|
1348
|
-
|
|
1371
|
+
// 2026-11-03 "Also in step N": that step's row for the file, opened, is where it lands
|
|
1372
|
+
const fileRow = () => { if (!file) return null; const d = node.querySelector('details.wt-sf[data-sfk="' + CSS.escape(step + '|' + file) + '"]'); if (d && !d.open) d.open = true; return d }
|
|
1373
|
+
if (deck) { const sl = [...deck.children].findIndex((x) => x.dataset.step === step); if (sl >= 0) { wtUi.cur.set(k, step); wtSlideGo(sl); const d = fileRow(); if (d) d.scrollIntoView({ block: 'nearest', behavior: 'auto' }) } return } // 0.9.18 the slideshow: that step's slide
|
|
1349
1374
|
wtUi.cur.set(k, step)
|
|
1350
1375
|
for (const a of node.querySelectorAll('article.wt-st')) a.classList.toggle('cur', a.dataset.step === step)
|
|
1351
1376
|
for (const a of node.querySelectorAll('.wt-fm-i[data-step]')) a.classList.toggle('cur', a.dataset.step === step)
|
|
1352
1377
|
const el = node.querySelector('#wt-' + CSS.escape(step))
|
|
1353
|
-
|
|
1378
|
+
const row = fileRow()
|
|
1379
|
+
if (el) { (row || el).scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); el.focus({ preventScroll: true }) }
|
|
1354
1380
|
const data = wtUi.data.get(k); const n = node.querySelector('#wtPnavN'); if (n && data) n.textContent = stepPos(data.steps, { filter: wtUi.filter, cur: step })
|
|
1355
1381
|
}
|
|
1356
1382
|
// 0.9.18 the walkthrough as slides (ui/semdiff.js deckHtml): "Show all" (kept per device)
|
|
@@ -2880,7 +2906,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2880
2906
|
const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
|
|
2881
2907
|
'<button type="button" class="fs-side-x" data-action="fs-side" aria-expanded="true" aria-controls="fsSide" aria-label="Hide the session panel" data-tip="Hide panel (⌘\\)">' + icon('chevron-right') + '</button>' +
|
|
2882
2908
|
title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
|
|
2883
|
-
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
|
|
2909
|
+
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + hooksQuietHtml(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
|
|
2884
2910
|
// 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
|
|
2885
2911
|
(where.length ? '<div class="fs-where fs-repo" data-tip="' + esc(wherePath ? wherePath + (branch ? ' · ' + branch : '') : where.join(' · ')) + '">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
|
|
2886
2912
|
'<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
|
|
@@ -8543,7 +8569,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8543
8569
|
const sm = d.querySelector('summary'); if (sm) sm.focus({ preventScroll: true })
|
|
8544
8570
|
return
|
|
8545
8571
|
}
|
|
8546
|
-
if (action === 'wt-go') { ev.preventDefault(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null }) }
|
|
8572
|
+
if (action === 'wt-go') { ev.preventDefault(); ev.stopPropagation(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null, file: btn.dataset.file || null }) }
|
|
8547
8573
|
if (action === 'wt-step') return wtStep(Number(btn.dataset.dir) || 1)
|
|
8548
8574
|
// 2026-10-30 walkthrough round 3
|
|
8549
8575
|
if (action === 'wt-dmode') { try { if (btn.dataset.mode === 'split') localStorage.setItem('lo-wt-split', '1'); else localStorage.removeItem('lo-wt-split') } catch {} wtUi.sig = ''; return void wtFill(wtSession(wtUi.sid)) }
|
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
|
+
}
|