Submits a continue prompt while the task list has unfinished tasks, at most 99 times in a row by default, and turns the task tools on for every model unless…

The model writes a task list of eight steps, finishes three, and ends its turn with a summary; the session then sits idle until you type "go on". This mod does that for you: when a main-loop turn ends and the task list still has pending or in-progress tasks, it submits a continue prompt. It reads the engine's own task list at the session's start, so a list opened weeks ago in a session you resume today is counted from the first turn, whether or not the mod was installed when those tasks were made. It stops after 99 consecutive pokes, after the limit you set with /task-poke limit <n>, or after three pokes in a row that moved nothing. A prompt you send (from the composer, the bridge or the SDK) resets the count.
It reads both task formats:
TodoWrite: each call replaces the whole list.TaskCreate, TaskUpdate and TaskList (default since Claude Code 2.1.142): TaskCreate adds a task, and the id comes from its tool result ({ task: { id } }). TaskUpdate patches a task by taskId (the raw id and task_id keys are also read). status: "deleted" removes a task. A TaskList result carries the whole list, so it replaces what the replay held, and the rows after it apply on top.A later TodoWrite replaces any state built from the Task tools.
The poke runs the mod's own markdown command /task-poke:send <prompt>, whose body is its arguments alone. The transcript shows that command line, and the model reads the poke as it is written, as it reads a typed slash command. A $.prompt.submit text would reach it inside a The task-poke plugin sent a message: frame. The command runs from a timer once the turn ended, because the engine refuses $.command.run inside the turn.complete hook the turn waits on. When the engine refuses the command, one red entry says so and the poke goes out as a plugin prompt, with that frame.
In the live check on 2.1.282 a turn that left one of two tasks pending got three pokes as /task-poke:send The task list still has unfinished tasks. ..., the model answered each with words, and the mod stopped after the third.
$.session.messages() answers the newest messages of a long transcript alone, so a replay of that window sees no task created before it. The mod closes that gap from two sides:
$.tool.call({ tool: 'TaskList' }). A resumed session brings back tasks whose TaskCreate sits far outside the window, and this reading counts them from the first turn. The call carries no message into the conversation: it leaves no tool row and the model never sees it (measured). When it fails, because the task tools are off or the engine refused it, one red line says so and the transcript answers alone.A TaskList the model itself calls is read the same way: its result is the whole list, and it replaces what the replay held.
Claude Code offers the task-tracking tools only on Claude 3.x, Opus 4.0 to 4.7, Sonnet 4.0 to 4.6 and Haiku 4.5. On every other model the mod has nothing to count. So at session.start the mod sets CLAUDE_CODE_ENABLE_TODO_TOOLS=1 for the Claude Code process, and Claude Code then offers the task tools on every model.
CLAUDE_CODE_ENABLE_TODO_TOOLS=0 keeps the task tools off./task-poke off is stored. /task-poke off takes effect on the task tools from the next session.CLAUDE_CODE_ENABLE_TASKS=false replaces the Task tools with TodoWrite. The mod reads both formats.While the sidebar is open, the count stands there as a task list section for the session, rewritten at each turn:
task-poke: task list 3 unfinished tasks, poke 2/99
Only poke N/M is coloured: green below the last poke, yellow at it, and red once the pokes stopped; the unfinished tasks stay in the default colour. The section goes down when nothing is unfinished. Findings go into the stream instead, their head phrase red and the detail after its : in the default colour, so the next count does not take them off the pane: the stop at the limit of pokes, the stop after three pokes that moved nothing, a poke the engine dropped, and a task list the mod cannot read.
With the sidebar closed, or without that mod installed, only a turn that sent a poke writes its line to the transcript, and the three findings are transcript lines, as before.
A poke buys a turn. When that turn changed no task's status and ran no tool, the poke moved nothing: the model answered with words and the next poke buys the same answer again. After three such pokes in a row the mod stops and one red entry says so:
task-poke: stopped after 3 pokes that moved nothing: no task changed status and no tool ran. Send a prompt to start again.
The count goes back to zero at the first turn that moved something, so a model working through a long task is never stopped by this. Your next prompt starts the pokes again.
reason is not answer).AskUserQuestion.Stop input lists a background task or agent. The model only waits for it, so a poke would buy a turn of words. Such a turn sends no poke and does not count toward the three pokes that moved nothing. The first turn that ends with no background work pokes again. Measured before this check: a session that waited for two background agents got three continue prompts in a row and answered each with one sentence./task-poke off is set./task-poke status /task-poke on enable (default), stored across sessions /task-poke off disable, stored across sessions /task-poke limit 20 at most 20 pokes in a row; 1 to 999, 99 by default, stored across sessions /task-poke:send <prompt> the command a poke runs; typed, it sends the prompt as written
/task-poke:send is the mod's second command, the one exception to one command per mod, because only a markdown command hands the model a prompt without the plugin frame.
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install task-poke@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Load it from a local checkout for one session:
claude --plugin-dir plugins/task-poke
Restart Claude Code. The mod turns the task tools on at session start, so on a model outside the list above the task tools come from the next session.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, command.run{command=task-poke}, prompt.submit, classic.Stop, turn.complete ❯ ./register.ts calls: $.clock.after (via sendPoke), $.command.register, $.command.run (via sendPoke), $.env.get, $.env.set, $.prompt.submit (via submitPoke), $.session.messages (via afterTurn), $.sidebar.clear (via clearCount), $.sidebar.set (via toCount, toStream), $.store.get (via readLimit, readSettings), $.store.set, $.tool.call (via seedTasks), $.ui.log (via toCount, toStream) ❯ ./register.ts env writes: CLAUDE_CODE_ENABLE_TODO_TOOLS ❯ ./register.ts env reads: CLAUDE_CODE_ENABLE_TODO_TOOLS
Reach L2, drives Claude. Reads the transcript. Writes one environment variable.
$.session.messages() returns the newest 4096 messages. The reading at the session's start and the list the mod keeps between turns cover what that window loses.TaskGet result is not parsed. TodoWrite, TaskCreate, TaskUpdate and TaskList build the state./reload-plugins runs session.start again, so the engine's list is read again with it.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 314 lines1import type { EngineInterface, Register } from 'claude-code'
2import { countLine, countText, readTurn, signatureOf, streamLine, tasksOfList, workedThisTurn, type Tasks } from './tasks.ts'
3
4/** Pokes sent for one stretch of unfinished tasks, until the person sets another limit. */
5export const DEFAULT_MAX_POKES = 99
6
7/** Pokes in a row that moved nothing before the mod stops: no status changed and no tool ran. */
8export const MAX_STALLS = 3
9
10/** The band `/task-poke limit <n>` takes; a value outside it is refused, never clamped. */
11const MIN_LIMIT = 1
12const MAX_LIMIT = 999
13
14const ENABLED_KEY = 'enabled'
15const LIMIT_KEY = 'limit'
16const USAGE = 'expects nothing (the status), on, off or limit <n>'
17const USER_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
18const POKE_TEXT =
19 'The task list still has unfinished tasks. Continue with the next pending or in-progress task. ' +
20 'If a task is blocked or needs a decision from me, say so and stop.'
21
22/** The mod's markdown command (`commands/send.md`), whose body is its arguments alone. */
23const SEND_COMMAND = 'task-poke:send'
24
25/** The section this mod owns in the shared sidebar: the count of the running stretch. */
26const SECTION = { consumer: 'task-poke', key: 'pokes' }
27
28type Decision = { kind: 'idle' } | { kind: 'poke'; open: number } | { kind: 'limit' }
29
30/**
31 * The on/off setting, the limit, the pokes sent since the last user prompt, the last error logged,
32 * and the task list the last reading built. The list is kept because `$.session.messages()` answers
33 * the newest messages of a long transcript alone: a task created before that window and never
34 * updated inside it is in no later reading of the transcript.
35 */
36type State = {
37 enabled: boolean
38 max: number
39 pokes: number
40 limitLogged: boolean
41 lastError?: string
42 tasks: Tasks | null
43 /** Pokes in a row that moved nothing, and the list as it stood at the last reading. */
44 stalls: number
45 stallLogged: boolean
46 signature: string
47 /** Background tasks in flight when the main loop last stopped, from `classic.Stop` just before `turn.complete`. */
48 background: number
49}
50
51/** Decides what to do after a main-loop turn. `pokes` counts the pokes sent since the last user prompt. */
52export function decide(open: number, askedUser: boolean, pokes: number, max: number): Decision {
53 if (open === 0 || askedUser) return { kind: 'idle' }
54 if (pokes >= max) return { kind: 'limit' }
55 return { kind: 'poke', open }
56}
57
58/** The limit a `/task-poke limit <word>` argument names, or undefined when it is not one. */
59export function limitOf(arg: string): number | undefined {
60 if (!/^\d{1,3}$/.test(arg)) return undefined
61 const n = Number(arg)
62 return n >= MIN_LIMIT && n <= MAX_LIMIT ? n : undefined
63}
64
65/** The answer of `/task-poke limit <n>`, or of an argument it cannot read. */
66function limitText(limit: number | undefined): string {
67 if (limit === undefined) return `limit expects a whole number from ${MIN_LIMIT} to ${MAX_LIMIT}`
68 return `limit ${limit}: at most ${limit} poke(s) go out for one stretch of unfinished tasks`
69}
70
71/**
72 * The count the person watches: a section of the shared sidebar, rewritten at each turn. With the
73 * sidebar closed, and without that mod installed, only a turn that sent a poke writes the line, as
74 * before, because a line per turn would fill the transcript.
75 */
76async function toCount($: EngineInterface, open: number, pokes: number, max: number, log: boolean): Promise<void> {
77 try {
78 if (await $.sidebar.set({ ...SECTION, title: 'task list', lines: [countLine(open, pokes, max)], until: 'session', order: 20 })) return
79 } catch {
80 // The sidebar mod is not installed.
81 }
82 if (log) $.ui.log(countText(open, pokes, max))
83}
84
85/** Takes the count down, because a finished list has nothing to show. */
86async function clearCount($: EngineInterface): Promise<void> {
87 try {
88 await $.sidebar.clear(SECTION)
89 } catch {
90 // The sidebar mod is not installed.
91 }
92}
93
94/** A finding the next count must not overwrite: an entry in the sidebar's stream, else the transcript line. */
95async function toStream($: EngineInterface, key: string, title: string, text: string): Promise<void> {
96 try {
97 if (await $.sidebar.set({ consumer: 'task-poke', key, title, lines: [streamLine(text)], until: 'stream' })) return
98 } catch {
99 // The sidebar mod is not installed.
100 }
101 $.ui.log(text)
102}
103
104/** The poke as a plugin prompt, which the model reads inside a `The task-poke plugin sent a message:` frame. */
105function submitPoke($: EngineInterface): void {
106 void $.prompt.submit({ text: POKE_TEXT }).then(
107 res => {
108 if (res.drop !== undefined) void toStream($, 'dropped', 'poke dropped', `the poke was dropped: ${res.drop}`)
109 },
110 (err: unknown) => {
111 void toStream($, 'failed', 'poke not sent', `the poke was not submitted: ${String(err)}`)
112 },
113 )
114}
115
116/**
117 * Sends the poke through the mod's own `send` command, so the model reads the text alone, as a typed
118 * prompt. The command runs once the session is idle, from a timer, because the engine refuses
119 * `$.command.run` inside the `turn.complete` hook the turn waits on. A run the engine refuses sends the
120 * poke as a plugin prompt instead.
121 */
122function sendPoke($: EngineInterface): void {
123 $.clock.after(0, () => {
124 $.command.run({ command: SEND_COMMAND, args: POKE_TEXT }).catch((err: unknown) => {
125 void toStream($, 'unsent', 'poke sent as a plugin prompt', `the send command did not run, the poke goes out as a plugin prompt: ${String(err)}`)
126 submitPoke($)
127 })
128 })
129}
130
131/** The limit: the count turns red, and one entry says the pokes stopped. */
132async function atLimit($: EngineInterface, state: State, open: number): Promise<void> {
133 await toCount($, open, state.pokes, state.max, false)
134 if (state.limitLogged) return
135 state.limitLogged = true
136 await toStream($, 'limit', 'pokes stopped', `stopped after ${state.max} pokes with unfinished tasks. Send a prompt to reset the count.`)
137}
138
139/**
140 * Counts the pokes that moved nothing. A turn after a poke moved something when a task changed
141 * status or a tool ran; a turn that only wrote words moved nothing, and poking again would buy the
142 * same answer a second time. The count goes back to zero at the first turn that moved something.
143 */
144function trackStall(state: State, signature: string, worked: boolean): void {
145 if (state.pokes > 0) state.stalls = signature !== state.signature || worked ? 0 : state.stalls + 1
146 state.signature = signature
147}
148
149/** No progress: the pokes stop here, and one entry says why. The limit is not reached. */
150async function atStall($: EngineInterface, state: State, open: number): Promise<void> {
151 await toCount($, open, state.pokes, state.max, false)
152 if (state.stallLogged) return
153 state.stallLogged = true
154 await toStream($, 'stall', 'pokes stopped', `stopped after ${MAX_STALLS} pokes that moved nothing: no task changed status and no tool ran. Send a prompt to start again.`)
155}
156
157/** Writes the limit the person set; it holds across sessions, because it lives in $.store. */
158async function setLimit($: EngineInterface, state: State, arg: string): Promise<string> {
159 const limit = limitOf(arg)
160 if (limit === undefined) return limitText(undefined)
161 state.max = limit
162 await $.store.set(LIMIT_KEY, limit)
163 return limitText(limit)
164}
165
166/** The stored limit, or the default when nothing is stored and when the stored value is not one. */
167async function readLimit($: EngineInterface): Promise<number> {
168 const stored = await $.store.get(LIMIT_KEY)
169 return typeof stored === 'number' && limitOf(String(stored)) !== undefined ? stored : DEFAULT_MAX_POKES
170}
171
172/** Starts the count of a new stretch: the person spoke, or the mod was turned on or off. */
173function resetCount(state: State): void {
174 state.pokes = 0
175 state.limitLogged = false
176 state.stalls = 0
177 state.stallLogged = false
178}
179
180/** What turning the mod on or off does: the count starts again, on reads the task list, off takes the count down. */
181async function followSwitch($: EngineInterface, state: State): Promise<void> {
182 resetCount(state)
183 if (state.enabled) await seedTasks($, state)
184 else await clearCount($)
185}
186
187/**
188 * Reads the on/off setting and the limit from the store, which every window shares, so a change made in
189 * another window applies here at the next hook that acts on it. A switch flipped there does here what
190 * `on` and `off` do.
191 */
192async function readSettings($: EngineInterface, state: State): Promise<void> {
193 const was = state.enabled
194 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
195 state.max = await readLimit($)
196 if (state.enabled !== was) await followSwitch($, state)
197}
198
199/**
200 * Reads the engine's own task list once, at the session's start, and keeps it as the list every later
201 * turn is replayed over. A resumed session brings back tasks the transcript window no longer reaches,
202 * and without this reading the mod would count only what that window still holds. The call carries no
203 * message into the conversation: it leaves no tool row and the model never sees it (measured).
204 */
205async function seedTasks($: EngineInterface, state: State): Promise<void> {
206 try {
207 const answer = await $.tool.call({ tool: 'TaskList' })
208 state.tasks = tasksOfList(answer.result) ?? state.tasks
209 } catch (err) {
210 // The task tools are off, or the engine refused the call. The transcript replay still answers.
211 await toStream($, 'unseeded', 'task list', `cannot read the engine's task list, the count is what the transcript holds: ${err instanceof Error ? err.message : String(err)}`)
212 }
213}
214
215/** Whether the main loop stopped with background work in flight; each stop's count is read once. */
216function waitsInBackground(state: State): boolean {
217 const waits = state.background > 0
218 state.background = 0
219 return waits
220}
221
222/**
223 * Reads the task list after a main-loop turn and acts on it. The transcript window the engine answers
224 * is replayed over the list of the last reading, so a task older than the window still counts. The
225 * parser reads that window on every turn, so a bad record fails every later turn too: each distinct
226 * error is reported once, and no poke is sent while the list is unreadable.
227 */
228async function afterTurn($: EngineInterface, state: State): Promise<void> {
229 const messages = await $.session.messages()
230 const reading = readTurn(messages, state.tasks)
231 if (!reading.ok) {
232 if (reading.error !== state.lastError) await toStream($, 'unreadable', 'task list', `cannot read the task list, no poke is sent: ${reading.error}`)
233 state.lastError = reading.error
234 return
235 }
236 state.lastError = undefined
237 state.tasks = reading.tasks
238 // While background work runs, its notification wakes the session; a poke would only buy a "waiting"
239 // reply (measured: three in a row while two agents ran), so none is sent and none counts as a stall.
240 if (waitsInBackground(state) && reading.open > 0) return toCount($, reading.open, state.pokes, state.max, false)
241 trackStall(state, signatureOf(reading.tasks), workedThisTurn(messages))
242 if (state.stalls >= MAX_STALLS && reading.open > 0) return atStall($, state, reading.open)
243 const decision = decide(reading.open, reading.askedUser, state.pokes, state.max)
244 if (decision.kind === 'limit') return atLimit($, state, reading.open)
245 if (decision.kind === 'idle') return reading.open === 0 ? clearCount($) : toCount($, reading.open, state.pokes, state.max, false)
246 state.pokes += 1
247 await toCount($, decision.open, state.pokes, state.max, true)
248 sendPoke($)
249}
250
251export const register: Register = on => {
252 const state: State = {
253 enabled: true, max: DEFAULT_MAX_POKES, pokes: 0, limitLogged: false, tasks: null,
254 stalls: 0, stallLogged: false, signature: '', background: 0,
255 }
256
257 on('session.start', async ($, e, next) => {
258 await readSettings($, state)
259 // Claude Code offers TaskCreate and TodoWrite on some models only, and without them there is nothing
260 // to count. Turn them on unless the user set the variable. It must be set before next(e).
261 if (state.enabled && (await $.env.get('CLAUDE_CODE_ENABLE_TODO_TOOLS')) === undefined) {
262 await $.env.set('CLAUDE_CODE_ENABLE_TODO_TOOLS', '1')
263 }
264 const r = await next(e)
265 resetCount(state)
266 if (state.enabled) await seedTasks($, state)
267 await $.command.register({
268 name: 'task-poke',
269 description: 'Continue automatically while the task list has unfinished tasks: status, on, off, limit (task-poke)',
270 argumentHint: '[on | off | limit <n>]',
271 immediate: true,
272 })
273 return r
274 })
275
276 on('command.run', { command: 'task-poke' }, async ($, e) => {
277 const arg = String(e.args ?? '').trim()
278 await readSettings($, state)
279 if (arg === 'on' || arg === 'off') {
280 state.enabled = arg === 'on'
281 await $.store.set(ENABLED_KEY, state.enabled)
282 await followSwitch($, state)
283 } else if (arg.startsWith('limit')) {
284 return { text: await setLimit($, state, arg.slice(5).trim()) }
285 } else if (arg !== '' && arg !== 'status') {
286 return { text: USAGE }
287 }
288 return { text: `${state.enabled ? 'on' : 'off'} · ${state.pokes}/${state.max} pokes since your last prompt` }
289 })
290
291 // Only the origin is read. The prompt text passes through untouched. This hook never sees its own pokes.
292 on('prompt.submit', async (_, e, next) => {
293 const r = await next(e)
294 if (USER_ORIGINS.includes(e.origin.kind)) resetCount(state)
295 return r
296 })
297
298 // The main loop's stop comes just before its turn.complete (measured: 3 ms), and carries the
299 // background work still in flight. A subagent's stop is SubagentStop, so it never lands here.
300 on('classic.Stop', async (_, e, next) => {
301 state.background = (e.background_tasks ?? []).length
302 return next(e)
303 })
304
305 on('turn.complete', async ($, e, next) => {
306 const r = await next(e)
307 if (e.agentId !== undefined || e.reason !== 'answer') return r
308 await readSettings($, state)
309 if (!state.enabled) return r
310 await afterTurn($, state)
311 return r
312 })
313}
314hooks/tasks.ts 201 lines1import type { SessionMessage, ToolUseSummary } from 'claude-code'
2
3export type TaskStatus = 'pending' | 'in_progress' | 'completed'
4export type Tasks = Map<string, TaskStatus>
5
6const STATUSES: readonly string[] = ['pending', 'in_progress', 'completed']
7
8/**
9 * Rebuilds the task list from the transcript. Returns null when the session
10 * never used TodoWrite or the Task tools, and `seed` holds nothing either.
11 *
12 * TodoWrite replaces the whole list. TaskCreate adds one task and TaskUpdate
13 * patches one task by id. A TaskList result is the whole list at that point,
14 * so it replaces what the replay held. A later TodoWrite replaces everything
15 * before it.
16 *
17 * `seed` is the list an earlier reading of this session built. A long transcript
18 * answers its newest messages alone, so a task created before that window and
19 * never updated inside it is only in the seed.
20 */
21export function taskState(messages: readonly SessionMessage[], seed: Tasks | null = null): Tasks | null {
22 let tasks: Tasks | null = seed === null ? null : new Map(seed)
23 for (const message of messages) {
24 for (const use of message.toolUses) tasks = applyUse(tasks, use)
25 }
26 return tasks
27}
28
29/** Counts the tasks that are pending or in progress. */
30export function unfinishedCount(tasks: Tasks | null): number {
31 if (tasks === null) return 0
32 let count = 0
33 for (const status of tasks.values()) if (status !== 'completed') count += 1
34 return count
35}
36
37export type Reading =
38 | { ok: true; open: number; askedUser: boolean; tasks: Tasks | null }
39 | { ok: false; error: string }
40
41/**
42 * Reads the unfinished task count and the AskUserQuestion state from the transcript.
43 * A transcript the parser cannot read returns the parser's error instead of a count,
44 * so the caller can report it rather than guess a count. The list it built comes back
45 * with it, to seed the next reading.
46 */
47export function readTurn(messages: readonly SessionMessage[], seed: Tasks | null = null): Reading {
48 try {
49 const tasks = taskState(messages, seed)
50 return { ok: true, open: unfinishedCount(tasks), askedUser: asksUser(messages), tasks }
51 } catch (err) {
52 return { ok: false, error: err instanceof Error ? err.message : String(err) }
53 }
54}
55
56/** The list as one string, so two readings can be compared: any status change makes another. */
57export function signatureOf(tasks: Tasks | null): string {
58 if (tasks === null) return ''
59 return [...tasks].map(([id, status]) => `${id}:${status}`).sort().join(',')
60}
61
62/**
63 * True when a tool ran in the turn that just ended: the messages after the newest prompt, the user
64 * message that started it. A tool result is a user message too, so a user message that carries tool
65 * results is not a prompt. A turn that only wrote words did no work, and a poke that buys another
66 * such turn buys the same answer again.
67 */
68export function workedThisTurn(messages: readonly SessionMessage[]): boolean {
69 const prompt = messages.findLastIndex(m => m.role === 'user' && (m.toolResults ?? []).length === 0)
70 if (prompt === -1) return false
71 return messages.slice(prompt + 1).some(m => m.toolUses.length > 0)
72}
73
74/** True when the last assistant message asked the user a question through AskUserQuestion. */
75export function asksUser(messages: readonly SessionMessage[]): boolean {
76 const last = messages.findLast(m => m.role === 'assistant')
77 return last !== undefined && last.toolUses.some(u => u.tool === 'AskUserQuestion')
78}
79
80function applyUse(tasks: Tasks | null, use: ToolUseSummary): Tasks | null {
81 if (use.isError) return tasks
82 switch (use.tool) {
83 case 'TodoWrite':
84 return fromTodoWrite(use.input)
85 case 'TaskCreate':
86 return withCreate(tasks, use)
87 case 'TaskUpdate':
88 return withUpdate(tasks, use)
89 case 'TaskList':
90 return fromTaskList(tasks, use)
91 default:
92 return tasks
93 }
94}
95
96function fromTodoWrite(input: Record<string, unknown>): Tasks {
97 const todos = input.todos
98 if (!Array.isArray(todos)) throw new Error('task-poke: TodoWrite input has no todos array')
99 return new Map(todos.map((todo: unknown, i) => [String(i), parseStatus(field(todo, 'status'))]))
100}
101
102// The assigned id is not in the TaskCreate input. The tool result carries it as { task: { id } }.
103function withCreate(tasks: Tasks | null, use: ToolUseSummary): Tasks | null {
104 if (use.result === undefined) return tasks
105 const id = field(field(use.result, 'task'), 'id')
106 if (typeof id !== 'string' && typeof id !== 'number') {
107 throw new Error('task-poke: TaskCreate result has no task.id')
108 }
109 const next = new Map(tasks ?? [])
110 next.set(String(id), 'pending')
111 return next
112}
113
114// Claude Code repairs id and task_id to taskId before it runs the tool, but the transcript keeps the raw input.
115// A failed update (an unknown taskId) is not an error result: its record is { success: false, error }.
116function withUpdate(tasks: Tasks | null, use: ToolUseSummary): Tasks | null {
117 const input = use.input
118 const id = input.taskId ?? input.id ?? input.task_id
119 if (input.status === undefined || id === undefined) return tasks
120 if (field(use.result, 'success') === false) return tasks
121 const next = new Map(tasks ?? [])
122 if (input.status === 'deleted') next.delete(String(id))
123 else next.set(String(id), parseStatus(input.status))
124 return next
125}
126
127// The TaskList result carries the whole list, so it is the list at that point of the transcript and
128// replaces what the replay held. A result without a tasks array says nothing, and leaves the list alone.
129function fromTaskList(tasks: Tasks | null, use: ToolUseSummary): Tasks | null {
130 return tasksOfList(use.result) ?? tasks
131}
132
133/**
134 * The list a TaskList result names, or null when it names none. The same shape comes from the
135 * transcript and from the mod's own `$.tool.call({ tool: 'TaskList' })`, which reads the engine's
136 * list itself: the only source that holds a task older than the transcript window.
137 */
138export function tasksOfList(result: unknown): Tasks | null {
139 const rows = field(result, 'tasks')
140 if (!Array.isArray(rows)) {
141 // The engine answers an empty list with the text `No tasks found`, with no tasks array. That is
142 // a list, not its absence: keeping the old one here would keep a task the engine deleted.
143 const text = typeof result === 'string' ? result : field(result, 'text')
144 return text === 'No tasks found' ? new Map() : null
145 }
146 const next: Tasks = new Map()
147 for (const row of rows) {
148 const id = field(row, 'id')
149 if (typeof id !== 'string' && typeof id !== 'number') throw new Error('task-poke: a TaskList row has no id')
150 next.set(String(id), parseStatus(field(row, 'status')))
151 }
152 return next
153}
154
155/** How the sidebar colours a line or a part of one. */
156type Tone = 'ok' | 'warn' | 'error' | 'dim'
157export type Part = { text: string; kind?: Tone }
158/** A line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
159export type Line = { text: string; kind?: Tone; parts?: Part[] }
160
161const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
162
163/** A line made of parts, its `text` their texts joined. */
164const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
165
166/** The poke count's colour: red once the pokes stopped, yellow at the last one, green below. */
167export function countTone(pokes: number, max: number): 'ok' | 'warn' | 'error' {
168 if (pokes >= max) return 'error'
169 return pokes >= max - 1 ? 'warn' : 'ok'
170}
171
172/** The count as the transcript reads it: `2 unfinished tasks, poke 1/99`. */
173export function countText(open: number, pokes: number, max: number): string {
174 return countLine(open, pokes, max).text
175}
176
177/** The count as the sidebar draws it: only `poke N/M` coloured, the unfinished tasks in the default colour. */
178export function countLine(open: number, pokes: number, max: number): Line {
179 return partsLine([part(`${open} unfinished task${open === 1 ? '' : 's'}, `, undefined), part(`poke ${pokes}/${max}`, countTone(pokes, max))])
180}
181
182/**
183 * A stream entry: the head phrase before the first `: ` red and the detail after it in the default
184 * colour; a line with no detail is red whole.
185 */
186export function streamLine(text: string): Line {
187 const cut = text.indexOf(': ')
188 if (cut === -1) return { text, kind: 'error' }
189 return partsLine([part(text.slice(0, cut + 1), 'error'), part(text.slice(cut + 1), undefined)])
190}
191
192function parseStatus(value: unknown): TaskStatus {
193 if (typeof value === 'string' && STATUSES.includes(value)) return value as TaskStatus
194 throw new Error(`task-poke: unknown task status ${JSON.stringify(value)}`)
195}
196
197function field(value: unknown, key: string): unknown {
198 if (typeof value !== 'object' || value === null) return undefined
199 return (value as Record<string, unknown>)[key]
200}
201