SLOPSHOPPER

task-poke

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…

newcommandprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-poke
› fix the failing auth test and add an audit log call ⏺ 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 › /task-poke ⎿ task-poke: on · 0/99 pokes since your last prompt ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

task-poke

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.

How the poke is sent

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.

The count and the transcript window

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

  1. At the session's start it reads the engine's own list once, with $.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.
  2. Each later window is replayed over the list of the last reading, so a task created 8000 messages ago and never touched since stays counted.

A TaskList the model itself calls is read the same way: its result is the whole list, and it replaces what the replay held.

Task tools on every model

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.

  • The mod does not change a value you set yourself. CLAUDE_CODE_ENABLE_TODO_TOOLS=0 keeps the task tools off.
  • The mod does not set the variable while /task-poke off is stored. /task-poke off takes effect on the task tools from the next session.
  • The variable also reaches every Bash command and MCP server the session starts.
  • CLAUDE_CODE_ENABLE_TASKS=false replaces the Task tools with TodoWrite. The mod reads both formats.

What it shows

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.

When a poke moves nothing

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.

When it does not poke

  • The turn was interrupted, refused, or ended on an API error (reason is not answer).
  • The turn ran in a subagent.
  • The last assistant message called AskUserQuestion.
  • The limit of pokes was sent since your last prompt. One red entry reports the stop.
  • Three pokes in a row moved nothing, as above.
  • Background work still runs: the turn's 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.

Commands

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

Install

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

After installing

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.

What it can reach

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.

  1. Reads: the transcript through $.session.messages (tool names, inputs and results of TodoWrite, TaskCreate, TaskUpdate, TaskList and AskUserQuestion); the engine's task list through one $.tool.call at the session's start; the origin kind of each prompt, never its text; the number of background tasks in each turn's Stop input; CLAUDE_CODE_ENABLE_TODO_TOOLS
  2. Runs: one read-only TaskList call at the session's start and at /task-poke on; one /task-poke:send run per main-loop turn that ends with unfinished tasks, at most 99 in a row, or the limit you set, and at most 3 in a row that move nothing; sets CLAUDE_CODE_ENABLE_TODO_TOOLS=1 once per session when it is unset
  3. Sends: only the fixed poke prompt, as a normal turn
  4. Persists: one boolean (enabled) and the poke limit in $.store; the environment variable lasts for the process only
  5. Hostile input: no text from the transcript reaches the poke prompt; an unknown task status, a TaskCreate result without task.id or a TaskList row without an id stops the pokes, and one line names the error until the error changes

Limits

  • $.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.
  • A TaskGet result is not parsed. TodoWrite, TaskCreate, TaskUpdate and TaskList build the state.
  • The list the mod keeps lives in the session's plugin state. A /reload-plugins runs session.start again, so the engine's list is read again with it.
  • A new session starts with an empty engine list: Claude Code carries tasks into a resumed session, not into a fresh one (measured).

Development

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

Source 2 files
hooks/register.ts 314 lines
1import 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}
314
hooks/tasks.ts 201 lines
1import 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