One panel for every Claude Code session on this machine: what each agent needs from you, and what each one got done.

<img src="docs/assets/promo-en.gif" alt="Standup in 46 seconds: six sessions at once, tasks only you can do land in To do, an answer goes back to the session that asked, and the Summary lists what each project got done." />
<a href="./.claude-plugin/plugin.json"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FSma1lboy%2Fclaude-standup%2Fmain%2F.claude-plugin%2Fplugin.json&query=%24.version&label=version&style=flat-square&color=c96442" alt="version" /></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT license" /></a>
A Claude Code mod that puts every session on your machine into one panel. The top half, To do, lists what each agent is waiting on you for. The bottom half, Summary, is a stream of what the agents got done: one card per message, newest first, each naming the project it came from.
Needs Claude Code 2.1.299 or newer.
At the prompt of a terminal session:
/plugin install standup --marketplace Sma1lboy/claude-standup
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
From a shell:
claude plugin install standup --marketplace Sma1lboy/claude-standup
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Sma1lboy/claude-standup, then Add plugin.
Working on the mod itself? Add your clone as a local marketplace instead (claude plugin marketplace add <folder>, then claude plugin install standup@standup); edits are then picked up by /reload-plugins with no reinstall.
/standup opens the panel. In a plain terminal it is a band above the prompt, drawn on the terminal's own background, and /standup again folds it away; in the desktop app it is a pane./standup-settings opens the settings: language (中文 or English, default 中文), open on session start (default on), open the panel when a task arrives (default on), draggable divider between the two halves (default off), the "No more like this" button on messages (default off). Settings apply to every session on the machine.N tasks need you · /standup, counted across all sessions.project · branch · session title. Linked worktrees use their repository name; branches refresh every minute. Titles use the host title when available, then the first prompt (up to 80 characters), then the folder name. Older cards remain readable. Updates are grouped into project blocks, ordered by their newest unread update; each block lists updates newest first.When Claude needs something only you can do (put a key in place, act on your machine or accounts, make a call that is yours, review or approve), it calls assign_task. A task is one of:
You can answer from the panel in any session. The answer goes back to the session that asked: if that Claude is idle it gets the answer as a new message at once; if it is busy, after its current turn.
Guards:
sk-…, ghp_…, AKIA…, a private key block, a JWT, password=…, a long mixed-case token) is refused, so it never lands in a transcript.withdraw_task, and list its open ones with list_tasks.After each turn that used tools, Standup asks Haiku to summarize that session's transcript, at most once every 30 minutes per session. If Haiku is unavailable it uses the session's own model. The whole panel admits at most one new message every 30 minutes across all sessions, including consequential authorized actions. A session delayed by another publication retries later without losing its baseline. Each run keeps the newest unseen milestone, or an authorized action if there is no new milestone, and retains the full summary as its next baseline.
A milestone is the session’s overarching goal finishing, or a major stage of that goal: a daily standup update, not each PR, MR or finished run. PR/MR numbers do not belong in milestone headlines. Reading code, answering a question, a plan, a half-finished step or a commit never count. Consequential things Claude did without asking (a merge, a release, a deploy) because your memory, CLAUDE.md, settings or an earlier general yes covered it get a message too, with the permission named at the end.
The prompt sees the latest 60 feed items across all sessions as already reported. New lines with strongly overlapping normalized text from another session are also dropped before publication. Read messages and departed sessions still count for deduplication and cadence.
Messages stay after their session closes. OK hides a message in every session. No more like this (turn it on in /standup-settings) also saves it as a mute: later summaries in every session skip things of that kind (the model judges what is similar). If summarizing fails, the Summary section says <project>: Updates paused: <reason>.
Open ↗ on a task switches the desktop app to the session that handed it out (open claude://claude.ai/epitaxy/<id>). Tasks from this session, and from sessions running in a plain terminal (which have no such id), show no Open. Summary messages have no Open.
The mod adds a section to the end of the system prompt: things only you can do go through assign_task, even if Claude also asked in its reply, and a missing secret is always a task. If you already gave a standing permission for that kind of action, Claude does it without asking and ends its reply with one line saying what it did and under which permission, which is what those messages pick up. The three tool descriptions carry the same rule and are loaded up front, so the model sees them every turn.
Where an organization manages Claude Code, prompt.compose and session.compact hooks run only for plugins listed in managed settings. There the system-prompt section and the after-compact reminder are skipped, and the tool descriptions alone carry the rule.
All data lives in the mod's $.store, one JSON file under ~/.claude/plugins/store/. Each session writes only its own keys, so sessions never overwrite each other:
| Key | Holds | Written by |
|---|---|---|
card:<session> | that session's tasks, feed, last summary, heartbeat | that session |
reply:<session>:<task> | an answer to one task | whichever session answered |
ack:<message> | OK on a message | any session |
mute:<message> | a "No more like this" | any session |
settings | the settings | any session |
feed-claim:<session> | temporary publication claim timestamp | that session |
Every session reads the store every 3 seconds and writes a heartbeat every 60. A session with no heartbeat for 3 minutes is treated as gone and its tasks leave the panel. Cards, replies, OKs and abandoned claims older than 7 days are deleted; mutes are kept. Publication claims serialize competing writers and are removed after the write, including failures; a crashed writer’s claim expires after 30 minutes. Existing card and settings formats are unchanged.
/reload-plugins does not fire session.start, so joining the store and registering the commands and tools happens on whichever hook runs first.
The panel is one TSX tree for the terminal and the desktop app, using Claude Code's theme colors only (claude orange as the accent; warning, success, error, subtle for meaning). Cards have round borders that turn orange on hover, and lay out by the pane's width: in a narrow dock the options of a choose-task stack, a long detail is cut to two lines. The divider, when on, is a short handle drawn by a Client module that takes the drag and posts the new task-area height.
bun scripts/demo.ts
Adds three made-up sessions (tasks and messages) to the installed mod's store, visible for two hours. bun scripts/demo.ts --clear removes them.
The claude on your PATH must be 2.1.299 or newer, or validate rejects the hooks it does not know yet. The desktop app's own binary is at $CLAUDE_CODE_EXECPATH.
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
55 unit tests cover assigning (parallel calls included), cross-session answers, secret refusal, reply delivery (a dropped prompt kept for after the turn, overlapping polls submitting once), stale sessions, goal-level prompts, shared-feed deduplication, the global 30-minute gap and competing publications, model fallback, paused updates, OK and mutes, settings sync, auto-open, the band keeping other mods' content, registration after a reload, the system-prompt section, the after-compact reminder, Jump, the divider, and pruning, on both the terminal and desktop surfaces.
bun evals/run.ts all
Runs real models and appends to evals/results.jsonl, marking each case fixed or REGRESSED against its last run:
Evals load the mod with --plugin-dir, which gets its own store, so test tasks never show up in your real panel.
MIT
hooks/register.tsx 515 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Card, FeedItem, Reply, HubSettings, Snapshot, Summary, Task, TaskKind } from '../types'
5import {
6 DEFAULT_SETTINGS,
7 HEARTBEAT_MS,
8 HUB_PANE,
9 MAX_FEED,
10 MAX_OPEN_TASKS,
11 POLL_MS,
12 SETTINGS_KEY,
13 SETTINGS_PANE,
14 SUMMARY_GAP_MS,
15 ackKey,
16 cardKey,
17 duplicateOf,
18 feedGapPassed,
19 looksLikeSecret,
20 overlapsFeed,
21 muteKey,
22 newItems,
23 openTasks,
24 parseSummary,
25 readSettings,
26 readSummary,
27 recentFeed,
28 replyKey,
29 shortHash,
30 snapshotOf,
31 staleKeys,
32 statusText,
33 summaryPrompt,
34 t,
35 transcriptText,
36} from './hub'
37import type { FeedRow, OpenTask } from './hub'
38import { basename, currentBranch, firstPromptTitle, projectName, shortTitle, promptTitle, turnRepo } from './source'
39import { withFeedClaim } from './feed-claim'
40import { renderHub, renderSettings } from './panel'
41import type { Actions } from './panel'
42import {
43 ASSIGN_DESCRIPTION,
44 ASSIGN_SCHEMA,
45 LIST_DESCRIPTION,
46 STEERING,
47 WITHDRAW_DESCRIPTION,
48 WITHDRAW_SCHEMA,
49} from './steering'
50
51const snapAtom = atom({ plugin: 'standup', key: 'snap' } as const, null)
52const settingsAtom = atom({ plugin: 'standup', key: 'settings' } as const, DEFAULT_SETTINGS)
53const errorsAtom = atom({ plugin: 'standup', key: 'errors' } as const, {})
54const bandAtom = atom({ plugin: 'standup', key: 'bandOpen' } as const, false)
55
56const TOOL = {
57 assign: 'mcp__standup__assign_task',
58 withdraw: 'mcp__standup__withdraw_task',
59 list: 'mcp__standup__list_tasks',
60} as const
61
62const PRUNE_EVERY_MS = 10 * 60_000
63
64// Module state: a reload starts it over, and the lazy join below rebuilds it.
65const rt = {
66 joining: undefined as Promise<void> | undefined,
67 sid: '',
68 startHandled: false,
69 hostId: undefined as string | undefined,
70 seenTasks: undefined as Set<string> | undefined,
71 toolsThisTurn: 0,
72 summaryDue: false,
73 summarizing: false,
74 lastPrune: 0,
75 seenSessions: '',
76 // Replies whose prompt a hook dropped: kept, and tried again once the next turn ends.
77 held: new Set<string>(),
78 sending: new Set<string>(),
79 writes: Promise.resolve() as Promise<unknown>,
80}
81
82/** Every hook calls this: /reload-plugins fires no session.start, so the first hook to run joins. */
83export function join($: EngineInterface) {
84 rt.joining ??= doJoin($).catch(err => {
85 rt.joining = undefined
86 throw err
87 })
88 return rt.joining
89}
90
91async function doJoin($: EngineInterface) {
92 rt.sid = await $.session.id()
93 rt.hostId = (await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')) || undefined
94 const cwd = await $.session.cwd()
95 const now = await $.clock.now()
96 const old = (await $.store.get(cardKey(rt.sid))) as Card | undefined
97 const card: Card = {
98 sid: rt.sid,
99 hostId: rt.hostId,
100 cwd,
101 name: basename(cwd),
102 repo: await projectName($, cwd),
103 branch: await currentBranch($, cwd),
104 title: promptTitle(old?.title ?? '') || await firstPromptTitle($),
105 heartbeat: now,
106 tasks: old?.tasks ?? [],
107 feed: old?.feed ?? [],
108 summary: readSummary(old?.summary),
109 summaryAt: old?.summaryAt,
110 }
111 await $.store.set(cardKey(rt.sid), card)
112
113 await Promise.all([
114 $.command.register({ name: 'standup', description: 'Open Standup: what every session needs from you, and what each finished', immediate: true }),
115 $.command.register({ name: 'standup-settings', description: 'Standup settings: language, auto-open, divider', immediate: true }),
116 $.tool.register({ name: 'assign_task', description: ASSIGN_DESCRIPTION, inputSchema: ASSIGN_SCHEMA, isDeferred: false }),
117 $.tool.register({ name: 'withdraw_task', description: WITHDRAW_DESCRIPTION, inputSchema: WITHDRAW_SCHEMA, isDeferred: false }),
118 $.tool.register({ name: 'list_tasks', description: LIST_DESCRIPTION, inputSchema: { type: 'object', properties: {} }, isDeferred: false }),
119 ])
120
121 $.clock.every(POLL_MS, () => void poll($).catch(err => $.ui.log(`standup: poll failed: ${String(err)}`, { to: 'debug' })))
122 $.clock.every(HEARTBEAT_MS, () => void heartbeat($).catch(() => undefined))
123 await poll($)
124 if (!rt.startHandled) {
125 // Decide once at join, including the lazy join after /reload-plugins.
126 // Polls and turns must not undo a user's close or a disabled startup setting.
127 const settings = await read($, settingsAtom)
128 rt.startHandled = true
129 if (settings.openOnStart) await showHub($, settings.lang, false)
130 }
131}
132
133/** Read-modify-write of this session's own card, one at a time. */
134function mutateCard($: EngineInterface, fn: (card: Card) => Card | Promise<Card>) {
135 const run = rt.writes.then(async () => {
136 const card = (await $.store.get(cardKey(rt.sid))) as Card | undefined
137 if (!card) return
138 await $.store.set(cardKey(rt.sid), await fn(card))
139 })
140 rt.writes = run.catch(() => undefined)
141 return run
142}
143
144async function heartbeat($: EngineInterface) {
145 const now = await $.clock.now()
146 await mutateCard($, async card => ({
147 ...card, heartbeat: now,
148 branch: await currentBranch($, card.cwd),
149 title: promptTitle(card.title ?? '') || await firstPromptTitle($),
150 }))
151}
152
153async function readAll($: EngineInterface) {
154 const entries: Record<string, unknown> = {}
155 for (const key of await $.store.keys()) entries[key] = await $.store.get(key)
156 return entries
157}
158
159async function poll($: EngineInterface) {
160 const now = await $.clock.now()
161 let entries = await readAll($)
162
163 if (now - rt.lastPrune > PRUNE_EVERY_MS) {
164 rt.lastPrune = now
165 const stale = staleKeys(entries, now)
166 for (const key of stale) await $.store.delete(key)
167 if (stale.length) entries = await readAll($)
168 }
169
170 // Answers other sessions wrote for this one.
171 const mine = Object.keys(entries).filter(key => key.startsWith(`reply:${rt.sid}:`))
172 if (mine.length) {
173 await deliver($, mine.map(key => key.slice(`reply:${rt.sid}:`.length)))
174 entries = await readAll($)
175 }
176
177 const settings = readSettings(entries[SETTINGS_KEY])
178 const snap = snapshotOf(entries, rt.sid)
179 const seen = snap.cards.map(c => c.sid).sort().join(',')
180 if (seen !== rt.seenSessions) {
181 rt.seenSessions = seen
182 $.ui.log(`standup: sessions in the store: ${seen}`, { to: 'debug' })
183 }
184 if (JSON.stringify(await read($, settingsAtom)) !== JSON.stringify(settings)) await update($, settingsAtom, () => settings)
185 if (JSON.stringify(await read($, snapAtom)) !== JSON.stringify(snap)) await update($, snapAtom, () => snap)
186
187 const open = openTasks(snap, now)
188 $.ui.status(statusText(settings.lang, open.length))
189
190 const ids = open.map(task => `${task.sid}:${task.id}`)
191 const arrived = rt.seenTasks !== undefined && ids.some(id => !rt.seenTasks!.has(id))
192 rt.seenTasks = new Set(ids)
193 if (arrived && settings.autoOpen) void showHub($, settings.lang, false).catch(() => undefined)
194
195 const card = snap.cards.find(c => c.sid === rt.sid)
196 if (rt.summaryDue && !rt.summarizing && card && now - (card.summaryAt ?? 0) >= SUMMARY_GAP_MS && feedGapPassed(snap.cards, now)) {
197 rt.summaryDue = false
198 rt.summarizing = true
199 void summarize($, card, snap)
200 .catch(err => mutateCard($, c => ({ ...c, paused: String(err) })))
201 .finally(() => (rt.summarizing = false))
202 }
203}
204
205/** Hands each answer to the model as a prompt: at once when idle, after the turn when busy. */
206async function deliver($: EngineInterface, ids: readonly string[]) {
207 const card = (await $.store.get(cardKey(rt.sid))) as Card | undefined
208 const answered = new Set<string>()
209 for (const id of ids) {
210 // Polls can overlap: one whose reply is mid-submit, or already delivered, leaves it alone.
211 if (rt.held.has(id) || rt.sending.has(id)) continue
212 rt.sending.add(id)
213 try {
214 const reply = (await $.store.get(replyKey(rt.sid, id))) as Reply | undefined
215 if (!reply) continue
216 const task = card?.tasks.find(x => x.id === id)
217 // Submit first: if it throws, the reply key stays and the next poll tries again.
218 // If a hook drops it, the reply and task stay until the next turn ends.
219 if (task && 'drop' in (await $.prompt.submit({ text: replyMessage(task, reply) }))) {
220 rt.held.add(id)
221 continue
222 }
223 await $.store.delete(replyKey(rt.sid, id))
224 } finally {
225 rt.sending.delete(id)
226 }
227 answered.add(id)
228 }
229 if (answered.size) await mutateCard($, c => ({ ...c, tasks: c.tasks.filter(task => !answered.has(task.id)) }))
230}
231
232// ---- the feed ----
233
234async function summarize($: EngineInterface, card: Card, snap: Snapshot) {
235 const rows = await $.session.messages()
236 const repo = await turnRepo($, card.cwd, rows)
237 const prev = readSummary(card.summary)
238 const prompt = summaryPrompt(transcriptText(rows), prev, snap.mutes, recentFeed(snap.cards), card.repo)
239 const ask = (model: string) => $.model.complete({ model, prompt, maxTokens: 1500, effort: 'low', timeoutMs: 90_000 })
240 let reply = await ask('haiku')
241 if (!reply.isAnswered && reply.reason === 'api-error') reply = await ask(await $.session.model())
242
243 const now = await $.clock.now()
244 if (!reply.isAnswered) {
245 const why = reply.reason === 'api-error' ? `API error ${reply.status ?? ''} ${reply.error ?? ''}`.trim() : reply.reason
246 await mutateCard($, c => ({ ...c, paused: why, summaryAt: now }))
247 return
248 }
249 const result = parseSummary(reply.text)
250 if (!result) {
251 await mutateCard($, c => ({ ...c, paused: 'the summary came back unreadable', summaryAt: now }))
252 return
253 }
254 const summary: Summary = { milestones: result.milestones, authorized: result.authorized }
255 const fresh = newItems(prev, summary)
256 const item = (kind: FeedItem['kind'], text: string): FeedItem => ({ id: `${rt.sid}:${shortHash(kind + text + now)}`, kind, text, at: now, ...(repo ? { repo } : {}) })
257 // Require an explicit association, with both endpoints new in this run.
258 const folded = result.merged?.some(pair => fresh.milestones.includes(pair.milestone) && fresh.authorized.includes(pair.authorized)) ?? false
259 const candidates = [...fresh.milestones.map(s => item('milestone', s + (folded ? ' (merged on standing permission)' : ''))), ...(folded ? [] : fresh.authorized.map(s => item('authorized', s)))]
260 const claimed = await withFeedClaim($, rt.sid, async valid => {
261 // Another session may have published while the model was answering.
262 const current = snapshotOf(await readAll($), rt.sid)
263 const at = await $.clock.now()
264 if (!feedGapPassed(current.cards, at)) return false
265 const recent = recentFeed(current.cards).filter(row => row.sid !== rt.sid)
266 const added = candidates.filter(candidate => !recent.some(row => overlapsFeed(candidate.text.replace(/ \(merged on standing permission\)$/, ''), row.text.replace(/ \(merged on standing permission\)$/, '')))).slice(0, 1)
267 let published = false
268 await mutateCard($, async c => {
269 const publishedAt = await $.clock.now()
270 if (!await valid()) return c
271 published = true
272 return { ...c, summary, summaryAt: publishedAt, paused: undefined, feed: [...c.feed, ...added.map(item => ({ ...item, at: publishedAt }))].slice(-MAX_FEED) }
273 })
274 return published
275 })
276 // A competing publication delays this session, without consuming its baseline.
277 if (!claimed) {
278 rt.summaryDue = true
279 // Contention (including a crashed claim) must not cause a model call every poll.
280 await mutateCard($, c => ({ ...c, summaryAt: now }))
281 }
282 await poll($)
283}
284
285// ---- actions the panel calls ----
286
287export function actions($: EngineInterface): Actions {
288 return {
289 complete: task => sendReply($, task, ''),
290 skip: task => sendReply($, task, 'no longer needed', true),
291 answer: async (task, text) => {
292 const key = `${task.sid}:${task.id}`
293 if (looksLikeSecret(text)) {
294 const { lang } = await read($, settingsAtom)
295 await update($, errorsAtom, errs => ({ ...errs, [key]: t(lang, 'secretRefused') }))
296 return
297 }
298 await update($, errorsAtom, ({ [key]: _, ...rest }) => rest)
299 await sendReply($, task, text.trim())
300 },
301 choose: (task, option) => sendReply($, task, option),
302 ack: async row => {
303 await $.store.set(ackKey(row.id), await $.clock.now())
304 await poll($)
305 },
306 ackAll: async rows => {
307 const now = await $.clock.now()
308 for (const row of rows) await $.store.set(ackKey(row.id), now)
309 await poll($)
310 },
311 mute: async row => {
312 const now = await $.clock.now()
313 await $.store.set(ackKey(row.id), now)
314 await $.store.set(muteKey(row.id), { text: row.text, at: now })
315 await poll($)
316 },
317 jump: async target => {
318 await $.process.run(['open', `claude://claude.ai/epitaxy/${target}`])
319 },
320 setSettings: async patch => {
321 const next: HubSettings = { ...(await read($, settingsAtom)), ...patch }
322 await $.store.set(SETTINGS_KEY, next)
323 await update($, settingsAtom, () => next)
324 },
325 }
326}
327
328async function sendReply($: EngineInterface, task: OpenTask, answer: string, skipped?: true) {
329 const value: Reply = { answer, at: await $.clock.now(), by: rt.sid, ...(skipped ? { skipped } : {}) }
330 await $.store.set(replyKey(task.sid, task.id), value)
331 await poll($)
332}
333
334/**
335 * In a plain terminal the panel is a band above the prompt: it draws on the terminal's own
336 * background, where a docked pane gets the engine's grey. Elsewhere it is a pane.
337 * `toggle` (the /standup command) closes an open band; an arriving task only opens it.
338 * Returns whether the panel is showing afterwards.
339 */
340async function showHub($: EngineInterface, lang: HubSettings['lang'], toggle: boolean) {
341 const surfaces = await $.session.surfaces()
342 const terminalOnly = surfaces.length > 0 && surfaces.every(s => s === 'terminal')
343 if (!terminalOnly) {
344 await $.ui.open({ id: HUB_PANE, title: t(lang, 'title'), focus: toggle ? true : undefined })
345 return true
346 }
347 const open = toggle ? !(await read($, bandAtom)) : true
348 await update($, bandAtom, () => open)
349 return open
350}
351
352export const register: Register = on => {
353 // The classic host events expose titles; the mods session namespace has no title getter.
354 on('classic.SessionStart', async ($, e, next) => {
355 await join($)
356 if (e.session_title?.trim()) await mutateCard($, card => ({ ...card, title: shortTitle(e.session_title!) }))
357 return next(e)
358 }).catch(($, e, next) => next(e))
359
360 on('classic.UserPromptSubmit', async ($, e, next) => {
361 await join($)
362 await mutateCard($, card => ({
363 ...card,
364 title: shortTitle(e.session_title ?? '') || promptTitle(card.title ?? '') || promptTitle(e.prompt),
365 }))
366 return next(e)
367 }).catch(($, e, next) => next(e))
368
369 on('session.start', async ($, e, next) => {
370 await join($)
371 return next(e)
372 })
373
374 on('prompt.compose', async ($, e, next) => {
375 void join($).catch(() => undefined)
376 const composed = await next(e)
377 return { sections: [...composed.sections, { id: 'standup:steering', text: STEERING, scope: 'session' as const }] }
378 })
379
380 on('command.run', { command: 'standup' }, async ($, e) => {
381 await join($)
382 const { lang } = await read($, settingsAtom)
383 const shown = await showHub($, lang, true)
384 return { text: t(lang, shown ? 'opened' : 'closed') }
385 })
386
387 on('command.run', { command: 'standup-settings' }, async ($, e) => {
388 await join($)
389 const { lang } = await read($, settingsAtom)
390 await $.ui.open({ id: SETTINGS_PANE, title: t(lang, 'settingsTitle'), focus: true, closeOnEscape: true })
391 return { text: t(lang, 'settingsOpened') }
392 })
393
394 on('tool.call', { tool: TOOL.assign }, async ($, e) => {
395 await join($)
396 const input = e as unknown as { kind?: string; title?: string; detail?: string; options?: unknown }
397 const kind = input.kind as TaskKind
398 const title = String(input.title ?? '').trim()
399 if (!['do', 'answer', 'choose'].includes(kind)) return { deny: 'kind must be "do", "answer" or "choose".' }
400 if (!title) return { deny: 'title is required.' }
401 const options = Array.isArray(input.options) ? input.options.map(String).map(s => s.trim()).filter(Boolean) : []
402 if (kind === 'choose' && (options.length < 2 || options.length > 4)) return { deny: 'kind "choose" needs 2 to 4 options.' }
403
404 const now = await $.clock.now()
405 const task: Task = {
406 id: `t${shortHash(rt.sid + title + now).slice(0, 5)}`,
407 kind,
408 title,
409 ...(input.detail ? { detail: String(input.detail).trim() } : {}),
410 ...(kind === 'choose' ? { options } : {}),
411 createdAt: now,
412 }
413 // Checked inside the card's write: parallel calls in one turn would each pass a check made before it.
414 let refusal: { result: string } | { deny: string } | undefined
415 await mutateCard($, c => {
416 const same = duplicateOf(c.tasks, title)
417 if (same) refusal = { result: `Already assigned as ${same.id} and still open; not assigned again. The answer will arrive as a message.` }
418 else if (c.tasks.length >= MAX_OPEN_TASKS) {
419 refusal = { deny: `This session already has ${MAX_OPEN_TASKS} open tasks (${c.tasks.map(x => x.id).join(', ')}). Withdraw one with withdraw_task or wait for answers.` }
420 }
421 return refusal ? c : { ...c, tasks: [...c.tasks, task] }
422 })
423 if (refusal) return refusal
424 await poll($).catch(() => undefined)
425 return { result: `Assigned task ${task.id} to the user in Standup. Do not wait or poll: carry on with work that does not need it, or end your turn. Their answer will arrive as a new message.` }
426 })
427
428 on('tool.call', { tool: TOOL.withdraw }, async ($, e) => {
429 await join($)
430 const id = String((e as unknown as { id?: string }).id ?? '')
431 const card = (await $.store.get(cardKey(rt.sid))) as Card
432 if (!card.tasks.some(task => task.id === id)) return { deny: `No open task ${id}. Open: ${card.tasks.map(x => x.id).join(', ') || 'none'}.` }
433 await mutateCard($, c => ({ ...c, tasks: c.tasks.filter(task => task.id !== id) }))
434 await poll($).catch(() => undefined)
435 return { result: `Withdrew task ${id}.` }
436 })
437
438 on('tool.call', { tool: TOOL.list }, async $ => {
439 await join($)
440 const card = (await $.store.get(cardKey(rt.sid))) as Card
441 return { result: card.tasks.length ? card.tasks.map(taskLine).join('\n') : 'No open tasks.' }
442 })
443
444 on('tool.call', async ($, e, next) => {
445 if (e.agentId === undefined) rt.toolsThisTurn += 1
446 return next(e)
447 }).catch(($, e, next) => next(e))
448
449 on('turn.complete', async ($, e, next) => {
450 void join($).catch(() => undefined)
451 if (e.agentId === undefined) {
452 if (rt.toolsThisTurn > 0) rt.summaryDue = true
453 rt.toolsThisTurn = 0
454 rt.held.clear()
455 }
456 return next(e)
457 })
458
459 // After a compact, the model has lost the tool results that assigned its tasks: tell it again.
460 on('session.compact', async ($, e, next) => {
461 const result = await next(e)
462 if ('skip' in result && result.skip !== undefined) return result
463 const card = (await $.store.get(cardKey(rt.sid))) as Card | undefined
464 if (card?.tasks.length) {
465 const text = `[Standup] Tasks you assigned to the user that are still open (answers arrive as messages):\n${card.tasks.map(taskLine).join('\n')}`
466 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
467 }
468 return result
469 }).catch(($, e, next) => next(e))
470
471 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
472 void join($).catch(() => undefined)
473 if (e.surface !== 'terminal' || e.props.hasSurvey || !(await read($, bandAtom))) return next(e)
474 // Another mod's band content stays, on top: the panel is tall and scrolls, so anything below it would sit out of view.
475 const theirs = await next(e)
476 const { Box } = $.ui.resolve(e)
477 return (
478 <Box flexDirection="column" gap="md">
479 {theirs}
480 {await renderHub($, e, actions($))}
481 </Box>
482 )
483 })
484
485 on('ui.render', { component: 'Pane', requestId: HUB_PANE }, async ($, e) => {
486 void join($).catch(() => undefined)
487 return renderHub($, e, actions($))
488 })
489
490 on('ui.render', { component: 'Pane', requestId: SETTINGS_PANE }, async ($, e) => {
491 void join($).catch(() => undefined)
492 return renderSettings($, e, actions($))
493 })
494
495 // The divider's Client posts the rows it was dragged to.
496 on('ui.message', { requestId: HUB_PANE }, async ($, e) => {
497 const rows = Number((e.data as { taskRows?: number })?.taskRows)
498 if (Number.isFinite(rows)) await actions($).setSettings({ taskRows: Math.max(3, Math.min(60, Math.round(rows))) })
499 return {}
500 })
501}
502
503function taskLine(task: Task) {
504 const options = task.options ? ` [${task.options.join(' / ')}]` : ''
505 return `- ${task.id} (${task.kind}): ${task.title}${options}`
506}
507
508export function replyMessage(task: Task, reply: Reply) {
509 if (reply.skipped) return `The user skipped task ${task.id} (${task.title}): no longer needed.`
510 if (task.kind === 'do') return `[Standup] The user marked your task ${task.id} as done: "${task.title}".`
511 if (task.kind === 'choose') return `[Standup] The user chose "${reply.answer}" for your task ${task.id}: "${task.title}".`
512 return `[Standup] The user answered your task ${task.id} ("${task.title}"):\n${reply.answer}`
513}
514
515hooks/hub.ts 365 lines1import type { Card, FeedItem, Lang, Mute, Reply, HubSettings, Snapshot, Summary, Task } from '../types'
2
3import { sourceLabel } from './source'
4
5// The pure half of Standup: store keys, the view model, the guards and the copy.
6
7export const HUB_PANE = 'standup'
8export const SETTINGS_PANE = 'standup-settings'
9
10export const POLL_MS = 3_000
11export const HEARTBEAT_MS = 60_000
12export const STALE_MS = 3 * 60_000
13export const PRUNE_MS = 7 * 24 * 60 * 60_000
14export const SUMMARY_GAP_MS = 30 * 60_000
15export const MAX_OPEN_TASKS = 5
16export const MAX_FEED = 60
17
18export const cardKey = (sid: string) => `card:${sid}`
19export const replyKey = (sid: string, id: string) => `reply:${sid}:${id}`
20export const ackKey = (id: string) => `ack:${id}`
21export const muteKey = (id: string) => `mute:${id}`
22export const SETTINGS_KEY = 'settings'
23
24export const DEFAULT_SETTINGS: HubSettings = { lang: 'zh', openOnStart: true, autoOpen: true, divider: false, muteButton: false, taskRows: 12 }
25
26export function readSettings(value: unknown): HubSettings {
27 const v = (value ?? {}) as Partial<HubSettings>
28 return {
29 lang: v.lang === 'en' ? 'en' : 'zh',
30 openOnStart: v.openOnStart !== false,
31 autoOpen: v.autoOpen !== false,
32 divider: v.divider === true,
33 muteButton: v.muteButton === true,
34 taskRows: typeof v.taskRows === 'number' && v.taskRows >= 3 ? Math.round(v.taskRows) : DEFAULT_SETTINGS.taskRows,
35 }
36}
37
38/** Builds the snapshot from every store entry. */
39export function snapshotOf(entries: Record<string, unknown>, self: string): Snapshot {
40 const cards: Card[] = []
41 const replied: string[] = []
42 const acked: string[] = []
43 const mutes: Mute[] = []
44 for (const [key, value] of Object.entries(entries)) {
45 if (key.startsWith('card:') && isCard(value)) cards.push(value)
46 else if (key.startsWith('reply:')) replied.push(key.slice('reply:'.length))
47 else if (key.startsWith('ack:')) acked.push(key.slice('ack:'.length))
48 else if (key.startsWith('mute:') && value && typeof value === 'object') mutes.push(value as Mute)
49 }
50 return { self, cards, replied, acked, mutes }
51}
52
53function isCard(v: unknown): v is Card {
54 const c = v as Card
55 return !!c && typeof c.sid === 'string' && Array.isArray(c.tasks) && Array.isArray(c.feed)
56}
57
58export const isLive = (card: Card, now: number) => now - card.heartbeat <= STALE_MS
59
60export type OpenTask = Task & { sid: string; name: string; hostId?: string }
61
62/** Tasks waiting on the person: live sessions only, answered ones dropped at once. */
63export function openTasks(snap: Snapshot, now: number): OpenTask[] {
64 const replied = new Set(snap.replied)
65 return snap.cards
66 .filter(card => isLive(card, now))
67 .flatMap(card =>
68 card.tasks
69 .filter(task => !replied.has(`${card.sid}:${task.id}`))
70 .map(task => ({ ...task, sid: card.sid, name: sourceLabel(card), hostId: card.hostId })),
71 )
72 .sort((a, b) => a.createdAt - b.createdAt)
73}
74
75export type FeedRow = FeedItem & { sid: string; name: string; project: string }
76
77/** The progress feed, newest first; kept after its session leaves. */
78export function feedRows(snap: Snapshot): FeedRow[] {
79 const acked = new Set(snap.acked)
80 return snap.cards
81 .flatMap(card => card.feed.map(item => ({ ...item, sid: card.sid, name: sourceLabel({ ...card, repo: item.repo || card.repo }), project: item.repo || card.repo || card.name })))
82 .filter(item => !acked.has(item.id))
83 .sort((a, b) => b.at - a.at)
84}
85
86/** Project blocks ordered by newest unread activity; rows within each stay newest first. */
87export function feedGroups(snap: Snapshot): { project: string; rows: FeedRow[] }[] {
88 const groups = new Map<string, FeedRow[]>()
89 for (const row of feedRows(snap)) {
90 const rows = groups.get(row.project) ?? []
91 rows.push(row)
92 groups.set(row.project, rows)
93 }
94 return [...groups].map(([project, rows]) => ({ project, rows }))
95}
96
97export const pausedCards = (snap: Snapshot, now: number) => snap.cards.filter(card => card.paused && isLive(card, now))
98
99/** Store keys old enough to drop. Mutes stay: they are the person's preferences. */
100export function staleKeys(entries: Record<string, unknown>, now: number): string[] {
101 return Object.entries(entries)
102 .filter(([key, value]) => {
103 if (key.startsWith('card:')) return isCard(value) && now - value.heartbeat > PRUNE_MS
104 if (key.startsWith('reply:')) return now - ((value as Reply)?.at ?? 0) > PRUNE_MS
105 if (key.startsWith('feed-claim:')) return now - Number(value ?? 0) > PRUNE_MS
106 if (key.startsWith('ack:')) return now - Number(value ?? 0) > PRUNE_MS
107 return false
108 })
109 .map(([key]) => key)
110}
111
112// ---- guards ----
113
114const SECRET_PATTERNS = [
115 /\bsk-[A-Za-z0-9_-]{16,}/, // OpenAI / Anthropic style
116 /\bgh[pousr]_[A-Za-z0-9]{20,}/, // GitHub
117 /\bgithub_pat_[A-Za-z0-9_]{20,}/,
118 /\bAKIA[0-9A-Z]{16}\b/, // AWS access key
119 /\bxox[abposr]-[A-Za-z0-9-]{10,}/, // Slack
120 /\bAIza[0-9A-Za-z_-]{30,}/, // Google
121 /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
122 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, // JWT
123 /\b(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S{6,}/i,
124]
125
126/** An answer that looks like a credential is refused: it would land in a transcript. */
127export function looksLikeSecret(text: string): boolean {
128 if (SECRET_PATTERNS.some(re => re.test(text))) return true
129 // A long unbroken run of mixed letters and digits reads as a token, not prose.
130 return text.split(/\s+/).some(word => word.length >= 32 && /[a-z]/.test(word) && /[A-Z]/.test(word) && /\d/.test(word))
131}
132
133export const normalize = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
134
135/** Finds an open task that is the same ask, so one thing is never assigned twice. */
136export const duplicateOf = (tasks: readonly Task[], title: string) => tasks.find(task => normalize(task.title) === normalize(title))
137
138// ---- summaries ----
139
140const list = (x: unknown) => (Array.isArray(x) ? x.filter((s): s is string => typeof s === 'string' && s.trim() !== '').map(s => s.trim()) : [])
141
142/** Stored summaries must contain two valid lists; otherwise start a new baseline. */
143export function readSummary(value: unknown): Summary | undefined {
144 if (!value || typeof value !== 'object') return undefined
145 const v = value as Partial<Summary>
146 if (!Array.isArray(v.milestones) || !Array.isArray(v.authorized)) return undefined
147 const milestones = list(v.milestones)
148 const authorized = list(v.authorized)
149 if (milestones.length !== v.milestones.length || authorized.length !== v.authorized.length) return undefined
150 return { milestones, authorized }
151}
152
153/** Per-response evidence only: stored summary baselines keep their existing shape. */
154export type SummaryResult = Summary & { merged?: { milestone: string; authorized: string }[] }
155
156export function parseSummary(text: string): SummaryResult | null {
157 const match = text.match(/\{[\s\S]*\}/)
158 if (!match) return null
159 try {
160 const v = JSON.parse(match[0]) as Partial<SummaryResult>
161 const summary: Summary = { milestones: list(v.milestones), authorized: list(v.authorized) }
162 const merged = Array.isArray(v.merged) ? v.merged.filter(pair =>
163 pair && typeof pair.milestone === 'string' && typeof pair.authorized === 'string'
164 && summary.milestones.includes(pair.milestone) && summary.authorized.includes(pair.authorized)
165 && /\bmerged\b|合并/i.test(pair.authorized),
166 ).map(({ milestone, authorized }) => ({ milestone, authorized })) : []
167 return merged.length ? { ...summary, merged } : summary
168 } catch {
169 return null
170 }
171}
172
173/** Newest unseen item per kind; the first summary only announces its newest milestone. */
174export function newItems(prev: Summary | undefined, next: Summary): Summary {
175 prev = readSummary(prev)
176 if (!prev) return { milestones: next.milestones.slice(-1), authorized: [] }
177 const seen = (list: readonly string[] | undefined) => new Set((list ?? []).map(normalize))
178 const ms = seen(prev?.milestones)
179 const au = seen(prev?.authorized)
180 return {
181 milestones: next.milestones.filter(s => !ms.has(normalize(s))).slice(-1),
182 authorized: next.authorized.filter(s => !au.has(normalize(s))).slice(-1),
183 }
184}
185
186/** Include read/dismissed and departed sessions too: acknowledging is not forgetting. */
187export function recentFeed(cards: readonly Card[]): FeedRow[] {
188 return cards.flatMap(card => card.feed.map(item => ({ ...item, sid: card.sid, name: sourceLabel({ ...card, repo: item.repo || card.repo }), project: item.repo || card.repo || card.name })))
189 .sort((a, b) => b.at - a.at).slice(0, MAX_FEED)
190}
191
192export function feedGapPassed(cards: readonly Card[], now: number): boolean {
193 return cards.every(card => card.feed.every(item => now - item.at >= SUMMARY_GAP_MS))
194}
195
196/** Character bigrams also work for Chinese, which has no word separators. */
197export function overlapsFeed(a: string, b: string): boolean {
198 const compact = (s: string) => normalize(s).replace(/ /g, '')
199 const x = compact(a), y = compact(b)
200 if (!x || !y) return false
201 if (x === y) return true
202 const grams = (s: string) => {
203 const chars = Array.from(s)
204 return new Set(chars.slice(1).map((c, i) => chars[i]! + c))
205 }
206 const left = grams(x), right = grams(y)
207 const shared = [...left].filter(g => right.has(g)).length
208 // Require substantial overlap on BOTH sides; shared boilerplate is not enough.
209 return shared >= 6 && shared / Math.max(left.size, right.size) >= 0.8
210}
211
212export function summaryPrompt(transcript: string, prev: Summary | undefined, mutes: readonly Mute[], recent: readonly FeedRow[] = [], repo?: string): string {
213 const muted = mutes.length ? mutes.map(m => `- ${m.text}`).join('\n') : '(none)'
214 return [
215 'You maintain a running progress summary of one Claude Code session for its owner, who watches many sessions at once.',
216 'Return ONLY a JSON object: {"milestones": string[], "authorized": string[], "merged": {"milestone": string, "authorized": string}[]}.',
217 '',
218 'The owner skims this across many sessions, so think in OUTCOMES, at the level they would tell a teammate in one breath: "login works", "the CSV export shipped", "the flaky test is fixed". Not the steps that got there.',
219 '',
220 "milestones: the session's single overarching goal reaching DONE and WORKING, or a major stage of that goal completing. Think daily standup: a few lines per agent per day. An individual PR, MR, request or finished run is not a goal; fold those steps into the overarching goal they serve.",
221 'Small follow-ups after or around a goal add NOTHING: tweaking wording or links, removing or moving a section, re-rendering, copying or moving files, pushing, renaming, a side fix found on the way. They belong to the goal they served, already listed or not.',
222 'Never mention commits, pushes, branches or hashes anywhere: the owner does not care how work was saved, only what now works.',
223 'Never a milestone: a single tool call; a commit or push; reading, exploring or explaining code; answering a question; a plan; a step of an unfinished task (tests failing, "next I will...", half-done refactors). When unsure, leave it out: an empty list is the usual answer.',
224 'Write each line short (about 12 words, or 25 Chinese characters) and plain: no commit hashes, file paths, command names, flags, test counts or tool names, except a release version when it IS the outcome. Never put PR or MR numbers in milestone headlines.',
225 'authorized: consequential actions the agent took WITHOUT asking because a standing permission covered them (memory, CLAUDE.md, settings, or a general yes given earlier for that kind of action): merging, releasing or tagging, deploying, publishing, deleting, sending something outside, spending money. Routine local work (editing, committing, running tests) is never worth listing here. Name the permission briefly, e.g. "merged video-bench !42 (memory: may merge)".',
226 'Every PR/MR number in an authorized action must carry its repository, e.g. video-bench !129 or agent-hub #10, never a bare !129 or #10. Use the repository evidenced by the transcript; a workspace may contain several repositories. If unknown, omit the number instead of guessing.',
227 'merged: link a completed standing-permission merge to the milestone it served ONLY when the transcript explicitly establishes that relationship. Copy the exact strings from milestones and authorized into each pair. Same session, repository, or summary run is NOT evidence. Unrelated work, uncertain associations, and outcomes that are still local or not merged must have no link. Use [] when no relationship is evidenced. Do not carry links over from earlier runs.',
228 `Session repository (context only, not proof of which repository a PR/MR belongs to): ${repo || '(unknown)'}`,
229 'If the agent asked about that specific action and the owner said yes, it was asked: it is not authorized, list it as a milestone only if it is one.',
230 '',
231 'Keep every item from the previous summary that is still true, copied WORD FOR WORD, then add new ones; if a new outcome is a later stage of an old line, add the new line rather than rewording the old one. In the language the session is in. At most 8 per list.',
232 'Skip anything similar in kind to these muted items:',
233 muted,
234 '',
235 'Already reported across all sessions; do not repeat these outcomes as NEW items, even with different wording or PR/MR numbers. Still keep previous-summary items verbatim:',
236 JSON.stringify(recent.map(({ sid, text }) => ({ sid, text }))),
237 '',
238 'Previous summary:',
239 JSON.stringify(prev ?? { milestones: [], authorized: [] }),
240 '',
241 'Transcript (oldest first, trimmed):',
242 transcript,
243 ].join('\n')
244}
245
246/** The transcript as text, newest kept when it is too long. */
247export function transcriptText(rows: readonly { role: string; text: string; toolUses?: readonly { tool: string }[] }[], limit = 24_000): string {
248 const lines = rows.map(row => {
249 const tools = row.toolUses?.length ? ` [tools: ${row.toolUses.map(t => t.tool).join(', ')}]` : ''
250 return `${row.role}: ${row.text.slice(0, 2_000)}${tools}`
251 })
252 let out = ''
253 for (let i = lines.length - 1; i >= 0; i--) {
254 const next = `${lines[i]}\n${out}`
255 if (next.length > limit) break
256 out = next
257 }
258 return out.trim()
259}
260
261export const shortHash = (s: string) => {
262 let h = 5381
263 for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
264 return (h >>> 0).toString(36)
265}
266
267// ---- copy ----
268
269const EN = {
270 title: 'Standup',
271 settingsTitle: 'Standup settings',
272 needsYou: 'To do',
273 updates: 'Summary',
274 nothingWaiting: 'Nothing is waiting on you.',
275 noUpdates: 'No summaries yet. Each session shows up here after a turn that used tools.',
276 done: 'Done',
277 skip: 'Skip',
278 send: 'Send',
279 answerPlaceholder: 'Your answer',
280 moreTasks: '+{n} more',
281 fewerTasks: 'Show less',
282 jump: 'Open ↗',
283 ok: 'OK',
284 okAll: 'Mark all read',
285 noMore: 'No more like this',
286 paused: 'Updates paused: {reason}',
287 secretRefused: 'That looks like a secret. Put it where the task says (a file or env var), then tell the agent where.',
288 kindDo: 'do',
289 kindAnswer: 'answer',
290 kindChoose: 'choose',
291 status: '{n} tasks need you · /standup',
292 status1: '1 task needs you · /standup',
293 opened: 'Standup opened.',
294 closed: 'Standup closed.',
295 settingsOpened: 'Standup settings opened.',
296 language: 'Language',
297 openOnStart: 'Open on session start',
298 autoOpen: 'Open when a task arrives',
299 divider: 'Draggable divider',
300 muteButton: 'Show "No more like this" on messages',
301 on: 'On',
302 off: 'Off',
303 settingsNote: 'Applies to every session on this machine.',
304 ago: '{t} ago',
305 now: 'just now',
306}
307
308type Copy = typeof EN
309
310const ZH: Copy = {
311 title: 'Standup',
312 settingsTitle: 'Standup 设置',
313 needsYou: '待办',
314 updates: '进展',
315 nothingWaiting: '没有等你处理的事。',
316 noUpdates: '还没有总结。session 跑完一轮用过工具的对话后会出现在这里。',
317 done: '完成',
318 skip: '跳过',
319 send: '发送',
320 answerPlaceholder: '你的回答',
321 moreTasks: '还有 {n} 个',
322 fewerTasks: '收起',
323 jump: '跳转 ↗',
324 ok: 'OK',
325 okAll: '全部已读',
326 noMore: '不再显示这类',
327 paused: '进展已暂停:{reason}',
328 secretRefused: '这看起来像密钥。请按任务说的放到文件或环境变量里,再告诉 agent 放在哪。',
329 kindDo: '去做',
330 kindAnswer: '回答',
331 kindChoose: '选择',
332 status: '{n} 个任务等你 · /standup',
333 status1: '1 个任务等你 · /standup',
334 opened: '已打开 Standup。',
335 closed: '已收起 Standup。',
336 settingsOpened: '已打开 Standup 设置。',
337 language: '语言',
338 openOnStart: '会话开始时自动打开',
339 autoOpen: '来任务时自动打开',
340 divider: '可拖动分隔线',
341 muteButton: '消息上显示「不再显示这类」',
342 on: '开',
343 off: '关',
344 settingsNote: '对本机所有 session 生效。',
345 ago: '{t}前',
346 now: '刚刚',
347}
348
349const TABLES: Record<Lang, Copy> = { en: EN, zh: ZH }
350export type CopyKey = keyof Copy
351
352export function t(lang: Lang, key: CopyKey, vars: Record<string, string | number> = {}): string {
353 return TABLES[lang][key].replace(/\{(\w+)\}/g, (_, name: string) => String(vars[name] ?? ''))
354}
355
356export function ago(lang: Lang, then: number, now: number, short = false): string {
357 const s = Math.max(0, Math.round((now - then) / 1000))
358 if (s < 45) return t(lang, 'now')
359 const m = Math.round(s / 60)
360 const span = m < 60 ? `${m}${lang === 'zh' ? ' 分钟' : 'm'}` : m < 60 * 48 ? `${Math.round(m / 60)}${lang === 'zh' ? ' 小时' : 'h'}` : `${Math.round(m / 1440)}${lang === 'zh' ? ' 天' : 'd'}`
361 return short ? span : t(lang, 'ago', { t: span })
362}
363
364export const statusText = (lang: Lang, n: number) => (n === 0 ? undefined : n === 1 ? t(lang, 'status1') : t(lang, 'status', { n }))
365hooks/source.ts 86 lines1import type { EngineInterface, SessionMessage } from 'claude-code'
2import type { Card } from '../types'
3
4export const basename = (path: string) => path.replace(/\\/g, '/').split('/').filter(Boolean).at(-1) ?? path
5
6export function shortTitle(text: string): string {
7 const line = text.replace(/\s+/g, ' ').trim()
8 return line.length > 80 ? line.slice(0, 79) + '…' : line
9}
10
11/** session.repo resolves linked worktrees to their main repository root. */
12export async function projectName($: EngineInterface, cwd: string): Promise<string> {
13 const repo = await $.session.repo().catch(() => null)
14 const remote = repo?.remote?.replace(/\/+$/, '').split(/[/:]/).at(-1)?.replace(/\.git$/, '')
15 return remote || basename(repo?.root || cwd.split(/[\\/]\.claude[\\/]worktrees[\\/]/)[0] || cwd)
16}
17
18export async function currentBranch($: EngineInterface, cwd: string): Promise<string | undefined> {
19 try {
20 const result = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: 2000 })
21 return result.exitCode === 0 ? meaningfulBranch(result.stdout.trim(), cwd) : undefined
22 } catch {
23 return undefined
24 }
25}
26
27export async function firstPromptTitle($: EngineInterface): Promise<string | undefined> {
28 const rows = await $.session.messages().catch(() => [])
29 return promptTitle(rows.find(isPrompt)?.text ?? '')
30}
31
32/** Host-injected user rows must never become a session title or turn boundary. */
33export function promptTitle(text: string): string | undefined {
34 const value = text.trim()
35 if (/^<(?:system-reminder|command-[\w-]+|local-command-[\w-]+|tool_result)(?:[\s>])/i.test(value)
36 || /^This session is being continued from a previous conversation/i.test(value)
37 || /^\[Standup\]/.test(value)) return undefined
38 return shortTitle(value) || undefined
39}
40
41const isPrompt = (row: SessionMessage) => row.role === 'user' && !row.toolResults?.length && !!promptTitle(row.text)
42
43function meaningfulBranch(branch: string | undefined, cwd?: string): string | undefined {
44 if (!branch || branch.startsWith('claude/') || (cwd?.includes('/.claude/worktrees/') && branch === basename(cwd))) return undefined
45 return branch.length > 36 ? '…' + branch.slice(-35) : branch
46}
47
48/** Resolve edited files in the latest turn, including symlinks, through Git itself. */
49export async function turnRepo($: EngineInterface, cwd: string, rows: readonly SessionMessage[]): Promise<string | undefined> {
50 const start = rows.findLastIndex(isPrompt)
51 const paths = new Map<string, number>()
52 for (const row of rows.slice(Math.max(0, start))) {
53 for (const use of row.toolUses) {
54 if (use.isError || !['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(use.tool)) continue
55 const input = use.input as Record<string, unknown>
56 const path = input?.file_path ?? input?.notebook_path
57 if (typeof path === 'string' && path.trim()) paths.set(path, (paths.get(path) ?? 0) + 1)
58 }
59 }
60 const roots = new Map<string, number>()
61 for (const [path, count] of paths) {
62 const absolute = path.startsWith('/') ? path : `${cwd}/${path}`
63 let dir = absolute.slice(0, absolute.lastIndexOf('/')) || '/'
64 // A new file can have a not-yet-created parent; try its nearest existing ancestor.
65 while (dir) {
66 try {
67 const result = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: dir, timeoutMs: 2000 })
68 if (result.exitCode === 0 && result.stdout.trim()) {
69 const root = result.stdout.trim()
70 roots.set(root, (roots.get(root) ?? 0) + count)
71 break
72 }
73 } catch { /* Missing directory or Git: fall back to an ancestor, then the card. */ }
74 if (dir === cwd || dir === '/') break
75 dir = dir.slice(0, dir.lastIndexOf('/')) || '/'
76 }
77 }
78 const root = [...roots].sort((a, b) => b[1] - a[1])[0]?.[0]
79 return root ? basename(root.split('/.claude/worktrees/')[0]!) : undefined
80}
81
82/** Optional metadata keeps cards written by older sessions readable. */
83export function sourceLabel(card: Pick<Card, 'name' | 'repo' | 'branch' | 'title'> & { cwd?: string }): string {
84 return [card.repo || card.name, meaningfulBranch(card.branch, card.cwd), promptTitle(card.title ?? '') || (card.repo ? card.name : undefined)].filter(Boolean).join(' · ')
85}
86hooks/feed-claim.ts 34 lines1import type { EngineInterface } from 'claude-code'
2import { SUMMARY_GAP_MS } from './hub'
3
4const PREFIX = 'feed-claim:'
5
6/**
7 * Store has no CAS. Each session writes only its own claim, then checks for
8 * competitors. Both contenders may yield; neither can overlook an existing
9 * writer. Never use a shared last-writer-wins lock key.
10 * A crashed writer reserves at most one cadence interval. A resumed writer
11 * must check its deadline immediately before publishing.
12 */
13export async function withFeedClaim(
14 $: { store: EngineInterface['store']; clock: Pick<EngineInterface['clock'], 'now'> },
15 sid: string,
16 publish: (valid: () => Promise<boolean>) => Promise<boolean>,
17): Promise<boolean> {
18 const key = PREFIX + sid
19 const at = await $.clock.now()
20 await $.store.set(key, at)
21 const valid = async () => (await $.clock.now()) - at < SUMMARY_GAP_MS
22 try {
23 for (const other of await $.store.keys()) {
24 if (other === key || !other.startsWith(PREFIX)) continue
25 const claim = await $.store.get(other)
26 if (typeof claim === 'number' && at - claim < SUMMARY_GAP_MS) return false
27 }
28 if (!await valid()) return false
29 return await publish(valid)
30 } finally {
31 await $.store.delete(key)
32 }
33}
34hooks/panel.tsx 264 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
3
4import type { HubSettings } from '../types'
5import { DEFAULT_SETTINGS, ago, feedGroups, openTasks, pausedCards, t } from './hub'
6import { sourceLabel } from './source'
7import type { CopyKey, FeedRow, OpenTask } from './hub'
8
9const snapAtom = atom({ plugin: 'standup', key: 'snap' } as const, null)
10const settingsAtom = atom({ plugin: 'standup', key: 'settings' } as const, DEFAULT_SETTINGS)
11const errorsAtom = atom({ plugin: 'standup', key: 'errors' } as const, {})
12const showAllAtom = atom({ plugin: 'standup', key: 'showAllTasks' } as const, false)
13
14// Tasks shown before "+N more", so the summary below stays in view.
15const TASKS_SHOWN = 3
16
17// The panel: tasks on top, the feed below. One tree for every surface;
18// Claude Code's theme keys only, with claude orange as the one accent.
19
20export type Actions = {
21 complete: (task: OpenTask) => Promise<void>
22 skip: (task: OpenTask) => Promise<void>
23 answer: (task: OpenTask, text: string) => Promise<void>
24 choose: (task: OpenTask, option: string) => Promise<void>
25 ack: (row: FeedRow) => Promise<void>
26 ackAll: (rows: readonly FeedRow[]) => Promise<void>
27 mute: (row: FeedRow) => Promise<void>
28 jump: (hostId: string) => Promise<void>
29 setSettings: (patch: Partial<HubSettings>) => Promise<void>
30}
31
32type PaneInput = RenderInput<'Pane'> | RenderInput<'AbovePrompt'>
33
34const LANGS = [
35 ['en', 'English'],
36 ['zh', '中文'],
37] as const
38
39const KIND_KEY: Record<OpenTask['kind'], CopyKey> = { do: 'kindDo', answer: 'kindAnswer', choose: 'kindChoose' }
40
41/** Cuts text to about `lines` lines of `columns`, so one long detail cannot stretch a card. */
42const clip = (text: string, columns: number, lines: number) => {
43 // Wrapped lines lose a few cells at their ends, so leave some slack; cut at a word where there is one.
44 const room = Math.max(24, columns * lines - lines * 4)
45 if (text.length <= room) return text
46 const cut = text.slice(0, room - 1)
47 const space = cut.lastIndexOf(' ')
48 return `${(space > room * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`
49}
50
51export async function renderHub($: EngineInterface, e: PaneInput, act: Actions): Promise<RenderElement> {
52 const els = $.ui.resolve(e)
53 const { Box, Text, Button } = els
54 // Mobile has no text field: answers are typed on another surface there.
55 const Input = 'Input' in els ? els.Input : undefined
56 const snap = await read($, snapAtom)
57 const settings = (await read($, settingsAtom)) ?? DEFAULT_SETTINGS
58 const errors = (await read($, errorsAtom)) ?? {}
59 const showAll = (await read($, showAllAtom)) ?? false
60 const now = await $.clock.now()
61 const { lang } = settings
62 const tx = (key: CopyKey, vars?: Record<string, string | number>) => t(lang, key, vars)
63
64 // The desktop draws its own controls and borders: leave their colours to it. The terminal
65 // gets the theme's subtle grey so cards do not shout.
66 const native = e.surface !== 'terminal'
67 const cardBorder = native ? {} : { borderColor: 'subtle' as const }
68
69 // Inside a card: the pane's width less the border and padding.
70 const inner = Math.max(20, (e.props.bodyColumns || e.viewport?.columns || 60) - 4)
71 const wide = inner >= 56
72
73 const tasks = snap ? openTasks(snap, now) : []
74 const groups = snap ? feedGroups(snap) : []
75 const feed = groups.flatMap(group => group.rows)
76 const paused = snap ? pausedCards(snap, now) : []
77 const selfHost = snap?.cards.find(c => c.sid === snap.self)?.hostId
78 const header = (label: string, count: number, accent: boolean) => (
79 <Text size="heading" weight="semibold">
80 {label} <Text weight="regular" color={accent && count > 0 ? 'claude' : 'subtle'}>{count}</Text>
81 </Text>
82 )
83
84 const taskCard = (task: OpenTask) => {
85 const key = `${task.sid}:${task.id}`
86 const options = task.options ?? []
87 // The desktop wraps a row of its own buttons; the terminal stacks them when they do not fit.
88 const optionsInRow = native || (wide && options.join(' ').length + 8 < inner)
89 // The desktop puts a card's actions at its trailing edge, as its own cards do; the terminal keeps them left.
90 const actionsAlign = native ? ('flex-end' as const) : ('flex-start' as const)
91 return (
92 <Box key={`task-${key}`} flexDirection="column" borderStyle="round" {...cardBorder} hover={{ borderColor: 'claude' }} paddingX="sm" paddingY="sm" rowGap="xs">
93 <Box flexDirection="row" justifyContent="space-between" gap={1}>
94 <Text size="footnote">
95 <Text weight="medium">{task.name}</Text>
96 <Text dimColor>
97 {' '}
98 · {tx(KIND_KEY[task.kind])} · {ago(lang, task.createdAt, now, true)}
99 </Text>
100 </Text>
101 {task.hostId && task.hostId !== selfHost ? (
102 // Keyed Box of its own, so it lights under the pointer, not whenever the card is hovered.
103 <Box key={`jumphit-${key}`}>
104 <Button key={`jump-${key}`} plain dimColor hover={{ color: 'claude', dimColor: false }} label={tx('jump')} onPress={() => act.jump(task.hostId!)} />
105 </Box>
106 ) : null}
107 </Box>
108 <Text weight="semibold">{task.title}</Text>
109 {task.detail ? <Text size="footnote" dimColor>{clip(task.detail, inner, 2)}</Text> : null}
110 {task.kind === 'do' ? (
111 <Box flexDirection="row" justifyContent={actionsAlign} marginTop="xs">
112 <Button key={`done-${key}`} variant="secondary" label={tx('done')} onPress={() => act.complete(task)} />
113 </Box>
114 ) : task.kind === 'choose' ? (
115 <Box flexDirection={optionsInRow ? 'row' : 'column'} flexWrap="wrap" justifyContent={actionsAlign} gap="sm" marginTop="xs">
116 {options.map((option, i) => (
117 <Button key={`opt-${key}-${i}`} variant="secondary" label={option} onPress={() => act.choose(task, option)} />
118 ))}
119 </Box>
120 ) : Input ? (
121 <Box marginTop="xs" flexDirection="row" justifyContent={actionsAlign}>
122 <Input key={`ans-${key}`} placeholder={tx('answerPlaceholder')} submitLabel={tx('send')} onSubmit={(value: string) => (value.trim() ? act.answer(task, value) : undefined)} />
123 </Box>
124 ) : null}
125 <Box flexDirection="row" justifyContent={actionsAlign}>
126 <Button key={`skip-${key}`} plain dimColor label={tx('skip')} onPress={() => act.skip(task)} />
127 </Box>
128 {errors[key] ? <Text color="error">{errors[key]}</Text> : null}
129 </Box>
130 )
131 }
132
133 // One row per message, newest first within its project, each naming the project it came from. No Open: a message
134 // asks nothing of its session. Actions share the headline; metadata stays on one line.
135 // The terminal boxes each one; the desktop lists them bare, as its own session list does.
136 const feedCard = (row: FeedRow) => (
137 <Box key={`feed-${row.id}`} flexDirection="row" alignItems="flex-start" {...(native ? { paddingX: 'xs' as const } : { borderStyle: 'round', ...cardBorder, hover: { borderColor: 'claude' }, paddingX: 'sm' as const, paddingY: 'sm' as const })}>
138 <Text color="success">✓ </Text>
139 <Box flexDirection="column" flexShrink={1} flexGrow={1} rowGap="xs">
140 <Box key={`headline-${row.id}`} flexDirection="row" justifyContent="space-between" columnGap="md">
141 <Box flexShrink={1} flexGrow={1}><Text>{row.text}</Text></Box>
142 {/* Always drawn, dim at rest: no surface animates a reveal, and a row's actions popping in reads as a glitch. */}
143 <Box flexDirection="row" flexShrink={0} gap="md">
144 {/* Each in a keyed Box of its own: a hover lights its nearest keyed Box, which would otherwise be the whole row. */}
145 <Box key={`okhit-${row.id}`}>
146 <Button key={`ok-${row.id}`} plain dimColor hover={{ dimColor: false }} label={tx('ok')} onPress={() => act.ack(row)} />
147 </Box>
148 {settings.muteButton ? (
149 <Box key={`mutehit-${row.id}`}>
150 <Button key={`mute-${row.id}`} plain dimColor hover={{ dimColor: false }} label={tx('noMore')} onPress={() => act.mute(row)} />
151 </Box>
152 ) : null}
153 </Box>
154 </Box>
155 <Text key={`meta-${row.id}`} size="footnote" dimColor wrap="truncate-end">{row.name} · {ago(lang, row.at, now, true)}</Text>
156 </Box>
157 </Box>
158 )
159
160 const taskList = (
161 <Box flexDirection="column" rowGap="sm">
162 {tasks.length === 0 ? <Text dimColor>{tx('nothingWaiting')}</Text> : (showAll ? tasks : tasks.slice(0, TASKS_SHOWN)).map(taskCard)}
163 {tasks.length > TASKS_SHOWN ? (
164 <Box key="more-tasks-row" flexDirection="row">
165 <Button
166 key="more-tasks"
167 plain
168 dimColor
169 hover={{ dimColor: false }}
170 label={showAll ? tx('fewerTasks') : tx('moreTasks', { n: tasks.length - TASKS_SHOWN })}
171 onPress={() => update($, showAllAtom, v => !v)}
172 />
173 </Box>
174 ) : null}
175 </Box>
176 )
177
178 return (
179 <Box flexDirection="column" gap="md">
180 <Box flexDirection="column" rowGap="sm">
181 {header(tx('needsYou'), tasks.length, true)}
182 {settings.divider ? (
183 <Box flexDirection="column" height={settings.taskRows} overflow="hidden">
184 {taskList}
185 </Box>
186 ) : (
187 taskList
188 )}
189 </Box>
190 {settings.divider ? divider($, e, settings.taskRows) : null}
191 <Box flexDirection="column" rowGap="sm">
192 <Box key="summary-header" flexDirection="row" justifyContent="space-between" alignItems="center">
193 {header(tx('updates'), feed.length, false)}
194 {feed.length > 1 ? (
195 <Button key="ack-all" plain dimColor hover={{ dimColor: false }} label={tx('okAll')} onPress={() => act.ackAll(feed)} />
196 ) : null}
197 </Box>
198 {paused.map(card => (
199 <Text key={`paused-${card.sid}`} color="warning">
200 {sourceLabel(card)}: {tx('paused', { reason: card.paused ?? '' })}
201 </Text>
202 ))}
203 <Box flexDirection="column" rowGap={native ? 'md' : 'sm'}>
204 {feed.length === 0 ? <Text dimColor>{tx('noUpdates')}</Text> : groups.map(group => (
205 <Box key={`project-${group.project}`} flexDirection="column" rowGap={native ? 'md' : 'sm'}>
206 {group.rows.map(feedCard)}
207 </Box>
208 ))}
209 </Box>
210 </Box>
211 </Box>
212 )
213}
214
215/** The drag handle: a Client on the surfaces that have one, nothing elsewhere. */
216function divider($: EngineInterface, e: PaneInput, rows: number) {
217 if (e.surface !== 'terminal' && e.surface !== 'desktop') return null
218 const { Client } = $.ui.resolve({ ...e, surface: e.surface })
219 return <Client key="divider" module="./divider.tsx" props={{ rows }} height={1} />
220}
221
222export async function renderSettings($: EngineInterface, e: PaneInput, act: Actions): Promise<RenderElement> {
223 const { Box, Text, Button } = $.ui.resolve(e)
224 const settings = (await read($, settingsAtom)) ?? DEFAULT_SETTINGS
225 const tx = (key: CopyKey) => t(settings.lang, key)
226 const toggle = (key: 'openOnStart' | 'autoOpen' | 'divider' | 'muteButton') => (
227 <Box flexDirection="row" gap="sm">
228 {([true, false] as const).map(on => (
229 <Button key={`toggle-${key}-${on ? 'on' : 'off'}`} variant={settings[key] === on ? 'primary' : 'secondary'} label={on ? tx('on') : tx('off')} onPress={() => act.setSettings({ [key]: on })} />
230 ))}
231 </Box>
232 )
233
234 return (
235 <Box flexDirection="column" gap={1}>
236 <Box flexDirection="row" gap={2} alignItems="center">
237 <Text>{tx('language')}</Text>
238 <Box flexDirection="row" gap="sm">
239 {LANGS.map(([value, label]) => (
240 <Button key={`lang-${value}`} variant={settings.lang === value ? 'primary' : 'secondary'} label={label} onPress={() => act.setSettings({ lang: value })} />
241 ))}
242 </Box>
243 </Box>
244 <Box flexDirection="row" gap={2} alignItems="center">
245 <Text>{tx('openOnStart')}</Text>
246 {toggle('openOnStart')}
247 </Box>
248 <Box flexDirection="row" gap={2} alignItems="center">
249 <Text>{tx('autoOpen')}</Text>
250 {toggle('autoOpen')}
251 </Box>
252 <Box flexDirection="row" gap={2} alignItems="center">
253 <Text>{tx('divider')}</Text>
254 {toggle('divider')}
255 </Box>
256 <Box flexDirection="row" gap={2} alignItems="center">
257 <Text>{tx('muteButton')}</Text>
258 {toggle('muteButton')}
259 </Box>
260 <Text dimColor>{tx('settingsNote')}</Text>
261 </Box>
262 )
263}
264hooks/steering.ts 38 lines1// What the model reads: the system-prompt section and the three tool descriptions.
2// Kept in English on purpose: it is instructions for the model, not copy for the person.
3
4const RULE = `Some things only the user can do: put a secret or key in place, act on their machine or their accounts, make a decision that is theirs, review or approve something. For each of those, call assign_task, even when you also asked about it in your reply; a question asked only in prose is easy to miss when they run many sessions. A secret or key you do not have is always a task: never ask for it in chat.
5But when the user has already given a standing permission for that kind of action (memory, CLAUDE.md, settings, or a yes earlier in this conversation), do not ask: do it, and end your reply with one line saying what you did and which permission covered it.`
6
7export const STEERING = `# Standup
8The user watches every Claude Code session on this machine from one panel (/standup).
9${RULE}
10After assign_task, do not wait or poll: carry on with work that does not depend on the answer, or end your turn. The answer arrives as a new message from Standup.`
11
12export const ASSIGN_DESCRIPTION = `Hand the user a task only they can do, shown in their Standup panel across all sessions. Their answer comes back to you as a new message; do not wait for it.
13${RULE}
14kind: "do" (they go do it and press Done), "answer" (they write a few words), "choose" (2-4 options). Title: one short imperative line. Detail: what exactly, where, and how you will know it is done. At most 5 open tasks per session; the same ask is never assigned twice.`
15
16export const WITHDRAW_DESCRIPTION = `Withdraw a task you assigned with assign_task that is no longer needed (you found another way, the user answered in chat, the plan changed). Pass its id.
17${RULE}`
18
19export const LIST_DESCRIPTION = `List the tasks you assigned with assign_task that the user has not handled yet.
20${RULE}`
21
22export const ASSIGN_SCHEMA = {
23 type: 'object',
24 properties: {
25 kind: { type: 'string', enum: ['do', 'answer', 'choose'] },
26 title: { type: 'string', description: 'One short imperative line.' },
27 detail: { type: 'string', description: 'What exactly, where, and how you will know it is done.' },
28 options: { type: 'array', items: { type: 'string' }, minItems: 2, maxItems: 4, description: 'For kind "choose" only.' },
29 },
30 required: ['kind', 'title'],
31} as const
32
33export const WITHDRAW_SCHEMA = {
34 type: 'object',
35 properties: { id: { type: 'string' } },
36 required: ['id'],
37} as const
38hooks/divider.tsx 44 lines1import type { ClientModule } from 'claude-code'
2
3// The draggable line between tasks and updates. Runs on the drawing thread:
4// a drag (or ↑/↓ once clicked) posts the new task-area height to the hooks module.
5
6type Props = { rows: number }
7type State = { from?: number; delta: number; hover: boolean }
8
9const Divider: ClientModule<Props, State> = (props, surface) => {
10 const { Text } = surface.elements
11 const state = surface.state ?? { delta: 0, hover: false }
12
13 if (surface.state === undefined) {
14 surface.onPointer(e => {
15 const s = surface.state ?? { delta: 0, hover: false }
16 if (e.type === 'down') surface.setState({ ...s, from: e.y, delta: 0 })
17 else if (e.type === 'move' && s.from !== undefined) surface.setState({ ...s, delta: e.y - s.from })
18 else if (e.type === 'up' && s.from !== undefined) {
19 surface.post({ taskRows: props.rows + (e.y - s.from) })
20 surface.setState({ ...s, from: undefined, delta: 0 })
21 } else if (e.type === 'enter' || e.type === 'leave') surface.setState({ ...s, hover: e.type === 'enter' })
22 })
23 surface.onKey(e => {
24 if (e.key === 'up') surface.post({ taskRows: props.rows - 1 })
25 if (e.key === 'down') surface.post({ taskRows: props.rows + 1 })
26 })
27 surface.setState(state)
28 }
29
30 // A short handle in the middle, like a sheet's grabber; the number shows while dragging.
31 const width = Math.max(8, surface.columns || 40)
32 const handle = state.from !== undefined ? ` ${props.rows + state.delta} rows ` : '────────'
33 const side = Math.max(0, Math.floor((width - handle.length) / 2))
34 const active = state.hover || state.from !== undefined
35 return (
36 <Text color={active ? 'claude' : 'subtle'}>
37 {' '.repeat(side)}
38 {handle}
39 </Text>
40 )
41}
42
43export default Divider
44types/index.d.ts 76 lines1export type TaskKind = 'do' | 'answer' | 'choose'
2
3export type Task = {
4 id: string
5 kind: TaskKind
6 title: string
7 detail?: string
8 options?: string[]
9 createdAt: number
10}
11
12export type FeedKind = 'milestone' | 'authorized'
13
14export type FeedItem = {
15 id: string
16 kind: FeedKind
17 /** Repository touched in this turn; older items fall back to their card. */
18 repo?: string
19 text: string
20 at: number
21}
22
23/** `card:<session>`: written only by that session. */
24export type Card = {
25 sid: string
26 /** The desktop app's session id, for the jump link; absent in a plain terminal. */
27 hostId?: string
28 name: string
29 /** Source metadata; absent on cards written before source labels were introduced. */
30 repo?: string
31 branch?: string
32 title?: string
33 cwd: string
34 heartbeat: number
35 tasks: Task[]
36 feed: FeedItem[]
37 /** The last summary, the baseline the next one is diffed against. */
38 summary?: Summary
39 summaryAt?: number
40 /** Why updates stopped, shown on the panel instead of failing quietly. */
41 paused?: string
42}
43
44export type Summary = { milestones: string[]; authorized: string[] }
45
46/** `reply:<session>:<task>`: written by whichever session answered. */
47export type Reply = { answer: string; at: number; by: string; skipped?: true }
48
49/** `mute:<feed id>`: a "no more like this". */
50export type Mute = { text: string; at: number }
51
52export type Lang = 'en' | 'zh'
53
54export type HubSettings = { lang: Lang; openOnStart: boolean; autoOpen: boolean; divider: boolean; muteButton: boolean; taskRows: number }
55
56/** Everything the panel draws, read from the store every poll. */
57export type Snapshot = {
58 self: string
59 cards: Card[]
60 replied: string[]
61 acked: string[]
62 mutes: Mute[]
63}
64
65declare module 'claude-code' {
66 interface PluginState {
67 'standup': {
68 snap: Snapshot | null
69 settings: HubSettings
70 errors: Record<string, string>
71 showAllTasks: boolean
72 bandOpen: boolean
73 }
74 }
75}
76