SLOPSHOPPER

todo-list

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

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · todo-list
│ ┃ Plan ✕ › fix the failing auth test and add an audit log call │ ┃ No plan yet. │ ● todo-list: todo-list: plan tool registration failed TypeError: unde │ ● todo-list: todo-list: turn.start threw TypeError: undefined is not │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /todo │ ⎿ todo-list: Plan pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan
No plan yet.
README

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

CI Version License Stars

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

Todo List pane showing a plan tree with completed, running and blocked steps

Features

  • Plan tree tool. Claude writes a tree (up to 3 levels, 60 nodes) through mcp__todo-list__plan. Steps that do not conflict can be grouped as parallel.
  • Pane and status line. Both draw the tree with per-node status and a progress bar.
  • Live activity. The current activity (running a tool, waiting for permission, compacting, subagents running) comes from session events, not from Claude's own report.
  • Plan-first enforcement. Edit, Write, Bash and other state-changing tools are denied until a plan exists. Enforcement fails open and can be turned off.
  • /todo command. Reopen the pane, clear the plan, switch enforcement, and set the accent color (Claude orange by default, saved across sessions).

Installation

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.

Usage

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:

CommandEffect
/todoOpen the pane (reopens it at the height the plan needs).
/todo clearEmpty the plan.
/todo offTurn plan enforcement off for this session.
/todo onTurn 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.

Pane

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.

Node status

GlyphStatusMeaning
✓CompletedThe step is done.
◉In progressThe step is running. Outside a parallel group, one leaf runs at a time.
○PendingNot started.
■BlockedThe step cannot proceed; the note says why.
–SkippedThe 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.

Activity

LabelWhen
WorkingA 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 answerClaude asked a question with AskUserQuestion.
CompactingThe conversation is being compacted.
N subagentsAppended to any label while N subagents run.
InterruptedThe turn was aborted, for example with Esc.
ErrorThe turn ended in an error or a refusal.
(none)The turn ended with an answer. The activity line is left out.

The plan tool

mcp__todo-list__plan takes an op field.

OpInputEffect
settitle, nodesReplaces the plan. Nodes nest up to 3 levels, 60 nodes in total.
addparent?, nodesAppends children under a node, or at the top level when parent is absent.
updateupdates of { id, status?, title?, note? }Batch patch. Status applies to leaves only.
removeidDrops a node and its subtree. Ids are never reused.
shownoneReturns 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.

TaskCreate, TaskUpdate and TodoWrite mirroring

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.

Enforcement

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:

  • the enforce option is false, or /todo off was run for the session;
  • the plan tool did not register, or was not offered to the model;
  • the gate throws an error;
  • 3 calls were denied in one turn without a plan call (the gate pauses for the rest of that turn and shows a toast).

Configuration

Options are set at install (--config), in settings, or per run:

OptionTypeDefaultEffect
enforcebooleantrueBlock state-changing tools until a plan exists.
accentColorstringclaudeTheme 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.

How it works: hooks

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.

HookWhat it doesWhat it decides, and whenWhat it changes
session.startLoads 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.describeFor 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.PermissionRequestRecords "waiting for permission" for the tool.Nothing. Observe-only.Passes the event on unchanged. Updates activity.
classic.PermissionDeniedEnds the denied call's activity and clears the permission label.Nothing. Observe-only.Passes the event on unchanged. Updates activity.
classic.SubagentStartRecords that a subagent started.Nothing. Observe-only.Passes the event on unchanged. Updates the subagent count.
classic.SubagentStopRecords that a subagent stopped.Nothing. Observe-only.Passes the event on unchanged. Updates the subagent count.
classic.StopFailureRecords that the turn ended in an error.Nothing. Observe-only.Passes the event on unchanged. Updates activity.
classic.NotificationWrites the notification type to the debug log.Nothing. Observe-only.Passes the event on unchanged.
classic.PreCompactMarks "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.PostCompactClears "Compacting" when the compaction ends. A subagent's compaction is ignored.Nothing. Observe-only.Passes the event on unchanged. Updates activity.
turn.completeRecords how the turn ended (answer, interrupted, error).Nothing. Observe-only.Passes the event on unchanged. Updates activity.
session.endOn /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.PostToolUseAfter 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.PostToolUseFailureAfter a failed call, ends its "Running" state.Nothing. Observe-only.Passes the event on unchanged. Updates activity.
prompt.composeChecks 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.submitOn 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.startReloads 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.

Data and privacy

  • The plugin makes no network calls.
  • The only persisted data is the accent color, stored under one plugin store key (accentColor) when you run /todo color. /todo color reset deletes it.
  • The plan, activity and enforcement switch live in session state and are not written to disk by the plugin. /clear resets them.
  • The plugin reads no credentials, tokens or environment variables, and does not read or write files or run processes.
  • The plan tool is registered locally through $.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.

Requirements

  • Claude Code 2.1.289, the version the API types were taken from.
  • The terminal surface. The pane and toast need it.

Development

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.

  • Mod path: .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.
  • Hot reload: saving a file in the mod reloads the module in a running session. The plan survives because state lives in $.state atoms and not in module variables.
  • Under the RTK shell hook, run the check as rtk proxy npm run check.

Known limitations

  • Third-party MCP tools are not gated, because the API gives no read-only flag before a call runs.
  • All of Bash is gated, including read-only commands such as ls.
  • Notification types other than permission_prompt are not mapped to an activity.
  • A subagent's permission prompt shows as the main session waiting for permission.
  • Dialogs cover the pane.

Star history

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

License

MIT. See LICENSE.

Source 12 files
hooks/register.tsx 723 lines
1import { 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}
723
hooks/activity.ts 216 lines
1// 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}
216
hooks/fit.ts 23 lines
1// 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}
23
hooks/gate.ts 114 lines
1import 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}").`
114
hooks/ingest.ts 96 lines
1import 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  )
96
hooks/plan.ts 316 lines
1// 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')
316
hooks/plan-tool.ts 270 lines
1import { 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}
270
hooks/post-tool.ts 66 lines
1import 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)
66
hooks/sanitize.ts 10 lines
1// 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()
10
hooks/accent.ts 28 lines
1import { 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
28
hooks/tree.ts 362 lines
1import 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}
362
types/index.d.ts 83 lines
1// 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