@lovek4d/kickflip 0.0.0-stage → 0.1.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 +414 -0
  2. package/LICENSE +57 -0
  3. package/README.md +23 -2
  4. package/SKILL.md +121 -0
  5. package/bin/doctor.mjs +3 -0
  6. package/bin/install.mjs +3 -0
  7. package/bin/kickflip.mjs +125 -0
  8. package/bin/statusline.mjs +4 -0
  9. package/bin/wait-events.mjs +3 -0
  10. package/bin/web-ts.mjs +2 -0
  11. package/board/bin/agent.mjs +2 -0
  12. package/board/bin/board.mjs +207 -0
  13. package/board/bin/builder-stop-hook.mjs +5 -0
  14. package/board/build-script.mjs +1 -0
  15. package/board/domain.mjs +3 -0
  16. package/board/public/app-asks.js +169 -0
  17. package/board/public/app-banner.js +48 -0
  18. package/board/public/app-cards.js +200 -0
  19. package/board/public/app-core.js +96 -0
  20. package/board/public/app-header.js +108 -0
  21. package/board/public/app-lanes.js +92 -0
  22. package/board/public/app-needs.js +30 -0
  23. package/board/public/app-snapshot.js +97 -0
  24. package/board/public/app-threads.js +165 -0
  25. package/board/public/app.html +83 -0
  26. package/board/public/app.js +327 -0
  27. package/board/public/attach.js +84 -0
  28. package/board/public/dialogs.js +360 -0
  29. package/board/public/hub-kit.js +151 -0
  30. package/board/public/hub.css +164 -0
  31. package/board/public/kickflip.png +0 -0
  32. package/board/public/prompts.js +64 -0
  33. package/board/public/prototype-kit.js +193 -0
  34. package/board/public/prototype-switcher.js +226 -0
  35. package/board/public/prototype.html +55 -0
  36. package/board/public/router.js +223 -0
  37. package/board/public/rules-editor.js +127 -0
  38. package/board/public/shell.js +298 -0
  39. package/board/public/sidebar.css +199 -0
  40. package/board/public/sidebar.js +287 -0
  41. package/board/public/style.css +570 -0
  42. package/board/public/toasts.js +179 -0
  43. package/board/public/ui-cards.js +153 -0
  44. package/board/public/ui-enums.js +10 -0
  45. package/board/public/ui-header.js +84 -0
  46. package/board/public/ui-hub.js +94 -0
  47. package/board/public/ui-links.js +142 -0
  48. package/board/public/ui-markdown.js +80 -0
  49. package/board/public/ui-page-header.js +63 -0
  50. package/board/public/ui-prompts.js +39 -0
  51. package/board/public/ui-questions.js +130 -0
  52. package/board/public/ui-sidebar.js +177 -0
  53. package/board/public/ui-snapshot.js +56 -0
  54. package/board/public/ui-threads.js +81 -0
  55. package/board/public/ui.js +235 -0
  56. package/board/public/view-frame.js +93 -0
  57. package/board/public/view-hub-settings.js +266 -0
  58. package/board/public/view-hub.js +99 -0
  59. package/board/public/view-notes.js +29 -0
  60. package/board/public/view-polling.js +87 -0
  61. package/board/public/view-questions.js +391 -0
  62. package/board/public/view-settings.js +328 -0
  63. package/board/server.mjs +12 -0
  64. package/board/store.mjs +7 -0
  65. package/board/tickets.mjs +3 -0
  66. package/board/worktree.mjs +14 -0
  67. package/docs/hub-session.md +11 -0
  68. package/hooks-plugin/.claude-plugin/plugin.json +5 -0
  69. package/hooks-plugin/hooks/codex-share-auto.mjs +21 -0
  70. package/hooks-plugin/hooks/hooks.json +54 -0
  71. package/hooks-plugin/hooks/usage-gate.mjs +18 -0
  72. package/hub/board-repo.mjs +1 -0
  73. package/hub/boards.mjs +4 -0
  74. package/hub/close-board.mjs +1 -0
  75. package/hub/files.mjs +1 -0
  76. package/hub/github.mjs +5 -0
  77. package/hub/home.mjs +1 -0
  78. package/hub/hub.mjs +17 -0
  79. package/hub/icon-180.png +0 -0
  80. package/hub/icon-192.png +0 -0
  81. package/hub/icon-512.png +0 -0
  82. package/hub/icons.mjs +1 -0
  83. package/hub/linear.mjs +2 -0
  84. package/hub/live.mjs +2 -0
  85. package/hub/locks.mjs +1 -0
  86. package/hub/new-board.mjs +2 -0
  87. package/hub/permission-hook.mjs +3 -0
  88. package/hub/presence.mjs +1 -0
  89. package/hub/prompts.mjs +2 -0
  90. package/hub/push.mjs +2 -0
  91. package/hub/repos.mjs +2 -0
  92. package/hub/routes.mjs +1 -0
  93. package/hub/rules.mjs +1 -0
  94. package/hub/schedule.mjs +1 -0
  95. package/hub/sessions.mjs +2 -0
  96. package/hub/shell.mjs +1 -0
  97. package/hub/stream.mjs +19 -0
  98. package/hub/sw.js +144 -0
  99. package/hub/try.mjs +3 -0
  100. package/hub/usage.mjs +3 -0
  101. package/hub/views.mjs +1 -0
  102. package/package.json +26 -4
  103. package/shared/codex.mjs +1 -0
  104. package/shared/colors.css +13 -0
  105. package/shared/repos.mjs +1 -0
  106. package/shared/service.mjs +33 -0
  107. package/shared/usage-gate.mjs +1 -0
  108. package/starter/cross-site.mjs +1 -0
  109. package/starter/public/claude.js +67 -0
  110. package/starter/public/index.html +49 -0
  111. package/starter/server.mjs +4 -0
package/hub/stream.mjs ADDED
@@ -0,0 +1,19 @@
1
+ var j=Object.defineProperty;var n=(m,g)=>j(m,"name",{value:g,configurable:!0});import C from"node:http";function A({targets:m,sidebar:g,retryMs:q=2e3,sidebarMs:x=15e3,pingMs:z=25e3,settleMs:I=300,log:b=n(()=>{},"log")}){const o=new Set,a=new Map,w=new Set;let f=null,v=[],d=new Set,l=null;const u=n((e,t=o)=>{for(const r of t)r.write(e)},"write"),O=n((e,t)=>`event: ${e}
2
+ data: ${JSON.stringify(t)}
3
+
4
+ `,"event");async function h(e=[]){let t;try{t=await g({fresh:new Set(e)})}catch(s){b(`sidebar for the stream failed: ${s.message}`);return}const r=JSON.stringify(t);r!==f&&(f=r,u(`event: sidebar
5
+ data: ${r}
6
+
7
+ `))}n(h,"refreshSidebar");function H(e){d.add(e),!l&&(l=setTimeout(()=>{l=null;const t=[...d];d=new Set,h(t)},I))}n(H,"sidebarSoon");function S(e){u(O("change",{route:e})),H(e)}n(S,"changed");function T(e,t){const r={port:t,req:null};a.set(e,r);const s=n(()=>{a.get(e)===r&&a.delete(e),r.req?.destroy()},"drop");r.req=C.get({host:"127.0.0.1",port:t,path:"/api/stream",headers:{accept:"text/event-stream"}},i=>{if(i.statusCode!==200){i.resume(),s();return}w.has(e)&&S(e),w.add(e),i.setEncoding("utf8");let c="";i.on("data",N=>{c+=N.replace(/\r\n/g,`
8
+ `);for(let p=c.indexOf(`
9
+
10
+ `);p>=0;p=c.indexOf(`
11
+
12
+ `)){const _=c.slice(0,p);c=c.slice(p+2),/^event: ?change$/m.test(_)&&S(e)}}),i.on("close",s),i.on("error",s)}),r.req.on("error",s)}n(T,"connect");function y(){let e;try{e=new Map(m().map(t=>[t.route,t.port]))}catch(t){b(`stream targets failed: ${t.message}`);return}for(const[t,r]of a)e.get(t)!==r.port&&(a.delete(t),r.req?.destroy());for(const[t,r]of e)a.has(t)||T(t,r)}n(y,"reconcile");function k(){y(),v=[setInterval(y,q),setInterval(()=>h(),x),setInterval(()=>u(`: ping
13
+
14
+ `),z)]}n(k,"startHolding");function $(){for(const e of v)clearInterval(e);v=[],clearTimeout(l),l=null,d=new Set;for(const e of a.values())e.req?.destroy();a.clear(),w.clear(),f=null}n($,"stopHolding");function J(e,t){t.writeHead(200,{"content-type":"text/event-stream; charset=utf-8","cache-control":"no-store",connection:"keep-alive"}),t.write(`retry: 3000
15
+
16
+ `),o.add(t),o.size===1&&k(),f?u(`event: sidebar
17
+ data: ${f}
18
+
19
+ `,[t]):h(),e.on("close",()=>{o.delete(t),o.size||$()})}n(J,"handle");function M(){for(const e of o)e.end();o.clear(),$()}return n(M,"close"),{handle:J,close:M,changed:n(e=>{o.size&&S(e)},"changed"),refresh:n(()=>{o.size&&h()},"refresh")}}n(A,"hubStream");export{A as hubStream};
package/hub/sw.js ADDED
@@ -0,0 +1,144 @@
1
+ // The hub's service worker, served at /sw.js with scope /. Push messages come
2
+ // from push.mjs as {title, body, url, tag, badge}; url is a path on the hub, and badge, when
3
+ // present, is the Home Screen app badge count.
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
6
+ // go to the network.
7
+ // A {forget: 'shell'} message drops the saved shell before the owner's reload and replies on its port.
8
+ const CACHE = 'hub-app';
9
+ const SHELL = new URL('/', self.registration.scope).href;
10
+ // A sleeping host over Tailscale leaves a request hanging rather than failing, so a saved copy answers after this long.
11
+ const NETWORK_WAIT_MS = 3000;
12
+
13
+ // The paths hub/shell.mjs pageOf answers with the shell on a page load; hub/sw.test.mjs checks they agree.
14
+ const HUB_PAGES = new Set(['/', '/_hub/', '/_hub/settings', '/_hub/settings/polling']);
15
+ function isShellPage(url) {
16
+ if (HUB_PAGES.has(url.pathname)) return true;
17
+ if (url.searchParams.has('alone')) return false;
18
+ const m = /^\/([^/]+)\/(.*)$/.exec(url.pathname);
19
+ return Boolean(m) && m[1] !== '_hub' && !/^try(?:\/|$)/.test(m[2]) && (m[2] === '' || m[2].endsWith('/') || /\.html?$/.test(m[2]));
20
+ }
21
+ const isAsset = (url) => url.pathname.startsWith('/_hub/app/') || url.pathname === '/_hub/colors.css' || url.pathname === '/_hub/kickflip.png';
22
+ // hub/hub.mjs marks the shell it serves with the sidebar written in; a site's own page and the board server's copy
23
+ // aren't, so neither replaces the saved shell.
24
+ const isShell = (res) => res.ok && res.headers.get('x-app-shell') === '1';
25
+
26
+ // Saves res under key, dropping any other version of the same file.
27
+ async function save(key, res) {
28
+ const cache = await caches.open(CACHE);
29
+ await cache.put(key, res);
30
+ const { pathname } = new URL(key);
31
+ for (const old of await cache.keys()) {
32
+ const u = new URL(old.url);
33
+ if (u.pathname === pathname && u.href !== key) await cache.delete(old);
34
+ }
35
+ }
36
+ const saved = async (key) => (await caches.open(CACHE)).match(key, { ignoreVary: true });
37
+
38
+ // The network's answer, else the copy saved under key when the network fails, answers a 5xx (Tailscale's answer while
39
+ // the hub is down) or takes too long. An answer keep accepts replaces the copy.
40
+ function networkFirst(event, request, key, keep) {
41
+ const net = fetch(request).then((res) => {
42
+ if (keep(res)) event.waitUntil(save(key, res.clone()).catch(() => {}));
43
+ return res;
44
+ });
45
+ event.waitUntil(net.catch(() => {}));
46
+ return new Promise((resolve, reject) => {
47
+ const fallback = (otherwise) => saved(key).then((hit) => (hit ? resolve(hit) : otherwise())).catch(otherwise);
48
+ const timer = setTimeout(() => fallback(() => {}), NETWORK_WAIT_MS);
49
+ net.then((res) => {
50
+ clearTimeout(timer);
51
+ if (res.status >= 500) fallback(() => resolve(res)); else resolve(res);
52
+ }, (err) => {
53
+ clearTimeout(timer);
54
+ fallback(() => reject(err));
55
+ });
56
+ });
57
+ }
58
+
59
+ // The shell from `/` and the files it links, so one visit is enough to open offline.
60
+ async function saveShell() {
61
+ const res = await fetch(SHELL, { cache: 'no-store' });
62
+ if (!isShell(res)) return;
63
+ const html = await res.clone().text();
64
+ await save(SHELL, res);
65
+ const urls = new Set([...html.matchAll(/\b(?:src|href)="(\/_hub\/[^"]+)"/g)].map((m) => new URL(m[1], SHELL)).filter(isAsset).map((u) => u.href));
66
+ await Promise.all([...urls].map(async (u) => {
67
+ try { const r = await fetch(u); if (r.ok) await save(u, r); } catch {}
68
+ }));
69
+ }
70
+
71
+ self.addEventListener('install', (event) => {
72
+ self.skipWaiting();
73
+ event.waitUntil(saveShell().catch(() => {}));
74
+ });
75
+ self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
76
+
77
+ self.addEventListener('message', (event) => {
78
+ if (event.data?.forget !== 'shell') return;
79
+ event.waitUntil((async () => {
80
+ await (await caches.open(CACHE)).delete(SHELL);
81
+ event.ports[0]?.postMessage({ forgot: true });
82
+ })());
83
+ });
84
+
85
+ self.addEventListener('fetch', (event) => {
86
+ const req = event.request;
87
+ if (req.method !== 'GET') return;
88
+ const url = new URL(req.url);
89
+ if (url.origin !== self.location.origin) return;
90
+ if (req.mode === 'navigate') {
91
+ // A frame's page load always goes to the network, so a frame never shows the shell in place of its page.
92
+ if (req.destination !== 'document' || !isShellPage(url)) return;
93
+ // The hub tells the shell from a site's page by Sec-Fetch-Dest, which this fetch would lose (hub/shell.mjs destOf).
94
+ const page = new Request(url.href, { headers: { 'x-app-shell': '1' }, cache: 'no-store', redirect: 'manual' });
95
+ event.respondWith(networkFirst(event, page, SHELL, isShell));
96
+ return;
97
+ }
98
+ if (isAsset(url)) event.respondWith(networkFirst(event, req, url.href, (res) => res.ok));
99
+ });
100
+
101
+ self.addEventListener('push', (event) => {
102
+ let msg = {};
103
+ try { msg = event.data ? event.data.json() : {}; } catch { msg = { body: event.data ? event.data.text() : '' }; }
104
+ // A badge failure must never stop the notification: iOS revokes push from an app whose push shows nothing.
105
+ let badge = null;
106
+ try {
107
+ const nav = self.navigator;
108
+ if (Number.isInteger(msg.badge) && nav && 'setAppBadge' in nav) badge = (msg.badge > 0 ? nav.setAppBadge(msg.badge) : nav.clearAppBadge()).catch(() => {});
109
+ } catch {}
110
+ // renotify alerts again for a push that replaces one with its tag, such as a card's new question; browsers throw on
111
+ // renotify without a tag.
112
+ event.waitUntil(Promise.all([self.registration.showNotification(msg.title || 'Claude', {
113
+ body: msg.body || '',
114
+ tag: msg.tag || undefined,
115
+ renotify: Boolean(msg.tag),
116
+ icon: '/_hub/icon-192.png',
117
+ data: { url: new URL(msg.url || '/', self.registration.scope).href },
118
+ }), badge]));
119
+ });
120
+
121
+ // The app shell (board/public/shell.js) answers {open: url} on the message's port with {routed: true} when it shows the
122
+ // URL in place; a window that doesn't answer in time, such as an older page, is navigated, which reloads it.
123
+ const ROUTE_WAIT_MS = 1500;
124
+ function routeInPlace(win, url) {
125
+ return new Promise((resolve) => {
126
+ const channel = new MessageChannel();
127
+ const timer = setTimeout(() => resolve(false), ROUTE_WAIT_MS);
128
+ channel.port1.onmessage = (e) => { clearTimeout(timer); resolve(e.data?.routed === true); };
129
+ try { win.postMessage({ open: url }, [channel.port2]); } catch { clearTimeout(timer); resolve(false); }
130
+ });
131
+ }
132
+
133
+ self.addEventListener('notificationclick', (event) => {
134
+ event.notification.close();
135
+ const url = event.notification.data?.url || self.registration.scope;
136
+ event.waitUntil((async () => {
137
+ const wins = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
138
+ const win = wins.find((w) => 'focus' in w);
139
+ if (!win) return self.clients.openWindow(url);
140
+ await win.focus();
141
+ if (await routeInPlace(win, url)) return;
142
+ return win.navigate(url);
143
+ })());
144
+ });
package/hub/try.mjs ADDED
@@ -0,0 +1,3 @@
1
+ var pt=Object.defineProperty;var s=(r,d)=>pt(r,"name",{value:d,configurable:!0});import y from"node:fs";import b from"node:path";import et from"node:os";import ht from"node:http";import{execFile as mt,spawn as yt}from"node:child_process";import{createInterface as it}from"node:readline";import{promisify as wt}from"node:util";import{listBoards as R,readManifest as G,readJSON as rt,saveJSON as gt,GITHUB_STATE as bt}from"./boards.mjs";import{boardRepo as nt,githubFromOrigin as kt}from"./board-repo.mjs";import{buildScriptError as vt}from"../board/build-script.mjs";import{appendThread as St,recordedWorktree as $t}from"../board/store.mjs";class L extends Error{static{s(this,"BuildError")}constructor(d,w=400){super(d),this.status=w}}const Tt=wt(mt),O=s(async(r,...d)=>(await Tt("git",["-C",r,...d],{encoding:"utf8",timeout:3e3})).stdout.trim(),"git"),xt=/errSecInternalComponent|keychain[^\n]*\blocked\b|\blocked\b[^\n]*keychain/i,It=30*6e4,st=s(r=>b.join(r,".kickflip","build"),"scriptAt"),ot=s(r=>y.existsSync(r),"exists"),at=s((r,d)=>b.join(r.dir,"data","try",`${d.split("/").at(-1)}.log`),"logFileOf");function ct(r,d,w){const u=at(r,d);return y.mkdirSync(b.dirname(u),{recursive:!0}),y.writeFileSync(u,w.message+`
2
+ `,{mode:384}),y.chmodSync(u,384),u}s(ct,"failureLog");const Ct=s(r=>new Promise(d=>{const w=ht.get({host:"127.0.0.1",port:r,path:"/",timeout:1e3},u=>{u.destroy(),d(!0)});w.on("timeout",()=>w.destroy()),w.on("error",()=>d(!1))}),"probe"),lt=s((r,d)=>{try{process.kill(-r.pid,d)}catch{}},"signalGroup");async function dt(r,d,w){const u=await $t(r.dir,d),[m,E]=d.split("/"),M=r.waves.find($=>$.slug===m)?.wave,j=u?.path||M?.items.find($=>$.id===E)?.worktree;if(typeof j!="string"||!j||j.split(b.sep).includes(".."))return"";let I=u?.repo;const N=(r.github||M?.repo||"").toLowerCase();if(!I&&N){const $=[r.owner?.repo,r.dir,...Object.values(w?.sites||{}).map(k=>k.owner?.repo)];for(const k of new Set($.filter(T=>typeof T=="string"&&b.isAbsolute(T))))try{if(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/.exec(await O(k,"remote","get-url","origin"))?.[1].toLowerCase()===N){I=await O(k,"rev-parse","--show-toplevel");break}}catch{}}if(!u?.path&&!I||u?.path&&!b.isAbsolute(j))return"";try{const $=[b.join(y.realpathSync(et.homedir()),".claude","kickflip","worktrees")];if(typeof I=="string"&&b.isAbsolute(I))try{$.push(b.join(y.realpathSync(I),".claude","worktrees"))}catch{}const k=b.resolve(I||"/",j),T=y.realpathSync(k);return $.some(_=>k.startsWith(_+b.sep)&&T.startsWith(_+b.sep))&&y.statSync(T).isDirectory()?T:""}catch{return""}}s(dt,"installWorktree");function Ot({now:r=Date.now,answers:d=Ct,log:w=console.log}={}){const u=new Map,m=new Map,E=new Set,M=new Map;let j=!1,I=null;const N=s(()=>new Date(r()).toISOString(),"stamp");function $(t){if(u.has(t.dir))return u.get(t.dir);const i=b.join(t.dir,"data","try.json"),l=rt(i,{kind:null,main:null,cards:{}}),e={kind:l.kind||null,main:l.main||null,cards:{...l.cards}},n={state:e,file:i,written:JSON.stringify(l)};if(u.set(t.dir,n),e.kind==="web")e.cards={},e.main&&e.main.state!=="failed"&&(delete e.main.state,delete e.main.port);else{for(const o of Object.values(e.cards))o.state==="running"&&(delete o.state,delete o.sha);e.main?.state==="running"&&(delete e.main.state,delete e.main.sha)}return k(t),n}s($,"stateOf");function k(t){const i=u.get(t.dir),l=JSON.stringify(i.state);l!==i.written&&(gt(i.file,i.state),i.written=l)}s(k,"persist");const T=s((t,i)=>`${t.dir}\0${i}`,"runKey"),_=s((t,i)=>i==="main"?t.main:t.cards[i],"entryOf");function K(t,i,l){const{state:e}=$(t),n=_(e,i);if(!n)return;const o=Object.hasOwn(l,"state")&&l.state!==n.state;Object.assign(n,l,i!=="main"&&o?{at:N()}:{}),k(t)}s(K,"update");async function Y(t,i,l,e){const{board:n,key:o}=t;if(K(n,o,{state:"failed",port:void 0}),o==="main")return;const c=[];let v=!1;try{for await(const g of it({input:y.createReadStream(t.logFile),crlfDelay:1/0}))v||=xt.test(g),c.push(g),c.length>5&&c.shift()}catch(g){c.push(`Could not read the build log: ${g.message}`)}const S=t.port||($(n).state.kind||(n.waves.some(({wave:g})=>g.items.some(x=>x.install?.url))?"app":"web"))==="web"?"Serve":"Build";await St(n.dir,o,{text:[`${S} failed (exit ${i??1})`,e?.message,l&&`Stopped by ${l}.`,v&&"The login keychain must be unlocked on the Mac.",...c].filter(Boolean).join(`
3
+ `)})}s(Y,"failure");function J(t){return t.ending||(lt(t.child,"SIGTERM"),t.ending=new Promise(i=>{const l=s(()=>{clearTimeout(e),clearInterval(n),i()},"finish"),e=setTimeout(()=>{lt(t.child,"SIGKILL"),l()},4e3),n=setInterval(()=>{try{process.kill(-t.child.pid,0)}catch{l()}},20)})),t.ending}s(J,"terminate");async function P(t){t.stopped=!0,clearTimeout(t.deadline),clearInterval(t.poll),await J(t),await t.done,K(t.board,t.key,{state:void 0,port:void 0,...t.ready?{}:{sha:void 0}}),t.ready||M.delete(T(t.board,t.key))}s(P,"stopRun");async function Q(t,i,l,e={}){if(!l)throw new L("This card has no valid worktree.");const n=st(l),o=vt(n);if(o)throw new L(o);if(!y.realpathSync(n).startsWith(y.realpathSync(l)+b.sep))throw new L("Build script must be inside the worktree.");const c=await O(l,"rev-parse","HEAD");if(j)throw new L("The hub is stopping. Try again after it restarts.",503);const{state:v}=$(t),S=_(v,i),g={...i==="main"?{}:S,...e,sha:c,script:!0,state:"running",...i==="main"?{}:{at:N(),head:c}};delete g.port;const x=at(t,i);y.mkdirSync(b.dirname(x),{recursive:!0});const f=y.openSync(x,"w",384);y.fchmodSync(f,384);const p=yt(n,[i,l],{cwd:l,detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,KICKFLIP_BOARD:t.route,KICKFLIP_BASE:`/${t.route}/try/${i.split("/").at(-1).toLowerCase()}/`}});i==="main"?v.main=g:v.cards[i]=g,k(t);const C=T(t,i),a={child:p,board:t,key:i,logFile:x,visited:r(),stopped:!1,port:null,ready:!1};m.set(C,a),M.set(C,c);let h,D;const B=s(A=>{D||=new Error(A),J(a)},"fail");p.on("error",A=>{h=A}),p.stdout.on("data",A=>y.writeSync(f,A)),p.stderr.on("data",A=>y.writeSync(f,A));const Z=it({input:p.stdout,crlfDelay:1/0});return Z.on("line",A=>{const H=/^port (\d{1,5})$/.exec(A);if(!H||a.port||a.stopped)return;const F=Number(H[1]);if(F<1||F>65535){B("Build script printed an invalid port.");return}a.port=F,clearTimeout(a.deadline);const z=r()+6e4;let q=!1;const tt=s(async()=>{if(!(q||a.stopped||a.ready||p.exitCode!==null||p.signalCode!==null)){q=!0;try{if(await d(F)){if(a.stopped||D||p.exitCode!==null||p.signalCode!==null)return;a.ready=!0,a.visited=r(),v.kind="web",K(t,i,{state:"ready",port:F}),clearInterval(a.poll)}else r()>=z&&B("Nothing answered on the build script\u2019s port within 60 seconds.")}finally{q=!1}}},"check");a.poll=setInterval(()=>tt().catch(U=>B(U.message)),250),tt().catch(U=>B(U.message))}),a.deadline=setTimeout(()=>B("No port line within 60 minutes."),60*6e4),p.once("exit",()=>{J(a)}),a.done=new Promise(A=>p.once("close",(H,F)=>{clearTimeout(a.deadline),clearInterval(a.poll),Z.close(),y.closeSync(f),(async()=>{await J(a),!a.stopped&&(D||h||H!==0||a.port&&!a.ready?await Y(a,H??1,F,D||h||(a.port&&!a.ready?new Error("Build script exited before its port answered."):null)):a.ready?(i==="main"?(delete v.main.state,delete v.main.port):delete v.cards[i],k(t)):(v.kind="app",K(t,i,{state:"ready"})))})().catch(z=>w(`build /${t.route} ${i}: ${z.message}`)).finally(()=>{m.get(C)===a&&m.delete(C),A()})})),w(`build /${t.route} ${i}: started; log ${x}`),{status:"started"}}s(Q,"launch");async function V(t,{manifest:i=G()}={}){if(j)throw new L("The hub is stopping. Try again after it restarts.",503);if(!t||Array.isArray(t)||typeof t!="object"||Object.keys(t).some(c=>!["route","key"].includes(c))||typeof t.route!="string"||typeof t.key!="string")throw new L("Send only the board route and card key.");const{route:l,key:e}=t;if(!/^[\w.-]+\/[\w.-]+$/.test(e)||e.split("/").some(c=>c==="."||c===".."))throw new L("Card key must be <wave>/<id>.");const n=R(i).find(c=>c.route===l);if(!n)throw new L(`/${l} is not a board.`,404);if(!n.waves.some(({slug:c,wave:v})=>v.items.some(S=>`${c}/${S.id}`===e)))throw new L("There is no item with this card key.",404);const o=T(n,e);if(E.has(o)||m.has(o)&&!m.get(o).ready)throw new L("A build is already running for this card.",409);E.add(o);try{m.has(o)&&await P(m.get(o));const c=await dt(n,e,i);return await Q(n,e,c)}finally{E.delete(o)}}s(V,"start");function X(t,i,{manifest:l=G(),visit:e=!1}={}){const n=R(l).find(c=>c.route===t);if(!n)return null;const o=[...m.values()].find(c=>c.board.dir===n.dir&&c.key.split("/").at(-1).toLowerCase()===i.toLowerCase()&&c.ready&&!c.stopped&&c.child.exitCode===null&&c.child.signalCode===null);return o?(e&&(o.visited=r()),{key:o.key,port:o.port}):null}s(X,"lookup");async function ut({manifest:t=G()}={}){return I||j||(I=(async()=>{const i=R(t),l=rt(bt,{}).prs||{};for(const e of[...m.values()])if(!i.some(n=>n.dir===e.board.dir)||e.key!=="main"&&e.ready&&r()-e.visited>=It){await P(e);const{state:n}=$(e.board);e.key==="main"?n.main=null:delete n.cards[e.key],k(e.board)}for(const e of i){if(j)return;const{state:n}=$(e),o=nt(e.route,t),c=o.branch||"main",v=o.hub?"refs/heads/main":`refs/remotes/origin/${c}`;for(const{slug:S,wave:g}of e.waves)for(const x of g.items){const f=`${S}/${x.id}`;if(!(["done","merged","dropped"].includes(String(x.status).toLowerCase())||["MERGED","CLOSED"].includes(l[`${e.github||g.repo}#${x.pr}`]?.state)))try{const p=await dt(e,f,t);if(!p)continue;const C=await O(p,"rev-parse","HEAD");let a=0;try{a=Number(await O(p,"rev-list","--count",`${v}..HEAD`))}catch{}const h=ot(st(p)),D=n.cards[f]||={};if(Object.assign(D,{head:C,ahead:a,script:h}),k(e),e.auto_build&&h&&a>0&&(D.sha||M.get(T(e,f)))!==C&&D.state!=="running"&&!E.has(T(e,f)))try{await V({route:e.route,key:f},{manifest:t})}catch(B){if([409,503].includes(B.status))continue;throw K(e,f,{sha:C}),await Y({board:e,key:f,logFile:ct(e,f,B)},1),B}}catch(p){w(`try /${e.route} ${f}: ${p.message}`)}}try{if(!o.repo)continue;const S=await O(o.repo,"rev-parse",v);let g=!1;try{await O(o.repo,"cat-file","-e",`${S}:.kickflip/build`),g=!0}catch{}if(!g){const h=m.get(T(e,"main"));h&&await P(h),n.main=null,k(e);continue}const x=n.main;if(x?.state==="running"||x?.sha===S&&(x.state==="failed"||x.state==="ready"&&(n.kind!=="web"||X(e.route,"main",{manifest:t}))))continue;const f=T(e,"main");if(E.has(f))continue;const p=await O(o.repo,"log","-1","--format=%cI",S);let C=e.github;if(!C)try{C=kt(await O(o.repo,"remote","get-url","origin"))}catch{}const a={sha:S,branch:c,slug:C||"",at:p,script:g};E.add(f);try{m.has(f)&&await P(m.get(f));const h=b.join(et.homedir(),".claude","kickflip","try",`${e.route}-main`);y.mkdirSync(b.dirname(h),{recursive:!0}),ot(h)?await O(h,"checkout","--detach","--force",S):await O(o.repo,"worktree","add","--detach",h,S),await Q(e,"main",h,a)}catch(h){if(h.status===503)return;throw n.main={...a,state:"failed"},ct(e,"main",h),k(e),h}finally{E.delete(f)}}catch(S){w(`try /${e.route} main: ${S.message}`)}}})().finally(()=>{I=null})),I}s(ut,"tick");async function ft(){j=!0;const t=Promise.all([...m.values()].map(P));await Promise.all([t,I])}return s(ft,"stop"),{start:V,tick:ut,stop:ft,lookup:X,initialize:s((t=G())=>{for(const i of R(t))$(i)},"initialize")}}s(Ot,"createTryManager");async function Ht({manifest:r=G(),log:d=console.log}={}){for(const w of R(r)){const u=nt(w.route,r);if(u.hub||!u.repo)continue;const m=u.branch||"main";try{await O(u.repo,"fetch","--quiet","--no-tags","origin",`+refs/heads/${m}:refs/remotes/origin/${m}`)}catch(E){d(`fetch /${w.route}: ${E.message}`)}}}s(Ht,"fetchTryBranches");const W=Ot(),Rt=s((...r)=>W.start(...r),"startBuild"),Gt=s((...r)=>W.tick(...r),"tryTick"),Wt=s(()=>W.stop(),"stopTries"),Jt=s((...r)=>W.lookup(...r),"servingTry"),zt=s((...r)=>W.initialize(...r),"initializeTries");export{L as BuildError,Ot as createTryManager,Ht as fetchTryBranches,zt as initializeTries,dt as installWorktree,Jt as servingTry,Rt as startBuild,Wt as stopTries,Gt as tryTick};
package/hub/usage.mjs ADDED
@@ -0,0 +1,3 @@
1
+ var N=Object.defineProperty;var u=(e,t)=>N(e,"name",{value:t,configurable:!0});import f from"node:fs";import _ from"node:os";import m from"node:path";const h=6,b=180,D={five_hour:5*36e5,seven_day:7*864e5},S=u(e=>{try{return JSON.parse(f.readFileSync(e,"utf8"))}catch{return null}},"readJson"),y=u(e=>e==null||e===""||!Number.isFinite(Number(e))?null:Number(e),"num"),g=u(e=>{if(e==null||e==="")return null;const s=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(s).getTime())?new Date(s).toISOString():null},"isoOf"),w=u((e,t)=>({used_percentage:y(e),resets_at:g(t)}),"win");function O(e){let t;try{t=f.openSync(e,"r");const s=f.fstatSync(t).size;for(const n of[256*1024,2*1024*1024]){const r=Math.max(0,s-n),a=Buffer.alloc(s-r);f.readSync(t,a,0,a.length,r);const i=a.toString("utf8").split(`
2
+ `);r>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(r===0)break}}catch{}finally{if(t!==void 0)try{f.closeSync(t)}catch{}}return null}u(O,"lastRateLimits");function k(e){const t=[],s=u(n=>{try{return f.readdirSync(n).sort().reverse()}catch{return[]}},"kids");e:for(const n of s(e))for(const r of s(m.join(e,n)))for(const a of s(m.join(e,n,r))){const i=m.join(e,n,r,a);for(const c of s(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>=h*2)break e}return t.sort((n,r)=>r.mtime-n.mtime).slice(0,h)}u(k,"newestRollouts");function x(e){const t=[];for(const{file:o,mtime:l}of k(e)){const p=O(o);p&&t.push({...p,mtime:l,resets:y(p.rl.primary.resets_at)})}if(!t.length)return null;const s=Math.max(...t.map(o=>o.resets??-1/0)),n=t.filter(o=>o.resets!=null&&Math.abs(o.resets-s)<=b),r=u(o=>n.reduce((l,p)=>(y(p.rl[o]?.used_percent)??-1)>(y(l?.rl[o]?.used_percent)??-1)?p:l,null),"pick");if(!n.length)return null;const a=r("primary"),i=r("secondary"),c=n.reduce((o,l)=>l.mtime>o.mtime?l:o),d=u((o,l)=>o?w(o.rl[l].used_percent,o.rl[l].resets_at):null,"w");return{five_hour:d(a,"primary"),seven_day:d(i,"secondary"),at:new Date(c.mtime).toISOString()}}u(x,"scanCodex");function j(e,t){if(!e||typeof e!="object"||Array.isArray(e))return null;const s=g(e.at),n=u(r=>{const a=e[r];if(!a)return null;const i=w(a.used_percentage,a.resets_at);if(i.used_percentage==null)return null;const c=!s||t-Date.parse(s)>D[r]?"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:s,five_hour:n("five_hour"),seven_day:n("seven_day")}}u(j,"readingOf");function W({home:e=_.homedir(),now:t=Date.now,cacheMs:s=6e4}={}){const n=m.join(e,".codex","sessions");let r=null,a=-1/0;return({codexShare:i="off"}={})=>{const c=t(),d=j(S(m.join(e,".claude","kickflip","usage.json"))?.claude,c);return i==="off"?{claude:d,codex:null}:(c-a>=s&&(r=x(n),a=c),{claude:d,codex:j(r||{at:null},c)})}}u(W,"usageReader");async function $({gate:e,home:t=_.homedir(),resume:s}){const n=m.join(t,".claude","kickflip","usage-gate-state.json"),r=S(n);r?.state==="paused"&&e.state==="normal"&&await s(r.window),f.mkdirSync(m.dirname(n),{recursive:!0});const a=`${n}.${process.pid}.tmp`;f.writeFileSync(a,JSON.stringify(e)+`
3
+ `),f.renameSync(a,n)}u($,"checkUsagePause");export{$ as checkUsagePause,W as usageReader};
package/hub/views.mjs ADDED
@@ -0,0 +1 @@
1
+ var P=Object.defineProperty;var l=(e,t)=>P(e,"name",{value:t,configurable:!0});import{clock as m,windowAt as q,holdLine as x,checksIn as z,checksADay as I,holdsOvernight as C,daySegments as M,splitPoint as H,snoozeEnd as U}from"./schedule.mjs";import{siteIcon as R}from"./icons.mjs";import{codexStatus as L}from"../shared/codex.mjs";const y=l(e=>e.charAt(0).toUpperCase()+e.slice(1),"capital"),g=l((e,t)=>`${e} ${t}${e===1?"":"s"}`,"plural"),b=l(e=>e>=60&&e%60===0?`${e/60} h`:`${e} min`,"every"),p=l(e=>{const[t,o]=e.split(":").map(Number),s=new Date;return s.setHours(t,o,0,0),m(s,s)},"timeOfDay"),V=l(e=>{const[t,o]=e.split(":").map(Number);return t*60+o},"minuteOf"),$=l(e=>`${(e/1440*100).toFixed(3)}%`,"pct"),v=l((e,t)=>`checks every ${e.window.every_minutes} min until ${m(e.window.endsAt,t)}${e.hold?`; ${x(e.hold,t)}`:""}`,"nowLine"),N=l(e=>[`${g(e.windows.length,"window")}, ${I(e)} checks a day`,C(e)&&"held overnight",e.wakes.length?`wakes ${e.wakes.map(p).join(", ")}`:"no wakes"].filter(Boolean).join(" \xB7 "),"pollingSummary"),F=l((e,t)=>e.count?`${e.count} push${e.count===1?"":"es"} queued; next send ${m(e.nextFlushAt,t)}.`:"No pushes queued.","queuedLine");function T({running:e=!1,nextAt:t,last:o=null},s){if(e)return"Checking GitHub now";const n=`Next check ${m(t,s)}`;if(!o)return n;const d=o.failed.length?"could not reach GitHub for every PR":o.watched?`${g(o.watched,"PR")}, ${o.changes.length?`${o.changes.length} changed`:"no change"}`:"no PRs watched";return`${n} \xB7 last ${m(new Date(o.at),s)}, ${d}`}l(T,"pollLine");function O(e,t,o){return{title:y(v(e,o)),lines:[T(t,o),F(e,o)],count:e.count}}l(O,"pollingNow");function B(e,t){const o={port:"Down \xB7 not answering",files:"Down \xB7 folder missing"}[e.serves]||"Down \xB7 restarting";if(e.state!=="up")return{tone:e.state==="down"?"down":"wait",word:e.state==="down"?o:y(e.state),lines:[]};if(!t)return{tone:"ok",word:"Up",lines:[]};const s=t.filter(r=>!r.ok),n=s.filter(r=>r.state==="broken"),d=t.find(r=>r.link==="events"),a=d?.listen==="asleep"?"asleep":d?.ok?"listening":"not listening",w=n.length?`${g(n.length,"link")} broken \xB7 ${a}`:s.length?`Up \xB7 ${a} \xB7 ${s.length} settling`:`Up \xB7 ${a} \xB7 every link passes`;return{tone:n.length?"down":s.length?"wait":"ok",word:w,lines:s.map(r=>({link:r.link,detail:`${r.state==="broken"?"":"settling: "}${r.detail}`}))}}l(B,"siteHealth");const A=[{value:"always",label:"Always on",description:"Codex builds and reviews; past the pause line, Opus builders take over until it resets, and while Codex is out of usage that work pauses until a usage-reset check succeeds."},{value:"auto",label:"Auto",description:"Codex builds and reviews; while Codex is past the pause line or out of usage, Opus builders take over until it resets."},{value:"off",label:"Off",description:"No Codex."}],G=l((e,t)=>({usable:t.usable,tag:t.usable?null:y(t.reason),share:e,choices:A.map(({value:o,label:s})=>({value:o,label:s,on:o===e})),description:t.usable?A.find(o=>o.value===e).description:t.words,fix:t.fix,note:t.usable?"Applies to every board. A session's own /codex-share overrides it for that session.":null}),"codexView");function ee({now:e,st:t,poll:o,sidebar:s,rows:n,links:d=new Map,code:a={},host:w="",rules:r=[],remembered:f=new Map,codexShare:i="off",codex:c=L({installed:!1})}){const h=new Set(s.sites.filter(u=>u.board).map(u=>u.route)),k=a.running&&a.running===a.onDisk?` \xB7 runs the code in its files (${a.onDisk})`:"",_=l(u=>({route:u.route,title:u.title,icon:R(u.route,u),href:`/${encodeURIComponent(u.route)}/`,...B(u,d.get(u.route)),remembered:f.get(u.route)||[]}),"site"),D=[["Boards",n.filter(u=>h.has(u.route))],["Pages",n.filter(u=>!h.has(u.route))]].filter(([,u])=>u.length).map(([u,E])=>({label:u,sites:E.map(_)}));return{hub:{tone:s.hub.tone,word:s.hub.word,detail:`${s.hub.detail}${k}`},groups:D,usage:s.usage,pause:s.pause,codex:G(i,c),empty:n.length===0,polling:{...O(t,o,e),summary:N(t.settings)},notifications:{on:t.settings.notifications.on,text:s.notifications.text,device:v(t,e)},hold:t.hold&&t.hold.why!=="off"?{text:`Notifications ${x(t.hold,e)}`,end:t.hold.why==="snoozed"}:null,rules:r.length?g(r.length,"rule"):"None yet",sites:g(n.length,"site"),host:w,total:s.total}}l(ee,"hubView");const j=[["3h","3h"],["6h","6h"],["1d","1d"],["3d","3d"],["monday","Monday"]],te="That snooze ends in the past or more than 30 days on. Pick another time.",K=l((e,t)=>e.on&&e.snoozed_until&&new Date(e.snoozed_until)>t?new Date(e.snoozed_until):null,"snoozedUntil");function ne({now:e,st:t,rules:o=[],route:s={atMac:!1,desktops:0},linear:n={set:!1}}){const d=t.settings,a=d.notifications,[w,r]=[d.windows[0],d.windows.at(-1)],f=K(a,e),i=t.count?` The ${t.count} queued push${t.count===1?" is":"es are"} dropped now.`:"",c=j.map(([h,k])=>({value:h,label:k,until:`until ${m(U(e,h,d),e)}`}));return{snoozed:f?{title:`Snoozed until ${m(f,e)}`}:null,device:v(t,e),on:{on:a.on,sub:a.on?"On. Turn off to drop every push, urgent ones too.":"Off. Every push is dropped, urgent ones too. Needs you and the badge still update.",confirm:`Turn off notifications? Every push is dropped until you turn them back on, urgent ones too.${i} Needs you and the badge still update.`},presence:a.on?s.atMac?s.desktops?{title:"At the host",sub:"Pushes go to the host only. Prompts, faults and urgent pushes still reach the phone."}:{title:"At the host",sub:"No notifications on the host, so pushes still go to the phone."}:{title:"Away from the host",sub:"Pushes go to every device. At the host means unlocked and used in the last 3 minutes."}:null,snooze:a.on&&!f?{choices:c}:null,weekends:{on:a.weekends_off,sub:`Pushes wait from Friday ${p(r.from)} until Monday ${p(w.from)}. Urgent ones still go.`},polling:N(d),rules:o,linear:n.set?{set:!0,title:`${n.workspace} workspace`,sub:"Key \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",summary:`Key set \xB7 ${n.workspace} workspace`}:{set:!1,title:"No key",sub:"Paste a personal API key from Linear\u2019s Settings \u2192 Security & access.",summary:"No key"}}}l(ne,"settingsView");const W=[5,10,15,30,60,120],Z=l(e=>Math.round(100-Math.log(e/5)/Math.log(24)*70),"shade"),S=l((e,t)=>e.windows.length===1?"All day":`${p(e.windows[t].from)} \u2013 ${p(e.windows[(t+1)%e.windows.length].from)}`,"windowRange");function oe({now:e,st:t,poll:o,select:s=null}){const n=t.settings,d=n.windows.findIndex(i=>i.from===s),a=d>=0?d:n.windows.findIndex(i=>i.from===q(e,n).from),w=n.windows.length===1,[r,f]=[n.windows[0],n.windows.at(-1)];return{select:n.windows[a].from,now:O(t,o,e),day:{segments:M(n).map(i=>{const c=n.windows[i.window];return{w:c.from,left:$(i.start),width:$(i.end-i.start),shade:`${Z(c.every_minutes)}%`,held:i.held,label:i.end-i.start>=110?b(c.every_minutes).replace(" min","m").replace(" h","h"):"",aria:`${S(n,i.window)}, every ${b(c.every_minutes)}${i.held?", pushes held":""}`}}),pins:n.wakes.map(i=>({left:$(V(i)),title:`Wake ${p(i)}`})),now:$(e.getHours()*60+e.getMinutes()),held:C(n),wakes:n.wakes.length>0},windows:n.windows.map((i,c)=>({from:i.from,range:S(n,c),ends:w?"\u2014":p(n.windows[(c+1)%n.windows.length].from),every:[...new Set([...W,i.every_minutes])].sort((h,k)=>h-k).map(h=>({minutes:h,label:b(h),on:h===i.every_minutes})),checks:`${g(z(n,c),"check")} in this window`,split:!!H(n,c),remove:!w})),hold:{on:n.hold_overnight,sub:w?"With one window there is no overnight to hold.":`Pushes queued after ${p(f.from)} wait until ${p(r.from)}. GitHub checks still run.`},wakes:n.wakes.map(i=>({value:i,label:p(i)}))}}l(oe,"pollingView");function se(e){return e?e.ok?{sent:e.sent,count:e.count,ok:!0,message:`Sent ${e.count} queued push${e.count===1?"":"es"}.`}:{sent:e.sent,count:e.count,ok:!1,message:"Could not send the queue. It stays queued for the next check."}:{sent:0,count:0,ok:!0,message:"Nothing was queued."}}l(se,"flushView");export{te as SNOOZE_REFUSED,y as capital,b as every,se as flushView,ee as hubView,v as nowLine,g as plural,T as pollLine,N as pollingSummary,oe as pollingView,ne as settingsView,B as siteHealth};
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@lovek4d/kickflip",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Follow and steer Claude Code sessions from your phone.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "type": "module",
7
+ "bin": {
8
+ "kickflip": "bin/kickflip.mjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "board/",
16
+ "hub/",
17
+ "shared/",
18
+ "starter/",
19
+ "hooks-plugin/",
20
+ "docs/hub-session.md",
21
+ "SKILL.md",
22
+ "CONTEXT.md",
23
+ "!**/*.test.mjs",
24
+ "!board/mini-dom.mjs",
25
+ "!hub/fake-device.mjs",
26
+ "!shared/test-helpers.mjs"
27
+ ]
28
+ }
@@ -0,0 +1 @@
1
+ var u=Object.defineProperty;var o=(e,t)=>u(e,"name",{value:t,configurable:!0});import r from"node:fs";import x from"node:os";import a from"node:path";const f=["auto","always","off"],p={"not installed":o(e=>({words:"Codex isn't installed on the host.",fix:e==="darwin"?"brew install --cask codex":"npm i -g @openai/codex"}),"not installed"),"not logged in":o(()=>({words:"Codex isn't logged in on the host.",fix:"codex login"}),"not logged in")},m=o(e=>{try{return r.statSync(e).isFile()&&(r.accessSync(e,r.constants.X_OK),!0)}catch{return!1}},"executable"),c=o((e=process.env)=>(e.PATH||"").split(a.delimiter).some(t=>t&&m(a.join(t,"codex"))),"codexOnPath");function h({home:e=x.homedir(),env:t=process.env,installed:s=c(t),platform:l=process.platform}={}){const n=s?r.existsSync(a.join(e,".codex","auth.json"))?null:"not logged in":"not installed";return n?{usable:!1,reason:n,...p[n](l)}:{usable:!0,reason:null,words:null,fix:null}}o(h,"codexStatus");function b({home:e,env:t,now:s=Date.now,cacheMs:l=6e4}={}){let n,i=-1/0;return({fresh:d=!1}={})=>((d||s()-i>=l)&&(n=c(t),i=s()),h({home:e,installed:n}))}o(b,"codexStatusReader");const A=o((e,t)=>t.usable?f.includes(e)?e:"auto":"off","effectiveCodexShare");export{f as CODEX_SHARE_VALUES,c as codexOnPath,h as codexStatus,b as codexStatusReader,A as effectiveCodexShare};
@@ -0,0 +1,13 @@
1
+ /* The one colour set for the hub and every board (docs/adr/0033-one-colour-constants-file-shared-by-the-hub-and-every-board.md).
2
+ board/server.mjs serves it as colors.css and hub/hub.mjs as /_hub/colors.css, linked before each page's own CSS. */
3
+ :root {
4
+ --bg: #fbfbfc; --panel: #ffffff; --line: #e6e6ea; --text: #1b1b1f; --muted: #6b6b76;
5
+ --accent: #5e6ad2; --accent-soft: #eef0ff; --ok: #1f9d55; --warn: #c27c0e; --bad: #d1435b;
6
+ --chip: #f1f1f4; --band: #f2f2f5; --love: #eef6ff; --claude: #fdf3e4;
7
+ --color-quiet: var(--muted); --color-info: var(--accent); --color-notice: var(--warn); --color-danger: var(--bad); --color-success: var(--ok);
8
+ }
9
+ @media (prefers-color-scheme: dark) {
10
+ :root { --bg: #111114; --panel: #18181c; --line: #2a2a31; --text: #ececf1; --muted: #9a9aa6;
11
+ --accent: #8c95f0; --accent-soft: #23253d; --ok: #3ccf7f; --warn: #e3a33a; --bad: #f0687f;
12
+ --chip: #24242b; --band: #202026; --love: #1b2533; --claude: #2d2416; }
13
+ }
@@ -0,0 +1 @@
1
+ var t=Object.defineProperty;var e=(r,i)=>t(r,"name",{value:i,configurable:!0});import m from"node:fs";import p from"node:os";import o from"node:path";import{fileURLToPath as s}from"node:url";const f=e(()=>o.resolve(o.dirname(s(import.meta.url)),".."),"cloneRoot"),h=e((r=f())=>m.existsSync(o.join(r,".git"))?o.resolve(r,".repos"):o.join(p.homedir(),".claude","kickflip","repos"),"reposDir");export{f as cloneRoot,h as reposDir};
@@ -0,0 +1,33 @@
1
+ var L=Object.defineProperty;var n=(i,d)=>L(i,"name",{value:d,configurable:!0});import u from"node:fs";import A from"node:os";import f from"node:path";import{spawnSync as T}from"node:child_process";const $="local.claude.kickflip-hub",b="local.claude.web-ts-hub",p="kickflip-hub.service";function P(i,d,{env:y=process.env,timeout:o=1e4}={}){const e=T(i,d,{encoding:"utf8",env:y,timeout:o});return{status:e.status??1,stdout:e.stdout||"",error:(e.error?.message||e.stderr||`exit ${e.status}`).trim().split(`
2
+ `)[0]}}n(P,"runCommand");function U({platform:i=process.platform,home:d=A.homedir(),uid:y=process.getuid?.(),env:o=process.env,run:e=P}={}){const a=n((r,s,l={})=>{try{return e(r,s,{env:o,...l})}catch(c){return{status:1,stdout:"",error:c.message}}},"exec");if(i==="darwin")return x({home:d,uid:y,exec:a});if(i==="linux")return O({home:d,uid:y,exec:a});const t=`kickflip keeps the hub running with launchd on macOS or systemd on Linux, not on ${i}`;return{manager:null,item:"hub service",file:null,id:null,restartCommand:"node hub/hub.mjs from the kickflip folder",definition:n(()=>null,"definition"),install:n(async()=>{throw new Error(t)},"install"),restart:n(()=>t,"restart"),remove:n(()=>{},"remove"),status:n(()=>({installed:!1,running:!1,detail:t}),"status"),notes:n(()=>[],"notes")}}n(U,"hubService");const E=12e4;function x({home:i,uid:d,exec:y}){const o=f.join(i,"Library","LaunchAgents"),e=`gui/${d}`,a=f.join(o,`${$}.plist`),t=f.join(o,`${b}.plist`),r=n((s,l)=>y("launchctl",s,l),"launchctl");return{manager:"launchd",item:"hub login item",file:a,id:`${e}/${$}`,restartCommand:`launchctl kickstart -k gui/$(id -u)/${$}`,definition({node:s,entry:l,state:c,port:g,log:h}){const m=n(k=>String(k).replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;"),"xml");return`<?xml version="1.0" encoding="UTF-8"?>
3
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4
+ <plist version="1.0">
5
+ <dict>
6
+ <key>Label</key><string>${$}</string>
7
+ <key>ProgramArguments</key>
8
+ <array><string>${m(s)}</string><string>${m(l)}</string></array>
9
+ <key>WorkingDirectory</key><string>${m(c)}</string>
10
+ <key>EnvironmentVariables</key>
11
+ <dict><key>PATH</key><string>${m(f.dirname(s))}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string><key>KICKFLIP_HUB_PORT</key><string>${g}</string></dict>
12
+ <key>RunAtLoad</key><true/>
13
+ <key>KeepAlive</key><true/>
14
+ <key>StandardOutPath</key><string>${m(h)}</string>
15
+ <key>StandardErrorPath</key><string>${m(h)}</string>
16
+ </dict>
17
+ </plist>
18
+ `},async install(s,{wait:l=n(g=>new Promise(h=>setTimeout(h,g)),"wait"),dryRun:c=!1}={}){let g=null;try{g=u.readFileSync(t,"utf8")}catch(v){if(v.code!=="ENOENT")throw v}const h=[];if(c)return g!==null&&h.push(`would stop ${e}/${b} and remove ${t}`),[...h,`would write ${a} and start ${e}/${$}`];u.mkdirSync(o,{recursive:!0}),u.writeFileSync(a,s),g!==null&&(r(["bootout",`${e}/${b}`]),u.unlinkSync(t),h.push(`stopped ${e}/${b} and removed ${t}`)),r(["bootout",`${e}/${$}`]),await l(500);const m=r(["bootstrap",e,a]);if(m.status===0)return[...h,`started ${e}/${$} from ${a}`];if(g===null)throw new Error(m.error);u.writeFileSync(t,g),u.rmSync(a,{force:!0});const k=r(["bootstrap",e,t]);throw new Error(`${m.error}; ${k.status===0?`restarted ${e}/${b} from ${t}`:`could not restart ${e}/${b} either (${k.error})`}`)},restart(){const s=r(["kickstart","-k",`${e}/${$}`],{timeout:E});return s.status===0?null:s.error},remove(){r(["bootout",`${e}/${$}`]),u.rmSync(a,{force:!0})},status(){const s=r(["print",`${e}/${$}`]),l=u.existsSync(a),c=l&&s.status===0&&/\bstate = running\b/.test(s.stdout||"");return{installed:l,running:c,detail:c?"running":l?"not running":"not installed"}},notes:n(()=>[],"notes")}}n(x,"launchd");const w=n(i=>String(i).replaceAll("%","%%").replaceAll("$","$$$$").replace(/[\r\n]/g," "),"unitValue"),S=n(i=>`"${w(i).replaceAll("\\","\\\\").replaceAll('"','\\"')}"`,"execWord");function O({home:i,uid:d,exec:y}){const o=f.join(i,".config","systemd","user",p),e=n((t,r)=>y("systemctl",["--user",...t],r),"systemctl"),a=n(t=>{const r=e(t);if(r.status!==0)throw new Error(`systemctl --user ${t.join(" ")}: ${r.error}`)},"step");return{manager:"systemd",item:"hub service",file:o,id:p,restartCommand:`systemctl --user restart ${p}`,definition({node:t,entry:r,state:s,port:l,log:c}){return`[Unit]
19
+ Description=kickflip hub
20
+
21
+ [Service]
22
+ ExecStart=${S(t)} ${S(r)}
23
+ WorkingDirectory=${w(s)}
24
+ Environment=${S(`PATH=${f.dirname(t)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`)}
25
+ Environment=KICKFLIP_HUB_PORT=${l}
26
+ Restart=always
27
+ RestartSec=2
28
+ StandardOutput=append:${w(c)}
29
+ StandardError=append:${w(c)}
30
+
31
+ [Install]
32
+ WantedBy=default.target
33
+ `},async install(t,{dryRun:r=!1}={}){return r?[`would write ${o}, enable and start ${p}`]:(u.mkdirSync(f.dirname(o),{recursive:!0}),u.writeFileSync(o,t),a(["daemon-reload"]),a(["enable",p]),a(["restart",p]),[`enabled and started ${p} from ${o}`])},restart(){const t=e(["restart",p],{timeout:E});return t.status===0?null:t.error},remove(){e(["disable","--now",p]),u.rmSync(o,{force:!0}),e(["daemon-reload"])},status(){const t=e(["is-active",p]),r=u.existsSync(o),s=r&&t.status===0&&(t.stdout||"").trim()==="active";return{installed:r,running:s,detail:s?"running":r?"not running":"not installed"}},notes(){const t=y("loginctl",["show-user",String(d),"--property=Linger"]),r=t.status===0&&/^Linger=yes$/m.test(t.stdout||"");return[{item:"hub without a login",state:r?"ok":"optional",detail:r?"the user lingers, so the hub runs with nobody logged in":"the hub stops when your last login session ends",fix:'sudo loginctl enable-linger "$USER"'},{item:"macOS-only parts",state:"note",detail:"At the host always reads away, since presence comes from macOS ioreg, so pushes reach every device"}]}}}n(O,"systemd");export{$ as LOGIN_LABEL,p as UNIT,U as hubService,P as runCommand};
@@ -0,0 +1 @@
1
+ var m=Object.defineProperty;var n=(e,t)=>m(e,"name",{value:t,configurable:!0});import g from"node:os";import{usageReader as D}from"../hub/usage.mjs";import{readSettingsFile as f,writeSettingsFile as x,readCodexShare as S}from"../hub/push.mjs";import{codexStatusReader as N}from"./codex.mjs";const G=n(e=>Number.isInteger(e)&&e>=50&&e<=100&&e%5===0,"validPauseAt"),d=n(e=>Number(e),"timeOf"),O=["five_hour","seven_day"];function A({claude:e,codex:t,pauseAt:s=90,keepGoing:r,now:a=Date.now()}={}){const p=G(s)?s:90,l=n(c=>p===100?null:O.find(o=>{const i=c?.[o];return i?.state==="live"&&Number.isFinite(i.used_percentage)&&i.used_percentage>=p&&(!i.resets_at||Date.parse(i.resets_at)>d(a))})||null,"past"),w=n((c,o)=>({window:o,used:o?c[o].used_percentage:null,resets_at:o?c[o].resets_at:null}),"details"),u=l(e),h=w(e,u),k=u&&r?.window===u&&Math.abs(Date.parse(r.until)-Date.parse(h.resets_at))<=3e5&&d(a)<Date.parse(r.until),_=l(t);return{pause_at:p,state:u?k?"going":"paused":"normal",...h,codex:{past:!!_,...w(t,_)}}}n(A,"usageGate");const b=new Map;function E({home:e=g.homedir(),now:t=Date.now()}={}){let s=b.get(e);s||(s={now:d(t)},s.usage=D({home:e,now:n(()=>s.now,"now")}),s.codex=N({home:e,now:n(()=>s.now,"now")}),b.set(e,s)),s.now=d(t);const r=f(e);return A({...s.usage({codexShare:S(s.codex(),e)}),pauseAt:r.pauseAt,keepGoing:r.keepGoing,now:t})}n(E,"readUsageGate");function M(e,{home:t=g.homedir()}={}){if(!/^(?:50|55|60|65|70|75|80|85|90|95|100)$/.test(String(e)))throw Object.assign(new Error("pause-at takes 50..100 in steps of 5"),{status:400});x({...f(t),pauseAt:Number(e)},t)}n(M,"setPauseAt");function C(e,{home:t=g.homedir(),now:s=Date.now()}={}){if(!["on","off"].includes(e))throw Object.assign(new Error("keep-going takes on or off"),{status:400});const r=f(t);if(e==="on"){const a=E({home:t,now:s});if(a.state!=="paused")throw Object.assign(new Error("Nothing is paused."),{status:409});r.keepGoing={window:a.window,until:a.resets_at}}else delete r.keepGoing;x(r,t)}n(C,"setKeepGoing");const K=n(e=>{try{return["github-change","check-github","card-unblocked"].includes(JSON.parse(e).kind)}catch{return!1}},"heldUsageEvent"),P=n(e=>`Work is paused: Claude's ${e.window==="five_hour"?"5-hour":"weekly"} usage is at ${Math.round(e.used)}%. Resumes at ${e.resets_at}. The owner can tap Keep going on the Hub page.`,"pauseReason");export{K as heldUsageEvent,P as pauseReason,E as readUsageGate,C as setKeepGoing,M as setPauseAt,A as usageGate,G as validPauseAt};
@@ -0,0 +1 @@
1
+ var i=Object.defineProperty;var o=(t,n)=>i(t,"name",{value:n,configurable:!0});function s(t){const n=t["sec-fetch-site"];if(n!==void 0&&n!=="same-origin"&&n!=="none")return!0;if(t.origin===void 0)return!1;let e;try{e=new URL(t.origin).host}catch{return!0}return e.toLowerCase()!==String(t["x-forwarded-host"]||t.host||"").toLowerCase()}o(s,"crossSite");const c=o(t=>(t["content-type"]||"").split(";")[0].trim().toLowerCase()==="application/json","isJSON");export{s as crossSite,c as isJSON};
@@ -0,0 +1,67 @@
1
+ // Talks to the site's server (../server.mjs) from any page. Include it with a
2
+ // relative path, e.g. <script src="claude.js"></script> or "../claude.js":
3
+ // its own URL tells it the site's root, so links survive the /<route> prefix.
4
+ // ClaudeSite.url('notes.html') absolute URL under the site root
5
+ // await ClaudeSite.send(kind, data) sends same-origin application/json, records an event and wakes the owning session → {id}
6
+ // await ClaudeSite.events({kind}) events newest first, each with .reply ({html} or {json}) or null
7
+ // ClaudeSite.watch(fn, {kind}) calls fn(events) now and on change; polls every 10 s while
8
+ // any event waits for a reply, else every 60 s; returns stop()
9
+ // ClaudeSite.syncBadge() sets the Home Screen app's badge to what waits on the owner
10
+ // across every site, from the hub's /_hub/needs.json
11
+ // ClaudeSite.site('/<route>') the same API for another site on this hub, its root resolved
12
+ // against this page, so one page can talk to several sites
13
+ window.ClaudeSite = (function () {
14
+ function site(base) {
15
+ const root = new URL(base, location.href).href.replace(/\/+$/, '');
16
+ const url = (p) => root + '/' + String(p).replace(/^\/+/, '');
17
+
18
+ async function send(kind, data) {
19
+ const r = await fetch(url('_claude/events'), { method: 'POST', mode: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...(data || {}), kind }) });
20
+ if (!r.ok) throw new Error('send failed: ' + r.status);
21
+ return r.json();
22
+ }
23
+
24
+ async function events(opts) {
25
+ const q = new URLSearchParams();
26
+ if (opts && opts.kind) q.set('kind', opts.kind);
27
+ if (opts && opts.limit) q.set('limit', String(opts.limit));
28
+ const r = await fetch(url('_claude/events') + (q.toString() ? '?' + q : ''), { cache: 'no-store' });
29
+ if (!r.ok) throw new Error('events failed: ' + r.status);
30
+ return r.json();
31
+ }
32
+
33
+ function watch(fn, opts) {
34
+ let timer, last = '', stopped = false;
35
+ async function tick() {
36
+ clearTimeout(timer);
37
+ if (stopped) return;
38
+ let pending = false;
39
+ try {
40
+ const list = await events(opts);
41
+ const s = JSON.stringify(list);
42
+ if (s !== last) { last = s; fn(list); }
43
+ pending = list.some((e) => !e.reply);
44
+ } catch (e) { fn(null, e); }
45
+ if (!stopped) timer = setTimeout(tick, pending ? 10000 : 60000);
46
+ }
47
+ const onVisible = () => { if (!document.hidden) tick(); };
48
+ document.addEventListener('visibilitychange', onVisible);
49
+ tick();
50
+ return { refresh: tick, stop() { stopped = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVisible); } };
51
+ }
52
+
53
+ // The badge covers every site, so its count comes from the hub, not from this site.
54
+ async function syncBadge() {
55
+ if (!('setAppBadge' in navigator)) return;
56
+ try {
57
+ const { total } = await (await fetch('/_hub/needs.json', { cache: 'no-store' })).json();
58
+ if (typeof total === 'number') await (total ? navigator.setAppBadge(total) : navigator.clearAppBadge());
59
+ } catch { /* outside the hub, or badges not allowed */ }
60
+ }
61
+
62
+ return { root, url, send, events, watch, syncBadge, site };
63
+ }
64
+
65
+ const src = (document.currentScript && document.currentScript.src) || location.href;
66
+ return site(src.replace(/\/claude\.js(?:[?#].*)?$/, ''));
67
+ })();
@@ -0,0 +1,49 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>New Site</title>
7
+ <style>
8
+ /* Placeholder: the owning Claude session replaces this page with the real site. */
9
+ :root { --bg: #fafaf8; --ink: #1d1f1e; --muted: #6a706c; --line: #dadcd8; --accent: #3b6ea8; }
10
+ @media (prefers-color-scheme: dark) { :root { --bg: #141615; --ink: #e4e7e5; --muted: #9aa19d; --line: #2c302e; --accent: #8db6e8; color-scheme: dark; } }
11
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
12
+ main { max-width: 40rem; margin: 0 auto; padding: 2rem max(16px, env(safe-area-inset-right)) 3rem max(16px, env(safe-area-inset-left)); }
13
+ textarea { width: 100%; box-sizing: border-box; font: inherit; padding: .6rem; border: 1px solid var(--line); border-radius: 6px; background: transparent; color: inherit; }
14
+ button { font: inherit; padding: .4rem 1rem; border: 1px solid var(--accent); color: var(--accent); background: none; border-radius: 6px; margin-top: .5rem; }
15
+ .muted { color: var(--muted); }
16
+ li { margin: .8rem 0; }
17
+ </style>
18
+ </head>
19
+ <body>
20
+ <main>
21
+ <h1>New site</h1>
22
+ <p class="muted">A Claude session owns this route and hasn't built the site yet. You can still send it a message.</p>
23
+ <form id="f"><textarea id="m" rows="3" placeholder="Tell Claude what this site should be"></textarea><button>Send to Claude</button></form>
24
+ <ul id="list"></ul>
25
+ </main>
26
+ <script src="claude.js"></script>
27
+ <script>
28
+ const list = document.getElementById('list');
29
+ const w = ClaudeSite.watch((events) => {
30
+ if (!events) return;
31
+ list.replaceChildren(...events.map((e) => {
32
+ const li = document.createElement('li');
33
+ const q = document.createElement('div'); q.textContent = e.data.text || JSON.stringify(e.data);
34
+ const a = document.createElement('div'); a.className = 'muted';
35
+ if (e.reply && e.reply.html) a.innerHTML = e.reply.html; else a.textContent = e.reply ? JSON.stringify(e.reply.json) : 'Waiting for Claude…';
36
+ li.append(q, a); return li;
37
+ }));
38
+ }, { kind: 'message' });
39
+ document.getElementById('f').addEventListener('submit', async (ev) => {
40
+ ev.preventDefault();
41
+ const m = document.getElementById('m');
42
+ if (!m.value.trim()) return;
43
+ await ClaudeSite.send('message', { text: m.value.trim() });
44
+ m.value = '';
45
+ w.refresh();
46
+ });
47
+ </script>
48
+ </body>
49
+ </html>
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ var N=Object.defineProperty;var f=(e,n)=>N(e,"name",{value:n,configurable:!0});import b from"node:http";import r from"node:fs/promises";import i from"node:path";import{fileURLToPath as k}from"node:url";import{crossSite as H,isJSON as P}from"./cross-site.mjs";const j=i.dirname(k(import.meta.url)),u=i.join(j,"public"),g=i.join(j,"data"),y=i.join(g,"events.jsonl"),S=i.join(g,"replies"),T=i.join(g,"needs.json"),x=Number(process.env.PORT||4600),l=(process.env.PREFIX||"").replace(/\/+$/,""),v={".html":"text/html; charset=utf-8",".css":"text/css; charset=utf-8",".js":"text/javascript; charset=utf-8",".mjs":"text/javascript; charset=utf-8",".json":"application/json; charset=utf-8",".svg":"image/svg+xml",".png":"image/png",".jpg":"image/jpeg",".jpeg":"image/jpeg",".gif":"image/gif",".webp":"image/webp",".ico":"image/x-icon",".txt":"text/plain; charset=utf-8",".md":"text/markdown; charset=utf-8",".woff2":"font/woff2"},h=f((e,n,t)=>{e.writeHead(n,{"content-type":v[".json"],"cache-control":"no-store"}),e.end(JSON.stringify(t))},"json");async function J(e,n=64*1024){if(!P(e.headers))throw Object.assign(new Error("Send the body as application/json."),{status:403});let t="";for await(const a of e)if(t+=a,t.length>n)throw Object.assign(new Error("too large"),{status:413});return t?JSON.parse(t):{}}f(J,"readBody");async function D(e,n){let t=[];try{t=(await r.readFile(y,"utf8")).split(`
3
+ `).filter(Boolean).map(a=>JSON.parse(a))}catch{}return e&&(t=t.filter(a=>a.kind===e)),t=t.reverse().slice(0,n),Promise.all(t.map(async a=>{for(const o of["html","json"]){const m=i.join(S,`${a.id}.${o}`);try{const[s,c]=await Promise.all([r.readFile(m,"utf8"),r.stat(m)]);return{...a,reply:{[o]:o==="json"?JSON.parse(s):s,at:c.mtime.toISOString()}}}catch{}}return{...a,reply:null}}))}f(D,"listEvents");const F=b.createServer(async(e,n)=>{try{if(e.method!=="GET"&&e.method!=="HEAD"&&H(e.headers)){n.writeHead(403,{"content-type":"text/plain; charset=utf-8","cache-control":"no-store"}),n.end("Requests must come from the app\u2019s own pages.");return}const t=new URL(e.url,"http://site");let a=t.pathname;if(l&&(a===l||a.startsWith(l+"/"))&&(a=a.slice(l.length)||"/"),a==="/_claude/health")return h(n,200,{ok:!0});if(a==="/_claude/needs"){const s=await r.readFile(T,"utf8").catch(p=>p.code==="ENOENT"?'{"items":[]}':Promise.reject(p)),{items:c,working:d,summary:w}=JSON.parse(s);if(!Array.isArray(c))throw new Error("data/needs.json has no items array");return h(n,200,{items:c.map(({kind:p,text:E,url:O})=>({kind:String(p||""),text:String(E||""),url:String(O||"")})),...Number.isInteger(d)&&d>=0?{working:d}:{},...typeof w=="string"?{summary:w}:{}})}if(a==="/_claude/events"&&e.method==="POST"){const s=await J(e),c=String(s.kind||"message").slice(0,64);delete s.kind;const d={id:"e"+Date.now().toString(36)+Math.random().toString(36).slice(2,5),ts:new Date().toISOString(),kind:c,data:s};return await r.mkdir(S,{recursive:!0}),await r.appendFile(y,JSON.stringify(d)+`
4
+ `),h(n,200,{id:d.id})}if(a==="/_claude/events"&&e.method==="GET")return h(n,200,await D(t.searchParams.get("kind"),Math.min(500,Number(t.searchParams.get("limit")||100))));if(e.method!=="GET"&&e.method!=="HEAD"){n.writeHead(405).end();return}let o=i.resolve(u,"."+decodeURIComponent(a));if(o!==u&&!o.startsWith(u+i.sep)){n.writeHead(404).end();return}(await r.stat(o).catch(()=>null))?.isDirectory()&&(o=i.join(o,"index.html"));const m=await r.readFile(o);n.writeHead(200,{"content-type":v[i.extname(o).toLowerCase()]||"application/octet-stream","cache-control":"no-store"}),n.end(e.method==="HEAD"?void 0:m)}catch(t){n.headersSent||(t.status===403?n.writeHead(403,{"content-type":"text/plain; charset=utf-8","cache-control":"no-store"}).end(t.message):n.writeHead(t.status||(t.code==="ENOENT"?404:400)).end())}});F.listen(x,"127.0.0.1",()=>console.log(`site on http://127.0.0.1:${x}/ (prefix ${l||"none"})`));