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…

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.

Español · Install · What it does · Configure · Privacy · Develop
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.
Edit/Write are blocked until Claude registers one (switchable, see planMode).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.TodoWrite list are mirrored on the board (statuses included); plan/task stay available as the explicit way./taskboard stats prints the detail and the mod's own overhead.git checkout run in the shell.| Command | Does |
|---|---|
/taskboard | Open the panel |
/taskboard history | Show the history, numbered from the most recent (also in the panel) |
/taskboard history drop <n> | Delete entry n from the history |
/taskboard history clear | Empty the history |
/taskboard stats | Tokens and cost per task, plus the mod's own per-request overhead (an estimate) |
/taskboard clear | Archive the plan and empty the board |
/taskboard skip | Do not require a plan for the current request |
/taskboard close | Close the panel |
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).
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.
Run /plugin configure agent-taskboard@agent-taskboard or set them in settings under pluginConfigs.
| Option | Default | Meaning |
|---|---|---|
language | auto | auto follows the system locale (Spanish for es-*, English otherwise), or force es / en |
mode | full | full 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) |
planMode | block | (only in full) block blocks Edit/Write until there is a plan, warn only shows a notice, off does nothing |
minPromptChars | 240 | Length above which a request that names an action counts as complex |
extraChecks | empty | Extra commands that count as a check, comma separated (artisan test, composer test) |
openOnStart | true | Open 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.
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.
plan/task, plan mode (ExitPlanMode) and TodoWrite. Newer task tools (TaskCreate/TaskUpdate) are not mirrored yet.planMode: warn or /taskboard skip.extraChecks.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.
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
TodoWrite de Claude Code aparecen solos en el tablero, con sus estados.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./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.language: auto sigue el idioma del sistema; también es o en.block bloquea Edit/Write sin plan, warn solo avisa, off no vigila..git/HEAD para la rama y guarda todo en el almacén local del plugin.hooks/register.tsx 932 lines1import { 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}
932hooks/logic.ts 544 lines1import 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}
544hooks/i18n.ts 321 lines1import 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}
321types/index.d.ts 113 lines1export 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