Next up: open threads and today's plan, plus this session's turn, permissions, helpers, files touched and log, in one pane.

A Claude Code plugin with a pane beside the chat: what to do next, and what this session has done. MIT licensed.
In Claude Code:
/plugin marketplace add darrentmorgan/nextup
/plugin install nextup@nextup
The pane opens when a session starts. Use /nextup to reopen it. No board, GitHub account, external pipeline or worker skill is required for the Session tab.
To try a board, copy the synthetic example data folder to a writable folder you own:
mkdir -p "$HOME/nextup-demo"
cp examples/dataDir/*.json "$HOME/nextup-demo/"
Set the plugin option in ~/.claude/settings.json, then start a new session:
{
"pluginConfigs": {
"nextup@nextup": {
"options": { "dataDir": "~/nextup-demo" }
}
}
}
The example calendar has a fixed date; change it to today's local date to see its events. See examples/README.md and the schema to supply your own data.
dataDir): Next, work, notes, planning, marks and wins. Next puts the next step first, followed by Needs you and Later today. The Band offers a compact next step and visible action feedback.With no dataDir, template mode shows Session only and reads or writes no board files. The board's source files are threads.json and notes.json; calendar and drafts are optional. This plugin consumes a board, rather than collecting your projects or generating the board itself.
| Integration | Enable with | Behavior / limitation |
|---|---|---|
| Board refresh | Your own build.py in dataDir | /nextup-refresh can rebuild the board; a static example needs no producer. |
| GitHub PRs | Authenticated gh CLI and dataDir | Reads your open PRs every ten minutes; never runs in template mode. |
| Desktop sessions | Claude desktop session service | Needs you and Open session links depend on this service; unavailable surfaces hide them. |
| Worker dispatch | dispatchDir or DISPATCH_ROOT | Session shows worker lanes and a Fast switch. The switch writes a fast file for compatible Codex/pi workers, not the Claude session. |
| Skill launches | Matching thread/PR context and available skills | Launches wait ten seconds with Undo, with a shared five-per-local-day cap. The optional darren-core:worker-dispatch skill is used when installed; otherwise Delegate copies a plain instruction. |
| Calendar and drafts | calendar.json, drafts.json | Read-only summaries; the plugin does not send drafts. |
| Attention | interruptions.json | Local interruption summaries and ratings; hidden when absent. |
| Sentinel | External checker producing sentinel.json and sentinel-state.json | Evidence and action prompts; absence and stale checks are shown explicitly. The checker is not required. |
| Reload helper | reloadHelper script path | Runs python3 <path> --plugin nextup@nextup --json, expecting plugins and needs_restart. Without it, updates plugins and asks you to run /reload-plugins. |
| Worker questions | MCP form elicitation hook and dataDir | Local question/answer ledger; see below. Plain-chat questions are not covered. |
openOnStart defaults to true; set it to false for manual opening. dataDir accepts a leading ~. dispatchDir and reloadHelper are optional paths; neither is needed for core use.
An MCP form elicitation can appear as a One question card with Send answer and Skip. The hook writes asks.json, polls answers.json, and cancels unsupported or secret-shaped asks. URL-mode asks are declined. Safe saved boolean/enum rules can answer automatically; sensitive actions never match a rule. The default timeout is twenty minutes (NEXTUP_ASK_TIMEOUT in seconds). Only a safe ask may use its schema default on timeout; otherwise it cancels. Errors cancel. NEXTUP_ASK_OFF=1 restores Claude's normal prompt route.
The hook requires Node 22.18+ or 23.6+ to load TypeScript directly. With no board it steps aside. Fallbacks: /nextup ask, /nextup answer <id> <choice>, or scripts/nextup-answer <id> <value>. See the worker snippet and plugin/hooks/elicit/demo/.
Verification limits: question cards and Sentinel have harness coverage, not a verified desktop tap walkthrough. Elicitation was exercised through claude -p; interactive/desktop hook delivery and Select/Input taps remain unproven.
The session activity log is held in memory, not persisted or uploaded by Next up. Board actions do persist in your configured folder: marks, the daily plan, notes, launch reservations/history, press outcomes, interruption ratings, and the question/answer/rule ledger. Launch history retains up to 500 launches and evaluates their 48-hour outcomes. Press outcomes append to mod-events.jsonl, rotated past 1 MB. Optional integrations can run local commands, query GitHub or open sessions; enabling them is not an offline guarantee.
Keep real boards and credentials outside this repository. All supplied examples are synthetic. docs/PUBLIC-FILES.txt is the publication allowlist, not an automatic export tool.
NEXTUP_TYPES=/path/to/generated/types scripts/check.sh
The engine generates the types after hot-loading the plugin. Alternatively put them in ~/.cache/nextup/types. The check type-checks, validates and tests the plugin, including press-handler linting and privacy scanning. scripts/privacy-scan.sh also checks board titles when NEXTUP_DATA points to a board.
See CHANGELOG.md for merged-PR history and docs/SCHEMA.md for the data contract.
MIT; see LICENSE.
hooks/register.tsx 2404 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput, UiPressArgument } from 'claude-code'
3
4import { attentionSparkline, createAttention, drawnAttention, rateAttention, refreshAttention, projectFlag } from './attention'
5import { drawActivity, recordActivity } from './activity'
6import { polledSessions, rankedNeeds, loadNeeds, NEEDS_EVERY_MS, openSession, resetNeeds, waitFor } from './needs'
7import { ElicitCard, initialValues } from './elicit/ElicitCard'
8import { latest, sortQueue, sleptStats, stateLine } from './elicit/model'
9import { askCommand, readLedger, revokeRule, sendAnswer } from './elicit/store'
10import type { Ask, Answer, Rule, LogRow } from './elicit/model'
11import { refreshDispatch, REFRESH_MS, setDispatchRoot } from './dispatch'
12import { reloadMessage, reloadView, type ReloadState } from './reload'
13import { sessionToken, stallKey, workOf } from './work'
14import type { SessionRow, Work } from './work'
15import { act, view, late, beat, bindEvents, Declined, feedbackView } from './events'
16import type { Done } from './events'
17import { appliedSince, missingSince } from './applog'
18import { ageChip, parseSentinel, stripView } from './sentinel'
19import type { Card } from './sentinel'
20import { momentumStats, WIN_GOAL, winsRing } from './momentum'
21import { delegateThreads, launchLabel, stageHint, stageOf } from './stage'
22import type { Stage, StagePR } from './stage'
23import { desk, humanize, momentum, surfaceTheme, tone } from './theme'
24import { done, drop, park, undo, readMarks, filesOf, MarksUnreadable, isHidden, isParked, isKept, doneBetween } from './marks'
25import type { Board, Calendar, Need, CalEvent, Drafts, Marks, Note, Plan, PR, SentinelRead, Thread, Feedback } from '../types'
26
27// Accept M1's presentation fields, including an explicit unknown progress value.
28// Older board rows carry the same evidence in minutes/progress.
29const rowPresentation = (t: Thread) => {
30 const row = t as Thread & { progressValue?: number; estimateText?: string }
31 return {
32 progressValue: 'progressValue' in row ? row.progressValue :
33 typeof t.progress === 'number' && Number.isFinite(t.progress) && t.progress >= 0 && t.progress <= 100 ? t.progress : undefined,
34 estimateText: row.estimateText ?? (typeof t.minutes === 'number' && Number.isFinite(t.minutes) && t.minutes >= 0 ? `${t.minutes} min estimated` : 'estimate unknown'),
35 }
36}
37
38// Next up: what to do next and what this session has done, in one pane.
39// The board comes from a data folder (the dataDir option): threads.json and notes.json from
40// a pipeline outside this mod, plus mod-marks.json, today.json, calendar.json and drafts.json.
41// With no data folder the board is hidden and the pane shows this session's activity alone.
42
43const PANE = 'nextup'
44const TITLE = 'Next up'
45const BUILD_EVERY_MS = 30 * 60 * 1000
46const PRS_EVERY_MS = 10 * 60 * 1000
47const SETTLE_MS = 4000
48type Settling = Record<string, number>
49const settling = atom({ plugin: 'nextup', key: 'settling' } as const, {} as Settling)
50const readSettling = async ($: E) => await read($, settling)
51const updateSettling = ($: E, fn: (s: Settling) => Settling) => update($, settling, fn)
52
53const board = atom({ plugin: 'nextup', key: 'board' } as const, null)
54const marks = atom({ plugin: 'nextup', key: 'marks' } as const, {})
55const bandHidden = atom({ plugin: 'nextup', key: 'bandHidden' } as const, false)
56const building = atom({ plugin: 'nextup', key: 'building' } as const, false)
57const momentumMinute = atom({ plugin: 'nextup', key: 'momentumMinute' } as const, 0)
58const pluginReload = atom({ plugin: 'nextup', key: 'pluginReload' } as const, { running: false, message: '', failed: false, at: 0 } as ReloadState)
59const tab = atom({ plugin: 'nextup', key: 'tab' } as const, 'focus')
60// What sentinel.json and sentinel-state.json said at the last read (sentinel.ts); the result of the last press is the feedback atom (events.ts).
61const sentinel = atom({ plugin: 'nextup', key: 'sentinel' } as const, null as SentinelRead | null)
62const feedback = atom({ plugin: 'nextup', key: 'feedback' } as const, null as Feedback)
63// What the last press on a card did, drawn under its buttons: pending, done, or why it failed.
64type CardNote = { id: string; kind: 'pending' | 'ok' | 'fail'; text: string } | null
65const cardNote = atom({ plugin: 'nextup', key: 'cardNote' } as const, null as CardNote)
66const openRow = atom({ plugin: 'nextup', key: 'open' } as const, null)
67// Also declared in activity.tsx: the engine's state scan reads only atoms made in the module that uses them.
68const more = atom({ plugin: 'nextup', key: 'more' } as const, [])
69// The dev seed below writes the session log and files; activity.tsx owns the same keys.
70const logAtom = atom({ plugin: 'nextup', key: 'log' } as const, [])
71const filesAtom = atom({ plugin: 'nextup', key: 'files' } as const, [])
72const log = atom({ plugin: 'nextup', key: 'log' } as const, [])
73const plan = atom({ plugin: 'nextup', key: 'plan' } as const, { date: '', ids: [] } as Plan)
74const prs = atom({ plugin: 'nextup', key: 'prs' } as const, null)
75const cal = atom({ plugin: 'nextup', key: 'cal' } as const, null)
76const drafts = atom({ plugin: 'nextup', key: 'drafts' } as const, null)
77const problem = atom({ plugin: 'nextup', key: 'problem' } as const, null)
78const prsProblem = atom({ plugin: 'nextup', key: 'prsProblem' } as const, null)
79const loaded = atom({ plugin: 'nextup', key: 'loaded' } as const, false)
80const workerFast = atom({ plugin: 'nextup', key: 'workerFast' } as const, null)
81const confirm = atom({ plugin: 'nextup', key: 'confirm' } as const, null)
82const needs = atom({ plugin: 'nextup', key: 'needs' } as const, null)
83const needsProblem = atom({ plugin: 'nextup', key: 'needsProblem' } as const, null)
84/** Sessions stopped on a permission prompt, from the permission-wait hook's files in the data folder. */
85type PermWait = { id: string; title: string; tool: string; since: number }
86const permWaits = atom({ plugin: 'nextup', key: 'permWaits' } as const, [] as PermWait[])
87const attentionAll = atom({ plugin: 'nextup', key: 'attentionAll' } as const, false)
88const launches = atom({ plugin: 'nextup', key: 'launches' } as const, {})
89const handoffReady = atom({ plugin: 'nextup', key: 'handoff' } as const, null)
90const ledger = atom({ plugin: 'nextup', key: 'elicit-ledger' } as const, { asks: [] as Ask[], answers: [] as Answer[], rules: [] as Rule[], log: [] as LogRow[] })
91const askDrafts = atom({ plugin: 'nextup', key: 'elicit-drafts' } as const, {} as Record<string, { picked: Record<string, unknown>; remember: boolean }>)
92
93async function refreshLedger($: E) {
94 const next = await readLedger($, await dirOf($))
95 // Write only on a change: a write redraws the pane, and a redraw can read the board again.
96 if (JSON.stringify(await read($, ledger)) !== JSON.stringify(next)) await update($, ledger, () => next)
97}
98// Hosts expose command arguments as a string or a word array.
99function commandArgs(event: unknown): string {
100 const args = (event as { args?: unknown }).args
101 return typeof args === 'string' ? args : Array.isArray(args) ? args.join(' ') : ''
102}
103
104type E = EngineInterface
105
106
107/** The helper is scoped to Next up; it must never inject keystrokes into sessions. */
108async function reloadPlugins($: E): Promise<Done> {
109 let started = false
110 const t0 = await boardNow($, await read($, marks))
111 await update($, pluginReload, state => {
112 started = !reloadView(state, t0).running
113 if (!started) return state
114 return { running: true, message: 'Reloading plugins...', failed: false, at: t0 }
115 })
116 if (!started) throw new Declined('A reload is already running.')
117 let step = 'marketplace update'
118 try {
119 const run = async (argv: string[]) => {
120 const result = await $.process.run(argv, { timeoutMs: 180_000 })
121 if (result.exitCode !== 0) throw new Error((result.stderr || result.stdout || `exit ${result.exitCode}`).slice(0, 240))
122 return result.stdout
123 }
124 await run(['claude', 'plugin', 'marketplace', 'update', 'nextup'])
125 step = 'plugin update'
126 await run(['claude', 'plugin', 'update', 'nextup@nextup'])
127 // Optional in-place reload helper (the reloadHelper option). Without one, or when its file is missing, the
128 // two commands above are the whole generic reload and open sessions take /reload-plugins themselves.
129 const helper = await homePath($, reloadHelper)
130 if (!helper || !(await $.fs.exists(helper))) {
131 const message = 'Plugin updated. Type /reload-plugins in your open sessions.'
132 const at = await boardNow($, await read($, marks))
133 await update($, pluginReload, () => ({ running: false, message, failed: false, at }))
134 $.ui.toast(message)
135 return message
136 }
137 step = 'session reload'
138 const result = JSON.parse(await run(['python3', helper, '--plugin', 'nextup@nextup', '--json'])) as {
139 plugins?: { update_error?: string; before?: string; after?: string }[]; reloaded?: string[]; reloaded_by_app?: string[]; pending_app_reload?: string[]; needs_restart?: string[]; list_error?: string
140 }
141 if (result.list_error || !Array.isArray(result.plugins) || !result.plugins.length || !Array.isArray(result.needs_restart) || !result.needs_restart.every(id => typeof id === 'string'))
142 throw new Error(result.list_error || 'Unrecognized reload report')
143 const failed = result.plugins.find(p => p.update_error)?.update_error
144 if (failed) throw new Error(failed)
145 // The helper says what it sent; only the app's own log says which sessions reloaded (census 3 and 16).
146 const log = await readMainLog($)
147 const applied = log === null ? null : appliedSince(log, t0)
148 const message = reloadMessage({ ...result, version: result.plugins[0]?.after, already_current: !!result.plugins[0]?.before && result.plugins[0]?.before === result.plugins[0]?.after }, (await read($, board))?.threads ?? [], applied)
149 const at = await boardNow($, await read($, marks))
150 await update($, pluginReload, () => ({ running: false, message, failed: false, at }))
151 $.ui.toast(message)
152 return applied ? { outcome: 'ok', msg: message, proof: `main.log reload_plugins applied x${applied}` } : { outcome: 'noop', msg: message }
153 } catch (error) {
154 const message = `Reload failed: ${step}. ${error instanceof Error ? error.message : 'Try again.'}`
155 const at = await boardNow($, await read($, marks)).catch(() => Date.now())
156 await update($, pluginReload, () => ({ running: false, message, failed: true, at }))
157 $.ui.toast(message)
158 return { outcome: 'fail', msg: message }
159 }
160}
161
162/** The tail of the Claude desktop app's main.log, or null when it cannot be read: the app's own record of what it did. */
163async function readMainLog($: E): Promise<string | null> {
164 try {
165 const r = await $.process.run(['/bin/sh', '-c', 'tail -c 200000 "$HOME/Library/Logs/Claude/main.log"'], { timeoutMs: 5_000 })
166 return r.exitCode === 0 ? r.stdout : null
167 } catch { return null }
168}
169
170
171let attention = createAttention()
172let timers: { cancel(): void }[] = []
173// One live loop per module: session.start starts it (replacing, never stacking), session.end stops it. Each tick asks
174// whether the pane is placed and shown at that moment, not at open time: the desktop app starts the session before
175// its view connects, so the open answers not placed and no ui.open comes when the view draws the pane later.
176let liveTimer: { cancel(): void } | null = null
177// Set by session.end so a late drawing of the pane does not start the loop again for a session that is over.
178let ended = false
179const stopLive = () => { liveTimer?.cancel(); liveTimer = null }
180const startLive = ($: E) => {
181 stopLive()
182 ended = false
183 liveTimer = $.clock.every(REFRESH_MS, () => void (async () => {
184 // A pane behind another tab, or undrawn, has nothing to redraw.
185 const pane = (await $.ui.panes()).find(x => x.id === PANE)
186 const shown = !!(pane?.isPlaced && pane.isShown)
187 await beatOnce($, shown)
188 if (shown) await refreshLive($)
189 })().catch(() => undefined))
190}
191// The heartbeat tells the Sentinel the live loop is running; once every 30 s is often enough to notice it stop.
192let lastBeat = 0
193async function beatOnce($: E, shown: boolean) {
194 const now = await $.clock.now()
195 if (now - lastBeat < 30_000) return
196 lastBeat = now
197 await beat($, await dirOf($), shown)
198}
199/** Reread sentinel.json and sentinel-state.json; the pane redraws only when they changed. */
200async function readSentinel($: E) {
201 const dir = await dirOf($)
202 if (!dir) return
203 const file = async (name: string) => {
204 try { return (await $.fs.exists(`${dir}/${name}`)) ? await $.fs.read(`${dir}/${name}`) as string : null } catch { return null }
205 }
206 const next = parseSentinel(await file('sentinel.json'), await file('sentinel-state.json'), await $.fs.exists(`${dir}/sentinel.py`))
207 if (JSON.stringify(await read($, sentinel)) !== JSON.stringify(next)) await update($, sentinel, () => next)
208}
209/** Reread what the pane draws live: the worker lanes, the Attention card while the Next tab shows, the hand-off command. */
210async function refreshLive($: E) {
211 const minute = Math.floor(await $.clock.now() / 60_000)
212 if (await read($, momentumMinute) !== minute) await update($, momentumMinute, () => minute)
213 await refreshDispatch($).catch(() => undefined)
214 await readSentinel($).catch(() => undefined)
215 if (dataDir && await read($, tab) === 'focus') await refreshAttention($, attention, await dirOf($)).catch(() => undefined)
216 await refreshHandoff($)
217 await refreshLedger($).catch(() => undefined)
218}
219// A mod installed mid-session gets no session.start; its first drawing starts the loop through the clock.
220function ensureLive($: E) {
221 if (liveTimer || ended) return
222 $.clock.after(0, () => { if (!liveTimer && !ended) startLive($) })
223}
224
225// Set from the dataDir option each time the module registers; empty means no board.
226let dataDir = ''
227// Set from the openOnStart option; on unless the person turned it off.
228let openOnStart = true
229// Set from the dispatchDir option; '' falls back to DISPATCH_ROOT.
230let dispatchOpt = ''
231// Set from the reloadHelper option: a script that reloads open sessions in place; '' means none.
232let reloadHelper = ''
233/** A folder option with a leading ~ expanded, or '' when none is set. */
234const homePath = async ($: E, p: string) => {
235 if (!p.startsWith('~')) return p.replace(/\/+$/, '')
236 return `${(await $.env.get('HOME')) ?? ''}${p.slice(1)}`.replace(/\/+$/, '')
237}
238
239const dismissReload = ($: E) => update($, pluginReload, () => ({ running: false, message: '', failed: false, at: 0 }))
240
241/** Opens a link with the system, and says so when it would not open (an exit code is the only answer open gives). */
242async function openUrl($: E, url: string) {
243 const r = await $.process.run(['open', url])
244 if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `open exited ${r.exitCode}`)
245}
246
247/** Retry: a rescan, or when one ran a moment ago just a fresh read, so a fixed file is picked up at once. */
248async function retryBoard($: E): Promise<Done> {
249 try { return await rescan($) } catch (error) {
250 if (!(error instanceof Declined)) throw error
251 await loadBoard($)
252 return 'Board read again'
253 }
254}
255
256/** The board's data folder, or '' when none is set. */
257const dirOf = ($: E) => homePath($, dataDir)
258
259/** Empty boards can also draw in hosts that have no clock adapter. */
260async function boardNow($: E, m: Marks): Promise<number> {
261 try { return await $.clock.now() } catch (error) {
262 if (Object.keys(m).length) throw error
263 return Date.now()
264 }
265}
266
267async function readJson<T>($: E, path: string, fallback: T): Promise<T> {
268 try {
269 if (!(await $.fs.exists(path))) return fallback
270 const v = JSON.parse((await $.fs.read(path)) as string) as unknown
271 // A file holding null or a bare value reads as missing, so callers can trust the fallback's shape.
272 return fallback !== null && (typeof v !== 'object' || v === null) ? fallback : (v as T)
273 } catch {
274 return fallback
275 }
276}
277
278/** Why a data file cannot be used, in words for the pane; null when it reads fine. `need` makes a missing file a problem. */
279async function fileProblem($: E, path: string, name: string, need: boolean): Promise<string | null> {
280 try {
281 if (!(await $.fs.exists(path))) return need ? `${name} is not in the data folder.` : null
282 JSON.parse((await $.fs.read(path)) as string)
283 return null
284 } catch {
285 return `${name} is unreadable (not valid JSON). Fix or restore it, then Retry.`
286 }
287}
288
289// A plugin installed or updated mid-session gets no session.start, so the pane starts the first load
290// itself. A drawing cannot write state, so it hands the load to the clock, and only when no load is
291// running (session.start's own, say). A load that failed leaves `loaded` false, so the next draw retries.
292let loading: Promise<void> | null = null
293function ensureLoaded($: E) {
294 if (loading) return
295 $.clock.after(0, async () => {
296 if (!loading && !(await read($, loaded))) await loadBoard($).catch(() => undefined)
297 })
298}
299
300async function loadBoard($: E) {
301 const run = readBoard($).finally(() => {
302 if (loading === run) loading = null
303 })
304 loading = run
305 return run
306}
307
308// Worker fast mode is the dispatch skill's saved default: a file named fast in its folder.
309// The folder comes from the dispatchDir option, then DISPATCH_ROOT; with neither, no toggle.
310const dispatchDir = async ($: E) => homePath($, dispatchOpt || ((await $.env.get('DISPATCH_ROOT')) ?? ''))
311
312async function readWorkerFast($: E) {
313 const dir = await dispatchDir($)
314 const v = dir && (await $.fs.exists(dir)) ? await $.fs.exists(`${dir}/fast`) : null
315 await update($, workerFast, () => v)
316 return v
317}
318
319/** Flips what is on disk now, not what the pane last read: another session may have changed it. */
320async function toggleWorkerFast($: E) {
321 const now = await readWorkerFast($)
322 if (now === null) return
323 const path = `${await dispatchDir($)}/fast`
324 const on = !now
325 try {
326 if (on) await $.fs.write(path, '')
327 else if ((await $.process.run(['/bin/rm', '-f', path])).exitCode !== 0) throw new Error('rm failed')
328 } catch {
329 $.ui.toast(`Could not turn worker fast mode ${on ? 'on' : 'off'}`)
330 return
331 }
332 await update($, workerFast, () => on)
333 $.ui.toast(`Worker fast mode ${on ? 'on: Codex and pi use the priority tier' : 'off'}`)
334}
335
336async function readBoard($: E) {
337 await refreshLedger($)
338 const dir = await dirOf($)
339 if (!dir) {
340 await update($, board, () => null)
341 await update($, workerFast, () => null)
342 await update($, problem, () => null)
343 await update($, loaded, () => true)
344 await setStatus($)
345 return
346 }
347 // Read only with a data folder: with none, nextup touches nothing on disk.
348 await readWorkerFast($)
349 const bad = (await fileProblem($, `${dir}/threads.json`, 'threads.json', true)) ?? (await fileProblem($, `${dir}/notes.json`, 'notes.json', false))
350 await update($, problem, () => bad)
351 const t = await readJson<{ threads?: Thread[]; generated?: string }>($, `${dir}/threads.json`, {})
352 const n = await readJson<{ items?: Note[] }>($, `${dir}/notes.json`, {})
353 const next: Board = {
354 threads: t.threads ?? [],
355 notes: (n.items ?? []).filter(i => !i.ticked),
356 generated: t.generated ?? '',
357 }
358 await update($, board, () => next)
359 await readSentinel($).catch(() => undefined)
360 let m: Marks = {}
361 try {
362 m = await readMarks(filesOf($), dir)
363 } catch (error) {
364 if (!(error instanceof MarksUnreadable)) throw error
365 if (!bad) await update($, problem, () => error.message)
366 }
367 await update($, marks, () => m)
368 await reconcileLaunches($)
369 const p = await readJson<Plan>($, `${dir}/today.json`, { date: '', ids: [] })
370 await update($, plan, () => (p.date === dayKey() ? p : { date: dayKey(), ids: [] }))
371 const c = cleanCalendar(await readJson<unknown>($, `${dir}/calendar.json`, null))
372 await update($, cal, () => c)
373 const d = cleanDrafts(await readJson<unknown>($, `${dir}/drafts.json`, null))
374 await update($, drafts, () => d)
375 await update($, loaded, () => true)
376 await setStatus($)
377}
378
379// Your open PRs with the head commit's check state, one GraphQL call for all of them.
380const PR_QUERY =
381 '{ search(query:"is:pr is:open author:@me archived:false sort:updated-desc", type:ISSUE, first:20){ nodes{ ... on PullRequest { title url number headRefName isDraft updatedAt repository{nameWithOwner} commits(last:1){nodes{commit{statusCheckRollup{state}}}} } } } }'
382type GhPR = {
383 title?: string
384 url?: string
385 number?: number
386 headRefName?: string
387 isDraft?: boolean
388 updatedAt?: string
389 repository?: { nameWithOwner?: string }
390 commits?: { nodes?: { commit?: { statusCheckRollup?: { state?: string } | null } }[] }
391}
392
393/** Reads the open PRs with gh at most every ten minutes (force: the timer); a failed gh keeps what was there. */
394async function loadPrs($: E, force = false) {
395 const have = await read($, prs)
396 if (!force && have && (await $.clock.now()) - have.at < PRS_EVERY_MS) return
397 try {
398 // gh lives in Homebrew, which a mod's child process may not have on its PATH.
399 const r = await $.process.run(['/bin/sh', '-c', 'PATH=/opt/homebrew/bin:/usr/local/bin:$PATH exec gh api graphql -f query="$Q"'], {
400 env: { Q: PR_QUERY },
401 timeoutMs: 20_000,
402 })
403 if (r.exitCode !== 0) {
404 await update($, prsProblem, () => 'gh could not read your pull requests. Run gh auth status in a terminal.')
405 return
406 }
407 const nodes = (JSON.parse(r.stdout) as { data?: { search?: { nodes?: (GhPR | null)[] } } }).data?.search?.nodes
408 // Search can hand back null or empty nodes for items it cannot show; those are skipped, not fatal.
409 const items: StagePR[] = (Array.isArray(nodes) ? nodes : [])
410 .filter((n): n is GhPR => !!n?.url && !!n.number)
411 .map(n => ({
412 title: n.title ?? '',
413 url: n.url ?? '',
414 number: n.number ?? 0,
415 repo: n.repository?.nameWithOwner ?? '',
416 branch: n.headRefName ?? '',
417 draft: !!n.isDraft,
418 updated: n.updatedAt ?? '',
419 checks: n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? 'NONE',
420 }))
421 const at = await $.clock.now()
422 await update($, prs, () => ({ at, items }))
423 await update($, prsProblem, () => null)
424 await reconcileLaunches($)
425 } catch {
426 // No gh, no network or odd output: the PR section keeps what it had and says so.
427 await update($, prsProblem, () => 'gh is not available, so pull requests may be out of date.')
428 }
429}
430
431const prRank = (p: PR) => (isRed(p) ? 0 : p.checks === 'PENDING' || p.checks === 'EXPECTED' ? 1 : 2)
432const isRed = (p: PR) => p.checks === 'FAILURE' || p.checks === 'ERROR'
433/** Failing first, then still checking, then the rest; newest first within each. */
434const sortPrs = (items: PR[]) => [...items].sort((a, b) => prRank(a) - prRank(b) || b.updated.localeCompare(a.updated))
435
436// calendar.json and drafts.json are written by an agent run, so every field is checked once here
437// and the views can trust what they get.
438const str = (v: unknown) => (typeof v === 'string' ? v : '')
439const hhmm = (v: string) => (/^\d:\d\d$/.test(v) ? `0${v}` : v)
440const objects = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is Record<string, unknown> => !!x && typeof x === 'object') : [])
441
442function cleanCalendar(raw: unknown): Calendar | null {
443 const c = raw as { date?: unknown; events?: unknown } | null
444 if (!c || typeof c !== 'object' || typeof c.date !== 'string') return null
445 const events = objects(c.events)
446 .map(e => ({ start: hhmm(str(e.start)), end: hhmm(str(e.end)), title: str(e.title), where: str(e.where), link: str(e.link) }))
447 .filter(e => e.title && (e.start === 'all day' || /^\d\d:\d\d$/.test(e.start)))
448 return { date: c.date, events }
449}
450
451function cleanDrafts(raw: unknown): Drafts | null {
452 const d = raw as { at?: unknown; drafts?: unknown } | null
453 if (!d || typeof d !== 'object') return null
454 // Ids become element keys, so a missing or repeated one takes its place in the list instead.
455 const seen = new Set<string>()
456 const list = objects(d.drafts).map((x, i) => {
457 const id = str(x.id) && !seen.has(str(x.id)) ? str(x.id) : `draft-${i}`
458 seen.add(id)
459 return { id, to: str(x.to), subject: str(x.subject), link: str(x.link) }
460 })
461 return { at: str(d.at), drafts: list }
462}
463
464/** What is left of today's calendar: timed events not yet over, then all-day ones; nothing from another day. */
465function agenda(c: Calendar | null): { next?: CalEvent; rest: number } {
466 const d = new Date()
467 const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
468 if (!c || c.date !== today || !Array.isArray(c.events)) return { rest: 0 }
469 const now = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
470 const timed = /^\d\d:\d\d$/
471 // An end before the start runs past midnight, so it is still on until the day ends.
472 const ends = (e: CalEvent) => (!timed.test(e.end) ? e.start : e.end < e.start ? '24:00' : e.end)
473 const left = c.events.filter(e => timed.test(e.start) && ends(e) > now)
474 const allDay = c.events.filter(e => e.start === 'all day')
475 const list = [...left.sort((a, b) => a.start.localeCompare(b.start)), ...allDay]
476 return { next: list[0], rest: Math.max(0, list.length - 1) }
477}
478
479/** Today's date as the plan keys it: the day turns at 4am, so late nights plan the same day. */
480const dayKey = () => {
481 const d = new Date(Date.now() - 4 * 3600 * 1000)
482 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
483}
484
485/** Today's planned items still open and on the board, in plan order; an earlier day's plan is empty. */
486function plannedItems(b: Board | null, m: Marks, pl: Plan, now: number, s: Record<string, number> = {}): (Thread | Note)[] {
487 if (pl.date !== dayKey()) return []
488 const all = [...(b?.threads ?? []), ...(b?.notes ?? [])]
489 return (Array.isArray(pl.ids) ? pl.ids : []).map(id => all.find(x => x.id === id)).filter((x): x is Thread | Note => !!x && !(s[x.id] === undefined && isHidden(m, x.id, now)))
490}
491
492/**
493 * Changes today's plan and writes today.json. Re-reads the file first, so a plan edited in another
494 * session is kept, and keeps only items still open, so done and stale ids fall out as it is edited.
495 */
496async function editPlan($: E, fn: (ids: string[]) => string[]) {
497 const now = await $.clock.now()
498 const dir = await dirOf($)
499 const file = `${dir}/today.json`
500 const b = await read($, board)
501 // With no board loaded nothing could be told open, and the saved plan would be wiped.
502 if (!b) return
503 const onDisk = await readJson<Plan>($, file, { date: '', ids: [] })
504 const open = plannedItems(b, await read($, marks), onDisk, now).map(x => x.id)
505 const ids = fn(open)
506 const next = { date: dayKey(), ids: ids.filter((x, i) => ids.indexOf(x) === i) }
507 await $.fs.write(file, JSON.stringify(next))
508 await update($, plan, () => next)
509}
510const planAdd = ($: E, id: string) => editPlan($, ids => [...ids, id])
511const planDrop = ($: E, id: string) => editPlan($, ids => ids.filter(x => x !== id))
512const planUp = ($: E, id: string) =>
513 editPlan($, ids => {
514 const i = ids.indexOf(id)
515 return i > 0 ? [...ids.slice(0, i - 1), id, ids[i - 1]!, ...ids.slice(i + 1)] : ids
516 })
517
518/** Every pane action goes through the Marks ledger, with one clock time and one write. */
519async function applyMark($: E, verb: 'done' | 'drop' | 'park' | 'undo', ids: string[]): Promise<boolean> {
520 // A press that changes nothing says why (S21): it throws Declined, and act() draws the reason.
521 if (verb === 'undo') await updateSettling($, s => {
522 const next = { ...s }
523 for (const id of ids) delete next[id]
524 return next
525 })
526 if (verb !== 'undo') { await noteStalled($, ids); for (const id of ids) await undoLaunch($, id) }
527 const dir = await dirOf($)
528 if (!dir) throw new Declined('No Next up data folder is set, so nothing was saved.')
529 if (!ids.length) throw new Declined('There was nothing to mark.')
530 const now = await $.clock.now()
531 let next: Marks
532 try {
533 const f = filesOf($)
534 next = verb === 'park' ? await park(f, dir, ids, tomorrow8(now), now)
535 : await ({ done, drop, undo }[verb])(f, dir, ids, now)
536 } catch (error) {
537 if (!(error instanceof MarksUnreadable)) throw error
538 await update($, problem, p => p || error.message)
539 throw new Declined(error.message)
540 }
541 await update($, marks, () => next)
542 await setStatus($)
543 const b = await read($, board)
544 for (const id of ids) {
545 const item = [...(b?.threads ?? []), ...(b?.notes ?? [])].find(x => x.id === id)
546 const name = (item ? item.title || ('text' in item ? item.text : '') : '') || 'item'
547 if (verb === 'done') $.ui.toast(`✓ Done: ${name}`)
548 else if (verb === 'park') $.ui.toast(`Back tomorrow: ${name}`)
549 else if (verb === 'drop') $.ui.toast(`Dropped: ${name}`)
550 }
551 return true
552}
553
554const TABS = ['focus', 'work', 'notes', 'wins', 'parked', 'session'] as const
555
556// Shows a tab and drops any half-made confirmation from the one left.
557async function showTab($: E, id: string) {
558 const next = TABS.find(t => t === id)
559 if (!next) return
560 await update($, tab, () => next)
561 await update($, confirm, () => null)
562}
563
564async function markDone($: E, id: string) {
565 const at = await $.clock.now()
566 await updateSettling($, s => ({ ...s, [id]: at }))
567 try {
568 if (!(await applyMark($, 'done', [id]))) {
569 await updateSettling($, s => { const next = { ...s }; delete next[id]; return next })
570 return
571 }
572 } catch (error) {
573 await updateSettling($, s => { const next = { ...s }; delete next[id]; return next })
574 throw error
575 }
576 $.clock.after(Math.max(0, SETTLE_MS - ((await $.clock.now()) - at)), async () => {
577 await updateSettling($, s => {
578 if (s[id] !== at) return s
579 const next = { ...s }
580 delete next[id]
581 return next
582 })
583 })
584}
585
586/** Keep native control geometry while settling; Button has no disabled prop.
587 * Rebuild inert buttons rather than keeping their original press handles alive.
588 */
589function settledControls($: E, e: RenderInput, active: boolean, doneKey: string, undoKey: string, id: string, tree: RenderElement): RenderElement {
590 if (!active) return tree
591 const { Button } = $.ui.resolve(e)
592 const visit = (node: RenderElement): RenderElement => {
593 if (node.type === 'Button') {
594 const isDone = node.props.key === doneKey
595 return <Button {...{ ...node.props, key: isDone ? undoKey : node.props.key,
596 label: isDone ? node.props.label.replace('Done', 'Undo') : node.props.label,
597 dimColor: !isDone }} onPress={isDone ? act($, 'undo', () => applyMark($, 'undo', [id]), { label: 'undo it' }) : view($, () => {})} />
598 }
599 if (node.type === 'Link') return { ...node, props: { ...node.props, href: '' } }
600 if ('children' in node && node.children) return { ...node, children: node.children.map(child => typeof child === 'object' && child !== null ? visit(child) : child) } as RenderElement
601 return node
602 }
603 return visit(tree)
604}
605
606/** Done on all finished threads is one ledger write. */
607const tickAll = ($: E, wins: Thread[]) => applyMark($, 'done', wins.map(t => t.id))
608
609/** The phone board's rank: money value squared over log of effort; unknown minutes count as 30, unrated value as 2. */
610const score = (t: Thread) => (t.value ?? 2) ** 2 / Math.log2((t.minutes ?? 30) + 2)
611const rank = (a: Thread, b: Thread) => score(b) - score(a) || b.progress - a.progress || b.last.localeCompare(a.last)
612
613/** Explain the actual money/effort score and its tie breakers, without inventing deadlines. */
614export const whyThis = (t: Thread, local = false) => local
615 ? 'Waiting here: the open step in this repository'
616 : `Money: value ${t.value ?? 2} for ${t.minutes ?? 30} min${t.minutes == null ? ' (estimated)' : ''} · best return on effort`
617
618export const freshness = (generated: string | undefined, now: number) => {
619 const at = Date.parse(generated ?? '')
620 return Number.isFinite(at) ? `updated ${Math.max(0, Math.floor((now - at) / 60_000))} min ago` : 'scan time unavailable'
621}
622
623const cwdOf = (t: Thread) => /cd "([^"]+)"/.exec(t.resume)?.[1] ?? ''
624const sameRepo = (a: string, b: string) => !!a && !!b && (a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`))
625const repoRoot = (p: string) => p.replace(/\/\.claude\/worktrees\/[^/]+.*$/, '')
626
627const tomorrow8 = (now: number) => {
628 const d = new Date(now)
629 d.setDate(d.getDate() + 1)
630 d.setHours(8, 0, 0, 0)
631 return d.getTime()
632}
633
634/** A labelled copy keeps the board's own title here, for goals and session matching. */
635const plainTitles = new WeakMap<Thread, string>()
636const plain = (t: Thread) => plainTitles.get(t) ?? t.title
637
638/** Live session rows, read at most once a minute so every band redraw does not call the app. */
639let rowsCache: { at: number; rows: SessionRow[] } | undefined
640async function sessionRows($: E, now: number): Promise<SessionRow[]> {
641 // The Needs you poll already reads the list every minute; reuse it rather than ask the app twice.
642 const polled = polledSessions() as { at: number; rows: SessionRow[] } | undefined
643 const fresh = [polled, rowsCache].filter((x): x is { at: number; rows: SessionRow[] } => !!x && now >= x.at && now - x.at < 5 * 60_000).sort((a, b) => b.at - a.at)[0]
644 if (fresh && (fresh !== rowsCache || now - fresh.at < 60_000)) return fresh.rows
645 let rows: SessionRow[] = []
646 try {
647 const r = await $.mcp.call('ccd_session_mgmt', 'list_sessions', { limit: 60 })
648 const v: unknown = r.isError ? [] : JSON.parse(r.content.filter(c => c.type === 'text').map(c => c.text).join('\n'))
649 if (Array.isArray(v)) rows = v.filter((x): x is SessionRow => !!x && typeof x === 'object')
650 } catch { /* Outside the desktop app only launch history is known. */ }
651 rowsCache = { at: now, rows }
652 return rows
653}
654
655/** The stall each stalled thread is in now, for noting that Darren has seen it. */
656let stallKeys = new Map<string, number>()
657const stalledSeen = async ($: E): Promise<Record<string, number>> => {
658 try { const v = await $.store.get('stalledSeen'); return v && typeof v === 'object' ? v as Record<string, number> : {} } catch { return {} }
659}
660async function noteStalled($: E, ids: string[]) {
661 const hit = ids.filter(id => stallKeys.has(id))
662 if (!hit.length) return
663 const seen = await stalledSeen($)
664 for (const id of hit) seen[id] = stallKeys.get(id)!
665 await $.store.set('stalledSeen', seen)
666}
667/** Rescue on a stalled row: the same handler and feedback as the Rescue card. */
668async function rescueRow($: E, t: Thread) {
669 await noteStalled($, [t.id])
670 await update($, openRow, () => t.id)
671 await say($, t.id, 'ok', `Rescue: ${plain(t)}. First step below.`)
672}
673const isStalled = (t: Thread) => stallKeys.has(t.id) && plainTitles.has(t) && t.title.startsWith('Stalled:')
674
675/** What each open thread is doing: nothing, being worked on, delegate finished, or stalled (work.ts holds the rules). */
676async function workStates($: E, threads: Thread[], now: number): Promise<{ states: Map<string, Work>; keys: Map<string, number> }> {
677 const out = new Map<string, Work>()
678 const keys = new Map<string, number>()
679 if (!threads.length || !dataDir) return { states: out, keys }
680 let history: LaunchHistoryEntry[] = []
681 try { history = await launchHistory($, `${await dirOf($)}/mod-launch-history.json`) } catch { /* History stays optional. */ }
682 const rows = await sessionRows($, now)
683 for (const t of threads) {
684 const launch = history.filter(x => x.id === t.id && x.at <= now && now - x.at < LAUNCH_WINDOW_MS).sort((a, b) => b.at - a.at)[0]
685 const w = workOf(t, launch, rows, now)
686 out.set(t.id, w)
687 if (w === 'stalled') keys.set(t.id, stallKey(t, launch, rows, now))
688 }
689 return { states: out, keys }
690}
691
692/**
693 * The phone board's lanes: to do is checked, still needed and has nobody on it; ask is unchecked; wins are rated
694 * finished threads. A thread with a live session goes to working. When its delegate finished it returns as
695 * "Delegate finished: review"; when the delegate went quiet it returns as "Stalled: <title>".
696 */
697async function openWork($: E, m: Marks, now: number, s: Record<string, number> = {}) {
698 const b = await read($, board)
699 const all = (b?.threads ?? []).filter(t => !(s[t.id] === undefined && isHidden(m, t.id, now)))
700 const { states, keys } = await workStates($, all.filter(t => t.progress < 100), now)
701 // A stall is shown once: after Darren acts on it, it stays hidden until there is new activity (a new key).
702 const seen = await stalledSeen($)
703 stallKeys = keys
704 const state = (t: Thread) => states.get(t.id) ?? 'free'
705 const opened = await read($, openRow) // The row Rescue opened stays up until it is closed.
706 const dismissed = (t: Thread) => state(t) === 'stalled' && seen[t.id] === keys.get(t.id) && opened !== t.id
707 const cardIds = new Set(((await read($, sentinel))?.cards ?? []).map(c => c.id))
708 const open = all.filter(t => t.progress < 100)
709 const working = open.filter(t => state(t) === 'working').sort(rank)
710 const mark = (t: Thread): Thread => {
711 const label = state(t) === 'review' ? 'Delegate finished: review' : state(t) === 'stalled' ? 'Stalled:' : ''
712 if (!label) return t
713 const copy = { ...t, title: `${label} ${t.title}` }
714 plainTitles.set(copy, t.title)
715 return copy
716 }
717 const idle = open.filter(t => state(t) !== 'working' && !dismissed(t))
718 const confirmed = (t: Thread) => t.check === 'verified' || isKept(m, t.id)
719 return {
720 todo: idle.filter(t => !t.blocked && confirmed(t)).sort(rank).map(mark),
721 ask: idle.filter(t => !t.blocked && !confirmed(t)).sort(rank).map(mark),
722 waiting: open.filter(t => t.blocked && state(t) !== 'working').sort(rank),
723 working,
724 wins: all.filter(t => t.progress >= 100 && t.rated),
725 // A Sentinel card that build.py also wrote as a note shows once, as the card.
726 notes: (b?.notes ?? []).filter(n => !cardIds.has(n.id) && !(s[n.id] === undefined && isHidden(m, n.id, now))),
727 }
728}
729
730/** Longest status text: the host adds "nextup: " in front, and the band must fit 48 columns. */
731const STATUS_MAX = 40
732const fit = (v: string, n: number) => (v.length > n ? `${v.slice(0, n - 1).trimEnd()}…` : v)
733
734/** Reads the permission-wait files and keeps the ones for sessions that are still listed (or all when no list is known). */
735async function loadPermissionWaits($: E) {
736 const found: PermWait[] = []
737 try {
738 const dir = await dirOf($)
739 if (dir && (await $.fs.exists(`${dir}/permission-wait`))) {
740 const rows = polledSessions()?.rows
741 const titles = new Map((rows ?? []).map(r => [typeof r.sessionId === 'string' ? r.sessionId : '', typeof r.title === 'string' ? r.title : ''] as const))
742 for (const f of await $.fs.list(`${dir}/permission-wait`)) {
743 if (f.kind !== 'file' || !f.name.endsWith('.json') || f.name.startsWith('.')) continue
744 try {
745 const v = JSON.parse((await $.fs.read(`${dir}/permission-wait/${f.name}`)) as string) as { session_id?: unknown; status?: unknown; at?: unknown; tool?: unknown }
746 if (v.status !== 'waiting-on-permission' || typeof v.session_id !== 'string' || typeof v.at !== 'number') continue
747 if (rows && !titles.has(v.session_id)) continue
748 found.push({ id: v.session_id, title: titles.get(v.session_id) || v.session_id, tool: typeof v.tool === 'string' ? v.tool : '', since: v.at })
749 } catch { /* one unreadable file never hides the others */ }
750 }
751 }
752 } catch { /* no folder or no listing: nothing waits */ }
753 await update($, permWaits, () => found)
754}
755
756/** Things that need Darren now: sessions asking, open questions, and permission prompts, each session once. */
757function actionable(sessions: Need[], asks: number, perms: PermWait[]) {
758 const ids = new Set(sessions.map(x => x.id))
759 return sessions.length + asks + perms.filter(x => !ids.has(x.id)).length
760}
761
762async function needsCount($: E) {
763 const led = await read($, ledger) as Awaited<ReturnType<typeof readLedger>>
764 const answered = new Set(led.answers.map(a => a.id))
765 return actionable((await read($, needs)) ?? [], led.asks.filter(a => !answered.has(a.id)).length, (await read($, permWaits)) ?? [])
766}
767
768async function refreshNeeds($: E) {
769 await loadNeeds($)
770 // A timer tick must never reject (the session can end mid-read); the status line is a convenience.
771 try {
772 await loadPermissionWaits($)
773 await setStatus($)
774 } catch { /* the next tick tries again */ }
775}
776
777async function setStatus($: E) {
778 const m = await read($, marks)
779 const now = await boardNow($, m)
780 const w = await openWork($, m, now)
781 const n = await needsCount($)
782 const parts = [
783 n && `${n} need${n === 1 ? 's' : ''} you`,
784 w.todo.length && `${w.todo.length} to do`,
785 ].filter(Boolean)
786 // The host already prefixes the plugin name, so the text carries no title of its own.
787 $.ui.status(parts.length ? fit(parts.join(' · '), STATUS_MAX) : undefined)
788}
789
790const MANUAL_RESCAN_MS = 60_000
791/**
792 * Runs the data folder's own build.py, if it has one; a board with no pipeline is only re-read.
793 * A person's rescan waits one minute between runs, and says so when it is asked too soon (S26); the timers keep ten.
794 */
795async function runBuild($: E, why: string): Promise<boolean | 'failed'> {
796 const dir = await dirOf($)
797 if (!dir) return false
798 if (!(await $.fs.exists(`${dir}/build.py`))) {
799 await loadBoard($)
800 return true
801 }
802 const now = await $.clock.now()
803 const last = ((await $.store.get('lastBuildAt')) as number | undefined) ?? 0
804 const manual = why === 'manual'
805 if (now - last < (manual ? MANUAL_RESCAN_MS : 10 * 60 * 1000)) {
806 if (!manual) return false
807 const ago = Math.max(0, Math.round((now - last) / 1000))
808 throw new Declined(`Scanned ${ago} s ago, next in ${Math.max(1, Math.ceil((MANUAL_RESCAN_MS - (now - last)) / 60_000))} min.`)
809 }
810 await $.store.set('lastBuildAt', now)
811 await update($, building, () => true)
812 let failed = false
813 try {
814 const r = await $.process.run(['python3', 'build.py', '7'], { cwd: dir })
815 failed = r.exitCode !== 0
816 if (failed) $.ui.toast(`${TITLE} rebuild failed (${why})`)
817 // The Sentinel checks right after a scan, so a fresh board is judged now and not at its next timer.
818 else if (await $.fs.exists(`${dir}/sentinel.py`)) await $.process.run(['python3', 'sentinel.py', '--quick'], { cwd: dir, timeoutMs: 60_000 }).catch(() => undefined)
819 } finally {
820 await update($, building, () => false)
821 await loadBoard($)
822 // loadBoard reads the files again; a failed rescan is kept on screen, not only in a toast.
823 if (failed) await update($, problem, p => p ?? 'The last rescan failed, so this is the previous board.')
824 }
825 return failed ? 'failed' : true
826}
827
828/** A person's Rescan: the result is a sentence, not a silent redraw. */
829async function rescan($: E): Promise<Done> {
830 const r = await runBuild($, 'manual')
831 if (r === 'failed') throw new Error('the scan failed, so this is the previous board')
832 return r ? 'Board rescanned' : false
833}
834
835async function resume($: E, t: Thread, surface: Parameters<E['ui']['copy']>[0]['surface']) {
836 if (/^https:\/\//.test(t.link)) return // drawn as a Link instead
837 if (/^http:\/\/127\.0\.0\.1:47811\//.test(t.link)) {
838 // The local helper opens Codex CLI chats in Terminal and app chats in the ChatGPT app.
839 const r = await $.process.run(['open', t.link])
840 if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `open exited ${r.exitCode}`)
841 return
842 }
843 await $.ui.copy({ text: t.resume, surface })
844 $.ui.toast('Resume command copied. Paste it in a terminal.')
845}
846
847
848async function openBoard($: E) {
849 return (await $.ui.open({ id: PANE, title: TITLE })).isPlaced
850}
851
852// Context lines stay short enough to scan: markdown marks out, cut at a word near 160 characters.
853const clip = (v: string, n = 160) => {
854 const t = v.replace(/<\/?pasted_content[^>]*>|\*\*|`/g, '').replace(/\s+/g, ' ').trim()
855 return t.length <= n ? t : `${t.slice(0, n).replace(/\s+\S*$/, '')}…`
856}
857
858// A Claude Code session opens in the desktop app by its id (claude://resume, proven 22 Oct).
859const isClaudeSession = (t: Thread) => t.source === 'Claude Code' && /^[\w-]+$/.test(t.id)
860type Surface = Parameters<E['ui']['copy']>[0]['surface']
861const say = ($: E, id: string, kind: 'pending' | 'ok' | 'fail', text: string) => update($, cardNote, () => ({ id, kind, text }))
862// A session opens only where its transcript is: the app logs transcript_missing and shows nothing otherwise.
863// A session on the Mac Mini has no transcript here, so no link opens it from this Mac (SN-08).
864const onMacMini = (t: Thread) => /^ssh\s/.test(t.resume)
865const MAC_MINI = 'On Mac Mini, open it there'
866async function copySsh($: E, t: Thread, surface: Surface) {
867 await $.ui.copy({ text: t.resume, surface })
868 await say($, t.id, 'ok', `${t.title} is on the Mac Mini. Resume command copied: paste it in a terminal.`)
869 return 'ssh command copied'
870}
871/** The card note says what happened where the button is; the returned outcome is for the result line and the event log. */
872async function goTo($: E, t: Thread, surface?: Surface): Promise<Done> {
873 await say($, t.id, 'pending', `Opening ${t.title}…`)
874 try {
875 const here = (await $.process.run(['/bin/sh', '-c', `ls "$HOME"/.claude/projects/*/${t.id}.jsonl >/dev/null 2>&1`])).exitCode === 0
876 if (!here) {
877 if (onMacMini(t)) {
878 await $.ui.copy({ text: t.resume, surface: surface ?? 'desktop' })
879 await say($, t.id, 'ok', `${t.title} is on the Mac Mini. Resume command copied: paste it in a terminal.`)
880 return { outcome: 'ok', msg: '' }
881 }
882 const text = `Session not found on this Mac: ${t.title}`
883 await say($, t.id, 'fail', text)
884 return { outcome: 'fail', msg: text }
885 }
886 const pressed = await $.clock.now()
887 const r = await $.process.run(['open', `claude://resume?session=${t.id}`])
888 if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `open exited ${r.exitCode}`)
889 await say($, t.id, 'ok', `Opened ${t.title}`)
890 // open exiting 0 only proves the link was handed over. The app's log says if it found no transcript.
891 $.clock.after(3000, () => void (async () => {
892 const log = await readMainLog($)
893 if (log === null || !missingSince(log, t.id, pressed)) return
894 const text = `${t.title} did not open: the app has no transcript for it on this Mac.`
895 await say($, t.id, 'fail', text)
896 await late($, 'go-to-session', 'fail', text, 'main.log transcript_missing')
897 })().catch(() => undefined))
898 return { outcome: 'ok', msg: '' }
899 } catch (error) {
900 const text = `Could not open ${t.title}: ${error instanceof Error ? error.message : 'unknown error'}`
901 await say($, t.id, 'fail', text)
902 return { outcome: 'fail', msg: text }
903 }
904}
905
906// A side task is a new desktop session with a one-line /goal: measurable, verified, reviewed.
907// Code items open in the repo root, never the parent's worktree, so two agents never share files.
908// The app cuts q at 14336 characters; one line well under that keeps the done-when clause whole.
909const oneLine = (v: string) => v.replace(/\s+/g, ' ').trim().slice(0, 8000)
910function sidePrompt(x: Thread | Note) {
911 if (x.prompt) return oneLine(x.prompt)
912 const t = 'resume' in x ? x : null
913 const what = clip(t ? `${plain(t)}: ${t.finish}` : x.title || (x as Note).text, 400).replace(/[.…]+$/, '')
914 return oneLine([
915 `/goal ${what}.`,
916 t ? `Side task of session ${t.id} (${t.project})${t.left_off ? `, which left off: ${clip(t.left_off, 140)}` : ''}.` : '',
917 'Done when the change is shown working in this conversation: tests pass for code, a screenshot for anything visual, a read-back for data;',
918 'a fresh subagent has adversarially reviewed it and passed it; and for code the feature branch is pushed with green checks at its head.',
919 'Keep work on a branch; ask before sending, money, production or deleting data.',
920 'Each turn: do the next step, verify it, print `Milestones: x/N`. Log a gate as BLOCKED with its unlock; stop only when all remaining work is blocked.',
921 ]
922 .filter(Boolean)
923 .join(' '))
924}
925// The app turns a leading "/" in a deep link's q into "/ " (no link may run a command), so the goal
926// goes on the clipboard and the new session opens empty; a pasted /goal arms (checked 22 Oct).
927async function startGoal($: E, goal: string, root: string, surface: Surface) {
928 await $.ui.copy({ text: goal, surface })
929 // S24: the exit code was ignored, so a person could land nowhere and be told it worked.
930 const r = await $.process.run(['open', root ? `claude://code/new?folder=${encodeURIComponent(root)}` : 'claude://code/new'])
931 if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `open exited ${r.exitCode}`)
932 await $.ui.toast('Goal copied: paste it in the new session and press Enter')
933}
934const startSide = ($: E, x: Thread | Note, surface: Surface, goal = sidePrompt(x)) => startGoal($, goal, 'resume' in x ? repoRoot(cwdOf(x)) : '', surface)
935
936// Launch state is ephemeral; the daily ledger is shared through the data folder.
937type LaunchLedger = { date: string; count: number; log: { id: string; skill: string; at: number }[] }
938type PendingLaunch = { skill: string; stage: string; waiting?: boolean; cancel(): void }
939// The cancel handles stay here; what the card draws (pending, waiting) is mirrored into the launches state.
940const pendingLaunches = new Map<string, PendingLaunch>()
941const syncLaunch = ($: E, id: string) => update($, launches, s => {
942 const p = pendingLaunches.get(id)
943 const { [id]: _gone, ...rest } = s
944 return p ? { ...rest, [id]: { skill: p.skill, stage: p.stage, ...(p.waiting ? { waiting: true } : {}) } } : rest
945})
946let launchQueue: Promise<unknown> = Promise.resolve()
947const localDate = (at: number) => {
948 const d = new Date(at)
949 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
950}
951async function launchLedger($: E): Promise<LaunchLedger> {
952 const date = localDate(await $.clock.now())
953 const empty = { date, count: 0, log: [] }
954 const dir = await dirOf($)
955 if (!dir || !(await $.fs.exists(`${dir}/mod-launches.json`))) return empty
956 const v = JSON.parse(await $.fs.read(`${dir}/mod-launches.json`) as string) as LaunchLedger
957 if (typeof v.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v.date) || !Number.isInteger(v.count) || v.count < 0 || !Array.isArray(v.log) || v.count !== v.log.length
958 || !v.log.every(x => x && typeof x.id === 'string' && typeof x.skill === 'string' && Number.isFinite(x.at))) throw new Error('Invalid launch ledger')
959 return v.date === date ? v : empty
960}
961type LaunchHistoryEntry = {
962 id: string; skill: string; stage: string; at: number; progress: number
963 outcome?: 'moved' | 'none' | 'gone'; checkedAt?: number
964}
965async function launchHistory($: E, path: string): Promise<LaunchHistoryEntry[]> {
966 if (!(await $.fs.exists(path))) return []
967 const v: unknown = JSON.parse(await $.fs.read(path) as string)
968 if (!Array.isArray(v) || !v.every(x => x && typeof x.id === 'string' && typeof x.skill === 'string'
969 && typeof x.stage === 'string' && Number.isFinite(x.at) && Number.isFinite(x.progress)
970 && (x.outcome === undefined || ['moved', 'none', 'gone'].includes(x.outcome))
971 && (x.checkedAt === undefined || Number.isFinite(x.checkedAt)))) throw new Error('Invalid launch history')
972 return v
973}
974const LAUNCH_WINDOW_MS = 48 * 3600_000
975async function activeLaunch($: E, id: string): Promise<LaunchHistoryEntry | undefined> {
976 try {
977 const now = await $.clock.now()
978 const history = await launchHistory($, `${await dirOf($)}/mod-launch-history.json`)
979 return history.filter(x => x.id === id && x.at <= now && now - x.at < LAUNCH_WINDOW_MS)
980 .sort((a, b) => b.at - a.at)[0]
981 } catch { return undefined } // History remains optional, as it is for outcome tracking.
982}
983
984// A short tag per thread rides in the launched prompt (sessionToken, work.ts), so the new session's title or first prompt names its thread.
985
986/** Only creation time proves that a session belongs to this launch; activity time does not. */
987async function delegatedSession($: E, thread: Thread, at: number): Promise<string | undefined> {
988 try {
989 const result = await $.mcp.call('ccd_session_mgmt', 'list_sessions', { limit: 30 })
990 if (result.isError) return
991 const rows: unknown = JSON.parse(result.content.filter(c => c.type === 'text').map(c => c.text).join('\n'))
992 if (!Array.isArray(rows)) return
993 const normalize = (v: unknown) => typeof v === 'string' ? v.replace(/[^\p{L}\p{N}]+/gu, ' ').trim().toLowerCase() : ''
994 const title = normalize(plain(thread))
995 const token = ` ${normalize(sessionToken(thread.id))} `
996 const now = await $.clock.now()
997 const matches = rows.filter(row => {
998 if (!row || typeof row.sessionId !== 'string' || !/^[\w-]+$/.test(row.sessionId)) return false
999 // Live rows carry lastActivityAt but no createdAt: a session last active before the launch cannot be its child.
1000 const stamp = row.createdAt ?? row.lastActivityAt
1001 const created = typeof stamp === 'number' ? stamp : Date.parse(stamp)
1002 return Number.isFinite(created) && created >= at && created <= now
1003 && [row.title, row.firstPrompt].some(v => { const n = ` ${normalize(v)} `; return n.includes(token) || (!!title && n.includes(` ${title} `)) })
1004 })
1005 // An ambiguous title is not enough evidence to send the user to a particular session.
1006 if (matches.length !== 1) return
1007 return /^claude:\/\//.test(String(matches[0].link)) ? matches[0].link : `claude://resume?session=${matches[0].sessionId}`
1008 } catch { return undefined } // Outside the desktop app the marker still works.
1009}
1010
1011// Drawing cannot write files. Defer to the clock and serialize with launch reservations,
1012// rereading under their cross-session lock so an outcome never overwrites a new launch.
1013let checkingOutcomes = false
1014function ensureLaunchOutcomes($: E) {
1015 if (checkingOutcomes) return
1016 checkingOutcomes = true
1017 $.clock.after(0, () => {
1018 const run = async () => {
1019 let locked = false
1020 let lock = ''
1021 try {
1022 const b = await read($, board)
1023 if (!b || await read($, problem)) return
1024 const dir = await dirOf($)
1025 if (!dir) return
1026 const path = `${dir}/mod-launch-history.json`
1027 if (!(await $.fs.exists(path))) return
1028 lock = `${dir}/.mod-launches.lock`
1029 locked = (await $.process.run(['/bin/mkdir', lock])).exitCode === 0
1030 if (!locked) return // The next drawing retries a busy history check.
1031 const history = await launchHistory($, path)
1032 const now = await $.clock.now()
1033 let changed = false
1034 for (const entry of history) {
1035 if (entry.outcome !== undefined || now - entry.at < LAUNCH_WINDOW_MS) continue
1036 const t = b.threads.find(t => t.id === entry.id)
1037 entry.outcome = !t ? 'gone' : t.progress >= 100 || t.progress > entry.progress ? 'moved' : 'none'
1038 entry.checkedAt = now
1039 changed = true
1040 }
1041 if (changed) await $.fs.write(path, JSON.stringify(history))
1042 } catch { /* Optional history never prevents the board from rendering. */ }
1043 finally {
1044 try { if (locked) await $.process.run(['/bin/rmdir', lock]) }
1045 finally { checkingOutcomes = false }
1046 }
1047 }
1048 launchQueue = launchQueue.then(run, run).catch(() => { checkingOutcomes = false })
1049 return launchQueue
1050 })
1051}
1052async function reconcileLaunches($: E) {
1053 if (!pendingLaunches.size) return
1054 const b = await read($, board)
1055 const m = await read($, marks)
1056 const now = await $.clock.now()
1057 const evidence = await read($, prs)
1058 for (const [id, pending] of pendingLaunches) {
1059 const t = b?.threads.find(t => t.id === id)
1060 const stage = t && stageOf(t, (evidence?.items ?? []) as StagePR[], now)
1061 if (!t || isHidden(m, id, now) || !stage || stage.stage !== pending.stage || stage.skill !== pending.skill
1062 || (stage.live && (!evidence || now - evidence.at > PRS_EVERY_MS || await read($, prsProblem)))) await undoLaunch($, id)
1063 }
1064}
1065async function undoLaunch($: E, id: string) {
1066 pendingLaunches.get(id)?.cancel()
1067 pendingLaunches.delete(id)
1068 await syncLaunch($, id)
1069}
1070async function launchSkill($: E, thread: Thread, stage: Stage, surface: Surface): Promise<boolean | undefined> {
1071 if (!dataDir || pendingLaunches.has(thread.id) || await activeLaunch($, thread.id)) return false
1072 try { if ((await launchLedger($)).count >= 5) return false } catch { await $.ui.toast('Could not read mod-launches.json. Restore it before launching.'); return false }
1073 if (pendingLaunches.has(thread.id)) return false
1074 const entry: PendingLaunch = { skill: stage.skill, stage: stage.stage, cancel() {} }
1075 pendingLaunches.set(thread.id, entry)
1076 const enqueue = () => {
1077 launchQueue = launchQueue.then(run, run)
1078 return launchQueue
1079 }
1080 const run = async (): Promise<void> => {
1081 if (pendingLaunches.get(thread.id) !== entry) return
1082 const dir = await dirOf($)
1083 const lock = `${dir}/.mod-launches.lock`
1084 let locked = false
1085 try {
1086 // mkdir is atomic across local sessions. A busy reservation defers itself, retaining Undo.
1087 locked = (await $.process.run(['/bin/mkdir', lock])).exitCode === 0
1088 if (!locked) {
1089 if (pendingLaunches.get(thread.id) === entry) {
1090 entry.waiting = true
1091 const retry = $.clock.after(1000, enqueue)
1092 entry.cancel = () => retry.cancel()
1093 }
1094 return
1095 }
1096 if (pendingLaunches.get(thread.id) !== entry) return
1097 const now = await $.clock.now()
1098 const current = (await read($, board))?.threads.find(t => t.id === thread.id)
1099 const fresh = current && stageOf(current, ((await read($, prs))?.items ?? []) as StagePR[], now)
1100 const evidence = await read($, prs)
1101 if (fresh?.live && (!evidence || now - evidence.at > PRS_EVERY_MS || await read($, prsProblem))) {
1102 await $.ui.toast('PR evidence unavailable; launch cancelled')
1103 return
1104 }
1105 if (!current || isHidden(await read($, marks), thread.id, now) || !fresh || fresh.stage !== stage.stage || fresh.skill !== stage.skill) {
1106 await $.ui.toast('Thread changed; launch cancelled')
1107 return
1108 }
1109 if (await activeLaunch($, thread.id)) return
1110 const ledger = await launchLedger($)
1111 // Undo remains effective through the asynchronous reads; an older attempt never clears a new one.
1112 if (pendingLaunches.get(thread.id) !== entry) return
1113 // Once execution starts, budget is reserved before opening the app; failures cannot overspend.
1114 pendingLaunches.delete(thread.id)
1115 if (ledger.count >= 5) { await $.ui.toast('5 of 5 launches used today'); return }
1116 const next = { date: ledger.date, count: ledger.count + 1, log: [...ledger.log, { id: thread.id, skill: fresh.skill, at: now }] }
1117 await $.fs.write(`${dir}/mod-launches.json`, JSON.stringify(next))
1118 try {
1119 const path = `${dir}/mod-launch-history.json`
1120 const history = await launchHistory($, path)
1121 history.push({ id: current.id, skill: fresh.skill, stage: fresh.stage, at: now, progress: current.progress })
1122 await $.fs.write(path, JSON.stringify(history.slice(-500)))
1123 } catch { /* An unavailable history must not change the daily cap or launch. */ }
1124 // A darren-core skill only exists where darren-core is installed; elsewhere a plain instruction stands in.
1125 const skillLine = fresh.skill.startsWith('darren-core:') && !(await hasDarrenCore($))
1126 ? 'Unblock or finish this thread: take the next concrete step, verify it, and say what is left.'
1127 : `Start with the ${fresh.skill} skill.`
1128 const tag = `Name this session with the tag [${sessionToken(current.id)}].`
1129 await startSide($, current, surface, `${sidePrompt(current)}\n${skillLine} ${tag}`)
1130 } catch { await $.ui.toast('Launch could not finish. Check mod-launches.json before trying again.') }
1131 finally {
1132 if (locked) {
1133 if (pendingLaunches.get(thread.id) === entry) pendingLaunches.delete(thread.id)
1134 await $.process.run(['/bin/rmdir', lock])
1135 }
1136 await syncLaunch($, thread.id)
1137 }
1138 }
1139 const timer = $.clock.after(10_000, enqueue)
1140 entry.cancel = () => timer.cancel()
1141 await syncLaunch($, thread.id)
1142}
1143async function launchControl($: E, e: RenderInput, thread: Thread, prefix: string, delegate = false): Promise<RenderElement> {
1144 const { Box, Text, Button } = $.ui.resolve(e)
1145 if (!dataDir) return <Box />
1146 // Subscribe to launch changes even when history replaces the launch button.
1147 const pending = (await read($, launches))[thread.id]
1148 const delegated = await activeLaunch($, thread.id)
1149 if (delegated) {
1150 const link = await delegatedSession($, thread, delegated.at)
1151 return <Box key={`${prefix}-delegated-${thread.id}`} gap="xs" flexWrap="wrap" alignItems="center">
1152 <Text size="caption" dimColor>{`Delegated ${waitFor(await $.clock.now() - delegated.at)}`}</Text>
1153 {link ? <Button key={`${prefix}-delegated-open-${thread.id}`} plain label="Open session" onPress={act($, 'open-session', () => openUrl($, link), { label: 'open the session' })} /> : null}
1154 </Box>
1155 }
1156 // A reload drops the cancel handles: a launch left in state with none is gone, so the clock clears it.
1157 if (pending && !pendingLaunches.has(thread.id)) $.clock.after(0, () => syncLaunch($, thread.id))
1158 if (pending) return <Box gap="xs" flexWrap="wrap" alignItems="center">
1159 <Text size="caption">{pending.waiting ? `Waiting to launch ${pending.skill} ·` : `Launching ${pending.skill} in 10 s ·`}</Text>
1160 <Button key={`${prefix}-launch-undo-${thread.id}`} plain label="Undo" onPress={act($, 'undo-launch', () => undoLaunch($, thread.id), { label: 'cancel the launch' })} />
1161 </Box>
1162 const evidence = await read($, prs)
1163 const now = await $.clock.now()
1164 const stage = stageOf(thread, (evidence?.items ?? []) as StagePR[], now)
1165 if (!stage) return <Box />
1166 if (stage.live && (!evidence || now - evidence.at > PRS_EVERY_MS || await read($, prsProblem))) return <Text size="caption" dimColor>PR evidence unavailable; rescan before launching</Text>
1167 if (!stage.live && !delegate) return <Text key={`${prefix}-stage-hint`} size="caption" dimColor>{stageHint(stage)}</Text>
1168 if (!repoRoot(cwdOf(thread))) return <Text size="caption" dimColor>Repository folder unavailable; launch disabled</Text>
1169 let count: number
1170 try { count = (await launchLedger($)).count } catch { return <Text size="caption" dimColor>Launches unavailable: mod-launches.json is unreadable</Text> }
1171 if (count >= 5) return <Text size="caption" dimColor>5 of 5 launches used today</Text>
1172 return <Box flexDirection="column" gap="xs">
1173 {delegate ? null : <Text size="caption" dimColor>{stage.reason}</Text>}
1174 <Button key={`${prefix}-launch-${thread.id}`} label={delegate ? 'Delegate' : launchLabel(stage)} onPress={act($, 'launch', (pe: UiPressArgument) => launchSkill($, thread, stage, pe.surface), { label: 'launch the skill', noop: 'It is already launching, or today\'s 5 launches are used.' })} />
1175 </Box>
1176}
1177
1178export const register: Register = (on, options) => {
1179 pendingLaunches.forEach(p => p.cancel())
1180 pendingLaunches.clear()
1181 dataDir = typeof options.dataDir === 'string' ? options.dataDir.trim() : ''
1182 openOnStart = options.openOnStart !== false
1183 reloadHelper = typeof options.reloadHelper === 'string' ? options.reloadHelper.trim() : ''
1184 dispatchOpt = typeof options.dispatchDir === 'string' ? options.dispatchDir.trim() : ''
1185 attention = createAttention()
1186 bindEvents(dataDir)
1187 recordActivity(on)
1188
1189 on('session.start', async ($, e, next) => {
1190 await $.command.register({ name: 'nextup', description: 'Open the Next up pane' })
1191 await $.command.register({ name: 'nextup-refresh', description: 'Rescan and reload the Next up board' })
1192 await $.command.register({ name: 'nextup-ask', description: 'List waiting questions with ids and choices' })
1193 await $.command.register({ name: 'nextup-answer', description: 'Answer a waiting question: <id> <choice>' })
1194 // darren-core may register /handoff-and-clear after this pane first draws: look again once it has had time.
1195 for (const ms of [3000, 10_000]) $.clock.after(ms, () => void refreshHandoff($))
1196 await update($, launches, () => ({}))
1197 // Open the pane first, without taking the keyboard, so it shows before the board has loaded. Not gated on
1198 // isInteractive: the desktop app runs on the SDK, which reports no person and no surface yet; the engine
1199 // holds the open undrawn until a surface attaches, and a -p run never attaches one.
1200 // Not awaited: an open the engine holds back (a narrow terminal) never holds up the session.hooks/attention.ts 144 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4// The shapes the card draws live in the state contract (types/index.d.ts): the last read is kept in state.
5import type { Attention, AttentionRead, Counts, Ratings, Recent } from '../types'
6export type { Attention, AttentionRead, Counts, Ratings, Recent }
7export const ATTENTION_MS = 60_000
8const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
9const list = (v: unknown) => Array.isArray(v) ? v.filter(object) : []
10const number = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0
11const isDay = (v: unknown): v is string => typeof v === 'string' && /^\d{4}-\d\d-\d\d$/.test(v) && Number.isFinite(Date.parse(`${v}T00:00:00Z`))
12const counts = (v: Record<string, unknown>): v is Record<string, unknown> & Counts => isDay(v.day) && ['a', 'b', 'c', 'd', 'total', 'blocked_wait_min'].every(k => number(v[k]))
13
14export function parseAttention(raw: unknown): Attention | null {
15 if (!object(raw)) return null
16 const totals = list(raw.totals).filter(counts).sort((a, b) => a.day.localeCompare(b.day))
17 const today = totals[totals.length - 1]?.day
18 if (!today) return null
19 const projects = list(raw.projects).filter(p => typeof p.project === 'string')
20 return {
21 today, totals,
22 trials: projects.filter(p => p.trial === true).map(p => p.project as string),
23 projects: projects.flatMap(p => {
24 const c = list(p.days).filter(counts).find(d => d.day === today)
25 return c && c.total > 0 ? [{ project: p.project as string, trial: p.trial === true, counts: c }] : []
26 }).sort((a, b) => Number(b.trial) - Number(a.trial) || a.project.localeCompare(b.project)),
27 recent: list(raw.recent).filter(r => typeof r.id === 'string' && typeof r.project === 'string' && typeof r.ts === 'string' && Number.isFinite(Date.parse(r.ts)) && isDay(r.day) && ['a', 'b', 'c', 'd'].includes(String(r.cls)) && typeof r.summary === 'string' && number(r.wait_min))
28 .map(r => r as unknown as Recent).sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts)),
29 }
30}
31
32export const projectFlag = (c: Counts) => c.total > 3 || (c.total > 0 && (c.a + c.b) / c.total < 0.9)
33
34function parseRatings(raw: unknown): Ratings {
35 if (!object(raw) || Object.values(raw).some(v => !object(v) || !['worth', 'not'].includes(String(v.rating)) || typeof v.at !== 'string' || !Number.isFinite(Date.parse(v.at)))) throw new Error('Ratings unreadable')
36 return raw as Ratings
37}
38
39export function mergeRatings(a: Ratings, b: Ratings): Ratings {
40 const result = { ...a }
41 for (const [id, value] of Object.entries(b)) {
42 if (!Object.hasOwn(result, id) || Date.parse(value.at) >= Date.parse(result[id]!.at)) {
43 Object.defineProperty(result, id, { value, enumerable: true, configurable: true, writable: true })
44 }
45 }
46 return result
47}
48
49// What the card draws lives in state, so a write redraws it on its own and a hot reload keeps it.
50const attentionAtom = atom({ plugin: 'nextup', key: 'attention' } as const, null)
51/** Session-local coordination only: a read in flight (kept until state holds it) and the rating writes queue. */
52export const createAttention = () => ({ pending: Promise.resolve() as Promise<void>, loading: null as { dir: string; promise: Promise<AttentionRead> } | null })
53type State = ReturnType<typeof createAttention>
54async function readRatings($: EngineInterface, dir: string): Promise<Ratings> {
55 return await $.fs.exists(`${dir}/interruption-ratings.json`) ? parseRatings(JSON.parse(await $.fs.read(`${dir}/interruption-ratings.json`) as string)) : {}
56}
57const fresh = (r: AttentionRead | null, dir: string, now: number): r is AttentionRead => r?.dir === dir && now - r.at < ATTENTION_MS
58
59/** For a drawing: the read in state while it is under a minute old, else a new one (shared by overlapping draws).
60 * A drawing cannot write state, so the clock stores the new read; until then later draws reuse it.
61 */
62export async function drawnAttention($: EngineInterface, state: State, dir: string) {
63 if (!dir) return null
64 const now = await $.clock.now().catch(() => Date.now())
65 const held = await read($, attentionAtom)
66 if (fresh(held, dir, now)) return held
67 if (state.loading?.dir === dir) return state.loading.promise
68 const promise = readAttention($, dir, now)
69 state.loading = { dir, promise }
70 $.clock.after(0, async () => {
71 try { await store($, state, dir, promise) } catch { /* The next draw reads again. */ }
72 })
73 return promise
74}
75
76/** For the live loop: reread when the read in state is a minute old, and store it. */
77export async function refreshAttention($: EngineInterface, state: State, dir: string) {
78 if (!dir) return
79 const now = await $.clock.now().catch(() => Date.now())
80 if (fresh(await read($, attentionAtom), dir, now)) return
81 if (state.loading?.dir === dir) return store($, state, dir, state.loading.promise)
82 const promise = readAttention($, dir, now)
83 state.loading = { dir, promise }
84 await store($, state, dir, promise)
85}
86
87async function store($: EngineInterface, state: State, dir: string, promise: Promise<AttentionRead>) {
88 try {
89 const next = await promise
90 // A rating saved meanwhile is newer than this read: keep it.
91 await update($, attentionAtom, held => held?.dir === dir && held.at >= next.at ? held : next)
92 } finally {
93 if (state.loading?.promise === promise) state.loading = null
94 }
95}
96
97async function readAttention($: EngineInterface, dir: string, now: number): Promise<AttentionRead> {
98 let data: Attention | null = null
99 let ratings: Ratings = {}
100 try { data = parseAttention(JSON.parse(await $.fs.read(`${dir}/interruptions.json`) as string)) } catch { /* Missing or invalid input hides the card. */ }
101 if (data) { try { ratings = await readRatings($, dir) } catch { /* A rating action reports unreadable ratings without overwriting them. */ } }
102 return { dir, at: now, data, ratings }
103}
104async function ratingStamp($: EngineInterface, dir: string) {
105 const path = `${dir}/interruption-ratings.json`
106 if (!(await $.fs.exists(path))) return null
107 const stat = await $.fs.stat(path)
108 return `${stat.mtimeMs}:${stat.size}`
109}
110/** Reread on a concurrent file change; refuse a busy file instead of overwriting unseen ids.
111 * The engine has no atomic rename or cross-process transaction, so this is optimistic concurrency.
112 */
113export function rateAttention($: EngineInterface, state: State, dir: string, id: string, rating: 'worth' | 'not') {
114 const run = state.pending.then(async () => {
115 const at = new Date(await $.clock.now()).toISOString()
116 for (let attempt = 0; attempt < 3; attempt++) {
117 // The read the card drew: in state, or one a drawing has not stored yet.
118 const held = await read($, attentionAtom)
119 const base = held?.dir === dir ? held : state.loading?.dir === dir ? await state.loading.promise : null
120 const before = await ratingStamp($, dir)
121 const disk = await readRatings($, dir)
122 const ratings = mergeRatings(mergeRatings(base?.ratings ?? {}, disk), { [id]: { rating, at } })
123 if (before !== await ratingStamp($, dir)) continue
124 await $.fs.write(`${dir}/interruption-ratings.json`, JSON.stringify(ratings, null, 2))
125 if (base) await update($, attentionAtom, () => ({ ...base, ratings }))
126 return
127 }
128 throw new Error('Ratings changed repeatedly; try again')
129 })
130 state.pending = run.catch(() => undefined)
131 return run
132}
133
134/** Fourteen calendar days ending at the newest totals day, filling gaps with zero. */
135export function attentionSparkline(data: Attention): string {
136 const end = Date.parse(`${data.today}T00:00:00Z`)
137 const values = Array.from({ length: 14 }, (_, i) => {
138 const day = new Date(end - (13 - i) * 86_400_000).toISOString().slice(0, 10)
139 return data.totals.find(d => d.day === day)?.total ?? 0
140 })
141 const max = Math.max(1, ...values)
142 return values.map(n => '▁▂▃▄▅▆▇█'[Math.round(n / max * 7)]).join('')
143}
144hooks/activity.tsx 553 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderElement, RenderInput } from 'claude-code'
3
4import { drawDispatch } from './dispatch'
5import { act, view } from './events'
6import { desk, surfaceTheme, tone } from './theme'
7import type { FileTouch, ShellRun, Spawn, ToolEvent } from '../types'
8
9// What this session has done, recorded from its own tool calls as they happen.
10// Kept in session state only: nothing is written to disk or sent anywhere.
11
12export const LOG_CAP = 500
13export const FILES_CAP = 200
14export const SHELL_CAP = 200
15/** Longest command text kept; a heredoc can be pages long. */
16export const COMMAND_CHARS = 2000
17
18export const log = atom({ plugin: 'nextup', key: 'log' } as const, [])
19export const files = atom({ plugin: 'nextup', key: 'files' } as const, [])
20export const shell = atom({ plugin: 'nextup', key: 'shell' } as const, [])
21export const turn = atom({ plugin: 'nextup', key: 'turn' } as const, null)
22export const spawns = atom({ plugin: 'nextup', key: 'spawns' } as const, [])
23const more = atom({ plugin: 'nextup', key: 'more' } as const, [])
24
25export async function pressActivity($: EngineInterface, id: string) {
26 if (id.startsWith('file-') || id === 'turn-calls') {
27 await update($, more, s => s.includes(id) ? s.filter(x => x !== id) : [...s, id])
28 } else if (['gate-waiting', 'gate-allowed', 'gate-asked', 'gate-denied'].includes(id)) {
29 const gate = gateStats(await read($, log))
30 const count = id === 'gate-waiting' ? (await read($, log)).filter(isWaiting).length : id === 'gate-allowed' ? gate.allowed : id === 'gate-asked' ? gate.asked : gate.denied
31 if (count) await update($, more, s => [...s.filter(x => !['gate-waiting', 'gate-allowed', 'gate-asked', 'gate-denied'].includes(x)), ...(s.includes(id) ? [] : [id])])
32 }
33}
34
35const WRITES = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
36
37const firstLine = (v: string, n = 120) => {
38 const line = v.trim().split('\n')[0] ?? ''
39 return line.length > n ? `${line.slice(0, n - 1)}…` : line
40}
41
42/** A path under the session's folder is shown relative to it. */
43export const relative = (path: string, cwd: string) =>
44 cwd && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
45
46type Args = Record<string, unknown>
47const s = (a: Args, k: string) => (typeof a[k] === 'string' ? (a[k] as string) : '')
48
49/** The path a writing tool changed, or '' for any other tool. */
50export function touchedPath(tool: string, a: Args) {
51 if (!WRITES.has(tool)) return ''
52 return s(a, 'file_path') || s(a, 'notebook_path')
53}
54
55/** One line saying what the call did, from its arguments. */
56export function summarise(tool: string, a: Args, cwd = '') {
57 const path = s(a, 'file_path') || s(a, 'notebook_path') || s(a, 'path')
58 if (tool === 'Bash') return firstLine(s(a, 'command'))
59 if (path && tool !== 'Grep' && tool !== 'Glob') return relative(path, cwd)
60 if (tool === 'Grep' || tool === 'Glob') return firstLine(`${s(a, 'pattern')}${path ? ` in ${relative(path, cwd)}` : ''}`)
61 if (tool === 'WebFetch') return firstLine(s(a, 'url'))
62 if (tool === 'WebSearch') return firstLine(s(a, 'query'))
63 if (tool === 'Agent' || tool === 'Task') return firstLine(s(a, 'description') || s(a, 'subagent_type'))
64 if (tool === 'Skill') return firstLine(s(a, 'skill'))
65 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
66 if (mcp) return `${mcp[1]} · ${mcp[2]}`
67 return ''
68}
69
70/** Short name for the log: an MCP tool by its own name, others as they are. */
71export const toolName = (tool: string) => /^mcp__.+?__(.+)$/.exec(tool)?.[1] ?? tool
72
73async function timestamp($: EngineInterface) {
74 try { return await $.clock.now() } catch { return Date.now() }
75}
76
77const TYPED = ['composer', 'bridge', 'sdk', 'user']
78const SESSIONS = ['peer', 'peer-send-message', 'projects-relay', 'coordinator', 'channel', 'slack-ping']
79
80/**
81 * A log row for a prompt: who sent it and a short line saying what it is. The person's own, typed
82 * or from their phone, is as typed with no sender ("you"). Anything else names its sender (a helper by
83 * its agent id, another session, or the main loop) and reads as a short label, never its raw markup:
84 * a helper's hand-back is "reported back", a task notification its summary, a session's message who sent it.
85 */
86export function describePrompt(text: string, origin: { kind: string } | string | undefined, helpers: Spawn[]): { from?: string; summary: string } {
87 const kind = typeof origin === 'string' ? origin : origin?.kind
88 const tag = /^\s*<([\w-]+)([^>]*)>/.exec(text)
89 if (!kind || TYPED.includes(kind) || (kind === 'unclassified' && !tag)) return { summary: firstLine(text) }
90 const attr = (name: string) => tag ? new RegExp(`(?:^|\\s)${name}="([^"]*)"`).exec(tag[2]!)?.[1] : undefined
91 const helper = (id?: string) => (id && helpers.find(x => x.id === id.trim())?.name) || undefined
92 const by = SESSIONS.includes(kind) ? 'session' : 'main'
93 switch (tag?.[1]) {
94 case 'agent-message': return { from: helper(attr('from')) ?? 'helper', summary: 'reported back' }
95 case 'task-notification': {
96 const field = (name: string) => new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(text)?.[1]?.trim()
97 const status = field('status')
98 return { from: helper(field('task-id')) ?? 'main', summary: firstLine(field('summary') || (status ? `task ${status}` : 'task finished')) }
99 }
100 case 'cross-session-message': {
101 const name = attr('name')
102 return { from: 'session', summary: firstLine(name ? `message from ${name}` : 'message from another session') }
103 }
104 case undefined: return { from: by, summary: firstLine(text) }
105 default: return { from: by, summary: tag![1]!.replace(/-/g, ' ') }
106 }
107}
108
109const cap = <T,>(list: T[], n: number) => (list.length > n ? list.slice(list.length - n) : list)
110
111/** Records every tool call; the call itself runs exactly as it would without this hook. */
112export function recordActivity(on: On) {
113 let seq = 0
114 // Classic permission requests have no call id; correlate with the still-running
115 // call's tool, arguments and loop instead of inferring prompts from outcomes.
116 const pending = new Map<string, { tool: string; args: Args; agent?: string }>()
117 on('classic.PermissionRequest', async ($, e, next) => {
118 try {
119 const entry = [...pending].find(([, x]) => x.tool === e.tool_name && x.agent === e.agent_id &&
120 Object.entries((e.tool_input ?? {}) as Args).every(([k, v]) => JSON.stringify(x.args[k]) === JSON.stringify(v)))
121 if (entry) await update($, log, l => l.map(x => x.id === entry[0] ? { ...x, asked: true } : x))
122 } catch { /* observational only */ }
123 return next(e)
124 })
125 on('prompt.submit', async ($, e, next) => {
126 if (!(e as unknown as Args).agentId) {
127 try {
128 const at = await timestamp($)
129 await update($, turn, () => ({ at }))
130 const { from, summary } = describePrompt(e.text, e.origin, await read($, spawns))
131 await update($, log, l => cap([...l, { id: `prompt-${at}-${++seq}`, at, tool: '', summary, status: 'ok', prompt: true, ...(from ? { from } : {}) }], LOG_CAP))
132 } catch { /* observational only */ }
133 }
134 return next(e)
135 })
136 on('agent.spawn', async ($, e, next) => {
137 const at = await timestamp($)
138 const out = await next(e)
139 if (out.agentId) {
140 const id = out.agentId
141 try { await update($, spawns, l => cap([...l, { id, name: firstLine(e.description || e.subagentType || 'Helper'), at, call: e.tool_use_id }], LOG_CAP)) } catch { /* observational only */ }
142 }
143 return out
144 })
145 on('turn.complete', async ($, e, next) => {
146 try {
147 const end = await timestamp($)
148 if (e.agentId) await update($, spawns, l => l.map(x => x.id === e.agentId && x.end === undefined ? { ...x, end } : x))
149 else await update($, turn, t => t ? { ...t, end } : t)
150 } catch { /* observational only */ }
151 return next(e)
152 })
153 on('tool.call', async ($, e, next) => {
154 const id = e.tool_use_id ?? `call-${Date.now()}-${++seq}`
155 const args = e as unknown as Args
156 const started = await timestamp($)
157 pending.set(id, { tool: e.tool, args, agent: e.agentId })
158 let cwd = ''
159 try {
160 cwd = await $.session.cwd()
161 const event: ToolEvent = { id, at: started, tool: e.tool, summary: summarise(e.tool, args, cwd), status: 'running' }
162 if (typeof e.agentId === 'string' && e.agentId) event.agent = e.agentId
163 await update($, log, l => cap([...l, event], LOG_CAP))
164 if (e.tool === 'Bash') {
165 const run: ShellRun = { id, command: s(args, 'command').trim().slice(0, COMMAND_CHARS), at: started, status: 'running' }
166 await update($, shell, l => cap([...l, run], SHELL_CAP))
167 }
168 } catch {
169 // Recording never stands in the way of the call.
170 }
171 let out
172 try { out = await next(e) } finally { pending.delete(id) }
173 try {
174 const status: ToolEvent['status'] = out.deny !== undefined ? 'denied' : out.isError ? 'failed' : 'ok'
175 const finished = await timestamp($)
176 const ms = finished - started
177 await update($, log, l => l.map(x => (x.id === id ? { ...x, status, ms } : x)))
178 if (e.tool === 'Bash') await update($, shell, l => l.map(x => (x.id === id ? { ...x, status, ms } : x)))
179 const path = status === 'ok' ? touchedPath(e.tool, args) : ''
180 if (path) {
181 const shown = relative(path, cwd)
182 await update($, files, l => {
183 const was = l.find(f => f.path === shown)
184 const rest = l.filter(f => f.path !== shown)
185 const touch: FileTouch = { path: shown, count: (was?.count ?? 0) + 1, at: finished, tool: e.tool,
186 edits: cap([...(was?.edits ?? []), { at: finished, tool: e.tool, ...(e.agentId ? { agent: e.agentId } : {}) }], 20) }
187 return cap([...rest, touch], FILES_CAP)
188 })
189 }
190 } catch {
191 // As above: the result goes back untouched.
192 }
193 return out
194 })
195}
196
197
198// ---------- Gate and helpers, worked out from the log ----------
199
200export type Gate = { total: number; allowed: number; denied: number; asked?: number; running: number }
201
202/** Finished calls split into allowed and denied; a call still running is counted apart. */
203export function gateStats(events: ToolEvent[]): Gate {
204 const g: Gate = { total: 0, allowed: 0, denied: 0, running: 0 }
205 for (const x of events) {
206 if (x.prompt) continue
207 if (x.asked) g.asked = (g.asked ?? 0) + 1
208 if (x.status === 'running') {
209 g.running += 1
210 continue
211 }
212 g.total += 1
213 if (x.status === 'denied') g.denied += 1
214 else if (!x.asked) g.allowed += 1
215 }
216 return g
217}
218
219const isWaiting = (x: ToolEvent) => !x.prompt && x.asked === true && x.status === 'running'
220
221/** Keep live and recent lanes; age a finish from completion, not launch. */
222export function sessionLanes(records: Spawn[], now: number) {
223 const visible = records.filter(x => x.end === undefined || now - x.end <= 2 * 60 * 60_000)
224 return { visible, finished: records.length - visible.length }
225}
226
227/** Helpers show an activity label, not tool arguments. Full details remain in the log. */
228export function helperLabel(event: ToolEvent) {
229 const labels: Record<string, string> = {
230 Bash: 'Running command', Read: 'Reading files', Edit: 'Editing files',
231 MultiEdit: 'Editing files', Write: 'Writing files', NotebookEdit: 'Editing notebook',
232 Grep: 'Searching files', Glob: 'Finding files', WebFetch: 'Fetching page',
233 WebSearch: 'Searching web', Agent: 'Delegating work', Task: 'Delegating work',
234 Skill: 'Using skill',
235 }
236 return labels[event.tool] ?? 'Using tool'
237}
238
239export type Tick = { id: string; at: number; bad: boolean }
240export type Helper = { id: string; name: string; status: ToolEvent['status']; at: number; ms?: number; start: number; len: number; calls: number; last?: ToolEvent; ticks: Tick[] }
241
242/**
243 * The helpers' shared time axis: from the earliest start to now while any runs, else to the last finish,
244 * so finished lanes keep their length however long the card stays open.
245 */
246export function helperAxis(records: Spawn[], now: number) {
247 const live = records.some(x => x.end === undefined)
248 const first = Math.min(now, ...records.map(x => x.at))
249 const last = live || !records.length ? now : Math.max(first, ...records.map(x => x.end!))
250 return { first, last, live }
251}
252
253/** How long after a batch's last helper ends (or last called a tool, while it runs) a new start still joins it. */
254export const BATCH_GAP = 120_000
255/** How long after its start a running helper counts as busy even before its first call. */
256export const BUSY = 600_000
257
258/**
259 * The latest batch of helpers, oldest first: walking by start time, a helper that starts more than
260 * BATCH_GAP after every helper so far has ended begins a new batch. A running helper reaches as far as
261 * its last call, and at least BUSY past its start (it may be thinking), so one left idle cannot hold a
262 * batch open for hours. Without `events`
263 * a running helper reaches now.
264 */
265export function latestBatch(records: Spawn[], now: number, events?: ToolEvent[]): Spawn[] {
266 const reach = (x: Spawn) => x.end ?? (events ? Math.max(Math.min(now, x.at + BUSY), ...events.filter(e => e.agent === x.id).map(e => e.at)) : now)
267 let batch: Spawn[] = []
268 let until = -Infinity
269 for (const x of [...records].sort((p, q) => p.at - q.at)) {
270 if (x.at > until + BATCH_GAP) batch = []
271 batch.push(x)
272 until = Math.max(until, reach(x))
273 }
274 return batch
275}
276
277/** A helper's own hand-back to the main loop: plumbing, never what it is doing, counted or ticked. */
278const INTERNAL = /^(SubagentHandback|StructuredOutput)$/
279
280/**
281 * Spawn lifetimes on one shared axis (helperAxis), each with its own calls (matched by agent id)
282 * as ticks. Never use Agent launch latency. `events` are oldest first.
283 */
284export function helperRuns(events: ToolEvent[], now: number, records: Spawn[] = []): Helper[] {
285 const { first, last } = helperAxis(records, now)
286 const width = Math.max(1, last - first)
287 const pos = (at: number) => Math.min(1, Math.max(0, (at - first) / width))
288 return records.map(x => {
289 const ms = x.end === undefined ? undefined : x.end - x.at
290 const own = events.filter(e => e.agent === x.id && !INTERNAL.test(e.tool))
291 const start = pos(x.at)
292 return {
293 id: x.id, name: x.name, status: x.end === undefined ? 'running' : 'ok', at: x.at, ms,
294 start, len: pos(x.end ?? now) - start, calls: own.length, last: own.at(-1),
295 ticks: own.map(e => ({ id: e.id, at: pos(e.at), bad: e.status === 'failed' || e.status === 'denied' })),
296 }
297 })
298}
299
300// ---------- Drawing ----------
301
302const MARK: Record<ToolEvent['status'], [string, string]> = {
303 running: ['◌', tone.brand],
304 ok: ['✓', tone.done],
305 failed: ['✗', tone.fail],
306 denied: ['⊘', tone.warn],
307}
308
309const clock = (at: number) => new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
310/** A length of time for the axis scale: 45s, 1m 20s, 2m, 1h 5m. */
311export const span = (ms: number) => {
312 const s = Math.max(0, Math.round(ms / 1000))
313 if (s < 60) return `${s}s`
314 if (s < 3600) return s % 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s / 60}m`
315 const m = Math.floor(s / 60)
316 return m % 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m / 60}h`
317}
318export const took = (ms?: number) => ms === undefined ? '' : `${Math.floor(ms / 60_000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`
319export const age = (at: number, now: number) => {
320 const m = Math.max(0, Math.floor((now - at) / 60_000))
321 return m < 1 ? 'now' : m < 60 ? `${m}m` : m < 1440 ? `${Math.floor(m / 60)}h` : `${Math.floor(m / 1440)}d`
322}
323
324// A phone-style switch, drawn: green track with the knob right when on, grey with it left when off.
325const fastPill = (on: boolean) => `<svg xmlns="http://www.w3.org/2000/svg" width="50" height="24" viewBox="0 0 50 24">` +
326 `<rect width="50" height="24" rx="12" fill="${on ? '#22c55e' : '#8e8e93'}" fill-opacity="${on ? 1 : 0.45}"/>` +
327 `<text x="${on ? 15 : 35}" y="15.5" text-anchor="middle" font-family="-apple-system, system-ui, sans-serif" font-size="9" font-weight="700" fill="#ffffff">${on ? 'ON' : 'OFF'}</text>` +
328 `<circle cx="${on ? 38 : 12}" cy="12" r="10" fill="#ffffff"/></svg>`
329
330/** How many rows each section shows before Show more. */
331export const SHOWN = { log: 7, files: 8, turn: 8 } as const
332
333/** The Session tab: what this session has done, newest first. */
334export async function drawActivity($: EngineInterface, e: RenderInput<'Pane'>, fast?: { on: boolean; toggle: () => unknown }): Promise<RenderElement> {
335 const els = $.ui.resolve(e)
336 const { Box, Text, Button } = els
337 const events = [...(await read($, log))].reverse()
338 // A hand-back or structured-output call is how a helper reports back; its "reported back" row already says so.
339 const logged = events.filter(x => x.prompt || !INTERNAL.test(x.tool))
340 const touched = [...(await read($, files))].reverse()
341 const current = await read($, turn)
342 const records = await read($, spawns)
343 const open = await read($, more)
344 const terminal = e.surface === 'terminal'
345 const skin = surfaceTheme(e.surface)
346 const now = await timestamp($)
347 const cut = <T,>(id: string, items: T[], n: number) => (open.includes(`act-${id}`) ? items : items.slice(0, n))
348 const moreRow = (id: string, total: number, n: number) =>
349 total > n ? (
350 <Box key={`am-${id}`} paddingX={1} hover={{ backgroundColor: skin.hover }}>
351 <Button
352 key={`amore-${id}`}
353 plain
354 dimColor
355 label={open.includes(`act-${id}`) ? 'Show less' : `Show ${total - n} more`}
356 onPress={view($, () => update($, more, s => (s.includes(`act-${id}`) ? s.filter(x => x !== `act-${id}`) : [...s, `act-${id}`])))}
357 />
358 </Box>
359 ) : null
360 const empty = (id: string, text: string) => (
361 <Box key={`ae-${id}`} paddingX={1}>
362 <Text size="caption" dimColor>{text}</Text>
363 </Box>
364 )
365 const card = (key: string, title: string, side: string, children: (RenderElement | null)[], glyph?: RenderElement, control?: RenderElement) => (
366 <Box key={`pc-${key}`} width="100%" flexShrink={1} minWidth={0} flexDirection="column" gap="xs" paddingX="sm" paddingY="xs" borderStyle="round" {...skin.card()}>
367 <Box width="100%" flexShrink={1} minWidth={0} justifyContent="space-between" alignItems="center" gap={1}>
368 <Box gap={1} flexShrink={0} alignItems="center">{glyph}<Text size={skin.title} weight="bold">{title}</Text>{control}</Box>
369 <Box flexShrink={1} minWidth={0}><Text size="caption" dimColor tabular wrap="truncate-end">{side}</Text></Box>
370 </Box>
371 {children}
372 </Box>
373 )
374 const gate = gateStats(events)
375 const waiting = events.filter(isWaiting).length
376 const counts = [waiting, gate.asked ?? 0, gate.denied]
377 const colors = [tone.warn, tone.accent, tone.warn]
378 const kinds = ['waiting', 'asked', 'denied'] as const
379 const who = (agent?: string) => agent ? records.find(a => a.id === agent)?.name || 'Helper' : 'main'
380 const row = (key: string, at: number, identity: string, detail: string, color: string) => (
381 <Box key={key} {...skin.rows} width="100%" flexShrink={1} minWidth={0} gap={1} flexWrap="nowrap" alignItems="center" hover={{ backgroundColor: skin.hover }}>
382 <Box width={5} flexShrink={0}><Text size="caption" dimColor tabular wrap="truncate-end">{clock(at)}</Text></Box>
383 <Box width={1} flexShrink={0}><Text size="caption" dimColor wrap="truncate-end">·</Text></Box>
384 <Box width={12} flexShrink={0}><Text size="caption" color={color} wrap="truncate-end">{firstLine(identity, 12)}</Text></Box>
385 <Box width={1} flexShrink={0}><Text size="caption" dimColor wrap="truncate-end">·</Text></Box>
386 <Box flexShrink={1} flexGrow={1} minWidth={0}><Text size="caption" wrap="truncate-end">{detail}</Text></Box>
387 </Box>
388 )
389 const callRow = (x: ToolEvent, prefix: string) => row(`${prefix}-${x.id}`, x.at, x.prompt ? x.from ?? 'you' : who(x.agent),
390 x.prompt ? x.summary : [toolName(x.tool), x.summary].filter(Boolean).join(' · '), x.agent || x.from ? tone.brand : x.prompt ? tone.quiet : tone.warn)
391 const gateCalls = (kind: typeof kinds[number] | 'allowed') => events.filter(x => !x.prompt && (kind === 'waiting' ? isWaiting(x) : kind === 'asked' ? x.asked : kind === 'denied' ? x.status === 'denied' : x.status !== 'running' && x.status !== 'denied' && !x.asked))
392 // Fast mode is the build workers' setting (Codex and pi), never this Claude session's own.
393 const pressFast = act($, 'worker-fast', async () => { if (fast) await fast.toggle() }, { label: 'change worker fast mode' })
394 const fastSwitch = fast ? terminal ?
395 <Button key="helper-fast" plain label={`⚡ Fast ${fast.on ? '●' : '○'}`} onPress={pressFast} /> :
396 <Box key="helper-fast-group" gap="xs" alignItems="center">
397 <Text color={fast.on ? tone.warn : tone.quiet}>⚡</Text>
398 {'Svg' in els ? <els.Svg source={fastPill(fast.on)} alt={`Fast mode ${fast.on ? 'on' : 'off'}`} width={50} height={24} /> :
399 <Text color={fast.on ? tone.done : tone.quiet}>{fast.on ? '●' : '○'}</Text>}
400 <Button key="helper-fast" plain label="Fast" onPress={pressFast} />
401 <Text size="caption" dimColor wrap="truncate-end">Codex and pi only</Text>
402 </Box> : undefined
403 const thisTurn = events.filter(x => !x.prompt && current && x.at >= current.at)
404 const failed = thisTurn.filter(x => x.status === 'failed').length
405 const edits = thisTurn.filter(x => WRITES.has(x.tool) && x.status === 'ok').length
406 // Collapse stale finishes before applying the row limit so they cannot hide live work.
407 const { visible, finished } = sessionLanes(records, now)
408 const shown = [...visible].sort((x, y) => y.at - x.at).slice(0, 6)
409 const helpers = helperRuns([...events].reverse(), now, shown)
410 // The lanes and their axis cover the latest batch only (latestBatch), at most six, oldest on top.
411 const batch = latestBatch(visible, now, events).slice(-6)
412 const lanes = helperRuns([...events].reverse(), now, batch)
413 const axis = helperAxis(batch, now)
414 const doing = (run: Helper) => {
415 const [glyph, color] = MARK[run.status]
416 return (
417 <Box key={`hn-${run.id}`} width="100%" flexShrink={1} minWidth={0} gap={1} flexWrap="nowrap" alignItems="center" hover={{ backgroundColor: skin.hover }}>
418 <Box width={1} flexShrink={0}><Text color={color}>{glyph}</Text></Box>
419 <Box width={12} flexShrink={0}><Text size="caption" wrap="truncate-end">{run.name}</Text></Box>
420 <Box flexShrink={1} flexGrow={1} minWidth={0}><Text size="caption" dimColor={!run.last} wrap="truncate-end">{run.last ? helperLabel(run.last) : run.status === 'running' ? 'starting' : 'done'}</Text></Box>
421 <Box flexShrink={0}><Text size="caption" dimColor tabular>{`${took(run.ms ?? now - run.at)} · ${run.calls} call${run.calls === 1 ? '' : 's'}`}</Text></Box>
422 </Box>
423 )
424 }
425 // Terminal: a fixed run of cells, ─ for the helper's span and │ for each call.
426 const cells = Math.max(10, Math.min(100, ((e.props as { bodyColumns?: number }).bodyColumns ?? 60) - 22))
427 const glyphLine = (run: Helper) => {
428 const cell = (at: number) => Math.min(cells - 1, Math.round(at * (cells - 1)))
429 const a = cell(run.start)
430 const b = Math.max(a, cell(run.start + run.len))
431 const chars = Array.from({ length: cells }, (_, i) => i < a || i > b ? [' ', tone.quiet] : ['─', tone.quiet])
432 for (const t of run.ticks) chars[cell(t.at)] = ['│', t.bad ? tone.fail : tone.brand]
433 const runs: [string, string][] = []
434 for (const [c, k] of chars) runs.at(-1)?.[1] === k ? (runs.at(-1)![0] += c) : runs.push([c!, k!])
435 return <Text size="caption">{runs.map(([c, k], i) => <Text key={`hg-${run.id}-${i}`} color={k}>{c}</Text>)}</Text>
436 }
437 // Desktop: flex segments across the full axis, a thin line for the span (a quarter cell) and a tall mark per call.
438 const boxLine = (run: Helper) => {
439 const seg = (key: string, grow: number, on: boolean) =>
440 <Box key={key} flexGrow={Math.max(0, grow)} width={0} height={0.25} {...(on ? { backgroundColor: run.status === 'running' ? tone.brand : desk.raised } : {})} />
441 const parts = [seg(`hs-${run.id}-pre`, run.start, false)]
442 let at = run.start
443 run.ticks.forEach((t, i) => {
444 const x = Math.max(at, t.at)
445 parts.push(seg(`hs-${run.id}-${i}`, x - at, true))
446 parts.push(<Box key={`tick-${run.id}-${i}`} flexShrink={0} width={0.25} height={1} backgroundColor={t.bad ? tone.fail : tone.brand} />)
447 at = x
448 })
449 parts.push(seg(`hs-${run.id}-end`, run.start + run.len - at, true), seg(`hs-${run.id}-post`, 1 - run.start - run.len, false))
450 return <Box key={`tl-${run.id}`} flexGrow={1} minWidth={0} height={1} alignItems="center">{parts}</Box>
451 }
452 const nameCol = (key: string, label: string) => <Box key={key} width={12} flexShrink={0}><Text size="caption" dimColor wrap="truncate-end">{label}</Text></Box>
453 const timeline = lanes.length ? <Box key="help-lines" width="100%" flexShrink={1} minWidth={0} flexDirection="column">
454 {lanes.map(run => <Box key={`hl-${run.id}`} width="100%" flexShrink={1} minWidth={0} gap={1} flexWrap="nowrap" alignItems="center">
455 {nameCol(`hln-${run.id}`, run.name)}
456 {terminal ? glyphLine(run) : boxLine(run)}
457 </Box>)}
458 <Box key="help-axis" width="100%" flexShrink={1} minWidth={0} gap={1} flexWrap="nowrap">
459 {nameCol('help-axis-pad', '')}
460 <Box flexShrink={terminal ? 0 : 1} minWidth={0} {...(terminal ? { width: cells } : { flexGrow: 1 })} justifyContent="space-between">
461 <Text size="caption" dimColor tabular>{clock(axis.first)}</Text>
462 <Text size="caption" dimColor tabular>{`+${span((axis.last - axis.first) / 2)}`}</Text>
463 <Text size="caption" dimColor tabular>{`${span(axis.last - axis.first)} · ${axis.live ? 'now' : clock(axis.last)}`}</Text>
464 </Box>
465 </Box>
466 </Box> : null
467 const lanesCard = await drawDispatch($, e)
468 const panels = [
469 card('sum', 'This turn', current ? `${clock(current.at)} · ${edits} edits · ${failed} errors` : 'No turn yet',
470 current && open.includes('turn-calls') ? [<Box key="turn-calls-list" flexDirection="column">
471 {thisTurn.length ? cut('turn', thisTurn, SHOWN.turn).map(x => callRow(x, 'at')) : [empty('turn', 'No tool calls this turn yet.')]}
472 {moreRow('turn', thisTurn.length, SHOWN.turn)}
473 </Box>] : [],
474 current ? <Text color={current.end === undefined ? tone.warn : tone.done}>{current.end === undefined ? '◐' : '✓'}</Text> : undefined,
475 current ? <Button key="turn-calls" plain dimColor label={`${thisTurn.length} call${thisTurn.length === 1 ? '' : 's'} ${open.includes('turn-calls') ? '▾' : '▸'}`} onPress={view($, () => pressActivity($, 'turn-calls'))} /> : undefined),
476 card('gate', 'Permissions', `${waiting} waiting · ${gate.denied} denied`, [
477 <Box key={`gate-history-${gate.allowed}`}>
478 <Button key="gate-allowed" plain dimColor label={`${gate.allowed} allowed`} onPress={view($, () => pressActivity($, 'gate-allowed'))} />
479 </Box>,
480 open.includes('gate-allowed') ? <Box key="gate-calls-allowed" flexDirection="column">
481 {cut('gate-allowed', gateCalls('allowed'), 8).map(x => callRow(x, 'ag-allowed'))}
482 {moreRow('gate-allowed', gateCalls('allowed').length, 8)}
483 </Box> : null,
484 // The counts are in the key: a desktop native Button may keep the label it first drew, so a new count
485 // draws new Buttons rather than relabelling the old ones (tiles stuck at 0 in 0.4.31).
486 <Box key={`gate-tiles-${counts.join('-')}`} width="100%" gap="sm">{kinds.map((kind, i) => {
487 const id = `gate-${kind}`
488 const active = open.includes(id)
489 const n = counts[i]!
490 const label = kind === 'asked' ? 'asked you' : kind
491 const fill = active ? desk.raised : desk.track
492 // Desktop: the tile is a column that stretches its one child, the native Button, so the whole tile takes the press.
493 return <Box key={`gate-tile-${kind}`} {...(!terminal ? { flexGrow: 1, width: 0, flexDirection: 'column' as const, alignItems: 'stretch' as const, backgroundColor: fill, borderStyle: 'round' as const, borderColor: fill, hover: { backgroundColor: desk.raised } } : {})}>
494 {terminal ? <Button key={id} plain label={`${n} ${label}`} dimColor={!n} onPress={view($, () => pressActivity($, id))}>
495 <Text tabular weight={active ? 'bold' : 'regular'} dimColor={!n}>{`${n} ${label}`}</Text>
496 </Button> : <Button key={id} plain label={`${n} ${label}`} dimColor={!n} onPress={view($, () => pressActivity($, id))}>
497 <Text size="caption" color={colors[i]} dimColor={!n}>●</Text>
498 {' '}
499 <Text weight="bold" tabular dimColor={!n}>{String(n)}</Text>
500 {` ${label}`}
501 </Button>}
502 </Box>
503 })}</Box>,
504 ...kinds.map(kind => open.includes(`gate-${kind}`) ? <Box key={`gate-calls-${kind}`} flexDirection="column">
505 {cut(`gate-${kind}`, gateCalls(kind), 8).map(x => callRow(x, `ag-${kind}`))}
506 {moreRow(`gate-${kind}`, gateCalls(kind).length, 8)}
507 </Box> : null),
508 ]),
509 records.length || fast ? card('help', 'Helpers', `${records.filter(x => x.end === undefined).length} running · ${records.length} total`, [
510 ...helpers.map(doing), timeline,
511 finished ? empty('help-finished', `${finished} finished`) : null,
512 !records.length ? empty('help', 'No helpers yet.') : null,
513 ], undefined, fastSwitch) : null,
514 lanesCard,
515 ].filter(Boolean)
516
517 return (
518 <Box width="100%" flexShrink={1} minWidth={0} flexDirection="column" gap="md">
519 {panels}
520 {card('files', 'Files touched', `${touched.length} edited`, touched.length ? [
521 ...cut('files', touched, SHOWN.files).map(f => {
522 const path = f.path
523 const slash = path.lastIndexOf('/')
524 const parent = slash < 0 ? '' : path.slice(0, slash).split('/').filter(Boolean).at(-1)
525 const hint = terminal ? (slash < 0 ? './' : path.slice(0, slash + 1)) : path.startsWith('/') ? `~/…/${parent ? `${parent}/` : ''}` : parent ? `${parent}/` : './'
526 const id = `file-${path}`
527 const expanded = open.includes(id)
528 return <Box key={`file-wrap-${path}`} flexDirection="column" width="100%" flexShrink={1} minWidth={0}>
529 {/* Desktop: one native Button holds the name and its hint, stretched across the row, so the whole row takes the press. */}
530 {terminal ? <Box key={`af-${f.path}`} {...skin.rows} width="100%" flexShrink={1} minWidth={0} gap={1} justifyContent="space-between" alignItems="center">
531 <Box flexShrink={1} flexGrow={1} minWidth={0}><Button key={id} plain label={`${expanded ? '▾' : '▸'} ${path.slice(slash + 1)}`} onPress={view($, () => pressActivity($, id))}><Text size="caption" wrap="truncate-end">{expanded ? '▾ ' : '▸ '}{path.slice(slash + 1)}</Text></Button></Box>
532 <Box flexShrink={1} minWidth={0}><Text size="caption" dimColor wrap="truncate-end">{`${hint} · ×${f.count} · ${age(f.at, now)}`}</Text></Box>
533 </Box> : <Box key={`af-${f.path}`} {...skin.rows} width="100%" flexShrink={1} minWidth={0} flexDirection="column" alignItems="stretch" hover={{ backgroundColor: skin.hover }}>
534 <Button key={id} plain label={`${expanded ? '▾' : '▸'} ${path.slice(slash + 1)}`} onPress={view($, () => pressActivity($, id))}>
535 <Text size="caption" wrap="truncate-end">{expanded ? '▾ ' : '▸ '}{path.slice(slash + 1)}</Text>
536 {' '}
537 <Text size="caption" dimColor wrap="truncate-end">{`${hint} · ×${f.count} · ${age(f.at, now)}`}</Text>
538 </Button>
539 </Box>}
540 {expanded ? <Box key={`file-edits-${path}`} flexDirection="column">
541 {[...(f.edits ?? [])].reverse().map((x, i) => row(`fe-${path}-${i}`, x.at, x.tool, who(x.agent), tone.quiet))}
542 </Box> : null}
543 </Box>
544 }), moreRow('files', touched.length, SHOWN.files),
545 ] : [empty('files', 'No files edited or written yet.')])}
546 {card('log', 'Session log', 'newest first', logged.length ? [
547 ...cut('log', logged, SHOWN.log).map(x => callRow(x, 'al')), moreRow('log', logged.length, SHOWN.log),
548 ] : [empty('log', 'No tool calls yet. Each one shows here as it runs.')])}
549 {events.length ? <Text size="caption" dimColor>This log is for this session only and is not saved.</Text> : null}
550 </Box>
551 )
552}
553hooks/needs.ts 207 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { Need } from '../types'
5
6// Needs you: the other desktop sessions that sit idle with a reply asking you something.
7// Read through the desktop app's ccd_session_mgmt server; outside the app there is none and the card hides.
8// A permission prompt shows as a running session, so it is not detected.
9
10type E = EngineInterface
11const SERVER = 'ccd_session_mgmt'
12export const NEEDS_EVERY_MS = 60_000
13
14// register.tsx declares the same atom: the state scan reads an atom only where its file declares it.
15const needs = atom({ plugin: 'nextup', key: 'needs' } as const, null)
16const needsProblem = atom({ plugin: 'nextup', key: 'needsProblem' } as const, null)
17
18// The reply rules mark every reply that needs an answer with [HQ] or [↳ child of ...] on its first line.
19const MARK = /^\[(HQ|↳ child of [^\]\n]*)\]\s*/
20const NEXT_ACTION = /^\s*Next action:/i
21const clip = (v: string, n = 140) => (v.length > n ? `${v.slice(0, n - 1).trimEnd()}…` : v)
22const lines = (v: string) => v.split('\n').map(x => x.trim()).filter(Boolean)
23
24// A "Next action:" is the agent's own step only when it says so ("I will...", "wait for the review", "run the tests").
25// Anything else, or one that asks, names you or waits on you, still waits on your answer.
26const OWN_STEP = /^(i\b|i'll\b|wait (for|on|until) (?!you|darren)|(run|rerun|check|monitor|watch|build|fix|ship|push|merge|deploy|report|tell you|send you)\b)/i
27const ON_YOU = /\?[.*_)]*$|\byour (go|ok|okay|answer|reply|approval|input|call|pick|choice|decision|review|sign-off|eyes|hands|word|permission|confirmation|feedback|thoughts|say)\b|\bneeds? you\b|\b(once|when|if|unless|after) you\b|\bwait(ing)? (for|on) (you|darren)\b/i
28const ownStep = (next: string) => OWN_STEP.test(next) && !ON_YOU.test(next)
29
30/** What a reply asks you, one line, or null when it asks nothing. A mark alone is no sign: status reports carry it too. */
31export function askOf(reply: string): string | null {
32 const all = lines(reply)
33 const nexts = all.filter(x => NEXT_ACTION.test(x)).map(x => x.replace(NEXT_ACTION, '').trim())
34 const text = all.filter(x => !NEXT_ACTION.test(x))
35 if (!text.length) return null
36 if (MARK.test(text[0]!)) {
37 const rest = [text[0]!.replace(MARK, ''), ...text.slice(1)].filter(Boolean)
38 const question = rest.find(x => x.endsWith('?'))
39 if (question) return clip(question)
40 if (nexts.length && nexts.every(ownStep)) return null
41 return clip(rest[0] ?? 'Waiting on your answer')
42 }
43 const last = text[text.length - 1]!
44 return last.endsWith('?') ? clip(last) : null
45}
46
47// list_events rows start with a role in brackets; a reply's own lines may start with [HQ], so only these roles split rows.
48const ROW = /^\[(assistant|user|result|system|tool[\w ]*)\] ?/
49const LOCAL = /^<(command-name|command-message|command-args|local-command)/
50
51/** The last assistant text of an idle session's transcript, or null when it is running, has none, or you answered since. */
52export function lastReply(events: string): string | null {
53 if (!/^Session ".*" \(idle\)/m.test(events.split('\n')[0] ?? '')) return null
54 const rows: { role: string; text: string }[] = []
55 for (const line of events.split('\n')) {
56 const m = ROW.exec(line)
57 if (m) rows.push({ role: m[1]!, text: line.slice(m[0].length) })
58 else if (rows.length) rows[rows.length - 1]!.text += `\n${line}`
59 }
60 for (let i = rows.length - 1; i >= 0; i--) {
61 const r = rows[i]!
62 // A prompt you sent after the reply answered it; a slash command you ran did not.
63 if (r.role === 'user' && !LOCAL.test(r.text.trim())) return null
64 if (r.role === 'assistant' && !/^\(called [^)]*\)$/.test(r.text.trim())) return r.text.trim() || null
65 }
66 return null
67}
68
69/** How long a session has waited: under a minute, minutes, hours, then days. */
70export function waitFor(ms: number): string {
71 const m = Math.floor(ms / 60_000)
72 if (m < 1) return '<1m'
73 if (m < 60) return `${m}m`
74 const h = Math.floor(m / 60)
75 return h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`
76}
77
78type Row = { sessionId?: unknown; title?: unknown; isRunning?: unknown; isArchived?: unknown; lastActivityAt?: unknown; link?: unknown; startedBy?: unknown }
79const str = (v: unknown) => (typeof v === 'string' ? v : '')
80const textOf = (r: { content?: { type: string; text?: string }[] }) => (r.content ?? []).map(c => (c.type === 'text' ? c.text ?? '' : '')).join('\n')
81const firstLine = (r: { content?: { type: string; text?: string }[] }) => clip(lines(textOf(r))[0] ?? 'the session server returned an error', 200)
82
83// Each session's verdict, kept until its lastActivityAt moves, so an unchanged session is read once.
84const verdicts = new Map<string, { at: string; ask: string | null }>()
85let polling = false
86let polled: { at: number; rows: Row[] } | undefined
87/** The session list the last poll read, for other draws that need to know which sessions are alive. */
88export const polledSessions = () => polled
89/** Forgets the cached verdicts; session.start calls it so a restarted session reads each session afresh. */
90export const resetNeeds = () => {
91 verdicts.clear()
92 polling = false
93 polled = undefined
94}
95
96/** Reads the sessions, asks list_events only of idle ones whose activity time changed, and stores who waits on you. */
97export async function loadNeeds($: E) {
98 // Up to 30 transcripts are read one by one, so a slow poll must not overlap the next.
99 if (polling) return
100 polling = true
101 try {
102 await poll($)
103 } catch {
104 // A poll cut off mid-way (the session ending, say) leaves the last card standing.
105 } finally {
106 polling = false
107 }
108}
109
110async function poll($: E) {
111 let rows: Row[]
112 try {
113 const r = await $.mcp.call(SERVER, 'list_sessions', { limit: 60 })
114 // A refusal (a permission denial, say) is said on the card, so the card never goes quiet for a reason you can fix.
115 if (r.isError) return void (await update($, needsProblem, () => firstLine(r as never)))
116 const parsed: unknown = JSON.parse(textOf(r as never))
117 rows = Array.isArray(parsed) ? (parsed as Row[]) : []
118 polled = { at: await $.clock.now(), rows }
119 } catch {
120 // No session server: this is not the desktop app, so the card stays hidden.
121 await update($, needs, () => null)
122 await update($, needsProblem, () => null)
123 return
124 }
125 let problem: string | null = null
126 const found: Need[] = []
127 for (const s of rows) {
128 const id = str(s.sessionId)
129 const at = str(s.lastActivityAt)
130 const since = Date.parse(at)
131 if (!id || !Number.isFinite(since) || s.isRunning !== false || s.isArchived === true) continue
132 let v = verdicts.get(id)
133 if (!v || v.at !== at) {
134 try {
135 const r = await $.mcp.call(SERVER, 'list_events', { session_id: id, limit: 30 })
136 if (r.isError) {
137 problem ??= firstLine(r as never)
138 continue
139 }
140 const reply = lastReply(textOf(r as never))
141 v = { at, ask: reply ? askOf(reply) : null }
142 verdicts.set(id, v)
143 } catch {
144 continue
145 }
146 }
147 if (!v.ask) continue
148 const title = str(s.title) || id
149 found.push({
150 id,
151 title,
152 kind: /(^|\s)HQ(:|$)/.test(title) && !title.startsWith('↳') ? 'hq' : str(s.startedBy) || title.startsWith('↳') ? 'child' : '',
153 ...(str(s.startedBy) ? { parent: str(s.startedBy) } : {}),
154 ask: v.ask,
155 since,
156 link: /^claude:\/\//.test(str(s.link)) ? str(s.link) : '',
157 })
158 }
159 // A child with no startedBy nests under the one listed HQ its title prefix names ("↳ app: ..." under "app HQ: ...").
160 for (const n of found) {
161 if (n.kind !== 'child' || n.parent) continue
162 const project = /^↳\s*([^:]+):/.exec(n.title)?.[1]?.trim()
163 const hqs = project ? found.filter(x => x.kind === 'hq' && x.title.startsWith(`${project} HQ`)) : []
164 if (hqs.length === 1) n.parent = hqs[0]!.id
165 }
166 // Sessions no longer listed drop out of the cache.
167 const live = new Set(rows.map(r => str(r.sessionId)))
168 for (const id of verdicts.keys()) if (!live.has(id)) verdicts.delete(id)
169 await update($, needs, () => found)
170 await update($, needsProblem, () => problem)
171}
172
173/** Money mentioned in a waiting ask, in dollars; no new persisted fields. */
174export function needMoney(n: Pick<Need, 'title' | 'ask'>): number {
175 const amounts = [...`${n.title} ${n.ask}`.matchAll(/\$([\d,]+(?:\.\d+)?)\s*([km])?\b/gi)]
176 return Math.max(0, ...amounts.map(m => Number(m[1]!.replace(/,/g, '')) * (m[2]?.toLowerCase() === 'k' ? 1000 : m[2]?.toLowerCase() === 'm' ? 1_000_000 : 1)))
177}
178
179/** Strict money-first order, then oldest wait, even when a child outranks its parent. */
180export function rankedNeeds(items: Need[]): { need: Need; depth: number }[] {
181 return [...items].sort((a, b) => needMoney(b) - needMoney(a) || a.since - b.since || a.id.localeCompare(b.id))
182 .map(need => ({ need, depth: 0 }))
183}
184
185/** Longest wait first; each session sits under its parent when that one is listed, at any depth. */
186export function grouped(items: Need[]): { need: Need; depth: number }[] {
187 const listed = new Set(items.map(x => x.id))
188 const kids = (id: string) => items.filter(x => x.parent === id).sort((a, b) => a.since - b.since)
189 const out: { need: Need; depth: number }[] = []
190 const seen = new Set<string>()
191 const walk = (n: Need, depth: number) => {
192 if (seen.has(n.id)) return
193 seen.add(n.id)
194 out.push({ need: n, depth })
195 kids(n.id).forEach(k => walk(k, depth + 1))
196 }
197 const byWait = (a: Need, b: Need) => a.since - b.since
198 items.filter(x => !x.parent || !listed.has(x.parent)).sort(byWait).forEach(n => walk(n, 0))
199 // A startedBy loop has no root: its oldest member leads it.
200 items.filter(x => !seen.has(x.id)).sort(byWait).forEach(n => walk(n, 0))
201 return out
202}
203
204export async function openSession($: E, n: Need) {
205 if (n.link) await $.process.run(['open', n.link])
206}
207hooks/elicit/ElicitCard.tsx 50 lines1import type { EngineInterface } from 'claude-code'
2import { isAnswerable, minutesBlocked, optionsOf, ruleEligible } from './model'
3import type { Ask, Field } from './model'
4
5/** Choices for a field: enum options with the recommended one first, or Yes/No for a boolean. */
6export function choicesOf(field: Field, recommended?: unknown) {
7 if (field.type !== 'boolean') return optionsOf(field, recommended)
8 const yes = recommended !== false && recommended !== 'false'
9 return [{ value: 'true', label: 'Yes', recommended: yes && recommended !== undefined }, { value: 'false', label: 'No', recommended: !yes }].sort((x, y) => Number(y.recommended) - Number(x.recommended))
10}
11/** What the card starts with: the ask's default, else the field default, else the first choice. */
12export function initialValues(ask: Ask) {
13 return Object.fromEntries(Object.entries(ask.schema?.properties ?? {}).map(([name, f]) => [name, ask.default?.[name] ?? f.default ?? (f.type === 'boolean' ? true : optionsOf(f)[0]?.value ?? '')]))
14}
15type Els = ReturnType<EngineInterface['ui']['resolve']>
16export type CardProps = {
17 /** Every press goes through these, so it always shows a result (events.ts act/view, bound to the engine by the caller). */
18 act: <A extends unknown[]>(action: string, fn: (...a: A) => unknown, o?: { label?: string }) => (...a: A) => Promise<void>
19 view: <A extends unknown[]>(fn: (...a: A) => unknown) => (...a: A) => Promise<void>
20 ask: Ask; more: number; now: number; picked: Record<string, unknown>; remember: boolean; quiet: boolean
21 change: (name: string, value: unknown) => void | Promise<void>; toggle: () => void | Promise<void>
22 send: (cancel?: boolean) => void | Promise<void>; open: (url?: string) => void | Promise<void>
23}
24export function ElicitCard(els: Els, props: CardProps) {
25 const { Box, Text, Button } = els
26 const { ask, picked } = props
27 const browser = ask as Ask & { kind?: string; mode?: string; url?: string }
28 const inBrowser = browser.kind === 'browser' || browser.mode === 'url'
29 const answerable = !inBrowser && isAnswerable(ask.schema)
30 const minutes = minutesBlocked(ask, props.now)
31 return <Box key="ask-card" flexDirection="column" gap="xs">
32 <Text weight="bold">One question</Text>
33 <Text>{`${ask.thread} is blocked, ${minutes} min`}</Text>
34 <Text>{ask.message}</Text>
35 {inBrowser ? <Box flexDirection="column"><Text>Needs you in the browser</Text><Button key="ask-open" label={browser.url ? `Open ${browser.url}` : 'Open'} onPress={props.act('open-question', () => props.open(browser.url), { label: 'open the question' })} /></Box> : !answerable ? <Button key="ask-open" label="Open session" onPress={props.act('open-question', () => props.open(), { label: 'open the question' })} /> : <Box flexDirection="column" gap="xs">
36 {Object.entries(ask.schema.properties ?? {}).map(([name, field]) => {
37 const choices = choicesOf(field, ask.default?.[name] ?? field.default)
38 return <Box key={`ask-row-${name}`} flexDirection="column" gap="xs">
39 <Text>{field.title ?? name}</Text>
40 {choices.length ? ('Select' in els ? <els.Select key={`ask-field-${name}`} value={String(picked[name] ?? '')} options={choices.map(o => ({ value: o.value, label: `${o.label}${o.recommended ? ' (Recommended)' : ''}` }))} onSelect={props.view((value: string) => props.change(name, value))} /> : choices.map(o => <Button key={`ask-option-${name}-${o.value}`} label={`${o.label}${o.recommended ? ' (Recommended)' : ''}${String(picked[name]) === o.value ? ' ✓' : ''}`} onPress={props.view(() => props.change(name, o.value))} />)) : 'Input' in els ? <els.Input key={`ask-field-${name}`} value={String(picked[name] ?? '')} onInput={value => void props.change(name, value)} onSubmit={() => void props.send()} /> : <Text>Use nextup-answer to enter text.</Text>}
41 </Box>
42 })}
43 {ruleEligible(ask) ? <Button key="ask-remember" plain label={`${props.remember ? '☑' : '☐'} Always answer this way`} onPress={props.view(() => props.toggle())} /> : null}
44 <Box gap="xs"><Button key="ask-send" label="Send answer" onPress={props.act('answer', () => props.send(), { label: 'send the answer' })} /><Button key="ask-skip" label="Skip (cancel)" onPress={props.act('skip-question', () => props.send(true), { label: 'skip the question' })} /></Box>
45 </Box>}
46 {props.quiet && props.now - ask.askedAt > 120_000 ? <Text key="ask-quiet" size="caption">{`Quiet for ${minutes} min`}</Text> : null}
47 {props.more > 0 ? <Text key="ask-more" size="caption">{`${props.more} more waiting`}</Text> : null}
48 </Box>
49}
50hooks/elicit/model.ts 143 lines1// Blocker Ledger model: pure functions and the record shapes shared by the hook (ask-hook.mjs),
2// the card (ElicitCard.tsx) and the board files (asks.json, answers.json, elicit-rules.json, elicit-log.json).
3// No I/O and no clock reads here: callers pass `now`. Keep this file to erasable TypeScript (the hook imports it
4// directly under Node's type stripping), and use no `.ts` extension imports.
5
6export type Status = 'waiting' | 'answered' | 'auto' | 'defaulted' | 'cancelled'
7export type AskClass = 'boolean' | 'enum' | 'text' | 'secret-like' | 'url'
8export type Field = { type?: string; title?: string; description?: string; enum?: string[]; oneOf?: { const: string; title?: string }[]; default?: unknown; maxLength?: number }
9export type Schema = { type?: string; properties?: Record<string, Field>; required?: string[] }
10/** The Elicitation hook's stdin, as far as we use it. */
11export type ElicitRequest = { mode?: string; message?: string; mcp_server_name?: string; requested_schema?: Schema; elicitation_id?: string; url?: string }
12/** One row of asks.json (an array). `at` is a ms stamp; for the same id the newest `at` wins. */
13export type Ask = {
14 id: string; source: string; thread: string; message: string; schema: Schema
15 askedAt: number; timeoutSec: number
16 default?: Record<string, unknown>; safe?: boolean; neverAuto?: boolean
17 status: Status; at: number
18}
19/** One row of answers.json (an array). Written by the card, /nextup answer and nextup-answer; read by the hook. */
20export type Answer = { id: string; content: Record<string, unknown>; at: number; answeredBy: 'darren'; remember?: boolean; cancel?: boolean }
21/** One row of elicit-rules.json. `shape` comes from shapeOf. */
22export type Rule = { id: string; server: string; shape: string; value: Record<string, unknown>; createdAt: number; revoked?: boolean; at: number }
23/** One row of elicit-log.json: an audit line per finished ask. */
24export type LogRow = { id: string; source: string; message: string; status: Exclude<Status, 'waiting'>; answeredBy: 'darren' | 'rule' | 'timeout' | 'hook'; content?: Record<string, unknown>; waitedSec: number; ruleId?: string; at: number }
25
26/** Words that make an ask never auto-answerable by a rule or a timeout default. */
27export const NEVER_AUTO = /\b(deploy\w*|delet\w*|spen[dt]\w*|send\w*|sent|production|prod|merg\w*|push\w*|publish\w*|releas\w*|pay\w*|purchas\w*|buy\w*|transfer\w*|drop\w*|wipe\w*|destroy\w*|remov\w*|overwrit\w*|reset\w*|refund\w*|invoice\w*|email\w*)\b|[$€£¥]/i
28/** Field names or titles that look like secrets. */
29export const SECRET_LIKE = /key|token|password|passphrase|passcode|secret|credential|\bpin\b|\botp\b|cvv|\bssn\b/i
30/** Default seconds a hook waits for Darren (20 minutes). */
31export const DEFAULT_TIMEOUT_SEC = 20 * 60
32
33/** Lowercase, collapse whitespace, replace digit runs with #, strip punctuation at the ends: so "Deploy 3 now?" and "deploy 4 now" match. */
34export function normalize(message: string): string {
35 return message.toLowerCase().replace(/\d+/g, '#').replace(/\s+/g, ' ').trim().replace(/^[^\p{L}\p{N}#]+|[^\p{L}\p{N}#]+$/gu, '').trim()
36}
37/** A stable string for a schema's shape, e.g. "enum:ship|hold" or "boolean:approve"; field names sorted. */
38export function shapeOf(schema: Schema | undefined): string {
39 return Object.entries(schema?.properties ?? {}).sort(([a], [b]) => a.localeCompare(b)).map(([name, field]) => {
40 const options = optionsOf(field)
41 return options.length ? `enum:${options.map(o => o.value).join('|')}` : `${field.type ?? 'unknown'}:${name}`
42 }).join(';')
43}
44/** url when mode is url; secret-like when any field name or title matches SECRET_LIKE; boolean when every field is boolean; enum when every field is boolean or enum and at least one is enum; else text. */
45export function classify(req: Pick<ElicitRequest, 'mode' | 'requested_schema'>): AskClass {
46 if (req.mode === 'url') return 'url'
47 const fields = Object.entries(req.requested_schema?.properties ?? {})
48 if (fields.some(([name, field]) => SECRET_LIKE.test(name) || SECRET_LIKE.test(field.title ?? ''))) return 'secret-like'
49 if (fields.length && fields.every(([, f]) => f.type === 'boolean')) return 'boolean'
50 if (fields.length && fields.every(([, f]) => f.type === 'boolean' || optionsOf(f).length > 0)) return 'enum'
51 return 'text'
52}
53/** Flat object, 1 to 3 fields, each boolean, enum/oneOf, or a string with maxLength absent or <= 280. Anything else is not answerable here (the card shows Open session). */
54export function isAnswerable(schema: Schema | undefined): boolean {
55 if (!schema || (schema.type !== undefined && schema.type !== 'object')) return false
56 const fields = Object.values(schema.properties ?? {})
57 return fields.length >= 1 && fields.length <= 3 && fields.every(f =>
58 f.type === 'boolean' || ((f.type === undefined || f.type === 'string') && optionsOf(f).length > 0) ||
59 (f.type === 'string' && !f.enum && !f.oneOf && (f.maxLength === undefined || (f.maxLength >= 0 && f.maxLength <= 280))))
60}
61/** True only for boolean or enum asks with a default and no never-auto word in the message and no neverAuto flag. Text, secret-like and url are never eligible. */
62export function ruleEligible(ask: { message: string; schema: Schema; neverAuto?: boolean; default?: Record<string, unknown> }): boolean {
63 const kind = classify({ requested_schema: ask.schema })
64 return (kind === 'boolean' || kind === 'enum') && !ask.neverAuto && !NEVER_AUTO.test(ask.message) &&
65 ask.default !== undefined && buildContent(ask.schema, ask.default) !== null
66}
67/** The newest rule (by `at`, per id) that is not revoked, whose id equals ruleId(server, message, schema) (server + normalised message + shape), whose value passes buildContent for the schema, and only when the ask is ruleEligible. Else undefined. */
68export function matchRule(rules: Rule[], req: { server: string; message: string; schema: Schema; neverAuto?: boolean; default?: Record<string, unknown> }): Rule | undefined {
69 if (!ruleEligible(req)) return undefined
70 return latest(rules).filter(r => !r.revoked && r.server === req.server && r.shape === shapeOf(req.schema) &&
71 r.id === ruleId(req.server, req.message, req.schema) && buildContent(req.schema, r.value) !== null).sort((a, b) => b.at - a.at)[0]
72}
73/** The rule id the card writes for "Always answer this way": `${server}|${shapeOf(schema)}|${normalize(message)}`. */
74export function ruleId(server: string, message: string, schema: Schema): string {
75 return `${server}|${shapeOf(schema)}|${normalize(message)}`
76}
77/** The MCP `content` for a picked value set, or null when it is not valid for the schema (enum value not in options, string over maxLength, required field missing). Booleans are coerced; strings trimmed. */
78export function buildContent(schema: Schema, picked: Record<string, unknown>): Record<string, unknown> | null {
79 if (!schema?.properties || !picked || typeof picked !== 'object' || Array.isArray(picked)) return null
80 if (schema.required?.some(name => !Object.hasOwn(picked, name) || picked[name] === undefined)) return null
81 const entries: [string, unknown][] = []
82 for (const [name, field] of Object.entries(schema.properties)) {
83 if (!Object.hasOwn(picked, name) || picked[name] === undefined) continue
84 let value = picked[name]
85 if (field.type === 'boolean') {
86 if (typeof value === 'string') {
87 const word = value.trim().toLowerCase()
88 if (['true', '1', 'yes'].includes(word)) value = true
89 else if (['false', '0', 'no', ''].includes(word)) value = false
90 else return null
91 } else if (typeof value === 'boolean' || value === 0 || value === 1) value = Boolean(value)
92 else return null
93 } else {
94 if (typeof value !== 'string') return null
95 value = value.trim()
96 const options = optionsOf(field)
97 if ((field.enum || field.oneOf) && !options.some(o => o.value === value)) return null
98 if (!options.length && field.type !== 'string') return null
99 if (field.maxLength !== undefined && (value as string).length > field.maxLength) return null
100 }
101 entries.push([name, value])
102 }
103 return Object.fromEntries(entries)
104}
105/** Options of an enum field in display order: the one equal to `recommended` first. Labels come from oneOf titles when present. */
106export function optionsOf(field: Field, recommended?: unknown): { value: string; label: string; recommended: boolean }[] {
107 return (field.oneOf?.map(o => ({ value: o.const, label: o.title ?? o.const })) ??
108 field.enum?.map(value => ({ value, label: value })) ?? []).map(o => ({ ...o, recommended: o.value === recommended }))
109 .sort((a, b) => Number(b.recommended) - Number(a.recommended))
110}
111/** Waiting asks only (newest `at` per id, status waiting, not past askedAt + timeoutSec), longest blocked first. */
112export function sortQueue(asks: Ask[], now: number): Ask[] {
113 return latest(asks).filter(a => a.status === 'waiting' && now < a.askedAt + a.timeoutSec * 1000).sort((a, b) => a.askedAt - b.askedAt)
114}
115/** The current rows of a log-like array: newest `at` per id. */
116export function latest<T extends { id: string; at: number }>(rows: T[]): T[] {
117 const current = new Map<string, T>()
118 for (const row of rows) {
119 if (!row || typeof row.id !== 'string' || !Number.isFinite(row.at)) continue
120 const prev = current.get(row.id)
121 if (!prev || row.at >= prev.at) current.set(row.id, row)
122 }
123 return [...current.values()]
124}
125/** Whole minutes blocked, at least 0. */
126export function minutesBlocked(ask: Pick<Ask, 'askedAt'>, now: number): number {
127 return Math.max(0, Math.floor((now - ask.askedAt) / 60000))
128}
129/** "Answered N for you while you slept, median wait Xs" inputs from the log since `dayStart` (ms): count of auto/defaulted/answered-by-rule rows and median waitedSec, 0 when none. */
130export function sleptStats(log: LogRow[], dayStart: number): { answered: number; medianWaitSec: number } {
131 const waits = latest(log).filter(r => r.at >= dayStart && (r.status === 'auto' || r.status === 'defaulted' || (r.status === 'answered' && r.answeredBy === 'rule'))).map(r => r.waitedSec).sort((a, b) => a - b)
132 const n = waits.length
133 return { answered: n, medianWaitSec: n ? (waits[Math.floor((n - 1) / 2)]! + waits[Math.floor(n / 2)]!) / 2 : 0 }
134}
135/** The wording for a finished ask's state line. defaulted: "Defaulted to <value> after N min"; cancelled: "Cancelled, thread is blocked"; auto: "Answered by rule: <value>". */
136export function stateLine(row: LogRow, defaultMinutes?: number): string {
137 const value = Object.values(row.content ?? {}).map(v => typeof v === 'string' ? v : JSON.stringify(v)).join(', ')
138 if (row.status === 'defaulted') return `Defaulted to ${value} after ${defaultMinutes ?? Math.floor(row.waitedSec / 60)} min`
139 if (row.status === 'cancelled') return 'Cancelled, thread is blocked'
140 if (row.status === 'auto') return `Answered by rule: ${value}`
141 return `Answered by ${row.answeredBy}: ${value}`
142}
143hooks/elicit/store.ts 71 lines1import type { EngineInterface } from 'claude-code'
2import type { Ask, Answer, Rule, LogRow } from './model'
3import { buildContent, latest, ruleEligible, ruleId, shapeOf, sortQueue } from './model'
4import { choicesOf, initialValues } from './ElicitCard'
5
6type E = EngineInterface
7export async function readRows<T>($: E, dir: string, name: string): Promise<T[]> {
8 if (!dir) return []
9 try {
10 const path = `${dir}/${name}.json`
11 if (!await $.fs.exists(path)) return []
12 const rows: unknown = JSON.parse(await $.fs.read(path) as string)
13 return Array.isArray(rows) ? rows.filter(row => row && typeof row === 'object' && typeof row.id === 'string' && typeof row.at === 'number') as T[] : []
14 } catch { return [] }
15}
16// The engine's fs has no lock or rename, so another writer (the helper, another pane) can overwrite between our read and
17// write. Read back after writing and retry until the row is there; the helper takes a lock that narrows the window further.
18async function append<T extends { id: string; at: number }>($: E, dir: string, name: string, row: T) {
19 for (let attempt = 0; attempt < 5; attempt++) {
20 const rows = await readRows<T>($, dir, name)
21 await $.fs.write(`${dir}/${name}.json`, JSON.stringify([...rows, row]))
22 if ((await readRows<T>($, dir, name)).some(r => r.id === row.id && r.at === row.at)) return
23 }
24 throw new Error('Could not save the answer; try again.')
25}
26export async function readLedger($: E, dir: string) {
27 const [asks, answers, rules, log] = await Promise.all([
28 readRows<Ask>($, dir, 'asks'), readRows<Answer>($, dir, 'answers'), readRows<Rule>($, dir, 'elicit-rules'), readRows<LogRow>($, dir, 'elicit-log'),
29 ])
30 return { asks, answers, rules, log }
31}
32export async function sendAnswer($: E, dir: string, ask: Ask, picked: Record<string, unknown>, remember = false, cancel = false) {
33 if (!dir) throw new Error('No data folder set.')
34 const content = cancel ? {} : buildContent(ask.schema, picked)
35 if (!content) throw new Error('Choose a valid answer for every required field.')
36 const at = await $.clock.now()
37 const saveRule = !cancel && remember && ruleEligible(ask)
38 const row: Answer = { id: ask.id, content, at, answeredBy: 'darren', remember: saveRule, ...(cancel ? { cancel: true } : {}) }
39 // Save the rule first: a failed rule write must not consume the waiting question.
40 if (saveRule) await append<Rule>($, dir, 'elicit-rules', { id: ruleId(ask.source, ask.message, ask.schema), server: ask.source, shape: shapeOf(ask.schema), value: content, createdAt: at, at })
41 await append($, dir, 'answers', row)
42}
43export async function revokeRule($: E, dir: string, id: string) {
44 const rule = latest(await readRows<Rule>($, dir, 'elicit-rules')).find(r => r.id === id)
45 if (!rule || rule.revoked) return
46 await append($, dir, 'elicit-rules', { ...rule, revoked: true, at: Math.max(await $.clock.now(), rule.at + 1) })
47}
48export async function waitingAsks($: E, dir: string) {
49 const ledger = await readLedger($, dir)
50 const queue = sortQueue(ledger.asks, await $.clock.now())
51 // An answer counts only if it is newer than the ask, so a reused id never hides a new question.
52 return queue.filter(a => !ledger.answers.some(r => r.id === a.id && r.at >= a.askedAt))
53}
54export async function askCommand($: E, dir: string, args?: string) {
55 if (!dir) return { text: 'Next up has no data folder set.' }
56 const queue = await waitingAsks($, dir)
57 if (args === undefined) return { text: queue.map(a => `${a.id}: ${a.thread}: ${a.message}\n${Object.entries(a.schema?.properties ?? {}).map(([name, f]) => `${name}: ${choicesOf(f, a.default?.[name] ?? f.default).map(o => `${o.value}${o.recommended ? ' (Recommended)' : ''}`).join(', ') || 'text'}`).join('\n')}`).join('\n') || 'No questions waiting.' }
58 const match = args.trim().match(/^(\S+)\s+([\s\S]+)$/)
59 if (!match) return { text: 'Usage: nextup-answer <id> <choice>' }
60 const ask = queue.find(a => a.id === match[1])
61 if (!ask) return { text: 'No waiting question with that id.' }
62 const fields = Object.keys(ask.schema?.properties ?? {})
63 let picked: Record<string, unknown>
64 if (fields.length === 1) picked = { ...initialValues(ask), [fields[0]!]: match[2] }
65 else {
66 try { picked = JSON.parse(match[2]!) as Record<string, unknown> } catch { return { text: 'For multiple fields, supply a JSON object as the choice.' } }
67 }
68 try { await sendAnswer($, dir, ask, picked); return { text: `Answered ${ask.id}.` } }
69 catch (error) { return { text: error instanceof Error ? error.message : 'Could not save answer.' } }
70}
71hooks/dispatch.tsx 147 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
3
4import { groupLanes, isRunning, readLanes, rowOf } from './lanes'
5import type { Lane, Port, Row } from './lanes'
6import { view } from './events'
7import { surfaceTheme, tone } from './theme'
8
9// The worker dispatch panel on the Session tab: one group per Workflow run, one row per lane.
10// Read only. The dispatch folder is the one the Fast switch uses (the dispatchDir option, else DISPATCH_ROOT).
11
12const expanded = atom({ plugin: 'nextup', key: 'dispatchOpen' } as const, [])
13// The lanes as last read: written by the live loop (register.tsx), read by the drawing, so a reread redraws on its own.
14const snapshot = atom({ plugin: 'nextup', key: 'lanes' } as const, null)
15
16/** The folder to read; '' until register resolves it, and when there is none. */
17let root = ''
18export const REFRESH_MS = 5000
19/** Lanes drawn before Show more. */
20const SHOWN = 8
21
22function portOf($: EngineInterface): Port {
23 return {
24 list: async dir => (await $.fs.list(dir)).map(x => ({ name: x.name, kind: x.kind })),
25 read: path => $.fs.read(path) as Promise<string>,
26 mtime: async path => (await $.fs.stat(path)).mtimeMs,
27 // kill -0 sends nothing; exit 0 means the process is there. A host that cannot run it believes the lane.
28 alive: async pid => {
29 try { return (await $.process.run(['kill', '-0', String(pid)])).exitCode === 0 } catch { return true }
30 },
31 }
32}
33
34async function stamp($: EngineInterface) {
35 try { return await $.clock.now() } catch { return Date.now() }
36}
37
38export function setDispatchRoot(dir: string) {
39 root = dir
40}
41
42/** What the card shows, as one string: the rows and the header counts. Equal strings draw equal cards. */
43export function cardSignature(groups: ReturnType<typeof groupLanes>, now: number) {
44 return JSON.stringify(groups.map(g => [g.name, g.lanes.map(l => { const r = rowOf(l, now); return [r.task, r.kind, r.state, r.harness, r.elapsed, r.step] })]))
45}
46
47// When the folder was last read, written or not: equal data skips the write, so the atom alone cannot say how old it is.
48let readAt = 0
49
50/** Reread the folder; write the lanes only when the card would draw differently, so equal data redraws nothing. */
51export async function refreshDispatch($: EngineInterface) {
52 if (!root) return
53 const dir = root
54 const now = await stamp($)
55 const lanes = await readLanes(portOf($), dir)
56 const signature = cardSignature(groupLanes(lanes, now), now)
57 readAt = now
58 const last = await read($, snapshot)
59 if (last?.root === dir && last.signature === signature) return
60 await update($, snapshot, () => ({ root: dir, lanes, signature }))
61}
62
63// A drawing cannot write state, so a first draw (a mod installed mid-session, before any reread) reads the folder
64// itself and hands the write to the clock; one write at a time.
65let seeding = false
66function seedDispatch($: EngineInterface) {
67 if (seeding) return
68 seeding = true
69 $.clock.after(0, async () => {
70 try { await refreshDispatch($) } catch { /* The next tick tries again. */ } finally { seeding = false }
71 })
72}
73
74/** The name cut to `max` cells with an ellipsis, so a row stays on one line. */
75export const fit = (text: string, max: number) => (text.length <= max ? text : max <= 1 ? '…'.slice(0, Math.max(0, max)) : `${text.slice(0, max - 1)}…`)
76
77const toneOf = (r: Row) => r.kind === 'unreadable' || r.kind === 'failed' ? tone.fail : r.kind === 'running' ? tone.warn : r.kind === 'done' ? tone.done : tone.quiet
78const glyphOf = (r: Row) => ({ running: '◐', done: '✓', failed: '✕', unreadable: '!', stale: '·', other: '·' })[r.kind]
79
80/** The Worker lanes card, or null when there is no dispatch folder or nothing to show. */
81export async function drawDispatch($: EngineInterface, e: RenderInput<'Pane'>): Promise<RenderElement | null> {
82 const { Box, Text, Button } = $.ui.resolve(e)
83 if (!root) return null
84 const now = await stamp($)
85 const snap = await read($, snapshot)
86 // A pane hidden or undrawn gets no reread, so a snapshot that missed a tick is redrawn from the folder itself.
87 const fresh = snap?.root === root && now - readAt <= 2 * REFRESH_MS
88 if (!fresh) seedDispatch($)
89 const groups = groupLanes(fresh ? snap.lanes : await readLanes(portOf($), root), now)
90 if (!groups.length) return null
91 const open = await read($, expanded)
92 const skin = surfaceTheme(e.surface)
93 const terminal = e.surface === 'terminal'
94 const all = groups.flatMap(g => g.lanes)
95 // Running lanes are always drawn; the cap hides only finished ones.
96 const showAll = open.includes('dp-all')
97 const live = all.filter(isRunning)
98 const shown = new Set(showAll ? all : [...live, ...all.filter(l => !isRunning(l))].slice(0, Math.max(SHOWN, live.length)))
99 const running = all.filter(isRunning).length
100 const unreadable = all.filter(l => l.unreadable).length
101 // A narrow pane says live, so the unreadable count is not cut off.
102 const counts = `${running} ${unreadable && (e.props.bodyColumns ?? 60) < 50 ? 'live' : 'running'} · ${all.length} lane${all.length === 1 ? '' : 's'}${unreadable ? ` · ${unreadable} unreadable` : ''}`
103 const pathLine = (key: string, label: string, path: string) => path ? <Box key={key} width="100%" flexShrink={1} minWidth={0} gap={1}>
104 <Box width={7} flexShrink={0}><Text size="caption" dimColor>{label}</Text></Box>
105 <Box flexShrink={1} flexGrow={1} minWidth={0}><Text size="caption" wrap="truncate-start">{path}</Text></Box>
106 </Box> : null
107 const laneRow = (l: Lane) => {
108 const r = rowOf(l, now)
109 const id = `dp-${l.dir}`
110 const isOpen = open.includes(id)
111 const press = view($, () => update($, expanded, s => (s.includes(id) ? s.filter(x => x !== id) : [...s, id])))
112 // One line: the pane's padding and the card's frame take 8 columns; the glyph, the toggle, two gaps and the
113 // elapsed time (always shown whole) take the rest. The name gives way, never the time.
114 const room = Math.max(8, (e.props.bodyColumns ?? 60) - 8 - 1 - 2 - 2 - Math.max(5, r.elapsed.length) - 1)
115 const head = `${isOpen ? '▾' : '▸'} ${fit(r.task, room)}`
116 const detail = [r.harness, r.step].filter(Boolean).join(' · ')
117 return <Box key={`dp-lane-${l.dir}`} width="100%" flexShrink={1} minWidth={0} flexDirection="column" {...skin.rows} hover={{ backgroundColor: skin.hover }}>
118 <Box width="100%" flexShrink={1} minWidth={0} gap={1} flexWrap="nowrap" alignItems="center">
119 <Box width={1} flexShrink={0}><Text size="caption" color={toneOf(r)}>{glyphOf(r)}</Text></Box>
120 <Box flexShrink={1} flexGrow={1} minWidth={0}>
121 <Button key={`dp-open-${l.dir}`} plain label={head} onPress={press} />
122 </Box>
123 <Box flexShrink={0}><Text size="caption" color={toneOf(r)} tabular>{r.elapsed}</Text></Box>
124 </Box>
125 <Box width="100%" flexShrink={1} minWidth={0} paddingLeft={2}><Text size="caption" dimColor wrap="truncate-end">{detail}</Text></Box>
126 {isOpen ? <Box key={`dp-detail-${l.dir}`} width="100%" flexShrink={1} minWidth={0} flexDirection="column" paddingLeft={2}>
127 {pathLine(`dp-brief-${l.dir}`, 'brief', r.brief)}
128 {pathLine(`dp-report-${l.dir}`, 'report', r.report)}
129 </Box> : null}
130 </Box>
131 }
132 return <Box key="pc-dispatch" width="100%" flexShrink={1} minWidth={0} flexDirection="column" gap="xs" paddingX="sm" paddingY="xs" borderStyle="round" {...skin.card()}>
133 <Box width="100%" flexShrink={1} minWidth={0} flexDirection="column">
134 <Box key="dp-title" flexShrink={0}><Text size={skin.title} weight="bold">Worker lanes</Text></Box>
135 <Box key="dp-count" width="100%" flexShrink={1} minWidth={0}><Text size="caption" dimColor tabular wrap="truncate-end">{counts}</Text></Box>
136 </Box>
137 {groups.filter(g => g.lanes.some(l => shown.has(l))).map(g => <Box key={`dp-group-${g.name}`} width="100%" flexShrink={1} minWidth={0} flexDirection="column">
138 <Box width="100%" flexShrink={1} minWidth={0}><Text size="caption" weight="bold" dimColor wrap="truncate-end">{g.name}</Text></Box>
139 {g.lanes.filter(l => shown.has(l)).map(laneRow)}
140 </Box>)}
141 {all.length > shown.size || showAll && all.length > SHOWN ? <Box key="dp-more" paddingX={1} hover={{ backgroundColor: skin.hover }}>
142 <Button key="dp-more-button" plain dimColor label={showAll ? 'Show less' : `Show ${all.length - shown.size} more`}
143 onPress={view($, () => update($, expanded, s => (s.includes('dp-all') ? s.filter(x => x !== 'dp-all') : [...s, 'dp-all'])))} />
144 </Box> : null}
145 </Box>
146}
147hooks/reload.ts 46 lines1/** The Reload plugins result as one short line set: counts, never session ids. */
2export type ReloadReport = {
3 version?: string
4 already_current?: boolean
5 reloaded?: string[]
6 reloaded_by_app?: string[]
7 pending_app_reload?: string[]
8 needs_restart?: string[]
9}
10
11const list = (l?: string[]) => (Array.isArray(l) ? l : []).filter(id => typeof id === 'string')
12const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
13
14/**
15 * What a reload may claim. `applied` is how many sessions the app's own log says reloaded since the press, or null when
16 * the log could not be read. The helper's lists are what it sent; only the log says what landed.
17 */
18export function reloadMessage(r: ReloadReport, threads: { id: string; title?: string }[], applied: number | null = null): string {
19 const pending = list(r.pending_app_reload).length
20 const stuck = list(r.needs_restart)
21 const rows = Array.isArray(threads) ? threads : []
22 const titles = stuck.map(id => rows.find(t => t.id === id)?.title).filter((t): t is string => !!t)
23 const named = titles.length ? `: ${titles.slice(0, 3).join(', ')}${stuck.length > Math.min(3, titles.length) ? ` +${stuck.length - Math.min(3, titles.length)} more` : ''}` : ''
24 return [
25 r.already_current ? 'Already current.' : r.version ? `Updated to ${r.version}.` : 'Updated.',
26 applied === null ? 'Sent, not confirmed: the app log could not be read.'
27 : applied > 0 ? `Reloaded ${plural(applied, 'session', 'sessions')}.`
28 : 'Sent, not confirmed: no session has said it reloaded.',
29 pending ? `The app is reloading ${pending} more.` : '',
30 stuck.length ? `${plural(stuck.length, 'session still needs', 'sessions still need')} /reload-plugins typed${named}.` : '',
31 ].filter(Boolean).join(' ')
32}
33
34export type ReloadState = { running: boolean; message: string; failed: boolean; at?: number }
35
36const SHOW_MS = 10 * 60_000
37const STUCK_MS = 5 * 60_000
38const none = { running: false, message: '', failed: false }
39
40/** What the card shows. `at` is when the run started (running) or finished; no `at` is old saved state and counts as expired. */
41export function reloadView(s: ReloadState, now: number): { running: boolean; message: string; failed: boolean } {
42 const at = typeof s.at === 'number' ? s.at : -Infinity
43 if (s.running) return now - at <= STUCK_MS ? { running: true, message: s.message, failed: s.failed } : none
44 return s.message && now - at <= SHOW_MS ? { running: false, message: s.message, failed: s.failed } : none
45}
46hooks/work.ts 62 lines1// Who is already on a thread. A thread with a live delegated or working session is not a to-do:
2// it waits under "Being worked on" until that session finishes (review) or goes quiet (stalled).
3import type { Thread } from '../types'
4
5/** A delegated session with no activity for this long is stalled and comes back once as "Stalled: <title>". */
6export const STALL_MS = 6 * 3600_000
7
8export type SessionRow = { sessionId?: unknown; title?: unknown; firstPrompt?: unknown; isRunning?: unknown; isArchived?: unknown; lastActivityAt?: unknown; createdAt?: unknown }
9export type Launch = { id: string; at: number }
10export type Work = 'free' | 'working' | 'review' | 'stalled'
11
12const stamp = (v: unknown) => typeof v === 'number' ? v : typeof v === 'string' ? Date.parse(v) : NaN
13const norm = (v: unknown) => typeof v === 'string' ? v.replace(/[^\p{L}\p{N}]+/gu, ' ').trim().toLowerCase() : ''
14export const sessionToken = (id: string) => `nu-${id.slice(0, 8)}`
15
16/** The one session created after the launch that carries the thread's tag or title; ambiguity is no match. */
17export function childOf(t: Thread, launch: Launch, rows: SessionRow[], now: number): SessionRow | undefined {
18 const title = norm(t.title)
19 const token = ` ${norm(sessionToken(t.id))} `
20 const hit = rows.filter(r => {
21 if (typeof r.sessionId !== 'string' || !/^[\w-]+$/.test(r.sessionId)) return false
22 const created = stamp(r.createdAt ?? r.lastActivityAt)
23 return Number.isFinite(created) && created >= launch.at && created <= now
24 && [r.title, r.firstPrompt].some(v => { const n = ` ${norm(v)} `; return n.includes(token) || (!!title && n.includes(` ${title} `)) })
25 })
26 return hit.length === 1 ? hit[0] : undefined
27}
28
29const quiet = (r: SessionRow, now: number) => {
30 const at = stamp(r.lastActivityAt ?? r.createdAt)
31 return !Number.isFinite(at) || now - at >= STALL_MS
32}
33
34/**
35 * working: a live session is on it, so it is not a to-do. review: the delegated session ended.
36 * stalled: the session (or an unseen launch) has been quiet for STALL_MS. free: nothing is on it.
37 * `rows` are the live session rows; the thread's own session id is `local_<thread id>` in the desktop app.
38 */
39export function workOf(t: Thread, launch: Launch | undefined, rows: SessionRow[], now: number): Work {
40 const own = rows.find(r => r.sessionId === `local_${t.id}` || r.sessionId === t.id)
41 const lastCheck = Date.parse(t.last)
42 const ownLive = !!own && own.isArchived !== true && own.isRunning === true && !quiet(own, now)
43 // A session worked on it since the board last looked at it.
44 const ownSince = !!own && own.isArchived !== true && !quiet(own, now) && Number.isFinite(lastCheck) && stamp(own.lastActivityAt) > lastCheck
45 if (!launch) return ownLive || ownSince ? 'working' : 'free'
46 const child = childOf(t, launch, rows, now)
47 if (child) {
48 if (child.isRunning !== false && child.isArchived !== true) return quiet(child, now) ? 'stalled' : 'working'
49 return ownLive ? 'working' : 'review'
50 }
51 if (ownLive || ownSince) return 'working'
52 return now - launch.at >= STALL_MS ? 'stalled' : 'working'
53}
54
55/** Identifies one stall: it changes when the launch or any session on the thread has new activity. */
56export function stallKey(t: Thread, launch: Launch | undefined, rows: SessionRow[], now: number): number {
57 const own = rows.find(r => r.sessionId === `local_${t.id}` || r.sessionId === t.id)
58 const child = launch ? childOf(t, launch, rows, now) : undefined
59 const at = (r?: SessionRow) => { const v = stamp(r?.lastActivityAt ?? r?.createdAt); return Number.isFinite(v) ? v : 0 }
60 return Math.max(launch?.at ?? 0, at(own), at(child))
61}
62hooks/events.ts 143 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { Feedback } from '../types'
5
6// Every button press ends in a visible result and one line in mod-events.jsonl, so a press that did nothing cannot be silent.
7// act() wraps a handler: it draws "Working…" at once, then the outcome (done, failed, or nothing changed and why).
8// The Sentinel reads mod-events.jsonl (SN-07: feedback within a second, no press without an outcome, no failure unseen).
9
10type E = EngineInterface
11export type Outcome = 'ok' | 'fail' | 'noop'
12/** What a handler may return: nothing or true is ok, false is nothing changed, a string is an ok message. */
13export type Done = void | boolean | string | { outcome?: Outcome; msg?: string; proof?: string }
14type Result = { outcome: Outcome; msg: string; proof?: string }
15
16// register.tsx declares the same atom: the state scan reads an atom only where its file declares it.
17export const feedback = atom({ plugin: 'nextup', key: 'feedback' } as const, null as Feedback)
18
19/** Thrown by a handler that did not act and knows why; the reason is the message and the outcome is a no-op. */
20export class Declined extends Error {}
21
22const OK_MS = 6_000
23const FAIL_MS = 30_000
24const PENDING_MS = 30_000
25const NOTHING = 'Nothing changed.'
26const MAX_FILE = 1_048_576
27
28let dataDir = ''
29/** register() gives the data folder option; with no folder nothing is logged, but results still show. */
30export const bindEvents = (dir: string) => { dataDir = dir }
31async function dirOf($: E): Promise<string> {
32 if (!dataDir.startsWith('~')) return dataDir.replace(/\/+$/, '')
33 return `${(await $.env.get('HOME')) ?? ''}${dataDir.slice(1)}`.replace(/\/+$/, '')
34}
35
36const clip = (v: string, n: number) => (v.length > n ? `${v.slice(0, n - 1)}…` : v)
37
38export function eventLine(e: { t: number; action: string; outcome: Outcome; msg: string; feedbackMs: number; proof?: string }): string {
39 return JSON.stringify({ ...e, msg: clip(e.msg.replace(/\s+/g, ' ').trim(), 200), ...(e.proof ? { proof: clip(e.proof, 120) } : {}) })
40}
41
42const reason = (e: unknown) => (e instanceof Error ? e.message : String(e ?? '')).split('\n')[0]!.trim()
43
44/** The outcome of a handler's return value or throw, as words for the pane. */
45export function outcomeOf(r: unknown, label: string): Result {
46 if (r instanceof Declined) return { outcome: 'noop', msg: r.message }
47 if (r instanceof Error) {
48 const why = clip(reason(r), 140)
49 return { outcome: 'fail', msg: why ? `Couldn't ${label}: ${why}` : `Couldn't ${label}.` }
50 }
51 if (r === false) return { outcome: 'noop', msg: NOTHING }
52 if (typeof r === 'string') return { outcome: 'ok', msg: r }
53 if (r && typeof r === 'object') {
54 const o = r as { outcome?: Outcome; msg?: string; proof?: string }
55 return { outcome: o.outcome ?? 'ok', msg: o.msg ?? '', ...(o.proof ? { proof: o.proof } : {}) }
56 }
57 return { outcome: 'ok', msg: '' }
58}
59
60/** What the pane draws for the last press: null once it has expired; a pending press that never ends reads as a failure. */
61export function feedbackView(f: Feedback, now: number): Feedback {
62 if (!f) return null
63 const age = now - f.at
64 if (f.kind === 'pending') return age > PENDING_MS ? { ...f, kind: 'fail', text: `${f.action} is taking longer than expected.` } : f
65 return age > (f.kind === 'ok' ? OK_MS : FAIL_MS) ? null : f
66}
67
68async function append($: E, line: string) {
69 const dir = await dirOf($).catch(() => '')
70 if (!dir) return
71 const file = `${dir}/mod-events.jsonl`
72 // One short append is atomic enough for one writer; past 1 MB the file keeps its last 2000 lines.
73 const script = `printf '%s\\n' "$L" >> "$F"; n=$(wc -c < "$F"); if [ "$n" -gt ${MAX_FILE} ]; then tail -n 2000 "$F" > "$F.tmp" && mv "$F.tmp" "$F"; fi`
74 try { await $.process.run(['/bin/sh', '-c', script], { env: { F: file, L: line }, timeoutMs: 5_000 }) } catch { /* A log that cannot be written must not hide the result. */ }
75}
76
77let seq = 0
78/** The engine's clock, or the system's in a host with no clock adapter. */
79async function clockNow($: E): Promise<number> {
80 try { return await $.clock.now() } catch { return Date.now() }
81}
82/** Clears this result's line after its time, unless a newer one replaced it. */
83function expireLater($: E, ms: number, id: string) {
84 try { $.clock.after(ms + 1, () => void update($, feedback, f => (f && f.id === id ? null : f))) } catch { /* No clock: the next press replaces it. */ }
85}
86type Opts = { /** The action in a failure sentence: "Couldn't <label>: why". Defaults to the action name. */ label?: string
87 /** Why nothing changed, when the handler returns false. */ noop?: string
88 /** A toggle: no pending state, no ok message, no log line unless it fails. */ quiet?: boolean
89 /** Long work (a rescan, a reload): the press answers once Working… shows and the work finishes behind it, so nothing queues behind the press. */ detach?: boolean }
90
91/** Wraps a button handler: pending at once, then done, failed or nothing changed (with the reason), and one logged line. */
92export function act<A extends unknown[]>($: E, action: string, fn: (...a: A) => unknown, o: Opts = {}): (...a: A) => Promise<void> {
93 const label = o.label ?? action
94 return async (...a: A) => {
95 const me = ++seq
96 const id = `${action}:${me}`
97 const t0 = await clockNow($)
98 const show = (kind: NonNullable<Feedback>['kind'], text: string) => update($, feedback, () => ({ id, action, kind, text, at: t0 }))
99 if (!o.quiet) await show('pending', 'Working…')
100 const t1 = await clockNow($)
101 const finish = async () => {
102 let r: Result
103 try { r = outcomeOf(await fn(...a), label) } catch (error) { r = outcomeOf(error, label) }
104 if (r.outcome === 'noop' && (!r.msg || r.msg === NOTHING)) r.msg = `${NOTHING} ${o.noop ?? 'Check the Next up data folder option.'}`
105 // An older press finishing late never replaces what a newer one is showing.
106 const mine = async (fn2: (f: Feedback) => Feedback) => update($, feedback, f => (f && Number(f.id.split(':')[1]) > me ? f : fn2(f)))
107 if (r.outcome === 'ok' && !r.msg) await mine(f => (f && f.id === id ? null : f))
108 else await mine(() => ({ id, action, kind: r.outcome, text: r.msg, at: t0 }))
109 if (r.outcome !== 'ok' || r.msg) {
110 const ms = r.outcome === 'ok' ? OK_MS : FAIL_MS
111 expireLater($, ms, id)
112 }
113 if (o.quiet && r.outcome === 'ok') return
114 await append($, eventLine({ t: t0, action, outcome: r.outcome, msg: r.msg, feedbackMs: Math.max(0, t1 - t0), ...(r.proof ? { proof: r.proof } : {}) }))
115 }
116 if (o.detach) void finish().catch(() => undefined)
117 else await finish()
118 }
119}
120
121/**
122 * A result that arrives after the press has already answered: the app's log said the session never opened, say.
123 * It takes the pane's result line like a press does, and is logged like one, with what proved it.
124 */
125export async function late($: E, action: string, outcome: Outcome, msg: string, proof?: string) {
126 const t = await clockNow($)
127 const id = `${action}:${++seq}`
128 await update($, feedback, () => ({ id, action, kind: outcome, text: msg, at: t }))
129 expireLater($, outcome === 'ok' ? OK_MS : FAIL_MS, id)
130 await append($, eventLine({ t, action, outcome, msg, feedbackMs: 0, ...(proof ? { proof } : {}) }))
131}
132
133/** A pure view change (a tab, an expand): quiet while it works, loud when it fails. */
134export function view<A extends unknown[]>($: E, fn: (...a: A) => unknown) {
135 return act($, 'view', async (...a: A) => { await fn(...a) }, { quiet: true, label: 'update the view' })
136}
137
138/** The live loop's heartbeat: when it last ran and whether the pane was shown. The Sentinel flags a shown pane whose beat stopped. */
139export async function beat($: E, dir: string, shown: boolean) {
140 if (!dir) return
141 try { await $.fs.write(`${dir}/mod-heartbeat.json`, JSON.stringify({ t: await clockNow($), shown })) } catch { /* Read-only folders are reported by the board read. */ }
142}
143hooks/applog.ts 48 lines1// Reads the Claude desktop app's main.log for proof of what it did after a press.
2// A press that exits 0 proves a command ran; only the app's own log says whether the session opened or reloaded.
3
4const LINE = /^(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d) /
5
6/** A log line's local time as epoch ms; null when the line has none. */
7export function logTime(line: string): number | null {
8 const m = LINE.exec(line)
9 if (!m) return null
10 const [y, mo, d, h, mi, s] = m.slice(1).map(Number) as [number, number, number, number, number, number]
11 const at = new Date(y, mo - 1, d, h, mi, s)
12 // A date the calendar rolled over (month 13) is not a time.
13 return at.getFullYear() === y && at.getMonth() === mo - 1 && at.getDate() === d && at.getHours() === h ? at.getTime() : null
14}
15
16/** Epoch ms as the log writes it, local time. */
17export function stampOf(at: number): string {
18 const d = new Date(at)
19 const p = (n: number) => String(n).padStart(2, '0')
20 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
21}
22
23const lines = (log: string) => log.split('\n')
24// The log keeps whole seconds, so a line written in the second of the press counts as after it.
25const wholeSecond = (at: number) => Math.floor(at / 1000) * 1000
26
27/** True when the app logged transcript_missing for this session at or after `since` (the press). */
28export function missingSince(log: string, id: string, since: number): boolean {
29 since = wholeSecond(since)
30 return lines(log).some(l => {
31 const t = logTime(l)
32 return t !== null && t >= since && l.includes('transcript_missing') && l.includes(`'${id}'`)
33 })
34}
35
36/** How many distinct sessions the app says it reloaded cleanly at or after `since`. */
37export function appliedSince(log: string, since: number): number {
38 since = wholeSecond(since)
39 const ids = new Set<string>()
40 for (const l of lines(log)) {
41 const t = logTime(l)
42 if (t === null || t < since) continue
43 const m = /reload_plugins applied for (local_[\w-]+): .*?(\d+) plugin error\(s\)/.exec(l)
44 if (m && m[2] === '0') ids.add(m[1]!)
45 }
46 return ids.size
47}
48