Agent-reported session scope and progress sections

A Claude Code pane showing what your agent session is doing: its scope, progress sections, pending decisions, session usage and environment, and the activity of its workers.

The image illustrates the chosen design with example data. Each progress section has its own count and three-state bar. Expand a section to see its items; click a name or item for a floating detail card. Only the card's open ↗ action opens a link. Worker boxes show status, elapsed time, token activity and context fill.
Requires Claude Code with Mods support and Python 3.10+ available as python3. The Python reader is bundled; no pip install or separate download is needed.
claude plugin marketplace add coreyfloyd/whatsup
claude plugin install whatsup@whatsup
Start a new session, then run /whatsup to open the pane. It opens only when you ask. See INSTALLATION.md for verification, configuration and removal.
The plugin registers mcp__whatsup__report and supplies a short reporting instruction. One call replaces that session's entire report. A minimum call is:
{"scope":"Prepare the next release"}
Sections and workers are optional. The agent supplies ordered items in queued, active or done states; the pane derives each section's counts. A new session starts empty. Reports describe work; they grant no permission to do it. The github, task-list and bare templates guide reporting. An optional .whatsup.json at the repository root overrides the defaults.
Expanded decision cards let you select, change or clear an option before explicitly choosing Send answer. The answer enters this same conversation as input from the whatsup plugin. Sent — awaiting agent confirms submission acceptance, including queueing during a busy turn; it does not mean the agent has processed or resolved the decision. Receipts survive reopening and same-session resume. An unconfirmed receipt stays locked: check the conversation and continue there manually. Known refusal allows an explicit retry. Engines without input support explain that you should answer in the conversation.
Talk about this in chat requests explanation or discussion of that question without sending your selected option or resolving the decision. You can challenge the options or type a custom answer in the main conversation. Discussion feedback is separate from answer receipts. Unconfirmed discussion blocks another discussion request across reopening and same-session resume, while option selection and Send answer remain available. Continue manually in chat if delivery is uncertain. The agent updates missing context and removes a decision from its next complete report once resolved (including a custom chat answer) or withdrawn.
Reports and source enrichment supply display context. Only explicit input actions send a message; they do not mutate tickets, grant tool permissions or bypass the receiving session's authorization rules.
The Mod renders session facts and reports. A bundled standard-library Python reader adds optional GitHub titles, states, membership and destinations. Without GitHub credentials, reports still work and the pane explains that enrichment is off. Credentials are resolved from gh or GITHUB_TOKEN; no credentials belong in configuration files.
This is a Claude Code Mod, requiring a build that exposes the Mods API. The local contract suite is exercised with Claude Code 2.1.302. On organization-managed installations, automatic reporting instructions require the plugin to be listed in managed settings; the report tool remains available. Link opening currently uses the macOS open command. In Claude Desktop the pane draws native boxes and scrolls without More/Back; its layout, card dismissal, and decision answers and discussion requests have been checked by hand there. There is no Codex pane adapter.
Apache 2.0. Author: Corey Floyd. See LICENSE.
hooks/register.tsx 204 lines1import { REFRESH_TEXT, decisionLane, inputUnavailableKey, reconcileAnswers } from './decision-input'
2import { atom, read, update } from 'claude-code'
3import type { Register } from 'claude-code'
4
5import { FOOTER, MARKER_WIDTH, NO_PAGE, OPEN_LABEL, paneLines, placeCard, wrap } from './layout'
6import type { Item, Line, Part } from './layout'
7import { DEFAULT_SNAPSHOT, SNAPSHOTS } from './snapshots'
8import { registerEnrichment } from './enrichment'
9import { readSessionEnvironment, registerSessionFacts } from './session-facts'
10import { paneWorkers, registerWorkers } from './workers'
11import { registerReportPane } from './report-pane'
12import { REPORT_SCHEMA, REPORT_DESCRIPTION, REPORT_TOOL, reportArguments, reportError, reportKey } from './report'
13import type { ContextReport } from './report'
14import { configKey, initialExpanded, resolveConfig, sessionInstruction } from './templates'
15import type { Template } from './templates'
16
17const PANE = 'session-status'
18const TITLE = 'Session Status'
19// The key of the item whose card is shown, or null: one card at a time.
20const openCard = atom({ plugin: 'whatsup', key: 'openCard' } as const, null)
21
22export const register: Register = (on, options) => {
23 const recorded = typeof options.snapshot === 'string' && options.snapshot in SNAPSHOTS
24 registerWorkers(on, recorded)
25 registerSessionFacts(on, recorded)
26 registerReportPane(on, recorded)
27 registerEnrichment(on, recorded, typeof options.githubIssueKinds === 'string' ? options.githubIssueKinds : undefined)
28 const chosen = typeof options.snapshot === 'string' && options.snapshot in SNAPSHOTS ? options.snapshot : DEFAULT_SNAPSHOT
29 const entry = SNAPSHOTS[chosen]!
30
31 on('session.start', async ($, e, next) => {
32 if (!recorded) await readSessionEnvironment($, await $.session.id(), e.cwd)
33 await $.tool.register({ name: 'report', description: REPORT_DESCRIPTION, inputSchema: REPORT_SCHEMA })
34 const home = await $.env.get('HOME')
35 const userPath = home ? `${home}/.config/whatsup/config.json` : null
36 let directory = e.cwd.replace(/\/$/, '') || '/'
37 while (directory !== '/' && !await $.fs.exists(`${directory}/.git`)) directory = directory.slice(0, directory.lastIndexOf('/')) || '/'
38 if (directory === '/' && !await $.fs.exists('/.git')) directory = e.cwd.replace(/\/$/, '')
39 const repoPath = `${directory}/.whatsup.json`
40 const user = userPath && await $.fs.exists(userPath) ? JSON.parse(await $.fs.read(userPath)) : undefined
41 const repository = await $.fs.exists(repoPath) ? JSON.parse(await $.fs.read(repoPath)) : undefined
42 const config = resolveConfig(user, repository)
43 sessionInstruction(config) // Validate the instruction budget at startup.
44 const session = await $.session.id()
45 await $.store.set(configKey(session), config)
46 const report = recorded ? null : await $.store.get(reportKey(session)) as ContextReport | null
47 if (!recorded) await decisionLane(session, () => reconcileAnswers($, session, report, true))
48 await update($, { plugin: 'whatsup', key: 'reportViews' } as const, all => ({ ...all, [session]: { shown: null, expanded: initialExpanded(config, report) } }))
49 $.ui.invalidate('ui.render')
50 // Core may cache prompt.context before this async startup handler finishes.
51 $.ui.invalidate('prompt.context')
52 await $.command.register({ name: 'whatsup', description: 'Show this session’s scope, progress and workers' })
53 // Registers the command only; the pane opens when the person asks for it.
54 await $.command.register({ name: 'session-status', description: 'Show this session’s project, progress and in-progress tickets' })
55 return next(e)
56 })
57
58 on('prompt.context', async ($, e, next) => {
59 const result = await next(e)
60 const config = await $.store.get(configKey(await $.session.id())) as Template | undefined
61 const instruction = config ? sessionInstruction(config) : ''
62 return instruction ? { ...result, blocks: [...result.blocks, { name: 'whatsup', text: instruction }] } : result
63 })
64
65 on('tool.call', { tool: REPORT_TOOL }, async ($, e) => {
66 const input = reportArguments(e)
67 const error = reportError(input)
68 if (error) return { deny: error }
69 const session = await $.session.id()
70 await decisionLane(session, async () => {
71 await $.store.set(reportKey(session), input as ContextReport)
72 await reconcileAnswers($, session, input as ContextReport)
73 })
74 if (!recorded) await paneWorkers($, await $.clock.now())
75 const config = await $.store.get(configKey(session)) as Template | undefined
76 await update($, { plugin: 'whatsup', key: 'reportViews' } as const, all => ({ ...all, [session]: { shown: null, expanded: initialExpanded(config ?? resolveConfig(), input as ContextReport) } }))
77 $.ui.invalidate('ui.render')
78 // A registered tool answers with plain text; the engine refuses an MCP-style content object.
79 return { result: 'Context report replaced.' }
80 })
81
82 for (const command of ['whatsup', 'session-status']) on('command.run', { command }, async $ => {
83 // Each opening starts with no card shown.
84 await update($, openCard, () => null)
85 const session = await $.session.id()
86 const config = recorded ? undefined : await $.store.get(configKey(session)) as Template | undefined
87 const report = recorded ? null : await $.store.get(reportKey(session)) as ContextReport | null
88 await update($, { plugin: 'whatsup', key: 'reportViews' } as const, all => ({ ...all, [session]: { shown: null, expanded: initialExpanded(config ?? resolveConfig(), report) } }))
89 await $.ui.open({ id: PANE, title: TITLE })
90 if (recorded) return { text: `Session Status opened with recorded snapshot "${chosen}".` }
91 if (await $.store.get(inputUnavailableKey(session))) return { text: 'whatsup opened for this session.' }
92 // The engine refuses prompt.submit inside command.run, whose turn it would wait on.
93 // Submit once the command returns; a plugin's prompt runs when the session is idle.
94 $.clock.after(0, () => { void $.prompt.submit({ text: REFRESH_TEXT }).catch(() => {}) })
95 return { text: 'whatsup opened; asked the agent for a fresh report.' }
96 })
97
98 if (recorded) on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
99 const { Box, Text, Button } = $.ui.resolve(e)
100 const columns = Math.max(MARKER_WIDTH + 1, e.props.bodyColumns)
101 const windowRows = Math.max(1, e.props.scroll.bodyRows)
102 const offset = e.props.scroll.offset
103 const shown = await read($, openCard)
104 const lines = paneLines(entry.snapshot, chosen, columns - MARKER_WIDTH)
105
106 const openPage = async (page: string) => {
107 try {
108 const run = await $.process.run(['open', page])
109 if (run.exitCode !== 0) $.ui.toast(`Couldn't open ${page}`)
110 } catch {
111 $.ui.toast(`Couldn't open ${page}`)
112 }
113 }
114
115 // A press on the shown item, or anywhere else in the pane, hides the card; another item swaps it.
116 const show = (key: string) => update($, openCard, current => (current === key ? null : key))
117 const hide = () => update($, openCard, () => null)
118
119 const styled = (part: Part, index: number) => (
120 <Text key={String(index)} dimColor={part.dim} bold={part.bold}>{part.text}</Text>
121 )
122
123 // Every cell of the pane is a press target: a show Button on an item's
124 // marker and text, a hide Button over everything else, padded with spaces
125 // to the pane's right edge.
126 const blank = (key: string, width: number) => (
127 <Button key={key} label={' '.repeat(width)} plain onPress={hide}>{' '.repeat(width)}</Button>
128 )
129
130 const card = (item: Item, row: number) => {
131 const box = placeCard(item.page === null ? NO_PAGE : OPEN_LABEL, item.details, row, offset, windowRows, columns)
132 const inner = box.width - (box.bordered ? 4 : 0)
133 // The border is drawn as text, not borderStyle, so its cells hide the card too.
134 // Spaces, not padding: padding cells are not painted, and the rows beneath would show through.
135 const left = box.bordered ? '│ ' : ''
136 const right = box.bordered ? ' │' : ''
137 // The head as placeCard clipped it to the card, then the blank space beside it and the right border.
138 const head = box.lines[0]!
139 const beside = ' '.repeat(inner - head.length) + right
140 const edge = (key: string, corners: string) => {
141 const text = corners[0] + '─'.repeat(box.width - 2) + corners[1]
142 return <Button key={`hide-card-${item.key}-${key}`} label={text} plain onPress={hide}>{text}</Button>
143 }
144 return (
145 <Box key={`card-${item.key}`} position="absolute" top={row + box.top} left={0} width={box.width} flexDirection="column">
146 {box.bordered && edge('top', '╭╮')}
147 {box.lines.map((text, index) =>
148 index === 0 && item.page !== null
149 ? (
150 <Box key="head" flexDirection="row">
151 {box.bordered && <Button key={`hide-card-${item.key}-before`} label={left} plain onPress={hide}>{left}</Button>}
152 <Button key={`open-${item.key}`} label={head} plain onPress={() => openPage(item.page!)}>{head}</Button>
153 {beside.length > 0 && <Button key={`hide-card-${item.key}-beside`} label={beside} plain onPress={hide}>{beside}</Button>}
154 </Box>
155 )
156 : <Button key={`hide-card-${item.key}-${index}`} label={left + text.padEnd(inner) + right} plain onPress={hide}>{left + text.padEnd(inner) + right}</Button>,
157 )}
158 {box.bordered && edge('bottom', '╰╯')}
159 </Box>
160 )
161 }
162
163 const lineOf = (line: Line) => {
164 const text = line.parts.map(part => part.text).join('')
165 const rest = ' '.repeat(Math.max(0, columns - MARKER_WIDTH - text.length))
166 if (line.item) {
167 const { item } = line
168 return (
169 <Box key={`line-${item.key}`} flexDirection="row">
170 <Button key={`show-${item.key}`} label={`▸ ${text}`} plain onPress={() => show(item.key)}>
171 {'▸ '}{line.parts.map(styled)}
172 </Button>
173 {rest.length > 0 && <Button key={`hide-${item.key}-rest`} label={rest} plain onPress={hide}>{rest}</Button>}
174 </Box>
175 )
176 }
177 if (text.trim() === '') return blank(`hide-${line.key}`, columns)
178 return (
179 <Button key={`hide-${line.key}`} label={' '.repeat(MARKER_WIDTH) + text + rest} plain onPress={hide}>
180 {' '.repeat(MARKER_WIDTH)}{line.parts.map(styled)}{rest}
181 </Button>
182 )
183 }
184
185 const footer = wrap(FOOTER, columns)
186 // The rows between the footer and the pane's bottom edge, when the content is shorter than the window.
187 const below = Math.max(0, windowRows - lines.length - 1 - footer.length)
188 const shownRow = lines.findIndex(line => line.item?.key === shown)
189 return (
190 <Box flexDirection="column" width={columns} minHeight={windowRows}>
191 {lines.map(lineOf)}
192 {blank('hide-gap-footer', columns)}
193 {footer.map((text, index) => {
194 const padded = text.padEnd(columns)
195 return <Button key={`hide-footer-${index}`} label={padded} plain dimColor onPress={hide}>{padded}</Button>
196 })}
197 {Array.from({ length: below }, (_, index) => blank(`hide-below-${index}`, columns))}
198 {/* Drawn last, so no line paints over it. */}
199 {shownRow >= 0 && card(lines[shownRow]!.item!, shownRow)}
200 </Box>
201 )
202 })
203}
204hooks/decision-input.ts 102 lines1import type { CoreEngineInterface } from 'claude-code'
2import { decisionVersion, reportKey } from './report'
3import type { ContextReport, Decision, DecisionOption } from './report'
4
5type Attempt = { attempt: string; status: 'sending' | 'sent' | 'failed' | 'unconfirmed'; reason?: string }
6export type AnswerState = { discussion?: Attempt; version: string; selected?: string; receipt?: { attempt: string; option: DecisionOption; status: 'sending' | 'sent' | 'failed' | 'unconfirmed'; reason?: string } }
7export type AnswerStates = Record<string, AnswerState>
8export const answersKey = (session: string) => `decision-answers:${session}`
9// Serialize local read/modify/write operations, never the transport wait. Reports
10// and presses share this lane so removal/revision invalidates captured callbacks.
11const lanes = new Map<string, Promise<unknown>>()
12export function decisionLane<T>(session: string, task: () => Promise<T>): Promise<T> {
13 const result = (lanes.get(session) ?? Promise.resolve()).then(task, task)
14 lanes.set(session, result)
15 void result.finally(() => { if (lanes.get(session) === result) lanes.delete(session) }).catch(() => {})
16 return result
17}
18export async function answerStates($: CoreEngineInterface, session: string): Promise<AnswerStates> {
19 return await $.store.get(answersKey(session)) as AnswerStates ?? {}
20}
21export async function reconcileAnswers($: CoreEngineInterface, session: string, report: ContextReport | null, resume = false) {
22 const old = await answerStates($, session)
23 const states: AnswerStates = {}
24 for (const decision of report?.decisions ?? []) {
25 const version = decisionVersion(decision)
26 const previous = Object.hasOwn(old, decision.identity) ? old[decision.identity] : undefined
27 if (previous?.version !== version) continue
28 const receipt = previous.receipt
29 const discussion = previous.discussion
30 states[decision.identity] = { ...previous, ...(resume && discussion?.status === 'sending' ? { discussion: { ...discussion, status: 'unconfirmed' as const } } : {}), ...(resume && receipt?.status === 'sending' ? { receipt: { ...receipt, status: 'unconfirmed' as const } } : {}) }
31 }
32 await $.store.set(answersKey(session), states)
33}
34export const inputUnavailableKey = (session: string) => `decision-input-unavailable:${session}`
35export function answerText(decision: Decision, option: DecisionOption): string {
36 return JSON.stringify({ action: 'Answer selected in the whatsup pane. Apply the receiving session’s normal authorization rules.', decision: { identity: decision.identity, question: decision.question }, answer: option })
37}
38export const REFRESH_TEXT = JSON.stringify({ action: 'The whatsup pane was opened. Send one complete mcp__whatsup__report with the current scope, sections, workers and outstanding decisions; remove decisions already resolved, including answers given in chat. This is display state only, not a new task or an authorization.' })
39export async function selectAnswer($: CoreEngineInterface, session: string, identity: string, version: string, option?: string) {
40 await decisionLane(session, async () => {
41 if (await $.session.id() !== session) return
42 const report = await $.store.get(reportKey(session)) as ContextReport | undefined
43 const decision = report?.decisions?.find(d => d.identity === identity && decisionVersion(d) === version)
44 if (!decision || (option && !decision.options.some(o => o.identity === option))) return
45 const states = await answerStates($, session)
46 const state = Object.hasOwn(states, identity) ? states[identity] : undefined
47 if (state?.version === version && state.receipt && state.receipt.status !== 'failed') return
48 await $.store.set(answersKey(session), { ...states, [identity]: { ...state, version, selected: option } })
49 $.ui.invalidate('ui.render')
50 })
51}
52export function discussionText(decision: Decision): string {
53 return JSON.stringify({ action: 'Discussion request from the whatsup pane, not an answer, approval or resolution. Discuss this question in the main conversation: provide any missing explanation and invite challenges to the options or an alternative, including a custom answer in chat. Keep this decision pending; update missing context when appropriate. Apply the receiving session’s normal authorization rules.', decision: { identity: decision.identity, question: decision.question } })
54}
55export async function discussDecision($: CoreEngineInterface, session: string, identity: string, version: string) {
56 return submitDecision($, session, identity, version, 'discussion')
57}
58let attemptNumber = 0
59export async function sendAnswer($: CoreEngineInterface, session: string, identity: string, version: string, displayedOption: string) {
60 return submitDecision($, session, identity, version, 'receipt', displayedOption)
61}
62async function submitDecision($: CoreEngineInterface, session: string, identity: string, version: string, kind: 'receipt' | 'discussion', displayedOption?: string) {
63 await decisionLane(session, async () => {
64 if (await $.session.id() !== session || await $.store.get(inputUnavailableKey(session))) return
65 const report = await $.store.get(reportKey(session)) as ContextReport | undefined
66 const decision = report?.decisions?.find(d => d.identity === identity && decisionVersion(d) === version)
67 const states = await answerStates($, session)
68 const state = (Object.hasOwn(states, identity) ? states[identity] : undefined) ?? { version }
69 if (!decision || state.version !== version || (state.receipt && state.receipt.status !== 'failed') || (kind === 'discussion' && state.discussion && state.discussion.status !== 'failed' && state.discussion.status !== 'sent') || (kind === 'receipt' && state.selected !== displayedOption)) return
70 const option = decision.options.find(o => o.identity === state.selected)
71 if (kind === 'receipt' && !option) return
72 const attempt = `${await $.clock.now()}:${++attemptNumber}`
73 const receipt = { attempt, ...(kind === 'receipt' ? { option: option! } : {}), status: 'sending' as const }
74 await $.store.set(answersKey(session), { ...states, [identity]: { ...state, [kind]: receipt } })
75 $.ui.invalidate('ui.render')
76 const finish = (status: 'sent' | 'failed' | 'unconfirmed', reason?: string) => decisionLane(session, async () => {
77 const latest = await answerStates($, session)
78 const current = Object.hasOwn(latest, identity) ? latest[identity] : undefined
79 if (current?.version !== version || current[kind]?.attempt !== attempt || current[kind].status === 'sent' || current[kind].status === 'failed') return
80 await $.store.set(answersKey(session), { ...latest, [identity]: { ...current, [kind]: { ...receipt, status, reason } } })
81 $.ui.invalidate('ui.render')
82 })
83 const timeout = $.clock.after(15000, () => { void finish('unconfirmed', 'No acceptance confirmation received.') })
84 const text = kind === 'receipt' ? answerText(decision, option!) : discussionText(decision)
85 // Do not await the engine in the press handler: the pane stays responsive
86 // during a busy main turn; the durable attempt already blocks duplicates.
87 void $.prompt.submit({ text }).then(async result => {
88 timeout.cancel()
89 if (result && typeof result.drop === 'string') await finish('failed', result.drop)
90 else if (result && result.text === text && result.origin?.kind === 'plugin' && result.origin.name === 'whatsup') await finish('sent')
91 else await finish('unconfirmed', 'The engine did not confirm the submitted text and plugin provenance.')
92 }, async error => {
93 timeout.cancel()
94 const reason = String(error)
95 if (/no implementation for prompt\.submit|prompt\.submit.*(?:unsupported|not supported)/i.test(reason)) {
96 await $.store.set(inputUnavailableKey(session), true)
97 await finish('failed', 'Input submission unavailable in this engine. Continue in the main conversation.')
98 } else await finish('unconfirmed', 'The submission ended without confirmation.')
99 })
100 })
101}
102hooks/layout.ts 262 lines1// Turns one snapshot into the pane's lines and each item's card. Plain data,
2// no drawing. Counts, membership and pages come from the snapshot as read;
3// nothing is derived here.
4
5import { cells, fit } from './session-facts'
6import type { NotRecorded, ProgressSet, Row, Section, SetCard, Snapshot, TicketCard } from './snapshot'
7
8export type Part = { text: string; dim?: boolean; bold?: boolean; underline?: boolean; color?: string }
9
10export type Item = {
11 /** Unique within the pane; addresses the item's Box and Buttons. */
12 key: string
13 /** The source-supplied page, or null: such an item opens nothing. */
14 page: string | null
15 /** The card's logical detail lines, unwrapped; the page is not among them. */
16 details: string[]
17}
18
19export type Line = { key: string; parts: Part[]; item?: Item }
20
21const BAR_LABEL = 10
22/** Every item line starts with its `▸` marker. */
23export const MARKER_WIDTH = 2
24export const FOOTER = '▸ shows details · open ↗ opens the page in your browser'
25export const OPEN_LABEL = 'open ↗'
26export const NO_PAGE = 'No page'
27const NOT_RECORDED = 'Not recorded'
28const SET_NAMES = { milestone: 'Milestone', epic: 'Epic', slate: 'Slate' } as const
29
30/** One row of text: newlines and tabs folded, clipped to `width` cells with an ellipsis. */
31export function clip(text: string, width: number): string {
32 const flat = text.replace(/[\t\r\n]+/g, ' ')
33 if (width <= 0) return ''
34 return cells(flat) <= width ? flat : fit(flat, width - 1) + '…'
35}
36
37function listText(value: string[] | NotRecorded, empty: string): string {
38 if (value === NOT_RECORDED) return NOT_RECORDED
39 return value.length === 0 ? empty : value.join(', ')
40}
41
42function countText(set: ProgressSet): string {
43 return set.total === 0 ? 'no tickets' : `${set.closed}/${set.total} closed`
44}
45
46/** The bar's fill: full only when every ticket is closed, never empty once one is. */
47export function barCells(closed: number, total: number, width: number): string {
48 const share = closed === total ? width : Math.min(width - 1, Math.max(closed > 0 ? 1 : 0, Math.round((closed / total) * width)))
49 return '█'.repeat(share) + '░'.repeat(width - share)
50}
51
52function setCard(kind: 'milestone' | 'epic' | 'slate', set: ProgressSet): string[] {
53 const card: Section<SetCard> = set.card
54 const name = SET_NAMES[kind]
55 if (card.state !== 'present' || card.value === null) {
56 return [`${name}: ${set.identity}`, `Progress: ${countText(set)}`, `Couldn't load ${kind} details`]
57 }
58 const value = card.value
59 if (kind === 'slate') {
60 return [`Slate: ${value.identity}`, `Revision: ${value.revision ?? NOT_RECORDED}`, `Progress: ${countText(set)}`]
61 }
62 return [
63 `${name}: ${value.name ?? NOT_RECORDED}`,
64 `Objective: ${value.objective ?? NOT_RECORDED}`,
65 `Progress: ${countText(set)}`,
66 `Blockers: ${listText(value.blockers ?? NOT_RECORDED, 'none')}`,
67 ]
68}
69
70function rowLabel(row: Row): string {
71 // Unknown (null) still names the repository, as the read contract asks.
72 const repository = row.different_repository === false ? '' : row.repository
73 return `${repository}#${row.number}`
74}
75
76function ticketCard(row: Row): string[] {
77 const head = `${rowLabel(row)} ${row.title}`
78 const card: Section<TicketCard> = row.card
79 if (card.state !== 'present' || card.value === null) {
80 return [head, "Couldn't load ticket details"]
81 }
82 const value = card.value
83 const criteria = value.acceptance_criteria === NOT_RECORDED
84 ? [`Acceptance criteria: ${NOT_RECORDED}`]
85 : value.acceptance_criteria.length === 0 ? ['Acceptance criteria: none'] : ['Acceptance criteria:', ...value.acceptance_criteria]
86 const labels = value.status.labels === NOT_RECORDED ? NOT_RECORDED : value.status.labels.join(', ') || 'no status label'
87 return [
88 head,
89 `Requested outcome: ${value.requested_outcome}`,
90 ...criteria,
91 `Status: ${value.status.state} · ${labels}`,
92 `Blockers: ${listText(value.blockers, 'none')}`,
93 ]
94}
95
96/** The pane's lines for one snapshot, each clipped to `columns`, the text after an item's marker. */
97export function paneLines(snapshot: Snapshot, snapshotName: string, columns: number): Line[] {
98 const lines: Line[] = []
99 const text = (key: string, value: string, style: Omit<Part, 'text'> = {}) =>
100 lines.push({ key, parts: [{ text: clip(value, columns), ...style }] })
101 const item = (key: string, value: string, entry: Omit<Item, 'key'>) =>
102 lines.push({ key, parts: [{ text: clip(value, columns) }], item: { key, ...entry } })
103
104 text('source', `Recorded snapshot: ${snapshotName}`, { dim: true })
105
106 const { project, milestone, scope, progress } = snapshot
107 if (project.state === 'unavailable') text('project', "Couldn't load project")
108 else if (project.state === 'present' && project.value) {
109 const value = project.value
110 const shown = value.progress_sets.map(kind => SET_NAMES[kind as keyof typeof SET_NAMES] ?? kind)
111 item('project', `Project: ${value.name}`, {
112 page: value.page,
113 details: [
114 `Project: ${value.name}`,
115 `Scope: ${value.scope ?? NOT_RECORDED}`,
116 `Progress sets shown: ${shown.length === 0 ? 'none' : shown.join(', ')}`,
117 ],
118 })
119 }
120
121 if (milestone.state === 'unavailable') text('milestone', "Couldn't load milestone")
122 else if (milestone.state === 'present' && milestone.value) {
123 const value = milestone.value
124 const bar = progress.milestone
125 const card = bar.state === 'present' && bar.value && bar.value.identity === value.identity
126 ? setCard('milestone', bar.value)
127 : [
128 `Milestone: ${value.name}`,
129 `Objective: ${value.objective}`,
130 bar.state === 'unavailable' ? "Progress: Couldn't load milestone" : `Progress: ${NOT_RECORDED}`,
131 ]
132 item('milestone', `Milestone: ${value.name}`, { page: value.page, details: card })
133 }
134
135 if (scope.state === 'unavailable' || scope.value === null) text('scope', "Couldn't load scope")
136 else if (scope.value.authority === null) text('scope', scope.value.text)
137 else {
138 text('scope', `Scope: ${scope.value.text}`)
139 if (scope.value.label !== null) text('scope-source', ` ${scope.value.label}`, { dim: true })
140 }
141
142 const bars = (['milestone', 'epic', 'slate'] as const).filter(kind => progress[kind].state !== 'hidden')
143 if (bars.length > 0) lines.push({ key: 'gap-bars', parts: [{ text: ' ' }] })
144 for (const kind of bars) {
145 const section = progress[kind]
146 const label = SET_NAMES[kind].padEnd(BAR_LABEL)
147 if (section.state === 'unavailable' || section.value === null) {
148 text(`bar-${kind}`, `${label}Couldn't load ${kind}`)
149 continue
150 }
151 const set = section.value
152 const page = set.card.state === 'present' && set.card.value ? set.card.value.page : null
153 const count = countText(set)
154 const width = Math.max(4, Math.min(24, columns - BAR_LABEL - count.length - 1))
155 const parts: Part[] = set.total === 0
156 ? [{ text: clip(label + count, columns) }]
157 : [{ text: label }, { text: barCells(set.closed, set.total, width) }, { text: ' ' + count }]
158 const used = parts.reduce((sum, part) => sum + part.text.length, 0)
159 const fitted = used <= columns ? parts : [{ text: clip(label + count, columns) }]
160 lines.push({ key: `bar-${kind}`, parts: fitted, item: { key: `bar-${kind}`, page, details: setCard(kind, set) } })
161 }
162
163 lines.push({ key: 'gap-rows', parts: [{ text: ' ' }] })
164 text('heading-rows', 'In progress', { bold: true })
165 const inProgress = snapshot.in_progress
166 if (inProgress.state !== 'present' || inProgress.value === null) text('rows-message', "Couldn't load in progress")
167 else if (inProgress.value.rows.length === 0) text('rows-message', inProgress.value.message ?? 'Nothing in progress')
168 else {
169 for (const row of inProgress.value.rows) {
170 const page = row.card.state === 'present' && row.card.value ? row.card.value.page : null
171 item(`row-${row.identity}`, `${rowLabel(row)} ${row.title}`, { page, details: ticketCard(row) })
172 }
173 }
174 return lines
175}
176
177/** Word-wraps one logical detail line to `width`, continuation lines indented under its text. */
178export function wrap(line: string, width: number): string[] {
179 const out: string[] = []
180 for (const paragraph of line.split('\n')) {
181 const indent = paragraph.match(/^\s*/)?.[0] ?? ''
182 const checklist = /^- \[[ xX]\] /.test(paragraph.slice(indent.length))
183 const hang = ' '.repeat(Math.min(indent.length + (checklist ? 6 : 2), Math.max(0, width - 8)))
184 let current = indent
185 let fresh = true
186 for (const word of paragraph.slice(indent.length).split(/\s+/).filter(Boolean)) {
187 let rest = word
188 while (rest.length > 0) {
189 const room = width - cells(current) - (fresh ? 0 : 1)
190 if (cells(rest) <= room) {
191 current += (fresh ? '' : ' ') + rest
192 rest = ''
193 fresh = false
194 } else if (!fresh) {
195 out.push(current)
196 current = hang
197 fresh = true
198 } else {
199 const take = Math.max(1, width - cells(current))
200 const piece = fit(rest, take)
201 out.push(current + (piece || clip(rest, take)))
202 rest = rest.slice(piece.length || [...rest][0]!.length)
203 current = hang
204 }
205 }
206 }
207 if (!fresh || out.length === 0 || current.trim().length > 0) out.push(current)
208 }
209 return out.map(text => fit(text, width))
210}
211
212/** The card's rows within `rows`: the overflow replaced by "… N more lines", shortened only where the card is too narrow for it. */
213export function fitCard(lines: string[], rows: number, width = Infinity): string[] {
214 if (lines.length <= rows) return lines
215 if (rows <= 0) return []
216 const kept = lines.slice(0, rows - 1)
217 const hidden = lines.length - kept.length
218 const marker = [`… ${hidden} more lines`, `… ${hidden} more`, `…${hidden}`].find(text => text.length <= width) ?? '…'
219 return [...kept, marker]
220}
221
222export type CardBox = {
223 /** Offset from the item's row: 1 directly below, negative above. */
224 top: number
225 width: number
226 bordered: boolean
227 lines: string[]
228}
229
230const CARD_MAX_WIDTH = 80
231
232/**
233 * Seats a card inside the pane's window without covering its item: below the
234 * item when it fits there, above when it fits there, otherwise on the roomier
235 * side, truncated. A window too short for that is covered whole.
236 *
237 * The first line is `head`, the card's action line; the details follow.
238 * `row` is the item's row in the tree, `offset` the window's first row and
239 * `windowRows` how many rows the window shows.
240 */
241export function placeCard(head: string, details: string[], row: number, offset: number, windowRows: number, columns: number): CardBox {
242 const width = Math.max(1, Math.min(columns, CARD_MAX_WIDTH))
243 const bordered = width >= 8 && windowRows >= 3
244 const frame = bordered ? 2 : 0
245 const inner = width - (bordered ? 4 : 0)
246 // The head line (open ↗ or No page) is never wrapped and never cut off.
247 const wrapped = [clip(head, inner), ...details.flatMap(line => wrap(line, inner))]
248 const fit = (rows: number) => (rows <= 1 ? wrapped.slice(0, 1) : fitCard(wrapped, rows, inner))
249 const seen = Math.min(Math.max(row - offset, 0), Math.max(windowRows - 1, 0))
250 const below = windowRows - seen - 1
251 const above = seen
252 const full = wrapped.length + frame
253 if (full <= below) return { top: 1, width, bordered, lines: wrapped }
254 if (full <= above) return { top: -full, width, bordered, lines: wrapped }
255 const side = Math.max(below, above)
256 if (side >= frame + 2) {
257 const lines = fit(side - frame)
258 return { top: below >= above ? 1 : -side, width, bordered, lines }
259 }
260 return { top: -seen, width, bordered, lines: fit(Math.max(1, windowRows - frame)) }
261}
262hooks/snapshots.ts 6875 lines1// Generated by tests/build_pane_snapshots.py from tests/fixtures; do not edit.
2// Each snapshot is read_session output over the named fixture (and in-progress case).
3import type { SnapshotEntry } from './snapshot'
4
5export const DEFAULT_SNAPSHOT = "several-rows"
6
7const DATA = {
8 "automatic-scope": {
9 "case": null,
10 "fixture": "scope-automatic.json",
11 "name": "automatic-scope",
12 "purpose": "Scope from the automatic session title.",
13 "snapshot": {
14 "in_progress": {
15 "evidence": [
16 {
17 "availability": "complete",
18 "identity": "codex:00000000-0000-4000-8000-000000000001",
19 "observed_at": "2026-10-14T06:24:01.548527+00:00",
20 "revision": null,
21 "source": "naming",
22 "source_time": "2026-10-14T06:24:01.548527+00:00"
23 },
24 {
25 "availability": "complete",
26 "identity": "codex:00000000-0000-4000-8000-000000000001",
27 "observed_at": "2026-10-14T06:24:01.548527+00:00",
28 "revision": 1,
29 "source": "slates",
30 "source_time": "2026-10-14T06:24:01.548527+00:00"
31 },
32 {
33 "availability": "unsupported",
34 "identity": "derived-read-module-slate@revision/1",
35 "observed_at": "2026-10-14T12:00:00Z",
36 "revision": null,
37 "source": "runs",
38 "source_time": null
39 }
40 ],
41 "reason": "unsupported",
42 "state": "unavailable",
43 "value": null
44 },
45 "milestone": {
46 "evidence": [
47 {
48 "availability": "complete",
49 "identity": "codex:00000000-0000-4000-8000-000000000001",
50 "observed_at": "2026-10-14T06:24:01.548527+00:00",
51 "revision": null,
52 "source": "naming",
53 "source_time": "2026-10-14T06:24:01.548527+00:00"
54 }
55 ],
56 "reason": null,
57 "state": "hidden",
58 "value": null
59 },
60 "progress": {
61 "epic": {
62 "evidence": [
63 {
64 "availability": "complete",
65 "identity": "codex:00000000-0000-4000-8000-000000000001",
66 "observed_at": "2026-10-14T06:24:01.548527+00:00",
67 "revision": null,
68 "source": "naming",
69 "source_time": "2026-10-14T06:24:01.548527+00:00"
70 }
71 ],
72 "reason": null,
73 "state": "hidden",
74 "value": null
75 },
76 "milestone": {
77 "evidence": [
78 {
79 "availability": "complete",
80 "identity": "codex:00000000-0000-4000-8000-000000000001",
81 "observed_at": "2026-10-14T06:24:01.548527+00:00",
82 "revision": null,
83 "source": "naming",
84 "source_time": "2026-10-14T06:24:01.548527+00:00"
85 }
86 ],
87 "reason": null,
88 "state": "hidden",
89 "value": null
90 },
91 "slate": {
92 "evidence": [
93 {
94 "availability": "complete",
95 "identity": "codex:00000000-0000-4000-8000-000000000001",
96 "observed_at": "2026-10-14T06:24:01.548527+00:00",
97 "revision": 1,
98 "source": "slates",
99 "source_time": "2026-10-14T06:24:01.548527+00:00"
100 },
101 {
102 "availability": "complete",
103 "identity": "example/project#3",
104 "observed_at": "2026-10-14T06:24:01.548527+00:00",
105 "revision": null,
106 "source": "issues",
107 "source_time": "2026-10-14T06:24:01.548527+00:00"
108 },
109 {
110 "availability": "complete",
111 "identity": "example/project#4",
112 "observed_at": "2026-10-14T06:24:01.548527+00:00",
113 "revision": null,
114 "source": "issues",
115 "source_time": "2026-10-14T06:24:01.548527+00:00"
116 }
117 ],
118 "reason": null,
119 "state": "present",
120 "value": {
121 "card": {
122 "evidence": [
123 {
124 "availability": "complete",
125 "identity": "codex:00000000-0000-4000-8000-000000000001",
126 "observed_at": "2026-10-14T06:24:01.548527+00:00",
127 "revision": 1,
128 "source": "slates",
129 "source_time": "2026-10-14T06:24:01.548527+00:00"
130 },
131 {
132 "availability": "complete",
133 "identity": "example/project#3",
134 "observed_at": "2026-10-14T06:24:01.548527+00:00",
135 "revision": null,
136 "source": "issues",
137 "source_time": "2026-10-14T06:24:01.548527+00:00"
138 },
139 {
140 "availability": "complete",
141 "identity": "example/project#4",
142 "observed_at": "2026-10-14T06:24:01.548527+00:00",
143 "revision": null,
144 "source": "issues",
145 "source_time": "2026-10-14T06:24:01.548527+00:00"
146 }
147 ],
148 "reason": null,
149 "state": "present",
150 "value": {
151 "closed": 0,
152 "identity": "derived-read-module-slate",
153 "page": null,
154 "revision": 1,
155 "total": 2
156 }
157 },
158 "closed": 0,
159 "identity": "derived-read-module-slate",
160 "kind": "slate",
161 "members": [
162 "example/project#3",
163 "example/project#4"
164 ],
165 "revision": 1,
166 "total": 2
167 }
168 }
169 },
170 "project": {
171 "evidence": [
172 {
173 "availability": "complete",
174 "identity": "codex:00000000-0000-4000-8000-000000000001",
175 "observed_at": "2026-10-14T06:24:01.548527+00:00",
176 "revision": null,
177 "source": "naming",
178 "source_time": "2026-10-14T06:24:01.548527+00:00"
179 },
180 {
181 "availability": "complete",
182 "identity": "example/project",
183 "observed_at": "2026-10-14T06:33:18.293903+00:00",
184 "revision": null,
185 "source": "projects",
186 "source_time": "2026-10-12T19:35:47.865Z"
187 }
188 ],
189 "reason": null,
190 "state": "present",
191 "value": {
192 "identity": "session-status",
193 "name": "Session Status",
194 "page": null,
195 "progress_sets": [
196 "slate"
197 ],
198 "scope": "Build Session Status initial slices"
199 }
200 },
201 "scope": {
202 "evidence": [
203 {
204 "availability": "complete",
205 "identity": "codex:00000000-0000-4000-8000-000000000001",
206 "observed_at": "2026-10-14T06:24:01.548527+00:00",
207 "revision": null,
208 "source": "naming",
209 "source_time": "2026-10-14T06:24:01.548527+00:00"
210 }
211 ],
212 "reason": null,
213 "state": "present",
214 "value": {
215 "authority": "stated_objective",
216 "label": "from session title (automatic)",
217 "reference": null,
218 "repository": "example/project",
219 "text": "Build Session Status initial slices"
220 }
221 },
222 "session": {
223 "observed_at": "2026-10-14T12:00:00Z",
224 "provider": "codex",
225 "session_id": "00000000-0000-4000-8000-000000000001"
226 }
227 }
228 },
229 "couldnt-load-card": {
230 "case": "failed-blockers",
231 "fixture": "baseline.json",
232 "name": "couldnt-load-card",
233 "purpose": "Blocker read failed: the row stays, its card is unavailable.",
234 "snapshot": {
235 "in_progress": {
236 "evidence": [
237 {
238 "availability": "complete",
239 "identity": "codex:00000000-0000-4000-8000-000000000001",
240 "observed_at": "2026-10-14T06:24:01.548527+00:00",
241 "revision": null,
242 "source": "naming",
243 "source_time": "2026-10-14T06:24:01.548527+00:00"
244 },
245 {
246 "availability": "complete",
247 "identity": "codex:00000000-0000-4000-8000-000000000001",
248 "observed_at": "2026-10-14T06:24:01.548527+00:00",
249 "revision": 1,
250 "source": "slates",
251 "source_time": "2026-10-14T06:24:01.548527+00:00"
252 },
253 {
254 "availability": "complete",
255 "identity": "derived-read-module-slate@revision/1",
256 "observed_at": "2026-10-14T12:00:00Z",
257 "revision": null,
258 "source": "runs",
259 "source_time": "2026-10-14T12:00:00Z"
260 },
261 {
262 "availability": "complete",
263 "identity": "codex:00000000-0000-4000-8000-000000000001",
264 "observed_at": "2026-10-14T12:00:00Z",
265 "revision": null,
266 "source": "claims",
267 "source_time": "2026-10-14T12:00:00Z"
268 },
269 {
270 "availability": "complete",
271 "identity": "example/project#4",
272 "observed_at": "2026-10-14T06:24:01.548527+00:00",
273 "revision": null,
274 "source": "issues",
275 "source_time": "2026-10-14T06:24:01.548527+00:00"
276 }
277 ],
278 "reason": null,
279 "state": "present",
280 "value": {
281 "message": null,
282 "rows": [
283 {
284 "card": {
285 "evidence": [
286 {
287 "availability": "complete",
288 "identity": "codex:00000000-0000-4000-8000-000000000001",
289 "observed_at": "2026-10-14T06:24:01.548527+00:00",
290 "revision": null,
291 "source": "naming",
292 "source_time": "2026-10-14T06:24:01.548527+00:00"
293 },
294 {
295 "availability": "complete",
296 "identity": "codex:00000000-0000-4000-8000-000000000001",
297 "observed_at": "2026-10-14T06:24:01.548527+00:00",
298 "revision": 1,
299 "source": "slates",
300 "source_time": "2026-10-14T06:24:01.548527+00:00"
301 },
302 {
303 "availability": "complete",
304 "identity": "derived-read-module-slate@revision/1",
305 "observed_at": "2026-10-14T12:00:00Z",
306 "revision": null,
307 "source": "runs",
308 "source_time": "2026-10-14T12:00:00Z"
309 },
310 {
311 "availability": "complete",
312 "identity": "codex:00000000-0000-4000-8000-000000000001",
313 "observed_at": "2026-10-14T12:00:00Z",
314 "revision": null,
315 "source": "claims",
316 "source_time": "2026-10-14T12:00:00Z"
317 },
318 {
319 "availability": "complete",
320 "identity": "example/project#4",
321 "observed_at": "2026-10-14T06:24:01.548527+00:00",
322 "revision": null,
323 "source": "issues",
324 "source_time": "2026-10-14T06:24:01.548527+00:00"
325 },
326 {
327 "availability": "failed",
328 "identity": "example/project#4",
329 "observed_at": "2026-10-14T06:24:01.548527+00:00",
330 "revision": null,
331 "source": "blockers",
332 "source_time": "2026-10-14T06:24:01.548527+00:00"
333 }
334 ],
335 "reason": "failed",
336 "state": "unavailable",
337 "value": null
338 },
339 "different_repository": false,
340 "identity": "example/project#4",
341 "number": 4,
342 "owner": "example",
343 "repository": "project",
344 "title": "Add in-progress rows to the read module"
345 }
346 ]
347 }
348 },
349 "milestone": {
350 "evidence": [
351 {
352 "availability": "complete",
353 "identity": "codex:00000000-0000-4000-8000-000000000001",
354 "observed_at": "2026-10-14T06:24:01.548527+00:00",
355 "revision": null,
356 "source": "naming",
357 "source_time": "2026-10-14T06:24:01.548527+00:00"
358 },
359 {
360 "availability": "complete",
361 "identity": "example/project#2",
362 "observed_at": "2026-10-14T06:24:01.548527+00:00",
363 "revision": null,
364 "source": "issues",
365 "source_time": "2026-10-14T06:24:01.548527+00:00"
366 },
367 {
368 "availability": "complete",
369 "identity": "example/project@milestone/1",
370 "observed_at": "2026-10-14T06:24:01.548527+00:00",
371 "revision": null,
372 "source": "milestones",
373 "source_time": "2026-10-14T06:24:01.548527+00:00"
374 }
375 ],
376 "reason": null,
377 "state": "present",
378 "value": {
379 "identity": "example/project@milestone/1",
380 "name": "Initial build",
381 "objective": "Exit criterion: the example release is verified.",
382 "page": "https://github.com/example/project/milestone/1"
383 }
384 },
385 "progress": {
386 "epic": {
387 "evidence": [
388 {
389 "availability": "complete",
390 "identity": "codex:00000000-0000-4000-8000-000000000001",
391 "observed_at": "2026-10-14T06:24:01.548527+00:00",
392 "revision": null,
393 "source": "naming",
394 "source_time": "2026-10-14T06:24:01.548527+00:00"
395 },
396 {
397 "availability": "complete",
398 "identity": "example/project#2",
399 "observed_at": "2026-10-14T06:24:01.548527+00:00",
400 "revision": null,
401 "source": "issues",
402 "source_time": "2026-10-14T06:24:01.548527+00:00"
403 },
404 {
405 "availability": "complete",
406 "identity": "example/project#2",
407 "observed_at": "2026-10-14T06:24:01.548527+00:00",
408 "revision": null,
409 "source": "children",
410 "source_time": "2026-10-14T06:24:01.548527+00:00"
411 },
412 {
413 "availability": "complete",
414 "identity": "example/project#3",
415 "observed_at": "2026-10-14T06:24:01.548527+00:00",
416 "revision": null,
417 "source": "issues",
418 "source_time": "2026-10-14T06:24:01.548527+00:00"
419 },
420 {
421 "availability": "complete",
422 "identity": "example/project#3",
423 "observed_at": "2026-10-14T06:24:01.548527+00:00",
424 "revision": null,
425 "source": "children",
426 "source_time": "2026-10-14T06:24:01.548527+00:00"
427 },
428 {
429 "availability": "complete",
430 "identity": "example/project#4",
431 "observed_at": "2026-10-14T06:24:01.548527+00:00",
432 "revision": null,
433 "source": "issues",
434 "source_time": "2026-10-14T06:24:01.548527+00:00"
435 },
436 {
437 "availability": "complete",
438 "identity": "example/project#4",
439 "observed_at": "2026-10-14T06:24:01.548527+00:00",
440 "revision": null,
441 "source": "children",
442 "source_time": "2026-10-14T06:24:01.548527+00:00"
443 },
444 {
445 "availability": "complete",
446 "identity": "example/project#5",
447 "observed_at": "2026-10-14T06:24:01.548527+00:00",
448 "revision": null,
449 "source": "issues",
450 "source_time": "2026-10-14T06:24:01.548527+00:00"
451 },
452 {
453 "availability": "complete",
454 "identity": "example/project#5",
455 "observed_at": "2026-10-14T06:24:01.548527+00:00",
456 "revision": null,
457 "source": "children",
458 "source_time": "2026-10-14T06:24:01.548527+00:00"
459 }
460 ],
461 "reason": null,
462 "state": "present",
463 "value": {
464 "card": {
465 "evidence": [
466 {
467 "availability": "complete",
468 "identity": "codex:00000000-0000-4000-8000-000000000001",
469 "observed_at": "2026-10-14T06:24:01.548527+00:00",
470 "revision": null,
471 "source": "naming",
472 "source_time": "2026-10-14T06:24:01.548527+00:00"
473 },
474 {
475 "availability": "complete",
476 "identity": "example/project#2",
477 "observed_at": "2026-10-14T06:24:01.548527+00:00",
478 "revision": null,
479 "source": "issues",
480 "source_time": "2026-10-14T06:24:01.548527+00:00"
481 },
482 {
483 "availability": "complete",
484 "identity": "example/project#2",
485 "observed_at": "2026-10-14T06:24:01.548527+00:00",
486 "revision": null,
487 "source": "children",
488 "source_time": "2026-10-14T06:24:01.548527+00:00"
489 },
490 {
491 "availability": "complete",
492 "identity": "example/project#3",
493 "observed_at": "2026-10-14T06:24:01.548527+00:00",
494 "revision": null,
495 "source": "issues",
496 "source_time": "2026-10-14T06:24:01.548527+00:00"
497 },
498 {
499 "availability": "complete",
500 "identity": "example/project#3",
501 "observed_at": "2026-10-14T06:24:01.548527+00:00",
502 "revision": null,
503 "source": "children",
504 "source_time": "2026-10-14T06:24:01.548527+00:00"
505 },
506 {
507 "availability": "complete",
508 "identity": "example/project#4",
509 "observed_at": "2026-10-14T06:24:01.548527+00:00",
510 "revision": null,
511 "source": "issues",
512 "source_time": "2026-10-14T06:24:01.548527+00:00"
513 },
514 {
515 "availability": "complete",
516 "identity": "example/project#4",
517 "observed_at": "2026-10-14T06:24:01.548527+00:00",
518 "revision": null,
519 "source": "children",
520 "source_time": "2026-10-14T06:24:01.548527+00:00"
521 },
522 {
523 "availability": "complete",
524 "identity": "example/project#5",
525 "observed_at": "2026-10-14T06:24:01.548527+00:00",
526 "revision": null,
527 "source": "issues",
528 "source_time": "2026-10-14T06:24:01.548527+00:00"
529 },
530 {
531 "availability": "complete",
532 "identity": "example/project#5",
533 "observed_at": "2026-10-14T06:24:01.548527+00:00",
534 "revision": null,
535 "source": "children",
536 "source_time": "2026-10-14T06:24:01.548527+00:00"
537 },
538 {
539 "availability": "complete",
540 "identity": "example/project#2",
541 "observed_at": "2026-10-14T06:24:01.548527+00:00",
542 "revision": null,
543 "source": "blockers",
544 "source_time": "2026-10-14T06:24:01.548527+00:00"
545 }
546 ],
547 "reason": null,
548 "state": "present",
549 "value": {
550 "blockers": [],
551 "closed": 0,
552 "identity": "example/project#2",
553 "name": "Build Session Status initial slices",
554 "objective": "Not recorded",
555 "page": "https://github.com/example/project/issues/2",
556 "total": 3
557 }
558 },
559 "closed": 0,
560 "identity": "example/project#2",
561 "kind": "epic",
562 "members": [
563 "example/project#3",
564 "example/project#4",
565 "example/project#5"
566 ],
567 "revision": null,
568 "total": 3
569 }
570 },
571 "milestone": {
572 "evidence": [
573 {
574 "availability": "complete",
575 "identity": "codex:00000000-0000-4000-8000-000000000001",
576 "observed_at": "2026-10-14T06:24:01.548527+00:00",
577 "revision": null,
578 "source": "naming",
579 "source_time": "2026-10-14T06:24:01.548527+00:00"
580 },
581 {
582 "availability": "complete",
583 "identity": "example/project#2",
584 "observed_at": "2026-10-14T06:24:01.548527+00:00",
585 "revision": null,
586 "source": "issues",
587 "source_time": "2026-10-14T06:24:01.548527+00:00"
588 },
589 {
590 "availability": "complete",
591 "identity": "example/project@milestone/1",
592 "observed_at": "2026-10-14T06:24:01.548527+00:00",
593 "revision": null,
594 "source": "members",
595 "source_time": "2026-10-14T06:24:01.548527+00:00"
596 },
597 {
598 "availability": "complete",
599 "identity": "example/project#3",
600 "observed_at": "2026-10-14T06:24:01.548527+00:00",
601 "revision": null,
602 "source": "issues",
603 "source_time": "2026-10-14T06:24:01.548527+00:00"
604 },
605 {
606 "availability": "complete",
607 "identity": "example/project#4",
608 "observed_at": "2026-10-14T06:24:01.548527+00:00",
609 "revision": null,
610 "source": "issues",
611 "source_time": "2026-10-14T06:24:01.548527+00:00"
612 },
613 {
614 "availability": "complete",
615 "identity": "example/project#5",
616 "observed_at": "2026-10-14T06:24:01.548527+00:00",
617 "revision": null,
618 "source": "issues",
619 "source_time": "2026-10-14T06:24:01.548527+00:00"
620 },
621 {
622 "availability": "complete",
623 "identity": "example/project@milestone/1",
624 "observed_at": "2026-10-14T06:24:01.548527+00:00",
625 "revision": null,
626 "source": "milestones",
627 "source_time": "2026-10-14T06:24:01.548527+00:00"
628 }
629 ],
630 "reason": null,
631 "state": "present",
632 "value": {
633 "card": {
634 "evidence": [
635 {
636 "availability": "complete",
637 "identity": "codex:00000000-0000-4000-8000-000000000001",
638 "observed_at": "2026-10-14T06:24:01.548527+00:00",
639 "revision": null,
640 "source": "naming",
641 "source_time": "2026-10-14T06:24:01.548527+00:00"
642 },
643 {
644 "availability": "complete",
645 "identity": "example/project#2",
646 "observed_at": "2026-10-14T06:24:01.548527+00:00",
647 "revision": null,
648 "source": "issues",
649 "source_time": "2026-10-14T06:24:01.548527+00:00"
650 },
651 {
652 "availability": "complete",
653 "identity": "example/project@milestone/1",
654 "observed_at": "2026-10-14T06:24:01.548527+00:00",
655 "revision": null,
656 "source": "members",
657 "source_time": "2026-10-14T06:24:01.548527+00:00"
658 },
659 {
660 "availability": "complete",
661 "identity": "example/project#3",
662 "observed_at": "2026-10-14T06:24:01.548527+00:00",
663 "revision": null,
664 "source": "issues",
665 "source_time": "2026-10-14T06:24:01.548527+00:00"
666 },
667 {
668 "availability": "complete",
669 "identity": "example/project#4",
670 "observed_at": "2026-10-14T06:24:01.548527+00:00",
671 "revision": null,
672 "source": "issues",
673 "source_time": "2026-10-14T06:24:01.548527+00:00"
674 },
675 {
676 "availability": "complete",
677 "identity": "example/project#5",
678 "observed_at": "2026-10-14T06:24:01.548527+00:00",
679 "revision": null,
680 "source": "issues",
681 "source_time": "2026-10-14T06:24:01.548527+00:00"
682 },
683 {
684 "availability": "complete",
685 "identity": "example/project@milestone/1",
686 "observed_at": "2026-10-14T06:24:01.548527+00:00",
687 "revision": null,
688 "source": "milestones",
689 "source_time": "2026-10-14T06:24:01.548527+00:00"
690 },
691 {
692 "availability": "complete",
693 "identity": "example/project@milestone/1",
694 "observed_at": "2026-10-14T06:24:01.548527+00:00",
695 "revision": null,
696 "source": "blockers",
697 "source_time": "2026-10-14T06:24:01.548527+00:00"
698 }
699 ],
700 "reason": null,
701 "state": "present",
702 "value": {
703 "blockers": [],
704 "closed": 0,
705 "identity": "example/project@milestone/1",
706 "name": "Initial build",
707 "objective": "Exit criterion: the example release is verified.",
708 "page": "https://github.com/example/project/milestone/1",
709 "total": 4
710 }
711 },
712 "closed": 0,
713 "identity": "example/project@milestone/1",
714 "kind": "milestone",
715 "members": [
716 "example/project#2",
717 "example/project#3",
718 "example/project#4",
719 "example/project#5"
720 ],
721 "revision": null,
722 "total": 4
723 }
724 },
725 "slate": {
726 "evidence": [
727 {
728 "availability": "complete",
729 "identity": "codex:00000000-0000-4000-8000-000000000001",
730 "observed_at": "2026-10-14T06:24:01.548527+00:00",
731 "revision": 1,
732 "source": "slates",
733 "source_time": "2026-10-14T06:24:01.548527+00:00"
734 },
735 {
736 "availability": "complete",
737 "identity": "example/project#3",
738 "observed_at": "2026-10-14T06:24:01.548527+00:00",
739 "revision": null,
740 "source": "issues",
741 "source_time": "2026-10-14T06:24:01.548527+00:00"
742 },
743 {
744 "availability": "complete",
745 "identity": "example/project#4",
746 "observed_at": "2026-10-14T06:24:01.548527+00:00",
747 "revision": null,
748 "source": "issues",
749 "source_time": "2026-10-14T06:24:01.548527+00:00"
750 }
751 ],
752 "reason": null,
753 "state": "present",
754 "value": {
755 "card": {
756 "evidence": [
757 {
758 "availability": "complete",
759 "identity": "codex:00000000-0000-4000-8000-000000000001",
760 "observed_at": "2026-10-14T06:24:01.548527+00:00",
761 "revision": 1,
762 "source": "slates",
763 "source_time": "2026-10-14T06:24:01.548527+00:00"
764 },
765 {
766 "availability": "complete",
767 "identity": "example/project#3",
768 "observed_at": "2026-10-14T06:24:01.548527+00:00",
769 "revision": null,
770 "source": "issues",
771 "source_time": "2026-10-14T06:24:01.548527+00:00"
772 },
773 {
774 "availability": "complete",
775 "identity": "example/project#4",
776 "observed_at": "2026-10-14T06:24:01.548527+00:00",
777 "revision": null,
778 "source": "issues",
779 "source_time": "2026-10-14T06:24:01.548527+00:00"
780 }
781 ],
782 "reason": null,
783 "state": "present",
784 "value": {
785 "closed": 0,
786 "identity": "derived-read-module-slate",
787 "page": null,
788 "revision": 1,
789 "total": 2
790 }
791 },
792 "closed": 0,
793 "identity": "derived-read-module-slate",
794 "kind": "slate",
795 "members": [
796 "example/project#3",
797 "example/project#4"
798 ],
799 "revision": 1,
800 "total": 2
801 }
802 }
803 },
804 "project": {
805 "evidence": [
806 {
807 "availability": "complete",
808 "identity": "codex:00000000-0000-4000-8000-000000000001",
809 "observed_at": "2026-10-14T06:24:01.548527+00:00",
810 "revision": null,
811 "source": "naming",
812 "source_time": "2026-10-14T06:24:01.548527+00:00"
813 },
814 {
815 "availability": "complete",
816 "identity": "example/project",
817 "observed_at": "2026-10-14T06:33:18.293903+00:00",
818 "revision": null,
819 "source": "projects",
820 "source_time": "2026-10-12T19:35:47.865Z"
821 }
822 ],
823 "reason": null,
824 "state": "present",
825 "value": {
826 "identity": "session-status",
827 "name": "Session Status",
828 "page": null,
829 "progress_sets": [
830 "milestone",
831 "epic",
832 "slate"
833 ],
834 "scope": "Build Session Status initial slices"
835 }
836 },
837 "scope": {
838 "evidence": [
839 {
840 "availability": "complete",
841 "identity": "codex:00000000-0000-4000-8000-000000000001",
842 "observed_at": "2026-10-14T06:24:01.548527+00:00",
843 "revision": null,
844 "source": "naming",
845 "source_time": "2026-10-14T06:24:01.548527+00:00"
846 }
847 ],
848 "reason": null,
849 "state": "present",
850 "value": {
851 "authority": "epic",
852 "label": "from epic example/project#2",
853 "reference": "example/project#2",
854 "repository": "example/project",
855 "text": "Build Session Status initial slices"
856 }
857 },
858 "session": {
859 "observed_at": "2026-10-14T12:00:00Z",
860 "provider": "codex",
861 "session_id": "00000000-0000-4000-8000-000000000001"
862 }
863 }
864 },
865 "couldnt-load-epic": {
866 "case": null,
867 "fixture": "children-partial.json",
868 "name": "couldnt-load-epic",
869 "purpose": "Partial sub-issue read: epic bar unavailable.",
870 "snapshot": {
871 "in_progress": {
872 "evidence": [
873 {
874 "availability": "complete",
875 "identity": "codex:00000000-0000-4000-8000-000000000001",
876 "observed_at": "2026-10-14T06:24:01.548527+00:00",
877 "revision": null,
878 "source": "naming",
879 "source_time": "2026-10-14T06:24:01.548527+00:00"
880 },
881 {
882 "availability": "complete",
883 "identity": "codex:00000000-0000-4000-8000-000000000001",
884 "observed_at": "2026-10-14T06:24:01.548527+00:00",
885 "revision": 1,
886 "source": "slates",
887 "source_time": "2026-10-14T06:24:01.548527+00:00"
888 },
889 {
890 "availability": "unsupported",
891 "identity": "derived-read-module-slate@revision/1",
892 "observed_at": "2026-10-14T12:00:00Z",
893 "revision": null,
894 "source": "runs",
895 "source_time": null
896 }
897 ],
898 "reason": "unsupported",
899 "state": "unavailable",
900 "value": null
901 },
902 "milestone": {
903 "evidence": [
904 {
905 "availability": "complete",
906 "identity": "codex:00000000-0000-4000-8000-000000000001",
907 "observed_at": "2026-10-14T06:24:01.548527+00:00",
908 "revision": null,
909 "source": "naming",
910 "source_time": "2026-10-14T06:24:01.548527+00:00"
911 },
912 {
913 "availability": "complete",
914 "identity": "example/project#2",
915 "observed_at": "2026-10-14T06:24:01.548527+00:00",
916 "revision": null,
917 "source": "issues",
918 "source_time": "2026-10-14T06:24:01.548527+00:00"
919 },
920 {
921 "availability": "complete",
922 "identity": "example/project@milestone/1",
923 "observed_at": "2026-10-14T06:24:01.548527+00:00",
924 "revision": null,
925 "source": "milestones",
926 "source_time": "2026-10-14T06:24:01.548527+00:00"
927 }
928 ],
929 "reason": null,
930 "state": "present",
931 "value": {
932 "identity": "example/project@milestone/1",
933 "name": "Initial build",
934 "objective": "Exit criterion: the example release is verified.",
935 "page": "https://github.com/example/project/milestone/1"
936 }
937 },
938 "progress": {
939 "epic": {
940 "evidence": [
941 {
942 "availability": "complete",
943 "identity": "codex:00000000-0000-4000-8000-000000000001",
944 "observed_at": "2026-10-14T06:24:01.548527+00:00",
945 "revision": null,
946 "source": "naming",
947 "source_time": "2026-10-14T06:24:01.548527+00:00"
948 },
949 {
950 "availability": "complete",
951 "identity": "example/project#2",
952 "observed_at": "2026-10-14T06:24:01.548527+00:00",
953 "revision": null,
954 "source": "issues",
955 "source_time": "2026-10-14T06:24:01.548527+00:00"
956 },
957 {
958 "availability": "partial",
959 "identity": "example/project#2",
960 "observed_at": "2026-10-14T06:24:01.548527+00:00",
961 "revision": null,
962 "source": "children",
963 "source_time": "2026-10-14T06:24:01.548527+00:00"
964 }
965 ],
966 "reason": "partial",
967 "state": "unavailable",
968 "value": null
969 },
970 "milestone": {
971 "evidence": [
972 {
973 "availability": "complete",
974 "identity": "codex:00000000-0000-4000-8000-000000000001",
975 "observed_at": "2026-10-14T06:24:01.548527+00:00",
976 "revision": null,
977 "source": "naming",
978 "source_time": "2026-10-14T06:24:01.548527+00:00"
979 },
980 {
981 "availability": "complete",
982 "identity": "example/project#2",
983 "observed_at": "2026-10-14T06:24:01.548527+00:00",
984 "revision": null,
985 "source": "issues",
986 "source_time": "2026-10-14T06:24:01.548527+00:00"
987 },
988 {
989 "availability": "complete",
990 "identity": "example/project@milestone/1",
991 "observed_at": "2026-10-14T06:24:01.548527+00:00",
992 "revision": null,
993 "source": "members",
994 "source_time": "2026-10-14T06:24:01.548527+00:00"
995 },
996 {
997 "availability": "complete",
998 "identity": "example/project#3",
999 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1000 "revision": null,
1001 "source": "issues",
1002 "source_time": "2026-10-14T06:24:01.548527+00:00"
1003 },
1004 {
1005 "availability": "complete",
1006 "identity": "example/project#4",
1007 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1008 "revision": null,
1009 "source": "issues",
1010 "source_time": "2026-10-14T06:24:01.548527+00:00"
1011 },
1012 {
1013 "availability": "complete",
1014 "identity": "example/project#5",
1015 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1016 "revision": null,
1017 "source": "issues",
1018 "source_time": "2026-10-14T06:24:01.548527+00:00"
1019 },
1020 {
1021 "availability": "complete",
1022 "identity": "example/project@milestone/1",
1023 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1024 "revision": null,
1025 "source": "milestones",
1026 "source_time": "2026-10-14T06:24:01.548527+00:00"
1027 }
1028 ],
1029 "reason": null,
1030 "state": "present",
1031 "value": {
1032 "card": {
1033 "evidence": [
1034 {
1035 "availability": "complete",
1036 "identity": "codex:00000000-0000-4000-8000-000000000001",
1037 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1038 "revision": null,
1039 "source": "naming",
1040 "source_time": "2026-10-14T06:24:01.548527+00:00"
1041 },
1042 {
1043 "availability": "complete",
1044 "identity": "example/project#2",
1045 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1046 "revision": null,
1047 "source": "issues",
1048 "source_time": "2026-10-14T06:24:01.548527+00:00"
1049 },
1050 {
1051 "availability": "complete",
1052 "identity": "example/project@milestone/1",
1053 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1054 "revision": null,
1055 "source": "members",
1056 "source_time": "2026-10-14T06:24:01.548527+00:00"
1057 },
1058 {
1059 "availability": "complete",
1060 "identity": "example/project#3",
1061 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1062 "revision": null,
1063 "source": "issues",
1064 "source_time": "2026-10-14T06:24:01.548527+00:00"
1065 },
1066 {
1067 "availability": "complete",
1068 "identity": "example/project#4",
1069 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1070 "revision": null,
1071 "source": "issues",
1072 "source_time": "2026-10-14T06:24:01.548527+00:00"
1073 },
1074 {
1075 "availability": "complete",
1076 "identity": "example/project#5",
1077 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1078 "revision": null,
1079 "source": "issues",
1080 "source_time": "2026-10-14T06:24:01.548527+00:00"
1081 },
1082 {
1083 "availability": "complete",
1084 "identity": "example/project@milestone/1",
1085 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1086 "revision": null,
1087 "source": "milestones",
1088 "source_time": "2026-10-14T06:24:01.548527+00:00"
1089 },
1090 {
1091 "availability": "complete",
1092 "identity": "example/project@milestone/1",
1093 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1094 "revision": null,
1095 "source": "blockers",
1096 "source_time": "2026-10-14T06:24:01.548527+00:00"
1097 }
1098 ],
1099 "reason": null,
1100 "state": "present",
1101 "value": {
1102 "blockers": [],
1103 "closed": 0,
1104 "identity": "example/project@milestone/1",
1105 "name": "Initial build",
1106 "objective": "Exit criterion: the example release is verified.",
1107 "page": "https://github.com/example/project/milestone/1",
1108 "total": 4
1109 }
1110 },
1111 "closed": 0,
1112 "identity": "example/project@milestone/1",
1113 "kind": "milestone",
1114 "members": [
1115 "example/project#2",
1116 "example/project#3",
1117 "example/project#4",
1118 "example/project#5"
1119 ],
1120 "revision": null,
1121 "total": 4
1122 }
1123 },
1124 "slate": {
1125 "evidence": [
1126 {
1127 "availability": "complete",
1128 "identity": "codex:00000000-0000-4000-8000-000000000001",
1129 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1130 "revision": 1,
1131 "source": "slates",
1132 "source_time": "2026-10-14T06:24:01.548527+00:00"
1133 },
1134 {
1135 "availability": "complete",
1136 "identity": "example/project#3",
1137 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1138 "revision": null,
1139 "source": "issues",
1140 "source_time": "2026-10-14T06:24:01.548527+00:00"
1141 },
1142 {
1143 "availability": "complete",
1144 "identity": "example/project#4",
1145 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1146 "revision": null,
1147 "source": "issues",
1148 "source_time": "2026-10-14T06:24:01.548527+00:00"
1149 }
1150 ],
1151 "reason": null,
1152 "state": "present",
1153 "value": {
1154 "card": {
1155 "evidence": [
1156 {
1157 "availability": "complete",
1158 "identity": "codex:00000000-0000-4000-8000-000000000001",
1159 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1160 "revision": 1,
1161 "source": "slates",
1162 "source_time": "2026-10-14T06:24:01.548527+00:00"
1163 },
1164 {
1165 "availability": "complete",
1166 "identity": "example/project#3",
1167 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1168 "revision": null,
1169 "source": "issues",
1170 "source_time": "2026-10-14T06:24:01.548527+00:00"
1171 },
1172 {
1173 "availability": "complete",
1174 "identity": "example/project#4",
1175 "observed_at": "2026-10-14T06:24:01.548527+00:00",
1176 "revision": null,
1177 "source": "issues",
1178 "source_time": "2026-10-14T06:24:01.548527+00:00"
1179 }
1180 ],
1181 "reason": null,
1182 "state": "present",
1183 "value": {
1184 "closed": 0,
1185 "identity": "derived-read-module-slate",
1186 "page": null,
1187 "revision": 1,
1188 "total": 2
1189 }
1190 },
1191 "closed": 0,
1192 "identity": "derived-read-module-slate",
1193 "kind": "slate",
1194 "members": [
1195 "example/project#3",
1196 "example/project#4"
1197 ],
1198 "revision": 1,
1199 "total": 2
1200 }hooks/enrichment.ts 98 lines1import type { CoreEngineInterface, On, Timer } from 'claude-code'
2import type { ContextReport, ReportItem, ReportSection } from './report'
3import { REPORT_TOOL, reportError, reportKey } from './report'
4
5export type Enrichment = { state: 'present' | 'unavailable'; reason?: string; value?: { title?: string; status: string; labels?: string[]; blockers?: string[]; page?: string | null; total?: number; closed?: number } }
6export type EnrichedItem = ReportItem & { enrichment?: Enrichment }
7export type EnrichedSection = Omit<ReportSection, 'items'> & { items?: EnrichedItem[]; enrichment?: Enrichment; counts?: { done: number; active: number; total: number } }
8export type EnrichedReport = Omit<ContextReport, 'sections'> & { sections?: EnrichedSection[] }
9export type EnrichedSnapshot = { report: EnrichedReport; off_reason: string | null; input: string }
10export const enrichmentKey = (session: string) => `github-enrichment:${session}`
11
12const githubIdentity = (value?: string) => !!value && /^[^/\s]+\/[^/#@\s]+(?:#[1-9]\d*|@milestone\/[1-9]\d*)$/.test(value)
13function unavailable(report: ContextReport): EnrichedReport {
14 return { ...report, sections: report.sections?.map(section => ({ ...section,
15 ...(githubIdentity(section.identity) ? { enrichment: { state: 'unavailable' as const, reason: 'reader failed' } } : {}),
16 items: section.items?.map(item => ({ ...item, ...(githubIdentity(item.identity) ? { enrichment: { state: 'unavailable' as const, reason: 'reader failed' } } : {}) })),
17 })) }
18}
19
20/** Reject an incomplete subprocess document before it can erase entities. */
21function validateSnapshot(parsed: any, report: ContextReport) {
22 if (!parsed || parsed.report?.scope !== report.scope || !(parsed.off_reason === null || typeof parsed.off_reason === 'string')) throw Error('Invalid snapshot')
23 const sections = parsed.report.sections ?? []
24 if (!Array.isArray(sections) || sections.length !== (report.sections ?? []).length) throw Error('Partial snapshot')
25 const clean = { ...parsed.report, sections: sections.map((section: EnrichedSection, index: number) => {
26 const { enrichment, counts, items, ...rest } = section
27 if ((items ?? []).length !== (report.sections![index]!.items ?? []).length) throw Error('Partial items')
28 if (counts && (![counts.done, counts.active, counts.total].every(n => Number.isInteger(n) && n >= 0) || counts.done + counts.active > counts.total)) throw Error('Invalid counts')
29 const original = report.sections![index]!
30 if (rest.identity !== original.identity) throw Error('Changed identity')
31 if (githubIdentity(original.identity) && !enrichment && !parsed.off_reason) throw Error('Missing enrichment')
32 const cleanItems = items?.map((item, itemIndex) => {
33 const { enrichment: facts, ...value } = item
34 if (value.identity !== original.items![itemIndex]!.identity) throw Error('Changed item identity')
35 if (githubIdentity(value.identity) && !facts && !parsed.off_reason) throw Error('Missing item enrichment')
36 if (facts && !['present','unavailable'].includes(facts.state)) throw Error('Invalid entity')
37 if (facts?.state === 'present' && !facts.value) throw Error('Missing facts')
38 return value
39 })
40 return { ...rest, ...(cleanItems ? { items: cleanItems } : {}) }
41 }) }
42 if (reportError(clean)) throw Error('Invalid report')
43}
44
45async function refresh($: CoreEngineInterface, session: string, versions: Map<string, number>, issue_kinds: string[]) {
46 const report = await $.store.get(reportKey(session)) as ContextReport | undefined
47 if (!report) return
48 const version = (versions.get(session) ?? 0) + 1
49 versions.set(session, version)
50 const input = JSON.stringify(report)
51 let snapshot: EnrichedSnapshot = { report: unavailable(report), off_reason: null, input }
52 try {
53 const result = await $.process.run(['python3', `${$.plugin.root}/reader/main.py`], {
54 stdin: JSON.stringify({ report, issue_kinds }), timeoutMs: 55000,
55 })
56 if (result.exitCode === 0 && !('isStdoutTruncated' in result && result.isStdoutTruncated)) {
57 const parsed = JSON.parse(result.stdout)
58 validateSnapshot(parsed, report)
59 snapshot = { ...parsed, input }
60 }
61 } catch { /* Do not display stderr or exception text: they may contain credentials. */ }
62 if (versions.get(session) !== version || JSON.stringify(await $.store.get(reportKey(session))) !== input) return
63 await $.store.set(enrichmentKey(session), snapshot)
64 $.ui.invalidate('ui.render')
65 }
66
67/** Each open pane owns one timer; closing it stops reads and cancels the timer. */
68export function registerEnrichment(on: On, recorded: boolean, issueKinds = 'tickets,issues,epics') {
69 const shown = new Map<string, Timer>()
70 const versions = new Map<string, number>()
71 const issue_kinds = issueKinds.split(',').map(kind => kind.trim()).filter(Boolean)
72 if (!recorded) {
73 on('tool.call', { tool: REPORT_TOOL }, async ($, e, next) => {
74 const result = await next(e)
75 const session = await $.session.id()
76 if (shown.has(session)) await refresh($, session, versions, issue_kinds)
77 return result
78 })
79 on('ui.open', async ($, e, next) => {
80 if (e.id !== 'session-status') return next(e)
81 const session = await $.session.id()
82 shown.get(session)?.cancel()
83 const timer = $.clock.every(60000, () => { void refresh($, session, versions, issue_kinds) })
84 shown.set(session, timer)
85 await refresh($, session, versions, issue_kinds)
86 return next(e)
87 })
88 on('ui.close', async ($, e, next) => {
89 if (e.id === 'session-status') {
90 const session = await $.session.id()
91 shown.get(session)?.cancel(); shown.delete(session)
92 versions.set(session, (versions.get(session) ?? 0) + 1)
93 }
94 return next(e)
95 })
96 }
97}
98hooks/session-facts.ts 135 lines1import type { CoreEngineInterface, On, SessionUsage, Timer } from 'claude-code'
2
3export type SessionFacts = {
4 started: number; folder: string; branch?: string; worktree?: string;
5 repository?: { name: string; page: string };
6 pr?: { number: number; page: string; checks: 'passing' | 'failing' | 'pending' };
7}
8export const factsKey = (session: string) => `session-facts:${session}`
9
10export async function readSessionEnvironment($: CoreEngineInterface, session: string, folder: string) {
11 const previous = await $.store.get(factsKey(session)) as SessionFacts | undefined
12 const facts: SessionFacts = { started: previous?.started ?? await $.clock.now(), folder }
13 try {
14 const repo = await $.session.repo()
15 const match = repo?.remote?.match(/^(?:git@github\.com:|https:\/\/github\.com\/|ssh:\/\/git@github\.com\/)([^/\s]+\/[^/\s]+?)?(?:\.git)?\/?$/)
16 if (match?.[1]) facts.repository = { name: match[1], page: `https://github.com/${match[1]}` }
17 const git = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD', '--show-toplevel', '--absolute-git-dir', '--path-format=absolute', '--git-common-dir'], { cwd: folder, timeoutMs: 5000 })
18 if (git.exitCode === 0 && !git.isStdoutTruncated) {
19 const [branch, root, directory, common] = git.stdout.trim().split('\n')
20 if (branch && branch !== 'HEAD') facts.branch = branch
21 if (root && directory && common && directory !== common) facts.worktree = root.split('/').pop()
22 }
23 if (facts.repository && facts.branch) {
24 const result = await $.process.run(['gh', 'pr', 'view', facts.branch, '--repo', facts.repository.name, '--json', 'number,url,statusCheckRollup'], { cwd: folder, timeoutMs: 5000 })
25 if (result.exitCode === 0 && !result.isStdoutTruncated) {
26 const pr = JSON.parse(result.stdout)
27 if (Number.isInteger(pr.number) && pr.number > 0 && pr.url === `${facts.repository.page}/pull/${pr.number}`) {
28 const checks = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : []
29 const failed = checks.some((check: any) => ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(check.conclusion ?? check.state))
30 const passed = checks.length > 0 && checks.every((check: any) => ['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(check.conclusion ?? check.state))
31 facts.pr = { number: pr.number, page: pr.url, checks: failed ? 'failing' : passed ? 'passing' : 'pending' }
32 }
33 }
34 }
35 } catch { /* Missing git/GitHub facts stay absent, never fabricated. */ }
36 await $.store.set(factsKey(session), facts)
37}
38
39export function registerSessionFacts(on: On, recorded: boolean) {
40 if (recorded) return
41 const timers = new Map<string, Timer>()
42 on('session.measure', async ($, e, next) => { $.ui.invalidate('ui.render'); return next(e) })
43 on('ui.open', { id: 'session-status' }, async ($, e, next) => {
44 if (e.id === 'session-status') {
45 const session = await $.session.id()
46 await readSessionEnvironment($, session, await $.session.cwd())
47 timers.get(session)?.cancel()
48 timers.set(session, $.clock.every(60000, () => { $.ui.invalidate('ui.render') }))
49 }
50 return next(e)
51 })
52 on('ui.close', { id: 'session-status' }, async ($, e, next) => {
53 if (e.id === 'session-status') {
54 const session = await $.session.id()
55 timers.get(session)?.cancel(); timers.delete(session)
56 }
57 return next(e)
58 })
59}
60
61export type SessionPart = { text: string; action?: string; target?: string; accent?: boolean }
62export type SessionRow = { key: string; parts: SessionPart[]; dim?: boolean }
63// Emoji presentation and wide characters use two cells; text symbols such as ↗ use one.
64export function cells(text: string): number {
65 const characters = [...text]
66 return characters.reduce((n, c, index) => n + (/[\p{Mark}\uFE0F\u200D]/u.test(c) ? 0
67 : /[\p{Emoji_Presentation}\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFF01-\uFF60]/u.test(c)
68 || (characters[index + 1] === '\uFE0F' && /\p{Extended_Pictographic}/u.test(c)) ? 2 : 1), 0)
69}
70export function fit(text: string, width: number) {
71 let result = ''
72 for (const c of text.replace(/[\r\n\t\x00-\x1f\x7f]/g, ' ')) {
73 if (cells(result + c) > width) break
74 result += c
75 }
76 return result
77}
78export function sessionRows(scope: string, id: string, facts: SessionFacts, usage: SessionUsage | undefined, now: number, columns: number, density: 'compact' | 'full', offReason?: string | null): SessionRow[] {
79 if (!Number.isFinite(columns) || columns < 6) return []
80 const width = columns - 4, rows: SessionRow[] = []
81 const add = (key: string, text: string, dim = false) => rows.push({ key, parts: [{ text: fit(text, width) }], dim })
82 const minutes = Math.max(0, Math.floor((now - facts.started) / 60000))
83 const elapsed = minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`
84 const five = usage?.rateLimits.find(r => r.kind === 'five_hour'), week = usage?.rateLimits.find(r => r.kind === 'seven_day')
85 const fittedScope = fit(scope, width)
86 add('scope', fittedScope.length < scope.length ? fit(scope, width - 1) + '…' : fittedScope)
87 const wide = columns >= 56
88 const reset = (at: string | undefined) => {
89 if (!at || !Number.isFinite(Date.parse(at))) return '—'
90 const minutes = Math.max(0, Math.ceil((Date.parse(at) - now) / 60000))
91 return minutes >= 1440 ? `${Math.floor(minutes / 1440)}d ${Math.floor(minutes % 1440 / 60)}h` : minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`
92 }
93 const usageLine = density === 'full' && wide
94 ? `${elapsed} · 5h ${five ? five.percentUsed + '%' : '—'} · reset ${reset(five?.resetsAt)} · weekly ${week ? week.percentUsed + '%' : '—'} · reset ${reset(week?.resetsAt)}`
95 : `${wide ? elapsed : elapsed.replace(' ', '')} · 5h ${five ? five.percentUsed + '%' : '—'} · wk ${week ? week.percentUsed + '%' : '—'}`
96 add('usage', usageLine)
97 const where: SessionPart[][] = [
98 ...(facts.branch ? [[{ text: `🌿 ${facts.branch}` }]] : []),
99 ...(facts.worktree ? [[{ text: `🌳 ${facts.worktree}` }]] : []),
100 ]
101 const repo: SessionPart[][] = facts.repository ? [[{ text: `🔗 ${wide ? facts.repository.name : 'repo'}`, action: 'open-repository', target: facts.repository.page }]] : []
102 const links: SessionPart[][] = [
103 [{ text: `📁 ${wide ? facts.folder : 'folder'}`, action: 'open-folder', target: facts.folder }, { text: ' ' }, { text: '⧉', action: 'copy-folder', target: facts.folder, accent: true }],
104 [{ text: id }, { text: ' ' }, { text: '⧉', action: 'copy-id', target: id, accent: true }],
105 ...(facts.pr ? [[{ text: `PR:#${facts.pr.number}`, action: 'open-pr', target: facts.pr.page, accent: true }, { text: ' ' + (facts.pr.checks === 'passing' ? '✓' : facts.pr.checks === 'failing' ? '✗' : '…') }]] : []),
106 ]
107 const groupWidth = (groups: SessionPart[][]) => groups.flat().reduce((n, p) => n + cells(p.text), 0) + Math.max(0, groups.length - 1) * 3
108 const referenceLines = wide && density === 'compact' && groupWidth([...where, ...repo]) <= width ? [[...where, ...repo], links] : [where, repo, links]
109 // Wrap complete references when possible; long ids and paths retain their action targets.
110 for (const groups of referenceLines) {
111 let parts: SessionPart[] = [], used = 0
112 const flush = () => { if (parts.length) rows.push({ key: `links-${rows.length}`, parts, dim: true }); parts = []; used = 0 }
113 for (const group of groups) {
114 if (used && used + 3 + groupWidth([group]) > width) flush()
115 if (used) { parts.push({ text: ' · ' }); used += 3 }
116 for (const part of group) {
117 let remaining = part.text.replace(/[\r\n\t\x00-\x1f\x7f]/g, ' ')
118 while (remaining) {
119 const piece = fit(remaining, width - used)
120 if (!piece) { flush(); continue }
121 parts.push({ ...part, text: piece }); used += cells(piece); remaining = remaining.slice(piece.length)
122 if (remaining) flush()
123 }
124 }
125 }
126 flush()
127 }
128 if (density === 'full') {
129 if (!wide) for (const [label, limit] of [['5h', five], ['week', week]] as const) if (limit?.resetsAt) add(`reset-${label}`, `${label} resets ${reset(limit.resetsAt)}`, true)
130 add('context', `Context ${usage?.context.tokens?.toLocaleString('en-US') ?? '—'}/${usage?.context.window?.toLocaleString('en-US') ?? '—'}`, true)
131 }
132 if (offReason) add('enrichment-off', offReason, true)
133 return rows
134}
135hooks/workers.ts 202 lines1import { CHOSEN, workerStyle } from './chosen-style'
2import type { CoreEngineInterface, On, Timer } from 'claude-code'
3import type { Worker, ContextReport } from './report'
4import { reportKey } from './report'
5import { factsKey } from './session-facts'
6import type { SessionFacts } from './session-facts'
7import { cells, fit as terminalFit } from './session-facts'
8import type { ReportLine } from './report-layout'
9import type { Part } from './layout'
10
11export type LiveWorker = Omit<Worker, 'tokens' | 'context'> & { id?: string; started: number; changed: number; minutes: Record<number, number>; context?: { tokens: number; window?: number }; inFlight?: boolean }
12type WorkerStore = { observed: LiveWorker[]; merged: LiveWorker[] }
13export const workersKey = (session: string) => `workers:${session}`
14const terminal = (w: Pick<Worker, 'state'>) => w.state === 'done' || w.state === 'failed'
15const timestamp = (value: string | undefined, fallback: number) => value && Number.isFinite(Date.parse(value)) ? Date.parse(value) : fallback
16
17/** Observation owns measurements and identity; reports can fill a missing context window. */
18export function mergeWorkers(observed: LiveWorker[], reported: Worker[], previous: LiveWorker[], now: number, retainExpired = false): LiveWorker[] {
19 const result = observed.map(w => ({ ...w }))
20 for (const report of reported) {
21 const index = result.findIndex(w => w.name === report.name || (w.id === 'main' && report.role === 'main'))
22 if (index >= 0) {
23 const w = result[index]!
24 const context = w.context ? { ...w.context, window: w.context.window ?? report.context?.window } : undefined
25 result[index] = { ...w, context, ...(w.id === 'main' ? { name: report.name, role: report.role } : {}), status: report.status, state: report.state, task: report.task ?? w.task, source: 'both', changed: w.state === report.state ? w.changed : now }
26 } else {
27 const prior = previous.find(w => w.name === report.name)
28 const started = timestamp(report.first_seen, prior?.started ?? now)
29 result.push({ ...report, started, changed: timestamp(report.last_state_change, now), total: report.total ?? (report.tokens ? report.tokens.reduce((a, b) => a + b, 0) : undefined), minutes: Object.fromEntries((report.tokens ?? []).map((tokens, i) => [Math.floor(started / 60000) + i, tokens])), source: 'reported' })
30 }
31 }
32 return result.map(w => {
33 const prior = previous.find(p => (w.id && p.id === w.id) || p.name === w.name)
34 return { ...w, changed: prior?.state === w.state ? prior.changed : w.changed }
35 }).filter((w, index) => retainExpired || index === 0 && w.id === 'main' || !terminal(w) || now - w.changed < 600000)
36}
37
38// Braille columns fill bottom-up: dots 7, 3, 2, 1 on the left and 8, 6, 5, 4 on the right.
39const LEFT_DOTS = [0, 0x40, 0x44, 0x46, 0x47], RIGHT_DOTS = [0, 0x80, 0xa0, 0xb0, 0xb8]
40/** One braille cell holding two minutes; each level is 0 (no minute) to 4. */
41export function brailleCell(left: number, right: number): string {
42 return String.fromCharCode(0x2800 + LEFT_DOTS[left]! + RIGHT_DOTS[right]!)
43}
44
45/** Two minutes per cell; four height steps relative to the busiest visible minute; idle minutes draw the baseline. */
46export function tokenGraph(worker: LiveWorker, width: number, now: number, reduced: boolean): string {
47 const keys = Object.keys(worker.minutes).map(Number)
48 if (!keys.length || width <= 0) return ''
49 const end = Math.floor((terminal(worker) || worker.state === 'waiting' ? worker.changed : reduced ? Math.max(...keys) * 60000 : now) / 60000)
50 const start = Math.max(Math.min(...keys), end - 2 * width + 1)
51 const peak = Math.max(0, ...keys.filter(minute => minute >= start && minute <= end).map(minute => worker.minutes[minute]!))
52 const levels: number[] = []
53 for (let minute = start; minute <= end; minute++) {
54 let level = Math.max(1, peak > 0 ? Math.ceil(4 * (worker.minutes[minute] ?? 0) / peak) : 0)
55 if (!reduced && worker.inFlight && worker.state === 'running' && minute === end) level = Math.max(1, level - 1)
56 levels.push(level)
57 }
58 // The newest minute always takes a cell's right column.
59 if (levels.length % 2) levels.unshift(0)
60 let graph = ''
61 for (let index = 0; index < levels.length; index += 2) graph += brailleCell(levels[index]!, levels[index + 1]!)
62 return graph
63}
64const compact = (n: number) => n >= 1000 ? `${Math.round(n / 100) / 10}k` : String(n)
65const emoji: Record<string, string> = { generating: '🐝', reviewing: '🧐', evaluating: '🔬', testing: '🧪', waiting: '🪑', done: '✅', failed: '❌' }
66export function workerLines(workers: LiveWorker[], columns: number, now: number, reduced: boolean, statusEmoji: Record<string, string> = emoji, native = false): ReportLine[] {
67 const fit = native ? (text: string, _width: number) => text : terminalFit
68 if (!workers.length || columns < 2 || !native && columns < 12) return []
69 const lines: ReportLine[] = [{ key: 'agents', parts: [{ text: ' 🤖 Agents', color: CHOSEN.agents, bold: true }, { text: fit(` · ${workers.filter(w => w.state === 'running').length} running`, Math.max(0, columns - 12)), dim: true, color: CHOSEN.muted }] }]
70 const width = columns - 4
71 for (const [index, worker] of workers.entries()) {
72 // Failure freezes activity; only completed work is dimmed.
73 const key = `worker-${index}`, dim = worker.state === 'done'
74 const palette = workerStyle(worker.state)
75 const accent = palette.accent
76 const style = (parts: Part[]) => parts.map(p => ({ color: accent, ...p, dim }))
77 const badge = fit(columns < 56 ? (worker.model ?? '—').replace(/^claude-/, '') : worker.model ?? '—', Math.max(1, width - 6))
78 const title = fit(`▾ ${worker.role} · ${worker.name}`, Math.max(1, columns - cells(badge) - 11))
79 const suffix = ' ' + '─'.repeat(Math.max(0, columns - cells(title) - cells(badge) - 10)) + ` [${badge}] ─┐`
80 lines.push({ key, prefix: '┌─ ', parts: style([{ text: title, bold: true }]), suffix, badge, dim, accent })
81 const boxed = (id: string, parts: Part[], item?: ReportLine['item']) => lines.push({ key: `${key}-${id}`, prefix: '│ ', parts: style(parts), background: palette.tint, suffix: ' '.repeat(Math.max(0, width - parts.reduce((n, p) => n + cells(p.text), 0))) + ' │', item, dim, accent })
82 boxed('top-space', [])
83 const minutes = Math.max(0, Math.floor(((terminal(worker) ? worker.changed : now) - worker.started) / 60000))
84 const elapsed = minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`
85 const task = worker.task, taskWidth = Math.max(0, width - cells(elapsed) - 1)
86 const parts: Part[] = []
87 if (typeof task === 'object' && task.number !== undefined) parts.push({ text: fit(`#${task.number}`, taskWidth), underline: !!task.link })
88 const used = parts.reduce((n, p) => n + cells(p.text), 0)
89 parts.push({ color: CHOSEN.text, text: fit((used ? ' ' : '') + (typeof task === 'string' ? task : task?.title ?? ''), Math.max(0, taskWidth - used)) })
90 parts.push({ color: CHOSEN.text, bold: true, text: ' '.repeat(Math.max(1, width - parts.reduce((n, p) => n + cells(p.text), 0) - cells(elapsed))) + elapsed })
91 boxed('task', parts, typeof task === 'object' ? { key: `${key}-task`, page: task.link ?? null, details: [task.title, ...(task.number ? [`Number: ${task.number}`] : [])] } : undefined)
92 const percent = worker.context?.window ? Math.round(100 * worker.context.tokens / worker.context.window) : undefined
93 const filled = percent === undefined ? 0 : Math.min(6, Math.max(0, Math.round(percent * 6 / 100)))
94 const gauge = percent === undefined ? '—' : '▰'.repeat(filled) + '▱'.repeat(6 - filled) + ` ${percent}%`
95 const total = worker.total === undefined ? '' : `${compact(worker.total)} tokens`
96 const side = Math.min(width, Math.max(12, cells(total), cells(gauge)))
97 const left = Math.max(0, width - side - 1)
98 const graph = tokenGraph(worker, left, now, reduced)
99 boxed('graph', [{ text: graph + ' '.repeat(Math.max(0, width - cells(graph) - cells(total))) + total }])
100 boxed('status-space', [])
101 const status = fit(`${statusEmoji[worker.status] ?? '◐'} ${worker.status}`, left)
102 boxed('status', [{ bold: true, text: status + ' '.repeat(Math.max(0, width - cells(status) - cells(gauge))) }, { text: gauge, color: percent !== undefined && percent > 80 ? CHOSEN.warning : accent }])
103 lines.push({ key: `${key}-bottom`, parts: style([{ text: '└' + '─'.repeat(columns - 2) + '┘' }]), dim, accent })
104 }
105 return lines
106}
107
108// Serialize store updates per session so concurrent subagent requests cannot lose usage.
109const pending = new Map<string, Promise<unknown>>()
110async function mutate($: CoreEngineInterface, change: (data: WorkerStore, now: number) => void, invalidate = true) {
111 const session = await $.session.id()
112 const previous = pending.get(session) ?? Promise.resolve()
113 const task = previous.catch(() => {}).then(async () => {
114 const data = await $.store.get(workersKey(session)) as WorkerStore | undefined ?? { observed: [], merged: [] }
115 change(data, await $.clock.now())
116 await $.store.set(workersKey(session), data)
117 if (invalidate) $.ui.invalidate('ui.render')
118 })
119 pending.set(session, task)
120 try { await task } finally { if (pending.get(session) === task) pending.delete(session) }
121}
122export async function paneWorkers($: CoreEngineInterface, now: number): Promise<LiveWorker[]> {
123 const session = await $.session.id()
124 let agents: Awaited<ReturnType<typeof $.agent.list>> = []
125 try { agents = await $.agent.list() } catch { /* Spawn hooks still supply workers. */ }
126 let model: string | undefined, usage: Awaited<ReturnType<typeof $.session.usage>> | undefined
127 try { model = await $.session.model() } catch {}
128 try { usage = await $.session.usage() } catch {}
129 const facts = await $.store.get(factsKey(session)) as SessionFacts | undefined
130 const report = await $.store.get(reportKey(session)) as ContextReport | undefined
131 await mutate($, (data, time) => {
132 if (!data.observed.some(w => w.id === 'main')) data.observed.unshift({ id: 'main', name: 'session', role: 'main', status: 'waiting', state: 'waiting', started: facts?.started ?? time, changed: time, minutes: {}, source: 'observed' })
133 const main = data.observed.find(w => w.id === 'main')!
134 main.model = model ?? main.model
135 if (usage?.context.tokens !== undefined) main.context = { tokens: usage.context.tokens, window: usage.context.window }
136 for (const agent of agents) {
137 let w = data.observed.find(w => w.id === agent.id)
138 if (!w) { w = { id: agent.id, name: agent.name ?? agent.id, role: agent.type, task: agent.description, status: 'generating', state: 'running', started: time, changed: time, minutes: {}, source: 'observed' }; data.observed.push(w) }
139 const state = agent.status === 'completed' ? 'done' : ['failed', 'killed'].includes(agent.status) ? 'failed' : agent.status === 'running' ? 'running' : 'waiting'
140 if (w.state !== state) { w.state = state; w.changed = time; w.status = state === 'running' ? 'generating' : state }
141 }
142 // A measured model window is reusable only for the same resolved model id.
143 for (const w of data.observed) if (w.context && w.model === model && usage?.context.window) w.context.window = usage.context.window
144 data.merged = mergeWorkers(data.observed, report?.workers ?? [], data.merged, now, true)
145 }, false)
146 const data = await $.store.get(workersKey(session)) as WorkerStore
147 return data.merged.filter(w => w.id === 'main' || !terminal(w) || now - w.changed < 600000)
148}
149export async function reducedMotion($: CoreEngineInterface): Promise<boolean> {
150 try { const settings = await $.settings.read(); return settings.prefersReducedMotion === true } catch { return false }
151}
152export function registerWorkers(on: On, recorded: boolean) {
153 if (recorded) return
154 const timers = new Map<string, Timer>()
155 on('agent.spawn', async ($, e, next) => {
156 const started = await $.clock.now()
157 const result = await next(e)
158 if (result.agentId) await mutate($, data => {
159 const existing = data.observed.find(w => w.id === result.agentId)
160 const worker: LiveWorker = { ...existing, id: result.agentId, name: e.name ?? result.agentId!, role: e.subagentType, task: e.description, model: result.model, status: 'generating', state: 'running', started, changed: started, minutes: existing?.minutes ?? {}, source: 'observed' }
161 if (existing) Object.assign(existing, worker); else data.observed.push(worker)
162 })
163 return result
164 })
165 on('turn.step', async function* ($, e, next) {
166 const id = e.agentId ?? 'main'
167 await mutate($, (data, now) => {
168 let w = data.observed.find(w => w.id === id)
169 if (!w) { w = { id, name: id === 'main' ? 'session' : id, role: id === 'main' ? 'main' : 'agent', status: 'generating', state: 'running', started: now, changed: now, minutes: {}, source: 'observed' }; id === 'main' ? data.observed.unshift(w) : data.observed.push(w) }
170 w.inFlight = true; w.model = e.model
171 if (w.state !== 'running') w.changed = now
172 w.state = 'running'; w.status = 'generating'
173 })
174 try {
175 const result = yield* next(e)
176 if (result.usage) await mutate($, (data, now) => {
177 const w = data.observed.find(w => w.id === id)!, u = result.usage!
178 const input = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
179 const tokens = input + u.output_tokens, minute = Math.floor(now / 60000)
180 w.minutes[minute] = (w.minutes[minute] ?? 0) + tokens; w.total = (w.total ?? 0) + tokens
181 w.model = u.model; w.context = { tokens: input, window: w.context?.window }
182 })
183 return result
184 } finally { await mutate($, data => { data.observed.find(w => w.id === id)!.inFlight = false }) }
185 })
186 on('turn.complete', async ($, e, next) => {
187 await mutate($, (data, now) => {
188 const w = data.observed.find(w => w.id === (e.agentId ?? 'main'))
189 if (w) { w.state = e.agentId ? e.reason === 'error' || e.reason === 'refusal' ? 'failed' : 'done' : 'waiting'; w.status = w.state; w.changed = now; w.inFlight = false }
190 })
191 return next(e)
192 })
193 on('ui.open', { id: 'session-status' }, async ($, e, next) => {
194 const session = await $.session.id()
195 timers.get(session)?.cancel()
196 if (!await reducedMotion($)) timers.set(session, $.clock.every(60000, () => { $.ui.invalidate('ui.render') }))
197 return next(e)
198 })
199 on('ui.close', { id: 'session-status' }, async ($, e, next) => { const session = await $.session.id(); timers.get(session)?.cancel(); timers.delete(session); return next(e) })
200 on('session.end', async ($, e, next) => { timers.get(e.sessionId)?.cancel(); timers.delete(e.sessionId); return next(e) })
201}
202hooks/report-pane.tsx 284 lines1import { answerStates, discussDecision, inputUnavailableKey, selectAnswer, sendAnswer } from './decision-input'
2import { CHOSEN } from './chosen-style'
3import { atom, read, update } from 'claude-code'
4import type { On, SessionUsage, ButtonProps, ElementChildren, RenderElement } from 'claude-code'
5import { placeCard, OPEN_LABEL, clip } from './layout'
6import { reportLines } from './report-layout'
7import { decisionVersion, reportKey } from './report'
8import { enrichmentKey } from './enrichment'
9import type { EnrichedSnapshot } from './enrichment'
10import type { ContextReport } from './report'
11import { cells, factsKey, sessionRows } from './session-facts'
12import type { SessionFacts } from './session-facts'
13import { paneWorkers, reducedMotion, workerLines } from './workers'
14import { configKey } from './templates'
15import type { Template } from './templates'
16
17export type ReportView = { shown: string | null; expanded: number[]; decision?: string | null }
18export const reportViews = atom({ plugin: 'whatsup', key: 'reportViews' } as const, {} as Record<string, ReportView>)
19
20export function registerReportPane(on: On, recorded: boolean) {
21 if (recorded) return
22 const desktop = new Map<string, { revision: number; input: string; sequence?: number; actions: Map<string, () => unknown> }>()
23 let revision = 0
24 on('ui.message', { component: 'Pane', requestId: 'session-status' }, async ($, e, next) => {
25 if (!['./desktop-pane.tsx', 'hooks/desktop-pane.tsx'].includes(e.module) || e.surface !== 'desktop' || !e.data || typeof e.data !== 'object') return next(e)
26 const session = await $.session.id(), active = desktop.get(session)
27 const data = e.data as { action?: string; revision?: number; sequence?: number; dismiss?: boolean }
28 if (active && data.revision === active.revision && Number.isSafeInteger(data.sequence) && data.sequence! > (active.sequence ?? 0)) {
29 active.sequence = data.sequence
30 if (typeof data.action === 'string') await active.actions.get(data.action)?.()
31 else if (data.dismiss === true) await update($, reportViews, all => ({ ...all, [session]: { ...(all[session] ?? { expanded: [] }), shown: null } }))
32 }
33 return {}
34 })
35 on('ui.message', { component: 'Pane', requestId: 'session-status' }, async ($, e, next) => {
36 if (!['./dismiss.tsx', 'hooks/dismiss.tsx'].includes(e.module) || !(e.data && typeof e.data === 'object' && 'dismiss' in e.data && e.data.dismiss === true)) return next(e)
37 const session = await $.session.id()
38 await update($, reportViews, all => ({ ...all, [session]: { ...(all[session] ?? { expanded: [] }), shown: null } }))
39 return {}
40 })
41 on('ui.render', { component: 'Pane', requestId: 'session-status' }, async ($, e) => {
42 const elements = $.ui.resolve(e)
43 const { Box, Text, Button } = elements
44 if (!('Client' in elements)) return <Text>whatsup requires a terminal or desktop pane.</Text>
45 const { Client } = elements
46 const session = await $.session.id()
47 const report = (await $.store.get(reportKey(session)) ?? null) as ContextReport | null
48 const view = (await read($, reportViews))[session] ?? { shown: null, expanded: [] }
49 const columns = Math.max(1, e.props.bodyColumns)
50 const rows = Math.max(1, e.props.scroll.bodyRows)
51 const enriched = await $.store.get(enrichmentKey(session)) as EnrichedSnapshot | undefined
52 const current = enriched?.input === JSON.stringify(report) ? enriched : undefined
53 const config = await $.store.get(configKey(session)) as Template | undefined
54 const now = await $.clock.now()
55 const facts = await $.store.get(factsKey(session)) as SessionFacts | undefined
56 let usage: SessionUsage | undefined
57 try { usage = await $.session.usage() } catch { /* Unknown measurements render as unavailable. */ }
58 const sessionBox = sessionRows(report?.scope ?? 'No scope yet', session, facts ?? { started: now, folder: await $.session.cwd() }, usage, now, columns, config?.session.density ?? 'compact', current?.off_reason)
59 const workers = await paneWorkers($, now)
60 const lines = [...reportLines(current?.report ?? report, columns, view.expanded, undefined, config, view.decision, await answerStates($, session), !await $.store.get(inputUnavailableKey(session)), e.surface === 'desktop').filter(line => line.key !== 'scope'), ...workerLines(workers, columns, now, config?.motion === false || await reducedMotion($), config?.status_emoji, e.surface === 'desktop')]
61 const rowKeys = ['hide-session-top', 'hide-session-top-space', ...sessionBox.map(row => `session-row-${row.key}`), 'hide-session-bottom',
62 ...lines.map(line => (line.item || line.decision || line.action) ? `line-${line.key}` : `hide-${line.key}`)]
63 const overflowing = rows > 1 && rowKeys.length > rows
64 const change = (next: (current: ReportView) => ReportView) => update($, reportViews, all => ({ ...all, [session]: next(all[session] ?? { shown: null, expanded: [] }) }))
65 const hide = () => change(current => ({ ...current, shown: null }))
66 const show = (key: string) => change(current => ({ ...current, shown: current.shown === key ? null : key }))
67 const expand = (index: number) => change(current => ({ shown: null, expanded: current.expanded.includes(index) ? current.expanded.filter(n => n !== index) : [...current.expanded, index] }))
68 const expandDecision = async (identity: string, version: string) => {
69 const latest = await $.store.get(reportKey(session)) as ContextReport | undefined
70 if (!latest?.decisions?.some(decision => decision.identity === identity && decisionVersion(decision) === version)) return
71 await change(current => ({ ...current, shown: null, decision: current.decision === version ? null : version }))
72 }
73 const quiet = (key: string, parts: { text: string; color?: string; backgroundColor?: string; dimColor?: boolean; bold?: boolean; underline?: boolean }[]) => <Client key={key} module="./dismiss.tsx" width={parts.reduce((n, p) => n + cells(p.text), 0)} height={1} props={{ parts: parts.map(part => Object.fromEntries(Object.entries(part).filter(([, value]) => value !== undefined))) }} />
74 const blank = (key: string, text: string, unavailable = false, color = CHOSEN.text, backgroundColor?: string) => quiet(key, [{ text, dimColor: unavailable, color: unavailable ? CHOSEN.grey : color, ...(backgroundColor ? { backgroundColor } : {}) }])
75 const shownRow = lines.findIndex(line => line.item?.key === view.shown)
76 const shown = shownRow >= 0 ? lines[shownRow]!.item : undefined
77 const card = () => {
78 if (!shown) return null
79 // Text-only cards have no action row; their first line is the supplied title.
80 const box = placeCard(shown.page ? OPEN_LABEL : shown.details[0] ?? '', shown.page ? shown.details : shown.details.slice(1), shownRow + sessionBox.length + 3, e.props.scroll.offset, overflowing ? rows - 1 : rows, columns)
81 const inner = box.width - (box.bordered ? 4 : 0)
82 const left = box.bordered ? '│ ' : ''
83 const right = box.bordered ? ' │' : ''
84 const open = async () => {
85 try {
86 const result = await $.process.run(['open', '--', shown.page!])
87 if (result.exitCode !== 0) $.ui.toast(`Couldn't open ${shown.page}`)
88 } catch { $.ui.toast(`Couldn't open ${shown.page}`) }
89 }
90 return <Box key={`card-${shown.key}`} position="absolute" top={shownRow + sessionBox.length + 3 + box.top} left={0} width={box.width} flexDirection="column">
91 {box.bordered && blank(`hide-card-${shown.key}-top`, '╭' + '─'.repeat(box.width - 2) + '╮')}
92 {box.lines.map((line, index) => index === 0 && shown.page
93 ? <Box key="head" flexDirection="row">
94 {blank(`hide-card-${shown.key}-before`, left)}
95 <Button key={`open-${shown.key}`} label={line} plain onPress={open}>{line}</Button>
96 {blank(`hide-card-${shown.key}-beside`, ' '.repeat(Math.max(0, inner - cells(line))) + right)}
97 </Box>
98 : blank(`hide-card-${shown.key}-${index}`, left + line + ' '.repeat(Math.max(0, inner - cells(line))) + right))}
99 {box.bordered && blank(`hide-card-${shown.key}-bottom`, '╰' + '─'.repeat(box.width - 2) + '╯')}
100 </Box>
101 }
102 const activate = async (action: string, target: string) => {
103 await hide()
104 try {
105 const result = action.startsWith('copy-')
106 ? await $.process.run(['pbcopy'], { stdin: target })
107 : await $.process.run(['open', '--', target])
108 if (result.exitCode !== 0) $.ui.toast(`Couldn't ${action.startsWith('copy-') ? 'copy' : 'open'} ${target}`)
109 } catch { $.ui.toast(`Couldn't ${action.startsWith('copy-') ? 'copy' : 'open'} ${target}`) }
110 }
111 const actionKeys = new Set<string>()
112 const inner = Math.max(1, columns - 4)
113 const offset = e.props.scroll.offset
114 const page = Math.max(1, rows - 1)
115 const more = offset + page < rowKeys.length
116 const scrollPage = async (direction: number) => {
117 await hide()
118 const index = Math.max(0, Math.min(rowKeys.length - 1, offset + direction * page))
119 await $.ui.scroll({ in: 'session-status', to: { key: rowKeys[index]! }, block: 'start' })
120 }
121 const navigation = (key: string, label: string, hotkey: string, direction: number) => {
122 const width = offset > 0 && more ? Math.floor(columns / 2) : columns
123 const shortcut = columns >= 24 ? hotkey : undefined
124 const gap = key === 'pane-previous' && more && columns >= 24 ? ' ' : ''
125 const title = clip(label, Math.max(1, width - (shortcut ? 3 : 0) - gap.length)) + gap
126 return <Button key={key} label={title} plain hotkey={shortcut} onPress={() => scrollPage(direction)}><Text backgroundColor={CHOSEN.ink}>{title}</Text></Button>
127 }
128 if (e.surface === 'desktop') {
129 const actions = new Map<string, () => unknown>()
130 const NativeButton = ({ onPress, children, ...props }: Omit<ButtonProps, 'onPress'> & ElementChildren & { onPress: () => unknown }) => {
131 const key = props.key ?? props.label!
132 actions.set(key, onPress)
133 return { type: 'Button', props: { ...props, key }, children: Array.isArray(children) ? children.flat(Infinity) : children === undefined ? [] : [children] } as RenderElement
134 }
135 // Keep semantic rows and their handlers; the surface owns frame geometry.
136 const clean = (text: string) => text.replace(/[─│┌┐└┘╭╮╰╯]/g, '').replace(/ {2,}/g, ' ').trim()
137 const nativeText = (key: string, text: string, color = CHOSEN.text, dimColor = false, bold = false) =>
138 <Text key={key} color={color} dimColor={dimColor} bold={bold}>{clean(text)}</Text>
139 const nativeParts = (line: typeof lines[number]) => line.parts.map((part, i) =>
140 <Text key={String(i)} color={part.color ?? CHOSEN.text} bold={part.bold} dimColor={part.dim || line.dim || line.unavailable} underline={part.underline}>{(i && !line.key.endsWith('-bar') && clean(part.text) ? ' ' : '') + clean(part.text)}</Text>)
141 const action = (line: typeof lines[number]) => {
142 const a = line.action!
143 return a.kind === 'discuss' ? discussDecision($, session, a.identity, a.version)
144 : a.kind === 'send' ? sendAnswer($, session, a.identity, a.version, a.option!)
145 : selectAnswer($, session, a.identity, a.version, a.kind === 'clear' ? undefined : a.option)
146 }
147 const nativeCard = (line: typeof lines[number]) => {
148 return shown ? <Box key={`card-${shown.key}`} flexDirection="column" borderStyle="round" borderColor={line.accent ?? CHOSEN.text} backgroundColor={line.background ?? CHOSEN.ink} paddingX={1}>
149 {shown.page && <NativeButton key={`open-${shown.key}`} label={OPEN_LABEL} plain onPress={() => activate('open', shown.page!)}>{OPEN_LABEL}</NativeButton>}
150 {shown.details.map((detail, i) => nativeText(`card-detail-${i}`, detail))}
151 <NativeButton key={`close-${shown.key}`} label="Back" plain onPress={hide}>Back</NativeButton>
152 </Box> : null
153 }
154 const nativeRow = (line: typeof lines[number]) => {
155 const key = (line.item || line.decision || line.action) ? `line-${line.key}` : `hide-${line.key}`
156 if (!line.item && !line.decision && !line.action && line.parts.every(part => !clean(part.text))) {
157 return <Box key={key} height={1} />
158 }
159 const separated = /^section-\d+-(description|kind|item-0)$/.test(line.key)
160 let content = nativeParts(line)
161 const workerIndex = /^worker-(\d+)$/.exec(line.key)?.[1]
162 if (workerIndex !== undefined) content = [nativeText('title', `▾ ${workers[Number(workerIndex)]!.role} · ${workers[Number(workerIndex)]!.name}`, line.accent, line.dim, true)]
163 if (line.decision && columns >= 24) content = [nativeText('title', `${view.decision === line.decision.version ? '▾' : '▸'} Decision ${Number(line.key.split('-')[1]) + 1}`, line.accent, false, true)]
164 let body
165 if (line.action) {
166 const a = line.action
167 const base = a.kind === 'select' ? `select-decision-${line.key.split('-')[1]}-${line.key.split('-')[3]}` : `${a.kind}-decision-${line.key.split('-')[1]}`
168 const buttonKey = actionKeys.has(base) ? `${base}-${line.key}` : base
169 actionKeys.add(base)
170 body = <NativeButton key={buttonKey} label={clean(line.parts.map(p => p.text).join(''))} plain hotkey={a.hotkey} onPress={() => action(line)}>{content}</NativeButton>
171 } else if (line.decision) body = <NativeButton key={`expand-${line.key}`} label={clean(line.parts.map(p => p.text).join(''))} plain hotkey={line.decision.hotkey} onPress={() => expandDecision(line.decision!.identity, line.decision!.version)}>{content}</NativeButton>
172 else if (line.item) body = <Box flexDirection="row" flexWrap="wrap" gap={1}>
173 {line.expand !== undefined && <NativeButton key={`expand-${line.key}`} label={line.marker!.trim()} plain onPress={() => expand(line.expand!)}>{line.marker!.trim()}</NativeButton>}
174 <NativeButton key={`show-${line.item.key}`} label={clean(line.parts.map(p => p.text).join(''))} plain onPress={() => show(line.item!.key)}>{content}</NativeButton>
175 </Box>
176 else body = <Box flexDirection="row" flexWrap="wrap" gap={line.key.endsWith('-bar') ? 0 : 1}>{content}</Box>
177 const badge = workerIndex !== undefined ? workers[Number(workerIndex)]!.model ?? '—' : line.badge
178 const row = <Box flexDirection="row" flexWrap="wrap" justifyContent="space-between" gap={1}>
179 {line.item ? <Box key={line.badge ? `native-content-${line.key}` : `hide-${line.key}-rest`}>{body}</Box> : body}
180 {badge ? <Text key={`hide-${line.key}-rest`} bold dimColor={line.dim || line.unavailable} color={workerIndex !== undefined ? CHOSEN.muted : line.dim || line.unavailable ? CHOSEN.grey : CHOSEN.ink} backgroundColor={workerIndex !== undefined || line.dim || line.unavailable ? CHOSEN.badgeBackground : line.accent}>{clean(badge)}</Text>
181 : line.decision ? nativeText(`hide-${line.key}-rest`, line.suffix ?? '', line.accent)
182 : null}
183 </Box>
184 return <Box key={key} flexDirection="column" {...(separated ? { marginTop: 1 } : {})}>{row}{line.item && shown?.key === line.item.key && nativeCard(line)}</Box>
185 }
186 const groups: typeof lines[] = []
187 for (const line of lines) {
188 if (/^(decision|section|worker)-\d+$/.test(line.key)) groups.push([line])
189 else if (/^(decision|section|worker)-\d+-/.test(line.key)) groups[groups.length - 1]!.push(line)
190 else groups.push([line])
191 }
192 const renderInput = JSON.stringify([columns, rows, sessionBox, lines, report?.scope, workers.map(w => [w.name, w.model]), view.shown])
193 const previous = desktop.get(session)
194 const tree = <Box flexDirection="column" width="100%" minHeight={rows} paddingBottom={1} gap={1}>
195 <Box key="desktop-session" flexDirection="column" borderStyle="single" borderColor={CHOSEN.session} backgroundColor={CHOSEN.sessionTint} paddingX={1}>
196 {nativeText('desktop-session-title', 'Session', CHOSEN.session, false, true)}
197 {sessionBox.map(row => <Box key={`session-row-${row.key}`} flexDirection="row" flexWrap="wrap" gap={1}>
198 {row.parts.map((part, i) => {
199 if (!part.action) return nativeText(`hide-session-${row.key}-${i}`, row.key === 'scope' ? report?.scope ?? 'No scope yet' : part.text, CHOSEN.text, row.dim)
200 const base = `session-${part.action}`
201 const key = actionKeys.has(base) ? `${base}-${row.key}-${i}` : base
202 actionKeys.add(base)
203 return <NativeButton key={key} label={clean(part.text)} plain onPress={() => activate(part.action!, part.target!)}><Text color={part.accent ? CHOSEN.session : CHOSEN.text} bold={part.accent} dimColor={row.dim && !part.accent}>{clean(part.text)}</Text></NativeButton>
204 })}
205 </Box>)}
206 </Box>
207 {groups.map(group => {
208 const first = group[0]!
209 const boxed = /^(decision|section|worker)-\d+$/.test(first.key)
210 return boxed ? <Box key={`desktop-${first.key}`} flexDirection="column" borderStyle="single" borderColor={first.accent ?? CHOSEN.text} backgroundColor={group.find(line => line.background)?.background ?? CHOSEN.ink} paddingX={1}>
211 {group.filter(line => !line.key.endsWith('-bottom')).map(nativeRow)}
212 </Box> : nativeRow(first)
213 })}
214 </Box>
215 const currentRevision = previous?.input === renderInput ? previous.revision : ++revision
216 desktop.set(session, { revision: currentRevision, input: renderInput, actions, ...(previous?.revision === currentRevision ? { sequence: previous.sequence } : {}) })
217 return <Client key="desktop-pane" module="./desktop-pane.tsx" width="100%" props={JSON.parse(JSON.stringify({ tree, revision: currentRevision, sequence: desktop.get(session)!.sequence ?? 0 }))} />
218 }
219 return <Box flexDirection="column" width={columns} minHeight={rows}>
220 {quiet('hide-session-top', [{ text: '┌─ ', color: CHOSEN.session }, { text: 'Session', color: CHOSEN.session, bold: true }, { text: ' ' + '─'.repeat(Math.max(0, columns - 12)) + '┐', color: CHOSEN.session }])}
221 {quiet('hide-session-top-space', [{ text: '│', color: CHOSEN.session }, { text: ' '.repeat(Math.max(0, columns - 2)), backgroundColor: CHOSEN.sessionTint }, { text: '│', color: CHOSEN.session }])}
222 {sessionBox.map(row => <Box key={`session-row-${row.key}`} flexDirection="row">
223 {quiet(`hide-session-${row.key}-before`, [{ text: '│', color: CHOSEN.session }, { text: ' ', backgroundColor: CHOSEN.sessionTint }])}
224 {row.parts.map((part, index) => {
225 let key = row.key === 'scope' ? 'hide-scope' : `hide-session-${row.key}-${index}`
226 if (part.action) {
227 const base = `session-${part.action}`
228 key = actionKeys.has(base) ? `${base}-${row.key}-${index}` : base
229 actionKeys.add(base)
230 }
231 if (!part.action) return quiet(key, [{ text: part.text, dimColor: row.dim, color: CHOSEN.text, backgroundColor: CHOSEN.sessionTint }])
232 return <Button key={key} label={part.text} plain onPress={part.action ? () => activate(part.action!, part.target!) : hide}>
233 <Text bold={part.accent} dimColor={row.dim && !part.accent} color={part.accent ? CHOSEN.session : CHOSEN.text} backgroundColor={CHOSEN.sessionTint}>{part.text}</Text>
234 </Button>
235 })}
236 {quiet(`hide-session-${row.key}-after`, [{ text: ' '.repeat(Math.max(0, inner - row.parts.reduce((n, part) => n + cells(part.text), 0)) + 1), backgroundColor: CHOSEN.sessionTint }, { text: '│', color: CHOSEN.session }])}
237 </Box>)}
238 {blank('hide-session-bottom', '└' + '─'.repeat(Math.max(0, columns - 2)) + '┘', false, CHOSEN.session)}
239 {lines.map(line => {
240 const color = line.unavailable ? CHOSEN.grey : line.accent ?? CHOSEN.text
241 const dimColor = line.unavailable || line.dim
242 const parts = line.parts.map(part => ({ text: part.text, color: part.color ?? CHOSEN.text, bold: part.bold, dimColor: part.dim || dimColor, underline: part.underline, backgroundColor: line.background }))
243 const edge = (text: string) => ({ text, color, dimColor, backgroundColor: line.background })
244 const suffix = () => {
245 if (!line.badge) return [edge(line.suffix ?? '')]
246 const [before, after] = line.suffix!.split(`[${line.badge}]`)
247 return [edge(before!), { text: ` ${line.badge} `, backgroundColor: line.key.startsWith('worker-') || line.unavailable || line.dim ? CHOSEN.badgeBackground : color, color: line.key.startsWith('worker-') ? CHOSEN.muted : line.unavailable || line.dim ? CHOSEN.grey : CHOSEN.ink, dimColor, bold: true }, edge(after!)]
248 }
249 if (line.action) {
250 const a = line.action
251 const base = a.kind === 'select' ? `select-decision-${line.key.split('-')[1]}-${line.key.split('-')[3]}` : `${a.kind}-decision-${line.key.split('-')[1]}`
252 const key = actionKeys.has(base) ? `${base}-${line.key}` : base
253 actionKeys.add(base)
254 return <Box key={`line-${line.key}`} flexDirection="row">
255 {quiet(`hide-${line.key}-before`, [edge(line.prefix ?? '')])}
256 <Button key={key} label={line.parts.map(p => p.text).join('')} plain hotkey={a.hotkey} onPress={() => a.kind === 'discuss' ? discussDecision($, session, a.identity, a.version) : a.kind === 'send' ? sendAnswer($, session, a.identity, a.version, a.option!) : selectAnswer($, session, a.identity, a.version, a.kind === 'clear' ? undefined : a.option)}>{parts.map(({ text, ...style }, i) => <Text key={String(i)} {...style}>{text}</Text>)}</Button>
257 {quiet(`hide-${line.key}-rest`, suffix())}
258 </Box>
259 }
260 if (line.decision) return <Box key={`line-${line.key}`} flexDirection="row">
261 {quiet(`hide-${line.key}-before`, [edge(line.prefix ?? '')])}
262 <Button key={`expand-${line.key}`} label={line.parts.map(p => p.text).join('')} plain hotkey={line.decision.hotkey} onPress={() => expandDecision(line.decision!.identity, line.decision!.version)}>{parts.map(({ text, ...style }, i) => <Text key={String(i)} {...style}>{text}</Text>)}</Button>
263 {quiet(`hide-${line.key}-rest`, suffix())}
264 </Box>
265 if (line.item) return <Box key={`line-${line.key}`} flexDirection="row">
266 {line.prefix && quiet(`hide-${line.key}-before`, [edge(line.prefix)])}
267 {line.expand !== undefined && <Button key={`expand-${line.key}`} label={line.marker!} plain onPress={() => expand(line.expand!)}><Text dimColor={dimColor} color={color}>{line.marker}</Text></Button>}
268 <Button key={`show-${line.item.key}`} label={line.parts.map(p => p.text).join('')} plain onPress={() => show(line.item!.key)}>{parts.map(({ text, ...style }, i) => <Text key={String(i)} {...style}>{text}</Text>)}</Button>
269 {quiet(`hide-${line.key}-rest`, suffix())}
270 </Box>
271 return quiet(`hide-${line.key}`, [...(line.prefix ? [edge(line.prefix)] : []), ...parts, ...suffix(), edge(' '.repeat(Math.max(0, columns - cells(line.prefix ?? '') - line.parts.reduce((n, p) => n + cells(p.text), 0) - cells(line.suffix ?? ''))))])
272 })}
273 {Array.from({ length: Math.max(0, rows - lines.length - sessionBox.length - 3) }, (_, index) => blank(`hide-below-${index}`, ' '.repeat(columns)))}
274 {/* One trailing row lets the final content row clear the pinned controls. */}
275 {overflowing && blank('hide-overflow-end', ' '.repeat(columns))}
276 {card()}
277 {overflowing && <Box key="overflow-controls" position="absolute" top={offset + rows - 1} left={0} width={columns} height={1} flexDirection="row" backgroundColor={CHOSEN.ink}>
278 {offset > 0 && navigation('pane-previous', '↑ Back', 'p', -1)}
279 {more && navigation('pane-next', 'More ↓', 'n', 1)}
280 </Box>}
281 </Box>
282 })
283}
284hooks/report.ts 112 lines1/** Public report contract. The interface document reproduces REPORT_SCHEMA verbatim. */
2export type ReportItem = { title: string; number?: number; identity?: string; link?: string; state: 'queued' | 'active' | 'done' }
3export type ReportSection = { label: string; name?: string; verb: string; item_kind: string; description?: string; identity?: string; link?: string; items?: ReportItem[]; fields?: { label: string; value: string }[] }
4export type Worker = { name: string; role: string; status: string; state: 'running' | 'waiting' | 'done' | 'failed'; model?: string; task?: string | ReportItem; first_seen?: string; last_state_change?: string; tokens?: number[]; total?: number; context?: { tokens: number; window: number }; source?: 'observed' | 'reported' | 'both' }
5export type DecisionOption = { identity: string; label: string; benefit: string; cost: string }
6export type Decision = { identity: string; question: string; why_now: string; context: string; options: DecisionOption[]; recommendation: { option: string; reason: string } }
7/** Canonical answer-relevant version, independent of object property insertion order. */
8export const decisionVersion = (decision: Decision): string => JSON.stringify([
9 decision.identity, decision.question, decision.why_now, decision.context,
10 decision.options.map(option => [option.identity, option.label, option.benefit, option.cost]),
11 decision.recommendation.option, decision.recommendation.reason,
12])
13export type ContextReport = { scope: string; decisions?: Decision[]; sections?: ReportSection[]; workers?: Worker[] }
14
15type Schema = { type?: string; properties?: Record<string, Schema>; required?: string[]; additionalProperties?: boolean; items?: Schema; enum?: string[]; minimum?: number; anyOf?: Schema[]; description?: string; minLength?: number; pattern?: string; minItems?: number }
16const string: Schema = { type: 'string' }
17const item: Schema = {
18 type: 'object', additionalProperties: false, required: ['title', 'state'],
19 properties: { title: string, number: { type: 'integer', minimum: 1 }, identity: string, link: string, state: { type: 'string', enum: ['queued', 'active', 'done'] } },
20}
21const requiredText: Schema = { type: 'string', minLength: 1, pattern: '\\S' }
22const decision: Schema = {
23 type: 'object', additionalProperties: false, required: ['identity', 'question', 'why_now', 'context', 'options', 'recommendation'],
24 properties: {
25 identity: requiredText, question: requiredText, why_now: requiredText, context: requiredText,
26 options: { type: 'array', minItems: 2, items: {
27 type: 'object', additionalProperties: false, required: ['identity', 'label', 'benefit', 'cost'],
28 properties: { identity: requiredText, label: requiredText, benefit: requiredText, cost: requiredText },
29 } },
30 recommendation: { type: 'object', additionalProperties: false, required: ['option', 'reason'], properties: { option: requiredText, reason: requiredText } },
31 },
32}
33export const REPORT_SCHEMA: Schema = {
34 type: 'object', additionalProperties: false, required: ['scope'],
35 properties: {
36 scope: { type: 'string', description: 'One line describing what this session is working on.' },
37 decisions: { type: 'array', items: decision },
38 sections: {
39 type: 'array', items: {
40 type: 'object', additionalProperties: false, required: ['label', 'verb', 'item_kind'],
41 properties: {
42 label: string, name: string, verb: string, item_kind: string, description: string, identity: string, link: string,
43 items: { type: 'array', items: item },
44 fields: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['label', 'value'], properties: { label: string, value: string } } },
45 },
46 },
47 },
48 workers: {
49 type: 'array', items: {
50 type: 'object', additionalProperties: false, required: ['name', 'role', 'status', 'state'],
51 properties: {
52 name: string, role: string, status: string, state: { type: 'string', enum: ['running', 'waiting', 'done', 'failed'] },
53 model: string, task: { anyOf: [string, item] }, first_seen: string, last_state_change: string,
54 tokens: { type: 'array', items: { type: 'integer', minimum: 0 } }, total: { type: 'integer', minimum: 0 },
55 context: { type: 'object', additionalProperties: false, required: ['tokens', 'window'], properties: { tokens: { type: 'integer', minimum: 0 }, window: { type: 'integer', minimum: 1 } } },
56 source: { type: 'string', enum: ['observed', 'reported', 'both'] },
57 },
58 },
59 },
60 },
61}
62export const REPORT_TOOL = 'mcp__whatsup__report'
63export const REPORT_DESCRIPTION = 'Replace this session’s entire context report: scope text; optional ordered sections (label, name, verb, item_kind, description, items, fields); items (title, number, identity, link, queued/active/done state), each exactly one unit of its section’s item_kind that belongs to that section (one ticket, never a group of tickets or work from another container), with groupings and work steps in the queue section; optional workers (name, role, status, state, model, task, times and token usage). Minimum: {"scope":"What I am working on"}. Omitted sections, workers and decisions clear the previous lists. Decisions require stable identity, question, why_now, answer-sufficient context, at least two options (identity, label, benefit, cost), and recommendation (option identity, reason). Retain outstanding decisions in every complete report; update missing context during discussion and remove only resolved or withdrawn decisions, including resolutions from custom answers in chat. Discussion requests leave decisions pending; submission acceptance is not resolution. Keep identities stable across unchanged reports; changed question, context, options or recommendation creates a new version. Validation checks structure and nonempty text, not prose truth or sufficiency. This is display state, not authorization.'
64export const reportKey = (session: string) => `context-report:${session}`
65
66/** The engine spreads a tool's arguments beside its own keys on the `tool.call` event; this keeps only the arguments. */
67export function reportArguments(event: object): Record<string, unknown> {
68 const { tool: _tool, tool_use_id: _call, agentId: _agent, requestMeta: _meta, consent: _consent, ...input } = event as Record<string, unknown>
69 return input
70}
71
72/** Validate against the same JSON schema registered with the model, before any write. */
73export function reportError(value: unknown, schema: Schema = REPORT_SCHEMA, path = 'report'): string | null {
74 if (schema.anyOf) return schema.anyOf.some(branch => reportError(value, branch, path) === null) ? null : `${path}: expected text or an item`
75 const kind = schema.type
76 if (kind === 'object') {
77 if (value === null || typeof value !== 'object' || Array.isArray(value)) return `${path}: expected object`
78 const record = value as Record<string, unknown>
79 for (const field of schema.required ?? []) if (!Object.hasOwn(record, field)) return `${path}.${field}: required`
80 for (const [key, entry] of Object.entries(record)) {
81 const child = schema.properties && Object.hasOwn(schema.properties, key) ? schema.properties[key] : undefined
82 if (!child && schema.additionalProperties === false) return `${path}.${key}: unknown field`
83 if (child) { const error = reportError(entry, child, `${path}.${key}`); if (error) return error }
84 }
85 } else if (kind === 'array') {
86 if (!Array.isArray(value)) return `${path}: expected array`
87 if (value.length < (schema.minItems ?? 0)) return `${path}: expected at least ${schema.minItems} entries`
88 for (let index = 0; index < value.length; index++) {
89 const error = reportError(value[index], schema.items!, `${path}[${index}]`)
90 if (error) return error
91 }
92 } else if (kind === 'string' && typeof value !== 'string') return `${path}: expected string`
93 else if (kind === 'integer' && (!Number.isInteger(value) || (value as number) < (schema.minimum ?? -Infinity))) return `${path}: expected integer >= ${schema.minimum ?? 0}`
94 if (kind === 'string' && typeof value === 'string' && schema.pattern && !new RegExp(schema.pattern).test(value)) return `${path}: expected nonblank text`
95 if (schema === REPORT_SCHEMA) {
96 const identities = new Set<string>()
97 for (const [index, entry] of ((value as ContextReport).decisions ?? []).entries()) {
98 const at = `${path}.decisions[${index}]`
99 if (identities.has(entry.identity)) return `${at}.identity: duplicate decision identity`
100 identities.add(entry.identity)
101 const options = new Set<string>()
102 for (const [optionIndex, option] of entry.options.entries()) {
103 if (options.has(option.identity)) return `${at}.options[${optionIndex}].identity: duplicate option identity`
104 options.add(option.identity)
105 }
106 if (!options.has(entry.recommendation.option)) return `${at}.recommendation.option: absent option`
107 }
108 }
109 if (schema.enum && !schema.enum.includes(value as string)) return `${path}: expected ${schema.enum.join(', ')}`
110 return null
111}
112hooks/templates.ts 76 lines1import type { ContextReport } from './report'
2
3export type SectionDefinition = { label: string; verb: string; item_kind: string; emoji: string; description: boolean; collapsed: boolean }
4export type Template = { template: string; sections: Record<string, SectionDefinition>; section_order: string[]; status_emoji: Record<string, string>; session: { density: 'compact' | 'full' }; motion: boolean; instruction: boolean }
5const queue: SectionDefinition = { label: 'Queue', verb: 'finished', item_kind: 'tasks', emoji: '📋', description: false, collapsed: true }
6const preferences = {
7 status_emoji: { generating: '🐝', reviewing: '🧐', evaluating: '🔬', testing: '🧪', waiting: '🪑', done: '✅', failed: '❌' },
8 session: { density: 'compact' as const }, motion: true, instruction: true,
9}
10export const TEMPLATES: Record<string, Template> = {
11 github: { ...preferences, template: 'github', section_order: ['project', 'milestone', 'epic', 'queue'], sections: {
12 project: { label: 'Project', verb: 'achieved', item_kind: 'milestones', emoji: '🎯', description: true, collapsed: true },
13 milestone: { label: 'Milestone', verb: 'shipped', item_kind: 'epics', emoji: '⛰️', description: true, collapsed: true },
14 epic: { label: 'Epic', verb: 'closed', item_kind: 'tickets', emoji: '🚀', description: true, collapsed: true }, queue,
15 } },
16 'task-list': { ...preferences, template: 'task-list', section_order: ['queue'], sections: { queue } },
17 bare: { ...preferences, template: 'bare', section_order: ['queue'], sections: { queue: { ...queue, emoji: '' } } },
18}
19function object(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === 'object' && !Array.isArray(value) }
20function merge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
21 const result = structuredClone(base)
22 for (const [key, value] of Object.entries(override)) {
23 if (['__proto__', 'constructor', 'prototype'].includes(key)) throw new Error(`Invalid configuration key: ${key}`)
24 result[key] = object(value) && object(result[key]) ? merge(result[key], value) : structuredClone(value)
25 }
26 return result
27}
28function validate(value: unknown, shape: unknown, path = 'config') {
29 if (object(shape)) {
30 if (!object(value)) throw new Error(`${path}: expected object`)
31 for (const [key, entry] of Object.entries(value)) {
32 const child = path === 'config.sections' ? queue : path === 'config.status_emoji' ? '' : shape[key]
33 if (child === undefined) throw new Error(`${path}.${key}: unknown field`)
34 validate(entry, child, `${path}.${key}`)
35 }
36 } else if (Array.isArray(shape)) {
37 if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) throw new Error(`${path}: expected string list`)
38 } else if (typeof value !== typeof shape) throw new Error(`${path}: expected ${typeof shape}`)
39}
40/** Precedence: built-in template, user overrides, repository overrides. Arrays replace. */
41export function resolveConfig(user: unknown = {}, repository: unknown = {}): Template {
42 for (const file of [user, repository]) validate(file, TEMPLATES.github)
43 const overrides = merge(user as Record<string, unknown>, repository as Record<string, unknown>)
44 const name = overrides.template ?? 'github'
45 if (typeof name !== 'string' || !Object.hasOwn(TEMPLATES, name)) throw new Error(`config.template: unknown template ${name}`)
46 const resolved = merge(TEMPLATES[name] as unknown as Record<string, unknown>, overrides) as unknown as Template
47 if (!['compact', 'full'].includes(resolved.session.density)) throw new Error('config.session.density: expected compact or full')
48 if (new Set(resolved.section_order).size !== resolved.section_order.length) throw new Error('config.section_order: duplicate section')
49 for (const key of resolved.section_order) {
50 if (!Object.hasOwn(resolved.sections, key)) throw new Error(`config.section_order: unknown section ${key}`)
51 for (const field of Object.keys(queue)) if (!(field in resolved.sections[key])) throw new Error(`config.sections.${key}.${field}: required`)
52 }
53 return resolved
54}
55export const configKey = (session: string) => `template-config:${session}`
56export function sessionInstruction(config: Template): string {
57 if (!config.instruction) return ''
58 const sections = config.section_order.map(key => {
59 const { label, verb, item_kind } = config.sections[key]
60 return { label, verb, item_kind }
61 })
62 const descriptions = config.section_order.filter(key => config.sections[key].description).map(key => config.sections[key].label)
63 const queueInstruction = config.section_order.includes('queue')
64 ? ` Queue is the session's running work log: every piece of work in order, including unticketed and newly discovered work; move waiting work to queued behind its dependency. Groups go in Queue.`
65 : ''
66 const instruction = `mcp__whatsup__report: "report your context"; task starts/finishes, scope shifts, ticket, milestone or decision is created. Replace scope/sections/workers/decisions. Sections: copy verbatim label/verb/item_kind:
67${JSON.stringify(sections)}
68Descriptions: ${JSON.stringify(descriptions)}. Section name/items (one item_kind each, never groups): title/state queued/active/done/number/identity/link. Workers (any means): name/role/status/state/model/task. Retain outstanding decisions: answer-sufficient context/tradeoffs/recommendation, stable identities; update discussion context; remove resolved/withdrawn, including chat answers. Details: tool description.${queueInstruction} Display state only.`
69 // Arbitrary user labels must never produce an over-budget startup instruction.
70 if (instruction.split(/\s+/).length >= 120) throw new Error('config: reporting instruction must stay under 120 words; shorten section labels or reduce sections')
71 return instruction
72}
73export function initialExpanded(config: Template, report: ContextReport | null): number[] {
74 return (report?.sections ?? []).flatMap((section, index) => Object.values(config.sections).some(def => def.label === section.label && !def.collapsed) ? [index] : [])
75}
76hooks/snapshot.ts 87 lines1// The read module's snapshot shape (docs/read-module.md), as far as the pane reads it.
2
3export type Section<T> = {
4 state: 'present' | 'hidden' | 'unavailable'
5 value: T | null
6 reason: string | null
7}
8
9export type NotRecorded = 'Not recorded'
10
11export type Scope = {
12 text: string
13 authority: string | null
14 reference: string | null
15 label: string | null
16 repository: string | null
17}
18
19export type Project = {
20 identity: string | null
21 name: string
22 scope: string | null
23 progress_sets: string[]
24 page: string | null
25}
26
27export type Milestone = {
28 identity: string
29 name: string
30 objective: string
31 page: string | null
32}
33
34export type SetCard = {
35 identity: string
36 name?: string
37 objective?: string
38 revision?: number
39 closed: number
40 total: number
41 blockers?: string[] | NotRecorded
42 page: string | null
43}
44
45export type ProgressSet = {
46 kind: 'milestone' | 'epic' | 'slate'
47 identity: string
48 revision: number | null
49 closed: number
50 total: number
51 card: Section<SetCard>
52}
53
54export type TicketCard = {
55 requested_outcome: string
56 acceptance_criteria: string[] | NotRecorded
57 status: { state: string; labels: string[] | NotRecorded }
58 blockers: string[] | NotRecorded
59 page: string | null
60}
61
62export type Row = {
63 identity: string
64 owner: string
65 repository: string
66 number: number
67 title: string
68 different_repository: boolean | null
69 card: Section<TicketCard>
70}
71
72export type Snapshot = {
73 scope: Section<Scope>
74 project: Section<Project>
75 milestone: Section<Milestone>
76 progress: { milestone: Section<ProgressSet>; epic: Section<ProgressSet>; slate: Section<ProgressSet> }
77 in_progress: Section<{ rows: Row[]; message: string | null }>
78}
79
80export type SnapshotEntry = {
81 name: string
82 fixture: string
83 case: string | null
84 purpose: string
85 snapshot: Snapshot
86}
87hooks/chosen-style.ts 30 lines1import type { Worker } from './report'
2
3// Shared palette for terminal lines and Desktop native boxes.
4export const CHOSEN = {
5 text: '#d8d8d8', muted: '#a0a0a0', faint: '#5e5e5e', grey: '#808080', ink: '#1a1a1a',
6 session: '#9fd3e8', sessionTint: '#1f2a30', success: '#7ddc8a', warning: '#ffcf5a',
7 agents: '#e39b3a', decision: '#ff7b86', badgeBackground: '#3a3a3a',
8}
9export const DONE_GRADIENT = ['#5fbf74', '#c4f2b8']
10export const SECTION_STYLES = [
11 { accent: '#6ad4a8', tint: '#1b2e27', gradient: ['#2fa87a', '#b8f0d8'] },
12 { accent: '#5aa9ff', tint: '#1c2636', gradient: ['#2f7fe0', '#b8d6ff'] },
13 { accent: '#b6e05a', tint: '#262e1c', gradient: ['#86b832', '#e2f5b8'] },
14 { accent: '#58c8d8', tint: '#1b2c30', gradient: ['#2ea2b5', '#b8ecf3'] },
15]
16const WORKER_STYLES = {
17 running: { accent: CHOSEN.agents, tint: '#302518' },
18 waiting: { accent: '#ececec', tint: undefined },
19 failed: { accent: CHOSEN.decision, tint: '#33222a' },
20 done: { accent: CHOSEN.grey, tint: undefined },
21}
22export function gradient(from: string, to: string, index: number, length: number): string {
23 const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
24 const a = rgb(from), b = rgb(to), fraction = length > 1 ? index / (length - 1) : 0
25 return '#' + a.map((v, i) => Math.round(v + (b[i]! - v) * fraction).toString(16).padStart(2, '0')).join('')
26}
27export function workerStyle(state: Worker['state']) {
28 return WORKER_STYLES[state]
29}
30