SLOPSHOPPER

agent-taskboard

A live task board for Claude Code: plans before building and shows every task, its status, time, subagents and checks in a side panel. Progress survives…

newpaneguardcommandprompttool
v0.7.0MITupdated 2026-10-13YohanGarcia/agent-taskboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-taskboard
│ ┃ Task board ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ╭───────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ ◈ TASK BOARD ⎿ Read 6 lines │ ┃ │ No active plan ⏺ Update(src/auth.ts) │ ┃ ╰───────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ history ] ⎿ 3 pass, 1 fail │ ┃ │ ┃ No plan. Claude will create one for a comp ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ request. │ ┃ ────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ root: /work/app │ ┃ branch: no git › /taskboard │ ┃ ⎿ agent-taskboard: No active plan. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Task board
╭──────────────────────────────────────────────────╮ │ ◈ TASK BOARD │ │ No active plan │ ╰──────────────────────────────────────────────────╯ [ history ] [ clear ] No plan. Claude will create one for a complex request. ──────────────────────────────────────────────────── root: /work/app branch: no git
README

agent-taskboard

A live task board for Claude Code. It makes Claude plan before it builds, then shows every task, its status, time, subagents and checks in a side panel, and remembers your plans between sessions.

agent-taskboard is a Claude Code mod: it uses the official Mods API only. No MCP server, no background processes, no network, no files written outside Claude Code's own plugin store.

The task board panel next to a Claude Code session: progress, the running task with its checks, tokens and cost, and the project root and branch in the footer

Español · Install · What it does · Configure · Privacy · Develop

Install

Inside Claude Code:

/plugin marketplace add YohanGarcia/agent-taskboard
/plugin install agent-taskboard@agent-taskboard
/reload-plugins

Or from a terminal:

claude plugin marketplace add YohanGarcia/agent-taskboard
claude plugin install agent-taskboard@agent-taskboard

Built and tested on Claude Code 2.1.296. The Mods API is early access and may change between releases, so on a much newer version run claude plugin validate and claude plugin test before relying on it.

What it does

  • Side panel with your tasks. A card for the plan: goal, progress bar, percentage and working time (the hours a plan sat open between sessions are not counted), counters by status, and one row per task with its duration, subagents and check result. Press a task (or its number) to open its detail and action buttons.
  • A plan before the work. Claude is told to register a plan for complex requests. If a request looks complex and no plan exists, Edit/Write are blocked until Claude registers one (switchable, see planMode).
  • Status from real events, not promises. The first real tool call starts a task. Subagents are attached to the task they name (t2: review types). A test, build, lint or typecheck command that fails is recorded on the task, and the board refuses to mark it done until the check passes.
  • Plan mode and todo lists show up by themselves. The plan you approve in plan mode and Claude Code's own TodoWrite list are mirrored on the board (statuses included); plan/task stay available as the explicit way.
  • Progress survives sessions. The active plan and a history of up to 100 plans are stored per project. A plan that was in flight comes back paused.
  • History with detail. Open any past plan, see its tasks and final states, and resume it.
  • Tokens and cost per task. Each task shows what the main agent and its subagents spent (fresh input, output, cache) and the session cost while it ran, with a plan total. /taskboard stats prints the detail and the mod's own overhead.
  • Where you are. The bottom of the panel shows the project root and the git branch, and follows a git checkout run in the shell.

Commands

CommandDoes
/taskboardOpen the panel
/taskboard historyShow the history, numbered from the most recent (also in the panel)
/taskboard history drop <n>Delete entry n from the history
/taskboard history clearEmpty the history
/taskboard statsTokens and cost per task, plus the mod's own per-request overhead (an estimate)
/taskboard clearArchive the plan and empty the board
/taskboard skipDo not require a plan for the current request
/taskboard closeClose the panel

Panel keys

1-9 open a task · i start · d done · b block · f failed · e expand all · h history · l clear · o skip plan · r resume a past plan and x delete it (in history).

How Claude talks to it

The mod registers two tools the model can call, both served by the mod itself (not an MCP server):

  • mcp__agent-taskboard__plan with { goal, tasks } registers the plan.
  • mcp__agent-taskboard__task with { id, status, note? } updates a task.

It also adds a short section to the system prompt (about 150 words) explaining when to use them.

Configure

Run /plugin configure agent-taskboard@agent-taskboard or set them in settings under pluginConfigs.

OptionDefaultMeaning
languageautoauto follows the system locale (Spanish for es-*, English otherwise), or force es / en
modefullfull registers the plan/task tools, adds the rules to the prompt and guards Edit/Write. light does none of that: no tools, no prompt rules, no guard; the board only mirrors plan mode and TodoWrite and keeps its time, checks and token accounting (no per-request overhead)
planModeblock(only in full) block blocks Edit/Write until there is a plan, warn only shows a notice, off does nothing
minPromptChars240Length above which a request that names an action counts as complex
extraChecksemptyExtra commands that count as a check, comma separated (artisan test, composer test)
openOnStarttrueOpen the panel when a session starts

Recognised checks out of the box: npm|pnpm|yarn|bun run test/build/lint/check, vitest, jest, pytest, go test, cargo test/check/build, dotnet test, tsc, eslint, ruff, mypy, claude plugin test/validate, make test.

What it can see and what it keeps

It observes your prompts (to detect complex requests), tool calls (names and inputs, to attribute work and checks), subagents and the token counts of each model request (numbers only, to attribute them to a task). It only answers with its own plan and task tools, and with a refusal of Edit/Write while a plan is missing. It never rewrites a tool call or a prompt.

It keeps the plan, the history and check commands (clipped to 80 characters) in Claude Code's own per-plugin store, on your machine. It makes no network requests, runs no processes of its own and writes no files. To show the branch it reads one file, .git/HEAD (walking up from the project folder); it never runs git.

Known limits

  • It fills from plan/task, plan mode (ExitPlanMode) and TodoWrite. Newer task tools (TaskCreate/TaskUpdate) are not mirrored yet.
  • Tokens are attributed to the task that is active when the request finishes; requests made before the first task starts count as "no task". Cost is the session's cost while a task ran, so work on two tasks in parallel is not split.
  • "Complex request" is a heuristic (action words, lists, length) and can be wrong either way. Use planMode: warn or /taskboard skip.
  • A check is recognised by its command text, so unusual script names need extraChecks.
  • Subagents with no task id in their description go to the active task, which may not be theirs.

Develop

git clone https://github.com/YohanGarcia/agent-taskboard
claude --plugin-dir ./agent-taskboard       # load it for one session
claude plugin validate ./agent-taskboard
claude plugin test ./agent-taskboard

Layout: hooks/register.tsx (the hooks and the panel), hooks/logic.ts (pure functions), hooks/i18n.ts (messages), types/index.d.ts (the $.state contract), tests/. See CONTRIBUTING.md.

Español

agent-taskboard es un mod de Claude Code que hace que Claude planifique antes de implementar y muestra en un panel lateral las tareas de la sesión con su estado, progreso, duración, subagentes y verificaciones. Conserva tus planes entre sesiones y guarda un historial por proyecto.

/plugin marketplace add YohanGarcia/agent-taskboard
/plugin install agent-taskboard@agent-taskboard
/reload-plugins
  • Plan nativo y lista de tareas: el plan que apruebas en modo plan y la lista TodoWrite de Claude Code aparecen solos en el tablero, con sus estados.
  • Modo ligero: con mode: light no registra herramientas, no añade reglas al prompt ni bloquea Edit/Write; solo refleja el plan nativo y TodoWrite. Sin sobrecosto de tokens. full es el valor por defecto.
  • Limpiar el historial: /taskboard history drop <n> borra una entrada (1 es la más reciente), /taskboard history clear lo vacía, y en el detalle de una entrada la tecla x la borra.
  • Tiempo de trabajo: la duración de un plan suma solo los tramos en que hubo trabajo; las horas con el plan abierto entre sesiones no cuentan.
  • Estados desde eventos reales: una verificación que falla (tests, build, lint, tsc) impide marcar la tarea como hecha.
  • Idioma: language: auto sigue el idioma del sistema; también es o en.
  • Modo del plan: block bloquea Edit/Write sin plan, warn solo avisa, off no vigila.
  • Dónde estás: el pie del panel muestra la raíz del proyecto y la rama de git.
  • Privacidad: no usa red ni procesos; solo lee .git/HEAD para la rama y guarda todo en el almacén local del plugin.

License

MIT

Source 4 files
hooks/register.tsx 932 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentRun, Board, HistoryEntry, Project, TaskStatus, View } from '../types'
5import type { NativeItem } from './logic'
6import { messages, resolveLang, systemLocale } from './i18n'
7import type { Messages } from './i18n'
8import {
9  EMPTY_BOARD,
10  GLYPH,
11  STATUSES,
12  addUsage,
13  billableOf,
14  boardFromEntry,
15  boardFromNative,
16  branchFromHead,
17  checkKindOf,
18  clip,
19  commandLabel,
20  countsOf,
21  dropEntry,
22  durationOf,
23  elapsedOf,
24  emptyUsage,
25  estimateTokens,
26  fmtTokens,
27  fmtUsd,
28  gauge,
29  gitDirFromFile,
30  historyEntryOf,
31  isFinished,
32  isPlanFile,
33  itemsFromTodos,
34  lastCheck,
35  looksComplex,
36  markCost,
37  normalizeTask,
38  parentOf,
39  planFromMarkdown,
40  progressOf,
41  settleCost,
42  sharesTask,
43  stampOf,
44  sumUsage,
45  tailClip,
46  taskIdFrom,
47  tasksFrom,
48  withTask,
49  workTimeOf,
50} from './logic'
51
52const PANE = 'agent-taskboard'
53const PLAN_TOOL = 'mcp__agent-taskboard__plan'
54const TASK_TOOL = 'mcp__agent-taskboard__task'
55const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
56const PLAN_MODE_TOOL = 'ExitPlanMode'
57const TODO_TOOL = 'TodoWrite'
58const HISTORY_LIMIT = 100
59
60const boardAtom = atom({ plugin: 'agent-taskboard', key: 'board' } as const, EMPTY_BOARD)
61const FRESH_VIEW: View = { mode: 'board', expanded: null, expandAll: false, historyOpen: null }
62const viewAtom = atom({ plugin: 'agent-taskboard', key: 'view' } as const, FRESH_VIEW)
63const historyAtom = atom({ plugin: 'agent-taskboard', key: 'history' } as const, [] as HistoryEntry[])
64const projectAtom = atom({ plugin: 'agent-taskboard', key: 'project' } as const, { root: '', branch: null } as Project)
65
66let ticker: Timer | null = null
67
68// ---------------------------------------------------------------- project root and branch
69
70/** Reads `.git/HEAD` by walking up from the project folder: no process, only file reads. */
71async function readBranch($: EngineInterface, root: string): Promise<string | null> {
72  const sep = root.includes('\\') ? '\\' : '/'
73  let dir: string | null = root
74
75  for (let i = 0; i < 8 && dir !== null; i += 1) {
76    const dot = `${dir.replace(/[\\/]+$/, '')}${sep}.git`
77    try {
78      const stat = await $.fs.stat(dot)
79      let gitDir = dot
80      if (stat.kind === 'file') {
81        const raw = await $.fs.read(dot)
82        const pointed = gitDirFromFile(typeof raw === 'string' ? raw : '')
83        if (!pointed) return null
84        gitDir = /^([A-Za-z]:|[\\/])/.test(pointed) ? pointed : `${dir}${sep}${pointed}`
85      }
86      const head = await $.fs.read(`${gitDir}${sep}HEAD`)
87
88      return branchFromHead(typeof head === 'string' ? head : '')
89    } catch {
90      dir = parentOf(dir)
91    }
92  }
93
94  return null
95}
96
97async function refreshProject($: EngineInterface): Promise<void> {
98  const root = await $.session.cwd()
99  const branch = await readBranch($, root)
100
101  await update($, projectAtom, cur => (cur.root === root && cur.branch === branch ? cur : { root, branch }))
102}
103
104// ---------------------------------------------------------------- persistence
105
106async function persist($: EngineInterface): Promise<void> {
107  const cwd = await $.session.cwd()
108  const [b, h] = await Promise.all([read($, boardAtom), read($, historyAtom)])
109  await $.store.set(`board:${cwd}`, b)
110  await $.store.set(`history:${cwd}`, h)
111}
112
113async function syncTicker($: EngineInterface): Promise<void> {
114  const b = await read($, boardAtom)
115  const isLive = b.tasks.some(t => t.status === 'in_progress')
116
117  if (isLive && ticker === null) {
118    ticker = $.clock.every(1000, () => $.ui.invalidate('ui.render'))
119  } else if (!isLive && ticker !== null) {
120    ticker.cancel()
121    ticker = null
122  }
123}
124
125async function mutate($: EngineInterface, fn: (b: Board) => Board): Promise<void> {
126  await update($, boardAtom, fn)
127  await persist($)
128  await syncTicker($)
129}
130
131async function restore($: EngineInterface, t: Messages): Promise<void> {
132  const cwd = await $.session.cwd()
133  const saved = (await $.store.get(`board:${cwd}`)) as Board | undefined
134  const entries = (await $.store.get(`history:${cwd}`)) as HistoryEntry[] | undefined
135
136  if (Array.isArray(entries)) await update($, historyAtom, () => entries.slice(-HISTORY_LIMIT))
137  if (!saved || !Array.isArray(saved.tasks) || saved.tasks.length === 0 || saved.isArchived) return
138
139  // Work in flight when the last session ended comes back paused, its agents stopped.
140  const resumed: Board = {
141    ...EMPTY_BOARD,
142    ...saved,
143    activeId: null,
144    needsPlan: false,
145    isResumed: true,
146    looseAgents: [],
147    untracked: saved.untracked ?? emptyUsage(),
148    tasks: saved.tasks.map(normalizeTask).map(task => ({
149      ...task,
150      agents: task.agents.map(a => (a.status === 'running' ? { ...a, status: 'stopped' as const } : a)),
151      ...(task.status === 'in_progress'
152        ? { status: 'pending' as const, startedAt: null, endedAt: null, note: task.note || t.resumedNote }
153        : {}),
154    })),
155  }
156
157  await update($, boardAtom, () => resumed)
158}
159
160async function archiveBoard($: EngineInterface, reason: HistoryEntry['reason']): Promise<void> {
161  const b = await read($, boardAtom)
162  if (b.isArchived) return
163
164  const entry = historyEntryOf(b, await $.clock.now(), isFinished(b) ? 'completed' : reason)
165  if (!entry) return
166
167  await update($, historyAtom, list => [...list, entry].slice(-HISTORY_LIMIT))
168  await update($, boardAtom, x => ({ ...x, isArchived: true }))
169}
170
171async function clearBoard($: EngineInterface): Promise<void> {
172  await archiveBoard($, 'cleared')
173  await update($, viewAtom, () => FRESH_VIEW)
174  await mutate($, () => EMPTY_BOARD)
175}
176
177// ---------------------------------------------------------------- real events
178
179async function afterCall(
180  $: EngineInterface,
181  tool: string,
182  input: Record<string, unknown>,
183  isOk: boolean,
184  extraChecks: readonly string[],
185): Promise<void> {
186  const b = await read($, boardAtom)
187  if (b.tasks.length === 0) return
188
189  const now = await $.clock.now()
190  const isAuto = b.source !== 'todo'
191  const willStart = isAuto && b.activeId === null && b.tasks.some(x => x.status === 'pending')
192  const cost = willStart ? await costNow($) : null
193  const command = typeof input.command === 'string' ? input.command : ''
194  const kind = tool === 'Bash' || tool === 'PowerShell' ? checkKindOf(command, extraChecks) : null
195
196  await mutate($, cur => {
197    let next = cur
198    let activeId = cur.activeId
199
200    // The first real call after a plan starts its first pending task.
201    if (activeId === null && isAuto) {
202      const first = cur.tasks.find(task => task.status === 'pending')
203      if (!first) return cur
204      activeId = first.id
205      next = withTask({ ...cur, activeId }, first.id, task => ({
206        ...markCost(task, cost),
207        status: 'in_progress',
208        startedAt: task.startedAt !== null && task.endedAt !== null ? now - (task.endedAt - task.startedAt) : now,
209        endedAt: null,
210      }))
211    }
212
213    if (activeId === null) return cur
214
215    return withTask(next, activeId, task => ({
216      ...task,
217      calls: task.calls + 1,
218      checks: kind ? [...task.checks, { kind, command: clip(commandLabel(command), 80), ok: isOk, at: now }].slice(-10) : task.checks,
219    }))
220  })
221}
222
223/**
224 * Claude Code's own plan (ExitPlanMode) or todo list (TodoWrite) becomes the board.
225 * A list that shares no task with the current plan replaces it; the same list updates it.
226 */
227async function ingestNative(
228  $: EngineInterface,
229  source: NonNullable<Board['source']>,
230  goal: string,
231  items: readonly NativeItem[],
232  untitled: string,
233): Promise<void> {
234  const b = await read($, boardAtom)
235  if (items.length === 0) {
236    if (b.needsPlan) await skipPlan($)
237    return
238  }
239
240  const shares = sharesTask(b, items)
241  const isFinal = items.every(i => i.status === 'done')
242  // The same finished list sent again must not open a second history entry.
243  if (b.isArchived && shares && isFinal) return
244
245  const fresh = b.tasks.length === 0 || b.isArchived || !shares
246  if (fresh && b.tasks.length > 0) await archiveBoard($, 'replaced')
247  if (fresh) await update($, viewAtom, () => FRESH_VIEW)
248
249  const now = await $.clock.now()
250  const cost = await costNow($)
251  await mutate($, cur =>
252    boardFromNative(fresh ? EMPTY_BOARD : cur, items, { goal: goal || untitled, source, now, cost, fresh }),
253  )
254
255  if (isFinished(await read($, boardAtom))) await archiveBoard($, 'completed')
256}
257
258async function setTaskStatus($: EngineInterface, id: string, status: TaskStatus, note: string): Promise<void> {
259  const now = await $.clock.now()
260  const cost = await costNow($)
261
262  await mutate($, cur => {
263    const paused = cur.tasks.find(task => task.status === 'in_progress' && task.id !== id)
264    let next = cur
265
266    // One task at a time: starting another pauses the running one.
267    if (status === 'in_progress' && paused) {
268      next = withTask(next, paused.id, task => ({ ...settleCost(task, cost), status: 'pending', endedAt: now }))
269    }
270
271    next = withTask(next, id, task => {
272      if (status === 'in_progress') {
273        const wasPaused = task.startedAt !== null && task.endedAt !== null
274        return {
275          ...markCost(task, cost),
276          status,
277          startedAt: wasPaused ? now - ((task.endedAt ?? now) - (task.startedAt ?? now)) : (task.startedAt ?? now),
278          endedAt: null,
279          note: note || task.note,
280        }
281      }
282      if (status === 'pending') return { ...settleCost(task, cost), status, startedAt: null, endedAt: null, note: note || task.note }
283
284      return { ...settleCost(task, cost), status, startedAt: task.startedAt ?? now, endedAt: now, note: note || task.note }
285    })
286
287    const activeId = status === 'in_progress' ? id : cur.activeId === id ? null : cur.activeId
288
289    return { ...next, activeId }
290  })
291
292  if (isFinished(await read($, boardAtom))) await archiveBoard($, 'completed')
293}
294
295/** The session's cost so far, read fresh; null where the host keeps no ledger. */
296async function costNow($: EngineInterface): Promise<number | null> {
297  const usage = await $.session.usage().catch(() => null)
298
299  return usage?.cost?.usd ?? null
300}
301
302/** Adds one model request to the task it belongs to: the active one for the main agent, the owner for a subagent. */
303async function recordUsage($: EngineInterface, agentId: string | null, step: Parameters<typeof addUsage>[1]): Promise<void> {
304  if (!step) return
305
306  await mutate($, cur => {
307    if (agentId === null) {
308      if (cur.activeId === null) return { ...cur, untracked: addUsage(cur.untracked, step) }
309
310      return withTask(cur, cur.activeId, task => ({ ...task, usage: addUsage(task.usage, step) }))
311    }
312
313    const owner = cur.tasks.find(task => task.agents.some(a => a.id === agentId))?.id ?? cur.activeId
314    if (owner === null) return { ...cur, untracked: addUsage(cur.untracked, step) }
315
316    return withTask(cur, owner, task => ({ ...task, agentUsage: addUsage(task.agentUsage, step) }))
317  })
318}
319
320async function setAgentStatus($: EngineInterface, agentId: string, status: AgentRun['status']): Promise<void> {
321  const swap = (list: AgentRun[]) => list.map(a => (a.id === agentId ? { ...a, status } : a))
322
323  await mutate($, cur => ({
324    ...cur,
325    looseAgents: swap(cur.looseAgents),
326    tasks: cur.tasks.map(task => ({ ...task, agents: swap(task.agents) })),
327  }))
328}
329
330async function skipPlan($: EngineInterface): Promise<void> {
331  await mutate($, cur => ({ ...cur, needsPlan: false }))
332}
333
334async function reopenEntry($: EngineInterface, entry: HistoryEntry, t: Messages): Promise<void> {
335  await archiveBoard($, 'replaced')
336  const now = await $.clock.now()
337  await update($, viewAtom, () => FRESH_VIEW)
338  await mutate($, () => boardFromEntry(entry, now, status => t.previously(t.status[status])))
339}
340
341/** Deletes one history entry by its number (1 = most recent); null when there is none. */
342async function removeEntry($: EngineInterface, number: number): Promise<HistoryEntry | null> {
343  const found = dropEntry(await read($, historyAtom), number)
344  if (!found) return null
345
346  await update($, historyAtom, () => found.rest)
347  await update($, viewAtom, x => (x.historyOpen === found.dropped.startedAt ? { ...x, historyOpen: null } : x))
348  await persist($)
349
350  return found.dropped
351}
352
353async function wipeHistory($: EngineInterface): Promise<number> {
354  const count = (await read($, historyAtom)).length
355  await update($, historyAtom, () => [])
356  await update($, viewAtom, x => ({ ...x, historyOpen: null }))
357  await persist($)
358
359  return count
360}
361
362// ---------------------------------------------------------------- text answers
363
364function historyText(entries: readonly HistoryEntry[], t: Messages): string {
365  if (entries.length === 0) return t.historyEmpty
366  const lines = entries
367    .slice(-15)
368    .reverse()
369    .map((entry, i) => {
370      const mark = entry.reason === 'completed' ? '✓' : entry.reason === 'replaced' ? '↻' : '✗'
371      return `${i + 1}. ${mark} ${stampOf(entry.startedAt)} · ${entry.done}/${entry.total} · ${durationOf(entry.durationMs)} · ${entry.goal}`
372    })
373
374  return [t.historyHeading, ...lines].join('\n')
375}
376
377function boardText(b: Board, t: Messages): string {
378  if (b.tasks.length === 0) return t.noActivePlan
379  const { done, total, percent } = progressOf(b.tasks)
380  const rows = b.tasks.map(task => `${GLYPH[task.status]} ${task.id} ${task.title} (${t.status[task.status]})`)
381
382  return [`${b.goal} · ${done}/${total} (${percent}%)`, ...rows].join('\n')
383}
384
385/** Tokens and cost per task, plus what the mod itself adds to every request (an estimate). */
386function statsText(b: Board, t: Messages, isLight = false): string {
387  const overheadRules = isLight ? 0 : estimateTokens(t.planRules)
388  const overheadTools = isLight
389    ? 0
390    : estimateTokens(
391        [t.planToolDescription, t.taskToolDescription, t.goalDescription, t.tasksDescription, t.idDescription, t.noteDescription].join(' '),
392      ) + 70
393  const overhead = t.statsOverhead(fmtTokens(overheadRules), fmtTokens(overheadTools), fmtTokens(overheadRules + overheadTools))
394  if (b.tasks.length === 0 && billableOf(b.untracked) === 0) return `${t.statsNoPlan}\n${overhead}`
395
396  const usd = (n: number) => (n > 0 ? fmtUsd(n) : '')
397  const rows = b.tasks.map(task => {
398    const all = sumUsage(task.usage, task.agentUsage)
399    const detail =
400      all.steps === 0 ? '' : t.tokensLine(fmtTokens(all.input + all.cacheWrite), fmtTokens(all.output), fmtTokens(all.cacheRead), '', '')
401    return t.statsRow(task.id, task.title, fmtTokens(billableOf(all)), detail, usd(task.cost))
402  })
403  const total =
404    b.tasks.reduce((n, task) => n + billableOf(task.usage) + billableOf(task.agentUsage), 0) + billableOf(b.untracked)
405  const cost = b.tasks.reduce((n, task) => n + task.cost, 0)
406  const lines = [t.statsTitle(b.goal || '-'), ...rows]
407  if (billableOf(b.untracked) > 0) lines.push(t.statsUntracked(fmtTokens(billableOf(b.untracked))))
408
409  return [...lines, t.statsTotal(fmtTokens(total), usd(cost)), '', overhead].join('\n')
410}
411
412// ---------------------------------------------------------------- the module
413
414export const register: Register = (on, options) => {
415  const isOpenOnStart = options.openOnStart !== false
416  const planMode = options.planMode === 'warn' || options.planMode === 'off' ? options.planMode : 'block'
417  // Light mode: no tools of its own, no rules in the prompt, no guard. The board only mirrors
418  // Claude Code's plan mode and TodoWrite, and keeps its time, checks and token accounting.
419  const isLight = options.mode === 'light'
420  const isEnforcing = !isLight && planMode === 'block'
421  const isFlagging = !isLight && planMode !== 'off'
422  const minChars = Number(options.minPromptChars) > 0 ? Number(options.minPromptChars) : 240
423  const extraChecks = String(options.extraChecks ?? '')
424    .split(',')
425    .map(part => part.trim())
426    .filter(part => part !== '')
427  const t = messages(resolveLang(options.language, systemLocale()))
428
429  on('session.start', async ($, e, next) => {
430    await $.command.register({
431      name: 'taskboard',
432      description: t.commandDescription,
433      argumentHint: '[open|close|history [drop <n>|clear]|clear|skip|stats]',
434    })
435    if (!isLight) await $.tool.register({
436      name: 'plan',
437      description: t.planToolDescription,
438      inputSchema: {
439        type: 'object',
440        properties: {
441          goal: { type: 'string', description: t.goalDescription },
442          tasks: { type: 'array', items: { type: 'string' }, description: t.tasksDescription },
443        },
444        required: ['goal', 'tasks'],
445      },
446      isDeferred: false,
447    })
448    if (!isLight) await $.tool.register({
449      name: 'task',
450      description: t.taskToolDescription,
451      inputSchema: {
452        type: 'object',
453        properties: {
454          id: { type: 'string', description: t.idDescription },
455          status: { type: 'string', enum: ['in_progress', 'done', 'failed', 'blocked', 'pending'] },
456          note: { type: 'string', description: t.noteDescription },
457        },
458        required: ['id', 'status'],
459      },
460      isDeferred: false,
461    })
462    await restore($, t)
463    await refreshProject($).catch(() => undefined)
464    await syncTicker($)
465    if (isOpenOnStart) void $.ui.open({ id: PANE, title: t.paneTitle, columns: 52 }).catch(() => undefined)
466
467    return next(e)
468  })
469
470  on('prompt.compose', async ($, e, next) => {
471    const composed = await next(e)
472    if (isLight) return composed
473
474    return { sections: [...composed.sections, { id: 'agent-taskboard:plan', text: t.planRules, scope: 'session' as const }] }
475  }).catch(($, e, next) => next(e))
476
477  on('prompt.submit', async ($, e, next) => {
478    if (isFlagging && looksComplex(e.text, minChars)) {
479      const b = await read($, boardAtom)
480      const hasOpenPlan = b.tasks.length > 0 && !isFinished(b)
481      if (!hasOpenPlan) await update($, boardAtom, x => ({ ...x, needsPlan: true }))
482    }
483
484    return next(e)
485  }).catch(($, e, next) => next(e))
486
487  on('turn.step', async function* ($, e, next) {
488    const result = yield* next(e)
489    await recordUsage($, e.agentId ?? null, result.usage).catch(() => undefined)
490
491    return result
492  })
493
494  on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
495    const input = e as unknown as Record<string, unknown>
496    const tasks = tasksFrom(input.tasks)
497    if (tasks.length === 0) return { result: t.emptyPlan }
498
499    await archiveBoard($, 'replaced')
500    const now = await $.clock.now()
501    const goal = clip(String(input.goal ?? ''), 160) || t.untitled
502    await update($, viewAtom, () => FRESH_VIEW)
503    await mutate($, () => ({ ...EMPTY_BOARD, goal, createdAt: now, tasks }))
504
505    return { result: t.planRegistered(tasks.length, tasks.map(task => `${task.id} ${task.title}`).join(' | ')) }
506  })
507
508  on('tool.call', { tool: TASK_TOOL }, async ($, e) => {
509    const input = e as unknown as Record<string, unknown>
510    const id = String(input.id ?? '')
511    const status = String(input.status ?? '') as TaskStatus
512    const note = clip(String(input.note ?? ''), 160)
513    const b = await read($, boardAtom)
514    const task = b.tasks.find(x => x.id === id)
515
516    if (!task) return { result: t.noSuchTask(id, b.tasks.map(x => x.id).join(', ')) }
517    if (!STATUSES.includes(status)) return { result: t.badStatus(status) }
518
519    if (status === 'done') {
520      const last = lastCheck(task)
521      if (last && !last.ok) return { deny: t.denyDone(id, last.kind, last.command) }
522    }
523
524    await setTaskStatus($, id, status, note)
525    const after = await read($, boardAtom)
526    const { done, total } = progressOf(after.tasks)
527    const verification = status !== 'done' ? 'n/a' : task.checks.some(c => c.ok) ? 'with' : 'without'
528
529    return { result: t.taskUpdated(id, t.status[status], verification, done, total) }
530  })
531
532  on('tool.call', async ($, e, next) => {
533    const tool = String(e.tool)
534    if (tool === PLAN_TOOL || tool === TASK_TOOL) return next(e)
535
536    // The plan file is where plan mode writes the plan; blocking it would stop ExitPlanMode from ever running.
537    const target = (e as unknown as Record<string, unknown>).file_path
538    if (isEnforcing && !e.agentId && EDIT_TOOLS.has(tool) && !isPlanFile(target)) {
539      const b = await read($, boardAtom)
540      if (b.needsPlan) return { deny: t.denyNoPlan }
541    }
542
543    const ran = await next(e)
544
545    if (!e.agentId) {
546      const isOk = ran.deny === undefined && ran.isError !== true
547      await afterCall($, tool, e as unknown as Record<string, unknown>, isOk, extraChecks)
548      const input = e as unknown as Record<string, unknown>
549      if (isOk && tool === PLAN_MODE_TOOL) {
550        const plan = planFromMarkdown(String(input.plan ?? ''))
551        const items = plan.steps.map(title => ({ title, status: 'pending' as const }))
552        await ingestNative($, 'plan-mode', plan.goal, items, t.untitled).catch(() => undefined)
553      } else if (isOk && tool === TODO_TOOL) {
554        await ingestNative($, 'todo', '', itemsFromTodos(input.todos), t.todoGoal).catch(() => undefined)
555      }
556      // A shell command may have switched branch.
557      if (tool === 'Bash' || tool === 'PowerShell') await refreshProject($).catch(() => undefined)
558    }
559
560    return ran
561  }).catch(($, e, next) => next(e))
562
563  on('agent.spawn', async ($, e, next) => {
564    const started = await next(e)
565    if (started.deny !== undefined || !started.agentId) return started
566
567    const run: AgentRun = {
568      id: started.agentId,
569      name: clip(e.description || e.subagentType, 40),
570      type: e.subagentType,
571      status: 'running',
572      startedAt: await $.clock.now(),
573    }
574    // A subagent belongs to the task it names ("t3: …"); only unnamed ones fall back to the active task.
575    const named = taskIdFrom(e.description ?? '', e.prompt ?? '')
576
577    await mutate($, cur => {
578      const owner = named !== null && cur.tasks.some(x => x.id === named) ? named : cur.activeId
579      if (owner === null) return { ...cur, looseAgents: [...cur.looseAgents, run].slice(-12) }
580
581      return withTask(cur, owner, task => ({ ...task, agents: [...task.agents, run].slice(-12) }))
582    })
583
584    return started
585  }).catch(($, e, next) => next(e))
586
587  on('turn.complete', async ($, e, next) => {
588    const done = await next(e)
589
590    if (e.agentId) {
591      await setAgentStatus($, e.agentId, e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed')
592    } else if (e.reason !== 'answer') {
593      await mutate($, cur =>
594        cur.activeId === null ? cur : withTask(cur, cur.activeId, task => ({ ...task, note: task.note || t.interruptedNote })),
595      )
596    }
597
598    return done
599  }).catch(($, e, next) => next(e))
600
601  on('command.run', { command: 'taskboard' }, async ($, e) => {
602    const [verb = 'open'] = e.args.trim().split(/\s+/)
603
604    if (verb === 'close') {
605      await $.ui.close({ id: PANE })
606      return { text: t.closed }
607    }
608    if (verb === 'history') {
609      const [, sub = '', arg = ''] = e.args.trim().split(/\s+/)
610      if (sub === 'clear') return { text: t.historyWiped(await wipeHistory($)) }
611      if (sub === 'drop') {
612        const number = Number(arg)
613        const dropped = await removeEntry($, number)
614        if (dropped) return { text: t.historyDropped(number, clip(dropped.goal, 80)) }
615
616        return { text: t.historyNoEntry(number, (await read($, historyAtom)).length) }
617      }
618      if (sub !== '') return { text: t.historyUsage }
619
620      await update($, viewAtom, x => ({ ...x, mode: 'history' as const }))
621      void $.ui.open({ id: PANE, title: t.paneTitle, columns: 52 }).catch(() => undefined)
622      return { text: historyText(await read($, historyAtom), t) }
623    }
624    if (verb === 'clear') {
625      await clearBoard($)
626      return { text: t.cleared }
627    }
628    if (verb === 'stats') return { text: statsText(await read($, boardAtom), t, isLight) }
629    if (verb === 'skip') {
630      await update($, boardAtom, x => ({ ...x, needsPlan: false }))
631      return { text: t.skipped }
632    }
633
634    await update($, viewAtom, x => ({ ...x, mode: 'board' as const }))
635    const opened = await $.ui.open({ id: PANE, title: t.paneTitle, columns: 52 })
636    const summary = boardText(await read($, boardAtom), t)
637
638    return { text: opened.isPlaced ? summary : `${t.paneNotShown(opened.reason ?? '')}\n${summary}` }
639  })
640
641  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
642    const { Box, Text, Button } = $.ui.resolve(e)
643    // Never name a local `h`: JSX compiles to calls of the global `h`.
644    const [b, v, past, proj, now] = await Promise.all([
645      read($, boardAtom),
646      read($, viewAtom),
647      read($, historyAtom),
648      read($, projectAtom),
649      $.clock.now(),
650    ])
651    const FULL = Math.max(34, e.props.bodyColumns)
652    // The panel pads itself (2 columns each side), so W is what fits inside.
653    const W = FULL - 4
654    // The pane's own height: the footer sits at its bottom edge, whatever the content.
655    const rows = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 24
656    const { done, total, percent } = progressOf(b.tasks)
657    const counts = countsOf(b.tasks)
658    const isHistory = v.mode === 'history'
659    const g = gauge(done, total, Math.min(16, Math.max(8, W - 30)))
660
661    const colorOf = (s: TaskStatus) =>
662      s === 'done' ? 'green' : s === 'failed' ? 'red' : s === 'blocked' ? 'yellow' : s === 'in_progress' ? 'cyan' : 'gray'
663
664    const card = (title: string, right: string, body: unknown) => (
665      <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={W}>
666        <Box justifyContent="space-between" width={W - 4}>
667          <Text bold color="cyan">{`◈ ${title}`}</Text>
668          <Text dimColor>{right}</Text>
669        </Box>
670        {body}
671      </Box>
672    )
673
674    const actions = (
675      <Box justifyContent="flex-end" columnGap={1} width={W}>
676        {!isHistory && b.tasks.length > 0 ? (
677          <Button
678            key="expand-all"
679            hotkey="e"
680            label={v.expandAll ? t.collapse : t.expandAll}
681            onPress={() => update($, viewAtom, x => ({ ...x, expandAll: !x.expandAll }))}
682          />
683        ) : null}
684        <Button
685          key="toggle-view"
686          hotkey="h"
687          label={isHistory ? t.viewBoard : t.viewHistory}
688          onPress={() => update($, viewAtom, x => ({ ...x, mode: x.mode === 'board' ? ('history' as const) : ('board' as const) }))}
689        />
690        <Button key="clear" hotkey="l" label={t.clear} dimColor={b.tasks.length === 0} onPress={() => clearBoard($)} />
691      </Box>
692    )
693
694    // ---- where the session runs: the project folder and its branch, at the bottom of every view
695    const place = (
696      <Box flexDirection="column" width={W}>
697        <Text color="gray">{'─'.repeat(Math.max(1, W))}</Text>
698        <Text wrap="truncate">
699          <Text dimColor>{`${t.rootLabel}: `}</Text>
700          <Text>{tailClip(proj.root, W - t.rootLabel.length - 2)}</Text>
701        </Text>
702        <Text wrap="truncate">
703          <Text dimColor>{`${t.branchLabel}: `}</Text>
704          <Text color="magenta" bold>
705            {proj.branch ?? t.noBranch}
706          </Text>
707        </Text>
708      </Box>
709    )
710
711    // ---- history: a list, one plan open for its detail
712    if (isHistory) {
713      const room = Math.max(3, Math.floor((rows - 8) / 2))
714      const shown = past.slice(-room).reverse()
715
716      return (
717        <Box flexDirection="column" width={FULL} minHeight={rows} paddingX={2} paddingY={1} justifyContent="space-between">
718          <Box flexDirection="column" width={W} rowGap={1}>
719            {card(t.historyTitle, t.plansCount(past.length), <Text dimColor>{t.savedPlans}</Text>)}
720            {actions}
721            {shown.length === 0 ? <Text dimColor>{t.noFinished}</Text> : null}
722            {shown.map((entry, i) => {
723              const isOpen = v.historyOpen === entry.startedAt
724              const markColor = entry.reason === 'completed' ? 'green' : entry.reason === 'replaced' ? 'yellow' : 'gray'
725              const mark = entry.reason === 'completed' ? '✓' : entry.reason === 'replaced' ? '↻' : '✗'
726
727              return (
728                <Box flexDirection="column" borderStyle={isOpen ? 'round' : undefined} borderColor={isOpen ? markColor : undefined} paddingX={isOpen ? 1 : 0}>
729                  <Box>
730                    <Text color={markColor}>{`${mark} `}</Text>
731                    <Button
732                      key={`hist-${entry.startedAt}`}
733                      plain
734                      hotkey={i < 9 ? String(i + 1) : undefined}
735                      label={clip(entry.goal, Math.max(10, W - 8))}
736                      onPress={() => update($, viewAtom, x => ({ ...x, historyOpen: x.historyOpen === entry.startedAt ? null : entry.startedAt }))}
737                    />
738                  </Box>
739                  <Text dimColor wrap="truncate">{`  ${entry.done}/${entry.total} · ${durationOf(entry.durationMs)} · ${stampOf(entry.startedAt)}`}</Text>
740                  {isOpen ? (
741                    <Box flexDirection="column">
742                      {entry.tasks.map(task => (
743                        <Text wrap="truncate" color={colorOf(task.status)}>
744                          {`  ${GLYPH[task.status]} ${clip(task.title, W - 8)}`}
745                        </Text>
746                      ))}
747                      <Box columnGap={2}>
748                        <Button key={`reopen-${entry.startedAt}`} plain hotkey="r" label={t.reopen} onPress={() => reopenEntry($, entry, t)} />
749                        <Button
750                          key={`drop-${entry.startedAt}`}
751                          plain
752                          hotkey="x"
753                          label={t.dropEntry}
754                          onPress={() => removeEntry($, past.length - past.indexOf(entry))}
755                        />
756                      </Box>
757                    </Box>
758                  ) : null}
759                </Box>
760              )
761            })}
762            <Text dimColor wrap="truncate">{t.historyHelp}</Text>
763          </Box>
764          {place}
765        </Box>
766      )
767    }
768
769    // ---- the friction notice: visible, with a way out
770    const notice = b.needsPlan ? (
771      <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} width={W}>
772        <Text color="yellow" bold>
773          {t.noticeTitle}
774        </Text>
775        <Text dimColor wrap="wrap">
776          {isEnforcing ? t.noticeBlocking : t.noticeSuggesting}
777        </Text>
778        <Box>
779          <Button key="skip-plan" plain hotkey="o" label={t.skipPlan} onPress={() => skipPlan($)} />
780        </Box>
781      </Box>
782    ) : null
783
784    if (b.tasks.length === 0) {
785      return (
786        <Box flexDirection="column" width={FULL} minHeight={rows} paddingX={2} paddingY={1} justifyContent="space-between">
787          <Box flexDirection="column" width={W} rowGap={1}>
788            {card(t.boardTitle, '', <Text dimColor>{t.noPlanBody}</Text>)}
789            {actions}
790            {notice}
791            {notice === null ? <Text dimColor wrap="wrap">{t.noPlanHint}</Text> : null}
792            {past.length > 0 ? <Text dimColor>{t.historyHint(past.length)}</Text> : null}
793          </Box>
794          {place}
795        </Box>
796      )
797    }
798
799    // ---- the board: summary, then one row per task, cards for the ones in focus
800    const elapsed = b.createdAt === null ? '' : ` · ${durationOf(workTimeOf(b.tasks, now))}`
801    const planTokens =
802      b.tasks.reduce((n, x) => n + billableOf(x.usage) + billableOf(x.agentUsage), 0) + billableOf(b.untracked)
803    const planCost = b.tasks.reduce((n, x) => n + x.cost, 0)
804    const running = b.tasks.reduce((n, x) => n + x.agents.filter(a => a.status === 'running').length, 0) + b.looseAgents.filter(a => a.status === 'running').length
805
806    const taskView = (task: Board['tasks'][number], i: number) => {
807      const ms = elapsedOf(task, now)
808      const color = colorOf(task.status)
809      const isOpen = v.expanded === task.id
810      const isCard = task.status === 'in_progress' || isOpen || v.expandAll
811      const last = lastCheck(task)
812      const spent = sumUsage(task.usage, task.agentUsage)
813      const meta = `${ms === null ? '' : durationOf(ms)}${spent.steps > 0 ? ` · ${fmtTokens(billableOf(spent))}` : ''}${task.agents.length > 0 ? ` · ⚙${task.agents.length}` : ''}${last ? (last.ok ? ' · ✔' : ' · ✘') : ''}`
814
815      const titleRow = (
816        <Box justifyContent="space-between" width={isCard ? W - 4 : W}>
817          <Box>
818            <Text color={color}>{`${GLYPH[task.status]} `}</Text>
819            <Button
820              key={`task-${task.id}`}
821              plain
822              hotkey={i < 9 ? String(i + 1) : undefined}
823              label={clip(task.title, Math.max(10, W - 18))}
824              onPress={() => update($, viewAtom, x => ({ ...x, expanded: x.expanded === task.id ? null : task.id }))}
825            />
826          </Box>
827          <Text dimColor>{meta}</Text>
828        </Box>
829      )
830
831      if (!isCard) return <Box key={`row-${task.id}`}>{titleRow}</Box>
832
833      return (
834        <Box key={`card-${task.id}`} flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={W}>
835          {titleRow}
836          <Text dimColor wrap="truncate">{t.taskDetail(t.status[task.status], task.calls, task.checks.length)}</Text>
837          {spent.steps > 0 ? (
838            <Text dimColor wrap="truncate">
839              {t.tokensLine(
840                fmtTokens(task.usage.input + task.usage.cacheWrite),
841                fmtTokens(task.usage.output),
842                fmtTokens(spent.cacheRead),
843                task.agentUsage.steps > 0 ? fmtTokens(billableOf(task.agentUsage)) : '',
844                task.cost > 0 ? fmtUsd(task.cost) : '',
845              )}
846            </Text>
847          ) : null}
848          {task.agents.map(a => (
849            <Text wrap="truncate" color={a.status === 'failed' ? 'red' : a.status === 'running' ? 'cyan' : 'gray'}>
850              {`⚙ ${a.name} · ${t.agentStatus[a.status]}`}
851            </Text>
852          ))}
853          {task.checks.slice(-3).map(c => (
854            <Text wrap="truncate" color={c.ok ? 'green' : 'red'}>
855              {`${c.ok ? '✔' : '✘'} ${c.kind}: ${c.command}`}
856            </Text>
857          ))}
858          {task.note ? (
859            <Text dimColor wrap="wrap">
860              {`» ${task.note}`}
861            </Text>
862          ) : null}
863          {isOpen ? (
864            <Box columnGap={2}>
865              {task.status !== 'in_progress' ? (
866                <Button key={`go-${task.id}`} plain hotkey="i" label={t.start} onPress={() => setTaskStatus($, task.id, 'in_progress', '')} />
867              ) : (
868                <Button key={`ok-${task.id}`} plain hotkey="d" label={t.markDone} onPress={() => setTaskStatus($, task.id, 'done', t.manualDone)} />
869              )}
870              {task.status !== 'blocked' ? (
871                <Button key={`block-${task.id}`} plain hotkey="b" label={t.block} onPress={() => setTaskStatus($, task.id, 'blocked', t.manualBlocked)} />
872              ) : null}
873              {task.status !== 'failed' ? (
874                <Button key={`fail-${task.id}`} plain hotkey="f" label={t.fail} onPress={() => setTaskStatus($, task.id, 'failed', t.manualFailed)} />
875              ) : null}
876            </Box>
877          ) : null}
878        </Box>
879      )
880    }
881
882    return (
883      <Box flexDirection="column" width={FULL} minHeight={rows} paddingX={2} paddingY={1} justifyContent="space-between">
884        <Box flexDirection="column" width={W} rowGap={1}>
885          {card(
886            t.boardTitle,
887            `${percent}%${elapsed}`,
888            <Box flexDirection="column">
889              <Text bold wrap="truncate">
890                {clip(b.goal, W - 4)}
891              </Text>
892              <Text wrap="truncate">
893                <Text color="green">{g.on}</Text>
894                <Text color="gray">{g.off}</Text>
895                <Text bold>{` ${done}/${total}`}</Text>
896              </Text>
897              <Text wrap="truncate">
898                <Text color="green">{`✓ ${counts.done}`}</Text>
899                <Text dimColor>{'   '}</Text>
900                <Text color="cyan">{`◐ ${counts.in_progress}`}</Text>
901                <Text dimColor>{'   '}</Text>
902                <Text color="gray">{`○ ${counts.pending}`}</Text>
903                {counts.blocked > 0 ? <Text color="yellow">{`   ⏸ ${counts.blocked}`}</Text> : null}
904                {counts.failed > 0 ? <Text color="red">{`   ✗ ${counts.failed}`}</Text> : null}
905                {running > 0 ? <Text color="cyan">{`   ${t.activeAgents(running)}`}</Text> : null}
906              </Text>
907              {planTokens > 0 ? (
908                <Text dimColor wrap="truncate">
909                  {t.totalLine(fmtTokens(planTokens), planCost > 0 ? fmtUsd(planCost) : '')}
910                </Text>
911              ) : null}
912            </Box>,
913          )}
914          {actions}
915          {notice}
916          {b.isResumed ? <Text dimColor>{t.resumedBanner}</Text> : null}
917          <Box flexDirection="column" width={W}>
918            {b.tasks.map((task, i) => taskView(task, i))}
919          </Box>
920          {b.looseAgents.length > 0 ? (
921            <Text dimColor wrap="truncate">{t.looseAgents(b.looseAgents.map(a => a.name).join(', '))}</Text>
922          ) : null}
923          <Text dimColor wrap="truncate">
924            {v.expanded !== null ? t.helpTask : t.helpBoard}
925          </Text>
926        </Box>
927        {place}
928      </Box>
929    )
930  })
931}
932
hooks/logic.ts 544 lines
1import type { Board, Check, HistoryEntry, Task, TaskStatus, Usage } from '../types'
2
3export function emptyUsage(): Usage {
4  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, steps: 0 }
5}
6
7export const EMPTY_BOARD: Board = {
8  goal: '',
9  createdAt: null,
10  tasks: [],
11  activeId: null,
12  looseAgents: [],
13  untracked: emptyUsage(),
14  needsPlan: false,
15  isArchived: false,
16  isResumed: false,
17}
18
19const CHECK_PATTERNS: [Check['kind'], RegExp][] = [
20  ['test', /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|\b(?:vitest|jest|pytest|mocha|playwright|cypress|phpunit)\b|\bgo\s+test\b|\bcargo\s+test\b|\bdotnet\s+test\b|\bclaude\s+plugin\s+test\b|\bmake\s+test\b/i],
21  ['validate', /\bclaude\s+plugin\s+validate\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:check|validate|verify)\b/i],
22  ['typecheck', /\btsc\b|\bmypy\b|\bpyright\b|\bcargo\s+check\b/i],
23  ['lint', /\beslint\b|\bruff\b|\bflake8\b|\bclippy\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?lint\b/i],
24  ['build', /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b|\bvite\s+build\b|\bcargo\s+build\b|\bgo\s+build\b|\bgradle\b|\bmvn\b/i],
25]
26
27/**
28 * What kind of verification a shell command is, or null when it is not one.
29 * `extra` are the person's own commands (substrings, any case) that also count.
30 */
31export function checkKindOf(command: string, extra: readonly string[] = []): Check['kind'] | null {
32  for (const [kind, pattern] of CHECK_PATTERNS) {
33    if (pattern.test(command)) return kind
34  }
35
36  const lower = command.toLowerCase()
37  if (extra.some(part => part !== '' && lower.includes(part.toLowerCase()))) return 'custom'
38
39  return null
40}
41
42const CD_PREFIX = /^\s*(?:cd|pushd|set-location)\s+(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s*(?:&&|;)\s*/i
43
44/** A command as the board shows it: without the `cd <folder> &&` that only says where it ran. */
45export function commandLabel(command: string): string {
46  let text = command
47  for (let i = 0; i < 3; i += 1) {
48    const next = text.replace(CD_PREFIX, '')
49    if (next === text || next.trim() === '') break
50    text = next
51  }
52
53  return text.trim()
54}
55
56/**
57 * The task a subagent was started for, when its description or prompt names one:
58 * `t3: revisar tipos`, `[t3] revisar tipos`, `(t3) …`.
59 */
60export function taskIdFrom(...texts: readonly string[]): string | null {
61  for (const text of texts) {
62    const head = text.slice(0, 120)
63    const lead = /^\s*[[(]?\b(t\d{1,2})\b[\])]?\s*[:·–-]/i.exec(head)
64    const tagged = /[[(]\s*(t\d{1,2})\s*[\])]/i.exec(head)
65    const id = lead?.[1] ?? tagged?.[1]
66    if (id) return id.toLowerCase()
67  }
68
69  return null
70}
71
72const ACTION_WORDS =
73  /\b(?:implement\w*|cre(?:a|ar|e|es|en)|agreg\w*|a[ñn]ad\w*|refactor\w*|migr\w*|integr\w*|constru\w*|desarroll\w*|build\w*|add|create\w*|develop\w*)\b/gi
74
75/** Whether a prompt reads as a complex request that deserves a plan first. */
76export function looksComplex(text: string, minChars: number): boolean {
77  const t = text.trim()
78  if (t === '' || t.startsWith('/')) return false
79
80  const bullets = t.split(/\r?\n/).filter(l => /^\s*(?:[-*•]|\d+[.)])\s+/.test(l)).length
81  const actions = (t.match(ACTION_WORDS) ?? []).length
82
83  if (bullets >= 3) return true
84  if (actions >= 2) return true
85  if (t.endsWith('?') && t.length < minChars) return false
86
87  // Length alone is not a task: a pasted log or a long question names no action.
88  return actions >= 1 && t.length >= minChars
89}
90
91export function countsOf(tasks: readonly Task[]): Record<TaskStatus, number> {
92  const counts: Record<TaskStatus, number> = { pending: 0, in_progress: 0, done: 0, failed: 0, blocked: 0 }
93  for (const t of tasks) counts[t.status] += 1
94
95  return counts
96}
97
98/** A past plan brought back to the board: what was done stays done, the rest starts again. */
99export function boardFromEntry(
100  entry: HistoryEntry,
101  now: number,
102  noteOf: (status: TaskStatus) => string = () => '',
103): Board {
104  return {
105    ...EMPTY_BOARD,
106    goal: entry.goal,
107    createdAt: now,
108    isResumed: true,
109    tasks: entry.tasks.map((t, i) => ({
110      id: `t${i + 1}`,
111      title: t.title,
112      status: t.status === 'done' ? ('done' as const) : ('pending' as const),
113      startedAt: null,
114      endedAt: null,
115      calls: 0,
116      agents: [],
117      checks: [],
118      note: t.status === 'done' || t.status === 'pending' ? '' : noteOf(t.status),
119      usage: emptyUsage(),
120      agentUsage: emptyUsage(),
121      cost: 0,
122      costMark: null,
123    })),
124  }
125}
126
127export function progressOf(tasks: readonly Task[]): { done: number; total: number; percent: number } {
128  const total = tasks.length
129  const done = tasks.filter(t => t.status === 'done').length
130
131  return { done, total, percent: total === 0 ? 0 : Math.round((done / total) * 100) }
132}
133
134export function isFinished(board: Board): boolean {
135  return board.tasks.length > 0 && board.tasks.every(t => t.status === 'done' || t.status === 'failed')
136}
137
138/** A task's elapsed time: running ones count to `now`, finished ones to their end. */
139export function elapsedOf(task: Task, now: number): number | null {
140  if (task.startedAt === null) return null
141
142  return Math.max(0, (task.endedAt ?? now) - task.startedAt)
143}
144
145/**
146 * Time actually spent on a plan: the union of its tasks' running stretches (to `now` for running ones).
147 * A plan left open overnight does not count the hours nobody worked on it.
148 */
149export function workTimeOf(tasks: readonly Task[], now: number): number {
150  const spans = tasks
151    .filter(t => t.startedAt !== null)
152    .map(t => [t.startedAt as number, Math.max(t.startedAt as number, t.endedAt ?? now)] as const)
153    .sort((a, b) => a[0] - b[0])
154
155  let total = 0
156  let end = -Infinity
157  for (const [from, to] of spans) {
158    total += Math.max(0, to - Math.max(from, end))
159    end = Math.max(end, to)
160  }
161
162  return total
163}
164
165/** `45s`, `3m05s`, `1h02m`. */
166export function durationOf(ms: number): string {
167  const s = Math.floor(ms / 1000)
168  if (s < 60) return `${s}s`
169  const m = Math.floor(s / 60)
170  if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
171
172  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
173}
174
175export function gauge(done: number, total: number, width: number): { on: string; off: string } {
176  const full = total === 0 ? 0 : Math.round((done / total) * width)
177
178  return { on: '▰'.repeat(full), off: '▱'.repeat(Math.max(0, width - full)) }
179}
180
181export function clip(text: string, max: number): string {
182  const one = text.replace(/\s+/g, ' ').trim()
183
184  return one.length > max ? `${one.slice(0, Math.max(0, max - 1)).trimEnd()}…` : one
185}
186
187export const GLYPH: Record<TaskStatus, string> = {
188  pending: '○',
189  in_progress: '◐',
190  done: '✓',
191  failed: '✗',
192  blocked: '⏸',
193}
194
195export const STATUSES: readonly TaskStatus[] = ['pending', 'in_progress', 'done', 'failed', 'blocked']
196
197/** Tasks from the model's plan input: strings or `{ title }` objects, ids t1, t2, … */
198export function tasksFrom(input: unknown): Task[] {
199  if (!Array.isArray(input)) return []
200
201  return input
202    .map(item =>
203      typeof item === 'string' ? item : typeof item === 'object' && item !== null ? String((item as { title?: unknown }).title ?? '') : '',
204    )
205    .map(title => clip(title, 120))
206    .filter(title => title !== '')
207    .slice(0, 30)
208    .map((title, i) => ({
209      id: `t${i + 1}`,
210      title,
211      status: 'pending' as const,
212      startedAt: null,
213      endedAt: null,
214      calls: 0,
215      agents: [],
216      checks: [],
217      note: '',
218      usage: emptyUsage(),
219      agentUsage: emptyUsage(),
220      cost: 0,
221      costMark: null,
222    }))
223}
224
225/** A task as Claude Code's own plan mode or todo list names it. */
226export type NativeItem = { title: string; status: TaskStatus }
227
228function blankTask(id: string, title: string, status: TaskStatus = 'pending'): Task {
229  return {
230    id,
231    title,
232    status,
233    startedAt: null,
234    endedAt: null,
235    calls: 0,
236    agents: [],
237    checks: [],
238    note: '',
239    usage: emptyUsage(),
240    agentUsage: emptyUsage(),
241    cost: 0,
242    costMark: null,
243  }
244}
245
246const NATIVE_STATUS: Record<string, TaskStatus> = {
247  pending: 'pending',
248  in_progress: 'in_progress',
249  completed: 'done',
250  done: 'done',
251}
252
253/** The items of a `TodoWrite` input (`{ todos: [{ content, status, activeForm }] }`). */
254export function itemsFromTodos(input: unknown): NativeItem[] {
255  if (!Array.isArray(input)) return []
256
257  const items: NativeItem[] = []
258  for (const raw of input) {
259    if (typeof raw !== 'object' || raw === null) continue
260    const r = raw as { content?: unknown; subject?: unknown; activeForm?: unknown; status?: unknown }
261    const title = clip(String(r.content ?? r.subject ?? r.activeForm ?? ''), 120)
262    if (title === '') continue
263    items.push({ title, status: NATIVE_STATUS[String(r.status ?? '')] ?? 'pending' })
264  }
265
266  return items.slice(0, 30)
267}
268
269function stripMarkdown(text: string): string {
270  return text
271    .replace(/^\[[ xX]\]\s+/, '')
272    .replace(/[*_`]+/g, '')
273    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
274    .trim()
275}
276
277/**
278 * The goal and the steps of a plan-mode plan (markdown): the first heading is the goal;
279 * the steps are its numbered items, or its bullets when nothing is numbered, or its
280 * sub-headings as a last resort. Nested items are details, not steps.
281 */
282export function planFromMarkdown(text: string): { goal: string; steps: string[] } {
283  const lines = text.split(/\r?\n/)
284  const heading = lines.map(l => /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/.exec(l)?.[1]).find(h => h !== undefined)
285  const firstText = lines.map(l => l.trim()).find(l => l !== '' && !/^(?:#|[-*•]|\d+[.)])/.test(l))
286  const goal = clip(stripMarkdown(heading ?? firstText ?? ''), 160)
287
288  const top = (re: RegExp) =>
289    lines
290      .map(l => (/^ {0,1}\S/.test(l) ? re.exec(l)?.[1] : undefined))
291      .filter((s): s is string => s !== undefined)
292      .map(s => clip(stripMarkdown(s), 120))
293      .filter(s => s !== '')
294  const numbered = top(/^\s*\d+[.)]\s+(.+)$/)
295  const bullets = top(/^\s*[-*•]\s+(.+)$/)
296  const subs = lines
297    .map(l => /^\s{0,3}#{2,6}\s+(.+?)\s*#*\s*$/.exec(l)?.[1])
298    .filter((s): s is string => s !== undefined)
299    .map(s => clip(stripMarkdown(s), 120))
300
301  const steps = numbered.length > 0 ? numbered : bullets.length > 0 ? bullets : subs
302
303  return { goal, steps: steps.slice(0, 30) }
304}
305
306const sameTitle = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
307
308/** Whether the board already holds at least one of these items (by title). */
309export function sharesTask(board: Board, items: readonly NativeItem[]): boolean {
310  return items.some(i => board.tasks.some(t => sameTitle(t.title, i.title)))
311}
312
313/**
314 * The board after Claude Code's own list changed. Tasks already on the board (by title)
315 * keep their time, tokens, checks and agents; the list's statuses are the truth.
316 * `fresh` starts a new board instead of editing the current one.
317 */
318export function boardFromNative(
319  board: Board,
320  items: readonly NativeItem[],
321  opts: { goal: string; source: NonNullable<Board['source']>; now: number; cost: number | null; fresh: boolean },
322): Board {
323  const base: Board = opts.fresh ? { ...EMPTY_BOARD, createdAt: opts.now } : board
324  const taken = new Set<string>()
325
326  const tasks = items.map((item, i) => {
327    const old = base.tasks.find(t => !taken.has(t.id) && sameTitle(t.title, item.title))
328    if (old) taken.add(old.id)
329    const was = old ?? blankTask('', item.title)
330    const settled = was.status === 'in_progress' && item.status !== 'in_progress' ? settleCost(was, opts.cost) : was
331    const id = `t${i + 1}`
332
333    if (item.status === 'in_progress') {
334      const resumed = was.status === 'in_progress' ? settled : markCost(settled, opts.cost)
335      return { ...resumed, id, title: item.title, status: item.status, startedAt: was.startedAt ?? opts.now, endedAt: null }
336    }
337    if (item.status === 'pending') {
338      return { ...settled, id, title: item.title, status: item.status, startedAt: null, endedAt: null }
339    }
340
341    return {
342      ...settled,
343      id,
344      title: item.title,
345      status: item.status,
346      startedAt: was.startedAt ?? opts.now,
347      endedAt: was.endedAt ?? opts.now,
348    }
349  })
350
351  return {
352    ...base,
353    goal: base.goal !== '' && !opts.fresh ? base.goal : opts.goal,
354    createdAt: base.createdAt ?? opts.now,
355    tasks,
356    activeId: tasks.find(t => t.status === 'in_progress')?.id ?? null,
357    needsPlan: false,
358    isArchived: false,
359    source: opts.source,
360  }
361}
362
363/** Replaces one task, leaving the others. */
364export function withTask(board: Board, id: string, fn: (task: Task) => Task): Board {
365  return { ...board, tasks: board.tasks.map(t => (t.id === id ? fn(t) : t)) }
366}
367
368/** The check that decides whether a task may be called done: its latest one, if any. */
369export function lastCheck(task: Task): Check | null {
370  return task.checks.length === 0 ? null : (task.checks[task.checks.length - 1] ?? null)
371}
372
373export function historyEntryOf(board: Board, now: number, reason: HistoryEntry['reason']): HistoryEntry | null {
374  if (board.tasks.length === 0 || board.createdAt === null) return null
375  const { done, total } = progressOf(board.tasks)
376  const lastEnd = board.tasks.reduce((m, t) => Math.max(m, t.endedAt ?? 0), 0)
377  const endedAt = reason === 'completed' && lastEnd > 0 ? lastEnd : now
378
379  return {
380    goal: clip(board.goal, 160),
381    startedAt: board.createdAt,
382    endedAt,
383    durationMs: workTimeOf(board.tasks, endedAt),
384    total,
385    done,
386    failed: board.tasks.filter(t => t.status === 'failed').length,
387    reason,
388    tokens: board.tasks.reduce((n, t) => n + billableOf(t.usage) + billableOf(t.agentUsage), 0) + billableOf(board.untracked),
389    cost: Math.round(board.tasks.reduce((n, t) => n + (t.cost ?? 0), 0) * 10_000) / 10_000,
390    tasks: board.tasks.map(t => ({ title: clip(t.title, 100), status: t.status })),
391  }
392}
393
394/**
395 * The history without one entry. `number` counts as the list shows it: 1 is the most recent.
396 * Returns the entry that left, or null when there is no such number.
397 */
398export function dropEntry(
399  entries: readonly HistoryEntry[],
400  number: number,
401): { rest: HistoryEntry[]; dropped: HistoryEntry } | null {
402  if (!Number.isInteger(number) || number < 1 || number > entries.length) return null
403  const index = entries.length - number
404
405  return { rest: entries.filter((_, i) => i !== index), dropped: entries[index] as HistoryEntry }
406}
407
408/** `2026-10-10 20:31`, local time. */
409export function stampOf(ms: number): string {
410  const d = new Date(ms)
411  const p = (n: number) => String(n).padStart(2, '0')
412
413  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
414}
415
416// ---------------------------------------------------------------- project
417
418/** The branch a `.git/HEAD` text names; a detached HEAD reads as its short hash in parentheses. */
419export function branchFromHead(text: string): string | null {
420  const line = text.trim().split(/\r?\n/)[0] ?? ''
421  const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(line)
422  if (ref?.[1]) return ref[1].trim()
423  if (/^[0-9a-f]{7,40}$/i.test(line)) return `(${line.slice(0, 7)})`
424
425  return null
426}
427
428/** The git directory a `.git` file (a worktree or a submodule) points to, or null. */
429export function gitDirFromFile(text: string): string | null {
430  const match = /^gitdir:\s*(.+)$/m.exec(text.trim())
431
432  return match?.[1]?.trim() ?? null
433}
434
435/** A file Claude Code itself keeps its plans in (`.claude/plans/…`): writing it is planning, not building. */
436export function isPlanFile(path: unknown): boolean {
437  return typeof path === 'string' && /(?:^|[\\/])\.claude[\\/]plans[\\/]/.test(path)
438}
439
440/** Keeps the end of a long path: `…\money\Money-Fast-Web`. */
441export function tailClip(text: string, max: number): string {
442  if (max <= 1) return ''
443
444  if (text.length <= max) return text
445
446  // Start at a folder boundary when that still keeps most of the room, not in the middle of a name.
447  const tail = text.slice(-(max - 1))
448  const sep = tail.search(/[\\/]/)
449
450  return `…${sep > 0 && sep < tail.length - 4 ? tail.slice(sep) : tail}`
451}
452
453/** The parent folder of a path, or null at the top. Works with `\` and `/`. */
454export function parentOf(path: string): string | null {
455  const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
456  if (cut <= 0) return null
457  const parent = path.slice(0, cut)
458
459  return /^[A-Za-z]:$/.test(parent) ? `${parent}${path[cut]}` : parent
460}
461
462// ---------------------------------------------------------------- tokens and cost
463
464/** One model request's usage as the API spells it; any field may be missing. */
465export type StepUsage = {
466  input_tokens?: number | null
467  output_tokens?: number | null
468  cache_creation_input_tokens?: number | null
469  cache_read_input_tokens?: number | null
470} | null
471
472export function addUsage(into: Usage | undefined, step: StepUsage): Usage {
473  const u = into ?? emptyUsage()
474  if (!step) return u
475
476  return {
477    input: u.input + (step.input_tokens ?? 0),
478    output: u.output + (step.output_tokens ?? 0),
479    cacheRead: u.cacheRead + (step.cache_read_input_tokens ?? 0),
480    cacheWrite: u.cacheWrite + (step.cache_creation_input_tokens ?? 0),
481    steps: u.steps + 1,
482  }
483}
484
485export function sumUsage(a: Usage | undefined, b: Usage | undefined): Usage {
486  const x = a ?? emptyUsage()
487  const y = b ?? emptyUsage()
488
489  return {
490    input: x.input + y.input,
491    output: x.output + y.output,
492    cacheRead: x.cacheRead + y.cacheRead,
493    cacheWrite: x.cacheWrite + y.cacheWrite,
494    steps: x.steps + y.steps,
495  }
496}
497
498/** What a request really sent and got: fresh input, cache writes and output. Cache reads are the cheap part. */
499export function billableOf(u: Usage | undefined): number {
500  return u ? u.input + u.cacheWrite + u.output : 0
501}
502
503/** `842`, `12.4k`, `1.3M`. */
504export function fmtTokens(n: number): string {
505  if (n < 1000) return String(Math.round(n))
506  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
507
508  return `${(n / 1_000_000).toFixed(1)}M`
509}
510
511/** `$0.04`, `$1.20`. */
512export function fmtUsd(n: number): string {
513  return `$${n.toFixed(n < 10 ? 2 : 1)}`
514}
515
516/** A rough token count for a text the mod adds to every request (about 4 characters a token). */
517export function estimateTokens(text: string): number {
518  return Math.ceil(text.length / 4)
519}
520
521/** A stored task from an older version, brought up to the current shape. */
522export function normalizeTask(t: Task): Task {
523  return {
524    ...t,
525    usage: t.usage ?? emptyUsage(),
526    agentUsage: t.agentUsage ?? emptyUsage(),
527    cost: typeof t.cost === 'number' ? t.cost : 0,
528    costMark: typeof t.costMark === 'number' ? t.costMark : null,
529  }
530}
531
532/** Closes a running stretch: the session cost since the mark joins the task's own. */
533export function settleCost(task: Task, cost: number | null): Task {
534  if (task.costMark === null) return task
535  const added = cost === null ? 0 : Math.max(0, cost - task.costMark)
536
537  return { ...task, cost: task.cost + added, costMark: null }
538}
539
540/** Opens a running stretch at the session cost now. */
541export function markCost(task: Task, cost: number | null): Task {
542  return { ...task, costMark: cost }
543}
544
hooks/i18n.ts 321 lines
1import type { AgentStatus, TaskStatus } from '../types'
2
3export type Lang = 'es' | 'en'
4
5/** Every sentence the mod shows or says to the model, in one language. */
6export type Messages = {
7  // for the model: the rules it reads, the tools it calls and what they answer
8  planRules: string
9  denyNoPlan: string
10  planToolDescription: string
11  taskToolDescription: string
12  goalDescription: string
13  tasksDescription: string
14  idDescription: string
15  noteDescription: string
16  emptyPlan: string
17  untitled: string
18  todoGoal: string
19  planRegistered: (count: number, list: string) => string
20  noSuchTask: (id: string, ids: string) => string
21  badStatus: (status: string) => string
22  denyDone: (id: string, kind: string, command: string) => string
23  taskUpdated: (id: string, status: string, verification: 'with' | 'without' | 'n/a', done: number, total: number) => string
24
25  // vocabulary
26  status: Record<TaskStatus, string>
27  agentStatus: Record<AgentStatus, string>
28
29  // notes the mod writes on a task
30  resumedNote: string
31  interruptedNote: string
32  manualDone: string
33  manualBlocked: string
34  manualFailed: string
35  previously: (status: string) => string
36
37  // /taskboard command
38  commandDescription: string
39  paneTitle: string
40  closed: string
41  cleared: string
42  skipped: string
43  paneNotShown: (reason: string) => string
44  noActivePlan: string
45  historyEmpty: string
46  historyHeading: string
47
48  // panel
49  boardTitle: string
50  historyTitle: string
51  plansCount: (count: number) => string
52  savedPlans: string
53  noFinished: string
54  expandAll: string
55  collapse: string
56  viewBoard: string
57  viewHistory: string
58  clear: string
59  reopen: string
60  historyHelp: string
61  dropEntry: string
62  historyDropped: (number: number, goal: string) => string
63  historyNoEntry: (number: number, count: number) => string
64  historyWiped: (count: number) => string
65  historyUsage: string
66  noticeTitle: string
67  noticeBlocking: string
68  noticeSuggesting: string
69  skipPlan: string
70  noPlanBody: string
71  noPlanHint: string
72  historyHint: (count: number) => string
73  taskDetail: (status: string, calls: number, checks: number) => string
74  start: string
75  markDone: string
76  block: string
77  fail: string
78  activeAgents: (count: number) => string
79  resumedBanner: string
80  looseAgents: (names: string) => string
81  helpTask: string
82  helpBoard: string
83  rootLabel: string
84  branchLabel: string
85  noBranch: string
86  // tokens and cost
87  tokensLine: (input: string, output: string, cache: string, agents: string, cost: string) => string
88  totalLine: (tokens: string, cost: string) => string
89  statsTitle: (goal: string) => string
90  statsNoPlan: string
91  statsRow: (id: string, title: string, tokens: string, detail: string, cost: string) => string
92  statsUntracked: (tokens: string) => string
93  statsTotal: (tokens: string, cost: string) => string
94  statsOverhead: (rules: string, tools: string, total: string) => string
95}
96
97const es: Messages = {
98  planRules: [
99    'Tablero de tareas (agent-taskboard).',
100    'Ante una solicitud compleja (varios archivos, varias etapas, una funcionalidad nueva o un refactor), ANTES de editar o escribir archivos llama a mcp__agent-taskboard__plan con { goal, tasks }: tasks es una lista corta de pasos concretos y verificables.',
101    'Mientras trabajas usa mcp__agent-taskboard__task con { id, status }: marca "in_progress" al empezar cada tarea y "done" al terminarla. Antes de "done" ejecuta la verificación que corresponda (tests, build, lint o tsc): el tablero rechaza "done" si la última verificación de esa tarea falló.',
102    'Si una tarea no se puede completar, márcala "failed" o "blocked" con una nota breve. Para solicitudes simples no crees plan.',
103    'El plan del modo plan (ExitPlanMode) y la lista TodoWrite se reflejan solos en el tablero: si usas uno de ellos no hace falta llamar a plan.',
104    'Al lanzar un subagente para una tarea del plan, empieza su description con el id, por ejemplo "t2: revisar tipos", para que el tablero lo asigne a esa tarea.',
105  ].join('\n'),
106  denyNoPlan:
107    'agent-taskboard: esta solicitud es compleja y aún no hay plan. Antes de editar o escribir archivos, registra el plan con mcp__agent-taskboard__plan ({ goal, tasks }) y luego continúa.',
108  planToolDescription:
109    'Registra el plan de trabajo en el tablero de tareas antes de implementar una solicitud compleja. Reemplaza el plan anterior.',
110  taskToolDescription:
111    'Actualiza el estado de una tarea del plan (id t1, t2, …). "done" se rechaza si la última verificación de esa tarea falló.',
112  goalDescription: 'Objetivo general en una frase',
113  tasksDescription: 'Pasos concretos y verificables, en orden',
114  idDescription: 'Id de la tarea, por ejemplo t1',
115  noteDescription: 'Nota breve opcional',
116  emptyPlan: 'Plan vacío: indica al menos una tarea en "tasks".',
117  untitled: 'Sin título',
118  todoGoal: 'Tareas de Claude Code',
119  planRegistered: (count, list) =>
120    `Plan registrado (${count} tareas): ${list}. Actualiza cada una con mcp__agent-taskboard__task.`,
121  noSuchTask: (id, ids) => `No existe la tarea "${id}". Tareas del plan: ${ids || 'ninguna'}.`,
122  badStatus: status => `Estado inválido "${status}". Usa in_progress, done, failed, blocked o pending.`,
123  denyDone: (id, kind, command) =>
124    `agent-taskboard: "${id}" no puede pasar a hecha: la última verificación (${kind}: ${command}) falló. Corrígela y vuelve a ejecutarla.`,
125  taskUpdated: (id, status, verification, done, total) =>
126    `Tarea ${id} → ${status}${verification === 'with' ? ' (con verificación)' : verification === 'without' ? ' (sin verificación)' : ''}. Progreso: ${done}/${total}.`,
127
128  status: { pending: 'pendiente', in_progress: 'en curso', done: 'hecha', failed: 'falló', blocked: 'bloqueada' },
129  agentStatus: { running: 'en curso', done: 'hecho', failed: 'falló', stopped: 'detenido' },
130
131  resumedNote: 'retomada: la sesión anterior terminó con ella en curso',
132  interruptedNote: 'turno interrumpido',
133  manualDone: 'marcada a mano',
134  manualBlocked: 'bloqueada a mano',
135  manualFailed: 'marcada a mano',
136  previously: status => `antes: ${status}`,
137
138  commandDescription: 'Tablero de tareas: abrir, cerrar, historial, limpiar u omitir el plan',
139  paneTitle: 'Tablero de tareas',
140  closed: 'Tablero cerrado.',
141  cleared: 'Tablero limpiado; el plan quedó en el historial.',
142  skipped: 'Plan omitido para esta solicitud: Edit/Write ya no se bloquean.',
143  paneNotShown: reason => `El panel aún no se muestra: ${reason}`,
144  noActivePlan: 'Sin plan activo.',
145  historyEmpty: 'Historial vacío: aún no hay planes terminados en este proyecto.',
146  historyHeading: 'Historial (más reciente primero):',
147
148  boardTitle: 'TABLERO DE TAREAS',
149  historyTitle: 'HISTORIAL',
150  plansCount: count => `${count} plan(es)`,
151  savedPlans: 'Planes guardados en este proyecto',
152  noFinished: 'Aún no hay planes terminados.',
153  expandAll: 'todo',
154  collapse: 'menos',
155  viewBoard: 'tablero',
156  viewHistory: 'historial',
157  clear: 'limpiar',
158  reopen: '↻ retomar este plan',
159  historyHelp: '1-9 abre · r retoma · x borra · h vuelve al tablero',
160  dropEntry: '✗ borrar del historial',
161  historyDropped: (number, goal) => `Entrada ${number} borrada del historial: ${goal}`,
162  historyNoEntry: (number, count) =>
163    count === 0 ? 'El historial está vacío.' : `No hay una entrada ${number}; el historial tiene ${count}.`,
164  historyWiped: count => `Historial vaciado (${count} plan(es) borrados).`,
165  historyUsage: 'Uso: /taskboard history [drop <n> | clear]',
166  noticeTitle: 'Solicitud compleja detectada',
167  noticeBlocking: 'Edit/Write quedan bloqueados hasta que Claude registre un plan.',
168  noticeSuggesting: 'Conviene que Claude registre un plan antes de editar.',
169  skipPlan: 'omitir plan',
170  noPlanBody: 'Sin plan activo',
171  noPlanHint: 'Sin plan. Claude creará uno al recibir una solicitud compleja.',
172  historyHint: count => `${count} plan(es) en el historial · h para verlos`,
173  taskDetail: (status, calls, checks) =>
174    `${status} · ${calls} llamadas${checks > 0 ? ` · ${checks} verificación(es)` : ''}`,
175  start: '▶ iniciar',
176  markDone: '✓ hecha',
177  block: '⏸ bloquear',
178  fail: '✗ falló',
179  activeAgents: count => `⚙ ${count} activo(s)`,
180  resumedBanner: 'retomado de una sesión anterior',
181  looseAgents: names => `sin tarea: ${names}`,
182  helpTask: 'i iniciar · d hecha · b bloquear · f falló · e todo',
183  helpBoard: '1-9 abre una tarea · e todo · h historial · l limpiar',
184  rootLabel: 'raíz',
185  branchLabel: 'rama',
186  noBranch: 'sin git',
187  tokensLine: (input, output, cache, agents, cost) =>
188    `↑${input} ↓${output} · caché ${cache}${agents ? ` · subagentes ${agents}` : ''}${cost ? ` · ${cost}` : ''}`,
189  totalLine: (tokens, cost) => `Σ ${tokens} tokens${cost ? ` · ${cost}` : ''}`,
190  statsTitle: goal => `Tokens y costo del plan: ${goal}`,
191  statsNoPlan: 'No hay plan activo; nada que medir.',
192  statsRow: (id, title, tokens, detail, cost) => `${id} ${title} · ${tokens}${detail ? ` (${detail})` : ''}${cost ? ` · ${cost}` : ''}`,
193  statsUntracked: tokens => `sin tarea: ${tokens}`,
194  statsTotal: (tokens, cost) => `Total: ${tokens} tokens${cost ? ` · ${cost}` : ''}`,
195  statsOverhead: (rules, tools, total) =>
196    `Costo propio de agent-taskboard (estimación, texto fijo en cada petición): ~${total} tokens (reglas ${rules} + herramientas ${tools}). Casi todo se sirve desde la caché del prompt.`,
197}
198
199const en: Messages = {
200  planRules: [
201    'Task board (agent-taskboard).',
202    'For a complex request (several files, several stages, a new feature or a refactor), BEFORE editing or writing files call mcp__agent-taskboard__plan with { goal, tasks }: tasks is a short list of concrete, verifiable steps.',
203    'While you work use mcp__agent-taskboard__task with { id, status }: mark "in_progress" when you start each task and "done" when you finish it. Before "done" run the check that fits (tests, build, lint or tsc): the board rejects "done" if that task\'s last check failed.',
204    'If a task cannot be completed, mark it "failed" or "blocked" with a short note. For simple requests do not create a plan.',
205    'The plan from plan mode (ExitPlanMode) and the TodoWrite list are mirrored on the board by themselves: if you use one of them there is no need to call plan.',
206    'When you start a subagent for a plan task, begin its description with the id, for example "t2: review types", so the board assigns it to that task.',
207  ].join('\n'),
208  denyNoPlan:
209    'agent-taskboard: this request is complex and there is no plan yet. Before editing or writing files, register the plan with mcp__agent-taskboard__plan ({ goal, tasks }) and then continue.',
210  planToolDescription:
211    'Registers the work plan on the task board before implementing a complex request. Replaces the previous plan.',
212  taskToolDescription:
213    'Updates the status of a plan task (id t1, t2, …). "done" is rejected if that task\'s last check failed.',
214  goalDescription: 'Overall goal in one sentence',
215  tasksDescription: 'Concrete, verifiable steps, in order',
216  idDescription: 'Task id, for example t1',
217  noteDescription: 'Optional short note',
218  emptyPlan: 'Empty plan: give at least one task in "tasks".',
219  untitled: 'Untitled',
220  todoGoal: 'Claude Code tasks',
221  planRegistered: (count, list) =>
222    `Plan registered (${count} tasks): ${list}. Update each one with mcp__agent-taskboard__task.`,
223  noSuchTask: (id, ids) => `There is no task "${id}". Plan tasks: ${ids || 'none'}.`,
224  badStatus: status => `Invalid status "${status}". Use in_progress, done, failed, blocked or pending.`,
225  denyDone: (id, kind, command) =>
226    `agent-taskboard: "${id}" cannot be marked done: its last check (${kind}: ${command}) failed. Fix it and run it again.`,
227  taskUpdated: (id, status, verification, done, total) =>
228    `Task ${id} → ${status}${verification === 'with' ? ' (verified)' : verification === 'without' ? ' (unverified)' : ''}. Progress: ${done}/${total}.`,
229
230  status: { pending: 'pending', in_progress: 'in progress', done: 'done', failed: 'failed', blocked: 'blocked' },
231  agentStatus: { running: 'running', done: 'done', failed: 'failed', stopped: 'stopped' },
232
233  resumedNote: 'resumed: the previous session ended with it in progress',
234  interruptedNote: 'turn interrupted',
235  manualDone: 'set by hand',
236  manualBlocked: 'blocked by hand',
237  manualFailed: 'set by hand',
238  previously: status => `before: ${status}`,
239
240  commandDescription: 'Task board: open, close, history, clear or skip the plan',
241  paneTitle: 'Task board',
242  closed: 'Task board closed.',
243  cleared: 'Task board cleared; the plan is in the history.',
244  skipped: 'Plan skipped for this request: Edit/Write are no longer blocked.',
245  paneNotShown: reason => `The pane is not shown yet: ${reason}`,
246  noActivePlan: 'No active plan.',
247  historyEmpty: 'History is empty: no finished plans in this project yet.',
248  historyHeading: 'History (most recent first):',
249
250  boardTitle: 'TASK BOARD',
251  historyTitle: 'HISTORY',
252  plansCount: count => `${count} plan(s)`,
253  savedPlans: 'Plans saved in this project',
254  noFinished: 'No finished plans yet.',
255  expandAll: 'all',
256  collapse: 'less',
257  viewBoard: 'board',
258  viewHistory: 'history',
259  clear: 'clear',
260  reopen: '↻ resume this plan',
261  historyHelp: '1-9 open · r resume · x delete · h back to the board',
262  dropEntry: '✗ delete from history',
263  historyDropped: (number, goal) => `Entry ${number} deleted from the history: ${goal}`,
264  historyNoEntry: (number, count) =>
265    count === 0 ? 'The history is empty.' : `There is no entry ${number}; the history has ${count}.`,
266  historyWiped: count => `History emptied (${count} plan(s) deleted).`,
267  historyUsage: 'Usage: /taskboard history [drop <n> | clear]',
268  noticeTitle: 'Complex request detected',
269  noticeBlocking: 'Edit/Write are blocked until Claude registers a plan.',
270  noticeSuggesting: 'Claude should register a plan before editing.',
271  skipPlan: 'skip plan',
272  noPlanBody: 'No active plan',
273  noPlanHint: 'No plan. Claude will create one for a complex request.',
274  historyHint: count => `${count} plan(s) in history · h to see them`,
275  taskDetail: (status, calls, checks) => `${status} · ${calls} calls${checks > 0 ? ` · ${checks} check(s)` : ''}`,
276  start: '▶ start',
277  markDone: '✓ done',
278  block: '⏸ block',
279  fail: '✗ failed',
280  activeAgents: count => `⚙ ${count} active`,
281  resumedBanner: 'resumed from a previous session',
282  looseAgents: names => `no task: ${names}`,
283  helpTask: 'i start · d done · b block · f failed · e all',
284  helpBoard: '1-9 open a task · e all · h history · l clear',
285  rootLabel: 'root',
286  branchLabel: 'branch',
287  noBranch: 'no git',
288  tokensLine: (input, output, cache, agents, cost) =>
289    `↑${input} ↓${output} · cache ${cache}${agents ? ` · subagents ${agents}` : ''}${cost ? ` · ${cost}` : ''}`,
290  totalLine: (tokens, cost) => `Σ ${tokens} tokens${cost ? ` · ${cost}` : ''}`,
291  statsTitle: goal => `Tokens and cost of the plan: ${goal}`,
292  statsNoPlan: 'No active plan; nothing to measure.',
293  statsRow: (id, title, tokens, detail, cost) => `${id} ${title} · ${tokens}${detail ? ` (${detail})` : ''}${cost ? ` · ${cost}` : ''}`,
294  statsUntracked: tokens => `no task: ${tokens}`,
295  statsTotal: (tokens, cost) => `Total: ${tokens} tokens${cost ? ` · ${cost}` : ''}`,
296  statsOverhead: (rules, tools, total) =>
297    `agent-taskboard's own cost (estimate, fixed text on every request): ~${total} tokens (rules ${rules} + tools ${tools}). Most of it is served from the prompt cache.`,
298}
299
300const TABLE: Record<Lang, Messages> = { es, en }
301
302export function messages(lang: Lang): Messages {
303  return TABLE[lang]
304}
305
306/** The system's locale as the runtime reports it, or an empty string when it does not. */
307export function systemLocale(): string {
308  try {
309    return Intl.DateTimeFormat().resolvedOptions().locale
310  } catch {
311    return ''
312  }
313}
314
315/** `es`, `en`, or `auto` (the system locale: Spanish for es-*, English otherwise). */
316export function resolveLang(option: unknown, locale: string): Lang {
317  if (option === 'es' || option === 'en') return option
318
319  return locale.toLowerCase().startsWith('es') ? 'es' : 'en'
320}
321
types/index.d.ts 113 lines
1export type TaskStatus = 'pending' | 'in_progress' | 'done' | 'failed' | 'blocked'
2
3export type AgentStatus = 'running' | 'done' | 'failed' | 'stopped'
4
5/** A subagent that ran under a task (or loose, when no task was active). */
6export type AgentRun = {
7  id: string
8  name: string
9  type: string
10  status: AgentStatus
11  startedAt: number
12}
13
14/** A real verification: a test, build, lint or typecheck command that ran. */
15export type Check = {
16  kind: 'test' | 'typecheck' | 'lint' | 'build' | 'validate' | 'custom'
17  command: string
18  ok: boolean
19  at: number
20}
21
22/** Tokens as the API counts them, summed over the model requests of a task. */
23export type Usage = {
24  input: number
25  output: number
26  cacheRead: number
27  cacheWrite: number
28  /** Model requests counted. */
29  steps: number
30}
31
32export type Task = {
33  id: string
34  title: string
35  status: TaskStatus
36  startedAt: number | null
37  endedAt: number | null
38  calls: number
39  agents: AgentRun[]
40  checks: Check[]
41  note: string
42  /** The main agent's requests while this task was the active one. */
43  usage: Usage
44  /** The requests of the subagents that belong to this task. */
45  agentUsage: Usage
46  /** Session cost (USD) accumulated while the task ran. */
47  cost: number
48  /** The session cost when the running stretch began; null when not running. */
49  costMark: number | null
50}
51
52export type Board = {
53  goal: string
54  createdAt: number | null
55  tasks: Task[]
56  activeId: string | null
57  looseAgents: AgentRun[]
58  /** Requests made while no task was active. */
59  untracked: Usage
60  /** A complex request came in and no plan exists yet. */
61  needsPlan: boolean
62  /** The plan was stored in history already (finished or replaced). */
63  isArchived: boolean
64  /** The board came back from a previous session. */
65  isResumed: boolean
66  /** Where the plan came from: the mod's own tool, Claude Code's plan mode or its todo list. */
67  source?: 'taskboard' | 'plan-mode' | 'todo'
68}
69
70export type HistoryEntry = {
71  goal: string
72  startedAt: number
73  endedAt: number
74  durationMs: number
75  total: number
76  done: number
77  failed: number
78  reason: 'completed' | 'replaced' | 'cleared'
79  /** Billable tokens of the plan (input + cache writes + output); absent in older entries. */
80  tokens?: number
81  /** Session cost of the plan in USD; absent in older entries. */
82  cost?: number
83  tasks: { title: string; status: TaskStatus }[]
84}
85
86export type View = {
87  mode: 'board' | 'history'
88  /** The task whose card shows its detail and action buttons. */
89  expanded: string | null
90  /** Show every task's detail at once. */
91  expandAll: boolean
92  /** The history entry opened for detail, by its `startedAt`. */
93  historyOpen: number | null
94}
95
96/** Where the session runs: the project folder and the git branch checked out there. */
97export type Project = {
98  root: string
99  /** `null` outside a git repository; `(abc1234)` for a detached HEAD. */
100  branch: string | null
101}
102
103declare module 'claude-code' {
104  interface PluginState {
105    'agent-taskboard': {
106      board: Board
107      view: View
108      history: HistoryEntry[]
109      project: Project
110    }
111  }
112}
113