Plan trees written by Claude through a plan tool, shown in a pane and status line with live activity.

<img src=".claude/skills/todo-list/.claude-plugin/icon.png" alt="Todo List icon" width="120" height="120"> <h1>Todo List for Claude Code</h1>
<a href="#installation">Install</a> · <a href="#usage">Usage</a> · <a href="#configuration">Configuration</a> · <a href="#how-it-works-hooks">How it works</a>
Todo List is a Claude Code plugin that shows Claude's task plan as a tree in a pane and the status line, with live activity. Claude writes the plan through a plugin tool, mcp__todo-list__plan, and state-changing tools wait until a plan exists.

mcp__todo-list__plan. Steps that do not conflict can be grouped as parallel./todo command. Reopen the pane, clear the plan, switch enforcement, and set the accent color (Claude orange by default, saved across sessions).Install from GitHub:
/plugin marketplace add 0xnicholasy/claude-mod-todo-list
/plugin install todo-list@claude-mod-todo-list
The same works from the shell as claude plugin marketplace add ... and claude plugin install .... Set options at install with --config, for example --config accentColor=#c084fc or --config enforce=false. Add -s project to install for one project only (scopes: user, project, local; default user).
To run from a checkout instead:
git clone https://github.com/0xnicholasy/claude-mod-todo-list.git
cd claude-mod-todo-list
claude --plugin-dir .claude/skills/todo-list
Run it from the repo root, or pass the absolute path to .claude/skills/todo-list. An installed plugin takes precedence over a local copy with the same name, so uninstall it while developing. If /todo is not offered, run claude plugin list to check that the plugin is loaded and enabled.
Update (restart required):
claude plugin marketplace update claude-mod-todo-list
claude plugin update todo-list@claude-mod-todo-list
Uninstall with claude plugin uninstall todo-list@claude-mod-todo-list.
Claude creates the plan on its own: the plugin adds one instruction to the system prompt that asks for a plan before any tool other than read-only ones. You control the plugin with /todo:
| Command | Effect | ||
|---|---|---|---|
/todo | Open the pane (reopens it at the height the plan needs). | ||
/todo clear | Empty the plan. | ||
/todo off | Turn plan enforcement off for this session. | ||
/todo on | Turn plan enforcement back on. | ||
| `/todo color <name\ | #hex\ | reset>` | Set the accent color, saved for every session. reset returns to the accentColor option. |
The pane opens at session start on an interactive terminal 110 columns or wider. On a narrower terminal the host holds an unasked pane undrawn, so it opens on your first prompt instead (once per session, so a pane you close stays closed). The status line shows a short form, for example Plan 3/7 · Escaping quotes and commas · Running Bash.
Add CSV export
━━━━━━━━━━━───────────────────────────── 2/7 · 29%
◉ Running Bash · 1 subagent
├─ ✓ 1 Read the existing exporter 2/2
├─ ◉ 2 Write the CSV writer ∥ parallel 0/3
│ ├─ ◉ 2.1 Header row ◂
│ ├─ ◉ 2.2 Escape quotes and commas
│ └─ ○ 2.3 Stream large files
├─ ■ 3 Wire the CLI flag (blocked: flag name?)
└─ ○ 4 Tests
The first line is the plan title and the second is a progress bar (leaves done out of total leaves). Then comes the live activity, then the tree with each node's id. Parent rows show their done/total count at the right edge. The current step is marked with ◂. A parallel group shows ∥ parallel after its title; every running step in it has the accent color and a bold title, but only the first carries ◂. When the tree is too tall, the paths to all running steps stay visible and the rest is summarized as +N more.
Below 50 columns the pane compacts: a shorter bar, done/total with no percentage, the count after a parent's title, a bare ∥ for a parallel group, notes cut to 20 characters, and no subagent count when it does not fit.
Claude Code chooses where the pane goes; a plugin cannot force it. The pane docks beside the transcript only in the fullscreen layout, from 110 columns. The main screen always shows it inline above the prompt. The plugin's type declarations describe the main screen as "CLAUDE_CODE_NO_FLICKER=0, tmux by default", so setting CLAUDE_CODE_NO_FLICKER=1 should give the fullscreen layout (inferred from that note; the declarations name only the =0 value). The pane asks for 56 columns when docked and for as many rows as the tree needs (6 to 20) when inline. A size you dragged wins.
| Glyph | Status | Meaning |
|---|---|---|
✓ | Completed | The step is done. |
◉ | In progress | The step is running. Outside a parallel group, one leaf runs at a time. |
○ | Pending | Not started. |
■ | Blocked | The step cannot proceed; the note says why. |
– | Skipped | The step was dropped; the note says why. |
A parent takes its status from its children. Any child in progress makes the parent in progress. Otherwise any blocked child makes it blocked. If every child is completed or skipped, the parent is completed. If some are done and the rest are pending, it is in progress. Otherwise it is pending. Only leaves are set through the tool.
| Label | When |
|---|---|
| Working | A prompt was submitted and Claude is thinking. |
Running <tool> | A tool call from the main session is running. In a parallel batch it shows the most recently started call until the last one ends. |
Waiting for permission: <tool> | A tool call is waiting on a permission dialog. |
| Waiting for your answer | Claude asked a question with AskUserQuestion. |
| Compacting | The conversation is being compacted. |
| N subagents | Appended to any label while N subagents run. |
| Interrupted | The turn was aborted, for example with Esc. |
| Error | The turn ended in an error or a refusal. |
| (none) | The turn ended with an answer. The activity line is left out. |
mcp__todo-list__plan takes an op field.
| Op | Input | Effect |
|---|---|---|
set | title, nodes | Replaces the plan. Nodes nest up to 3 levels, 60 nodes in total. |
add | parent?, nodes | Appends children under a node, or at the top level when parent is absent. |
update | updates of { id, status?, title?, note? } | Batch patch. Status applies to leaves only. |
remove | id | Drops a node and its subtree. Ids are never reused. |
show | none | Returns the current tree with ids. |
A node may set parallel: true (on set or add). Its children may then be in progress at the same time; anywhere else a second in-progress leaf is rejected with an error naming the shared parent. Every answer is the plain-text tree. A failure is a result that starts with Error:. The current plan is sent to Claude at the start of each turn, so ids stay in sync after a compaction. /clear resets the plan.
When the session offers TaskCreate, TaskUpdate or TodoWrite, successful main-session calls are mirrored into the tree as top-level nodes and count as having a plan. The plan tool stays the preferred path. In Claude Code 2.1.289, TodoWrite does not exist and TaskCreate and TaskUpdate are not offered by default, so the plan tool is the only default path.
A mirrored call the plugin cannot apply is dropped: the plan stays as it was, the detail goes to the debug log, and one toast per session says "Plan not updated" with a fixed reason (a limit was hit, or the tool response was not recognised). The toast never repeats the task text. A call that fails on one attempt and fits on a retry shows no toast, and a /clear re-arms the toast.
Until the current task has a plan, these main-session tools are denied: Edit, Write, NotebookEdit, Bash, Agent, Workflow, CronCreate, CronDelete, EnterWorktree, ExitWorktree and RemoteTrigger. The denial tells Claude the exact set call to make.
These are never blocked: the plan tool, read-only tools (Read, Grep, Glob, LSP, WebFetch, WebSearch, ToolSearch, TaskGet, ListMcpResourcesTool, ReadMcpResourceTool, Skill), AskUserQuestion, and any call made inside a subagent. A prompt that only reads files or answers a question is never blocked.
A task starts at each typed prompt. A background-agent completion (<task-notification>) continues the current task. A task counts as planned when the plan has unfinished nodes, or after any successful plan tool call or mirrored task call.
Enforcement fails open. It allows every call when:
enforce option is false, or /todo off was run for the session;Options are set at install (--config), in settings, or per run:
| Option | Type | Default | Effect |
|---|---|---|---|
enforce | boolean | true | Block state-changing tools until a plan exists. |
accentColor | string | claude | Theme key or color for the current step, running steps and progress bar, for example claude, magenta, #c084fc. An invalid value falls back to Claude orange. |
claude --plugin-dir .claude/skills/todo-list \
--settings '{"pluginConfigs":{"todo-list":{"options":{"enforce":false,"accentColor":"#c084fc"}}}}'
A color saved with /todo color takes precedence over accentColor. The claude theme key follows the light and dark themes.
The plugin registers 21 hooks in .claude/skills/todo-list/hooks/register.tsx. Two of them decide anything: the catch-all tool.call hook (the gate) and the tool.call hook for the plan tool (it answers calls to that one tool). Hooks pass the host's event or result through unchanged except where the last column says otherwise. A hook that throws is caught and logged to the debug log, and the call proceeds as if the plugin were not there.
| Hook | What it does | What it decides, and when | What it changes |
|---|---|---|---|
session.start | Loads the saved accent color, registers the plan tool and the /todo command, and on an interactive terminal arms the first-prompt pane open and opens the pane. | Nothing. | Registers the plan tool and /todo; opens the pane; invalidates the cached tool.describe answer. |
command.run (todo) | Runs /todo and its subcommands. | Which subcommand to run, from the typed arguments. | Opens or resizes the pane, clears the plan, flips the session enforcement switch, writes or deletes the saved accent color. |
tool.describe | For the plan tool only, marks it as not deferred so the model sees it on the first turn. | Nothing; other tools pass through. | Sets isDeferred: false on the plan tool's description. |
classic.PermissionRequest | Records "waiting for permission" for the tool. | Nothing. Observe-only. | Passes the event on unchanged. Updates activity. |
classic.PermissionDenied | Ends the denied call's activity and clears the permission label. | Nothing. Observe-only. | Passes the event on unchanged. Updates activity. |
classic.SubagentStart | Records that a subagent started. | Nothing. Observe-only. | Passes the event on unchanged. Updates the subagent count. |
classic.SubagentStop | Records that a subagent stopped. | Nothing. Observe-only. | Passes the event on unchanged. Updates the subagent count. |
classic.StopFailure | Records that the turn ended in an error. | Nothing. Observe-only. | Passes the event on unchanged. Updates activity. |
classic.Notification | Writes the notification type to the debug log. | Nothing. Observe-only. | Passes the event on unchanged. |
classic.PreCompact | Marks "Compacting" for the main session when a compaction starts. A subagent's compaction is ignored. | Nothing. Observe-only. | Passes the event on unchanged. Updates activity. |
classic.PostCompact | Clears "Compacting" when the compaction ends. A subagent's compaction is ignored. | Nothing. Observe-only. | Passes the event on unchanged. Updates activity. |
turn.complete | Records how the turn ended (answer, interrupted, error). | Nothing. Observe-only. | Passes the event on unchanged. Updates activity. |
session.end | On /clear, resets the activity state. | Nothing. Observe-only. | Passes the event on unchanged. |
tool.call (catch-all) | For a main-loop call other than the plan tool, runs the gate, then records "Running <tool>" (or "Waiting for your answer" for AskUserQuestion). Subagent calls and plan tool calls pass through. | Denies a state-changing main-loop tool (see Enforcement for the list) when the task has no plan and enforcement is on. Allows everything else. Fails open: guard failure, plan tool not registered or not offered, enforcement off, or 3 denies in the turn. | A denied call returns a fixed deny message instead of running. For all other calls the event goes on unchanged. Updates activity. |
tool.call (mcp__todo-list__plan) | Answers every call to the plan tool: applies the op to the plan and returns the plan text, or an Error: text for a bad op. | Why the mod needs this hook: the API serves a plugin's own registered tool only from a tool.call hook, and no other hook or the core serves it. What it decides: nothing is refused; it always answers calls to this one tool. When: on every call to mcp__todo-list__plan. A subagent's call gets an error text and changes nothing. | Returns the plan text as the tool result and updates the plan and the status line. Never calls next. |
classic.PostToolUse | After a call finishes, ends its "Running" state. After a main-loop TaskCreate, TaskUpdate or TodoWrite, mirrors the call into the plan. | Nothing. A response in a shape the plugin does not know is logged and skipped. | Passes the event on unchanged. Adds, updates or removes plan nodes; updates activity. |
classic.PostToolUseFailure | After a failed call, ends its "Running" state. | Nothing. Observe-only. | Passes the event on unchanged. Updates activity. |
prompt.compose | Checks whether the plan tool is in the request's tool list and records it. | Whether to add the instruction: only when the plan tool is offered. | Adds one system prompt section (todo-list:plan) that asks Claude to plan first. Adds nothing when the tool is not offered. |
prompt.submit | On the first prompt of a session, opens the pane if it is not placed. | Nothing. | Passes the event on unchanged. May open the pane. |
turn.start | Reloads the saved accent color after a /clear, resets the per-turn deny count, records "Working", and sends the current plan to the model. | Whether to send the plan: only when the plan has nodes. | Appends the current plan as one user-role row the model reads (the person does not see it as typed). Updates task state and activity. Passes the event on unchanged. |
ui.render (Pane, todo) | Draws the plan tree. If the terminal size, placement or plan height changed, re-opens the pane so the host resizes it. | Nothing. | Returns the pane contents. May close and reopen the pane. |
Observe-only hooks: classic.PermissionRequest, classic.PermissionDenied, classic.SubagentStart, classic.SubagentStop, classic.StopFailure, classic.Notification, classic.PreCompact, classic.PostCompact, classic.PostToolUse, classic.PostToolUseFailure, turn.complete, session.end. They update the plugin's own activity state and pass the host's event through unchanged. classic.PostToolUse also copies a finished TaskCreate, TaskUpdate or TodoWrite into the plan; it never changes the tool's result.
accentColor) when you run /todo color. /todo color reset deletes it./clear resets them.$.tool.register and appears as an MCP tool named mcp__todo-list__plan. It is answered by the plugin's own hook, not by a separate server.The plugin sends the current plan to the model at the start of each turn as a user-role row, and the plan tool's name and the planning instruction as part of the system prompt. That is how Claude sees the plan.
npm install
npm run check
npm run check runs validate (claude plugin validate), typecheck (tsc -p tsconfig.json) and test (claude plugin test). Validate and test need the claude CLI and run locally only; CI runs typecheck.
.claude/skills/todo-list/ (manifest .claude-plugin/plugin.json, hooks in hooks/, state contract in types/index.d.ts). The marketplace manifest is .claude-plugin/marketplace.json. Bump the version in plugin.json for releases.$.state atoms and not in module variables.rtk proxy npm run check.ls.permission_prompt are not mapped to an activity.<a href="https://star-history.com/#0xnicholasy/claude-mod-todo-list&Date"> <img alt="Star history chart" src="https://api.star-history.com/svg?repos=0xnicholasy/claude-mod-todo-list&type=Date" /> </a>
MIT. See LICENSE.
hooks/register.tsx 723 lines1import { atom, read, update } from 'claude-code'
2import type { CommandPresentation, EngineInterface, Register } from 'claude-code'
3import type { Plan, PlanToolState, TaskState } from '../types'
4import { emptyActivity, reduceActivity } from './activity'
5import type { ActivityEvent } from './activity'
6import { needsRefit } from './fit'
7import type { PaneFit } from './fit'
8import type { GateDecision, GateTransition } from './gate'
9import { INSTRUCTION_ID, INSTRUCTION_TEXT, onNewPrompt, onPlanTouched, planContext, transition } from './gate'
10import { DROPPED_NOT_MIRRORED, DROPPED_UNRECOGNISED, ingestTaskCreate, ingestTaskUpdate, ingestTodoWrite } from './ingest'
11import type { IngestResult } from './ingest'
12import { emptyPlan } from './plan'
13import type { PlanApplied } from './plan-tool'
14import {
15 applyPlanOp,
16 formatForModel,
17 parsePlanInput,
18 PLAN_INPUT_SCHEMA,
19 PLAN_TOOL_DESCRIPTION,
20 PLAN_TOOL_SHORT_NAME,
21 touchesPlan,
22} from './plan-tool'
23import { taskCreateFrom, taskUpdateFailed, taskUpdateFrom, todosFrom } from './post-tool'
24import { clean } from './sanitize'
25import { ACCENT_STORE_KEY, resolveAccent, validAccent } from './accent'
26import { buildTree, DEFAULT_WIDTH, paneRows, statusLine } from './tree'
27
28// D4: the registered name is `mcp__<plugin>__<name>`, confirmed by the T01 spike (Q1).
29const PLAN_TOOL_FULL_NAME = `mcp__todo-list__${PLAN_TOOL_SHORT_NAME}`
30const PANE = 'todo'
31const PANE_ROWS = 20
32const PANE_COLUMNS = 56
33const DOCK_TIP = ' Tip: the fullscreen layout docks this pane beside the transcript.'
34const DOCK_MIN_COLUMNS = 110
35// The host drops $.ui.log text over 4096 characters (T01 extra findings).
36const LOG_LIMIT = 4000
37const ASK_TOOL = 'AskUserQuestion'
38const USAGE = 'Usage: /todo (opens the pane) | /todo clear | /todo off | /todo on | /todo color <name|#hex|reset>'
39const unknownColorText = (value: string): string =>
40 `Unknown color "${value}". Use a name (red, green, yellow, blue, magenta, cyan, white, gray, claude, or a ...Bright variant) or #rrggbb. For orange, try claude.`
41const NOT_OWNER_TEXT =
42 'Error: the plan is owned by the main session. Subagents cannot change it; report your progress in your result instead.'
43
44const EMPTY_TASK: TaskState = { open: false, planned: false, denies: 0 }
45const plan = atom({ plugin: 'todo-list', key: 'plan' } as const, emptyPlan())
46const task = atom({ plugin: 'todo-list', key: 'task' } as const, EMPTY_TASK)
47const activity = atom({ plugin: 'todo-list', key: 'activity' } as const, emptyActivity(0))
48// D8: the session half of the enforcement switch; `/todo on|off` flips it. The other half is the
49// plugin option `enforce`.
50const enforceSession = atom({ plugin: 'todo-list', key: 'enforceSession' } as const, true as boolean)
51// The accent set by `/todo color`, loaded from the plugin store at session start so it applies to
52// every session; null defers to the plugin option `accentColor`.
53const accentOverride = atom({ plugin: 'todo-list', key: 'accentOverride' } as const, null as string | null)
54// Whether the dropped-mirror toast has shown this session. Claimed with a compare-and-set update so
55// only one of several concurrent drops toasts. The host resets atoms on /clear (T01 Q7), and the
56// session.end clear path resets it too.
57const dropShown = atom({ plugin: 'todo-list', key: 'dropShown' } as const, false as boolean)
58const planTool = atom({ plugin: 'todo-list', key: 'planTool' } as const, {
59 name: null,
60 offered: false,
61} as PlanToolState)
62
63// What the pane was last re-opened for; null until the first re-open. Written only from refitPane
64// (state writes are refused while a render draws).
65const paneFit = atom({ plugin: 'todo-list', key: 'paneFit' } as const, null as PaneFit | null)
66
67// Whether the first prompt may still open the pane: 'armed' at an eligible session.start, 'done'
68// once the first prompt has had its one chance. Never re-armed, so a pane the person closes stays
69// closed. A /clear resets it to 'idle' and no session.start follows, so a cleared session does not re-open.
70const firstPromptOpen = atom({ plugin: 'todo-list', key: 'firstPromptOpen' } as const, 'idle' as 'idle' | 'armed' | 'done')
71
72// Runs a hook body and returns its fallback on any throw. Top-level function declaration because
73// `$` is passed to it (plugin validate rule).
74async function guard<T>(
75 $: EngineInterface,
76 name: string,
77 fallback: T | (() => T),
78 body: () => Promise<T> | T,
79): Promise<T> {
80 try {
81 return await body()
82 } catch (error) {
83 try {
84 $.ui.log(`todo-list: ${name} threw ${String(error)}`, { to: 'debug' })
85 } catch {
86 // Logging must never throw out of a hook.
87 }
88 return typeof fallback === 'function' ? (fallback as () => T)() : fallback
89 }
90}
91
92// Matches the plan tool by its literal name as well as the stored one. The atoms reset on
93// /clear (T01 Q7) while the registration persists (Q1), so the stored name alone would stop
94// matching after a /clear. The literal is safe: D4 fixes the name and Q1 confirmed it.
95function isPlanTool(tool: string, stored: string | null): boolean {
96 return tool === PLAN_TOOL_FULL_NAME || (stored !== null && tool === stored)
97}
98
99async function refreshStatus($: EngineInterface): Promise<void> {
100 const [p, a] = await Promise.all([read($, plan), read($, activity)])
101 $.ui.status(statusLine(p, a))
102}
103
104// Feeds one event to the activity reducer and redraws the status line. Top-level function
105// declaration because `$` is passed to it (plugin validate rule).
106async function applyActivity($: EngineInterface, event: ActivityEvent): Promise<void> {
107 const now = await $.clock.now()
108 await update($, activity, cur => reduceActivity(cur, event, now))
109 await refreshStatus($)
110}
111
112// Ends the permission label only when it belongs to `tool`. The check runs inside the update so a
113// concurrent permissionAsk for another call cannot slip between a read and the write. A call denied by
114// a rule (no dialog) must not clear the live dialog of a different call.
115async function endPermissionFor($: EngineInterface, tool: string): Promise<void> {
116 const now = await $.clock.now()
117 const name = clean(tool) || 'tool'
118 await update($, activity, cur =>
119 cur.phase === 'permission' && cur.tool !== name ? cur : reduceActivity(cur, { type: 'permissionEnd' }, now),
120 )
121 await refreshStatus($)
122}
123
124// A debug line must never stop what follows it (a toast, a gate decision), so a throwing log is swallowed.
125function debugLog($: EngineInterface, text: string): void {
126 try {
127 $.ui.log(text.slice(0, LOG_LIMIT), { to: 'debug' })
128 } catch {
129 // Logging must never throw out of a hook.
130 }
131}
132
133// Inline the pane is as tall as the tree wants (a short plan wastes no rows); docked it is
134// PANE_COLUMNS wide. Both are requests: the host decides the placement and may keep a size
135// the person dragged.
136//
137// `rows` only counts when the pane opens: one already open keeps the size it opened at (the
138// session-start open sees an empty plan, so 6 rows). `resize` closes it first so the person's
139// `/todo` re-opens at the height the plan wants now.
140async function openPane($: EngineInterface, resize = false): Promise<void> {
141 const [p, a] = await Promise.all([read($, plan), read($, activity)])
142 const rows = paneRows(p, a)
143 if (resize && (await $.ui.panes()).some(pane => pane.id === PANE)) await $.ui.close({ id: PANE })
144 await $.ui.open({ id: PANE, title: 'Plan', rows, columns: PANE_COLUMNS })
145}
146
147// Re-opens the listed pane so the host sizes it for the terminal and plan as they are now.
148// The fit is recorded first: the re-open redraws the pane, and that render must find the same
149// fit and stop. A pane the person closed is not listed and is left closed.
150async function refitPane($: EngineInterface, fit: PaneFit): Promise<void> {
151 if (!(await $.ui.panes()).some(pane => pane.id === PANE)) return
152 await update($, paneFit, () => fit)
153 await $.ui.close({ id: PANE })
154 const opened = await $.ui.open({ id: PANE, title: 'Plan', rows: fit.wantRows, columns: PANE_COLUMNS })
155 // The fit stays recorded, so a render that finds it unchanged does not try again.
156 if (!opened.isPlaced) $.ui.log(`todo-list: refit open not placed: ${opened.reason}`.slice(0, LOG_LIMIT), { to: 'debug' })
157}
158
159async function runTodoCommand(
160 $: EngineInterface,
161 args: string,
162 presentation: CommandPresentation,
163): Promise<{ text: string }> {
164 const raw = args.trim()
165 const word = raw.toLowerCase()
166 if (word === 'color' || word.startsWith('color ')) {
167 const value = raw.slice('color'.length).trim()
168 if (value.toLowerCase() === 'reset') {
169 await update($, accentOverride, () => null)
170 try {
171 await $.store.delete(ACCENT_STORE_KEY)
172 } catch (error) {
173 $.ui.log(`todo-list: accent store delete failed ${String(error)}`, { to: 'debug' })
174
175 return { text: 'Accent color reset for this session only; the saved color could not be cleared.' }
176 }
177
178 return { text: 'Accent color reset. The saved color is cleared for future sessions.' }
179 }
180 if (value === '') return { text: USAGE }
181 const accent = validAccent(value)
182 if (accent === null) return { text: unknownColorText(value) }
183 await update($, accentOverride, () => accent)
184 try {
185 await $.store.set(ACCENT_STORE_KEY, accent)
186 } catch (error) {
187 $.ui.log(`todo-list: accent store write failed ${String(error)}`, { to: 'debug' })
188
189 return { text: `Accent color set to ${accent} for this session only; it could not be saved.` }
190 }
191
192 return { text: `Accent color set to ${accent}. Saved for future sessions.` }
193 }
194 if (word === '') {
195 await openPane($, true)
196 const tip = !presentation.isFullscreen && presentation.columns >= DOCK_MIN_COLUMNS ? DOCK_TIP : ''
197
198 return { text: `Plan pane opened.${tip}` }
199 }
200 if (word === 'off' || word === 'on') {
201 await update($, enforceSession, () => word === 'on')
202
203 return { text: word === 'on' ? 'Plan enforcement is on.' : 'Plan enforcement is off for this session.' }
204 }
205 if (word !== 'clear') return { text: USAGE }
206 await update($, plan, () => emptyPlan())
207 await update($, task, () => EMPTY_TASK)
208 await refreshStatus($)
209
210 return { text: 'Plan cleared.' }
211}
212
213// A toast failure must never change the deny/allow outcome of the gate. Answers whether it was shown.
214function safeToast($: EngineInterface, text: string): boolean {
215 try {
216 $.ui.toast(text)
217
218 return true
219 } catch (error) {
220 debugLog($, `todo-list: toast threw ${String(error)}`)
221
222 return false
223 }
224}
225
226// The gate (D8). Runs for main-loop calls other than the plan tool, inside guard(): any throw,
227// including one from the state, allows the call; a toast failure is caught and does not. `denies` counts blocked calls in this turn
228// (turn.start resets it); after MAX_DENIES the gate pauses, and the pause toast shows once
229// because the pausing call moves `denies` past MAX_DENIES.
230async function runGate($: EngineInterface, tool: string, enforceConfig: boolean): Promise<GateDecision> {
231 const [stored, session, snapshot] = await Promise.all([read($, planTool), read($, enforceSession), read($, task)])
232 const input = {
233 tool,
234 isPlanTool: false,
235 planToolName: stored.name ?? PLAN_TOOL_FULL_NAME,
236 enforceConfig,
237 enforceSession: session,
238 toolRegistered: stored.name !== null,
239 toolOffered: stored.offered,
240 }
241 // Fast path: within a turn `planned` only goes false to true, so a snapshot that allows with no
242 // denies counted is final and needs no write. Any other snapshot goes through the transition.
243 const early = transition({ ...snapshot, denies: 0 }, input)
244 if (early.decision.kind === 'allow') return early.decision
245 // The decision and the deny count come from one transition on the value the write is checked
246 // against. `update` reruns the reducer when its write misses ifVersion, so `out` is reassigned on
247 // every attempt and only the attempt that landed is acted on. Toasts fire after update resolves.
248 let out: GateTransition | undefined
249 await update($, task, cur => {
250 out = transition(cur, input)
251
252 return out.next
253 })
254 if (out === undefined) {
255 debugLog($, `todo-list: gate reducer did not run, allowing ${tool}`)
256
257 return { kind: 'allow' }
258 }
259 if (out.toast === 'deny') safeToast($, `Blocked ${tool}: no plan yet. /todo off turns this off.`)
260 else if (out.toast === 'pause' && out.decision.kind === 'pause') safeToast($, out.decision.toast)
261
262 return out.decision
263}
264
265// Tells the person once per session that a task call was not mirrored. The reason is one of the
266// fixed DROPPED_* strings. Must run after the mirror's own update() has resolved, never inside a
267// reducer. `update` reruns the reducer when its write misses ifVersion, so `won` is reassigned on
268// every attempt and only the attempt that flipped false to true toasts. A claim whose toast failed is released.
269async function reportDrop($: EngineInterface, reason: string): Promise<void> {
270 let won = false
271 await update($, dropShown, cur => {
272 won = !cur
273
274 return true
275 })
276 // A toast that did not show gives the claim back, so the next drop can still tell the person.
277 if (won && !safeToast($, `Plan not updated: ${reason}`)) await update($, dropShown, () => false)
278}
279
280// A tool response the narrowing does not know: logged with detail, and reported once.
281async function dropUnrecognised($: EngineInterface, name: string): Promise<void> {
282 debugLog($, `todo-list: ${name} response not recognised, not mirrored`)
283 await reportDrop($, DROPPED_UNRECOGNISED)
284}
285
286// Answers a call to the plan tool with the text the model reads. The plan tool's tool.call hook
287// answers itself and never calls next(e) (T01 Q1/Q3): a result from the hook reaches the model
288// verbatim, and an error is result text starting "Error:".
289async function answerPlanCall($: EngineInterface, input: unknown, agentId: string | undefined): Promise<string> {
290 // Only the main loop owns the plan; a subagent's call changes nothing.
291 if (agentId !== undefined) return NOT_OWNER_TEXT
292 const parsed = parsePlanInput(input)
293 if ('error' in parsed) return parsed.error
294 const now = await $.clock.now()
295 let applied = { error: 'Error: plan unchanged.' } as PlanApplied
296 await update($, plan, (cur: Plan) => {
297 applied = applyPlanOp(cur, parsed.parsed, now)
298
299 return 'error' in applied ? cur : applied.plan
300 })
301 if ('error' in applied) return applied.error
302 if (touchesPlan(parsed.parsed)) await update($, task, onPlanTouched)
303 await refreshStatus($)
304
305 return applied.text
306}
307
308// Mirrors a successful TaskCreate/TaskUpdate/TodoWrite call into the plan (D10). The call has
309// already run; a rejected mapping (a limit, say) leaves the plan alone and is only logged. An
310// ignored call (a TaskUpdate for an id the plan does not hold) is also only logged: it changes
311// nothing, so it neither counts as having a plan nor redraws the status line.
312async function mirror($: EngineInterface, name: string, apply: (cur: Plan, now: number) => IngestResult): Promise<void> {
313 const now = await $.clock.now()
314 // `update` runs the reducer again when its write misses ifVersion, so every attempt assigns the
315 // whole outcome: a result from an earlier attempt must not outlive a retry that differs.
316 let outcome = { kind: 'failed', text: 'plan unchanged' } as { kind: 'ok' | 'failed' | 'ignored'; text: string }
317 await update($, plan, (cur: Plan) => {
318 const out = apply(cur, now)
319 if ('error' in out) {
320 outcome = { kind: 'failed', text: out.error }
321
322 return cur
323 }
324 if ('ignored' in out) {
325 outcome = { kind: 'ignored', text: out.ignored }
326
327 return cur
328 }
329 outcome = { kind: 'ok', text: '' }
330
331 return out.plan
332 })
333 if (outcome.kind === 'failed') {
334 debugLog($, `todo-list: ${name} not mirrored: ${outcome.text}`)
335 await reportDrop($, DROPPED_NOT_MIRRORED)
336
337 return
338 }
339 if (outcome.kind === 'ignored') {
340 debugLog($, `todo-list: ${name} ignored: ${outcome.text}`)
341
342 return
343 }
344 await update($, task, onPlanTouched)
345 await refreshStatus($)
346}
347
348// The call id as the activity reducer should see it: a blank id is absent, so the name fallback applies.
349const presentId = (id: string | undefined): string | undefined => (id !== undefined && id.trim() !== '' ? id : undefined)
350
351// Ends the activity a finished call started, by its tool_use_id so a parallel batch keeps the calls
352// still running. A subagent's call never started one; it only clears a permission label (its own
353// ask may be the one shown) and leaves a main-loop tool phase alone. The plan tool never showed as
354// Running.
355async function endTool(
356 $: EngineInterface,
357 tool: string,
358 toolUseId: string | undefined,
359 agentId: string | undefined,
360): Promise<void> {
361 if (isPlanTool(tool, (await read($, planTool)).name)) return
362 if (agentId !== undefined) {
363 // Only redraws when a permission label is up, so an ordinary subagent call stays silent.
364 if ((await read($, activity)).phase === 'permission') await applyActivity($, { type: 'permissionEnd' })
365
366 return
367 }
368 const id = presentId(toolUseId)
369 await applyActivity($, tool === ASK_TOOL ? { type: 'questionClose' } : { type: 'toolEnd', tool, ...(id !== undefined ? { id } : {}) })
370}
371
372// A call finished: ends its activity, then mirrors a main-loop TaskCreate, TaskUpdate or TodoWrite
373// into the plan (D10). The hook only runs for a call that succeeded; a response in a shape the
374// narrowing does not know is logged and skipped. `input` and `response` are `unknown` because the
375// host types tool_input and tool_response that way; post-tool.ts narrows them.
376async function afterTool(
377 $: EngineInterface,
378 tool: string,
379 toolUseId: string | undefined,
380 input: unknown,
381 response: unknown,
382 agentId: string | undefined,
383): Promise<void> {
384 await endTool($, tool, toolUseId, agentId)
385 if (agentId !== undefined) return
386 if (tool === 'TaskCreate') {
387 const created = taskCreateFrom(input, response)
388 if (created === null) return dropUnrecognised($, 'TaskCreate')
389 await mirror($, 'TaskCreate', (cur, now) => ingestTaskCreate(cur, created, now))
390 } else if (tool === 'TaskUpdate') {
391 // The tool said the update failed: nothing to mirror and nothing to report.
392 if (taskUpdateFailed(response)) return debugLog($, 'todo-list: TaskUpdate reported failure, not mirrored')
393 const updated = taskUpdateFrom(input, response)
394 if (updated === null) return dropUnrecognised($, 'TaskUpdate')
395 await mirror($, 'TaskUpdate', (cur, now) => ingestTaskUpdate(cur, updated, now))
396 } else if (tool === 'TodoWrite') {
397 const todos = todosFrom(input, response)
398 if (todos === null) return dropUnrecognised($, 'TodoWrite')
399 await mirror($, 'TodoWrite', (cur, now) => ingestTodoWrite(cur, todos, now))
400 }
401}
402
403// Loads the colour saved by `/todo color` into the atom. A store error or a bad value keeps the
404// default. Atoms reset on /clear after session.end and no session.start fires for it, so the first
405// turn.start after a /clear calls this too.
406async function loadSavedAccent($: EngineInterface): Promise<void> {
407 await guard($, 'accent load', undefined, async () => {
408 const saved = validAccent(await $.store.get(ACCENT_STORE_KEY))
409 if (saved !== null) await update($, accentOverride, () => saved)
410 })
411}
412
413export const register: Register = (on, options) => {
414 // A missing value counts as on (D8).
415 const enforceConfig = options.enforce !== false
416 // Effective accent = saved `/todo color` (accentOverride) ?? plugin option ?? default; see resolveAccent.
417 const accentConfig = resolveAccent(null, options.accentColor)
418
419 on('session.start', async ($, e, next) => {
420 // Load the colour saved by `/todo color`. A store error or a bad value keeps the default.
421 await loadSavedAccent($)
422 await guard($, 'session.start', undefined, async () => {
423 try {
424 const registered = await $.tool.register({
425 name: PLAN_TOOL_SHORT_NAME,
426 description: PLAN_TOOL_DESCRIPTION,
427 inputSchema: PLAN_INPUT_SCHEMA,
428 })
429 await update($, planTool, cur => ({ ...cur, name: registered.tool }))
430 // The describe answer is cached per session: after a hot reload it may have been computed
431 // before this module's pin existed, which leaves the tool deferred behind ToolSearch.
432 try {
433 $.ui.invalidate('tool.describe')
434 } catch (error) {
435 $.ui.log(`todo-list: tool.describe invalidate failed ${String(error)}`, { to: 'debug' })
436 }
437 } catch (error) {
438 // The name stays null: the gate and the instruction both fail open without the tool.
439 $.ui.log(`todo-list: plan tool registration failed ${String(error)}`, { to: 'debug' })
440 }
441 await $.command.register({
442 name: 'todo',
443 description: 'Show the plan pane, clear the plan, or turn plan enforcement off or on',
444 argumentHint: 'clear | off | on | color <name|#hex|reset>',
445 })
446 // The pane opens unasked only on the terminal under a person at the prompt. The host leaves
447 // an unasked open undrawn below 110 columns, so the first prompt (an open the person asked
448 // for, placed at any width) opens it again if it is still unplaced.
449 if (e.isInteractive && e.surface === 'terminal') {
450 await update($, firstPromptOpen, () => 'armed')
451 await openPane($)
452 }
453 })
454
455 return next(e)
456 })
457
458 on('command.run', { command: 'todo' }, async ($, e) =>
459 guard($, 'command.run', { text: 'Todo command failed.' }, () => runTodoCommand($, e.args, e.presentation)),
460 )
461
462 // Without the pin the tool is deferred behind ToolSearch and the model does not see it on
463 // turn one (T01 Q2).
464 on('tool.describe', async ($, e, next) => {
465 const stored = await guard($, 'tool.describe', null as string | null, async () => (await read($, planTool)).name)
466 if (!isPlanTool(e.tool, stored)) return next(e)
467
468 return { ...e, isDeferred: false }
469 })
470
471 // Session activity (T09). Observe-only hooks: each records an event and returns the event it was
472 // given, unchanged; a guard failure never blocks or alters the call. The tool.call and
473 // PostToolUse hooks below record tool start and end.
474 //
475 // The permission signal. Recorded before next: the chain may wait on the dialog itself.
476 // Repeating permissionAsk is idempotent in the reducer.
477 on('classic.PermissionRequest', async ($, e, next) => {
478 await guard($, 'PermissionRequest', undefined, () => applyActivity($, { type: 'permissionAsk', tool: e.tool_name }))
479
480 return next(e)
481 })
482
483 // A denied permission never reaches PostToolUse, so this ends the call's activity and clears the
484 // permission label that would otherwise stay up for the rest of the turn.
485 on('classic.PermissionDenied', async ($, e, next) => {
486 await guard($, 'PermissionDenied', undefined, async () => {
487 await endTool($, e.tool_name, e.tool_use_id, e.agent_id)
488 if (e.agent_id === undefined) await endPermissionFor($, e.tool_name)
489 })
490
491 return next(e)
492 })
493
494 on('classic.SubagentStart', async ($, e, next) => {
495 await guard($, 'SubagentStart', undefined, () => applyActivity($, { type: 'subagentStart', id: e.agent_id }))
496
497 return next(e)
498 })
499
500 on('classic.SubagentStop', async ($, e, next) => {
501 await guard($, 'SubagentStop', undefined, () => applyActivity($, { type: 'subagentStop', id: e.agent_id }))
502
503 return next(e)
504 })
505
506 on('classic.StopFailure', async ($, e, next) => {
507 await guard($, 'StopFailure', undefined, () =>
508 applyActivity($, { type: 'stopFailure', detail: e.error_details ?? e.error }),
509 )
510
511 return next(e)
512 })
513
514 // Only the notification type is logged for now (spike: only permission_prompt observed).
515 on('classic.Notification', async ($, e, next) => {
516 await guard($, 'Notification', undefined, () => {
517 debugLog($, `todo-list: notification ${clean(e.notification_type)}`)
518 })
519
520 return next(e)
521 })
522
523 // A subagent's compaction carries agent_id and is not shown.
524 on('classic.PreCompact', async ($, e, next) => {
525 if (e.agent_id === undefined) await guard($, 'compact start', undefined, () => applyActivity($, { type: 'compactStart' }))
526
527 return next(e)
528 })
529
530 on('classic.PostCompact', async ($, e, next) => {
531 if (e.agent_id === undefined) await guard($, 'compact end', undefined, () => applyActivity($, { type: 'compactEnd' }))
532
533 return next(e)
534 })
535
536 // The subagent's turn.complete carries agentId; the reducer ignores it (T01 Q7).
537 on('turn.complete', async ($, e, next) => {
538 await guard($, 'turn.complete', undefined, () =>
539 applyActivity($, { type: 'turnComplete', reason: e.reason, agentId: e.agentId }),
540 )
541
542 return next(e)
543 })
544
545 on('session.end', async ($, e, next) => {
546 if (e.reason === 'clear') {
547 await guard($, 'session.end', undefined, () => applyActivity($, { type: 'sessionClear' }))
548 await guard($, 'session.end', undefined, () => update($, dropShown, () => false))
549 }
550
551 return next(e)
552 })
553
554 // The gate and the start of the activity. The catch-all form is used because the registered name
555 // is known only after session.start.
556 //
557 // The host refuses two tool.call hooks without a matcher, so this is the only one. It passes
558 // every call on with next(e) except a gate deny. Subagent calls and the plan tool go straight to
559 // next(e): the plan tool is answered by the matcher hook below, which is registered after this
560 // one so that this one sees the call first. Other main-loop calls run the gate and, once
561 // allowed, record Running (or Waiting for your answer). The end of the call is recorded by
562 // classic.PostToolUse and classic.PostToolUseFailure.
563 on('tool.call', async ($, e, next) => {
564 const kind = await guard($, 'tool.call', 'skip' as 'skip' | 'tool' | 'question', async () => {
565 if (e.agentId !== undefined) return 'skip'
566 if (isPlanTool(e.tool, (await read($, planTool)).name)) return 'skip'
567
568 return e.tool === ASK_TOOL ? 'question' : 'tool'
569 })
570 if (kind !== 'tool' && kind !== 'question') return next(e)
571 // A guard failure allows the call.
572 const gated = await guard($, 'gate', { kind: 'allow' } as GateDecision, () => runGate($, e.tool, enforceConfig))
573 // This text is gate.ts's denyText without the tool name (the hook must return a fixed string);
574 // register.test.ts checks the two stay in step.
575 if (gated.kind === 'deny') {
576 return { deny: 'Blocked: there is no plan for this task yet. Call mcp__todo-list__plan with {"op":"set","title":"<task>","nodes":[{"title":"<step>"}]} first, then retry the tool. If the tool is not loaded, load it first with ToolSearch (query "select:mcp__todo-list__plan").' }
577 }
578 // A denied call never shows as Running.
579 // AskUserQuestion only opens the question phase; it has no running entry to end.
580 const id = presentId(e.tool_use_id)
581 const open: ActivityEvent =
582 kind === 'question' ? { type: 'questionOpen' } : { type: 'toolStart', tool: e.tool, ...(id !== undefined ? { id } : {}) }
583 await guard($, 'tool start', undefined, () => applyActivity($, open))
584
585 return next(e)
586 })
587
588 // The plan tool. The API serves a plugin's own tool only from a tool.call hook, so this one answers
589 // every call to it and never reads `next`. The model reads the returned text as the tool result.
590 on('tool.call', { tool: 'mcp__todo-list__plan' }, async ($, e) => {
591 const text = await guard($, 'plan tool', 'Error: the plan tool failed. Try again.', () =>
592 answerPlanCall($, e, e.agentId),
593 )
594
595 return { result: text }
596 })
597
598 // The end of a call, and D10: mirror TaskCreate, TaskUpdate and TodoWrite into the tree. The hook
599 // runs after the tool succeeded and changes nothing in what the model sees.
600 on('classic.PostToolUse', async ($, e, next) => {
601 await guard($, 'PostToolUse', undefined, () =>
602 afterTool($, e.tool_name, e.tool_use_id, e.tool_input, e.tool_response, e.agent_id),
603 )
604
605 return next(e)
606 })
607
608 on('classic.PostToolUseFailure', async ($, e, next) => {
609 await guard($, 'PostToolUseFailure', undefined, () => endTool($, e.tool_name, e.tool_use_id, e.agent_id))
610
611 return next(e)
612 })
613
614 // The instruction is added only when the plan tool is in the request's tool list. The pin
615 // (tool.describe above) always applies to a registered plan tool, so membership in e.tools
616 // means the tool is loaded. The name found here also repairs planTool after a /clear.
617 on('prompt.compose', async ($, e, next) => {
618 const result = await next(e)
619
620 return guard($, 'prompt.compose', result, async () => {
621 const stored = (await read($, planTool)).name
622 const found = [stored, PLAN_TOOL_FULL_NAME].find(n => n !== null && e.tools.includes(n)) ?? null
623 await update($, planTool, cur => ({ name: found ?? cur.name, offered: found !== null }))
624 if (found === null) return result
625
626 return {
627 sections: [
628 ...result.sections.filter(s => s.id !== INSTRUCTION_ID),
629 { id: INSTRUCTION_ID, text: INSTRUCTION_TEXT(found), scope: 'session' as const },
630 ],
631 }
632 })
633 })
634
635 on('prompt.submit', async ($, e, next) => {
636 await guard($, 'first prompt open', undefined, async () => {
637 if ((await read($, firstPromptOpen)) !== 'armed') return
638 await update($, firstPromptOpen, () => 'done')
639 if (!(await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)) await openPane($)
640 })
641
642 return next(e)
643 })
644
645 // turn.start fires once per prompt of the main loop and not for subagents (T01 Q7).
646 on('turn.start', async ($, e, next) => {
647 if ((await read($, accentOverride)) === null) await loadSavedAccent($)
648 await guard($, 'turn.start', undefined, async () => {
649 const current = await read($, plan)
650 await update($, task, t => onNewPrompt(t, current, e.text))
651 })
652 await guard($, 'turn.start activity', undefined, () => applyActivity($, { type: 'turnStart' }))
653 // D12: the plan rides each new prompt as a user-role row the model reads (the person does not
654 // see it as typed), which also resyncs ids after a compaction.
655 await guard($, 'plan context', undefined, async () => {
656 const name = (await read($, planTool)).name ?? PLAN_TOOL_FULL_NAME
657 const text = planContext(await read($, plan), name, formatForModel)
658 if (text !== undefined) await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
659 })
660
661 return next(e)
662 })
663
664 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) =>
665 guard(
666 $,
667 'ui.render',
668 () => {
669 const { Text } = $.ui.resolve(e)
670 return <Text>Plan failed to draw.</Text>
671 },
672 async () => {
673 const { Box, Text } = $.ui.resolve(e)
674 const [p, a, override] = await Promise.all([read($, plan), read($, activity), read($, accentOverride)])
675 // The engine clips the body to scroll.bodyRows, so the tree must fit that and not the
676 // whole terminal, or the bottom (usually the current step) is cut off silently.
677 const bodyRows = e.props.scroll?.bodyRows ?? 0
678 const maxLines = bodyRows > 0 ? bodyRows : Math.max(6, (e.viewport?.rows ?? PANE_ROWS) - 4)
679 // bodyColumns is the room inside the pane frame; the viewport is the whole terminal.
680 const width = e.props.bodyColumns > 0 ? e.props.bodyColumns : DEFAULT_WIDTH
681 const accent = override ?? accentConfig
682 // The host keeps the size a pane opened at, and a change of height alone does not redraw
683 // it. A render that sees another terminal size, placement or plan height re-opens it.
684 // Fired and not awaited: the re-open closes the instance being drawn. Main view only.
685 if (e.viewport !== undefined && e.props.view?.agentId === undefined) {
686 const fit: PaneFit = {
687 columns: e.viewport.columns,
688 rows: e.viewport.rows,
689 placement: e.props.placement,
690 wantRows: paneRows(p, a),
691 }
692 const prev = await read($, paneFit)
693 if (needsRefit(prev, fit)) {
694 void guard($, 'refit pane', undefined, () => refitPane($, fit))
695 }
696 }
697
698 return (
699 <Box flexDirection="column">
700 {buildTree(p, a, { maxLines, width, accent }).map((line, i) => (
701 <Text key={`line-${i}`} wrap="truncate-end">
702 {line.segments.length === 1 && line.text === ''
703 ? ' '
704 : line.segments.map((s, k) => (
705 <Text
706 key={`seg-${k}`}
707 color={s.color}
708 bold={s.bold}
709 dimColor={s.dim}
710 strikethrough={s.strikethrough}
711 >
712 {s.text}
713 </Text>
714 ))}
715 </Text>
716 ))}
717 </Box>
718 )
719 },
720 ),
721 )
722}
723hooks/activity.ts 216 lines1// Pure activity reducer: what Claude is doing right now, derived from events.
2// No `$` here: register.tsx raises events from the hooks and stores the result in the atom.
3//
4// The atom holds only the visible phase, so precedence is enforced by the transitions:
5// question > permission > compacting > tool > working. A lower-phase event never overrides a
6// higher phase; ending a phase falls back to `working` (the turn is still running).
7import type { ActivityPhase, ActivityState } from '../types'
8import { clean } from './sanitize'
9
10export type { ActivityPhase, ActivityState }
11
12export type TurnReason = 'answer' | 'aborted' | 'error' | 'refusal' | (string & {})
13
14export type ActivityEvent =
15 | { type: 'turnStart' }
16 // An `agentId` marks a subagent's turn end; only main-loop turn ends move the phase.
17 | { type: 'turnComplete'; reason: TurnReason; agentId?: string }
18 // `id` is the call's id when the hook carries one; without it the entry is synthetic (`name:<tool>`).
19 | { type: 'toolStart'; tool: string; id?: string }
20 // By id first; an absent or unknown id falls back to the oldest synthetic entry of `tool`. With neither
21 // id nor tool every running call is cleared.
22 | { type: 'toolEnd'; id?: string; tool?: string }
23 | { type: 'permissionAsk'; tool: string }
24 | { type: 'permissionEnd' }
25 | { type: 'questionOpen' }
26 | { type: 'questionClose' }
27 | { type: 'compactStart' }
28 | { type: 'compactEnd' }
29 | { type: 'subagentStart'; id: string }
30 | { type: 'subagentStop'; id: string }
31 | { type: 'stopFailure'; detail: string }
32 | { type: 'sessionClear' }
33
34export const emptyActivity = (now: number): ActivityState => ({ phase: 'idle', subagents: [], running: [], since: now })
35
36// Higher rank wins. idle, interrupted and error are turn-end states, ranked below working.
37const RANK: Record<ActivityPhase, number> = {
38 idle: 0,
39 interrupted: 0,
40 error: 0,
41 working: 1,
42 tool: 2,
43 compacting: 3,
44 permission: 4,
45 question: 5,
46}
47
48// Moves to `phase` (keeping `since` when the phase is unchanged).
49const enter = (prev: ActivityState, phase: ActivityPhase, now: number, tool?: string, detail?: string): ActivityState => {
50 const next: ActivityState = {
51 phase,
52 subagents: prev.subagents,
53 running: prev.running,
54 since: prev.phase === phase ? prev.since : now,
55 }
56 if (tool !== undefined) next.tool = tool
57 if (detail !== undefined) next.detail = detail
58
59 return next
60}
61
62// Applies `phase` only when it outranks (or equals) the current phase.
63const raise = (prev: ActivityState, phase: ActivityPhase, now: number, tool?: string): ActivityState =>
64 RANK[prev.phase] > RANK[phase] ? prev : enter(prev, phase, now, tool)
65
66// Settles into the turn's resting phase: `tool` labelled by the newest running call, else `working`.
67const settle = (prev: ActivityState, now: number): ActivityState => {
68 const last = prev.running[prev.running.length - 1]
69
70 return last ? enter(prev, 'tool', now, last.tool) : enter(prev, 'working', now)
71}
72
73// Ends `ended` and settles; any other phase is left alone.
74const fallBack = (prev: ActivityState, ended: ActivityPhase, now: number): ActivityState =>
75 prev.phase === ended ? settle(prev, now) : prev
76
77// Moves to `phase` with no running calls (a turn boundary: a stuck entry lasts at most one turn).
78const enterIdle = (prev: ActivityState, phase: ActivityPhase, now: number, detail?: string): ActivityState => ({
79 ...enter(prev, phase, now, undefined, detail),
80 running: [],
81})
82
83// The entry a toolEnd removes: by id, else the oldest synthetic entry of `tool`; -1 when none matches.
84const endedIndex = (running: ActivityState['running'], id: string | undefined, tool: string | undefined): number => {
85 const byId = id ? running.findIndex(r => r.id === id) : -1
86 if (byId >= 0 || !tool) return byId
87 const synthetic = running.findIndex(r => r.tool === tool && r.id === `name:${r.tool}`)
88 // An end hook without any id may belong to a real-id entry; an end carrying an unknown id never removes one.
89 if (synthetic >= 0 || id) return synthetic
90
91 return running.findIndex(r => r.tool === tool)
92}
93
94// An atom value from before a hot reload can lack `running` or `subagents`; treat those as empty.
95const normalize = (prev: ActivityState): ActivityState =>
96 Array.isArray(prev.running) && Array.isArray(prev.subagents)
97 ? prev
98 : {
99 ...prev,
100 running: Array.isArray(prev.running) ? prev.running : [],
101 subagents: Array.isArray(prev.subagents) ? prev.subagents : [],
102 }
103
104export const reduceActivity = (stored: ActivityState, event: ActivityEvent, now: number): ActivityState => {
105 const prev = normalize(stored)
106 switch (event.type) {
107 case 'turnStart':
108 return enterIdle(prev, 'working', now)
109 case 'turnComplete': {
110 if (event.agentId !== undefined) return prev
111 if (event.reason === 'aborted') return enterIdle(prev, 'interrupted', now)
112 if (event.reason === 'error' || event.reason === 'refusal') return enterIdle(prev, 'error', now)
113
114 return enterIdle(prev, 'idle', now)
115 }
116 case 'toolStart': {
117 const tool = clean(event.tool) || 'tool'
118 const entry = { id: clean(event.id ?? '') || `name:${tool}`, tool }
119 const running = [...prev.running, entry]
120 // A higher phase (permission, compacting, question) keeps showing; only `running` grows.
121 if (RANK[prev.phase] > RANK.tool) return { ...prev, running }
122
123 return raise({ ...prev, running }, 'tool', now, tool)
124 }
125 case 'toolEnd': {
126 const tool = event.tool === undefined ? undefined : clean(event.tool) || 'tool'
127 const id = event.id === undefined ? undefined : clean(event.id)
128 const all = id === undefined && tool === undefined
129 const at = all ? -1 : endedIndex(prev.running, id, tool)
130 // An unknown call changes nothing.
131 if (!all && at < 0) return prev
132 const running = all ? [] : prev.running.filter((_, i) => i !== at)
133 if (prev.phase !== 'tool' && prev.phase !== 'permission') return { ...prev, running }
134 // A permission wait belongs to the call that asked; another call ending must not hide it.
135 if (prev.phase === 'permission' && !all && running.some(r => r.tool === prev.tool)) return { ...prev, running }
136 // The asking call ending while permission is shown means the dialog is over too.
137 return settle({ ...prev, running }, now)
138 }
139 case 'permissionAsk':
140 return raise(prev, 'permission', now, clean(event.tool) || 'tool')
141 case 'permissionEnd':
142 // A subagent's call ended, so its dialog is over; it must not touch the main loop's tool phase.
143 return fallBack(prev, 'permission', now)
144 case 'questionOpen':
145 return enter(prev, 'question', now)
146 case 'questionClose':
147 return fallBack(prev, 'question', now)
148 case 'compactStart': {
149 // A repeat start, or a higher phase, changes nothing; keep the first resume phase.
150 if (RANK[prev.phase] >= RANK.compacting) return prev
151 // A tool name would be stale once compaction ends, so a tool resumes as working.
152 const next = enter(prev, 'compacting', now)
153 next.resume = prev.phase === 'tool' ? 'working' : prev.phase
154
155 return next
156 }
157 case 'compactEnd':
158 if (prev.phase !== 'compacting') return prev
159 const resume = prev.resume ?? 'working'
160
161 return resume === 'working' ? settle(prev, now) : enter(prev, resume, now)
162 case 'subagentStart': {
163 const id = clean(event.id)
164 if (!id || prev.subagents.includes(id)) return prev
165
166 return { ...prev, subagents: [...prev.subagents, id] }
167 }
168 case 'subagentStop': {
169 const id = clean(event.id)
170 if (!prev.subagents.includes(id)) return prev
171
172 return { ...prev, subagents: prev.subagents.filter(s => s !== id) }
173 }
174 case 'stopFailure':
175 return enter(prev, 'error', now, undefined, clean(event.detail))
176 case 'sessionClear':
177 return emptyActivity(now)
178 }
179}
180
181// The status line and pane label; undefined when there is nothing to show.
182export const activityLabel = (activity: ActivityState): string | undefined => {
183 const n = activity.subagents.length
184 const tool = activity.tool ?? 'tool'
185 let base: string | undefined
186 switch (activity.phase) {
187 case 'working':
188 base = 'Working'
189 break
190 case 'tool':
191 base = `Running ${tool}`
192 break
193 case 'permission':
194 base = `Waiting for permission: ${tool}`
195 break
196 case 'question':
197 base = 'Waiting for your answer'
198 break
199 case 'compacting':
200 base = 'Compacting'
201 break
202 case 'interrupted':
203 base = 'Interrupted'
204 break
205 case 'error':
206 base = activity.detail ? `Error: ${activity.detail}` : 'Error'
207 break
208 case 'idle':
209 base = undefined
210 break
211 }
212 if (n === 0) return base
213
214 return `${base ?? 'Idle'} · ${n} subagent${n === 1 ? '' : 's'}`
215}
216hooks/fit.ts 23 lines1// When the pane must be re-opened so the host sizes it again. `$.ui.open` on an open pane
2// retitles it and keeps its size, so a changed terminal or plan only shows after a close + open.
3export type PaneFit = { columns: number; rows: number; placement: string; wantRows: number }
4
5// A plan growing or shrinking by less than this keeps the current frame.
6export const WANT_ROWS_SLACK = 3
7
8// Below this width a re-open is not placed. The d.ts for `$.ui.open` says: "Asked, the person's
9// command, prompt or press behind it (not `focus`), it is placed at any width; unasked, from 144
10// columns, 110 for one once asked." The refit open is unasked, so under 110 columns the pane
11// would be closed and never drawn again. A narrow redraw already gets the compact layout.
12export const REFIT_MIN_COLUMNS = 110
13
14// `prev` is what the pane was last re-opened for (null: never recorded). True when the terminal
15// size or the placement differs (never below REFIT_MIN_COLUMNS), or the rows the plan wants moved by WANT_ROWS_SLACK or more.
16export const needsRefit = (prev: PaneFit | null, now: PaneFit): boolean => {
17 if (now.columns < REFIT_MIN_COLUMNS) return false
18 if (prev === null) return true
19 if (prev.columns !== now.columns || prev.rows !== now.rows || prev.placement !== now.placement) return true
20
21 return Math.abs(prev.wantRows - now.wantRows) >= WANT_ROWS_SLACK
22}
23hooks/gate.ts 114 lines1import type { Plan, TaskState } from '../types'
2import { hasUnfinished } from './plan'
3
4export type { Plan, TaskState }
5
6// D6: main-loop tools that change state. Everything else is allowed, including third-party MCP tools.
7export const BLOCKED_TOOLS: ReadonlySet<string> = new Set([
8 'Edit',
9 'Write',
10 'NotebookEdit',
11 'Bash',
12 'Agent',
13 'Workflow',
14 'CronCreate',
15 'CronDelete',
16 'EnterWorktree',
17 'ExitWorktree',
18 'RemoteTrigger',
19])
20
21export const MAX_DENIES = 3
22
23export type GateInput = {
24 tool: string
25 agentId?: string
26 isPlanTool: boolean
27 planToolName: string
28 enforceConfig: boolean
29 enforceSession: boolean
30 toolRegistered: boolean
31 toolOffered: boolean
32 planned: boolean
33 denies: number
34}
35
36export type GateDecision =
37 | { kind: 'allow' }
38 | { kind: 'deny'; message: string }
39 | { kind: 'pause'; toast: string }
40
41export const PAUSE_TOAST = 'Plan enforcement paused for this turn'
42
43// Follows the gate algorithm in docs/plan-tree/plan.md. `planToolName` is the full registered
44// plan-tool name, used only in the deny text.
45export const decideGate = (input: GateInput): GateDecision => {
46 if (input.agentId !== undefined && input.agentId !== '') return { kind: 'allow' }
47 if (!BLOCKED_TOOLS.has(input.tool) || input.isPlanTool) return { kind: 'allow' }
48 if (!input.enforceConfig || !input.enforceSession || !input.toolRegistered || !input.toolOffered) {
49 return { kind: 'allow' }
50 }
51 if (input.planned) return { kind: 'allow' }
52 if (input.denies >= MAX_DENIES) return { kind: 'pause', toast: PAUSE_TOAST }
53
54 return { kind: 'deny', message: denyText(input.tool, input.planToolName) }
55}
56
57export type GateTransition = {
58 next: TaskState
59 decision: GateDecision
60 toast: 'deny' | 'pause' | null
61}
62
63// The decision plus the deny count that follows it, as one pure step. A deny and the first pause
64// each add one to `denies`; the deny toast shows on the first deny and the pause toast once, because
65// the pausing call moves `denies` past MAX_DENIES. Allows and later pauses return `cur` itself.
66export function transition(cur: TaskState, input: Omit<GateInput, 'planned' | 'denies'>): GateTransition {
67 const decision = decideGate({ ...input, planned: cur.planned, denies: cur.denies })
68 if (decision.kind === 'deny') {
69 return { next: { ...cur, denies: cur.denies + 1 }, decision, toast: cur.denies === 0 ? 'deny' : null }
70 }
71 if (decision.kind === 'pause' && cur.denies === MAX_DENIES) {
72 return { next: { ...cur, denies: cur.denies + 1 }, decision, toast: 'pause' }
73 }
74
75 return { next: cur, decision, toast: null }
76}
77
78// D7 as revised: empty text and background-agent completions are continuations and change nothing.
79export const onNewPrompt = (task: TaskState, plan: Plan, text: string): TaskState => {
80 if (text.trim() === '' || text.startsWith('<task-notification>')) return task
81
82 return { open: true, planned: hasUnfinished(plan), denies: 0 }
83}
84
85export const onPlanTouched = (task: TaskState): TaskState => ({ ...task, planned: true })
86
87export const INSTRUCTION_ID = 'todo-list:plan'
88
89export const INSTRUCTION_TEXT = (toolName: string): string =>
90 `Plan tree: before using any tool other than read-only ones, create a plan with the ${toolName} tool (op "set"). ` +
91 'Before creating the plan, find steps that do not conflict (different files, independent research, separate subagents) and put them under one parent with "parallel": true, then run them concurrently (for example several Agent calls in one message). Outside a parallel group keep exactly one leaf in_progress. Mark each leaf completed immediately when it is done. ' +
92 'Use blocked or skipped with a note when a leaf cannot or need not be done. ' +
93 'When a requirement is unclear, ask with AskUserQuestion and mark the affected node blocked with the question as the note. ' +
94 'Pure Q&A needs no plan. ' +
95 `If the tool is not loaded, load it first with ToolSearch (query "select:${toolName}").`
96
97// Sent with each new prompt (D12). `format` renders the tree; without it the ids and statuses are listed.
98// Returns undefined for an empty plan.
99export const planContext = (
100 plan: Plan,
101 toolName: string,
102 format?: (plan: Plan) => string,
103): string | undefined => {
104 if (plan.nodes.length === 0) return undefined
105 const body = format !== undefined ? format(plan) : plan.nodes.map(n => `${n.id} ${n.status}`).join(', ')
106
107 return `Current plan (update it with ${toolName}):\n${body}`
108}
109
110export const denyText = (tool: string, toolName: string): string =>
111 `Blocked ${tool}: there is no plan for this task yet. ` +
112 `Call ${toolName} with {"op":"set","title":"<task>","nodes":[{"title":"<step>"}]} first, then retry ${tool}. ` +
113 `If the tool is not loaded, load it first with ToolSearch (query "select:${toolName}").`
114hooks/ingest.ts 96 lines1import type { Plan, PlanNode, PlanResult, PlanStatus } from './plan'
2import { checkText, fromCounters, MAX_NODES, MAX_TITLE, removeNode, rollup, sortNodes, toCounters } from './plan'
3import type { PlanSource } from '../types'
4import { clean } from './sanitize'
5
6// Mirrors of the TaskCreate, TaskUpdate and TodoWrite tools into the plan. Pure: the caller
7// applies a result only after the tool call succeeded. Mirrored nodes are top-level leaves
8// tagged with their source, so a plan `set` (which replaces only source 'plan') keeps them.
9
10export type TaskCreateInput = { id: string; subject: string; activeForm?: string }
11export type TaskUpdateInput = {
12 taskId: string
13 subject?: string
14 activeForm?: string
15 status?: PlanStatus | 'deleted'
16}
17export type TodoInput = { content: string; status: 'pending' | 'in_progress' | 'completed'; activeForm?: string }
18
19// A call the mirror recognised but has nothing to apply: not an error, so no failure log or toast.
20// Reasons are fixed strings with no user content in them.
21export type Ignored = { ignored: string }
22export type IngestResult = PlanResult | Ignored
23export const IGNORED_UNKNOWN_TASK = 'task id not in the plan'
24
25// Why a mirror was dropped, as shown in the once-per-session toast. Fixed strings: they must never
26// carry the subject, a title or the ingest error text, which can hold user content.
27export const DROPPED_NOT_MIRRORED = 'a task was not mirrored to the plan'
28export const DROPPED_UNRECOGNISED = 'a task response was not recognised, so it was not mirrored'
29
30type NewLeaf = { title: string; activeForm?: string; status: PlanStatus; externalId?: string }
31
32const withSource = (plan: Plan, source: PlanSource, leaves: readonly NewLeaf[], now: number, kept: PlanNode[]): PlanResult => {
33 const counters = toCounters({ title: plan.title, nodes: plan.nodes, issued: plan.issued })
34 const made: PlanNode[] = []
35 for (const leaf of leaves) {
36 const title = checkText('title', leaf.title, MAX_TITLE, false)
37 if ('error' in title) return title
38 const last = (counters.get('') ?? 0) + 1
39 counters.set('', last)
40 const node: PlanNode = { id: `${last}`, parentId: null, title: title.text, status: leaf.status, source, updatedAt: now }
41 if (leaf.externalId !== undefined) node.externalId = leaf.externalId
42 if (leaf.activeForm !== undefined) {
43 const form = checkText('activeForm', leaf.activeForm, MAX_TITLE, true)
44 if ('error' in form) return form
45 if (form.text !== '') node.activeForm = form.text
46 }
47 made.push(node)
48 }
49 const nodes = [...kept, ...made]
50 if (nodes.length > MAX_NODES) return { error: `the plan would have ${nodes.length} nodes, the limit is ${MAX_NODES}` }
51
52 return { plan: rollup({ ...plan, nodes: sortNodes(nodes), issued: fromCounters(counters) }, now) }
53}
54
55const findTask = (plan: Plan, externalId: string): PlanNode | undefined =>
56 plan.nodes.find(n => n.source === 'task' && n.externalId === externalId)
57
58export const ingestTaskCreate = (plan: Plan, input: TaskCreateInput, now: number): PlanResult => {
59 const externalId = checkText('task id', String(input.id), MAX_TITLE, false)
60 if ('error' in externalId) return externalId
61 if (findTask(plan, externalId.text) !== undefined) return { plan }
62
63 return withSource(plan, 'task', [{ title: input.subject, activeForm: input.activeForm, status: 'pending', externalId: externalId.text }], now, plan.nodes)
64}
65
66export const ingestTaskUpdate = (plan: Plan, input: TaskUpdateInput, now: number): IngestResult => {
67 const node = findTask(plan, clean(String(input.taskId)))
68 if (node === undefined) return { ignored: IGNORED_UNKNOWN_TASK }
69 if (input.status === 'deleted') return removeNode(plan, node.id, now)
70 const next: PlanNode = { ...node, updatedAt: now }
71 if (input.subject !== undefined) {
72 const title = checkText('title', input.subject, MAX_TITLE, false)
73 if ('error' in title) return title
74 next.title = title.text
75 }
76 if (input.activeForm !== undefined) {
77 const form = checkText('activeForm', input.activeForm, MAX_TITLE, true)
78 if ('error' in form) return form
79 if (form.text === '') delete next.activeForm
80 else next.activeForm = form.text
81 }
82 if (input.status !== undefined) next.status = input.status
83
84 return { plan: rollup({ ...plan, nodes: plan.nodes.map(n => (n.id === node.id ? next : n)) }, now) }
85}
86
87// TodoWrite sends the whole list each time: replace the source 'todo' leaves, keep the rest.
88export const ingestTodoWrite = (plan: Plan, todos: readonly TodoInput[], now: number): PlanResult =>
89 withSource(
90 plan,
91 'todo',
92 todos.map(t => ({ title: t.content, activeForm: t.activeForm, status: t.status })),
93 now,
94 plan.nodes.filter(n => n.source !== 'todo'),
95 )
96hooks/plan.ts 316 lines1// Pure plan-tree logic: reducers, rollup, progress and the current node.
2// No `$` here: register.tsx reads and writes the atoms and passes plain data in.
3//
4// Shape: the plan is a flat node list with `parentId` and stable path ids ("2", "2.1", "2.1.3").
5// The list is always kept in tree (pre-order) order. Every op returns `{ plan } | { error }`
6// and never throws; an op that fails changes nothing.
7import type { Plan, PlanNode, PlanStatus } from '../types'
8import { clean } from './sanitize'
9
10export type { Plan, PlanNode, PlanStatus }
11
12export const MAX_DEPTH = 3
13export const MAX_NODES = 60
14export const MAX_TITLE = 120
15export const MAX_NOTE = 200
16
17// setPlan and addNodes take nested input: { title, activeForm?, children? }. It becomes flat
18// nodes whose ids are paths ("1", "1.1", "1.1.1") handed out in input order. The plan tool
19// parser (T03) turns tool input into this shape.
20export type PlanInputNode = { title: string; activeForm?: string; parallel?: boolean; children?: PlanInputNode[] }
21
22export type NodeUpdate = { id: string; status?: PlanStatus; title?: string; note?: string; parallel?: boolean }
23
24export type PlanResult = { plan: Plan } | { error: string }
25
26export const emptyPlan = (): Plan => ({ title: '', nodes: [], issued: [] })
27
28const STATUSES: readonly PlanStatus[] = ['pending', 'in_progress', 'completed', 'blocked', 'skipped']
29
30const depthOf = (id: string): number => id.split('.').length
31
32// Numeric path order, so "2" < "2.1" < "2.10" < "10".
33const compareIds = (a: string, b: string): number => {
34 const x = a.split('.')
35 const y = b.split('.')
36 for (let i = 0; i < Math.min(x.length, y.length); i++) {
37 const d = Number(x[i]) - Number(y[i])
38 if (d !== 0 && !Number.isNaN(d)) return d
39 }
40
41 return x.length - y.length
42}
43
44export const sortNodes = (nodes: PlanNode[]): PlanNode[] => [...nodes].sort((a, b) => compareIds(a.id, b.id))
45
46const childrenOf = (nodes: readonly PlanNode[], id: string): PlanNode[] =>
47 nodes.filter(n => n.parentId === id)
48
49const isLeaf = (nodes: readonly PlanNode[], id: string): boolean => !nodes.some(n => n.parentId === id)
50
51const leaves = (plan: Plan): PlanNode[] => plan.nodes.filter(n => isLeaf(plan.nodes, n.id))
52
53const validIds = (plan: Plan): string =>
54 plan.nodes.length === 0 ? 'the plan has no nodes' : `valid ids: ${plan.nodes.map(n => n.id).join(', ')}`
55
56// Cleans a title or form and checks the length limit. Returns the text or an error.
57export const checkText = (label: string, raw: string, max: number, allowEmpty: boolean): { text: string } | { error: string } => {
58 if (typeof raw !== 'string') return { error: `${label} must be text` }
59 const text = clean(raw)
60 if (text === '' && !allowEmpty) return { error: `${label} is empty` }
61 if (text.length > max) return { error: `${label} is ${text.length} characters, the limit is ${max}` }
62
63 return { text }
64}
65
66// Counters of issued child numbers, keyed by parent id ('' is the top level).
67export const toCounters = (plan: Plan): Map<string, number> => {
68 const counters = new Map<string, number>()
69 for (const c of plan.issued) counters.set(c.parent, c.last)
70 // Nodes that exist always count, in case `issued` was never written for them.
71 for (const n of plan.nodes) {
72 const key = n.parentId ?? ''
73 const last = Number(n.id.split('.').pop())
74 if (!Number.isNaN(last) && last > (counters.get(key) ?? 0)) counters.set(key, last)
75 }
76
77 return counters
78}
79
80export const fromCounters = (counters: Map<string, number>): Plan['issued'] =>
81 [...counters.entries()].map(([parent, last]) => ({ parent, last }))
82
83// Flattens nested input under `parentId`, issuing ids from `counters`. `depth` is the depth of
84// the nodes being created. Returns the new nodes or an error.
85const flatten = (
86 input: readonly PlanInputNode[],
87 parentId: string | null,
88 depth: number,
89 counters: Map<string, number>,
90 now: number,
91): { nodes: PlanNode[] } | { error: string } => {
92 if (depth > MAX_DEPTH) return { error: `the tree is deeper than ${MAX_DEPTH} levels` }
93 const out: PlanNode[] = []
94 for (const item of input) {
95 const title = checkText('node title', item.title, MAX_TITLE, false)
96 if ('error' in title) return title
97 const key = parentId ?? ''
98 const last = (counters.get(key) ?? 0) + 1
99 counters.set(key, last)
100 const id = parentId === null ? `${last}` : `${parentId}.${last}`
101 const node: PlanNode = { id, parentId, title: title.text, status: 'pending', source: 'plan', updatedAt: now }
102 if (item.activeForm !== undefined) {
103 const form = checkText('activeForm', item.activeForm, MAX_TITLE, true)
104 if ('error' in form) return form
105 if (form.text !== '') node.activeForm = form.text
106 }
107 const hasKids = item.children !== undefined && item.children.length > 0
108 if (item.parallel === true) {
109 if (!hasKids) return { error: `"${node.title}" has "parallel": true but no children; parallel only applies to a parent` }
110 node.parallel = true
111 }
112 out.push(node)
113 if (item.children !== undefined && item.children.length > 0) {
114 const kids = flatten(item.children, id, depth + 1, counters, now)
115 if ('error' in kids) return kids
116 out.push(...kids.nodes)
117 }
118 }
119
120 return { nodes: out }
121}
122
123// Parents' stored status is derived from their children after every op; leaves are set
124// directly. Rules, per parent from the bottom up:
125// any child in_progress -> in_progress
126// any child blocked (none in_progress) -> blocked
127// every child completed or skipped -> completed
128// some child completed or skipped -> in_progress (work has started)
129// otherwise -> pending
130// A parent whose status changes gets updatedAt = now.
131export const rollup = (plan: Plan, now: number): Plan => {
132 const nodes = plan.nodes.map(n => ({ ...n }))
133 const byId = new Map(nodes.map(n => [n.id, n]))
134 const parents = nodes.filter(n => !isLeaf(nodes, n.id)).sort((a, b) => depthOf(b.id) - depthOf(a.id))
135 for (const parent of parents) {
136 const kids = childrenOf(nodes, parent.id).map(k => byId.get(k.id)?.status ?? k.status)
137 let status: PlanStatus
138 if (kids.includes('in_progress')) status = 'in_progress'
139 else if (kids.includes('blocked')) status = 'blocked'
140 else if (kids.every(s => s === 'completed' || s === 'skipped')) status = 'completed'
141 else if (kids.some(s => s === 'completed' || s === 'skipped')) status = 'in_progress'
142 else status = 'pending'
143 if (parent.status !== status) {
144 parent.status = status
145 parent.updatedAt = now
146 }
147 }
148
149 return { ...plan, nodes }
150}
151
152// Wraps an op so a bad input shape can never throw out of the hook.
153const safe = (op: () => PlanResult): PlanResult => {
154 try {
155 return op()
156 } catch (e) {
157 return { error: e instanceof Error ? e.message : 'invalid plan input' }
158 }
159}
160
161// Replaces the source:'plan' nodes with `input`. Nodes from TodoWrite or Task* (other sources)
162// stay, and new top-level ids start after the highest kept top-level id. A fresh set restarts
163// numbering, because the model sees the whole new tree in the result.
164export const setPlan = (prev: Plan, title: string, input: readonly PlanInputNode[], now: number): PlanResult =>
165 safe(() => {
166 const t = checkText('plan title', title, MAX_TITLE, false)
167 if ('error' in t) return t
168 if (input.length === 0) return { error: 'the plan has no nodes' }
169 const kept = prev.nodes.filter(n => n.source !== 'plan')
170 const counters = toCounters({ title: prev.title, nodes: kept, issued: [] })
171 const made = flatten(input, null, 1, counters, now)
172 if ('error' in made) return made
173 const nodes = [...kept, ...made.nodes]
174 if (nodes.length > MAX_NODES) return { error: `the plan would have ${nodes.length} nodes, the limit is ${MAX_NODES}` }
175
176 return { plan: rollup({ title: t.text, nodes: sortNodes(nodes), issued: fromCounters(counters) }, now) }
177 })
178
179// Appends nodes under `parentId` (top level when null). A new id is one more than both the
180// highest existing sibling and any id ever issued under that parent, so a removed id is not reused.
181export const addNodes = (prev: Plan, parentId: string | null, input: readonly PlanInputNode[], now: number): PlanResult =>
182 safe(() => {
183 if (input.length === 0) return { error: 'no nodes to add' }
184 if (parentId !== null && !prev.nodes.some(n => n.id === parentId)) {
185 return { error: `unknown parent "${clean(String(parentId))}"; ${validIds(prev)}` }
186 }
187 const counters = toCounters(prev)
188 const made = flatten(input, parentId, parentId === null ? 1 : depthOf(parentId) + 1, counters, now)
189 if ('error' in made) return made
190 const nodes = [...prev.nodes, ...made.nodes]
191 if (nodes.length > MAX_NODES) return { error: `the plan would have ${nodes.length} nodes, the limit is ${MAX_NODES}` }
192
193 return { plan: rollup({ ...prev, nodes: sortNodes(nodes), issued: fromCounters(counters) }, now) }
194 })
195
196// Batch patch: all updates apply or none. Status can only be set on a leaf (a parent's status
197// is derived). `note: ''` clears the note.
198export const updateNodes = (prev: Plan, updates: readonly NodeUpdate[], now: number): PlanResult =>
199 safe(() => {
200 if (updates.length === 0) return { error: 'no updates given' }
201 const nodes = prev.nodes.map(n => ({ ...n }))
202 for (const u of updates) {
203 const node = nodes.find(n => n.id === u.id)
204 if (node === undefined) return { error: `unknown id "${clean(String(u.id))}"; ${validIds(prev)}` }
205 if (u.status !== undefined) {
206 if (!STATUSES.includes(u.status)) return { error: `unknown status "${clean(String(u.status))}"; use ${STATUSES.join(', ')}` }
207 if (!isLeaf(nodes, node.id)) return { error: `"${node.id}" has children, so its status comes from them; update a leaf` }
208 node.status = u.status
209 }
210 if (u.title !== undefined) {
211 const t = checkText('node title', u.title, MAX_TITLE, false)
212 if ('error' in t) return t
213 node.title = t.text
214 }
215 if (u.note !== undefined) {
216 const n = checkText('note', u.note, MAX_NOTE, true)
217 if ('error' in n) return n
218 if (n.text === '') delete node.note
219 else node.note = n.text
220 }
221 if (u.parallel !== undefined) {
222 if (u.parallel && isLeaf(nodes, node.id)) return { error: `"${node.id}" has no children, so "parallel": true does not apply; set it on a parent` }
223 if (u.parallel) node.parallel = true
224 else delete node.parallel
225 }
226 node.updatedAt = now
227 }
228 const next = rollup({ ...prev, nodes }, now)
229 // Clearing parallel can strand concurrent leaves, so it is checked too.
230 if (updates.some(u => u.status === 'in_progress' || u.parallel === false)) {
231 const conflict = validateConcurrency(next)
232 if (conflict !== null) return { error: conflict }
233 }
234
235 return { plan: next }
236 })
237
238// Drops a node and its subtree. Ids already issued stay recorded, so they are never reused.
239const withoutParallel = (n: PlanNode): PlanNode => {
240 const rest = { ...n }
241 delete rest.parallel
242
243 return rest
244}
245
246export const removeNode = (prev: Plan, id: string, now: number): PlanResult =>
247 safe(() => {
248 if (!prev.nodes.some(n => n.id === id)) return { error: `unknown id "${clean(String(id))}"; ${validIds(prev)}` }
249 const nodes = prev.nodes.filter(n => n.id !== id && !n.id.startsWith(`${id}.`))
250 // Counters of the removed subtree go with it: its parent ids are never issued again.
251 const issued = prev.issued.filter(c => c.parent !== id && !c.parent.startsWith(`${id}.`))
252 // A parent left without children becomes a leaf: its derived status is stale, so restart it.
253 const parent = prev.nodes.find(n => n.id === id)?.parentId ?? null
254 if (parent !== null && isLeaf(nodes, parent)) {
255 return { plan: rollup({ ...prev, nodes: nodes.map(n => (n.id === parent ? withoutParallel({ ...n, status: 'pending', updatedAt: now }) : n)), issued }, now) }
256 }
257
258 return { plan: rollup({ ...prev, nodes, issued }, now) }
259 })
260
261// Counts leaves. Skipped leaves count as done (finished), so a plan with a skipped step can
262// still reach 100%.
263export const progress = (plan: Plan): { done: number; total: number } => {
264 const all = leaves(plan)
265
266 return { done: all.filter(n => n.status === 'completed' || n.status === 'skipped').length, total: all.length }
267}
268
269// The node Claude is working on: the first in_progress leaf in tree order, else the first
270// pending leaf, else null.
271export const currentNode = (plan: Plan): PlanNode | null => {
272 const all = leaves(plan)
273
274 return all.find(n => n.status === 'in_progress') ?? all.find(n => n.status === 'pending') ?? null
275}
276
277// True while any leaf is pending, in_progress or blocked (blocked work is still open).
278export const activeLeaves = (plan: Plan): PlanNode[] => leaves(plan).filter(n => n.status === 'in_progress')
279
280const ancestorsOf = (plan: Plan, id: string): string[] => {
281 const byId = new Map(plan.nodes.map(n => [n.id, n]))
282 const out: string[] = []
283 let parent = byId.get(id)?.parentId ?? null
284 while (parent !== null) {
285 out.push(parent)
286 parent = byId.get(parent)?.parentId ?? null
287 }
288
289 return out
290}
291
292// Concurrency rule: two in_progress leaves may coexist only when their lowest common ancestor is
293// itself a node with parallel: true. Leaves with no common ancestor (two top-level branches) have
294// none, so they are never allowed together. A parallel group nested in a sequential parent therefore
295// does not allow a leaf outside the group to run alongside it, and two leaves inside one sequential
296// child of a parallel node are rejected (the lowest common ancestor is that sequential child).
297export const validateConcurrency = (plan: Plan): string | null => {
298 const active = activeLeaves(plan)
299 const byId = new Map(plan.nodes.map(n => [n.id, n]))
300 for (const [i, a] of active.entries()) {
301 const aUp = ancestorsOf(plan, a.id)
302 for (const b of active.slice(i + 1)) {
303 const common = ancestorsOf(plan, b.id).find(id => aUp.includes(id)) ?? null
304 if (common !== null && byId.get(common)?.parallel === true) continue
305 const where = common === null ? 'the top level' : `"${common}"`
306
307 return `"${a.id}" and "${b.id}" cannot both be in_progress: their closest shared parent is ${where}, which is not parallel. Put them under a parent with "parallel": true, or finish one first`
308 }
309 }
310
311 return null
312}
313
314export const hasUnfinished = (plan: Plan): boolean =>
315 leaves(plan).some(n => n.status === 'pending' || n.status === 'in_progress' || n.status === 'blocked')
316hooks/plan-tool.ts 270 lines1import { addNodes, MAX_DEPTH, MAX_NOTE, MAX_TITLE, progress, removeNode, setPlan, updateNodes } from './plan'
2import type { NodeUpdate, Plan, PlanInputNode, PlanStatus } from './plan'
3import { clean } from './sanitize'
4
5export const PLAN_TOOL_SHORT_NAME = 'plan'
6
7export const MAX_FORMAT_CHARS = 4000
8
9export const PLAN_OPS = ['set', 'add', 'update', 'remove', 'show'] as const
10export type PlanOpName = (typeof PLAN_OPS)[number]
11
12const PLAN_STATUSES: readonly PlanStatus[] = ['pending', 'in_progress', 'completed', 'blocked', 'skipped']
13
14export const PLAN_TOOL_DESCRIPTION = [
15 'Keep a plan tree for the current task. Call op "set" once at the start of any task that needs tools, with a short title and the steps as nodes (children nest up to 3 levels).',
16 'Put steps that do not conflict (different files, independent subagents) under one parent with "parallel": true and run them concurrently; outside a parallel group keep exactly one leaf in_progress at a time, and mark each leaf completed with op "update" as soon as it is done.',
17 'If a step cannot proceed or is dropped, set it to blocked or skipped and give a note that says why.',
18 'Use op "add" to attach new subtasks under a node (or at the top level) when the work grows, op "remove" to drop a node and its subtree, and op "show" to read the current tree and ids.',
19 'A parent node takes its status from its children, so only update leaves.',
20].join(' ')
21
22// unknown: a JSON Schema fragment is free-form JSON that the engine passes through untouched, so no narrower type exists.
23type SchemaNode = Record<string, unknown>
24
25// Written out level by level because the schema must not use $ref.
26const nodeSchema = (levels: number): SchemaNode => {
27 const properties: Record<string, SchemaNode> = {
28 title: { type: 'string', description: `Short step title, at most ${MAX_TITLE} characters` },
29 activeForm: { type: 'string', description: 'Present-tense form shown while the step runs, e.g. "Writing tests"' },
30 parallel: { type: 'boolean', description: 'True when this parent\'s children do not conflict and may run concurrently; parents only' },
31 }
32 if (levels > 1) {
33 properties.children = { type: 'array', description: 'Substeps', items: nodeSchema(levels - 1) }
34 }
35
36 return { type: 'object', properties, required: ['title'] }
37}
38
39// JSON Schema is open-ended data, hence the Record<string, unknown> shape the ToolSpec type itself uses.
40export const PLAN_INPUT_SCHEMA: Record<string, unknown> = {
41 type: 'object',
42 properties: {
43 op: { type: 'string', enum: [...PLAN_OPS], description: 'What to do with the plan' },
44 title: { type: 'string', description: 'Plan title (op set)' },
45 nodes: { type: 'array', description: 'Steps to create (ops set and add)', items: nodeSchema(MAX_DEPTH) },
46 parent: { type: 'string', description: 'Id of the node to add under, e.g. "2" or "2.1"; omit for the top level (op add)' },
47 updates: {
48 type: 'array',
49 description: 'Patches to apply (op update)',
50 items: {
51 type: 'object',
52 properties: {
53 id: { type: 'string', description: 'Node id, e.g. "2.1"' },
54 status: { type: 'string', enum: [...PLAN_STATUSES], description: 'New status; leaves only' },
55 title: { type: 'string', description: 'New title' },
56 parallel: { type: 'boolean', description: 'Set or clear the parallel flag; parents only' },
57 note: { type: 'string', description: `Why it is blocked or skipped, at most ${MAX_NOTE} characters; empty text clears it` },
58 },
59 required: ['id'],
60 },
61 },
62 id: { type: 'string', description: 'Id of the node to remove, with its subtree (op remove)' },
63 },
64 required: ['op'],
65}
66
67export type PlanOp =
68 | { op: 'set'; title: string; nodes: PlanInputNode[] }
69 | { op: 'add'; parent: string | null; nodes: PlanInputNode[] }
70 | { op: 'update'; updates: NodeUpdate[] }
71 | { op: 'remove'; id: string }
72 | { op: 'show' }
73
74// Only ops that write the plan count as planning for the gate (D2). `show` is read-only, so it
75// must not let the model bypass the gate on an empty plan.
76export const touchesPlan = (op: PlanOp): boolean => op.op !== 'show'
77
78export type ParsedPlanInput = { parsed: PlanOp } | { error: string }
79
80export type PlanApplied = { plan: Plan; text: string } | { error: string }
81
82const fail = (message: string): { error: string } => ({ error: `Error: ${message}` })
83
84// tool.call arguments arrive as McpToolCallInputFallback (loose keys), so every field is checked here.
85// unknown is genuinely needed: nothing about the model's JSON is known until it is narrowed.
86type Raw = Record<string, unknown>
87
88// unknown: type guard over untrusted model input.
89const isRecord = (v: unknown): v is Raw => typeof v === 'object' && v !== null && !Array.isArray(v)
90
91// unknown: the value is read from untrusted model input and narrowed to string here.
92const stringField = (obj: Raw, key: string, where: string): { value: string | undefined } | { error: string } => {
93 const v = obj[key]
94 if (v === undefined) return { value: undefined }
95 if (typeof v !== 'string') return fail(`${where}${key} must be a string, got ${describe(v)}`)
96
97 return { value: v }
98}
99
100// unknown: only reports the runtime type of untrusted model input in an error message.
101const boolField = (obj: Raw, key: string, where: string): { value: boolean | undefined } | { error: string } => {
102 const v = obj[key]
103 if (v === undefined) return { value: undefined }
104 if (typeof v !== 'boolean') return fail(`${where}${key} must be a boolean, got ${describe(v)}`)
105
106 return { value: v }
107}
108
109const describe = (v: unknown): string => (v === null ? 'null' : Array.isArray(v) ? 'an array' : `a ${typeof v}`)
110
111// unknown: nodes arrive as untrusted model input and are validated element by element.
112const parseNodes = (raw: unknown, maxLevels: number, where: string, level = 1): { nodes: PlanInputNode[] } | { error: string } => {
113 if (!Array.isArray(raw)) return fail(`${where} must be an array, got ${describe(raw)}`)
114 const nodes: PlanInputNode[] = []
115 for (const [i, item] of raw.entries()) {
116 const at = `${where}[${i}]`
117 if (!isRecord(item)) return fail(`${at} must be an object, got ${describe(item)}`)
118 if (typeof item.title !== 'string') return fail(`${at}.title must be a string, got ${describe(item.title)}`)
119 const node: PlanInputNode = { title: item.title }
120 const form = stringField(item, 'activeForm', `${at}.`)
121 if ('error' in form) return form
122 if (form.value !== undefined) node.activeForm = form.value
123 const par = boolField(item, 'parallel', `${at}.`)
124 if ('error' in par) return par
125 if (par.value !== undefined) node.parallel = par.value
126 if (item.children !== undefined) {
127 if (!Array.isArray(item.children)) return fail(`${at}.children must be an array, got ${describe(item.children)}`)
128 if (item.children.length > 0) {
129 if (level >= maxLevels) return fail(`${at}.children is too deep: the plan allows ${MAX_DEPTH} levels in total, depth ${level + 1} is not allowed here`)
130 const kids = parseNodes(item.children, maxLevels, `${at}.children`, level + 1)
131 if ('error' in kids) return kids
132 node.children = kids.nodes
133 }
134 }
135 nodes.push(node)
136 }
137
138 return { nodes }
139}
140
141// unknown: updates arrive as untrusted model input and are validated element by element.
142const parseUpdates = (raw: unknown): { updates: NodeUpdate[] } | { error: string } => {
143 if (!Array.isArray(raw)) return fail(`updates must be an array, got ${describe(raw)}`)
144 const updates: NodeUpdate[] = []
145 for (const [i, item] of raw.entries()) {
146 const at = `updates[${i}]`
147 if (!isRecord(item)) return fail(`${at} must be an object, got ${describe(item)}`)
148 if (typeof item.id !== 'string') return fail(`${at}.id must be a string, got ${describe(item.id)}`)
149 const update: NodeUpdate = { id: item.id }
150 if (item.status !== undefined) {
151 if (typeof item.status !== 'string') return fail(`${at}.status must be a string, got ${describe(item.status)}`)
152 const status = PLAN_STATUSES.find(s => s === item.status)
153 if (status === undefined) return fail(`${at}.status "${clean(item.status)}" is unknown; use ${PLAN_STATUSES.join(', ')}`)
154 update.status = status
155 }
156 const title = stringField(item, 'title', `${at}.`)
157 if ('error' in title) return title
158 if (title.value !== undefined) update.title = title.value
159 const note = stringField(item, 'note', `${at}.`)
160 if ('error' in note) return note
161 if (note.value !== undefined) update.note = note.value
162 const par = boolField(item, 'parallel', `${at}.`)
163 if ('error' in par) return par
164 if (par.value !== undefined) update.parallel = par.value
165 updates.push(update)
166 }
167
168 return { updates }
169}
170
171// unknown: raw is the loose tool.call input (McpToolCallInputFallback), validated here before any typed use.
172export const parsePlanInput = (raw: unknown): ParsedPlanInput => {
173 if (!isRecord(raw)) return fail(`the plan tool takes an object with an "op" field, got ${describe(raw)}`)
174 if (raw.op === undefined) return fail(`missing "op"; use one of ${PLAN_OPS.join(', ')}`)
175 if (typeof raw.op !== 'string') return fail(`"op" must be a string, got ${describe(raw.op)}`)
176 const op = PLAN_OPS.find(o => o === raw.op)
177 if (op === undefined) return fail(`unknown op "${clean(raw.op)}"; use one of ${PLAN_OPS.join(', ')}`)
178
179 switch (op) {
180 case 'set': {
181 if (typeof raw.title !== 'string') return fail(`op set needs a "title" string, got ${describe(raw.title)}`)
182 if (raw.nodes === undefined) return fail('op set needs "nodes"')
183 const made = parseNodes(raw.nodes, MAX_DEPTH, 'nodes')
184 if ('error' in made) return made
185
186 return { parsed: { op, title: raw.title, nodes: made.nodes } }
187 }
188 case 'add': {
189 if (raw.nodes === undefined) return fail('op add needs "nodes"')
190 const parent = stringField(raw, 'parent', '')
191 if ('error' in parent) return parent
192 const parentId = parent.value === undefined || parent.value === '' ? null : parent.value
193 // A parent at depth 2 leaves one level for the new nodes.
194 const room = parentId === null ? MAX_DEPTH : MAX_DEPTH - parentId.split('.').length
195 if (room < 1) return fail(`"${clean(parentId ?? '')}" is already at depth ${MAX_DEPTH}; add under a shallower node`)
196 const made = parseNodes(raw.nodes, room, 'nodes')
197 if ('error' in made) return made
198
199 return { parsed: { op, parent: parentId, nodes: made.nodes } }
200 }
201 case 'update': {
202 if (raw.updates === undefined) return fail('op update needs "updates"')
203 const made = parseUpdates(raw.updates)
204 if ('error' in made) return made
205
206 return { parsed: { op, updates: made.updates } }
207 }
208 case 'remove': {
209 if (typeof raw.id !== 'string') return fail(`op remove needs an "id" string, got ${describe(raw.id)}`)
210
211 return { parsed: { op, id: raw.id } }
212 }
213 case 'show':
214 return { parsed: { op } }
215 }
216}
217
218const STATUS_WORD: Record<PlanStatus, string> = {
219 pending: 'pending',
220 in_progress: 'in_progress',
221 completed: 'completed',
222 blocked: 'blocked',
223 skipped: 'skipped',
224}
225
226// Plain text, one node per line, indented by depth: `2.1 [in_progress] Title (note)`. No ANSI, capped.
227export const formatForModel = (plan: Plan): string => {
228 if (plan.nodes.length === 0) return 'No plan yet. Call op "set" with a title and nodes to create one.'
229 const { done, total } = progress(plan)
230 const head = `Plan: ${clean(plan.title)} (${done}/${total} done)`
231 const lines = plan.nodes.map(n => {
232 const note = n.note === undefined ? '' : ` (${clean(n.note)})`
233
234 const par = n.parallel === true ? ' [parallel]' : ''
235
236 return `${' '.repeat(n.id.split('.').length - 1)}${n.id} [${STATUS_WORD[n.status]}] ${clean(n.title)}${par}${note}`
237 })
238 const out = [head]
239 let size = head.length
240 for (const [i, line] of lines.entries()) {
241 const left = lines.length - i
242 const tail = `\n... ${left} more nodes not shown; use op "show" after removing finished nodes`
243 const fits = left === 1 ? size + 1 + line.length <= MAX_FORMAT_CHARS : size + 1 + line.length + tail.length <= MAX_FORMAT_CHARS
244 if (!fits) {
245 out.push(tail.slice(1))
246
247 return out.join('\n').slice(0, MAX_FORMAT_CHARS)
248 }
249 out.push(line)
250 size += 1 + line.length
251 }
252
253 return out.join('\n').slice(0, MAX_FORMAT_CHARS)
254}
255
256export const applyPlanOp = (plan: Plan, op: PlanOp, now: number): PlanApplied => {
257 if (op.op === 'show') return { plan, text: formatForModel(plan) }
258 const result =
259 op.op === 'set'
260 ? setPlan(plan, op.title, op.nodes, now)
261 : op.op === 'add'
262 ? addNodes(plan, op.parent, op.nodes, now)
263 : op.op === 'update'
264 ? updateNodes(plan, op.updates, now)
265 : removeNode(plan, op.id, now)
266 if ('error' in result) return fail(result.error)
267
268 return { plan: result.plan, text: formatForModel(result.plan) }
269}
270hooks/post-tool.ts 66 lines1import type { TaskCreateInput, TaskUpdateInput, TodoInput } from './ingest'
2
3// Narrows the tool_input and tool_response of a finished TaskCreate, TaskUpdate or TodoWrite call
4// to what ingest.ts takes. The host types both fields `unknown`, so every field is checked here
5// and a bad shape gives null: the caller logs it and mirrors nothing.
6
7type Dict = Record<string, unknown>
8
9// `unknown` is justified: the host hands the tool's input and response over untyped.
10const isDict = (value: unknown): value is Dict => typeof value === 'object' && value !== null && !Array.isArray(value)
11
12const isText = (value: unknown): value is string => typeof value === 'string'
13
14// Task ids arrive as strings; a number is accepted and kept as text.
15const idText = (value: unknown): string | undefined =>
16 isText(value) && value !== '' ? value : typeof value === 'number' ? String(value) : undefined
17
18const TASK_STATUSES: readonly string[] = ['pending', 'in_progress', 'completed', 'deleted']
19const TODO_STATUSES: readonly string[] = ['pending', 'in_progress', 'completed']
20
21export const taskCreateFrom = (input: unknown, response: unknown): TaskCreateInput | null => {
22 if (!isDict(input) || !isDict(response) || !isDict(response.task)) return null
23 const id = idText(response.task.id)
24 if (id === undefined || !isText(input.subject)) return null
25 const out: TaskCreateInput = { id, subject: input.subject }
26 if (isText(input.activeForm)) out.activeForm = input.activeForm
27
28 return out
29}
30
31// A TaskUpdate the tool itself reported as failed: a known outcome, not an unrecognised shape.
32export const taskUpdateFailed = (response: unknown): boolean => isDict(response) && response.success === false
33
34// Only a call the tool reported as successful (`success: true`) counts.
35export const taskUpdateFrom = (input: unknown, response: unknown): TaskUpdateInput | null => {
36 if (!isDict(input) || !isDict(response) || response.success !== true) return null
37 const taskId = idText(input.taskId)
38 if (taskId === undefined) return null
39 const out: TaskUpdateInput = { taskId }
40 if (isText(input.subject)) out.subject = input.subject
41 if (isText(input.activeForm)) out.activeForm = input.activeForm
42 if (input.status !== undefined) {
43 if (!isText(input.status) || !TASK_STATUSES.includes(input.status)) return null
44 out.status = input.status as TaskUpdateInput['status']
45 }
46
47 return out
48}
49
50const todoList = (value: unknown): TodoInput[] | null => {
51 if (!Array.isArray(value)) return null
52 const out: TodoInput[] = []
53 for (const item of value as unknown[]) {
54 if (!isDict(item) || !isText(item.content) || !isText(item.status) || !TODO_STATUSES.includes(item.status)) return null
55 const todo: TodoInput = { content: item.content, status: item.status as TodoInput['status'] }
56 if (isText(item.activeForm)) todo.activeForm = item.activeForm
57 out.push(todo)
58 }
59
60 return out
61}
62
63// The list the tool stored (`newTodos`), else the list the call sent.
64export const todosFrom = (input: unknown, response: unknown): TodoInput[] | null =>
65 (isDict(response) ? todoList(response.newTodos) : null) ?? (isDict(input) ? todoList(input.todos) : null)
66hooks/sanitize.ts 10 lines1// Model-supplied text goes through clean() before it reaches state or the terminal.
2// Control characters (including ESC, so an escape sequence cannot reach the terminal)
3// become spaces; bidi and zero-width characters are dropped.
4export const clean = (s: string): string =>
5 s
6 .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
7 .replace(/[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, '')
8 .replace(/\s+/g, ' ')
9 .trim()
10hooks/accent.ts 28 lines1import { DEFAULT_ACCENT } from './tree'
2
3const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/
4const BASE_NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'gray', 'grey']
5// The renderer silently ignores a colour name it does not know, so only names it draws are accepted.
6// Keyed by lower-case spelling, valued by the canonical camelCase spelling the renderer expects.
7const NAMED: Record<string, string> = Object.fromEntries([
8 ...BASE_NAMES.map(name => [name, name]),
9 ...BASE_NAMES.map(name => [`${name}bright`, `${name}Bright`]),
10 ['claude', 'claude'],
11])
12// The $.store key that keeps the colour set by `/todo color` across sessions.
13export const ACCENT_STORE_KEY = 'accentColor'
14
15// Returns the canonical accent for a #rrggbb hex or an allowed colour name (any letter case), else
16// null. The parameter is `unknown` because $.store.get and plugin options return untyped JSON.
17export const validAccent = (value: unknown): string | null => {
18 if (typeof value !== 'string') return null
19 if (HEX_PATTERN.test(value)) return value
20
21 return Object.hasOwn(NAMED, value.toLowerCase()) ? (NAMED[value.toLowerCase()] ?? null) : null
22}
23
24// Saved `/todo color` value > `accentColor` plugin option > DEFAULT_ACCENT. An invalid value at
25// either level is skipped. Both params are `unknown`: $.store.get and plugin options are untyped.
26export const resolveAccent = (saved: unknown, option: unknown): string =>
27 validAccent(saved) ?? validAccent(option) ?? DEFAULT_ACCENT
28hooks/tree.ts 362 lines1import type { ActivityState, Plan, PlanNode, PlanStatus } from '../types'
2import { activityLabel } from './activity'
3import { activeLeaves, currentNode, progress } from './plan'
4
5export const GLYPHS = {
6 completed: '✓',
7 in_progress: '◉',
8 pending: '○',
9 blocked: '■',
10 skipped: '–',
11 branch: '├─',
12 last: '└─',
13 pipe: '│',
14 filled: '━',
15 track: '─',
16 marker: '◂',
17 parallel: '∥',
18} as const
19
20export const DEFAULT_WIDTH = 56
21export const DEFAULT_ACCENT = 'claude'
22const STATUS_TITLE_MAX = 30
23// Below this width the pane drops the percentage, right-aligned counts and long notes.
24export const NARROW_WIDTH = 50
25const NARROW_NOTE_MAX = 20
26const NOTE_FLOOR = 12
27export const COMPACT_BELOW = 10
28
29// One run of text with one style. A row is a list of these so ids, connectors and titles
30// can be styled apart. There is deliberately no inverse or background field.
31export type Seg = {
32 text: string
33 color?: string
34 bold: boolean
35 dim: boolean
36 strikethrough?: boolean
37}
38
39export type TreeLine = {
40 // The segments joined: what the row reads as without colour.
41 text: string
42 segments: Seg[]
43}
44
45export type TreeOptions = { maxLines: number; width?: number; accent?: string }
46
47const seg = (text: string, over: Partial<Seg> = {}): Seg => ({ text, bold: false, dim: false, ...over })
48const dimSeg = (text: string): Seg => seg(text, { dim: true })
49const lineOf = (segments: Seg[]): TreeLine => ({ text: segments.map(s => s.text).join(''), segments })
50const blank = (): TreeLine => lineOf([seg('')])
51
52const percent = (done: number, total: number): number => (total === 0 ? 0 : Math.round((done / total) * 100))
53
54const shorten = (text: string, max: number): string => {
55 if (text.length <= max) return text
56
57 return max <= 1 ? '…' : `${text.slice(0, max - 1)}…`
58}
59
60const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n))
61
62const bar = (done: number, total: number, accent: string, width: number): Seg[] => {
63 const narrow = width < NARROW_WIDTH
64 const cells = narrow ? clamp(width - 14, 6, 40) : clamp(width - 16, 10, 40)
65 const filled = total === 0 ? 0 : Math.round((done / total) * cells)
66
67 return [
68 seg(GLYPHS.filled.repeat(filled), { color: accent }),
69 dimSeg(GLYPHS.track.repeat(cells - filled)),
70 seg(' '),
71 dimSeg(narrow ? `${done}/${total}` : `${done}/${total} · ${percent(done, total)}%`),
72 ]
73}
74
75const childrenOf = (nodes: readonly PlanNode[], id: string): PlanNode[] => nodes.filter(n => n.parentId === id)
76
77const leavesUnder = (nodes: readonly PlanNode[], id: string): PlanNode[] => {
78 const kids = childrenOf(nodes, id)
79
80 return kids.length === 0 ? [] : kids.flatMap(k => (childrenOf(nodes, k.id).length === 0 ? [k] : leavesUnder(nodes, k.id)))
81}
82
83const countUnder = (nodes: readonly PlanNode[], id: string): string => {
84 const under = leavesUnder(nodes, id)
85 const done = under.filter(n => n.status === 'completed' || n.status === 'skipped').length
86
87 return `${done}/${under.length}`
88}
89
90const highlighted = (plan: Plan): PlanNode | null => {
91 const current = currentNode(plan)
92 if (current !== null) return current
93
94 return plan.nodes.find(n => n.status === 'blocked' && childrenOf(plan.nodes, n.id).length === 0) ?? null
95}
96
97const glyphStyle = (status: PlanStatus, accent: string): Partial<Seg> => {
98 switch (status) {
99 case 'completed':
100 return { color: 'green' }
101 case 'in_progress':
102 return { color: accent }
103 case 'blocked':
104 return { color: 'yellow' }
105 case 'skipped':
106 return { dim: true }
107 case 'pending':
108 return {}
109 }
110}
111
112// The note text without a leading space: a leaf's own note, with its status when blocked or
113// skipped. A parent (hasKids) and a blocked leaf with no note carry none: the glyph says it.
114const noteOf = (node: PlanNode, hasKids: boolean): string => {
115 if (hasKids) return ''
116 const tagged = node.status === 'blocked' || node.status === 'skipped'
117 if (node.note !== undefined) return tagged ? `(${node.status}: ${node.note})` : `(${node.note})`
118
119 return node.status === 'skipped' ? '(skipped)' : ''
120}
121
122type Row = { line: TreeLine; keep: boolean; node: PlanNode; top: boolean }
123
124type Ctx = { plan: Plan; currentId: string | null; runningIds: readonly string[]; accent: string; width: number }
125
126const PARALLEL_TAG = ` ${GLYPHS.parallel} parallel`
127const PARALLEL_TAG_NARROW = ` ${GLYPHS.parallel}`
128
129const rowFor = (ctx: Ctx, node: PlanNode, lead: string, hasKids: boolean, collapse: boolean): TreeLine => {
130 const { plan, currentId, runningIds, accent, width } = ctx
131 const isCurrent = node.id === currentId
132 const isRunning = !hasKids && runningIds.includes(node.id)
133 const narrow = width < NARROW_WIDTH
134 const tag = node.parallel === true && hasKids ? (narrow ? PARALLEL_TAG_NARROW : PARALLEL_TAG) : ''
135 const count = hasKids ? countUnder(plan.nodes, node.id) : null
136 const marker = isCurrent ? ` ${GLYPHS.marker}` : ''
137 const countWidth = count === null ? 0 : count.length + 1
138 const rest = lead.length + 2 + node.id.length + 1 + tag.length + marker.length + countWidth
139 const avail = width - rest
140 const full = collapse ? '' : noteOf(node, hasKids)
141 let note = ''
142 let title = node.title
143 if (full !== '') {
144 const cap = narrow ? NARROW_NOTE_MAX : Number.POSITIVE_INFINITY
145 const fitsAfterTitle = avail - node.title.length - 1
146 const floor = Math.min(NOTE_FLOOR, cap, full.length)
147 const room = Math.min(clamp(fitsAfterTitle, floor, cap), avail - 2)
148 if (room >= 2) note = shorten(full, room)
149 }
150 title = shorten(title, Math.max(1, avail - (note === '' ? 0 : note.length + 1)))
151 let titleSeg: Seg
152 let glyphSeg: Seg
153 if (collapse) {
154 titleSeg = dimSeg(title)
155 glyphSeg = dimSeg(GLYPHS[node.status])
156 } else {
157 glyphSeg = seg(GLYPHS[node.status], glyphStyle(node.status, accent))
158 if (isCurrent || isRunning) titleSeg = seg(title, { color: accent, bold: true })
159 else if (hasKids) titleSeg = seg(title, { bold: true })
160 else if (node.status === 'completed') titleSeg = dimSeg(title)
161 else if (node.status === 'in_progress') titleSeg = seg(title, { bold: true })
162 else if (node.status === 'skipped') titleSeg = seg(title, { dim: true, strikethrough: true })
163 else titleSeg = seg(title)
164 }
165 const segs: Seg[] = [dimSeg(lead), glyphSeg, seg(' '), dimSeg(node.id), seg(' '), titleSeg]
166 if (narrow && count !== null) segs.push(dimSeg(` ${count}`))
167 if (tag !== '') segs.push(dimSeg(tag))
168 if (note !== '') segs.push(dimSeg(` ${note}`))
169 if (marker !== '') segs.push(dimSeg(marker))
170 if (!narrow && count !== null) {
171 const used = segs.reduce((n, s) => n + s.text.length, 0)
172 segs.push(seg(' '.repeat(Math.max(1, width - used - count.length))), dimSeg(count))
173 }
174
175 return lineOf(segs)
176}
177
178const rowsFor = (ctx: Ctx, parentId: string | null, prefix: string): Row[] => {
179 const { plan, runningIds } = ctx
180 const kids = plan.nodes.filter(n => n.parentId === parentId)
181 const out: Row[] = []
182 kids.forEach((node, i) => {
183 const isLast = i === kids.length - 1
184 const hasKids = childrenOf(plan.nodes, node.id).length > 0
185 const isRunning = !hasKids && runningIds.includes(node.id)
186 const isAncestor = runningIds.some(id => id.startsWith(`${node.id}.`))
187 const collapse = hasKids && node.status === 'completed' && !isAncestor
188 const lead = `${prefix}${isLast ? GLYPHS.last : GLYPHS.branch} `
189 out.push({ line: rowFor(ctx, node, lead, hasKids, collapse), keep: isRunning || isAncestor, node, top: parentId === null })
190 if (hasKids && !collapse) {
191 out.push(...rowsFor(ctx, node.id, `${prefix}${isLast ? ' ' : `${GLYPHS.pipe} `}`))
192 }
193 })
194
195 return out
196}
197
198const activityLine = (activity: ActivityState, accent: string, width: number): TreeLine | null => {
199 let label = activityLabel(activity)
200 if (label === undefined) return null
201 if (width < NARROW_WIDTH && label.length + 2 > width) {
202 // Drop the subagent count first, then cut what is left to the width.
203 label = activityLabel({ ...activity, subagents: [] }) ?? label
204 }
205 const text = shorten(`${GLYPHS.in_progress} ${label}`, width)
206 switch (activity.phase) {
207 case 'permission':
208 case 'question':
209 return lineOf([seg(text, { color: 'yellow' })])
210 case 'error':
211 return lineOf([seg(text, { color: 'red' })])
212 case 'interrupted':
213 return lineOf([dimSeg(text)])
214 case 'idle':
215 return lineOf([dimSeg(shorten(`${GLYPHS.pending} ${label}`, width))])
216 case 'working':
217 case 'tool':
218 case 'compacting':
219 return lineOf([seg(text, { color: accent })])
220 }
221}
222
223const doneTop = (r: Row): boolean => r.node.status === 'completed' && !r.keep
224
225// "1-2, 4-7" for the top-level ids, in tree order, runs of adjacent rows joined with an en dash.
226const rangesOf = (ids: readonly string[][]): string =>
227 ids.map(run => (run.length === 1 ? run[0] : `${run[0]}–${run[run.length - 1]}`)).join(', ')
228
229const overflowLabel = (hidden: readonly Row[]): string => {
230 const count = (pick: (s: PlanStatus) => boolean): number => hidden.filter(r => pick(r.node.status)).length
231 const parts: [number, string][] = [
232 [count(s => s === 'completed' || s === 'skipped'), 'done'],
233 [count(s => s === 'in_progress'), 'running'],
234 [count(s => s === 'blocked'), 'blocked'],
235 [count(s => s === 'pending'), 'pending'],
236 ]
237 const text = parts.filter(([n]) => n > 0).map(([n, name]) => `${n} ${name}`).join(', ')
238
239 return `+${hidden.length} more${text === '' ? '' : ` · ${text}`}`
240}
241
242// Picks which rows fit in `budget` lines, always in tree order. Kept first: the paths to the
243// running leaves; then every top-level row; then the current leaf's siblings; then the rest.
244// When the first two tiers alone do not fit, completed top-level rows fold into one line.
245const fitRows = (rows: Row[], budget: number, current: PlanNode | null, width: number): TreeLine[] => {
246 const keepCount = rows.filter(r => r.keep).length
247 const topCount = rows.filter(r => !r.keep && r.top).length
248 const fold = keepCount + topCount > budget
249 const folded = fold ? rows.filter(r => r.top && doneTop(r)) : []
250 const foldLine = ((): TreeLine | null => {
251 if (folded.length === 0) return null
252 const runs: string[][] = []
253 let prev = -2
254 rows.forEach((r, i) => {
255 if (!folded.includes(r)) return
256 if (i === prev + 1 && runs.length > 0) runs[runs.length - 1]?.push(r.node.id)
257 else runs.push([r.node.id])
258 prev = i
259 })
260
261 return lineOf([dimSeg(shorten(`${GLYPHS.completed} ${rangesOf(runs)} done`, width))])
262 })()
263 const tierOf = (r: Row): number => {
264 if (r.keep) return 0
265 if (r.top) return 1
266 if (current !== null && r.node.parentId === current.parentId) return 2
267
268 return 3
269 }
270 const picked = new Set<Row>(rows.filter(r => r.keep))
271 let spare = budget - keepCount - (foldLine === null ? 0 : 1)
272 for (const tier of [1, 2, 3]) {
273 for (const row of rows) {
274 if (spare <= 0) break
275 if (tierOf(row) === tier && !folded.includes(row)) {
276 picked.add(row)
277 spare -= 1
278 }
279 }
280 }
281 const out: TreeLine[] = []
282 let foldPlaced = false
283 const hidden: Row[] = []
284 for (const row of rows) {
285 if (folded.includes(row)) {
286 if (!foldPlaced && foldLine !== null) out.push(foldLine)
287 foldPlaced = true
288 } else if (picked.has(row)) out.push(row.line)
289 else hidden.push(row)
290 }
291 if (hidden.length > 0) out.push(lineOf([dimSeg(shorten(overflowLabel(hidden), width))]))
292
293 return out
294}
295
296const compactHead = (plan: Plan, done: number, total: number, accent: string, width: number): TreeLine => {
297 const cells = clamp(Math.round(width / 6), 4, 10)
298 const filled = total === 0 ? 0 : Math.round((done / total) * cells)
299 const count = `${done}/${total}`
300 const title = shorten(plan.title, Math.max(1, width - cells - count.length - 4))
301
302 return lineOf([
303 seg(title, { bold: true }),
304 seg(' '),
305 seg(GLYPHS.filled.repeat(filled), { color: accent }),
306 dimSeg(GLYPHS.track.repeat(cells - filled)),
307 seg(' '),
308 dimSeg(count),
309 ])
310}
311
312export const buildTree = (plan: Plan, activity: ActivityState, opts: TreeOptions): TreeLine[] => {
313 const width = opts.width ?? DEFAULT_WIDTH
314 const accent = opts.accent ?? DEFAULT_ACCENT
315 const act = activityLine(activity, accent, width)
316 if (plan.nodes.length === 0) {
317 const empty = lineOf([dimSeg('No plan yet.')])
318
319 return act === null ? [empty] : [empty, act]
320 }
321 const { done, total } = progress(plan)
322 const tight = opts.maxLines < COMPACT_BELOW
323 const head: TreeLine[] = tight
324 ? [compactHead(plan, done, total, accent, width)]
325 : [lineOf([seg(shorten(plan.title, width), { bold: true })]), lineOf(bar(done, total, accent, width))]
326 if (act !== null) head.push(act)
327 if (!tight) head.push(blank())
328 const current = highlighted(plan)
329 const runningIds = [...new Set([...activeLeaves(plan).map(n => n.id), ...(current === null ? [] : [current.id])])]
330 const rows = rowsFor({ plan, currentId: current?.id ?? null, runningIds, accent, width }, null, '')
331 const room = opts.maxLines - head.length
332 if (rows.length <= room) return [...head, ...rows.map(r => r.line)]
333
334 return [...head, ...fitRows(rows, Math.max(room - 1, 1), current, width)]
335}
336
337export const preferredRows = (plan: Plan, activity: ActivityState): number =>
338 buildTree(plan, activity, { maxLines: Number.POSITIVE_INFINITY }).length
339
340export const PANE_MIN_ROWS = 6
341export const PANE_MAX_ROWS = 20
342
343// The body height to ask the host for: what the tree wants, clamped.
344export const paneRows = (plan: Plan, activity: ActivityState): number =>
345 Math.min(PANE_MAX_ROWS, Math.max(PANE_MIN_ROWS, preferredRows(plan, activity)))
346
347export const statusLine = (plan: Plan, activity: ActivityState): string | undefined => {
348 const label = activityLabel(activity)
349 if (plan.nodes.length === 0) return label
350 const { done, total } = progress(plan)
351 const node = highlighted(plan)
352 const parts = [`Plan ${done}/${total}`]
353 if (node !== null) {
354 const extra = activeLeaves(plan).length - 1
355 const first = shorten(node.activeForm ?? node.title, STATUS_TITLE_MAX)
356 parts.push(extra > 0 ? `${first} +${extra} more running` : first)
357 }
358 if (label !== undefined) parts.push(label)
359
360 return parts.join(' · ')
361}
362types/index.d.ts 83 lines1// Every atom is declared inline: `claude plugin validate` refuses a PluginState
2// entry that points at an alias. The named types below are for use in code.
3export type PlanStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'skipped'
4export type PlanSource = 'plan' | 'todo' | 'task'
5export type PlanNode = {
6 id: string
7 parentId: string | null
8 title: string
9 activeForm?: string
10 status: PlanStatus
11 note?: string
12 parallel?: boolean
13 source: PlanSource
14 externalId?: string
15 updatedAt: number
16}
17// `issued` remembers the last child number handed out per parent ('' is the top level), so a
18// removed id is never reused.
19export type Plan = {
20 title: string
21 nodes: PlanNode[]
22 issued: Array<{ parent: string; last: number }>
23}
24export type TaskState = { open: boolean; planned: boolean; denies: number }
25export type ActivityPhase =
26 | 'idle'
27 | 'working'
28 | 'tool'
29 | 'permission'
30 | 'question'
31 | 'compacting'
32 | 'interrupted'
33 | 'error'
34export type ActivityState = {
35 phase: ActivityPhase
36 tool?: string
37 detail?: string
38 resume?: ActivityPhase
39 subagents: string[]
40 running: Array<{ id: string; tool: string }>
41 since: number
42}
43export type PlanToolState = { name: string | null; offered: boolean }
44
45declare module 'claude-code' {
46 interface PluginState {
47 'todo-list': {
48 plan: {
49 title: string
50 nodes: Array<{
51 id: string
52 parentId: string | null
53 title: string
54 activeForm?: string
55 status: 'pending' | 'in_progress' | 'completed' | 'blocked' | 'skipped'
56 note?: string
57 parallel?: boolean
58 source: 'plan' | 'todo' | 'task'
59 externalId?: string
60 updatedAt: number
61 }>
62 issued: Array<{ parent: string; last: number }>
63 }
64 task: { open: boolean; planned: boolean; denies: number }
65 activity: {
66 phase: 'idle' | 'working' | 'tool' | 'permission' | 'question' | 'compacting' | 'interrupted' | 'error'
67 tool?: string
68 detail?: string
69 resume?: 'idle' | 'working' | 'tool' | 'permission' | 'question' | 'compacting' | 'interrupted' | 'error'
70 subagents: string[]
71 running: Array<{ id: string; tool: string }>
72 since: number
73 }
74 enforceSession: boolean
75 dropShown: boolean
76 accentOverride: string | null
77 firstPromptOpen: 'idle' | 'armed' | 'done'
78 planTool: { name: string | null; offered: boolean }
79 paneFit: { columns: number; rows: number; placement: string; wantRows: number } | null
80 }
81 }
82}
83