@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.
@@ -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 M from"node:fs";import N 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`
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 M.statSync(c).isFile()}catch{return!1}},isDir:o=c=>{try{return M.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 R=s.stdio[3];for(let i of[s.stdin,R])i&&i.on("error",()=>{});let G=(i,h)=>{if(j)return;j=!0;let b=p.end();if(b)for(let P of u)P(b);for(let P of H)P({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
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{R.write("R "+i+" "+h+`
57
- `)}catch{}},kill:(i="SIGHUP")=>{try{R.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=>M.statSync(d),chmod:a=(d,e)=>M.chmodSync(d,e)}={}){let d=[];for(let e of[N.join(t,"prebuilds",n+"-"+o,"spawn-helper"),N.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(N.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(`
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"),F=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 T=K({env:l.env||process.env});T.kind||(E("no backend: "+T.why),process.exit(3));var f;try{f=T.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=[],C=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:T.kind,...l.meta||{}},D=()=>{try{let t=F+".tmp";y.writeFileSync(t,JSON.stringify({...L,cols:w,rows:g,exited:m,code:O}),{mode:384}),y.renameSync(t,F)}catch(t){E("meta: "+t.message)}},I=new Set,A=t=>{let n=JSON.stringify(t)+`
62
- `;for(let o of I)if(o.ready)try{o.write(n)}catch{}},$=()=>[...I].filter(t=>t.ready).map(t=>({role:t.role,...t.info||{}}));f.onData(t=>{for(k.push(t),C+=t.length,x+=t.length;C>ot&&k.length>1;)C-=k[0].length,k.shift();A({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(),A({t:"exit",code:O,signal:S}),ct=setTimeout(Y,J?300:l.linger==null?3e4:l.linger)});function Y(){for(let t of I)try{t.end()}catch{}try{z.close()}catch{}for(let t of[v,F])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="";I.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-C,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})+`
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
- `),A({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=()=>{I.delete(t)&&A({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 ("+T.kind+", pid "+f.pid+")")});z.on("error",t=>{E("socket: "+t.message);try{f.kill("SIGHUP")}catch{}process.exit(5)});
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",
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"
@@ -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.3' // 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
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",
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
- if (deck) { const sl = [...deck.children].findIndex((x) => x.dataset.step === step); if (sl >= 0) { wtUi.cur.set(k, step); wtSlideGo(sl) } return } // 0.9.18 the slideshow: that step's slide
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
- if (el) { el.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); el.focus({ preventScroll: true }) }
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
+ }