SLOPSHOPPER

whatsup

Agent-reported session scope and progress sections

paneguardcommandtoastprompttool
v0.2.2Apache-2.0updated 2026-10-24coreyfloyd/whatsup/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · whatsup
│ ┃ Session Status ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx ⏺ Read(src/auth.ts) │ ┃ ▣ client module ./dismiss.tsx▣ client module ⎿ Read 6 lines │ ┃ ▣ client module ./dismiss.tsx▣ client module ⏺ Update(src/auth.ts) │ ┃ ▣ client module ./dismiss.tsx▣ client module ⎿ Added 2 lines, removed 1 line │ ┃ ▣ client module ./dismiss.tsx📁 /work/app▣ c ⏺ Bash(bun test) │ ┃ ▣ client module ./dismiss.tsx ⎿ 3 pass, 1 fail │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx ✻ Worked for 42s · done 4:20 PM │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx › /whatsup │ ┃ ▣ client module ./dismiss.tsx ⎿ whatsup: whatsup opened; asked the agent for a fresh report. │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx │ ┃ ▣ client module ./dismiss.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session Status
▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx▣ client module ./dismiss.tsx▣ ▣ client module ./dismiss.tsx▣ client module ./dismiss.tsx▣ ▣ client module ./dismiss.tsx▣ client module ./dismiss.tsx▣ ▣ client module ./dismiss.tsx📁 /work/app▣ client module ./d ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx ▣ client module ./dismiss.tsx
README

whatsup

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.

Chosen pane design with illustrative data

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.

Installation and configuration

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.

How the agent reports

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.

Architecture

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.

Documents

Compatibility

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.

License

Apache 2.0. Author: Corey Floyd. See LICENSE.

Source 16 files
hooks/register.tsx 204 lines
1import { 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}
204
hooks/decision-input.ts 102 lines
1import 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}
102
hooks/layout.ts 262 lines
1// 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}
262
hooks/snapshots.ts 6875 lines
1// 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 lines
1import 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}
98
hooks/session-facts.ts 135 lines
1import 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}
135
hooks/workers.ts 202 lines
1import { 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}
202
hooks/report-pane.tsx 284 lines
1import { 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}
284
hooks/report.ts 112 lines
1/** 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}
112
hooks/templates.ts 76 lines
1import 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}
76
hooks/snapshot.ts 87 lines
1// 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}
87
hooks/chosen-style.ts 30 lines
1import 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