@lovek4d/kickflip 0.3.1 → 0.5.0

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 (111) hide show
  1. package/CONTEXT.md +170 -118
  2. package/README.md +23 -14
  3. package/SKILL.md +40 -38
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +34 -26
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/arch-review.mjs +1 -1
  9. package/board/auto-review.mjs +2 -0
  10. package/board/bin/board.mjs +213 -74
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +4 -0
  13. package/board/headless.mjs +4 -0
  14. package/board/merge3.mjs +1 -0
  15. package/board/on-host.mjs +1 -1
  16. package/board/permissions.mjs +2 -0
  17. package/board/public/app-arch.js +2 -2
  18. package/board/public/app-asks.js +39 -103
  19. package/board/public/app-banner.js +2 -2
  20. package/board/public/app-cards.js +166 -73
  21. package/board/public/app-core.js +2 -23
  22. package/board/public/app-header.js +12 -5
  23. package/board/public/app-lanes.js +201 -47
  24. package/board/public/app-needs.js +2 -1
  25. package/board/public/app-reader.js +174 -0
  26. package/board/public/app-snapshot.js +2 -2
  27. package/board/public/app-threads.js +53 -42
  28. package/board/public/app.html +4 -1
  29. package/board/public/app.js +78 -96
  30. package/board/public/attach.js +39 -3
  31. package/board/public/dialogs.js +59 -14
  32. package/board/public/hub-kit.js +9 -6
  33. package/board/public/hub.css +6 -18
  34. package/board/public/linear-key.js +6 -4
  35. package/board/public/log.html +48 -0
  36. package/board/public/new-task.js +121 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +8 -7
  42. package/board/public/rules-editor.js +3 -4
  43. package/board/public/shell.js +24 -6
  44. package/board/public/sidebar.css +22 -1
  45. package/board/public/sidebar.js +64 -31
  46. package/board/public/style.css +245 -64
  47. package/board/public/toasts.js +2 -2
  48. package/board/public/ui-cards.js +205 -75
  49. package/board/public/ui-enums.js +6 -5
  50. package/board/public/ui-header.js +10 -10
  51. package/board/public/ui-hub.js +4 -4
  52. package/board/public/ui-markdown.js +1 -1
  53. package/board/public/ui-order.js +9 -6
  54. package/board/public/ui-page-header.js +10 -7
  55. package/board/public/ui-patch.js +53 -0
  56. package/board/public/ui-phase-bars.js +17 -0
  57. package/board/public/ui-prompts.js +8 -8
  58. package/board/public/ui-questions.js +61 -29
  59. package/board/public/ui-sidebar.js +54 -24
  60. package/board/public/ui-snapshot.js +20 -9
  61. package/board/public/ui-threads.js +40 -21
  62. package/board/public/ui.js +116 -18
  63. package/board/public/view-frame.js +2 -1
  64. package/board/public/view-hub-settings.js +25 -18
  65. package/board/public/view-hub.js +53 -12
  66. package/board/public/view-notes.js +1 -1
  67. package/board/public/view-questions.js +142 -62
  68. package/board/public/view-settings.js +189 -52
  69. package/board/review.mjs +1 -1
  70. package/board/server.mjs +20 -12
  71. package/board/store.mjs +15 -12
  72. package/board/tickets.mjs +3 -3
  73. package/board/worktree.mjs +21 -15
  74. package/docs/hub-session.md +4 -4
  75. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  76. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/close-board.mjs +1 -1
  80. package/hub/front-door.mjs +20 -0
  81. package/hub/github.mjs +4 -4
  82. package/hub/home.mjs +1 -1
  83. package/hub/hub.mjs +11 -14
  84. package/hub/incoming.mjs +2 -0
  85. package/hub/linear.mjs +2 -2
  86. package/hub/live.mjs +2 -2
  87. package/hub/new-board.mjs +2 -2
  88. package/hub/owner-checks.mjs +1 -0
  89. package/hub/permission-denied-hook.mjs +2 -0
  90. package/hub/pr-report.mjs +3 -1
  91. package/hub/presence.mjs +1 -1
  92. package/hub/prompts.mjs +1 -1
  93. package/hub/push.mjs +2 -2
  94. package/hub/readiness.mjs +2 -2
  95. package/hub/repos.mjs +3 -3
  96. package/hub/restart-board.mjs +1 -1
  97. package/hub/schedule.mjs +1 -1
  98. package/hub/sessions.mjs +2 -2
  99. package/hub/sw.js +1 -1
  100. package/hub/try.mjs +8 -4
  101. package/hub/usage.mjs +3 -3
  102. package/hub/views.mjs +1 -1
  103. package/package.json +5 -1
  104. package/shared/card-prs.mjs +1 -0
  105. package/shared/colors.css +5 -4
  106. package/shared/fake-gh.mjs +59 -8
  107. package/shared/owner-check.mjs +3 -0
  108. package/shared/repos.mjs +1 -1
  109. package/shared/tailscale.mjs +1 -0
  110. package/shared/usage-gate.mjs +1 -1
  111. package/board/public/view-polling.js +0 -87
package/hub/readiness.mjs CHANGED
@@ -1,2 +1,2 @@
1
- var T=Object.defineProperty;var s=(t,r)=>T(t,"name",{value:r,configurable:!0});import f from"node:fs";import m from"node:os";import y from"node:path";import{execFile as N}from"node:child_process";import{githubLogin as P}from"./pr-report.mjs";import{codexStatusReader as w}from"../shared/codex.mjs";import{linearStatus as R}from"./linear.mjs";import{readSettings as L,subscriptions as O}from"./push.mjs";import{tailscaleState as C,tailscaleInstallHint as I}from"../bin/install.mjs";const x=[["github","GitHub","GitHub polling and PR reports wait on this."],["codex","Codex","Codex implementing and reviewing wait on this; Codex share stays Off."],["linear","Linear","Linear moves and picking Linear for a board\u2019s tickets wait on this."],["slack","Slack","Reviewer DMs and digests wait on this."],["tailscale","Tailscale","Try builds wait on this."],["notifications","Notifications","Pushes wait on this."]],k=s(t=>y.join(t,".claude","kickflip","integrations.json"),"reportFile");function F(t=m.homedir()){try{return JSON.parse(f.readFileSync(k(t),"utf8")).slack||null}catch{return null}}s(F,"readSlackReport");function Q(t,{home:r=m.homedir(),now:i=new Date,route:l=null,title:o="Board"}={}){const e=k(r),n={ready:t,at:i.toISOString(),route:l,title:o};f.mkdirSync(y.dirname(e),{recursive:!0});const a=`${e}.${process.pid}.tmp`;return f.writeFileSync(a,JSON.stringify({slack:n},null,2)+`
2
- `,{mode:384}),f.renameSync(a,e),n}s(Q,"reportSlack");const H=s((t,r)=>{const i=Math.floor((r-Date.parse(t))/6e4);return i<1?"just now":i<60?`${i} min ago`:i<1440?`${Math.floor(i/60)} h ago`:`${Math.floor(i/1440)} days ago`},"age"),u=s((t,r,i)=>({settings:t,label:`Hub settings \u203A ${r}`,text:i}),"settingsFix");function b(t,{now:r=Date.now(),platform:i=process.platform}={}){const l=s((o,e)=>{switch(o){case"github":return e?{ready:!0,detail:`gh logged in as ${e}`}:{ready:!1,missing:"gh isn't logged in on the host",fix:{command:"gh auth login"}};case"codex":return e.usable?{ready:!0,detail:"Installed and logged in"}:{ready:!1,missing:e.words,fix:{command:e.fix}};case"linear":return e.set?e.refused?{ready:!1,missing:"Linear refused the key",fix:u("linear","Linear","Paste a new one in")}:{ready:!0,detail:`Key set \xB7 ${e.workspace} workspace`}:{ready:!1,missing:"No Linear key set",fix:u("linear","Linear","Add one in")};case"slack":{const n=e?`${e.title}\u2019s session reported ${e.ready?"a":"no"} Slack connector, ${H(e.at,r)}`:"No board session has reported yet";return e?.ready?{ready:!0,detail:n}:{ready:!1,missing:n,fix:{text:"Add Slack to Claude\u2019s connectors. The next board session to start reports it."}}}case"tailscale":{let n,a;return e.installed?e.signedIn?e.pointsAtHub?e.operatorChecked&&e.operator!==e.username&&(n=`Tailscale operator is ${e.operator||"none"} (expected ${e.username})`,a="sudo tailscale set --operator=$USER"):(n="Tailscale / is not serving the hub",a="kickflip doctor --fix"):(n="Tailscale isn't signed in",a="tailscale up"):(n="Tailscale isn't installed on the host",a=I(i)),n?{ready:!1,missing:n,fix:{command:a}}:{ready:!0,detail:`Serving ${e.dns}${e.operatorChecked?" \xB7 operator "+e.operator:""}`}}case"notifications":return e.on&&e.devices?{ready:!0,detail:`Pushes on \xB7 ${e.devices} device${e.devices===1?"":"s"}`}:{ready:!1,missing:e.on?"No device has turned on notifications":"Pushes are off",fix:u("notifications","Notifications","Turn on notifications in")}}},"row");return x.filter(([o])=>Object.hasOwn(t,o)).map(([o,e,n])=>({id:o,name:e,...l(o,t[o]),waits:n}))}s(b,"readinessRows");const j=s(t=>(r,i)=>new Promise(l=>{N(r,i,{env:t,timeout:3e3},(o,e)=>l({status:o?1:0,stdout:e}))}),"asyncRun");function D({home:t=m.homedir(),env:r=process.env,now:i=Date.now,cacheMs:l=6e4,probes:o={}}={}){const e=w({home:t,env:r,now:i,cacheMs:l}),n={github:s(()=>P({env:r}).catch(()=>null),"github"),codex:e,linear:s(()=>R(t),"linear"),slack:s(()=>F(t),"slack"),tailscale:s(()=>C({run:j(r),port:Number(r.KICKFLIP_HUB_PORT||4500)}),"tailscale"),notifications:s(()=>({on:L(t).notifications.on,devices:O(t).length}),"notifications"),...o},a=new Map;async function g(c,d){if(!["github","tailscale"].includes(c))return n[c]({fresh:d});const p=a.get(c);if(!d&&p&&i()-p.at<l)return p.value;const h=Promise.resolve().then(()=>n[c]());return a.set(c,{at:i(),value:h}),h}s(g,"check");const $=s(async({fresh:c=!1}={})=>b(Object.fromEntries(await Promise.all(x.map(async([d])=>[d,await g(d,c)]))),{now:i()}),"rows");async function S(c){const[d]=b({[c]:await g(c,!1)},{now:i()});return d.ready?null:d.waits.replace("this",`${d.name}: ${d.missing.replace(/\.$/,"")}`)}return s(S,"waiting"),{rows:$,waiting:S}}s(D,"readinessReader");const A=w(),V=D({probes:{codex:A}});export{A as readCodex,F as readSlackReport,V as readiness,D as readinessReader,b as readinessRows,Q as reportSlack};
1
+ var T=Object.defineProperty;var i=(t,n)=>T(t,"name",{value:n,configurable:!0});import f from"node:fs";import m from"node:os";import y from"node:path";import{execFile as N}from"node:child_process";import{githubLogin as P}from"./pr-report.mjs";import{codexStatusReader as w}from"../shared/codex.mjs";import{linearStatus as R}from"./linear.mjs";import{readSettings as C,subscriptions as O}from"./push.mjs";import{tailscaleState as I}from"../bin/install.mjs";import{tailscaleBin as L,tailscaleInstallCommand as F}from"../shared/tailscale.mjs";const x=[["github","GitHub","GitHub polling and PR reports wait on this."],["codex","Codex","Codex implementing and reviewing wait on this; Codex share stays Off."],["linear","Linear",null],["slack","Slack","Reviewer DMs and digests wait on this."],["tailscale","Tailscale","Try builds wait on this."],["notifications","Notifications","Pushes wait on this."]],k=i(t=>y.join(t,".claude","kickflip","integrations.json"),"reportFile");function j(t=m.homedir()){try{return JSON.parse(f.readFileSync(k(t),"utf8")).slack||null}catch{return null}}i(j,"readSlackReport");function W(t,{home:n=m.homedir(),now:s=new Date,route:d=null,title:o="Board"}={}){const e=k(n),r={ready:t,at:s.toISOString(),route:d,title:o};f.mkdirSync(y.dirname(e),{recursive:!0});const a=`${e}.${process.pid}.tmp`;return f.writeFileSync(a,JSON.stringify({slack:r},null,2)+`
2
+ `,{mode:384}),f.renameSync(a,e),r}i(W,"reportSlack");const D=i((t,n)=>{const s=Math.floor((n-Date.parse(t))/6e4);return s<1?"just now":s<60?`${s} min ago`:s<1440?`${Math.floor(s/60)} h ago`:`${Math.floor(s/1440)} days ago`},"age"),u=i((t,n,s)=>({settings:t,label:`Hub settings \u203A ${n}`,text:s}),"settingsFix");function $(t,{now:n=Date.now(),platform:s=process.platform}={}){const d=i((o,e)=>{switch(o){case"github":return e?{ready:!0,detail:`gh logged in as ${e}`}:{ready:!1,missing:"gh isn't logged in on the host",fix:{command:"gh auth login"}};case"codex":return e.usable?{ready:!0,detail:"Installed and logged in"}:{ready:!1,missing:e.words,fix:{command:e.fix}};case"linear":return e.set?e.refused?{ready:!1,missing:"Linear refused the key",fix:u("linear","Linear","Paste a new one in")}:{ready:!0,detail:`Key set \xB7 ${e.workspace} workspace`}:{ready:!1,missing:"No Linear key set",fix:u("linear","Linear","Add one in")};case"slack":{const r=e?`${e.title}\u2019s session reported ${e.ready?"a":"no"} Slack connector, ${D(e.at,n)}`:"No board session has reported yet";return e?.ready?{ready:!0,detail:r}:{ready:!1,missing:r,fix:{text:"Add Slack to Claude\u2019s connectors. The next board session to start reports it."}}}case"tailscale":{let r,a;return e.installed?e.signedIn?e.pointsAtHub?e.operatorChecked&&e.operator!==e.username&&(r=`Tailscale operator is ${e.operator||"none"} (expected ${e.username})`,a="sudo tailscale set --operator=$USER"):(r="Tailscale / is not serving the hub",a="kickflip doctor --fix"):(r="Tailscale isn't signed in",a="tailscale up"):(r="Tailscale isn't installed on the host",a=F(s)),r?{ready:!1,missing:r,fix:{command:a}}:{ready:!0,detail:`Serving ${e.dns}${e.operatorChecked?" \xB7 operator "+e.operator:""}`}}case"notifications":return e.on&&e.devices?{ready:!0,detail:`Pushes on \xB7 ${e.devices} device${e.devices===1?"":"s"}`}:{ready:!1,missing:e.on?"No device has turned on notifications":"Pushes are off",fix:u("notifications","Notifications","Turn on notifications in")}}},"row");return x.filter(([o])=>Object.hasOwn(t,o)).map(([o,e,r])=>({id:o,name:e,...d(o,t[o]),waits:r}))}i($,"readinessRows");const H=i(t=>(n,s)=>new Promise(d=>{N(n,s,{env:t,timeout:3e3},(o,e)=>d({status:o?1:0,stdout:e}))}),"asyncRun");function A({home:t=m.homedir(),env:n=process.env,now:s=Date.now,cacheMs:d=6e4,probes:o={}}={}){const e=w({home:t,env:n,now:s,cacheMs:d}),r={github:i(()=>P({env:n}).catch(()=>null),"github"),codex:e,linear:i(()=>R(t),"linear"),slack:i(()=>j(t),"slack"),tailscale:i(()=>I({run:H(n),port:Number(n.KICKFLIP_HUB_PORT||4500),tailscale:L({env:n})}),"tailscale"),notifications:i(()=>({on:C(t).notifications.on,devices:O(t).length}),"notifications"),...o},a=new Map;async function g(l,c){if(!["github","tailscale"].includes(l))return r[l]({fresh:c});const p=a.get(l);if(!c&&p&&s()-p.at<d)return p.value;const h=Promise.resolve().then(()=>r[l]());return a.set(l,{at:s(),value:h}),h}i(g,"check");const b=i(async({fresh:l=!1}={})=>$(Object.fromEntries(await Promise.all(x.map(async([c])=>[c,await g(c,l)]))),{now:s()}),"rows");async function S(l){const[c]=$({[l]:await g(l,!1)},{now:s()});return c.ready||!c.waits?null:c.waits.replace("this",`${c.name}: ${c.missing.replace(/\.$/,"")}`)}return i(S,"waiting"),{rows:b,waiting:S}}i(A,"readinessReader");const M=w(),X=A({probes:{codex:M}});export{M as readCodex,j as readSlackReport,X as readiness,A as readinessReader,$ as readinessRows,W as reportSlack};
package/hub/repos.mjs CHANGED
@@ -1,3 +1,3 @@
1
- var X=Object.defineProperty;var i=(t,e)=>X(t,"name",{value:e,configurable:!0});import y from"node:fs";import h from"node:path";import{spawn as Y}from"node:child_process";import{reposDir as E}from"../shared/repos.mjs";import{withFileLock as Z}from"./locks.mjs";import{storedPath as G}from"../bin/install.mjs";const m=i((t,e=400)=>{throw Object.assign(new Error(t),{status:e})},"refuse");function S(t){return(typeof t!="string"||t.length>140||!/^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?\/[a-z\d_.-]{1,100}$/i.test(t)||[".","..",".git"].includes(t.split("/")[1].toLowerCase()))&&m("Repo must be owner/name on GitHub."),t}i(S,"validateGithub");function O(t){return(typeof t!="string"||!t||t.length>255||t==="@"||t.startsWith("-")||/[\s\x00-\x1f\x7f~^:?*\[\\]/.test(t)||t.includes("..")||t.includes("@{")||t.split("/").some(e=>!e||e.startsWith(".")||e.endsWith(".")||e.endsWith(".lock")))&&m("Choose a valid branch name."),t}i(O,"validateBranch");const b=i((t,e)=>{try{return t(e),!0}catch{return!1}},"valid");function tt(t){const e=/^(?:git@github\.com:|https:\/\/github\.com\/|ssh:\/\/git@github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(t.trim());return e?z(e[1]):null}i(tt,"githubFromOrigin");const I=/^[a-z\d](?:[a-z\d-]*[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]*[a-z\d])?)+$/i,N=/^[\w~][\w.~-]{0,99}$|^\.[\w~-][\w.~-]{0,98}$/;function A(t){if(typeof t!="string")return null;const e=t.trim().replace(/\/+$/,""),n=/^https:\/\/([^/@\s?#]+?)(?::\d{1,5})?\/([^\s?#]+)$/i.exec(e)||/^ssh:\/\/(?:[^@/:\s]+@)?([^/@:\s?#]+)(?::\d{1,5})?\/([^\s?#]+)$/i.exec(e)||/^[^@/:\s]+@([^/@:\s?#]+):([^\s?#]+)$/.exec(e);if(!n||e.length>400||!I.test(n[1])||/^(?:www\.)?github\.com$/i.test(n[1]))return null;const r=n[2].replace(/^\/+/,"").replace(/\.git$/i,"").split("/");return r.length>10||!r.every(o=>N.test(o)&&o!=="..")?null:{remote:e,host:n[1].toLowerCase(),path:r.join("/")}}i(A,"remoteFromInput");const M="Paste a git link: https://host/path, ssh://host/path or user@host:path.",k=i(t=>A(t)||m(M),"validateRemote"),$=i(t=>typeof t=="string"&&t.includes(":"),"isRemote"),x=i(t=>$(t)?(({host:e,path:n})=>[e,...n.toLowerCase().split("/")])(k(t)):S(t).toLowerCase().split("/"),"repoParts"),B=i(t=>$(t)?(({host:e,path:n})=>`${e}/${n}`)(k(t)):S(t),"repoName"),W=i((t,e)=>{try{return x(t).join("/")===x(e).join("/")}catch{return!1}},"sameRepo");function z(t){if(typeof t!="string")return null;const e=t.trim(),n=/^(?:https?:\/\/(?:www\.)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s?#]+\/[^/\s?#]+)(?:\/[^\s]*)?(?:[?#][^\s]*)?$/i.exec(e),r=n?n[1].replace(/\.git$/i,""):e;try{return S(r)}catch{return null}}i(z,"githubFromInput");async function yt(t,{repos:e,manifest:n,sendJSON:r}){try{return r(200,await e.lookup(t.searchParams.get("github")??t.searchParams.get("remote"),n))}catch(o){return r(o.status||502,{error:o.message})}}i(yt,"repoLookupRoute");function et(t,e,n={}){return new Promise(r=>{const o=Y(t,e,{...n,shell:!1,stdio:["ignore","pipe","pipe"]});let u="",g="";o.stdout.on("data",w=>{u+=w}),o.stderr.on("data",w=>{g+=w}),o.on("error",w=>r({status:1,stdout:u,stderr:w.message})),o.on("close",w=>r({status:w,stdout:u,stderr:g}))})}i(et,"runRepo");function C(t,e=E()){const n=x(t),r=h.resolve(e),o=h.resolve(r,...n);o.startsWith(`${r}${h.sep}`)||m("Clone path must stay inside .repos.");let u=r;for(const w of["",...n])w&&(u=h.join(u,w)),y.lstatSync(u,{throwIfNoEntry:!1})?.isSymbolicLink()&&m("Clone path must stay inside .repos; symlinks are refused.");let g=r;for(;!y.existsSync(g);)g=h.dirname(g);return y.realpathSync(g)!==g&&m("Clone path must stay inside .repos; symlinks are refused."),o}i(C,"repoPath");const P=i(t=>y.lstatSync(h.join(t,".git"),{throwIfNoEntry:!1})?.isDirectory()===!0,"hasGit");function $t(t,e=E()){let n,r,o;try{n=y.realpathSync(t),r=y.realpathSync(e),o=h.relative(r,n).split(h.sep)}catch{return!1}if(o.length===2&&b(S,o.join("/")))return P(n);if(o.length<2||o.length>11||!I.test(o[0])||!o.slice(1).every(u=>N.test(u)&&u!==".."))return!1;for(let u=2;u<o.length;u++)if(P(h.join(r,...o.slice(0,u))))return!1;return P(n)}i($t,"isClone");const st=/^(?:Cloning into |remote: (?:Enumerating|Counting|Compressing|Total) |Receiving objects:|Resolving deltas:|Updating files:)/;function nt(t,e){return(String(t||"").split(/\n\s*\n/).map(r=>r.split(/[\r\n]+/).map(o=>o.trim()).filter(o=>o&&!st.test(o))).find(r=>r.length)?.at(-1)||e).replace(/(\w+:\/\/)[^/\s@]+@/g,"$1")}i(nt,"errorLine");const _=i((t,e)=>nt(t.stderr||t.stdout,e),"failure"),ot=i(async(t,e,n,r)=>{const o=await t(e,n,r);return o.status!==0&&m(_(o,`${e} failed`),502),o.stdout},"checked"),R=i(async(t,e)=>JSON.parse(await ot(t,"gh",e)),"ghJSON");async function D(t,e,n){y.lstatSync(h.join(e,".git"),{throwIfNoEntry:!1})?.isDirectory()||m("The clone path must have a real .git metadata directory; symlinks are refused.");const r=await t("git",["-C",e,"rev-parse","--show-toplevel"]);(r.status!==0||h.resolve(r.stdout.trim())!==y.realpathSync(e))&&m("The clone path must be the root of a Git work tree.");const o=await t("git",["-C",e,"remote","get-url","origin"]);o.status===0&&($(n)?W(o.stdout.trim(),n):tt(o.stdout)?.toLowerCase()===n.toLowerCase())||m(`The clone path origin must be ${$(n)?B(n):`github.com/${n}`}.`)}i(D,"verifyClone");const H=i((t,e)=>t("git",["-c","credential.helper=",...e],{env:{...process.env,GIT_TERMINAL_PROMPT:"0"}}),"publicGit"),rt=i(t=>["-c","credential.helper=","-c","credential.https://github.com.helper=!gh auth git-credential",...t],"githubGitArgv"),it=i((t,e)=>t("git",rt(e),{env:{...process.env,GIT_TERMINAL_PROMPT:"0"}}),"githubGit"),at=i((t=process.env)=>({...t,GIT_TERMINAL_PROMPT:"0",GIT_SSH_COMMAND:t.GIT_SSH_COMMAND||"ssh -o BatchMode=yes"}),"remoteGitEnv"),L=i((t,e)=>t("git",e,{env:at()}),"remoteGit"),F=i(t=>$(t)?L:it,"gitFor"),ct=i(t=>$(t)?t:`https://github.com/${t}.git`,"cloneURL"),lt=/not found|authentication failed|could not read username|invalid username or password|permission denied|terminal prompts disabled|\b403\b/i;function ut(t,{github:e="",remote:n="",repo:r}){const o=String(t||"").split(`
2
- `).map(v=>v.trim()).filter(Boolean),u=(o.find(v=>/^remote:/i.test(v))||o.at(-1)||"git failed without a message").replace(/(\w+:\/\/)[^/\s@]+@/g,"$1"),g=A(n),w=g?`check git can reach ${g.host}: git ls-remote ${g.remote}`:e&&lt.test(o.join(`
3
- `))?`git's github.com login can't see ${e}; run gh auth setup-git`:`run git -C ${r} fetch origin to see the whole error`;return{error:u,hint:w}}i(ut,"gitReachFault");const T=i((t,e)=>{const{error:n,hint:r}=ut(t.stderr||t.stdout,{remote:e,repo:""});return`${n.replace(/[.\s]*$/,".")} ${r[0].toUpperCase()}${r.slice(1)}.`},"reachError");async function U(t,e,n,r){const o=await F(n)(t,["-C",e,"fetch","--no-tags","origin",`+refs/heads/${r}:refs/remotes/origin/${r}`]);o.status!==0&&m(`Could not fetch branch "${r}" from ${B(n)}: ${_(o,"git fetch failed")}`,502)}i(U,"fetchBranch");function vt({run:t=et,base:e=E(),now:n=Date.now}={}){let r,o;const u=new Map,g=i(s=>({github:s.nameWithOwner,private:s.isPrivate,pushedAt:s.pushedAt||"",defaultBranch:s.defaultBranchRef?.name||"",description:s.description||""}),"repoRow"),w=i((s,l)=>{const a=s.github||s.remote,c=C(a,e),p=Object.entries(l.sites||{}).filter(([,f])=>W(f.github||f.remote,a)&&typeof f.owner?.repo=="string"&&G(h.resolve(f.owner.repo)).toLowerCase()===G(c).toLowerCase()).map(([f,d])=>({route:f,title:d.title||f}));return{...s,clone:c,cloned:y.lstatSync(h.join(c,".git"),{throwIfNoEntry:!1})?.isDirectory()||!1,shared:p}},"decorate");async function v(s,l){const a=await s(t,["ls-remote","--symref",l,"HEAD","refs/heads/*"]);if(a.status!==0)return{failed:a};const c=a.stdout,p=[...new Set([...c.matchAll(/^[\da-f]+\s+refs\/heads\/(.+)$/gm)].map(d=>d[1]).filter(d=>b(O,d)))],f=/^ref:\s+refs\/heads\/(.+)\s+HEAD$/m.exec(c)?.[1];return{defaultBranch:p.includes(f)?f:p[0]||"",branches:p}}i(v,"lsRemote");async function J(s){const l=await v(H,`https://github.com/${s}.git`);return l.failed?null:l}i(J,"remoteBranches");async function q(s){const l=await v(L,s);l.failed&&m(T(l.failed,s),404);const{host:a,path:c}=k(s);return{remote:s,host:a,name:`${a}/${c}`,github:"",pushedAt:"",defaultBranch:l.defaultBranch,description:""}}i(q,"findRemote");async function K(s){const l=await t("gh",["repo","view",s,"--json","nameWithOwner,isPrivate,description,pushedAt,defaultBranchRef"]);if(l.status===0){const c=JSON.parse(l.stdout);return S(c.nameWithOwner),g(c)}return(await H(t,["ls-remote","--heads",`https://github.com/${s}.git`])).status!==0?null:{github:s,private:!1,pushedAt:"",defaultBranch:"",description:""}}i(K,"find");async function Q(){if((await t("gh",["auth","status"])).status!==0)return{signedIn:!1,repos:[]};const l=await R(t,["api","user"]),a=await R(t,["api","user/orgs","--paginate","--slurp"]),c=[...new Set([l.login,...a.flat().map(d=>d.login)])],p=await Promise.all(c.map(d=>R(t,["repo","list",d,"--limit","100000","--json","nameWithOwner,isPrivate,pushedAt,defaultBranchRef,description"]))),f=new Map;for(const d of p.flat())b(S,d.nameWithOwner)&&f.set(d.nameWithOwner.toLowerCase(),g(d));return{signedIn:!0,login:l.login,owners:c,repos:[...f.values()].sort((d,j)=>(j.pushedAt||"").localeCompare(d.pushedAt||"")||d.github.localeCompare(j.github))}}return i(Q,"discover"),{base:h.resolve(e),path:i(s=>C(s,e),"path"),cloned:i(s=>y.lstatSync(h.join(C(s,e),".git"),{throwIfNoEntry:!1})?.isDirectory()||!1,"cloned"),async list(s={},{fresh:l=!1}={}){return(l||!r||n()-r.at>=6e4)&&(o||=Q().then(a=>(r={at:n(),value:a},a)).finally(()=>{o=null}),await o),{...r.value,repos:r.value.repos.map(a=>w(a,s))}},async lookup(s,l={}){const a=z(s),c=a?null:A(s);!a&&!c&&m($(s)?M:"Repo must be owner/name on GitHub.");const p=a?a.toLowerCase():x(c.remote).join("/");let f=u.get(p);if(!f||n()-f.at>=6e4){for(const[j,V]of u)n()-V.at>=6e4&&u.delete(j);f={at:n(),pending:a?K(a):q(c.remote)},u.set(p,f),f.pending.catch(()=>{u.get(p)===f&&u.delete(p)})}const d=await f.pending;return d||m("Not found, or you don't have access.",404),w({...d,listed:!1},l)},async branches(s){if($(s)){k(s);const a=await v(L,s);return a.failed&&m(T(a.failed,s),502),a}const l=S(s);try{const a=await R(t,["api",`repos/${l}`]),c=await R(t,["api",`repos/${l}/branches?per_page=100`,"--paginate","--slurp"]);return{defaultBranch:a.default_branch,branches:c.flat().map(p=>p.name).filter(p=>b(O,p))}}catch(a){const c=await J(l);if(!c)throw a;return c}},async ensure(s,l="main"){const a=C(s,e);return O(l),y.mkdirSync(h.dirname(a),{recursive:!0}),Z(h.join(h.resolve(e),".locks",...x(s)),async()=>{if(C(s,e),y.existsSync(a))return await D(t,a,s),await U(t,a,s,l),a;const c=y.mkdtempSync(h.join(h.resolve(e),".clone-"));try{const p=await F(s)(t,["clone",ct(s),h.join(c,"repo")]);p.status!==0&&m($(s)?T(p,s):_(p,"git clone failed"),502),await D(t,h.join(c,"repo"),s),await U(t,h.join(c,"repo"),s,l),C(s,e),y.renameSync(h.join(c,"repo"),a)}finally{y.rmSync(c,{recursive:!0,force:!0})}return a},{timeout:10*6e4,heartbeat:!0})}}}i(vt,"repoService");export{ut as gitReachFault,z as githubFromInput,tt as githubFromOrigin,rt as githubGitArgv,$t as isClone,$ as isRemote,A as remoteFromInput,at as remoteGitEnv,yt as repoLookupRoute,B as repoName,x as repoParts,C as repoPath,vt as repoService,et as runRepo,O as validateBranch,S as validateGithub,k as validateRemote};
1
+ var Y=Object.defineProperty;var i=(t,e)=>Y(t,"name",{value:e,configurable:!0});import v from"node:fs";import h from"node:path";import{spawn as Z}from"node:child_process";import{reposDir as A}from"../shared/repos.mjs";import{withFileLock as tt}from"./locks.mjs";import{storedPath as T}from"../bin/install.mjs";const w=i((t,e=400)=>{throw Object.assign(new Error(t),{status:e})},"refuse");function S(t){return(typeof t!="string"||t.length>140||!/^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?\/[a-z\d_.-]{1,100}$/i.test(t)||[".","..",".git"].includes(t.split("/")[1].toLowerCase()))&&w("Repo must be owner/name on GitHub."),t}i(S,"validateGithub");function E(t){return(typeof t!="string"||!t||t.length>255||t==="@"||t.startsWith("-")||/[\s\x00-\x1f\x7f~^:?*\[\\]/.test(t)||t.includes("..")||t.includes("@{")||t.split("/").some(e=>!e||e.startsWith(".")||e.endsWith(".")||e.endsWith(".lock")))&&w("Choose a valid branch name."),t}i(E,"validateBranch");const C=i((t,e)=>{try{return t(e),!0}catch{return!1}},"valid");function et(t){const e=/^(?:git@github\.com:|https:\/\/github\.com\/|ssh:\/\/git@github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(t.trim());return e?W(e[1]):null}i(et,"githubFromOrigin");const G=/^[a-z\d](?:[a-z\d-]*[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]*[a-z\d])?)+$/i,I=/^[\w~][\w.~-]{0,99}$|^\.[\w~-][\w.~-]{0,98}$/;function O(t){if(typeof t!="string")return null;const e=t.trim().replace(/\/+$/,""),s=/^https:\/\/([^/@\s?#]+?)(?::\d{1,5})?\/([^\s?#]+)$/i.exec(e)||/^ssh:\/\/(?:[^@/:\s]+@)?([^/@:\s?#]+)(?::\d{1,5})?\/([^\s?#]+)$/i.exec(e)||/^[^@/:\s]+@([^/@:\s?#]+):([^\s?#]+)$/.exec(e);if(!s||e.length>400||!G.test(s[1])||/^(?:www\.)?github\.com$/i.test(s[1]))return null;const r=s[2].replace(/^\/+/,"").replace(/\.git$/i,"").split("/");return r.length>10||!r.every(o=>I.test(o)&&o!=="..")?null:{remote:e,host:s[1].toLowerCase(),path:r.join("/")}}i(O,"remoteFromInput");const M="Paste a git link: https://host/path, ssh://host/path or user@host:path.",k=i(t=>O(t)||w(M),"validateRemote"),$=i(t=>typeof t=="string"&&t.includes(":"),"isRemote"),b=i(t=>$(t)?(({host:e,path:s})=>[e,...s.toLowerCase().split("/")])(k(t)):S(t).toLowerCase().split("/"),"repoParts"),B=i(t=>$(t)?(({host:e,path:s})=>`${e}/${s}`)(k(t)):S(t),"repoName"),D=i((t,e)=>{try{return b(t).join("/")===b(e).join("/")}catch{return!1}},"sameRepo");function W(t){if(typeof t!="string")return null;const e=t.trim(),s=/^(?:https?:\/\/(?:www\.)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s?#]+\/[^/\s?#]+)(?:\/[^\s]*)?(?:[?#][^\s]*)?$/i.exec(e),r=s?s[1].replace(/\.git$/i,""):e;try{return S(r)}catch{return null}}i(W,"githubFromInput");async function vt(t,{repos:e,manifest:s,sendJSON:r}){try{return r(200,await e.lookup(t.searchParams.get("github")??t.searchParams.get("remote"),s))}catch(o){return r(o.status||502,{error:o.message})}}i(vt,"repoLookupRoute");function z(t,e,{onStderr:s,...r}={}){return new Promise(o=>{const l=Z(t,e,{...r,shell:!1,stdio:["ignore","pipe","pipe"]});let m="",y="";l.stdout.on("data",g=>{m+=g}),l.stderr.on("data",g=>{y+=g,s?.(String(g))}),l.on("error",g=>o({status:1,stdout:m,stderr:g.message})),l.on("close",g=>o({status:g,stdout:m,stderr:y}))})}i(z,"runRepo");async function $t(t,e=z){const s=await e("git",["-C",t,"symbolic-ref","--quiet","--short","refs/remotes/origin/HEAD"]),r=await e("git",["-C",t,"symbolic-ref","--quiet","--short","HEAD"]);for(const o of[s.status===0&&s.stdout.trim().replace(/^origin\//,""),r.status===0&&r.stdout.trim()])if(o&&C(E,o))return o;return""}i($t,"defaultBranch");function x(t,e=A()){const s=b(t),r=h.resolve(e),o=h.resolve(r,...s);o.startsWith(`${r}${h.sep}`)||w("Clone path must stay inside the clone folder.");let l=r;for(const y of["",...s])y&&(l=h.join(l,y)),v.lstatSync(l,{throwIfNoEntry:!1})?.isSymbolicLink()&&w("Clone path must stay inside the clone folder; symlinks are refused.");let m=r;for(;!v.existsSync(m);)m=h.dirname(m);return v.realpathSync(m)!==m&&w("Clone path must stay inside the clone folder; symlinks are refused."),o}i(x,"repoPath");const P=i(t=>v.lstatSync(h.join(t,".git"),{throwIfNoEntry:!1})?.isDirectory()===!0,"hasGit");function St(t,e=A()){let s,r,o;try{s=v.realpathSync(t),r=v.realpathSync(e),o=h.relative(r,s).split(h.sep)}catch{return!1}if(o.length===2&&C(S,o.join("/")))return P(s);if(o.length<2||o.length>11||!G.test(o[0])||!o.slice(1).every(l=>I.test(l)&&l!==".."))return!1;for(let l=2;l<o.length;l++)if(P(h.join(r,...o.slice(0,l))))return!1;return P(s)}i(St,"isClone");function st(t){let e="",s=0,r=0;return o=>{const l=(e+o).split(/[\r\n]/);e=l.pop();for(const m of l){const y=/^(Receiving objects|Resolving deltas):\s+(\d+)%/.exec(m.trim());y&&(y[1]==="Receiving objects"?s=Number(y[2]):r=Number(y[2]),t(Math.floor(s*.9+r*.1)))}}}i(st,"cloneMeter");const nt=/^(?:Cloning into |remote: (?:Enumerating|Counting|Compressing|Total) |Receiving objects:|Resolving deltas:|Updating files:)/;function ot(t,e){return(String(t||"").split(/\n\s*\n/).map(r=>r.split(/[\r\n]+/).map(o=>o.trim()).filter(o=>o&&!nt.test(o))).find(r=>r.length)?.at(-1)||e).replace(/(\w+:\/\/)[^/\s@]+@/g,"$1")}i(ot,"errorLine");const _=i((t,e)=>ot(t.stderr||t.stdout,e),"failure"),rt=i(async(t,e,s,r)=>{const o=await t(e,s,r);return o.status!==0&&w(_(o,`${e} failed`),502),o.stdout},"checked"),R=i(async(t,e)=>JSON.parse(await rt(t,"gh",e)),"ghJSON");async function H(t,e,s){v.lstatSync(h.join(e,".git"),{throwIfNoEntry:!1})?.isDirectory()||w("The clone path must have a real .git metadata directory; symlinks are refused.");const r=await t("git",["-C",e,"rev-parse","--show-toplevel"]);(r.status!==0||h.resolve(r.stdout.trim())!==v.realpathSync(e))&&w("The clone path must be the root of a Git work tree.");const o=await t("git",["-C",e,"remote","get-url","origin"]);o.status===0&&($(s)?D(o.stdout.trim(),s):et(o.stdout)?.toLowerCase()===s.toLowerCase())||w(`The clone path origin must be ${$(s)?B(s):`github.com/${s}`}.`)}i(H,"verifyClone");const F=i((t,e)=>t("git",["-c","credential.helper=",...e],{env:{...process.env,GIT_TERMINAL_PROMPT:"0"}}),"publicGit"),it=i(t=>["-c","credential.helper=","-c","credential.https://github.com.helper=!gh auth git-credential",...t],"githubGitArgv"),at=i((t,e,s={})=>t("git",it(e),{...s,env:{...process.env,GIT_TERMINAL_PROMPT:"0"}}),"githubGit"),ct=i((t=process.env)=>({...t,GIT_TERMINAL_PROMPT:"0",GIT_SSH_COMMAND:t.GIT_SSH_COMMAND||"ssh -o BatchMode=yes"}),"remoteGitEnv"),L=i((t,e,s={})=>t("git",e,{...s,env:ct()}),"remoteGit"),U=i(t=>$(t)?L:at,"gitFor"),lt=i(t=>$(t)?t:`https://github.com/${t}.git`,"cloneURL"),ut=/not found|authentication failed|could not read username|invalid username or password|permission denied|terminal prompts disabled|\b403\b/i;function ft(t,{github:e="",remote:s="",repo:r}){const o=String(t||"").split(`
2
+ `).map(g=>g.trim()).filter(Boolean),l=(o.find(g=>/^remote:/i.test(g))||o.at(-1)||"git failed without a message").replace(/(\w+:\/\/)[^/\s@]+@/g,"$1"),m=O(s),y=m?`check git can reach ${m.host}: git ls-remote ${m.remote}`:e&&ut.test(o.join(`
3
+ `))?`git's github.com login can't see ${e}; run gh auth setup-git`:`run git -C ${r} fetch origin to see the whole error`;return{error:l,hint:y}}i(ft,"gitReachFault");const N=i((t,e)=>{const{error:s,hint:r}=ft(t.stderr||t.stdout,{remote:e,repo:""});return`${s.replace(/[.\s]*$/,".")} ${r[0].toUpperCase()}${r.slice(1)}.`},"reachError");async function q(t,e,s,r){const o=await U(s)(t,["-C",e,"fetch","--no-tags","origin",`+refs/heads/${r}:refs/remotes/origin/${r}`]);o.status!==0&&w(`Could not fetch branch "${r}" from ${B(s)}: ${_(o,"git fetch failed")}`,502)}i(q,"fetchBranch");function xt({run:t=z,base:e=A(),now:s=Date.now}={}){let r,o;const l=new Map,m=i(n=>({github:n.nameWithOwner,private:n.isPrivate,pushedAt:n.pushedAt||"",defaultBranch:n.defaultBranchRef?.name||"",description:n.description||""}),"repoRow"),y=i((n,u)=>{const a=n.github||n.remote,c=x(a,e),p=Object.entries(u.sites||{}).filter(([,f])=>D(f.github||f.remote,a)&&typeof f.owner?.repo=="string"&&T(h.resolve(f.owner.repo)).toLowerCase()===T(c).toLowerCase()).map(([f,d])=>({route:f,title:d.title||f}));return{...n,clone:c,cloned:v.lstatSync(h.join(c,".git"),{throwIfNoEntry:!1})?.isDirectory()||!1,shared:p}},"decorate");async function g(n,u){const a=await n(t,["ls-remote","--symref",u,"HEAD","refs/heads/*"]);if(a.status!==0)return{failed:a};const c=a.stdout,p=[...new Set([...c.matchAll(/^[\da-f]+\s+refs\/heads\/(.+)$/gm)].map(d=>d[1]).filter(d=>C(E,d)))],f=/^ref:\s+refs\/heads\/(.+)\s+HEAD$/m.exec(c)?.[1];return{defaultBranch:p.includes(f)?f:p[0]||"",branches:p}}i(g,"lsRemote");async function J(n){const u=await g(F,`https://github.com/${n}.git`);return u.failed?null:u}i(J,"remoteBranches");async function K(n){const u=await g(L,n);u.failed&&w(N(u.failed,n),404);const{host:a,path:c}=k(n);return{remote:n,host:a,name:`${a}/${c}`,github:"",pushedAt:"",defaultBranch:u.defaultBranch,description:""}}i(K,"findRemote");async function Q(n){const u=await t("gh",["repo","view",n,"--json","nameWithOwner,isPrivate,description,pushedAt,defaultBranchRef"]);if(u.status===0){const c=JSON.parse(u.stdout);return S(c.nameWithOwner),m(c)}return(await F(t,["ls-remote","--heads",`https://github.com/${n}.git`])).status!==0?null:{github:n,private:!1,pushedAt:"",defaultBranch:"",description:""}}i(Q,"find");async function V(){if((await t("gh",["auth","status"])).status!==0)return{signedIn:!1,repos:[]};const u=await R(t,["api","user"]),a=await R(t,["api","user/orgs","--paginate","--slurp"]),c=[...new Set([u.login,...a.flat().map(d=>d.login)])],p=await Promise.all(c.map(d=>R(t,["repo","list",d,"--limit","100000","--json","nameWithOwner,isPrivate,pushedAt,defaultBranchRef,description"]))),f=new Map;for(const d of p.flat())C(S,d.nameWithOwner)&&f.set(d.nameWithOwner.toLowerCase(),m(d));return{signedIn:!0,login:u.login,owners:c,repos:[...f.values()].sort((d,j)=>(j.pushedAt||"").localeCompare(d.pushedAt||"")||d.github.localeCompare(j.github))}}return i(V,"discover"),{base:h.resolve(e),path:i(n=>x(n,e),"path"),cloned:i(n=>v.lstatSync(h.join(x(n,e),".git"),{throwIfNoEntry:!1})?.isDirectory()||!1,"cloned"),async list(n={},{fresh:u=!1}={}){return(u||!r||s()-r.at>=6e4)&&(o||=V().then(a=>(r={at:s(),value:a},a)).finally(()=>{o=null}),await o),{...r.value,repos:r.value.repos.map(a=>y(a,n))}},async lookup(n,u={}){const a=W(n),c=a?null:O(n);!a&&!c&&w($(n)?M:"Repo must be owner/name on GitHub.");const p=a?a.toLowerCase():b(c.remote).join("/");let f=l.get(p);if(!f||s()-f.at>=6e4){for(const[j,X]of l)s()-X.at>=6e4&&l.delete(j);f={at:s(),pending:a?Q(a):K(c.remote)},l.set(p,f),f.pending.catch(()=>{l.get(p)===f&&l.delete(p)})}const d=await f.pending;return d||w("Not found, or you don't have access.",404),y({...d,listed:!1},u)},async branches(n){if($(n)){k(n);const a=await g(L,n);return a.failed&&w(N(a.failed,n),502),a}const u=S(n);try{const a=await R(t,["api",`repos/${u}`]),c=await R(t,["api",`repos/${u}/branches?per_page=100`,"--paginate","--slurp"]);return{defaultBranch:a.default_branch,branches:c.flat().map(p=>p.name).filter(p=>C(E,p))}}catch(a){const c=await J(u);if(!c)throw a;return c}},async ensure(n,u="main",{progress:a}={}){const c=x(n,e);return E(u),v.mkdirSync(h.dirname(c),{recursive:!0}),tt(h.join(h.resolve(e),".locks",...b(n)),async()=>{if(x(n,e),v.existsSync(c))return await H(t,c,n),await q(t,c,n,u),c;const p=v.mkdtempSync(h.join(h.resolve(e),".clone-"));try{const f=await U(n)(t,["clone","--progress",lt(n),h.join(p,"repo")],a?{onStderr:st(a)}:{});f.status!==0&&w($(n)?N(f,n):_(f,"git clone failed"),502),await H(t,h.join(p,"repo"),n),await q(t,h.join(p,"repo"),n,u),x(n,e),v.renameSync(h.join(p,"repo"),c)}finally{v.rmSync(p,{recursive:!0,force:!0})}return c},{timeout:10*6e4,heartbeat:!0})}}}i(xt,"repoService");export{st as cloneMeter,$t as defaultBranch,ft as gitReachFault,W as githubFromInput,et as githubFromOrigin,it as githubGitArgv,St as isClone,$ as isRemote,O as remoteFromInput,ct as remoteGitEnv,vt as repoLookupRoute,B as repoName,b as repoParts,x as repoPath,xt as repoService,z as runRepo,E as validateBranch,S as validateGithub,k as validateRemote};
@@ -1 +1 @@
1
- var $=Object.defineProperty;var r=(s,e)=>$(s,"name",{value:e,configurable:!0});import{listBoards as w,readManifest as h,withManifestLock as k,writeManifest as g}from"./boards.mjs";import{routeRecord as l,validRoute as B}from"./routes.mjs";import{sessionOf as M,stopSession as S,withSessionLock as T}from"./sessions.mjs";const t=r((s,e=400)=>{throw Object.assign(new Error(s),{status:e})},"refuse"),j=r(s=>!!(Array.isArray(s?.cmd)&&s.cmd.length&&s.port&&s.owner?.session&&s.owner.repo),"restartable");async function R(s,{serverUp:e,live:c,stop:m,run:p}={}){return B(s)||t("Choose a board route."),T(s,async({resume:b})=>{const n=h();w(n).find(o=>o.route===s)||t("That board is not open.",404);const a=l(n.sites,s),i=a.title||s;j(a)||t(`${i} has no server the hub runs or no session to resume.`,409);const f=w(n).filter(o=>o.route!==s&&o.owner?.session===a.owner.session);f.length&&t(`This session is also recorded by ${f.map(o=>`${o.title} (/${o.route})`).join(", ")}; restart refused.`,409),await S(M(n,s),{live:c,stop:m}),await k(async()=>{const o=h(),d=l(o.sites,s);d||t("That board is not open.",404),d.restart_at=Date.now(),await g(o)});const y=await e(s),{session:u}=await b({run:p,live:c});return y||t(`${i}\u2019s session is back, but its server hasn\u2019t answered yet. See kickflip logs ${s}.`,504),{route:s,title:i,session:u}})}r(R,"restartBoard");export{R as restartBoard,j as restartable};
1
+ var L=Object.defineProperty;var r=(s,n)=>L(s,"name",{value:n,configurable:!0});import{listBoards as m,readManifest as p,withManifestLock as M,writeManifest as S}from"./boards.mjs";import{routeRecord as h,validRoute as A}from"./routes.mjs";import{sessionOf as B,stopSession as I,withSessionLock as O}from"./sessions.mjs";const o=r((s,n=400)=>{throw Object.assign(new Error(s),{status:n})},"refuse"),R=r(s=>!!(Array.isArray(s?.cmd)&&s.cmd.length&&s.port&&s.owner?.session&&s.owner.repo),"restartable"),_=12e4,j=1e3;async function E(s,{serverUp:n,portUp:b,live:d,stop:u,run:T,now:w=Date.now,sleep:y=r(f=>new Promise(e=>setTimeout(e,f)),"sleep"),onStarting:$}={}){return A(s)||o("Choose a board route."),O(s,async({resume:f})=>{const e=p();m(e).find(t=>t.route===s)||o("That board is not open.",404);const a=h(e.sites,s),c=a.title||s;R(a)||o(`${c} has no server the hub runs or no session to resume.`,409);const l=m(e).filter(t=>t.route!==s&&t.owner?.session===a.owner.session);l.length&&o(`This session is also recorded by ${l.map(t=>`${t.title} (/${t.route})`).join(", ")}; restart refused.`,409),await I(B(e,s),{live:d,stop:u}),await M(async()=>{const t=p(),i=h(t.sites,s);i||o("That board is not open.",404),i.restart_at=Date.now(),await S(t)});const g=await n(s),{session:k}=await f({run:T,live:d});if(!g){$?.(c);const t=w()+_;let i=!1;for(;!(i=await b(a.port))&&w()<t;)await y(j);i||o(`${c}\u2019s session is back, but its server is still starting after 2 minutes. See kickflip logs ${s}.`,504)}return{route:s,title:c,session:k}})}r(E,"restartBoard");export{E as restartBoard,R as restartable};
package/hub/schedule.mjs CHANGED
@@ -1 +1 @@
1
- var H=Object.defineProperty;var f=(e,t)=>H(e,"name",{value:t,configurable:!0});import{FOREVER as m,durationEnd as T,inRange as j}from"./duration.mjs";const d=Object.freeze({windows:Object.freeze([Object.freeze({from:"08:00",every_minutes:15}),Object.freeze({from:"18:00",every_minutes:30}),Object.freeze({from:"22:00",every_minutes:60})]),hold_overnight:!0,wakes:Object.freeze(["08:00","09:00"]),notifications:Object.freeze({on:!0,weekends_off:!1,snoozed_until:null})}),b="never",g=8,x=8,p=/^([01]\d|2[0-3]):([0-5]\d)$/,$=2*6e4,w=f(e=>{const[t,n]=e.split(":").map(Number);return t*60+n},"minutes"),W=f(e=>e.getHours()*60+e.getMinutes()+e.getSeconds()/60+e.getMilliseconds()/6e4,"minuteOfDay"),c=f((e,t,n=0)=>{const[o,r]=t.split(":").map(Number);return new Date(e.getFullYear(),e.getMonth(),e.getDate()+n,o,r)},"atOn"),_=f((e,t)=>{const n=c(e,t);return n>e?n:c(e,t,1)},"nextAt"),I=f((e,t)=>{const n=c(e,t);return n<=e?n:c(e,t,-1)},"lastAt"),M=f(e=>({windows:e.windows.map(t=>({from:t.from,every_minutes:t.every_minutes})),hold_overnight:e.hold_overnight,wakes:[...e.wakes],notifications:{...e.notifications}}),"copy");function S(e){const t=e?.on??d.notifications.on,n=e?.weekends_off??d.notifications.weekends_off,o=e?.snoozed_until??null;return typeof t!="boolean"?{error:"Notifications must be on or off."}:typeof n!="boolean"?{error:"Off on weekends must be on or off."}:o!==null&&o!==m&&(typeof o!="string"||Number.isNaN(Date.parse(o)))?{error:"Snooze must end at a date and time, or be forever."}:{notifications:{on:t,weekends_off:n,snoozed_until:o===null||o===m?o:new Date(o).toISOString()}}}f(S,"validateNotifications");function A(e){const t=e?.windows,n=e?.hold_overnight??d.hold_overnight,o=e?.wakes??d.wakes,{notifications:r,error:i}=S(e?.notifications);if(i)return{error:i};if(typeof n!="boolean")return{error:"Hold pushes overnight must be on or off."};if(!Array.isArray(o))return{error:"Wake times must be a list of HH:MM times."};if(o.length>x)return{error:`Use at most ${x} wake times.`};for(const[a,u]of o.entries()){if(typeof u!="string"||!p.test(u))return{error:`Wake ${a+1} must be HH:MM (24-hour).`};if(o.indexOf(u)!==a)return{error:`Two wakes are at ${u}; keep one.`}}if(!Array.isArray(t)||!t.length)return{error:"Add at least one window."};if(t.length>g)return{error:`Use at most ${g} windows.`};const s=[];for(const[a,u]of t.entries()){if(typeof u?.from!="string"||!p.test(u.from))return{error:`Window ${a+1}'s start must be HH:MM (24-hour).`};const h=Number(u.every_minutes);if(u.every_minutes===""||u.every_minutes==null||!Number.isInteger(h)||h<5||h>120)return{error:`Window ${a+1}'s interval must be a whole number of minutes from 5 to 120.`};if(s.some(y=>y.from===u.from))return{error:`Two windows start at ${u.from}; give each its own start.`};s.push({from:u.from,every_minutes:h})}return{settings:{windows:s.sort((a,u)=>w(a.from)-w(u.from)),hold_overnight:n,wakes:[...o].sort((a,u)=>w(a)-w(u)),notifications:r}}}f(A,"validate");function P(e){const t=e?.immediate?.start,n=e?.immediate?.end,o=e?.bundle?.until;if(![t,n,o].every(a=>typeof a=="string"&&p.test(a)))return null;const r=Number(e.bundle.every_minutes),i=[{from:t,every_minutes:d.windows[0].every_minutes}];n!==t&&o!==n&&i.push({from:n,every_minutes:Number.isInteger(r)?r:d.windows[1].every_minutes});const s=o===n?n:o;return s!==t&&!i.some(a=>a.from===s)&&i.push({from:s,every_minutes:d.windows[2].every_minutes}),A({windows:i,hold_overnight:o!==t}).settings||null}f(P,"fromPush");function G(e){const t=S(e?.notifications).notifications||M(d).notifications,n=f(o=>o&&{...o,notifications:t},"withThem");return A({...e?.polling,notifications:t}).settings||n(P(e?.push))||n(M(d))}f(G,"pollingOf");function v(e,t){const n=t.windows,o=W(e);let r=n.length-1;for(let a=0;a<n.length;a++)w(n[a].from)<=o&&(r=a);const i=n[r],s=n[(r+1)%n.length];return{from:i.from,every_minutes:i.every_minutes,startedAt:I(e,i.from),endsAt:_(e,s.from)}}f(v,"windowAt");function k(e,t){const n=e.windows;if(n.length===1)return 1440;const o=w(n[(t+1)%n.length].from)-w(n[t].from);return o>0?o:o+1440}f(k,"windowSpan");const R=f((e,t)=>Math.ceil(k(e,t)/e.windows[t].every_minutes),"checksIn"),q=f(e=>e.windows.reduce((t,n,o)=>t+R(e,o),0),"checksADay"),z=f(e=>e.hold_overnight&&e.windows.length>1,"holdsOvernight");function B(e){const t=e.windows.length-1;return e.windows.flatMap((n,o)=>{const r=w(n.from),i=r+k(e,o),s=z(e)&&o===t;return e.windows.length===1?[{window:o,start:0,end:1440,held:s}]:i<=1440?[{window:o,start:r,end:i,held:s}]:[{window:o,start:r,end:1440,held:s},{window:o,start:0,end:i-1440,held:s}]})}f(B,"daySegments");function F(e,t){if(e.windows.length>=g)return null;const n=w(e.windows[t].from),o=k(e,t),r=Math.round((n+o/2)/15)*15;if(r<=n||r>=n+o)return null;const i=r%1440;return`${String(Math.floor(i/60)).padStart(2,"0")}:${String(i%60).padStart(2,"0")}`}f(F,"splitPoint");function J(e,{op:t,w:n,value:o}){const r=e.windows.map(u=>({from:u.from,every_minutes:u.every_minutes})),i={windows:r,hold_overnight:e.hold_overnight,wakes:[...e.wakes]},s=r.findIndex(u=>u.from===n);if(["from","every","split","remove"].includes(t)&&s<0)return{error:"That window has changed. Pick it again on the bar."};const a=s<0?e.windows[0].from:n;switch(t){case"from":return r[s].from=o,{input:i,select:o};case"every":return r[s].every_minutes=o,{input:i,select:a};case"split":{const u=F(e,s);return u?(r.push({from:u,every_minutes:r[s].every_minutes}),{input:i,select:u}):{error:r.length>=g?`Use at most ${g} windows.`:"This window is too short to split."}}case"remove":{if(r.length<2)return{error:"Keep at least one window."};const u=r[(s-1+r.length)%r.length].from;return r.splice(s,1),{input:i,select:u}}case"hold":return i.hold_overnight=o==="on",{input:i,select:a};case"wake-add":return i.wakes.push(o),{input:i,select:a};case"wake-remove":return i.wakes=i.wakes.filter(u=>u!==o),{input:i,select:a};default:return{error:"That change is not one the Polling modal makes."}}}f(J,"editPolling");function U(e,t){if(!z(t))return!1;const n=v(e,t);return n.from===t.windows.at(-1).from&&e-n.startedAt>$}f(U,"holdsPushes");function D(e,t){if(!t.notifications?.weekends_off)return null;const[n,o]=[t.windows[0].from,t.windows.at(-1).from];let r=c(e,o,-((e.getDay()-5+7)%7));r>e&&(r=c(r,o,-7));const i=c(r,n,3);return e-r>$&&e<i?i:null}f(D,"weekendEnd");function L(e,t){if(t.notifications?.on===!1)return{until:b,why:"off"};if(t.notifications?.snoozed_until===m)return{until:m,why:"snoozed"};const n=t.notifications?.snoozed_until?new Date(t.notifications.snoozed_until):null,o=f(s=>(n>s?["snoozed",n]:null)||D(s,t)&&["weekend",D(s,t)]||U(s,t)&&["overnight",v(s,t).endsAt],"at"),r=o(e);if(!r)return null;let i=r[1];for(let s=o(i),a=0;s&&a<8;s=o(i),a++)i=s[1];return{until:i,why:r[0]}}f(L,"hold");function K(e,t){return L(e,t)?.until??null}f(K,"heldUntil");function Q(e,t){let n=O(e,t);for(let o=0;o<8;o++){const r=K(n,t);if(r===b||r===m)return r;if(!r)return n;n=O(new Date(r.getTime()-1),t)}return n}f(Q,"flushAt");function Z(e,t,n){const o=String(t),[r,i]=[n.windows[0].from,n.windows.length];let s;if(o==="morning")s=_(e,r);else if(o==="tonight")s=i>1?_(e,n.windows[Math.max(1,i-2)].from):null;else if(o==="monday")s=c(e,r,(8-e.getDay())%7),s<=e&&(s=c(s,r,7));else return T(e,o);return j(e,s)?s:null}f(Z,"snoozeEnd");function ee(e,t=new Date){if(e.why==="off")return"notifications are off";if(e.until===m)return"snoozed until you end it";const n=E(e.until,t);return e.why==="snoozed"?`snoozed until ${n}`:e.why==="weekend"?`held for the weekend until ${n}`:`held overnight until ${n}`}f(ee,"holdLine");function O(e,t){const n=v(e,t),o=n.every_minutes*6e4,r=new Date(n.startedAt.getTime()+(Math.floor((e-n.startedAt)/o)+1)*o);return r<n.endsAt?r:n.endsAt}f(O,"nextSlot");function V(e){return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")}`}f(V,"localDay");function te(e,t,n={}){const o=V(e);return t.wakes.filter(r=>c(e,r)<=e&&n?.[r]!==o)}f(te,"wakesDue");const X=f(e=>/^\/([^/?#]+)(?:[/?#]|$)/.exec(e||"")?.[1]??null,"routeOf");function ne(e,t={}){const n=new Map;e.forEach((r,i)=>{r.tag&&n.set(r.tag,i)});const o=new Map;return e.filter((r,i)=>!r.tag||n.get(r.tag)===i).forEach(r=>{const i=X(r.url)??Symbol(r.id);o.set(i,[...o.get(i)||[],r])}),[...o].flatMap(([r,i])=>{if(i.length===1){const[l]=i;return[{title:l.title,body:l.body||"",url:l.url||"/",tag:l.tag||void 0,kind:l.kind,members:[l.id]}]}const s=t[r];if(s&&!s.count)return[];const a=r==="_hub"?"Hub":r,u=i.map(l=>l.title),h=s?`${s.count} need${s.count===1?"s":""} you on ${a}`:`${i.length} updates on ${a}`,y=s?s.summary:[...u.slice(0,2),...u.length>2?[`and ${u.length-2} more`]:[]].join(" \xB7 "),N=i.every(l=>l.kind==="action")?"action":i.find(l=>l.kind!=="action").kind;return[{title:h,body:y,url:`/${r}/`,tag:`summary:${r}`,kind:N,members:i.map(l=>l.id)}]})}f(ne,"notifications");const oe=f((e,t=new Date)=>e===m?"once the snooze ends":E(e,t),"sendWords");function E(e,t=new Date){const n=e.getHours(),o=`${n%12||12}:${String(e.getMinutes()).padStart(2,"0")}${n<12?"am":"pm"}`,r=Math.round((c(e,"00:00")-c(t,"00:00"))/864e5);return r===0?o:r===1?`${o} tomorrow`:`${o} ${e.toDateString().slice(0,10)}`}f(E,"clock");export{d as DEFAULTS,b as NEVER,q as checksADay,R as checksIn,E as clock,B as daySegments,J as editPolling,Q as flushAt,P as fromPush,K as heldUntil,L as hold,ee as holdLine,z as holdsOvernight,U as holdsPushes,V as localDay,O as nextSlot,ne as notifications,G as pollingOf,oe as sendWords,Z as snoozeEnd,F as splitPoint,A as validate,te as wakesDue,v as windowAt,k as windowSpan};
1
+ var S=Object.defineProperty;var i=(t,e)=>S(t,"name",{value:e,configurable:!0});import{FOREVER as c,durationEnd as O,inRange as H}from"./duration.mjs";const w=Object.freeze({quiet:Object.freeze({start:"22:00",end:"08:00",hold:!0}),notifications:Object.freeze({on:!0,weekends_off:!1,snoozed_until:null})}),N="never",f=Object.freeze({present:1,away:5,lowPresent:5,lowAway:15}),A=1e3,Q=f.lowAway,h=/^([01]\d|2[0-3]):([0-5]\d)$/,m=f.away*6e4,d=i(t=>{const[e,n]=t.split(":").map(Number);return e*60+n},"minutes"),a=i((t,e,n=0)=>{const[o,r]=e.split(":").map(Number);return new Date(t.getFullYear(),t.getMonth(),t.getDate()+n,o,r)},"atOn"),y=i((t,e)=>{const n=a(t,e);return n>t?n:a(t,e,1)},"nextAt"),q=i((t,e)=>{const n=a(t,e);return n<=t?n:a(t,e,-1)},"lastAt"),x=i(t=>({quiet:{...t.quiet},notifications:{...t.notifications}}),"copy");function v({present:t,remaining:e=null}){const n=e!==null&&e<A;return t?n?f.lowPresent:f.present:n?f.lowAway:f.away}i(v,"checkEvery");function U({last:t,present:e,remaining:n=null,backAt:o=null}){return o&&o>t?new Date(o):new Date(t.getTime()+v({present:e,remaining:n})*6e4)}i(U,"nextCheckAt");function _(t){const e=t?.on??w.notifications.on,n=t?.weekends_off??w.notifications.weekends_off,o=t?.snoozed_until??null;return typeof e!="boolean"?{error:"Notifications must be on or off."}:typeof n!="boolean"?{error:"Off on weekends must be on or off."}:o!==null&&o!==c&&(typeof o!="string"||Number.isNaN(Date.parse(o)))?{error:"Snooze must end at a date and time, or be forever."}:{notifications:{on:e,weekends_off:n,snoozed_until:o===null||o===c?o:new Date(o).toISOString()}}}i(_,"validateNotifications");function b(t){const{start:e,end:n,hold:o=w.quiet.hold}=t?.quiet||{},{notifications:r,error:u}=_(t?.notifications);return u?{error:u}:typeof e!="string"||!h.test(e)?{error:"Quiet hours' start must be HH:MM (24-hour)."}:typeof n!="string"||!h.test(n)?{error:"Quiet hours' end must be HH:MM (24-hour)."}:e===n?{error:"Quiet hours must start and end at different times."}:typeof o!="boolean"?{error:"Hold pushes must be on or off."}:{settings:{quiet:{start:e,end:n,hold:o},notifications:r}}}i(b,"validate");function T(t){const e=(Array.isArray(t?.windows)?t.windows:[]).map(n=>n?.from).filter(n=>typeof n=="string"&&h.test(n)).sort((n,o)=>d(n)-d(o));return e.length<2?null:{start:e.at(-1),end:e[0],hold:t.hold_overnight!==!1}}i(T,"fromPolling");function W(t){const e=_(t?.notifications).notifications||x(w).notifications;return b({quiet:t?.quiet,notifications:e}).settings||b({quiet:T(t?.polling),notifications:e}).settings||{...x(w),notifications:e}}i(W,"quietOf");function Y(t,e){const n={...t.quiet};return"start"in e&&(n.start=e.start),"end"in e&&(n.end=e.end),"hold"in e&&(n.hold=e.hold==="on"),{quiet:n,notifications:t.notifications}}i(Y,"editQuiet");function $(t,e){const n=t.getHours()*60+t.getMinutes(),[o,r]=[d(e.quiet.start),d(e.quiet.end)];return o<r?n>=o&&n<r:n>=o||n<r}i($,"quietAt");function K(t,e){if(!$(t,e))return null;const n=q(t,e.quiet.start),o=(d(e.quiet.end)-d(e.quiet.start)+1440)%1440,r=new Date(n.getFullYear(),n.getMonth(),n.getDate(),n.getHours(),n.getMinutes()+o/2);return r<=t?r:null}i(K,"quietMiddleOf");function C(t,e){return e.quiet.hold&&$(t,e)&&t-q(t,e.quiet.start)>m}i(C,"holdsPushes");function D(t,e){if(!e.notifications?.weekends_off)return null;let n=a(t,e.quiet.start,-((t.getDay()-5+7)%7));n>t&&(n=a(n,e.quiet.start,-7));const o=a(n,e.quiet.end,3);return t-n>m&&t<o?o:null}i(D,"weekendEnd");function R(t,e){if(e.notifications?.on===!1)return{until:N,why:"off"};if(e.notifications?.snoozed_until===c)return{until:c,why:"snoozed"};const n=e.notifications?.snoozed_until?new Date(e.notifications.snoozed_until):null,o=i(s=>(n>s?["snoozed",n]:null)||D(s,e)&&["weekend",D(s,e)]||C(s,e)&&["overnight",y(s,e.quiet.end)],"at"),r=o(t);if(!r)return null;let u=r[1];for(let s=o(u),p=0;s&&p<8;s=o(u),p++)u=s[1];return{until:u,why:r[0]}}i(R,"hold");function j(t,e){return R(t,e)?.until??null}i(j,"heldUntil");function V(t,e){return j(t,e)??new Date(t.getTime()+f.away*6e4)}i(V,"flushAt");function G(t,e,n){const o=String(e);let r;if(o==="morning")r=y(t,n.quiet.end);else if(o==="tonight")r=y(t,n.quiet.start);else if(o==="monday")r=a(t,n.quiet.end,(8-t.getDay())%7),r<=t&&(r=a(r,n.quiet.end,7));else return O(t,o);return H(t,r)?r:null}i(G,"snoozeEnd");function B(t,e=new Date){if(t.why==="off")return"notifications are off";if(t.until===c)return"snoozed until you end it";const n=k(t.until,e);return t.why==="snoozed"?`snoozed until ${n}`:t.why==="weekend"?`held for the weekend until ${n}`:`held overnight until ${n}`}i(B,"holdLine");function F(t){return`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")}`}i(F,"localDay");function J(t,e,n={}){const o=e.quiet.end;return a(t,o)<=t&&n?.[o]!==F(t)?[o]:[]}i(J,"wakesDue");const I=i(t=>/^\/([^/?#]+)(?:[/?#]|$)/.exec(t||"")?.[1]??null,"routeOf");function X(t,e={}){const n=new Map;t.forEach((r,u)=>{r.tag&&n.set(r.tag,u)});const o=new Map;return t.filter((r,u)=>!r.tag||n.get(r.tag)===u).forEach(r=>{const u=I(r.url)??Symbol(r.id);o.set(u,[...o.get(u)||[],r])}),[...o].flatMap(([r,u])=>{if(u.length===1){const[l]=u;return[{title:l.title,body:l.body||"",url:l.url||"/",tag:l.tag||void 0,kind:l.kind,members:[l.id]}]}const s=e[r];if(s&&!s.count)return[];const p=r==="_hub"?"Hub":r,g=u.map(l=>l.title),z=s?`${s.count} need${s.count===1?"s":""} you on ${p}`:`${u.length} updates on ${p}`,E=s?s.summary:[...g.slice(0,2),...g.length>2?[`and ${g.length-2} more`]:[]].join(" \xB7 "),M=u.every(l=>l.kind==="action")?"action":u.find(l=>l.kind!=="action").kind;return[{title:z,body:E,url:`/${r}/`,tag:`summary:${r}`,kind:M,members:u.map(l=>l.id)}]})}i(X,"notifications");const Z=i((t,e=new Date)=>t===c?"once the snooze ends":k(t,e),"sendWords");function k(t,e=new Date){const n=t.getHours(),o=`${n%12||12}:${String(t.getMinutes()).padStart(2,"0")}${n<12?"am":"pm"}`,r=Math.round((a(t,"00:00")-a(e,"00:00"))/864e5);return r===0?o:r===1?`${o} tomorrow`:`${o} ${t.toDateString().slice(0,10)}`}i(k,"clock");export{f as CHECK_MINUTES,w as DEFAULTS,A as LOW_POINTS,N as NEVER,Q as SLOWEST_CHECK_MINUTES,v as checkEvery,k as clock,Y as editQuiet,V as flushAt,T as fromPolling,j as heldUntil,R as hold,B as holdLine,C as holdsPushes,F as localDay,U as nextCheckAt,X as notifications,$ as quietAt,K as quietMiddleOf,W as quietOf,Z as sendWords,G as snoozeEnd,b as validate,J as wakesDue};
package/hub/sessions.mjs CHANGED
@@ -1,2 +1,2 @@
1
- var z=Object.defineProperty;var a=(e,s)=>z(e,"name",{value:s,configurable:!0});import x from"node:fs";import h from"node:path";import A from"node:os";import{spawn as B,spawnSync as V}from"node:child_process";import{fileURLToPath as Z}from"node:url";import{joinName as ee,routeRecord as m,validRoute as se}from"./routes.mjs";import{withFileLock as te}from"./locks.mjs";import{RESUME_STATE as R,carryCursor as oe,hubBoard as ne,isListening as re,listBoards as M,oldestUnheard as ie,readManifest as y,readJSON as ae,saveJSON as ce,watchedCounts as de,watchedLines as ue,watcherCursor as le,withManifestLock as fe,writeManifest as pe}from"./boards.mjs";import{readUsageGate as me,heldUsageEvent as he}from"../shared/usage-gate.mjs";import{liveSessions as S,sessionRecords as we,processStarts as be,verifiedProcess as ge}from"./live.mjs";import{deliver as $e}from"./push.mjs";import{WAIT_MS as ke}from"./prompts.mjs";import{nodeBin as ye,storedPath as G}from"../bin/install.mjs";import{reposDir as Ie}from"../shared/repos.mjs";import{isClone as Se}from"./repos.mjs";import{trustFolder as Te}from"./trust.mjs";const C=G(h.resolve(h.dirname(Z(import.meta.url)),"..")),_e=3*6e4,xe=5*6e4,ve=15*6e4,Oe=a((e,s=Date.now())=>!!e.error||s-Date.parse(e.asked_at||e.since)>=ve,"startFailed");function is(e,s=Date.now()){return Object.entries(e.starting||{}).filter(([t])=>!m(e.sites,t)).map(([t,o])=>({route:t,title:o.title||o.label||t,repo:o.repo,...o.phase?{phase:o.phase}:{},...o.branch?{branch:o.branch,tickets:o.tickets}:{},askedAt:o.asked_at||o.since,failed:Oe(o,s),...o.error?{error:o.error}:{}}))}a(is,"startingBoards");const Ee=ke/1e3+60,je=JSON.stringify({worktree:{bgIsolation:"none"},hooks:{PermissionRequest:[{matcher:"*",hooks:[{type:"command",command:`"${ye()}" "${h.join(C,"hub","permission-hook.mjs")}"`,timeout:Ee}]}]}});class p extends Error{static{a(this,"SessionError")}constructor(s,t){super(s),this.reason=t}}function Ae(e,s){if(!se(e))throw new p("Choose a board route.","none");return te(h.join(A.homedir(),".claude","kickflip","session-locks",e),()=>s({resume:a(t=>Ge(e,t),"resume")}),{staleAfter:12e4,timeout:9e4})}a(Ae,"withSessionLock");function v(){if(process.env.KICKFLIP_CLAUDE)return process.env.KICKFLIP_CLAUDE;const e=h.join(A.homedir(),".local","bin","claude");return x.existsSync(e)?e:"claude"}a(v,"claudeBin");const O=a(e=>e==="CLAUDECODE"||e==="CLAUDE_PID"||e==="CLAUDE_EFFORT"||e.startsWith("CLAUDE_CODE_"),"inherited");function F(){return process.env.KICKFLIP_TMUX?process.env.KICKFLIP_TMUX:["/opt/homebrew/bin/tmux","/usr/local/bin/tmux","/usr/bin/tmux"].find(e=>x.existsSync(e))||"tmux"}a(F,"tmuxBin");function K(e){const s=V(F(),e,{encoding:"utf8",timeout:1e4,env:Object.fromEntries(Object.entries(process.env).filter(([t])=>!O(t)))});return s.error?.code==="ENOENT"?{status:127,stdout:"",stderr:"tmux isn't installed; kickflip doctor names how to install it"}:{status:s.status??1,stdout:s.stdout||"",stderr:s.stderr||s.error?.message||""}}a(K,"runTmux");const T=a(e=>`kickflip-${e}`,"tmuxName"),J=a((e,s)=>e(["has-session","-t",`=${T(s)}`]).status===0,"hasTmux");function L(e,{cwd:s,timeout:t=6e4}){const o=v(),i=Object.fromEntries(Object.entries(process.env).filter(([r])=>!O(r)));return i.CLAUDE_BG_ISOLATION="none",h.isAbsolute(o)&&(i.PATH=`${h.dirname(o)}:${i.PATH||""}`),new Promise(r=>{let n="",f="",d;try{d=B(o,e,{cwd:s,env:i,detached:!0,stdio:["ignore","pipe","pipe"]})}catch(l){r({status:1,stdout:n,stderr:l.message});return}const c=a(l=>{clearTimeout(w),d.stdout.destroy(),d.stderr.destroy(),r({status:l,stdout:n,stderr:f})},"done"),w=setTimeout(()=>{try{d.kill("SIGKILL")}catch{}c(1)},t);d.stdout.on("data",l=>{n+=l}),d.stderr.on("data",l=>{f+=l}),d.on("error",l=>{f+=l.message,c(1)}),d.on("close",l=>c(l??1)),d.on("exit",l=>setTimeout(()=>c(l??1),300)),d.unref()})}a(L,"runClaude");async function Ce(e,{cwd:s,run:t=L,starts:o=be,signal:i=process.kill}={}){if(e.kind==="bg"){const r=await t(["stop",e.jobId||e.sessionId.slice(0,8)],{cwd:s,timeout:8e3});if(r.status!==0)throw new p(`claude stop failed: ${(r.stderr||r.stdout).trim().slice(0,300)}`,"failed")}else{if(!ge(e,o))throw new p(`Cannot verify session ${e.sessionId}'s process identity; refusing to signal pid ${e.pid}.`,"failed");i(e.pid,"SIGTERM")}}a(Ce,"runSessionStop");async function as(e,{live:s=S,stop:t=Ce}={}){if(!e?.session)return;const o=s().get(e.session);if(!o)return;await t(o,{cwd:e.repo||o.cwd||A.homedir()});const i=Date.now()+5e3;for(;s().get(e.session)?.pid===o.pid;){if(Date.now()>=i)throw new p(`session ${e.session} did not stop`,"failed");await new Promise(r=>setTimeout(r,100))}}a(as,"stopSession");function Le(e){const s=`=${T(e)}`;return De(F(),process.env.TMUX?["switch-client","-t",s]:["attach-session","-t",s])}a(Le,"attachTmux");function De(e,s){const t=Object.fromEntries(Object.entries(process.env).filter(([r])=>!O(r)&&r!=="CLAUDE_BG_ISOLATION")),o=a(()=>{},"ignore");process.on("SIGINT",o).on("SIGQUIT",o);const i=a(()=>process.off("SIGINT",o).off("SIGQUIT",o),"settle");return new Promise((r,n)=>{const f=B(e,s,{env:t,stdio:"inherit"});f.on("error",d=>{i(),n(new p(d.message,"failed"))}),f.on("close",d=>{i(),r({status:d??1})})})}a(De,"runForeground");const D=a(({name:e,model:s})=>["--name",e,"--permission-mode","auto","--settings",je,...s?["--model",s]:[]],"common"),Ue=a(e=>`'${e.replaceAll("'","'\\''")}'`,"quote"),E=a((e,s)=>`--name ${Ue(ee(e,s))}`,"boardFlag"),Pe=a(({route:e,name:s,model:t,prompt:o})=>["--bg",...D({name:s,model:t}),`/kickflip ${E(e,s)}${o?` ${o}`:""}`],"startArgv"),Ne=a((e,s,t)=>`The hub resumed this session because /${e} had nobody listening. Rejoin the board as session ${s} (kickflip steps 3 and 5): rerun \`node ${h.join(C,"bin","kickflip.mjs")} join ${E(e,t)} --session ${s}\`, relaunch its watcher as a background task, then go on with what its events ask. If your session id (the folder above your scratchpad) isn't ${s}, rejoin with yours and --force.`,"resumePrompt"),Be=a(({id:e,route:s,name:t,model:o,background:i})=>["--resume",e,"--bg",...i?[]:D({name:t,model:o}),Ne(s,e,t)],"resumeArgv"),W=a((e,s)=>`You are the hub session. Read \`${h.join(C,"docs","hub-session.md")}\`, then rejoin /${e} (join ${E(e,s)}) and relaunch its watcher.`,"hubPrompt"),H=a(({route:e,title:s,id:t,model:o})=>[...t?["--resume",t]:[],...D({name:s,model:o}),t?`${W(e,s)} If your session id (the folder above your scratchpad) isn't ${t}, rejoin with yours and --force.`:`/kickflip ${E(e,s)} --hub
2
- ${W(e,s)}`],"hubArgv");function X(e,s,{cwd:t,tmux:o}){const i=v(),r=[...new Set(["CLAUDECODE","CLAUDE_BG_ISOLATION",...Object.keys(process.env).filter(O)])].flatMap(f=>["-u",f]),n=o(["new-session","-d","-s",T(e),"-c",t,"/usr/bin/env",...r,...h.isAbsolute(i)?[`PATH=${h.dirname(i)}:${process.env.PATH||""}`]:[],i,...s]);if(n.status!==0)throw new p(`tmux new-session failed (${n.status}): ${(n.stderr||n.stdout).trim().slice(0,300)}`,"failed");return[i,...s]}a(X,"startTmux");async function cs({manifest:e=y(),repo:s,tmux:t=K,attach:o=Le,live:i=S}={}){const r=ne(e);if(!r)throw new p("No hub board is flagged; flag one with kickflip set <route> --hub, or run kickflip join --hub in its repo folder","hub");if(!J(t,r.route)){const n=r.owner?.session?r.owner:null;if(n&&!n.repo&&!s)throw new p(`/${r.route}'s session ${n.session} has no folder recorded; pass --repo <its folder>`,"none");P(n,i);const f=n?.repo||h.resolve(s||process.cwd());if(!x.statSync(f,{throwIfNoEntry:!1})?.isDirectory())throw new p(n?.repo?`/${r.route}'s recorded folder ${f} is gone; rejoin from its new folder with --repo`:`no folder at ${f}`,"none");X(r.route,H({route:r.route,title:r.title,id:n?.session,model:n?.model}),{cwd:f,tmux:t})}return o(r.route)}a(cs,"hubSession");function U(e,s){const t=m(e?.sites,s),o=t?.owner?.session?t.owner:m(e?.starting,s);return o?.session?{...o,title:t?.title||s}:null}a(U,"sessionOf");async function q(e,s=null){const t=e.match(/backgrounded\s*·\s*([0-9a-f]{8})\b/)?.[1]||e.match(/\bclaude attach ([0-9a-f]{8})\b/)?.[1];if(!t)return{session:s,confirmed:!1};if(s?.startsWith(t))return{session:s,confirmed:!0};for(let o=0;o<20;o++){const i=we().find(r=>r.jobId===t||r.sessionId.startsWith(t));if(i)return{session:i.sessionId,confirmed:!0};await new Promise(r=>setTimeout(r,500))}return{session:s,confirmed:!1}}a(q,"startedId");async function Re(e,{run:s,cwd:t}){const o=await s(["agents","--json","--all"],{cwd:t});try{return JSON.parse(o.stdout).some(i=>i?.sessionId===e&&i.kind==="background")}catch{return!1}}a(Re,"isBackground");function P(e,s){const t=e&&s().get(e.session);if(t)throw new p(`session ${e.session} is live (${t.kind==="bg"?"in the background":"in a terminal"}, pid ${t.pid}); it gets no second process`,"live")}a(P,"refuseLive");async function Q(e,{from:s,to:t,mark:o}){await fe(async()=>{const i=y(),r=M(i).find(f=>f.route===e);if(r?.owner?.session===s&&(o||t!==s)&&oe(r,{from:s,to:t,mark:o}),t===s)return;const n=m(i.sites,e);if(n?.owner?.session===s)Object.assign(n,{owner:{...n.owner,session:t},updated:new Date().toISOString()});else if(m(i.starting,e)?.session===s)i.starting[e]={...m(i.starting,e),session:t};else return;await pe(i)})}a(Q,"moveOwner");async function ds(e,{manifest:s=y(),repo:t,model:o,prompt:i,run:r=L,live:n=S,clones:f=Ie()}={}){if(m(s.closed,e))throw new p(`/${e} is closed; use kickflip reopen ${e}`,"none");const d=U(s,e);P(d,n);const c=h.resolve(t||d?.repo||process.cwd());if(!x.statSync(c,{throwIfNoEntry:!1})?.isDirectory())throw new p(`no folder at ${c}`,"none");if(Se(c,f))try{Te(c)}catch(g){throw new p(`Couldn't trust this folder for Claude Code: ${g.message}`,"failed")}const w=m(s?.sites,e)?.title||m(s?.starting,e)?.title||e,l=Pe({route:e,name:w,model:o||d?.model,prompt:i}),u=await r(l,{cwd:c});if(u.status!==0){const g=(u.stderr||"").trim()||(u.stdout||"").trim();throw new p(g?`claude --bg failed (${u.status}): ${g.slice(0,300)}`:`claude exited ${u.status} with no output; check \`claude agents\``,"failed")}const{session:$,confirmed:I}=await q(u.stdout);if(!$)throw new p(`claude --bg started a session but no session record names it yet; find it with claude agents (${u.stdout.trim().slice(0,300)})`,"failed");return{route:e,session:$,confirmed:I,repo:G(c),model:o||d?.model||null,name:w,argv:[v(),...l],output:u.stdout.trim()}}a(ds,"startSession");function Me(e,s={}){return Ae(e,({resume:t})=>t(s))}a(Me,"resumeSession");async function Ge(e,{manifest:s=y(),run:t=L,tmux:o=K,live:i=S,mark:r=null}={}){if(m(y().closed,e))throw new p(`/${e} is closed; use kickflip reopen ${e}`,"none");const n=U(s,e);if(!n)throw new p(`/${e} has no recorded session`,"none");if(!n.repo)throw new p(`/${e}'s session ${n.session} has no folder recorded; its session records one by rejoining (kickflip join, --repo)`,"none");if(P(n,i),m(s.sites,e)?.hub===!0){if(J(o,e))throw new p(`/${e}'s tmux session ${T(e)} is still open; attach with kickflip hub`,"live");const u=X(e,H({route:e,title:n.title,id:n.session,model:n.model}),{cwd:n.repo,tmux:o});return await Q(e,{from:n.session,to:n.session,mark:r}),{route:e,session:n.session,previous:n.session,confirmed:!1,repo:n.repo,argv:u,output:`in tmux session ${T(e)}`}}const f=await Re(n.session,{run:t,cwd:n.repo}),d=Be({id:n.session,route:e,name:n.title,model:n.model,background:f}),c=await t(d,{cwd:n.repo});if(c.status!==0)throw new p(`claude --resume failed (${c.status}): ${(c.stderr||c.stdout).trim().slice(0,300)}`,"failed");const{session:w,confirmed:l}=await q(c.stdout,n.session);return await Q(e,{from:n.session,to:w,mark:r}),{route:e,session:w,previous:n.session,confirmed:l,repo:n.repo,argv:[v(),...d],output:c.stdout.trim()}}a(Ge,"resumeRecordedSession");function Fe(e,{listening:s,live:t,recorded:o,counts:i,waiting:r,asked:n=!1,now:f}){if(s)return{action:"none",entry:null};const d=new Date(f).toISOString(),c=e?{...e}:{down_since:d};return t?delete c.mark:!c.mark&&i&&(c.mark=i),c.pushed_at?{action:"none",entry:c}:c.resumed_at?c.error||f-Date.parse(c.resumed_at)>=xe?{action:"push",entry:{...c,pushed_at:d}}:{action:"none",entry:c}:!o||t?{action:"none",entry:c}:!!(e?.mark&&i&&Object.entries(i).some(([l,u])=>u>(m(e.mark,l)??0)))||n||r&&f-Date.parse(c.down_since)>=_e?{action:"resume",entry:{...c,resumed_at:d}}:{action:"none",entry:c}}a(Fe,"resumeStep");async function us({manifest:e=y(),now:s=new Date,live:t=S,asked:o=new Set,listening:i=a((d,c)=>re(d,{now:s.getTime(),running:c}),"listening"),resume:r=a((d,c)=>Me(d,{manifest:e,live:t,mark:c}),"resume"),push:n=$e,gate:f=me({now:s})}={}){const d=ae(R,{}),c=t(),w=Object.create(null),l={resumed:[],pushed:[],failed:[]};for(const u of M(e)){const $=U(e,u.route),I=!!($&&c.has($.session)),g=i(u,c);let{action:_,entry:b}=Fe(m(d,u.route),{listening:g,live:I,recorded:!!$?.repo,now:s.getTime(),asked:o.has(u.route),counts:g?null:de(u),waiting:!g&&!I&&ie(u)!==null});if(_==="resume"&&f.state==="paused"){const k=m(d,u.route)?.mark||le(u);Object.entries(ue(u)).flatMap(([j,Y])=>Y.slice(m(k,j)||0)).some(j=>!he(j))||(_="none",delete b.resumed_at)}if(_==="resume")try{await r(u.route,b.mark),l.resumed.push(u.route)}catch(k){b={...b,error:k.message},l.failed.push(`${u.route}: could not resume its session (${k.message})`)}else if(_==="push"){const k=b.error?`The hub couldn't resume it: ${b.error}`:I?"Its session is running but hasn't relaunched its watcher.":`It still isn't listening after the hub resumed it. Try kickflip resume ${u.route}, or claude agents.`;try{await n({title:`${u.title}'s session didn't come back`,body:k,url:`/${u.route}/`,tag:`resume-${u.route}`},{kind:"fault",immediate:!0,at:s}),l.pushed.push(u.route)}catch(N){b={...b,pushed_at:void 0},l.failed.push(`${u.route}: could not push that its session didn't come back (${N.message})`)}}b&&(w[u.route]=b)}return JSON.stringify(w)!==JSON.stringify(d)&&ce(R,w),l}a(us,"checkSessions");export{je as BG_SETTINGS,xe as COMEBACK_WAIT,Ee as PROMPT_HOOK_TIMEOUT_S,_e as RESUME_AFTER,ve as STARTING_WAIT,p as SessionError,Le as attachTmux,us as checkSessions,v as claudeBin,H as hubArgv,cs as hubSession,Be as resumeArgv,Ne as resumePrompt,Me as resumeSession,Fe as resumeStep,L as runClaude,Ce as runSessionStop,K as runTmux,U as sessionOf,Pe as startArgv,Oe as startFailed,ds as startSession,is as startingBoards,as as stopSession,F as tmuxBin,T as tmuxName,Ae as withSessionLock};
1
+ var ne=Object.defineProperty;var a=(e,s)=>ne(e,"name",{value:s,configurable:!0});import x from"node:fs";import w from"node:path";import j from"node:os";import{spawn as K,spawnSync as oe}from"node:child_process";import{fileURLToPath as ie}from"node:url";import{joinName as re,routeRecord as h,validRoute as ae}from"./routes.mjs";import{withFileLock as ce}from"./locks.mjs";import{RESUME_STATE as D,carryCursor as ue,hubBoard as W,isListening as de,listBoards as L,listenState as le,oldestUnheard as fe,readManifest as I,readJSON as P,saveJSON as J,watchedCounts as pe,watchedLines as me,watcherCursor as he,withManifestLock as we,writeManifest as ge}from"./boards.mjs";import{readUsageGate as be,heldUsageEvent as ke}from"../shared/usage-gate.mjs";import{liveSessions as _,sessionRecords as $e,processStarts as ye,verifiedProcess as Ie}from"./live.mjs";import{deliver as Se}from"./push.mjs";import{WAIT_MS as _e}from"./prompts.mjs";import{nodeBin as H,storedPath as X}from"../bin/install.mjs";import{reposDir as Te}from"../shared/repos.mjs";import{isClone as xe}from"./repos.mjs";import{trustFolder as je}from"./trust.mjs";import{contextFull as Oe,contextReading as ve}from"./usage.mjs";const O=X(w.resolve(w.dirname(ie(import.meta.url)),"..")),Ae=3*6e4,Ee=5*6e4,Ce=15*6e4,q=w.join(j.homedir(),".claude","kickflip","compact-state.json"),De=a((e,s=Date.now())=>!!e.error||s-Date.parse(e.asked_at||e.since)>=Ce,"startFailed");function Le(e){return e.joined_at?{step:"landing",stepAt:e.joined_at}:e.phase==="cloning"?{step:"cloning",stepAt:e.asked_at}:{step:"starting",stepAt:e.phase_at||e.asked_at||e.since}}a(Le,"startStep");function ps(e,s=Date.now(),t=()=>{},r=()=>!0){return Object.entries(e.starting||{}).filter(([i,n])=>h(e.sites,i)?n.joined_at&&!r(i):!0).map(([i,n])=>{const o=De(n,s);return{route:i,title:n.title||n.label||i,repo:n.repo,...Le(n),...n.phase==="cloning"&&t(i)!==void 0?{percent:t(i)}:{},...n.branch?{branch:n.branch,tickets:n.tickets}:{},askedAt:n.asked_at||n.since,failed:o,...n.joined_at?{joined:!0}:{},...n.error?{error:n.error}:o&&n.joined_at?{error:"Its session joined, but the board is not listening yet."}:{}}})}a(ps,"startingBoards");const Pe=_e/1e3+60,Ue=JSON.stringify({worktree:{bgIsolation:"none"},permissions:{deny:["Workflow"]},hooks:{PermissionRequest:[{matcher:"*",hooks:[{type:"command",command:`"${H()}" "${w.join(O,"hub","permission-hook.mjs")}"`,timeout:Pe}]}],PermissionDenied:[{matcher:"Bash",hooks:[{type:"command",command:`"${H()}" "${w.join(O,"hub","permission-denied-hook.mjs")}"`,timeout:15}]}]}});class m extends Error{static{a(this,"SessionError")}constructor(s,t){super(s),this.reason=t}}function Ne(e,s){if(!ae(e))throw new m("Choose a board route.","none");return ce(w.join(j.homedir(),".claude","kickflip","session-locks",e),()=>s({resume:a(t=>Xe(e,t),"resume")}),{staleAfter:12e4,timeout:9e4})}a(Ne,"withSessionLock");function v(){if(process.env.KICKFLIP_CLAUDE)return process.env.KICKFLIP_CLAUDE;const e=w.join(j.homedir(),".local","bin","claude");return x.existsSync(e)?e:"claude"}a(v,"claudeBin");const A=a(e=>e==="CLAUDECODE"||e==="CLAUDE_PID"||e==="CLAUDE_EFFORT"||e.startsWith("CLAUDE_CODE_"),"inherited");function Q(){return process.env.KICKFLIP_TMUX?process.env.KICKFLIP_TMUX:["/opt/homebrew/bin/tmux","/usr/local/bin/tmux","/usr/bin/tmux"].find(e=>x.existsSync(e))||"tmux"}a(Q,"tmuxBin");function U(e){const s=oe(Q(),e,{encoding:"utf8",timeout:1e4,env:Object.fromEntries(Object.entries(process.env).filter(([t])=>!A(t)))});return s.error?.code==="ENOENT"?{status:127,stdout:"",stderr:"tmux isn't installed; kickflip doctor names how to install it"}:{status:s.status??1,stdout:s.stdout||"",stderr:s.stderr||s.error?.message||""}}a(U,"runTmux");const S=a(e=>`kickflip-${e}`,"tmuxName"),N=a((e,s)=>e(["has-session","-t",`=${S(s)}`]).status===0,"hasTmux");async function ms({manifest:e=I(),now:s=new Date,running:t=_(),wakes:r=P(D,{}),tmux:i=U}={}){if(!t)return{compacted:[],failed:[]};const n=Object.assign(Object.create(null),P(q,{})),o=[],l=[];let u=!1;const c=W(e)?.route;for(const p of L(e)){const f=ve(p.owner?.session);if(!Oe(le(p,{now:s.getTime(),running:t,wakes:r}),f)){h(n,p.route)&&(delete n[p.route],u=!0);continue}if(p.route!==c||h(n,p.route)||!N(i,p.route))continue;const d=`=${S(p.route)}:`,$=i(["send-keys","-t",d,"-l","/compact"]),k=$.status===0?i(["send-keys","-t",d,"Enter"]):$,g=k.status===0?null:(k.stderr||k.stdout).trim().slice(0,300)||`tmux exited ${k.status}`;n[p.route]={session:p.owner.session,percent:f.percent,at:s.toISOString(),...g?{error:g}:{}},u=!0,g?l.push(`${p.route}: could not send /compact to tmux session ${S(p.route)} (${g})`):o.push(p.route)}return u&&J(q,n),{compacted:o,failed:l}}a(ms,"compactFullContexts");function B(e,{cwd:s,timeout:t=6e4}){const r=v(),i=Object.fromEntries(Object.entries(process.env).filter(([n])=>!A(n)));return i.CLAUDE_BG_ISOLATION="none",w.isAbsolute(r)&&(i.PATH=`${w.dirname(r)}:${i.PATH||""}`),new Promise(n=>{let o="",l="",u;try{u=K(r,e,{cwd:s,env:i,detached:!0,stdio:["ignore","pipe","pipe"]})}catch(f){n({status:1,stdout:o,stderr:f.message});return}const c=a(f=>{clearTimeout(p),u.stdout.destroy(),u.stderr.destroy(),n({status:f,stdout:o,stderr:l})},"done"),p=setTimeout(()=>{try{u.kill("SIGKILL")}catch{}c(1)},t);u.stdout.on("data",f=>{o+=f}),u.stderr.on("data",f=>{l+=f}),u.on("error",f=>{l+=f.message,c(1)}),u.on("close",f=>c(f??1)),u.on("exit",f=>setTimeout(()=>c(f??1),300)),u.unref()})}a(B,"runClaude");async function Be(e,{cwd:s,run:t=B,starts:r=ye,signal:i=process.kill}={}){if(e.kind==="bg"){const n=await t(["stop",e.jobId||e.sessionId.slice(0,8)],{cwd:s,timeout:8e3});if(n.status!==0)throw new m(`claude stop failed: ${(n.stderr||n.stdout).trim().slice(0,300)}`,"failed")}else{if(!Ie(e,r))throw new m(`Cannot verify session ${e.sessionId}'s process identity; refusing to signal pid ${e.pid}.`,"failed");i(e.pid,"SIGTERM")}}a(Be,"runSessionStop");async function hs(e,{live:s=_,stop:t=Be}={}){if(!e?.session)return;const r=s();if(!r)throw new m(ee(e.session),"unknown");const i=r.get(e.session);if(!i)return;await t(i,{cwd:e.repo||i.cwd||j.homedir()});const n=Date.now()+5e3;for(let o=s();!o||o.get(e.session)?.pid===i.pid;o=s()){if(Date.now()>=n)throw new m(`session ${e.session} did not stop`,"failed");await new Promise(l=>setTimeout(l,100))}}a(hs,"stopSession");function Me(e){const s=`=${S(e)}`;return Re(Q(),process.env.TMUX?["switch-client","-t",s]:["attach-session","-t",s])}a(Me,"attachTmux");function Re(e,s){const t=Object.fromEntries(Object.entries(process.env).filter(([n])=>!A(n)&&n!=="CLAUDE_BG_ISOLATION")),r=a(()=>{},"ignore");process.on("SIGINT",r).on("SIGQUIT",r);const i=a(()=>process.off("SIGINT",r).off("SIGQUIT",r),"settle");return new Promise((n,o)=>{const l=K(e,s,{env:t,stdio:"inherit"});l.on("error",u=>{i(),o(new m(u.message,"failed"))}),l.on("close",u=>{i(),n({status:u??1})})})}a(Re,"runForeground");const M=a(({name:e,model:s})=>["--name",e,"--permission-mode","auto","--settings",Ue,...s?["--model",s]:[]],"common"),Fe=a(e=>`'${e.replaceAll("'","'\\''")}'`,"quote"),E=a((e,s)=>`--name ${Fe(re(e,s))}`,"boardFlag"),Ge=a(({route:e,name:s,model:t,prompt:r})=>["--bg",...M({name:s,model:t}),`/kickflip ${E(e,s)}${r?` ${r}`:""}`],"startArgv"),Ke=a((e,s,t)=>`The hub resumed this session because /${e} had nobody listening. Rejoin the board as session ${s} (kickflip steps 3 and 5): rerun \`node ${w.join(O,"bin","kickflip.mjs")} join ${E(e,t)} --session ${s}\`, relaunch its watcher as a background task, then go on with what its events ask. If your session id (the folder above your scratchpad) isn't ${s}, rejoin with yours and --force.`,"resumePrompt"),We=a(({id:e,route:s,name:t,model:r,background:i})=>["--resume",e,"--bg",...i?[]:M({name:t,model:r}),Ke(s,e,t)],"resumeArgv"),Y=a((e,s)=>`You are the hub session. Read \`${w.join(O,"docs","hub-session.md")}\`, then rejoin /${e} (join ${E(e,s)}) and relaunch its watcher.`,"hubPrompt"),z=a(({route:e,title:s,id:t,model:r})=>[...t?["--resume",t]:[],...M({name:s,model:r}),t?`${Y(e,s)} If your session id (the folder above your scratchpad) isn't ${t}, rejoin with yours and --force.`:`/kickflip ${E(e,s)} --hub
2
+ ${Y(e,s)}`],"hubArgv");function V(e,s,{cwd:t,tmux:r}){const i=v(),n=[...new Set(["CLAUDECODE","CLAUDE_BG_ISOLATION",...Object.keys(process.env).filter(A)])].flatMap(l=>["-u",l]),o=r(["new-session","-d","-s",S(e),"-c",t,"/usr/bin/env",...n,...w.isAbsolute(i)?[`PATH=${w.dirname(i)}:${process.env.PATH||""}`]:[],i,...s]);if(o.status!==0)throw new m(`tmux new-session failed (${o.status}): ${(o.stderr||o.stdout).trim().slice(0,300)}`,"failed");return[i,...s]}a(V,"startTmux");async function ws({manifest:e=I(),repo:s,tmux:t=U,attach:r=Me,live:i=_}={}){const n=W(e);if(!n)throw new m("No hub board is flagged; flag one with kickflip set <route> --hub, or run kickflip join --hub in its repo folder","hub");if(!N(t,n.route)){const o=n.owner?.session?n.owner:null;if(o&&!o.repo&&!s)throw new m(`/${n.route}'s session ${o.session} has no folder recorded; pass --repo <its folder>`,"none");F(o,i);const l=o?.repo||w.resolve(s||process.cwd());if(!x.statSync(l,{throwIfNoEntry:!1})?.isDirectory())throw new m(o?.repo?`/${n.route}'s recorded folder ${l} is gone; rejoin from its new folder with --repo`:`no folder at ${l}`,"none");V(n.route,z({route:n.route,title:n.title,id:o?.session,model:o?.model}),{cwd:l,tmux:t})}return r(n.route)}a(ws,"hubSession");function R(e,s){const t=h(e?.sites,s),r=t?.owner?.session?t.owner:h(e?.starting,s);return r?.session?{...r,title:t?.title||s}:null}a(R,"sessionOf");async function Z(e,s=null){const t=e.match(/backgrounded\s*·\s*([0-9a-f]{8})\b/)?.[1]||e.match(/\bclaude attach ([0-9a-f]{8})\b/)?.[1];if(!t)return{session:s,confirmed:!1};if(s?.startsWith(t))return{session:s,confirmed:!0};for(let r=0;r<20;r++){const i=$e().find(n=>n.jobId===t||n.sessionId.startsWith(t));if(i)return{session:i.sessionId,confirmed:!0};await new Promise(n=>setTimeout(n,500))}return{session:s,confirmed:!1}}a(Z,"startedId");async function Je(e,{run:s,cwd:t}){const r=await s(["agents","--json","--all"],{cwd:t});try{return JSON.parse(r.stdout).some(i=>i?.sessionId===e&&i.kind==="background")}catch{return!1}}a(Je,"isBackground");const ee=a(e=>`Couldn't tell whether session ${e} is live: ps gave no answer in time, as happens on a busy Mac. Try again in a moment.`,"unknownLiveness");function F(e,s){if(!e)return;const t=s();if(!t)throw new m(ee(e.session),"unknown");const r=t.get(e.session);if(r)throw new m(`session ${e.session} is live (${r.kind==="bg"?"in the background":"in a terminal"}, pid ${r.pid}); it gets no second process`,"live")}a(F,"refuseLive");async function se(e,{from:s,to:t,mark:r}){await we(async()=>{const i=I(),n=L(i).find(l=>l.route===e);if(n?.owner?.session===s&&(r||t!==s)&&ue(n,{from:s,to:t,mark:r}),t===s)return;const o=h(i.sites,e);if(o?.owner?.session===s)Object.assign(o,{owner:{...o.owner,session:t},updated:new Date().toISOString()});else if(h(i.starting,e)?.session===s)i.starting[e]={...h(i.starting,e),session:t};else return;await ge(i)})}a(se,"moveOwner");async function gs(e,{manifest:s=I(),repo:t,model:r,prompt:i,run:n=B,live:o=_,clones:l=Te()}={}){if(h(s.closed,e))throw new m(`/${e} is closed; use kickflip reopen ${e}`,"none");const u=R(s,e);F(u,o);const c=w.resolve(t||u?.repo||process.cwd());if(!x.statSync(c,{throwIfNoEntry:!1})?.isDirectory())throw new m(`no folder at ${c}`,"none");if(xe(c,l))try{je(c)}catch(g){throw new m(`Couldn't trust this folder for Claude Code: ${g.message}`,"failed")}const p=h(s?.sites,e)?.title||h(s?.starting,e)?.title||e,f=Ge({route:e,name:p,model:r||u?.model,prompt:i}),d=await n(f,{cwd:c});if(d.status!==0){const g=(d.stderr||"").trim()||(d.stdout||"").trim();throw new m(g?`claude --bg failed (${d.status}): ${g.slice(0,300)}`:`claude exited ${d.status} with no output; check \`claude agents\``,"failed")}const{session:$,confirmed:k}=await Z(d.stdout);if(!$)throw new m(`claude --bg started a session but no session record names it yet; find it with claude agents (${d.stdout.trim().slice(0,300)})`,"failed");return{route:e,session:$,confirmed:k,repo:X(c),model:r||u?.model||null,name:p,argv:[v(),...f],output:d.stdout.trim()}}a(gs,"startSession");function He(e,s={}){return Ne(e,({resume:t})=>t(s))}a(He,"resumeSession");async function Xe(e,{manifest:s=I(),run:t=B,tmux:r=U,live:i=_,mark:n=null}={}){if(h(I().closed,e))throw new m(`/${e} is closed; use kickflip reopen ${e}`,"none");const o=R(s,e);if(!o)throw new m(`/${e} has no recorded session`,"none");if(!o.repo)throw new m(`/${e}'s session ${o.session} has no folder recorded; its session records one by rejoining (kickflip join, --repo)`,"none");if(F(o,i),h(s.sites,e)?.hub===!0){if(N(r,e))throw new m(`/${e}'s tmux session ${S(e)} is still open; attach with kickflip hub`,"live");const d=V(e,z({route:e,title:o.title,id:o.session,model:o.model}),{cwd:o.repo,tmux:r});return await se(e,{from:o.session,to:o.session,mark:n}),{route:e,session:o.session,previous:o.session,confirmed:!1,repo:o.repo,argv:d,output:`in tmux session ${S(e)}`}}const l=await Je(o.session,{run:t,cwd:o.repo}),u=We({id:o.session,route:e,name:o.title,model:o.model,background:l}),c=await t(u,{cwd:o.repo});if(c.status!==0)throw new m(`claude --resume failed (${c.status}): ${(c.stderr||c.stdout).trim().slice(0,300)}`,"failed");const{session:p,confirmed:f}=await Z(c.stdout,o.session);return await se(e,{from:o.session,to:p,mark:n}),{route:e,session:p,previous:o.session,confirmed:f,repo:o.repo,argv:[v(),...u],output:c.stdout.trim()}}a(Xe,"resumeRecordedSession");function qe(e,{listening:s,live:t,recorded:r,counts:i,waiting:n,asked:o=!1,now:l}){if(s)return{action:"none",entry:null};const u=new Date(l).toISOString(),c=e?{...e}:{down_since:u};return t?delete c.mark:!c.mark&&i&&(c.mark=i),c.pushed_at?{action:"none",entry:c}:c.resumed_at?c.error||l-Date.parse(c.resumed_at)>=Ee?{action:"push",entry:{...c,pushed_at:u}}:{action:"none",entry:c}:!r||t?{action:"none",entry:c}:!!(e?.mark&&i&&Object.entries(i).some(([f,d])=>d>(h(e.mark,f)??0)))||o||n&&l-Date.parse(c.down_since)>=Ae?{action:"resume",entry:{...c,resumed_at:u}}:{action:"none",entry:c}}a(qe,"resumeStep");async function bs({manifest:e=I(),now:s=new Date,live:t=_,asked:r=new Set,listening:i=a((u,c)=>de(u,{now:s.getTime(),running:c}),"listening"),resume:n=a((u,c)=>He(u,{manifest:e,live:t,mark:c}),"resume"),push:o=Se,gate:l=be({now:s})}={}){const u=t();if(!u)return{resumed:[],pushed:[],failed:[]};const c=P(D,{}),p=Object.create(null),f={resumed:[],pushed:[],failed:[]};for(const d of L(e)){const $=R(e,d.route),k=!!($&&u.has($.session)),g=i(d,u);let{action:T,entry:b}=qe(h(c,d.route),{listening:g,live:k,recorded:!!$?.repo,now:s.getTime(),asked:r.has(d.route),counts:g?null:pe(d),waiting:!g&&!k&&fe(d)!==null});if(T==="resume"&&l.state==="paused"){const y=h(c,d.route)?.mark||he(d);Object.entries(me(d)).flatMap(([C,te])=>te.slice(h(y,C)||0)).some(C=>!ke(C))||(T="none",delete b.resumed_at)}if(T==="resume")try{await n(d.route,b.mark),f.resumed.push(d.route)}catch(y){y.reason==="unknown"?delete b.resumed_at:b={...b,error:y.message},f.failed.push(`${d.route}: could not resume its session (${y.message})`)}else if(T==="push"){const y=b.error?`The hub couldn't resume it: ${b.error}`:k?"Its session is running but hasn't relaunched its watcher.":`It still isn't listening after the hub resumed it. Try kickflip resume ${d.route}, or claude agents.`;try{await o({title:`${d.title}'s session didn't come back`,body:y,url:`/${d.route}/`,tag:`resume-${d.route}`},{kind:"fault",immediate:!0,at:s}),f.pushed.push(d.route)}catch(G){b={...b,pushed_at:void 0},f.failed.push(`${d.route}: could not push that its session didn't come back (${G.message})`)}}b&&(p[d.route]=b)}return JSON.stringify(p)!==JSON.stringify(c)&&J(D,p),f}a(bs,"checkSessions");export{Ue as BG_SETTINGS,Ee as COMEBACK_WAIT,Pe as PROMPT_HOOK_TIMEOUT_S,Ae as RESUME_AFTER,Ce as STARTING_WAIT,m as SessionError,Me as attachTmux,bs as checkSessions,v as claudeBin,ms as compactFullContexts,z as hubArgv,ws as hubSession,A as inherited,We as resumeArgv,Ke as resumePrompt,He as resumeSession,qe as resumeStep,B as runClaude,Be as runSessionStop,U as runTmux,R as sessionOf,Ge as startArgv,De as startFailed,gs as startSession,ps as startingBoards,hs as stopSession,Q as tmuxBin,S as tmuxName,Ne as withSessionLock};
package/hub/sw.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // from push.mjs as {title, body, url, tag, badge}; url is a path on the hub, and badge, when
3
3
  // present, is the Home Screen app badge count.
4
4
  // It keeps the app shell and its files, network first, so the app opens from the last copy while the host is asleep
5
- // (docs/adr/0055-one-app-for-the-whole-hub.md). Nothing else is cached: API calls, streams, uploads and frames always
5
+ // (docs/adr/2026-10-29-2005-one-app-for-the-whole-hub.md). Nothing else is cached: API calls, streams, uploads and frames always
6
6
  // go to the network.
7
7
  // A {forget: 'shell'} message drops the saved shell before the owner's reload and replies on its port.
8
8
  const CACHE = 'hub-app';
package/hub/try.mjs CHANGED
@@ -1,4 +1,8 @@
1
- var Bt=Object.defineProperty;var o=(s,u)=>Bt(s,"name",{value:u,configurable:!0});import S from"node:fs";import T from"node:path";import bt from"node:os";import Ft from"node:http";import $t from"node:net";import{execFile as Lt,spawn as Rt}from"node:child_process";import{createInterface as vt}from"node:readline";import{promisify as Gt}from"node:util";import{listBoards as z,readManifest as U,readJSON as V,saveJSON as kt,GITHUB_STATE as Ht,REPO_FETCH as St}from"./boards.mjs";import{boardRepo as st,githubFromOrigin as Tt}from"./board-repo.mjs";import{gitReachFault as Jt,remoteFromInput as Kt,remoteGitEnv as Wt}from"./repos.mjs";import{readiness as qt}from"./readiness.mjs";import{buildScriptError as zt}from"../board/build-script.mjs";import{appendThread as Ut,recordedWorktree as Yt,setItem as Qt}from"../board/store.mjs";class j extends Error{static{o(this,"BuildError")}constructor(u,w=400){super(u),this.status=w}}const ot=Gt(Lt),E=o(async(s,...u)=>(await ot("git",["-C",s,...u],{encoding:"utf8",timeout:3e3})).stdout.trim(),"git"),Vt=/errSecInternalComponent|keychain[^\n]*\blocked\b|\blocked\b[^\n]*keychain/i,H={first:8500,last:8599},at=o(s=>/^\d+$/.test(String(s))&&Number(s)>=H.first&&Number(s)<=H.last,"isTryPort"),Xt=30*6e4,xt=o(s=>T.join(s,".kickflip","build"),"scriptAt"),Ot=o(s=>S.existsSync(s),"exists"),It=o((s,u)=>T.join(s.dir,"data","try",`${u.split("/").at(-1)}.log`),"logFileOf");function Ct(s,u,w){const b=It(s,u);return S.mkdirSync(T.dirname(b),{recursive:!0}),S.writeFileSync(b,w.message+`
2
- `,{mode:384}),S.chmodSync(b,384),b}o(Ct,"failureLog");const Zt=o(s=>new Promise(u=>{const w=Ft.get({host:"127.0.0.1",port:s,path:"/",timeout:1e3},b=>{b.destroy(),u(!0)});w.on("timeout",()=>w.destroy()),w.on("error",()=>u(!1))}),"probe"),Et=o((s,u)=>{try{process.kill(-s.pid,u)}catch{}},"signalGroup");async function jt(s,u,w){const b=await Yt(s.dir,u),[g,h]=u.split("/"),v=s.waves.find(k=>k.slug===g)?.wave,f=b?.path||v?.items.find(k=>k.id===h)?.worktree;if(typeof f!="string"||!f||f.split(T.sep).includes(".."))return"";let y=b?.repo;const m=(s.github||v?.repo||"").toLowerCase();if(!y&&m){const k=[s.owner?.repo,s.dir,...Object.values(w?.sites||{}).map(P=>P.owner?.repo)];for(const P of new Set(k.filter(x=>typeof x=="string"&&T.isAbsolute(x))))try{if(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/.exec(await E(P,"remote","get-url","origin"))?.[1].toLowerCase()===m){y=await E(P,"rev-parse","--show-toplevel");break}}catch{}}if(!b?.path&&!y||b?.path&&!T.isAbsolute(f))return"";try{const k=[T.join(S.realpathSync(bt.homedir()),".claude","kickflip","worktrees")];if(typeof y=="string"&&T.isAbsolute(y))try{k.push(T.join(S.realpathSync(y),".claude","worktrees"))}catch{}const P=T.resolve(y||"/",f),x=S.realpathSync(P);return k.some(N=>P.startsWith(N+T.sep)&&x.startsWith(N+T.sep))&&S.statSync(x).isDirectory()?x:""}catch{return""}}o(jt,"installWorktree");function te({now:s=Date.now,answers:u=Zt,log:w=console.log,waiting:b=o(async()=>null,"waiting")}={}){const g=new Map,h=new Map,v=new Set,f=new Set,y=new Map,m=new Map,k=new Map;let P=!1,x=null,N=null,Y=null;const ct=o(()=>new Date(s()).toISOString(),"stamp");function B(t){if(g.has(t.dir))return g.get(t.dir);const e=T.join(t.dir,"data","try.json"),n=V(e,{kind:null,main:null,cards:{}}),r={...at(n.main_port)?{main_port:n.main_port}:{},kind:n.kind||null,main:n.main||null,cards:{...n.cards}},i={state:r,file:e,written:JSON.stringify(n)};g.set(t.dir,i),r.main_port&&m.set(t.dir,r.main_port);const c=Object.entries(r.cards).filter(([,l])=>l.state==="running");for(const[l]of c)f.add(A(t,l));return r.kind==="web"?(r.cards=Object.fromEntries(c),r.main&&r.main.state!=="failed"&&(delete r.main.state,delete r.main.port,delete r.main.https)):r.main?.state==="running"&&(delete r.main.state,delete r.main.sha),F(t),i}o(B,"stateOf");function F(t){const e=g.get(t.dir),n=JSON.stringify(e.state);n!==e.written&&(kt(e.file,e.state),e.written=n)}o(F,"persist");const A=o((t,e)=>`${t.dir}\0${e}`,"runKey"),lt=o((t,e)=>e==="main"?t.main:t.cards[e],"entryOf");function J(t,e,n){const{state:r}=B(t),i=lt(r,e);if(!i)return;const c=Object.hasOwn(n,"state")&&n.state!==i.state;Object.assign(i,n,e!=="main"&&c?{at:ct()}:{}),F(t)}o(J,"update");function Z(t){const e=[...Object.values(t?.sites||{}),...Object.values(t?.closed||{}).map(n=>n.site)];for(const n of e){if(!n?.dir)continue;const r=V(T.join(n.dir,"data","try.json"),{}).main_port;at(r)&&m.set(n.dir,Number(r))}}o(Z,"reserveMains");function Mt(t){const{board:e,key:n}=t,r=A(e,n),i=n==="main"?m.get(e.dir):y.get(r);if(i)return i;const c=new Set([...m.values(),...y.values()]);let l=H.first;for(;c.has(l)&&l<=H.last;)l++;if(l>H.last)throw new Error(`No try ports available (${H.first}\u2013${H.last}). Stop a served card and retry.`);return n==="main"?(m.set(e.dir,l),B(e).state.main_port=l,F(e)):y.set(r,l),l}o(Mt,"publicPort");const tt=o(t=>ot("tailscale",t,{encoding:"utf8",timeout:1e4}),"tailscale");async function dt(t){try{await tt(["serve",`--https=${t}`,"off"])}catch(e){w(`try port ${t}: could not close port: ${(e.stderr||e.message).trim()}`)}}o(dt,"removePort");async function Dt(t){t.https=Mt(t);const e=t.sockets=new Set,n=o(()=>{t.visited=s()},"visit");if(t.relay=$t.createServer(r=>{n();const i=$t.connect(t.port,"127.0.0.1");for(const c of[r,i])e.add(c),c.on("data",n),c.on("error",()=>{r.destroy(),i.destroy()}),c.on("close",()=>{e.delete(c),r.destroy(),i.destroy()});r.pipe(i),i.pipe(r)}),await new Promise((r,i)=>{t.relay.once("error",i),t.relay.listen(0,"127.0.0.1",r)}),!t.stopped){t.exposed=!0;try{await tt(["serve","--bg",`--https=${t.https}`,`http://127.0.0.1:${t.relay.address().port}`])}catch(r){throw new Error(`Couldn't open try port ${t.https}: ${(r.stderr||r.message).trim()}`)}}}o(Dt,"openPort");async function At(t){if(await t.opening?.catch(()=>{}),t.exposed&&await dt(t.https),t.relay){for(const e of t.sockets)e.destroy();await new Promise(e=>t.relay.close(e))}t.keepPort||y.delete(A(t.board,t.key))}o(At,"closePort");function et(t=U()){if(Y)return Y;Z(t);for(const e of z(t))B(e);return Y=(async()=>{try{const{stdout:e}=await tt(["serve","status","--json"]),n=JSON.parse(e);for(const r of Object.keys(n?.TCP||{}))at(r)&&![...h.values()].some(i=>i.https===Number(r))&&await dt(r)}catch(e){w(`try startup cleanup: ${(e.stderr||e.message).trim()}`)}})(),Y}o(et,"initialize");async function ut(t,e,n,r){const{board:i,key:c}=t;if(J(i,c,{state:"failed",port:void 0,https:void 0}),c==="main")return;const l=[];let $=!1;try{for await(const d of vt({input:S.createReadStream(t.logFile),crlfDelay:1/0}))$||=Vt.test(d),l.push(d),l.length>5&&l.shift()}catch(d){l.push(`Could not read the build log: ${d.message}`)}const R=t.port||(B(i).state.kind||(i.waves.some(({wave:d})=>d.items.some(O=>O.install?.url))?"app":"web"))==="web"?"Serve":"Build";await Ut(i.dir,c,{text:[`${R} failed (exit ${e??1})`,r?.message,n&&`Stopped by ${n}.`,$&&"The login keychain must be unlocked on the Mac.",...l].filter(Boolean).join(`
3
- `)})}o(ut,"failure");function Q(t){return t.ending||(Et(t.child,"SIGTERM"),t.ending=new Promise(e=>{const n=o(()=>{clearTimeout(r),clearInterval(i),e()},"finish"),r=setTimeout(()=>{Et(t.child,"SIGKILL"),n()},4e3),i=setInterval(()=>{try{process.kill(-t.child.pid,0)}catch{n()}},20)})),t.ending}o(Q,"terminate");async function ft(t,e=!1){t.keepPort=e,t.stopped=!0,clearTimeout(t.deadline),clearInterval(t.poll),await Q(t),await t.done}o(ft,"halt");async function K(t,e=!1){await ft(t,e),J(t.board,t.key,{state:void 0,port:void 0,https:void 0,...t.ready?{}:{sha:void 0}}),t.ready||k.delete(A(t.board,t.key))}o(K,"stopRun");async function pt(t,e,n,r={}){if(!n)throw new j("This card has no valid worktree.");const i=xt(n),c=zt(i);if(c)throw new j(c);if(!S.realpathSync(i).startsWith(S.realpathSync(n)+T.sep))throw new j("Build script must be inside the worktree.");const l=await E(n,"rev-parse","HEAD");if(P)throw new j("The hub is stopping. Try again after it restarts.",503);const{state:$}=B(t),R=lt($,e),d={...e==="main"?{}:R,...r,sha:l,script:!0,state:"running",...e==="main"?{}:{at:ct(),head:l}};delete d.port,delete d.https;const O=It(t,e);S.mkdirSync(T.dirname(O),{recursive:!0});const I=S.openSync(O,"w",384);S.fchmodSync(I,384);const p=Rt(i,[e,n],{cwd:n,detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,KICKFLIP_BOARD:t.route}});e==="main"?$.main=d:$.cards[e]=d,F(t);const C=A(t,e),a={child:p,board:t,key:e,logFile:O,visited:s(),stopped:!1,port:null,ready:!1};h.set(C,a),k.set(C,l);let _,L;const M=o(D=>{L||=new Error(D),Q(a)},"fail");p.on("error",D=>{_=D}),p.stdout.on("data",D=>S.writeSync(I,D)),p.stderr.on("data",D=>S.writeSync(I,D));const W=vt({input:p.stdout,crlfDelay:1/0});return W.on("line",D=>{const q=/^port (\d{1,5})$/.exec(D);if(!q||a.port||a.stopped)return;const G=Number(q[1]);if(G<1||G>65535){M("Build script printed an invalid port.");return}a.port=G,clearTimeout(a.deadline);const it=s()+6e4;let rt=!1;const gt=o(async()=>{if(!(rt||a.stopped||a.ready||p.exitCode!==null||p.signalCode!==null)){rt=!0;try{if(await u(G)){if(a.stopped||L||p.exitCode!==null||p.signalCode!==null||(a.opening=Dt(a),await a.opening,a.stopped||L||p.exitCode!==null||p.signalCode!==null))return;a.ready=!0,a.visited=s(),$.kind="web",J(t,e,{state:"ready",port:G,https:a.https}),clearInterval(a.poll)}else s()>=it&&M("Nothing answered on the build script\u2019s port within 60 seconds.")}finally{rt=!1}}},"check");a.poll=setInterval(()=>gt().catch(nt=>M(nt.message)),250),gt().catch(nt=>M(nt.message))}),a.deadline=setTimeout(()=>M("No port line within 60 minutes."),60*6e4),p.once("exit",()=>{Q(a)}),a.done=new Promise(D=>p.once("close",(q,G)=>{clearTimeout(a.deadline),clearInterval(a.poll),W.close(),S.closeSync(I),(async()=>{await Q(a),await At(a),a.finished=!0,!a.stopped&&(L||_||q!==0||a.port&&!a.ready?await ut(a,q??1,G,L||_||(a.port&&!a.ready?new Error("Build script exited before its port answered."):null)):a.ready?(e==="main"?(delete $.main.state,delete $.main.port,delete $.main.https):delete $.cards[e],F(t)):($.kind="app",J(t,e,{state:"ready"})))})().catch(it=>w(`build /${t.route} ${e}: ${it.message}`)).finally(()=>{h.get(C)===a&&h.delete(C),D()})})),w(`build /${t.route} ${e}: started; log ${O}`),{status:"started"}}o(pt,"launch");async function ht(t,e,n){const r=A(t,"main");if(v.has(r))throw new j("A build is already running for main.",409);v.add(r);try{h.has(r)&&await K(h.get(r),!0);const i=T.join(bt.homedir(),".claude","kickflip","try",`${t.route}-main`);return S.mkdirSync(T.dirname(i),{recursive:!0}),Ot(i)?await E(i,"checkout","--detach","--force",n.sha):(await E(e,"worktree","prune"),await E(e,"worktree","add","--detach",i,n.sha)),await pt(t,"main",i,n)}catch(i){throw i.status!==503&&(B(t).state.main={...n,state:"failed"},Ct(t,"main",i),F(t)),i}finally{v.delete(r)}}o(ht,"launchMain");async function mt(t,{manifest:e=U()}={}){if(await et(e),Z(e),P)throw new j("The hub is stopping. Try again after it restarts.",503);if(!t||Array.isArray(t)||typeof t!="object"||Object.keys(t).some(d=>!["route","key"].includes(d))||typeof t.route!="string"||typeof t.key!="string")throw new j("Send only the board route and card key.");const{route:n,key:r}=t;if(r!=="main"&&(!/^[\w.-]+\/[\w.-]+$/.test(r)||r.split("/").some(d=>d==="."||d==="..")))throw new j("Card key must be <wave>/<id>.");const i=z(e).find(d=>d.route===n);if(!i)throw new j(`/${n} is not a board.`,404);if(r!=="main"&&!i.waves.some(({slug:d,wave:O})=>O.items.some(I=>`${d}/${I.id}`===r)))throw new j("There is no item with this card key.",404);const c=await b();if(c)throw new j(c,409);const l=A(i,r),$=h.get(l);if(v.has(l)||$&&!$.ready&&!$.finished)throw new j(`A build is already running for ${r==="main"?"main":"this card"}.`,409);if(r==="main"){const d=B(i).state.main;if(d?.state==="running")throw new j("A build is already running for main.",409);if(d?.state!=="failed"||!d.sha)throw new j("Main has no failed build to retry.",409);const{sha:O,branch:I,slug:p,at:C,script:a}=d;return ht(i,st(n,e).repo,{sha:O,branch:I,slug:p,at:C,script:a})}v.add(l);const R=f.delete(l);try{h.has(l)&&await K(h.get(l),!0);const d=await jt(i,r,e);return await pt(i,r,d)}catch(d){throw y.delete(l),R&&d.status!==503&&J(i,r,{state:void 0,sha:void 0}),d}finally{v.delete(l)}}o(mt,"start");function wt(t,e,{manifest:n=U()}={}){const r=z(n).find(c=>c.route===t);if(!r)return null;const i=[...h.values()].find(c=>c.board.dir===r.dir&&c.key.split("/").at(-1).toLowerCase()===e.toLowerCase()&&c.ready&&!c.stopped&&c.child.exitCode===null&&c.child.signalCode===null);return i?{key:i.key,port:i.port}:null}o(wt,"lookup");async function yt({manifest:t=U(),routes:e}={}){if(await et(t),Z(t),P)return x;if(x&&e){if(!N){const n={routes:new Set,manifest:t};n.done=x.catch(()=>{}).then(()=>(N=null,yt({manifest:n.manifest,routes:[...n.routes]}))),N=n}N.manifest=t;for(const n of e)N.routes.add(n);return N.done}return x||(x=(async()=>{const n=z(t),r=V(Ht,{}).prs||{};for(const i of[...h.values()])if(!n.some(c=>c.dir===i.board.dir)||i.key!=="main"&&i.ready&&s()-i.visited>=Xt){await K(i);const{state:c}=B(i.board);i.key==="main"?c.main=null:delete c.cards[i.key],F(i.board)}for(const i of n){if(P)return;if(e&&!e.includes(i.route))continue;const{state:c}=B(i),l=st(i.route,t),$=l.branch||"main",R=l.local?"refs/heads/main":`refs/remotes/origin/${$}`;for(const{slug:d,wave:O}of i.waves)for(const I of O.items){const p=`${d}/${I.id}`;if(!(["done","merged","dropped"].includes(String(I.status).toLowerCase())||["MERGED","CLOSED"].includes(r[`${i.github||O.repo}#${I.pr}`]?.state)))try{const C=A(i,p),a=await jt(i,p,t);if(a){const M=await E(a,"rev-parse","HEAD");let W=0;try{W=Number(await E(a,"rev-list","--count",`${R}..HEAD`))}catch{}Object.assign(c.cards[p]||={},{head:M,ahead:W,script:Ot(xt(a))}),F(i)}else if(!f.has(C))continue;const _=c.cards[p];if((f.has(C)||i.auto_build&&_.script&&_.ahead>0&&(_.sha||k.get(C))!==_.head&&_.state!=="running")&&!v.has(C))try{await mt({route:i.route,key:p},{manifest:t})}catch(M){if([409,503].includes(M.status))continue;throw J(i,p,{sha:_.head}),await ut({board:i,key:p,logFile:Ct(i,p,M)},1),M}}catch(C){w(`try /${i.route} ${p}: ${C.message}`)}}try{if(!l.repo||await b())continue;const d=await E(l.repo,"rev-parse",R);let O=!1;try{await E(l.repo,"cat-file","-e",`${d}:.kickflip/build`),O=!0}catch{}if(!O){const L=h.get(A(i,"main"));L&&await K(L),c.main=null,F(i);continue}const I=c.main;if(I?.state==="running"||I?.sha===d&&(I.state==="failed"||I.state==="ready"&&(c.kind!=="web"||wt(i.route,"main",{manifest:t}))))continue;const p=A(i,"main");if(v.has(p))continue;const C=await E(l.repo,"log","-1","--format=%cI",d);let a=i.github;if(!a)try{a=Tt(await E(l.repo,"remote","get-url","origin"))}catch{}const _={sha:d,branch:$,slug:a||"",at:C,script:O};await ht(i,l.repo,_)}catch(d){w(`try /${i.route} main: ${d.message}`)}}})().finally(()=>{x=null}),x)}o(yt,"tick");async function Nt(){P=!0;const t=Promise.all([...h.values()].map(e=>e.ready?K(e):ft(e)));await Promise.all([t,x])}return o(Nt,"stop"),{start:mt,tick:yt,stop:Nt,lookup:wt,initialize:et}}o(te,"createTryManager");const Pt=1e4,_t=o(async(s,...u)=>(await ot("git",["-C",s,...u],{encoding:"utf8",timeout:Pt,env:Wt()})).stdout,"originGit");async function ye({manifest:s=U(),log:u=console.log,routes:w}={}){const b=w?V(St,{}):{};for(const g of z(s)){if(w&&!w.includes(g.route))continue;const h=st(g.route,s);if(h.local||!h.repo)continue;const v=h.branch||"main",f=new Date().toISOString(),y=Date.now();try{await _t(h.repo,"fetch","--quiet","--no-tags","origin",`+refs/heads/${v}:refs/remotes/origin/${v}`),b[g.route]={at:f,ok:!0}}catch(m){u(`fetch /${g.route} after ${Date.now()-y} ms: ${m.message}`);let k=g.github;if(!k&&!g.remote)try{k=Tt(await E(h.repo,"remote","get-url","origin"))}catch{}b[g.route]={at:f,ok:!1,...Jt(m.killed?`git fetch timed out after ${Pt/1e3} s`:m.stderr,{github:k||"",remote:g.remote,repo:h.repo})};continue}if(g.remote)try{await ee(g,h.repo,v,u)}catch(m){u(`pushed branches /${g.route}: ${m.message}`)}}kt(St,b)}o(ye,"fetchTryBranches");async function ee(s,u,w,b){const g=s.waves.flatMap(({slug:f,wave:y})=>y.items.filter(m=>m.pushed?.branch&&!m.pr&&!["done","merged","dropped"].includes(String(m.status).toLowerCase())).map(m=>({key:`${f}/${m.id}`,branch:m.pushed.branch})));if(!g.length)return;const h=new Map((await _t(u,"ls-remote","--heads","origin")).split(`
4
- `).map(f=>/^([\da-f]+)\trefs\/heads\/(.+)$/.exec(f)).filter(Boolean).map(f=>[f[2],f[1]])),v=Kt(s.remote)?.host||"the remote";for(const f of g){let y=h.get(f.branch);if(!y)try{y=await E(u,"rev-parse","--verify","--quiet",`refs/heads/${f.branch}^{commit}`)}catch{}let m=!1;if(y)try{await E(u,"merge-base","--is-ancestor",y,`refs/remotes/origin/${w}`),m=!0}catch{}if(!(!m&&h.has(f.branch)))try{await Qt(s.dir,f.key,{status:m?"merged":"done",force:!0,text:m?`${f.branch} is merged into ${w} on ${v}, so this card is Done.`:`${f.branch} is gone from ${v}, so this card is Done.`})}catch(k){b(`pushed ${f.key}: ${k.message}`)}}}o(ee,"settlePushed");const X=te({waiting:o(()=>qt.waiting("tailscale"),"waiting")}),ge=o((...s)=>X.start(...s),"startBuild"),be=o((...s)=>X.tick(...s),"tryTick"),$e=o(()=>X.stop(),"stopTries"),ve=o((...s)=>X.initialize(...s),"initializeTries");export{j as BuildError,H as TRY_PORTS,te as createTryManager,ye as fetchTryBranches,ve as initializeTries,jt as installWorktree,at as isTryPort,ge as startBuild,$e as stopTries,be as tryTick};
1
+ var Wt=Object.defineProperty;var a=(n,f)=>Wt(n,"name",{value:f,configurable:!0});import x from"node:fs";import O from"node:path";import Ot from"node:os";import ut from"node:http";import{execFile as Jt,spawn as zt}from"node:child_process";import{createInterface as Ct}from"node:readline";import{promisify as Ut}from"node:util";import{listBoards as W,readManifest as J,readJSON as rt,saveJSON as Et,appendEvent as Yt,GITHUB_STATE as qt,REPO_FETCH as It}from"./boards.mjs";import{boardRepo as Q,githubFromOrigin as jt}from"./board-repo.mjs";import{gitReachFault as Mt,remoteFromInput as Qt,remoteGitEnv as Vt}from"./repos.mjs";import{readiness as Xt}from"./readiness.mjs";import{tailscaleBin as Zt}from"../shared/tailscale.mjs";import{buildScriptError as te}from"../board/build-script.mjs";import{appendThread as ee,recordedWorktree as ie,setItem as re}from"../board/store.mjs";import{cardPRs as se,hasPR as ne,prKeyOf as oe}from"../shared/card-prs.mjs";import{hubGate as ae}from"../board/on-host.mjs";import{pipeUpgrade as ce,refuse as le,refuseUpgrade as de}from"./front-door.mjs";class T extends Error{static{a(this,"BuildError")}constructor(f,w=400){super(f),this.status=w}}const ft=Ut(Jt),M=a(async(n,...f)=>(await ft("git",["-C",n,...f],{encoding:"utf8",timeout:3e3})).stdout.trim(),"git"),ue=/errSecInternalComponent|keychain[^\n]*\blocked\b|\blocked\b[^\n]*keychain/i,z={first:8500,last:8599},pt=a(n=>/^\d+$/.test(String(n))&&Number(n)>=z.first&&Number(n)<=z.last,"isTryPort"),fe=30*6e4,Pt=a(n=>O.join(n,".kickflip","build"),"scriptAt"),_t=a(n=>x.existsSync(n),"exists"),At=a((n,f)=>O.join(n.dir,"data","try",`${f.split("/").at(-1)}.log`),"logFileOf");function Nt(n,f,w){const m=At(n,f);return x.mkdirSync(O.dirname(m),{recursive:!0}),x.writeFileSync(m,w.message+`
2
+ `,{mode:384}),x.chmodSync(m,384),m}a(Nt,"failureLog");const pe=a(n=>new Promise(f=>{const w=ut.get({host:"127.0.0.1",port:n,path:"/",timeout:1e3},m=>{m.destroy(),f(!0)});w.on("timeout",()=>w.destroy()),w.on("error",()=>f(!1))}),"probe"),Dt=a((n,f)=>{try{process.kill(-n.pid,f)}catch{}},"signalGroup");async function Bt(n,f,w){const m=await ie(n.dir,f),[v,p]=f.split("/"),k=n.waves.find(b=>b.slug===v)?.wave,d=m?.path||k?.items.find(b=>b.id===p)?.worktree;if(typeof d!="string"||!d||d.split(O.sep).includes(".."))return"";let g=m?.repo;const y=(n.github||k?.repo||"").toLowerCase();if(!g&&y){const b=[n.owner?.repo,n.dir,...Object.values(w?.sites||{}).map(I=>I.owner?.repo)];for(const I of new Set(b.filter(P=>typeof P=="string"&&O.isAbsolute(P))))try{if(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/.exec(await M(I,"remote","get-url","origin"))?.[1].toLowerCase()===y){g=await M(I,"rev-parse","--show-toplevel");break}}catch{}}if(!m?.path&&!g||m?.path&&!O.isAbsolute(d))return"";try{const b=[O.join(x.realpathSync(Ot.homedir()),".claude","kickflip","worktrees")];if(typeof g=="string"&&O.isAbsolute(g))try{b.push(O.join(x.realpathSync(g),".claude","worktrees"))}catch{}const I=O.resolve(g||"/",d),P=x.realpathSync(I);return b.some(L=>I.startsWith(L+O.sep)&&P.startsWith(L+O.sep))&&x.statSync(P).isDirectory()?P:""}catch{return""}}a(Bt,"installWorktree");function he({now:n=Date.now,answers:f=pe,log:w=console.log,waiting:m=a(async()=>null,"waiting"),event:v=Yt,gate:p=ae}={}){const k=new Map,d=new Map,g=new Set,y=new Set,b=new Map,I=new Map,P=new Map,L=new Map;let G=!1,R=null,U=null,et=null;const st=a(()=>new Date(n()).toISOString(),"stamp");function D(t){if(k.has(t.dir))return k.get(t.dir);const i=O.join(t.dir,"data","try.json"),r=rt(i,{kind:null,main:null,cards:{}}),s={...pt(r.main_port)?{main_port:r.main_port}:{},kind:r.kind||null,main:r.main||null,cards:{...r.cards},...r.pull?{pull:{...r.pull,running:void 0}}:{}},e={state:s,file:i,written:JSON.stringify(r)};k.set(t.dir,e),s.main_port&&I.set(t.dir,s.main_port);const o=Object.entries(s.cards).filter(([,c])=>c.state==="running");for(const[c]of o)y.add(B(t,c));return s.kind==="web"?(s.cards=Object.fromEntries(o),s.main&&s.main.state!=="failed"&&(delete s.main.state,delete s.main.port,delete s.main.https)):s.main?.state==="running"&&(delete s.main.state,delete s.main.sha),A(t),e}a(D,"stateOf");function A(t){const i=k.get(t.dir),r=JSON.stringify(i.state);r!==i.written&&(Et(i.file,i.state),i.written=r)}a(A,"persist");const B=a((t,i)=>`${t.dir}\0${i}`,"runKey"),wt=a((t,i)=>i==="main"?t.main:t.cards[i],"entryOf");function Y(t,i,r){const{state:s}=D(t),e=wt(s,i);if(!e)return;const o=Object.hasOwn(r,"state")&&r.state!==e.state;Object.assign(e,r,i!=="main"&&o?{at:st()}:{}),A(t)}a(Y,"update");function nt(t){const i=[...Object.values(t?.sites||{}),...Object.values(t?.closed||{}).map(r=>r.site)];for(const r of i){if(!r?.dir)continue;const s=rt(O.join(r.dir,"data","try.json"),{}).main_port;pt(s)&&I.set(r.dir,Number(s))}}a(nt,"reserveMains");function Ft(t){const{board:i,key:r}=t,s=B(i,r),e=r==="main"?I.get(i.dir):b.get(s);if(e)return e;const o=new Set([...I.values(),...b.values()]);let c=z.first;for(;o.has(c)&&c<=z.last;)c++;if(c>z.last)throw new Error(`No try ports available (${z.first}\u2013${z.last}). Stop a served card and retry.`);return r==="main"?(I.set(i.dir,c),D(i).state.main_port=c,A(i)):b.set(s,c),c}a(Ft,"publicPort");const ot=a(async t=>{const i=Zt();if(!i)throw new Error("Tailscale isn't installed on this host");return ft(i,t,{encoding:"utf8",timeout:1e4})},"tailscale");async function yt(t){try{await ot(["serve",`--https=${t}`,"off"])}catch(i){w(`try port ${t}: could not close port: ${(i.stderr||i.message).trim()}`)}}a(yt,"removePort");async function Rt(t){t.https=Ft(t);const i=t.sockets=new Set,r=a(()=>{t.visited=n()},"visit"),s=a(e=>{i.add(e),e.on("data",r),e.on("close",()=>i.delete(e))},"track");if(t.relay=ut.createServer(async(e,o)=>{const c=await p(e);if(c!=="owner"){le(e,o,c);return}const h=ut.request({host:"127.0.0.1",port:t.port,method:e.method,path:e.url,headers:e.headers},S=>{o.writeHead(S.statusCode,S.headers),S.on("data",r),S.pipe(o),S.on("close",()=>{S.complete||o.destroy()})});o.on("close",()=>{o.writableFinished||h.destroy()}),h.on("error",()=>{o.headersSent?o.destroy():o.writeHead(502).end()}),e.pipe(h)}),t.relay.on("connection",e=>{r(),s(e)}),t.relay.on("upgrade",async(e,o,c)=>{const h=await p(e);if(h!=="owner"){de(o,h);return}s(ce({req:e,socket:o,head:c,port:t.port,path:e.url}))}),await new Promise((e,o)=>{t.relay.once("error",o),t.relay.listen(0,"127.0.0.1",e)}),!t.stopped){t.exposed=!0;try{await ot(["serve","--bg",`--https=${t.https}`,`http://127.0.0.1:${t.relay.address().port}`])}catch(e){throw new Error(`Couldn't open try port ${t.https}: ${(e.stderr||e.message).trim()}`)}}}a(Rt,"openPort");async function Lt(t){if(await t.opening?.catch(()=>{}),t.exposed&&await yt(t.https),t.relay){for(const i of t.sockets)i.destroy();await new Promise(i=>t.relay.close(i))}t.keepPort||b.delete(B(t.board,t.key))}a(Lt,"closePort");function X(t=J()){if(et)return et;nt(t);for(const i of W(t))D(i);return et=(async()=>{try{const{stdout:i}=await ot(["serve","status","--json"]),r=JSON.parse(i);for(const s of Object.keys(r?.TCP||{}))pt(s)&&![...d.values()].some(e=>e.https===Number(s))&&await yt(s)}catch(i){w(`try startup cleanup: ${(i.stderr||i.message).trim()}`)}})(),et}a(X,"initialize");async function gt(t,i,r,s){const{board:e,key:o}=t;if(Y(e,o,{state:"failed",port:void 0,https:void 0}),o==="main")return;const c=[];let h=!1;try{for await(const u of Ct({input:x.createReadStream(t.logFile),crlfDelay:1/0}))h||=ue.test(u),c.push(u),c.length>5&&c.shift()}catch(u){c.push(`Could not read the build log: ${u.message}`)}const S=t.port||(D(e).state.kind||(e.waves.some(({wave:u})=>u.items.some(C=>C.install?.url))?"app":"web"))==="web"?"Serve":"Build";await ee(e.dir,o,{text:[`${S} failed (exit ${i??1})`,s?.message,r&&`Stopped by ${r}.`,h&&"The login keychain must be unlocked on the Mac.",...c].filter(Boolean).join(`
3
+ `)})}a(gt,"failure");function it(t){return t.ending||(Dt(t.child,"SIGTERM"),t.ending=new Promise(i=>{const r=a(()=>{clearTimeout(s),clearInterval(e),i()},"finish");let s=setTimeout(()=>{Dt(t.child,"SIGKILL"),s=setTimeout(r,1e3)},4e3);const e=setInterval(()=>{try{process.kill(-t.child.pid,0)}catch{r()}},20)})),t.ending}a(it,"terminate");async function $t(t,i=!1){t.keepPort=i,t.stopped=!0,clearTimeout(t.deadline),clearInterval(t.poll),await it(t),await t.done}a($t,"halt");async function Z(t,i=!1){await $t(t,i),Y(t.board,t.key,{state:void 0,port:void 0,https:void 0,...t.ready?{}:{sha:void 0}}),t.ready||P.delete(B(t.board,t.key))}a(Z,"stopRun");async function bt(t,i,r,s={}){if(!r)throw new T("This card has no valid worktree.");const e=Pt(r),o=te(e);if(o)throw new T(o);if(!x.realpathSync(e).startsWith(x.realpathSync(r)+O.sep))throw new T("Build script must be inside the worktree.");const c=await M(r,"rev-parse","HEAD");if(G)throw new T("The hub is stopping. Try again after it restarts.",503);const{state:h}=D(t),S=wt(h,i),u={...i==="main"?{}:S,...s,sha:c,script:!0,state:"running",...i==="main"?{}:{at:st(),head:c}};delete u.port,delete u.https;const C=At(t,i);x.mkdirSync(O.dirname(C),{recursive:!0});const E=x.openSync(C,"w",384);x.fchmodSync(E,384);const $=zt(e,[i,r],{cwd:r,detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,KICKFLIP_BOARD:t.route,HOST:"127.0.0.1"}});i==="main"?h.main=u:h.cards[i]=u,A(t);const N=B(t,i),l={child:$,board:t,key:i,logFile:C,visited:n(),stopped:!1,port:null,ready:!1};d.set(N,l),P.set(N,c);let F,j;const q=a(_=>{j||=new Error(_),it(l)},"fail");$.on("error",_=>{F=_}),$.stdout.on("data",_=>x.writeSync(E,_)),$.stderr.on("data",_=>x.writeSync(E,_));const H=Ct({input:$.stdout,crlfDelay:1/0});return H.on("line",_=>{const tt=/^port (\d{1,5})$/.exec(_);if(!tt||l.port||l.stopped)return;const K=Number(tt[1]);if(K<1||K>65535){q("Build script printed an invalid port.");return}l.port=K,clearTimeout(l.deadline);const ct=n()+6e4;let lt=!1;const xt=a(async()=>{if(!(lt||l.stopped||l.ready||$.exitCode!==null||$.signalCode!==null)){lt=!0;try{if(await f(K)){if(l.stopped||j||$.exitCode!==null||$.signalCode!==null||(l.opening=Rt(l),await l.opening,l.stopped||j||$.exitCode!==null||$.signalCode!==null))return;l.ready=!0,l.visited=n(),h.kind="web",Y(t,i,{state:"ready",port:K,https:l.https}),clearInterval(l.poll)}else n()>=ct&&q("Nothing answered on the build script\u2019s port within 60 seconds.")}finally{lt=!1}}},"check");l.poll=setInterval(()=>xt().catch(dt=>q(dt.message)),250),xt().catch(dt=>q(dt.message))}),l.deadline=setTimeout(()=>q("No port line within 60 minutes."),60*6e4),$.once("exit",()=>{it(l)}),l.done=new Promise(_=>$.once("close",(tt,K)=>{clearTimeout(l.deadline),clearInterval(l.poll),H.close(),x.closeSync(E),(async()=>{await it(l),await Lt(l),l.finished=!0,!l.stopped&&(j||F||tt!==0||l.port&&!l.ready?await gt(l,tt??1,K,j||F||(l.port&&!l.ready?new Error("Build script exited before its port answered."):null)):l.ready?(i==="main"?(delete h.main.state,delete h.main.port,delete h.main.https):delete h.cards[i],A(t)):(h.kind="app",Y(t,i,{state:"ready"})))})().catch(ct=>w(`build /${t.route} ${i}: ${ct.message}`)).finally(()=>{d.get(N)===l&&d.delete(N),_()})})),w(`build /${t.route} ${i}: started; log ${C}`),{status:"started"}}a(bt,"launch");async function vt(t,i,r){const s=B(t,"main");if(g.has(s))throw new T("A build is already running for main.",409);g.add(s);try{d.has(s)&&await Z(d.get(s),!0);const e=O.join(Ot.homedir(),".claude","kickflip","try",`${t.route}-main`);return x.mkdirSync(O.dirname(e),{recursive:!0}),_t(e)?await M(e,"checkout","--detach","--force",r.sha):(await M(i,"worktree","prune"),await M(i,"worktree","add","--detach",e,r.sha)),await bt(t,"main",e,r)}catch(e){throw e.status!==503&&(D(t).state.main={...r,state:"failed"},Nt(t,"main",e),A(t)),e}finally{g.delete(s)}}a(vt,"launchMain");async function kt(t,{manifest:i=J()}={}){if(await X(i),nt(i),G)throw new T("The hub is stopping. Try again after it restarts.",503);if(!t||Array.isArray(t)||typeof t!="object"||Object.keys(t).some(u=>!["route","key"].includes(u))||typeof t.route!="string"||typeof t.key!="string")throw new T("Send only the board route and card key.");const{route:r,key:s}=t;if(s!=="main"&&(!/^[\w.-]+\/[\w.-]+$/.test(s)||s.split("/").some(u=>u==="."||u==="..")))throw new T("Card key must be <wave>/<id>.");const e=W(i).find(u=>u.route===r);if(!e)throw new T(`/${r} is not a board.`,404);if(s!=="main"&&!e.waves.some(({slug:u,wave:C})=>C.items.some(E=>`${u}/${E.id}`===s)))throw new T("There is no item with this card key.",404);const o=await m();if(o)throw new T(o,409);const c=B(e,s),h=d.get(c);if(g.has(c)||h&&!h.ready&&!h.finished)throw new T(`A build is already running for ${s==="main"?"main":"this card"}.`,409);if(s==="main"){const u=D(e).state.main;if(u?.state==="running")throw new T("A build is already running for main.",409);if(u?.state!=="failed"||!u.sha)throw new T("Main has no failed build to retry.",409);const{sha:C,branch:E,slug:$,at:N,script:l}=u;return vt(e,Q(r,i).repo,{sha:C,branch:E,slug:$,at:N,script:l})}g.add(c);const S=y.delete(c);try{d.has(c)&&await Z(d.get(c),!0);const u=await Bt(e,s,i);return await bt(e,s,u)}catch(u){throw b.delete(c),S&&u.status!==503&&Y(e,s,{state:void 0,sha:void 0}),u}finally{g.delete(c)}}a(kt,"start");function St(t,i,{manifest:r=J()}={}){const s=W(r).find(o=>o.route===t);if(!s)return null;const e=[...d.values()].find(o=>o.board.dir===s.dir&&o.key.split("/").at(-1).toLowerCase()===i.toLowerCase()&&o.ready&&!o.stopped&&o.child.exitCode===null&&o.child.signalCode===null);return e?{key:e.key,port:e.port}:null}a(St,"lookup");async function at({manifest:t=J(),routes:i}={}){if(await X(t),nt(t),G)return R;if(R&&i){if(!U){const r={routes:new Set,manifest:t};r.done=R.catch(()=>{}).then(()=>(U=null,at({manifest:r.manifest,routes:[...r.routes]}))),U=r}U.manifest=t;for(const r of i)U.routes.add(r);return U.done}return R||(R=(async()=>{const r=W(t),s=rt(qt,{}).prs||{};for(const e of[...d.values()])if(!r.some(o=>o.dir===e.board.dir)||e.key!=="main"&&e.ready&&n()-e.visited>=fe){await Z(e);const{state:o}=D(e.board);e.key==="main"?o.main=null:delete o.cards[e.key],A(e.board)}for(const e of r){if(G)return;if(i&&!i.includes(e.route))continue;const{state:o}=D(e),c=Q(e.route,t),h=c.branch||"main",S=c.local?`refs/heads/${h}`:`refs/remotes/origin/${h}`;for(const{slug:u,wave:C}of e.waves)for(const E of C.items){const $=`${u}/${E.id}`,N=se(E,e.github||C.repo).map(l=>s[oe(l)]);if(!(["done","merged","dropped"].includes(String(E.status).toLowerCase())||N.length&&N.every(l=>["MERGED","CLOSED"].includes(l?.state))))try{const l=B(e,$),F=await Bt(e,$,t);if(F){const H=await M(F,"rev-parse","HEAD");let _=0;try{_=Number(await M(F,"rev-list","--count",`${S}..HEAD`))}catch{}Object.assign(o.cards[$]||={},{head:H,ahead:_,script:_t(Pt(F))}),A(e)}else if(!y.has(l))continue;const j=o.cards[$];if((y.has(l)||e.auto_build&&j.script&&j.ahead>0&&(j.sha||P.get(l))!==j.head&&j.state!=="running")&&!g.has(l))try{await kt({route:e.route,key:$},{manifest:t})}catch(H){if([409,503].includes(H.status))continue;throw Y(e,$,{sha:j.head}),await gt({board:e,key:$,logFile:Nt(e,$,H)},1),H}}catch(l){w(`try /${e.route} ${$}: ${l.message}`)}}try{if(!c.repo||await m())continue;const u=await M(c.repo,"rev-parse",S);let C=!1;try{await M(c.repo,"cat-file","-e",`${u}:.kickflip/build`),C=!0}catch{}if(!C){const j=d.get(B(e,"main"));j&&await Z(j),o.main=null,A(e);continue}const E=o.main;if(E?.state==="running"||E?.sha===u&&(E.state==="failed"||E.state==="ready"&&(o.kind!=="web"||St(e.route,"main",{manifest:t}))))continue;const $=B(e,"main");if(g.has($))continue;const N=await M(c.repo,"log","-1","--format=%cI",u);let l=e.github;if(!l)try{l=jt(await M(c.repo,"remote","get-url","origin"))}catch{}const F={sha:u,branch:h,slug:l||"",at:N,script:C};await vt(e,c.repo,F)}catch(u){w(`try /${e.route} main: ${u.message}`)}}})().finally(()=>{R=null}),R)}a(at,"tick");function Tt(t,i){if(L.has(t.dir))return L.get(t.dir);const r=(async()=>{const s=Q(t.route,i),e=s.branch||"main",{state:o}=D(t);o.pull={...o.pull,running:!0},A(t);let c,h;try{c=await me(s.repo,e)}catch(S){h=S.message}if(c===null?delete o.pull:o.pull={at:st(),...h?{error:h}:{}},A(t),c===null)return null;if(h)return w(`pull /${t.route}: ${h}`),{commits:0,error:h};if(c.commits){w(`pull /${t.route}: ${e} fast-forwarded ${c.commits} commit${c.commits===1?"":"s"} to ${c.to.slice(0,7)}`);try{await v(t,"main-pulled",{branch:e,...c})}catch(S){w(`pull /${t.route}: could not send main-pulled (${S.message})`)}}return{commits:c.commits}})().finally(()=>L.delete(t.dir));return L.set(t.dir,r),r}a(Tt,"pullMain");async function Ht({manifest:t=J(),routes:i}={}){await X(t);const r=[];for(const s of W(t)){if(G)break;if(i&&!i.includes(s.route))continue;const e=Q(s.route,t);!e.local||!e.repo||(await Tt(s,t))?.commits&&r.push(s.route)}return r}a(Ht,"pullMains");async function Gt(t,{manifest:i=J()}={}){if(await X(i),G)throw new T("The hub is stopping. Try again after it restarts.",503);if(!t||Array.isArray(t)||typeof t!="object"||Object.keys(t).some(o=>o!=="route")||typeof t.route!="string")throw new T("Send only the board route.");const r=W(i).find(o=>o.route===t.route);if(!r)throw new T(`/${t.route} is not a board.`,404);const s=Q(r.route,i);if(!s.local||!s.repo)throw new T("This board fetches its branch on every poll; only a local board pulls.",409);const e=await Tt(r,i);if(!e)throw new T("This board\u2019s folder has no origin to pull from.",409);return e.commits&&at({manifest:i,routes:[r.route]}).catch(o=>w(`try /${r.route} main: ${o.message}`)),e}a(Gt,"pull");async function Kt(){G=!0;const t=Promise.all([...d.values()].map(i=>i.ready?Z(i):$t(i)));await Promise.all([t,R,...L.values()])}return a(Kt,"stop"),{start:kt,tick:at,stop:Kt,lookup:St,initialize:X,pull:Gt,pullMains:Ht}}a(he,"createTryManager");const ht=1e4,mt=a(async(n,...f)=>(await ft("git",["-C",n,...f],{encoding:"utf8",timeout:ht,env:Vt()})).stdout,"originGit");async function me(n,f,{run:w=mt}={}){const m=a(async(...p)=>(await w(...p)).trim(),"git"),v=a(p=>{throw new Error(`can\u2019t pull: ${p}`)},"refuse");try{await m(n,"config","--get","remote.origin.url")}catch{return null}try{await m(n,"fetch","--quiet","--no-tags","origin",`+refs/heads/${f}:refs/remotes/origin/${f}`)}catch(p){v(p.killed?`git fetch timed out after ${ht/1e3} s`:Mt(p.stderr,{repo:n}).error)}try{const p=await m(n,"rev-parse","--verify",`refs/heads/${f}^{commit}`),k=await m(n,"rev-parse","--verify",`refs/remotes/origin/${f}^{commit}`),d=a((b,I)=>m(n,"merge-base","--is-ancestor",b,I).then(()=>!0,P=>{if(P.code===1)return!1;throw P}),"isAncestor");if(p===k||await d(k,p))return{from:p,to:p,commits:0};await d(p,k)||v(`${f} has split from origin`);const g=Number(await m(n,"rev-list","--count",`${p}..${k}`)),y=(await m(n,"worktree","list","--porcelain")).split(`
4
+
5
+ `).map(b=>b.split(`
6
+ `)).find(b=>b.includes(`branch refs/heads/${f}`))?.[0].slice(9);return y?(await m(y,"status","--porcelain","--untracked-files=no")&&v("local changes"),await m(y,"merge","--ff-only","--quiet",k)):await m(n,"update-ref",`refs/heads/${f}`,k,p),{from:p,to:k,commits:g}}catch(p){if(p.message.startsWith("can\u2019t pull: "))throw p;v(String(p.stderr||p.message).trim().split(`
7
+ `).at(-1))}}a(me,"pullBranch");async function Ne({manifest:n=J(),log:f=console.log,routes:w}={}){const m=w?rt(It,{}):{};for(const v of W(n)){if(w&&!w.includes(v.route))continue;const p=Q(v.route,n);if(p.local||!p.repo)continue;const k=p.branch||"main",d=new Date().toISOString(),g=Date.now();try{await mt(p.repo,"fetch","--quiet","--no-tags","origin",`+refs/heads/${k}:refs/remotes/origin/${k}`),m[v.route]={at:d,ok:!0}}catch(y){f(`fetch /${v.route} after ${Date.now()-g} ms: ${y.message}`);let b=v.github;if(!b&&!v.remote)try{b=jt(await M(p.repo,"remote","get-url","origin"))}catch{}m[v.route]={at:d,ok:!1,...Mt(y.killed?`git fetch timed out after ${ht/1e3} s`:y.stderr,{github:b||"",remote:v.remote,repo:p.repo})};continue}if(v.remote)try{await we(v,p.repo,k,f)}catch(y){f(`pushed branches /${v.route}: ${y.message}`)}}Et(It,m)}a(Ne,"fetchTryBranches");async function we(n,f,w,m){const v=n.waves.flatMap(({slug:d,wave:g})=>g.items.filter(y=>y.pushed?.branch&&!ne(y)&&!["done","merged","dropped"].includes(String(y.status).toLowerCase())).map(y=>({key:`${d}/${y.id}`,branch:y.pushed.branch})));if(!v.length)return;const p=new Map((await mt(f,"ls-remote","--heads","origin")).split(`
8
+ `).map(d=>/^([\da-f]+)\trefs\/heads\/(.+)$/.exec(d)).filter(Boolean).map(d=>[d[2],d[1]])),k=Qt(n.remote)?.host||"the remote";for(const d of v){let g=p.get(d.branch);if(!g)try{g=await M(f,"rev-parse","--verify","--quiet",`refs/heads/${d.branch}^{commit}`)}catch{}let y=!1;if(g)try{await M(f,"merge-base","--is-ancestor",g,`refs/remotes/origin/${w}`),y=!0}catch{}if(!(!y&&p.has(d.branch)))try{await re(n.dir,d.key,{status:y?"merged":"done",force:!0,text:y?`${d.branch} is merged into ${w} on ${k}, so this card is Done.`:`${d.branch} is gone from ${k}, so this card is Done.`})}catch(b){m(`pushed ${d.key}: ${b.message}`)}}}a(we,"settlePushed");const V=he({waiting:a(()=>Xt.waiting("tailscale"),"waiting")}),De=a((...n)=>V.start(...n),"startBuild"),Be=a((...n)=>V.tick(...n),"tryTick"),Fe=a((...n)=>V.pull(...n),"pullNow"),Re=a((...n)=>V.pullMains(...n),"pullMains"),Le=a(()=>V.stop(),"stopTries"),He=a((...n)=>V.initialize(...n),"initializeTries");export{T as BuildError,z as TRY_PORTS,he as createTryManager,Ne as fetchTryBranches,He as initializeTries,Bt as installWorktree,pt as isTryPort,me as pullBranch,Re as pullMains,Fe as pullNow,De as startBuild,Le as stopTries,Be as tryTick};
package/hub/usage.mjs CHANGED
@@ -1,3 +1,3 @@
1
- var N=Object.defineProperty;var a=(e,t)=>N(e,"name",{value:t,configurable:!0});import f from"node:fs";import _ from"node:os";import m from"node:path";const S=6,b=180,x={five_hour:5*36e5,seven_day:7*864e5},h=a(e=>{try{return JSON.parse(f.readFileSync(e,"utf8"))}catch{return null}},"readJson"),y=a(e=>e==null||e===""||!Number.isFinite(Number(e))?null:Number(e),"num"),g=a(e=>{if(e==null||e==="")return null;const n=typeof e=="number"||/^\d+(\.\d+)?$/.test(String(e))?Number(e)>1e12?Number(e):Number(e)*1e3:Date.parse(String(e));return Number.isFinite(new Date(n).getTime())?new Date(n).toISOString():null},"isoOf"),w=a((e,t)=>({used_percentage:y(e),resets_at:g(t)}),"win");function k(e){let t;try{t=f.openSync(e,"r");const n=f.fstatSync(t).size;for(const r of[256*1024,2*1024*1024]){const s=Math.max(0,n-r),u=Buffer.alloc(n-s);f.readSync(t,u,0,u.length,s);const i=u.toString("utf8").split(`
2
- `);s>0&&i.shift();for(let c=i.length-1;c>=0;c--)if(i[c].includes("rate_limits"))try{const d=JSON.parse(i[c]),o=d?.payload?.rate_limits??d?.rate_limits;if(o?.primary)return{rl:o}}catch{}if(s===0)break}}catch{}finally{if(t!==void 0)try{f.closeSync(t)}catch{}}return null}a(k,"lastRateLimits");function D(e){const t=[],n=a(r=>{try{return f.readdirSync(r).sort().reverse()}catch{return[]}},"kids");e:for(const r of n(e))for(const s of n(m.join(e,r)))for(const u of n(m.join(e,r,s))){const i=m.join(e,r,s,u);for(const c of n(i))if(/^rollout-.*\.jsonl$/.test(c))try{t.push({file:m.join(i,c),mtime:f.statSync(m.join(i,c)).mtimeMs})}catch{}if(t.length>=S*2)break e}return t.sort((r,s)=>s.mtime-r.mtime).slice(0,S)}a(D,"newestRollouts");function O(e){const t=[];for(const{file:o,mtime:l}of D(e)){const p=k(o);p&&t.push({...p,mtime:l,resets:y(p.rl.primary.resets_at)})}if(!t.length)return null;const n=Math.max(...t.map(o=>o.resets??-1/0)),r=t.filter(o=>o.resets!=null&&Math.abs(o.resets-n)<=b),s=a(o=>r.reduce((l,p)=>(y(p.rl[o]?.used_percent)??-1)>(y(l?.rl[o]?.used_percent)??-1)?p:l,null),"pick");if(!r.length)return null;const u=s("primary"),i=s("secondary"),c=r.reduce((o,l)=>l.mtime>o.mtime?l:o),d=a((o,l)=>o?w(o.rl[l].used_percent,o.rl[l].resets_at):null,"w");return{five_hour:d(u,"primary"),seven_day:d(i,"secondary"),at:new Date(c.mtime).toISOString()}}a(O,"scanCodex");function j(e,t){if(!e||typeof e!="object"||Array.isArray(e))return null;const n=g(e.at),r=a(s=>{const u=e[s];if(!u)return null;const i=w(u.used_percentage,u.resets_at);if(i.used_percentage==null)return null;const c=!n||t-Date.parse(n)>x[s]?"stale":i.resets_at&&t>=Date.parse(i.resets_at)?"reset":"live";return{...i,used_percentage:c==="reset"?0:i.used_percentage,state:c}},"window");return{at:n,five_hour:r("five_hour"),seven_day:r("seven_day")}}a(j,"readingOf");function J({home:e=_.homedir(),now:t=Date.now,cacheMs:n=6e4}={}){const r=m.join(e,".codex","sessions");let s=null,u=-1/0;return({codexShare:i="off"}={})=>{const c=t(),d=j(h(m.join(e,".claude","kickflip","usage.json"))?.claude,c);return i==="off"?{claude:d,codex:null}:(c-u>=n&&(s=O(r),u=c),{claude:d,codex:j(s||{at:null},c)})}}a(J,"usageReader");function R(e,{home:t=_.homedir()}={}){if(typeof e!="string"||!/^[\w-]+$/.test(e))return null;const n=h(m.join(t,".claude","kickflip","context",`${e}.json`));return y(n?.percent)==null||!n.at?null:{percent:y(n.percent),at:n.at}}a(R,"contextReading");async function W({gate:e,home:t=_.homedir(),resume:n}){const r=m.join(t,".claude","kickflip","usage-gate-state.json"),s=h(r);s?.state==="paused"&&e.state==="normal"&&await n(s.window),f.mkdirSync(m.dirname(r),{recursive:!0});const u=`${r}.${process.pid}.tmp`;f.writeFileSync(u,JSON.stringify(e)+`
3
- `),f.renameSync(u,r)}a(W,"checkUsagePause");export{W as checkUsagePause,R as contextReading,J as usageReader};
1
+ var x=Object.defineProperty;var a=(t,e)=>x(t,"name",{value:e,configurable:!0});import f from"node:fs";import _ from"node:os";import m from"node:path";const S=6,N=180,b={five_hour:5*36e5,seven_day:7*864e5},h=a(t=>{try{return JSON.parse(f.readFileSync(t,"utf8"))}catch{return null}},"readJson"),y=a(t=>t==null||t===""||!Number.isFinite(Number(t))?null:Number(t),"num"),g=a(t=>{if(t==null||t==="")return null;const n=typeof t=="number"||/^\d+(\.\d+)?$/.test(String(t))?Number(t)>1e12?Number(t):Number(t)*1e3:Date.parse(String(t));return Number.isFinite(new Date(n).getTime())?new Date(n).toISOString():null},"isoOf"),w=a((t,e)=>({used_percentage:y(t),resets_at:g(e)}),"win");function O(t){let e;try{e=f.openSync(t,"r");const n=f.fstatSync(e).size;for(const r of[256*1024,2*1024*1024]){const s=Math.max(0,n-r),u=Buffer.alloc(n-s);f.readSync(e,u,0,u.length,s);const c=u.toString("utf8").split(`
2
+ `);s>0&&c.shift();for(let i=c.length-1;i>=0;i--)if(c[i].includes("rate_limits"))try{const d=JSON.parse(c[i]),o=d?.payload?.rate_limits??d?.rate_limits;if(o?.primary)return{rl:o}}catch{}if(s===0)break}}catch{}finally{if(e!==void 0)try{f.closeSync(e)}catch{}}return null}a(O,"lastRateLimits");function k(t){const e=[],n=a(r=>{try{return f.readdirSync(r).sort().reverse()}catch{return[]}},"kids");t:for(const r of n(t))for(const s of n(m.join(t,r)))for(const u of n(m.join(t,r,s))){const c=m.join(t,r,s,u);for(const i of n(c))if(/^rollout-.*\.jsonl$/.test(i))try{e.push({file:m.join(c,i),mtime:f.statSync(m.join(c,i)).mtimeMs})}catch{}if(e.length>=S*2)break t}return e.sort((r,s)=>s.mtime-r.mtime).slice(0,S)}a(k,"newestRollouts");function D(t){const e=[];for(const{file:o,mtime:l}of k(t)){const p=O(o);p&&e.push({...p,mtime:l,resets:y(p.rl.primary.resets_at)})}if(!e.length)return null;const n=Math.max(...e.map(o=>o.resets??-1/0)),r=e.filter(o=>o.resets!=null&&Math.abs(o.resets-n)<=N),s=a(o=>r.reduce((l,p)=>(y(p.rl[o]?.used_percent)??-1)>(y(l?.rl[o]?.used_percent)??-1)?p:l,null),"pick");if(!r.length)return null;const u=s("primary"),c=s("secondary"),i=r.reduce((o,l)=>l.mtime>o.mtime?l:o),d=a((o,l)=>o?w(o.rl[l].used_percent,o.rl[l].resets_at):null,"w");return{five_hour:d(u,"primary"),seven_day:d(c,"secondary"),at:new Date(i.mtime).toISOString()}}a(D,"scanCodex");function j(t,e){if(!t||typeof t!="object"||Array.isArray(t))return null;const n=g(t.at),r=a(s=>{const u=t[s];if(!u)return null;const c=w(u.used_percentage,u.resets_at);if(c.used_percentage==null)return null;const i=!n||e-Date.parse(n)>b[s]?"stale":c.resets_at&&e>=Date.parse(c.resets_at)?"reset":"live";return{...c,used_percentage:i==="reset"?0:c.used_percentage,state:i}},"window");return{at:n,five_hour:r("five_hour"),seven_day:r("seven_day")}}a(j,"readingOf");function J({home:t=_.homedir(),now:e=Date.now,cacheMs:n=6e4}={}){const r=m.join(t,".codex","sessions");let s=null,u=-1/0;return({codexShare:c="off"}={})=>{const i=e(),d=j(h(m.join(t,".claude","kickflip","usage.json"))?.claude,i);return c==="off"?{claude:d,codex:null}:(i-u>=n&&(s=D(r),u=i),{claude:d,codex:j(s||{at:null},i)})}}a(J,"usageReader");const F=95,L=a((t,e)=>t==="deaf"&&e?.percent>=F,"contextFull");function R(t,{home:e=_.homedir()}={}){if(typeof t!="string"||!/^[\w-]+$/.test(t))return null;const n=h(m.join(e,".claude","kickflip","context",`${t}.json`));return y(n?.percent)==null||!n.at?null:{percent:y(n.percent),at:n.at}}a(R,"contextReading");async function W({gate:t,home:e=_.homedir(),resume:n}){const r=m.join(e,".claude","kickflip","usage-gate-state.json"),s=h(r);s?.state==="paused"&&t.state==="normal"&&await n(s.window),f.mkdirSync(m.dirname(r),{recursive:!0});const u=`${r}.${process.pid}.tmp`;f.writeFileSync(u,JSON.stringify(t)+`
3
+ `),f.renameSync(u,r)}a(W,"checkUsagePause");export{F as CONTEXT_FULL,W as checkUsagePause,L as contextFull,R as contextReading,J as usageReader};