SLOPSHOPPER

nextup

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

newpanebandguardcommandtoast
v0.6.1MITupdated 2026-10-26darrentmorgan/nextup/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nextup
│ ┃ Next up ✕ › fix the failing auth test and add an audit log call │ ┃ Next up More ▾ │ ┃ ▣ client module ./rail.tsx ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │✓ This turn 9 calls ▸ 08:53 · 3 edits · 1 e ⏺ Update(src/auth.ts) │ ┃ ╰─────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ╭──────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │Permissions 0 waiting · 0 denied│ ⎿ 3 pass, 1 fail │ ┃ │9 allowed │ │ ┃ │0 waiting0 asked you0 denied │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ ✻ Worked for 42s · done 4:20 PM │ ┃ │Files touched 3 edited│ │ ┃ │▸ cache.ts src/ · ×1 · now│ › /nextup │ ┃ │▸ audit.ts src/ · ×1 · now│ ⎿ nextup: Next up opened. │ ┃ │▸ auth.ts src/ · ×1 · now│ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭─────────────────────────────────────────── │ ┃ │Session log newest first │ ┃ │08:53 · main · Bash · cat .env │ ┃ │08:53 · main · Bash · rm -rf build │ ┃ │08:53 · main · Bash · git status -- │ ┃ │08:53 · main · Bash · bun test │ ┃ │08:53 · main · Write · src/cache.ts │ ┃ │08:53 · main · Write · src/audit.ts │ ┃ │08:53 · main · Edit · src/auth.ts ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Next up
Next up More ▾ ▣ client module ./rail.tsx ╭──────────────────────────────────────────────────────────╮ │✓ This turn 9 calls ▸ 08:53 · 3 edits · 1 errors│ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │Permissions 0 waiting · 0 denied│ │9 allowed │ │0 waiting0 asked you0 denied │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │Files touched 3 edited│ │▸ cache.ts src/ · ×1 · now│ │▸ audit.ts src/ · ×1 · now│ │▸ auth.ts src/ · ×1 · now│ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │Session log newest first │08:53 · main · Bash · cat .env │08:53 · main · Bash · rm -rf build && git push --fo │08:53 · main · Bash · git status --porcelain │08:53 · main · Bash · bun test │08:53 · main · Write · src/cache.ts │08:53 · main · Write · src/audit.ts │08:53 · main · Edit · src/auth.ts │ Show 3 more ╰─────────────────────────────────────────────────────────── This log is for this session only and is not saved. No data folder set. Set dataDir in this plugin's options to show your board.
README

Next up

A Claude Code plugin with a pane beside the chat: what to do next, and what this session has done. MIT licensed.

Quick start

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.

Core versus optional integrations

Core

  • Session: edits, errors, permission decisions, helper lifetimes, files touched, prompts and tool calls. Session activity stays in memory.
  • Board (with 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.
  • Local state: Done, Drop, Park and Keep are saved by item id; the newest timestamp wins. Producers must preserve stable ids and follow the marks format.

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.

Optional

IntegrationEnable withBehavior / limitation
Board refreshYour own build.py in dataDir/nextup-refresh can rebuild the board; a static example needs no producer.
GitHub PRsAuthenticated gh CLI and dataDirReads your open PRs every ten minutes; never runs in template mode.
Desktop sessionsClaude desktop session serviceNeeds you and Open session links depend on this service; unavailable surfaces hide them.
Worker dispatchdispatchDir or DISPATCH_ROOTSession shows worker lanes and a Fast switch. The switch writes a fast file for compatible Codex/pi workers, not the Claude session.
Skill launchesMatching thread/PR context and available skillsLaunches 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 draftscalendar.json, drafts.jsonRead-only summaries; the plugin does not send drafts.
Attentioninterruptions.jsonLocal interruption summaries and ratings; hidden when absent.
SentinelExternal checker producing sentinel.json and sentinel-state.jsonEvidence and action prompts; absence and stale checks are shown explicitly. The checker is not required.
Reload helperreloadHelper script pathRuns python3 <path> --plugin nextup@nextup --json, expecting plugins and needs_restart. Without it, updates plugins and asks you to run /reload-plugins.
Worker questionsMCP form elicitation hook and dataDirLocal question/answer ledger; see below. Plain-chat questions are not covered.

Options

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.

Worker questions

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.

Privacy and local writes

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.

Develop

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.

License

MIT; see LICENSE.

Source 21 files
hooks/register.tsx 2404 lines
1import { 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 lines
1import { 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}
144
hooks/activity.tsx 553 lines
1import { 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}
553
hooks/needs.ts 207 lines
1import { 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}
207
hooks/elicit/ElicitCard.tsx 50 lines
1import 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}
50
hooks/elicit/model.ts 143 lines
1// 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}
143
hooks/elicit/store.ts 71 lines
1import 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}
71
hooks/dispatch.tsx 147 lines
1import { 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}
147
hooks/reload.ts 46 lines
1/** 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}
46
hooks/work.ts 62 lines
1// 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}
62
hooks/events.ts 143 lines
1import { 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}
143
hooks/applog.ts 48 lines
1// 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