@lovek4d/kickflip 0.7.7 → 0.7.8
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 +3 -3
- package/SKILL.md +2 -2
- package/bin/event-lines.mjs +1 -1
- package/bin/wait-events.mjs +2 -2
- package/board/domain.mjs +1 -1
- package/board/public/hub.css +1 -0
- package/board/public/ui-cards.js +3 -3
- package/board/public/ui-threads.js +1 -1
- package/board/public/ui.js +1 -1
- package/board/public/view-hub.js +25 -2
- package/hooks-plugin/hooks/board-messages.mjs +53 -0
- package/hooks-plugin/hooks/hooks.json +10 -0
- package/hub/hub.mjs +9 -9
- package/hub/try.mjs +10 -10
- package/hub/upgrade.mjs +1 -1
- package/package.json +1 -1
package/CONTEXT.md
CHANGED
|
@@ -335,7 +335,7 @@ The orange words last among the orange ones on a card's time line ("10:26 · hel
|
|
|
335
335
|
_Avoid_: open items, needs you (Needs you is the box at the top of the board)
|
|
336
336
|
|
|
337
337
|
**Request**:
|
|
338
|
-
The owner's ask for a grill, a prototype or a **visual** on a card: the thread composer's fire, eye or image, each sending at once, or /grill, /prototype or /visual typed in the box on Reply, sending `grill-more`, `prototype-request` or `visual-request` with whatever was typed; a question's own eye on Waiting on you asks for a prototype of that question. A request with nothing typed is one gray **status line** in the thread, "Grill requested", "Prototype requested" or "Visual requested", keyed `grill`, `prototype` or `visual` as every button without a text box keys its line ([a button without a text box posts a keyed status line](docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md)); one with text is the owner's message carrying it, with that status in the message's footer, and "
|
|
338
|
+
The owner's ask for a grill, a prototype or a **visual** on a card: the thread composer's fire, eye or image, each sending at once, or /grill, /prototype or /visual typed in the box on Reply, sending `grill-more`, `prototype-request` or `visual-request` with whatever was typed; a question's own eye on Waiting on you asks for a prototype of that question. A request with nothing typed is one gray **status line** in the thread, "Grill requested", "Prototype requested" or "Visual requested", keyed `grill`, `prototype` or `visual` as every button without a text box keys its line ([a button without a text box posts a keyed status line](docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md)); one with text is the owner's message carrying it, with that status in the message's footer, and "waiting…" to its right until Claude picks it up, then "thinking…". An answer submitted from Waiting on you is likewise a message whose footer reads "answers submitted". A question asked for a prototype reads "prototype requested", locked, until a question naming it in `round_of` resolves it, and `question resolve` refuses it; a prototype request still waiting 30 minutes on with no prototype agent running comes back to the session as `prototype-overdue` ([a button tap is a status line or a message footer](docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md), [a prototype request waits for its prototype](docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md)).
|
|
339
339
|
_Avoid_: Grill button, Prototype button (on a card)
|
|
340
340
|
|
|
341
341
|
**Visual**:
|
|
@@ -375,7 +375,7 @@ The open card's link to its PR, on every card with an open PR, last on the detai
|
|
|
375
375
|
_Avoid_: Review #<n> on GitHub, PR link
|
|
376
376
|
|
|
377
377
|
**Install**:
|
|
378
|
-
The app card's one badge that opens its build on the owner's phone, or the badge on main's slim bar above the lanes. A ready card reads "Install ↗", or "Install (stale) ↗" when its build's sha differs from the current branch head. While a build runs it reads "Serving…" with an inert spinner; a failed build reads "Serve failed ↗" and opens its thread. The open card's badge sits between Notes and Review on GitHub. The workflow button starts a Serve, or Retry after failure, while Auto serve is off and the card's branch has changes ([only the owner or Auto serve starts a card run](docs/adr/2026-11-02-1806-only-the-owner-or-auto-build-starts-a-card-run.md)). Main's bar is two rows: the repo's owner/name with Visit right-justified, then main@sha, its age as muted text and the refresh icon, with Updating… (spinner) or Update failed right-justified, even with no old server. While main re-serves, its old server keeps serving until the new one answers: the bar names the old commit and keeps Visit to it ([a Main re-serve keeps the old server up until the new one answers](docs/adr/2026-11-09-1815-a-main-re-serve-keeps-the-old-server-up-until-the-new-one-answers.md)); a new serve that exits before answering stops the old server and serves once more ([Main serves again after the pair update](docs/adr/2026-11-10-0108-main-serves-again-after-the-pair-update.md)). Main’s red Update failed button opens a bottom sheet with the last 40 lines of `data/try/main.log` and Retry at the recorded failed tip. Retry shows readiness refusals in the sheet, reads Building… in orange while running, refreshes the log after failure and closes with a toast when ready. On a local board, a refresh icon right of main's timestamp pulls its branch from origin now, as every poll does, and turns red with the reason when a pull can't fast-forward; a tap or click always pulls again, on any device, and opens the reason when that pull fails ([a local board's Main pulls from origin on its own](docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md)). A Done card has no try badge ([a board's build script serves or builds each card and main](docs/adr/2026-11-01-1846-a-boards-build-script-serves-or-builds-each-card-and-main.md)).
|
|
378
|
+
The app card's one badge that opens its build on the owner's phone, or the badge on main's slim bar above the lanes. A ready card reads "Install ↗", or "Install (stale) ↗" when its build's sha differs from the current branch head. While a build runs it reads "Serving…" with an inert spinner; a failed build reads "Serve failed ↗" and opens its thread. The open card's badge sits between Notes and Review on GitHub. The workflow button starts a Serve, or Retry after failure, while Auto serve is off and the card's branch has changes ([only the owner or Auto serve starts a card run](docs/adr/2026-11-02-1806-only-the-owner-or-auto-build-starts-a-card-run.md)). Main's bar is two rows: the repo's owner/name with Visit right-justified, then main@sha, its age as muted text and the refresh icon, with Updating… (spinner) or Update failed right-justified, even with no old server. While main re-serves, its old server keeps serving until the new one answers: the bar names the old commit and keeps Visit to it ([a Main re-serve keeps the old server up until the new one answers](docs/adr/2026-11-09-1815-a-main-re-serve-keeps-the-old-server-up-until-the-new-one-answers.md)); a new serve that exits before answering with a log naming the old server's worktree, as Next does when it refuses a second server, stops the old server and serves once more, and any other failed serve leaves the old server up beside Update failed ([Main serves again after the pair update](docs/adr/2026-11-10-0108-main-serves-again-after-the-pair-update.md), [a Main serve that fails on its own keeps the old serve up](docs/adr/2026-11-10-1100-a-main-serve-that-fails-on-its-own-keeps-the-old-serve-up.md)). Main’s red Update failed button opens a bottom sheet with the last 40 lines of `data/try/main.log` and Retry at the recorded failed tip. Retry shows readiness refusals in the sheet, reads Building… in orange while running, refreshes the log after failure and closes with a toast when ready. On a local board, a refresh icon right of main's timestamp pulls its branch from origin now, as every poll does, and turns red with the reason when a pull can't fast-forward; a tap or click always pulls again, on any device, and opens the reason when that pull fails ([a local board's Main pulls from origin on its own](docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md)). A Done card has no try badge ([a board's build script serves or builds each card and main](docs/adr/2026-11-01-1846-a-boards-build-script-serves-or-builds-each-card-and-main.md)).
|
|
379
379
|
_Avoid_: download, TestFlight (a different channel), older build
|
|
380
380
|
|
|
381
381
|
**Build script**:
|
|
@@ -469,7 +469,7 @@ A free-form request or question the owner sends from the board's + button (or by
|
|
|
469
469
|
**Thread**:
|
|
470
470
|
The conversation on one work item: Claude's reports, builders' status lines, the board's status lines, the owner's messages, mirrored GitHub comments, and answers to the item's questions. Every line the board (Kickflip) writes is a status line, a mark and at most 100 characters, red when it is a fault, which alone of them counts as unread; only the orchestrator's own words make a message titled Orchestrator, at most 2000 characters ([the board writes status lines, and only the orchestrator writes messages](docs/adr/2026-11-09-1200-the-board-writes-status-lines-and-only-the-orchestrator-writes-messages.md)). An In Review card's reply form sends with Reply then Done, which closes the card; a Todo card closes from Close, right of Hold in its actions, and an In Progress card has neither, only Send ([Done shows only in In Review, beside Reply](docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md)).
|
|
471
471
|
An open card shows its thread's newest 3 messages as it opens, or from its oldest **unread message** when more than 3 messages follow it, at most 10, unread as of when the card opened. Messages that arrive while it is open add below, hiding none. Messages are the owner's, Claude's (replies included) and other people's; builder, status and ask-link lines show among the messages they sit between and don't count. "Show more · N previous messages" at the top adds the next 3 older messages each tap and goes when nothing older is left. The expansion lasts while the card stays open, through repaints and new messages, and closing the card puts it back to 3. A message being edited always shows, with every line after it; one waiting on Claude shows only where the window reaches it.
|
|
472
|
-
An owner message Claude hasn't answered yet ends with **
|
|
472
|
+
An owner message Claude hasn't answered yet ends with **waiting…**, or **thinking…** once Claude has it: from its watcher's wake, or from the first tool call after it arrived while Claude was mid-task ([the owner's words reach the orchestrator after its next tool call](docs/adr/2026-11-10-1056-the-owners-words-reach-the-orchestrator-after-its-next-tool-call.md)). An answer to a question stops waiting once it's acted on: its question resolved after it, a later prototype round on it, or a build started on the card after it ([an answer stops waiting once it's acted on, and a Next set during the card's questions clears](docs/adr/2026-11-03-1313-an-answer-stops-waiting-once-its-acted-on.md)). A batch of the owner's answers in a row in the same state carries it once, under the last answer.
|
|
473
473
|
_Avoid_: comments, chat
|
|
474
474
|
|
|
475
475
|
**Unread message**:
|
package/SKILL.md
CHANGED
|
@@ -24,7 +24,7 @@ kickflip init
|
|
|
24
24
|
|
|
25
25
|
For development, clone the private repo, run `npm install`, `npm link`, then `kickflip init`. `npm run build` makes the minified package in `dist/`, which is what gets published. To release, when the owner asks: run `npm version <x.y.z>` in the clone, then `git push origin main v<x.y.z>`. The tag runs `.github/workflows/release.yml`, which runs the suite, builds, checks the tag against `dist/package.json`'s version, smoke-installs the packed `dist/` and publishes it to npm by trusted publishing ([a v* tag publishes kickflip to npm](docs/adr/2026-11-03-1345-a-v-tag-publishes-kickflip-to-npm.md)).
|
|
26
26
|
|
|
27
|
-
Init also links `~/.claude/statusline.sh` through `kickflip/bin/statusline.mjs`, replacing a dangling link, a link into `~/.claude/kickflip/sites/` (a status-line prototype) or one pointing directly at the running package folder or clone. A real file or another working status-line link is refused. It adds `statusLine` to `~/.claude/settings.json` only when absent; an existing choice stays, with a note if it points elsewhere. Settings writes preserve other keys, and init removes only commands for the old `~/.claude/hooks/no-paths.py` when that script is missing. Rerunning checks the links and skips unchanged settings. The status line prints directory, branch (with `*` for tracked changes), model, context percentage and Claude's usage (`5h 23% · 7d 57%`), then atomically saves Claude's rate-limit reading in `~/.claude/kickflip/usage.json` for the hub. The plugin supplies Codex sharing and pause line reminders on SessionStart and UserPromptSubmit, denies new Agent/Task calls while paused, and supplies builder cleanup on SubagentStop; it adds no settings.json hook entries. Init adds the watcher Stop hook, `hooks-plugin/hooks/watcher-stop.mjs`, to settings.json's `Stop` hooks, which the plugin can't supply, and doctor reports it missing. Doctor lists the hub’s try ports and their board/card or main; on Linux a non-root hub user needs `sudo tailscale set --operator=$USER` if doctor reports an operator mismatch ([a try build serves on its own Tailscale port](docs/adr/2026-11-01-2254-a-try-build-serves-on-its-own-tailscale-port.md)). Setup needs no dotfiles scripts ([kickflip installs its own status line and hooks](docs/adr/2026-11-01-1322-kickflip-installs-its-own-status-line-and-hooks.md)). `uninstall` removes kickflip's status-line link along with the hub's service and Tailscale mapping; skill links, Claude settings and live state stay.
|
|
27
|
+
Init also links `~/.claude/statusline.sh` through `kickflip/bin/statusline.mjs`, replacing a dangling link, a link into `~/.claude/kickflip/sites/` (a status-line prototype) or one pointing directly at the running package folder or clone. A real file or another working status-line link is refused. It adds `statusLine` to `~/.claude/settings.json` only when absent; an existing choice stays, with a note if it points elsewhere. Settings writes preserve other keys, and init removes only commands for the old `~/.claude/hooks/no-paths.py` when that script is missing. Rerunning checks the links and skips unchanged settings. The status line prints directory, branch (with `*` for tracked changes), model, context percentage and Claude's usage (`5h 23% · 7d 57%`), then atomically saves Claude's rate-limit reading in `~/.claude/kickflip/usage.json` for the hub. The plugin supplies Codex sharing and pause line reminders on SessionStart and UserPromptSubmit, denies new Agent/Task calls while paused, shows a board's session the owner's new words after each tool call (PostToolUse), and supplies builder cleanup on SubagentStop; it adds no settings.json hook entries. Init adds the watcher Stop hook, `hooks-plugin/hooks/watcher-stop.mjs`, to settings.json's `Stop` hooks, which the plugin can't supply, and doctor reports it missing. Doctor lists the hub’s try ports and their board/card or main; on Linux a non-root hub user needs `sudo tailscale set --operator=$USER` if doctor reports an operator mismatch ([a try build serves on its own Tailscale port](docs/adr/2026-11-01-2254-a-try-build-serves-on-its-own-tailscale-port.md)). Setup needs no dotfiles scripts ([kickflip installs its own status line and hooks](docs/adr/2026-11-01-1322-kickflip-installs-its-own-status-line-and-hooks.md)). `uninstall` removes kickflip's status-line link along with the hub's service and Tailscale mapping; skill links, Claude settings and live state stay.
|
|
28
28
|
|
|
29
29
|
Kickflip doesn't ship `wave-build`, `slack-bridge` or `codex-share`; init doesn't add them and doctor doesn't check them. The mattpocock skills an architecture review follows, improve-codebase-architecture, codebase-design, grilling and prototype, are a prerequisite kickflip doesn't pin: init adds any that are missing with the skills CLI (`npx skills add mattpocock/skills -g -y -a claude-code --skill …`), and doctor marks a missing or out-of-date one optional with the command that fixes it (`shared/skills.mjs`). The `monitor` skill is optional: the hub polls GitHub itself and, when Hub's Linear key is set, moves the owner's Linear tickets from PR state ([the hub's poll moves the owner's Linear tickets](docs/adr/2026-11-02-0023-the-hubs-poll-moves-the-owners-linear-tickets.md)); without a key it moves none. The session leaves these PR-based moves to the hub. This skill's GitHub tick handles `github-change` and `check-github`. When the owner's monitor skill is installed, a session may run its tick instead, which can add its own steps; kickflip owns the reviewer steps below.
|
|
30
30
|
|
|
@@ -61,7 +61,7 @@ These make a site Claude answers: a board, or a page joined with `--watch`.
|
|
|
61
61
|
- **A board** (Linear-style lanes of PRs and tickets, item threads, questions for Love): add `--template board`, plus `--items <file>` when the items come from the site's own item file rather than every `~/.claude/waves/*/wave.json`. The site then runs the shared `board/` code, whose `server.mjs` header documents `board.json` and the API. Every board shares that code, so make board changes in `board/` and copy none of it into a site. A site's `public/` holds only its own files, such as prototype pages, and any page it overrides. Changes to the hub or the board template come in as asks on the hub board ([hub changes come as asks, not Improvements rounds](docs/adr/2026-10-29-1639-hub-changes-come-as-asks-not-improvement-rounds.md)).
|
|
62
62
|
- **Exit 3 means another session owns it.** Ask the user with AskUserQuestion: take it over (recommended when the old session has ended or the user asked for this), or leave it. On yes, rerun with `--force`. The takeover is total: the old owner's watcher prints `handed-over` and that session stops serving the route.
|
|
63
63
|
4. **Build or resume the site.** Done when `curl -s -o /dev/null -w '%{http_code}' <url>` returns 200 for each page you changed, and a page meant for the phone renders at 390px wide with no sideways scroll. Follow the prefix rule below. The page design is yours; the starter's `public/index.html` is a placeholder.
|
|
64
|
-
5. **Watch.** Run the `watcher` command `join` printed as a background Bash task (`run_in_background`), never with `&` or with its output thrown away: a detached copy takes events no one reads. Check for one already running with `pgrep -f 'wait-events.mjs <route>'`. It exits on the next event that needs you; record-only events (`main-pulled`, `settings`, `reviewers-removed`, `reviewers-added`, `question-reopen`, `repo-changed`, `ping`, and the button taps the board server applies itself: `card-hold`, `card-done`, `card-unbump`, `card-unblocked`, `grill-more`, `prototype-request`, `visual-request`, and `usage-resumed`, since the watcher reads the pause itself and the work a pause held wakes you once it lifts) never wake it, and their lines print ahead of the new events on the next wake. For each `event` line, relaunch the watcher first, then act and reply (below); its plain line says what to do, and every kind that wakes you has one and an entry here (`bin/event-lines.mjs`, kept so by its test; [every signal that wakes the orchestrator says what to do](docs/adr/2026-11-07-0043-every-signal-that-wakes-the-orchestrator-says-what-to-do.md)). Its cursor means no line is missed. The board reads "not listening" while no watcher runs in your running session, or while your session isn't running and the hub can't wake it. A background session that idles out reads asleep, in grey, and the hub resumes it when the next event lands or the owner taps the board's page, the header's refresh included; the header reads waking… until it listens ([a board's session wakes when an event arrives, and live means its session runs](docs/adr/2026-10-30-1601-a-boards-session-wakes-when-an-event-arrives.md), [boards run in the background, sleep and wake from the page, and the hub board runs in tmux](docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md)). On `handed-over` or `site-removed`, stop; don't relaunch. Tell the user. The Stop hook `hooks-plugin/hooks/watcher-stop.mjs` keeps a turn from ending once while a board your session owns has no live watcher, naming the command to relaunch, or while a message there reads Working on it with no answer, naming each ([the CLI and board server do the board's housekeeping](docs/adr/2026-11-02-2044-the-cli-and-board-server-do-the-boards-housekeeping.md), [the board records what it knows, and commands make the rule calls](docs/adr/2026-11-03-0003-the-board-records-what-it-knows-and-commands-make-the-rule-calls.md)). On a board, follow the `quiet` line the watcher prints on every wake: the terminal gets only the one-liners `board.mjs` and the watcher print, except in answer to a prompt typed there. Every wake on a board also prints `waits <kind> <ref> <key>[ bumped] since <iso>[, picked up <iso>]: <next>`, one line per card idle on you: bumped cards first, the earliest bump first, then In Progress then Todo in the board's order. Never filter them out of the watcher's output; filter only its rule, setting, quiet and link lines. Each lane works as your queue: after the events, walk that list top-down and handle every card on it, not only the cards the events named: `calls`, verify the builder's judgment calls; `checking`, land the checked build; `answer`, act on the owner's answer; `reply`, the owner's reply on a reopened card waiting in In Review: answer it in the thread, or start a builder on it, which alone moves it to In Progress ([In Progress means a builder runs](docs/adr/2026-11-09-1804-in-progress-means-a-builder-runs.md)); `ask`, answer or defer the builder's ask; `staging`, judge the staged card against the whole board (the checklist under Staging, below), then `item set <key> --phase ready --text "<what you checked>"` or send it back to grilling or prototyping; `screen`, screen a finished grill's questions before they reach the owner: `question settle <id> --note "<the answer>"` for each one the card's notes and thread, merged code or another card already answer, never the prototype question, then `question publish <key>` for the rest, which the board publishes itself once they wait 15 minutes unscreened; `intake`, sort the new task, answer the owner's message in an ask's thread, or link and file the card an agent added; `triage`, run the overnight triage and `reply` to its event; `grill` or `prototype`, start the planner asked for; `refused`, fix what refused the build and start it, or say on the card why it waits; `unowned`, start a builder on the card or set its Next; `unblocked`, a card past Todo whose block ended (a dep finished or `--blocked-until ""`), so act on what it waited for or set its Next, a thread line ending the wait ([an unblock past Todo waits on the orchestrator](docs/adr/2026-11-07-1500-an-unblock-past-todo-waits-on-the-orchestrator.md)); `recheck`, a ready Todo card whose block ended, which the pull leaves until you re-read its notes and answered questions against what landed, answer a stale one again with `question settle <id> --note` or send the card back with `item set --phase grilling`, then release it with `item set --phase ready --text "<what you re-checked>"` ([a block only keeps a card from In Progress](docs/adr/2026-11-09-1939-a-block-only-keeps-a-card-from-in-progress.md)); `stale` on a run, check the builder's process and log, then finish the run or restart it once from its worktree with one thread line, and if the restarted run goes stale too, `item stall` it with the reason; `stale` on a review, seven days in Review: find out why (CI red, a conflict, changes requested, reviewers silent), fix what you can (rebase, fix CI, answer comments), re-ping silent reviewers once in Slack, write one thread line, naming who when only a reviewer owes it, and ask the owner only when a decision is needed (close it, change reviewers), or `item stall` it. Once you pick up a stale review it wakes you again once a day, and sooner by `github-change` when anything changes on its PR. The waits list is your per-lane review; on the hub's morning wake, `check-github {reason: "morning"}`, also walk every lane top-down once: clear each blocked-until condition that holds and act on any card stuck with no wait. Report only in a thread line on each card you act on, nothing on the cards you pass over ([the orchestrator reviews every lane on a schedule](docs/adr/2026-11-06-1653-the-orchestrator-reviews-every-lane-on-a-schedule.md)). A line with `picked up` is one you started and didn't finish. The server appends `orchestrator-waits {added: [key], again}` when a card joins the list, a hand-back with calls included, and again for each card still waiting 15 minutes after its last wake, or an hour once you picked it up (a day for a stale review), so the event lines alone name every wait; it pushes the owner once about a card left waiting 30 minutes. A builder whose launch a usage pause holds is no wait: the board launches it when the pause lifts ([nothing waiting on the orchestrator is missed](docs/adr/2026-11-05-2358-nothing-waiting-on-the-orchestrator-is-missed.md)). The card reads waiting for orchestrator until your first write on it (a thread line, `builder verified`, a builder start, or the watcher's working mark on its event), then orchestrator review ([each lane is a queue for the orchestrator](docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md)).
|
|
64
|
+
5. **Watch.** Run the `watcher` command `join` printed as a background Bash task (`run_in_background`), never with `&` or with its output thrown away: a detached copy takes events no one reads. Check for one already running with `pgrep -f 'wait-events.mjs <route>'`. It exits on the next event that needs you; record-only events (`main-pulled`, `settings`, `reviewers-removed`, `reviewers-added`, `question-reopen`, `repo-changed`, `ping`, and the button taps the board server applies itself: `card-hold`, `card-done`, `card-unbump`, `card-unblocked`, `grill-more`, `prototype-request`, `visual-request`, and `usage-resumed`, since the watcher reads the pause itself and the work a pause held wakes you once it lifts) never wake it, and their lines print ahead of the new events on the next wake. For each `event` line, relaunch the watcher first, then act and reply (below); its plain line says what to do, and every kind that wakes you has one and an entry here (`bin/event-lines.mjs`, kept so by its test; [every signal that wakes the orchestrator says what to do](docs/adr/2026-11-07-0043-every-signal-that-wakes-the-orchestrator-says-what-to-do.md)). Its cursor means no line is missed. While you work, the plugin's PostToolUse hook `hooks-plugin/hooks/board-messages.mjs` shows the owner's new thread messages, asks and ask edits after your next tool call, each with its sent time and age, and marks them thinking…; read and handle them before carrying on. The next wake prints them again with their plain line prefixed `already shown: `, so don't act on them twice ([the owner's words reach the orchestrator after its next tool call](docs/adr/2026-11-10-1056-the-owners-words-reach-the-orchestrator-after-its-next-tool-call.md)). The board reads "not listening" while no watcher runs in your running session, or while your session isn't running and the hub can't wake it. A background session that idles out reads asleep, in grey, and the hub resumes it when the next event lands or the owner taps the board's page, the header's refresh included; the header reads waking… until it listens ([a board's session wakes when an event arrives, and live means its session runs](docs/adr/2026-10-30-1601-a-boards-session-wakes-when-an-event-arrives.md), [boards run in the background, sleep and wake from the page, and the hub board runs in tmux](docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md)). On `handed-over` or `site-removed`, stop; don't relaunch. Tell the user. The Stop hook `hooks-plugin/hooks/watcher-stop.mjs` keeps a turn from ending once while a board your session owns has no live watcher, naming the command to relaunch, or while a message there reads Working on it with no answer, naming each ([the CLI and board server do the board's housekeeping](docs/adr/2026-11-02-2044-the-cli-and-board-server-do-the-boards-housekeeping.md), [the board records what it knows, and commands make the rule calls](docs/adr/2026-11-03-0003-the-board-records-what-it-knows-and-commands-make-the-rule-calls.md)). On a board, follow the `quiet` line the watcher prints on every wake: the terminal gets only the one-liners `board.mjs` and the watcher print, except in answer to a prompt typed there. Every wake on a board also prints `waits <kind> <ref> <key>[ bumped] since <iso>[, picked up <iso>]: <next>`, one line per card idle on you: bumped cards first, the earliest bump first, then In Progress then Todo in the board's order. Never filter them out of the watcher's output; filter only its rule, setting, quiet and link lines. Each lane works as your queue: after the events, walk that list top-down and handle every card on it, not only the cards the events named: `calls`, verify the builder's judgment calls; `checking`, land the checked build; `answer`, act on the owner's answer; `reply`, the owner's reply on a reopened card waiting in In Review: answer it in the thread, or start a builder on it, which alone moves it to In Progress ([In Progress means a builder runs](docs/adr/2026-11-09-1804-in-progress-means-a-builder-runs.md)); `ask`, answer or defer the builder's ask; `staging`, judge the staged card against the whole board (the checklist under Staging, below), then `item set <key> --phase ready --text "<what you checked>"` or send it back to grilling or prototyping; `screen`, screen a finished grill's questions before they reach the owner: `question settle <id> --note "<the answer>"` for each one the card's notes and thread, merged code or another card already answer, never the prototype question, then `question publish <key>` for the rest, which the board publishes itself once they wait 15 minutes unscreened; `intake`, sort the new task, answer the owner's message in an ask's thread, or link and file the card an agent added; `triage`, run the overnight triage and `reply` to its event; `grill` or `prototype`, start the planner asked for; `refused`, fix what refused the build and start it, or say on the card why it waits; `unowned`, start a builder on the card or set its Next; `unblocked`, a card past Todo whose block ended (a dep finished or `--blocked-until ""`), so act on what it waited for or set its Next, a thread line ending the wait ([an unblock past Todo waits on the orchestrator](docs/adr/2026-11-07-1500-an-unblock-past-todo-waits-on-the-orchestrator.md)); `recheck`, a ready Todo card whose block ended, which the pull leaves until you re-read its notes and answered questions against what landed, answer a stale one again with `question settle <id> --note` or send the card back with `item set --phase grilling`, then release it with `item set --phase ready --text "<what you re-checked>"` ([a block only keeps a card from In Progress](docs/adr/2026-11-09-1939-a-block-only-keeps-a-card-from-in-progress.md)); `stale` on a run, check the builder's process and log, then finish the run or restart it once from its worktree with one thread line, and if the restarted run goes stale too, `item stall` it with the reason; `stale` on a review, seven days in Review: find out why (CI red, a conflict, changes requested, reviewers silent), fix what you can (rebase, fix CI, answer comments), re-ping silent reviewers once in Slack, write one thread line, naming who when only a reviewer owes it, and ask the owner only when a decision is needed (close it, change reviewers), or `item stall` it. Once you pick up a stale review it wakes you again once a day, and sooner by `github-change` when anything changes on its PR. The waits list is your per-lane review; on the hub's morning wake, `check-github {reason: "morning"}`, also walk every lane top-down once: clear each blocked-until condition that holds and act on any card stuck with no wait. Report only in a thread line on each card you act on, nothing on the cards you pass over ([the orchestrator reviews every lane on a schedule](docs/adr/2026-11-06-1653-the-orchestrator-reviews-every-lane-on-a-schedule.md)). A line with `picked up` is one you started and didn't finish. The server appends `orchestrator-waits {added: [key], again}` when a card joins the list, a hand-back with calls included, and again for each card still waiting 15 minutes after its last wake, or an hour once you picked it up (a day for a stale review), so the event lines alone name every wait; it pushes the owner once about a card left waiting 30 minutes. A builder whose launch a usage pause holds is no wait: the board launches it when the pause lifts ([nothing waiting on the orchestrator is missed](docs/adr/2026-11-05-2358-nothing-waiting-on-the-orchestrator-is-missed.md)). The card reads waiting for orchestrator until your first write on it (a thread line, `builder verified`, a builder start, or the watcher's working mark on its event), then orchestrator review ([each lane is a queue for the orchestrator](docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md)).
|
|
65
65
|
|
|
66
66
|
## Reference
|
|
67
67
|
|
package/bin/event-lines.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var h=Object.defineProperty;var t=(e,a)=>h(e,"name",{value:a,configurable:!0});import l from"node:path";import{waitsAgainLine as c}from"../board/domain.mjs";import{cardName as u}from"../board/store.mjs";import{routeRecord as d}from"../hub/routes.mjs";const b=["item-message","ask","ask-edited"],f=t((e,a)=>l.join(e,"data",`.shown-${a}.json`),"shownFile"),p=["main-pulled","settings","reviewers-removed","reviewers-added","question-reopen","repo-changed","ping","hello","card-hold","card-done","card-unbump","card-unblocked","grill-more","prototype-request","visual-request","usage-resumed"],k=t(e=>{try{return JSON.parse(e).kind}catch{return null}},"kindOf"),v=t(e=>p.includes(k(e)),"recordOnly"),o={"owner-answer":t(e=>`answer received to question ${e.data?.id}: act on it on its card`,"owner-answer"),ping:t(e=>`ping ${e.id} from the hub answered`,"ping"),hello:t(e=>`ping ${e.id} from the hub answered`,"hello"),"overnight-triage":t(e=>`Overnight triage ${e.data?.date} asks Claude to reply: ${e.data?.cards?.length} cards wait on open questions; run the triage, then board.mjs reply ${e.id}`,"overnight-triage"),"pr-review":t(e=>`review asked on PR #${e.data?.pr}: the board was idle; read its diff and review post it`,"pr-review"),"orchestrator-waits":t(e=>[e.data?.added?.length&&`waiting on you now: ${e.data.added.join(", ")}`,e.data?.again?.length&&c(e.data.again,Date.parse(e.ts))].filter(Boolean).join(" \xB7 "),"orchestrator-waits"),"check-github":t(e=>e.data?.reason==="morning"?"the daily wake: run one GitHub tick, then walk every lane top-down once: clear each blocked-until condition that holds, and act on any card stuck with no wait; a thread line on each card you act on, nothing else":"GitHub check asked: run one GitHub tick","check-github"),"github-change":t(e=>`${e.data?.prs?.length??0} PRs changed on GitHub: run one GitHub tick`,"github-change"),"review-answer":t(e=>e.data?.answer==="skip"?`skip answered on the review of PR #${e.data?.pr}: record it posted, post nothing`:`post answered on the review of PR #${e.data?.pr}: review post it`,"review-answer"),"plan-waves":t(e=>`Plan waves asked on ${e.data?.keys?.length??0} Todo cards: triage them into waves and ask the owner about them`,"plan-waves"),"arch-review":t(e=>`architecture review asked on ${e.data?.key}: review ${e.data?.repo} on ${e.data?.branch} and file the findings`,"arch-review"),rules:t(e=>`${e.data?.scope==="hub"?"hub":"board"} rules changed: the rule lines below are the new ones; follow them from now`,"rules"),"prototype-overdue":t(e=>`prototype overdue on ${e.data?.key}: dispatch its prototype agent now`,"prototype-overdue")},s={"item-message":t((e,a)=>String(a.data?.key).startsWith("ask:")&&a.data?.intent==="build"?`Implement asked on ${e}: file it with item add --from-ask --phase ready, with its --size and notes`:a.data?.intent==="build"?`Implement asked on ${e}: it is staging, the message in its notes; judge it against the whole board, then item set --phase ready --text "<what you checked>", or --phase grilling or prototyping`:`message received from ${e}'s thread: reply in its thread`,"item-message"),"card-bump":t(e=>`${e} bumped: handle its waits lines first; with none, leave it`,"card-bump"),"ask-edited":t(e=>`${e} edited: use the new text, and say what changed if you had started`,"ask-edited"),"ask-withdrawn":t(e=>`${e} deleted: drop it`,"ask-withdrawn"),"card-done":t(e=>`${e} marked done`,"card-done"),"card-hold":t((e,a)=>`${e} ${a.data?.hold?"held":"resumed"}`,"card-hold"),"card-unblocked":t(e=>`${e} unblocked`,"card-unblocked"),"prototype-request":t(e=>`prototype asked on ${e}`,"prototype-request"),"visual-request":t(e=>`visual asked on ${e}: the board launches its visual agent`,"visual-request"),"reask-request":t(e=>`question asked again on ${e}: ask it again with question add reask_of`,"reask-request"),"builder-resume":t((e,a)=>`${e}'s pause cleared (${a.data?.cleared}): tell its builder in your session to carry on where it stopped`,"builder-resume"),"builder-ask":t((e,a)=>`${a.data?.ask} from ${e}'s builder: ${a.data?.text} (builder answer it, or builder defer it)`,"builder-ask"),"owner-check-broken":t((e,a)=>`${e}'s owner-step check can't run: ${a.data?.error}; fix it with item set --check, or drop it`,"owner-check-broken"),"card-added":t(e=>`${e} added by an agent: link it and file it: item notes, then item set --relate and --phase`,"card-added"),ask:t((e,a)=>`new task ${a.id} received as ${e}: sort that New card: item notes, then item set <its key> --phase grilling or prototyping (ready, with --size, for a Quick build); merge it into a card with item relate-ask ${a.id} <card> --summary; or answer it with reply <its key>`,"ask")};async function x(e,a){let r;try{r=JSON.parse(a)}catch{return null}if(typeof r?.kind!="string")return null;const n=typeof r.data?.key=="string"?r.data.key:r.kind.startsWith("ask-")&&typeof r.data?.id=="string"?`ask:${r.data.id}`:r.kind==="ask"?`ask:${r.id}`:null,i=n&&await u(e,n).catch(()=>n);return d(o,r.kind)?o[r.kind](r):i&&d(s,r.kind)?s[r.kind](i,r):i?`${r.kind} on ${i}`:`${r.kind} received`}t(x,"plainLine");export{s as ON_CARD,b as OWNER_WORDS,o as PLAIN,p as RECORD_ONLY,x as plainLine,v as recordOnly,f as shownFile};
|
package/bin/wait-events.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var
|
|
3
|
-
`).filter(Boolean)}catch{}
|
|
2
|
+
var D=Object.defineProperty;var d=(e,r)=>D(e,"name",{value:r,configurable:!0});import a from"node:fs";import p from"node:path";import W from"node:os";import{routeRecord as h}from"../hub/routes.mjs";import{readHubRules as K,rulesOf as Q,ruleLines as _}from"../hub/rules.mjs";import{boardAt as B,linksOf as M,hubLink as z,stopPreviousWatcher as C}from"../hub/boards.mjs";import{parentOf as A,sessionGone as V}from"../hub/live.mjs";import{ackPing as X,markWorking as Y,settingsLines as Z,settingsOf as ee,WORKING_KINDS as te}from"../board/store.mjs";import{readUsageGate as I,heldUsageEvent as se,pausedLine as ne}from"../shared/usage-gate.mjs";import{recordOnly as j,plainLine as ie,shownFile as oe}from"./event-lines.mjs";const[$,O]=process.argv.slice(2);(!$||!O)&&(console.error("usage: wait-events.mjs <route> <session-id>"),process.exit(2));const re=p.join(W.homedir(),".claude","kickflip","manifest.json");function L(){try{return h(JSON.parse(a.readFileSync(re,"utf8")).sites,$)||null}catch{return}}d(L,"site");function v(e){const r=e.filter?new RegExp(e.filter):null,o=Object.create(null);for(const l of e.watch||["data/events.jsonl"]){let m=[];try{m=a.readFileSync(p.resolve(e.dir,l),"utf8").split(`
|
|
3
|
+
`).filter(Boolean)}catch{}o[l]=r?m.filter(g=>r.test(g)):m}return o}d(v,"matching");const ce=`quiet The owner doesn't read this session's terminal. Answer a prompt typed in it in full. A wake, a task notification or a builder's result is not one: write no text of your own, since board.mjs and this watcher print one line for each thing done. Talk and questions go on the board, never AskUserQuestion; only a failure the board can't show, such as the hub being down, gets one line here. Each message to the owner stands alone in its card's thread: name cards by label (#96), and a pointer such as "above" or "here" must make sense in that thread, never point into this session.`,ae=d(e=>{try{return JSON.parse(e)}catch{return null}},"parsed");function P(e){try{return JSON.parse(a.readFileSync(p.resolve(e.dir,"board.json"),"utf8"))}catch(r){return r.code==="ENOENT"?null:{}}}d(P,"boardOf");async function G(e,r){for(const o of r){let l;try{l=JSON.parse(o)}catch{continue}(l.kind==="ping"||l.kind==="hello")&&typeof l.id=="string"&&await X(e,l.id).catch(m=>console.log(`ping ${l.id} not answered (${m.message})`))}}d(G,"ackPings");function le(e){let r=[];try{r=JSON.parse(a.readFileSync(p.resolve(e,"data","orchestrator-waits.json"),"utf8")).waits||[]}catch{}return r.map(o=>`waits ${o.kind} ${o.ref} ${o.key}${o.bumpedAt?" bumped":""} since ${o.since}${o.picked?`, picked up ${o.picked}`:""}: ${o.next}`)}d(le,"waitLines");const b=L();b||(console.log("site-removed"),process.exit(0));const w=p.resolve(b.dir,"data",`.watch-cursor-${O}.json`),N=p.resolve(b.dir,"data","watcher.json"),F=[process.ppid,A(process.ppid)],R=d(()=>{F[1]??=A(F[0]),V(O,F)&&process.exit(0)},"orphaned");C(b.dir,$,O);try{a.mkdirSync(p.dirname(w),{recursive:!0}),a.writeFileSync(N,JSON.stringify({pid:process.pid,since:new Date().toISOString()}))}catch{}process.on("exit",()=>{try{const e=JSON.parse(a.readFileSync(N,"utf8"));e.pid===process.pid&&a.writeFileSync(N,JSON.stringify({...e,exited_at:new Date().toISOString()}))}catch{}});let c;try{c=JSON.parse(a.readFileSync(w,"utf8"))}catch{}for(c||(c=Object.fromEntries(Object.entries(v(b)).map(([e,r])=>[e,r.length])),a.mkdirSync(p.dirname(w),{recursive:!0}),a.writeFileSync(w,JSON.stringify(c)));;){await new Promise(t=>setTimeout(t,2e3));const e=L();if(e===void 0)continue;e===null&&(console.log("site-removed"),process.exit(0)),e.owner?.session&&e.owner.session!==O&&(console.log("handed-over "+JSON.stringify(e.owner)),process.exit(0)),R();const r=d(t=>Object.fromEntries(Object.entries(t).map(([n,s])=>[n,[...h(c.$quietHeld,n)||[],...h(c.$usageHeld,n)||[],...s.slice(h(c,n)||0)]])),"pending"),o=d((t,n)=>n.state!=="paused"||!se(t),"eligible"),l=d((t,n)=>Object.values(t).some(s=>s.some(i=>o(i,n)&&!j(i))),"wakes"),m=I({dir:e.dir}),g=v(e);if(!l(r(g),m)){const t=Object.fromEntries(Object.entries(g).map(([s,i])=>[s,i.slice(h(c,s)||0).filter(j)])),n=Object.fromEntries(Object.entries(g).map(([s,i])=>[s,i.slice(h(c,s)||0).filter(f=>!j(f))]));if(Object.values(t).some(s=>s.length)){P(e)&&await G(e.dir,Object.values(t).flat());const s=d((i,f)=>Object.fromEntries(Object.keys(g).map(k=>[k,[...h(i,k)||[],...f[k]]])),"append");c={...Object.fromEntries(Object.entries(g).map(([i,f])=>[i,f.length])),$quietHeld:s(c.$quietHeld,t),$usageHeld:s(c.$usageHeld,n)},Object.values(c.$usageHeld).some(i=>i.length)||delete c.$usageHeld,a.writeFileSync(w,JSON.stringify(c))}continue}await new Promise(t=>setTimeout(t,2e3)),R();const E=v(e),y=I({dir:e.dir}),J=r(E);if(!l(J,y))continue;const S=Object.create(null),x=[],U=new Set(Object.values(c.$quietHeld||{}).flat()),H=[];let T=[];try{T=JSON.parse(a.readFileSync(oe(e.dir,O),"utf8")).ids||[]}catch{}for(const[t,n]of Object.entries(J)){S[t]=n.filter(s=>!o(s,y));for(const s of n.filter(i=>o(i,y))){U.has(s)||H.push(s),console.log(`event ${t} ${s}`);const i=await ie(e.dir,s),f=ae(s);i&&console.log(T.includes(f?.id)?`already shown: ${i}`:i),f&&te.includes(f.kind)&&typeof f.id=="string"&&x.push(f.id)}}const u=P(e);if(u&&await Y(e.dir,x).catch(t=>console.log(`working marks not written (${t.message})`)),u&&await G(e.dir,H),u)for(const t of le(e.dir))console.log(t);if(u)for(const t of _(K(),Q(u)))console.log(`rule ${t}`);if(u)for(const t of Z(ee(u)))console.log(t);u&&console.log(ce),y.state==="paused"&&console.log(ne(y));try{const t=u&&B(e.dir);if(t)for(const n of[...M(t,{listen:"listening"}),z()])n.ok||console.log(`link ${n.link} ${n.detail}`)}catch(t){console.log(`link registry could not check the links (${t.message})`)}const q=Object.fromEntries(Object.entries(E).map(([t,n])=>[t,n.length]));Object.values(S).some(t=>t.length)&&(q.$usageHeld=S),a.writeFileSync(w,JSON.stringify(q)),process.exit(0)}
|
package/board/domain.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var Vt=Object.defineProperty;var d=(e,t)=>Vt(e,"name",{value:t,configurable:!0});import{routeRecord as R}from"../hub/routes.mjs";import Xt from"node:fs";import{ticketBadge as pt,ticketIn as Yt}from"./tickets.mjs";import{prLinks as zt,reviewLinks as Jt,reviewTime as Zt,reviewersOf as en}from"./review.mjs";import{remoteFromInput as tn}from"../hub/repos.mjs";import{reviewsOf as nn,reviewNeeds as sn,reviewActions as on}from"./auto-review.mjs";import{cardPRs as rn,hasPR as et,leastReady as an,prKeyOf as ln,prURL as dn}from"../shared/card-prs.mjs";import ft from"node:vm";import{checkWords as cn,parseCheck as un}from"../shared/owner-check.mjs";import{buildScriptOf as pn}from"./build-script.mjs";import{HUB as Qe,hubLineOf as gt}from"./hub-lines.mjs";const fn="card-merged",tt=ft.createContext({});for(const e of["ui-enums.js","ui-order.js"])ft.runInContext(Xt.readFileSync(new URL(`./public/${e}`,import.meta.url),"utf8"),tt);const{BUTTON:ce,ACTION_KIND:q,BUILDER_KIND:ue,PR_STATE:V,PHASES:kt}=tt.BoardUI,{laneOrder:gn}=tt.BoardUI,kn=["item-message","mark-ready","grill-more","prototype-request","visual-request","reask-request"],mt="question-reopen",bo=Object.freeze({todo:"Todo",progress:"In Progress",review:"In Review",done:"Done"}),Ee="move",mn=10*6e4,wn=5*6e4,wt="build-refused",Ne=Object.freeze({ask:"builder-ask",answer:"builder-answer"}),hn=864e5,pe=d(e=>e.lane==="done","closedLane"),yn=d(e=>`file:${e}`,"fileFold"),ht=7*hn,yt=d((e,t)=>({id:e,url:t?.workspace?`https://linear.app/${t.workspace}/issue/${e}`:null}),"linearRef"),bn=d(e=>String(e).replace(/^S(\d+)$/,"INT-$1"),"internalRef"),nt=d((e,t,n)=>Yt(n,t.ticket)?t.ticket:e.internal===!0?bn(t.id):t.id,"refOf"),Sn=d((e,t,n)=>pt(e,t,n)||(t?{id:String(t),url:null,source:null}:null),"ticketOf");function vn(e,t,n,s,r,a,c,l,u){return!a&&(e.lane==="done"||t?.state==="MERGED"||t?.state==="CLOSED")?"done":(n||s)&&!l?"progress":r?"review":t?.state==="OPEN"?t.checks?.state==="fail"&&(!t.isDraft||c)?"progress":"review":t||u?"progress":"todo"}d(vn,"laneOf");const He="Lander",bt=d(e=>e.kind!==ue.install&&e.label!==He,"changesCode");function xn(e,t){return e?.state==="MERGED"||t?V.merged:e?.state==="CLOSED"?V.closed:e?.state==="OPEN"?e.isDraft?V.draft:V.open:null}d(xn,"prStateOf");const St=d(e=>e.pr?.status===V.closed,"unlanded"),On=d(e=>e.some(t=>t.kind==="work")&&e.every(t=>t.lane==="done"),"finishedOf");function st(e){const t=vt(e);return t?.done&&t.handed_back?t.handed_back:null}d(st,"handedBackAt");const vt=d(e=>e.filter(t=>t?.started&&Q(t)&&t.kind!==ue.install).sort((t,n)=>String(t.started).localeCompare(String(n.started))).at(-1),"newestBuild");function En(e,t,n){return Object.fromEntries(e.map(s=>{const r=R(t?.reviews,s.github),a=r==="APPROVED"?"approved":r==="CHANGES_REQUESTED"?"changes":Object.hasOwn(n,s.name)?"sent":"unsent";return[s.name,{state:a,since:a==="sent"?R(n,s.name):null,github:s.github||null}]}))}d(En,"reviewOf");function $n(e,t,n,s,r,a,{held:c,needsApproval:l}){return n?.kind===ue.install?"builder":s?"checking":n?"builder":c&&e==="review"?"merge":r&&e==="review"?"owner":e==="review"&&t?.state==="OPEN"&&t.isDraft?a?"ci":"ready":e==="review"?xt(t,l)?"merge":"reviewers":null}d($n,"waitsOnOf");const xt=d((e,t)=>e?.reviewDecision==="APPROVED"||!t&&!["REVIEW_REQUIRED","CHANGES_REQUESTED"].includes(e?.reviewDecision),"reviewed"),De=/(^|\/)prototype-[\w-]+\.html/,An={reviewers:"Waiting for PR reviewers",merge:"Approved, merges once CI and the merge state allow",ci:"Marked ready for review once CI passes"},Ve={waiting:"Approved, Merge shows once CI and the merge state allow",ready:"Approved and green: tap Merge",merging:"Merging"},ot={auto:"Ready, merges once CI and the merge state allow",waiting:"Ready, Merge shows once CI and the merge state allow",ready:"Ready and green: tap Merge"},Bn=d(e=>`Verified: tap Merge to land it on ${e}`,"heldNext"),_n="Fix the failing CI",Rn="Your reply waits on the orchestrator",Mn="Tap Done",In=["status","agent","ask-link","command",Ee,Ne.answer],qn=d(e=>(e.who==="claude"||e.via==="github")&&!In.includes(e.kind)&&(e.kind!==Qe||gt(e).fault),"countsUnread"),Cn=["lander-handoff","land-waiting"];function Nn({slug:e,itemFile:t,it:n,order:s,prs:r,repo:a,remote:c,base:l,builder:u,reachedPhase:m,agents:f,worktree:p,eta:w,thread:v,events:_,working:x,said:P,askedAt:C,topicItems:L,prSlack:T,boardReviewers:W,now:h,bumpedAt:b,holdAsk:i,closedAt:S,mergeAt:oe,autoMerge:ke,skipDraft:me,tickets:K}){const F=r.map(k=>({repo:k.repo,number:k.number,url:dn(k),...k.gh||k.kept||{},status:xn(k.gh,k.merged_at)})),ee=an(r.map(k=>k.gh)),I=r[ee]?.gh||null,G=F[ee]?.status??null,ze=n.lane||null,U=!!S&&String(S)>String(n.lane_at||"");U&&(n={...n,lane:"done",closed_at:S,reopened_at:void 0});const N=[n.closed_at,...r.flatMap(k=>[k.merged_at,k.gh?.mergedAt])].filter(Boolean).map(String).sort().pop()||null,we=String(N||""),he=F.length>0&&F.every(k=>k.status===V.closed),ye=(n.lane==="done"||n.reopened_at||G===V.merged)&&!he&&([...P.filter(k=>k.kind==="item-message").map(k=>k.ts),...C].map(String).filter(k=>k>we).sort().pop()||n.reopened_at)||void 0,Le=n.answered_at&&!n.phase&&!n.closed_at&&!ye?String(n.answered_at):void 0,j=ye||Le,Re=n.owner_step&&!U?String(n.owner_step):null,Je=(n.lane==="done"||G===V.merged)&&!he,H=!!j&&!!n.next&&String(n.next_at||"")>String(n.reopened_at||N||"");j&&!H&&(n={...n,next:void 0});const X=st(f),Te=j&&P.filter(k=>String(k.ts)>we&&(k.state==="waiting"||k.state==="working")).map(k=>String(k.ts)).sort()[0]||null,be=!!Te,Se=`${e}/${n.id}`,ve=en(t,W),Pe=Object.fromEntries(Object.entries(Jt(t,n,Se,L,zt(T,r))).map(([k,de])=>[k,Zt(de.ts)])),te=Object.values(Pe).filter(Boolean).sort()[0]||null,Ue=[...v.map(k=>k.ts),..._.map(k=>k.ts)].sort().pop()||null,je=[...v.filter(k=>qn({...k,who:k.who||"claude"})).map(k=>k.ts),..._.map(k=>k.reply?.at)].filter(Boolean).sort(),Y=!et(n)&&n.pushed?.branch?{branch:n.pushed.branch,at:n.pushed.at||null,host:c?.host||null}:null,z=!et(n)&&n.merge_ready?.branch?{branch:n.merge_ready.branch,at:n.merge_ready.at||null,base:l}:null,Me=!!n.rebase_run&&I?.state==="OPEN",Ie=(n.rebased||[]).at(-1),re=f.filter(k=>k.started&&!k.done&&Q(k)),Ze=!!X&&bt(vt(f));let A=vn(n,I,re.some(bt),Ze,Y||z,!!j,me,Me,re.length>0||!!X);j&&A==="todo"&&(A="progress");const We=A==="review"&&!!te&&h-Date.parse(te)>ht,ae=!!Re&&Je&&A==="done";ae&&(A="review");let D=$n(A,I,u,!!X&&A!=="done",Y,me,{held:z,needsApproval:ve.length>0});const Fe=D==="builder"&&I?.state==="OPEN"&&!I.isDraft,qe=A==="progress"&&!D&&I?.state==="OPEN"&&(!I.isDraft||me),xe=ae||!!j&&A==="progress"&&!D&&!qe&&!be&&!H;xe&&(D="owner");const o=!!j&&f.some(k=>Q(k)&&String(k.started||"")>String(j)),g=A==="progress"&&!D&&(qe||!!j&&!o);g&&(A="review");const E=i&&String(i.ts)>String(n.held_at||""),$=A==="todo"&&(E?!!i.data.hold:!!n.held),y=$&&(E?i.data.until:n.pick_up_at)||null,M=A!=="done",B=!!b&&!(we&&String(b)<=we),Ke=v.filter(k=>(k.who||"claude")==="claude"&&!["status","command",Ee].includes(k.kind)&&!Cn.includes(k.line)).map(k=>String(k.ts)).sort().pop()||"",le=[oe,z&&n.merge_ready.tapped].filter(Boolean).map(String).sort().pop(),Ce=D==="merge"&&!!le&&le>Ke,Oe=v.filter(k=>(k.kind==="status"||k.kind===Qe)&&String(k.ts)>String(le)).sort((k,de)=>String(k.ts).localeCompare(String(de.ts))).at(-1),ne=Oe?.line==="land-waiting"?`${Ve.merging} \xB7 ${Oe.text[0].toLowerCase()}${Oe.text.slice(1)}`:Ve.merging,J=v.filter(k=>!["status",Ee].includes(k.kind)).sort((k,de)=>String(k.ts).localeCompare(String(de.ts))).at(-1),O=A==="todo"&&J?.line===wt?J.text:null,ie=j||f.filter(Q).map(k=>k.started).filter(Boolean).map(String).sort().pop()||null,Ge=I?.reviewDecision==="APPROVED",Kt=Y&&D==="owner"?`merge ${Y.branch} into ${l}`:z&&D==="merge"?Ce?ne:Bn(l):D==="merge"&&!ke?Ce?ne:(Ge?Ve:ot).waiting:D==="merge"&&!Ge?ot.auto:An[D],Gt=G===V.draft?`Review draft PR #${F[ee].number}`:null,Qt=!Me&&Ie?.to&&Ie.to===I?.headRefOid?`Rebased onto ${Ie.base||l}, pushed${I.mergeable==="MERGEABLE"?" \xB7 PR is clean again":""}`:null,Ht=Me&&D==="checking"?f.flatMap(k=>k.calls).sort((k,de)=>String(k.at).localeCompare(String(de.at))).at(-1)?.text??null:null;return{key:Se,kind:"work",order:s,file:e,fileTitle:t.title||e,id:n.id,ref:nt(t,n,K),title:n.title,storedLane:ze,created:n.created||null,reviewSince:g&&j||n.review_since||te||n.merge_ready?.at||n.pushed?.at||n.created||null,progressSince:ie,closedAt:A==="done"?N:null,internal:t.internal===!0,repo:t.internal===!0&&!t.repo?null:a,lane:A,waitsOn:D,builder:D==="checking"?null:u,reachedPhase:m,agents:f,worktree:p??null,eta:w,working:x,reopened:!!ye,answered:!!Le,awaitsDone:xe,ownerStep:xe?Re:null,ownerCheck:xe&&Re&&n.owner_check?{words:cn(un(n.owner_check.check)),broken:n.owner_check.broken||null}:null,stagedAt:A==="todo"&&n.phase==="staging"&&n.staged_at||null,roundAt:n.round_at||null,deps:n.deps||[],blockedUntil:n.blocked_until||null,blockReason:n.block_reason||null,held:$,heldUntil:y,risky:!!n.risky,bumpable:M,bumpedAt:M&&B?b:null,holdable:A!=="done",hasNotes:!!(n.plan||String(n.notes||"").trim()),finishable:A!=="done",merging:Ce,refusal:O,manualSteps:n.manual_steps||[],fromAsk:n.from_ask||null,unfiled:!!n.unfiled,filedHeld:!!n.filed_held,next:Fe||A==="done"||D==="checking"?null:g&&be?Rn:qe?_n:Qt||Kt||n.next||Gt||null,rebaseFailed:Ht,branch:n.branch||I?.headRefName||Y?.branch||null,pushed:A==="done"||ae?null:Y,mergeReady:A==="review"?z:null,stale:A!=="done"&&(We||!!u?.stale),stalled:n.stalled?.reason||null,landStopped:A==="review"&&z&&n.merge_ready.stopped&&String(n.merge_ready.stopped)>String(n.merge_ready.tapped||"")?{words:At("land"),at:n.merge_ready.stopped}:null,ticket:K?.source==="linear"&&!n.ticket&&n.linear?.length?null:Sn(K,n.ticket,{repo:a}),ticketSource:K?.source||null,linear:(n.linear||[]).map(k=>K?.source==="linear"&&pt(K,k,{repo:a})||yt(k,K)),pr:F[ee]||null,prs:F,reviewers:ve.map(k=>k.name),review:En(ve,I||r[ee]?.kept,Pe),inReviewSince:te,threadCount:v.length+_.length,lastTs:Ue,othersAt:je,grilling:Bt("grill",_,f,C),prototyping:Bt("prototype",_,f,C),storedPhase:n.phase||null,phase:null,recheckAt:n.recheck_at||null,updatedAt:I?.mergedAt||I?.updatedAt||r[ee]?.merged_at||u?.at||Ue||null,ask:null,fold:null,action:null,install:A==="done"?null:Ln(n.install,I),waitFacts:Dn(n,{agents:f,thread:v,events:_,refusedAt:O?J.ts:null,replyAt:g?Te:null})}}d(Nn,"workItem");function Dn(e,{agents:t,thread:n,events:s,refusedAt:r,replyAt:a}){const c=new Set(n.filter(f=>f.kind===Ne.answer).map(f=>f.ask)),l=n.filter(f=>f.kind===Ne.ask&&!c.has(f.ask)).sort((f,p)=>String(f.ts).localeCompare(String(p.ts)))[0],u=d(f=>s.filter(p=>at(p,f)).pop()?.ts||null,"requestAt"),m=n.filter(f=>(f.who||"claude")==="claude"&&!["status","agent","command",Ee].includes(f.kind)&&f.line!==wt).map(f=>f.ts);return{handedBackAt:st(t),next:e.next||null,ask:l?{at:l.ts,text:l.text}:null,grillAt:u("grill"),prototypeAt:u("prototype"),refusedAt:r,replyAt:a,unblockedAt:e.unblocked_at||null,unverified:t.some(f=>f.calls.some(p=>!f.verified||String(p.at)>String(f.verified.at))),writes:[...m,...t.flatMap(f=>[f.verified?.at,f.started])].filter(Boolean).map(String).sort()}}d(Dn,"waitFactsOf");function Ln(e,t){if(!e?.url)return null;const n=e.sha?String(e.sha).toLowerCase():null,s=t?.headRefOid?String(t.headRefOid).toLowerCase():null;return{url:e.url,label:e.label||null,builtAt:e.built_at||null,sha:n,older:!!(n&&s&&!s.startsWith(n))}}d(Ln,"installOf");function Tn(e,{needsApproval:t=!0}={}){if(e?.state!=="OPEN")return[`PR is ${String(e?.state||"unknown").toLowerCase()}`];const{state:n,pending:s,failing:r}=e.checks||{};return[e.isDraft&&"it is a draft",!xt(e,t)&&`review: ${String(e.reviewDecision||"none").toLowerCase().replace(/_/g," ")}`,n==="fail"&&`failing: ${r?.length?r.join(", "):"a check"}`,n==="pending"&&`pending: ${s?se(s,"check"):"checks"}`,e.mergeStateStatus!=="CLEAN"&&`merge state: ${String(e.mergeStateStatus||"unknown").toLowerCase()}`].filter(Boolean)}d(Tn,"mergeBlockers");const Pn=d((e,t)=>!Tn(e,{needsApproval:t}).length,"mergeable");function Un(e,t,n){if(pe(e))return null;const s=`#selectedCard=${encodeURIComponent(e.key)}`;if(e.stalled)return{kind:q.stalled,url:s,label:`stalled: ${e.stalled}`};if(jn(e)){const r=t.get(e.questions.first);return De.test(r?.url||"")?{kind:q.pick,url:`questions.html#${r.id}`,label:"Prototypes ready: pick a take"}:{kind:q.questions,url:`questions.html#${e.questions.first}`,label:`Answer ${se(e.questions.open,"question")}`}}return e.waitsOn==="ready"?{kind:q.ready,url:e.pr.url,label:`Review draft PR #${e.pr.number}`}:e.waitsOn==="merge"&&e.manualSteps.length&&!e.mergeReady?{kind:q.steps,url:s,label:"Run the manual steps, then merge"}:e.waitsOn==="merge"&&(!n||e.mergeReady)&&!e.merging&&e.prs.every(r=>["MERGED","CLOSED"].includes(r.state)||Pn(r,e.reviewers.length>0))?{kind:q.merge,url:s,label:"Merge"}:null}d(Un,"actionOf");const jn=d(e=>!!(e.questions&&e.questions.state!=="resolved"&&e.questions.open>0),"asking"),rt=new Set([q.pick,q.questions]);function Ot(e){return e.questions?ce.openQuestions:null}d(Ot,"actionButtonOf");const Wn=d(e=>e.kind==="work"&&e.lane==="todo"&&!e.action&&!e.unpicked,"buildableOf"),Fn=d((e,t)=>/^item:/.test(e.topic||"")&&De.test(e.url||"")&&!(e.options||[]).some(n=>n.id===t),"picksNoTake");function Kn(e,t){const n=e.filter(a=>a.topic===`item:${t}`),r=[...n.filter(a=>De.test(a.url||"")).flatMap(a=>a.answers.map(c=>({q:a,a:c,again:Fn(a,c.choice)}))),...n.filter(a=>a.request).map(a=>({q:a,a:a.request,again:!0}))].sort((a,c)=>String(a.a.ts).localeCompare(String(c.a.ts))).at(-1);return r?.again?{id:r.q.id,note:r.a.note.replace(/\s+/g," ").trim()}:null}d(Kn,"noTakePicked");const Gn="Prototype again",Qn=Object.freeze({[q.pick]:ce.openQuestions,[q.questions]:ce.openQuestions,[q.ready]:ce.github,[q.steps]:ce.github,[q.merge]:ce.merge,[q.stalled]:ce.reply}),Hn="Merges once unblocked",Vn=d(e=>`Blocked ${[e.blockedBy.length&&`by ${e.blockedBy.map(t=>t.ref).join(", ")}`,e.blockedUntil&&`until ${e.blockedUntil}`].filter(Boolean).join(" and ")}`,"mergeBlockedWords");function Xn(e,t,n){if(e.action=Un(e,t,n),e.mergeBlocked=e.action?.kind===q.merge&&e.blocked?Vn(e):null,e.mergeBlocked&&Object.assign(e,{action:null,next:Hn}),e.action?.kind===q.questions&&e.questions.fromAsks){Object.assign(e,{action:null,waitsOn:"owner",next:e.action.label,actionButton:Ot(e),resolvedBy:null});return}e.awaitsDone&&=!e.action,e.awaitsDone||Object.assign(e,{ownerStep:null,ownerCheck:null}),e.actionButton=Ot(e),e.resolvedBy=Qn[e.action?.kind]||null,e.action?.kind===q.merge&&!e.mergeReady&&(e.next=(e.pr?.reviewDecision==="APPROVED"?Ve:ot).ready),e.awaitsDone&&Object.assign(e,{lane:"review",next:e.ownerStep||Mn,bumpable:!1,bumpedAt:null});const s=e.lane==="todo"||e.waitsOn==="ready"||e.lane==="progress"&&e.waitsOn==="owner";if(!e.action||!s||e.stalled)return;const r=e.lane==="todo"&&rt.has(e.action.kind)?"todo":"review";Object.assign(e,{lane:r,waitsOn:e.waitsOn==="ready"?"ready":"owner",next:e.action.label})}d(Xn,"placeOwnerWait");const Et=d((e,t)=>e?e!=="todo":t.some(Q),"started"),$t="none set yet";function So(e,t){const n=t.filter(s=>s?.started);return e.next||et(e)||e.pushed||e.held||e.lane==="done"||st(n)?!1:!n.some(s=>!s.done)&&Et(e.lane,n)}d(So,"owesNext");const fe="visual",$e="helper",Yn=new Set(["grill","prototype",fe]),Z=d(e=>Yn.has(e.kind),"plans"),Q=d(e=>!Z(e),"builds"),zn={grill:"grilling",prototype:"prototyping"},Jn=d((e,t)=>`${e} ${t} by orchestrator`,"orchestratorDid"),At=d(e=>Jn(e,"stopped"),"stoppedWords"),Zn={grill:"grill-more",prototype:"prototype-request"},at=d((e,t)=>e.kind===Zn[t]&&!e.data?.question,"isRequest");function Bt(e,t,n,s){if(n.some(a=>a.kind===e&&a.started&&!a.done))return!0;const r=t.filter(a=>at(a,e)).pop();return!!(r&&!r.reply&&!s.some(a=>String(a)>String(r.ts))&&!n.some(a=>a.kind===e&&a.done&&String(a.started)>=String(r.ts)))}d(Bt,"planningOf");const vo=30*6e4;function xo(e,t){const n=[];for(const s of e.items){if(pe(s)||(s.agents||[]).some(a=>a.kind==="prototype"&&a.started&&!a.done))continue;const r=s.prototyping&&t.filter(a=>a.data?.key===s.key&&at(a,"prototype")).pop();r&&n.push({key:s.key,request:r.id,since:r.ts});for(const a of e.owner.filter(c=>c.topic===`item:${s.key}`&&c.state==="requested"))n.push({key:s.key,request:a.request.id,question:a.id,since:a.request.ts})}return n}d(xo,"prototypeWaits");function lt(e={},t=!1){return Object.entries(e).filter(([n,s])=>s&&typeof s=="object"&&(t||!n.startsWith("past-"))).map(([n,s])=>({name:n,kind:_t(s.kind)||null,model:s.model||(s.kind==="opus"||s.kind==="sonnet"?s.kind:null),label:s.label||null,started:s.started||null,eta:s.done?null:s.eta||null,done:s.done||null,log:s.session?.log||null,pid:s.session?.pid??null,calls:Array.isArray(s.calls)?s.calls:[],verified:s.verified||null,stopped:s.stopped||null,handed_back:s.handed_back||null,...s.pause?{pause:s.pause}:{},...s.kind===$e?{writes:!!s.writes,requested:s.requested||null}:{},...s.waits?{waits:s.waits}:{}})).sort((n,s)=>String(n.started||"").localeCompare(String(s.started||""))||n.name.localeCompare(s.name))}d(lt,"agentsOf");function it(e={}){return Object.values(e).filter(t=>t?.worktree?.path).sort((t,n)=>String(t.started||"").localeCompare(String(n.started||""))).at(-1)?.worktree.path??null}d(it,"worktreeOf");function es(e,t=[]){const n=e.filter(Q).map(s=>String(s.started||"")).filter(Boolean).sort().pop();return n===void 0?null:t.filter(s=>s.kind==="status"&&s.phase&&!s.planner&&String(s.ts)>=n).sort((s,r)=>String(s.ts).localeCompare(String(r.ts))).pop()?.phase||null}d(es,"reachedPhaseOf");function ts(e,t=[],n,s=null,r=null,a=!1){const c=e.filter(b=>b.started&&!b.done&&b.kind!==fe),l=c.every(Z),u=l?c:c.filter(Q),m=u.map(b=>String(b.started||"")).sort()[0];if(m===void 0)return null;const f=t.filter(b=>b.kind==="status"&&String(b.ts)>=m&&(l||!b.planner)).sort((b,i)=>String(b.ts).localeCompare(String(i.ts))).pop(),p=u.filter(b=>b.kind!==$e).at(-1),w=u.some(b=>b.kind===ue.install)?ue.install:p?_t(p.kind):"claude",v=l?null:u.find(b=>b.stopped&&String(b.stopped.at)>=String(b.started))?.stopped,_=f?.ts||m,x=v?v.at:s!==null&&s>Date.parse(_)?new Date(s).toISOString():_,P=f?f.phase||f.planner?f.text.replace(": "," \xB7 "):f.text||null:l?zn[w]:null,C=n-Date.parse(x),L=l?null:u.find(b=>b.pause)?.pause??null,T=!l&&!v&&(r==="held"||!!L?.paused),W=l||T?null:v?!1:r,h=T?!1:W===null?C>=mn:!W;return{progress:P,phase:f?.phase||null,at:_,kind:w,planner:l,silentSince:x,stale:h,alive:W,held:T,quiet:!!L||W===!0&&!a&&C>=wn,stopped:v?At(v.run):null,...L?{pause:{cause:L.cause,words:L.words,paused:!!L.paused}}:{}}}d(ts,"builderOf");const Oo={blocked:["nothing blocks it","nothing blocks it"],held:["you unhold it","unheld"],usage:["the usage pause lifts","the usage pause lifted"],bumped:["a slot frees","a slot freed"]},ns=15*6e4,ss=d(e=>(e.agents||[]).find(t=>t.name==="main"&&t.started&&!t.done&&Q(t))??null,"mainBuild"),os=d((e,t,n,{now:s,usageHeld:r})=>e.blocked||e.held||r(n??"claude")||!!(t.until&&s<Date.parse(t.until)),"stillPaused");function Eo(e,{now:t=Date.now(),usageHeld:n}){const s={ask:[],drop:[],stop:[],resume:[]},r=[];for(const a of e){if(a.kind!=="work"||a.lane!=="progress")continue;const c=ss(a);if(!c)continue;const{pause:l}=c;if(!l){a.builder?.phase!=="landing"&&n(c.kind??"claude")&&s.ask.push(a.key);continue}const u=os(a,l,c.kind,{now:t,usageHeld:n});if(l.paused){u||r.push({key:a.key,at:l.paused});continue}!u&&l.cause!=="bumped"?s.drop.push(a.key):t-Date.parse(l.at)>=ns&&s.stop.push(a.key)}return s.resume=r.sort((a,c)=>String(a.at).localeCompare(String(c.at))).map(a=>a.key),s}d(Eo,"pauseWork");const dt={opus:"Claude Opus",sonnet:"Claude Sonnet",codex:"Codex"},rs=d(({model:e,kind:t})=>dt[e]||dt[t]||(t==="claude"?dt.opus:null),"modelName"),_t=d(e=>e==="opus"||e==="sonnet"?"claude":e,"providerOf");function Ae(e){const t=rs(e),{kind:n}=e,s=e.label?.replace(/\bbuilder\b/,"implementer").replace(/\bBuilder\b/,"Implementer"),r=d(c=>c[0].toLowerCase()+c.slice(1),"lower");if(t&&Z(e))return`${t} ${n}`;if(t)return`${t} ${r(s?.replace(/^(?:claude\s+)?(?:opus|sonnet|codex)\b\s*/i,"")||"implementer")}`;const a=n?n[0].toUpperCase()+n.slice(1):"";return a?s?s.toLowerCase().includes(n.toLowerCase())?s:`${a} ${r(s)}`:a:s||"Implementer"}d(Ae,"agentName");const Rt=d((e,t)=>!!e.resolved&&(!e.resolved_at||String(e.resolved_at)>=String(t)),"resolvedSince");function as(e,t,n=[],s=[],r=[]){const a=t.filter(x=>x.data?.id===e.id),c=a[a.length-1]||null,l=r.filter(x=>x.data?.question===e.id).at(-1),u=d((x,...P)=>!!x&&P.every(C=>String(x.ts)>String(C||"")),"after"),m=!!(e.resolved&&e.resolved_at&&c&&String(c.ts)>e.resolved_at),f=n.filter(x=>x.data?.question===e.id&&u(x,c?.ts,l?.ts)).at(-1),p=u(l,c?.ts,f?.ts,e.resolved_at),w=e.resolved&&!m&&!p,v=w?s.filter(x=>x.data?.question===e.id&&String(x.ts)>String(e.resolved_at||"")).at(-1):null,_=p?"open":w?"resolved":f?"requested":c?c.reply?"replied":"answered":"open";return{...e,state:_,reopened:p,request:f?{id:f.id,ts:f.ts,note:f.data.note||"",choice:f.data.choice||null}:null,reask:v?{id:v.id,ts:v.ts}:null,answers:a.map(x=>({ts:x.ts,choice:x.data.choice||null,note:x.data.note||"",files:x.data.files||[],reply:x.reply,replies:x.replies,acted:Rt(e,x.ts)}))}}d(as,"ownerQuestion");const ge=d(e=>e.round??(De.test(e.url||"")?1:0),"roundOf");function ls(e,t){const n=d((s,r)=>s.confirm_of||(s.options||[]).some(a=>a.id===r.choice),"settles");return e.filter(s=>ge(s)===t&&s.answers.length).map(s=>[s,s.answers.at(-1)]).filter(([s,r])=>n(s,r)).map(([,s])=>s).sort((s,r)=>String(s.ts).localeCompare(String(r.ts))).at(-1)}d(ls,"roundSettledBy");function is(e){const t=e.find(r=>r.request&&!e.some(a=>ge(a)>ge(r)));if(t)return{n:ge(t)+1,state:"making"};const n=Math.max(0,...e.map(ge));if(!n)return null;const s=ls(e,n);return s?{n,state:"closed",take:s.choice}:e.some(r=>ge(r)===n&&r.state!=="resolved")?{n,state:"back"}:null}d(is,"roundState");function ds(e){const t=e.filter(n=>De.test(n.url||"")).map(n=>({n:ge(n),q:n.id,url:n.url,confirm:!!n.confirm_of,state:n.state})).sort((n,s)=>n.n-s.n);return t.length?{open:t.some(n=>n.state==="open"),rounds:t}:null}d(ds,"prototypesOf");const Be=d((e,t,n)=>{const s=e.get(t);s?s.push(n):e.set(t,[n])},"add"),_e=d((e,t)=>{const n=new Map;for(const s of e)Be(n,t(s),s);return n},"groupBy");function cs(e,t,n=new Set){const s=new Set(e.map(c=>c.id)),r=[...e];t.some(c=>!s.has(c.topic))&&r.push({id:"",title:"Other"});const a=_e(t,c=>s.has(c.topic)?c.topic:"");return r.map(c=>{const l=a.get(c.id)||[],u=d(p=>l.filter(w=>w.state===p).length,"count"),m=l.every(p=>p.state==="resolved")||n.has(c.id)&&!u("open"),f=l.length&&m?"resolved":"open";return{...c,state:f,questions:l.map(p=>p.id),open:u("open"),answered:u("answered"),replied:u("replied"),resolved:u("resolved"),round:is(l)}}).filter(c=>c.questions.length)}d(cs,"ownerTopics");const Xe={implement:"Implementation requested",prototype:"Prototype requested",grill:"Grill requested",visual:"Visual requested",reask:"Re-ask requested",hold:"Held",resume:"Resumed",done:"Marked done",staged:"Marked as staging",readied:"Marked as ready",related:"Related to",unrelated:"Unrelated from",followup:"Follow-up","followup-of":"Follow-up of"},Mt={"grill-more":"grill","prototype-request":"prototype","visual-request":"visual","reask-request":"reask"},It={"mark-ready":"Mark ready",...Object.fromEntries(Object.entries(Mt).map(([e,t])=>[e,Xe[t]]))},us=["grill-more","prototype-request","visual-request"],ps="answers submitted",qt={reply:"Reply",build:"Implement"},fs=d(e=>qt[e.data.intent],"intentLabel"),gs=d((e,t)=>{if(e.kind!=="prototype-request"||!t.has(e.data.question))return e.data.text||(e.data.files?.length?"":R(It,e.kind))||"";const n=t.get(e.data.question),s=(n.options||[]).find(r=>r.id===e.data.choice);return[Nt(t,n),s&&`${s.id}. ${s.label}`,e.data.note].filter(Boolean).join(`
|
|
1
|
+
var Vt=Object.defineProperty;var d=(e,t)=>Vt(e,"name",{value:t,configurable:!0});import{routeRecord as R}from"../hub/routes.mjs";import Xt from"node:fs";import{ticketBadge as pt,ticketIn as Yt}from"./tickets.mjs";import{prLinks as zt,reviewLinks as Jt,reviewTime as Zt,reviewersOf as en}from"./review.mjs";import{remoteFromInput as tn}from"../hub/repos.mjs";import{reviewsOf as nn,reviewNeeds as sn,reviewActions as on}from"./auto-review.mjs";import{cardPRs as rn,hasPR as et,leastReady as an,prKeyOf as ln,prURL as dn}from"../shared/card-prs.mjs";import ft from"node:vm";import{checkWords as cn,parseCheck as un}from"../shared/owner-check.mjs";import{buildScriptOf as pn}from"./build-script.mjs";import{HUB as Qe,hubLineOf as gt}from"./hub-lines.mjs";const fn="card-merged",tt=ft.createContext({});for(const e of["ui-enums.js","ui-order.js"])ft.runInContext(Xt.readFileSync(new URL(`./public/${e}`,import.meta.url),"utf8"),tt);const{BUTTON:ce,ACTION_KIND:q,BUILDER_KIND:ue,PR_STATE:V,PHASES:kt}=tt.BoardUI,{laneOrder:gn}=tt.BoardUI,kn=["item-message","mark-ready","grill-more","prototype-request","visual-request","reask-request"],mt="question-reopen",bo=Object.freeze({todo:"Todo",progress:"In Progress",review:"In Review",done:"Done"}),Ee="move",mn=10*6e4,wn=5*6e4,wt="build-refused",Ne=Object.freeze({ask:"builder-ask",answer:"builder-answer"}),hn=864e5,pe=d(e=>e.lane==="done","closedLane"),yn=d(e=>`file:${e}`,"fileFold"),ht=7*hn,yt=d((e,t)=>({id:e,url:t?.workspace?`https://linear.app/${t.workspace}/issue/${e}`:null}),"linearRef"),bn=d(e=>String(e).replace(/^S(\d+)$/,"INT-$1"),"internalRef"),nt=d((e,t,n)=>Yt(n,t.ticket)?t.ticket:e.internal===!0?bn(t.id):t.id,"refOf"),Sn=d((e,t,n)=>pt(e,t,n)||(t?{id:String(t),url:null,source:null}:null),"ticketOf");function vn(e,t,n,s,r,a,c,l,u){return!a&&(e.lane==="done"||t?.state==="MERGED"||t?.state==="CLOSED")?"done":(n||s)&&!l?"progress":r?"review":t?.state==="OPEN"?t.checks?.state==="fail"&&(!t.isDraft||c)?"progress":"review":t||u?"progress":"todo"}d(vn,"laneOf");const He="Lander",bt=d(e=>e.kind!==ue.install&&e.label!==He,"changesCode");function xn(e,t){return e?.state==="MERGED"||t?V.merged:e?.state==="CLOSED"?V.closed:e?.state==="OPEN"?e.isDraft?V.draft:V.open:null}d(xn,"prStateOf");const St=d(e=>e.pr?.status===V.closed,"unlanded"),On=d(e=>e.some(t=>t.kind==="work")&&e.every(t=>t.lane==="done"),"finishedOf");function st(e){const t=vt(e);return t?.done&&t.handed_back?t.handed_back:null}d(st,"handedBackAt");const vt=d(e=>e.filter(t=>t?.started&&Q(t)&&t.kind!==ue.install).sort((t,n)=>String(t.started).localeCompare(String(n.started))).at(-1),"newestBuild");function En(e,t,n){return Object.fromEntries(e.map(s=>{const r=R(t?.reviews,s.github),a=r==="APPROVED"?"approved":r==="CHANGES_REQUESTED"?"changes":Object.hasOwn(n,s.name)?"sent":"unsent";return[s.name,{state:a,since:a==="sent"?R(n,s.name):null,github:s.github||null}]}))}d(En,"reviewOf");function $n(e,t,n,s,r,a,{held:c,needsApproval:l}){return n?.kind===ue.install?"builder":s?"checking":n?"builder":c&&e==="review"?"merge":r&&e==="review"?"owner":e==="review"&&t?.state==="OPEN"&&t.isDraft?a?"ci":"ready":e==="review"?xt(t,l)?"merge":"reviewers":null}d($n,"waitsOnOf");const xt=d((e,t)=>e?.reviewDecision==="APPROVED"||!t&&!["REVIEW_REQUIRED","CHANGES_REQUESTED"].includes(e?.reviewDecision),"reviewed"),De=/(^|\/)prototype-[\w-]+\.html/,An={reviewers:"Waiting for PR reviewers",merge:"Approved, merges once CI and the merge state allow",ci:"Marked ready for review once CI passes"},Ve={waiting:"Approved, Merge shows once CI and the merge state allow",ready:"Approved and green: tap Merge",merging:"Merging"},ot={auto:"Ready, merges once CI and the merge state allow",waiting:"Ready, Merge shows once CI and the merge state allow",ready:"Ready and green: tap Merge"},Bn=d(e=>`Verified: tap Merge to land it on ${e}`,"heldNext"),_n="Fix the failing CI",Rn="Your reply waits on the orchestrator",Mn="Tap Done",In=["status","agent","ask-link","command",Ee,Ne.answer],qn=d(e=>(e.who==="claude"||e.via==="github")&&!In.includes(e.kind)&&(e.kind!==Qe||gt(e).fault),"countsUnread"),Cn=["lander-handoff","land-waiting"];function Nn({slug:e,itemFile:t,it:n,order:s,prs:r,repo:a,remote:c,base:l,builder:u,reachedPhase:m,agents:f,worktree:p,eta:w,thread:v,events:_,working:x,said:P,askedAt:C,topicItems:L,prSlack:T,boardReviewers:W,now:h,bumpedAt:b,holdAsk:i,closedAt:S,mergeAt:oe,autoMerge:ke,skipDraft:me,tickets:K}){const F=r.map(k=>({repo:k.repo,number:k.number,...k.gh||k.kept||{},url:(k.gh||k.kept)?.url||dn(k),status:xn(k.gh,k.merged_at)})),ee=an(r.map(k=>k.gh)),I=r[ee]?.gh||null,G=F[ee]?.status??null,ze=n.lane||null,U=!!S&&String(S)>String(n.lane_at||"");U&&(n={...n,lane:"done",closed_at:S,reopened_at:void 0});const N=[n.closed_at,...r.flatMap(k=>[k.merged_at,k.gh?.mergedAt])].filter(Boolean).map(String).sort().pop()||null,we=String(N||""),he=F.length>0&&F.every(k=>k.status===V.closed),ye=(n.lane==="done"||n.reopened_at||G===V.merged)&&!he&&([...P.filter(k=>k.kind==="item-message").map(k=>k.ts),...C].map(String).filter(k=>k>we).sort().pop()||n.reopened_at)||void 0,Le=n.answered_at&&!n.phase&&!n.closed_at&&!ye?String(n.answered_at):void 0,j=ye||Le,Re=n.owner_step&&!U?String(n.owner_step):null,Je=(n.lane==="done"||G===V.merged)&&!he,H=!!j&&!!n.next&&String(n.next_at||"")>String(n.reopened_at||N||"");j&&!H&&(n={...n,next:void 0});const X=st(f),Te=j&&P.filter(k=>String(k.ts)>we&&(k.state==="waiting"||k.state==="working")).map(k=>String(k.ts)).sort()[0]||null,be=!!Te,Se=`${e}/${n.id}`,ve=en(t,W),Pe=Object.fromEntries(Object.entries(Jt(t,n,Se,L,zt(T,r))).map(([k,de])=>[k,Zt(de.ts)])),te=Object.values(Pe).filter(Boolean).sort()[0]||null,Ue=[...v.map(k=>k.ts),..._.map(k=>k.ts)].sort().pop()||null,je=[...v.filter(k=>qn({...k,who:k.who||"claude"})).map(k=>k.ts),..._.map(k=>k.reply?.at)].filter(Boolean).sort(),Y=!et(n)&&n.pushed?.branch?{branch:n.pushed.branch,at:n.pushed.at||null,host:c?.host||null}:null,z=!et(n)&&n.merge_ready?.branch?{branch:n.merge_ready.branch,at:n.merge_ready.at||null,base:l}:null,Me=!!n.rebase_run&&I?.state==="OPEN",Ie=(n.rebased||[]).at(-1),re=f.filter(k=>k.started&&!k.done&&Q(k)),Ze=!!X&&bt(vt(f));let A=vn(n,I,re.some(bt),Ze,Y||z,!!j,me,Me,re.length>0||!!X);j&&A==="todo"&&(A="progress");const We=A==="review"&&!!te&&h-Date.parse(te)>ht,ae=!!Re&&Je&&A==="done";ae&&(A="review");let D=$n(A,I,u,!!X&&A!=="done",Y,me,{held:z,needsApproval:ve.length>0});const Fe=D==="builder"&&I?.state==="OPEN"&&!I.isDraft,qe=A==="progress"&&!D&&I?.state==="OPEN"&&(!I.isDraft||me),xe=ae||!!j&&A==="progress"&&!D&&!qe&&!be&&!H;xe&&(D="owner");const o=!!j&&f.some(k=>Q(k)&&String(k.started||"")>String(j)),g=A==="progress"&&!D&&(qe||!!j&&!o);g&&(A="review");const E=i&&String(i.ts)>String(n.held_at||""),$=A==="todo"&&(E?!!i.data.hold:!!n.held),y=$&&(E?i.data.until:n.pick_up_at)||null,M=A!=="done",B=!!b&&!(we&&String(b)<=we),Ke=v.filter(k=>(k.who||"claude")==="claude"&&!["status","command",Ee].includes(k.kind)&&!Cn.includes(k.line)).map(k=>String(k.ts)).sort().pop()||"",le=[oe,z&&n.merge_ready.tapped].filter(Boolean).map(String).sort().pop(),Ce=D==="merge"&&!!le&&le>Ke,Oe=v.filter(k=>(k.kind==="status"||k.kind===Qe)&&String(k.ts)>String(le)).sort((k,de)=>String(k.ts).localeCompare(String(de.ts))).at(-1),ne=Oe?.line==="land-waiting"?`${Ve.merging} \xB7 ${Oe.text[0].toLowerCase()}${Oe.text.slice(1)}`:Ve.merging,J=v.filter(k=>!["status",Ee].includes(k.kind)).sort((k,de)=>String(k.ts).localeCompare(String(de.ts))).at(-1),O=A==="todo"&&J?.line===wt?J.text:null,ie=j||f.filter(Q).map(k=>k.started).filter(Boolean).map(String).sort().pop()||null,Ge=I?.reviewDecision==="APPROVED",Kt=Y&&D==="owner"?`merge ${Y.branch} into ${l}`:z&&D==="merge"?Ce?ne:Bn(l):D==="merge"&&!ke?Ce?ne:(Ge?Ve:ot).waiting:D==="merge"&&!Ge?ot.auto:An[D],Gt=G===V.draft?`Review draft PR #${F[ee].number}`:null,Qt=!Me&&Ie?.to&&Ie.to===I?.headRefOid?`Rebased onto ${Ie.base||l}, pushed${I.mergeable==="MERGEABLE"?" \xB7 PR is clean again":""}`:null,Ht=Me&&D==="checking"?f.flatMap(k=>k.calls).sort((k,de)=>String(k.at).localeCompare(String(de.at))).at(-1)?.text??null:null;return{key:Se,kind:"work",order:s,file:e,fileTitle:t.title||e,id:n.id,ref:nt(t,n,K),title:n.title,storedLane:ze,created:n.created||null,reviewSince:g&&j||n.review_since||te||n.merge_ready?.at||n.pushed?.at||n.created||null,progressSince:ie,closedAt:A==="done"?N:null,internal:t.internal===!0,repo:t.internal===!0&&!t.repo?null:a,lane:A,waitsOn:D,builder:D==="checking"?null:u,reachedPhase:m,agents:f,worktree:p??null,eta:w,working:x,reopened:!!ye,answered:!!Le,awaitsDone:xe,ownerStep:xe?Re:null,ownerCheck:xe&&Re&&n.owner_check?{words:cn(un(n.owner_check.check)),broken:n.owner_check.broken||null}:null,stagedAt:A==="todo"&&n.phase==="staging"&&n.staged_at||null,roundAt:n.round_at||null,deps:n.deps||[],blockedUntil:n.blocked_until||null,blockReason:n.block_reason||null,held:$,heldUntil:y,risky:!!n.risky,bumpable:M,bumpedAt:M&&B?b:null,holdable:A!=="done",hasNotes:!!(n.plan||String(n.notes||"").trim()),finishable:A!=="done",merging:Ce,refusal:O,manualSteps:n.manual_steps||[],fromAsk:n.from_ask||null,unfiled:!!n.unfiled,filedHeld:!!n.filed_held,next:Fe||A==="done"||D==="checking"?null:g&&be?Rn:qe?_n:Qt||Kt||n.next||Gt||null,rebaseFailed:Ht,branch:n.branch||I?.headRefName||Y?.branch||null,pushed:A==="done"||ae?null:Y,mergeReady:A==="review"?z:null,stale:A!=="done"&&(We||!!u?.stale),stalled:n.stalled?.reason||null,landStopped:A==="review"&&z&&n.merge_ready.stopped&&String(n.merge_ready.stopped)>String(n.merge_ready.tapped||"")?{words:At("land"),at:n.merge_ready.stopped}:null,ticket:K?.source==="linear"&&!n.ticket&&n.linear?.length?null:Sn(K,n.ticket,{repo:a}),ticketSource:K?.source||null,linear:(n.linear||[]).map(k=>K?.source==="linear"&&pt(K,k,{repo:a})||yt(k,K)),pr:F[ee]||null,prs:F,reviewers:ve.map(k=>k.name),review:En(ve,I||r[ee]?.kept,Pe),inReviewSince:te,threadCount:v.length+_.length,lastTs:Ue,othersAt:je,grilling:Bt("grill",_,f,C),prototyping:Bt("prototype",_,f,C),storedPhase:n.phase||null,phase:null,recheckAt:n.recheck_at||null,updatedAt:I?.mergedAt||I?.updatedAt||r[ee]?.merged_at||u?.at||Ue||null,ask:null,fold:null,action:null,install:A==="done"?null:Ln(n.install,I),waitFacts:Dn(n,{agents:f,thread:v,events:_,refusedAt:O?J.ts:null,replyAt:g?Te:null})}}d(Nn,"workItem");function Dn(e,{agents:t,thread:n,events:s,refusedAt:r,replyAt:a}){const c=new Set(n.filter(f=>f.kind===Ne.answer).map(f=>f.ask)),l=n.filter(f=>f.kind===Ne.ask&&!c.has(f.ask)).sort((f,p)=>String(f.ts).localeCompare(String(p.ts)))[0],u=d(f=>s.filter(p=>at(p,f)).pop()?.ts||null,"requestAt"),m=n.filter(f=>(f.who||"claude")==="claude"&&!["status","agent","command",Ee].includes(f.kind)&&f.line!==wt).map(f=>f.ts);return{handedBackAt:st(t),next:e.next||null,ask:l?{at:l.ts,text:l.text}:null,grillAt:u("grill"),prototypeAt:u("prototype"),refusedAt:r,replyAt:a,unblockedAt:e.unblocked_at||null,unverified:t.some(f=>f.calls.some(p=>!f.verified||String(p.at)>String(f.verified.at))),writes:[...m,...t.flatMap(f=>[f.verified?.at,f.started])].filter(Boolean).map(String).sort()}}d(Dn,"waitFactsOf");function Ln(e,t){if(!e?.url)return null;const n=e.sha?String(e.sha).toLowerCase():null,s=t?.headRefOid?String(t.headRefOid).toLowerCase():null;return{url:e.url,label:e.label||null,builtAt:e.built_at||null,sha:n,older:!!(n&&s&&!s.startsWith(n))}}d(Ln,"installOf");function Tn(e,{needsApproval:t=!0}={}){if(e?.state!=="OPEN")return[`PR is ${String(e?.state||"unknown").toLowerCase()}`];const{state:n,pending:s,failing:r}=e.checks||{};return[e.isDraft&&"it is a draft",!xt(e,t)&&`review: ${String(e.reviewDecision||"none").toLowerCase().replace(/_/g," ")}`,n==="fail"&&`failing: ${r?.length?r.join(", "):"a check"}`,n==="pending"&&`pending: ${s?se(s,"check"):"checks"}`,e.mergeStateStatus!=="CLEAN"&&`merge state: ${String(e.mergeStateStatus||"unknown").toLowerCase()}`].filter(Boolean)}d(Tn,"mergeBlockers");const Pn=d((e,t)=>!Tn(e,{needsApproval:t}).length,"mergeable");function Un(e,t,n){if(pe(e))return null;const s=`#selectedCard=${encodeURIComponent(e.key)}`;if(e.stalled)return{kind:q.stalled,url:s,label:`stalled: ${e.stalled}`};if(jn(e)){const r=t.get(e.questions.first);return De.test(r?.url||"")?{kind:q.pick,url:`questions.html#${r.id}`,label:"Prototypes ready: pick a take"}:{kind:q.questions,url:`questions.html#${e.questions.first}`,label:`Answer ${se(e.questions.open,"question")}`}}return e.waitsOn==="ready"?{kind:q.ready,url:e.pr.url,label:`Review draft PR #${e.pr.number}`}:e.waitsOn==="merge"&&e.manualSteps.length&&!e.mergeReady?{kind:q.steps,url:s,label:"Run the manual steps, then merge"}:e.waitsOn==="merge"&&(!n||e.mergeReady)&&!e.merging&&e.prs.every(r=>["MERGED","CLOSED"].includes(r.state)||Pn(r,e.reviewers.length>0))?{kind:q.merge,url:s,label:"Merge"}:null}d(Un,"actionOf");const jn=d(e=>!!(e.questions&&e.questions.state!=="resolved"&&e.questions.open>0),"asking"),rt=new Set([q.pick,q.questions]);function Ot(e){return e.questions?ce.openQuestions:null}d(Ot,"actionButtonOf");const Wn=d(e=>e.kind==="work"&&e.lane==="todo"&&!e.action&&!e.unpicked,"buildableOf"),Fn=d((e,t)=>/^item:/.test(e.topic||"")&&De.test(e.url||"")&&!(e.options||[]).some(n=>n.id===t),"picksNoTake");function Kn(e,t){const n=e.filter(a=>a.topic===`item:${t}`),r=[...n.filter(a=>De.test(a.url||"")).flatMap(a=>a.answers.map(c=>({q:a,a:c,again:Fn(a,c.choice)}))),...n.filter(a=>a.request).map(a=>({q:a,a:a.request,again:!0}))].sort((a,c)=>String(a.a.ts).localeCompare(String(c.a.ts))).at(-1);return r?.again?{id:r.q.id,note:r.a.note.replace(/\s+/g," ").trim()}:null}d(Kn,"noTakePicked");const Gn="Prototype again",Qn=Object.freeze({[q.pick]:ce.openQuestions,[q.questions]:ce.openQuestions,[q.ready]:ce.github,[q.steps]:ce.github,[q.merge]:ce.merge,[q.stalled]:ce.reply}),Hn="Merges once unblocked",Vn=d(e=>`Blocked ${[e.blockedBy.length&&`by ${e.blockedBy.map(t=>t.ref).join(", ")}`,e.blockedUntil&&`until ${e.blockedUntil}`].filter(Boolean).join(" and ")}`,"mergeBlockedWords");function Xn(e,t,n){if(e.action=Un(e,t,n),e.mergeBlocked=e.action?.kind===q.merge&&e.blocked?Vn(e):null,e.mergeBlocked&&Object.assign(e,{action:null,next:Hn}),e.action?.kind===q.questions&&e.questions.fromAsks){Object.assign(e,{action:null,waitsOn:"owner",next:e.action.label,actionButton:Ot(e),resolvedBy:null});return}e.awaitsDone&&=!e.action,e.awaitsDone||Object.assign(e,{ownerStep:null,ownerCheck:null}),e.actionButton=Ot(e),e.resolvedBy=Qn[e.action?.kind]||null,e.action?.kind===q.merge&&!e.mergeReady&&(e.next=(e.pr?.reviewDecision==="APPROVED"?Ve:ot).ready),e.awaitsDone&&Object.assign(e,{lane:"review",next:e.ownerStep||Mn,bumpable:!1,bumpedAt:null});const s=e.lane==="todo"||e.waitsOn==="ready"||e.lane==="progress"&&e.waitsOn==="owner";if(!e.action||!s||e.stalled)return;const r=e.lane==="todo"&&rt.has(e.action.kind)?"todo":"review";Object.assign(e,{lane:r,waitsOn:e.waitsOn==="ready"?"ready":"owner",next:e.action.label})}d(Xn,"placeOwnerWait");const Et=d((e,t)=>e?e!=="todo":t.some(Q),"started"),$t="none set yet";function So(e,t){const n=t.filter(s=>s?.started);return e.next||et(e)||e.pushed||e.held||e.lane==="done"||st(n)?!1:!n.some(s=>!s.done)&&Et(e.lane,n)}d(So,"owesNext");const fe="visual",$e="helper",Yn=new Set(["grill","prototype",fe]),Z=d(e=>Yn.has(e.kind),"plans"),Q=d(e=>!Z(e),"builds"),zn={grill:"grilling",prototype:"prototyping"},Jn=d((e,t)=>`${e} ${t} by orchestrator`,"orchestratorDid"),At=d(e=>Jn(e,"stopped"),"stoppedWords"),Zn={grill:"grill-more",prototype:"prototype-request"},at=d((e,t)=>e.kind===Zn[t]&&!e.data?.question,"isRequest");function Bt(e,t,n,s){if(n.some(a=>a.kind===e&&a.started&&!a.done))return!0;const r=t.filter(a=>at(a,e)).pop();return!!(r&&!r.reply&&!s.some(a=>String(a)>String(r.ts))&&!n.some(a=>a.kind===e&&a.done&&String(a.started)>=String(r.ts)))}d(Bt,"planningOf");const vo=30*6e4;function xo(e,t){const n=[];for(const s of e.items){if(pe(s)||(s.agents||[]).some(a=>a.kind==="prototype"&&a.started&&!a.done))continue;const r=s.prototyping&&t.filter(a=>a.data?.key===s.key&&at(a,"prototype")).pop();r&&n.push({key:s.key,request:r.id,since:r.ts});for(const a of e.owner.filter(c=>c.topic===`item:${s.key}`&&c.state==="requested"))n.push({key:s.key,request:a.request.id,question:a.id,since:a.request.ts})}return n}d(xo,"prototypeWaits");function lt(e={},t=!1){return Object.entries(e).filter(([n,s])=>s&&typeof s=="object"&&(t||!n.startsWith("past-"))).map(([n,s])=>({name:n,kind:_t(s.kind)||null,model:s.model||(s.kind==="opus"||s.kind==="sonnet"?s.kind:null),label:s.label||null,started:s.started||null,eta:s.done?null:s.eta||null,done:s.done||null,log:s.session?.log||null,pid:s.session?.pid??null,calls:Array.isArray(s.calls)?s.calls:[],verified:s.verified||null,stopped:s.stopped||null,handed_back:s.handed_back||null,...s.pause?{pause:s.pause}:{},...s.kind===$e?{writes:!!s.writes,requested:s.requested||null}:{},...s.waits?{waits:s.waits}:{}})).sort((n,s)=>String(n.started||"").localeCompare(String(s.started||""))||n.name.localeCompare(s.name))}d(lt,"agentsOf");function it(e={}){return Object.values(e).filter(t=>t?.worktree?.path).sort((t,n)=>String(t.started||"").localeCompare(String(n.started||""))).at(-1)?.worktree.path??null}d(it,"worktreeOf");function es(e,t=[]){const n=e.filter(Q).map(s=>String(s.started||"")).filter(Boolean).sort().pop();return n===void 0?null:t.filter(s=>s.kind==="status"&&s.phase&&!s.planner&&String(s.ts)>=n).sort((s,r)=>String(s.ts).localeCompare(String(r.ts))).pop()?.phase||null}d(es,"reachedPhaseOf");function ts(e,t=[],n,s=null,r=null,a=!1){const c=e.filter(b=>b.started&&!b.done&&b.kind!==fe),l=c.every(Z),u=l?c:c.filter(Q),m=u.map(b=>String(b.started||"")).sort()[0];if(m===void 0)return null;const f=t.filter(b=>b.kind==="status"&&String(b.ts)>=m&&(l||!b.planner)).sort((b,i)=>String(b.ts).localeCompare(String(i.ts))).pop(),p=u.filter(b=>b.kind!==$e).at(-1),w=u.some(b=>b.kind===ue.install)?ue.install:p?_t(p.kind):"claude",v=l?null:u.find(b=>b.stopped&&String(b.stopped.at)>=String(b.started))?.stopped,_=f?.ts||m,x=v?v.at:s!==null&&s>Date.parse(_)?new Date(s).toISOString():_,P=f?f.phase||f.planner?f.text.replace(": "," \xB7 "):f.text||null:l?zn[w]:null,C=n-Date.parse(x),L=l?null:u.find(b=>b.pause)?.pause??null,T=!l&&!v&&(r==="held"||!!L?.paused),W=l||T?null:v?!1:r,h=T?!1:W===null?C>=mn:!W;return{progress:P,phase:f?.phase||null,at:_,kind:w,planner:l,silentSince:x,stale:h,alive:W,held:T,quiet:!!L||W===!0&&!a&&C>=wn,stopped:v?At(v.run):null,...L?{pause:{cause:L.cause,words:L.words,paused:!!L.paused}}:{}}}d(ts,"builderOf");const Oo={blocked:["nothing blocks it","nothing blocks it"],held:["you unhold it","unheld"],usage:["the usage pause lifts","the usage pause lifted"],bumped:["a slot frees","a slot freed"]},ns=15*6e4,ss=d(e=>(e.agents||[]).find(t=>t.name==="main"&&t.started&&!t.done&&Q(t))??null,"mainBuild"),os=d((e,t,n,{now:s,usageHeld:r})=>e.blocked||e.held||r(n??"claude")||!!(t.until&&s<Date.parse(t.until)),"stillPaused");function Eo(e,{now:t=Date.now(),usageHeld:n}){const s={ask:[],drop:[],stop:[],resume:[]},r=[];for(const a of e){if(a.kind!=="work"||a.lane!=="progress")continue;const c=ss(a);if(!c)continue;const{pause:l}=c;if(!l){a.builder?.phase!=="landing"&&n(c.kind??"claude")&&s.ask.push(a.key);continue}const u=os(a,l,c.kind,{now:t,usageHeld:n});if(l.paused){u||r.push({key:a.key,at:l.paused});continue}!u&&l.cause!=="bumped"?s.drop.push(a.key):t-Date.parse(l.at)>=ns&&s.stop.push(a.key)}return s.resume=r.sort((a,c)=>String(a.at).localeCompare(String(c.at))).map(a=>a.key),s}d(Eo,"pauseWork");const dt={opus:"Claude Opus",sonnet:"Claude Sonnet",codex:"Codex"},rs=d(({model:e,kind:t})=>dt[e]||dt[t]||(t==="claude"?dt.opus:null),"modelName"),_t=d(e=>e==="opus"||e==="sonnet"?"claude":e,"providerOf");function Ae(e){const t=rs(e),{kind:n}=e,s=e.label?.replace(/\bbuilder\b/,"implementer").replace(/\bBuilder\b/,"Implementer"),r=d(c=>c[0].toLowerCase()+c.slice(1),"lower");if(t&&Z(e))return`${t} ${n}`;if(t)return`${t} ${r(s?.replace(/^(?:claude\s+)?(?:opus|sonnet|codex)\b\s*/i,"")||"implementer")}`;const a=n?n[0].toUpperCase()+n.slice(1):"";return a?s?s.toLowerCase().includes(n.toLowerCase())?s:`${a} ${r(s)}`:a:s||"Implementer"}d(Ae,"agentName");const Rt=d((e,t)=>!!e.resolved&&(!e.resolved_at||String(e.resolved_at)>=String(t)),"resolvedSince");function as(e,t,n=[],s=[],r=[]){const a=t.filter(x=>x.data?.id===e.id),c=a[a.length-1]||null,l=r.filter(x=>x.data?.question===e.id).at(-1),u=d((x,...P)=>!!x&&P.every(C=>String(x.ts)>String(C||"")),"after"),m=!!(e.resolved&&e.resolved_at&&c&&String(c.ts)>e.resolved_at),f=n.filter(x=>x.data?.question===e.id&&u(x,c?.ts,l?.ts)).at(-1),p=u(l,c?.ts,f?.ts,e.resolved_at),w=e.resolved&&!m&&!p,v=w?s.filter(x=>x.data?.question===e.id&&String(x.ts)>String(e.resolved_at||"")).at(-1):null,_=p?"open":w?"resolved":f?"requested":c?c.reply?"replied":"answered":"open";return{...e,state:_,reopened:p,request:f?{id:f.id,ts:f.ts,note:f.data.note||"",choice:f.data.choice||null}:null,reask:v?{id:v.id,ts:v.ts}:null,answers:a.map(x=>({ts:x.ts,choice:x.data.choice||null,note:x.data.note||"",files:x.data.files||[],reply:x.reply,replies:x.replies,acted:Rt(e,x.ts)}))}}d(as,"ownerQuestion");const ge=d(e=>e.round??(De.test(e.url||"")?1:0),"roundOf");function ls(e,t){const n=d((s,r)=>s.confirm_of||(s.options||[]).some(a=>a.id===r.choice),"settles");return e.filter(s=>ge(s)===t&&s.answers.length).map(s=>[s,s.answers.at(-1)]).filter(([s,r])=>n(s,r)).map(([,s])=>s).sort((s,r)=>String(s.ts).localeCompare(String(r.ts))).at(-1)}d(ls,"roundSettledBy");function is(e){const t=e.find(r=>r.request&&!e.some(a=>ge(a)>ge(r)));if(t)return{n:ge(t)+1,state:"making"};const n=Math.max(0,...e.map(ge));if(!n)return null;const s=ls(e,n);return s?{n,state:"closed",take:s.choice}:e.some(r=>ge(r)===n&&r.state!=="resolved")?{n,state:"back"}:null}d(is,"roundState");function ds(e){const t=e.filter(n=>De.test(n.url||"")).map(n=>({n:ge(n),q:n.id,url:n.url,confirm:!!n.confirm_of,state:n.state})).sort((n,s)=>n.n-s.n);return t.length?{open:t.some(n=>n.state==="open"),rounds:t}:null}d(ds,"prototypesOf");const Be=d((e,t,n)=>{const s=e.get(t);s?s.push(n):e.set(t,[n])},"add"),_e=d((e,t)=>{const n=new Map;for(const s of e)Be(n,t(s),s);return n},"groupBy");function cs(e,t,n=new Set){const s=new Set(e.map(c=>c.id)),r=[...e];t.some(c=>!s.has(c.topic))&&r.push({id:"",title:"Other"});const a=_e(t,c=>s.has(c.topic)?c.topic:"");return r.map(c=>{const l=a.get(c.id)||[],u=d(p=>l.filter(w=>w.state===p).length,"count"),m=l.every(p=>p.state==="resolved")||n.has(c.id)&&!u("open"),f=l.length&&m?"resolved":"open";return{...c,state:f,questions:l.map(p=>p.id),open:u("open"),answered:u("answered"),replied:u("replied"),resolved:u("resolved"),round:is(l)}}).filter(c=>c.questions.length)}d(cs,"ownerTopics");const Xe={implement:"Implementation requested",prototype:"Prototype requested",grill:"Grill requested",visual:"Visual requested",reask:"Re-ask requested",hold:"Held",resume:"Resumed",done:"Marked done",staged:"Marked as staging",readied:"Marked as ready",related:"Related to",unrelated:"Unrelated from",followup:"Follow-up","followup-of":"Follow-up of"},Mt={"grill-more":"grill","prototype-request":"prototype","visual-request":"visual","reask-request":"reask"},It={"mark-ready":"Mark ready",...Object.fromEntries(Object.entries(Mt).map(([e,t])=>[e,Xe[t]]))},us=["grill-more","prototype-request","visual-request"],ps="answers submitted",qt={reply:"Reply",build:"Implement"},fs=d(e=>qt[e.data.intent],"intentLabel"),gs=d((e,t)=>{if(e.kind!=="prototype-request"||!t.has(e.data.question))return e.data.text||(e.data.files?.length?"":R(It,e.kind))||"";const n=t.get(e.data.question),s=(n.options||[]).find(r=>r.id===e.data.choice);return[Nt(t,n),s&&`${s.id}. ${s.label}`,e.data.note].filter(Boolean).join(`
|
|
2
2
|
\u2192 `)},"actionText"),Ct=d((e,t)=>[...e.keys()].indexOf(t)+1,"numberOf"),Nt=d((e,t)=>`Question ${Ct(e,t.id)} \xB7 ${t.question}`,"numbered"),ks="question reopened",Ye=d(e=>e.data?.shared_by?.login?{by:e.data.shared_by.name||e.data.shared_by.login}:{},"byOf"),ms=d((e,t)=>{const n=t.get(e.card);return{...e,text:`${Xe[e.command]} ${n?`${n.ref}: ${n.title}`:e.card}`,link:`#selectedCard=${encodeURIComponent(e.card)}`}},"linkLine"),Dt=d(e=>({kind:"command",command:e,request:!0,mark:Xe[e]}),"commandLine"),ws=d((e,t)=>{if(e.kind==="item-message")return qt[e.data.intent]?{status:fs(e)}:{};const n={request:!0,command:Mt[e.kind]};return us.includes(e.kind)?(e.data.question?t.has(e.data.question):String(e.data.text||"").trim()||e.data.files?.length)?{status:It[e.kind]}:n:e.kind==="reask-request"?n:{}},"requestOf"),Lt=d(e=>[...e.filter(t=>t.who==="claude"&&!["status","agent","command",Ee,Ne.answer].includes(t.kind)).map(t=>t.ts),...e.filter(t=>t.kind==="ask").map(t=>t.reply?.at)].filter(Boolean).map(String).sort().pop()||"","claudeAtOf");function Tt(e,t={}){const n=new Map,s=new Map;for(const p of t.items||[])Be(n,p.topic,p),s.set(p.id,p.topic);const r=new Map,a=new Map,c=new Map,l=new Map,u=new Map,m=new Map,f=new Map;e.forEach((p,w)=>{f.set(p,w),p.data?.key&&kn.includes(p.kind)&&Be(r,p.data.key,p),p.kind==="owner-answer"&&s.has(p.data?.id)&&Be(a,s.get(p.data.id),p),p.kind===mt&&s.has(p.data?.question)&&Be(c,s.get(p.data.question),p),p.kind==="ask"&&l.set(p.id,p)});for(const p of e.filter(w=>w.kind==="ask-edited").sort((w,v)=>String(w.ts).localeCompare(String(v.ts)))){u.set(p.data?.id,p.data?.text);for(const w of p.data?.files||[])Be(m,p.data?.id,w)}return{byKey:r,answers:a,reopens:c,asks:l,edits:u,editFiles:m,questions:n,order:f}}d(Tt,"threadIndex");const ct=d((e,t)=>{const n=t.filter(s=>s?.length);return n.length>1?n.flat().sort((s,r)=>e.order.get(s)-e.order.get(r)):[...n[0]||[]]},"inLogOrder"),hs=/; its log is \S*\/\.claude\/kickflip\/logs\/([^/\s]+\.log)\.$/,ys=d(e=>{const t=!e.log&&typeof e.text=="string"?hs.exec(e.text):null;return t?{...e,text:`${e.text.slice(0,t.index)}.`,log:t[1]}:e},"withLog");function bs(e,t){const n=t.run?e.find(s=>s.started===t.run):e.filter(s=>Z(s)===!!t.planner&&s.started&&String(s.started)<=String(t.ts)&&(!s.done||String(t.ts)<=String(s.done))).pop();return n?{agent:Ae(n),run:ut(e,n),running:!n.done}:{}}d(bs,"agentOfLine");function ut(e,t){return t.label!==He?t.started:e.filter(n=>Q(n)&&n.label!==He&&n.started&&String(n.started)<String(t.started)).pop()?.started??t.started}d(ut,"sectionOf");function Ss(e,{lines:t=[],events:n=[],owner:s={},working:r={},fromAsk:a=null,links:c=[],agents:l={},moved:u=[],cards:m=new Map,index:f=Tt(n,s)}){const p=e.startsWith("ask:")?[e.slice(4)]:[a,...u].filter(Boolean),w=[...new Set([e,...p.map(i=>`ask:${i}`)])],v=new Map((f.questions.get(`item:${e}`)||[]).map(i=>[i.id,i])),_=f.answers.get(`item:${e}`)||[],x=new Set([...v.values()].map(i=>i.round_of).filter(Boolean)),P=lt(l,!0).filter(Q).map(i=>String(i.started||"")).filter(Boolean),C=new Set(_.filter(i=>Rt(v.get(i.data.id),i.ts)||x.has(i.data.id)||P.some(S=>S>String(i.ts))).map(i=>i.id)),L=lt(l,!0),T=[...L.flatMap(i=>[...i.started?[{ts:i.started,who:"claude",kind:"agent",text:`${Ae(i)} started`,agent:Ae(i),run:ut(L,i),finished:!1,eta:i.done?null:i.eta,...i.log?{log:i.log}:{},...Z(i)?{planner:!0}:{}}]:[],...i.done?[{ts:i.done,who:"claude",kind:"agent",text:`${Ae(i)} finished`,agent:Ae(i),run:ut(L,i),finished:!0,eta:null,...Z(i)?{planner:!0}:{}}]:[],...i.calls.length?[{ts:i.done||i.calls.at(-1).at,who:"claude",kind:"calls",text:Ae(i),calls:i.calls.map(S=>S.text),verified:i.verified}]:[]]),...t.map(ys).map(i=>i.card?ms(i,m):i).map(i=>({ts:i.ts,who:i.who||"claude",...i.log?{log:i.log}:{},...i.kind==="status"?{kind:"status",...i.planner?{planner:!0}:{},...bs(L,i)}:{},...i.kind===fe||i.kind===Ee?{kind:i.kind}:{},...i.kind===Qe?{kind:Qe,line:i.line,...gt(i)}:{},...i.command?Dt(i.command):{},...i.card?{card:i.card}:{},...Object.values(Ne).includes(i.kind)?{kind:i.kind,ask:i.ask}:{},text:i.text||"",html:i.html||null,link:i.link||null,via:i.via||null,...i.by?{by:i.by}:{}})),...ct(f,w.map(i=>f.byKey.get(i))).map(i=>({ts:i.ts,who:"love",via:"site",kind:i.kind,text:gs(i,v),files:i.data.files||[],eventId:i.id,reply:i.reply,replies:i.replies,...ws(i,v),...Ye(i)})),..._.map(i=>{const S=[i.data.label||i.data.choice,i.data.note].filter(Boolean).join(" \xB7 ");return{ts:i.ts,who:"love",via:"questions",kind:i.kind,text:`${Nt(v,v.get(i.data.id))}
|
|
3
3
|
\u2192 ${S}`,files:i.data.files||[],eventId:i.id,reply:i.reply,replies:i.replies,status:ps,...Ye(i)}}),...(f.reopens.get(`item:${e}`)||[]).map(i=>({ts:i.ts,who:"love",kind:i.kind,request:!0,mark:ks,text:`Question ${Ct(v,i.data.question)} was reopened`,...Ye(i)})),...ct(f,[...new Set(p)].map(i=>f.asks.has(i)?[f.asks.get(i)]:[])).flatMap(i=>[{ts:i.ts,who:"love",via:"ask",kind:i.kind,text:i.data?.text||"",files:i.data?.files||[],eventId:i.id,reply:i.reply,replies:i.replies,...u.includes(i.id)?{moved:!0}:{},...Ye(i),...typeof i.data?.from?.title=="string"?{from:i.data.from.title}:{}},...i.data?.quick===!0?[{ts:i.ts,who:"love",...Dt("implement"),text:Xe.implement}]:[],...c.map(S=>({ts:i.ts,who:"claude",kind:"ask-link",text:S.folded?`Moved to ${S.ref}: ${S.title}`:`${S.became?"Became":"Added to"} ${S.ref}`,link:`#selectedCard=${encodeURIComponent(S.key)}`,html:null,via:null}))])].sort((i,S)=>String(i.ts).localeCompare(String(S.ts))),W=Lt(T),{edits:h,editFiles:b}=f;for(const i of T){if(!i.eventId)continue;(i.kind==="ask"||i.kind==="item-message")&&typeof h.get(i.eventId)=="string"&&Object.assign(i,{text:h.get(i.eventId),edited:!0,files:[...i.files,...b.get(i.eventId)||[]]});const S=i.kind==="ask"&&(c.length>0||!!i.moved);i.state=i.reply?"replied":S||C.has(i.eventId)||W>String(i.ts)?"answered":R(r,i.eventId)?"working":"waiting",i.canDelete=i.kind==="item-message"&&(i.state==="working"||i.state==="waiting"),i.canEdit=i.canDelete}return vs(T)}d(Ss,"threadOf");const Pt=["waiting","working","answered","replied"];function vs(e){const t=new Map;for(const n of e)n.kind==="owner-answer"&&t.set(n.ts,[...t.get(n.ts)||[],n]);return e.flatMap(n=>{if(n.kind!=="owner-answer")return[n];const s=t.get(n.ts);return s.length===1?[n]:s[0]!==n?[]:[{...n,text:s.map(r=>r.text).join(`
|
|
4
4
|
|
package/board/public/hub.css
CHANGED
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
.hub-view .dur-on .title { font-weight:600; white-space:normal; }
|
|
79
79
|
.hub-view code { font-family:var(--mono); font-size:.85em; white-space:nowrap; }
|
|
80
80
|
.hub-view footer { color:var(--muted); font-size:12px; margin:1.8rem 2px 0; }
|
|
81
|
+
.hub-view .version { font:inherit; color:inherit; background:none; border:0; padding:0; text-decoration:underline dotted; cursor:pointer; }
|
|
81
82
|
.hub-view .host { font-family:var(--mono); font-size:11px; overflow-wrap:anywhere; }
|
|
82
83
|
.hub-view .st.ok { color:var(--color-success); }
|
|
83
84
|
.hub-view .links { display:block; margin-top:2px; }
|
package/board/public/ui-cards.js
CHANGED
|
@@ -333,9 +333,9 @@
|
|
|
333
333
|
const ownerDoneButton = (it) => `<button type="button" class="ghost icon owner-done" data-confirm="finish" data-key="${esc(it.key)}" aria-label="Done" title="${esc(`Done: ${it.ownerStep}`)}">${CHECK}</button>`;
|
|
334
334
|
const cardActions = (it) => (it.isNew ? askActions(it) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
|
|
335
335
|
: it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
|
|
336
|
-
// docs/adr/2026-11-07-0115-the-orchestrator-sorts-new-tasks-itself.md: a New card reads a grey "waiting", its dot
|
|
337
|
-
// still, until the orchestrator picks it up (the watcher's working mark), then "thinking".
|
|
338
|
-
const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting
|
|
336
|
+
// docs/adr/2026-11-07-0115-the-orchestrator-sorts-new-tasks-itself.md: a New card reads a grey "waiting…", its dot
|
|
337
|
+
// still, until the orchestrator picks it up (the watcher's or the PostToolUse hook's working mark), then "thinking…".
|
|
338
|
+
const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting…</span>';
|
|
339
339
|
const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' ? thinkingLine() : ask.state === 'waiting' && PICKUP_WAIT].filter(Boolean).join(' · ');
|
|
340
340
|
function workLines(it, { unread = 0 } = {}) {
|
|
341
341
|
const ticket = it.ticket ? ticketChip(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : it.linear?.length ? linearChips(it) : taskChip(it);
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
|
|
21
21
|
// A message's footer: the tap it came from (m.status, such as "answers submitted"), then, to its right, Claude's
|
|
22
22
|
// pick-up while it waits (docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
|
|
23
|
-
const PICK_UP = { working: thinkingLine, waiting: () => '
|
|
23
|
+
const PICK_UP = { working: thinkingLine, waiting: () => 'waiting…' };
|
|
24
24
|
const msgFooter = (m) => {
|
|
25
25
|
const parts = [m.status && `<span class="foot-status">${statusMark(m.status)}${esc(m.status)}</span>`, PICK_UP[stateOf(m)]?.()].filter(Boolean);
|
|
26
26
|
return parts.length ? `<div class="reply muted small">${parts.join('')}</div>` : '';
|
package/board/public/ui.js
CHANGED
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
const cardBumpButton = (key, bumped) => (bumped
|
|
137
137
|
? `<button class="ghost icon bumped" data-card-bump="${esc(key)}" data-bumped aria-pressed="true" aria-label="Unbump" title="Bumped. Tap to unbump">${BUMP}</button>`
|
|
138
138
|
: `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
|
|
139
|
-
const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking
|
|
139
|
+
const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking…</span>';
|
|
140
140
|
// Every paused status, whatever its place, is this one class (style.css .paused); BoardUI.usagePause is set by the
|
|
141
141
|
// board view (app.js) to read the hub's usage pause.
|
|
142
142
|
const PAUSED = '<span class="paused">paused</span>';
|
package/board/public/view-hub.js
CHANGED
|
@@ -91,7 +91,15 @@ ${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/k
|
|
|
91
91
|
${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembered?.length ? `<h2 class="label">Sessions without a board</h2><div class="group">${rememberedHtml(d.remembered)}</div>` : ''}<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, the fetch of its repo, events (its session and a watcher run, or the hub can wake it), daily wakes and reviewer links. One that just broke reads settling first.</p>`;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
// The version is a button: a tap checks npm now (POST /_hub/check-update) and the answer shows beside it (updateNote).
|
|
95
|
+
const footer = (d, note = '') => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / <button type="button" class="version" data-check-update>v${esc(d.version)}</button>${note ? ` <span class="update-note" role="status">${esc(note)}</span>` : ''}</span></footer>`;
|
|
96
|
+
// What the hub's answer to a version tap says (hub/upgrade.mjs check()).
|
|
97
|
+
const updateNote = (out) => ({
|
|
98
|
+
clone: `${out.commit} · pulls with main`,
|
|
99
|
+
same: 'up to date',
|
|
100
|
+
pulling: `pulling ${out.latest}…`,
|
|
101
|
+
failed: `check failed: ${out.error}`,
|
|
102
|
+
})[out.state];
|
|
95
103
|
|
|
96
104
|
// iOS shows an app badge only in a Home Screen app with notifications allowed; elsewhere this does nothing.
|
|
97
105
|
function badge(n) {
|
|
@@ -166,6 +174,20 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
166
174
|
flash = { text: `The hub hasn’t come back. On the host, run ${out.command}`, error: true };
|
|
167
175
|
if (B.alive()) paint();
|
|
168
176
|
}
|
|
177
|
+
// A tap on the version asks the hub to check npm now and shows the answer beside it until the next paint of new data.
|
|
178
|
+
let updateText = '';
|
|
179
|
+
async function checkUpdate() {
|
|
180
|
+
if (updateText === 'checking…') return;
|
|
181
|
+
updateText = 'checking…';
|
|
182
|
+
paint();
|
|
183
|
+
try {
|
|
184
|
+
const r = await fetch('/_hub/check-update', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' });
|
|
185
|
+
const out = await r.json();
|
|
186
|
+
if (!r.ok) throw new Error();
|
|
187
|
+
updateText = updateNote(out);
|
|
188
|
+
} catch { updateText = 'could not check. Is the host awake?'; }
|
|
189
|
+
if (B.alive()) paint();
|
|
190
|
+
}
|
|
169
191
|
async function forgetDevice(id) {
|
|
170
192
|
const out = await fetch('/_hub/push/forget', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then((r) => r.ok, () => false);
|
|
171
193
|
if (!out) { flash = { text: 'Could not remove that device. Is the host awake?', error: true }; paint(); return; }
|
|
@@ -181,12 +203,13 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
181
203
|
if (restarter) restart(restarter.dataset.restart, restarter.dataset.title);
|
|
182
204
|
});
|
|
183
205
|
const foot = B.$('.hub-foot');
|
|
206
|
+
B.on(foot, 'click', (e) => { if (e.target.closest?.('[data-check-update]')) checkUpdate(); });
|
|
184
207
|
// A #keep-awake link scrolls to Health's Keep awake row once it is drawn.
|
|
185
208
|
let scrollToAwake = ctx.view?.hash === '#keep-awake';
|
|
186
209
|
const paint = () => {
|
|
187
210
|
if (!last) return;
|
|
188
211
|
main.innerHTML = html(last, { flash, prompts, restarting, restartingHub });
|
|
189
|
-
foot.innerHTML = footer(last);
|
|
212
|
+
foot.innerHTML = footer(last, updateText);
|
|
190
213
|
if (scrollToAwake) { scrollToAwake = false; B.$('#keep-awake')?.scrollIntoView(); }
|
|
191
214
|
};
|
|
192
215
|
const prompts = PromptAnswers.wire(B, {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A Claude Code PostToolUse hook that shows a board's session the owner's new words while it works, after its next tool
|
|
3
|
+
// call, instead of only at its watcher's next wake: each thread message, ask and ask edit (OWNER_WORDS) past the
|
|
4
|
+
// watcher's cursor that it hasn't shown yet, with its sent time and age, its plain line and its event line. It marks
|
|
5
|
+
// the ones the watcher marks working (WORKING_KINDS), so they read thinking… on the board at once, and records them in
|
|
6
|
+
// the session's shown file, so the watcher's next wake prints them again tagged already shown. A subagent's tool call
|
|
7
|
+
// carries agent_id and gets nothing. Any error shows nothing
|
|
8
|
+
// (docs/adr/2026-11-10-1056-the-owners-words-reach-the-orchestrator-after-its-next-tool-call.md).
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
process.stdout.on('error', () => {});
|
|
13
|
+
|
|
14
|
+
const EVENTS = 'data/events.jsonl';
|
|
15
|
+
const readJSON = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
16
|
+
const parsed = (line) => { try { return JSON.parse(line); } catch { return null; } };
|
|
17
|
+
function age(ts, now) {
|
|
18
|
+
const min = Math.floor((now - Date.parse(ts)) / 60_000);
|
|
19
|
+
if (!(min >= 1)) return 'just now';
|
|
20
|
+
return min < 60 ? `${min} min ago` : `${Math.floor(min / 60)} h ${min % 60} min ago`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function shownOn(board, session, now) {
|
|
24
|
+
const { OWNER_WORDS, plainLine, shownFile } = await import('../../bin/event-lines.mjs');
|
|
25
|
+
const { markWorking, WORKING_KINDS } = await import('../../board/store.mjs');
|
|
26
|
+
const cursor = readJSON(path.join(board.dir, 'data', `.watch-cursor-${session}.json`));
|
|
27
|
+
if (!Number.isInteger(cursor?.[EVENTS])) return [];
|
|
28
|
+
let lines = [];
|
|
29
|
+
try { lines = fs.readFileSync(path.join(board.dir, EVENTS), 'utf8').split('\n').filter(Boolean).slice(cursor[EVENTS]); } catch {}
|
|
30
|
+
const pending = lines.map((line) => ({ line, e: parsed(line) })).filter(({ e }) => OWNER_WORDS.includes(e?.kind) && typeof e.id === 'string');
|
|
31
|
+
const file = shownFile(board.dir, session);
|
|
32
|
+
// Ids the watcher has since woken past drop out.
|
|
33
|
+
const before = (readJSON(file)?.ids || []).filter((id) => pending.some(({ e }) => e.id === id));
|
|
34
|
+
const fresh = pending.filter(({ e }) => !before.includes(e.id));
|
|
35
|
+
if (!fresh.length) return [];
|
|
36
|
+
await markWorking(board.dir, fresh.filter(({ e }) => WORKING_KINDS.includes(e.kind)).map(({ e }) => e.id));
|
|
37
|
+
fs.writeFileSync(file, JSON.stringify({ ids: [...before, ...fresh.map(({ e }) => e.id)] }));
|
|
38
|
+
const out = [`The owner wrote on /${board.route} while you worked. Read it now: their words come before what you were doing. Your watcher prints each again on its next wake, tagged already shown.`];
|
|
39
|
+
for (const { line, e } of fresh) out.push(`sent ${e.ts}, ${age(e.ts, now)}: ${await plainLine(board.dir, line)}`, `event ${EVENTS} ${line}`);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
45
|
+
if (input.hook_event_name === 'PostToolUse' && !input.agent_id && typeof input.session_id === 'string' && input.session_id) {
|
|
46
|
+
const { listBoards } = await import('../../hub/boards.mjs');
|
|
47
|
+
const owned = listBoards().filter((b) => b.owner?.session === input.session_id);
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
const lines = (await Promise.all(owned.map((b) => shownOn(b, input.session_id, now).catch(() => [])))).flat();
|
|
50
|
+
if (lines.length) console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: lines.join('\n') } }));
|
|
51
|
+
}
|
|
52
|
+
} catch {}
|
|
53
|
+
process.exitCode = 0;
|
|
@@ -39,6 +39,16 @@
|
|
|
39
39
|
]
|
|
40
40
|
}
|
|
41
41
|
],
|
|
42
|
+
"PostToolUse": [
|
|
43
|
+
{
|
|
44
|
+
"hooks": [
|
|
45
|
+
{
|
|
46
|
+
"type": "command",
|
|
47
|
+
"command": "node \"$HOME/.claude/skills/kickflip/hooks-plugin/hooks/board-messages.mjs\""
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
],
|
|
42
52
|
"PreToolUse": [
|
|
43
53
|
{
|
|
44
54
|
"matcher": "Agent|Task",
|
package/hub/hub.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var
|
|
2
|
+
var Nt=Object.defineProperty;var u=(e,t)=>Nt(e,"name",{value:t,configurable:!0});import Be from"node:http";import Ft from"node:net";import{spawn as Ne,execFileSync as Fe}from"node:child_process";import b from"node:fs";import k from"node:path";import Ge from"node:os";import{fileURLToPath as Gt}from"node:url";import ze from"node:vm";import zt from"node:zlib";import{routeRecord as P}from"./routes.mjs";import{publicKey as Jt,addSubscription as Kt,removeSubscription as Wt,removeSubscriptionById as Vt,sendAll as Zt,flushQueue as Je,queueStatus as Ke,saveSettings as Qt,readSettings as F,migrateSettings as Xt,setNotifications as Yt,readCodexShare as We,setCodexShare as qt,readClaudeModel as en,setClaudeModel as tn,deliver as nn}from"./push.mjs";import{ownerTracker as sn}from"./presence.mjs";import{POLL_MS as Ve,answerOf as an,expired as rn,pendingFor as Ze,promptRecord as on,pushOf as cn,updatePrompts as G,shown as ln,pruneRemembered as Se,rememberRules as un,rememberedAnswer as dn,rememberedFor as Qe}from"./prompts.mjs";import{hold as hn,heldUntil as fn,holdLine as pn,editQuiet as mn,checkEvery as gn,nextCheckAt as wn,quietAt as bn}from"./schedule.mjs";import{capital as yn,hubView as Sn,settingsView as kn,flushView as $n,SNOOZE_REFUSED as _n}from"./views.mjs";import{caffeinate as xn,setKeepAwake as vn,awakeOn as Tn,awakeUnsupported as On}from"./awake.mjs";import{endOf as jn}from"./duration.mjs";import{nodeBin as Pn}from"../bin/install.mjs";import{checkGitHub as Rn,sendWakes as En}from"./github.mjs";import{checkIncoming as Dn}from"./incoming.mjs";import{listBoards as D,isListening as Ln,isBoardSite as ke,listenState as Xe,RESUME_STATE as Cn,sendToBoards as Ye,checkListening as An,recordHubCode as In,hubCode as Hn,runningHubCode as Mn,boardCode as qe,recordLinkFaults as Un,linksOf as Bn,hubLink as Nn,hubCodeState as Fn,readJSON as et}from"./boards.mjs";import{liveSessions as tt}from"./live.mjs";import{RuleError as Gn,applyRules as zn,changed as nt,readHubRules as $e,writeHubRules as Jn}from"./rules.mjs";import{siteIcon as Kn}from"./icons.mjs";import{readNeeds as Wn,sidebarSites as Vn,startRoute as Zn,hubHealth as Qn}from"./home.mjs";import{checkSessions as Xn,compactFullContexts as Yn,startingBoards as qn}from"./sessions.mjs";import{repoService as es,repoLookupRoute as ts}from"./repos.mjs";import{boardRepo as st,changeBoardRepo as ns,migrateBoardRepos as ss,migrateHubBoard as as}from"./board-repo.mjs";import{requestBoard as rs,retryBoard as os,dismissBoard as is,clonePercent as cs,settleStarts as ls}from"./new-board.mjs";import{boardClosure as us,closeBoard as ds,closedBoards as hs,reopenBoard as fs}from"./close-board.mjs";import{restartBoard as ps,restartable as ms}from"./restart-board.mjs";import{crossSite as gs,isJSON as _e}from"../starter/cross-site.mjs";import{BOARD_PUBLIC as xe,appFile as ws,colorsVersion as at,destOf as bs,fileVersion as ys,pageOf as rt,shellHtml as Ss}from"./shell.mjs";import{hubStream as ks}from"./stream.mjs";import{usageReader as $s,checkUsagePause as _s,contextReading as xs}from"./usage.mjs";import{readUsageGate as ee,setPauseAt as vs,setKeepGoing as Ts,setHubPause as Os,boardPausedUntil as js}from"../shared/usage-gate.mjs";import{checkMachine as Ps}from"../shared/machine-gate.mjs";import{readiness as Rs,readCodex as te}from"./readiness.mjs";import{BuildError as Es,startBuild as Ds,stopTries as Ls,endUnsharedTries as Cs,tryTick as ve,initializeTries as As,fetchTryBranches as ot,pullNow as Is,pullMains as Hs}from"./try.mjs";import{serveFiles as Ms}from"./files.mjs";import{upgrader as Us,CHECK_MS as Bs}from"./upgrade.mjs";import{restarter as Ns}from"./self-restart.mjs";import{fallBackFromLinear as Fs,migrateWaveNames as Gs,openBoardBuilders as zs}from"../board/store.mjs";import{recordRefusal as Js}from"../board/permissions.mjs";import{hubGate as Te}from"../board/on-host.mjs";import{pipeUpgrade as Ks,refuse as Ws,refuseUpgrade as Vs}from"./front-door.mjs";import{shareEntries as Zs,sharedRoutes as it,shareReach as Qs,routeOfPath as Xs,sharedSidebar as Ys,shareTracker as qs}from"./share.mjs";import{restartRunner as ct}from"../board/worktree.mjs";import{hubService as ea}from"../shared/service.mjs";import{cloneRoot as ne}from"../shared/repos.mjs";import{LinearError as Oe,linearStatus as se,removeLinearKey as ta,saveLinearKey as na,savedClient as lt}from"./linear.mjs";import{SlackError as sa,slackStatus as aa,removeSlackToken as ra,saveSlackToken as oa}from"./slack.mjs";import{sendReviews as ia}from"./review-send.mjs";const z=k.dirname(Gt(import.meta.url)),ca=k.join(z,"..","shared","colors.css"),ut=JSON.parse(b.readFileSync(k.join(z,"..","package.json"),"utf8")).version,c=u((...e)=>console.log(new Date().toISOString(),...e),"log"),J=k.join(Ge.homedir(),".claude","kickflip"),dt=k.join(J,"manifest.json"),ae=k.join(J,"logs"),ht=k.join(J,"run"),je=Number(process.env.KICKFLIP_HUB_PORT||4500);for(const e of[J,ae,ht])b.mkdirSync(e,{recursive:!0});try{const e=In();c(`hub code ${e.hash} (${e.files.length} files)`)}catch(e){c("could not record the hub code hash:",e.message)}try{const e=await ss();e.length&&c(`manifest: recorded GitHub repos for ${e.map(t=>`/${t}`).join(", ")}`)}catch(e){c("board repo migration failed:",e.message)}try{const e=await as();e&&c(`manifest: /${e}, the hub board, builds from its own folder's local main`)}catch(e){c("hub board migration failed:",e.message)}try{const e=await Gs();e.length&&c(`boards: lineups renamed to waves and wave files to item files on ${e.map(t=>`/${t}`).join(", ")}`)}catch(e){c("wave name migration failed:",e.message)}const H=es(),la=$s(),re=xn({log:c});re.apply();let oe={version:1,sites:{}};function g(){try{oe=JSON.parse(b.readFileSync(dt,"utf8")),oe.sites||={}}catch(e){e.code==="ENOENT"?oe={version:1,sites:{}}:c("manifest unreadable, keeping the last good copy:",e.message)}return oe}u(g,"loadManifest");const j=new Map,M=new Set;let ie=!1;const ft=5e3,ce=u(e=>JSON.stringify([e.cmd,e.cwd||e.dir,e.port,e.env||{},e.restart_at||0]),"keyOf"),Pe=u(e=>Array.isArray(e?.cmd)&&e.cmd.length>0&&!!e.port,"runs"),U=u(e=>Pe(e)?"cmd":e?.port?"port":"files","servesOf"),K=new Map;function le(e){return k.join(ht,e+".pid")}u(le,"pidFile");function ua(e,t){try{const n=Number(b.readFileSync(le(e),"utf8")),s=n?Fe("ps",["-o","command=","-p",String(n)],{encoding:"utf8"}).trim():"";s&&s.includes(t.cmd[t.cmd.length-1])&&(process.kill(-n,"SIGTERM"),c(`stopped stale ${e} (pgid ${n})`))}catch{}try{b.unlinkSync(le(e))}catch{}}u(ua,"killStale");function da(e,t){const n=j.get(e)||{restarts:0},s=b.openSync(k.join(ae,e+".log"),"a"),[o,...r]=t.cmd,p={...process.env,...t.env||{},PORT:String(t.port),PREFIX:"/"+e,KICKFLIP_ROUTE:e};b.writeSync(s,`
|
|
3
3
|
--- ${new Date().toISOString()} hub starting: ${t.cmd.join(" ")} (port ${t.port})
|
|
4
|
-
`),n.code=
|
|
5
|
-
`),b.closeSync(s),Object.assign(n,{child:null,key:ie(t),state:"crashed",exitCode:null,nextStart:Date.now()+ht(n.restarts++)}),j.set(e,n);return}b.closeSync(s),b.writeFileSync(ce(e),String(h.pid)),Object.assign(n,{child:h,key:ie(t),state:"starting",since:Date.now(),exitCode:null,up:!1}),j.set(e,n),c(`started ${e} pid ${h.pid}`),h.pid&&H.add(h);const u=l((_,S)=>{if(H.delete(h),n.child!==h)return;try{b.unlinkSync(ce(e))}catch{}Date.now()-n.since>6e4&&(n.restarts=0),Object.assign(n,{child:null,state:n.stopping?"stopped":"crashed",exitCode:_??S,nextStart:Date.now()+ht(n.restarts++),why:n.stopping?n.why:void 0}),n.stopping=!1,c(`${e} exited (${_??S})`)},"ended");h.on("error",_=>{c(`${e} spawn error: ${_.message}`),h.pid||u(_.code,null)}),h.on("exit",u)}l(da,"start");const ht=l(e=>Math.min(6e4,1e3*2**Math.min(e,6)),"backoff");function le(e){const t=j.get(e);if(!t||!t.child){j.delete(e);return}t.stopping=!0;const n=t.child;ue(n,"SIGTERM"),setTimeout(()=>{H.has(n)&&ue(n,"SIGKILL")},dt)}l(le,"stop");const ue=l((e,t)=>{try{process.kill(-e.pid,t)}catch{}},"signal");function Re(e){return new Promise(t=>{const n=Nt.connect({port:e,host:"127.0.0.1"}),s=l(o=>{n.destroy(),t(o)},"done");n.setTimeout(800,()=>s(!1)),n.on("connect",()=>s(!0)),n.on("error",()=>s(!1))})}l(Re,"probe");let ft=!0;const B=new Set;let Ee=!1;const W=new Set,ha=3e4;async function fa(e){const t=Date.now()+ha;for(;;){const n=P(g().sites,e),s=j.get(e);if(n&&s?.child&&s.up&&s.key===ie(n))return!0;if(Date.now()>=t)return!1;await new Promise(o=>setTimeout(o,250))}}l(fa,"serverAnswers");async function pt(){if(oe)return;mt&>.check();const{sites:e}=g();for(const t of[...j.keys()])B.has(t)||(P(e,t)?Pe(P(e,t))||(c(`${t} no longer has a command; stopping it`),le(t),j.delete(t)):(c(`${t} left the manifest; stopping it`),le(t),j.delete(t)));for(const t of[...K.keys()])U(P(e,t))!=="port"&&K.delete(t);for(const[t,n]of Object.entries(e))U(n)==="port"&&K.set(t,await Re(n.port));for(const[t,n]of Object.entries(e)){if(!Pe(n))continue;ft&&ua(t,n);const s=j.get(t);if(s&&s.child&&s.key!==ie(n)){c(`${t} entry changed; restarting it`),le(t),s.restarts=0,s.nextStart=Date.now()+1500;continue}const o=mt&&s?.child&&s.code&&!s.stopping&&!W.has(t)&&!B.has(t)?Xe(n):null;if(o&&o!==s.code){if(!(await zs(n.dir)).length){c(`${t} runs older code than its files; restarting it`),le(t),Object.assign(s,{restarts:0,nextStart:Date.now()+1500,why:"code"});continue}s.waitedOn!==o&&c(`${t} runs older code than its files; restarts once no builder runs`),s.waitedOn=o}(!s||!s.child&&Date.now()>=(s.nextStart||0))&&da(t,n)}ft=!1;for(const[t,n]of j){const s=P(e,t);s&&(n.up=n.child?await Re(s.port):!1,n.child&&n.up&&Object.assign(n,{state:"up"},n.stopping?{}:{why:void 0}))}}l(pt,"reconcile");const mt=process.env.KICKFLIP_CODE_RESTARTS!=="off",gt=Ns({root:Te(),files:Mn().files,restart:l(()=>ot("hub"),"restart"),log:c}),pa=setInterval(()=>pt().catch(e=>c("reconcile failed:",e.message)),2e3);pt().catch(e=>c("reconcile failed:",e.message));try{Xt()&&c("settings: migrated the polling windows to quiet hours")}catch(e){c("settings migration failed:",e.message)}const wt=l(async()=>{try{for(const e of await Fs())c(`linear: ${e}`)}catch(e){c("linear fallback failed:",e.message)}},"linearFallback");await wt();const ma=6e4,De=60*6e4,$={running:null,startedAt:0,firstAt:Date.now()+ma,last:null,remaining:null,errorLoggedAt:0,linearErrorLoggedAt:0,slackErrorLoggedAt:0},de=sn(),bt=l(()=>bn(new Date,F()),"quietNow"),ga=l(()=>$.startedAt?wn({last:new Date($.startedAt),...de.state(),quiet:bt(),remaining:$.remaining}):new Date($.firstAt),"nextPollAt");function yt({changed:e,back:t}){e&&x.refresh(),t&&$.startedAt&&!$.running&&!bt()&&Le().catch(n=>c("poll failed:",n.message))}l(yt,"presenceChanged");function Le({flush:e=!0}={}){if($.running)return $.running;const t=new Date;return $.startedAt=t.getTime(),$.running=(async()=>{const n=F();let s;try{s=await Rn({every:gn({present:!1,remaining:$.remaining}),manifest:g()})}catch(o){s={at:t.toISOString(),watched:0,failed:[o.message],changes:[],events:[],checked:[],merged:[],linear:{moved:[],failed:[]},rate:null}}if($.remaining=s.rate?.remaining??null,s.merged.length){try{await at({manifest:g(),log:c,routes:s.merged})}catch(o){c("merge fetch failed:",o.message)}c(`merged on ${s.merged.join(", ")}: fetched and refreshing Main`),xe({manifest:g(),routes:s.merged}).catch(o=>c("merge refresh failed:",o.message))}try{await at({manifest:g(),log:c})}catch(o){c("fetch failed:",o.message)}try{const o=await Ms({manifest:g()});o.length&&xe({manifest:g(),routes:o}).catch(r=>c("pull refresh failed:",r.message))}catch(o){c("pull failed:",o.message)}try{s.failed.push(...(await Dn({manifest:g()})).failed)}catch(o){s.failed.push(`incoming PRs: ${o.message}`)}s.failed.length&&Date.now()-$.errorLoggedAt>De&&($.errorLoggedAt=Date.now(),c(`github check: ${s.failed.length} step${s.failed.length===1?"":"s"} failed, first: ${s.failed[0]} (logged at most hourly)`)),s.changes.length&&c(`github check: ${s.changes.map(o=>`#${o.pr} ${o.changes.join(",")}`).join("; ")} \u2192 ${s.events.map(o=>o.route).join(", ")||"no board"}`);for(const{id:o,state:r}of s.linear.moved)c(`linear: ${o} \u2192 ${r}`);s.linear.failed.length&&Date.now()-$.linearErrorLoggedAt>De&&($.linearErrorLoggedAt=Date.now(),c(`linear check: ${s.linear.failed.length} step${s.linear.failed.length===1?"":"s"} failed, first: ${s.linear.failed[0]} (logged at most hourly)`));try{s.slack=await ia({boards:D(g())})}catch(o){s.slack={sent:[],failed:[o.message]}}for(const o of s.slack.sent)c(`slack: sent ${o}`);s.slack.failed.length&&Date.now()-$.slackErrorLoggedAt>De&&($.slackErrorLoggedAt=Date.now(),c(`slack sends: ${s.slack.failed.length} failed, first: ${s.slack.failed[0]} (logged at most hourly)`));try{s.deaf=await An({manifest:g()})}catch(o){s.deaf={pushed:[],failed:[o.message]}}(s.deaf.pushed.length||s.deaf.failed.length)&&c(`not listening: pushed ${s.deaf.pushed.join(", ")||"none"}${s.deaf.failed.length?`; ${s.deaf.failed.join("; ")}`:""}`);try{Un(g())}catch(o){c("could not record link faults:",o.message)}if(s.flushed=null,s.held=fn(t,n)!==null,e&&!s.held)try{const o=await Ge();o&&(s.flushed={count:o.count,sent:o.sent,ok:o.ok},c(`push queue: sent ${o.sent} of ${o.count} queued`))}catch(o){c("push queue flush failed:",o.message)}return $.last=s,s})().finally(()=>{$.running=null}),$.running}l(Le,"runPoll");let St=null;function wa(){St||=En({settings:F(),manifest:g()}).then(e=>{e.due.length&&c(`wake ${e.due.join(", ")}: check-github \u2192 ${e.events.map(t=>t.route).join(", ")||"no board"}${e.failed.length?`; ${e.failed.join("; ")}`:""}`)}).catch(e=>c("wake failed:",e.message)).finally(()=>{St=null})}l(wa,"runWakes");const Ce=new Set,A={running:null};function kt(){if(A.running||B.size||W.size)return;const e=new Set(Ce);Ce.clear(),A.running=Xn({manifest:g(),asked:e}).then(t=>{(t.resumed.length||t.pushed.length||t.failed.length)&&c(`sessions: resumed ${t.resumed.join(", ")||"none"}; pushed ${t.pushed.join(", ")||"none"}${t.failed.length?`; ${t.failed.join("; ")}`:""}`)}).catch(t=>c("session check failed:",t.message)).then(()=>Yn({manifest:g()})).then(t=>{(t.compacted.length||t.failed.length)&&c(`context full: sent /compact to ${t.compacted.join(", ")||"none"}${t.failed.length?`; ${t.failed.join("; ")}`:""}`)}).catch(t=>c("context full check failed:",t.message)).finally(()=>{A.running=null})}l(kt,"runSessionCheck"),await As(g());const ba=Date.now(),V=new Map;function Z(e){e?(pe.delete(e),x.changed(e)):x.refresh()}l(Z,"promptChanged");async function $t(){const{gone:e,rulesChanged:t}=await G(n=>{const s=rn(n,{now:Date.now(),aliveSince:ba}),o=ye(n,Ae());for(const r of s)delete n.prompts[r.id];return{gone:s,rulesChanged:o}});for(const n of e)Z(n.route);t&&x.refresh(),e.length&&c(`prompts: dropped ${e.map(n=>n.id).join(", ")}, whose hook went away`)}l($t,"prunePrompts");const Ae=l(()=>({running:qe(),boards:D(g())}),"promptContext");async function _t(e){const t=D(g()).find(n=>n.owner?.session&&n.owner.session===e.session_id);if(!t)return null;try{const n=await Js(tt(t.route).repo,t.dir,e);return n&&c(`permissions: /${t.route} was still ${e.hook_event_name==="PermissionDenied"?"refused":"asked about"} ${e.tool_input.command}`),n}catch(n){return c(`permissions: could not note /${t.route}'s refusal: ${n.message}`),null}}l(_t,"noteRefusal");let Q=null,xt=null;async function he(){return Q?xt??=Q.catch(()=>{}).then(()=>(xt=null,he())):(Q=(async()=>_s({gate:ee(),resume:l(async e=>{const t=await Qe(D(g()),"usage-resumed",{window:e});for(const n of t.failed)c(n)},"resume")}))().finally(()=>{Q=null}),Q)}l(he,"checkUsageGate"),setInterval(()=>{xe({manifest:g()}).catch(e=>c("try tick failed:",e.message)),he().catch(e=>c("usage gate failed:",e.message));try{Ps()}catch(e){c("machine check failed:",e.message)}de.check().then(yt,e=>c("presence check failed:",e.message)).finally(()=>{!$.running&&Date.now()>=ga().getTime()&&Le().catch(e=>c("poll failed:",e.message))}),ls({listening:l(e=>Ln(e)&&fe().some(t=>t.route===e.route&&t.state==="up"),"listening")}).then(e=>{e.length&&x.refresh()},e=>c("settling starts failed:",e.message)),wa(),ae.apply(),$t().catch(e=>c("prompt expiry failed:",e.message)).finally(kt)},15e3);function vt(){if(oe)return;oe=!0,clearInterval(pa),ae.stop(),c("hub stopping; stopping sites");for(const n of j.values())n.child&&(n.stopping=!0);for(const n of H)ue(n,"SIGTERM");let e=!1;Ls().finally(()=>{e=!0});const t=Date.now()+dt;setInterval(()=>{if(!((!e||H.size)&&Date.now()<t)){for(const n of H)c(`pid ${n.pid} ignored SIGTERM; killing it`),ue(n,"SIGKILL");process.exit(0)}},50)}l(vt,"shutdown"),process.on("SIGTERM",vt),process.on("SIGINT",vt);const Tt=new Map;function Ot(e,t={n:3e3}){let n=0,s;try{s=b.readdirSync(e,{withFileTypes:!0})}catch{return 0}for(const o of s){if(t.n--<=0)break;if(o.name.startsWith(".")||o.name==="node_modules")continue;const r=k.join(e,o.name);if(o.isDirectory())n=Math.max(n,Ot(r,t));else try{n=Math.max(n,b.statSync(r).mtimeMs)}catch{}}return n}l(Ot,"newestMtime");function ya(e,t){const n=Tt.get(e);if(n&&Date.now()-n.checked<3e4)return n.at;const s=Ot(t.dir);return Tt.set(e,{at:s,checked:Date.now()}),s}l(ya,"changedAt");const jt=l(e=>{try{return typeof e=="string"&&b.statSync(e).isDirectory()}catch{return!1}},"isFolder");function fe(){const{sites:e}=g();return Object.entries(e).sort(([t],[n])=>t.localeCompare(n)).map(([t,n])=>{const s=j.get(t)||{},o=U(n),r=o==="files"?jt(n.dir)?"up":"down":o==="port"?K.has(t)?K.get(t)?"up":"down":"starting":s.child?s.up?"up":"starting":s.state==="crashed"?"down":s.state||"starting";return{route:t,title:n.title||t,description:n.description||"",icon:n.icon||null,color:n.color||null,port:n.port??null,serves:o,state:r,exitCode:s.exitCode??null,owner:n.owner||null,restart:ms(n),changedAt:ya(t,n)||null,created:n.created||null,log:k.join(se,t+".log")}})}l(fe,"status");const N=l(e=>String(e??"").replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]),"esc"),pe=new Map,Pt=2e4,Sa=1500;async function ka(e){try{const t=await fetch(`http://127.0.0.1:${e}/_claude/needs`,{signal:AbortSignal.timeout(Sa)});return t.ok?Wn(await t.json()):null}catch{return null}}l(ka,"fetchNeeds");async function Rt(e,{fresh:t=!1}={}){const n=e.filter(o=>o.state==="up"&&o.owner&&o.port);for(const o of n){const r=pe.get(o.route);(t===!0||t?.has?.(o.route)||!r||Date.now()-r.at>=Pt)&&pe.set(o.route,{at:Date.now(),needs:ka(o.port)})}const s=await Promise.all(n.map(o=>pe.get(o.route).needs));return new Map(n.map((o,r)=>[o.route,s[r]]))}l(Rt,"waitingCounts");const Et=l(e=>[...e.values()].reduce((t,n)=>t+(n?.count||0),0)+We(null).length,"badgeTotal"),Ie=new Map;function $a(e){const t=new Map,n=new Map,s={running:qe(),wakes:Ye(Cn,{})};for(const o of e)try{const r=Ze(o,s);n.set(o.route,r);let p=Ie.get(o.route);(!p||p.listen!==r||Date.now()-p.at>=Pt)&&(p={at:Date.now(),listen:r,links:Bn(o,{listen:r})},Ie.set(o.route,p)),t.set(o.route,p.links)}catch(r){c(`links of ${o.route} failed:`,r.message),t.set(o.route,[])}return{links:t,listens:n}}l($a,"boardLinks");async function X({fresh:e=!1}={}){const t=new Date,n=fe(),s=await Rt(n,{fresh:e}),o=g(),r=D(o),{links:p,listens:h}=$a(r),u=Fn(),_=new Map(r.map(i=>[i.route,xs(i.owner?.session)]).filter(([,i])=>i)),S=qn(o,t.getTime(),cs,i=>h.get(i)==="listening"&&n.some(d=>d.route===i&&d.state==="up")),y=new Set(S.map(i=>i.route)),m=new Map(r.filter(i=>js(i.dir)).map(i=>[i.route,ee({dir:i.dir})])),w=Vn(n,s,new Set(r.map(i=>i.route)),h,_).filter(i=>!y.has(i.route)).map(i=>m.has(i.route)?{...i,pause:m.get(i.route)}:i),O=F(),C=hn(t,O);return{data:{code:u.running??null,version:ct,total:Et(s),start:Zn(w),sites:w,hub:Qn(w,p,Nn(u)),home:b.realpathSync(Ne.homedir()),starting:S,usage:(({start:i,...d})=>d)(la({codexShare:Je(te())})),pause:ee(),notifications:C?C.why==="off"?{state:"off",text:"Off"}:{state:"held",text:yn(pn(C,t))}:{state:"on",text:"On"},away:!de.state().present},links:p,rows:n,code:u}}l(X,"sidebarState");function Dt(e,t,n,s,o){let r=Buffer.isBuffer(o)?o:Buffer.from(o);const p={...s,vary:[s.vary,"accept-encoding"].filter(Boolean).join(", ")};r.length>=1024&&/\bgzip\b/.test(e.headers["accept-encoding"]||"")&&(r=zt.gzipSync(r),p["content-encoding"]="gzip"),p["content-length"]=r.length,t.writeHead(n,p),t.end(e.method==="HEAD"?void 0:r)}l(Dt,"sendText");async function _a(e,t=null){const n=t?t.sidebar((await X()).data):(await X()).data,s=Fe.createContext({});for(const o of["ui-progress-bar.js","ui-page-header.js","ui-sidebar.js"])Fe.runInContext(b.readFileSync(k.join(_e,o),"utf8"),s);return Ss({sidebar:s.BoardUI.sidebar(n,{here:e}),sidebarData:JSON.stringify(n).replace(/</g,"\\u003c"),touchIcon:me(180)})}l(_a,"shellPage");const Lt=Us({root:Te(),run:xa,log:c,pulled:l(()=>gt.restartNow(),"pulled")});function xa(){const e=k.join(se,"kickflip-update.log");b.writeFileSync(e,`${new Date().toISOString()} kickflip update
|
|
6
|
-
`);const t=b.openSync(e,"a");return c("pulling the new kickflip"),new Promise(n=>{const s=
|
|
7
|
-
`).at(-1);c(`kickflip update exited ${o}: ${r}`),n(o===0?{ok:!0}:{ok:!1,error:r.replace(/^kickflip: /,"")})})})}
|
|
8
|
-
<title>${N(e)}</title><link rel="manifest" href="/manifest.webmanifest"><link rel="icon" type="image/png" href="/_hub/kickflip.png"><link rel="apple-touch-icon" href="${
|
|
9
|
-
<meta name="apple-mobile-web-app-title" content="Claude">${t}<link rel="stylesheet" href="/_hub/colors.css?v=${nt()}"><link rel="stylesheet" href="/_hub/hub.css"></head><body><main>`,"head"),Ta=l(e=>/\bapplication\/json\b/.test(e.headers.accept||""),"wantsJSON");async function ge(e){await $t();const t=new Date,{data:n,links:s,rows:o,code:r}=await X();return{...Sn({now:t,st:ze(t),sidebar:n,rows:o,links:s,rules:ke(),integrations:await Rs.rows(),code:r,version:ct,host:e.headers["x-forwarded-host"]||e.headers.host||"",codexShare:Je(te()),claudeModel:en(),codex:te(),awake:{on:Tn(),unsupported:On()},remembered:new Map(D(g()).map(p=>[p.route,Ve(p.route)]))}),prompts:We(null).map(ln),remembered:Ve(null)}}l(ge,"hubData");const I=l(async()=>{const e=new Date,t=ee({now:e}),n=t.manual==="hub"?jn(t.resets_at,e):null;return{...kn({now:e,st:ze(e),rules:ke(),linear:ne(),slack:aa(),pausedUntil:n}),closed:hs()}},"settingsData"),R=l((e,t)=>{e.writeHead(303,{location:t,"cache-control":"no-store"}),e.end()},"redirect");async function Oa(e,t,n,s=null){const o=l((a,i,d,f={})=>{t.writeHead(a,{"content-type":i,"cache-control":"no-store",...f}),t.end(d)},"send"),r=l((a,i)=>{const d=Hn();return o(a,"application/json",JSON.stringify(i),d?{"x-hub-code":d}:{})},"sendJSON"),p=l(({route:a,started:i})=>(i.then(({error:d})=>{d&&c(`new board /${a} didn't start: ${d}`),x.refresh()}),x.refresh(),r(200,{route:a})),"starting"),h=Ta(e),u=n.pathname,_=/^\/_hub\/close-board\/([a-z0-9-]+)$/.exec(u);if(_&&e.method==="GET")try{return r(200,await us(_[1]))}catch(a){return r(a.status||400,{error:a.message})}if(["/_hub/close-board","/_hub/reopen-board"].includes(u)&&e.method==="POST"){let a;try{const i=await T(e);if(a=i.route,u==="/_hub/reopen-board"){const v=await fs(a);return x.refresh(),r(200,v)}if(B.has(a))return r(409,{error:"That board is already closing."});B.add(a);const d=l(()=>B.delete(a),"release");t.once("finish",d),t.once("close",d),await A.running;const f=await ds(a,i.name);return x.refresh(),r(200,f)}catch(i){return r(i.status||400,{error:i.message})}}if(u==="/_hub/restart-board"&&e.method==="POST"){let a;try{if(a=(await T(e)).route,W.has(a))return r(409,{error:"That board is already restarting."});W.add(a),await A.running;const i=await ps(a,{serverUp:fa,portUp:Re,onStarting:l(d=>c(`restart /${a}: ${d}\u2019s session is back, its server is still starting`),"onStarting")});return c(`restarted /${a}: server answers, session ${i.session} resumed`),r(200,i)}catch(i){return a&&c(`restart /${a} failed: ${i.message}`),r(i.status||400,{error:i.message})}finally{W.delete(a),x.refresh()}}if(u==="/_hub/restart-hub"&&e.method==="POST")try{return await T(e),Ee?r(409,{error:"The hub is already restarting."}):(Ee=!0,t.once("finish",()=>{c("restarting the hub from the hub page");const a=ot("hub");a&&(Ee=!1,c(`hub restart failed: ${a}`))}),r(200,{restarting:!0,command:ea().restartCommand}))}catch(a){return r(a.status||400,{error:a.message})}if(u==="/_hub/presence"&&e.method==="POST")return await T(e),yt(de.beat()),t.writeHead(204,{"cache-control":"no-store"}),t.end();if(u==="/_hub/wake"&&e.method==="POST")try{const{route:a}=await T(e),i=D(g()).find(d=>d.route===a);return i?(Ce.add(a),await A.running,kt(),await A.running,Ie.clear(),x.changed(a),r(200,{route:a,listen:Ze(i)})):r(404,{error:"That board is not open."})}catch(a){return r(a.status||400,{error:a.message})}if(u==="/_hub/repos"&&e.method==="GET")try{return r(200,await M.list(g(),{fresh:n.searchParams.get("fresh")==="1"}))}catch(a){return r(a.status||502,{error:a.message})}if(u==="/_hub/branches"&&e.method==="GET")try{return r(200,await M.branches(n.searchParams.get("github")??n.searchParams.get("remote")))}catch(a){return r(a.status||502,{error:a.message})}if(u==="/_hub/repo"&&e.method==="GET")return ts(n,{repos:M,manifest:g(),sendJSON:r});const S=/^\/_hub\/board-repo\/([a-z0-9-]+)$/.exec(u);if(S&&["GET","POST"].includes(e.method))try{const a=e.method==="GET"?tt(S[1]):await ns(S[1],await T(e),{repos:M});return e.method==="POST"&&x.refresh(),r(200,a)}catch(a){return r(a.status||400,{error:a.message})}if(u==="/_hub/new-board"&&e.method==="POST"){if(!$e(e.headers))return o(403,"text/plain; charset=utf-8","Send name, github, branch and purpose as application/json.");let a;try{a=await T(e)}catch{return o(400,"text/plain; charset=utf-8","Send name, github, branch and purpose as JSON.")}try{return p(await rs(a,{repos:M,changed:l(()=>x.refresh(),"changed")}))}catch(i){if(i.status)return o(i.status,"text/plain; charset=utf-8",i.message);throw i}}const y=/^\/_hub\/new-board\/([a-z0-9-]+)\/retry$/.exec(u);if(y&&e.method==="POST"){if(!$e(e.headers))return o(403,"text/plain; charset=utf-8","Send the retry as application/json.");try{return p(await os(y[1],{repos:M,changed:l(()=>x.refresh(),"changed")}))}catch(a){if(a.status)return o(a.status,"text/plain; charset=utf-8",a.message);throw a}}const m=/^\/_hub\/new-board\/([a-z0-9-]+)$/.exec(u);if(m&&e.method==="DELETE")try{const a=await is(m[1]);return x.refresh(),r(200,a)}catch(a){if(a.status)return o(a.status,"text/plain; charset=utf-8",a.message);throw a}if((u==="/_hub/build"||u==="/_hub/pull")&&e.method==="POST"){if(oe)return r(503,{error:"The hub is stopping. Try again after it restarts."});const a=["127.0.0.1","::1","::ffff:127.0.0.1"],i=(e.headers.host||"").replace(/:\d+$/,"");if(!a.includes(e.socket.remoteAddress)||!["127.0.0.1","localhost","[::1]"].includes(i)||e.headers.origin||Object.keys(e.headers).some(f=>f==="forwarded"||f.startsWith("x-forwarded-")))return r(403,{error:`${u==="/_hub/pull"?"Pulls":"Builds"} can only be started from the host over loopback.`});let d;try{d=await T(e)}catch(f){return r(f.status||400,{error:u==="/_hub/pull"?"Send the board route as JSON.":"Send the board route and card key as JSON."})}try{return u==="/_hub/pull"?r(200,await Is(d,{manifest:g()})):r(202,await Ds(d,{manifest:g(),log:c}))}catch(f){if(f instanceof Es)return r(f.status,{error:f.message});throw f}}if(u==="/sw.js")return o(200,"text/javascript; charset=utf-8",b.readFileSync(k.join(z,"sw.js")),{"service-worker-allowed":"/"});if(u==="/manifest.webmanifest")return o(200,"application/manifest+json",va());const w=/^\/_hub\/icon-(180|192|512)\.png$/.exec(u);if(w)try{return o(200,"image/png",b.readFileSync(k.join(z,`icon-${w[1]}.png`)),{"cache-control":"max-age=86400"})}catch{t.writeHead(404).end();return}if(u==="/_hub/colors.css"){const a=n.searchParams.get("v")===nt()?"public, max-age=31536000, immutable":"no-cache";try{return o(200,"text/css; charset=utf-8",b.readFileSync(ca),{"cache-control":a})}catch{t.writeHead(404).end();return}}if(u==="/_hub/kickflip.png")try{return o(200,"image/png",b.readFileSync(k.join(_e,"kickflip.png")),{"cache-control":"max-age=86400"})}catch{t.writeHead(404).end();return}if(u==="/_hub/hub.css")return o(200,"text/css; charset=utf-8",b.readFileSync(k.join(_e,"hub.css")));if(u==="/_hub/sidebar.json"){const{data:a}=await X({fresh:n.searchParams.get("fresh")==="1"});return r(200,s?s.sidebar(a):a)}if(u==="/_hub/stream"&&e.method==="GET"){if(s){const a={login:s.login,routes:s.routes,res:t};we.add(a),e.on("close",()=>we.delete(a)),e.on("close",Y.watching(s.login))}x.handle(e,t,s&&{routes:s.routes,view:s.sidebar});return}const O=/^\/_hub\/share\/([a-z0-9-]+)$/.exec(u);if(O&&e.method==="GET"){const a=P(g().sites,O[1]);if(!Se(a))return r(404,{error:"That board is not open."});const i=Zs(Ye(k.join(a.dir,"board.json"),{})),d=i.map(L=>L.login),f=(await ve.node())?.dnsName||e.headers["x-forwarded-host"],v=Y.people(O[1],d).map((L,q)=>({...L,level:i[q].level}));return r(200,{people:v,refused:Y.refusals(O[1],d),link:f?`https://${f}/${O[1]}/`:null})}if(u.startsWith("/_hub/app/")){const a=ws(u.slice(10));if(!a){t.writeHead(404).end();return}const i=k.extname(a),d={".js":"text/javascript; charset=utf-8",".css":"text/css; charset=utf-8",".png":"image/png"}[i],f=n.searchParams.get("v")===ys(a)?"public, max-age=31536000, immutable":"no-cache";return i===".png"?o(200,d,b.readFileSync(a),{"cache-control":f}):Dt(e,t,200,{"content-type":d,"cache-control":f},b.readFileSync(a))}if(u==="/_hub/api/hub")return r(200,await ge(e));if(u==="/_hub/api/settings")return r(200,await I());if(u==="/_hub/push/key")return r(200,{key:Jt()});if(u==="/_hub/push/subscribe"&&e.method==="POST"){const a=e.headers["x-forwarded-host"]||e.headers.host;return r(200,Kt(await T(e),`https://${a}/`,e.headers["user-agent"]))}if(u==="/_hub/push/forget"&&e.method==="POST")return r(200,{count:Vt((await T(e))?.id)});if(u==="/_hub/push/unsubscribe"&&e.method==="POST")return r(200,{count:Wt((await T(e))?.endpoint)});if(u==="/_hub/push/test"&&e.method==="POST"){const a=await Zt({title:"Claude",body:"Test notification from the hub.",url:"/",tag:"hub-test"});return r(200,{sent:a.filter(i=>i.status>=200&&i.status<300).length,results:a})}if(u==="/_hub/settings/notifications"&&e.method==="POST"){const a=new URLSearchParams(await E(e)),i=a.get("set"),d=i==="on"?{on:a.get("value")==="on"}:i==="weekends"?{weekends_off:a.get("value")==="on"}:a.has("snooze")?{snooze:a.get("snooze")}:null,f="Pass set=on or set=weekends with value=on, or snooze.";if(!d)return h?r(400,{error:f}):o(400,"text/plain; charset=utf-8",f);let v;try{v=await Yt(d)}catch(L){if(L.status!==400)throw L;return h?r(400,{error:_n}):R(t,"/_hub/?refused=snooze#notifications")}return v.dropped&&c(`notifications off: dropped ${v.dropped} queued push${v.dropped===1?"":"es"}`),h?r(200,await I()):R(t,"/_hub/#notifications")}if(u==="/_hub/settings/keep-awake"&&e.method==="POST"){const a=new URLSearchParams(await E(e));try{vn(a.get("keep_awake")==="on")}catch(i){if(i.status!==409)throw i;return h?r(409,{error:i.message}):o(409,"text/plain; charset=utf-8",i.message)}return ae.apply(),h?r(200,await I()):R(t,"/_hub/#keep-awake")}if(u==="/_hub/settings/hub-pause"&&e.method==="POST"){const a=new URLSearchParams(await E(e));try{Os(a.get("pause"))}catch(i){if(i.status!==400)throw i;return h?r(400,{error:i.message}):o(400,"text/plain; charset=utf-8",i.message)}return await he(),h?r(200,await I()):R(t,"/_hub/#pause")}if(u==="/_hub/settings/codex"&&e.method==="POST"){const a=new URLSearchParams(await E(e));try{qt(a.get("value"),te({fresh:!0}))}catch(i){if(i.status!==400&&i.status!==409)throw i;return h?r(i.status,{error:i.message}):o(i.status,"text/plain; charset=utf-8",i.message)}return h?r(200,await ge(e)):R(t,"/_hub/")}if(u==="/_hub/settings/claude-model"&&e.method==="POST"){const a=new URLSearchParams(await E(e));try{tn(a.get("value"))}catch(i){if(i.status!==400)throw i;return h?r(400,{error:i.message}):o(400,"text/plain; charset=utf-8",i.message)}return h?r(200,await ge(e)):R(t,"/_hub/")}if(["/_hub/settings/pause-at","/_hub/settings/keep-going"].includes(u)&&e.method==="POST"){const a=new URLSearchParams(await E(e));try{u.endsWith("/pause-at")?vs(a.get("value")):Ts(a.get("value")),await he()}catch(i){if(i.status!==400&&i.status!==409)throw i;return h?r(i.status,{error:i.message}):o(i.status,"text/plain; charset=utf-8",i.message)}return h?r(200,await ge(e)):R(t,"/_hub/")}if(u==="/_hub/settings/linear"&&e.method==="POST"){const a=new URLSearchParams(await E(e,4096));try{a.has("remove")?(ta(),c("linear: key removed"),await wt()):c(`linear: key ${(await na(a.get("key"))).refused?"refused by Linear":"saved"}`)}catch(i){if(!(i instanceof Oe))throw i;return h?r(i.status,{error:i.message}):o(i.status,"text/plain; charset=utf-8",i.message)}return h?r(200,await I()):R(t,"/_hub/#linear")}if(u==="/_hub/settings/slack"&&e.method==="POST"){const a=new URLSearchParams(await E(e,4096));try{a.has("remove")?(ra(),c("slack: token removed")):c(`slack: token ${(await oa(a.get("token"))).refused?"refused by Slack":"saved"}`)}catch(i){if(!(i instanceof sa))throw i;return h?r(i.status,{error:i.message}):o(i.status,"text/plain; charset=utf-8",i.message)}return h?r(200,await I()):R(t,"/_hub/#slack")}if(u==="/_hub/api/linear"&&e.method==="GET"){const a=ne();if(!a.set||a.refused)return r(200,a);const i=it();try{const d=await i.teams();return r(200,{...ne(),teams:d.map(({key:f,name:v})=>({key:f,name:v})),projects:d.flatMap(f=>f.projects.map(v=>({id:v.id,name:v.name,team:f.key})))})}catch(d){if(!(d instanceof Oe))throw d;return r(200,{...ne(),error:d.message})}}if(u==="/_hub/linear/projects"&&e.method==="POST"){const a=it();if(!a)return r(400,{error:"Add a Linear key on Hub first."});let i;try{i=await T(e,4*1024)}catch(f){return r(f.status||400,{error:"Send the team and project name as JSON."})}const d=typeof i.name=="string"?i.name.trim():"";if(!d||d.length>80||typeof i.team!="string"||!/^[A-Z][A-Z0-9]*$/.test(i.team))return r(400,{error:"Name the project and its team."});try{return r(200,await a.createProject(i.team,d))}catch(f){if(!(f instanceof Oe))throw f;return r(f.status,{error:f.message})}}if(u==="/_hub/settings/quiet"&&e.method==="POST"){const a=new URLSearchParams(await E(e)),i=Object.fromEntries(["start","end"].filter(d=>a.has(d)).map(d=>[d,a.get(d).trim()]));a.get("set")==="hold"&&(i.hold=a.get("value")||"off");try{Qt(mn(F(),i))}catch(d){if(d.status!==400)throw d;return h?r(400,{error:d.message}):o(400,"text/plain; charset=utf-8",d.message)}return x.refresh(),h?r(200,await I()):R(t,"/_hub/#quiet")}if(u==="/_hub/settings/rules"&&e.method==="POST"){let a,i;try{a=await T(e,64*1024),i=zn(ke(),a.rules,a.base??[]),et(i.changes)&&Jn(i.rules)}catch(f){if(!(f instanceof Gn)&&!(f instanceof SyntaxError))throw f;return r(400,{error:f.message})}if(!et(i.changes))return r(200,{rules:i.rules,changed:!1,events:[],failed:[]});const d=await Qe(D(g()),"rules",{scope:"hub",...i.changes});return c(`hub rules saved: rules \u2192 ${d.events.map(f=>f.route).join(", ")||"no board"}${d.failed.length?`; ${d.failed.join("; ")}`:""}`),r(200,{rules:i.rules,changed:!0,...d})}if(u==="/_hub/push/flush"&&e.method==="POST"){const a=await Ge();return a&&c(`push queue: ${a.ok?"sent":"could not send"} ${a.count} queued (from the settings page)`),h?r(200,$n(a)):R(t,"/_hub/")}if(u==="/_hub/poll"&&e.method==="POST"){const a=await T(e);return r(200,await Le({flush:a.flush!==!1}))}if(u==="/_hub/prompts"&&e.method==="POST"){let a;try{a=await T(e,8*1024*1024)}catch(f){if(f.status)throw f;return r(400,{error:"Send the hook input as JSON."})}if(!a?.tool_name||a.hook_event_name&&a.hook_event_name!=="PermissionRequest")return r(400,{error:"Send a PermissionRequest hook input."});const{answer:i,rec:d}=await G(f=>{const v=Ae(),L=v.boards.find(He=>He.owner?.session&&He.owner.session===a.session_id)||null;ye(f,v)&&x.refresh();const q=dn(f,a);if(q)return{answer:q};const be=on(a,{board:L,live:v.running?.get(a.session_id)});return f.prompts[be.id]=be,{rec:be}});return i?r(200,{state:"answered",answer:i}):(Z(d.route),await _t(a),c(`prompt ${d.id}: ${d.route?`/${d.route}`:d.cwd} ${d.tool}${d.action?` (${d.action})`:""}`),nn(cn(d),{kind:"prompt",immediate:!0}).catch(f=>c(`prompt ${d.id}: push failed: ${f.message}`)),r(200,{id:d.id,deadline:d.deadline}))}if(u==="/_hub/permission-denied"&&e.method==="POST"){let a;try{a=await T(e,8*1024*1024)}catch(i){if(i.status)throw i;return r(400,{error:"Send the hook input as JSON."})}return a?.hook_event_name!=="PermissionDenied"?r(400,{error:"Send a PermissionDenied hook input."}):r(200,{noted:await _t(a)})}const C=/^\/_hub\/prompts\/([\w-]+)(\/wait|\/answer)?$/.exec(u);return C?ja(e,t,{id:C[1],op:C[2]?.slice(1)||null,url:n,sendJSON:r}):!1}l(Oa,"hubRoute");async function ja(e,t,{id:n,op:s,url:o,sendJSON:r}){if(s==="wait"&&e.method==="GET"){const p=l(()=>G(y=>{const m=P(y.prompts,n);return m?.answer?delete y.prompts[n]:m&&(m.seen=new Date().toISOString()),m}),"seen"),h=l(y=>(Z(y.route),r(200,{state:"answered",answer:y.answer})),"take"),u=await p();if(!u)return r(404,{state:"gone"});if(u.answer)return h(u);const _=Math.max(0,Math.min(Number(o.searchParams.get("ms"))||Ke,Ke));if(await new Promise(y=>{const m=V.get(n)||new Set;V.set(n,m);const w=l(()=>{clearTimeout(O),m.delete(w),!m.size&&V.get(n)===m&&V.delete(n),y()},"done"),O=setTimeout(w,_);m.add(w),t.on("close",w)}),t.writableEnded||t.destroyed)return;const S=await p();return S?S.answer?h(S):r(200,{state:"pending"}):r(404,{state:"gone"})}if(s==="answer"&&e.method==="POST"){const p=await T(e),h=await G(S=>{const y=P(S.prompts,n);if(!y)return{status:404,error:"That prompt has expired."};if(y.answer)return{status:409,error:"That prompt was already answered."};const{answer:m,error:w}=an(y,p);if(w)return{status:400,error:w};y.answer=m;const O=Ae();return ye(S,O),un(S,y,O),{rec:y,answer:m}});if(h.error)return r(h.status,{error:h.error});const{rec:u,answer:_}=h;c(`prompt ${n}: ${_.choice}`);for(const S of[...V.get(n)||[]])S();return Z(u.route),r(200,{ok:!0})}if(s===null&&e.method==="DELETE"){const p=await G(h=>{const u=P(h.prompts,n);return delete h.prompts[n],u});return p&&(Z(p.route),c(`prompt ${n}: its hook gave up`)),r(200,{ok:!0})}return!1}l(ja,"promptRoute");const Pa=l(({icon:e="",title:t,sub:n=""})=>`<div class="row">${e}<span class="body"><span class="text"><span class="title">${t}</span>${n?`<span class="sub">${n}</span>`:""}</span></span></div>`,"row");function At(e,t){const n=j.get(e)||{},s=U(t);return s==="port"?"stopped":s==="files"?"missing":n.why==="code"?"code":n.child?"starting":"crashed"}l(At,"downWhy");function It(e,t,n){e.writeHead(502,{"content-type":"text/html; charset=utf-8","cache-control":"no-store","x-kickflip-down":At(t,n)}),e.end(Ra(t,n))}l(It,"sendDown");function Ra(e,t){const n=j.get(e)||{},s=n.exitCode!==void 0&&n.exitCode!==null?` (last exit: ${n.exitCode})`:"",o={stopped:`down \xB7 nothing answers on port ${t.port}`,missing:"down \xB7 its folder is missing",code:"restarting \xB7 its code changed",starting:"starting \xB7 waiting for it to answer",crashed:`down \xB7 restarting${s}`},r=At(e,t),p=r==="code"||r==="starting"?"wait":"down",h=U(t),u=h==="cmd"?"The hub runs every site and restarts one that stops.":h==="port"?"Its session runs this server, so it comes back when that session starts it again.":"The hub serves this page from a folder that is no longer there.";return`${Ct(`${t.title||e} is down`,'<meta http-equiv="refresh" content="5">')}
|
|
4
|
+
`),n.code=ke(t)?qe(t):null;let h;try{h=Ne(o,r,{cwd:t.cwd||t.dir,env:p,stdio:["ignore",s,s],detached:!0})}catch(_){b.writeSync(s,`hub could not start it: ${_.message}
|
|
5
|
+
`),b.closeSync(s),Object.assign(n,{child:null,key:ce(t),state:"crashed",exitCode:null,nextStart:Date.now()+pt(n.restarts++)}),j.set(e,n);return}b.closeSync(s),b.writeFileSync(le(e),String(h.pid)),Object.assign(n,{child:h,key:ce(t),state:"starting",since:Date.now(),exitCode:null,up:!1}),j.set(e,n),c(`started ${e} pid ${h.pid}`),h.pid&&M.add(h);const l=u((_,S)=>{if(M.delete(h),n.child!==h)return;try{b.unlinkSync(le(e))}catch{}Date.now()-n.since>6e4&&(n.restarts=0),Object.assign(n,{child:null,state:n.stopping?"stopped":"crashed",exitCode:_??S,nextStart:Date.now()+pt(n.restarts++),why:n.stopping?n.why:void 0}),n.stopping=!1,c(`${e} exited (${_??S})`)},"ended");h.on("error",_=>{c(`${e} spawn error: ${_.message}`),h.pid||l(_.code,null)}),h.on("exit",l)}u(da,"start");const pt=u(e=>Math.min(6e4,1e3*2**Math.min(e,6)),"backoff");function ue(e){const t=j.get(e);if(!t||!t.child){j.delete(e);return}t.stopping=!0;const n=t.child;de(n,"SIGTERM"),setTimeout(()=>{M.has(n)&&de(n,"SIGKILL")},ft)}u(ue,"stop");const de=u((e,t)=>{try{process.kill(-e.pid,t)}catch{}},"signal");function Re(e){return new Promise(t=>{const n=Ft.connect({port:e,host:"127.0.0.1"}),s=u(o=>{n.destroy(),t(o)},"done");n.setTimeout(800,()=>s(!1)),n.on("connect",()=>s(!0)),n.on("error",()=>s(!1))})}u(Re,"probe");let mt=!0;const B=new Set;let Ee=!1;const W=new Set,ha=3e4;async function fa(e){const t=Date.now()+ha;for(;;){const n=P(g().sites,e),s=j.get(e);if(n&&s?.child&&s.up&&s.key===ce(n))return!0;if(Date.now()>=t)return!1;await new Promise(o=>setTimeout(o,250))}}u(fa,"serverAnswers");async function gt(){if(ie)return;wt&&bt.check();const{sites:e}=g();for(const t of[...j.keys()])B.has(t)||(P(e,t)?Pe(P(e,t))||(c(`${t} no longer has a command; stopping it`),ue(t),j.delete(t)):(c(`${t} left the manifest; stopping it`),ue(t),j.delete(t)));for(const t of[...K.keys()])U(P(e,t))!=="port"&&K.delete(t);for(const[t,n]of Object.entries(e))U(n)==="port"&&K.set(t,await Re(n.port));for(const[t,n]of Object.entries(e)){if(!Pe(n))continue;mt&&ua(t,n);const s=j.get(t);if(s&&s.child&&s.key!==ce(n)){c(`${t} entry changed; restarting it`),ue(t),s.restarts=0,s.nextStart=Date.now()+1500;continue}const o=wt&&s?.child&&s.code&&!s.stopping&&!W.has(t)&&!B.has(t)?qe(n):null;if(o&&o!==s.code){if(!(await zs(n.dir)).length){c(`${t} runs older code than its files; restarting it`),ue(t),Object.assign(s,{restarts:0,nextStart:Date.now()+1500,why:"code"});continue}s.waitedOn!==o&&c(`${t} runs older code than its files; restarts once no builder runs`),s.waitedOn=o}(!s||!s.child&&Date.now()>=(s.nextStart||0))&&da(t,n)}mt=!1;for(const[t,n]of j){const s=P(e,t);s&&(n.up=n.child?await Re(s.port):!1,n.child&&n.up&&Object.assign(n,{state:"up"},n.stopping?{}:{why:void 0}))}}u(gt,"reconcile");const wt=process.env.KICKFLIP_CODE_RESTARTS!=="off",bt=Ns({root:ne(),files:Hn().files,restart:u(()=>ct("hub"),"restart"),log:c}),pa=setInterval(()=>gt().catch(e=>c("reconcile failed:",e.message)),2e3);gt().catch(e=>c("reconcile failed:",e.message));try{Xt()&&c("settings: migrated the polling windows to quiet hours")}catch(e){c("settings migration failed:",e.message)}const yt=u(async()=>{try{for(const e of await Fs())c(`linear: ${e}`)}catch(e){c("linear fallback failed:",e.message)}},"linearFallback");await yt();const ma=6e4,De=60*6e4,$={running:null,startedAt:0,firstAt:Date.now()+ma,last:null,remaining:null,errorLoggedAt:0,linearErrorLoggedAt:0,slackErrorLoggedAt:0},he=sn(),St=u(()=>bn(new Date,F()),"quietNow"),ga=u(()=>$.startedAt?wn({last:new Date($.startedAt),...he.state(),quiet:St(),remaining:$.remaining}):new Date($.firstAt),"nextPollAt");function kt({changed:e,back:t}){e&&x.refresh(),t&&$.startedAt&&!$.running&&!St()&&Le().catch(n=>c("poll failed:",n.message))}u(kt,"presenceChanged");function Le({flush:e=!0}={}){if($.running)return $.running;const t=new Date;return $.startedAt=t.getTime(),$.running=(async()=>{const n=F();let s;try{s=await Rn({every:gn({present:!1,remaining:$.remaining}),manifest:g()})}catch(o){s={at:t.toISOString(),watched:0,failed:[o.message],changes:[],events:[],checked:[],merged:[],linear:{moved:[],failed:[]},rate:null}}if($.remaining=s.rate?.remaining??null,s.merged.length){try{await ot({manifest:g(),log:c,routes:s.merged})}catch(o){c("merge fetch failed:",o.message)}c(`merged on ${s.merged.join(", ")}: fetched and refreshing Main`),ve({manifest:g(),routes:s.merged}).catch(o=>c("merge refresh failed:",o.message))}try{await ot({manifest:g(),log:c})}catch(o){c("fetch failed:",o.message)}try{const o=await Hs({manifest:g()});o.length&&ve({manifest:g(),routes:o}).catch(r=>c("pull refresh failed:",r.message))}catch(o){c("pull failed:",o.message)}try{s.failed.push(...(await Dn({manifest:g()})).failed)}catch(o){s.failed.push(`incoming PRs: ${o.message}`)}s.failed.length&&Date.now()-$.errorLoggedAt>De&&($.errorLoggedAt=Date.now(),c(`github check: ${s.failed.length} step${s.failed.length===1?"":"s"} failed, first: ${s.failed[0]} (logged at most hourly)`)),s.changes.length&&c(`github check: ${s.changes.map(o=>`#${o.pr} ${o.changes.join(",")}`).join("; ")} \u2192 ${s.events.map(o=>o.route).join(", ")||"no board"}`);for(const{id:o,state:r}of s.linear.moved)c(`linear: ${o} \u2192 ${r}`);s.linear.failed.length&&Date.now()-$.linearErrorLoggedAt>De&&($.linearErrorLoggedAt=Date.now(),c(`linear check: ${s.linear.failed.length} step${s.linear.failed.length===1?"":"s"} failed, first: ${s.linear.failed[0]} (logged at most hourly)`));try{s.slack=await ia({boards:D(g())})}catch(o){s.slack={sent:[],failed:[o.message]}}for(const o of s.slack.sent)c(`slack: sent ${o}`);s.slack.failed.length&&Date.now()-$.slackErrorLoggedAt>De&&($.slackErrorLoggedAt=Date.now(),c(`slack sends: ${s.slack.failed.length} failed, first: ${s.slack.failed[0]} (logged at most hourly)`));try{s.deaf=await An({manifest:g()})}catch(o){s.deaf={pushed:[],failed:[o.message]}}(s.deaf.pushed.length||s.deaf.failed.length)&&c(`not listening: pushed ${s.deaf.pushed.join(", ")||"none"}${s.deaf.failed.length?`; ${s.deaf.failed.join("; ")}`:""}`);try{Un(g())}catch(o){c("could not record link faults:",o.message)}if(s.flushed=null,s.held=fn(t,n)!==null,e&&!s.held)try{const o=await Je();o&&(s.flushed={count:o.count,sent:o.sent,ok:o.ok},c(`push queue: sent ${o.sent} of ${o.count} queued`))}catch(o){c("push queue flush failed:",o.message)}return $.last=s,s})().finally(()=>{$.running=null}),$.running}u(Le,"runPoll");let $t=null;function wa(){$t||=En({settings:F(),manifest:g()}).then(e=>{e.due.length&&c(`wake ${e.due.join(", ")}: check-github \u2192 ${e.events.map(t=>t.route).join(", ")||"no board"}${e.failed.length?`; ${e.failed.join("; ")}`:""}`)}).catch(e=>c("wake failed:",e.message)).finally(()=>{$t=null})}u(wa,"runWakes");const Ce=new Set,A={running:null};function _t(){if(A.running||B.size||W.size)return;const e=new Set(Ce);Ce.clear(),A.running=Xn({manifest:g(),asked:e}).then(t=>{(t.resumed.length||t.pushed.length||t.failed.length)&&c(`sessions: resumed ${t.resumed.join(", ")||"none"}; pushed ${t.pushed.join(", ")||"none"}${t.failed.length?`; ${t.failed.join("; ")}`:""}`)}).catch(t=>c("session check failed:",t.message)).then(()=>Yn({manifest:g()})).then(t=>{(t.compacted.length||t.failed.length)&&c(`context full: sent /compact to ${t.compacted.join(", ")||"none"}${t.failed.length?`; ${t.failed.join("; ")}`:""}`)}).catch(t=>c("context full check failed:",t.message)).finally(()=>{A.running=null})}u(_t,"runSessionCheck"),await As(g());const ba=Date.now(),V=new Map;function Z(e){e?(me.delete(e),x.changed(e)):x.refresh()}u(Z,"promptChanged");async function xt(){const{gone:e,rulesChanged:t}=await G(n=>{const s=rn(n,{now:Date.now(),aliveSince:ba}),o=Se(n,Ae());for(const r of s)delete n.prompts[r.id];return{gone:s,rulesChanged:o}});for(const n of e)Z(n.route);t&&x.refresh(),e.length&&c(`prompts: dropped ${e.map(n=>n.id).join(", ")}, whose hook went away`)}u(xt,"prunePrompts");const Ae=u(()=>({running:tt(),boards:D(g())}),"promptContext");async function vt(e){const t=D(g()).find(n=>n.owner?.session&&n.owner.session===e.session_id);if(!t)return null;try{const n=await Js(st(t.route).repo,t.dir,e);return n&&c(`permissions: /${t.route} was still ${e.hook_event_name==="PermissionDenied"?"refused":"asked about"} ${e.tool_input.command}`),n}catch(n){return c(`permissions: could not note /${t.route}'s refusal: ${n.message}`),null}}u(vt,"noteRefusal");let Q=null,Tt=null;async function fe(){return Q?Tt??=Q.catch(()=>{}).then(()=>(Tt=null,fe())):(Q=(async()=>_s({gate:ee(),resume:u(async e=>{const t=await Ye(D(g()),"usage-resumed",{window:e});for(const n of t.failed)c(n)},"resume")}))().finally(()=>{Q=null}),Q)}u(fe,"checkUsageGate"),setInterval(()=>{ve({manifest:g()}).catch(e=>c("try tick failed:",e.message)),fe().catch(e=>c("usage gate failed:",e.message));try{Ps()}catch(e){c("machine check failed:",e.message)}he.check().then(kt,e=>c("presence check failed:",e.message)).finally(()=>{!$.running&&Date.now()>=ga().getTime()&&Le().catch(e=>c("poll failed:",e.message))}),ls({listening:u(e=>Ln(e)&&pe().some(t=>t.route===e.route&&t.state==="up"),"listening")}).then(e=>{e.length&&x.refresh()},e=>c("settling starts failed:",e.message)),wa(),re.apply(),xt().catch(e=>c("prompt expiry failed:",e.message)).finally(_t)},15e3);function Ot(){if(ie)return;ie=!0,clearInterval(pa),re.stop(),c("hub stopping; stopping sites");for(const n of j.values())n.child&&(n.stopping=!0);for(const n of M)de(n,"SIGTERM");let e=!1;Ls().finally(()=>{e=!0});const t=Date.now()+ft;setInterval(()=>{if(!((!e||M.size)&&Date.now()<t)){for(const n of M)c(`pid ${n.pid} ignored SIGTERM; killing it`),de(n,"SIGKILL");process.exit(0)}},50)}u(Ot,"shutdown"),process.on("SIGTERM",Ot),process.on("SIGINT",Ot);const jt=new Map;function Pt(e,t={n:3e3}){let n=0,s;try{s=b.readdirSync(e,{withFileTypes:!0})}catch{return 0}for(const o of s){if(t.n--<=0)break;if(o.name.startsWith(".")||o.name==="node_modules")continue;const r=k.join(e,o.name);if(o.isDirectory())n=Math.max(n,Pt(r,t));else try{n=Math.max(n,b.statSync(r).mtimeMs)}catch{}}return n}u(Pt,"newestMtime");function ya(e,t){const n=jt.get(e);if(n&&Date.now()-n.checked<3e4)return n.at;const s=Pt(t.dir);return jt.set(e,{at:s,checked:Date.now()}),s}u(ya,"changedAt");const Rt=u(e=>{try{return typeof e=="string"&&b.statSync(e).isDirectory()}catch{return!1}},"isFolder");function pe(){const{sites:e}=g();return Object.entries(e).sort(([t],[n])=>t.localeCompare(n)).map(([t,n])=>{const s=j.get(t)||{},o=U(n),r=o==="files"?Rt(n.dir)?"up":"down":o==="port"?K.has(t)?K.get(t)?"up":"down":"starting":s.child?s.up?"up":"starting":s.state==="crashed"?"down":s.state||"starting";return{route:t,title:n.title||t,description:n.description||"",icon:n.icon||null,color:n.color||null,port:n.port??null,serves:o,state:r,exitCode:s.exitCode??null,owner:n.owner||null,restart:ms(n),changedAt:ya(t,n)||null,created:n.created||null,log:k.join(ae,t+".log")}})}u(pe,"status");const N=u(e=>String(e??"").replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]),"esc"),me=new Map,Et=2e4,Sa=1500;async function ka(e){try{const t=await fetch(`http://127.0.0.1:${e}/_claude/needs`,{signal:AbortSignal.timeout(Sa)});return t.ok?Wn(await t.json()):null}catch{return null}}u(ka,"fetchNeeds");async function Dt(e,{fresh:t=!1}={}){const n=e.filter(o=>o.state==="up"&&o.owner&&o.port);for(const o of n){const r=me.get(o.route);(t===!0||t?.has?.(o.route)||!r||Date.now()-r.at>=Et)&&me.set(o.route,{at:Date.now(),needs:ka(o.port)})}const s=await Promise.all(n.map(o=>me.get(o.route).needs));return new Map(n.map((o,r)=>[o.route,s[r]]))}u(Dt,"waitingCounts");const Lt=u(e=>[...e.values()].reduce((t,n)=>t+(n?.count||0),0)+Ze(null).length,"badgeTotal"),Ie=new Map;function $a(e){const t=new Map,n=new Map,s={running:tt(),wakes:et(Cn,{})};for(const o of e)try{const r=Xe(o,s);n.set(o.route,r);let p=Ie.get(o.route);(!p||p.listen!==r||Date.now()-p.at>=Et)&&(p={at:Date.now(),listen:r,links:Bn(o,{listen:r})},Ie.set(o.route,p)),t.set(o.route,p.links)}catch(r){c(`links of ${o.route} failed:`,r.message),t.set(o.route,[])}return{links:t,listens:n}}u($a,"boardLinks");async function X({fresh:e=!1}={}){const t=new Date,n=pe(),s=await Dt(n,{fresh:e}),o=g(),r=D(o),{links:p,listens:h}=$a(r),l=Fn(),_=new Map(r.map(i=>[i.route,xs(i.owner?.session)]).filter(([,i])=>i)),S=qn(o,t.getTime(),cs,i=>h.get(i)==="listening"&&n.some(d=>d.route===i&&d.state==="up")),y=new Set(S.map(i=>i.route)),m=new Map(r.filter(i=>js(i.dir)).map(i=>[i.route,ee({dir:i.dir})])),w=Vn(n,s,new Set(r.map(i=>i.route)),h,_).filter(i=>!y.has(i.route)).map(i=>m.has(i.route)?{...i,pause:m.get(i.route)}:i),O=F(),C=hn(t,O);return{data:{code:l.running??null,version:ut,total:Lt(s),start:Zn(w),sites:w,hub:Qn(w,p,Nn(l)),home:b.realpathSync(Ge.homedir()),starting:S,usage:(({start:i,...d})=>d)(la({codexShare:We(te())})),pause:ee(),notifications:C?C.why==="off"?{state:"off",text:"Off"}:{state:"held",text:yn(pn(C,t))}:{state:"on",text:"On"},away:!he.state().present},links:p,rows:n,code:l}}u(X,"sidebarState");function Ct(e,t,n,s,o){let r=Buffer.isBuffer(o)?o:Buffer.from(o);const p={...s,vary:[s.vary,"accept-encoding"].filter(Boolean).join(", ")};r.length>=1024&&/\bgzip\b/.test(e.headers["accept-encoding"]||"")&&(r=zt.gzipSync(r),p["content-encoding"]="gzip"),p["content-length"]=r.length,t.writeHead(n,p),t.end(e.method==="HEAD"?void 0:r)}u(Ct,"sendText");async function _a(e,t=null){const n=t?t.sidebar((await X()).data):(await X()).data,s=ze.createContext({});for(const o of["ui-progress-bar.js","ui-page-header.js","ui-sidebar.js"])ze.runInContext(b.readFileSync(k.join(xe,o),"utf8"),s);return Ss({sidebar:s.BoardUI.sidebar(n,{here:e}),sidebarData:JSON.stringify(n).replace(/</g,"\\u003c"),touchIcon:ge(180)})}u(_a,"shellPage");const He=Us({root:ne(),run:xa,log:c,pulled:u(()=>bt.restartNow(),"pulled")});function xa(){const e=k.join(ae,"kickflip-update.log");b.writeFileSync(e,`${new Date().toISOString()} kickflip update
|
|
6
|
+
`);const t=b.openSync(e,"a");return c("pulling the new kickflip"),new Promise(n=>{const s=Ne(Pn(),[k.join(ne(),"bin","kickflip.mjs"),"update","--no-restart"],{detached:!0,stdio:["ignore",t,t]});b.closeSync(t),s.unref(),s.on("error",o=>n({ok:!1,error:o.message})),s.on("exit",o=>{const r=b.readFileSync(e,"utf8").trim().split(`
|
|
7
|
+
`).at(-1);c(`kickflip update exited ${o}: ${r}`),n(o===0?{ok:!0}:{ok:!1,error:r.replace(/^kickflip: /,"")})})})}u(xa,"runUpdate"),He.check(),setInterval(()=>He.check(),Bs);const x=ks({targets:u(()=>Object.entries(g().sites).filter(([,e])=>ke(e)&&e.port).map(([e,t])=>({route:e,port:t.port})),"targets"),watched:u(()=>D(g()).map(({route:e,dir:t})=>({route:e,dir:t})),"watched"),sidebar:u(async({fresh:e})=>(await X({fresh:e})).data,"sidebar"),log:c});function ge(e){let t="0";try{t=Math.floor(b.statSync(k.join(z,`icon-${e}.png`)).mtimeMs).toString(36)}catch{}return`/_hub/icon-${e}.png?v=${t}`}u(ge,"iconUrl");const va=u(()=>JSON.stringify({name:"Claude sites",short_name:"Claude",start_url:"/",scope:"/",display:"standalone",background_color:"#121514",theme_color:"#121514",icons:[{src:ge(192),sizes:"192x192",type:"image/png"},{src:ge(512),sizes:"512x512",type:"image/png"}]}),"manifestJson");async function E(e,t=16*1024){let n="";for await(const s of e)if(n+=s,n.length>t)throw Object.assign(new Error("too large"),{status:413});return n}u(E,"readBody");async function v(e,t){if(!_e(e.headers))throw Object.assign(new Error("Send the body as application/json."),{status:403});const n=await E(e,t);return n?JSON.parse(n):{}}u(v,"readJSONBody");const At=u((e,t="")=>`<!doctype html><html lang="en" class="hub-view"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
8
|
+
<title>${N(e)}</title><link rel="manifest" href="/manifest.webmanifest"><link rel="icon" type="image/png" href="/_hub/kickflip.png"><link rel="apple-touch-icon" href="${ge(180)}">
|
|
9
|
+
<meta name="apple-mobile-web-app-title" content="Claude">${t}<link rel="stylesheet" href="/_hub/colors.css?v=${at()}"><link rel="stylesheet" href="/_hub/hub.css"></head><body><main>`,"head"),Ta=u(e=>/\bapplication\/json\b/.test(e.headers.accept||""),"wantsJSON");async function we(e){await xt();const t=new Date,{data:n,links:s,rows:o,code:r}=await X();return{...Sn({now:t,st:Ke(t),sidebar:n,rows:o,links:s,rules:$e(),integrations:await Rs.rows(),code:r,version:ut,host:e.headers["x-forwarded-host"]||e.headers.host||"",codexShare:We(te()),claudeModel:en(),codex:te(),awake:{on:Tn(),unsupported:On()},remembered:new Map(D(g()).map(p=>[p.route,Qe(p.route)]))}),prompts:Ze(null).map(ln),remembered:Qe(null)}}u(we,"hubData");const I=u(async()=>{const e=new Date,t=ee({now:e}),n=t.manual==="hub"?jn(t.resets_at,e):null;return{...kn({now:e,st:Ke(e),rules:$e(),linear:se(),slack:aa(),pausedUntil:n}),closed:hs()}},"settingsData"),R=u((e,t)=>{e.writeHead(303,{location:t,"cache-control":"no-store"}),e.end()},"redirect");async function Oa(e,t,n,s=null){const o=u((a,i,d,f={})=>{t.writeHead(a,{"content-type":i,"cache-control":"no-store",...f}),t.end(d)},"send"),r=u((a,i)=>{const d=Mn();return o(a,"application/json",JSON.stringify(i),d?{"x-hub-code":d}:{})},"sendJSON"),p=u(({route:a,started:i})=>(i.then(({error:d})=>{d&&c(`new board /${a} didn't start: ${d}`),x.refresh()}),x.refresh(),r(200,{route:a})),"starting"),h=Ta(e),l=n.pathname,_=/^\/_hub\/close-board\/([a-z0-9-]+)$/.exec(l);if(_&&e.method==="GET")try{return r(200,await us(_[1]))}catch(a){return r(a.status||400,{error:a.message})}if(["/_hub/close-board","/_hub/reopen-board"].includes(l)&&e.method==="POST"){let a;try{const i=await v(e);if(a=i.route,l==="/_hub/reopen-board"){const T=await fs(a);return x.refresh(),r(200,T)}if(B.has(a))return r(409,{error:"That board is already closing."});B.add(a);const d=u(()=>B.delete(a),"release");t.once("finish",d),t.once("close",d),await A.running;const f=await ds(a,i.name);return x.refresh(),r(200,f)}catch(i){return r(i.status||400,{error:i.message})}}if(l==="/_hub/restart-board"&&e.method==="POST"){let a;try{if(a=(await v(e)).route,W.has(a))return r(409,{error:"That board is already restarting."});W.add(a),await A.running;const i=await ps(a,{serverUp:fa,portUp:Re,onStarting:u(d=>c(`restart /${a}: ${d}\u2019s session is back, its server is still starting`),"onStarting")});return c(`restarted /${a}: server answers, session ${i.session} resumed`),r(200,i)}catch(i){return a&&c(`restart /${a} failed: ${i.message}`),r(i.status||400,{error:i.message})}finally{W.delete(a),x.refresh()}}if(l==="/_hub/restart-hub"&&e.method==="POST")try{return await v(e),Ee?r(409,{error:"The hub is already restarting."}):(Ee=!0,t.once("finish",()=>{c("restarting the hub from the hub page");const a=ct("hub");a&&(Ee=!1,c(`hub restart failed: ${a}`))}),r(200,{restarting:!0,command:ea().restartCommand}))}catch(a){return r(a.status||400,{error:a.message})}if(l==="/_hub/check-update"&&e.method==="POST")try{await v(e);const a=await He.check();return a.state==="clone"&&(a.commit=Fe("git",["-C",ne(),"rev-parse","--short","HEAD"],{encoding:"utf8"}).trim()),r(200,a)}catch(a){return r(a.status||400,{error:a.message})}if(l==="/_hub/presence"&&e.method==="POST")return await v(e),kt(he.beat()),t.writeHead(204,{"cache-control":"no-store"}),t.end();if(l==="/_hub/wake"&&e.method==="POST")try{const{route:a}=await v(e),i=D(g()).find(d=>d.route===a);return i?(Ce.add(a),await A.running,_t(),await A.running,Ie.clear(),x.changed(a),r(200,{route:a,listen:Xe(i)})):r(404,{error:"That board is not open."})}catch(a){return r(a.status||400,{error:a.message})}if(l==="/_hub/repos"&&e.method==="GET")try{return r(200,await H.list(g(),{fresh:n.searchParams.get("fresh")==="1"}))}catch(a){return r(a.status||502,{error:a.message})}if(l==="/_hub/branches"&&e.method==="GET")try{return r(200,await H.branches(n.searchParams.get("github")??n.searchParams.get("remote")))}catch(a){return r(a.status||502,{error:a.message})}if(l==="/_hub/repo"&&e.method==="GET")return ts(n,{repos:H,manifest:g(),sendJSON:r});const S=/^\/_hub\/board-repo\/([a-z0-9-]+)$/.exec(l);if(S&&["GET","POST"].includes(e.method))try{const a=e.method==="GET"?st(S[1]):await ns(S[1],await v(e),{repos:H});return e.method==="POST"&&x.refresh(),r(200,a)}catch(a){return r(a.status||400,{error:a.message})}if(l==="/_hub/new-board"&&e.method==="POST"){if(!_e(e.headers))return o(403,"text/plain; charset=utf-8","Send name, github, branch and purpose as application/json.");let a;try{a=await v(e)}catch{return o(400,"text/plain; charset=utf-8","Send name, github, branch and purpose as JSON.")}try{return p(await rs(a,{repos:H,changed:u(()=>x.refresh(),"changed")}))}catch(i){if(i.status)return o(i.status,"text/plain; charset=utf-8",i.message);throw i}}const y=/^\/_hub\/new-board\/([a-z0-9-]+)\/retry$/.exec(l);if(y&&e.method==="POST"){if(!_e(e.headers))return o(403,"text/plain; charset=utf-8","Send the retry as application/json.");try{return p(await os(y[1],{repos:H,changed:u(()=>x.refresh(),"changed")}))}catch(a){if(a.status)return o(a.status,"text/plain; charset=utf-8",a.message);throw a}}const m=/^\/_hub\/new-board\/([a-z0-9-]+)$/.exec(l);if(m&&e.method==="DELETE")try{const a=await is(m[1]);return x.refresh(),r(200,a)}catch(a){if(a.status)return o(a.status,"text/plain; charset=utf-8",a.message);throw a}if((l==="/_hub/build"||l==="/_hub/pull")&&e.method==="POST"){if(ie)return r(503,{error:"The hub is stopping. Try again after it restarts."});const a=["127.0.0.1","::1","::ffff:127.0.0.1"],i=(e.headers.host||"").replace(/:\d+$/,"");if(!a.includes(e.socket.remoteAddress)||!["127.0.0.1","localhost","[::1]"].includes(i)||e.headers.origin||Object.keys(e.headers).some(f=>f==="forwarded"||f.startsWith("x-forwarded-")))return r(403,{error:`${l==="/_hub/pull"?"Pulls":"Builds"} can only be started from the host over loopback.`});let d;try{d=await v(e)}catch(f){return r(f.status||400,{error:l==="/_hub/pull"?"Send the board route as JSON.":"Send the board route and card key as JSON."})}try{return l==="/_hub/pull"?r(200,await Is(d,{manifest:g()})):r(202,await Ds(d,{manifest:g(),log:c}))}catch(f){if(f instanceof Es)return r(f.status,{error:f.message});throw f}}if(l==="/sw.js")return o(200,"text/javascript; charset=utf-8",b.readFileSync(k.join(z,"sw.js")),{"service-worker-allowed":"/"});if(l==="/manifest.webmanifest")return o(200,"application/manifest+json",va());const w=/^\/_hub\/icon-(180|192|512)\.png$/.exec(l);if(w)try{return o(200,"image/png",b.readFileSync(k.join(z,`icon-${w[1]}.png`)),{"cache-control":"max-age=86400"})}catch{t.writeHead(404).end();return}if(l==="/_hub/colors.css"){const a=n.searchParams.get("v")===at()?"public, max-age=31536000, immutable":"no-cache";try{return o(200,"text/css; charset=utf-8",b.readFileSync(ca),{"cache-control":a})}catch{t.writeHead(404).end();return}}if(l==="/_hub/kickflip.png")try{return o(200,"image/png",b.readFileSync(k.join(xe,"kickflip.png")),{"cache-control":"max-age=86400"})}catch{t.writeHead(404).end();return}if(l==="/_hub/hub.css")return o(200,"text/css; charset=utf-8",b.readFileSync(k.join(xe,"hub.css")));if(l==="/_hub/sidebar.json"){const{data:a}=await X({fresh:n.searchParams.get("fresh")==="1"});return r(200,s?s.sidebar(a):a)}if(l==="/_hub/stream"&&e.method==="GET"){if(s){const a={login:s.login,routes:s.routes,res:t};be.add(a),e.on("close",()=>be.delete(a)),e.on("close",Y.watching(s.login))}x.handle(e,t,s&&{routes:s.routes,view:s.sidebar});return}const O=/^\/_hub\/share\/([a-z0-9-]+)$/.exec(l);if(O&&e.method==="GET"){const a=P(g().sites,O[1]);if(!ke(a))return r(404,{error:"That board is not open."});const i=Zs(et(k.join(a.dir,"board.json"),{})),d=i.map(L=>L.login),f=(await Te.node())?.dnsName||e.headers["x-forwarded-host"],T=Y.people(O[1],d).map((L,q)=>({...L,level:i[q].level}));return r(200,{people:T,refused:Y.refusals(O[1],d),link:f?`https://${f}/${O[1]}/`:null})}if(l.startsWith("/_hub/app/")){const a=ws(l.slice(10));if(!a){t.writeHead(404).end();return}const i=k.extname(a),d={".js":"text/javascript; charset=utf-8",".css":"text/css; charset=utf-8",".png":"image/png"}[i],f=n.searchParams.get("v")===ys(a)?"public, max-age=31536000, immutable":"no-cache";return i===".png"?o(200,d,b.readFileSync(a),{"cache-control":f}):Ct(e,t,200,{"content-type":d,"cache-control":f},b.readFileSync(a))}if(l==="/_hub/api/hub")return r(200,await we(e));if(l==="/_hub/api/settings")return r(200,await I());if(l==="/_hub/push/key")return r(200,{key:Jt()});if(l==="/_hub/push/subscribe"&&e.method==="POST"){const a=e.headers["x-forwarded-host"]||e.headers.host;return r(200,Kt(await v(e),`https://${a}/`,e.headers["user-agent"]))}if(l==="/_hub/push/forget"&&e.method==="POST")return r(200,{count:Vt((await v(e))?.id)});if(l==="/_hub/push/unsubscribe"&&e.method==="POST")return r(200,{count:Wt((await v(e))?.endpoint)});if(l==="/_hub/push/test"&&e.method==="POST"){const a=await Zt({title:"Claude",body:"Test notification from the hub.",url:"/",tag:"hub-test"});return r(200,{sent:a.filter(i=>i.status>=200&&i.status<300).length,results:a})}if(l==="/_hub/settings/notifications"&&e.method==="POST"){const a=new URLSearchParams(await E(e)),i=a.get("set"),d=i==="on"?{on:a.get("value")==="on"}:i==="weekends"?{weekends_off:a.get("value")==="on"}:a.has("snooze")?{snooze:a.get("snooze")}:null,f="Pass set=on or set=weekends with value=on, or snooze.";if(!d)return h?r(400,{error:f}):o(400,"text/plain; charset=utf-8",f);let T;try{T=await Yt(d)}catch(L){if(L.status!==400)throw L;return h?r(400,{error:_n}):R(t,"/_hub/?refused=snooze#notifications")}return T.dropped&&c(`notifications off: dropped ${T.dropped} queued push${T.dropped===1?"":"es"}`),h?r(200,await I()):R(t,"/_hub/#notifications")}if(l==="/_hub/settings/keep-awake"&&e.method==="POST"){const a=new URLSearchParams(await E(e));try{vn(a.get("keep_awake")==="on")}catch(i){if(i.status!==409)throw i;return h?r(409,{error:i.message}):o(409,"text/plain; charset=utf-8",i.message)}return re.apply(),h?r(200,await I()):R(t,"/_hub/#keep-awake")}if(l==="/_hub/settings/hub-pause"&&e.method==="POST"){const a=new URLSearchParams(await E(e));try{Os(a.get("pause"))}catch(i){if(i.status!==400)throw i;return h?r(400,{error:i.message}):o(400,"text/plain; charset=utf-8",i.message)}return await fe(),h?r(200,await I()):R(t,"/_hub/#pause")}if(l==="/_hub/settings/codex"&&e.method==="POST"){const a=new URLSearchParams(await E(e));try{qt(a.get("value"),te({fresh:!0}))}catch(i){if(i.status!==400&&i.status!==409)throw i;return h?r(i.status,{error:i.message}):o(i.status,"text/plain; charset=utf-8",i.message)}return h?r(200,await we(e)):R(t,"/_hub/")}if(l==="/_hub/settings/claude-model"&&e.method==="POST"){const a=new URLSearchParams(await E(e));try{tn(a.get("value"))}catch(i){if(i.status!==400)throw i;return h?r(400,{error:i.message}):o(400,"text/plain; charset=utf-8",i.message)}return h?r(200,await we(e)):R(t,"/_hub/")}if(["/_hub/settings/pause-at","/_hub/settings/keep-going"].includes(l)&&e.method==="POST"){const a=new URLSearchParams(await E(e));try{l.endsWith("/pause-at")?vs(a.get("value")):Ts(a.get("value")),await fe()}catch(i){if(i.status!==400&&i.status!==409)throw i;return h?r(i.status,{error:i.message}):o(i.status,"text/plain; charset=utf-8",i.message)}return h?r(200,await we(e)):R(t,"/_hub/")}if(l==="/_hub/settings/linear"&&e.method==="POST"){const a=new URLSearchParams(await E(e,4096));try{a.has("remove")?(ta(),c("linear: key removed"),await yt()):c(`linear: key ${(await na(a.get("key"))).refused?"refused by Linear":"saved"}`)}catch(i){if(!(i instanceof Oe))throw i;return h?r(i.status,{error:i.message}):o(i.status,"text/plain; charset=utf-8",i.message)}return h?r(200,await I()):R(t,"/_hub/#linear")}if(l==="/_hub/settings/slack"&&e.method==="POST"){const a=new URLSearchParams(await E(e,4096));try{a.has("remove")?(ra(),c("slack: token removed")):c(`slack: token ${(await oa(a.get("token"))).refused?"refused by Slack":"saved"}`)}catch(i){if(!(i instanceof sa))throw i;return h?r(i.status,{error:i.message}):o(i.status,"text/plain; charset=utf-8",i.message)}return h?r(200,await I()):R(t,"/_hub/#slack")}if(l==="/_hub/api/linear"&&e.method==="GET"){const a=se();if(!a.set||a.refused)return r(200,a);const i=lt();try{const d=await i.teams();return r(200,{...se(),teams:d.map(({key:f,name:T})=>({key:f,name:T})),projects:d.flatMap(f=>f.projects.map(T=>({id:T.id,name:T.name,team:f.key})))})}catch(d){if(!(d instanceof Oe))throw d;return r(200,{...se(),error:d.message})}}if(l==="/_hub/linear/projects"&&e.method==="POST"){const a=lt();if(!a)return r(400,{error:"Add a Linear key on Hub first."});let i;try{i=await v(e,4*1024)}catch(f){return r(f.status||400,{error:"Send the team and project name as JSON."})}const d=typeof i.name=="string"?i.name.trim():"";if(!d||d.length>80||typeof i.team!="string"||!/^[A-Z][A-Z0-9]*$/.test(i.team))return r(400,{error:"Name the project and its team."});try{return r(200,await a.createProject(i.team,d))}catch(f){if(!(f instanceof Oe))throw f;return r(f.status,{error:f.message})}}if(l==="/_hub/settings/quiet"&&e.method==="POST"){const a=new URLSearchParams(await E(e)),i=Object.fromEntries(["start","end"].filter(d=>a.has(d)).map(d=>[d,a.get(d).trim()]));a.get("set")==="hold"&&(i.hold=a.get("value")||"off");try{Qt(mn(F(),i))}catch(d){if(d.status!==400)throw d;return h?r(400,{error:d.message}):o(400,"text/plain; charset=utf-8",d.message)}return x.refresh(),h?r(200,await I()):R(t,"/_hub/#quiet")}if(l==="/_hub/settings/rules"&&e.method==="POST"){let a,i;try{a=await v(e,64*1024),i=zn($e(),a.rules,a.base??[]),nt(i.changes)&&Jn(i.rules)}catch(f){if(!(f instanceof Gn)&&!(f instanceof SyntaxError))throw f;return r(400,{error:f.message})}if(!nt(i.changes))return r(200,{rules:i.rules,changed:!1,events:[],failed:[]});const d=await Ye(D(g()),"rules",{scope:"hub",...i.changes});return c(`hub rules saved: rules \u2192 ${d.events.map(f=>f.route).join(", ")||"no board"}${d.failed.length?`; ${d.failed.join("; ")}`:""}`),r(200,{rules:i.rules,changed:!0,...d})}if(l==="/_hub/push/flush"&&e.method==="POST"){const a=await Je();return a&&c(`push queue: ${a.ok?"sent":"could not send"} ${a.count} queued (from the settings page)`),h?r(200,$n(a)):R(t,"/_hub/")}if(l==="/_hub/poll"&&e.method==="POST"){const a=await v(e);return r(200,await Le({flush:a.flush!==!1}))}if(l==="/_hub/prompts"&&e.method==="POST"){let a;try{a=await v(e,8*1024*1024)}catch(f){if(f.status)throw f;return r(400,{error:"Send the hook input as JSON."})}if(!a?.tool_name||a.hook_event_name&&a.hook_event_name!=="PermissionRequest")return r(400,{error:"Send a PermissionRequest hook input."});const{answer:i,rec:d}=await G(f=>{const T=Ae(),L=T.boards.find(Ue=>Ue.owner?.session&&Ue.owner.session===a.session_id)||null;Se(f,T)&&x.refresh();const q=dn(f,a);if(q)return{answer:q};const ye=on(a,{board:L,live:T.running?.get(a.session_id)});return f.prompts[ye.id]=ye,{rec:ye}});return i?r(200,{state:"answered",answer:i}):(Z(d.route),await vt(a),c(`prompt ${d.id}: ${d.route?`/${d.route}`:d.cwd} ${d.tool}${d.action?` (${d.action})`:""}`),nn(cn(d),{kind:"prompt",immediate:!0}).catch(f=>c(`prompt ${d.id}: push failed: ${f.message}`)),r(200,{id:d.id,deadline:d.deadline}))}if(l==="/_hub/permission-denied"&&e.method==="POST"){let a;try{a=await v(e,8*1024*1024)}catch(i){if(i.status)throw i;return r(400,{error:"Send the hook input as JSON."})}return a?.hook_event_name!=="PermissionDenied"?r(400,{error:"Send a PermissionDenied hook input."}):r(200,{noted:await vt(a)})}const C=/^\/_hub\/prompts\/([\w-]+)(\/wait|\/answer)?$/.exec(l);return C?ja(e,t,{id:C[1],op:C[2]?.slice(1)||null,url:n,sendJSON:r}):!1}u(Oa,"hubRoute");async function ja(e,t,{id:n,op:s,url:o,sendJSON:r}){if(s==="wait"&&e.method==="GET"){const p=u(()=>G(y=>{const m=P(y.prompts,n);return m?.answer?delete y.prompts[n]:m&&(m.seen=new Date().toISOString()),m}),"seen"),h=u(y=>(Z(y.route),r(200,{state:"answered",answer:y.answer})),"take"),l=await p();if(!l)return r(404,{state:"gone"});if(l.answer)return h(l);const _=Math.max(0,Math.min(Number(o.searchParams.get("ms"))||Ve,Ve));if(await new Promise(y=>{const m=V.get(n)||new Set;V.set(n,m);const w=u(()=>{clearTimeout(O),m.delete(w),!m.size&&V.get(n)===m&&V.delete(n),y()},"done"),O=setTimeout(w,_);m.add(w),t.on("close",w)}),t.writableEnded||t.destroyed)return;const S=await p();return S?S.answer?h(S):r(200,{state:"pending"}):r(404,{state:"gone"})}if(s==="answer"&&e.method==="POST"){const p=await v(e),h=await G(S=>{const y=P(S.prompts,n);if(!y)return{status:404,error:"That prompt has expired."};if(y.answer)return{status:409,error:"That prompt was already answered."};const{answer:m,error:w}=an(y,p);if(w)return{status:400,error:w};y.answer=m;const O=Ae();return Se(S,O),un(S,y,O),{rec:y,answer:m}});if(h.error)return r(h.status,{error:h.error});const{rec:l,answer:_}=h;c(`prompt ${n}: ${_.choice}`);for(const S of[...V.get(n)||[]])S();return Z(l.route),r(200,{ok:!0})}if(s===null&&e.method==="DELETE"){const p=await G(h=>{const l=P(h.prompts,n);return delete h.prompts[n],l});return p&&(Z(p.route),c(`prompt ${n}: its hook gave up`)),r(200,{ok:!0})}return!1}u(ja,"promptRoute");const Pa=u(({icon:e="",title:t,sub:n=""})=>`<div class="row">${e}<span class="body"><span class="text"><span class="title">${t}</span>${n?`<span class="sub">${n}</span>`:""}</span></span></div>`,"row");function It(e,t){const n=j.get(e)||{},s=U(t);return s==="port"?"stopped":s==="files"?"missing":n.why==="code"?"code":n.child?"starting":"crashed"}u(It,"downWhy");function Ht(e,t,n){e.writeHead(502,{"content-type":"text/html; charset=utf-8","cache-control":"no-store","x-kickflip-down":It(t,n)}),e.end(Ra(t,n))}u(Ht,"sendDown");function Ra(e,t){const n=j.get(e)||{},s=n.exitCode!==void 0&&n.exitCode!==null?` (last exit: ${n.exitCode})`:"",o={stopped:`down \xB7 nothing answers on port ${t.port}`,missing:"down \xB7 its folder is missing",code:"restarting \xB7 its code changed",starting:"starting \xB7 waiting for it to answer",crashed:`down \xB7 restarting${s}`},r=It(e,t),p=r==="code"||r==="starting"?"wait":"down",h=U(t),l=h==="cmd"?"The hub runs every site and restarts one that stops.":h==="port"?"Its session runs this server, so it comes back when that session starts it again.":"The hub serves this page from a folder that is no longer there.";return`${At(`${t.title||e} is down`,'<meta http-equiv="refresh" content="5">')}
|
|
10
10
|
<a class="back" href="/_hub/">\u2039 Hub</a>
|
|
11
11
|
<h1>${N(t.title||e)} isn't answering</h1>
|
|
12
|
-
<p class="lede">${N(
|
|
12
|
+
<p class="lede">${N(l)}</p>
|
|
13
13
|
<h2 class="label">Site</h2>
|
|
14
14
|
<div class="group">${Pa({icon:Kn(e,t),title:N(t.title||e),sub:`<span class="st ${p}">${N(o[r])}</span>`})}</div>
|
|
15
15
|
<p class="note">This page retries every 5 seconds.</p>
|
|
16
|
-
</main></body></html>`}
|
|
16
|
+
</main></body></html>`}u(Ra,"downPage");const Me=u(e=>new URL(e.url.replace(/^\/+/,"/"),"http://hub"),"requestURL"),Y=qs({file:k.join(J,"share-seen.json")}),be=new Set;function Mt(){const e=g().sites;for(const t of be){const n=it(t.login,e);[...t.routes].some(([s,o])=>n.get(s)!==o)&&(be.delete(t),t.res.destroy())}Cs()}u(Mt,"endUnshared"),setInterval(Mt,2e3).unref();async function Ut(e){const t=await Te(e),n=e.headers["tailscale-user-login"];if(t!=="other"||!n)return{verdict:t};const s=e.headers["tailscale-user-name"]||null,{pathname:o}=Me(e),r=it(n,g().sites),p=r.size?Qs({method:e.method,pathname:o,routes:r}):"other",h=Xs(o);if(p==="other")return(!r.size||h)&&Y.refuse(n,s,h),{verdict:"other"};if(p!=="pass")return{verdict:p};Y.visit(n,s,h);const l=await Te.node();return{verdict:"shared",viewer:{login:n,routes:r,sidebar:u(_=>Ys(_,r,l?.name||l?.login),"sidebar")}}}u(Ut,"access");const Bt=Be.createServer((e,t)=>{Ut(e).then(({verdict:n,viewer:s})=>{n==="owner"||n==="shared"?Ea(e,t,s):n==="home"?(t.writeHead(302,{location:"/","cache-control":"no-store"}).end(),e.resume()):Ws(e,t,n)})});function Ea(e,t,n=null){const s=Me(e);if(e.method!=="GET"&&e.method!=="HEAD"&&gs(e.headers)){t.writeHead(403,{"content-type":"text/plain; charset=utf-8","cache-control":"no-store"}),t.end("Requests must come from the app\u2019s own pages.");return}const o=new Set(Object.keys(g().sites));if(rt({method:e.method,pathname:s.pathname,search:s.search,dest:bs(e.headers),sites:o})==="shell"){const m=s.pathname==="/"?"":s.pathname.startsWith("/_hub/")?s.pathname.slice(1):decodeURIComponent(s.pathname.split("/")[1]);_a(m,n).then(w=>Ct(e,t,200,{"content-type":"text/html; charset=utf-8","cache-control":"no-cache",vary:"sec-fetch-dest","x-app-shell":"1"},w)).catch(w=>{t.headersSent||t.writeHead(500,{"content-type":"text/plain"}),t.end(String(w.message||w))});return}if(s.pathname==="/_hub"){t.writeHead(302,{location:"/_hub/"+s.search}).end();return}if(s.pathname==="/_hub/status.json"){t.writeHead(200,{"content-type":"application/json","cache-control":"no-store"}),t.end(JSON.stringify({hub:{port:je,pid:process.pid},sites:pe()},null,2));return}if(s.pathname==="/_hub/needs.json"){const m=pe();Dt(m,{fresh:s.searchParams.get("fresh")==="1"}).then(w=>{t.writeHead(200,{"content-type":"application/json","cache-control":"no-store"}),t.end(JSON.stringify({total:Lt(w),sites:Object.fromEntries(m.map(O=>[O.route,w.get(O.route)??null]))}))}).catch(w=>{t.headersSent||t.writeHead(500,{"content-type":"text/plain"}),t.end(String(w.message||w))});return}if(s.pathname==="/favicon.ico"){t.writeHead(301,{location:"/_hub/kickflip.png"}).end();return}if(s.pathname==="/sw.js"||s.pathname==="/manifest.webmanifest"||s.pathname.startsWith("/_hub/")){Oa(e,t,s,n).then(m=>{m===!1&&t.writeHead(404).end()}).catch(m=>{t.headersSent||t.writeHead(m.status||500,{"content-type":"text/plain"}),t.end(String(m.message||m))});return}const r=s.pathname.split("/")[1],p=P(g().sites,r);if(!p){t.writeHead(404,{"content-type":"text/html; charset=utf-8","x-kickflip-down":"removed"}),t.end(`${At("No Site")}<a class="back" href="/_hub/">\u2039 Hub</a><h1>No site at /${N(r)}</h1></main></body></html>`);return}if(s.pathname==="/"+r){t.writeHead(302,{location:"/"+r+"/"+s.search}).end();return}const h="/"+r,l=n?s.pathname.slice(h.length)+s.search:e.url.slice(h.length)||"/",_=rt({pathname:s.pathname,dest:"document",sites:o})==="shell";if(U(p)==="files"){if(!Rt(p.dir)){Ht(t,r,p);return}Ms(e,t,{dir:p.dir,rest:l,prefix:h,headers:_?{vary:"sec-fetch-dest"}:{}}).catch(m=>{t.headersSent||t.writeHead(500,{"content-type":"text/plain"}),t.end(String(m.message||m))});return}const S={...e.headers,"x-forwarded-prefix":h,"x-forwarded-host":e.headers["x-forwarded-host"]||e.headers.host,"x-forwarded-proto":e.headers["x-forwarded-proto"]||"https"};delete S["x-kickflip-shared"],n&&(S["x-kickflip-shared"]=n.login);const y=Be.request({host:"127.0.0.1",port:p.port,method:e.method,path:l,headers:S},m=>{const w={...m.headers,"x-kickflip-hub":"1"};_&&(w.vary=[w.vary,"sec-fetch-dest"].filter(Boolean).join(", ")),typeof w.location=="string"&&w.location.startsWith("/")&&!w.location.startsWith("//")&&!w.location.startsWith(h+"/")&&w.location!==h&&(w.location=h+w.location),t.writeHead(m.statusCode,w),m.pipe(t),m.on("close",()=>{m.complete||t.destroy()})});t.on("close",()=>{t.writableFinished||y.destroy()}),!n&&e.method==="POST"&&l.split("?")[0]==="/api/share"&&t.on("finish",Mt),y.on("error",()=>{if(t.headersSent){t.destroy();return}Ht(t,r,p)}),e.pipe(y)}u(Ea,"serve"),Bt.on("upgrade",async(e,t,n)=>{const{verdict:s}=await Ut(e);if(s!=="owner"){Vs(t,s==="shared"?"view-only":s==="home"?"other":s);return}const o=Me(e).pathname.split("/")[1],r=P(g().sites,o);if(!r?.port){t.destroy();return}const p=`/${o}`;Ks({req:e,socket:t,head:n,port:r.port,path:e.url.slice(p.length)||"/",replace:["x-forwarded-prefix"],extra:[`X-Forwarded-Prefix: ${p}`]})}),Bt.listen(je,"127.0.0.1",()=>c(`hub on http://127.0.0.1:${je}/ (manifest ${dt})`));
|
package/hub/try.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
var
|
|
2
|
-
`,{mode:384}),
|
|
3
|
-
`).map(c=>/^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(c)).filter(c=>c&&Number(c[2])===o).map(c=>({pid:c[1],command:c[3],cwd:""}));return h.length&&await ne(h,["-a","-d","cwd","-Fpn","-p",h.map(c=>c.pid).join(",")]),h}a(
|
|
4
|
-
`))u[0]==="p"?y=c.get(u.slice(1)):u[0]==="n"&&y&&(y.cwd=u.slice(1));for(const u of o){if(!u.cwd&&
|
|
5
|
-
`).map(c=>/^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(c)).filter(Boolean).map(c=>({pid:c[1],pgid:Number(c[2]),command:c[3],cwd:""}));return await ne(h,["-d","cwd","-Fpn"]),h.filter(c=>oe(c,o))}a(Xe,"processesIn");const Ze=a((o,d)=>o===d||o.startsWith(d+T.sep),"insideDir"),oe=a((o,d)=>Ze(o.cwd,d)||o.command.includes(d+T.sep)||o.command.endsWith(d),"inWorktree");async function ae(o,d,h){const c=await Ne(o.dir,d),[y,u]=d.split("/"),k=o.files.find(O=>O.slug===y)?.itemFile,g=c?.path||k?.items.find(O=>O.id===u)?.worktree;if(typeof g!="string"||!g||g.split(T.sep).includes(".."))return"";let m=c?.repo;const p=(o.github||k?.repo||"").toLowerCase();if(!m&&p){const O=[o.owner?.repo,o.dir,...Object.values(h?.sites||{}).map(F=>F.owner?.repo)];for(const F of new Set(O.filter(x=>typeof x=="string"&&T.isAbsolute(x))))try{if(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/.exec(await M(F,"remote","get-url","origin"))?.[1].toLowerCase()===p){m=await M(F,"rev-parse","--show-toplevel");break}}catch{}}if(!c?.path&&!m||c?.path&&!T.isAbsolute(g))return"";try{const O=[T.join(S.realpathSync(Jt.homedir()),".claude","kickflip","worktrees")];if(typeof m=="string"&&T.isAbsolute(m))try{O.push(T.join(S.realpathSync(m),".claude","worktrees"))}catch{}const F=T.resolve(m||"/",g),x=S.realpathSync(F);return O.some(z=>F.startsWith(z+T.sep)&&x.startsWith(z+T.sep))&&S.statSync(x).isDirectory()?x:""}catch{return""}}a(ae,"installWorktree");function tr({now:o=Date.now,answers:d=re,lingers:h=re,log:c=console.log,waiting:y=a(async()=>null,"waiting"),event:u=Te,gate:k=Ge}={}){const g=new Map,m=new Map,p=new Map,O=new Set,F=a(()=>[...m.values(),...p.values()],"live"),x=new Set,z=new Set,it=new Map,st=new Map,bt=new Map,nt=new Map;let W=!1,G=null,X=null,pt=null;const ht=a(()=>new Date(o()).toISOString(),"stamp");function C(t){if(g.has(t.dir))return g.get(t.dir);const e=T.join(t.dir,"data","try.json"),r=ft(e,{kind:null,main:null,cards:{}}),s={...Array.isArray(r.groups)&&r.groups.length?{groups:r.groups}:{},..._t(r.main_port)?{main_port:r.main_port}:{},kind:r.kind||null,main:r.main||null,cards:{...r.cards},...r.pull?{pull:{...r.pull,running:void 0}}:{}},i={state:s,file:e,written:JSON.stringify(r)};g.set(t.dir,i),s.main_port&&st.set(t.dir,s.main_port);const n=Object.entries(s.cards).filter(([,l])=>l.state==="running");for(const[l]of n)z.add(B(t,l));return s.kind==="web"?(s.cards=Object.fromEntries(n),s.main&&s.main.state!=="failed"&&(delete s.main.state,delete s.main.port,delete s.main.https)):s.main?.state==="running"&&(delete s.main.state,delete s.main.sha),s.main&&delete s.main.serving,r.prototypes?.state==="failed"&&(s.prototypes=r.prototypes),P(t),i}a(C,"stateOf");function P(t){const e=g.get(t.dir),r=JSON.stringify(e.state);r!==e.written&&(Yt(e.file,e.state),e.written=r)}a(P,"persist");function ce(t){const{state:e}=C(t.board);t.group={pgid:t.child.pid,key:t.key,worktree:t.worktree},(e.groups||=[]).push(t.group),P(t.board)}a(ce,"recordGroup");function le(t){const{state:e}=C(t.board);e.groups?.includes(t.group)&&(e.groups=e.groups.filter(r=>r!==t.group),e.groups.length||delete e.groups,P(t.board))}a(le,"forgetGroup");async function de(t){const{state:e}=C(t),r=e.groups||[];delete e.groups,P(t);for(const s of r){if(!se(s.pgid))continue;let i=[];try{i=await qe(s.pgid)}catch(n){c(`try startup sweep: could not read group ${s.pgid}: ${n.message}`)}i.some(n=>oe(n,s.worktree))&&(await At(s.pgid),c(`stopped leftover try /${t.route} ${s.key} (pgid ${s.pgid})`),await Mt(t,s.key,s.port))}}a(de,"sweepGroups");async function Mt(t,e,r){if(!r||!await h(r))return;const s=`Port ${r} still answers after the run was stopped.`;H(e)&&await Xt(t.dir,e,"run-failed",{kind:"Serve",code:1,why:s}).catch(i=>c(`build /${t.route} ${e}: ${i.message}`)),c(`build /${t.route} ${e}: ${s}`)}a(Mt,"checkQuiet");const B=a((t,e)=>`${t.dir}\0${e}`,"runKey"),Pt=a((t,e)=>H(e)?t.cards[e]:t[yt[e]],"entryOf"),ue=a((t,e,r)=>{H(e)?t.cards[e]=r:t[yt[e]]=r},"setEntry"),Ct=a((t,e)=>{H(e)?delete t.cards[e]:t[yt[e]]=null},"dropEntry");function K(t,e,r){const{state:s}=C(t),i=Pt(s,e);if(!i)return;const n=Object.hasOwn(r,"state")&&r.state!==i.state;Object.assign(i,r,H(e)&&n?{at:ht()}:{}),P(t)}a(K,"update");function kt(t){const e=[...Object.values(t?.sites||{}),...Object.values(t?.closed||{}).map(r=>r.site)];for(const r of e){if(!r?.dir)continue;const s=ft(T.join(r.dir,"data","try.json"),{}).main_port;_t(s)&&st.set(r.dir,Number(s))}}a(kt,"reserveMains");function fe(t){const{board:e,key:r}=t,s=B(e,r),i=r==="main"?st.get(e.dir):it.get(s);if(i)return i;const n=new Set([...st.values(),...it.values()]);let l=V.first;for(;n.has(l)&&l<=V.last;)l++;if(l>V.last)throw new Error(`No try ports available (${V.first}\u2013${V.last}). Stop a served card and retry.`);return r==="main"?(st.set(e.dir,l),C(e).state.main_port=l,P(e)):it.set(s,l),l}a(fe,"publicPort");const ot=a(async t=>{const e=Pe();if(!e)throw new Error("Tailscale isn't installed on this host");return rt(e,t,{encoding:"utf8",timeout:1e4})},"tailscale");async function Nt(t){try{await ot(["serve",`--https=${t}`,"off"])}catch(e){c(`try port ${t}: could not close port: ${(e.stderr||e.message).trim()}`)}}a(Nt,"removePort");const Ft=a(t=>Je(ft(T.join(t.dir,"board.json"),{})),"sharedWith");async function Dt(t,e){const r=await k(t),s=t.headers["tailscale-user-login"];return r!=="other"||!s||!Ft(e).includes(s)?r:t.method==="GET"||t.method==="HEAD"?"shared":"view-only"}a(Dt,"relayVerdict");function pe(){for(const t of F()){if(!t.shared?.size)continue;const e=Ft(t.board);for(const r of t.shared)e.includes(r.login)||(t.shared.delete(r),r.res.destroy())}}a(pe,"endUnshared");async function he(t){t.https=fe(t);const e=t.sockets=new Set,r=a(()=>{t.visited=o()},"visit"),s=a(n=>{e.add(n),n.on("data",r),n.on("close",()=>e.delete(n))},"track"),i=t.shared=new Set;if(t.relay=Tt.createServer(async(n,l)=>{const $=await Dt(n,t.board);if($!=="owner"&&$!=="shared"){Ue(n,l,$);return}if($==="shared"){const v={login:n.headers["tailscale-user-login"],res:l};i.add(v),l.on("close",()=>i.delete(v))}const b=Object.fromEntries(Object.entries(n.headers).filter(([v])=>!Zt.includes(v))),w=Tt.request({host:"127.0.0.1",port:t.port,method:n.method,path:n.url,headers:b},v=>{l.writeHead(v.statusCode,t.key===U?Ve(v.headers):v.headers),v.on("data",r),v.pipe(l),v.on("close",()=>{v.complete||l.destroy()})});l.on("close",()=>{l.writableFinished||w.destroy()}),w.on("error",()=>{l.headersSent?l.destroy():l.writeHead(502).end()}),n.pipe(w)}),t.relay.on("connection",n=>{r(),s(n)}),t.relay.on("upgrade",async(n,l,$)=>{const b=await Dt(n,t.board);if(b!=="owner"){Ke(l,b==="shared"?"view-only":b);return}s(Le({req:n,socket:l,head:$,port:t.port,path:n.url,replace:Zt}))}),await new Promise((n,l)=>{t.relay.once("error",l),t.relay.listen(0,"127.0.0.1",n)}),!t.stopped){t.exposed=!0;try{await ot(["serve","--bg",`--https=${t.https}`,`http://127.0.0.1:${t.relay.address().port}`])}catch(n){throw new Error(`Couldn't open try port ${t.https}: ${(n.stderr||n.message).trim()}`)}}}a(he,"openPort");async function me(t){t.closing=!0,await t.opening?.catch(()=>{});const e=[...m.values()].some(r=>r!==t&&r.exposed&&r.https===t.https);if(t.exposed&&!W&&!e&&await Nt(t.https),t.relay){for(const r of t.sockets)r.destroy();await new Promise(r=>t.relay.close(r))}t.keepPort||it.delete(B(t.board,t.key))}a(me,"closePort");async function we(){const t=F().filter(r=>r.ready&&r.relay&&!r.closing);if(!t.length)return;let e;try{e=JSON.parse((await ot(["serve","status","--json"])).stdout)}catch(r){c(`try ports check: ${(r.stderr||r.message).trim()}`);return}for(const r of t){if(r.stopped)continue;const s=`http://127.0.0.1:${r.relay.address().port}`,i=Object.entries(e?.Web||{}).find(([n])=>n.endsWith(`:${r.https}`))?.[1];if(i?.Handlers?.["/"]?.Proxy!==s){c(`try port ${r.https}: its serve mapping was ${i?"changed":"gone"}; serving it again`);try{await ot(["serve","--bg",`--https=${r.https}`,s])}catch(n){c(`try port ${r.https}: could not serve it again: ${(n.stderr||n.message).trim()}`)}}}}a(we,"reserve");function at(t=Q()){if(pt)return pt;kt(t);for(const e of J(t))C(e);return pt=(async()=>{for(const e of J(t))await de(e).catch(r=>c(`try startup sweep /${e.route}: ${r.message}`));try{const{stdout:e}=await ot(["serve","status","--json"]),r=JSON.parse(e);for(const s of Object.keys(r?.TCP||{}))_t(s)&&![...m.values()].some(i=>i.https===Number(s))&&await Nt(s)}catch(e){c(`try startup cleanup: ${(e.stderr||e.message).trim()}`)}})(),pt}a(at,"initialize");async function Bt(t,e,r,s){const{board:i,key:n}=t;if(K(i,n,{state:"failed",port:void 0,https:void 0}),!H(n))return;let l="",$=!1;try{for await(const v of zt({input:S.createReadStream(t.logFile),crlfDelay:1/0}))$||=ze.test(v),v.trim()&&(l=v)}catch(v){l=`could not read the build log: ${v.message}`}const b=t.port||(C(i).state.kind||(i.files.some(({itemFile:v})=>v.items.some(j=>j.install?.url))?"app":"web"))==="web"?"Serve":"Build",w=$?"unlock the login keychain on the host":s?.message||r&&`stopped by ${r}`||l;await Xt(i.dir,n,"run-failed",{kind:b,code:e??1,why:w})}a(Bt,"failure");function mt(t){return t.ending||(t.ending=At(t.child.pid)),t.ending}a(mt,"terminate");async function ct(t,e=!1){t.keepPort=e,t.stopped=!0,clearTimeout(t.deadline),clearInterval(t.poll),await mt(t),await t.done}a(ct,"halt");async function Z(t,e=!1){await ct(t,e),K(t.board,t.key,{state:void 0,port:void 0,https:void 0,...t.ready?{}:{sha:void 0}}),t.ready||bt.delete(B(t.board,t.key))}a(Z,"stopRun");async function St(t,e,r,s={},i=""){if(!r)throw new A("This card has no valid worktree.");const n=te(r,t.build_target),l=Ce(n);if(l)throw new A(l);if(!S.realpathSync(n).startsWith(S.realpathSync(r)+T.sep))throw new A("Build script must be inside the worktree.");const $=await M(r,"rev-parse","HEAD");if(W)throw new A("The hub is stopping. Try again after it restarts.",503);const{state:b}=C(t),w=Pt(b,e),v={...H(e)?w:{},...s,sha:$,script:!0,state:"running",...H(e)?{at:ht(),head:$}:{},...e===U?{since:ht()}:{}};delete v.port,delete v.https;const j=ee(t,e);S.mkdirSync(T.dirname(j),{recursive:!0});const I=S.openSync(j,"w",384);S.fchmodSync(I,384),i&&S.writeSync(I,i);const _=xe(n,[e,r],{cwd:r,detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,KICKFLIP_BOARD:t.route,HOST:"127.0.0.1"}});ue(b,e,v);const E=B(t,e),f={child:_,board:t,key:e,worktree:r,sha:$,at:s.at,metadata:s,logFile:j,visited:o(),stopped:!1,port:null,ready:!1};ce(f),m.set(E,f),bt.set(E,$);let N,tt;const D=a(R=>{tt||=new Error(R),mt(f)},"fail");_.on("error",R=>{N=R}),_.stdout.on("data",R=>S.writeSync(I,R)),_.stderr.on("data",R=>S.writeSync(I,R));const lt=zt({input:_.stdout,crlfDelay:1/0});return lt.on("line",R=>{const dt=/^port (\d{1,5})$/.exec(R);if(!dt||f.port||f.stopped)return;const L=Number(dt[1]);if(L<1||L>65535){D("Build script printed an invalid port.");return}f.port=L,f.group.port=L,P(t),clearTimeout(f.deadline);let Y,Et=!1;const Ut=a(async()=>{if(!(Et||f.stopped||f.ready||_.exitCode!==null||_.signalCode!==null)){Et=!0;try{if(await d(L)){if(f.stopped||tt||_.exitCode!==null||_.signalCode!==null||(f.opening=he(f),await f.opening,f.stopped||tt||_.exitCode!==null||_.signalCode!==null))return;f.ready=!0,f.visited=o(),b.kind="web",K(t,e,{state:"ready",port:L,https:f.https}),clearInterval(f.poll),e==="main"&&await wt(t)}else o()>=Y&&D("Nothing answered on the build script\u2019s port within 60 seconds.")}finally{Et=!1}}},"check"),Kt=a(()=>{f.stopped||(Y=o()+6e4,f.poll=setInterval(()=>Ut().catch(ut=>D(ut.message)),250),Ut().catch(ut=>D(ut.message)))},"probeFrom");e==="main"&&p.get(t.dir)?.port===L?wt(t).then(Kt,ut=>D(ut.message)):Kt()}),f.deadline=setTimeout(()=>D("No port line within 60 minutes."),60*6e4),_.once("exit",()=>{mt(f)}),f.done=new Promise(R=>_.once("close",(dt,L)=>{clearTimeout(f.deadline),clearInterval(f.poll),lt.close(),S.closeSync(I),(async()=>{if(await mt(f),await me(f),le(f),W||await Mt(t,e,f.port),f.finished=!0,f.stopped)return;if(p.get(t.dir)===f){p.delete(t.dir),K(t,e,{serving:void 0});return}if(e==="main"&&!f.ready&&p.has(t.dir)&&!W){await ye(f);return}const Y=e===U&&!f.port?new Error('Build script exited without printing "port <n>".'):null;tt||N||dt!==0||f.port&&!f.ready||Y?await Bt(f,dt??1,L,tt||N||(f.port&&!f.ready?new Error("Build script exited before its port answered."):Y)):f.ready?(e==="main"?(delete b.main.state,delete b.main.port,delete b.main.https):Ct(b,e),P(t)):(b.kind="app",K(t,e,{state:"ready"}))})().catch(Y=>c(`build /${t.route} ${e}: ${Y.message}`)).finally(()=>{m.get(E)===f&&m.delete(E),R()})})),c(`build /${t.route} ${e}: started; log ${j}`),{status:"started"}}a(St,"launch");async function wt(t){const e=p.get(t.dir);e&&(p.delete(t.dir),K(t,"main",{serving:void 0}),await ct(e))}a(wt,"retire");async function ye(t){const{board:e,worktree:r}=t;await wt(e),c(`try /${e.route} main: the new serve exited before answering while the old one served; stopped the old serve and serving again in ${r}`);const{serving:s,...i}=t.metadata;try{await St(e,"main",r,i,`The new serve exited before answering while the old one served; stopped the old serve and serving again.
|
|
6
|
-
`)}catch(n){Rt(e,i,n)}}a(
|
|
7
|
-
`).at(-1)}`),await M(e,"worktree","prune")}catch(n){c(`${s} could not remove the old main worktree ${r}: ${n.message}`)}}}a(
|
|
1
|
+
var Oe=Object.defineProperty;var a=(o,d)=>Oe(o,"name",{value:d,configurable:!0});import k from"node:fs";import T from"node:path";import Jt from"node:os";import Tt from"node:http";import{execFile as xe,spawn as Ee}from"node:child_process";import{createInterface as zt}from"node:readline";import{promisify as Te}from"node:util";import{listBoards as J,readManifest as Q,readJSON as ft,saveJSON as Yt,appendEvent as je,GITHUB_STATE as _e,REPO_FETCH as Qt}from"./boards.mjs";import{boardRepo as et,githubFromOrigin as Vt,mainRefOf as Ie}from"./board-repo.mjs";import{gitReachFault as jt,remoteFromInput as Ae,remoteGitEnv as Me}from"./repos.mjs";import{readiness as Pe}from"./readiness.mjs";import{tailscaleBin as Ce}from"../shared/tailscale.mjs";import{buildScriptError as Fe,buildScriptOf as qt}from"../board/build-script.mjs";import{appendStatus as Xt,recordedWorktree as Ne,servedRoundsOpen as Be,setItem as De}from"../board/store.mjs";import{BASE_KEY as U,baseWorktreeOf as Re}from"../board/prototype-base.mjs";import{cardPRs as He,hasPR as We,prKeyOf as Ge}from"../shared/card-prs.mjs";import{hubGate as Le}from"../board/on-host.mjs";import{pipeUpgrade as Ue,refuse as Ke,refuseUpgrade as Je}from"./front-door.mjs";import{shareOf as ze}from"./share.mjs";class A extends Error{static{a(this,"BuildError")}constructor(d,h=400){super(d),this.status=h}}const rt=Te(xe),M=a(async(o,...d)=>(await rt("git",["-C",o,...d],{encoding:"utf8",timeout:3e3})).stdout.trim(),"git"),Ye=/errSecInternalComponent|keychain[^\n]*\blocked\b|\blocked\b[^\n]*keychain/i,V={first:8500,last:8599},_t=a(o=>/^\d+$/.test(String(o))&&Number(o)>=V.first&&Number(o)<=V.last,"isTryPort"),Qe=30*6e4,Zt=["tailscale-user-login","tailscale-user-name","tailscale-user-profile-pic","x-forwarded-for"],Ve=["x-frame-options"],qe=a(o=>{const d=Object.fromEntries(Object.entries(o).filter(([h])=>!Ve.includes(h)));if(typeof d["content-security-policy"]=="string"){const h=d["content-security-policy"].split(";").filter(c=>!/^\s*frame-ancestors\b/i.test(c)).join(";").trim();h?d["content-security-policy"]=h:delete d["content-security-policy"]}return d},"withoutFraming"),yt={main:"main",[U]:"prototypes"},H=a(o=>!Object.hasOwn(yt,o),"isCard"),te=a((o,d)=>T.join(o,qt(d)),"scriptAt"),gt=a(o=>k.existsSync(o),"exists"),ee=a((o,d)=>T.join(o.dir,"data","try",`${d.split("/").at(-1)}.log`),"logFileOf");function It(o,d,h){const c=ee(o,d);return k.mkdirSync(T.dirname(c),{recursive:!0}),k.writeFileSync(c,h.message+`
|
|
2
|
+
`,{mode:384}),k.chmodSync(c,384),c}a(It,"failureLog");const re=a(o=>new Promise(d=>{const h=Tt.get({host:"127.0.0.1",port:o,path:"/",timeout:1e3},c=>{c.destroy(),d(!0)});h.on("timeout",()=>h.destroy()),h.on("error",()=>d(!1))}),"probe"),ie=a((o,d)=>{try{process.kill(-o,d)}catch{}},"signalGroup"),se=a(o=>{try{return process.kill(-o,0),!0}catch{return!1}},"groupUp"),At=a(o=>new Promise(d=>{const h=a(()=>{clearTimeout(c),clearInterval(y),d()},"finish");ie(o,"SIGTERM");let c=setTimeout(()=>{ie(o,"SIGKILL"),c=setTimeout(h,1e3)},4e3);const y=setInterval(()=>{se(o)||h()},20)}),"endGroup");async function Xe(o){const{stdout:d}=await rt("ps",["-axo","pid=,pgid=,command="],{encoding:"utf8",timeout:5e3}),h=d.split(`
|
|
3
|
+
`).map(c=>/^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(c)).filter(c=>c&&Number(c[2])===o).map(c=>({pid:c[1],command:c[3],cwd:""}));return h.length&&await ne(h,["-a","-d","cwd","-Fpn","-p",h.map(c=>c.pid).join(",")]),h}a(Xe,"groupMembers");async function ne(o,d){let h="";try{h=(await rt("lsof",d,{encoding:"utf8",timeout:1e4,maxBuffer:64*1024*1024})).stdout}catch(u){h=u.stdout||""}const c=new Map(o.map(u=>[u.pid,u]));let y;for(const u of h.split(`
|
|
4
|
+
`))u[0]==="p"?y=c.get(u.slice(1)):u[0]==="n"&&y&&(y.cwd=u.slice(1));for(const u of o){if(!u.cwd&&k.existsSync("/proc"))try{u.cwd=k.readlinkSync(`/proc/${u.pid}/cwd`)}catch{}u.cwd=u.cwd.replace(/ \(deleted\)$/,"")}}a(ne,"readCwds");async function Ze(o){const{stdout:d}=await rt("ps",["-axo","pid=,pgid=,command="],{encoding:"utf8",timeout:5e3,maxBuffer:67108864}),h=d.split(`
|
|
5
|
+
`).map(c=>/^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(c)).filter(Boolean).map(c=>({pid:c[1],pgid:Number(c[2]),command:c[3],cwd:""}));return await ne(h,["-d","cwd","-Fpn"]),h.filter(c=>oe(c,o))}a(Ze,"processesIn");const tr=a((o,d)=>o===d||o.startsWith(d+T.sep),"insideDir"),oe=a((o,d)=>tr(o.cwd,d)||o.command.includes(d+T.sep)||o.command.endsWith(d),"inWorktree");async function ae(o,d,h){const c=await Ne(o.dir,d),[y,u]=d.split("/"),S=o.files.find(O=>O.slug===y)?.itemFile,g=c?.path||S?.items.find(O=>O.id===u)?.worktree;if(typeof g!="string"||!g||g.split(T.sep).includes(".."))return"";let m=c?.repo;const p=(o.github||S?.repo||"").toLowerCase();if(!m&&p){const O=[o.owner?.repo,o.dir,...Object.values(h?.sites||{}).map(N=>N.owner?.repo)];for(const N of new Set(O.filter(x=>typeof x=="string"&&T.isAbsolute(x))))try{if(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/.exec(await M(N,"remote","get-url","origin"))?.[1].toLowerCase()===p){m=await M(N,"rev-parse","--show-toplevel");break}}catch{}}if(!c?.path&&!m||c?.path&&!T.isAbsolute(g))return"";try{const O=[T.join(k.realpathSync(Jt.homedir()),".claude","kickflip","worktrees")];if(typeof m=="string"&&T.isAbsolute(m))try{O.push(T.join(k.realpathSync(m),".claude","worktrees"))}catch{}const N=T.resolve(m||"/",g),x=k.realpathSync(N);return O.some(z=>N.startsWith(z+T.sep)&&x.startsWith(z+T.sep))&&k.statSync(x).isDirectory()?x:""}catch{return""}}a(ae,"installWorktree");function er({now:o=Date.now,answers:d=re,lingers:h=re,log:c=console.log,waiting:y=a(async()=>null,"waiting"),event:u=je,gate:S=Le}={}){const g=new Map,m=new Map,p=new Map,O=new Set,N=a(()=>[...m.values(),...p.values()],"live"),x=new Set,z=new Set,it=new Map,st=new Map,bt=new Map,nt=new Map;let W=!1,G=null,X=null,pt=null;const ht=a(()=>new Date(o()).toISOString(),"stamp");function C(t){if(g.has(t.dir))return g.get(t.dir);const e=T.join(t.dir,"data","try.json"),r=ft(e,{kind:null,main:null,cards:{}}),s={...Array.isArray(r.groups)&&r.groups.length?{groups:r.groups}:{},..._t(r.main_port)?{main_port:r.main_port}:{},kind:r.kind||null,main:r.main||null,cards:{...r.cards},...r.pull?{pull:{...r.pull,running:void 0}}:{}},i={state:s,file:e,written:JSON.stringify(r)};g.set(t.dir,i),s.main_port&&st.set(t.dir,s.main_port);const n=Object.entries(s.cards).filter(([,l])=>l.state==="running");for(const[l]of n)z.add(D(t,l));return s.kind==="web"?(s.cards=Object.fromEntries(n),s.main&&s.main.state!=="failed"&&(delete s.main.state,delete s.main.port,delete s.main.https)):s.main?.state==="running"&&(delete s.main.state,delete s.main.sha),s.main&&delete s.main.serving,r.prototypes?.state==="failed"&&(s.prototypes=r.prototypes),P(t),i}a(C,"stateOf");function P(t){const e=g.get(t.dir),r=JSON.stringify(e.state);r!==e.written&&(Yt(e.file,e.state),e.written=r)}a(P,"persist");function ce(t){const{state:e}=C(t.board);t.group={pgid:t.child.pid,key:t.key,worktree:t.worktree},(e.groups||=[]).push(t.group),P(t.board)}a(ce,"recordGroup");function le(t){const{state:e}=C(t.board);e.groups?.includes(t.group)&&(e.groups=e.groups.filter(r=>r!==t.group),e.groups.length||delete e.groups,P(t.board))}a(le,"forgetGroup");async function de(t){const{state:e}=C(t),r=e.groups||[];delete e.groups,P(t);for(const s of r){if(!se(s.pgid))continue;let i=[];try{i=await Xe(s.pgid)}catch(n){c(`try startup sweep: could not read group ${s.pgid}: ${n.message}`)}i.some(n=>oe(n,s.worktree))&&(await At(s.pgid),c(`stopped leftover try /${t.route} ${s.key} (pgid ${s.pgid})`),await Mt(t,s.key,s.port))}}a(de,"sweepGroups");async function Mt(t,e,r){if(!r||!await h(r))return;const s=`Port ${r} still answers after the run was stopped.`;H(e)&&await Xt(t.dir,e,"run-failed",{kind:"Serve",code:1,why:s}).catch(i=>c(`build /${t.route} ${e}: ${i.message}`)),c(`build /${t.route} ${e}: ${s}`)}a(Mt,"checkQuiet");const D=a((t,e)=>`${t.dir}\0${e}`,"runKey"),Pt=a((t,e)=>H(e)?t.cards[e]:t[yt[e]],"entryOf"),ue=a((t,e,r)=>{H(e)?t.cards[e]=r:t[yt[e]]=r},"setEntry"),Ct=a((t,e)=>{H(e)?delete t.cards[e]:t[yt[e]]=null},"dropEntry");function K(t,e,r){const{state:s}=C(t),i=Pt(s,e);if(!i)return;const n=Object.hasOwn(r,"state")&&r.state!==i.state;Object.assign(i,r,H(e)&&n?{at:ht()}:{}),P(t)}a(K,"update");function kt(t){const e=[...Object.values(t?.sites||{}),...Object.values(t?.closed||{}).map(r=>r.site)];for(const r of e){if(!r?.dir)continue;const s=ft(T.join(r.dir,"data","try.json"),{}).main_port;_t(s)&&st.set(r.dir,Number(s))}}a(kt,"reserveMains");function fe(t){const{board:e,key:r}=t,s=D(e,r),i=r==="main"?st.get(e.dir):it.get(s);if(i)return i;const n=new Set([...st.values(),...it.values()]);let l=V.first;for(;n.has(l)&&l<=V.last;)l++;if(l>V.last)throw new Error(`No try ports available (${V.first}\u2013${V.last}). Stop a served card and retry.`);return r==="main"?(st.set(e.dir,l),C(e).state.main_port=l,P(e)):it.set(s,l),l}a(fe,"publicPort");const ot=a(async t=>{const e=Ce();if(!e)throw new Error("Tailscale isn't installed on this host");return rt(e,t,{encoding:"utf8",timeout:1e4})},"tailscale");async function Ft(t){try{await ot(["serve",`--https=${t}`,"off"])}catch(e){c(`try port ${t}: could not close port: ${(e.stderr||e.message).trim()}`)}}a(Ft,"removePort");const Nt=a(t=>ze(ft(T.join(t.dir,"board.json"),{})),"sharedWith");async function Bt(t,e){const r=await S(t),s=t.headers["tailscale-user-login"];return r!=="other"||!s||!Nt(e).includes(s)?r:t.method==="GET"||t.method==="HEAD"?"shared":"view-only"}a(Bt,"relayVerdict");function pe(){for(const t of N()){if(!t.shared?.size)continue;const e=Nt(t.board);for(const r of t.shared)e.includes(r.login)||(t.shared.delete(r),r.res.destroy())}}a(pe,"endUnshared");async function he(t){t.https=fe(t);const e=t.sockets=new Set,r=a(()=>{t.visited=o()},"visit"),s=a(n=>{e.add(n),n.on("data",r),n.on("close",()=>e.delete(n))},"track"),i=t.shared=new Set;if(t.relay=Tt.createServer(async(n,l)=>{const $=await Bt(n,t.board);if($!=="owner"&&$!=="shared"){Ke(n,l,$);return}if($==="shared"){const v={login:n.headers["tailscale-user-login"],res:l};i.add(v),l.on("close",()=>i.delete(v))}const b=Object.fromEntries(Object.entries(n.headers).filter(([v])=>!Zt.includes(v))),w=Tt.request({host:"127.0.0.1",port:t.port,method:n.method,path:n.url,headers:b},v=>{l.writeHead(v.statusCode,t.key===U?qe(v.headers):v.headers),v.on("data",r),v.pipe(l),v.on("close",()=>{v.complete||l.destroy()})});l.on("close",()=>{l.writableFinished||w.destroy()}),w.on("error",()=>{l.headersSent?l.destroy():l.writeHead(502).end()}),n.pipe(w)}),t.relay.on("connection",n=>{r(),s(n)}),t.relay.on("upgrade",async(n,l,$)=>{const b=await Bt(n,t.board);if(b!=="owner"){Je(l,b==="shared"?"view-only":b);return}s(Ue({req:n,socket:l,head:$,port:t.port,path:n.url,replace:Zt}))}),await new Promise((n,l)=>{t.relay.once("error",l),t.relay.listen(0,"127.0.0.1",n)}),!t.stopped){t.exposed=!0;try{await ot(["serve","--bg",`--https=${t.https}`,`http://127.0.0.1:${t.relay.address().port}`])}catch(n){throw new Error(`Couldn't open try port ${t.https}: ${(n.stderr||n.message).trim()}`)}}}a(he,"openPort");async function me(t){t.closing=!0,await t.opening?.catch(()=>{});const e=[...m.values()].some(r=>r!==t&&r.exposed&&r.https===t.https);if(t.exposed&&!W&&!e&&await Ft(t.https),t.relay){for(const r of t.sockets)r.destroy();await new Promise(r=>t.relay.close(r))}t.keepPort||it.delete(D(t.board,t.key))}a(me,"closePort");async function we(){const t=N().filter(r=>r.ready&&r.relay&&!r.closing);if(!t.length)return;let e;try{e=JSON.parse((await ot(["serve","status","--json"])).stdout)}catch(r){c(`try ports check: ${(r.stderr||r.message).trim()}`);return}for(const r of t){if(r.stopped)continue;const s=`http://127.0.0.1:${r.relay.address().port}`,i=Object.entries(e?.Web||{}).find(([n])=>n.endsWith(`:${r.https}`))?.[1];if(i?.Handlers?.["/"]?.Proxy!==s){c(`try port ${r.https}: its serve mapping was ${i?"changed":"gone"}; serving it again`);try{await ot(["serve","--bg",`--https=${r.https}`,s])}catch(n){c(`try port ${r.https}: could not serve it again: ${(n.stderr||n.message).trim()}`)}}}}a(we,"reserve");function at(t=Q()){if(pt)return pt;kt(t);for(const e of J(t))C(e);return pt=(async()=>{for(const e of J(t))await de(e).catch(r=>c(`try startup sweep /${e.route}: ${r.message}`));try{const{stdout:e}=await ot(["serve","status","--json"]),r=JSON.parse(e);for(const s of Object.keys(r?.TCP||{}))_t(s)&&![...m.values()].some(i=>i.https===Number(s))&&await Ft(s)}catch(e){c(`try startup cleanup: ${(e.stderr||e.message).trim()}`)}})(),pt}a(at,"initialize");async function Dt(t,e,r,s){const{board:i,key:n}=t;if(K(i,n,{state:"failed",port:void 0,https:void 0}),!H(n))return;let l="",$=!1;try{for await(const v of zt({input:k.createReadStream(t.logFile),crlfDelay:1/0}))$||=Ye.test(v),v.trim()&&(l=v)}catch(v){l=`could not read the build log: ${v.message}`}const b=t.port||(C(i).state.kind||(i.files.some(({itemFile:v})=>v.items.some(j=>j.install?.url))?"app":"web"))==="web"?"Serve":"Build",w=$?"unlock the login keychain on the host":s?.message||r&&`stopped by ${r}`||l;await Xt(i.dir,n,"run-failed",{kind:b,code:e??1,why:w})}a(Dt,"failure");function mt(t){return t.ending||(t.ending=At(t.child.pid)),t.ending}a(mt,"terminate");async function ct(t,e=!1){t.keepPort=e,t.stopped=!0,clearTimeout(t.deadline),clearInterval(t.poll),await mt(t),await t.done}a(ct,"halt");async function Z(t,e=!1){await ct(t,e),K(t.board,t.key,{state:void 0,port:void 0,https:void 0,...t.ready?{}:{sha:void 0}}),t.ready||bt.delete(D(t.board,t.key))}a(Z,"stopRun");async function St(t,e,r,s={},i=""){if(!r)throw new A("This card has no valid worktree.");const n=te(r,t.build_target),l=Fe(n);if(l)throw new A(l);if(!k.realpathSync(n).startsWith(k.realpathSync(r)+T.sep))throw new A("Build script must be inside the worktree.");const $=await M(r,"rev-parse","HEAD");if(W)throw new A("The hub is stopping. Try again after it restarts.",503);const{state:b}=C(t),w=Pt(b,e),v={...H(e)?w:{},...s,sha:$,script:!0,state:"running",...H(e)?{at:ht(),head:$}:{},...e===U?{since:ht()}:{}};delete v.port,delete v.https;const j=ee(t,e);k.mkdirSync(T.dirname(j),{recursive:!0});const I=k.openSync(j,"w",384);k.fchmodSync(I,384),i&&k.writeSync(I,i);const _=Ee(n,[e,r],{cwd:r,detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,KICKFLIP_BOARD:t.route,HOST:"127.0.0.1"}});ue(b,e,v);const E=D(t,e),f={child:_,board:t,key:e,worktree:r,sha:$,at:s.at,metadata:s,logFile:j,visited:o(),stopped:!1,port:null,ready:!1};ce(f),m.set(E,f),bt.set(E,$);let F,tt;const B=a(R=>{tt||=new Error(R),mt(f)},"fail");_.on("error",R=>{F=R}),_.stdout.on("data",R=>k.writeSync(I,R)),_.stderr.on("data",R=>k.writeSync(I,R));const lt=zt({input:_.stdout,crlfDelay:1/0});return lt.on("line",R=>{const dt=/^port (\d{1,5})$/.exec(R);if(!dt||f.port||f.stopped)return;const L=Number(dt[1]);if(L<1||L>65535){B("Build script printed an invalid port.");return}f.port=L,f.group.port=L,P(t),clearTimeout(f.deadline);let Y,Et=!1;const Ut=a(async()=>{if(!(Et||f.stopped||f.ready||_.exitCode!==null||_.signalCode!==null)){Et=!0;try{if(await d(L)){if(f.stopped||tt||_.exitCode!==null||_.signalCode!==null||(f.opening=he(f),await f.opening,f.stopped||tt||_.exitCode!==null||_.signalCode!==null))return;f.ready=!0,f.visited=o(),b.kind="web",K(t,e,{state:"ready",port:L,https:f.https}),clearInterval(f.poll),e==="main"&&await wt(t)}else o()>=Y&&B("Nothing answered on the build script\u2019s port within 60 seconds.")}finally{Et=!1}}},"check"),Kt=a(()=>{f.stopped||(Y=o()+6e4,f.poll=setInterval(()=>Ut().catch(ut=>B(ut.message)),250),Ut().catch(ut=>B(ut.message)))},"probeFrom");e==="main"&&p.get(t.dir)?.port===L?wt(t).then(Kt,ut=>B(ut.message)):Kt()}),f.deadline=setTimeout(()=>B("No port line within 60 minutes."),60*6e4),_.once("exit",()=>{mt(f)}),f.done=new Promise(R=>_.once("close",(dt,L)=>{clearTimeout(f.deadline),clearInterval(f.poll),lt.close(),k.closeSync(I),(async()=>{if(await mt(f),await me(f),le(f),W||await Mt(t,e,f.port),f.finished=!0,f.stopped)return;if(p.get(t.dir)===f){p.delete(t.dir),K(t,e,{serving:void 0});return}if(e==="main"&&!f.ready&&ye(t,f)&&!W){await ge(f);return}const Y=e===U&&!f.port?new Error('Build script exited without printing "port <n>".'):null;tt||F||dt!==0||f.port&&!f.ready||Y?await Dt(f,dt??1,L,tt||F||(f.port&&!f.ready?new Error("Build script exited before its port answered."):Y)):f.ready?(e==="main"?(delete b.main.state,delete b.main.port,delete b.main.https):Ct(b,e),P(t)):(b.kind="app",K(t,e,{state:"ready"}))})().catch(Y=>c(`build /${t.route} ${e}: ${Y.message}`)).finally(()=>{m.get(E)===f&&m.delete(E),R()})})),c(`build /${t.route} ${e}: started; log ${j}`),{status:"started"}}a(St,"launch");async function wt(t){const e=p.get(t.dir);e&&(p.delete(t.dir),K(t,"main",{serving:void 0}),await ct(e))}a(wt,"retire");function ye(t,e){const r=p.get(t.dir);return!!r&&k.readFileSync(e.logFile,"utf8").includes(r.worktree)}a(ye,"refusedByOld");async function ge(t){const{board:e,worktree:r}=t;await wt(e),c(`try /${e.route} main: the new serve refused to run beside the old one; stopped the old serve and serving again in ${r}`);const{serving:s,...i}=t.metadata;try{await St(e,"main",r,i,`The new serve exited before answering while the old one served; stopped the old serve and serving again.
|
|
6
|
+
`)}catch(n){Rt(e,i,n)}}a(ge,"handOverFailed");function Rt(t,e,r){r.status!==503&&(C(t).state.main={...e,state:"failed"},It(t,"main",r),P(t))}a(Rt,"mainFailed");async function $e(t,e,r){const s=`try /${t.route} main:`;if(!O.has(t.dir)){O.add(t.dir);try{const i=await Ze(r);for(const n of new Set(i.map(l=>l.pgid))){await At(n);for(const l of i.filter($=>$.pgid===n))c(`${s} stopped pid ${l.pid} (pgid ${n}) in the old main worktree ${r}`)}}catch(i){c(`${s} could not stop the processes in the old main worktree ${r}: ${i.message}`)}}if(gt(r))try{await M(e,"worktree","remove","--force",r);return}catch(i){try{k.rmSync(r,{recursive:!0,force:!0}),c(`${s} git could not remove the old main worktree ${r}; removed the folder: ${(i.stderr||i.message).trim().split(`
|
|
7
|
+
`).at(-1)}`),await M(e,"worktree","prune")}catch(n){c(`${s} could not remove the old main worktree ${r}: ${n.message}`)}}}a($e,"removeLegacyMain");async function Ht(t,e,r){const s=D(t,"main");if(x.has(s))throw new A("A build is already running for main.",409);x.add(s);try{const i=m.get(s);i?.ready&&!i.ending?(m.delete(s),p.set(t.dir,i)):i&&await Z(i,!0);const n=p.get(t.dir);n&&(r={...r,serving:{sha:n.sha,at:n.at,https:n.https}});const l=a(b=>T.join(Jt.homedir(),".claude","kickflip","try",`${t.route}-main${b}`),"worktreeOf"),$=l(n?.worktree===l("-a")?"-b":"-a");return k.mkdirSync(T.dirname($),{recursive:!0}),await $e(t,e,l("")),gt($)?await M($,"checkout","--detach","--force",r.sha):(await M(e,"worktree","prune"),await M(e,"worktree","add","--detach",$,r.sha)),await St(t,"main",$,r)}catch(i){throw Rt(t,r,i),i}finally{x.delete(s)}}a(Ht,"launchMain");async function Ot(t,{manifest:e=Q()}={}){if(await at(e),kt(e),W)throw new A("The hub is stopping. Try again after it restarts.",503);if(!t||Array.isArray(t)||typeof t!="object"||Object.keys(t).some(w=>!["route","key"].includes(w))||typeof t.route!="string"||typeof t.key!="string")throw new A("Send only the board route and card key.");const{route:r,key:s}=t;if(H(s)&&(!/^[\w.-]+\/[\w.-]+$/.test(s)||s.split("/").some(w=>w==="."||w==="..")))throw new A("Card key must be <file>/<id>.");const i=J(e).find(w=>w.route===r);if(!i)throw new A(`/${r} is not a board.`,404);if(H(s)&&!i.files.some(({slug:w,itemFile:v})=>v.items.some(j=>`${w}/${j.id}`===s)))throw new A("There is no item with this card key.",404);const n=await y();if(n)throw new A(n,409);const l=D(i,s),$=m.get(l);if(x.has(l)||$&&!$.ready&&!$.finished)throw new A(`A build is already running for ${s==="main"?"main":s===U?"the prototype site":"this card"}.`,409);if(s==="main"){const w=C(i).state.main;if(w?.state==="running")throw new A("A build is already running for main.",409);if(w?.state!=="failed"||!w.sha)throw new A("Main has no failed build to retry.",409);const{sha:v,branch:j,slug:I,at:_,script:E}=w;return Ht(i,et(r,e).repo,{sha:v,branch:j,slug:I,at:_,script:E,...i.build_target?{target:i.build_target}:{}})}x.add(l);const b=z.delete(l);try{m.has(l)&&await Z(m.get(l),!0);const w=s===U?Wt(i):await ae(i,s,e);return await St(i,s,w)}catch(w){throw it.delete(l),b&&w.status!==503&&K(i,s,{state:void 0,sha:void 0}),w}finally{x.delete(l)}}a(Ot,"start");const Wt=a(t=>{const e=Re(t.route);return gt(e)&&k.statSync(e).isDirectory()?k.realpathSync(e):""},"baseWorktree");async function ve(t,e){const{state:r}=C(t),s=D(t,U),i=m.get(s),n=(await Be(t.dir)).length>0&&Wt(t);if(!n){i&&await Z(i),r.prototypes&&(r.prototypes=null,P(t));return}if(i?.ready&&!i.stopped&&!x.has(s)){const l=await M(n,"rev-parse","HEAD");if(l===r.prototypes?.sha)return;c(`try /${t.route} prototypes: the base moved to ${l.slice(0,7)}; restarting its serve`)}else if(i||x.has(s)||r.prototypes?.state==="failed"||await y())return;try{await Ot({route:t.route,key:U},{manifest:e})}catch(l){if([409,503].includes(l.status))return;throw r.prototypes={state:"failed"},It(t,U,l),P(t),l}}a(ve,"serveBase");function Gt(t,e,{manifest:r=Q()}={}){const s=J(r).find(n=>n.route===t);if(!s)return null;const i=[...m.values()].find(n=>n.board.dir===s.dir&&n.key.split("/").at(-1).toLowerCase()===e.toLowerCase()&&n.ready&&!n.stopped&&n.child.exitCode===null&&n.child.signalCode===null);return i?{key:i.key,port:i.port}:null}a(Gt,"lookup");async function xt({manifest:t=Q(),routes:e}={}){if(await at(t),kt(t),W)return G;if(G&&e){if(!X){const r={routes:new Set,manifest:t};r.done=G.catch(()=>{}).then(()=>(X=null,xt({manifest:r.manifest,routes:[...r.routes]}))),X=r}X.manifest=t;for(const r of e)X.routes.add(r);return X.done}return G||(G=(async()=>{await we();const r=J(t),s=ft(_e,{}).prs||{};for(const[i,n]of[...p])r.some(l=>l.dir===i)||(p.delete(i),await ct(n));for(const i of[...m.values()])if(!r.some(n=>n.dir===i.board.dir)||H(i.key)&&i.ready&&o()-i.visited>=Qe){await Z(i);const{state:n}=C(i.board);Ct(n,i.key),P(i.board)}for(const i of r){if(W)return;if(e&&!e.includes(i.route))continue;const{state:n}=C(i),l=et(i.route,t),$=l.branch||"main",b=Ie(l);for(const{slug:w,itemFile:v}of i.files)for(const j of v.items){const I=`${w}/${j.id}`,_=He(j,i.github||v.repo).map(E=>s[Ge(E)]);if(!(j.lane==="done"||_.length&&_.every(E=>["MERGED","CLOSED"].includes(E?.state))))try{const E=D(i,I),f=await ae(i,I,t);if(f){const B=await M(f,"rev-parse","HEAD");let lt=0;try{lt=Number(await M(f,"rev-list","--count",`${b}..HEAD`))}catch{}Object.assign(n.cards[I]||={},{head:B,ahead:lt,script:gt(te(f,i.build_target))}),P(i)}else if(!z.has(E))continue;const F=n.cards[I];if((z.has(E)||i.auto_build&&F.script&&F.ahead>0&&(F.sha||bt.get(E))!==F.head&&F.state!=="running")&&!x.has(E))try{await Ot({route:i.route,key:I},{manifest:t})}catch(B){if([409,503].includes(B.status))continue;throw K(i,I,{sha:F.head}),await Dt({board:i,key:I,logFile:It(i,I,B)},1),B}}catch(E){c(`try /${i.route} ${I}: ${E.message}`)}}await ve(i,t).catch(w=>c(`try /${i.route} prototypes: ${w.message}`));try{if(!l.repo||await y())continue;const w=await M(l.repo,"rev-parse",b);let v=!1;try{await M(l.repo,"cat-file","-e",`${w}:${qt(i.build_target)}`),v=!0}catch{}if(!v){const F=m.get(D(i,"main"));F&&await Z(F),await wt(i),n.main=null,P(i);continue}const j=n.main;if(j?.state==="running"||j?.sha===w&&(j.target||null)===(i.build_target||null)&&(j.state==="failed"||j.state==="ready"&&(n.kind!=="web"||Gt(i.route,"main",{manifest:t}))))continue;const I=D(i,"main");if(x.has(I))continue;const _=await M(l.repo,"log","-1","--format=%cI",w);let E=i.github;if(!E)try{E=Vt(await M(l.repo,"remote","get-url","origin"))}catch{}const f={sha:w,branch:$,slug:E||"",at:_,script:v,...i.build_target?{target:i.build_target}:{}};await Ht(i,l.repo,f)}catch(w){c(`try /${i.route} main: ${w.message}`)}}})().finally(()=>{G=null}),G)}a(xt,"tick");function Lt(t,e){if(nt.has(t.dir))return nt.get(t.dir);const r=(async()=>{const s=et(t.route,e),i=s.branch||"main",{state:n}=C(t);n.pull={...n.pull,running:!0},P(t);let l,$;try{l=await(s.local?ir:rr)(s.repo,i)}catch(b){$=b.message}if(l===null?delete n.pull:n.pull={at:ht(),...$?{error:$}:{}},P(t),l===null)return null;if($)return c(`pull /${t.route}: ${$}`),{commits:0,error:$};if(l.commits){c(`pull /${t.route}: ${i} ${s.local?"fast-forwarded":"reset"} ${l.commits} commit${l.commits===1?"":"s"} to ${l.to.slice(0,7)}`);try{await u(t,"main-pulled",{branch:i,...l})}catch(b){c(`pull /${t.route}: could not send main-pulled (${b.message})`)}}return{commits:l.commits}})().finally(()=>nt.delete(t.dir));return nt.set(t.dir,r),r}a(Lt,"pullMain");async function be({manifest:t=Q(),routes:e}={}){await at(t);const r=[];for(const s of J(t)){if(W)break;if(!(e&&!e.includes(s.route)))try{if(!et(s.route,t).repo)continue;(await Lt(s,t))?.commits&&r.push(s.route)}catch(i){c(`pull /${s.route} failed: ${i.message}`)}}return r}a(be,"pullMains");async function ke(t,{manifest:e=Q()}={}){if(await at(e),W)throw new A("The hub is stopping. Try again after it restarts.",503);if(!t||Array.isArray(t)||typeof t!="object"||Object.keys(t).some(n=>n!=="route")||typeof t.route!="string")throw new A("Send only the board route.");const r=J(e).find(n=>n.route===t.route);if(!r)throw new A(`/${t.route} is not a board.`,404);const s=et(r.route,e),i=await Lt(r,e);if(!i)throw new A("This board\u2019s folder has no origin to pull from.",409);return(i.commits||!s.local)&&xt({manifest:e,routes:[r.route]}).catch(n=>c(`try /${r.route} main: ${n.message}`)),i}a(ke,"pull");async function Se(){W=!0;const t=Promise.all([...m.values()].map(e=>e.ready?Z(e):ct(e)).concat([...p.values()].map(e=>ct(e))));await Promise.all([t,G,...nt.values()])}return a(Se,"stop"),{start:Ot,tick:xt,stop:Se,lookup:Gt,initialize:at,pull:ke,pullMains:be,endUnshared:pe}}a(er,"createTryManager");const $t=1e4,vt=a(async(o,...d)=>(await rt("git",["-C",o,...d],{encoding:"utf8",timeout:$t,env:Me()})).stdout,"originGit");async function rr(o,d,{run:h=vt}={}){const c=a(async(...p)=>(await h(...p)).trim(),"git"),y=a(p=>c(o,"rev-parse","--verify","--quiet",`${p}^{commit}`).catch(()=>""),"tip"),u=`refs/remotes/origin/${d}`,S=await y(`refs/heads/${d}`)||await y(u);try{await c(o,"fetch","--quiet","--no-tags","origin",`+refs/heads/${d}:${u}`),await c(o,"checkout","--quiet","--force","-B",d,u),await c(o,"clean","--quiet","-fd")}catch(p){throw new Error(`can\u2019t pull: ${p.killed?`git fetch timed out after ${$t/1e3} s`:jt(p.stderr,{repo:o}).error}`)}const g=await y(u),m=S&&S!==g?Number(await c(o,"rev-list","--count",`${S}..${g}`)):0;return{from:S||g,to:g,commits:m}}a(rr,"resetBranch");async function ir(o,d,{run:h=vt}={}){const c=a(async(...u)=>(await h(...u)).trim(),"git"),y=a(u=>{throw new Error(`can\u2019t pull: ${u}`)},"refuse");try{await c(o,"config","--get","remote.origin.url")}catch{return null}try{await c(o,"fetch","--quiet","--no-tags","origin",`+refs/heads/${d}:refs/remotes/origin/${d}`)}catch(u){y(u.killed?`git fetch timed out after ${$t/1e3} s`:jt(u.stderr,{repo:o}).error)}try{const u=await c(o,"rev-parse","--verify",`refs/heads/${d}^{commit}`),S=await c(o,"rev-parse","--verify",`refs/remotes/origin/${d}^{commit}`),g=a((O,N)=>c(o,"merge-base","--is-ancestor",O,N).then(()=>!0,x=>{if(x.code===1)return!1;throw x}),"isAncestor");if(u===S||await g(S,u))return{from:u,to:u,commits:0};await g(u,S)||y(`${d} has split from origin`);const m=Number(await c(o,"rev-list","--count",`${u}..${S}`)),p=(await c(o,"worktree","list","--porcelain")).split(`
|
|
8
8
|
|
|
9
9
|
`).map(O=>O.split(`
|
|
10
|
-
`)).find(O=>O.includes(`branch refs/heads/${d}`))?.[0].slice(9);return p?(await c(p,"status","--porcelain","--untracked-files=no")&&y("local changes"),await c(p,"merge","--ff-only","--quiet",
|
|
11
|
-
`).at(-1))}}a(
|
|
12
|
-
`).map(g=>/^([\da-f]+)\trefs\/heads\/(.+)$/.exec(g)).filter(Boolean).map(g=>[g[2],g[1]])),
|
|
10
|
+
`)).find(O=>O.includes(`branch refs/heads/${d}`))?.[0].slice(9);return p?(await c(p,"status","--porcelain","--untracked-files=no")&&y("local changes"),await c(p,"merge","--ff-only","--quiet",S)):await c(o,"update-ref",`refs/heads/${d}`,S,u),{from:u,to:S,commits:m}}catch(u){if(u.message.startsWith("can\u2019t pull: "))throw u;y(String(u.stderr||u.message).trim().split(`
|
|
11
|
+
`).at(-1))}}a(ir,"pullBranch");async function xr({manifest:o=Q(),log:d=console.log,routes:h}={}){const c=h?ft(Qt,{}):{};for(const y of J(o))if(!(h&&!h.includes(y.route)))try{const u=et(y.route,o);if(u.local||!u.repo)continue;const S=u.branch||"main",g=new Date().toISOString(),m=Date.now();try{await vt(u.repo,"fetch","--quiet","--no-tags","origin",`+refs/heads/${S}:refs/remotes/origin/${S}`),c[y.route]={at:g,ok:!0}}catch(p){d(`fetch /${y.route} after ${Date.now()-m} ms: ${p.message}`);let O=y.github;if(!O&&!y.remote)try{O=Vt(await M(u.repo,"remote","get-url","origin"))}catch{}c[y.route]={at:g,ok:!1,...jt(p.killed?`git fetch timed out after ${$t/1e3} s`:p.stderr,{github:O||"",remote:y.remote,repo:u.repo})};continue}if(y.remote)try{await sr(y,u.repo,S,d)}catch(p){d(`pushed branches /${y.route}: ${p.message}`)}}catch(u){d(`fetch /${y.route} failed: ${u.message}`)}Yt(Qt,c)}a(xr,"fetchTryBranches");async function sr(o,d,h,c){const y=o.files.flatMap(({slug:g,itemFile:m})=>m.items.filter(p=>p.pushed?.branch&&!We(p)&&p.lane!=="done").map(p=>({key:`${g}/${p.id}`,branch:p.pushed.branch})));if(!y.length)return;const u=new Map((await vt(d,"ls-remote","--heads","origin")).split(`
|
|
12
|
+
`).map(g=>/^([\da-f]+)\trefs\/heads\/(.+)$/.exec(g)).filter(Boolean).map(g=>[g[2],g[1]])),S=Ae(o.remote)?.host||"the remote";for(const g of y){let m=u.get(g.branch);if(!m)try{m=await M(d,"rev-parse","--verify","--quiet",`refs/heads/${g.branch}^{commit}`)}catch{}let p=!1;if(m)try{await M(d,"merge-base","--is-ancestor",m,`refs/remotes/origin/${h}`),p=!0}catch{}if(!(!p&&u.has(g.branch)))try{await De(o.dir,g.key,{done:!0,force:!0,landed:p,closing:p?["branch-merged",{branch:g.branch,into:h,host:S}]:["branch-gone",{branch:g.branch,host:S}]})}catch(O){c(`pushed ${g.key}: ${O.message}`)}}}a(sr,"settlePushed");const q=er({waiting:a(()=>Pe.waiting("tailscale"),"waiting")}),Er=a((...o)=>q.start(...o),"startBuild"),Tr=a((...o)=>q.tick(...o),"tryTick"),jr=a((...o)=>q.pull(...o),"pullNow"),_r=a((...o)=>q.pullMains(...o),"pullMains"),Ir=a(()=>q.stop(),"stopTries"),Ar=a(()=>q.endUnshared(),"endUnsharedTries"),Mr=a((...o)=>q.initialize(...o),"initializeTries");export{A as BuildError,V as TRY_PORTS,er as createTryManager,Ar as endUnsharedTries,xr as fetchTryBranches,Mr as initializeTries,ae as installWorktree,_t as isTryPort,ir as pullBranch,_r as pullMains,jr as pullNow,rr as resetBranch,Er as startBuild,Ir as stopTries,Tr as tryTick};
|
package/hub/upgrade.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var y=Object.defineProperty;var e=(n,
|
|
1
|
+
var y=Object.defineProperty;var e=(n,a)=>y(n,"name",{value:a,configurable:!0});import p from"node:fs";import u from"node:path";const S="https://registry.npmjs.org/@lovek4d%2Fkickflip/latest",v=60*6e4,m=e(n=>String(n).split("-")[0].split(".").map(Number),"parts");function g(n,a){const[o,i]=[m(n),m(a)];for(let r=0;r<3;r++)if((o[r]||0)!==(i[r]||0))return(o[r]||0)>(i[r]||0);return!1}e(g,"newer");function _({root:n,fetch:a=globalThis.fetch,run:o,log:i=e(()=>{},"log"),pulled:r=e(()=>{},"pulled")}){const d=p.existsSync(u.join(n,".git")),l=e(()=>JSON.parse(p.readFileSync(u.join(n,"package.json"),"utf8")).version,"installed"),k=l();let c=!1;async function h(){if(d)return{state:"clone"};let s;try{const t=await a(S,{signal:AbortSignal.timeout(15e3)});if(!t.ok)throw new Error(`npm answered ${t.status}`);s=(await t.json()).version}catch(t){return i(`kickflip update check failed: ${t.message}`),{state:"failed",error:t.message}}return g(s,l())?(w(s),{state:"pulling",latest:s}):{state:"same",latest:s}}e(h,"check");function w(s){c||(c=!0,o().then(({ok:t})=>{if(!t)return;const f=l();g(f,k)?r():i(`update left v${f}; npm has ${s}`)},t=>i(`kickflip update failed: ${t.message}`)).finally(()=>{c=!1}))}return e(w,"pull"),{check:h}}e(_,"upgrader");export{v as CHECK_MS,S as LATEST_URL,g as newer,_ as upgrader};
|