@lovek4d/kickflip 0.4.0 → 0.5.3

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.
Files changed (90) hide show
  1. package/CONTEXT.md +69 -48
  2. package/README.md +1 -1
  3. package/SKILL.md +22 -20
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +27 -18
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/agents.mjs +2 -0
  9. package/board/bin/board.mjs +108 -39
  10. package/board/domain.mjs +7 -7
  11. package/board/finish.mjs +4 -3
  12. package/board/headless.mjs +5 -3
  13. package/board/merge3.mjs +1 -0
  14. package/board/on-host.mjs +1 -1
  15. package/board/permissions.mjs +2 -2
  16. package/board/public/app-asks.js +9 -92
  17. package/board/public/app-banner.js +7 -13
  18. package/board/public/app-cards.js +70 -37
  19. package/board/public/app-core.js +24 -25
  20. package/board/public/app-header.js +2 -1
  21. package/board/public/app-lanes.js +55 -23
  22. package/board/public/app-needs.js +129 -4
  23. package/board/public/app-reader.js +174 -0
  24. package/board/public/app-snapshot.js +6 -5
  25. package/board/public/app-threads.js +83 -58
  26. package/board/public/app.html +2 -0
  27. package/board/public/app.js +66 -24
  28. package/board/public/attach.js +2 -2
  29. package/board/public/dialogs.js +9 -39
  30. package/board/public/hub-kit.js +4 -2
  31. package/board/public/hub.css +1 -0
  32. package/board/public/linear-key.js +46 -35
  33. package/board/public/new-task.js +100 -0
  34. package/board/public/prototype-kit.js +28 -3
  35. package/board/public/prototype-switcher.js +42 -107
  36. package/board/public/shell.js +7 -1
  37. package/board/public/sidebar.css +1 -0
  38. package/board/public/sidebar.js +44 -5
  39. package/board/public/style.css +194 -35
  40. package/board/public/ui-cards.js +80 -44
  41. package/board/public/ui-enums.js +1 -1
  42. package/board/public/ui-header.js +2 -2
  43. package/board/public/ui-order.js +12 -10
  44. package/board/public/ui-phase-bars.js +4 -4
  45. package/board/public/ui-questions.js +63 -47
  46. package/board/public/ui-sidebar.js +27 -15
  47. package/board/public/ui-threads.js +27 -14
  48. package/board/public/ui.js +76 -14
  49. package/board/public/view-frame.js +1 -0
  50. package/board/public/view-hub-settings.js +11 -10
  51. package/board/public/view-hub.js +17 -3
  52. package/board/public/view-questions.js +174 -202
  53. package/board/public/view-settings.js +208 -70
  54. package/board/server.mjs +19 -18
  55. package/board/store.mjs +20 -13
  56. package/board/transcript.mjs +2 -0
  57. package/board/worktree.mjs +24 -20
  58. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  59. package/hooks-plugin/hooks/hooks.json +9 -0
  60. package/hub/boards.mjs +4 -4
  61. package/hub/front-door.mjs +20 -0
  62. package/hub/github.mjs +3 -3
  63. package/hub/hub.mjs +11 -14
  64. package/hub/linear.mjs +1 -2
  65. package/hub/live.mjs +2 -2
  66. package/hub/locks.mjs +1 -1
  67. package/hub/new-board.mjs +2 -2
  68. package/hub/owner-checks.mjs +1 -0
  69. package/hub/pr-report.mjs +2 -2
  70. package/hub/presence.mjs +1 -1
  71. package/hub/push.mjs +2 -2
  72. package/hub/readiness.mjs +1 -2
  73. package/hub/review-send.mjs +1 -0
  74. package/hub/secrets.mjs +2 -0
  75. package/hub/self-restart.mjs +2 -0
  76. package/hub/sessions.mjs +2 -2
  77. package/hub/share.mjs +1 -0
  78. package/hub/slack.mjs +2 -2
  79. package/hub/stream.mjs +10 -13
  80. package/hub/try.mjs +7 -7
  81. package/hub/upgrade.mjs +1 -1
  82. package/hub/usage.mjs +3 -3
  83. package/hub/views.mjs +1 -1
  84. package/package.json +2 -1
  85. package/shared/audit.mjs +4 -0
  86. package/shared/fake-gh.mjs +17 -4
  87. package/shared/owner-check.mjs +3 -0
  88. package/shared/service.mjs +4 -4
  89. package/shared/tailscale.mjs +1 -0
  90. package/shared/usage-gate.mjs +1 -1
package/bin/doctor.mjs CHANGED
@@ -1,4 +1,4 @@
1
- var ot=Object.defineProperty;var f=(i,c)=>ot(i,"name",{value:c,configurable:!0});import g from"node:fs";import K from"node:os";import m from"node:path";import{isTryPort as J}from"../hub/try.mjs";import{githubGitArgv as nt,gitReachFault as it}from"../hub/repos.mjs";import{buildsLocally as rt,isBoardSite as at}from"../hub/boards.mjs";import{cloneRoot as z,reposDir as lt}from"../shared/repos.mjs";import{codexStatus as ct}from"../shared/codex.mjs";import{REVIEW_SKILLS as ut,SKILLS_SOURCE as ft,addCommand as dt,skillsState as ht,updateCommand as mt}from"../shared/skills.mjs";import{hubService as pt,runCommand as gt}from"../shared/service.mjs";import{inspectLinks as kt,ownsStatusLine as bt,hasWatcherStop as $t,hubStatus as yt,tailscaleState as wt,runInstall as xt}from"./install.mjs";const d="kickflip doctor --fix",Q={ok:"ok",fixable:"fixable",yours:"yours",optional:"optional",note:"note"},$=f(i=>`'${i.replaceAll("'","'\\''")}'`,"quote"),St={darwin:{git:"xcode-select --install",gh:"brew install gh",tmux:"brew install tmux"},other:{git:"sudo apt install git",gh:"sudo apt install gh",tmux:"sudo apt install tmux"}},jt="cli/cli",Ot=15e3,Y=f(i=>{try{return g.realpathSync(i)}catch{return null}},"realPath"),Z=f(i=>i.length?`${i.map(c=>`/${c}`).join(", ")} ${i.length>1?"use":"uses"} it`:"","usedBy");function tt({home:i=K.homedir(),cloneRoot:c=z()}={}){let a={};try{a=JSON.parse(g.readFileSync(m.join(i,".claude","kickflip","manifest.json"),"utf8"))}catch{}const y=[...Object.entries(a.sites||{}).map(([n,r])=>[n,r?.owner?.repo]),...Object.entries(a.closed||{}).map(([n,r])=>[n,r?.site?.owner?.repo]),...Object.entries(a.starting||{}).map(([n,r])=>[n,r?.repo])].map(([n,r])=>[n,r&&Y(r)]).filter(([,n])=>n),h=`${m.sep}.repos${m.sep}`,C=new Set([Y(m.join(c,".repos")),...y.map(([,n])=>n.includes(h)?n.slice(0,n.indexOf(h)+h.length-1):null)].filter(Boolean)),k=[],w=f(n=>{for(const r of g.readdirSync(n,{withFileTypes:!0})){if(!r.isDirectory())continue;const u=m.join(n,r.name);g.existsSync(m.join(u,".git"))?k.push(u):w(u)}},"walk");for(const n of C)w(n);return k.sort().map(n=>({clone:n,routes:[...new Set(y.filter(([,r])=>r===n||r.startsWith(n+m.sep)).map(([r])=>r))]}))}f(tt,"oldBoardClones");async function Ct({home:i=K.homedir(),cloneRoot:c=z(),port:a=Number(process.env.KICKFLIP_HUB_PORT||4500),uid:y=process.getuid(),env:h=process.env,run:C=gt,platform:k=process.platform,username:w=K.userInfo().username,fetch:n=globalThis.fetch}={}){const r=St[k==="darwin"?"darwin":"other"],u=[];let j;const s=f((t,e,o,l=null)=>u.push({group:j,item:t,state:e,detail:o,fix:e==="ok"||e==="note"?null:l}),"add"),p=f((t,e,o=null,l=void 0)=>{try{return C(t,e,{env:o?{...h,...o}:h,...l&&{timeout:l}})}catch(S){return{status:1,error:S.message}}},"execute"),P=f((t,e,o,l,S="not on this session\u2019s PATH")=>{const F=p(e,o),G=F.status===0;return s(t,G?"ok":"yours",G?(F.stdout||"installed").trim().split(`
2
- `)[0]:S,l),G},"installed");j="prereqs";const q=P("git","git",["--version"],r.git);P("node on Claude\u2019s PATH","node",["--version"],`add ${$(m.dirname(process.execPath))} to PATH in your shell profile, then restart Claude Code`,`not on this session\u2019s PATH (${h.PATH||"empty"})`);const U=P("gh","gh",["--version"],r.gh),L=U&&p("gh",["auth","status"]).status===0;if(s("gh signed in",L?"ok":"yours",L?"signed in":U?"not signed in":"needs gh","gh auth login"),q){const t=L?p("gh",["repo","list","--limit","1","--json","nameWithOwner","--jq",".[0].nameWithOwner"]):null,e=t?.status===0&&t.stdout.trim()||jt,o=p("git",nt(["ls-remote",`https://github.com/${e}.git`,"HEAD"]),{GIT_TERMINAL_PROMPT:"0"});s("GitHub clone",o.status===0?"ok":"yours",o.status===0?`git reaches ${e} over https`:`git can\u2019t reach ${e} over https: ${o.error}`,L?"gh auth refresh -h github.com, then kickflip doctor":"check this machine reaches https://github.com, then kickflip doctor")}else s("GitHub clone","yours","needs git",r.git);const T=await wt({run:f((t,e)=>p(t,e),"run"),platform:k,uid:y,username:w,port:a});for(const t of T.prereqs)s(t.item,t.state,t.detail,t.fix);const M=p("tmux",["-V"]);s("tmux",M.status===0?"ok":"optional",M.status===0?(M.stdout||"installed").trim().split(`
3
- `)[0]:"not installed; a hub board\u2019s session (kickflip join --hub, kickflip hub) runs in it",r.tmux);const A=ct({home:i,env:h,platform:k});s("Codex",A.usable?"ok":"optional",A.usable?"installed and logged in":`${A.reason}; Codex builds and reviews need it`,A.fix);const x=ht({home:i,run:p,gh:L});x.missing.length?s("mattpocock skills","optional",`${x.missing.join(", ")} not installed; architecture reviews need ${x.missing.length===1?"it":"them"}`,dt(x.missing)):x.stale.length?s("mattpocock skills","optional",`${x.stale.join(", ")} out of date with ${ft}`,mt(x.stale)):s("mattpocock skills","ok",`${ut.join(", ")} installed${x.checked?" and current":""}`),j="setup";const V=["kickflip skill link","kickflip-hooks link","~/.claude/statusline.sh"];for(const[t,e]of kt({home:i,cloneRoot:c}).entries())if(e.refusal){const o=g.lstatSync(e.link);s(V[t],"yours",o.isSymbolicLink()?`links to ${g.readlinkSync(e.link)}, not kickflip`:`a real ${o.isDirectory()?"folder":"file"}, not kickflip\u2019s link`,`${o.isSymbolicLink()?`rm ${$(e.link)}`:`mv ${$(e.link)} ${$(e.link+".backup")}`} && ${d}`)}else s(V[t],e.created||e.replaced?"fixable":"ok",`${e.created?"missing":e.replaced?"needs relinking":"linked"} -> ${e.target}`,d);const E=m.join(i,".claude","settings.json");let b={},O;try{if(b=JSON.parse(g.readFileSync(E,"utf8")),!b||typeof b!="object"||Array.isArray(b))throw new Error("not a settings object")}catch(t){t.code!=="ENOENT"&&(O=t.message)}const I="settings.json statusLine";O?s(I,"yours",`unreadable: ${O}`,`repair the JSON in ${$(E)}, then ${d}`):Object.hasOwn(b,"statusLine")?bt(b.statusLine,i)?s(I,"ok","runs ~/.claude/statusline.sh"):s(I,"yours","another tool\u2019s status line is selected",`remove statusLine from ${$(E)}, then ${d}`):s(I,"fixable","missing",d);const W="settings.json watcher Stop hook";O?s(W,"yours",`unreadable: ${O}`,`repair the JSON in ${$(E)}, then ${d}`):$t(b)?s(W,"ok","runs hooks-plugin/hooks/watcher-stop.mjs"):s(W,"fixable","missing; a board session can end a turn with no live watcher or a message left Working on it",d);const B="settings.json autoCompactEnabled";O?s(B,"yours",`unreadable: ${O}`,`repair the JSON in ${$(E)}, then ${d}`):b.autoCompactEnabled===!0?s(B,"ok","on"):s(B,"fixable",`${Object.hasOwn(b,"autoCompactEnabled")?"off":"not set"}; a board session that fills its context stops answering`,d);const N=pt({platform:k,home:i,uid:y,env:h,run:C}),X=N.status();s(N.item,X.running?"ok":N.manager?"fixable":"yours",X.detail,N.manager?d:`run ${N.restartCommand} under a service manager of your own`);for(const t of N.notes())s(t.item,t.state,t.detail,t.fix);j="hub";const H=await yt({port:a,fetch:n});s("hub",H?"ok":"fixable",`${H?"running":"not running"} on 127.0.0.1:${a}`,d);const{serveConfig:D}=T;s(T.root.item,T.root.state,T.root.detail,T.root.fix);let v=H?.sites;if(!v){const t=m.join(i,".claude","kickflip","manifest.json");try{const e=JSON.parse(g.readFileSync(t,"utf8"));v=Object.entries(e.sites||{}).map(([o,l])=>({...l,route:o,state:"hub not running"}))}catch(e){e.code!=="ENOENT"&&s("site manifest","yours",e.message,`repair the JSON in ${$(t)}`),v=[]}}let _={};try{_=JSON.parse(g.readFileSync(m.join(i,".claude","kickflip","manifest.json"),"utf8"))}catch{}const R=new Map,et=[...Object.entries(_.sites||{}),...Object.entries(_.closed||{}).map(([t,e])=>[t,e.site]),...v.map(t=>[t.route,t])];for(const[t,e]of et){if(!e?.dir)continue;let o;try{o=JSON.parse(g.readFileSync(m.join(e.dir,"data","try.json"),"utf8"))}catch{continue}J(o.main_port)&&R.set(Number(o.main_port),`/${t} main`),J(o.main?.https)&&R.set(Number(o.main.https),`/${t} main`);for(const[l,S]of Object.entries(o.cards||{}))J(S.https)&&R.set(Number(S.https),`/${t} ${l}`)}const st=Object.keys(D?.TCP||{}).filter(J).sort((t,e)=>t-e);s("try ports","ok",st.map(t=>{const o=Object.entries(D.Web||{}).find(([l])=>l.endsWith(`:${t}`))?.[1]?.Handlers?.["/"]?.Proxy||D.TCP[t].TCPForward||"HTTPS";return`${t} \u2192 ${o} (${R.get(Number(t))||"unknown"})`}).join("; ")||"none"),v.length||s("sites","ok","none");for(const t of v){const e=t.port?`port ${t.port}`:`files ${t.dir}`,o=`${t.state} ${e} owner ${t.owner?.label||t.owner?.session||"-"} ${t.title||""}`.trimEnd();t.state==="up"?s("/"+t.route,"ok",o):H?t.serves==="port"?s("/"+t.route,"note",`${o}; its session runs this server, not the hub`):t.serves==="files"?s("/"+t.route,"note",`${o}; its folder is missing`):s("/"+t.route,"note",`${o}; the hub restarts it, see kickflip logs ${t.route}`):s("/"+t.route,"fixable",o,d)}if(q)for(const[t,e]of Object.entries(_.sites||{})){const o=e?.owner?.repo;if(!at(e)||rt(e)||!o)continue;const l=p("git",["-C",o,"ls-remote","--exit-code","origin","HEAD"],{GIT_TERMINAL_PROMPT:"0"},Ot),{error:S,hint:F}=it(l.error,{github:e.github||"",repo:o});s(`/${t} repo`,l.status===0?"ok":"yours",l.status===0?`git reaches ${e.github||"origin"} from ${o}`:`git can\u2019t reach ${e.github||"origin"} from ${o}: ${S}`,F)}for(const{clone:t,routes:e}of tt({home:i,cloneRoot:c}))s("old board clone","optional",[`${t} is in an old place`,`board clones now live in ${lt(i)}`,Z(e)].filter(Boolean).join("; "),d);return u}f(Ct,"doctorReport");function Tt(i){const c=Math.max(...Object.values(Q).map(a=>a.length));return i.flatMap(a=>[`${Q[a.state].padEnd(c)} ${a.item}: ${a.detail}`,...a.fix?[`${" ".repeat(c+1)}\u2192 ${a.fix}`]:[]])}f(Tt,"doctorLines");async function Mt({fix:i=!1,dryRun:c=!1,json:a=!1,write:y=console.log,error:h=console.error,install:C=xt,ask:k,...w}={}){let n=!1;if(i){const u=a?h:y;try{await C({...w,dryRun:c,log:u})}catch(s){n=!0,h("kickflip: "+s.message)}const j=tt(w);if(c){for(const{clone:s}of j)u(`would ask to remove the old board clone ${s}`);return{items:[],exitCode:n?1:0}}for(const{clone:s,routes:p}of k?j:[]){const P=await k(`Remove the old board clone ${s}? ${p.length?`${Z(p)}. `:""}(y/N) `);/^y(es)?$/i.test(P.trim())&&(g.rmSync(s,{recursive:!0,force:!0}),u(`removed ${s}`))}}const r=await Ct(w);return y(a?JSON.stringify({items:r},null,2):Tt(r).join(`
4
- `)),{items:r,exitCode:n||r.some(u=>u.state==="fixable"||u.state==="yours")?1:0}}f(Mt,"runDoctor");export{Tt as doctorLines,Ct as doctorReport,tt as oldBoardClones,Mt as runDoctor};
1
+ var it=Object.defineProperty;var d=(n,u)=>it(n,"name",{value:u,configurable:!0});import k from"node:fs";import q from"node:os";import p from"node:path";import{isTryPort as J}from"../hub/try.mjs";import{githubGitArgv as nt,gitReachFault as rt}from"../hub/repos.mjs";import{buildsLocally as at,isBoardSite as lt}from"../hub/boards.mjs";import{cloneRoot as Q,reposDir as ct}from"../shared/repos.mjs";import{codexStatus as ut}from"../shared/codex.mjs";import{REVIEW_SKILLS as ft,SKILLS_SOURCE as dt,addCommand as ht,skillsState as mt,updateCommand as pt}from"../shared/skills.mjs";import{hubService as gt,runCommand as kt}from"../shared/service.mjs";import{tailscaleBin as bt}from"../shared/tailscale.mjs";import{inspectLinks as $t,ownsStatusLine as yt,hasWatcherStop as wt,hubStatus as xt,tailscaleState as St,runInstall as jt}from"./install.mjs";const h="kickflip doctor --fix",Y={ok:"ok",fixable:"fixable",yours:"yours",optional:"optional",note:"note"},y=d(n=>`'${n.replaceAll("'","'\\''")}'`,"quote"),Ot={darwin:{git:"xcode-select --install",gh:"brew install gh",tmux:"brew install tmux"},other:{git:"sudo apt install git",gh:"sudo apt install gh",tmux:"sudo apt install tmux"}},Ct="cli/cli",Tt=15e3,Z=d(n=>{try{return k.realpathSync(n)}catch{return null}},"realPath"),tt=d(n=>n.length?`${n.map(u=>`/${u}`).join(", ")} ${n.length>1?"use":"uses"} it`:"","usedBy");function et({home:n=q.homedir(),cloneRoot:u=Q()}={}){let a={};try{a=JSON.parse(k.readFileSync(p.join(n,".claude","kickflip","manifest.json"),"utf8"))}catch{}const w=[...Object.entries(a.sites||{}).map(([i,r])=>[i,r?.owner?.repo]),...Object.entries(a.closed||{}).map(([i,r])=>[i,r?.site?.owner?.repo]),...Object.entries(a.starting||{}).map(([i,r])=>[i,r?.repo])].map(([i,r])=>[i,r&&Z(r)]).filter(([,i])=>i),f=`${p.sep}.repos${p.sep}`,C=new Set([Z(p.join(u,".repos")),...w.map(([,i])=>i.includes(f)?i.slice(0,i.indexOf(f)+f.length-1):null)].filter(Boolean)),g=[],x=d(i=>{for(const r of k.readdirSync(i,{withFileTypes:!0})){if(!r.isDirectory())continue;const c=p.join(i,r.name);k.existsSync(p.join(c,".git"))?g.push(c):x(c)}},"walk");for(const i of C)x(i);return g.sort().map(i=>({clone:i,routes:[...new Set(w.filter(([,r])=>r===i||r.startsWith(i+p.sep)).map(([r])=>r))]}))}d(et,"oldBoardClones");async function Nt({home:n=q.homedir(),cloneRoot:u=Q(),port:a=Number(process.env.KICKFLIP_HUB_PORT||4500),uid:w=process.getuid(),env:f=process.env,run:C=kt,platform:g=process.platform,username:x=q.userInfo().username,fetch:i=globalThis.fetch,tailscale:r=bt({env:f,platform:g})}={}){const c=Ot[g==="darwin"?"darwin":"other"],v=[];let m;const s=d((t,e,o,l=null)=>v.push({group:m,item:t,state:e,detail:o,fix:e==="ok"||e==="note"?null:l}),"add"),b=d((t,e,o=null,l=void 0)=>{try{return C(t,e,{env:o?{...f,...o}:f,...l&&{timeout:l}})}catch(j){return{status:1,error:j.message}}},"execute"),M=d((t,e,o,l,j="not on this session\u2019s PATH")=>{const F=b(e,o),K=F.status===0;return s(t,K?"ok":"yours",K?(F.stdout||"installed").trim().split(`
2
+ `)[0]:j,l),K},"installed");m="prereqs";const U=M("git","git",["--version"],c.git);M("node on Claude\u2019s PATH","node",["--version"],`add ${y(p.dirname(process.execPath))} to PATH in your shell profile, then restart Claude Code`,`not on this session\u2019s PATH (${f.PATH||"empty"})`);const V=M("gh","gh",["--version"],c.gh),L=V&&b("gh",["auth","status"]).status===0;if(s("gh signed in",L?"ok":"yours",L?"signed in":V?"not signed in":"needs gh","gh auth login"),U){const t=L?b("gh",["repo","list","--limit","1","--json","nameWithOwner","--jq",".[0].nameWithOwner"]):null,e=t?.status===0&&t.stdout.trim()||Ct,o=b("git",nt(["ls-remote",`https://github.com/${e}.git`,"HEAD"]),{GIT_TERMINAL_PROMPT:"0"});s("GitHub clone",o.status===0?"ok":"yours",o.status===0?`git reaches ${e} over https`:`git can\u2019t reach ${e} over https: ${o.error}`,L?"gh auth refresh -h github.com, then kickflip doctor":"check this machine reaches https://github.com, then kickflip doctor")}else s("GitHub clone","yours","needs git",c.git);const T=await St({run:d((t,e)=>b(t,e),"run"),platform:g,uid:w,username:x,port:a,tailscale:r});for(const t of T.prereqs)s(t.item,t.state,t.detail,t.fix);const B=b("tmux",["-V"]);s("tmux",B.status===0?"ok":"optional",B.status===0?(B.stdout||"installed").trim().split(`
3
+ `)[0]:"not installed; a hub board\u2019s session (kickflip join --hub, kickflip hub) runs in it",c.tmux);const A=ut({home:n,env:f,platform:g});s("Codex",A.usable?"ok":"optional",A.usable?"installed and logged in":`${A.reason}; Codex builds and reviews need it`,A.fix);const S=mt({home:n,run:b,gh:L});S.missing.length?s("mattpocock skills","optional",`${S.missing.join(", ")} not installed; architecture reviews need ${S.missing.length===1?"it":"them"}`,ht(S.missing)):S.stale.length?s("mattpocock skills","optional",`${S.stale.join(", ")} out of date with ${dt}`,pt(S.stale)):s("mattpocock skills","ok",`${ft.join(", ")} installed${S.checked?" and current":""}`),m="setup";const X=["kickflip skill link","kickflip-hooks link","~/.claude/statusline.sh"];for(const[t,e]of $t({home:n,cloneRoot:u}).entries())if(e.refusal){const o=k.lstatSync(e.link);s(X[t],"yours",o.isSymbolicLink()?`links to ${k.readlinkSync(e.link)}, not kickflip`:`a real ${o.isDirectory()?"folder":"file"}, not kickflip\u2019s link`,`${o.isSymbolicLink()?`rm ${y(e.link)}`:`mv ${y(e.link)} ${y(e.link+".backup")}`} && ${h}`)}else s(X[t],e.created||e.replaced?"fixable":"ok",`${e.created?"missing":e.replaced?"needs relinking":"linked"} -> ${e.target}`,h);const E=p.join(n,".claude","settings.json");let $={},O;try{if($=JSON.parse(k.readFileSync(E,"utf8")),!$||typeof $!="object"||Array.isArray($))throw new Error("not a settings object")}catch(t){t.code!=="ENOENT"&&(O=t.message)}const I="settings.json statusLine";O?s(I,"yours",`unreadable: ${O}`,`repair the JSON in ${y(E)}, then ${h}`):Object.hasOwn($,"statusLine")?yt($.statusLine,n)?s(I,"ok","runs ~/.claude/statusline.sh"):s(I,"yours","another tool\u2019s status line is selected",`remove statusLine from ${y(E)}, then ${h}`):s(I,"fixable","missing",h);const W="settings.json watcher Stop hook";O?s(W,"yours",`unreadable: ${O}`,`repair the JSON in ${y(E)}, then ${h}`):wt($)?s(W,"ok","runs hooks-plugin/hooks/watcher-stop.mjs"):s(W,"fixable","missing; a board session can end a turn with no live watcher or a message left Working on it",h);const D="settings.json autoCompactEnabled";O?s(D,"yours",`unreadable: ${O}`,`repair the JSON in ${y(E)}, then ${h}`):$.autoCompactEnabled===!0?s(D,"ok","on"):s(D,"fixable",`${Object.hasOwn($,"autoCompactEnabled")?"off":"not set"}; a board session that fills its context stops answering`,h);const N=gt({platform:g,home:n,uid:w,env:f,run:C}),z=N.status();s(N.item,z.running?"ok":N.manager?"fixable":"yours",z.detail,N.manager?h:`run ${N.restartCommand} under a service manager of your own`);for(const t of N.notes())s(t.item,t.state,t.detail,t.fix);m="hub";const H=await xt({port:a,fetch:i});s("hub",H?"ok":"fixable",`${H?"running":"not running"} on 127.0.0.1:${a}`,h);const{serveConfig:G}=T;s(T.root.item,T.root.state,T.root.detail,T.root.fix);let P=H?.sites;if(!P){const t=p.join(n,".claude","kickflip","manifest.json");try{const e=JSON.parse(k.readFileSync(t,"utf8"));P=Object.entries(e.sites||{}).map(([o,l])=>({...l,route:o,state:"hub not running"}))}catch(e){e.code!=="ENOENT"&&s("site manifest","yours",e.message,`repair the JSON in ${y(t)}`),P=[]}}let _={};try{_=JSON.parse(k.readFileSync(p.join(n,".claude","kickflip","manifest.json"),"utf8"))}catch{}const R=new Map,st=[...Object.entries(_.sites||{}),...Object.entries(_.closed||{}).map(([t,e])=>[t,e.site]),...P.map(t=>[t.route,t])];for(const[t,e]of st){if(!e?.dir)continue;let o;try{o=JSON.parse(k.readFileSync(p.join(e.dir,"data","try.json"),"utf8"))}catch{continue}J(o.main_port)&&R.set(Number(o.main_port),`/${t} main`),J(o.main?.https)&&R.set(Number(o.main.https),`/${t} main`);for(const[l,j]of Object.entries(o.cards||{}))J(j.https)&&R.set(Number(j.https),`/${t} ${l}`)}const ot=Object.keys(G?.TCP||{}).filter(J).sort((t,e)=>t-e);s("try ports","ok",ot.map(t=>{const o=Object.entries(G.Web||{}).find(([l])=>l.endsWith(`:${t}`))?.[1]?.Handlers?.["/"]?.Proxy||G.TCP[t].TCPForward||"HTTPS";return`${t} \u2192 ${o} (${R.get(Number(t))||"unknown"})`}).join("; ")||"none"),P.length||s("sites","ok","none");for(const t of P){const e=t.port?`port ${t.port}`:`files ${t.dir}`,o=`${t.state} ${e} owner ${t.owner?.label||t.owner?.session||"-"} ${t.title||""}`.trimEnd();t.state==="up"?s("/"+t.route,"ok",o):H?t.serves==="port"?s("/"+t.route,"note",`${o}; its session runs this server, not the hub`):t.serves==="files"?s("/"+t.route,"note",`${o}; its folder is missing`):s("/"+t.route,"note",`${o}; the hub restarts it, see kickflip logs ${t.route}`):s("/"+t.route,"fixable",o,h)}if(U)for(const[t,e]of Object.entries(_.sites||{})){const o=e?.owner?.repo;if(!lt(e)||at(e)||!o)continue;const l=b("git",["-C",o,"ls-remote","--exit-code","origin","HEAD"],{GIT_TERMINAL_PROMPT:"0"},Tt),{error:j,hint:F}=rt(l.error,{github:e.github||"",repo:o});s(`/${t} repo`,l.status===0?"ok":"yours",l.status===0?`git reaches ${e.github||"origin"} from ${o}`:`git can\u2019t reach ${e.github||"origin"} from ${o}: ${j}`,F)}for(const{clone:t,routes:e}of et({home:n,cloneRoot:u}))s("old board clone","optional",[`${t} is in an old place`,`board clones now live in ${ct(n)}`,tt(e)].filter(Boolean).join("; "),h);return v}d(Nt,"doctorReport");function Pt(n){const u=Math.max(...Object.values(Y).map(a=>a.length));return n.flatMap(a=>[`${Y[a.state].padEnd(u)} ${a.item}: ${a.detail}`,...a.fix?[`${" ".repeat(u+1)}\u2192 ${a.fix}`]:[]])}d(Pt,"doctorLines");async function Dt({fix:n=!1,dryRun:u=!1,json:a=!1,write:w=console.log,error:f=console.error,install:C=jt,ask:g,...x}={}){let i=!1;if(n){const c=a?f:w;try{await C({...x,dryRun:u,log:c})}catch(m){i=!0,f("kickflip: "+m.message)}const v=et(x);if(u){for(const{clone:m}of v)c(`would ask to remove the old board clone ${m}`);return{items:[],exitCode:i?1:0}}for(const{clone:m,routes:s}of g?v:[]){const b=await g(`Remove the old board clone ${m}? ${s.length?`${tt(s)}. `:""}(y/N) `);/^y(es)?$/i.test(b.trim())&&(k.rmSync(m,{recursive:!0,force:!0}),c(`removed ${m}`))}}const r=await Nt(x);return w(a?JSON.stringify({items:r},null,2):Pt(r).join(`
4
+ `)),{items:r,exitCode:i||r.some(c=>c.state==="fixable"||c.state==="yours")?1:0}}d(Dt,"runDoctor");export{Pt as doctorLines,Nt as doctorReport,et as oldBoardClones,Dt as runDoctor};
package/bin/install.mjs CHANGED
@@ -1,3 +1,3 @@
1
- var R=Object.defineProperty;var m=(e,s)=>R(e,"name",{value:s,configurable:!0});import r from"node:fs";import v from"node:os";import n from"node:path";import{SKILLS_SOURCE as L,addArgv as J,addCommand as I,missingSkills as _}from"../shared/skills.mjs";import{cloneRoot as O}from"../shared/repos.mjs";import{hubService as W,runCommand as H}from"../shared/service.mjs";const M=m(()=>r.existsSync("/opt/homebrew/bin/node")?"/opt/homebrew/bin/node":process.execPath,"nodeBin"),ot=m((e,s,t=O())=>`${M()} ${n.join(t,"bin","wait-events.mjs")} ${e} ${s}`,"watcherCommand");function D(e,{home:s=v.homedir(),skillsDir:t=n.join(s,".claude","skills"),cloneRoot:o=O()}={}){if(typeof e!="string"||!n.isAbsolute(e))return e;const i=n.relative(o,e);if(i===".."||i.startsWith(`..${n.sep}`)||n.isAbsolute(i))return e;const l=n.resolve(t,"kickflip");let c=!1;try{c=r.realpathSync(l)===r.realpathSync(o)}catch{}return c?n.join(l,i):e}m(D,"storedPath");function q({home:e=v.homedir(),skillsDir:s=n.join(e,".claude","skills"),cloneRoot:t=O()}={}){const o=r.realpathSync(t),i=[["kickflip",o],["kickflip-hooks",n.join(o,"hooks-plugin")]].map(([u,f])=>{if(!r.statSync(f,{throwIfNoEntry:!1})?.isDirectory())throw new Error(`not a directory: ${f}`);const h=u==="kickflip"?f:n.resolve(s,"kickflip","hooks-plugin"),k=n.resolve(s,u),y=r.lstatSync(k,{throwIfNoEntry:!1});let j;if(y){let b;try{b=r.realpathSync(k)}catch(g){if(g.code!=="ENOENT")throw g}y.isSymbolicLink()?b!==r.realpathSync(f)&&(j=`refusing to replace link ${k} -> ${r.readlinkSync(k)}; remove that link explicitly, then rerun kickflip doctor --fix`):(!y.isDirectory()||b!==r.realpathSync(f))&&(j=`refusing to replace real path ${k}; move it aside explicitly, then rerun kickflip doctor --fix`)}return{link:k,target:h,created:!y,...j?{refusal:j}:{}}}),l=n.join(o,"bin","statusline.mjs");if(!r.statSync(l,{throwIfNoEntry:!1})?.isFile())throw new Error(`not a file: ${l}`);const c=n.join(e,".claude","statusline.sh"),w=n.resolve(s,"kickflip","bin","statusline.mjs"),a=r.lstatSync(c,{throwIfNoEntry:!1});let $=!1,d;if(a)if(!a.isSymbolicLink())d=`refusing to replace real path ${c}; move it aside explicitly, then rerun kickflip doctor --fix`;else{let u;try{u=r.realpathSync(c)}catch(y){if(y.code!=="ENOENT")throw y}const f=n.join(e,".claude","kickflip","sites");let h=f;try{h=r.realpathSync(f)}catch{}const k=!!u&&[f,h].some(y=>u.startsWith(y+n.sep));u&&u!==r.realpathSync(l)&&!k&&(d=`refusing to replace link ${c} -> ${r.readlinkSync(c)}; remove that link explicitly, then rerun kickflip doctor --fix`),$=k||n.resolve(n.dirname(c),r.readlinkSync(c))!==w||!u}return i.push({link:c,target:w,created:!a,...$?{replaced:!0}:{},...d?{refusal:d}:{}}),i}m(q,"inspectLinks");function P({home:e=v.homedir(),skillsDir:s=n.join(e,".claude","skills"),cloneRoot:t=O(),dryRun:o=!1}={}){const i=q({home:e,skillsDir:s,cloneRoot:t}),l=i.find(c=>c.refusal);if(l)throw new Error(l.refusal);if(o)return i;r.mkdirSync(s,{recursive:!0}),r.mkdirSync(n.join(e,".claude"),{recursive:!0});for(const{link:c,target:w,created:a,replaced:$}of i)$&&r.unlinkSync(c),(a||$)&&r.symlinkSync(w,c,c===n.join(e,".claude","statusline.sh")?"file":"dir");return i}m(P,"installLinks");function z(e,s=v.homedir()){const t=n.join(s,".claude","statusline.sh"),o=[t,`"${t}"`,`'${t.replaceAll("'","'\\''")}'`,"~/.claude/statusline.sh","$HOME/.claude/statusline.sh",'"$HOME/.claude/statusline.sh"',"${HOME}/.claude/statusline.sh",'"${HOME}/.claude/statusline.sh"'];return e?.type==="command"&&o.includes(e.command)}m(z,"ownsStatusLine");const B='node "$HOME/.claude/skills/kickflip/hooks-plugin/hooks/watcher-stop.mjs"',G=m(e=>(Array.isArray(e?.hooks?.Stop)?e.hooks.Stop:[]).some(s=>Array.isArray(s?.hooks)&&s.hooks.some(t=>t?.type==="command"&&/\/hooks-plugin\/hooks\/watcher-stop\.mjs\b/.test(t.command))),"hasWatcherStop");async function Q({port:e=Number(process.env.KICKFLIP_HUB_PORT||4500),fetch:s=globalThis.fetch}={}){try{const t=await s(`http://127.0.0.1:${e}/_hub/status.json`,{signal:AbortSignal.timeout(1500)});if(!t.ok)return null;const o=await t.json();return Array.isArray(o?.sites)?o:null}catch{return null}}m(Q,"hubStatus");function U(e){const s=Object.entries(e?.Web||{}).filter(([t])=>!/:\d+$/.test(t)||t.endsWith(":443")).flatMap(([,t])=>Object.entries(t.Handlers||{}));return{root:s.find(([t])=>t==="/")?.[1]?.Proxy||null,paths:[...new Set(s.map(([t])=>t))]}}m(U,"tailscaleRoot");function rt(e,{run:s=H}={}){const t=s("tailscale",["status","--json"]);let o=null;try{o=t.status===0?JSON.parse(t.stdout):null}catch{}if(o?.BackendState!=="Running")throw new Error("Tailscale isn't running on this host; start it, or run tailscale up");const i=o.Self?.DNSName?.replace(/\.$/,"")||"";if(!i||!o.CertDomains?.includes(i))throw new Error("HTTPS isn't on for this tailnet; turn on HTTPS Certificates on the DNS page of the Tailscale admin console");return`https://${i}/${e?e+"/":""}`}m(rt,"tailnetUrl");const F=m((e=process.platform)=>e==="darwin"?"brew install --cask tailscale":"curl -fsSL https://tailscale.com/install.sh | sh","tailscaleInstallHint");async function it({run:e=H,platform:s=process.platform,uid:t=process.getuid(),username:o=v.userInfo().username,port:i=Number(process.env.KICKFLIP_HUB_PORT||4500)}={}){const l=m(async S=>{try{return await e("tailscale",S)}catch(E){return{status:1,error:E.message}}},"execute"),c=m(S=>{try{return S.status===0?JSON.parse(S.stdout):null}catch{return null}},"json"),w=await l(["version"]),a=w.status===0,$=a?c(await l(["status","--json"])):null,d=$?.BackendState==="Running",u=$?.Self?.DNSName?.replace(/\.$/,"")||"",f=a&&s==="linux"&&t!==0,h=f?c(await l(["debug","prefs"]))?.OperatorUser:null,k=a?c(await l(["serve","status","--json"])):null,y=U(k);let j=!1;try{const S=new URL(y.root);j=S.protocol==="http:"&&["127.0.0.1","localhost","[::1]"].includes(S.hostname)&&Number(S.port)===i&&S.pathname==="/"&&!S.search&&!S.hash}catch{}const b=m((S,E,p,x,A="yours")=>({item:S,state:E?"ok":A,detail:p,fix:E?null:x}),"entry"),g=[b("Tailscale",a,a?(w.stdout||"installed").trim().split(`
2
- `)[0]:"not on this session\u2019s PATH",F(s)),b("Tailscale signed in",d,d?`signed in${u?" as "+u:""}`:a?"not connected":"needs Tailscale","tailscale up")];f&&g.push(b("Tailscale operator",h===o,h===o?o:`${h||"none"} (expected ${o})`,"sudo tailscale set --operator=$USER"));const N=y.paths.filter(S=>S!=="/"),T=b("tailscale /",j,`${y.root||"nothing"}${j?"":` (expected the hub on ${i})`}${N.length?"; other mounts: "+N.join(", "):""}`,d?"kickflip doctor --fix":a?"tailscale up":F(s),d?"fixable":"yours");return{installed:a,signedIn:d,dns:u,operatorChecked:f,operator:h,username:o,pointsAtHub:j,prereqs:g,root:T,serveConfig:k}}m(it,"tailscaleState");async function at({home:e=v.homedir(),cloneRoot:s=O(),port:t=Number(process.env.KICKFLIP_HUB_PORT||4500),uid:o=process.getuid(),env:i=process.env,run:l=H,platform:c=process.platform,wait:w=m(u=>new Promise(f=>setTimeout(f,u)),"wait"),fetch:a=globalThis.fetch,dryRun:$=!1,log:d=console.log}={}){const u=n.join(e,".claude","kickflip"),f=W({platform:c,home:e,uid:o,env:i,run:l});if($){try{for(const{link:x,target:A,created:C,replaced:K}of P({home:e,cloneRoot:s,dryRun:$}))d(`would ${C||K?"link":"check"} ${x} -> ${A}`)}catch(x){d(`would refuse: ${x.message}`)}d(`would merge statusLine and the watcher Stop hook, turn on autoCompactEnabled and remove stale hooks in ${n.join(e,".claude","settings.json")}`);const p=_(e);p.length&&d(`would add ${p.join(", ")} from ${L} with ${I(p)}`);for(const x of await f.install(null,{wait:w,dryRun:$}))d(x);d(`would map tailscale / to the hub on ${t} and remove old site mounts`);return}for(const{link:p,target:x,created:A,replaced:C}of P({home:e,cloneRoot:s}))d(`${A||C?"linked":"checked"} ${p} -> ${x}`);for(const p of V({home:e}))d(p);const h=_(e);if(h.length){const p=l("npx",J(h),{env:i,timeout:3e5});d(p.status===0?`added ${h.join(", ")} from ${L}`:`could not add ${h.join(", ")} from ${L}: ${p.error}; run ${I(h)}`)}const k=M(),y=D(n.join(s,"hub","hub.mjs"),{home:e,cloneRoot:s});r.mkdirSync(u,{recursive:!0});const j=n.join(u,"hub.log"),b=f.definition({node:k,entry:y,state:u,port:t,log:j});for(const p of await f.install(b,{wait:w}))d(p);let g=!1;for(let p=0;p<30&&!g;p++)await w(300),g=!!await Q({port:t,fetch:a});if(!g)throw new Error("hub did not start; see "+j);const N=l("tailscale",["serve","--bg",String(t)],{env:i});if(N.status!==0)throw new Error(N.error||"tailscale serve failed");const T=l("tailscale",["serve","status","--json"],{env:i});let S;try{S=JSON.parse(T.stdout)}catch{return}let E={};try{E=JSON.parse(r.readFileSync(n.join(u,"manifest.json"),"utf8")).sites||{}}catch(p){if(p.code!=="ENOENT")throw p}for(const p of U(S).paths)p!=="/"&&Object.hasOwn(E,p.replace(/^\//,"").replace(/\/$/,""))&&l("tailscale",["serve","--https=443","--set-path="+p,"off"],{env:i})}m(at,"runInstall");function V({home:e=v.homedir()}={}){const s=n.join(e,".claude","settings.json");let t={},o=384;try{t=JSON.parse(r.readFileSync(s,"utf8")),o=r.statSync(s).mode}catch(a){if(a.code!=="ENOENT")throw a}if(!t||typeof t!="object"||Array.isArray(t))throw new Error(`not a settings object: ${s}`);const i=[];let l=!1;const c=n.join(e,".claude","statusline.sh");if(Object.hasOwn(t,"statusLine"))z(t.statusLine,e)||i.push(`kept existing statusLine in ${s}; it points elsewhere`);else{const a=/^[\w/.-]+$/.test(c)?c:`'${c.replaceAll("'","'\\''")}'`;t.statusLine={type:"command",command:a},l=!0,i.push(`set statusLine in ${s} -> ${a}`)}if(!G(t)){const a=t.hooks&&typeof t.hooks=="object"?t.hooks:{};t.hooks={...a,Stop:[...Array.isArray(a.Stop)?a.Stop:[],{hooks:[{type:"command",command:B}]}]},l=!0,i.push(`added the watcher Stop hook to ${s} -> ${B}`)}t.autoCompactEnabled!==!0&&(t.autoCompactEnabled=!0,l=!0,i.push(`turned on autoCompactEnabled in ${s}`));const w=n.join(e,".claude","hooks","no-paths.py");if(!r.existsSync(w)&&t.hooks&&typeof t.hooks=="object"){const a=m(u=>u.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"escape"),$=[w,"~/.claude/hooks/no-paths.py","$HOME/.claude/hooks/no-paths.py","${HOME}/.claude/hooks/no-paths.py"].map(a).join("|"),d=new RegExp(`^\\s*(?:exec\\s+)?(?:(?:/usr/bin/env\\s+)?(?:[^\\s]+/)?python(?:\\d+(?:\\.\\d+)*)?(?:\\s+-[^\\s]+)*\\s+)?(?:"(?:${$})"|'(?:${$})'|(?:${$}))(?=$|\\s)`);for(const[u,f]of Object.entries(t.hooks))Array.isArray(f)&&(t.hooks[u]=f.flatMap(h=>{if(!Array.isArray(h?.hooks))return[h];const k=h.hooks.filter(y=>y?.type!=="command"||typeof y.command!="string"||!d.test(y.command)?!0:(i.push(`removed stale hook ${u}: ${y.command}`),l=!0,!1));return k.length===h.hooks.length?[h]:k.length?[{...h,hooks:k}]:[]}))}if(l){r.mkdirSync(n.dirname(s),{recursive:!0});const a=`${s}.${process.pid}.${Date.now()}.tmp`;try{r.writeFileSync(a,JSON.stringify(t,null,2)+`
3
- `,{mode:o,flag:"wx"}),r.renameSync(a,s)}finally{try{r.unlinkSync(a)}catch{}}}return i}m(V,"installClaudeSettings");function lt({home:e=v.homedir(),skillsDir:s=n.join(e,".claude","skills"),cloneRoot:t=O()}={}){const o=n.join(e,".claude","statusline.sh");if(!r.lstatSync(o,{throwIfNoEntry:!1})?.isSymbolicLink())return!1;let l=n.resolve(n.dirname(o),r.readlinkSync(o))===n.resolve(s,"kickflip","bin","statusline.mjs");try{l||=r.realpathSync(o)===r.realpathSync(n.join(t,"bin","statusline.mjs"))}catch{}return l?(r.unlinkSync(o),!0):!1}m(lt,"uninstallStatusLine");export{B as WATCHER_STOP,G as hasWatcherStop,Q as hubStatus,q as inspectLinks,V as installClaudeSettings,P as installLinks,M as nodeBin,z as ownsStatusLine,at as runInstall,D as storedPath,rt as tailnetUrl,F as tailscaleInstallHint,U as tailscaleRoot,it as tailscaleState,lt as uninstallStatusLine,ot as watcherCommand};
1
+ var W=Object.defineProperty;var y=(e,s)=>W(e,"name",{value:s,configurable:!0});import o from"node:fs";import E from"node:os";import n from"node:path";import{SKILLS_SOURCE as I,addArgv as D,addCommand as M,missingSkills as P}from"../shared/skills.mjs";import{cloneRoot as N}from"../shared/repos.mjs";import{hubService as q,runCommand as _}from"../shared/service.mjs";import{tailscaleBin as B,tailscaleInstallHint as C}from"../shared/tailscale.mjs";const U=y(()=>o.existsSync("/opt/homebrew/bin/node")?"/opt/homebrew/bin/node":process.execPath,"nodeBin"),at=y((e,s,t=N())=>`${U()} ${n.join(t,"bin","wait-events.mjs")} ${e} ${s}`,"watcherCommand");function z(e,{home:s=E.homedir(),skillsDir:t=n.join(s,".claude","skills"),cloneRoot:i=N()}={}){if(typeof e!="string"||!n.isAbsolute(e))return e;const a=n.relative(i,e);if(a===".."||a.startsWith(`..${n.sep}`)||n.isAbsolute(a))return e;const c=n.resolve(t,"kickflip");let r=!1;try{r=o.realpathSync(c)===o.realpathSync(i)}catch{}return r?n.join(c,a):e}y(z,"storedPath");function G({home:e=E.homedir(),skillsDir:s=n.join(e,".claude","skills"),cloneRoot:t=N()}={}){const i=o.realpathSync(t),a=[["kickflip",i],["kickflip-hooks",n.join(i,"hooks-plugin")]].map(([l,f])=>{if(!o.statSync(f,{throwIfNoEntry:!1})?.isDirectory())throw new Error(`not a directory: ${f}`);const h=l==="kickflip"?f:n.resolve(s,"kickflip","hooks-plugin"),d=n.resolve(s,l),k=o.lstatSync(d,{throwIfNoEntry:!1});let b;if(k){let j;try{j=o.realpathSync(d)}catch(g){if(g.code!=="ENOENT")throw g}k.isSymbolicLink()?j!==o.realpathSync(f)&&(b=`refusing to replace link ${d} -> ${o.readlinkSync(d)}; remove that link explicitly, then rerun kickflip doctor --fix`):(!k.isDirectory()||j!==o.realpathSync(f))&&(b=`refusing to replace real path ${d}; move it aside explicitly, then rerun kickflip doctor --fix`)}return{link:d,target:h,created:!k,...b?{refusal:b}:{}}}),c=n.join(i,"bin","statusline.mjs");if(!o.statSync(c,{throwIfNoEntry:!1})?.isFile())throw new Error(`not a file: ${c}`);const r=n.join(e,".claude","statusline.sh"),S=n.resolve(s,"kickflip","bin","statusline.mjs"),u=o.lstatSync(r,{throwIfNoEntry:!1});let m=!1,w;if(u)if(!u.isSymbolicLink())w=`refusing to replace real path ${r}; move it aside explicitly, then rerun kickflip doctor --fix`;else{let l;try{l=o.realpathSync(r)}catch(k){if(k.code!=="ENOENT")throw k}const f=n.join(e,".claude","kickflip","sites");let h=f;try{h=o.realpathSync(f)}catch{}const d=!!l&&[f,h].some(k=>l.startsWith(k+n.sep));l&&l!==o.realpathSync(c)&&!d&&(w=`refusing to replace link ${r} -> ${o.readlinkSync(r)}; remove that link explicitly, then rerun kickflip doctor --fix`),m=d||n.resolve(n.dirname(r),o.readlinkSync(r))!==S||!l}return a.push({link:r,target:S,created:!u,...m?{replaced:!0}:{},...w?{refusal:w}:{}}),a}y(G,"inspectLinks");function F({home:e=E.homedir(),skillsDir:s=n.join(e,".claude","skills"),cloneRoot:t=N(),dryRun:i=!1}={}){const a=G({home:e,skillsDir:s,cloneRoot:t}),c=a.find(r=>r.refusal);if(c)throw new Error(c.refusal);if(i)return a;o.mkdirSync(s,{recursive:!0}),o.mkdirSync(n.join(e,".claude"),{recursive:!0});for(const{link:r,target:S,created:u,replaced:m}of a)m&&o.unlinkSync(r),(u||m)&&o.symlinkSync(S,r,r===n.join(e,".claude","statusline.sh")?"file":"dir");return a}y(F,"installLinks");function Q(e,s=E.homedir()){const t=n.join(s,".claude","statusline.sh"),i=[t,`"${t}"`,`'${t.replaceAll("'","'\\''")}'`,"~/.claude/statusline.sh","$HOME/.claude/statusline.sh",'"$HOME/.claude/statusline.sh"',"${HOME}/.claude/statusline.sh",'"${HOME}/.claude/statusline.sh"'];return e?.type==="command"&&i.includes(e.command)}y(Q,"ownsStatusLine");const K='node "$HOME/.claude/skills/kickflip/hooks-plugin/hooks/watcher-stop.mjs"',V=y(e=>(Array.isArray(e?.hooks?.Stop)?e.hooks.Stop:[]).some(s=>Array.isArray(s?.hooks)&&s.hooks.some(t=>t?.type==="command"&&/\/hooks-plugin\/hooks\/watcher-stop\.mjs\b/.test(t.command))),"hasWatcherStop");async function X({port:e=Number(process.env.KICKFLIP_HUB_PORT||4500),fetch:s=globalThis.fetch}={}){try{const t=await s(`http://127.0.0.1:${e}/_hub/status.json`,{signal:AbortSignal.timeout(1500)});if(!t.ok)return null;const i=await t.json();return Array.isArray(i?.sites)?i:null}catch{return null}}y(X,"hubStatus");function R(e){const s=Object.entries(e?.Web||{}).filter(([t])=>!/:\d+$/.test(t)||t.endsWith(":443")).flatMap(([,t])=>Object.entries(t.Handlers||{}));return{root:s.find(([t])=>t==="/")?.[1]?.Proxy||null,paths:[...new Set(s.map(([t])=>t))]}}y(R,"tailscaleRoot");function ct(e,{run:s=_,platform:t=process.platform,tailscale:i=B({platform:t})}={}){if(!i)throw new Error(`Tailscale isn't installed on this host; ${C(t)}`);const a=s(i,["status","--json"]);let c=null;try{c=a.status===0?JSON.parse(a.stdout):null}catch{}if(c?.BackendState!=="Running")throw new Error("Tailscale isn't running on this host; start it, or run tailscale up");const r=c.Self?.DNSName?.replace(/\.$/,"")||"";if(!r||!c.CertDomains?.includes(r))throw new Error("HTTPS isn't on for this tailnet; turn on HTTPS Certificates on the DNS page of the Tailscale admin console");return`https://${r}/${e?e+"/":""}`}y(ct,"tailnetUrl");async function lt({run:e=_,platform:s=process.platform,uid:t=process.getuid(),username:i=E.userInfo().username,port:a=Number(process.env.KICKFLIP_HUB_PORT||4500),tailscale:c=B({platform:s})}={}){const r=y(async $=>{try{return await e(c,$)}catch(v){return{status:1,error:v.message}}},"execute"),S=y($=>{try{return $.status===0?JSON.parse($.stdout):null}catch{return null}},"json"),u=c?await r(["version"]):null,m=u?.status===0,w=m?S(await r(["status","--json"])):null,l=w?.BackendState==="Running",f=w?.Self?.DNSName?.replace(/\.$/,"")||"",h=m&&s==="linux"&&t!==0,d=h?S(await r(["debug","prefs"]))?.OperatorUser:null,k=m?S(await r(["serve","status","--json"])):null,b=R(k);let j=!1;try{const $=new URL(b.root);j=$.protocol==="http:"&&["127.0.0.1","localhost","[::1]"].includes($.hostname)&&Number($.port)===a&&$.pathname==="/"&&!$.search&&!$.hash}catch{}const g=y(($,v,p,x,A="yours")=>({item:$,state:v?"ok":A,detail:p,fix:v?null:x}),"entry"),O=[g("Tailscale",m,m?`${(u.stdout||"installed").trim().split(`
2
+ `)[0]} (${c})`:`not on this session\u2019s PATH${s==="darwin"?" or in Applications":""}`,C(s)),g("Tailscale signed in",l,l?`signed in${f?" as "+f:""}`:m?"not connected":"needs Tailscale","tailscale up")];h&&O.push(g("Tailscale operator",d===i,d===i?i:`${d||"none"} (expected ${i})`,"sudo tailscale set --operator=$USER"));const T=b.paths.filter($=>$!=="/"),L=g("tailscale /",j,`${b.root||"nothing"}${j?"":` (expected the hub on ${a})`}${T.length?"; other mounts: "+T.join(", "):""}`,l?"kickflip doctor --fix":m?"tailscale up":C(s),l?"fixable":"yours");return{installed:m,signedIn:l,dns:f,operatorChecked:h,operator:d,username:i,pointsAtHub:j,prereqs:O,root:L,serveConfig:k}}y(lt,"tailscaleState");async function ut({home:e=E.homedir(),cloneRoot:s=N(),port:t=Number(process.env.KICKFLIP_HUB_PORT||4500),uid:i=process.getuid(),env:a=process.env,run:c=_,platform:r=process.platform,tailscale:S=B({env:a,platform:r}),wait:u=y(f=>new Promise(h=>setTimeout(h,f)),"wait"),fetch:m=globalThis.fetch,dryRun:w=!1,log:l=console.log}={}){const f=n.join(e,".claude","kickflip"),h=q({platform:r,home:e,uid:i,env:a,run:c});if(w){try{for(const{link:x,target:A,created:H,replaced:J}of F({home:e,cloneRoot:s,dryRun:w}))l(`would ${H||J?"link":"check"} ${x} -> ${A}`)}catch(x){l(`would refuse: ${x.message}`)}l(`would merge statusLine and the watcher Stop hook, turn on autoCompactEnabled and remove stale hooks in ${n.join(e,".claude","settings.json")}`);const p=P(e);p.length&&l(`would add ${p.join(", ")} from ${I} with ${M(p)}`);for(const x of await h.install(null,{wait:u,dryRun:w}))l(x);l(`would map tailscale / to the hub on ${t} and remove old site mounts`);return}for(const{link:p,target:x,created:A,replaced:H}of F({home:e,cloneRoot:s}))l(`${A||H?"linked":"checked"} ${p} -> ${x}`);for(const p of Y({home:e}))l(p);const d=P(e);if(d.length){const p=c("npx",D(d),{env:a,timeout:3e5});l(p.status===0?`added ${d.join(", ")} from ${I}`:`could not add ${d.join(", ")} from ${I}: ${p.error}; run ${M(d)}`)}const k=U(),b=z(n.join(s,"hub","hub.mjs"),{home:e,cloneRoot:s});o.mkdirSync(f,{recursive:!0});const j=n.join(f,"hub.log"),g=h.definition({node:k,entry:b,state:f,port:t,log:j});for(const p of await h.install(g,{wait:u}))l(p);let O=!1;for(let p=0;p<30&&!O;p++)await u(300),O=!!await X({port:t,fetch:m});if(!O)throw new Error("hub did not start; see "+j);if(!S)return l(`skipped mapping tailscale / to the hub: Tailscale isn't installed on this host; ${C(r)}`);const T=c(S,["serve","--bg",String(t)],{env:a});if(T.status!==0)throw new Error(T.error||"tailscale serve failed");const L=c(S,["serve","status","--json"],{env:a});let $;try{$=JSON.parse(L.stdout)}catch{return}let v={};try{v=JSON.parse(o.readFileSync(n.join(f,"manifest.json"),"utf8")).sites||{}}catch(p){if(p.code!=="ENOENT")throw p}for(const p of R($).paths)p!=="/"&&Object.hasOwn(v,p.replace(/^\//,"").replace(/\/$/,""))&&c(S,["serve","--https=443","--set-path="+p,"off"],{env:a})}y(ut,"runInstall");function Y({home:e=E.homedir()}={}){const s=n.join(e,".claude","settings.json");let t={},i=384;try{t=JSON.parse(o.readFileSync(s,"utf8")),i=o.statSync(s).mode}catch(u){if(u.code!=="ENOENT")throw u}if(!t||typeof t!="object"||Array.isArray(t))throw new Error(`not a settings object: ${s}`);const a=[];let c=!1;const r=n.join(e,".claude","statusline.sh");if(Object.hasOwn(t,"statusLine"))Q(t.statusLine,e)||a.push(`kept existing statusLine in ${s}; it points elsewhere`);else{const u=/^[\w/.-]+$/.test(r)?r:`'${r.replaceAll("'","'\\''")}'`;t.statusLine={type:"command",command:u},c=!0,a.push(`set statusLine in ${s} -> ${u}`)}if(!V(t)){const u=t.hooks&&typeof t.hooks=="object"?t.hooks:{};t.hooks={...u,Stop:[...Array.isArray(u.Stop)?u.Stop:[],{hooks:[{type:"command",command:K}]}]},c=!0,a.push(`added the watcher Stop hook to ${s} -> ${K}`)}t.autoCompactEnabled!==!0&&(t.autoCompactEnabled=!0,c=!0,a.push(`turned on autoCompactEnabled in ${s}`));const S=n.join(e,".claude","hooks","no-paths.py");if(!o.existsSync(S)&&t.hooks&&typeof t.hooks=="object"){const u=y(l=>l.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"escape"),m=[S,"~/.claude/hooks/no-paths.py","$HOME/.claude/hooks/no-paths.py","${HOME}/.claude/hooks/no-paths.py"].map(u).join("|"),w=new RegExp(`^\\s*(?:exec\\s+)?(?:(?:/usr/bin/env\\s+)?(?:[^\\s]+/)?python(?:\\d+(?:\\.\\d+)*)?(?:\\s+-[^\\s]+)*\\s+)?(?:"(?:${m})"|'(?:${m})'|(?:${m}))(?=$|\\s)`);for(const[l,f]of Object.entries(t.hooks))Array.isArray(f)&&(t.hooks[l]=f.flatMap(h=>{if(!Array.isArray(h?.hooks))return[h];const d=h.hooks.filter(k=>k?.type!=="command"||typeof k.command!="string"||!w.test(k.command)?!0:(a.push(`removed stale hook ${l}: ${k.command}`),c=!0,!1));return d.length===h.hooks.length?[h]:d.length?[{...h,hooks:d}]:[]}))}if(c){o.mkdirSync(n.dirname(s),{recursive:!0});const u=`${s}.${process.pid}.${Date.now()}.tmp`;try{o.writeFileSync(u,JSON.stringify(t,null,2)+`
3
+ `,{mode:i,flag:"wx"}),o.renameSync(u,s)}finally{try{o.unlinkSync(u)}catch{}}}return a}y(Y,"installClaudeSettings");function dt({home:e=E.homedir(),skillsDir:s=n.join(e,".claude","skills"),cloneRoot:t=N()}={}){const i=n.join(e,".claude","statusline.sh");if(!o.lstatSync(i,{throwIfNoEntry:!1})?.isSymbolicLink())return!1;let c=n.resolve(n.dirname(i),o.readlinkSync(i))===n.resolve(s,"kickflip","bin","statusline.mjs");try{c||=o.realpathSync(i)===o.realpathSync(n.join(t,"bin","statusline.mjs"))}catch{}return c?(o.unlinkSync(i),!0):!1}y(dt,"uninstallStatusLine");export{K as WATCHER_STOP,V as hasWatcherStop,X as hubStatus,G as inspectLinks,Y as installClaudeSettings,F as installLinks,U as nodeBin,Q as ownsStatusLine,ut as runInstall,z as storedPath,ct as tailnetUrl,R as tailscaleRoot,lt as tailscaleState,dt as uninstallStatusLine,at as watcherCommand};
package/bin/kickflip.mjs CHANGED
@@ -37,7 +37,7 @@
37
37
  // --template board runs the shared board (../board/server.mjs) from the site dir,
38
38
  // writing its board.json; --items is "waves" (default) or a wave.json-format file, made with no
39
39
  // items when missing.
40
- // It then runs handshake, unless this session already answered the board's hello, and
40
+ // It then runs handshake, unless this session already answered the board's ping, and
41
41
  // asks the hub for one poll (as poll-now --keep-queue), so the board's GitHub link passes
42
42
  // without waiting for the next slot. Prints poll: "reached this board", or why not
43
43
  // --icon names a line icon from ../hub/icons.mjs and --color tints it in the
@@ -88,9 +88,9 @@
88
88
  // kickflip sessions [--json]
89
89
  // one line per board: route, session id, live (background or terminal) or not, folder
90
90
  // kickflip handshake <route>
91
- // tests a board's hub links (../hub/boards.mjs handshake): appends a `hello` event, which the
92
- // owning session's watcher answers when it prints it. The board shows a setup checklist
93
- // until the watcher answered and the hub's GitHub check reached it. Prints {route, hello}
91
+ // tests a board's hub links (../hub/boards.mjs handshake): appends a `ping` event, which the
92
+ // owning session's watcher answers when it sees it. The board shows a setup checklist
93
+ // until the watcher answered and the hub's GitHub check reached it. Prints {route, ping}
94
94
  // kickflip set <route> [--icon <key>] [--color <hex>] [--hub | --no-hub]
95
95
  // changes an existing site's icon or color without joining; '' clears one. --hub makes the board the
96
96
  // hub board, whose session runs in tmux and `hub` attaches to (`hub: true` in its entry), clearing the flag from any other
@@ -99,9 +99,10 @@
99
99
  // removes the entry and keeps its files; --session (or --force) only for a route a session owns
100
100
  // kickflip url [route]
101
101
  // prints the hub's HTTPS address on the tailnet, the one to open on the phone, or with a route that
102
- // site's (./install.mjs tailnetUrl). Exit 1 when Tailscale isn't running or HTTPS isn't on for the tailnet,
102
+ // site's (./install.mjs tailnetUrl). Exit 1 when Tailscale isn't installed or running, or HTTPS isn't on for the tailnet,
103
103
  // 2 for a route with no site
104
- // kickflip restart <route>
104
+ // kickflip restart [route]
105
+ // restarts the site at that route; with no route, restarts the hub through its service
105
106
  // kickflip logs <route> [--lines <n>]
106
107
  // kickflip init [--dry-run] | uninstall
107
108
  // init is the first-run name for doctor --fix. It links the running package or clone, hooks plugin
@@ -112,8 +113,10 @@
112
113
  // --dry-run prints what init would move, link and start, and changes nothing.
113
114
  // uninstall stops the hub and removes its service, kickflip's status-line link and Tailscale /;
114
115
  // Claude settings and skill links are kept
115
- // kickflip update
116
- // installs @lovek4d/kickflip@latest globally with npm, then restarts the hub.
116
+ // kickflip update [--no-restart]
117
+ // installs @lovek4d/kickflip@latest globally with npm. When the version changed it prints
118
+ // "<old> > <new>" and restarts the hub; otherwise it prints "no new version available" and leaves the hub running.
119
+ // --no-restart leaves the hub running on its old code (the hub pulls this way, and offers Restart).
117
120
  // A clone refuses (exit 2); update it with git pull
118
121
  // kickflip push --kind action|prompt|fault|reminder --title <t> [--body <b>] [--url </route/path>] [--tag <tag>]
119
122
  // [--now | --urgent]
@@ -148,15 +151,20 @@
148
151
  // prints {pause_at, state, window, used, resets_at, codex: {past, window, used, resets_at}}; a read exits 0.
149
152
  // pause-at saves the line in steps of 5 (default 90, 100 Off); keep-going lifts the current Claude pause
150
153
  // until its window resets; stop restores it. Codex hands work to Opus past the same line.
154
+ // kickflip audit
155
+ // prints the hub's audit log of the last 30 days (~/.claude/kickflip/audit.jsonl): each call the hub's code
156
+ // made on its own for no one board (usage-pause, usage-resume, linear-move) as
157
+ // "<at> <call> [<ticket>]: <result>. <reason>" and the inputs it read. A board's own calls are in its
158
+ // data/audit.jsonl: board.mjs --site <route> audit (docs/adr/2026-11-05-0913-the-hub-and-each-board-keep-an-audit-log-of-the-calls-their-code-makes.md)
151
159
  // kickflip poll-now [--keep-queue]
152
160
  // asks the hub for one poll now (GitHub check, github-change events,
153
161
  // checked.json, then the queue unless --keep-queue) and prints its result
154
162
  // Exit codes: 0 ok, 1 error, 2 bad usage, 3 route owned by another session (join/leave), or its recorded session is
155
163
  // live (start/resume/hub). hub passes through claude's exit code.
156
- var ve=Object.defineProperty;var p=(e,t)=>ve(e,"name",{value:t,configurable:!0});import w from"node:fs";import u from"node:path";import L from"node:os";import oe from"node:net";import Oe from"node:readline/promises";import{execFileSync as ie,spawnSync as M}from"node:child_process";import{fileURLToPath as xe}from"node:url";import{ICON_KEYS as re}from"../hub/icons.mjs";import{liveSessions as ae}from"../hub/live.mjs";import{isBoardSite as P,writeManifest as v}from"../hub/boards.mjs";import{joinName as _e,routeFromName as F,routeRecord as g,validRoute as C}from"../hub/routes.mjs";import{defaultBranch as Ne,githubFromOrigin as Te}from"../hub/repos.mjs";import{uninstallStatusLine as Ie,storedPath as Fe,nodeBin as K,watcherCommand as Ce,tailnetUrl as Re}from"./install.mjs";import{hubService as ce}from"../shared/service.mjs";import{runDoctor as Ee}from"./doctor.mjs";import{cloneRoot as Be}from"../shared/repos.mjs";const j=Be(),_=u.join(L.homedir(),".claude","kickflip"),De=u.join(_,"manifest.json"),de=u.join(_,"logs"),T=Number(process.env.KICKFLIP_HUB_PORT||4500),U=4600,Le=/^#([0-9a-f]{3}|[0-9a-f]{6})$/i,[G,...S]=process.argv.slice(2),s=Object.assign(Object.create(null),{_:[]});for(let e=0;e<S.length;e++){const t=S[e];if(t.startsWith("--")){const n=t.slice(2);n==="fix"||n==="force"||n==="json"||n==="urgent"||n==="now"||n==="keep-queue"||n==="hub"||n==="no-hub"||n==="dry-run"||n==="watch"&&(e+1>=S.length||S[e+1].startsWith("--"))?s[n]=!0:s[n]=S[++e]}else s._.push(t)}const h=p(e=>console.log(typeof e=="string"?e:JSON.stringify(e,null,2)),"out");class I extends Error{static{p(this,"Exit")}constructor(t,n){super(t),this.code=n}}const i=p((e,t=1)=>{throw new I(e,t)},"fail");function W(){s.icon&&!re.includes(s.icon)&&i(`--icon takes one of: ${re.join(", ")}`,2),s.color&&!Le.test(s.color)&&i("--color takes a CSS hex color, such as #3b82f6",2)}p(W,"checkLook");function Y(e){for(const t of["icon","color"])s[t]===""?delete e[t]:s[t]!==void 0&&(e[t]=s[t])}p(Y,"applyLook");const ue=p(()=>s.hub&&s["no-hub"]&&i("pass --hub or --no-hub, not both",2),"checkHub"),le=p(e=>i(`/${e} is not a board; only a board can be the hub board`,2),"notBoard");function Q(e,t,n){if(s["no-hub"]&&delete n.hub,!!s.hub){P(n)||le(t);for(const[o,l]of Object.entries(e.sites))o!==t&&l?.hub&&delete l.hub;n.hub=!0}}p(Q,"applyHub");function y(){try{const e=JSON.parse(w.readFileSync(De,"utf8"));return e.sites||={},e}catch(e){if(e.code==="ENOENT")return{version:1,sites:{}};i("manifest unreadable: "+e.message)}}p(y,"readManifest");const fe=p(e=>{try{return JSON.parse(w.readFileSync(e,"utf8"))}catch{return null}},"readJSON"),R=p(()=>import(u.join(j,"hub","sessions.mjs")),"sessionsModule");async function A(e){const{SessionError:t}=await R();try{return await e()}catch(n){throw n instanceof t&&i(n.message,n.reason==="live"?3:n.reason==="hub"?2:1),n}}p(A,"sessionCall");async function he(e){const{handshake:t}=await import(u.join(j,"hub","boards.mjs"));try{return await t(e,y())}catch(n){return i(n.message,2)}}p(he,"runHandshake");async function Pe(e){for(let t=0;t<2;t++){let n;try{n=await fetch(`http://127.0.0.1:${T}/_hub/poll`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({flush:!1}),signal:AbortSignal.timeout(5*6e4)})}catch(l){return`not run (${l.message})`}if(!n.ok)return`not run (the hub answered ${n.status})`;const o=await n.json();if(o.checked?.includes(e))return"reached this board";if(o.failed?.length)return`missed this board: ${o.failed[0]}`}return"missed this board"}p(Pe,"pollFor");async function O(e){const{withManifestLock:t}=await import(u.join(j,"hub","boards.mjs"));try{return await t(e)}catch(n){throw n.code==="ELOCKED"&&i(n.message),n}}p(O,"withLock");function pe(e){return new Promise(t=>{const n=oe.connect({port:e,host:"127.0.0.1"}),o=p(l=>{n.destroy(),t(l)},"done");n.setTimeout(500,()=>o(!1)),n.on("connect",()=>o(!0)),n.on("error",()=>o(!1))})}p(pe,"connects");function Ae(e){return new Promise(t=>{const n=oe.createServer();n.once("error",()=>t(!1)),n.listen(e,"0.0.0.0",()=>n.close(()=>t(!0)))})}p(Ae,"bindable");async function J(e){const t=new Set([...Object.values(e.sites),...Object.values(e.closed||{}).map(n=>n.site)].map(n=>n?.port));for(let n=U;n<U+400;n++)if(!t.has(n)&&!await pe(n)&&await Ae(n))return n;i("no free port found from "+U)}p(J,"freePort");function Je(){try{return(JSON.parse(ie("tailscale",["status","--json"],{encoding:"utf8",stdio:["ignore","pipe","ignore"]})).Self?.DNSName||"").replace(/\.$/,"")}catch{return""}}p(Je,"tailnetHost");const X=p(e=>{const t=Je();return t?`https://${t}/${e}/`:`http://127.0.0.1:${T}/${e}/`},"siteUrl");async function H(){try{return await(await fetch(`http://127.0.0.1:${T}/_hub/status.json`,{signal:AbortSignal.timeout(1500)})).json()}catch{return null}}p(H,"hubStatus");const me=p(e=>{try{return w.statSync(e).isDirectory()}catch{return!1}},"isFolder"),He=p(e=>{try{return w.realpathSync(e)}catch{return null}},"realPath");function we(e=t=>{}){(typeof s.name!="string"||!s.name.trim())&&i('join needs --name "<name>": the board or page name, which gives its route',2);const t=s.name.trim().replace(/\s+/g," "),n=F(t);return C(n)||i(`--name "${t}" needs a letter or digit for its route`,2),{route:n,title:t===n&&e(n)?.title||t}}p(we,"named");function ge(e,{title:t,items:n,tickets:o}){w.mkdirSync(u.join(e,"data"),{recursive:!0});const l=u.join(e,"board.json"),c=w.existsSync(l)?JSON.parse(w.readFileSync(l,"utf8")):{};c.title=t,c.items=Fe(n||c.items||"waves"),o&&(c.tickets=o),w.writeFileSync(l,JSON.stringify(c,null,2)+`
157
- `);const r=c.items==="waves"?null:u.resolve(e,c.items);return r&&!w.existsSync(r)&&(w.mkdirSync(u.dirname(r),{recursive:!0}),w.writeFileSync(r,JSON.stringify({title:c.title,items:[]},null,2)+`
158
- `)),[K(),u.join(j,"board","server.mjs"),e]}p(ge,"boardFiles");async function Z(e,t){const{startSession:n,withSessionLock:o}=await R();return o(e,async()=>{const l=await A(()=>n(e,{manifest:y(),...t}));return await O(async()=>{const c=y(),r={session:l.session,label:l.name,since:new Date().toISOString(),repo:l.repo,...l.model?{model:l.model}:{}};g(c.sites,e)?(c.sites[e].owner=r,c.sites[e].updated=r.since):c.starting={...c.starting,[e]:{...g(c.starting,e),...r}},await v(c)}),l})}p(Z,"startRecorded");async function q(e){const{hubSession:t}=await R(),n=await A(()=>t({manifest:y(),repo:e}));process.exitCode=n.status}p(q,"openHub");const be=p((e,...t)=>{const n=M("git",["-C",e,...t],{encoding:"utf8"});return n.status===0?n.stdout.trim():null},"git"),V=p(()=>!!(process.stdin.isTTY&&process.stdout.isTTY),"canAsk");async function ee(e,t="stopped at the prompt; nothing changed"){const n=Oe.createInterface({input:process.stdin,output:process.stdout});try{return(await n.question(e)).trim()}catch(o){throw o.code==="ABORT_ERR"&&i(t),o}finally{n.close()}}p(ee,"ask");const ye=p((e,t)=>!!(g(e.sites,t)||g(e.starting,t)||g(e.closed,t)),"taken");function qe(e){return!/^[A-Za-z0-9 ]+$/.test(e)||!C(F(e))?"A board name takes letters, digits and spaces, up to 41 characters of route.":ye(y(),F(e))?`/${F(e)} is taken; pick another name.`:null}p(qe,"nameProblem");const ke="--template board --items data/wave.json";async function ze(){const e=be(process.cwd(),"rev-parse","--show-toplevel");e||i(`${process.cwd()} is not in a git repo; run kickflip join in the folder of the repo to add as a board`,2);const t=w.realpathSync(e);await H()||i("the hub is not running; run kickflip init to set it up, then kickflip join here again");const n=Object.entries(y().sites).find(([,f])=>P(f)&&f.owner?.repo&&He(f.owner.repo)===t),o=await Ne(t);if(n)return Me(...n,o);const l=u.basename(t).replace(/[^A-Za-z0-9]+/g," ").trim();let c=s.name?.trim();for(c===void 0&&!V()&&i('join needs --name "<Board name>" when it has no terminal to ask in',2);;){c===void 0&&(c=await ee(`Board name${l?` (${l})`:""}: `)||l);const f=qe(c);if(!f)break;s.name!==void 0&&i(f,2),h(f),c=void 0}const r=c.replace(/\s+/g," "),m=F(r),d=be(t,"remote","get-url","origin"),a=d?Te(d):null,{trustFolder:b}=await import("../hub/trust.mjs");try{b(t)}catch(f){i(`Couldn't trust this folder for Claude Code: ${f.message}`)}if(await O(async()=>{const f=y();ye(f,m)&&i(`/${m} is taken; pick another name`,2);const k=u.join(_,"sites",m),$=new Date().toISOString(),x={dir:k,port:await J(f),cmd:ge(k,{title:r,items:"data/wave.json"}),watch:["data/events.jsonl"],title:r,local:!0,...a?{github:a}:{},...o?{branch:o}:{},owner:{repo:t},created:$,updated:$};f.sites={...f.sites,[m]:x},Q(f,m,x),await v(f)}),h(`Made ${r} at ${X(m)}, working in ${t}${a?` (${a})`:""}.`),s.hub)return h("It is the hub board, so its session runs in tmux, attached here."),q(t);try{await Z(m,{repo:t,prompt:ke})}catch(f){throw f instanceof I&&i(`${f.message}
159
- The board is made; run kickflip join here again to start its session.`,f.code),f}h("Its session is starting in the background, and joins the board shortly.")}p(ze,"joinFolder");async function Me(e,t,n){const o=t.title||e;h(`This folder already has a board: ${o} (/${e}).`),n&&n!==t.branch&&(await O(async()=>{const m=y();Object.assign(g(m.sites,e),{branch:n,updated:new Date().toISOString()}),await v(m)}),h(`Its branch is now ${n}${t.branch?` (it was ${t.branch})`:""}.`)),V()||i(t.hub===!0?"Open its session with kickflip hub.":"Run kickflip join in a terminal to restart its session, or tap Restart on its row of the Hub page.");const l=(await ee("Restart its session? (Y/n) ")).toLowerCase();if(l&&!"yes".startsWith(l))return;if(!t.owner.session)return t.hub===!0?q(t.owner.repo):(await Z(e,{repo:t.owner.repo,prompt:ke}),h("Its session is starting in the background."));const c=await fetch(`http://127.0.0.1:${T}/_hub/restart-board`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({route:e}),signal:AbortSignal.timeout(5*6e4)}),r=await c.json();if(c.ok||i(r.error),h(`Restarted ${o}'s session.`),t.hub===!0)return q(t.owner.repo)}p(Me,"restartFolderBoard");const te=["title","description","icon","color","created","watch","filter","hub","github","remote","branch","local"],Se=["notifications","quiet","codexShare"],E="kickflip-board",Ke=/^(\.watch-cursor-.*\.json|watcher\.json)$/,B=p((e,t)=>Object.fromEntries(t.filter(n=>e[n]!==void 0).map(n=>[n,e[n]])),"pick"),z=p(e=>/^[\w@%+=:,./-]+$/.test(e)?e:`'${String(e).replace(/'/g,"'\\''")}'`,"shellQuote");function je(e){const t=M("tar",e[0]==="-czf"?["--no-xattrs",...e]:e,{encoding:"utf8",env:{...process.env,COPYFILE_DISABLE:"1"}});t.status!==0&&i(`tar ${e[0]} failed: ${(t.stderr||t.error?.message||"").trim()}`)}p(je,"tar");function Ue(e,t){const n=e.entry.title||e.route,o=Object.keys(e.settings).map(l=>({notifications:"Notifications",quiet:"Quiet hours",codexShare:"Codex share"})[l]);return`# Moving ${n} (/${e.route}) to another machine
164
+ var _e=Object.defineProperty;var h=(e,t)=>_e(e,"name",{value:t,configurable:!0});import w from"node:fs";import d from"node:path";import A from"node:os";import ie from"node:net";import Ne from"node:readline/promises";import{execFileSync as re,spawnSync as K}from"node:child_process";import{fileURLToPath as Te}from"node:url";import{ICON_KEYS as ae}from"../hub/icons.mjs";import{liveSessions as ce}from"../hub/live.mjs";import{isBoardSite as D,writeManifest as v}from"../hub/boards.mjs";import{joinName as Fe,routeFromName as I,routeRecord as g,validRoute as C}from"../hub/routes.mjs";import{defaultBranch as Ie,githubFromOrigin as Ce}from"../hub/repos.mjs";import{uninstallStatusLine as Re,storedPath as Ee,nodeBin as G,watcherCommand as Be,tailnetUrl as Le}from"./install.mjs";import{hubService as de}from"../shared/service.mjs";import{tailscaleBin as ue}from"../shared/tailscale.mjs";import{runDoctor as Ae}from"./doctor.mjs";import{cloneRoot as De}from"../shared/repos.mjs";const j=De(),_=d.join(A.homedir(),".claude","kickflip"),Pe=d.join(_,"manifest.json"),le=d.join(_,"logs"),T=Number(process.env.KICKFLIP_HUB_PORT||4500),U=4600,He=/^#([0-9a-f]{3}|[0-9a-f]{6})$/i,[W,...S]=process.argv.slice(2),s=Object.assign(Object.create(null),{_:[]});for(let e=0;e<S.length;e++){const t=S[e];if(t.startsWith("--")){const n=t.slice(2);n==="fix"||n==="force"||n==="json"||n==="urgent"||n==="now"||n==="keep-queue"||n==="hub"||n==="no-hub"||n==="dry-run"||n==="no-restart"||n==="watch"&&(e+1>=S.length||S[e+1].startsWith("--"))?s[n]=!0:s[n]=S[++e]}else s._.push(t)}const l=h(e=>console.log(typeof e=="string"?e:JSON.stringify(e,null,2)),"out");class F extends Error{static{h(this,"Exit")}constructor(t,n){super(t),this.code=n}}const i=h((e,t=1)=>{throw new F(e,t)},"fail");function Y(){s.icon&&!ae.includes(s.icon)&&i(`--icon takes one of: ${ae.join(", ")}`,2),s.color&&!He.test(s.color)&&i("--color takes a CSS hex color, such as #3b82f6",2)}h(Y,"checkLook");function Q(e){for(const t of["icon","color"])s[t]===""?delete e[t]:s[t]!==void 0&&(e[t]=s[t])}h(Q,"applyLook");const fe=h(()=>s.hub&&s["no-hub"]&&i("pass --hub or --no-hub, not both",2),"checkHub"),he=h(e=>i(`/${e} is not a board; only a board can be the hub board`,2),"notBoard");function X(e,t,n){if(s["no-hub"]&&delete n.hub,!!s.hub){D(n)||he(t);for(const[o,f]of Object.entries(e.sites))o!==t&&f?.hub&&delete f.hub;n.hub=!0}}h(X,"applyHub");function y(){try{const e=JSON.parse(w.readFileSync(Pe,"utf8"));return e.sites||={},e}catch(e){if(e.code==="ENOENT")return{version:1,sites:{}};i("manifest unreadable: "+e.message)}}h(y,"readManifest");const pe=h(e=>{try{return JSON.parse(w.readFileSync(e,"utf8"))}catch{return null}},"readJSON"),R=h(()=>import(d.join(j,"hub","sessions.mjs")),"sessionsModule");async function P(e){const{SessionError:t}=await R();try{return await e()}catch(n){throw n instanceof t&&i(n.message,n.reason==="live"?3:n.reason==="hub"?2:1),n}}h(P,"sessionCall");async function me(e){const{handshake:t}=await import(d.join(j,"hub","boards.mjs"));try{return await t(e,y())}catch(n){return i(n.message,2)}}h(me,"runHandshake");async function Je(e){for(let t=0;t<2;t++){let n;try{n=await fetch(`http://127.0.0.1:${T}/_hub/poll`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({flush:!1}),signal:AbortSignal.timeout(5*6e4)})}catch(f){return`not run (${f.message})`}if(!n.ok)return`not run (the hub answered ${n.status})`;const o=await n.json();if(o.checked?.includes(e))return"reached this board";if(o.failed?.length)return`missed this board: ${o.failed[0]}`}return"missed this board"}h(Je,"pollFor");async function O(e){const{withManifestLock:t}=await import(d.join(j,"hub","boards.mjs"));try{return await t(e)}catch(n){throw n.code==="ELOCKED"&&i(n.message),n}}h(O,"withLock");function we(e){return new Promise(t=>{const n=ie.connect({port:e,host:"127.0.0.1"}),o=h(f=>{n.destroy(),t(f)},"done");n.setTimeout(500,()=>o(!1)),n.on("connect",()=>o(!0)),n.on("error",()=>o(!1))})}h(we,"connects");function ze(e){return new Promise(t=>{const n=ie.createServer();n.once("error",()=>t(!1)),n.listen(e,"0.0.0.0",()=>n.close(()=>t(!0)))})}h(ze,"bindable");async function H(e){const t=new Set([...Object.values(e.sites),...Object.values(e.closed||{}).map(n=>n.site)].map(n=>n?.port));for(let n=U;n<U+400;n++)if(!t.has(n)&&!await we(n)&&await ze(n))return n;i("no free port found from "+U)}h(H,"freePort");function qe(){const e=ue();if(!e)return"";try{return(JSON.parse(re(e,["status","--json"],{encoding:"utf8",stdio:["ignore","pipe","ignore"]})).Self?.DNSName||"").replace(/\.$/,"")}catch{return""}}h(qe,"tailnetHost");const Z=h(e=>{const t=qe();return t?`https://${t}/${e}/`:`http://127.0.0.1:${T}/${e}/`},"siteUrl");async function J(){try{return await(await fetch(`http://127.0.0.1:${T}/_hub/status.json`,{signal:AbortSignal.timeout(1500)})).json()}catch{return null}}h(J,"hubStatus");const ge=h(e=>{try{return w.statSync(e).isDirectory()}catch{return!1}},"isFolder"),Me=h(e=>{try{return w.realpathSync(e)}catch{return null}},"realPath");function be(e=t=>{}){(typeof s.name!="string"||!s.name.trim())&&i('join needs --name "<name>": the board or page name, which gives its route',2);const t=s.name.trim().replace(/\s+/g," "),n=I(t);return C(n)||i(`--name "${t}" needs a letter or digit for its route`,2),{route:n,title:t===n&&e(n)?.title||t}}h(be,"named");function ye(e,{title:t,items:n,tickets:o}){w.mkdirSync(d.join(e,"data"),{recursive:!0});const f=d.join(e,"board.json"),c=w.existsSync(f)?JSON.parse(w.readFileSync(f,"utf8")):{};c.title=t,c.items=Ee(n||c.items||"waves"),o&&(c.tickets=o),w.writeFileSync(f,JSON.stringify(c,null,2)+`
165
+ `);const r=c.items==="waves"?null:d.resolve(e,c.items);return r&&!w.existsSync(r)&&(w.mkdirSync(d.dirname(r),{recursive:!0}),w.writeFileSync(r,JSON.stringify({title:c.title,items:[]},null,2)+`
166
+ `)),[G(),d.join(j,"board","server.mjs"),e]}h(ye,"boardFiles");async function V(e,t){const{startSession:n,withSessionLock:o}=await R();return o(e,async()=>{const f=await P(()=>n(e,{manifest:y(),...t}));return await O(async()=>{const c=y(),r={session:f.session,label:f.name,since:new Date().toISOString(),repo:f.repo,...f.model?{model:f.model}:{}};g(c.sites,e)?(c.sites[e].owner=r,c.sites[e].updated=r.since):c.starting={...c.starting,[e]:{...g(c.starting,e),...r}},await v(c)}),f})}h(V,"startRecorded");async function z(e){const{hubSession:t}=await R(),n=await P(()=>t({manifest:y(),repo:e}));process.exitCode=n.status}h(z,"openHub");const ke=h((e,...t)=>{const n=K("git",["-C",e,...t],{encoding:"utf8"});return n.status===0?n.stdout.trim():null},"git"),ee=h(()=>!!(process.stdin.isTTY&&process.stdout.isTTY),"canAsk");async function te(e,t="stopped at the prompt; nothing changed"){const n=Ne.createInterface({input:process.stdin,output:process.stdout});try{return(await n.question(e)).trim()}catch(o){throw o.code==="ABORT_ERR"&&i(t),o}finally{n.close()}}h(te,"ask");const Se=h((e,t)=>!!(g(e.sites,t)||g(e.starting,t)||g(e.closed,t)),"taken");function Ke(e){return!/^[A-Za-z0-9 ]+$/.test(e)||!C(I(e))?"A board name takes letters, digits and spaces, up to 41 characters of route.":Se(y(),I(e))?`/${I(e)} is taken; pick another name.`:null}h(Ke,"nameProblem");const je="--template board --items data/wave.json";async function Ge(){const e=ke(process.cwd(),"rev-parse","--show-toplevel");e||i(`${process.cwd()} is not in a git repo; run kickflip join in the folder of the repo to add as a board`,2);const t=w.realpathSync(e);await J()||i("the hub is not running; run kickflip init to set it up, then kickflip join here again");const n=Object.entries(y().sites).find(([,p])=>D(p)&&p.owner?.repo&&Me(p.owner.repo)===t),o=await Ie(t);if(n)return Ue(...n,o);const f=d.basename(t).replace(/[^A-Za-z0-9]+/g," ").trim();let c=s.name?.trim();for(c===void 0&&!ee()&&i('join needs --name "<Board name>" when it has no terminal to ask in',2);;){c===void 0&&(c=await te(`Board name${f?` (${f})`:""}: `)||f);const p=Ke(c);if(!p)break;s.name!==void 0&&i(p,2),l(p),c=void 0}const r=c.replace(/\s+/g," "),m=I(r),u=ke(t,"remote","get-url","origin"),a=u?Ce(u):null,{trustFolder:b}=await import("../hub/trust.mjs");try{b(t)}catch(p){i(`Couldn't trust this folder for Claude Code: ${p.message}`)}if(await O(async()=>{const p=y();Se(p,m)&&i(`/${m} is taken; pick another name`,2);const k=d.join(_,"sites",m),$=new Date().toISOString(),x={dir:k,port:await H(p),cmd:ye(k,{title:r,items:"data/wave.json"}),watch:["data/events.jsonl"],title:r,local:!0,...a?{github:a}:{},...o?{branch:o}:{},owner:{repo:t},created:$,updated:$};p.sites={...p.sites,[m]:x},X(p,m,x),await v(p)}),l(`Made ${r} at ${Z(m)}, working in ${t}${a?` (${a})`:""}.`),s.hub)return l("It is the hub board, so its session runs in tmux, attached here."),z(t);try{await V(m,{repo:t,prompt:je})}catch(p){throw p instanceof F&&i(`${p.message}
167
+ The board is made; run kickflip join here again to start its session.`,p.code),p}l("Its session is starting in the background, and joins the board shortly.")}h(Ge,"joinFolder");function $e(e){const t=de(),n=t.restart();n&&i(`${e}: ${n}; run ${t.restartCommand}`),l("restarted the hub")}h($e,"restartHub");async function Ue(e,t,n){const o=t.title||e;l(`This folder already has a board: ${o} (/${e}).`),n&&n!==t.branch&&(await O(async()=>{const m=y();Object.assign(g(m.sites,e),{branch:n,updated:new Date().toISOString()}),await v(m)}),l(`Its branch is now ${n}${t.branch?` (it was ${t.branch})`:""}.`)),ee()||i(t.hub===!0?"Open its session with kickflip hub.":"Run kickflip join in a terminal to restart its session, or tap Restart on its row of the Hub page.");const f=(await te("Restart its session? (Y/n) ")).toLowerCase();if(f&&!"yes".startsWith(f))return;if(!t.owner.session)return t.hub===!0?z(t.owner.repo):(await V(e,{repo:t.owner.repo,prompt:je}),l("Its session is starting in the background."));const c=await fetch(`http://127.0.0.1:${T}/_hub/restart-board`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({route:e}),signal:AbortSignal.timeout(5*6e4)}),r=await c.json();if(c.ok||i(r.error),l(`Restarted ${o}'s session.`),t.hub===!0)return z(t.owner.repo)}h(Ue,"restartFolderBoard");const ne=["title","description","icon","color","created","watch","filter","hub","github","remote","branch","local"],ve=["notifications","quiet","codexShare"],E="kickflip-board",We=/^(\.watch-cursor-.*\.json|watcher\.json)$/,B=h((e,t)=>Object.fromEntries(t.filter(n=>e[n]!==void 0).map(n=>[n,e[n]])),"pick"),q=h(e=>/^[\w@%+=:,./-]+$/.test(e)?e:`'${String(e).replace(/'/g,"'\\''")}'`,"shellQuote");function Oe(e){const t=K("tar",e[0]==="-czf"?["--no-xattrs",...e]:e,{encoding:"utf8",env:{...process.env,COPYFILE_DISABLE:"1"}});t.status!==0&&i(`tar ${e[0]} failed: ${(t.stderr||t.error?.message||"").trim()}`)}h(Oe,"tar");function Ye(e,t){const n=e.entry.title||e.route,o=Object.keys(e.settings).map(f=>({notifications:"Notifications",quiet:"Quiet hours",codexShare:"Codex share"})[f]);return`# Moving ${n} (/${e.route}) to another machine
160
168
 
161
169
  Exported from ${e.from} at ${e.exported_at}. This bundle holds the board's folder and manifest entry${o.length?`, and the hub's ${o.length>1?`${o.slice(0,-1).join(", ")} and ${o.at(-1)}`:o[0]} settings, which import writes over the new machine's`:""}.
162
170
 
@@ -174,15 +182,16 @@ The bundle carries no secret and nothing tied to the old machine. Set these up o
174
182
 
175
183
  - GitHub sign-in: \`gh auth login\`.
176
184
  - Linear API key: Hub \u2192 Linear.
177
- - Slack: Claude's Slack connector in the new session, and the hub's Slack identity with \`board.mjs slack set --self-dm <D\u2026> --user <U\u2026>\`.
185
+ - Slack user token: Hub \u2192 Slack. Digests follow the hub's Slack timezone, set with \`board.mjs slack set --timezone Area/City\`.
178
186
  - Codex: \`codex login\`, if Codex builds.
179
187
  - Notifications: open the Hub page on each phone and tap Turn on.
180
188
  - Other hub settings, such as the pause line and Rules for every board, stay on the old machine.
181
189
 
182
190
  Manifest fields left out, which the new machine sets itself: ${e.dropped.length?e.dropped.join(", "):"none"}.
183
- `}p(Ue,"exportReadme");async function Ge(){W();const e=s.port===void 0?void 0:Number(s.port);e!==void 0&&!(Number.isInteger(e)&&e>0&&e<65536)&&i("--port takes a port number",2),e===T&&i(`--port ${e} is the hub's own port`,2);let t;if(s.cmd!==void 0){try{t=JSON.parse(s.cmd)}catch{t=null}(!Array.isArray(t)||!t.length||!t.every(d=>typeof d=="string"))&&i(`--cmd takes a JSON array of strings, such as '["/abs/node","/abs/server.mjs"]'`,2)}const n=s.dir===void 0?void 0:u.resolve(s.dir);n!==void 0&&!t&&e===void 0&&!me(n)&&i(`--dir ${n} is not a folder`,2);const{route:o,title:l}=we(d=>g(y().sites,d)),c=await O(async()=>{const d=y();g(d.closed,o)&&i("That board is closed; use kickflip reopen "+o,2),g(d.starting,o)&&i(`/${o} is a board that is starting; pick another route`,2);const a=g(d.sites,o);if(a?.owner?.session)throw h({error:"owned",route:o,owner:a.owner,hint:"a session owns this route: pick another route, or that session leaves it first"}),new I("",3);const b=new Date().toISOString(),f=a?{...a}:{created:b};delete f.watch,delete f.filter;{const k=t?{cmd:t,dir:n||a?.dir||process.cwd(),port:e??(a?.cmd?a.port:void 0)??await J(d)}:e!==void 0?{port:e,...n?{dir:n}:{}}:{dir:n};for(const $ of["cmd","dir","port","cwd","env"])delete f[$];Object.assign(f,k)}return f.title=l,s.description&&(f.description=s.description),Y(f),f.updated=b,d.sites={...d.sites,[o]:f},await v(d),f}),r=c.cmd?"cmd":c.port?"port":"files",m=await H();h({route:o,url:X(o),serves:r,...c.port?{port:c.port}:{},...c.dir?{dir:c.dir}:{},...r==="port"?{answering:await pe(c.port)}:{},hub:m?"running":"NOT running: run `kickflip doctor --fix`"})}p(Ge,"joinPage");const ne={async doctor(){s["dry-run"]&&!s.fix&&i("--dry-run needs --fix",2);const e=V()&&!s.json?n=>ee(n,"stopped at the prompt; the clones not yet answered are kept"):void 0,{exitCode:t}=await Ee({fix:!!s.fix,dryRun:!!s["dry-run"],json:!!s.json,ask:e});process.exitCode=t},async join(){if(s._.length&&i(`join takes no route: --name "<name>" gives the title and the route, as in kickflip join --name ${z(s._[0])}`,2),s.title!==void 0&&i('join takes no --title: --name "<name>" gives the title and the route',2),!s.session){const r=["port","dir","cmd"].some(d=>s[d]!==void 0),m=["watch","template","filter","label","model","repo","force","items",...r?["hub"]:[]].find(d=>s[d]!==void 0);return m&&i(`--${m} needs --session <id> (this session's id)`,2),r?Ge():ze()}const{route:e,title:t}=we(r=>g(y().sites,r));s.template&&s.template!=="board"&&i('--template takes "board"',2),W(),ue();const n=await O(async()=>{const r=y();g(r.closed,e)&&i("That board is closed; use kickflip reopen "+e,2);const m=new Date().toISOString(),d=g(r.sites,e);if(d?.owner?.session&&d.owner.session!==s.session&&!s.force)throw h({error:"owned",route:e,owner:d.owner,hint:"ask the user, then rerun with --force to take it over"}),new I("",3);s.hub&&s.template!=="board"&&!P({dir:"",cmd:s.cmd?JSON.parse(s.cmd):d?.cmd})&&le(e);const a=d?{...d}:{};let b=!1;if(d||(a.dir=u.resolve(s.dir||u.join(_,"sites",e)),(!w.existsSync(a.dir)||w.readdirSync(a.dir).length===0)&&!s.cmd&&!s.template&&(w.cpSync(u.join(j,"starter"),a.dir,{recursive:!0}),b=!0),a.port=s.port?Number(s.port):await J(r),a.cmd=s.cmd?JSON.parse(s.cmd):[K(),u.join(a.dir,"server.mjs")],a.watch=["data/events.jsonl"],a.created=m),s.dir&&d&&(a.dir=u.resolve(s.dir)),s.port&&d&&(a.port=Number(s.port)),s.cmd&&d&&(a.cmd=JSON.parse(s.cmd)),s.template==="board"){const N=g(r.starting,e),$e=!d&&N?.tickets&&(!N.session||N.session===s.session)?N.tickets:void 0;a.cmd=ge(a.dir,{title:t,items:s.items,tickets:$e}),b=!d}a.title=t,s.description&&(a.description=s.description),typeof s.watch=="string"&&(a.watch=s.watch.split(",").map(N=>N.trim()).filter(Boolean)),s.filter!==void 0&&(a.filter=s.filter||void 0),Y(a),Q(r,e,a);const f=d?.owner&&d.owner.session!==s.session?d.owner:null,k=g(r.starting,e)?.session===s.session?g(r.starting,e):null,$=g(r.starting,e);!d&&($?.github||$?.remote)&&(!$.session||$.session===s.session)&&($.github?a.github=$.github:a.remote=$.remote,a.branch=$.branch);const x=d?.owner?.session===s.session?d.owner:null,D=x||k||f;a.owner={...x||{session:s.session,since:m},label:s.label||D?.label||"",repo:s.repo?u.resolve(s.repo):(a.github||a.remote)&&D?.repo||ae()?.get(s.session)?.cwd||D?.repo||process.cwd()};const se=s.model||D?.model;return se&&(a.owner.model=se),g(r.starting,e)&&(delete r.starting[e],Object.keys(r.starting).length||delete r.starting),a.updated=m,r.sites={...r.sites,[e]:a},await v(r),{route:e,created:b,previousOwner:f,site:a}}),o=await H();let l,c;if(s.template==="board"){const r=fe(u.join(n.site.dir,"data","handshake.json"));l=r?.ack&&r.hello?.session===s.session?"answered before":await he(e),c=o?await Pe(e):"not run (the hub is not running)"}h({route:e,url:X(e),dir:n.site.dir,port:n.site.port,owner:n.site.owner,createdFromStarter:n.created,previousOwner:n.previousOwner,log:u.join(de,e+".log"),watcher:Ce(e,s.session,j),hub:o?"running":"NOT running: run `kickflip doctor --fix`",...l?{handshake:l}:{},...c?{poll:c}:{}})},async handshake(){const e=s._[0];e||i("handshake needs a route",2),h(await he(e))},async leave(){const e=s._[0];e||i("leave needs a route",2),await O(async()=>{const t=y(),n=g(t.sites,e);if(n||i("no site at /"+e),n.owner?.session&&!s.session&&!s.force&&i("a session owns /"+e+"; leave needs --session <id>, or --force",2),n.owner?.session&&n.owner.session!==s.session&&!s.force)throw h({error:"owned",route:e,owner:n.owner}),new I("",3);delete t.sites[e],await v(t),h({left:e,filesKept:n.dir})})},async start(){const e=s._[0];C(e)||i("start needs a route: lowercase letters, digits and dashes",2);const t=await Z(e,{repo:s.repo,model:s.model,prompt:s.prompt});h({route:t.route,session:t.session,confirmed:t.confirmed,repo:t.repo,argv:t.argv,output:t.output})},async resume(){const e=s._[0];e||i("resume needs a route",2);const{resumeSession:t}=await R();h(await A(()=>t(e,{manifest:y()})))},async close(){const{closeBoard:e}=await import("../hub/close-board.mjs");try{h(await e(s._[0],s.name))}catch(t){i(t.message,t.status?2:1)}},async reopen(){const{reopenBoard:e}=await import("../hub/close-board.mjs");try{h(await A(()=>e(s._[0])))}catch(t){throw t.status&&i(t.message,2),t}},async export(){const e=s._[0];C(e)||i("export needs a route: lowercase letters, digits and dashes",2);const t=u.resolve(s.out||`kickflip-${e}.tgz`);w.existsSync(t)&&i(`${t} already exists; pass another --out`,2);const n=g(y().sites,e)||i("no site at /"+e);P(n)||i(`/${e} is not a board; export moves a board`,2);const o=w.realpathSync(n.dir),{itemSources:l,openBoardBuilders:c}=await import("../board/store.mjs"),r=(await l(o)).find(k=>u.relative(o,k.file).startsWith(".."));r&&i(`/${e}'s cards live outside its folder (${r.file}); export moves only a board whose board.json items is a file in its folder`);const m=await c(o);m.length&&i(`a builder is open on /${e}: ${m.map(k=>`${k.key} (${k.label})`).join(", ")}; export once it is done`);const{readSettingsFile:d}=await import("../hub/push.mjs"),a=B(n,te),b={kickflip_export:1,route:e,exported_at:new Date().toISOString(),from:L.hostname(),entry:a,owner:B(n.owner||{},["label","model"]),settings:B(d(),Se),dropped:Object.keys(n).filter(k=>!te.includes(k))},f=w.mkdtempSync(u.join(L.tmpdir(),"kickflip-export-"));try{const k=u.join(f,E),$=u.join(o,"data");w.cpSync(o,u.join(k,"site"),{recursive:!0,filter:p(x=>!(u.dirname(x)===$&&Ke.test(u.basename(x))),"filter")}),w.writeFileSync(u.join(k,"export.json"),JSON.stringify(b,null,2)+`
184
- `),w.writeFileSync(u.join(k,"README.md"),Ue(b,u.basename(t))),je(["-czf",t,"-C",f,E])}finally{w.rmSync(f,{recursive:!0,force:!0})}h({route:e,file:t,entry:Object.keys(a),dropped:b.dropped,settings:Object.keys(b.settings)})},async import(){s._[0]||i("import needs the file kickflip export wrote",2);const e=u.resolve(s._[0]);w.existsSync(e)||i(`no file at ${e}`,2),w.mkdirSync(_,{recursive:!0});const t=w.mkdtempSync(u.join(_,".import-"));try{je(["-xzf",e,"-C",t]);const n=fe(u.join(t,E,"export.json"));(n?.kickflip_export!==1||!C(n.route)||!me(u.join(t,E,"site")))&&i(`${e} is not a kickflip export`,2);const{route:o}=n,l=u.join(_,"sites",o),c=await O(async()=>{const a=y();(g(a.sites,o)||g(a.closed,o)||g(a.starting,o))&&i(`/${o} already exists on this host; import never replaces a board`),w.existsSync(l)&&i(`${l} already exists; import never replaces a board's folder`);const b=B(n.entry||{},te);b.hub&&Object.values(a.sites).some(k=>k?.hub)&&delete b.hub;const f={...b,dir:l,port:await J(a),cmd:[K(),u.join(j,"board","server.mjs"),l],updated:new Date().toISOString()};return w.mkdirSync(u.dirname(l),{recursive:!0}),w.renameSync(u.join(t,E,"site"),l),a.sites={...a.sites,[o]:f},await v(a),f}),r=B(n.settings||{},Se);if(Object.keys(r).length){const{readSettingsFile:a,writeSettingsFile:b}=await import("../hub/push.mjs");b({...a(),...r})}const{label:m,model:d}=n.owner||{};h({route:o,dir:l,port:c.port,settings:Object.keys(r),hub:c.hub===!0,join:`kickflip join --name ${z(_e(o,c.title))} --session <this session's id> --watch --template board${m?` --label ${z(m)}`:""}${d?` --model ${z(d)}`:""} --force`})}finally{w.rmSync(t,{recursive:!0,force:!0})}},async sessions(){const{sessionOf:e}=await R(),{listBoards:t}=await import(u.join(j,"hub","boards.mjs")),n=y(),o=ae(),c=[...t(n).map(r=>r.route),...Object.keys(n.starting||{}).filter(r=>!g(n.sites,r))].map(r=>{const m=e(n,r),d=m&&o?.get(m.session),a=m?o?d?d.kind==="bg"?"background":"terminal":null:"unknown":null;return{route:r,session:m?.session||null,live:a,repo:m?.repo||null,starting:!g(n.sites,r)}});if(s.json)return h(c);if(!c.length)return h("no boards");for(const r of c)h(`/${r.route} ${r.session||"no session"} ${r.live==="unknown"?"live unknown (ps gave no answer)":r.live?`live (${r.live})`:"not live"} ${r.repo||"no folder"}${r.starting?" (starting: not joined yet)":""}`)},async hub(){await q(s.repo)},async set(){const e=s._[0];e||i("set needs a route",2),s.icon===void 0&&s.color===void 0&&!s.hub&&!s["no-hub"]&&i("set needs --icon, --color, --hub or --no-hub",2),W(),ue(),await O(async()=>{const t=y(),n=g(t.sites,e)||i("no site at /"+e);Y(n),Q(t,e,n),n.updated=new Date().toISOString(),await v(t),h({route:e,icon:n.icon??null,color:n.color??null,hub:n.hub===!0})})},async restart(){const e=s._[0];await O(async()=>{const t=y();g(t.sites,e)||i("no site at /"+e),t.sites[e].restart_at=Date.now(),await v(t)}),h(`restarting /${e}; the hub picks it up within 2 seconds`)},async url(){const e=s._[0];e&&!g(y().sites,e)&&i("no site at /"+e,2);try{h(Re(e))}catch(t){i(t.message)}},async logs(){const e=s._[0],t=u.join(de,e+".log");w.existsSync(t)||i("no log yet for /"+e);const n=Number(s.lines||40);h(w.readFileSync(t,"utf8").split(`
191
+ `}h(Ye,"exportReadme");async function Qe(){Y();const e=s.port===void 0?void 0:Number(s.port);e!==void 0&&!(Number.isInteger(e)&&e>0&&e<65536)&&i("--port takes a port number",2),e===T&&i(`--port ${e} is the hub's own port`,2);let t;if(s.cmd!==void 0){try{t=JSON.parse(s.cmd)}catch{t=null}(!Array.isArray(t)||!t.length||!t.every(u=>typeof u=="string"))&&i(`--cmd takes a JSON array of strings, such as '["/abs/node","/abs/server.mjs"]'`,2)}const n=s.dir===void 0?void 0:d.resolve(s.dir);n!==void 0&&!t&&e===void 0&&!ge(n)&&i(`--dir ${n} is not a folder`,2);const{route:o,title:f}=be(u=>g(y().sites,u)),c=await O(async()=>{const u=y();g(u.closed,o)&&i("That board is closed; use kickflip reopen "+o,2),g(u.starting,o)&&i(`/${o} is a board that is starting; pick another route`,2);const a=g(u.sites,o);if(a?.owner?.session)throw l({error:"owned",route:o,owner:a.owner,hint:"a session owns this route: pick another route, or that session leaves it first"}),new F("",3);const b=new Date().toISOString(),p=a?{...a}:{created:b};delete p.watch,delete p.filter;{const k=t?{cmd:t,dir:n||a?.dir||process.cwd(),port:e??(a?.cmd?a.port:void 0)??await H(u)}:e!==void 0?{port:e,...n?{dir:n}:{}}:{dir:n};for(const $ of["cmd","dir","port","cwd","env"])delete p[$];Object.assign(p,k)}return p.title=f,s.description&&(p.description=s.description),Q(p),p.updated=b,u.sites={...u.sites,[o]:p},await v(u),p}),r=c.cmd?"cmd":c.port?"port":"files",m=await J();l({route:o,url:Z(o),serves:r,...c.port?{port:c.port}:{},...c.dir?{dir:c.dir}:{},...r==="port"?{answering:await we(c.port)}:{},hub:m?"running":"NOT running: run `kickflip doctor --fix`"})}h(Qe,"joinPage");const se={async doctor(){s["dry-run"]&&!s.fix&&i("--dry-run needs --fix",2);const e=ee()&&!s.json?n=>te(n,"stopped at the prompt; the clones not yet answered are kept"):void 0,{exitCode:t}=await Ae({fix:!!s.fix,dryRun:!!s["dry-run"],json:!!s.json,ask:e});process.exitCode=t},async join(){if(s._.length&&i(`join takes no route: --name "<name>" gives the title and the route, as in kickflip join --name ${q(s._[0])}`,2),s.title!==void 0&&i('join takes no --title: --name "<name>" gives the title and the route',2),!s.session){const r=["port","dir","cmd"].some(u=>s[u]!==void 0),m=["watch","template","filter","label","model","repo","force","items",...r?["hub"]:[]].find(u=>s[u]!==void 0);return m&&i(`--${m} needs --session <id> (this session's id)`,2),r?Qe():Ge()}const{route:e,title:t}=be(r=>g(y().sites,r));s.template&&s.template!=="board"&&i('--template takes "board"',2),Y(),fe();const n=await O(async()=>{const r=y();g(r.closed,e)&&i("That board is closed; use kickflip reopen "+e,2);const m=new Date().toISOString(),u=g(r.sites,e);if(u?.owner?.session&&u.owner.session!==s.session&&!s.force)throw l({error:"owned",route:e,owner:u.owner,hint:"ask the user, then rerun with --force to take it over"}),new F("",3);s.hub&&s.template!=="board"&&!D({dir:"",cmd:s.cmd?JSON.parse(s.cmd):u?.cmd})&&he(e);const a=u?{...u}:{};let b=!1;if(u||(a.dir=d.resolve(s.dir||d.join(_,"sites",e)),(!w.existsSync(a.dir)||w.readdirSync(a.dir).length===0)&&!s.cmd&&!s.template&&(w.cpSync(d.join(j,"starter"),a.dir,{recursive:!0}),b=!0),a.port=s.port?Number(s.port):await H(r),a.cmd=s.cmd?JSON.parse(s.cmd):[G(),d.join(a.dir,"server.mjs")],a.watch=["data/events.jsonl"],a.created=m),s.dir&&u&&(a.dir=d.resolve(s.dir)),s.port&&u&&(a.port=Number(s.port)),s.cmd&&u&&(a.cmd=JSON.parse(s.cmd)),s.template==="board"){const N=g(r.starting,e),xe=!u&&N?.tickets&&(!N.session||N.session===s.session)?N.tickets:void 0;a.cmd=ye(a.dir,{title:t,items:s.items,tickets:xe}),b=!u}a.title=t,s.description&&(a.description=s.description),typeof s.watch=="string"&&(a.watch=s.watch.split(",").map(N=>N.trim()).filter(Boolean)),s.filter!==void 0&&(a.filter=s.filter||void 0),Q(a),X(r,e,a);const p=u?.owner&&u.owner.session!==s.session?u.owner:null,k=g(r.starting,e)?.session===s.session?g(r.starting,e):null,$=g(r.starting,e);!u&&($?.github||$?.remote)&&(!$.session||$.session===s.session)&&($.github?a.github=$.github:a.remote=$.remote,a.branch=$.branch);const x=u?.owner?.session===s.session?u.owner:null,L=x||k||p;a.owner={...x||{session:s.session,since:m},label:s.label||L?.label||"",repo:s.repo?d.resolve(s.repo):(a.github||a.remote)&&L?.repo||ce()?.get(s.session)?.cwd||L?.repo||process.cwd()};const oe=s.model||L?.model;oe&&(a.owner.model=oe);const M=g(r.starting,e);return M&&(r.starting[e]={...M,joined_at:M.joined_at||m}),a.updated=m,r.sites={...r.sites,[e]:a},await v(r),{route:e,created:b,previousOwner:p,site:a}}),o=await J();let f,c;if(s.template==="board"){const r=pe(d.join(n.site.dir,"data","handshake.json"));f=r?.ack&&(r.ping||r.hello)?.session===s.session?"answered before":await me(e),c=o?await Je(e):"not run (the hub is not running)"}l({route:e,url:Z(e),dir:n.site.dir,port:n.site.port,owner:n.site.owner,createdFromStarter:n.created,previousOwner:n.previousOwner,log:d.join(le,e+".log"),watcher:Be(e,s.session,j),hub:o?"running":"NOT running: run `kickflip doctor --fix`",...f?{handshake:f}:{},...c?{poll:c}:{}})},async handshake(){const e=s._[0];e||i("handshake needs a route",2),l(await me(e))},async leave(){const e=s._[0];e||i("leave needs a route",2),await O(async()=>{const t=y(),n=g(t.sites,e);if(n||i("no site at /"+e),n.owner?.session&&!s.session&&!s.force&&i("a session owns /"+e+"; leave needs --session <id>, or --force",2),n.owner?.session&&n.owner.session!==s.session&&!s.force)throw l({error:"owned",route:e,owner:n.owner}),new F("",3);delete t.sites[e],await v(t),l({left:e,filesKept:n.dir})})},async start(){const e=s._[0];C(e)||i("start needs a route: lowercase letters, digits and dashes",2);const t=await V(e,{repo:s.repo,model:s.model,prompt:s.prompt});l({route:t.route,session:t.session,confirmed:t.confirmed,repo:t.repo,argv:t.argv,output:t.output})},async resume(){const e=s._[0];e||i("resume needs a route",2);const{resumeSession:t}=await R();l(await P(()=>t(e,{manifest:y()})))},async close(){const{closeBoard:e}=await import("../hub/close-board.mjs");try{l(await e(s._[0],s.name))}catch(t){i(t.message,t.status?2:1)}},async reopen(){const{reopenBoard:e}=await import("../hub/close-board.mjs");try{l(await P(()=>e(s._[0])))}catch(t){throw t.status&&i(t.message,2),t}},async export(){const e=s._[0];C(e)||i("export needs a route: lowercase letters, digits and dashes",2);const t=d.resolve(s.out||`kickflip-${e}.tgz`);w.existsSync(t)&&i(`${t} already exists; pass another --out`,2);const n=g(y().sites,e)||i("no site at /"+e);D(n)||i(`/${e} is not a board; export moves a board`,2);const o=w.realpathSync(n.dir),{itemSources:f,openBoardBuilders:c}=await import("../board/store.mjs"),r=(await f(o)).find(k=>d.relative(o,k.file).startsWith(".."));r&&i(`/${e}'s cards live outside its folder (${r.file}); export moves only a board whose board.json items is a file in its folder`);const m=await c(o);m.length&&i(`a builder is open on /${e}: ${m.map(k=>`${k.key} (${k.label})`).join(", ")}; export once it is done`);const{readSettingsFile:u}=await import("../hub/push.mjs"),a=B(n,ne),b={kickflip_export:1,route:e,exported_at:new Date().toISOString(),from:A.hostname(),entry:a,owner:B(n.owner||{},["label","model"]),settings:B(u(),ve),dropped:Object.keys(n).filter(k=>!ne.includes(k))},p=w.mkdtempSync(d.join(A.tmpdir(),"kickflip-export-"));try{const k=d.join(p,E),$=d.join(o,"data");w.cpSync(o,d.join(k,"site"),{recursive:!0,filter:h(x=>!(d.dirname(x)===$&&We.test(d.basename(x))),"filter")}),w.writeFileSync(d.join(k,"export.json"),JSON.stringify(b,null,2)+`
192
+ `),w.writeFileSync(d.join(k,"README.md"),Ye(b,d.basename(t))),Oe(["-czf",t,"-C",p,E])}finally{w.rmSync(p,{recursive:!0,force:!0})}l({route:e,file:t,entry:Object.keys(a),dropped:b.dropped,settings:Object.keys(b.settings)})},async import(){s._[0]||i("import needs the file kickflip export wrote",2);const e=d.resolve(s._[0]);w.existsSync(e)||i(`no file at ${e}`,2),w.mkdirSync(_,{recursive:!0});const t=w.mkdtempSync(d.join(_,".import-"));try{Oe(["-xzf",e,"-C",t]);const n=pe(d.join(t,E,"export.json"));(n?.kickflip_export!==1||!C(n.route)||!ge(d.join(t,E,"site")))&&i(`${e} is not a kickflip export`,2);const{route:o}=n,f=d.join(_,"sites",o),c=await O(async()=>{const a=y();(g(a.sites,o)||g(a.closed,o)||g(a.starting,o))&&i(`/${o} already exists on this host; import never replaces a board`),w.existsSync(f)&&i(`${f} already exists; import never replaces a board's folder`);const b=B(n.entry||{},ne);b.hub&&Object.values(a.sites).some(k=>k?.hub)&&delete b.hub;const p={...b,dir:f,port:await H(a),cmd:[G(),d.join(j,"board","server.mjs"),f],updated:new Date().toISOString()};return w.mkdirSync(d.dirname(f),{recursive:!0}),w.renameSync(d.join(t,E,"site"),f),a.sites={...a.sites,[o]:p},await v(a),p}),r=B(n.settings||{},ve);if(Object.keys(r).length){const{readSettingsFile:a,writeSettingsFile:b}=await import("../hub/push.mjs");b({...a(),...r})}const{label:m,model:u}=n.owner||{};l({route:o,dir:f,port:c.port,settings:Object.keys(r),hub:c.hub===!0,join:`kickflip join --name ${q(Fe(o,c.title))} --session <this session's id> --watch --template board${m?` --label ${q(m)}`:""}${u?` --model ${q(u)}`:""} --force`})}finally{w.rmSync(t,{recursive:!0,force:!0})}},async sessions(){const{sessionOf:e}=await R(),{listBoards:t}=await import(d.join(j,"hub","boards.mjs")),n=y(),o=ce(),c=[...t(n).map(r=>r.route),...Object.keys(n.starting||{}).filter(r=>!g(n.sites,r))].map(r=>{const m=e(n,r),u=m&&o?.get(m.session),a=m?o?u?u.kind==="bg"?"background":"terminal":null:"unknown":null;return{route:r,session:m?.session||null,live:a,repo:m?.repo||null,starting:!g(n.sites,r)}});if(s.json)return l(c);if(!c.length)return l("no boards");for(const r of c)l(`/${r.route} ${r.session||"no session"} ${r.live==="unknown"?"live unknown (ps gave no answer)":r.live?`live (${r.live})`:"not live"} ${r.repo||"no folder"}${r.starting?" (starting: not joined yet)":""}`)},async hub(){await z(s.repo)},async set(){const e=s._[0];e||i("set needs a route",2),s.icon===void 0&&s.color===void 0&&!s.hub&&!s["no-hub"]&&i("set needs --icon, --color, --hub or --no-hub",2),Y(),fe(),await O(async()=>{const t=y(),n=g(t.sites,e)||i("no site at /"+e);Q(n),X(t,e,n),n.updated=new Date().toISOString(),await v(t),l({route:e,icon:n.icon??null,color:n.color??null,hub:n.hub===!0})})},async restart(){const e=s._[0];if(!e)return $e("the hub did not restart");await O(async()=>{const t=y();g(t.sites,e)||i("no site at /"+e),t.sites[e].restart_at=Date.now(),await v(t)}),l(`restarting /${e}; the hub picks it up within 2 seconds`)},async url(){const e=s._[0];e&&!g(y().sites,e)&&i("no site at /"+e,2);try{l(Le(e))}catch(t){i(t.message)}},async logs(){const e=s._[0],t=d.join(le,e+".log");w.existsSync(t)||i("no log yet for /"+e);const n=Number(s.lines||40);l(w.readFileSync(t,"utf8").split(`
185
193
  `).slice(-n-1).join(`
186
- `))},async init(){s.fix=!0,await ne.doctor()},async update(){w.existsSync(u.join(j,".git"))&&i(`${j} is a clone; update it with git pull`,2);const e=M("npm",["i","-g","@lovek4d/kickflip@latest"],{stdio:"inherit"});e.status!==0&&i(`npm update failed${e.error?": "+e.error.message:""}`);const t=ce(),n=t.restart();n&&i(`update finished but the hub did not restart: ${n}; run ${t.restartCommand}`),h("updated kickflip and restarted the hub")},async uninstall(){Ie()&&h(`removed ${u.join(L.homedir(),".claude","statusline.sh")}`);const e=ce();e.remove();try{ie("tailscale",["serve","--https=443","--set-path=/","off"],{stdio:"ignore"})}catch{}h(`hub stopped and removed from ${e.manager??"the host"}; tailscale / unmapped. The manifest and sites are kept in ${_}`)},async push(){const{deliver:e,pushKindError:t}=await import(u.join(j,"hub","push.mjs")),n=t(s.kind);n&&i(n,2),s.title||i("push needs --title",2),s.url&&!s.url.startsWith("/")&&i("--url is a path on the hub, such as /<route>/page.html",2);const{sendWords:o,holdLine:l}=await import(u.join(j,"hub","schedule.mjs"));s.now&&s.urgent&&i("pass --now or --urgent, not both",2);const c=await e({title:s.title,body:s.body||"",url:s.url||"/",tag:s.tag},{kind:s.kind,urgent:!!s.urgent,immediate:!!s.now});if(c.noDevice&&i("no device has turned on notifications; open Hub \u2192 Notifications (/_hub/#notifications) in the Home Screen app and tap Turn on",3),c.dropped)return h("dropped (notifications are off on the Hub page)");if(c.queued){const{count:r,replaced:m,nextFlushAt:d,hold:a,sendFailed:b}=c.queued,f=s.now?a?`${l(a)}; `:b?"no device took it now; ":"":"";h(`queued (${f}${r} in queue${m?"; replaced the queued push with this tag":""}); next send ${o(d)}${d instanceof Date?` (${d.toISOString()})`:""}`),await H()||h("warning: the hub is not running, so the queue goes out only once it runs");return}h(c.sent),c.sent.some(r=>r.status>=200&&r.status<300)||i("every push was refused",1),h(s.urgent?"sent (urgent)":"sent")},async quiet(){const{readSettings:e}=await import(u.join(j,"hub","push.mjs")),{hold:t}=await import(u.join(j,"hub","schedule.mjs")),n=e(),o=t(new Date,n);h({quiet:n.quiet,held:o!==null,held_until:o?o.until instanceof Date?o.until.toISOString():o.until:null,held_why:o?.why??null,notifications:n.notifications})},async notifications(){const{setNotifications:e,readSettings:t}=await import(u.join(j,"hub","push.mjs")),[n,o]=s._,c=n===void 0?null:n==="on"||n==="off"?{on:n==="on"}:n==="weekends"?{weekends_off:p((m,d)=>m==="on"?!0:m==="off"?!1:i(`${d} takes on or off`,2),"onOff")(o,"weekends")}:n==="snooze"?{snooze:o||i("snooze takes a length: <n>m, <n>h, <n>d, <n>w, tonight, morning, monday, forever or a date and time",2)}:n==="unsnooze"?{snooze:"off"}:i("notifications takes on, off, snooze <length>, unsnooze, or weekends on|off",2);if(!c)return h(t().notifications);let r;try{r=await e(c)}catch(m){throw m.status===400&&i(m.message,2),m}h(r.dropped?{...r.notifications,dropped:r.dropped}:r.notifications)},async"codex-share"(){(S.length>1||S.length&&s._.length!==1)&&i("codex-share takes auto, always, or off",2);const{readCodexShare:t,setCodexShare:n}=await import(u.join(j,"hub","push.mjs"));if(!S.length)return h(t());try{h(n(S[0]))}catch(o){throw o.status===400&&i(o.message,2),o.status===409&&i(o.message),o}},async"usage-gate"(){const{readUsageGate:e,setPauseAt:t,setKeepGoing:n}=await import("../shared/usage-gate.mjs");try{S.length&&(S[0]==="pause-at"&&S.length===2?t(S[1]):S[0]==="keep-going"&&S.length===1?n("on"):S[0]==="stop"&&S.length===1?n("off"):i("usage-gate takes pause-at <50..100>, keep-going, or stop",2)),h(e())}catch(o){throw o.status&&i(o.message,o.status===400?2:1),o}},async"poll-now"(){let e;try{e=await fetch(`http://127.0.0.1:${T}/_hub/poll`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({flush:!s["keep-queue"]}),signal:AbortSignal.timeout(10*6e4)})}catch(t){i("the hub is not running: "+t.message)}e.ok||i(`the hub answered ${e.status}: ${(await e.text()).slice(0,200)}`),h(await e.json())}};if(!ne[G]){const e=w.readFileSync(xe(import.meta.url),"utf8").split(`
187
- `).slice(1);h(e.slice(0,e.findIndex(t=>!t.startsWith("//"))).map(t=>t.replace(/^\/\/ ?/,"")).join(`
188
- `)),process.exit(G?2:0)}try{await ne[G]()}catch(e){if(!(e instanceof I))throw e;e.message&&console.error("kickflip: "+e.message),process.exit(e.code)}
194
+ `))},async init(){s.fix=!0,await se.doctor()},async update(){w.existsSync(d.join(j,".git"))&&i(`${j} is a clone; update it with git pull`,2);const e=h(()=>JSON.parse(w.readFileSync(d.join(j,"package.json"),"utf8")).version,"installedVersion"),t=e(),n=K("npm",["i","-g","@lovek4d/kickflip@latest"],{stdio:"inherit"});n.status!==0&&i(`npm update failed${n.error?": "+n.error.message:""}`);const o=e();if(o===t)return l("no new version available");l(`${t} > ${o}`),!s["no-restart"]&&$e("update finished but the hub did not restart")},async uninstall(){Re()&&l(`removed ${d.join(A.homedir(),".claude","statusline.sh")}`);const e=de();e.remove();const t=ue();if(t)try{re(t,["serve","--https=443","--set-path=/","off"],{stdio:"ignore"})}catch{}l(`hub stopped and removed from ${e.manager??"the host"}${t?"; tailscale / unmapped":""}. The manifest and sites are kept in ${_}`)},async push(){const{deliver:e,pushKindError:t}=await import(d.join(j,"hub","push.mjs")),n=t(s.kind);n&&i(n,2),s.title||i("push needs --title",2),s.url&&!s.url.startsWith("/")&&i("--url is a path on the hub, such as /<route>/page.html",2);const{sendWords:o,holdLine:f}=await import(d.join(j,"hub","schedule.mjs"));s.now&&s.urgent&&i("pass --now or --urgent, not both",2);const c=await e({title:s.title,body:s.body||"",url:s.url||"/",tag:s.tag},{kind:s.kind,urgent:!!s.urgent,immediate:!!s.now});if(c.noDevice&&i("no device has turned on notifications; open Hub \u2192 Notifications (/_hub/#notifications) in the Home Screen app and tap Turn on",3),c.dropped)return l("dropped (notifications are off on the Hub page)");if(c.queued){const{count:r,replaced:m,nextFlushAt:u,hold:a,sendFailed:b}=c.queued,p=s.now?a?`${f(a)}; `:b?"no device took it now; ":"":"";l(`queued (${p}${r} in queue${m?"; replaced the queued push with this tag":""}); next send ${o(u)}${u instanceof Date?` (${u.toISOString()})`:""}`),await J()||l("warning: the hub is not running, so the queue goes out only once it runs");return}l(c.sent),c.sent.some(r=>r.status>=200&&r.status<300)||i("every push was refused",1),l(s.urgent?"sent (urgent)":"sent")},async quiet(){const{readSettings:e}=await import(d.join(j,"hub","push.mjs")),{hold:t}=await import(d.join(j,"hub","schedule.mjs")),n=e(),o=t(new Date,n);l({quiet:n.quiet,held:o!==null,held_until:o?o.until instanceof Date?o.until.toISOString():o.until:null,held_why:o?.why??null,notifications:n.notifications})},async notifications(){const{setNotifications:e,readSettings:t}=await import(d.join(j,"hub","push.mjs")),[n,o]=s._,c=n===void 0?null:n==="on"||n==="off"?{on:n==="on"}:n==="weekends"?{weekends_off:h((m,u)=>m==="on"?!0:m==="off"?!1:i(`${u} takes on or off`,2),"onOff")(o,"weekends")}:n==="snooze"?{snooze:o||i("snooze takes a length: <n>m, <n>h, <n>d, <n>w, tonight, morning, monday, forever or a date and time",2)}:n==="unsnooze"?{snooze:"off"}:i("notifications takes on, off, snooze <length>, unsnooze, or weekends on|off",2);if(!c)return l(t().notifications);let r;try{r=await e(c)}catch(m){throw m.status===400&&i(m.message,2),m}l(r.dropped?{...r.notifications,dropped:r.dropped}:r.notifications)},async"codex-share"(){(S.length>1||S.length&&s._.length!==1)&&i("codex-share takes auto, always, or off",2);const{readCodexShare:t,setCodexShare:n}=await import(d.join(j,"hub","push.mjs"));if(!S.length)return l(t());try{l(n(S[0]))}catch(o){throw o.status===400&&i(o.message,2),o.status===409&&i(o.message),o}},async"usage-gate"(){const{readUsageGate:e,setPauseAt:t,setKeepGoing:n}=await import("../shared/usage-gate.mjs");try{S.length&&(S[0]==="pause-at"&&S.length===2?t(S[1]):S[0]==="keep-going"&&S.length===1?n("on"):S[0]==="stop"&&S.length===1?n("off"):i("usage-gate takes pause-at <50..100>, keep-going, or stop",2)),l(e())}catch(o){throw o.status&&i(o.message,o.status===400?2:1),o}},async audit(){S.length&&i("audit takes no arguments",2);const{readAudit:e,auditLines:t,hubAudit:n}=await import("../shared/audit.mjs");l(t(e(n())).join(`
195
+ `))},async"poll-now"(){let e;try{e=await fetch(`http://127.0.0.1:${T}/_hub/poll`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({flush:!s["keep-queue"]}),signal:AbortSignal.timeout(10*6e4)})}catch(t){i("the hub is not running: "+t.message)}e.ok||i(`the hub answered ${e.status}: ${(await e.text()).slice(0,200)}`),l(await e.json())}};if(!se[W]){const e=w.readFileSync(Te(import.meta.url),"utf8").split(`
196
+ `).slice(1);l(e.slice(0,e.findIndex(t=>!t.startsWith("//"))).map(t=>t.replace(/^\/\/ ?/,"")).join(`
197
+ `)),process.exit(W?2:0)}try{await se[W]()}catch(e){if(!(e instanceof F))throw e;e.message&&console.error("kickflip: "+e.message),process.exit(e.code)}
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- var P=Object.defineProperty;var t=(e,i)=>P(e,"name",{value:i,configurable:!0});import c from"node:fs";import f from"node:path";import L from"node:os";import{routeRecord as m}from"../hub/routes.mjs";import{readHubRules as q,rulesOf as D,ruleLines as G}from"../hub/rules.mjs";import{boardAt as H,linksOf as U,hubLink as W,stopPreviousWatcher as _}from"../hub/boards.mjs";import{parentOf as J,sessionGone as K}from"../hub/live.mjs";import{ackHello as Q,cardName as B,markWorking as C,settingsLines as M,settingsOf as z,WORKING_KINDS as V}from"../board/store.mjs";import{readUsageGate as E,heldUsageEvent as X}from"../shared/usage-gate.mjs";const[O,g]=process.argv.slice(2);(!O||!g)&&(console.error("usage: wait-events.mjs <route> <session-id>"),process.exit(2));const Y=f.join(L.homedir(),".claude","kickflip","manifest.json");function A(){try{return m(JSON.parse(c.readFileSync(Y,"utf8")).sites,O)||null}catch{return}}t(A,"site");function v(e){const i=e.filter?new RegExp(e.filter):null,n=Object.create(null);for(const l of e.watch||["data/events.jsonl"]){let o=[];try{o=c.readFileSync(f.resolve(e.dir,l),"utf8").split(`
3
- `).filter(Boolean)}catch{}n[l]=i?o.filter(r=>i.test(r)):o}return n}t(v,"matching");const Z=`quiet The owner doesn't read this session's terminal. Answer a prompt typed in it in full. A wake, a task notification or a builder's result is not one: write no text of your own, since board.mjs and this watcher print one line for each thing done. Talk and questions go on the board, never AskUserQuestion; only a failure the board can't show, such as the hub being down, gets one line here. Each message to the owner stands alone in its card's thread: name cards by label (#96), and a pointer such as "above" or "here" must make sense in that thread, never point into this session.`,I={ask:t(e=>`new task ${e.id} received`,"ask"),"owner-answer":t(e=>`answer received to question ${e.data?.id}`,"owner-answer"),hello:t(e=>`hello ${e.id} from the hub answered`,"hello"),"overnight-triage":t(e=>`Overnight triage ${e.data?.date} asked: ${e.data?.cards?.length} cards wait on your answers`,"overnight-triage"),"pr-review":t(e=>`review asked on PR #${e.data?.pr}: the board was idle`,"pr-review"),"review-answer":t(e=>`${e.data?.answer==="skip"?"skip":"post"} answered on the review of PR #${e.data?.pr}`,"review-answer")},R={"item-message":t(e=>`message received from ${e}'s thread`,"item-message"),"card-build":t(e=>`build started on ${e}`,"card-build"),"card-merge":t(e=>`merge asked on ${e}`,"card-merge"),"card-done":t(e=>`${e} marked done`,"card-done"),"card-bump":t(e=>`${e} bumped`,"card-bump"),"ask-bump":t(e=>`${e} bumped`,"ask-bump"),"ask-edited":t(e=>`${e} edited`,"ask-edited"),"ask-done":t(e=>`${e} marked done`,"ask-done"),"ask-withdrawn":t(e=>`${e} deleted`,"ask-withdrawn"),"card-hold":t((e,i)=>`${e} ${i.data?.hold?"held":"resumed"}`,"card-hold"),"card-unblocked":t(e=>`${e} unblocked`,"card-unblocked"),"card-pull":t(e=>`${e} pulled in`,"card-pull"),"card-plan":t(e=>`plan asked on ${e}`,"card-plan"),"prototype-request":t(e=>`prototype asked on ${e}`,"prototype-request"),"builder-ask":t((e,i)=>`${i.data?.ask} from ${e}'s builder: ${i.data?.text}`,"builder-ask")};async function ee(e,i){let n;try{n=JSON.parse(i)}catch{return null}if(typeof n?.kind!="string")return null;const l=typeof n.data?.key=="string"?n.data.key:n.kind.startsWith("ask-")&&typeof n.data?.id=="string"?`ask:${n.data.id}`:null,o=l&&await B(e,l).catch(()=>l);return m(I,n.kind)?I[n.kind](n):o&&m(R,n.kind)?R[n.kind](o,n):o?`${n.kind} on ${o}`:`${n.kind} received`}t(ee,"plainLine");const k=A();k||(console.log("site-removed"),process.exit(0));const w=f.resolve(k.dir,"data",`.watch-cursor-${g}.json`),S=f.resolve(k.dir,"data","watcher.json"),j=[process.ppid,J(process.ppid)],T=t(()=>{j[1]??=J(j[0]),K(g,j)&&process.exit(0)},"orphaned");_(k.dir,O,g);try{c.mkdirSync(f.dirname(w),{recursive:!0}),c.writeFileSync(S,JSON.stringify({pid:process.pid,since:new Date().toISOString()}))}catch{}process.on("exit",()=>{try{const e=JSON.parse(c.readFileSync(S,"utf8"));e.pid===process.pid&&c.writeFileSync(S,JSON.stringify({...e,exited_at:new Date().toISOString()}))}catch{}});let p;try{p=JSON.parse(c.readFileSync(w,"utf8"))}catch{}for(p||(p=Object.fromEntries(Object.entries(v(k)).map(([e,i])=>[e,i.length])),c.mkdirSync(f.dirname(w),{recursive:!0}),c.writeFileSync(w,JSON.stringify(p)));;){await new Promise(o=>setTimeout(o,2e3));const e=A();if(e===void 0)continue;e===null&&(console.log("site-removed"),process.exit(0)),e.owner?.session&&e.owner.session!==g&&(console.log("handed-over "+JSON.stringify(e.owner)),process.exit(0)),T();const i=t(o=>Object.fromEntries(Object.entries(o).map(([r,$])=>[r,[...m(p.$usageHeld,r)||[],...$.slice(m(p,r)||0)]])),"pending"),n=t((o,r)=>r.state!=="paused"||!X(o),"eligible"),l=E();if(Object.values(i(v(e))).some(o=>o.some(r=>n(r,l)))){await new Promise(s=>setTimeout(s,2e3)),T();const o=v(e),r=E(),$=i(o);if(!Object.values($).some(s=>s.some(a=>n(a,r))))continue;const b=Object.create(null),N=[],x=[];for(const[s,a]of Object.entries($)){b[s]=a.filter(h=>!n(h,r));for(const h of a.filter(y=>n(y,r))){console.log(`event ${s} ${h}`);const y=await ee(e.dir,h);y&&console.log(y);try{const u=JSON.parse(h);V.includes(u.kind)&&typeof u.id=="string"&&N.push(u.id),u.kind==="hello"&&typeof u.id=="string"&&x.push(u.id)}catch{}}}let d=null;try{d=JSON.parse(c.readFileSync(f.resolve(e.dir,"board.json"),"utf8"))}catch(s){s.code!=="ENOENT"&&(d={})}if(d&&await C(e.dir,N).catch(s=>console.log(`working marks not written (${s.message})`)),d)for(const s of x)await Q(e.dir,s).catch(a=>console.log(`hello ${s} not answered (${a.message})`));if(d)for(const s of G(q(),D(d)))console.log(`rule ${s}`);if(d)for(const s of M(z(d)))console.log(s);d&&console.log(Z),r.state==="paused"&&console.log(`usage paused ${r.resets_at} ${r.window} ${r.used}%`);try{const s=d&&H(e.dir);if(s)for(const a of[...U(s,{listen:"listening"}),W()])a.ok||console.log(`link ${a.link} ${a.detail}`)}catch(s){console.log(`link registry could not check the links (${s.message})`)}const F=Object.fromEntries(Object.entries(o).map(([s,a])=>[s,a.length]));Object.values(b).some(s=>s.length)&&(F.$usageHeld=b),c.writeFileSync(w,JSON.stringify(F)),process.exit(0)}}
2
+ var U=Object.defineProperty;var t=(e,i)=>U(e,"name",{value:i,configurable:!0});import l from"node:fs";import h from"node:path";import W from"node:os";import{routeRecord as g}from"../hub/routes.mjs";import{readHubRules as C,rulesOf as K,ruleLines as Q}from"../hub/rules.mjs";import{boardAt as B,linksOf as M,hubLink as Y,stopPreviousWatcher as z}from"../hub/boards.mjs";import{parentOf as R,sessionGone as V}from"../hub/live.mjs";import{ackPing as X,cardName as Z,markWorking as ee,settingsLines as te,settingsOf as ne,WORKING_KINDS as se}from"../board/store.mjs";import{readUsageGate as I,heldUsageEvent as ie}from"../shared/usage-gate.mjs";const[v,$]=process.argv.slice(2);(!v||!$)&&(console.error("usage: wait-events.mjs <route> <session-id>"),process.exit(2));const re=h.join(W.homedir(),".claude","kickflip","manifest.json");function P(){try{return g(JSON.parse(l.readFileSync(re,"utf8")).sites,v)||null}catch{return}}t(P,"site");function S(e){const i=e.filter?new RegExp(e.filter):null,s=Object.create(null);for(const c of e.watch||["data/events.jsonl"]){let u=[];try{u=l.readFileSync(h.resolve(e.dir,c),"utf8").split(`
3
+ `).filter(Boolean)}catch{}s[c]=i?u.filter(m=>i.test(m)):u}return s}t(S,"matching");const oe=`quiet The owner doesn't read this session's terminal. Answer a prompt typed in it in full. A wake, a task notification or a builder's result is not one: write no text of your own, since board.mjs and this watcher print one line for each thing done. Talk and questions go on the board, never AskUserQuestion; only a failure the board can't show, such as the hub being down, gets one line here. Each message to the owner stands alone in its card's thread: name cards by label (#96), and a pointer such as "above" or "here" must make sense in that thread, never point into this session.`,ae=["main-pulled","settings","ask-done","reviewers-removed","reviewers-added","repo-changed","ping","hello"],ce=t(e=>{try{return JSON.parse(e).kind}catch{return null}},"kindOf"),j=t(e=>ae.includes(ce(e)),"recordOnly");function A(e){try{return JSON.parse(l.readFileSync(h.resolve(e.dir,"board.json"),"utf8"))}catch(i){return i.code==="ENOENT"?null:{}}}t(A,"boardOf");async function L(e,i){for(const s of i){let c;try{c=JSON.parse(s)}catch{continue}(c.kind==="ping"||c.kind==="hello")&&typeof c.id=="string"&&await X(e,c.id).catch(u=>console.log(`ping ${c.id} not answered (${u.message})`))}}t(L,"ackPings");const T={ask:t(e=>`new task ${e.id} received`,"ask"),"owner-answer":t(e=>`answer received to question ${e.data?.id}`,"owner-answer"),ping:t(e=>`ping ${e.id} from the hub answered`,"ping"),hello:t(e=>`ping ${e.id} from the hub answered`,"hello"),"overnight-triage":t(e=>`Overnight triage ${e.data?.date} asked: ${e.data?.cards?.length} cards wait on your answers`,"overnight-triage"),"pr-review":t(e=>`review asked on PR #${e.data?.pr}: the board was idle`,"pr-review"),"orchestrator-waits":t(e=>`waiting on you now: ${(e.data?.added||[]).join(", ")}`,"orchestrator-waits"),"review-answer":t(e=>`${e.data?.answer==="skip"?"skip":"post"} answered on the review of PR #${e.data?.pr}`,"review-answer")},D={"item-message":t((e,i)=>i.data?.intent==="build"?`Implement asked on ${e}: fold the message into its notes, then item set --state ready starts the build`:`message received from ${e}'s thread`,"item-message"),"card-merge":t(e=>`merge asked on ${e}`,"card-merge"),"card-done":t(e=>`${e} marked done`,"card-done"),"card-bump":t(e=>`${e} bumped`,"card-bump"),"ask-bump":t(e=>`${e} bumped`,"ask-bump"),"ask-edited":t(e=>`${e} edited`,"ask-edited"),"ask-done":t(e=>`${e} marked done`,"ask-done"),"ask-withdrawn":t(e=>`${e} deleted`,"ask-withdrawn"),"card-hold":t((e,i)=>`${e} ${i.data?.hold?"held":"resumed"}`,"card-hold"),"card-unblocked":t(e=>`${e} unblocked`,"card-unblocked"),"prototype-request":t(e=>`prototype asked on ${e}`,"prototype-request"),"reask-request":t(e=>`question asked again on ${e}`,"reask-request"),"builder-ask":t((e,i)=>`${i.data?.ask} from ${e}'s builder: ${i.data?.text}`,"builder-ask"),"owner-check-broken":t((e,i)=>`${e}'s owner-step check can't run: ${i.data?.error}`,"owner-check-broken"),"card-added":t(e=>`${e} added by an agent: link it and file it`,"card-added")};async function de(e,i){let s;try{s=JSON.parse(i)}catch{return null}if(typeof s?.kind!="string")return null;const c=typeof s.data?.key=="string"?s.data.key:s.kind.startsWith("ask-")&&typeof s.data?.id=="string"?`ask:${s.data.id}`:null,u=c&&await Z(e,c).catch(()=>c);return g(T,s.kind)?T[s.kind](s):u&&g(D,s.kind)?D[s.kind](u,s):u?`${s.kind} on ${u}`:`${s.kind} received`}t(de,"plainLine");function le(e){let i=[];try{i=JSON.parse(l.readFileSync(h.resolve(e,"data","orchestrator-waits.json"),"utf8")).waits||[]}catch{}return i.map(s=>`waits ${s.kind} ${s.ref} ${s.key} since ${s.since}${s.picked?`, picked up ${s.picked}`:""}: ${s.next}`)}t(le,"waitLines");const b=P();b||(console.log("site-removed"),process.exit(0));const w=h.resolve(b.dir,"data",`.watch-cursor-${$}.json`),N=h.resolve(b.dir,"data","watcher.json"),E=[process.ppid,R(process.ppid)],_=t(()=>{E[1]??=R(E[0]),V($,E)&&process.exit(0)},"orphaned");z(b.dir,v,$);try{l.mkdirSync(h.dirname(w),{recursive:!0}),l.writeFileSync(N,JSON.stringify({pid:process.pid,since:new Date().toISOString()}))}catch{}process.on("exit",()=>{try{const e=JSON.parse(l.readFileSync(N,"utf8"));e.pid===process.pid&&l.writeFileSync(N,JSON.stringify({...e,exited_at:new Date().toISOString()}))}catch{}});let d;try{d=JSON.parse(l.readFileSync(w,"utf8"))}catch{}for(d||(d=Object.fromEntries(Object.entries(S(b)).map(([e,i])=>[e,i.length])),l.mkdirSync(h.dirname(w),{recursive:!0}),l.writeFileSync(w,JSON.stringify(d)));;){await new Promise(n=>setTimeout(n,2e3));const e=P();if(e===void 0)continue;e===null&&(console.log("site-removed"),process.exit(0)),e.owner?.session&&e.owner.session!==$&&(console.log("handed-over "+JSON.stringify(e.owner)),process.exit(0)),_();const i=t(n=>Object.fromEntries(Object.entries(n).map(([o,r])=>[o,[...g(d.$quietHeld,o)||[],...g(d.$usageHeld,o)||[],...r.slice(g(d,o)||0)]])),"pending"),s=t((n,o)=>o.state!=="paused"||!ie(n),"eligible"),c=t((n,o)=>Object.values(n).some(r=>r.some(a=>s(a,o)&&!j(a))),"wakes"),u=I(),m=S(e);if(!c(i(m),u)){const n=Object.fromEntries(Object.entries(m).map(([r,a])=>[r,a.slice(g(d,r)||0).filter(j)])),o=Object.fromEntries(Object.entries(m).map(([r,a])=>[r,a.slice(g(d,r)||0).filter(p=>!j(p))]));if(Object.values(n).some(r=>r.length)){A(e)&&await L(e.dir,Object.values(n).flat());const r=t((a,p)=>Object.fromEntries(Object.keys(m).map(O=>[O,[...g(a,O)||[],...p[O]]])),"append");d={...Object.fromEntries(Object.entries(m).map(([a,p])=>[a,p.length])),$quietHeld:r(d.$quietHeld,n),$usageHeld:r(d.$usageHeld,o)},Object.values(d.$usageHeld).some(a=>a.length)||delete d.$usageHeld,l.writeFileSync(w,JSON.stringify(d))}continue}await new Promise(n=>setTimeout(n,2e3)),_();const J=S(e),k=I(),F=i(J);if(!c(F,k))continue;const y=Object.create(null),x=[],G=new Set(Object.values(d.$quietHeld||{}).flat()),H=[];for(const[n,o]of Object.entries(F)){y[n]=o.filter(r=>!s(r,k));for(const r of o.filter(a=>s(a,k))){G.has(r)||H.push(r),console.log(`event ${n} ${r}`);const a=await de(e.dir,r);a&&console.log(a);try{const p=JSON.parse(r);se.includes(p.kind)&&typeof p.id=="string"&&x.push(p.id)}catch{}}}const f=A(e);if(f&&await ee(e.dir,x).catch(n=>console.log(`working marks not written (${n.message})`)),f&&await L(e.dir,H),f)for(const n of le(e.dir))console.log(n);if(f)for(const n of Q(C(),K(f)))console.log(`rule ${n}`);if(f)for(const n of te(ne(f)))console.log(n);f&&console.log(oe),k.state==="paused"&&console.log(`usage paused ${k.resets_at} ${k.window} ${k.used}%`);try{const n=f&&B(e.dir);if(n)for(const o of[...M(n,{listen:"listening"}),Y()])o.ok||console.log(`link ${o.link} ${o.detail}`)}catch(n){console.log(`link registry could not check the links (${n.message})`)}const q=Object.fromEntries(Object.entries(J).map(([n,o])=>[n,o.length]));Object.values(y).some(n=>n.length)&&(q.$usageHeld=y),l.writeFileSync(w,JSON.stringify(q)),process.exit(0)}
@@ -0,0 +1,2 @@
1
+ var g=Object.defineProperty;var t=(e,n)=>g(e,"name",{value:n,configurable:!0});import a from"node:fs";import y from"node:path";import{spawn as v,execFileSync as h}from"node:child_process";import{claudeBin as w,inherited as _}from"../hub/sessions.mjs";function I(e){try{return y.resolve(e,h("git",["-C",e,"rev-parse","--git-common-dir"],{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim())}catch{return null}}t(I,"gitCommonDir");const A={claude:{bin:w,args:t(({prompt:e,model:n="opus"})=>["-p",e,"--permission-mode","auto","--model",n,"--output-format","stream-json","--verbose"],"args"),session:t(e=>{try{const n=JSON.parse(e);return n.type==="system"&&n.subtype==="init"?n.session_id??null:null}catch{return null}},"session")},codex:{bin:t(()=>process.env.KICKFLIP_CODEX||"codex","bin"),args:t(({prompt:e,cwd:n,site:c})=>["exec","--color","never","--cd",n,"--sandbox","workspace-write",...[c,I(n)].filter(Boolean).flatMap(u=>["--add-dir",u]),"-c","sandbox_workspace_write.network_access=true",e],"args"),session:t(e=>e.match(/^session id: (\S+)/)?.[1]??null,"session")}},O=15e3;function T(e,n,c,u){return new Promise(x=>{let o=!1;const l=t(i=>{o||(o=!0,clearInterval(f),clearTimeout(r),e.off("exit",p),x(i))},"finish"),s=t(()=>{const i=a.readFileSync(n,"utf8").slice(c);for(const m of i.split(`
2
+ `)){const S=u(m);if(S)return l(S)}},"scan"),f=setInterval(s,100),r=setTimeout(()=>l(null),O),p=t(()=>{s(),l(null)},"exited");e.once("exit",p)})}t(T,"sessionOf");function F({kind:e,model:n,cwd:c,site:u,prompt:x,log:o,bin:l}){const s=A[e];if(!s)throw new Error(`no agent of kind ${e}`);const f=l??s.bin();a.mkdirSync(y.dirname(o),{recursive:!0});const r=a.openSync(o,"a"),p=a.fstatSync(r).size,i=Object.fromEntries(Object.entries(process.env).filter(([m])=>!_(m)));return y.isAbsolute(f)&&(i.PATH=`${y.dirname(f)}:${i.PATH||""}`),new Promise((m,S)=>{const d=v(f,s.args({prompt:x,model:n,cwd:c,site:u}),{cwd:c,env:i,detached:!0,stdio:["ignore",r,r]});d.once("spawn",async()=>{a.closeSync(r);const b=await T(d,o,p,s.session);d.unref(),m({pid:d.pid,sessionId:b,log:o})}),d.once("error",b=>{a.closeSync(r),S(b)})})}t(F,"launchAgent");export{F as launchAgent};