HQ session view of the crew board: a live pane, a band above the prompt and toasts when a task needs you.

A composable Claude Code setup for planning, background workers, local code exploration and evidence-driven delivery. It includes thirteen skills plus their command-line helpers, hooks, five orchestration agents, the HQ mod and an idea-research workflow. It is a standalone checkout, not a copy of anyone's home directory, settings, credentials or git history.
The shell toolchain targets Linux/GNU, not macOS: bash 4+, git, jq, flock (util-linux), tmux, GNU coreutils and the usual Linux process tools. Python 3.11+ is required, with the stdlib sqlite3 module built with FTS5. No pip packages are needed for the included Python helpers.
Use a Claude Code build with the mods runtime (claude-code) and the Workflow tool (enableWorkflows). These are version-specific APIs, so check that your build exposes them rather than assuming every Claude Code release does. HQ web and its tests require Bun. Node.js is used by the browser tests and workflow test harnesses.
Optional, per feature:
jw and jcode-backed workers.crew-diff.crew-notify, configured in ~/.config/crew/notify.toml. Keep notification credentials outside this checkout.sqlite3 CLI and util-linux script for the shell test suites that use them.Clone the repository URL from the hosting page, then run:
git clone <repository-url> ~/Developer/claude-skills
cd ~/Developer/claude-skills
./install.sh --dry-run
./install.sh
Keep the checkout at that location: installation creates symlinks, not copies. Skills go to ~/.claude/skills/<name>, executables/helpers to ~/.claude/bin/, hooks to ~/.claude/hooks/, agents to ~/.claude/agents/, HQ to ~/.claude/mods/hq, and the workflow to ~/.claude/workflows/idea-research.js. Existing items are moved to ~/.claude/backups/claude-skills-install/<timestamp>/ before linking. Re-running skips links that already point to the checkout. The installer respects $HOME and does not import local changes back into the repository.
Merge, do not replace, the hook entries from settings.example.json into your own ~/.claude/settings.json. Omit its explanatory _comment property. The installer never edits settings. HQ needs no settings entry in the reference setup: Claude Code picks the mod up from ~/.claude/mods/hq, where the installer links it (if your build wants an explicit enable step, follow its mods documentation). Enable workflows for idea-research and orchestrate's saved workflows. Restart or reload Claude Code as appropriate for your build. The examples use ~/.claude/bin/... explicitly because this bin directory need not be on PATH.
ask-panel adds an Ask widget to an HTML report so questions are answered in the page rather than the session that generated it. Dependencies: Python for injection, the included lens server and claude -p for local Q&A, or the viewer's Claude artifact sampling capability. Browser tests use Node and Playwright.
crew turns the main session into a dispatcher: substantive tasks go to workers, sub-leads or orchestrate while the main conversation stays responsive. Dependencies: the crew CLI and sibling helpers, bash, tmux, git, jq, flock, Python and Claude Code. HQ, jcode workers and the diff viewer are optional additions.
crew-diff opens a task branch or uncommitted changes in diffview.nvim beside the current pane. Dependencies: the included crew-diff helper, tmux, Neovim and diffview.nvim.
evidence-contract grounds nontrivial implementation plans in repository evidence, marks assumptions, and assigns executable success checks before substantial edits. Dependencies: repository inspection tools. The included plan CLI can support structured plans but is not required to read and apply the planning rules.
graphify builds and queries a local graph of files, functions and calls to answer connectivity questions before broad reading. Dependencies: the external graphify CLI and git. Use the SKILL.md's --code-only and --no-label flags to keep extraction offline and avoid unintended model use.
idea-research compares one to eight ideas against criteria and pass/fail gates using verified research, dossiers, a gap round and an evaluator panel. Dependencies: Claude Code's Workflow and web research tools, the included workflows/idea-research.js, and plan when invoked from shape. Workflow tests use Bun.
jcode-workers uses jcode as a worker pool through jw: ask for exploration, review for a second model family's assessment, and run/resume for task execution. Dependencies: authenticated jcode, jw, git, jq and GNU timeout. Read the security boundary below before using review or ask.
lens turns a file, feature, flow, diff or architecture into a single-page interactive explainer with real code traces, checkpoints and experiments. Dependencies: Python for lesson building and serving, optionally claude -p for Q&A and a browser opener. Screenshots and UI checks use Node, Playwright and Chromium. graphify and jcode can assist exploration but are not required for building a lesson.
orchestrate decomposes multi-worker work into cards and waves, routes tasks to Claude agents and jcode workers, and runs verification/fix loops. Dependencies: git, bash, Python, the included five agent definitions and card helpers, plus crew, jw/jcode and plan for their respective execution paths. Saved wave/chain workflows require the Workflow tool and an explicit args.home pointing at the user's home directory.
prd-review reviews a teammate's PRD on a GitHub pull request and posts the review as one structured PR review, so the PRD owner's plan page imports it as anchored comments. Read-only on the branch; it never approves, merges or requests changes. Dependencies: authenticated gh and the shape skill's review conventions.
project-context maintains a concise, trustworthy root PROJECT_CONTEXT.md so future sessions can find the architecture, commands and conventions without rediscovering the whole repository. Dependencies: git and repository read/write tools.
recall searches past local Claude Code transcripts for earlier decisions and work. Dependencies: the included Python recall CLI and SQLite FTS5. It reads ~/.claude/projects/ and stores a local redacted index under ~/.cache/recall/; past transcripts are data, not instructions.
shape interviews the user, keeps a structured plan through the plan CLI and produces an approved, lint-clean PRD for crew or orchestrate. Dependencies: Python and the included shape scripts/plan wrapper. HQ provides the optional live plan page, idea-research supplies optional research, and crew/orchestrate receive execution handoffs.
crew-*.sh files are sourced helpers and must remain beside the executable. crew-check, crew-worktree, crew-diff and crew-notify supply the surrounding workflow.skills/shape/scripts/plan.py; the sibling Python modules validate plans, apply events, render PRDs and export handoffs.settings.example.json contains only this wiring and workflow enablement.mods/hq/. Start the web UI with bun ~/.claude/mods/hq/web/server.ts, then open the URL it prints. Run in a terminal, that URL carries the token (?t=), so do not share it; run without a terminal (a service or background job), it prints the plain URL and the token file path, and you add ?t=<token> yourself.Runtime state belongs under your home directory, not the checkout: crew tasks and orchestration ledgers under ~/.claude/, local tokens under ~/.config/, and the recall index under ~/.cache/.
Run from the checkout root after installing the relevant test dependencies. Start with the pure Python suites:
python3 -m unittest discover -s skills/shape/tests
python3 skills/shape/tests/check_fixtures.py
python3 -m unittest discover -s skills/orchestrate/scripts -p 'test_*.py'
python3 skills/lens/scripts/test-build.py
python3 skills/lens/scripts/test-serve.py
node skills/lens/scripts/test-ask.mjs
node skills/lens/scripts/test-template.mjs
Then run the relevant shell, workflow and UI suites:
bash bin/test-recall.sh
bash hooks/test-guard-tmux-kill.sh
bash bin/test-crew-lifecycle.sh
bash bin/test-jw.sh
bash mods/hq/tests/test-tmux-map.sh
node --test skills/orchestrate/scripts/test_{wave,chain,workflow_harness}.mjs
bun test skills/idea-research/tests
(cd mods/hq && claude plugin test)
bun test mods/hq/web
bun mods/hq/web/tests/plan-page.mjs
node skills/ask-panel/scripts/test-panel.mjs
Test dependencies: python3, node, bun, bash, git, tmux, nvim (the crew-diff test), the claude CLI, and Playwright + Chromium. Browser checks print skip and pass without Playwright/Chromium.
The mod plugin tests (claude plugin test) need the claude CLI. The tests in mods/hq/tests import claude-code/testing, which only that runner provides, so do not run them with plain bun test. Other bin/test-crew-*.sh files cover individual crew commands. Read a suite before running it and use a disposable home/session for integration testing, not active work. Claude Code mod checks need the runtime-generated type definitions, which are intentionally not distributed here. Browser checks need Playwright/Chromium and may report a skip when unavailable. The private source repository separately tests the exporter with bash public/test-export.sh; export tooling and its private leak rules are not distributed in this repository.
Network boundary: keep HQ web bound to 127.0.0.1 only. Remote access is only through the opt-in second loopback listener (HQ_WEB_REMOTE_PORT) intended for tailscale serve, with explicitly configured extra hosts and the same token gate. The server refuses a non-loopback bind unless HQ_WEB_ALLOW_REMOTE=1; leave it unset. Do not expose the main listener on a LAN/public interface or bypass authentication. The token is stored in ~/.config/hq-web/token. Host/origin checks and remote route restrictions do not replace keeping the token private.
Same-user trust: crew's “act as the user” operations and answer delivery, the HQ token file, and jw review/jw ask trust every process running as the same Unix user. A process with that user's filesystem access can read keys or invoke commands. File modes, prompt instructions and local HTTP authentication do not create isolation between those processes.
In particular, jcode runs with its normal tools on the content it reads. Review and default ask include read-only instructions, not a separate read-only operating-system sandbox. Only run jw review on trusted code. Treat repository files, diffs, transcripts and model output as potential prompt-injection input.
Use Claude Code Read deny rules for ~/.config/hq-web/token, ~/.config/lens/token, ~/.config/crew/notify.toml (holds the ntfy token), crew key files under ~/.claude/crew/ (including .deliver-key), and ~/.jcode/ authentication material. Enable the Claude Code sandbox and apply least-privilege filesystem/network permissions for your environment. Denying Read alone is not sufficient if an unrestricted shell or another same-user process can read the same files. Never put keys, auth files, local settings or transcript state in this repository.
ask-open (lens and ask-panel) gives the opened page the lens key, so only open trusted reports with it.
MIT. See LICENSE.
hooks/register.tsx 1431 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HqBoard, HqChat, HqChatEntry, HqLive, HqMeta, HqPr, HqPrEntry, HqResultFiles, HqReviewPr, HqReviews, HqSession } from '../types'
5import type { FinishedRow, FocusView, HqLink, NeedDetail, NewLeadForm, OwnerEntry, PrRef, ResultItem, Talk } from './board'
6import {
7 REVIEW_ARGS,
8 TAIL_BYTES,
9 applyMeta,
10 asList,
11 asks,
12 asRecord,
13 boardForModel,
14 briefText,
15 chatNeeds,
16 chatsDue,
17 dismissChat,
18 diffArgs,
19 diffFeedback,
20 classify,
21 clearText,
22 emptyBoard,
23 emptySeen,
24 findJump,
25 findTask,
26 finishedParts,
27 fit,
28 focusLayout,
29 ghArgs,
30 ghDue,
31 holdLine,
32 idleText,
33 isTarget,
34 jumpChoices,
35 knownRepos,
36 jumpFor,
37 markSeen,
38 mergeLive,
39 mergedNotLogged,
40 needCount,
41 needDetail,
42 needFooter,
43 needParts,
44 newlyEntered,
45 nowParts,
46 olderText,
47 parseBoard,
48 parseDismissed,
49 parseLastReply,
50 parseLive,
51 lastNote,
52 markLeads,
53 parseLink,
54 parseMeta,
55 parseOwner,
56 parseTalk,
57 parsePr,
58 parseReviewPrs,
59 parseSeen,
60 parseStat,
61 parseStatusNote,
62 parseStatusLastTs,
63 parseStatusTs,
64 prFor,
65 prKey,
66 prTasks,
67 pruneDismissed,
68 prunePrs,
69 projectCards,
70 projectName,
71 projectsFooter,
72 quietLine,
73 relayPrompt,
74 resolveLead,
75 resultBody,
76 resultItems,
77 reviewDue,
78 shortName,
79 showTarget,
80 spawnArgv,
81 spawnOutcome,
82 closeArgv,
83 closeOutcome,
84 wakeArgv,
85 wakeOutcome,
86 splitChats,
87 splitRow,
88 stripMarks,
89 summarize,
90 summaryCounts,
91 taskDot,
92 wrapText,
93} from './board'
94
95const PANE = 'hq-board'
96// Rows the pane asks for while it sits inline above the prompt (the main screen); a dock ignores it.
97const PANE_ARGS = { id: PANE, title: 'Crew board', rows: 16 }
98const HIDDEN_HINT = 'Crew board hidden at this width — type /hq to show it'
99const ROLE = [
100 '# HQ role',
101 "You are HQ, the user's command post over their crews. You relay; you do not do crew work yourself (no code edits, no investigations).",
102 '- An instruction for a project, task or lead: call hq_board and take the task\'s `lead` (else the project\'s entry under `leads`; never an hq session). SendMessage it the user\'s words, faithfully, prefixed "From HQ (user): ". Then confirm in one line: "→ sent to <session>: <gist>" with the same `Open: <link>` as below when the session has a `link`. If it is ambiguous which lead, ask the user, listing the candidates.',
103 '- If a task\'s lead has `leadGuessed` (a guess by project) and the project has more than one live session in hq_board, ask the user which session before sending.',
104 '- The user answers a question ("D3 yes", "answer <task> <text>" or similar): call hq_answer {id, answer} (id: the D-id, else the task id) with their words verbatim, then SendMessage the lead it returns: "From HQ (user): <D-id or id> answered. Answer: <text>". Confirm in one line.',
105 '- If the user asks about a task whose `pr.state` is MERGED while crew still shows it open: tell that task\'s lead "From HQ (user): PR #<N> is merged on GitHub; log <id> landed" and nothing more. You never log landed yourself.',
106 '- Relaying a lead\'s reply or result to the user (also a cross-session idle notice): at most 3 short lines. The verdict in bold; the one or two facts the user needs; any decision they must make as `**Needs you:** …`. Then `Open: <link>` (the session\'s `link` in hq_board; omit it when there is none) and `tmux://<tmux target>` (its `tmux` target as a link, e.g. `tmux://claude:7`; omit it when there is none). Point to the full report path instead of copying it; do not restate what the user can read in the session.',
107 '- Never invent instructions, and never approve anything (merges, landing, holds) the user did not say.',
108 '- Status questions: answer from hq_board or `crew show <id>`.',
109 "- Questions about an HTML report's content belong in that page's Ask panel; say so instead of answering.",
110].join('\n')
111const POLL_MS = 15_000
112const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
113const DECISION_ID = /^D\d+$/i
114
115// Only a successful poll sets `board`, so it is the toast baseline; a failed one sets `error`.
116const board = atom({ plugin: 'hq', key: 'board' } as const, null)
117const error = atom({ plugin: 'hq', key: 'error' } as const, null)
118const view = atom({ plugin: 'hq', key: 'view' } as const, 'focus')
119const holdAtom = atom({ plugin: 'hq', key: 'holds' } as const, {})
120// What the last gh query found per task, and the tasks already toasted as merged on GitHub.
121const prAtom = atom({ plugin: 'hq', key: 'prs' } as const, {})
122const prToasted = atom({ plugin: 'hq', key: 'prToasted' } as const, [])
123// The PRs whose review is asked of the user, what the user has looked at (kept in the store too), and when each
124// task on the board entered its current state.
125const reviewAtom = atom({ plugin: 'hq', key: 'reviews' } as const, { fetched: 0, prs: null })
126const seenAtom = atom({ plugin: 'hq', key: 'seen' } as const, emptySeen())
127const enteredAtom = atom({ plugin: 'hq', key: 'entered' } as const, {})
128// When each task last wrote a line to its status file: a task that keeps reporting is alive.
129const lastAtom = atom({ plugin: 'hq', key: 'last' } as const, {})
130// The Needs-you items of each live session's last reply, by transcript path.
131const chatAtom = atom({ plugin: 'hq', key: 'chats' } as const, {})
132// Which chat replies the user dismissed with Seen (kept in the store too).
133const dismissedAtom = atom({ plugin: 'hq', key: 'dismissed' } as const, {})
134// The summary.md / report.md text of each task in RESULTS, and the task whose result the pane shows in full.
135const resultsAtom = atom({ plugin: 'hq', key: 'results' } as const, {})
136const detailAtom = atom({ plugin: 'hq', key: 'detail' } as const, null)
137// The whole text of each open question (hold.md / ask.md) and the last note of each task, by task id, and the line of
138// NEEDS YOU whose page the pane shows.
139const questionAtom = atom({ plugin: 'hq', key: 'questions' } as const, {})
140const noteAtom = atom({ plugin: 'hq', key: 'notes' } as const, {})
141const needAtom = atom({ plugin: 'hq', key: 'need' } as const, null)
142// The first line of that page's question that is shown.
143const needScroll = atom({ plugin: 'hq', key: 'needScroll' } as const, 0)
144// The New lead form, null while it is closed.
145const leadAtom = atom({ plugin: 'hq', key: 'newLead' } as const, null)
146// The feature owners and talk threads of the registry (.owners, .talk), and the owner whose Wake form is open.
147const ownerAtom = atom({ plugin: 'hq', key: 'owners' } as const, [])
148const talkAtom = atom({ plugin: 'hq', key: 'talks' } as const, [])
149const wakeAtom = atom({ plugin: 'hq', key: 'wake' } as const, null)
150const SEEN_ALL = '[Seen all]'
151const WAKE_MARK = ' [Wake]'
152const CLOSE_MARK = ' ✕'
153const DISMISS = ' [Dismiss]'
154const RESULT_CHARS = 20_000
155const DETAIL_LINES = 30
156// A question longer than this is paged: the page shows this many of its lines, and Up / Down move by half of them.
157const NEED_LINES = 20
158const NEED_STEP = NEED_LINES / 2
159const CONTEXT_LINES = 12
160const NOTE_CHARS = 2_000
161
162// What the atoms hold may be of an older shape (state outlives a reload of this module): a read that is not of the
163// shape the pane expects counts as empty, so it can never throw while drawing.
164const isBoard = (b: unknown): b is HqBoard => {
165 const value = b as Partial<Record<keyof HqBoard, unknown>> | null
166
167 return !!value && typeof value === 'object' && (['needsYou', 'running', 'waiting', 'done', 'elsewhere', 'parked'] as const).every(key => Array.isArray(value[key]))
168}
169const readBoard = async ($: EngineInterface): Promise<HqBoard | null> => {
170 const value = await read($, board)
171
172 return isBoard(value) ? value : null
173}
174const readReviews = async ($: EngineInterface): Promise<HqReviews> => {
175 const value = (await read($, reviewAtom)) as Partial<HqReviews> | null
176
177 return { fetched: typeof value?.fetched === 'number' ? value.fetched : 0, prs: Array.isArray(value?.prs) ? value.prs : null }
178}
179const saneChats = (raw: unknown): Record<string, HqChatEntry> =>
180 Object.fromEntries(Object.entries(asRecord<Partial<HqChatEntry>>(raw)).filter(([, c]) => Array.isArray(c?.items) && typeof c?.at === 'number')) as Record<string, HqChatEntry>
181
182async function crew($: EngineInterface, args: string[]) {
183 const home = await $.env.get('HOME')
184 if (!home) throw new Error('HOME is not set')
185
186 return $.process.run([`${home}/.claude/bin/crew`, ...args], { timeoutMs: 20_000 })
187}
188
189// bin/tmux-map: every live claude session with its full name and cwd, and its tmux pane if it has one.
190async function liveSessions($: EngineInterface): Promise<HqLive[]> {
191 try {
192 const ran = await $.process.run([`${$.plugin.root}/bin/tmux-map`], { timeoutMs: 10_000 })
193
194 return ran.exitCode === 0 ? parseLive(ran.stdout) : []
195 } catch {
196 return []
197 }
198}
199
200// The open question of each task that has one, by id: hold.md for a held task, ask.md for another task with an open
201// decision. `lines` has its first line, `full` all of it.
202async function readHolds($: EngineInterface, b: HqBoard): Promise<{ lines: Record<string, string>; full: Record<string, string> }> {
203 const home = await $.env.get('HOME')
204 const lines: Record<string, string> = {}
205 const full: Record<string, string> = {}
206
207 for (const t of b.needsYou.filter(t => (t.state === 'held' || t.decision) && TASK_ID.test(t.id))) {
208 try {
209 const text = await $.fs.read(`${home}/.claude/crew/${t.id}/${t.state === 'held' ? 'hold' : 'ask'}.md`)
210 lines[t.id] = holdLine(text)
211 full[t.id] = text.trim().slice(0, RESULT_CHARS)
212 } catch {
213 // no hold.md: the task shows without its question
214 }
215 }
216
217 return { lines, full }
218}
219
220// From each task's status file: when it entered its current state (the first line of the last run of that state), when
221// it last wrote a line, and that line's note.
222async function readStatus($: EngineInterface, b: HqBoard): Promise<{ entered: Record<string, number>; last: Record<string, number>; notes: Record<string, string> }> {
223 const home = await $.env.get('HOME')
224 const entered: Record<string, number> = {}
225 const last: Record<string, number> = {}
226 const notes: Record<string, string> = {}
227
228 for (const t of [...b.needsYou, ...b.running, ...b.waiting, ...b.done].filter(t => TASK_ID.test(t.id))) {
229 try {
230 const text = await $.fs.read(`${home}/.claude/crew/${t.id}/status`)
231 const [enteredAt, lastAt] = [parseStatusTs(text), parseStatusLastTs(text)]
232 if (enteredAt !== undefined) entered[t.id] = enteredAt
233 if (lastAt !== undefined) last[t.id] = lastAt
234 notes[t.id] = parseStatusNote(text).slice(0, NOTE_CHARS)
235 } catch {
236 // no readable status file: the task counts as not new
237 }
238 }
239
240 return { entered, last, notes }
241}
242
243// One crew file as [text, mtime]: kept from the cache while its mtime is unchanged, [null, 0] when it is missing.
244async function readCached($: EngineInterface, path: string, was: [string | null, number]): Promise<[string | null, number]> {
245 try {
246 const { mtimeMs } = await $.fs.stat(path)
247 if (was[1] === mtimeMs && was[0] !== null) return was
248
249 return [(await $.fs.read(path)).slice(0, RESULT_CHARS), mtimeMs]
250 } catch {
251 return [null, 0]
252 }
253}
254
255// summary.md of each task in RESULTS and of each open question (report.md only for one without a summary), read again
256// only when its mtime changes.
257async function readResults($: EngineInterface, b: HqBoard, entered: Record<string, number>, prev: Record<string, HqResultFiles>) {
258 const home = await $.env.get('HOME')
259 const now = await $.clock.now()
260 const out: Record<string, HqResultFiles> = {}
261
262 for (const { id } of [...resultItems(b, entered, now), ...asks(b)].filter(r => TASK_ID.test(r.id))) {
263 const was = prev[id]
264 const [summary, summaryAt] = await readCached($, `${home}/.claude/crew/${id}/summary.md`, [was?.summary ?? null, was?.summaryAt ?? 0])
265 const [report, reportAt] = (summary ?? '').trim() === '' ? await readCached($, `${home}/.claude/crew/${id}/report.md`, [was?.report ?? null, was?.reportAt ?? 0]) : [null, 0]
266 out[id] = { summary, summaryAt, report, reportAt }
267 }
268
269 return out
270}
271
272// The lead, repo path and branch from each open task's meta file.
273async function readMetas($: EngineInterface, b: HqBoard): Promise<Record<string, HqMeta>> {
274 const home = await $.env.get('HOME')
275 const out: Record<string, HqMeta> = {}
276
277 for (const t of [...b.needsYou, ...b.running, ...b.waiting, ...b.done].filter(t => TASK_ID.test(t.id))) {
278 try {
279 out[t.id] = parseMeta(await $.fs.read(`${home}/.claude/crew/${t.id}/meta`))
280 } catch {
281 // no readable meta: the task keeps the repo heuristic
282 }
283 }
284
285 return out
286}
287
288// The chain links ($CREW_ROOT/.links/<sub sessionId>); a link that cannot be read is left out.
289async function readLinks($: EngineInterface): Promise<HqLink[]> {
290 const dir = `${await $.env.get('HOME')}/.claude/crew/.links`
291 const links: HqLink[] = []
292
293 try {
294 for (const e of (await $.fs.list(dir)).filter(e => e.kind !== 'dir' && !e.name.includes('.tmp.'))) {
295 try {
296 links.push(parseLink(await $.fs.read(`${dir}/${e.name}`)))
297 } catch {
298 // no readable link: its session counts as a lead if a task says so
299 }
300 }
301 } catch {
302 // no links folder: no chains
303 }
304
305 return links
306}
307
308// The lead named in every task's meta file, of any age and state (not only the tasks crew ls still lists).
309async function readLed($: EngineInterface): Promise<string[]> {
310 const dir = `${await $.env.get('HOME')}/.claude/crew`
311 let ids: string[] = []
312
313 try {
314 ids = (await $.fs.list(dir)).filter(e => e.kind === 'dir' && TASK_ID.test(e.name)).map(e => e.name)
315 } catch {
316 // no crew folder: no tasks
317 }
318 const metas = await Promise.all(ids.map(id => $.fs.read(`${dir}/${id}/meta`).then(parseMeta, () => ({}) as HqMeta)))
319
320 return metas.flatMap(m => (m.lead ? [m.lead] : []))
321}
322
323// The registry of feature owners ($CREW_ROOT/.owners/<id>/owner with the last line of its notes.md) and the talk
324// threads ($CREW_ROOT/.talk/<thread>/meta). A folder that cannot be read is left out; `.removed` is skipped.
325async function readRegistry($: EngineInterface): Promise<{ owners: OwnerEntry[]; talks: Talk[] }> {
326 const home = await $.env.get('HOME')
327 const owners: OwnerEntry[] = []
328 const talks: Talk[] = []
329 const folders = async (dir: string) => {
330 try {
331 return (await $.fs.list(dir)).filter(e => e.kind === 'dir' && !e.name.startsWith('.') && TASK_ID.test(e.name)).map(e => e.name)
332 } catch {
333 return []
334 }
335 }
336
337 for (const id of await folders(`${home}/.claude/crew/.owners`)) {
338 try {
339 const owner = parseOwner(await $.fs.read(`${home}/.claude/crew/.owners/${id}/owner`))
340 const note = await $.fs.read(`${home}/.claude/crew/.owners/${id}/notes.md`).then(lastNote, () => '')
341 if (owner.feature === id) owners.push({ ...owner, note: note.slice(0, NOTE_CHARS) })
342 } catch {
343 // no readable owner file: it shows nowhere
344 }
345 }
346 for (const id of await folders(`${home}/.claude/crew/.talk`)) {
347 try {
348 talks.push(parseTalk(await $.fs.read(`${home}/.claude/crew/.talk/${id}/meta`)))
349 } catch {
350 // no readable meta: the thread shows nowhere
351 }
352 }
353
354 return { owners, talks }
355}
356
357// One gh query; any failure (no auth, not a GitHub repo, network) is just "no PR known".
358async function fetchPr($: EngineInterface, ref: PrRef, cwd: string): Promise<HqPr | null> {
359 try {
360 const ran = await $.process.run(ghArgs(ref), { cwd, timeoutMs: 20_000 })
361
362 return ran.exitCode === 0 ? parsePr(ran.stdout) : null
363 } catch {
364 return null
365 }
366}
367
368// The one search for PRs whose review is asked of the user. A failed search keeps the last list.
369async function fetchReviews($: EngineInterface): Promise<HqReviewPr[] | null> {
370 try {
371 const ran = await $.process.run(REVIEW_ARGS, { timeoutMs: 20_000 })
372
373 return ran.exitCode === 0 ? parseReviewPrs(ran.stdout) : null
374 } catch {
375 return null
376 }
377}
378
379let isReading = false
380
381// Reads the end of each live session's transcript when the file changed since the last read: one `stat` for all of
382// them, then a `tail` for each changed one, reading more of the end while neither a reply nor a prompt is in it. No
383// model is involved, and what is read stays in the pane's state: it is never logged, toasted or sent to the model.
384async function refreshChats($: EngineInterface, sessions: HqSession[]) {
385 if (isReading) return
386 isReading = true
387
388 try {
389 const paths = sessions.flatMap(s => (s.transcript ? [s.transcript] : []))
390 const cache = saneChats(await read($, chatAtom))
391 const next: Record<string, HqChatEntry> = Object.fromEntries(Object.entries(cache).filter(([path]) => paths.includes(path)))
392 let changed = Object.keys(next).length !== Object.keys(cache).length
393
394 if (paths.length > 0) {
395 const stats = parseStat((await $.process.run(['stat', '-c', '%n|%s|%Y', '--', ...paths], { timeoutMs: 10_000 })).stdout)
396
397 for (const path of Object.keys(next).filter(path => !stats[path])) {
398 delete next[path]
399 changed = true
400 }
401 for (const path of chatsDue(stats, next)) {
402 const { size, mtime } = stats[path]!
403 let chat: HqChat | null = null
404 let failed = false
405 for (const bytes of TAIL_BYTES) {
406 try {
407 const tail = await $.process.run(['tail', '-c', String(bytes), '--', path], { timeoutMs: 10_000 })
408 if (tail.exitCode !== 0 || tail.isStdoutTruncated) throw new Error('unreadable')
409 chat = parseLastReply(tail.stdout, size > bytes)
410 } catch {
411 // a read that failed, at either size, is not kept: the next poll tries again
412 failed = true
413 break
414 }
415 if (chat !== null || size <= bytes) break
416 }
417 delete next[path]
418 if (!failed) next[path] = { items: chat?.items ?? [], at: chat?.at || mtime * 1000, size, mtime }
419 changed = true
420 }
421 }
422 if (changed) await update($, chatAtom, () => next)
423 } catch {
424 // an unreadable transcript leaves its session without chat items
425 } finally {
426 isReading = false
427 }
428}
429
430let isRefreshing = false
431
432// Asks GitHub (outside the board poll, at most three queries each time, the review search among them): who asked
433// the user for a review, and about the PRs of the tasks that are due. Caches the answers, and toasts each task the
434// first time it is seen merged on GitHub but not logged landed.
435async function refreshPrs($: EngineInterface) {
436 if (isRefreshing) return
437 isRefreshing = true
438
439 try {
440 const b = await readBoard($)
441 if (!b) return
442
443 const now = await $.clock.now()
444 const reviews = await readReviews($)
445 const searching = reviewDue(reviews, now)
446 if (searching) {
447 const prs = await fetchReviews($)
448 await update($, reviewAtom, () => ({ fetched: now, prs: prs ?? reviews.prs }))
449 }
450 const found: Record<string, HqPrEntry> = {}
451 for (const { task, ref } of ghDue(prTasks(b), asRecord<HqPrEntry>(await read($, prAtom)), now, searching ? 2 : undefined)) {
452 found[task.id] = { key: prKey(task)!, fetched: now, pr: await fetchPr($, ref, task.repoPath!) }
453 }
454 const cache = asRecord<HqPrEntry>(await read($, prAtom))
455 const kept = prunePrs(b, cache)
456 if (Object.keys(found).length === 0 && Object.keys(kept).length === Object.keys(cache).length) return
457
458 await update($, prAtom, () => ({ ...kept, ...found }))
459
460 const prs = asRecord<HqPrEntry>(await read($, prAtom))
461 const seen = asList<string>(await read($, prToasted))
462 for (const { task } of mergedNotLogged(b, prs).filter(m => !seen.includes(m.task.id))) {
463 $.ui.toast(`${shortName(task.id)} merged on GitHub — not logged`)
464 await update($, prToasted, list => [...list, task.id])
465 }
466 } catch {
467 // a failed refresh leaves the cache as it was
468 } finally {
469 isRefreshing = false
470 }
471}
472
473// Switches the tmux client to the session's pane; says why not (and toasts it) instead of throwing.
474async function jump($: EngineInterface, to: HqSession): Promise<string | null> {
475 const reason = await switchClient($, to.target)
476 if (reason) $.ui.toast(reason)
477
478 return reason
479}
480
481// The user asks for the changes of a task in review: `crew diff <id> --focus` opens them in nvim next to the lead. Its
482// one-line answer, or why it failed, is toasted; a failure to run it at all is toasted too, not thrown.
483async function diffInPane($: EngineInterface, id: string): Promise<void> {
484 if (!TASK_ID.test(id)) return $.ui.toast(`diff failed: no task "${id}"`)
485
486 try {
487 $.ui.toast(diffFeedback(await crew($, diffArgs(id)), id))
488 } catch (failure) {
489 $.ui.toast(`diff failed: ${String(failure)}`)
490 }
491}
492
493async function switchClient($: EngineInterface, target: string): Promise<string | null> {
494 if (!isTarget(target)) return `tmux: refusing the target "${target}"`
495 if (!(await $.env.get('TMUX'))) return "HQ isn't running inside tmux — start it with tmux"
496
497 try {
498 const ran = await $.process.run(['tmux', 'switch-client', '-t', target])
499
500 return ran.exitCode === 0 ? null : `tmux: ${ran.stderr.trim() || `switch-client exited ${ran.exitCode}`}`
501 } catch (failure) {
502 return `tmux: ${String(failure)}`
503 }
504}
505
506// hq_answer: records the user's answer to a held task or an open decision (a D-id, or the id of a task that has
507// one) and names the lead session to notify.
508async function answerHold($: EngineInterface, id: string, answerText: string) {
509 const answer = answerText.trim()
510 if (answer === '') return { deny: 'the answer is empty' }
511 if (!TASK_ID.test(id)) return { deny: `no held task "${id}"` }
512
513 const decision = DECISION_ID.test(id) ? id.toUpperCase() : undefined
514 await poll($)
515 const b = await readBoard($)
516 const task = b?.needsYou.find(t => (decision ? t.decision === decision : t.id === id))
517 if (!b || !task) return { deny: decision ? `no open decision "${id}"` : `no held task "${id}"` }
518 if (task.state !== 'held' && !task.decision) return { deny: `task ${id} is not held (state: ${task.state})` }
519
520 const ran = await crew($, ['answer', decision ?? id, answer])
521 if (ran.exitCode !== 0) return { deny: ran.stderr.trim() || `crew answer exited ${ran.exitCode}` }
522
523 const lead = resolveLead(b, task)
524
525 return {
526 result: JSON.stringify({
527 ok: true,
528 id: task.id,
529 ...(task.decision ? { decision: task.decision } : {}),
530 lead: lead ? lead.session.name : null,
531 ...(lead?.guessed ? { leadGuessed: true } : {}),
532 ...(lead ? {} : { note: 'no live session for this project: tell the user' }),
533 }),
534 }
535}
536
537// The user answers a question on its page: the same as hq_answer, then HQ's model is asked to tell the lead, as it
538// does for an answer typed in chat. Says why not (and toasts it) instead of throwing.
539async function answerInPane($: EngineInterface, id: string, text: string): Promise<boolean> {
540 const answered = await answerHold($, id, text)
541 if ('deny' in answered) {
542 $.ui.toast(`answer not recorded: ${answered.deny}`)
543
544 return false
545 }
546
547 const result = JSON.parse(answered.result) as { id: string; decision?: string; lead: string | null; leadGuessed?: boolean }
548 $.ui.toast(`${result.decision ?? result.id} answered${result.lead ? ` — telling ${result.lead}` : ' — no live lead to tell'}`)
549 void poll($)
550 // a turn of HQ's own: not waited for here, so the page closes at once
551 $.prompt
552 .submit({ text: relayPrompt(result, text.trim()) })
553 .then(submitted => submitted.drop !== undefined && $.ui.toast(`recorded, but the lead was not told: ${submitted.drop}`))
554 .catch(failure => $.ui.toast(`recorded, but the lead was not told: ${String(failure)}`))
555
556 return true
557}
558
559// Opens the New lead form with the folders HQ knows: where live sessions run and the repos of crew tasks.
560async function openLeadForm($: EngineInterface) {
561 const home = (await $.env.get('HOME')) ?? ''
562 const choices = knownRepos(await liveSessions($), await readBoard($), home)
563 await update($, leadAtom, () => ({ repo: '', name: '', text: '', model: '', choices }))
564}
565
566// Starts a top-level lead: `crew spawn --top` as argv, a plain command and never a model turn. Toasts what happened
567// (the window, or why not) instead of throwing; true when it started or queued.
568async function startLead($: EngineInterface, form: NewLeadForm): Promise<boolean> {
569 const home = (await $.env.get('HOME')) ?? ''
570 const built = spawnArgv(`${home}/.claude/bin/crew`, form, home)
571 if ('error' in built) {
572 $.ui.toast(`not started: ${built.error}`)
573
574 return false
575 }
576
577 const ran = await $.process.run(built.argv, { timeoutMs: 30_000 }).catch(failure => ({ exitCode: 127, stdout: '', stderr: String(failure) }))
578 const outcome = spawnOutcome(ran)
579 if (!outcome.ok) {
580 $.ui.toast(`not started: ${outcome.error}`)
581
582 return false
583 }
584
585 $.ui.toast(outcome.queued ? `${form.name.trim()} queued: waiting for RAM (crew spawn --resume ${outcome.id})` : `${form.name.trim()} started in ${outcome.window}`)
586 void poll($)
587
588 return true
589}
590
591async function openWakeForm($: EngineInterface, feature: string) {
592 await update($, wakeAtom, () => feature)
593}
594
595// Wakes an owner: `crew wake FEATURE -- text` as argv with CREW_AS_USER=1, a plain command and never a model turn.
596// Toasts what happened (woke in a window, queued, or why not) instead of throwing; true when it woke or queued.
597async function wakeOwner($: EngineInterface, feature: string, text: string): Promise<boolean> {
598 const home = (await $.env.get('HOME')) ?? ''
599 const built = wakeArgv(`${home}/.claude/bin/crew`, feature, text)
600 if ('error' in built) {
601 $.ui.toast(`not woken: ${built.error}`)
602
603 return false
604 }
605
606 const ran = await $.process.run(built.argv, { env: built.env, timeoutMs: 30_000 }).catch(failure => ({ exitCode: 127, stdout: '', stderr: String(failure) }))
607 const outcome = wakeOutcome(ran)
608 if (!outcome.ok) {
609 $.ui.toast(`not woken: ${outcome.error}`)
610
611 return false
612 }
613
614 $.ui.toast(outcome.message)
615 void poll($)
616
617 return true
618}
619
620// Closes a session's window: `crew close NAME` as argv with CREW_AS_USER=1 (HQ is the user's), a plain command and never a
621// model turn. Toasts what happened (the closed window, or crew's reason for refusing) instead of throwing.
622async function closeSession($: EngineInterface, name: string): Promise<void> {
623 const home = (await $.env.get('HOME')) ?? ''
624 const built = closeArgv(`${home}/.claude/bin/crew`, name)
625 if ('error' in built) {
626 $.ui.toast(`not closed: ${built.error}`)
627
628 return
629 }
630
631 const ran = await $.process.run(built.argv, { env: built.env, timeoutMs: 30_000 }).catch(failure => ({ exitCode: 127, stdout: '', stderr: String(failure) }))
632 const outcome = closeOutcome(ran)
633 $.ui.toast(outcome.ok ? outcome.message : `not closed: ${outcome.error}`)
634 void poll($)
635}
636
637// The user has looked at everything (no id) or at one task: JUST FINISHED drops it.
638async function see($: EngineInterface, id?: string) {
639 const next = markSeen(parseSeen(await read($, seenAtom)), await $.clock.now(), id)
640 await update($, seenAtom, () => next)
641 try {
642 await $.store.set('seen', next)
643 } catch {
644 // not persisted: the seen-state lasts until HQ closes
645 }
646}
647
648// The Needs-you items that sessions left in chat and nobody recorded.
649const allChats = async ($: EngineInterface, b: HqBoard) => chatNeeds(b, saneChats(await read($, chatAtom)), parseDismissed(await read($, dismissedAtom)))
650// Only the sessions whose last reply is under a day old are a need: the band and /hq count those.
651const chatsOf = async ($: EngineInterface, b: HqBoard) => splitChats(await allChats($, b), await $.clock.now()).fresh
652
653// Drops the dismissals of replies that were replaced and of sessions that are gone.
654async function forgetDismissed($: EngineInterface, b: HqBoard) {
655 const was = parseDismissed(await read($, dismissedAtom))
656 const kept = pruneDismissed(b, saneChats(await read($, chatAtom)), was)
657 if (Object.keys(kept).length === Object.keys(was).length) return
658 await update($, dismissedAtom, () => kept)
659 try {
660 await $.store.set('chatSeen', kept)
661 } catch {
662 // not persisted: the dismissals lasted until HQ closes
663 }
664}
665
666// The user dismisses what a session said in chat: the items stay hidden until its next reply.
667async function dismiss($: EngineInterface, key: string, at: number) {
668 const next = dismissChat(parseDismissed(await read($, dismissedAtom)), key, at)
669 await update($, dismissedAtom, () => next)
670 try {
671 await $.store.set('chatSeen', next)
672 } catch {
673 // not persisted: the dismissal lasts until HQ closes
674 }
675}
676
677// The user dismisses the sessions whose questions are over a day old.
678async function dismissAll($: EngineInterface, chats: { key: string; at: number }[]) {
679 for (const { key, at } of chats) await dismiss($, key, at)
680}
681
682let inflight: Promise<void> | null = null
683
684// One poll at a time; a caller that arrives meanwhile waits for the one under way.
685function poll($: EngineInterface): Promise<void> {
686 inflight ??= pollOnce($).finally(() => {
687 inflight = null
688 })
689
690 return inflight
691}
692
693async function pollOnce($: EngineInterface) {
694 try {
695 const prev = await readBoard($)
696 const [ran, live] = await Promise.all([crew($, ['ls']), liveSessions($)])
697 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `crew ls exited ${ran.exitCode}`)
698 const parsed = parseBoard(ran.stdout)
699 const next = markLeads(applyMeta(mergeLive(parsed, live), await readMetas($, parsed)), await readLinks($), await readLed($))
700 await update($, board, () => next)
701 const asked = await readHolds($, next)
702 const registry = await readRegistry($)
703 await update($, ownerAtom, () => registry.owners)
704 await update($, talkAtom, () => registry.talks)
705 await update($, holdAtom, () => asked.lines)
706 await update($, questionAtom, () => asked.full)
707 const { entered, last, notes } = await readStatus($, next)
708 await update($, enteredAtom, () => entered)
709 await update($, lastAtom, () => last)
710 await update($, noteAtom, () => notes)
711 const results = await readResults($, next, entered, asRecord<HqResultFiles>(await read($, resultsAtom)))
712 await update($, resultsAtom, () => results)
713 await refreshChats($, next.elsewhere.filter(s => s.kind === 'claude'))
714 await forgetDismissed($, next)
715 await update($, error, () => null)
716 for (const message of newlyEntered(prev, next)) $.ui.toast(message)
717 void refreshPrs($)
718 } catch (failure) {
719 await update($, error, () => String(failure))
720 }
721}
722
723export const register: Register = on => {
724 on('session.start', async ($, e, next) => {
725 await $.command.register({
726 name: 'hq',
727 description:
728 'Refresh the crew board and show its pane; "show <id>" prints one task, "view" switches Focus and Projects, "go <name>" jumps to a session or task in tmux, "seen" clears JUST FINISHED, "brief" says what changed since you last looked',
729 argumentHint: '[show <id> | view | go <name> | seen | brief]',
730 })
731 await $.tool.register({
732 name: 'hq_board',
733 description:
734 'Read-only. The crew board as JSON: open tasks (id, short name, repo, state, age, note, decision id and question when one is open), live claude sessions (name, project, state, age, tmux target, `link`: the claude.ai URL that opens the session, when it has one), the lead session of each task (`leadGuessed` when it is only a guess by project) and its GitHub PR (number, state) when known, and the best lead per project. Call it before relaying an instruction.',
735 isDeferred: false,
736 })
737 await $.tool.register({
738 name: 'hq_answer',
739 description:
740 "Records the user's answer to a held crew task or an open decision (runs crew answer). id: a decision id such as D3, or a held task; answer: the user's words verbatim. Returns the lead session to notify.",
741 inputSchema: {
742 type: 'object',
743 properties: { id: { type: 'string' }, answer: { type: 'string' } },
744 required: ['id', 'answer'],
745 },
746 isDeferred: false,
747 })
748 try {
749 const saved = parseSeen(await $.store.get('seen'))
750 await update($, seenAtom, () => saved)
751 } catch {
752 // no store: nothing has been looked at yet
753 }
754 try {
755 const dismissed = parseDismissed(await $.store.get('chatSeen'))
756 await update($, dismissedAtom, () => dismissed)
757 } catch {
758 // no store: nothing has been dismissed yet
759 }
760 const opened = await $.ui.open(PANE_ARGS)
761 if (!opened.isPlaced) $.ui.status(HIDDEN_HINT)
762 $.clock.after(1, () => void poll($))
763 $.clock.every(POLL_MS, () => void poll($))
764
765 return next(e)
766 })
767
768 on('tool.call', { tool: 'mcp__hq__hq_board' }, async $ => {
769 await poll($)
770 const [b, asked, prs] = [await readBoard($), asRecord<string>(await read($, holdAtom)), asRecord<HqPrEntry>(await read($, prAtom))]
771
772 return b ? { result: JSON.stringify(boardForModel(b, asked, prs)) } : { deny: (await read($, error)) ?? 'the board is not loaded yet' }
773 })
774
775 on('tool.call', { tool: 'mcp__hq__hq_answer' }, ($, e) => answerHold($, String(e.id ?? ''), String(e.answer ?? '')))
776
777 on('prompt.compose', async ($, e, next) => {
778 const composed = await next(e)
779
780 return { sections: [...composed.sections, { id: 'hq-role', text: ROLE, scope: 'session' as const }] }
781 })
782
783 on('command.run', { command: 'hq' }, async ($, e) => {
784 const [sub, ...ids] = e.args.trim().split(/\s+/)
785
786 if (sub === 'show') {
787 const [id] = ids
788 if (ids.length !== 1 || !id || !TASK_ID.test(id)) return { text: 'usage: /hq show <task id>' }
789 const ran = await crew($, ['show', id])
790 if (ran.exitCode === 0) await see($, id)
791
792 return { text: (ran.exitCode === 0 ? ran.stdout : ran.stderr || ran.stdout).trimEnd() }
793 }
794
795 if (sub === 'go') {
796 const b = (await readBoard($)) ?? emptyBoard()
797 const query = ids.length === 1 ? ids[0]! : ''
798 const named = b.elsewhere.find(s => s.kind === 'claude' && s.target !== '' && s.name === query)
799 const task = named ? undefined : findTask(b, query)
800 const hit = named ?? (task ? jumpFor(b, task)?.session : undefined) ?? (ids.length === 1 ? findJump(b, query) : undefined)
801
802 if (!hit) {
803 const choices = jumpChoices(b)
804
805 return { text: choices.length === 0 ? 'No tmux sessions known yet.' : `No match for "${ids.join(' ')}". Try: ${choices.slice(0, 12).join(', ')}` }
806 }
807
808 const reason = await jump($, hit)
809 if (!reason && task) await see($, task.id)
810
811 return { text: reason ?? `Jumped to ${hit.name} (${showTarget(hit.target)})` }
812 }
813
814 if (sub === 'brief') {
815 await poll($)
816 const failure = await read($, error)
817 const b = await readBoard($)
818 if (failure || !b) return { text: `crew ls failed: ${failure ?? 'the board is not loaded yet'}` }
819
820 const [now, last] = [await $.clock.now(), await $.store.get('lastBrief')]
821 const isFirst = typeof last !== 'number'
822 const text = briefText(b, isFirst ? now - 86_400_000 : last, now, isFirst, (await readStatus($, b)).entered, mergedNotLogged(b, asRecord<HqPrEntry>(await read($, prAtom))))
823 await $.store.set('lastBrief', now)
824
825 return { text }
826 }
827
828 if (sub === 'seen') {
829 await see($)
830
831 return { text: '✓/✗ marks cleared' }
832 }
833
834 if (sub === 'view') {
835 const next = (await read($, view)) === 'focus' ? 'projects' : 'focus'
836 await update($, view, () => next)
837
838 return { text: `View: ${next}` }
839 }
840
841 $.ui.status(undefined)
842 await poll($)
843 await $.ui.open(PANE_ARGS)
844 const current = await readBoard($)
845 const failure = await read($, error)
846
847 const reviews = (await readReviews($)).prs?.length ?? 0
848
849 return { text: failure ?? (current && summarize(current, reviews, await chatsOf($, current))) ?? 'Crew board refreshed.' }
850 })
851
852 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
853 const current = await readBoard($)
854 const reviews = (await readReviews($)).prs?.length ?? 0
855 const chats = current ? await chatsOf($, current) : []
856 const line = current && summarize(current, reviews, chats)
857
858 if (e.props.hasSurvey || !line) return next(e)
859
860 const { Box, Text } = $.ui.resolve(e)
861
862 return (
863 <Box>
864 <Text color={needCount(current, reviews, chats) > 0 ? 'warning' : undefined}>{fit(line, e.props.bodyColumns)}</Text>
865 </Box>
866 )
867 })
868
869 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
870 const { Box, Button, Input, Link, Text } = $.ui.resolve(e)
871 const current = await readBoard($)
872 const failure = await read($, error)
873 const mode = await read($, view)
874 const asked = asRecord<string>(await read($, holdAtom))
875 const prs = asRecord<HqPrEntry>(await read($, prAtom))
876 const now = await $.clock.now()
877 const reviewPrs = (await readReviews($)).prs ?? []
878 const results = asRecord<HqResultFiles>(await read($, resultsAtom))
879 const detailRead = await read($, detailAtom)
880 const detail = typeof detailRead === 'string' ? detailRead : null
881 const needRead = await read($, needAtom)
882 const needKey = typeof needRead === 'string' ? needRead : null
883 const scrolledRead = await read($, needScroll)
884 const scrolled = typeof scrolledRead === 'number' ? scrolledRead : 0
885 const leadRead = (await read($, leadAtom)) as (NewLeadForm & { choices: string[] }) | null
886 const leadForm = leadRead && typeof leadRead === 'object' && Array.isArray(leadRead.choices) ? leadRead : null
887 const wakeRead = await read($, wakeAtom)
888 const wakeFeature = typeof wakeRead === 'string' ? wakeRead : null
889 const owners = asList<OwnerEntry>(await read($, ownerAtom))
890 const talks = asList<Talk>(await read($, talkAtom))
891 const questions = asRecord<string>(await read($, questionAtom))
892 const notes = asRecord<string>(await read($, noteAtom))
893 const last = asRecord<number>(await read($, lastAtom))
894 const ctx = { now, entered: asRecord<number>(await read($, enteredAtom)), seen: parseSeen(await read($, seenAtom)), prs }
895 // all of them: the layout puts the sessions whose reply is over a day old apart
896 const chats = current ? await allChats($, current) : []
897 // inside the pane's padding of 1
898 const width = Math.max(10, e.props.bodyColumns - 2)
899
900 const tab = (key: 'focus' | 'projects', label: string) => (
901 <Button
902 key={`tab-${key}`}
903 plain
904 onPress={async () => {
905 await update($, detailAtom, () => null)
906 await update($, needAtom, () => null)
907 await update($, needScroll, () => 0)
908 await update($, leadAtom, () => null)
909 await update($, wakeAtom, () => null)
910 await update($, view, () => key)
911 }}
912 >
913 <Text bold={mode === key} dimColor={mode !== key}>
914 {label}
915 </Text>
916 </Button>
917 )
918
919 const jumpToLead = (b: HqBoard, id: string) => {
920 const task = findTask(b, id)
921
922 return task ? jumpFor(b, task)?.session : undefined
923 }
924
925 // A row of JUST FINISHED: a result opens its page and counts as seen; one without a page (its PR ended on GitHub)
926 // jumps to the lead that has to log it, and counts as seen once there.
927 const openFinished = async (b: HqBoard, row: FinishedRow) => {
928 if (row.detail) {
929 await see($, row.id)
930 await update($, detailAtom, () => row.id)
931
932 return
933 }
934 const hit = jumpToLead(b, row.id)
935 if (hit && !(await jump($, hit))) await see($, row.id)
936 }
937
938 const layoutOf = (b: HqBoard) => focusLayout({ board: b, plan: classify(b, ctx), ctx, holds: asked, requested: reviewPrs, chats, files: results, last, questions, owners, talks })
939
940 const focus = (b: HqBoard, layout: FocusView) => {
941 // inside the two columns of the glyph or indent that starts every line
942 const inner = width - 2
943 const needs = needParts(layout.needs, inner)
944 const nowLines = nowParts(layout.now, inner - CLOSE_MARK.length)
945 const finished = finishedParts(layout.finished, inner)
946 const heading = `JUST FINISHED${layout.newCount > 0 ? ` · ${layout.newCount} new` : ''}`
947 // with something new the line is padded so that [Seen all] ends at the right edge
948 const headingText = layout.newCount > 0 ? splitRow(heading, SEEN_ALL, width)[0] : heading
949
950 return (
951 <Box flexDirection="column">
952 {(layout.needs.length > 0 || layout.older.length > 0) && (
953 <Box flexDirection="column" marginTop={1}>
954 {layout.needs.length > 0 && <Text bold>{`NEEDS YOU (${layout.total})`}</Text>}
955 {layout.needs.map((n, i) => {
956 const open = (
957 <Button
958 key={`need-${n.key}`}
959 plain
960 onPress={async () => {
961 await update($, needScroll, () => 0)
962 await update($, needAtom, () => n.key)
963 }}
964 >
965 <Text color={n.kind === 'pr' ? 'yellow' : 'magenta'}>{'? '}</Text>
966 <Text wrap="truncate-end">{`${needs[i]!.name} ${needs[i]!.msg} `}</Text>
967 </Button>
968 )
969 const goKey = n.id ? `go-${n.id}` : n.kind === 'chat' ? `go-chat-${n.session?.name}` : `go-pr-${i}`
970 // where to go: at the right edge, or on a dim line of its own when the pane is too narrow for both
971 const goAt = (text: string, dim: boolean) =>
972 n.session ? (
973 <Button key={goKey} plain onPress={() => void jump($, n.session!)}>
974 <Text color="cyan" dimColor={dim}>
975 {text}
976 </Text>
977 </Button>
978 ) : (
979 <Text dimColor>{text}</Text>
980 )
981
982 return (
983 <Box flexDirection="column">
984 <Box>
985 {open}
986 {needs[i]!.below === '' && goAt(needs[i]!.go, false)}
987 </Box>
988 {needs[i]!.below !== '' && <Box>{goAt(` ${needs[i]!.below}`, true)}</Box>}
989 </Box>
990 )
991 })}
992 {layout.morePrs > 0 && <Text dimColor>{` +${layout.morePrs} more review requests`}</Text>}
993 {layout.older.length > 0 && (
994 <Box>
995 <Text dimColor wrap="truncate-end">
996 {olderText(layout.older, width - DISMISS.length)}
997 </Text>
998 <Button key="dismiss-older" plain onPress={() => void dismissAll($, layout.older)}>
999 <Text dimColor>{DISMISS}</Text>
1000 </Button>
1001 </Box>
1002 )}
1003 </Box>
1004 )}
1005 {layout.now.length > 0 && (
1006 <Box flexDirection="column" marginTop={1}>
1007 <Text bold>NOW</Text>
1008 {layout.now.map((e, i) => {
1009 const hit = e.session && e.session.target !== '' ? e.session : undefined
1010 const busy = e.session?.state === 'busy'
1011 const texts = [
1012 <Text bold={e.session !== undefined} dimColor={e.session === undefined}>{` ${nowLines[i]!.name}`}</Text>,
1013 <Text color={busy ? 'cyan' : undefined} dimColor={!busy}>
1014 {nowLines[i]!.status}
1015 </Text>,
1016 <Text wrap="truncate-end">{nowLines[i]!.lines[0]}</Text>,
1017 ]
1018
1019 return (
1020 <Box flexDirection="column">
1021 {hit ? (
1022 <Box>
1023 <Button key={`go-${hit.name}`} plain onPress={() => void jump($, hit)}>
1024 {texts}
1025 </Button>
1026 <Button key={`close-${hit.name}`} plain onPress={() => void closeSession($, hit.name)}>
1027 <Text color="cyan">{CLOSE_MARK}</Text>
1028 </Button>
1029 </Box>
1030 ) : (
1031 <Box>{texts}</Box>
1032 )}
1033 {nowLines[i]!.lines.slice(1).map(line => (
1034 <Text wrap="truncate-end">{` ${line}`}</Text>
1035 ))}
1036 {e.owners?.map(o => (
1037 <Box key={`owner-${o.feature}`}>
1038 <Text dimColor wrap="truncate-end">
1039 {fit(` ${o.text}`, inner - WAKE_MARK.length)}
1040 </Text>
1041 <Button key={`wake-${o.feature}`} plain onPress={() => void openWakeForm($, o.feature)}>
1042 <Text color="cyan">{WAKE_MARK}</Text>
1043 </Button>
1044 </Box>
1045 ))}
1046 {e.talks?.map(t => (
1047 <Text dimColor wrap="truncate-end">
1048 {fit(` ${t}`, inner)}
1049 </Text>
1050 ))}
1051 </Box>
1052 )
1053 })}
1054 </Box>
1055 )}
1056 {layout.finished.length > 0 && (
1057 <Box flexDirection="column" marginTop={1}>
1058 <Box>
1059 <Text bold>{headingText}</Text>
1060 {layout.newCount > 0 && (
1061 <Button key="seen-all" plain onPress={() => void see($)}>
1062 <Text dimColor>{SEEN_ALL}</Text>
1063 </Button>
1064 )}
1065 </Box>
1066 {layout.finished.map((row, i) => (
1067 <Button key={`result-${row.id}`} plain onPress={() => void openFinished(b, row)}>
1068 <Text color={row.mark === '✗' ? 'red' : 'green'}>{`${row.mark === '✗' ? '✗' : row.isNew ? '●' : ' '} `}</Text>
1069 <Text wrap="truncate-end">{`${finished[i]!.name} ${finished[i]!.verdict} `}</Text>
1070 {finished[i]!.lead !== '' && <Text dimColor>{finished[i]!.lead}</Text>}
1071 </Button>
1072 ))}
1073 {layout.earlier > 0 && <Text dimColor>{` +${layout.earlier} earlier`}</Text>}
1074 </Box>
1075 )}
1076 {(layout.isClear || layout.idle.length > 0) && (
1077 <Box marginTop={1}>
1078 <Text dimColor wrap="truncate-end">
1079 {layout.isClear ? clearText(layout.idle.length) : idleText(layout.idle, width)}
1080 </Text>
1081 </Box>
1082 )}
1083 </Box>
1084 )
1085 }
1086
1087 // One result in full: summary.md, else report.md, else the note; a missing file only shortens what is shown.
1088 const detailView = (b: HqBoard, item: ResultItem) => {
1089 const files = results[item.id]
1090 const hit = jumpToLead(b, item.id)
1091 const lines = wrapText(stripMarks(resultBody(files, item.note)), width, DETAIL_LINES)
1092
1093 return (
1094 <Box flexDirection="column">
1095 <Box marginTop={1}>
1096 <Button key="result-back" plain onPress={() => update($, detailAtom, () => null)}>
1097 <Text>← Back</Text>
1098 </Button>
1099 </Box>
1100 <Box marginTop={1}>
1101 <Text bold color={item.mark === '✓' ? 'green' : 'red'}>{`${item.mark} ${item.name} · ${item.project}`}</Text>
1102 </Box>
1103 <Box flexDirection="column" marginTop={1}>
1104 {lines.map(line => (
1105 <Text>{line === '' ? ' ' : line}</Text>
1106 ))}
1107 </Box>
1108 {hit && (
1109 <Box marginTop={1}>
1110 <Button key="result-go" plain onPress={() => void jump($, hit)}>
1111 <Text>{`→ ${hit.name} (${hit.target})`}</Text>
1112 </Button>
1113 </Box>
1114 )}
1115 <Box marginTop={1}>
1116 <Text dimColor>{`crew show ${item.id}`}</Text>
1117 </Box>
1118 </Box>
1119 )
1120 }
1121
1122 // One NEEDS YOU line in full: what it is and where it comes from, the whole text wrapped to the pane, what to
1123 // decide with, and what can be done (answer, go, dismiss, open the PR).
1124 const needView = (d: NeedDetail) => {
1125 const footer = needFooter(d)
1126 const back = async () => {
1127 await update($, needScroll, () => 0)
1128 await update($, needAtom, () => null)
1129 }
1130 const all = wrapText(stripMarks(d.text), width, Infinity)
1131 const top = Math.min(Math.max(0, scrolled), Math.max(0, all.length - NEED_LINES))
1132 const end = Math.min(all.length, top + NEED_LINES)
1133 const move = (to: number) => update($, needScroll, () => Math.max(0, Math.min(to, all.length - NEED_LINES)))
1134
1135 return (
1136 <Box flexDirection="column">
1137 <Box marginTop={1}>
1138 <Button key="need-back" plain onPress={back}>
1139 <Text>← Back</Text>
1140 </Button>
1141 </Box>
1142 <Box marginTop={1} flexDirection="column">
1143 <Text bold color={d.kind === 'pr' ? 'yellow' : 'magenta'}>{`? ${d.title}`}</Text>
1144 {d.meta.map(line => (
1145 <Text dimColor wrap="truncate-end">
1146 {line}
1147 </Text>
1148 ))}
1149 </Box>
1150 <Box flexDirection="column" marginTop={1}>
1151 {all.slice(top, end).map(line => (
1152 <Text>{line === '' ? ' ' : line}</Text>
1153 ))}
1154 {all.length > NEED_LINES && (
1155 <Box>
1156 {top > 0 && (
1157 <Button key="need-up" plain onPress={() => move(top - NEED_STEP)}>
1158 <Text color="cyan">▲ Up </Text>
1159 </Button>
1160 )}
1161 {end < all.length && (
1162 <Button key="need-down" plain onPress={() => move(top + NEED_STEP)}>
1163 <Text color="cyan">▼ Down </Text>
1164 </Button>
1165 )}
1166 <Text dimColor>{`lines ${top + 1}-${end} of ${all.length}`}</Text>
1167 </Box>
1168 )}
1169 </Box>
1170 {d.url && (
1171 <Box marginTop={1}>
1172 <Link href={d.url} />
1173 </Box>
1174 )}
1175 {d.context.map(c => (
1176 <Box flexDirection="column" marginTop={1}>
1177 <Text bold dimColor>
1178 {c.label}
1179 </Text>
1180 {wrapText(stripMarks(c.text), width - 2, CONTEXT_LINES).map(line => (
1181 <Text>{` ${line}`}</Text>
1182 ))}
1183 </Box>
1184 ))}
1185 {d.answer && (
1186 <Box marginTop={1}>
1187 <Input
1188 key="need-answer"
1189 label="Answer "
1190 placeholder="type it, then Enter"
1191 submitLabel="answer"
1192 onSubmit={async text => {
1193 if (await answerInPane($, d.answer!, text)) await back()
1194 }}
1195 />
1196 </Box>
1197 )}
1198 {(d.go || d.dismiss || d.diff) && (
1199 <Box marginTop={1}>
1200 {d.go && (hooks/board.ts 1703 lines1import type { HqBoard, HqChat, HqChatEntry, HqLive, HqMeta, HqParked, HqPr, HqPrEntry, HqResultFiles, HqReviewPr, HqReviews, HqSeen, HqSession, HqTask } from '../types'
2
3// Pure helpers over the text of `crew ls`; nothing here touches `$`.
4
5export const TOAST_STATES = ['review', 'held', 'done', 'landed', 'failed']
6
7// What a store or an atom held, when it may be of an older shape: anything but an object (a list) is empty.
8export const asRecord = <T = unknown>(value: unknown): Record<string, T> =>
9 value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, T>) : {}
10export const asList = <T = unknown>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
11
12export const emptyBoard = (): HqBoard => ({
13 needsYou: [],
14 running: [],
15 waiting: [],
16 done: [],
17 elsewhere: [],
18 parked: [],
19 parkedMore: 0,
20})
21
22const HEADER = /^[A-Z][A-Z ]*$/
23const AGE = '\\d+[smhd]'
24// ` <id> <state> <age> <shape> <repo> <note>`; shape, repo and note follow the age.
25const TASK = new RegExp(`^\\s+(\\S+)\\s+(\\S+)\\s+(${AGE})\\s*(.*)$`)
26const DETAIL = /^(?:ship|scout|draft)?\s*(\S+)\s*(.*)$/
27// A note that starts with the id of an open decision: `D3 · the rest` (crew ls adds it).
28const DECISION = /^(D\d+) ·(?: (.*))?$/
29// ` proc pid <n> run <age> <args>`; the claude rows are ignored: tmux-map lists those in full.
30const SESSION = new RegExp(`^\\s+(claude|proc)\\s+(.+?)\\s+(\\S+)\\s+(${AGE})\\s*(.*)$`)
31// ` P2 10-15 <repo> <note>`.
32const PARKED = /^\s+(P\d+)\s+(\d\d-\d\d)\s+(\S+)\s+(.*)$/
33const MORE = /^\s+\+(\d+) more\s*$/
34
35export function parseBoard(text: string): HqBoard {
36 const board = emptyBoard()
37 let section = ''
38
39 for (const line of text.split('\n')) {
40 if (HEADER.test(line)) {
41 section = line
42 continue
43 }
44
45 if (section === 'NEEDS YOU' || section === 'RUNNING' || section === 'WAITING ON OTHERS' || section === 'DONE') {
46 const m = TASK.exec(line)
47 if (!m) continue
48 const [, repo = '-', detail = ''] = DETAIL.exec(m[4]!) ?? []
49 const decided = DECISION.exec(detail.trim())
50 const note = decided ? (decided[2] ?? '') : detail
51 const task: HqTask = { id: m[1]!, state: m[2]!, age: m[3]!, repo, note: note.trim(), ...(decided ? { decision: decided[1]! } : {}) }
52 const list = {
53 'NEEDS YOU': board.needsYou,
54 RUNNING: board.running,
55 'WAITING ON OTHERS': board.waiting,
56 DONE: board.done,
57 }[section]!
58 list.push(task)
59 } else if (section === 'RUNNING ELSEWHERE') {
60 const m = SESSION.exec(line)
61 if (!m || m[1] !== 'proc') continue
62 const session: HqSession = {
63 kind: 'proc',
64 name: m[2]!,
65 state: m[3]!,
66 age: m[4]!,
67 seconds: ageSeconds(m[4]!),
68 where: m[5]!,
69 target: '',
70 window: '',
71 }
72 board.elsewhere.push(session)
73 } else if (section === 'PARKED') {
74 const m = PARKED.exec(line)
75 if (!m) {
76 board.parkedMore = Number(MORE.exec(line)?.[1] ?? board.parkedMore)
77 continue
78 }
79 const parked: HqParked = { pri: m[1]!, date: m[2]!, repo: m[3]!, text: m[4]! }
80 board.parked.push(parked)
81 }
82 }
83
84 return board
85}
86
87const stateById = (board: HqBoard): Map<string, string> =>
88 new Map([...board.needsYou, ...board.running, ...board.waiting, ...board.done].map(t => [t.id, t.state]))
89
90// One "<id> → <state>" per task whose state is a toast state it was not in at the previous poll.
91// The first poll (no previous board) reports nothing.
92export function newlyEntered(prev: HqBoard | null, next: HqBoard): string[] {
93 if (prev === null) return []
94
95 const before = stateById(prev)
96
97 return [...next.needsYou, ...next.running, ...next.waiting, ...next.done]
98 .filter(t => TOAST_STATES.includes(t.state) && before.get(t.id) !== t.state)
99 .map(t => `${t.id} → ${t.state}`)
100}
101
102// Tasks that wait for the user's answer: held ones and those with an open decision (a D-id).
103export const isAsk = (t: HqTask): boolean => t.state === 'held' || t.decision !== undefined
104export const asks = (board: HqBoard): HqTask[] => board.needsYou.filter(isAsk)
105
106// What waits for the user: questions (a held task, or a task with an open decision, one in review included), the PRs
107// whose review was asked of them, and the sessions that left Needs-you items in their last reply without recording
108// them (`chats`, one each however many items). A task in review without an open decision is the lead still verifying: it does not wait for the user.
109export const needCount = (board: HqBoard, reviews = 0, chats: ChatNeed[] = []): number =>
110 asks(board).length + reviews + asList(chats).length
111
112// The band line, or null when nothing needs you and nothing runs. `reviews` counts the PRs whose review was
113// asked of the user; a failed task without a question does not need them.
114export function summarize(board: HqBoard, reviews = 0, chats: ChatNeed[] = []): string | null {
115 const needs = needCount(board, reviews, chats)
116 const running = board.running.length
117 const sessions = board.elsewhere.filter(s => s.kind === 'claude').length
118
119 if (needs === 0 && running === 0) return null
120
121 const parts: string[] = []
122 if (needs > 0) parts.push(`⚠ ${needs} need you`)
123 if (running > 0) parts.push(`${running} running`)
124 if (sessions > 0) parts.push(`${sessions} sessions live`)
125
126 return parts.join(' · ')
127}
128
129// --- display width: wide characters (CJK, emoji) take two columns, combining marks none ---
130
131const ZERO_WIDTH = /[\p{Mn}\p{Me}-︀-️\u0000-\u001f\u007f-\u009f]/u
132const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰--⦆¢-₩\u{1F300}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{20000}-\u{3FFFD}]|\p{Emoji_Presentation}/u
133const PICTOGRAPH = /\p{Extended_Pictographic}/u
134
135const charCols = (ch: string): number => (ZERO_WIDTH.test(ch) ? 0 : WIDE.test(ch) ? 2 : 1)
136
137// The columns `text` takes on a terminal. A pictograph followed by U+FE0F is drawn wide.
138export function cols(text: string): number {
139 let total = 0
140 let previous = ''
141 for (const ch of text) {
142 total += ch === '️' && PICTOGRAPH.test(previous) && charCols(previous) === 1 ? 1 : charCols(ch)
143 previous = ch
144 }
145
146 return total
147}
148
149// How many of `chars` fit in `width` columns.
150function takeCols(chars: string[], width: number): number {
151 let used = 0
152 let n = 0
153 for (const ch of chars) {
154 used += charCols(ch)
155 if (used > width) break
156 n += 1
157 }
158
159 return n
160}
161
162export const cutCols = (text: string, width: number): string => {
163 const chars = Array.from(text)
164
165 return chars.slice(0, takeCols(chars, width)).join('')
166}
167export const padCols = (text: string, width: number): string => text + ' '.repeat(Math.max(0, width - cols(text)))
168
169export function fit(text: string, width: number): string {
170 if (width <= 0) return ''
171 if (cols(text) <= width) return text
172
173 return cutCols(text, width - 1) + '…'
174}
175
176export const shortName = (id: string): string => id.replace(/^(\d{4}-)+/, '') || id
177
178// The first clause of a task's note, without a leading "verified".
179export function shortNote(note: string): string {
180 const clause = (note.split(';')[0] ?? '').trim()
181
182 return clause.replace(/^(lead )?verified:?\s+/i, '')
183}
184
185export const projectName = (repo: string): string => (repo === '' || repo === '-' ? 'other' : repo)
186
187export function sessionProject(cwd: string): string {
188 const base = cwd.replace(/\/+$/, '').split('/').pop() ?? ''
189
190 return base === '' || base === '?' ? 'other' : base
191}
192
193export function taskDot(state: string): { glyph: string; color: string } {
194 if (state === 'review') return { glyph: '●', color: 'yellow' }
195 if (state === 'held') return { glyph: '●', color: 'magenta' }
196 if (state === 'failed') return { glyph: '●', color: 'red' }
197 if (state === 'done' || state === 'landed') return { glyph: '✓', color: 'green' }
198
199 return { glyph: '●', color: 'cyan' }
200}
201
202// `left` padded or cut so that `right` ends exactly at `width`, with a space between.
203export function splitRow(left: string, right: string, width: number): [string, string] {
204 const room = Math.max(0, width - cols(right) - 1)
205
206 return [padCols(fit(left, room), room + 1), right]
207}
208
209const UNIT_SECONDS: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 }
210
211function ageSeconds(age: string): number {
212 const m = /^(\d+)([smhd])$/.exec(age)
213
214 return m ? Number(m[1]) * UNIT_SECONDS[m[2]!]! : Infinity
215}
216
217const isToday = (t: HqTask): boolean => ageSeconds(t.age) < 86400
218
219export type HqCounts = { busy: number; idle: number; doneToday: number; parked: number }
220
221export function summaryCounts(board: HqBoard): HqCounts {
222 const claude = board.elsewhere.filter(s => s.kind === 'claude')
223 const busy = claude.filter(s => s.state === 'busy').length + board.elsewhere.length - claude.length
224
225 return {
226 busy,
227 idle: claude.length - claude.filter(s => s.state === 'busy').length,
228 doneToday: board.done.filter(isToday).length,
229 parked: board.parked.length + board.parkedMore,
230 }
231}
232
233// --- what goes where: NEEDS YOU / NOW / JUST FINISHED, and the tasks that wait on others ---
234
235export const FINISHED_TTL_MS = 12 * 3_600_000
236export const emptySeen = (): HqSeen => ({ all: 0, ids: {} })
237
238// `ended`: GitHub says the task's PR merged or closed (`pr` is its number).
239export type FinishedItem = { task: HqTask; mark: '✓' | '✗'; text: string; at: number; ended?: 'merged' | 'closed'; pr?: number }
240// `reviews`: tasks in review whose PR is still open (or unknown): they wait for the user, so they never expire.
241export type Focus = { asks: HqTask[]; reviews: HqTask[]; now: HqTask[]; finished: FinishedItem[]; waiting: HqTask[] }
242// `entered`: when each task entered its state (ms); `seen`: what the user has looked at; `prs`: the gh answers.
243export type FocusContext = { now: number; entered: Record<string, number>; seen: HqSeen; prs: Record<string, HqPrEntry> }
244
245// The board as the user needs it: only real questions need them; running work is NOW; what ended since they last
246// looked (and within 12 hours) is JUST FINISHED, a waiting task whose PR ended on GitHub among it; the rest of the
247// waiting tasks only count in the footer.
248export function classify(board: HqBoard, ctx: FocusContext): Focus {
249 const enteredAt = (t: HqTask) => ctx.entered[t.id] ?? ctx.now - ageSeconds(t.age) * 1000
250 const isNew = ({ task, at }: FinishedItem) => at > Math.max(ctx.seen.all, ctx.seen.ids[task.id] ?? 0) && ctx.now - at < FINISHED_TTL_MS
251
252 const ended = new Map<string, FinishedItem>()
253 for (const task of prTasks(board).filter(t => !isAsk(t))) {
254 const pr = prFor(task, ctx.prs)
255 if (!pr || pr.state === 'OPEN') continue
256 const at = pr.at ?? ctx.prs[task.id]!.fetched
257
258 ended.set(
259 task.id,
260 pr.state === 'MERGED'
261 ? { task, mark: '✓', text: `#${pr.number} merged on GitHub — log landed?`, at, ended: 'merged', pr: pr.number }
262 : { task, mark: '✗', text: `#${pr.number} closed on GitHub`, at, ended: 'closed', pr: pr.number },
263 )
264 }
265 const plain = [...board.needsYou.filter(t => !isAsk(t)), ...board.done]
266 .filter(t => !ended.has(t.id) && t.state !== 'dropped')
267 .map((task): FinishedItem => ({ task, mark: task.state === 'failed' ? '✗' : '✓', text: shortNote(task.note), at: enteredAt(task) }))
268
269 return {
270 asks: asks(board),
271 reviews: board.needsYou.filter(t => t.state === 'review' && !isAsk(t) && !ended.has(t.id)),
272 now: board.running.filter(t => !isAsk(t)),
273 finished: [...ended.values(), ...plain].filter(isNew).sort((a, b) => b.at - a.at),
274 waiting: board.waiting.filter(t => !isAsk(t) && !ended.has(t.id)),
275 }
276}
277
278export const MAX_REVIEW_PRS = 5
279
280export type ProjectCard = {
281 name: string
282 tasks: HqTask[]
283 busy: HqSession[]
284 idle: number
285 waiting: number
286 hasNeeds: boolean
287 // needs-you or running task, or a busy session: worth a full card
288 isActive: boolean
289 doneCount: number
290}
291
292const MAX_DONE_PER_CARD = 3
293
294// One card per project: its questions and running tasks, what just finished, how many wait on others, and its
295// claude sessions. Cards with a question come first, then the most recently active.
296export function projectCards(board: HqBoard, focus: Focus): ProjectCard[] {
297 const cards = new Map<string, ProjectCard & { done: HqTask[]; latest: number }>()
298 const card = (name: string) => {
299 let c = cards.get(name)
300 if (!c) cards.set(name, (c = { name, tasks: [], busy: [], idle: 0, waiting: 0, hasNeeds: false, isActive: false, doneCount: 0, done: [], latest: Infinity }))
301
302 return c
303 }
304
305 for (const t of focus.asks) {
306 const c = card(projectName(t.repo))
307 c.tasks.push(t)
308 c.hasNeeds = true
309 c.latest = Math.min(c.latest, ageSeconds(t.age))
310 }
311 for (const t of focus.now) {
312 const c = card(projectName(t.repo))
313 c.tasks.push(t)
314 c.latest = Math.min(c.latest, ageSeconds(t.age))
315 }
316 for (const { task: t } of focus.finished) {
317 const c = card(projectName(t.repo))
318 c.done.push(t)
319 c.latest = Math.min(c.latest, ageSeconds(t.age))
320 }
321 for (const t of focus.waiting) card(projectName(t.repo)).waiting += 1
322 for (const s of board.elsewhere.filter(s => s.kind === 'claude')) {
323 const c = card(sessionProject(s.where))
324 if (s.state === 'busy') c.busy.push(s)
325 else c.idle += 1
326 c.latest = Math.min(c.latest, s.seconds)
327 }
328
329 return [...cards.values()]
330 .sort((a, b) => Number(b.hasNeeds) - Number(a.hasNeeds) || a.latest - b.latest || a.name.localeCompare(b.name))
331 .map(({ done, latest: _latest, ...c }) => ({
332 ...c,
333 isActive: c.tasks.length > 0 || c.busy.length > 0,
334 doneCount: done.length,
335 tasks: [...c.tasks, ...done.sort((a, b) => ageSeconds(a.age) - ageSeconds(b.age)).slice(0, MAX_DONE_PER_CARD)],
336 }))
337}
338
339const joinCounts = (items: [number, (n: number) => string][], separator: string): string =>
340 items.filter(([n]) => n > 0).map(([n, text]) => text(n)).join(separator)
341
342export const projectsFooter = (c: HqCounts): string =>
343 joinCounts([[c.parked, n => `◇ ${n} parked`], [c.doneToday, n => `✓ ${n} done today`]], ' · ')
344
345// One line for the projects without a card: idle sessions as ○N, tasks done today as ✓N.
346export function quietLine(cards: ProjectCard[]): string {
347 const parts = cards
348 .filter(c => !c.isActive)
349 .map(c => [c.name, c.idle > 0 && `○${c.idle}`, c.waiting > 0 && `⏸${c.waiting}`, c.doneCount > 0 && `✓${c.doneCount}`].filter(Boolean).join(' '))
350
351 return parts.length === 0 ? '' : `quiet ${parts.join(' · ')}`
352}
353
354// --- live claude sessions (bin/tmux-map) and jumping to the tmux pane one runs in ---
355
356const TARGET = /^[A-Za-z0-9_.:-]+$/
357const HQ_SESSION = /^hq/
358
359export const isTarget = (s: string): boolean => TARGET.test(s)
360
361// A transcript path is handed to `stat` and `tail` as an argument: an absolute .jsonl path of plain characters,
362// without "..".
363const TRANSCRIPT = /^\/[A-Za-z0-9._/-]+\.jsonl$/
364export const isTranscript = (s: string): boolean => TRANSCRIPT.test(s) && !s.includes('..')
365
366// A session's link is only ever a claude.ai/code URL of plain characters: HQ prints it to the user.
367const SESSION_LINK = /^https:\/\/claude\.ai\/code\/[A-Za-z0-9_-]+$/
368export const isSessionLink = (s: string): boolean => SESSION_LINK.test(s)
369
370// `claude:6.0` shows as `claude:6`; other panes keep their index.
371export const showTarget = (target: string): string => target.replace(/\.0$/, '')
372
373export function fmtAge(seconds: number): string {
374 if (seconds < 60) return `${Math.round(seconds)}s`
375 if (seconds < 3600) return `${Math.round(seconds / 60)}m`
376
377 return seconds < 86400 ? `${Math.round(seconds / 3600)}h` : `${Math.round(seconds / 86400)}d`
378}
379
380// Lines of bin/tmux-map: `name<TAB>target<TAB>window<TAB>cwd<TAB>status<TAB>seconds<TAB>transcript<TAB>link`; target and
381// window are empty for a session outside tmux, the transcript when the file is not there, the link without Remote Control.
382export function parseLive(text: string): HqLive[] {
383 return text
384 .split('\n')
385 .map(line => line.split('\t'))
386 .filter(([name, target = '', , cwd, state, seconds]) => name && cwd && state && !Number.isNaN(Number(seconds)) && (target === '' || isTarget(target)))
387 .map(([name, target, window, cwd, state, seconds, transcript = '', link = '']) => ({
388 name: name!,
389 target: target!,
390 window: window!,
391 cwd: cwd!,
392 state: state!,
393 seconds: Number(seconds),
394 transcript: isTranscript(transcript) ? transcript : '',
395 link: isSessionLink(link) ? link : '',
396 }))
397}
398
399// The board with its claude sessions taken from tmux-map (full names, full cwd), not from `crew ls`, which cuts
400// both. hq sessions are left out: they are the viewer, not a crew.
401export function mergeLive(board: HqBoard, live: HqLive[]): HqBoard {
402 const sessions = live
403 .filter(l => !HQ_SESSION.test(l.name))
404 .map(l => ({
405 kind: 'claude' as const,
406 name: l.name,
407 state: l.state,
408 age: fmtAge(l.seconds),
409 seconds: l.seconds,
410 where: l.cwd,
411 target: l.target,
412 window: l.window,
413 ...(l.transcript ? { transcript: l.transcript } : {}),
414 ...(l.link ? { link: l.link } : {}),
415 }))
416
417 return { ...board, elsewhere: [...board.elsewhere.filter(s => s.kind === 'proc'), ...sessions] }
418}
419
420// A project's claude sessions, best lead first: busy, then the most recent.
421function rankedSessions(board: HqBoard, project: string): HqSession[] {
422 return board.elsewhere
423 .filter(s => s.kind === 'claude' && sessionProject(s.where) === project)
424 .sort((a, b) => Number(b.state === 'busy') - Number(a.state === 'busy') || a.seconds - b.seconds)
425}
426
427export const pickLead = (board: HqBoard, project: string): HqSession | undefined => rankedSessions(board, project)[0]
428
429// The session of a project to jump to: its best-ranked claude session that runs in tmux.
430export const repoJump = (board: HqBoard, project: string): HqSession | undefined =>
431 rankedSessions(board, project).find(s => s.target !== '')
432
433// A task of the board by its id or its short name.
434export const findTask = (board: HqBoard, query: string): HqTask | undefined =>
435 [...board.needsYou, ...board.running, ...board.waiting, ...board.done].find(t => t.id === query || shortName(t.id) === query)
436
437// `/hq go <query>`: a session name, else a project name.
438export const findJump = (board: HqBoard, query: string): HqSession | undefined =>
439 board.elsewhere.find(s => s.kind === 'claude' && s.target !== '' && s.name === query) ?? repoJump(board, query)
440
441export function jumpChoices(board: HqBoard): string[] {
442 const inTmux = board.elsewhere.filter(s => s.kind === 'claude' && s.target !== '')
443
444 return [...new Set([...inTmux.map(s => s.name), ...inTmux.map(s => sessionProject(s.where))])].sort()
445}
446
447// --- holds, the model's view of the board, and the brief ---
448
449export const holdLine = (text: string): string =>
450 text
451 .split('\n')
452 .map(line => line.trim())
453 .find(line => line !== '') ?? ''
454
455// What the hq_board tool returns: open tasks (with their lead, `leadGuessed` when it is a guess by project, and
456// their PR), claude sessions, and the best lead session per project.
457export function boardForModel(board: HqBoard, holds: Record<string, string>, prs: Record<string, HqPrEntry> = {}) {
458 const tasks = [...board.needsYou, ...board.running, ...board.waiting].map(t => {
459 const lead = resolveLead(board, t)
460 const pr = prFor(t, prs)
461
462 return {
463 id: t.id,
464 name: shortName(t.id),
465 repo: projectName(t.repo),
466 state: t.state,
467 age: t.age,
468 note: shortNote(t.note),
469 ...(t.decision ? { decision: t.decision } : {}),
470 ...(holds[t.id] ? { question: holds[t.id] } : {}),
471 ...(lead ? { lead: lead.session.name, ...(lead.guessed ? { leadGuessed: true } : {}) } : {}),
472 ...(pr ? { pr: { number: pr.number, state: pr.state } } : {}),
473 }
474 })
475 const claude = board.elsewhere.filter(s => s.kind === 'claude')
476 const sessions = claude.map(s => ({
477 name: s.name,
478 project: sessionProject(s.where),
479 state: s.state,
480 age: s.age,
481 ...(s.target ? { tmux: s.target } : {}),
482 ...(s.link ? { link: s.link } : {}),
483 }))
484 const leads: Record<string, string> = {}
485 for (const project of new Set(sessions.map(s => s.project))) {
486 const lead = pickLead(board, project)
487 if (lead) leads[project] = lead.name
488 }
489
490 return { tasks, sessions, leads }
491}
492
493// --- the task's own lead (meta file) and its pull request ---
494
495// A task's meta file is `key=value` lines; the last `lead=`, `repo=`, `branch=` and `worktree=` win, an empty value does not count.
496// The Diff action reads branch and worktree the way crew diff does (the last line of a key wins, an empty one clears it):
497// when that leaves neither, `diffCleared` is set so canDiff says no even though an earlier value is still in `branch`.
498export function parseMeta(text: string): HqMeta {
499 const meta: HqMeta = {}
500 const last: Record<string, string> = {}
501
502 for (const line of text.split('\n')) {
503 const m = /^(lead|repo|branch|worktree)=(.*)$/.exec(line.trim())
504 if (!m) continue
505 if (m[1] === 'branch' || m[1] === 'worktree') last[m[1]] = m[2]!
506 if (!m[2]) continue
507 if (m[1] === 'lead') meta.lead = m[2]
508 else if (m[1] === 'repo') meta.repoPath = m[2]
509 else if (m[1] === 'branch') meta.branch = m[2]
510 else meta.worktree = m[2]
511 }
512 if (!last.branch && !last.worktree && (meta.branch !== undefined || meta.worktree !== undefined)) meta.diffCleared = true
513
514 return meta
515}
516
517export function applyMeta(board: HqBoard, metas: Record<string, HqMeta>): HqBoard {
518 const withMeta = (t: HqTask): HqTask => (metas[t.id] ? { ...t, ...metas[t.id] } : t)
519
520 return {
521 ...board,
522 needsYou: board.needsYou.map(withMeta),
523 running: board.running.map(withMeta),
524 waiting: board.waiting.map(withMeta),
525 done: board.done.map(withMeta),
526 }
527}
528
529// A chain link ($CREW_ROOT/.links/<sub sessionId>): the sub-lead's name and the name of the lead it reports to.
530export type HqLink = { sub: string; parent: string }
531
532export function parseLink(text: string): HqLink {
533 const value = (key: string): string => text.split('\n').filter(l => l.startsWith(`${key}=`)).pop()?.slice(key.length + 1).trim() ?? ''
534
535 return { sub: value('sub_name'), parent: value('parent_name') }
536}
537
538// Marks the live top-level leads: claude sessions named as the lead of a task, or as the parent of a chain link, that are
539// not themselves a sub-lead (the sub of a link). `led` is the lead of every task meta under $CREW_ROOT, of any age and
540// state (a lead stays a lead after crew ls forgets its tasks); the board's own tasks count too. Other sessions stay plain.
541export function markLeads(board: HqBoard, links: HqLink[], led: string[] = []): HqBoard {
542 const subs = new Set(links.map(l => l.sub))
543 const leads = new Set([...[...board.needsYou, ...board.running, ...board.waiting, ...board.done].map(t => t.lead), ...led, ...links.map(l => l.parent)])
544 const marked = (s: HqSession): HqSession => (s.kind === 'claude' && leads.has(s.name) && !subs.has(s.name) ? { ...s, lead: true } : s)
545
546 return { ...board, elsewhere: board.elsewhere.map(marked) }
547}
548
549// The session that leads a task: the one its meta names, when it is live; else the best session of its project,
550// marked as a guess.
551export function resolveLead(board: HqBoard, task: HqTask): { session: HqSession; guessed: boolean } | undefined {
552 const named = task.lead ? board.elsewhere.find(s => s.kind === 'claude' && s.name === task.lead) : undefined
553 if (named) return { session: named, guessed: false }
554
555 const guess = pickLead(board, projectName(task.repo))
556
557 return guess ? { session: guess, guessed: true } : undefined
558}
559
560// Where to jump for a task: its exact lead, if it is in tmux; else a session of its project that is.
561export function jumpFor(board: HqBoard, task: HqTask): { session: HqSession; guessed: boolean } | undefined {
562 const lead = resolveLead(board, task)
563 if (lead && !lead.guessed) return lead.session.target === '' ? undefined : lead
564
565 const guess = repoJump(board, projectName(task.repo))
566
567 return guess ? { session: guess, guessed: true } : undefined
568}
569
570export type PrRef = { kind: 'number'; key: string; n: number } | { kind: 'branch'; key: string; branch: string }
571
572const BRANCH = /^[A-Za-z0-9._/][A-Za-z0-9._/-]*$/
573
574// What to ask GitHub about a task: the PR number its note mentions, else its meta branch (a plain ref name only).
575export function prRef(task: HqTask): PrRef | undefined {
576 const n = /PR #(\d{1,7})\b/.exec(task.note)?.[1]
577 if (n) return { kind: 'number', key: `#${n}`, n: Number(n) }
578 if (task.branch && BRANCH.test(task.branch)) return { kind: 'branch', key: `branch:${task.branch}`, branch: task.branch }
579
580 return undefined
581}
582
583// What a cached answer is about: the repo path and the reference. A task with no repo or no reference has none.
584export function prKey(task: HqTask): string | undefined {
585 const ref = prRef(task)
586
587 return ref && task.repoPath ? `${task.repoPath}|${ref.key}` : undefined
588}
589
590// The cached PR of a task, only when it was asked about the task's current repo and reference.
591export function prFor(task: HqTask, prs: Record<string, HqPrEntry>): HqPr | null | undefined {
592 const entry = prs[task.id]
593
594 return entry && entry.key === prKey(task) ? entry.pr : undefined
595}
596
597// The cache without the answers that no longer match their task, or whose task left the board.
598export function prunePrs(board: HqBoard, prs: Record<string, HqPrEntry>): Record<string, HqPrEntry> {
599 const tasks = prTasks(board)
600
601 return Object.fromEntries(Object.entries(prs).filter(([id, entry]) => entry.key === prKey(tasks.find(t => t.id === id) ?? ({} as HqTask))))
602}
603
604const PR_FIELDS = 'number,state,mergedAt,closedAt,url'
605
606export const ghArgs = (ref: PrRef): string[] =>
607 ref.kind === 'number'
608 ? ['gh', 'pr', 'view', String(ref.n), '--json', PR_FIELDS]
609 : ['gh', 'pr', 'list', '--head', ref.branch, '--state', 'all', '--json', PR_FIELDS, '--limit', '1']
610
611// The PR in gh's JSON (an object from `pr view`, a list from `pr list`); null for none or anything unreadable.
612// A merged or closed PR carries `at`, the time it ended (ms).
613export function parsePr(stdout: string): HqPr | null {
614 try {
615 const parsed: unknown = JSON.parse(stdout)
616 const pr = Array.isArray(parsed) ? parsed[0] : parsed
617 const { number, state, mergedAt, closedAt } = (pr ?? {}) as Record<string, unknown>
618 if (typeof number !== 'number' || (state !== 'OPEN' && state !== 'MERGED' && state !== 'CLOSED')) return null
619
620 const at = state === 'OPEN' ? NaN : Date.parse(String(state === 'MERGED' ? mergedAt : closedAt))
621
622 return Number.isNaN(at) ? { number, state } : { number, state, at }
623 } catch {
624 return null
625 }
626}
627
628// The tasks gh is asked about: the ones that wait on someone else, and those in review.
629export const prTasks = (board: HqBoard): HqTask[] => [...board.waiting, ...board.needsYou.filter(t => t.state === 'review')]
630
631export function mergedNotLogged(board: HqBoard, prs: Record<string, HqPrEntry>): { task: HqTask; pr: HqPr }[] {
632 return prTasks(board).flatMap(task => {
633 const pr = prFor(task, prs)
634
635 return pr?.state === 'MERGED' && task.state !== 'landed' ? [{ task, pr }] : []
636 })
637}
638
639export const PR_TTL_MS = 5 * 60_000
640export const PR_QUERIES_PER_POLL = 3
641
642// The gh queries to make now: tasks with something to ask and a repo to ask in, whose answer is missing, was about
643// another reference or is older than the TTL; the oldest first, never more than `limit` (three by default).
644export function ghDue(tasks: HqTask[], cache: Record<string, HqPrEntry>, now: number, limit = PR_QUERIES_PER_POLL): { task: HqTask; ref: PrRef }[] {
645 return tasks
646 .flatMap(task => {
647 const ref = prRef(task)
648 const cached = cache[task.id]
649 const fetched = cached && cached.key === prKey(task) ? cached.fetched : 0
650
651 return ref && task.repoPath && now - fetched >= PR_TTL_MS ? [{ task, ref, fetched }] : []
652 })
653 .sort((a, b) => a.fetched - b.fetched)
654 .slice(0, limit)
655 .map(({ task, ref }) => ({ task, ref }))
656}
657
658// The user has looked at everything (no id) or at one task. Entries too old to matter any more are dropped.
659export function markSeen(seen: HqSeen, now: number, id?: string): HqSeen {
660 if (id === undefined) return { all: now, ids: {} }
661 const ids = Object.fromEntries(Object.entries(seen.ids).filter(([, at]) => now - at < 2 * FINISHED_TTL_MS))
662
663 return { all: seen.all, ids: { ...ids, [id]: now } }
664}
665
666// What the store held: anything that is not the right shape is no seen-state at all.
667export function parseSeen(raw: unknown): HqSeen {
668 const { all, ids } = (raw ?? {}) as { all?: unknown; ids?: unknown }
669 const entries = Object.entries((ids && typeof ids === 'object' ? ids : {}) as Record<string, unknown>).filter(([, at]) => typeof at === 'number')
670
671 return { all: typeof all === 'number' ? all : 0, ids: Object.fromEntries(entries) as Record<string, number> }
672}
673
674// One search for the PRs whose review is asked of the user; it takes one of the poll's gh queries.
675export const REVIEW_ARGS = ['gh', 'search', 'prs', '--review-requested=@me', '--state=open', '--json', 'number,title,repository,url,updatedAt']
676
677export const reviewDue = (cache: HqReviews, now: number): boolean => now - cache.fetched >= PR_TTL_MS
678
679// The PRs in gh's JSON list; null for anything unreadable. Titles lose their control characters.
680export function parseReviewPrs(stdout: string): HqReviewPr[] | null {
681 try {
682 const parsed: unknown = JSON.parse(stdout)
683 if (!Array.isArray(parsed)) return null
684
685 return parsed.flatMap((item): HqReviewPr[] => {
686 const { number, title, repository, url, updatedAt } = (item ?? {}) as Record<string, unknown>
687 const repo = (repository ?? {}) as { nameWithOwner?: unknown; name?: unknown }
688 const name = typeof repo.nameWithOwner === 'string' ? repo.nameWithOwner : repo.name
689 if (typeof number !== 'number' || typeof title !== 'string' || typeof url !== 'string' || typeof name !== 'string') return []
690 const at = Date.parse(String(updatedAt))
691
692 return [{ repo: name, number, title: title.replace(/[\u0000-\u001f\u007f]/g, ' '), url, updatedAt: Number.isNaN(at) ? 0 : at }]
693 })
694 } catch {
695 return null
696 }
697}
698
699// The time a task entered its current state: the first line of the last run of one state in its status file
700// (`ts<TAB>state<TAB>note`). A later line with the same state, such as an answer note, does not move it.
701export function parseStatusTs(text: string): number | undefined {
702 let state = ''
703 let ts = ''
704 for (const line of text.split('\n').filter(l => l.trim() !== '')) {
705 const [at = '', now = ''] = line.split('\t')
706 if (now !== state) [state, ts] = [now, at]
707 }
708 const ms = Date.parse(ts)
709
710 return Number.isNaN(ms) ? undefined : ms
711}
712
713// The time of the last line of a status file, whatever its state: a task that keeps reporting is alive.
714export function parseStatusLastTs(text: string): number | undefined {
715 const last = text.split('\n').filter(l => l.trim() !== '').pop() ?? ''
716 const ms = Date.parse(last.split('\t')[0] ?? '')
717
718 return Number.isNaN(ms) ? undefined : ms
719}
720
721const fmtElapsed = (ms: number): string => fmtAge(ms / 1000)
722
723const FINISHED = ['done', 'landed']
724
725// "Since you last looked", plain text. `entered` is when each task entered its current state (ms, from its status
726// file); a task is new when that is after `since`, one with no time is not.
727export function briefText(
728 board: HqBoard,
729 since: number,
730 now: number,
731 isFirst: boolean,
732 entered: Record<string, number>,
733 merged: { task: HqTask; pr: HqPr }[] = [],
734): string {
735 const isNew = (t: HqTask) => (entered[t.id] ?? -Infinity) > since
736 const finished = board.done.filter(t => FINISHED.includes(t.state) && isNew(t))
737 const failed = board.needsYou.filter(t => t.state === 'failed' && isNew(t))
738 const fresh = board.needsYou.filter(t => t.state !== 'failed' && isNew(t))
739 const waiting = board.needsYou.filter(t => !isNew(t))
740 const withNote = (label: string, t: HqTask) => {
741 const note = shortNote(t.note)
742
743 return note === '' ? label : `${label} — ${note}`
744 }
745 const lines = [isFirst ? 'Last 24h:' : `Since your last look (${fmtElapsed(now - since)} ago):`]
746
747 if (finished.length > 0) lines.push(` landed or done: ${finished.map(t => shortName(t.id)).join(', ')} (${finished.length})`)
748 if (failed.length > 0) lines.push(` failed: ${failed.map(t => withNote(shortName(t.id), t)).join('; ')}`)
749 if (fresh.length > 0) lines.push(` needs you: ${fresh.map(t => withNote(`${shortName(t.id)} (${t.state})`, t)).join('; ')}`)
750 if (finished.length + failed.length + fresh.length === 0) lines.push(' nothing new')
751 if (merged.length > 0) lines.push(` merged on GitHub, not logged: ${merged.map(m => `${shortName(m.task.id)} (PR #${m.pr.number})`).join(', ')}`)
752 if (waiting.length > 0) lines.push(` still waiting: ${waiting.map(t => `${shortName(t.id)} (${t.state}, ${t.age})`).join(', ')}`)
753 if (board.running.length > 0) lines.push(` running: ${board.running.length}`)
754
755 return lines.join('\n')
756}
757
758// --- what sessions say in chat: the Needs-you block of the last reply of each live session ---
759
760// How much of the end of a transcript is read, and how much more if neither a reply nor a prompt is in it yet.
761export const TAIL_BYTES = [262_144, 1_048_576]
762const CHAT_ITEM_MAX = 100
763
764// Terminal escapes and other control characters in transcript text would reach the screen as they are.
765const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
766// What a user "prompt" with no origin can be besides a prompt: a slash command (`/word`, not a path), the tags of
767// shell and slash-command echoes, notifications and reminders, a message from another session, the summary after
768// a compaction.
769const NOT_A_PROMPT =
770 /^(?:\/[A-Za-z][\w:-]*(?:\s|$)|<(?:bash-input|bash-stdout|bash-stderr|local-command-[a-z]+|command-(?:name|message|args)|task-notification|system-reminder|teammate-message|user-prompt-submit-hook)\b|Caveat:|Another Claude session sent|This session is being continued)/
771
772type Block = { type?: unknown; text?: unknown }
773type TranscriptRecord = {
774 type?: unknown
775 timestamp?: unknown
776 isMeta?: unknown
777 isSidechain?: unknown
778 origin?: { kind?: unknown; body?: unknown } | null
779 message?: { content?: unknown }
780}
781
782const blocksOf = (rec: TranscriptRecord): Block[] => (Array.isArray(rec.message?.content) ? (rec.message.content as Block[]) : [])
783const textOf = (rec: TranscriptRecord): string => {
784 const content = rec.message?.content
785
786 return typeof content === 'string' ? content : blocksOf(rec).filter(b => b?.type === 'text' && typeof b.text === 'string').map(b => b.text as string).join('\n')
787}
788
789// A record that answers the session: a prompt the user typed, or their words relayed by HQ. Tool results, meta
790// records, sidechains, and what other senders put in the user's seat (notifications, other sessions) are not answers.
791function isAnswer(rec: TranscriptRecord): boolean {
792 if (rec.type !== 'user' || rec.isSidechain === true) return false
793 const origin = rec.origin
794 if (origin && typeof origin === 'object' && origin.kind === 'peer' && typeof origin.body === 'string' && origin.body.startsWith('From HQ (user)')) return true
795 if (rec.isMeta === true || blocksOf(rec).some(b => b?.type === 'tool_result')) return false
796 if (origin && typeof origin === 'object' && origin.kind !== 'human') return false
797
798 return origin?.kind === 'human' || !NOT_A_PROMPT.test(textOf(rec).trimStart())
799}
800
801const NEEDS_LABEL = /^\s*(?:\*\*|__)?needs you\s*(?:\*\*|__)?\s*:\s*(?:\*\*|__)?\s*(.*)$/i
802// What ends a block: a rule, a heading, or a Next line.
803const BLOCK_END = /^\s*(?:-{3,}|#{1,6}\s.*|(?:\*\*|__)?next(?:\s+steps?)?\s*(?:\*\*|__)?\s*:.*)$/i
804const FENCE = /^\s*(```+|~~~+)/
805const BULLET = /^(\s*)(?:[-*•]|\d+[.)])\s+(.*)$/
806// An item that only says there is nothing: "nothing", "none" or "n/a" (also "no") alone, with a short qualifier, or with
807// a sentence after it ("nothing yet. Once the review is done..."). "None of these fit" is a request, not an empty block.
808const NOTHING = /^(?:[\s—–-]*(?:nothing|none|n\/a)(?:\s+(?:yet|right now|for now|at the moment|needed|pending|else))?\s*(?:[.!;:,—–-]|$)|no(?:\s+(?:right now|for now|else|yet|needed|at the moment))?[.!]?$)/i
809
810const cleanItem = (text: string): string =>
811 text
812 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
813 .replace(/\*\*|__|`/g, '')
814 .replace(CONTROL, ' ')
815 .replace(/\s+/g, ' ')
816 .trim()
817 .slice(0, 400)
818
819// The items of the "Needs you" blocks in a message: the text after the label and the bullet or numbered lines below
820// it, up to the end of the message or the next rule, heading or Next line. Markdown is stripped, a line is one item.
821export function parseNeeds(text: string): string[] {
822 const items: string[] = []
823 let first = -1
824 let joins = false
825 let fence = ''
826 // the indent of the first bullet of the block: bullets indented deeper are part of the item above
827 let base = -1
828
829 // a lead-in such as "two things:" is no item when bullets follow it
830 const close = () => {
831 if (first >= 0 && items.length > first + 1 && items[first]!.trimEnd().endsWith(':')) items.splice(first, 1)
832 first = -1
833 }
834
835 for (const line of text.split('\n')) {
836 const marker = FENCE.exec(line)?.[1]
837 if (fence === '' && marker) fence = marker[0]!
838 else if (fence !== '' && marker?.[0] === fence) fence = ''
839 if (fence !== '' || marker) continue
840 const label = NEEDS_LABEL.exec(line)
841 if (label) {
842 close()
843 base = -1
844 first = items.length
845 items.push(label[1]!)
846 joins = true
847 continue
848 }
849 if (first < 0) continue
850 if (BLOCK_END.test(line)) {
851 close()
852 continue
853 }
854 if (line.trim() === '') {
855 joins = false
856 continue
857 }
858 const bullet = BULLET.exec(line)
859 const last = items.length - 1
860 if (bullet && base < 0) base = bullet[1]!.length
861 if (bullet && bullet[1]!.length <= base) {
862 items.push(bullet[2]!)
863 joins = true
864 } else if (joins) {
865 items[last] += ` ${bullet ? bullet[2]! : line}`
866 }
867 }
868 close()
869
870 return items.map(cleanItem).filter(item => item !== '' && !NOTHING.test(item))
871}
872
873// The Needs-you items of the last assistant message with text in the tail of a transcript (JSONL), and its time (ms,
874// 0 when it has none); no items when a prompt came after it. Null when the tail holds neither. `partial` says the text
875// begins in the middle of the file, so its first line is cut and left out. Lines that are not JSON are skipped.
876export function parseLastReply(text: string, partial: boolean): HqChat | null {
877 const lines = text.split('\n')
878 if (partial) lines.shift()
879
880 for (let i = lines.length - 1; i >= 0; i--) {
881 let rec: TranscriptRecord
882 try {
883 rec = JSON.parse(lines[i]!) as TranscriptRecord
884 } catch {
885 continue
886 }
887 if (!rec || typeof rec !== 'object') continue
888 if (isAnswer(rec)) return { items: [], at: 0 }
889 if (rec.type !== 'assistant' || rec.isSidechain === true) continue
890 const reply = textOf(rec)
891 if (reply.trim() === '') continue
892 const at = Date.parse(String(rec.timestamp))
893
894 return { items: parseNeeds(reply), at: Number.isNaN(at) ? 0 : at }
895 }
896
897 return null
898}
899
900// `stat -c '%n|%s|%Y'` output: size and modification time per path.
901export function parseStat(stdout: string): Record<string, { size: number; mtime: number }> {
902 const out: Record<string, { size: number; mtime: number }> = {}
903 for (const line of stdout.split('\n')) {
904 const [path, size, mtime] = line.split('|')
905 if (path && isTranscript(path) && /^\d+$/.test(size ?? '') && /^\d+$/.test(mtime ?? '')) out[path] = { size: Number(size), mtime: Number(mtime) }
906 }
907
908 return out
909}
910
911// The transcripts whose file changed since they were read: no entry, or another size or modification time.
912export const chatsDue = (stats: Record<string, { size: number; mtime: number }>, cache: Record<string, HqChatEntry>): string[] =>
913 Object.entries(stats)
914 .filter(([path, s]) => cache[path]?.size !== s.size || cache[path]?.mtime !== s.mtime)
915 .map(([path]) => path)
916
917// What a session said in chat that needs the user and is not recorded: `items` of its last reply, written at `at`.
918// Dismissing it stores `key` and `at`.
919// `items` are cut to fit a line; `full` (when set) is the same items whole.
920export type ChatNeed = { session: HqSession; key: string; items: string[]; full?: string[]; at: number }
921
922// What the user dismissed: per session key, the time (ms) of the reply that was dismissed. Kept apart from the seen-state
923// of tasks, which "Seen all" resets and which ages out.
924export type ChatDismissed = Record<string, number>
925
926export const CHAT_AGE_SHOWN_MS = 2 * 3_600_000
927export const CHAT_OLD_MS = 24 * 3_600_000
928
929// The sessions whose last reply is under a day old are a need; older ones are not, though the pane still names them.
930export function splitChats(chats: ChatNeed[], now: number): { fresh: ChatNeed[]; older: ChatNeed[] } {
931 const list = asList<ChatNeed>(chats)
932
933 return { fresh: list.filter(c => now - c.at < CHAT_OLD_MS), older: list.filter(c => now - c.at >= CHAT_OLD_MS) }
934}
935
936export const dismissChat = (dismissed: ChatDismissed, key: string, at: number): ChatDismissed => ({ ...dismissed, [key]: at })
937
938// What the store held: anything that is not a number per key is dropped.
939export function parseDismissed(raw: unknown): ChatDismissed {
940 const entries = Object.entries(raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}).filter(([, at]) => typeof at === 'number')
941
942 return Object.fromEntries(entries) as ChatDismissed
943}
944
945const chatKey = (session: HqSession): string => `chat:${session.name}`
946
947// The dismissals still worth keeping: not those of a session that wrote a newer reply since, nor of one that is gone
948// (judged only while some claude session is live, so a failed tmux-map does not wipe them). A session whose transcript
949// has not been read yet keeps its dismissal.
950export function pruneDismissed(board: HqBoard, chats: Record<string, HqChatEntry>, dismissed: ChatDismissed): ChatDismissed {
951 const sessions = board.elsewhere.filter(s => s.kind === 'claude')
952
953 return Object.fromEntries(
954 Object.entries(dismissed).filter(([key, at]) => {
955 const session = sessions.find(s => chatKey(s) === key)
956 if (!session) return sessions.length === 0
957
958 const chat = session.transcript ? chats[session.transcript] : undefined
959
960 return !chat || chat.at <= at
961 }),
962 )
963}
964
965// The items of each live session's last reply, minus those that name an open decision (the band shows that decision
966// itself), and those of a session whose reply was dismissed. The newest reply first.
967export function chatNeeds(board: HqBoard, chats: Record<string, HqChatEntry>, dismissed: ChatDismissed): ChatNeed[] {
968 const open = new Set([...board.needsYou, ...board.running, ...board.waiting, ...board.done].flatMap(t => (t.decision ? [t.decision] : [])))
969
970 return board.elsewhere
971 .flatMap((session): ChatNeed[] => {
972 const chat = session.kind === 'claude' && session.transcript ? chats[session.transcript] : undefined
973 const key = chatKey(session)
974 if (!chat || chat.at <= (dismissed[key] ?? -Infinity)) return []
975 const full = chat.items.filter(item => !(item.match(/\bD\d+\b/g) ?? []).some(id => open.has(id)))
976 const items = full.map(item => fit(item, CHAT_ITEM_MAX))
977
978 return items.length === 0 ? [] : [{ session, key, items, full, at: chat.at }]
979 })
980 .sort((a, b) => b.at - a.at)
981}
982
983// --- the Focus view: NEEDS YOU, NOW, JUST FINISHED, and the idle sessions on one line ---
984
985// One line per open question, each ending with where to go. `session` is the pane that pressing it jumps to (missing
986// when there is none); `go` is the text at the right edge. A chat line stands for a whole session: its first item, or
987// "N questions"; `suffix` says how old the reply is when it is more than two hours.
988// `key` names the line (`ask:<task id>`, `chat:<session>`, `pr:<repo>#<n>`): pressing it opens its page. `full` is the
989// whole text behind `msg`, when that is more than it shows.
990export type NeedLine = { kind: 'ask' | 'chat' | 'pr'; key: string; name: string; msg: string; suffix: string; go: string; id?: string; full?: string; session?: HqSession }
991// A claude session that is busy or has open tasks, and those tasks as short phrases; `session` is missing on the line of
992// tasks without a live lead.
993// `owners` and `talks` are the asleep owners and open talk threads led by that session (see ownerRows, talkRows), when it has any.
994export type NowEntry = { name: string; status: string; session?: HqSession; tasks: string[]; owners?: OwnerRow[]; talks?: string[] }
995// A finished task for the user to look at, `isNew` until they have seen it. `detail`: it is a result with a page of its own.
996export type FinishedRow = { id: string; mark: '✓' | '✗'; isNew: boolean; name: string; verdict: string; lead: string; detail: boolean }
997
998export type FocusInput = {
999 board: HqBoard
1000 plan: Focus
1001 ctx: FocusContext
1002 holds?: Record<string, string>
1003 requested?: HqReviewPr[]
1004 chats?: ChatNeed[]
1005 files?: Record<string, HqResultFiles>
1006 // when each task last wrote a line to its status file (ms)
1007 last?: Record<string, number>
1008 // the whole text of each open question, by task id (hold.md / ask.md)
1009 questions?: Record<string, string>
1010 // the feature owners and talk threads of the registry
1011 owners?: OwnerEntry[]
1012 talks?: Talk[]
1013}
1014
1015// `total`: what the NEEDS YOU header counts, the review requests cut from the list included. `older`: sessions whose last
1016// reply is over a day old: they are not a need any more. `earlier`: finished rows beyond the five shown.
1017export type FocusView = {
1018 needs: NeedLine[]
1019 morePrs: number
1020 total: number
1021 older: ChatNeed[]
1022 now: NowEntry[]
1023 finished: FinishedRow[]
1024 newCount: number
1025 earlier: number
1026 idle: { name: string; age: string }[]
1027 isClear: boolean
1028}
1029
1030export const MAX_FINISHED_ROWS = 5
1031export const STALE_MS = 6 * 3_600_000
1032const NEED_NAME_MAX = 22
1033const NOW_NAME_MAX = 20
1034const FINISHED_NAME_MAX = 20
1035const LEAD_MAX = 16
1036
1037const prNumber = (note: string): string | undefined => /PR #(\d+)/.exec(note)?.[1] ?? /#(\d+)/.exec(note)?.[1]
1038
1039// "on Sam" when the note says who it waits for, else the note's first clause.
1040function waitNote(note: string): string {
1041 const who = /\b(?:[Oo]n|[Ff]or)\s+([A-Z][\w-]*)/.exec(note)?.[1]
1042
1043 return who ? `on ${who}` : shortNote(note)
1044}
1045
1046const collapse = (text: string): string => text.replace(/\s+/g, ' ').trim()
1047
1048// `text` cut to `width` at the last word that fits, with an ellipsis; inside the word when the first half has no space.
1049export function cutWords(text: string, width: number): string {
1050 if (width <= 0) return ''
1051 if (cols(text) <= width) return text
1052
1053 const chars = Array.from(text)
1054 const n = takeCols(chars, width - 1)
1055 const head = chars.slice(0, n)
1056 const space = chars[n] === ' ' ? n : head.lastIndexOf(' ')
1057 const kept = space >= head.length / 2 ? head.slice(0, space) : head
1058
1059 return kept.join('').replace(/[\s,;:.\-–—(]+$/, '') + '…'
1060}
1061
1062const goText = (session: HqSession | undefined, lead: HqSession | undefined): string =>
1063 session ? `⏎ ${session.target}` : lead ? 'not in tmux' : 'no live lead'
1064
1065const statusOf = (s: HqSession): string => `${s.state === 'busy' ? '● working' : s.state === 'idle' ? '○ idle' : `⏸ ${s.state}`} ${s.age}`
1066
1067// What the user sees, in the order they need it: questions with where to answer them, work in flight, work that just
1068// finished, and the sessions with nothing to do. A task is in one place only: a question is not in NOW, and a finished
1069// task is not in NOW or in a card.
1070export function focusLayout({ board, plan, ctx, holds = {}, requested = [], chats = [], files = {}, last = {}, questions = {}, owners = [], talks = [] }: FocusInput): FocusView {
1071 const [heldQuestions, lastLine, results, wholeQuestions] = [asRecord<string>(holds), asRecord<number>(last), asRecord<HqResultFiles>(files), asRecord<string>(questions)]
1072 const { fresh, older } = splitChats(chats, ctx.now)
1073 const sessions = board.elsewhere.filter(s => s.kind === 'claude').sort((a, b) => Number(b.state === 'busy') - Number(a.state === 'busy') || a.seconds - b.seconds)
1074 // the sessions a question sends the user to: they are not idle
1075 const asked = new Set<string>()
1076 const lastAt = (t: HqTask): number => lastLine[t.id] ?? ctx.entered[t.id] ?? ctx.now - ageSeconds(t.age) * 1000
1077 const stale = (t: HqTask): string => (ctx.now - lastAt(t) > STALE_MS ? ' ⚠' : '')
1078
1079 const needs: NeedLine[] = [
1080 ...plan.asks.map((t): NeedLine => {
1081 const lead = resolveLead(board, t)?.session
1082 const to = jumpFor(board, t)?.session
1083 asked.add((to ?? lead)?.name ?? '')
1084
1085 const label = t.decision ?? 'held'
1086
1087 return {
1088 kind: 'ask',
1089 key: `ask:${t.id}`,
1090 name: (to ?? lead)?.name ?? shortName(t.id),
1091 msg: `${label} ${heldQuestions[t.id] ?? shortNote(t.note)}`.trimEnd(),
1092 full: `${label} ${collapse(wholeQuestions[t.id] ?? heldQuestions[t.id] ?? t.note)}`.trimEnd(),
1093 suffix: '',
1094 go: goText(to, lead),
1095 id: t.id,
1096 ...(to ? { session: to } : {}),
1097 }
1098 }),
1099 ...fresh.map((c): NeedLine => {
1100 asked.add(c.session.name)
1101 const age = ctx.now - c.at
1102
1103 return {
1104 kind: 'chat',
1105 key: chatKey(c.session),
1106 name: c.session.name,
1107 msg: c.items.length > 1 ? `${c.items.length} questions` : (c.items[0] ?? ''),
1108 full: collapse(c.items.join(' ')),
1109 suffix: age > CHAT_AGE_SHOWN_MS ? ` · ${fmtAge(age / 1000)} old` : '',
1110 go: goText(c.session.target === '' ? undefined : c.session, c.session),
1111 ...(c.session.target === '' ? {} : { session: c.session }),
1112 }
1113 }),
1114 ...asList<HqReviewPr>(requested).slice(0, MAX_REVIEW_PRS).map((pr): NeedLine => {
1115 const to = repoJump(board, pr.repo.split('/').pop() ?? pr.repo)
1116
1117 return { kind: 'pr', key: `pr:${pr.repo}#${pr.number}`, name: `PR ${pr.repo} #${pr.number}`, msg: pr.title, suffix: '', go: to ? `⏎ ${to.target}` : 'on GitHub', ...(to ? { session: to } : {}) }
1118 }),
1119 ]
1120 const morePrs = Math.max(0, asList(requested).length - MAX_REVIEW_PRS)
1121
1122 const tasksOf = new Map<HqSession, string[]>(sessions.map(s => [s, []]))
1123 const other: string[] = []
1124 const open = (t: HqTask, phrase: string) => {
1125 const lead = resolveLead(board, t)?.session
1126
1127 ;(lead ? tasksOf.get(lead)! : other).push(phrase)
1128 }
1129 // crew ls lists a task in review without a decision under RUNNING: the lead is verifying it
1130 for (const t of plan.now) open(t, t.state === 'queued' ? `${shortName(t.id)} queued` : `${shortName(t.id)} ${t.state === 'review' ? 'verifying' : t.state} ${t.age}${stale(t)}`)
1131 for (const t of plan.reviews) open(t, `${shortName(t.id)} verifying ${t.age}${stale(t)}`)
1132 for (const t of plan.waiting) open(t, `${[shortName(t.id), prNumber(t.note) && `#${prNumber(t.note)}`].filter(Boolean).join(' ')} waiting ${waitNote(t.note)}`.trimEnd())
1133
1134 // asleep owners and open talk threads sit under their lead's card; those whose lead is not live go with "other crew"
1135 const live = sessions.map(s => s.name)
1136 const extras = (lead: string | null) => {
1137 const [ownerList, talkList] = [ownerRows(asList<OwnerEntry>(owners), ctx.now, lead, live), talkRows(asList<Talk>(talks), lead, live)]
1138
1139 return { ...(ownerList.length > 0 ? { owners: ownerList } : {}), ...(talkList.length > 0 ? { talks: talkList } : {}) }
1140 }
1141 const extrasOf = new Map<HqSession, ReturnType<typeof extras>>(sessions.map(s => [s, extras(s.name)]))
1142 const hasExtras = (s: HqSession): boolean => Object.keys(extrasOf.get(s)!).length > 0
1143
1144 const now: NowEntry[] = sessions
1145 .filter(s => tasksOf.get(s)!.length > 0 || s.state !== 'idle' || hasExtras(s) || s.lead)
1146 // a live lead with nothing else to show names its project in the task column
1147 .map(s => ({ name: s.name, status: statusOf(s), session: s, tasks: tasksOf.get(s)!.length === 0 && s.lead && !hasExtras(s) ? [sessionProject(s.where)] : tasksOf.get(s)!, ...extrasOf.get(s)! }))
1148 const otherExtras = extras(null)
1149 if (other.length > 0 || Object.keys(otherExtras).length > 0) now.push({ name: 'other crew', status: '', tasks: other, ...otherExtras })
1150
1151 const idle = sessions.filter(s => s.state === 'idle' && !s.lead && tasksOf.get(s)!.length === 0 && !hasExtras(s) && !asked.has(s.name)).map(s => ({ name: s.name, age: s.age }))
1152
1153 // unseen: finished after the user last looked, at everything or at this task (a result stays in the list for 24 hours)
1154 const seenAt = (id: string): number => Math.max(ctx.seen.all, ctx.seen.ids[id] ?? 0)
1155 const leadOf = (t: HqTask | undefined): string => (t ? (resolveLead(board, t)?.session.name ?? '') : '')
1156 const rows = [
1157 ...recentResults(board, ctx.entered, ctx.now).map((r): [number, FinishedRow] => [
1158 r.at,
1159 { id: r.id, mark: r.mark, isNew: r.at > seenAt(r.id), name: r.name, verdict: resultVerdict(r, results[r.id]), lead: leadOf(findTask(board, r.id)), detail: true },
1160 ]),
1161 // a task whose PR ended on GitHub has no result page: it asks the lead to log it
1162 ...plan.finished
1163 .filter(f => f.ended)
1164 .map((f): [number, FinishedRow] => [
1165 f.at,
1166 { id: f.task.id, mark: f.mark, isNew: true, name: `${shortName(f.task.id)} #${f.pr}`, verdict: f.text, lead: leadOf(f.task), detail: false },
1167 ]),
1168 ]
1169 .sort((a, b) => b[0] - a[0])
1170 .map(([, row]) => row)
1171 const finished = rows.slice(0, MAX_FINISHED_ROWS)
1172
1173 return {
1174 needs,
1175 morePrs,
1176 total: needs.length + morePrs,
1177 older,
1178 now,
1179 finished,
1180 newCount: rows.filter(r => r.isNew).length,
1181 earlier: rows.length - finished.length,
1182 idle,
1183 isClear: needs.length === 0 && morePrs === 0 && older.length === 0 && now.length === 0 && rows.length === 0,
1184 }
1185}
1186
1187const MIN_MSG = 12
1188const MIN_TASK_ROOM = 24
1189const MIN_VERDICT = 10
1190const COMPACT_INDENT = 4
1191const STATUS_MAX = 18
1192
1193// A column may take at most this share of a narrow pane.
1194const share = (width: number, part = 0.3): number => Math.max(4, Math.floor(width * part))
1195const widest = (texts: string[]): number => Math.max(0, ...texts.map(cols))
1196
1197// The columns of the NEEDS YOU lines: name, what, and where to go, as wide as `width` together with their two gaps.
1198// In a narrow pane the name keeps two fifths of it at most; the target is shortened to "⏎ 1.0" when the message would
1199// get under 12 columns, and when it still would, it moves to `below`, a dim line under the message.
1200export const MORE_MARK = ' ▸'types/index.d.ts 116 lines1export type HqTask = {
2 id: string
3 state: string
4 age: string
5 repo: string
6 note: string
7 // the open decision's id (`D3`), from the `D3 · ` that crew ls puts in front of the note
8 decision?: string
9 // from the task's meta file (see applyMeta): its lead session, the repo's full path, its branch and its worktree
10 lead?: string
11 repoPath?: string
12 branch?: string
13 worktree?: string
14 // the meta's last branch= and worktree= lines are both empty: nothing to diff, whatever an earlier line said
15 diffCleared?: true
16}
17
18// A task's meta file: the last `lead=`, `repo=`, `branch=` and `worktree=` line each.
19export type HqMeta = { lead?: string; repoPath?: string; branch?: string; worktree?: string; diffCleared?: true }
20
21// A pull request as `gh` reports it: state is OPEN, MERGED or CLOSED. `at` (ms) is when it merged or closed.
22export type HqPr = { number: number; state: string; at?: number }
23
24// A pull request whose review the user was asked for (gh search prs --review-requested=@me).
25export type HqReviewPr = { repo: string; number: number; title: string; url: string; updatedAt: number }
26
27// What the last gh search for review requests found (`prs` is null until one has worked), and when it ran (ms).
28export type HqReviews = { fetched: number; prs: HqReviewPr[] | null }
29
30// What the user has looked at: everything that finished before `all`, and single tasks by the time they were seen.
31export type HqSeen = { all: number; ids: Record<string, number> }
32
33// What the last gh query for a task found; `pr` is null when it found nothing or failed. `key` says what was
34// asked (`#36` or the branch), `fetched` when (ms).
35export type HqPrEntry = { key: string; fetched: number; pr: HqPr | null }
36
37// The user-facing text kept for a finished task: summary.md, else report.md (read only while there is no summary),
38// each with the mtime (ms) it was read at; null when the file is missing.
39export type HqResultFiles = { summary: string | null; summaryAt: number; report: string | null; reportAt: number }
40
41export type HqSession = {
42 kind: 'claude' | 'proc'
43 name: string
44 state: string
45 age: string
46 seconds: number
47 where: string
48 // tmux pane of a claude session ('' for a proc or a session outside tmux)
49 target: string
50 window: string
51 // the session's transcript file (claude sessions only, when it exists)
52 transcript?: string
53 // https://claude.ai/code/<id> of a claude session with Remote Control on
54 link?: string
55 // a live top-level lead (see markLeads): HQ never folds it into the idle line or the page's "+N idle"
56 lead?: true
57}
58
59// The Needs-you items (markdown stripped) in the last reply of a live session, and when it was written (ms). Empty
60// when the reply has none or the user has answered since.
61export type HqChat = { items: string[]; at: number }
62
63// An HqChat as read from a transcript, with the file's size and mtime (s) it was read at.
64export type HqChatEntry = HqChat & { size: number; mtime: number }
65
66export type HqParked = { pri: string; date: string; repo: string; text: string }
67
68// One line of bin/tmux-map: a live claude session with its full cwd, and its tmux pane if it has one.
69export type HqLive = { name: string; target: string; window: string; cwd: string; state: string; seconds: number; transcript: string; link: string }
70
71export type HqBoard = {
72 needsYou: HqTask[]
73 running: HqTask[]
74 // state waiting: someone else has to act (crew ls: WAITING ON OTHERS)
75 waiting: HqTask[]
76 done: HqTask[]
77 elsewhere: HqSession[]
78 parked: HqParked[]
79 // the `+N more` line crew ls prints when it cuts PARKED
80 parkedMore: number
81}
82
83declare module 'claude-code' {
84 interface PluginState {
85 hq: {
86 board: HqBoard | null
87 error: string | null
88 view: 'focus' | 'projects'
89 holds: Record<string, string>
90 prs: Record<string, HqPrEntry>
91 prToasted: string[]
92 reviews: HqReviews
93 seen: HqSeen
94 // per transcript path: the Needs-you items of the session's last reply
95 chats: Record<string, HqChatEntry>
96 // per session key: the time of the chat reply the user dismissed
97 dismissed: Record<string, number>
98 entered: Record<string, number>
99 // when each task last wrote a line to its status file (ms)
100 last: Record<string, number>
101 results: Record<string, HqResultFiles>
102 // the task whose result the pane shows in full, null for the board
103 detail: string | null
104 // the whole text of each open question, and the last note of each task, by task id
105 questions: Record<string, string>
106 notes: Record<string, string>
107 // the key of the NEEDS YOU line whose page the pane shows, null for the board
108 need: string | null
109 // the first line of that page's question that is shown
110 needScroll: number
111 // the New lead form while the pane shows it (null otherwise), with the folders it offers
112 newLead: { repo: string; name: string; text: string; model: string; choices: string[] } | null
113 }
114 }
115}
116