@lovek4d/kickflip 0.0.0-stage → 0.3.1
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.
- package/CONTEXT.md +513 -0
- package/LICENSE +57 -0
- package/README.md +64 -2
- package/SKILL.md +142 -0
- package/bin/doctor.mjs +4 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +180 -0
- package/bin/statusline.mjs +5 -0
- package/bin/wait-events.mjs +3 -0
- package/board/arch-review.mjs +5 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +379 -0
- package/board/bin/builder-stop-hook.mjs +6 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +5 -0
- package/board/on-host.mjs +1 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +188 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +393 -0
- package/board/public/app-core.js +130 -0
- package/board/public/app-header.js +126 -0
- package/board/public/app-lanes.js +142 -0
- package/board/public/app-needs.js +31 -0
- package/board/public/app-snapshot.js +131 -0
- package/board/public/app-threads.js +167 -0
- package/board/public/app.html +86 -0
- package/board/public/app.js +438 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +358 -0
- package/board/public/hub-kit.js +168 -0
- package/board/public/hub.css +170 -0
- package/board/public/kickflip.png +0 -0
- package/board/public/linear-key.js +141 -0
- package/board/public/prompts.js +64 -0
- package/board/public/prototype-kit.js +193 -0
- package/board/public/prototype-switcher.js +226 -0
- package/board/public/prototype.html +55 -0
- package/board/public/router.js +226 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +219 -0
- package/board/public/sidebar.js +359 -0
- package/board/public/style.css +665 -0
- package/board/public/toasts.js +181 -0
- package/board/public/ui-cards.js +218 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +93 -0
- package/board/public/ui-hub.js +112 -0
- package/board/public/ui-links.js +150 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-order.js +18 -0
- package/board/public/ui-page-header.js +65 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +151 -0
- package/board/public/ui-sidebar.js +215 -0
- package/board/public/ui-snapshot.js +62 -0
- package/board/public/ui-threads.js +90 -0
- package/board/public/ui.js +247 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +282 -0
- package/board/public/view-hub.js +150 -0
- package/board/public/view-notes.js +32 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +411 -0
- package/board/public/view-settings.js +383 -0
- package/board/review.mjs +1 -0
- package/board/server.mjs +17 -0
- package/board/store.mjs +15 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +15 -0
- package/docs/hub-session.md +11 -0
- package/hooks-plugin/.claude-plugin/plugin.json +5 -0
- package/hooks-plugin/hooks/codex-share-auto.mjs +23 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/duration.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +4 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +19 -0
- package/hub/icon-180.png +0 -0
- package/hub/icon-192.png +0 -0
- package/hub/icon-512.png +0 -0
- package/hub/icons.mjs +1 -0
- package/hub/linear.mjs +2 -0
- package/hub/live.mjs +2 -0
- package/hub/locks.mjs +1 -0
- package/hub/new-board.mjs +2 -0
- package/hub/permission-hook.mjs +3 -0
- package/hub/pr-report.mjs +1 -0
- package/hub/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -0
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -0
- package/hub/rules.mjs +1 -0
- package/hub/schedule.mjs +1 -0
- package/hub/sessions.mjs +2 -0
- package/hub/shell.mjs +2 -0
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +4 -0
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -0
- package/hub/views.mjs +1 -0
- package/package.json +26 -4
- package/shared/codex.mjs +1 -0
- package/shared/colors.css +15 -0
- package/shared/fake-gh.mjs +54 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +68 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
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' && (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/trust.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var p=Object.defineProperty;var c=(t,r)=>p(t,"name",{value:r,configurable:!0});import n from"node:fs";import l from"node:os";import s from"node:path";import{randomUUID as f}from"node:crypto";const y=c(()=>s.join(process.env.CLAUDE_CONFIG_DIR||l.homedir(),".claude.json"),"claudeConfigFile"),m=c(t=>t.startsWith(l.homedir()+s.sep)?`~${t.slice(l.homedir().length)}`:t,"shown");function d(t){let r;try{r=n.readFileSync(t,"utf8")}catch(o){throw o.code==="ENOENT"?new Error(`${m(t)} doesn't exist; run claude once to make it`):o}let e=null;try{e=JSON.parse(r)}catch{}if(!e||typeof e!="object"||Array.isArray(e))throw new Error(`${m(t)} isn't valid JSON`);return e}c(d,"load");const w=c((t,r)=>t.projects?.[r]?.hasTrustDialogAccepted===!0,"marked");function S(t,r,e){const o=n.realpathSync(t),{mode:a}=n.statSync(o),h={...r.projects,[e]:{...r.projects?.[e],hasTrustDialogAccepted:!0}},i=s.join(s.dirname(o),`.${s.basename(o)}.kickflip-${f()}`);try{n.writeFileSync(i,`${JSON.stringify({...r,projects:h},null,2)}
|
|
2
|
+
`,{flag:"wx",mode:384}),n.chmodSync(i,a&4095),n.renameSync(i,o)}catch(u){throw n.rmSync(i,{force:!0}),u}}c(S,"save");function k(t){const r=y(),e=n.realpathSync(t);let o=d(r);for(let a=0;!w(o,e);a++){if(a===2)throw new Error(`a running Claude session overwrote the trust mark in ${m(r)}; retry`);S(r,o,e),o=d(r)}return e}c(k,"trustFolder");export{k as trustFolder};
|
package/hub/try.mjs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
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};
|
package/hub/upgrade.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var E=Object.defineProperty;var t=(r,s)=>E(r,"name",{value:s,configurable:!0});import h from"node:fs";import d from"node:path";const _="https://registry.npmjs.org/@lovek4d%2Fkickflip/latest",C=60*6e4,v=t(r=>String(r).split("-")[0].split(".").map(Number),"parts");function F(r,s){const[l,u]=[v(r),v(s)];for(let n=0;n<3;n++)if((l[n]||0)!==(u[n]||0))return(l[n]||0)>(u[n]||0);return!1}t(F,"newer");function L({root:r,fetch:s=globalThis.fetch,builders:l,run:u,log:n=t(()=>{},"log"),changed:c=t(()=>{},"changed")}){const k=h.existsSync(d.join(r,".git"));let i=null,f=!1,a=!1,p=0,o=null;const y=t(()=>JSON.parse(h.readFileSync(d.join(r,"package.json"),"utf8")).version,"installed");async function S(){if(!k){try{const e=await s(_,{signal:AbortSignal.timeout(15e3)});if(!e.ok)throw new Error(`npm answered ${e.status}`);i=(await e.json()).version}catch(e){n(`kickflip update check failed: ${e.message}`)}c()}}t(S,"check");function g(){return k||!i||!F(i,y())?null:a?{state:"updating",version:i}:f?{state:"waiting",version:i,builders:p}:o?{state:"failed",version:i,error:o}:{state:"available",version:i}}t(g,"state");let m=Promise.resolve();const w=t(()=>m=m.catch(()=>{}).then(b),"tick");async function b(){if(!(!f||a)){if(p=(await l()).length,p){c();return}f=!1,a=!0,o=null,c(),u().then(({ok:e,error:x})=>{e||(o=x)},e=>{o=e.message}).finally(()=>{a=!1,c()})}}t(b,"tickOnce");async function j(){if(!g())throw Object.assign(new Error("kickflip is up to date."),{status:409});return a||(f=!0),await w(),g()}return t(j,"request"),{check:S,state:g,request:j,tick:w}}t(L,"upgrader");export{C as CHECK_MS,_ as LATEST_URL,F as newer,L as upgrader};
|
package/hub/usage.mjs
ADDED
|
@@ -0,0 +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};
|
package/hub/views.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var F=Object.defineProperty;var u=(e,t)=>F(e,"name",{value:t,configurable:!0});import{clock as w,windowAt as K,holdLine as D,checksIn as T,checksADay as B,holdsOvernight as R,daySegments as G,splitPoint as j,sendWords as Y}from"./schedule.mjs";import{CHOICES as Z,FOREVER as x,durationEnd as X,endOf as J}from"./duration.mjs";import{siteIcon as Q}from"./icons.mjs";import{codexStatus as ee}from"../shared/codex.mjs";const O=u(e=>e.charAt(0).toUpperCase()+e.slice(1),"capital"),g=u((e,t)=>`${e} ${t}${e===1?"":"s"}`,"plural"),E=u(e=>e>=60&&e%60===0?`${e/60} h`:`${e} min`,"every"),f=u(e=>{const[t,s]=e.split(":").map(Number),o=new Date;return o.setHours(t,s,0,0),w(o,o)},"timeOfDay"),te=u(e=>{const[t,s]=e.split(":").map(Number);return t*60+s},"minuteOf"),y=u(e=>`${(e/1440*100).toFixed(3)}%`,"pct"),A=u((e,t)=>`checks every ${e.window.every_minutes} min until ${w(e.window.endsAt,t)}${e.hold?`; ${D(e.hold,t)}`:""}`,"nowLine"),_=u(e=>[`${g(e.windows.length,"window")}, ${B(e)} checks a day`,R(e)&&"held overnight",e.wakes.length?`wakes ${e.wakes.map(f).join(", ")}`:"no wakes"].filter(Boolean).join(" \xB7 "),"pollingSummary"),ne=u((e,t)=>e.count?`${e.count} push${e.count===1?"":"es"} queued; next send ${Y(e.nextFlushAt,t)}.`:"No pushes queued.","queuedLine");function oe({running:e=!1,nextAt:t,last:s=null},o){if(e)return"Checking GitHub now";const n=`Next check ${w(t,o)}`;if(!s)return n;const r=s.failed.length?"could not reach GitHub for every PR":s.watched?`${g(s.watched,"PR")}, ${s.changes.length?`${s.changes.length} changed`:"no change"}`:"no PRs watched";return`${n} \xB7 last ${w(new Date(s.at),o)}, ${r}`}u(oe,"pollLine");function N(e,t,s){return{title:O(A(e,s)),lines:[oe(t,s),ne(e,s)],count:e.count}}u(N,"pollingNow");function se(e,t){const s={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"?s:O(e.state),lines:[]};if(!t)return{tone:"ok",word:"Up",lines:[]};const o=t.filter(a=>!a.ok),n=o.filter(a=>a.state==="broken"),r=t.find(a=>a.link==="events"),d=r?.listen==="asleep"||r?.listen==="waking"?r.listen:r?.ok?"listening":"not listening",c=n.length?`Degraded \xB7 ${d}`:o.length?`Up \xB7 ${d} \xB7 ${o.length} settling`:`Up \xB7 ${d} \xB7 every link passes`;return{tone:o.length?"wait":"ok",word:c,lines:o.map(a=>({link:a.link,detail:`${a.state==="broken"?"":"settling: "}${a.detail}`}))}}u(se,"siteHealth");const I=[{value:"always",label:"Always on",description:u(()=>"Codex implements and reviews. Work pauses when it runs out.","description")},{value:"auto",label:"Auto",description:u(e=>`Codex implements and reviews, and ${e} takes over while it's out.`,"description")},{value:"off",label:"Off",description:u(e=>`${e} implements and reviews.`,"description")}],ie=u((e,t,s)=>({usable:t.usable,tag:t.usable?null:O(t.reason),share:e,choices:I.map(({value:o,label:n})=>({value:o,label:n,on:o===e})),description:t.usable?I.find(o=>o.value===e).description(P[s]):t.words,fix:t.fix}),"codexView"),P={opus:"Opus",sonnet:"Sonnet",auto:"Claude"},U=[{value:"opus",label:"Opus",description:"Always Opus. Auto would give small cards to Sonnet."},{value:"auto",label:"Auto",description:"Auto: small cards get Sonnet, the rest get Opus."},{value:"sonnet",label:"Sonnet",description:"Always Sonnet. Auto would give large cards to Opus."}],ue=u(e=>({model:e,builder:P[e],choices:U.map(({value:t,label:s})=>({value:t,label:s,on:t===e})),description:U.find(t=>t.value===e).description}),"claudeView");function me({now:e,st:t,poll:s,sidebar:o,rows:n,links:r=new Map,code:d={},host:c="",rules:a=[],remembered:m=new Map,integrations:i=[],codexShare:h="off",claudeModel:p="auto",codex:v=ee({installed:!1}),awake:C=null}){const b=new Set(o.sites.filter(l=>l.board).map(l=>l.route)),M=d.running&&d.running===d.onDisk?` \xB7 runs the code in its files (${d.onDisk})`:"",V=u(l=>({route:l.route,title:l.title,icon:Q(l.route,l),href:`/${encodeURIComponent(l.route)}/`,...se(l,r.get(l.route)),remembered:m.get(l.route)||[],restart:b.has(l.route)&&l.restart===!0}),"site"),z=[["Boards",n.filter(l=>b.has(l.route))],["Pages",n.filter(l=>!b.has(l.route))]].filter(([,l])=>l.length).map(([l,W])=>({label:l,sites:W.map(V)}));return{hub:{tone:o.hub.tone,word:o.hub.word,detail:`${o.hub.detail}${M}`},awake:C&&$(C,e,S),groups:z,integrations:i,usage:o.usage,pause:o.pause,codex:ie(h,v,p),claude:ue(p),empty:n.length===0,polling:{...N(t,s,e),summary:_(t.settings)},notifications:{on:t.settings.notifications.on,text:o.notifications.text,device:A(t,e)},hold:t.hold&&t.hold.why!=="off"?{text:`Notifications ${D(t.hold,e)}`,end:t.hold.why==="snoozed"}:null,rules:a.length?g(a.length,"rule"):"None yet",sites:g(n.length,"site"),host:c,total:o.total}}u(me,"hubView");const ge="That snooze ends in the past or more than 30 days on. Pick another time.",ye="That keep awake ends in the past or more than 30 days on. Pick another time.",L="until you end it",S="until you turn it off",$=u((e,t,s)=>e===x?s:`until ${w(e,t)}`,"untilWords"),q=u((e,t)=>Z.map(([s,o])=>({value:s,label:o,until:$(X(e,s),e,t)})),"durationChoices"),le=u((e,t)=>e.on?J(e.snoozed_until,t):null,"snoozedUntil");function $e({now:e,st:t,rules:s=[],route:o={atMac:!1,desktops:0},linear:n={set:!1},awake:r={until:null,unsupported:null}}){const d=t.settings,c=d.notifications,[a,m]=[d.windows[0],d.windows.at(-1)],i=le(c,e),h=t.count?` The ${t.count} queued push${t.count===1?" is":"es are"} dropped now.`:"",p=i&&`Snoozed ${$(i,e,L)}`;return{snoozed:i?{title:p,summary:i===x?"Snoozed \xB7 forever":p}:null,device:A(t,e),on:{on:c.on,sub:c.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.${h} Needs you and the badge still update.`},presence:c.on?o.atMac?o.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:c.on&&!i?{choices:q(e,L)}:null,awake:{unsupported:r.unsupported,on:r.until?{title:`Keeping awake ${$(r.until,e,S)}`}:null,choices:r.unsupported||r.until?null:q(e,S),summary:r.unsupported||(r.until?r.until===x?"On \xB7 forever":`On until ${w(r.until,e)}`:"Off")},weekends:{on:c.weekends_off,sub:`Pushes wait from Friday ${f(m.from)} until Monday ${f(a.from)}. Urgent ones still go.`},polling:_(d),rules:s,linear:re(n)}}u($e,"settingsView");const k={url:"https://linear.app/settings/account/security",label:"Linear \u203A Settings \u203A Security & access \u203A Personal API keys"};function re(e){const t=e.workspace?`${e.workspace} workspace`:"";return e.set?e.refused?{set:!0,refused:!0,title:t||"Linear key",down:"Doesn\u2019t work",sub:`Linear refused the key. Make a new one in ${k.label} and paste it here.`,keys:k,summary:t?`Key doesn\u2019t work \xB7 ${t}`:"Key doesn\u2019t work"}:{set:!0,title:t,sub:"Key \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",summary:`Key set \xB7 ${t}`}:{set:!1,title:"No key",sub:`Make a personal API key in ${k.label}, then paste it here.`,keys:k,summary:"No key"}}u(re,"linearWords");const ae=[5,10,15,30,60,120],de=u(e=>Math.round(100-Math.log(e/5)/Math.log(24)*70),"shade"),H=u((e,t)=>e.windows.length===1?"All day":`${f(e.windows[t].from)} \u2013 ${f(e.windows[(t+1)%e.windows.length].from)}`,"windowRange");function ke({now:e,st:t,poll:s,select:o=null}){const n=t.settings,r=n.windows.findIndex(i=>i.from===o),d=r>=0?r:n.windows.findIndex(i=>i.from===K(e,n).from),c=n.windows.length===1,[a,m]=[n.windows[0],n.windows.at(-1)];return{select:n.windows[d].from,now:N(t,s,e),day:{segments:G(n).map(i=>{const h=n.windows[i.window];return{w:h.from,left:y(i.start),width:y(i.end-i.start),shade:`${de(h.every_minutes)}%`,held:i.held,label:i.end-i.start>=110?E(h.every_minutes).replace(" min","m").replace(" h","h"):"",aria:`${H(n,i.window)}, every ${E(h.every_minutes)}${i.held?", pushes held":""}`}}),pins:n.wakes.map(i=>({left:y(te(i)),title:`Wake ${f(i)}`})),now:y(e.getHours()*60+e.getMinutes()),held:R(n),wakes:n.wakes.length>0},windows:n.windows.map((i,h)=>({from:i.from,range:H(n,h),ends:c?"\u2014":f(n.windows[(h+1)%n.windows.length].from),every:[...new Set([...ae,i.every_minutes])].sort((p,v)=>p-v).map(p=>({minutes:p,label:E(p),on:p===i.every_minutes})),checks:`${g(T(n,h),"check")} in this window`,split:!!j(n,h),remove:!c})),hold:{on:n.hold_overnight,sub:c?"With one window there is no overnight to hold.":`Pushes queued after ${f(m.from)} wait until ${f(a.from)}. GitHub checks still run.`},wakes:n.wakes.map(i=>({value:i,label:f(i)}))}}u(ke,"pollingView");function ve(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."}}u(ve,"flushView");export{ye as AWAKE_REFUSED,ge as SNOOZE_REFUSED,O as capital,q as durationChoices,E as every,ve as flushView,me as hubView,A as nowLine,g as plural,oe as pollLine,_ as pollingSummary,ke as pollingView,$e as settingsView,se as siteHealth};
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lovek4d/kickflip",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
+
}
|
package/shared/codex.mjs
ADDED
|
@@ -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,15 @@
|
|
|
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
|
+
--accent is the one human blue: new messages, links, buttons, focus rings, the owner's bubble (--accent-soft) and
|
|
4
|
+
planner lines; --on-accent is the label on an accent fill (docs/adr/0169-one-human-blue.md). */
|
|
5
|
+
:root {
|
|
6
|
+
--bg: #fbfbfc; --panel: #ffffff; --line: #e6e6ea; --text: #1b1b1f; --muted: #6b6b76;
|
|
7
|
+
--accent: #2b6cb0; --accent-soft: #eef6ff; --on-accent: #ffffff; --ok: #1f9d55; --warn: #c27c0e; --bad: #d1435b;
|
|
8
|
+
--chip: #f1f1f4; --band: #f2f2f5; --claude: #fdf3e4;
|
|
9
|
+
--color-quiet: var(--muted); --color-info: var(--accent); --color-notice: var(--warn); --color-danger: var(--bad); --color-success: var(--ok);
|
|
10
|
+
}
|
|
11
|
+
@media (prefers-color-scheme: dark) {
|
|
12
|
+
:root { --bg: #111114; --panel: #18181c; --line: #2a2a31; --text: #ececf1; --muted: #9a9aa6;
|
|
13
|
+
--accent: #6fa8e8; --accent-soft: #1b2533; --on-accent: #0b0b0e; --ok: #3ccf7f; --warn: #e3a33a; --bad: #f0687f;
|
|
14
|
+
--chip: #24242b; --band: #202026; --claude: #2d2416; }
|
|
15
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
var g=Object.defineProperty;var r=(e,s)=>g(e,"name",{value:s,configurable:!0});import n from"node:fs";import a from"node:path";import{PR_VIEW_FIELDS as p}from"../hub/pr-report.mjs";const i=r((e,s,t,c)=>{const o=a.join(e,s,`${t}.json`);n.mkdirSync(a.dirname(o),{recursive:!0}),n.writeFileSync(o,JSON.stringify(c))},"write"),S=r((e,s,t)=>i(e,"pr",s,t),"writePR"),j=r((e,s,t)=>i(e,"issue-comments",s,t),"writeIssueComments"),v=r((e,s,t)=>i(e,"review-comments",s,t),"writeReviewComments"),y=r((e,s,t)=>i(e,"reviews",s,t),"writeReviews"),w=r((e,s,t)=>i(e,"merge",s,t),"writeMerge"),x=r((e,s)=>i(e,"","user",s),"writeUser"),O=r((e,s)=>i(e,"","accounts",s),"writeAccounts"),l=r((e,s)=>{try{return n.readFileSync(a.join(e,s),"utf8").trim().split(`
|
|
2
|
+
`).filter(Boolean).map(JSON.parse)}catch{return[]}},"jsonl"),E=r(e=>l(e,"calls.jsonl"),"calls"),N=r(e=>l(e,"tokens.jsonl"),"tokens");function R({head:e="abc1234567",failing:s=[],pending:t=0,...c}={}){return{state:"OPEN",isDraft:!1,reviewDecision:"REVIEW_REQUIRED",mergeable:"MERGEABLE",mergeStateStatus:"BLOCKED",headRefOid:e,statusCheckRollup:[...s.map(o=>({__typename:"CheckRun",name:o,status:"COMPLETED",conclusion:"FAILURE"})),...Array.from({length:t},(o,u)=>({__typename:"CheckRun",name:`ci-${u}`,status:"IN_PROGRESS",conclusion:""}))],...c}}r(R,"prView");function k(e){const s=a.join(e,"bin");return n.mkdirSync(s,{recursive:!0}),n.writeFileSync(a.join(s,"gh"),`#!${process.execPath}
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const dir = ${JSON.stringify(e)};
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
fs.appendFileSync(path.join(dir, 'calls.jsonl'), JSON.stringify(args) + '\\n');
|
|
8
|
+
fs.appendFileSync(path.join(dir, 'tokens.jsonl'), JSON.stringify({ args, token: process.env.GH_TOKEN || null }) + '\\n');
|
|
9
|
+
const fail = () => { console.error('no fixture for: ' + args.join(' ')); process.exit(1); };
|
|
10
|
+
if (args.length === 4 && args[0] === 'auth' && args[1] === 'token' && args[2] === '-u') {
|
|
11
|
+
let accounts = {};
|
|
12
|
+
try { accounts = JSON.parse(fs.readFileSync(path.join(dir, 'accounts.json'), 'utf8')); } catch {}
|
|
13
|
+
if (!accounts[args[3]]) { console.error('no oauth token found for github.com account ' + args[3]); process.exit(1); }
|
|
14
|
+
console.log(accounts[args[3]]);
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
if (args[0] === 'issue' && args.includes('--repo')) {
|
|
18
|
+
const repo = args[args.indexOf('--repo') + 1];
|
|
19
|
+
if (args[1] === 'create') {
|
|
20
|
+
const n = fs.readFileSync(path.join(dir, 'calls.jsonl'), 'utf8').split('\\n').filter((l) => l.startsWith('["issue","create"')).length;
|
|
21
|
+
console.log('https://github.com/' + repo + '/issues/' + n);
|
|
22
|
+
} else if (args[1] === 'view') console.log(JSON.stringify(args[6] === 'number,url' ? { number: Number(args[2]), url: 'https://github.com/' + repo + '/issues/' + args[2] } : { state: 'OPEN', assignees: [] }));
|
|
23
|
+
else if (args[1] !== 'close') fail();
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
if (args.length === 6 && args[0] === 'pr' && args[1] === 'merge' && /^\\d+$/.test(args[2]) && args[3] === '--repo' && args[5] === '--squash') {
|
|
27
|
+
let refused = null;
|
|
28
|
+
try { refused = JSON.parse(fs.readFileSync(path.join(dir, 'merge', args[2] + '.json'), 'utf8')).error; } catch {}
|
|
29
|
+
if (refused) { console.error(refused); process.exit(1); }
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
let file;
|
|
33
|
+
let list = false;
|
|
34
|
+
if (args.length === 7 && args[0] === 'pr' && args[1] === 'view' && /^\\d+$/.test(args[2]) && args[3] === '--repo' && args[5] === '--json' && args[6] === ${JSON.stringify(p)}) file = path.join(dir, 'pr', args[2] + '.json');
|
|
35
|
+
else if (args.length === 2 && args[0] === 'api' && args[1] === 'user') file = path.join(dir, 'user.json');
|
|
36
|
+
else if (args.length === 4 && args[0] === 'api' && args[1] === '--paginate' && args[2] === '--slurp') {
|
|
37
|
+
const match = /^repos\\/[^/]+\\/[^/]+\\/(issues|pulls)\\/(\\d+)\\/(comments\\?since=.+|reviews)$/.exec(args[3]);
|
|
38
|
+
if (!match || (match[1] === 'issues' && match[3] === 'reviews')) process.exit(1);
|
|
39
|
+
file = path.join(dir, match[3] === 'reviews' ? 'reviews' : match[1] === 'issues' ? 'issue-comments' : 'review-comments', match[2] + '.json');
|
|
40
|
+
list = true;
|
|
41
|
+
} else process.exit(1);
|
|
42
|
+
let value;
|
|
43
|
+
try { value = fs.readFileSync(file, 'utf8'); } catch { if (!list) fail(); value = '[]'; }
|
|
44
|
+
if (list) {
|
|
45
|
+
value = JSON.parse(value);
|
|
46
|
+
if (value.fail) fail();
|
|
47
|
+
const pages = [];
|
|
48
|
+
for (let i = 0; i < value.length; i += 2) pages.push(value.slice(i, i + 2));
|
|
49
|
+
console.log(JSON.stringify(pages));
|
|
50
|
+
} else {
|
|
51
|
+
try { if (JSON.parse(value).fail) fail(); } catch {}
|
|
52
|
+
console.log(value);
|
|
53
|
+
}
|
|
54
|
+
`,{mode:493}),s}r(k,"fakeGh");export{E as calls,k as fakeGh,R as prView,N as tokens,O as writeAccounts,j as writeIssueComments,w as writeMerge,S as writePR,v as writeReviewComments,y as writeReviews,x as writeUser};
|
package/shared/repos.mjs
ADDED
|
@@ -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 S=Object.defineProperty;var n=(i,u)=>S(i,"name",{value:u,configurable:!0});import p from"node:fs";import v from"node:os";import $ from"node:path";import{spawnSync as A}from"node:child_process";const d="local.claude.kickflip-hub",m="kickflip-hub.service";function E(i,u,{env:g=process.env,timeout:o=1e4}={}){const r=A(i,u,{encoding:"utf8",env:g,timeout:o});return{status:r.status??1,stdout:r.stdout||"",error:(r.error?.message||r.stderr||`exit ${r.status}`).trim().split(`
|
|
2
|
+
`)[0]}}n(E,"runCommand");function D({platform:i=process.platform,home:u=v.homedir(),uid:g=process.getuid?.(),env:o=process.env,run:r=E}={}){const a=n((t,s,c={})=>{try{return r(t,s,{env:o,...c})}catch(l){return{status:1,stdout:"",error:l.message}}},"exec");if(i==="darwin")return L({home:u,uid:g,exec:a});if(i==="linux")return T({home:u,uid:g,exec:a});const e=`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(e)},"install"),restart:n(()=>e,"restart"),remove:n(()=>{},"remove"),status:n(()=>({installed:!1,running:!1,detail:e}),"status"),notes:n(()=>[],"notes")}}n(D,"hubService");const b=12e4;function L({home:i,uid:u,exec:g}){const o=$.join(i,"Library","LaunchAgents"),r=`gui/${u}`,a=$.join(o,`${d}.plist`),e=n((t,s)=>g("launchctl",t,s),"launchctl");return{manager:"launchd",item:"hub login item",file:a,id:`${r}/${d}`,restartCommand:`launchctl kickstart -k gui/$(id -u)/${d}`,definition({node:t,entry:s,state:c,port:l,log:h}){const y=n(w=>String(w).replaceAll("&","&").replaceAll("<","<").replaceAll(">",">"),"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>${d}</string>
|
|
7
|
+
<key>ProgramArguments</key>
|
|
8
|
+
<array><string>${y(t)}</string><string>${y(s)}</string></array>
|
|
9
|
+
<key>WorkingDirectory</key><string>${y(c)}</string>
|
|
10
|
+
<key>EnvironmentVariables</key>
|
|
11
|
+
<dict><key>PATH</key><string>${y($.dirname(t))}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string><key>KICKFLIP_HUB_PORT</key><string>${l}</string></dict>
|
|
12
|
+
<key>RunAtLoad</key><true/>
|
|
13
|
+
<key>KeepAlive</key><true/>
|
|
14
|
+
<key>StandardOutPath</key><string>${y(h)}</string>
|
|
15
|
+
<key>StandardErrorPath</key><string>${y(h)}</string>
|
|
16
|
+
</dict>
|
|
17
|
+
</plist>
|
|
18
|
+
`},async install(t,{wait:s=n(l=>new Promise(h=>setTimeout(h,l)),"wait"),dryRun:c=!1}={}){if(c)return[`would write ${a} and start ${r}/${d}`];p.mkdirSync(o,{recursive:!0}),p.writeFileSync(a,t),e(["bootout",`${r}/${d}`]),await s(500);const l=e(["bootstrap",r,a]);if(l.status===0)return[`started ${r}/${d} from ${a}`];throw new Error(l.error)},restart(){const t=e(["kickstart","-k",`${r}/${d}`],{timeout:b});return t.status===0?null:t.error},remove(){e(["bootout",`${r}/${d}`]),p.rmSync(a,{force:!0})},status(){const t=e(["print",`${r}/${d}`]),s=p.existsSync(a),c=s&&t.status===0&&/\bstate = running\b/.test(t.stdout||"");return{installed:s,running:c,detail:c?"running":s?"not running":"not installed"}},notes:n(()=>[],"notes")}}n(L,"launchd");const k=n(i=>String(i).replaceAll("%","%%").replaceAll("$","$$$$").replace(/[\r\n]/g," "),"unitValue"),f=n(i=>`"${k(i).replaceAll("\\","\\\\").replaceAll('"','\\"')}"`,"execWord");function T({home:i,uid:u,exec:g}){const o=$.join(i,".config","systemd","user",m),r=n((e,t)=>g("systemctl",["--user",...e],t),"systemctl"),a=n(e=>{const t=r(e);if(t.status!==0)throw new Error(`systemctl --user ${e.join(" ")}: ${t.error}`)},"step");return{manager:"systemd",item:"hub service",file:o,id:m,restartCommand:`systemctl --user restart ${m}`,definition({node:e,entry:t,state:s,port:c,log:l}){return`[Unit]
|
|
19
|
+
Description=kickflip hub
|
|
20
|
+
|
|
21
|
+
[Service]
|
|
22
|
+
ExecStart=${f(e)} ${f(t)}
|
|
23
|
+
WorkingDirectory=${k(s)}
|
|
24
|
+
Environment=${f(`PATH=${$.dirname(e)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`)}
|
|
25
|
+
Environment=KICKFLIP_HUB_PORT=${c}
|
|
26
|
+
Restart=always
|
|
27
|
+
RestartSec=2
|
|
28
|
+
StandardOutput=append:${k(l)}
|
|
29
|
+
StandardError=append:${k(l)}
|
|
30
|
+
|
|
31
|
+
[Install]
|
|
32
|
+
WantedBy=default.target
|
|
33
|
+
`},async install(e,{dryRun:t=!1}={}){return t?[`would write ${o}, enable and start ${m}`]:(p.mkdirSync($.dirname(o),{recursive:!0}),p.writeFileSync(o,e),a(["daemon-reload"]),a(["enable",m]),a(["restart",m]),[`enabled and started ${m} from ${o}`])},restart(){const e=r(["restart",m],{timeout:b});return e.status===0?null:e.error},remove(){r(["disable","--now",m]),p.rmSync(o,{force:!0}),r(["daemon-reload"])},status(){const e=r(["is-active",m]),t=p.existsSync(o),s=t&&e.status===0&&(e.stdout||"").trim()==="active";return{installed:t,running:s,detail:s?"running":t?"not running":"not installed"}},notes(){const e=g("loginctl",["show-user",String(u),"--property=Linger"]),t=e.status===0&&/^Linger=yes$/m.test(e.stdout||"");return[{item:"hub without a login",state:t?"ok":"optional",detail:t?"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(T,"systemd");export{d as LOGIN_LABEL,m as UNIT,D as hubService,E as runCommand};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var g=Object.defineProperty;var l=(t,e)=>g(t,"name",{value:e,configurable:!0});import k from"node:fs";import c from"node:os";import a from"node:path";const o="mattpocock/skills",d=["improve-codebase-architecture","codebase-design","grilling","prototype"],m=l((t,e=c.homedir())=>a.join(e,".claude","skills",t,"SKILL.md"),"skillFile"),f=l((t=c.homedir())=>d.filter(e=>!k.existsSync(m(e,t))),"missingSkills"),v=l(t=>["--yes","skills","add",o,"-g","-y","-a","claude-code","--skill",...t],"addArgv"),w=l(t=>`npx skills add ${o} -g -y -a claude-code --skill ${t.join(" ")}`,"addCommand"),E=l(t=>`npx skills update ${t.join(" ")} -g -y`,"updateCommand");function x(t){let e;try{e=JSON.parse(k.readFileSync(a.join(t,".agents",".skill-lock.json"),"utf8"))}catch{return{}}return Object.fromEntries(d.flatMap(r=>{const s=e?.skills?.[r];return s?.source===o&&s.skillPath&&s.skillFolderHash?[[r,{folder:a.posix.dirname(s.skillPath),hash:s.skillFolderHash}]]:[]}))}l(x,"lockedSkills");function F({home:t=c.homedir(),run:e,gh:r=!0}={}){const s=f(t),n=x(t),h=d.filter(i=>!s.includes(i)&&n[i]);if(!r||!h.length)return{missing:s,stale:[],checked:!1};const p=e("gh",["api",`repos/${o}/git/trees/HEAD?recursive=1`,"--jq",'.tree[] | select(.type == "tree") | .path + " " + .sha']);if(p.status!==0)return{missing:s,stale:[],checked:!1};const u=new Map(p.stdout.split(`
|
|
2
|
+
`).filter(Boolean).map(i=>i.split(" ")));return{missing:s,stale:h.filter(i=>u.has(n[i].folder)&&u.get(n[i].folder)!==n[i].hash),checked:!0}}l(F,"skillsState");const S=l(t=>t.length<3?t.join(" and "):`${t.slice(0,-1).join(", ")} and ${t.at(-1)}`,"list");function I(t=c.homedir()){const e=f(t);return e.length?`Architecture reviews wait on ${S(e)} from ${o}, which ${e.length===1?"isn\u2019t":"aren\u2019t"} installed. Run kickflip doctor --fix on the host to add ${e.length===1?"it":"them"}.`:null}l(I,"reviewWaits");export{d as REVIEW_SKILLS,o as SKILLS_SOURCE,v as addArgv,w as addCommand,f as missingSkills,I as reviewWaits,m as skillFile,F as skillsState,E as updateCommand};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var S=Object.defineProperty;var o=(e,t)=>S(e,"name",{value:t,configurable:!0});import g from"node:os";import{usageReader as $}from"../hub/usage.mjs";import{readSettingsFile as x,writeSettingsFile as m,readCodexShare as A,readClaudeModel as D}from"../hub/push.mjs";import{codexStatus as N,codexStatusReader as M,effectiveCodexShare as O}from"./codex.mjs";const E=o(e=>Number.isInteger(e)&&e>=50&&e<=100&&e%5===0,"validPauseAt"),h=o(e=>Number(e),"timeOf"),G=["five_hour","seven_day"];function P({claude:e,codex:t,pauseAt:s=90,keepGoing:n,now:a=Date.now()}={}){const i=E(s)?s:90,r=o(f=>i===100?null:G.find(d=>{const c=f?.[d];return c?.state==="live"&&Number.isFinite(c.used_percentage)&&c.used_percentage>=i&&(!c.resets_at||Date.parse(c.resets_at)>h(a))})||null,"past"),l=o((f,d)=>({window:d,used:d?f[d].used_percentage:null,resets_at:d?f[d].resets_at:null}),"details"),u=r(e),p=l(e,u),C=u&&n?.window===u&&Math.abs(Date.parse(n.until)-Date.parse(p.resets_at))<=3e5&&h(a)<Date.parse(n.until),w=r(t);return{pause_at:i,state:u?C?"going":"paused":"normal",...p,codex:{past:!!w,...l(t,w)}}}o(P,"usageGate");const _=new Map;function k({home:e=g.homedir(),now:t=Date.now()}={}){let s=_.get(e);s||(s={now:h(t)},s.usage=$({home:e,now:o(()=>s.now,"now")}),s.codex=M({home:e,now:o(()=>s.now,"now")}),_.set(e,s)),s.now=h(t);const n=x(e);return P({...s.usage({codexShare:A(s.codex(),e)}),pauseAt:n.pauseAt,keepGoing:n.keepGoing,now:t})}o(k,"readUsageGate");function U(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});m({...x(t),pauseAt:Number(e)},t)}o(U,"setPauseAt");function W(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 n=x(t);if(e==="on"){const a=k({home:t,now:s});if(a.state!=="paused")throw Object.assign(new Error("Nothing is paused."),{status:409});n.keepGoing={window:a.window,until:a.resets_at}}else delete n.keepGoing;m(n,t)}o(W,"setKeepGoing");const z=o(e=>{try{return["github-change","check-github","card-unblocked","card-pull","card-plan","todo-triage"].includes(JSON.parse(e).kind)}catch{return!1}},"heldUsageEvent");function B({home:e=g.homedir(),now:t=Date.now(),card:s={}}={}){const n=o(l=>{const{model:u,reason:p}=R(D(e),s);return{kind:"claude",model:u,reason:`${l} ${p}`}},"claude"),a=N({home:e});if(!a.usable)return n(a.words);const i=O(x(e).codexShare,a);if(i==="off")return n("Codex share is off.");const r=k({home:e,now:t});return r.codex.past?n(`Codex is at ${Math.round(r.codex.used)}%, past the ${r.pause_at}% line until ${r.codex.resets_at}.`):{kind:"codex",reason:`Codex share is ${i} and Codex is under the ${r.pause_at}% line.`}}o(B,"builderPick");const b={opus:"Opus",sonnet:"Sonnet"};function R(e,t){return e!=="auto"?{model:e,reason:`Claude model is ${b[e]}.`}:t.model?{model:t.model,reason:`Claude model is Auto and the card asks for Claude ${b[t.model]}.`}:t.size==="small"?{model:"sonnet",reason:"Claude model is Auto and the card is small."}:{model:"opus",reason:"Claude model is Auto and the card isn't small."}}o(R,"claudePick");const H=o(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{B as builderPick,z as heldUsageEvent,H as pauseReason,k as readUsageGate,W as setKeepGoing,U as setPauseAt,P as usageGate,E 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,68 @@
|
|
|
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
|
+
// a refusal throws with .status and .reason, the server's {error} or ''
|
|
7
|
+
// await ClaudeSite.events({kind}) events newest first, each with .reply ({html} or {json}) or null
|
|
8
|
+
// ClaudeSite.watch(fn, {kind}) calls fn(events) now and on change; polls every 10 s while
|
|
9
|
+
// any event waits for a reply, else every 60 s; returns stop()
|
|
10
|
+
// ClaudeSite.syncBadge() sets the Home Screen app's badge to what waits on the owner
|
|
11
|
+
// across every site, from the hub's /_hub/needs.json
|
|
12
|
+
// ClaudeSite.site('/<route>') the same API for another site on this hub, its root resolved
|
|
13
|
+
// against this page, so one page can talk to several sites
|
|
14
|
+
window.ClaudeSite = (function () {
|
|
15
|
+
function site(base) {
|
|
16
|
+
const root = new URL(base, location.href).href.replace(/\/+$/, '');
|
|
17
|
+
const url = (p) => root + '/' + String(p).replace(/^\/+/, '');
|
|
18
|
+
|
|
19
|
+
async function send(kind, data) {
|
|
20
|
+
const r = await fetch(url('_claude/events'), { method: 'POST', mode: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...(data || {}), kind }) });
|
|
21
|
+
if (!r.ok) throw Object.assign(new Error('send failed: ' + r.status), { status: r.status, reason: (await r.json().catch(() => null))?.error || '' });
|
|
22
|
+
return r.json();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function events(opts) {
|
|
26
|
+
const q = new URLSearchParams();
|
|
27
|
+
if (opts && opts.kind) q.set('kind', opts.kind);
|
|
28
|
+
if (opts && opts.limit) q.set('limit', String(opts.limit));
|
|
29
|
+
const r = await fetch(url('_claude/events') + (q.toString() ? '?' + q : ''), { cache: 'no-store' });
|
|
30
|
+
if (!r.ok) throw new Error('events failed: ' + r.status);
|
|
31
|
+
return r.json();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function watch(fn, opts) {
|
|
35
|
+
let timer, last = '', stopped = false;
|
|
36
|
+
async function tick() {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
if (stopped) return;
|
|
39
|
+
let pending = false;
|
|
40
|
+
try {
|
|
41
|
+
const list = await events(opts);
|
|
42
|
+
const s = JSON.stringify(list);
|
|
43
|
+
if (s !== last) { last = s; fn(list); }
|
|
44
|
+
pending = list.some((e) => !e.reply);
|
|
45
|
+
} catch (e) { fn(null, e); }
|
|
46
|
+
if (!stopped) timer = setTimeout(tick, pending ? 10000 : 60000);
|
|
47
|
+
}
|
|
48
|
+
const onVisible = () => { if (!document.hidden) tick(); };
|
|
49
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
50
|
+
tick();
|
|
51
|
+
return { refresh: tick, stop() { stopped = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVisible); } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The badge covers every site, so its count comes from the hub, not from this site.
|
|
55
|
+
async function syncBadge() {
|
|
56
|
+
if (!('setAppBadge' in navigator)) return;
|
|
57
|
+
try {
|
|
58
|
+
const { total } = await (await fetch('/_hub/needs.json', { cache: 'no-store' })).json();
|
|
59
|
+
if (typeof total === 'number') await (total ? navigator.setAppBadge(total) : navigator.clearAppBadge());
|
|
60
|
+
} catch { /* outside the hub, or badges not allowed */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { root, url, send, events, watch, syncBadge, site };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const src = (document.currentScript && document.currentScript.src) || location.href;
|
|
67
|
+
return site(src.replace(/\/claude\.js(?:[?#].*)?$/, ''));
|
|
68
|
+
})();
|
|
@@ -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"})`));
|