SLOPSHOPPER

standup

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

newpanebandguardcommandstatus
v0.1.0MITupdated 2026-10-21Sma1lboy/claude-standup
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · standup
│ ┃ Standup 设置 ✕ › fix the failing auth test and add an audit log call │ ┃ 语言 [ English ][ 中文 ] │ ┃ ● standup: standup: sessions in the store: preview-session │ ┃ 会话开始时自动打开 [ 开 ][ 关 ] ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 来任务时自动打开 [ 开 ][ 关 ] ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 可拖动分隔线 [ 开 ][ 关 ] ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ 消息上显示「不再显示这类」 [ 开 ][ 关 ] │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 对本机所有 session 生效。 │ ✻ Worked for 42s · done 4:20 PM │ │ › /standup │ ⎿ standup: 已收起 Standup。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Standup 设置
语言 [ English ][ 中文 ] 会话开始时自动打开 [ 开 ][ 关 ] 来任务时自动打开 [ 开 ][ 关 ] 可拖动分隔线 [ 开 ][ 关 ] 消息上显示「不再显示这类」 [ 开 ][ 关 ] 对本机所有 session 生效。
Pane · standup
待办 0 没有等你处理的事。 进展 0 还没有总结。session 跑完一轮用过工具的对话后会出现在这里。
README

Standup

<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.

Install

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.

Use

  • The hub opens once when a session joins, without taking keyboard focus. Closing it keeps it closed on subsequent polls and turns; task-arrival auto-open remains a separate setting.
  • /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.
  • The status line shows N tasks need you · /standup, counted across all sessions.
  • Task and update rows identify 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.

Tasks

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:

  • do: you go do it, then press Done.
  • answer: you type a few words.
  • choose: you pick one of 2 to 4 options.

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:

  • An answer that looks like a key or token (sk-…, ghp_…, AKIA…, a private key block, a JWT, password=…, a long mixed-case token) is refused, so it never lands in a transcript.
  • At most 5 open tasks per session.
  • The same ask is not assigned twice while it is open.
  • Claude can take a task back with withdraw_task, and list its open ones with list_tasks.
  • After a compact, Claude is told again which of its tasks are still open.

Summary

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.

How Claude is told to use it

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.

How it works

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:

KeyHoldsWritten by
card:<session>that session's tasks, feed, last summary, heartbeatthat session
reply:<session>:<task>an answer to one taskwhichever session answered
ack:<message>OK on a messageany session
mute:<message>a "No more like this"any session
settingsthe settingsany session
feed-claim:<session>temporary publication claim timestampthat 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.

Demo data

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.

Develop

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:

  • summary: 12 cases on Haiku (outcomes only, commits and fix-ups folded into one short line with no hashes or commands, small follow-ups after a goal adding nothing, consequential unasked work caught, routine commits and approved-just-now not counted, old lines kept verbatim, mutes respected, Chinese sessions get Chinese lines). About $0.01 a run.
  • assign: 5 cases on Opus, each in a fresh git sandbox (missing secret → do-task, plain edit → nothing, your decision → choose-task, standing permission → no task and the reply names it, sign-off → task). About $1.6 a run.

Evals load the mod with --plugin-dir, which gets its own store, so test tasks never show up in your real panel.

License

MIT

Source 8 files
hooks/register.tsx 515 lines
1import { 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
515
hooks/hub.ts 365 lines
1import 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 }))
365
hooks/source.ts 86 lines
1import 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}
86
hooks/feed-claim.ts 34 lines
1import 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}
34
hooks/panel.tsx 264 lines
1import { 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}
264
hooks/steering.ts 38 lines
1// 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
38
hooks/divider.tsx 44 lines
1import 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
44
types/index.d.ts 76 lines
1export 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