@leftoff/cli 0.9.17 → 0.9.18-beta.2

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.
@@ -0,0 +1,66 @@
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`
3
+ import os, sys, pty, select, struct, fcntl, termios, signal
4
+ cols, rows = int(sys.argv[1]), int(sys.argv[2])
5
+ argv = sys.argv[3:]
6
+ pid, fd = pty.fork()
7
+ if pid == 0:
8
+ try:
9
+ os.execvp(argv[0], argv)
10
+ finally:
11
+ os._exit(127)
12
+ def size(c, r):
13
+ try: fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', r, c, 0, 0))
14
+ except OSError: pass
15
+ size(cols, rows)
16
+ def wall(f, b):
17
+ while b:
18
+ try: n = os.write(f, b)
19
+ except InterruptedError: continue
20
+ b = b[n:]
21
+ ins = [0, fd, 3]
22
+ ctl = b''
23
+ while True:
24
+ try: r = select.select(ins, [], [])[0]
25
+ except InterruptedError: continue
26
+ if fd in r:
27
+ try: d = os.read(fd, 65536)
28
+ except OSError: d = b''
29
+ if not d: break
30
+ wall(1, d)
31
+ if 0 in r:
32
+ d = os.read(0, 65536)
33
+ if not d:
34
+ ins.remove(0)
35
+ try: os.kill(pid, signal.SIGHUP)
36
+ except OSError: pass
37
+ else:
38
+ try: wall(fd, d)
39
+ except OSError: pass
40
+ if 3 in r:
41
+ d = os.read(3, 4096)
42
+ if not d: ins.remove(3)
43
+ ctl += d
44
+ while b'\n' in ctl:
45
+ line, ctl = ctl.split(b'\n', 1)
46
+ p = line.split()
47
+ try:
48
+ if p[0] == b'R': size(int(p[1]), int(p[2]))
49
+ elif p[0] == b'K': os.kill(pid, int(p[1]))
50
+ except (IndexError, ValueError, OSError): pass
51
+ st = os.waitpid(pid, 0)[1]
52
+ code = os.waitstatus_to_exitcode(st)
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
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(`
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})+`
64
+ `);let u=r.slice(p-s);u&&t.write(JSON.stringify({t:"out",d:u,end:x})+`
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)});
@@ -0,0 +1,52 @@
1
+ # macOS permission prompts
2
+
3
+ ## What the prompt means
4
+
5
+ > **"node" would like to access data from other apps.**
6
+ > Keeping app data separate makes it easier to manage your privacy and security.
7
+
8
+ macOS 14 and later ask this when a program reads inside another app's data: `~/Library/Containers/<app>`, `~/Library/Group Containers/<group>`, and on macOS 15 and later an app's own folder in `~/Library/Application Support`. The privacy service is called App Data (`kTCCServiceSystemPolicyAppData`).
9
+
10
+ macOS asks on behalf of the *responsible* program. A program started by the login service is responsible for everything it starts, and so is a terminal app for its shells. Before Leftoff 1.0 the login service started `node` itself. So anything Leftoff ran showed up as "node": a headless Claude Code or Codex session, a board terminal, git. Claude Code's command sandbox asks for App Data on its own when it sets up.
11
+
12
+ Two things made it repeat:
13
+ - A bare `node` binary is remembered by its path, and a Homebrew or nvm upgrade puts node at a new path.
14
+ - macOS keeps no "Don't Allow" for this service, so after Don't Allow it simply asks again.
15
+
16
+ ## What Leftoff does about it (1.0)
17
+
18
+ - **Leftoff's own code stays out of other apps' data.** No walk, listing, git call or file read goes into `~/Library` except the two places Leftoff uses: its own `Application Support/Leftoff` and `LaunchAgents`. In particular:
19
+ - the board terminal's "Match my Ghostty" reads Ghostty's folder only once you pick that theme, not every time the menu opens;
20
+ - Syncthing's `config.xml` is read every 10 minutes instead of every minute;
21
+ - a git repo rooted at your home folder (dotfiles) never lets git walk `~/Library`.
22
+ - **The rest asks as "Leftoff", once.** On a Mac the login service now starts a small launcher inside `~/Library/Application Support/Leftoff/Agent/Leftoff.app` (bundle id `dev.leftoff.Leftoff`). That launcher runs node as its child, so macOS names Leftoff, and an Allow is kept for the app across Leftoff and node upgrades.
23
+ - The app is built on your Mac once, with the Command Line Tools' compiler, and signed ad hoc. It is never rebuilt by an upgrade.
24
+ - Without the Command Line Tools the service runs node directly, as before, and `leftoff doctor` says so (`xcode-select --install`, then `leftoff update`).
25
+
26
+ After `leftoff update`, expect at most one prompt that says "Leftoff". Click **Allow**.
27
+
28
+ ## If you clicked Don't Allow
29
+
30
+ macOS doesn't keep a Don't Allow for App Data, so the next request asks again; click Allow then.
31
+
32
+ To take back an Allow and be asked again (no sudo, only your own user's answers):
33
+
34
+ tccutil reset SystemPolicyAppData dev.leftoff.Leftoff
35
+
36
+ `tccutil reset SystemPolicyAppData` without the id resets the answer for every app.
37
+
38
+ ## See who is asking
39
+
40
+ leftoff doctor --tcc
41
+
42
+ This reads macOS's privacy log for the last 2 hours, then watches for a minute while you use Leftoff. It prints each request: who it was asked for (Leftoff, node, a terminal), which program touched the data, and whether a prompt showed. Options: `--seconds=120`, `--since=6h`, `--all` for every privacy service. It needs an admin account, never sudo; some fields may read `<private>`.
43
+
44
+ To watch it by hand:
45
+
46
+ log stream --style compact --predicate 'subsystem == "com.apple.TCC" AND eventMessage CONTAINS "AUTHREQ"'
47
+
48
+ `leftoff doctor` (without `--tcc`) says whether the login service runs as Leftoff.app.
49
+
50
+ ## Rebuilding
51
+
52
+ `leftoff update --force` rebuilds Leftoff.app. A new build is a new code signature, so macOS asks once more.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.17",
3
+ "version": "0.9.18-beta.2",
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"
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
29
 
30
30
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
31
- const MOD_VERSION = '0.9.17' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.18-beta.2' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.17",
3
+ "version": "0.9.18-beta.2",
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
@@ -3283,6 +3283,10 @@ del.gd-x { background: var(--gd-del-word); }
3283
3283
  /* 0.9.9 Permissions: the New session choice, the session page's switch and its pill beside Live */
3284
3284
  .start-mode { grid-template-columns: repeat(3, minmax(0, 1fr)); }
3285
3285
  .start-mode .start-opt-sub { white-space: normal; }
3286
+ /* 1.0 Mode: headless, board terminal, my terminal app (shape + words) */
3287
+ .run-glyph { color: var(--muted); font-weight: 400; display: inline-block; width: 1.1em; }
3288
+ .start-opt[aria-checked="true"] .run-glyph { color: var(--accent); }
3289
+ .start-run .start-opt-sub { white-space: normal; }
3286
3290
  .headless-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: -4px 0 10px; }
3287
3291
  .headless-mode .hm-l { font-size: 12.5px; font-weight: 600; color: var(--muted); }
3288
3292
  .hm-seg { display: inline-flex; border: 1px solid var(--line); border-radius: 10px; overflow: visible; background: var(--card); }
@@ -3321,6 +3325,8 @@ del.gd-x { background: var(--gd-del-word); }
3321
3325
  .help-more[hidden] { display: none; }
3322
3326
  .start-h-help { display: flex; align-items: center; gap: 6px; }
3323
3327
  @media (hover: hover) and (pointer: fine) { .help-q { width: 28px; height: 28px; margin: 0 -4px; } }
3328
+ .term-pill { display: inline-flex; align-items: center; gap: 4px; margin-left: 6px; padding: 1px 8px; border-radius: 999px; border: 1px dashed var(--line-strong); color: var(--text2); font-size: 11.5px; font-weight: 600; line-height: 1.5; white-space: nowrap; } /* 1.0 */
3329
+ .term-pill.t-app { border-style: solid; }
3324
3330
  .mode-pill { position: relative; display: inline-block; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--bg2); color: var(--text2); font-size: 11.5px; font-weight: 600; line-height: 1.5; white-space: nowrap; }
3325
3331
  .mode-pill.m-auto { border-color: var(--accent); background: var(--tint-accent); color: var(--text); }
3326
3332
  .mode-pill.m-acceptEdits { border-color: var(--yellow); background: var(--tint-yellow); color: var(--text); }
@@ -5281,8 +5287,8 @@ code .file-ref-off { font-size: inherit; }
5281
5287
  .wt-dmode { display: none; }
5282
5288
  @media (min-width: 1000px) { .wt-dmode { display: inline-flex; } }
5283
5289
  .wt-r2 { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin: 6px 0 0; }
5284
- .wt-r2:not(:has(.wt-spot)):not(:has(.wt-dmode:not([hidden]))) { display: none; }
5285
- @media (max-width: 999.98px) { .wt-r2:not(:has(.wt-spot)) { display: none; } }
5290
+ .wt-r2:not(:has(.wt-spot)):not(:has(.wt-dmode:not([hidden]))):not(:has(.fsc-seg)) { display: none; }
5291
+ @media (max-width: 999.98px) { .wt-r2:not(:has(.wt-spot)):not(:has(.fsc-seg)) { display: none; } }
5286
5292
  /* the side-by-side drawing */
5287
5293
  .gd-split .gd-srow { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-width: 0; }
5288
5294
  .gd-half { display: grid; grid-template-columns: var(--gd-gw) 2ch minmax(0, 1fr); min-width: 0; }
@@ -5351,3 +5357,24 @@ code .file-ref-off { font-size: inherit; }
5351
5357
  .fs-wt-rv { display: inline-flex; align-items: center; gap: 4px; height: 18px; margin-left: 6px; padding: 1px 6px 0; box-sizing: border-box; border: 1px solid var(--line); border-radius: 9px; font-size: 11px; font-weight: 600; line-height: 1; color: var(--text2); white-space: nowrap; vertical-align: middle; }
5352
5358
  .fs-wt-rv .ic { width: 11px; height: 11px; color: var(--green); margin-top: -1px; }
5353
5359
  .fs-wt-rv-part { border-style: dashed; }
5360
+
5361
+ /* 2026-11-03 This session · Whole folder (walkthrough, its Files view, What it did): a quiet segmented
5362
+ toggle with each side's count; 44px to a finger, 32px to a mouse. Files that aren't this session's carry
5363
+ a small hollow-dashed ring plus the words "not from this session" (shape and words, never color alone). */
5364
+ .fsc-seg { margin-right: auto; flex: none; }
5365
+ .wt-seg.fsc-seg button { height: 44px; gap: 7px; }
5366
+ .fsc-n { font: 500 11.5px/1 var(--mono); font-variant-numeric: tabular-nums; color: var(--muted); padding: 2px 5px 1px; border-radius: 6px; background: var(--bg2); }
5367
+ .wt-seg.fsc-seg button[aria-pressed="true"] .fsc-n { color: var(--text2); background: var(--bg); }
5368
+ @media (pointer: fine) { .wt-seg.fsc-seg button { height: 32px; } }
5369
+ @media (max-width: 600px) { .wt-r2 .fsc-seg { width: 100%; display: flex; } .wt-r2 .fsc-seg button { flex: 1 1 0; } }
5370
+ .nots-g { display: inline-block; flex: none; width: 7px; height: 7px; border: 1.5px dashed var(--muted); border-radius: 50%; box-sizing: border-box; margin: 0 5px 0 0; vertical-align: middle; }
5371
+ .wt-nots { display: inline-flex; align-items: center; flex: none; font-size: 11.5px; color: var(--muted); white-space: nowrap; margin-left: 6px; }
5372
+ .wt-pre { font-size: 11.5px; white-space: nowrap; margin-left: 6px; }
5373
+ .wt-fl-i .nots-g { margin: 0 2px 0 4px; }
5374
+ .wt-fl-i.nots .wt-fm-f, .wt-file.nots .wt-file-p, .wid-item.nots .wid-base { color: var(--text2); }
5375
+ .wid-scope { margin: 4px 0 8px; }
5376
+ .wid-scope .fsc-seg { display: flex; width: 100%; box-sizing: border-box; }
5377
+ .wid-scope .fsc-seg button { flex: 1 1 0; }
5378
+ .wid-path .wt-nots, .wid-path .wt-pre { font-family: var(--sans, inherit); }
5379
+ .wid-scope-n { display: flex; gap: 6px; align-items: flex-start; }
5380
+ .wid-scope-n .ic { width: 13px; height: 13px; flex: none; margin-top: 2px; }
package/ui/app.js CHANGED
@@ -23,7 +23,7 @@ import { parseBang, dropBang, ptyInput, commandOutput, outputMessage, createBang
23
23
  import { withFresh, pickFresh, newerFresh, freshWins, pendingHeadless, settled as headlessSettled } from './headless-fresh.js' // 0.9.9 another Mac's session, current after Stop/Resume/Permissions
24
24
  import { bgPillHtml } from './bgtasks.js' // 0.9.13 "2 tasks running"
25
25
  import { scopeHtml, scopeHintHtml, scopeStart } from './sm-scope.js' // 0.9.15 per-machine summarizer
26
- import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
26
+ import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, termPillHtml, termBarHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
27
27
  import { parseNewSession, projectStartItems, newSessionContext, startStateFor } from './cmdk-new.js' // 0.9.13 ⌘K New session…
28
28
  import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
29
29
  import { wireTips } from './tips.js'
@@ -54,7 +54,7 @@ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember
54
54
  import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
55
55
  import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
56
56
  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 } from './semdiff.js' // 0.9.17 Walk me through it
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
58
  import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
59
59
  import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
60
60
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
@@ -529,7 +529,7 @@ function agentChip(a, { title } = {}) {
529
529
 
530
530
  // 0.8.0: a Codex session wears a small "Codex" badge; Claude Code's wear none.
531
531
  function codexBadge(s) {
532
- return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex" title="' + esc('OpenAI Codex session' + (s.surface === 'codex-ide' ? ' (IDE)' : s.surface === 'codex-app' ? ' (app)' : '')) + '">Codex</span>' : ''
532
+ return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex" data-tip="' + esc('OpenAI Codex session' + (s.surface === 'codex-ide' ? ' (IDE)' : s.surface === 'codex-app' ? ' (app)' : '')) + '">Codex</span>' : ''
533
533
  }
534
534
 
535
535
  // 0.7.1: a session in a git worktree says which ("⑂ fix-login"); one filed
@@ -1088,7 +1088,7 @@ function card(s) {
1088
1088
  '<div class="row1"><div class="title">' + cardGlyph(s) +
1089
1089
  (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
1090
1090
  '<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
1091
- '</div>' + statusPill(s) + liveBadge(s) + eyeCountHtml(s.lookCount, { esc }) + '</div>' +
1091
+ '</div>' + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + eyeCountHtml(s.lookCount, { esc }) + '</div>' +
1092
1092
  '<div class="meta">' + spaceChip(s) + where(s, { repo: !titleIsWhere(s) }) + (ln && !off ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
1093
1093
  (s.needsInput && s.lastNotification && !s.needsYou && !off ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
1094
1094
  cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + recapLineHtml(s, { esc, ago, clock }) + cardCheckpoint(s) + beforeClearHtml(s, { esc }) +
@@ -1104,7 +1104,11 @@ function card(s) {
1104
1104
  // own node is kept between the page's redraws (the live poll repaints the page
1105
1105
  // every few seconds) so the scroll, the drawn code and the open groups stay.
1106
1106
  // Mark reviewed is the session's own record (session.md, like a pin).
1107
- function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') }
1107
+ // 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
1108
+ const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
1109
+ const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
1110
+ function wtBody(sid, extra = {}) { return { scope: wtUi.scope.get(sid) || 'session', ...(fileScope.get() === 'folder' ? { files: 'folder' } : {}), ...extra } }
1111
+ function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') + (fileScope.get() === 'folder' ? '|folder' : '') }
1108
1112
  function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
1109
1113
  function wtRemember() { try { if (wtUi.sid) sessionStorage.setItem('lo-wt', JSON.stringify({ sid: wtUi.sid, scope: wtUi.scope.get(wtUi.sid) || 'session', view: wtUi.view })); else sessionStorage.removeItem('lo-wt') } catch {} }
1110
1114
  function wtRestore(sid) {
@@ -1135,7 +1139,7 @@ async function wtCheck(sid) {
1135
1139
  if (wtUi.checking.has(k)) return
1136
1140
  wtUi.checking.add(k)
1137
1141
  try {
1138
- const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', { scope: wtUi.scope.get(sid) || 'session', stale: true, peek: true }, { timeoutMs: 60000 })
1142
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { stale: true, peek: true }), { timeoutMs: 60000 })
1139
1143
  const had = wtUi.data.get(k)
1140
1144
  if (r && r.status === 'ok' && had && !wtUi.loading.has(k) && (r.fingerprint !== had.fingerprint || JSON.stringify(r.stale || null) !== JSON.stringify(had.stale || null))) {
1141
1145
  wtUi.data.set(k, r)
@@ -1143,6 +1147,18 @@ async function wtCheck(sid) {
1143
1147
  }
1144
1148
  } catch {} finally { wtUi.checking.delete(k) }
1145
1149
  }
1150
+ // 2026-11-03 This session · Whole folder: kept on this device; the walkthrough's story goes stale (Update writes
1151
+ // it again, never a model run on its own: peek), its Files view and What it did show the other side at once
1152
+ function setFileScope(v, { id = null } = {}) {
1153
+ const want = v === 'folder' ? 'folder' : 'session'
1154
+ if (fileScope.get() === want) return
1155
+ fileScope.set(want)
1156
+ wtUi.filter = 'all'; wtUi.picks = []
1157
+ for (const [k, z] of wtSize) { if (!z.pending) { z.at = 0; wtSizeFetch(k) } }
1158
+ for (const [k, w] of widUi) { if (w.open && w.diff) loadWid(k); else if (w.diff) Object.assign(w, { tools: null, diff: null, files: {} }) }
1159
+ if (wtUi.sid) { wtUi.sig = ''; wtLoad(wtUi.sid, { peek: true }) }
1160
+ else if (id) repaintSession(id)
1161
+ }
1146
1162
  function wtClose() {
1147
1163
  const sid = wtUi.sid
1148
1164
  document.documentElement.classList.remove('wt-open')
@@ -1165,7 +1181,7 @@ async function wtSizeFetch(sid) {
1165
1181
  e.pending = true; wtSize.set(sid, e)
1166
1182
  const sc = wtUi.scope.get(sid)
1167
1183
  let data = e.data
1168
- try { data = await api('GET', '/v1/sessions/' + encodeURIComponent(sid) + '/change-size' + (sc === 'checkpoint' || sc === 'session' ? '?scope=' + sc : '')) } catch { data = e.data || null } // an older Mac: no line
1184
+ try { data = await api('GET', '/v1/sessions/' + encodeURIComponent(sid) + '/change-size' + (sc === 'checkpoint' || sc === 'session' ? '?scope=' + sc + filesQ('&') : filesQ('?'))) } catch { data = e.data || null } // an older Mac: no line
1169
1185
  wtSize.set(sid, { at: Date.now(), data, pending: false })
1170
1186
  for (const slot of document.querySelectorAll('.fs-wt-size[data-id="' + CSS.escape(sid) + '"]')) {
1171
1187
  slot.innerHTML = sizeLineHtml(data, { esc, icon, reviewed: wtRevCount(wtSession(sid), data) })
@@ -1190,7 +1206,7 @@ let loPop = null
1190
1206
  function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid); wtRemember() }
1191
1207
  // 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
1192
1208
  // walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
1193
- async function wtLoad(sid, { fresh = false, update = false } = {}) {
1209
+ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
1194
1210
  const k = wtKey(sid)
1195
1211
  const had = wtUi.data.get(k)
1196
1212
  if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
@@ -1198,7 +1214,7 @@ async function wtLoad(sid, { fresh = false, update = false } = {}) {
1198
1214
  wtUi.loading.add(k); wtUi.error.delete(k)
1199
1215
  wtFill(wtSession(sid))
1200
1216
  try {
1201
- const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', { scope: wtUi.scope.get(sid) || 'session', ...(fresh ? { fresh: true } : {}), ...(fresh || update ? {} : { stale: true }) }, { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
1217
+ 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
1202
1218
  wtUi.data.set(k, r)
1203
1219
  if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
1204
1220
  // 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
@@ -1396,8 +1412,11 @@ function wtAsk(sid, text) {
1396
1412
  const data = wtUi.data.get(wtKey(sid))
1397
1413
  // 2026-10-30 lines you picked: each ref and its exact lines go in with the question (ui/walk-lines.js)
1398
1414
  const picks = wtPicksNow(data)
1415
+ // 2026-11-03 which changes the question is about, as the toggle has it right now
1416
+ const line = scopeAskLine(data)
1417
+ const lead = (m) => (line ? line + '\n' + m : m)
1399
1418
  if (picks.length) {
1400
- const msg = refsPrompt(describePicks(picks, (p) => { const x = patchFor(data, p.k); return x && { rows: rowsOf(x.patch), label: x.label, stepTitle: x.stepTitle } }), text)
1419
+ const msg = lead(refsPrompt(describePicks(picks, (p) => { const x = patchFor(data, p.k); return x && { rows: rowsOf(x.patch), label: x.label, stepTitle: x.stepTitle } }), text))
1401
1420
  wtUi.picks = []
1402
1421
  return wtAskPut(sid, msg)
1403
1422
  }
@@ -1405,10 +1424,18 @@ function wtAsk(sid, text) {
1405
1424
  const st = data && data.status === 'ok' ? (data.steps || []).find((x) => x.id === wtUi.cur.get(wtKey(sid))) : null
1406
1425
  const co = st && st.callouts[0]
1407
1426
  const where = st ? (co ? co.file + ':' + co.line : st.files[0] + (st.snippets[0] ? ':' + ((/^@@ -\d+(?:,\d+)? \+(\d+)/.exec(st.snippets[0].patch) || [])[1] || '') : '')).replace(/:$/, '') : ''
1408
- const msg = (where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text
1427
+ const msg = lead((where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text)
1409
1428
  wtAskPut(sid, msg)
1410
1429
  }
1411
1430
  function wtAskPut(sid, msg) {
1431
+ // 0.9.17 a Codex session in the board's terminal: the question goes into Codex's own box there (pasted, not sent)
1432
+ const cs = wtSession(sid)
1433
+ if (cs && cs.codingAgent === 'codex' && cs.terminal && cs.terminal.kind === 'leftoff' && cs.terminal.termId) {
1434
+ const tid = cs.terminal.termId
1435
+ wtUi.ask = ''; wtClose()
1436
+ terminal.show(tid).then((ok) => ok && api('POST', '/v1/term/' + tid + '/in', { data: '\x1b[200~' + msg + '\x1b[201~' })).then((r) => toast(r ? 'In Codex’s box: press Enter there to send' : 'Its terminal isn’t running any more', 4000)).catch((e) => toast(e.message, 5000))
1437
+ return
1438
+ }
1412
1439
  const k = 'live:' + sid
1413
1440
  const typed = ((state.drafts && state.drafts[k]) || '').trim()
1414
1441
  state.drafts[k] = typed ? typed + '\n\n' + msg : msg
@@ -2577,6 +2604,7 @@ function freshSession(s) {
2577
2604
  if (op && out.headless) out = { ...out, headless: pendingHeadless(out.headless, op) }
2578
2605
  return out
2579
2606
  }
2607
+ const switchPending = new Set() // 1.0 a Continue in terminal / headless on its way
2580
2608
  const fetchFresh = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/headless', undefined, { timeoutMs: 8000 }).then((r) => (r && r.fresh) || null, () => null)
2581
2609
  // Redraw the session page from what it has (no fetch): the bar and the switch at once.
2582
2610
  function repaintSession(id) {
@@ -2741,7 +2769,7 @@ async function loadWid(id) {
2741
2769
  paintWid(id)
2742
2770
  await Promise.all([
2743
2771
  api('GET', widPath(id, 'tools')).then((r) => { ui.tools = r.tools || [] }, (e) => { ui.toolsError = widError(e) }),
2744
- api('GET', widPath(id, 'diff')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
2772
+ api('GET', widPath(id, 'diff') + filesQ('?')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
2745
2773
  ])
2746
2774
  ui.loading = false
2747
2775
  // a file you had open shows its lines again
@@ -2753,7 +2781,7 @@ async function loadWidFile(id, p) {
2753
2781
  const f = ui.diff && (ui.diff.files || []).find((x) => x.path === p)
2754
2782
  if (!f || f.hidden || f.binary || f.big || ui.fileLoading[p]) return
2755
2783
  ui.fileLoading[p] = true; paintWid(id)
2756
- try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p)); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
2784
+ try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p) + filesQ('&')); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
2757
2785
  delete ui.fileLoading[p]
2758
2786
  paintWid(id)
2759
2787
  }
@@ -2852,7 +2880,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2852
2880
  const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
2853
2881
  '<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>' +
2854
2882
  title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
2855
- '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(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>' +
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>' +
2856
2884
  // 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
2857
2885
  (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>' : '') +
2858
2886
  '<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
@@ -2873,6 +2901,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2873
2901
  side += '<section class="scu-wrap" id="session-catchup">' + sessionCatchupHtml(scuOf(s.id), { esc, icon, sid: s.id }) + '</section>' // 0.9.9 Catch me up on this session, at the top
2874
2902
  side += '<div class="sask-wrap" id="session-ask">' + sAskPanel(s).html + '</div>' // 0.9.11 Ask about this session
2875
2903
  side += ownerOfflineNote(s)
2904
+ side += termBarHtml(s, { esc, may: state.mayAnswer !== false }) // 1.0 in Leftoff's terminal: Continue headless
2876
2905
  side += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false, busy: modeBusy.get(s.id) || null, local: state.viewerLocal !== false, helpOpen: helpOpen.has(hmHelpId(s.id)) }) // 0.9.9: started from the board: Stop / Resume, Permissions
2877
2906
  if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
2878
2907
  // Checkpoints: a timeline, latest first, the latest open
@@ -3162,7 +3191,7 @@ function liveOpenHtml(s, v, sub) {
3162
3191
  if (isFullLive(s.id)) {
3163
3192
  const h = s.headless
3164
3193
  const stop = h && h.state === 'running' && state.mayAnswer !== false ? '<button type="button" class="btn lf-stop" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' : ''
3165
- return '<section class="live-box full" id="live" aria-label="Live link, full screen" data-id="' + esc(s.id) + '">' + fullBarHtml(s.id, { esc, icon, glyph: glyphFor(s, { cls: 'lf-sg' }), pills: statusPill(s) + liveBadge(s) + mode, stop, retitle: retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id), cls: 'lf-retitle' }) }) +
3194
+ return '<section class="live-box full" id="live" aria-label="Live link, full screen" data-id="' + esc(s.id) + '">' + fullBarHtml(s.id, { esc, icon, glyph: glyphFor(s, { cls: 'lf-sg' }), pills: statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + mode, stop, retitle: retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id), cls: 'lf-retitle' }) }) +
3166
3195
  '<nav class="lf-tabs" id="lfTabs" aria-label="Open sessions"' + (lfTabsUi.html ? '' : ' hidden') + '>' + lfTabsUi.html + '</nav>' // 0.9.14: the open tabs stay in full screen (paintLfTabs fills it)
3167
3196
  }
3168
3197
  return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2>' + mode + '<span class="faint">' + sub + '</span>' + fullButtonHtml(s.id, { esc, icon }) + '</div>'
@@ -5110,6 +5139,7 @@ function walkValueOf(w) {
5110
5139
  const s = w.saved || null
5111
5140
  const choice = s ? s.choice : w.choice
5112
5141
  if (choice === 'agent') return 'agent'
5142
+ if (choice === 'codex') return 'codex' // 0.9.17
5113
5143
  if (choice === 'claude') return 'claude:' + ((s && s.model) || 'sonnet')
5114
5144
  if (choice === 'ollama') return 'ollama:' + ((s && s.model) || w.model || '')
5115
5145
  return 'summarizer'
@@ -5151,7 +5181,7 @@ function paintWalk() {
5151
5181
  let h = '', h0 = ''
5152
5182
  // first: this session's own agent (recommended where Claude Code is here)
5153
5183
  const ag = opt('agent')
5154
- const fbLine = d.fallback ? 'When a session can’t be forked (a Codex session, say), ' + d.fallback.label + ' writes it.' : ''
5184
+ const fbLine = d.fallback ? 'When a session can’t be forked, ' + d.fallback.label + ' writes it' + (d.codexFound ? '; Codex sessions use Codex, and Codex stands in when Claude is unavailable.' : '.') : ''
5155
5185
  h0 = card({ value: 'agent', on: !typed && kind === 'agent', name: ag.name, note: ag.line, suggested: d.claudeFound && d.suggested === 'agent', disabled: !d.claudeFound,
5156
5186
  side: d.claudeFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.claudeFound ? (kind === 'agent' && !typed ? fbLine : '') : 'Needs Claude Code on this machine.' })
5157
5187
  const cl = opt('claude')
@@ -5166,6 +5196,10 @@ function paintWalk() {
5166
5196
  h += h0
5167
5197
  h += card({ value: 'claude:' + cm, on: claudeOn, name: cl.name, note: cl.line, suggested: d.claudeFound && d.suggested === 'claude', disabled: !d.claudeFound,
5168
5198
  side: d.claudeFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.claudeFound ? '' : 'Install Claude Code on this machine to use it.', extra: seg })
5199
+ // 0.9.17 Codex: read-only codex exec in the session's folder; Codex sessions use it by default, and Claude's stand-in when it's unavailable
5200
+ const cx = opt('codex')
5201
+ h += card({ value: 'codex', on: !typed && kind === 'codex', name: cx.name, note: cx.line, disabled: !d.codexFound,
5202
+ side: d.codexFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.codexFound ? '' : 'Install Codex on this machine to use it.' })
5169
5203
  const smo = opt('summarizer')
5170
5204
  h += card({ value: 'summarizer', on: !typed && kind === 'summarizer', name: smo.name, note: smo.line, warn: d.summarizerSmall ? 'Small for this job: expect thinner walkthroughs.' : '' })
5171
5205
  const ol = d.ollama || {}
@@ -7171,9 +7205,9 @@ async function startSend(btn) {
7171
7205
  const box = btn.closest('.start-box')
7172
7206
  const ta = box && box.querySelector('.start-text')
7173
7207
  const text = ta ? ta.value.trim() : ''
7174
- if (!text && !attachList(START_SID).length) { if (ta) ta.focus(); return }
7175
- if (startUi.sending) return
7176
7208
  const pick = startPick(startUi.data, startUi)
7209
+ if (!text && pick.agent !== 'codex' && !attachList(START_SID).length) { if (ta) ta.focus(); return } // 0.9.17 Codex may start with no first message
7210
+ if (startUi.sending) return
7177
7211
  if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
7178
7212
  startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
7179
7213
  try {
@@ -7182,14 +7216,21 @@ async function startSend(btn) {
7182
7216
  await uploadsSettled(START_SID)
7183
7217
  const atts = attachList(START_SID)
7184
7218
  if (atts.some((a) => a.status === 'failed')) throw new Error('An attachment didn’t upload. Retry it or remove it.')
7185
- const r = await api('POST', '/v1/sessions/start', startBody(pick, text, { draft: startUi.draft, atts }), { timeoutMs: 60000 })
7219
+ const r = await api('POST', '/v1/sessions/start', startBody(pick, text, { draft: pick.agent === 'codex' ? null : startUi.draft, atts: pick.agent === 'codex' ? [] : atts }), { timeoutMs: 60000 })
7186
7220
  lastStartWrite(pick) // 0.9.13 ⌘K's New session… starts from here next time
7187
7221
  if (atts.length) { attachUi.by.delete(START_SID); startUi.draft = null }
7188
7222
  const xn = startExtrasNote(pick, r); if (xn) setTimeout(() => toast(xn, 6000), 1200) // 0.9.12: never a silent drop of a project's repo
7189
7223
  if (state.drafts) delete state.drafts.start
7190
7224
  if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // 0.9.9: a phone's keyboard closes
7191
- if (r.state === 'started' && r.sessionId) { startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); openStarted(r.sessionId, r.peerVia ? r.machine || pick.m.machine : null, text); return }
7192
- startUi.ask = { ...r, machine: r.machine || pick.m.machine }
7225
+ if (r.app && !r.app.ok && r.app.message) setTimeout(() => toast(r.app.message, 7000), 900) // 1.0 My terminal app: it still runs (in the board's terminal)
7226
+ if (r.state === 'started' && r.sessionId) {
7227
+ startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); openStarted(r.sessionId, r.peerVia ? r.machine || pick.m.machine : null, text)
7228
+ if (r.run === 'board' && r.terminal && r.terminal.id && !r.peerVia) setTimeout(() => terminal.show(r.terminal.id).catch(() => {}), 50) // 1.0 Board terminal: shown now
7229
+ return
7230
+ }
7231
+ // 0.9.17 Codex: it runs in the board's terminal (shown now, on this Mac's board), and its session opens once Codex writes it
7232
+ if (r.agent === 'codex' && r.terminal && r.terminal.id && !r.peerVia) { toast('Codex started on ' + (r.machine || pick.m.machine)); if (r.run !== 'app' || !(r.app && r.app.ok)) terminal.show(r.terminal.id).catch(() => {}) }
7233
+ startUi.ask = { ...r, machine: r.machine || pick.m.machine, prompt: text }
7193
7234
  pollStartAsk()
7194
7235
  } catch (e) { toast(e.message, 6000) } finally { startUi.sending = false }
7195
7236
  if (state.route && state.route.name === 'new') render()
@@ -8471,6 +8512,7 @@ document.addEventListener('click', async (ev) => {
8471
8512
  if (action === 'wt-close') return wtClose()
8472
8513
  if (action === 'wt-stop') { const sid = btn.dataset.id; btn.disabled = true; const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough/stop').catch(() => null); if (!r || !r.stopped) { const k = wtKey(sid); wtUi.loading.delete(k); wtUi.error.set(k, 'Stopped waiting. It may still finish: open it again in a minute.'); wtUi.sig = ''; wtFill(wtSession(sid)) } return }
8473
8514
  if (action === 'wt-scope') { if (btn.disabled) return; wtUi.fellBack.delete(wtUi.sid); wtUi.scope.set(wtUi.sid, btn.dataset.scope); wtUi.filter = 'all'; wtRemember(); return wtLoad(wtUi.sid) }
8515
+ if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
8474
8516
  if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
8475
8517
  if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
8476
8518
  if (action === 'wt-reviewed') return wtReviewed(wtUi.sid, btn.dataset.step)
@@ -8561,6 +8603,7 @@ document.addEventListener('click', async (ev) => {
8561
8603
  // 0.9.8 What it did
8562
8604
  if (action === 'wid-toggle') { const ui = widOf(id); ui.open = !ui.open; paintWid(id); if (ui.open && !ui.tools && !ui.diff && !ui.loading) loadWid(id); return }
8563
8605
  if (action === 'wid-refresh') return loadWid(id)
8606
+ if (action === 'wid-files') return setFileScope(btn.dataset.files, { from: 'wid', id })
8564
8607
  if (action === 'wid-tool') {
8565
8608
  const ui = widOf(id), t = btn.dataset.tool
8566
8609
  ui.toolOpen[t] = !ui.toolOpen[t]; paintWid(id)
@@ -8742,7 +8785,7 @@ document.addEventListener('click', async (ev) => {
8742
8785
  // 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
8743
8786
  if (action === 'start-pick') {
8744
8787
  const k = btn.dataset.k, v = btn.dataset.v
8745
- if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null; Object.assign(startUi, { extra: [], adding: false, saving: false, find: '' }); if (attachList(START_SID).length) setTimeout(startReupload, 0) /* 0.9.13: its files go to that Mac */ } else if (k === 'project') { startUi.project = v || null; startUi.repo = null; startUi.drop = []; startUi.custom = null; startUi.browse = null; Object.assign(startUi, { extra: [], adding: false, saving: false }) } else if (k === 'repo') { startUi.repo = v; startUi.extra = startUi.extra.filter((x) => x !== v) } else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v
8788
+ if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null; Object.assign(startUi, { extra: [], adding: false, saving: false, find: '' }); if (attachList(START_SID).length) setTimeout(startReupload, 0) /* 0.9.13: its files go to that Mac */ } else if (k === 'project') { startUi.project = v || null; startUi.repo = null; startUi.drop = []; startUi.custom = null; startUi.browse = null; Object.assign(startUi, { extra: [], adding: false, saving: false }) } else if (k === 'repo') { startUi.repo = v; startUi.extra = startUi.extra.filter((x) => x !== v) } else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v; else if (k === 'agent') startUi.agent = v === 'codex' ? 'codex' : 'claude' /* 0.9.17 */; else if (k === 'run') startUi.run = ['headless', 'board', 'app'].includes(v) ? v : null // 1.0
8746
8789
  return render()
8747
8790
  }
8748
8791
  // 0.9.16 + Add another repo, and Save these as a project…
@@ -8821,6 +8864,21 @@ document.addEventListener('click', async (ev) => {
8821
8864
  return render()
8822
8865
  }
8823
8866
  if (action === 'ask-auto') return autoRestClick(btn)
8867
+ // 1.0 Continue in terminal / Continue headless (src/run-switch.js)
8868
+ if (action === 'run-continue') {
8869
+ const run = btn.dataset.run === 'headless' ? 'headless' : 'board'
8870
+ if (!id || hlPending.has(id) || switchPending.has(id)) return
8871
+ switchPending.add(id); btn.disabled = true
8872
+ toast(run === 'headless' ? 'Ending it in the terminal, then continuing headless…' : 'Continuing in a terminal…', 3000)
8873
+ try {
8874
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/continue', { run }, { timeoutMs: 45000 })
8875
+ toast(run === 'headless' ? 'Continuing headless: talk to it here' : 'In the terminal now', 3500)
8876
+ if (r && r.terminal && r.terminal.id && run !== 'headless') terminal.show(r.terminal.id)
8877
+ } catch (e) { toast(e.message, 6000) }
8878
+ switchPending.delete(id)
8879
+ repaintSession(id)
8880
+ return render()
8881
+ }
8824
8882
  if (action === 'headless-stop' || action === 'headless-run') {
8825
8883
  const op = action === 'headless-stop' ? 'stop' : 'resume'
8826
8884
  if (!id || hlPending.has(id)) return
@@ -9721,6 +9779,9 @@ async function needDismissClick(btn) {
9721
9779
 
9722
9780
  // Go: "Switching…" at once, a note if it's slow, the result (with timings when slow) after.
9723
9781
  function goClick(btn, id, s) {
9782
+ // 0.9.17 a session in the board's own terminal (a Codex start): show that terminal, here, on any device
9783
+ // 1.0 unless a terminal app is attached to it (leftoff attach): then Go brings that window forward on its Mac
9784
+ if (s && s.isLocal !== false && s.terminal && s.terminal.kind === 'leftoff' && s.terminal.termId && !(s.terminal.apps > 0 && state.viewerLocal !== false)) return terminal.show(s.terminal.termId).then((ok) => { if (!ok) toast('Its terminal in Leftoff isn’t running any more', 4000) })
9724
9785
  return goWithFeedback({
9725
9786
  id,
9726
9787
  title: s && s.title,
package/ui/ask.js CHANGED
@@ -94,6 +94,7 @@ export function byLabel(t, { machine = null } = {}) {
94
94
  if (!by) return ''
95
95
  const on = machine ? ' on ' + machine : ''
96
96
  if (by.engine === 'claude') return 'Claude Code (' + (by.model || 'sonnet') + ', read-only)' + on + (by.readRepo === false ? ', from your notes only' : '')
97
+ if (by.engine === 'codex') return 'Codex (read-only)' + (by.unavailable ? ' (Claude unavailable: ' + by.unavailable + ')' : '') + on + (by.readRepo === false ? ', from your notes only' : '') // 0.9.17
97
98
  if (by.engine === 'summary') return 'From your notes (no repo reading), by ' + (by.model || by.provider || 'your summary model') + on
98
99
  if (by.engine === 'findings') return 'What I found (no model answered)' + on
99
100
  if (by.engine === 'empty') return ''
package/ui/live-view.js CHANGED
@@ -20,7 +20,7 @@ export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.en
20
20
  // a thread while it is live-linked and the how-to otherwise.
21
21
  // -> 'none' | 'thread' | 'readonly' (a stopped headless one: no composer) | 'how'
22
22
  export function liveBoxKind(s) {
23
- if (!s || s.agent || s.codingAgent === 'codex') return 'none'
23
+ if (!s || s.agent || (s.codingAgent === 'codex' && !s.headless)) return 'none' // 1.0: headless Codex has Live
24
24
  if (s.capture === 'hooks' && !s.headless) return 'none'
25
25
  if (s.liveLink && s.liveLink.on) return 'thread'
26
26
  if (s.headless) return 'thread' // 0.9.13: not running (Paused, Stopped): a message resumes it
@@ -123,7 +123,7 @@ const LIVE_TIP = 'Live link: send this session messages from the board or your p
123
123
  export function liveBadgeHtml(s, now = Date.now()) {
124
124
  if (!cardPills(s).live) return ''
125
125
  const w = liveWorking(s, now)
126
- return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? 'Claude is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
126
+ return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? (s.codingAgent === 'codex' ? 'Codex' : 'Claude') + ' is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
127
127
  }
128
128
 
129
129
  // The board redraws a card (innerHTML) on every update, which would restart a
@@ -503,7 +503,7 @@ export function pendingClaudeHtml(you, { esc, ago, now = Date.now(), lvActs = nu
503
503
  if (st.phase === 'stopped') inner = (st.why && st.why.label ? whyHtml(st.why, { esc }) : '<div class="lv-text lv-none">' + esc(st.lost ? 'No reply recorded' : 'Stopped without a reply') + '</div>') + acts + toolsHtml(st, { esc, now })
504
504
  else if (st.phase === 'replied') inner = '<div class="lv-text lv-none">Replied in the terminal</div>' + toolsHtml(st, { esc, now }) // settled, its words not kept: never a Working bubble
505
505
  else inner = workingHtml(st, { esc, now })
506
- return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st) + (acts ? '|a' + actsSig(lvActs) : ''))) + '"><div class="lv-who">Claude ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
506
+ return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st) + (acts ? '|a' + actsSig(lvActs) : ''))) + '"><div class="lv-who">' + esc(you.answerBy || 'Claude') + ' ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
507
507
  }
508
508
 
509
509
  // The whole thread: each message you sent, then Claude's side of that turn.