SLOPSHOPPER

task-bars

Shows the session's tasks above the prompt as a list with a colored progress bar and a percentage

newbandguardcommandprompttool
★ 1v0.4.0no licenseupdated 2026-10-08mrtsvr-techloop/claude-taskbars
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-bars
› 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-bars ○ Migrazione schema database fine 100% ○ Aggiornare documentazione awaiting 0% ○ Refactor client API in corso 82% ○ Deploy su staging blocked 0% ○ Test componenti UI in corso 47% ○ Build di release stopped 63% ○ Audit permessi di accesso in corso 15% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
○ Migrazione schema database fine 100% ○ Aggiornare documentazione awaiting 0% ○ Refactor client API in corso 82% ○ Deploy su staging blocked 0% ○ Test componenti UI in corso 47% ○ Build di release stopped 63% ○ Audit permessi di accesso in corso 15%
README

task-bars

A Claude Code mod that shows the session's tasks above the prompt as progress bars.

Each task is one bar holding its name, its state and its percentage; the bars sit in two columns where the terminal is wide enough.

ColorState
Greenrunning (the bar grows) or done (100%)
Orangeawaiting
Redblocked by an unfinished task, or stopped by an interrupted turn

The colors are the theme's success, warning and error.

Install

At the prompt of a Claude Code terminal session:

/plugin install task-bars --marketplace mrtsvr-techloop/claude-taskbars

Answer y to add the marketplace, then pick a scope.

To run it from a clone instead: claude --plugin-dir <path to the clone>.

Pinning

Each bar starts with a pin: ○ loose, 📌 pinned. Click it to pin or unpin the task. The pin is the one spot of a bar a click reaches.

A pinned task is locked: it stays when the model sends a list without it, when the task list deletes it, and when the finished tasks are cleared. The model still updates its state and progress. Only the person's click unpins it.

The model pins a task too, by declaring it with persistent: true in set_tasks.

Tasks that are not pinned are cleared at the next prompt once all of them are done. Pins last for the session.

Commands

  • /task-bars hides or shows the bars.
  • /task-bars-demo adds or removes sample tasks at different percentages.

What it tracks

The bars appear by themselves; nobody asks for them. Four sources feed them:

  • the mod's own tool, mcp__task-bars__set_tasks, which the model calls with the whole list of tasks of its current work; it is what feeds the bars where the session offers no task list;
  • the task list (TaskCreate, TaskUpdate, TaskList);
  • the todos of TodoWrite;
  • the subagents the model launches with Agent: running from the launch, done when they stop.

Background shells and monitors are not tracked.

The mod adds a section to the system prompt asking the model to keep the bars current and to give each running task its progress (0-100). A running task with no reported progress sits at 50%: a subagent always does, and a todo is always at 0, 50 or 100.

While it draws, the mod takes the band above the prompt in place of other mods drawing there.

Mod Signals

The mod follows Mod Signals, a standard for mods to hear each other without knowing each other. It accepts open, close and toggle, and emits opened, closed and status-changed. Another mod, such as a dock of buttons, can open and close it with nothing added here.

The standard travels in hooks/mod-signals, a git subtree of its repository: the files are committed here and are not edited here. A newer version is pulled with

git subtree pull --prefix hooks/mod-signals git@github.com:mrtsvr-techloop/mod-signals.git main --squash

Develop

claude plugin validate .
claude plugin test .

Architecture: hooks/register.tsx is the adapter (the hooks on Claude Code's events and the drawing), hooks/model.ts the pure task model it calls, types/index.d.ts the state contract.

Source 5 files
hooks/register.tsx 376 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TaskRow } from '../types'
5
6import { hasDemo, withDemo, withoutDemo } from './demo'
7import {
8  agentAnswered,
9  agentCalled,
10  agentStopped,
11  cellOf,
12  changesOf,
13  cleared,
14  created,
15  declared,
16  fitting,
17  interrupted,
18  listed,
19  todos,
20  toggled,
21  toneOf,
22  updated,
23} from './model'
24import { ROLL_CALL_MS, readSignal, signalOf } from './mod-signals/kit/signals'
25import type { Announce, SignalKind } from './mod-signals/kit/signals'
26
27const COMMAND = 'task-bars'
28const DEMO_COMMAND = 'task-bars-demo'
29const MAX_ROWS = 10
30/** Two columns of bars from the width that gives each this much. */
31const MIN_CELL_COLUMNS = 30
32const MAX_CELL_COLUMNS = 60
33const GAP_COLUMNS = 2
34/** The pin at the head of each bar, the one spot of it a click reaches. */
35const PIN_COLUMNS = 3
36const PINNED = '📌'
37const LOOSE = '○'
38
39const PROGRESS_SECTION = {
40  id: 'task-bars:progress',
41  text:
42    'The person watches one progress bar per task above the prompt. Keep the bars current without ' +
43    'being asked and without announcing it: for any work of two or more steps, call ' +
44    'mcp__task-bars__set_tasks with the whole list as you start, and again whenever a step starts, ' +
45    'reaches a real milestone, ends or gets blocked, giving each in_progress task its progress ' +
46    '(0-100). Where you keep a task list with TaskCreate and TaskUpdate, that list is shown already: ' +
47    'report progress there with TaskUpdate metadata: { "progress": <0-100> } and leave set_tasks out.',
48  scope: 'session',
49} as const
50
51const TASK_STATUSES = ['pending', 'in_progress', 'completed', 'blocked']
52
53const SET_TASKS = {
54  name: 'set_tasks',
55  description:
56    'Shows the person the tasks of the current work as progress bars above the prompt. Takes the ' +
57    'whole list every time: it replaces the one shown before; an empty list clears it. A task ' +
58    'marked persistent is pinned: it stays, even left out of a later list, until the person unpins it.',
59  inputSchema: {
60    type: 'object',
61    properties: {
62      tasks: {
63        type: 'array',
64        items: {
65          type: 'object',
66          properties: {
67            name: { type: 'string', description: 'A short title for the task' },
68            status: { type: 'string', enum: TASK_STATUSES },
69            progress: { type: 'number', minimum: 0, maximum: 100, description: 'How far an in_progress task is' },
70            persistent: { type: 'boolean', description: 'Pins the task so no clean-up removes it' },
71          },
72          required: ['name', 'status'],
73        },
74      },
75    },
76    required: ['tasks'],
77  },
78}
79
80const tasks = atom({ plugin: 'task-bars', key: 'tasks' } as const, [])
81const isHidden = atom({ plugin: 'task-bars', key: 'isHidden' } as const, false)
82
83/** The mod's name as the engine gives it: the `to` of the commands it obeys. */
84const MOD = 'task-bars'
85const SELF: Announce = { title: 'Tasks', accepts: ['open', 'close', 'toggle'], emits: ['opened', 'closed', 'status-changed'] }
86
87const signal = atom({ plugin: 'task-bars', key: 'signal' } as const, null)
88const sent = { count: 0, answeredAt: 0 }
89
90/** One signal to whoever listens; a mod above that refuses the write stops nothing here. */
91const emit = async (
92  $: EngineInterface,
93  kind: SignalKind,
94  name: string,
95  data?: Record<string, unknown>,
96  tags?: string[],
97): Promise<void> => {
98  sent.count += 1
99
100  try {
101    await update($, signal, () => signalOf(kind, name, sent.count, Date.now(), { ...(data === undefined ? {} : { data }), ...(tags === undefined ? {} : { tags }) }))
102  } catch {
103    // The mod goes on without the signal.
104  }
105}
106
107/**
108 * Every write of the tasks: each change of status it makes is told to the mods
109 * hooked on `taskBars.statusChanged`, one that fails stopping nothing, and to
110 * whoever listens to signals, as the event `status-changed`.
111 */
112const change = async ($: EngineInterface, apply: (list: TaskRow[]) => TaskRow[]): Promise<void> => {
113  const before = await read($, tasks)
114  await update($, tasks, apply)
115
116  for (const one of changesOf(before, await read($, tasks))) {
117    try {
118      await $.taskBars.statusChanged(one)
119    } catch {
120      // The tasks go on whatever a listener met.
121    }
122
123    // A task that is done is the one change tagged for whoever tells the person.
124    await emit($, 'event', 'status-changed', { ...one, text: `${one.name} is done` }, one.to === 'completed' ? ['info', 'completion'] : undefined)
125  }
126}
127
128/** Shows or hides the bars, and says so where that changed. */
129const setHidden = async ($: EngineInterface, hidden: boolean): Promise<boolean> => {
130  const wasHidden = await read($, isHidden)
131  await update($, isHidden, () => hidden)
132
133  if (wasHidden !== hidden) {
134    await emit($, 'event', hidden ? 'closed' : 'opened')
135  }
136
137  return !hidden
138}
139
140/** A command another mod sent: what the person could do with `/task-bars`, and no more. */
141const obey = async ($: EngineInterface, name: string): Promise<void> => {
142  if (name === 'open') {
143    await $.taskBars.open()
144  } else if (name === 'close') {
145    await $.taskBars.close()
146  } else if (name === 'toggle') {
147    await $.taskBars.toggle()
148  }
149}
150
151export const register: Register = on => {
152  // Mod Signals: every signal, whoever writes it. The write goes on first and
153  // untouched; the mod answers a roll-call and obeys the commands sent to it.
154  on('state.set', { key: 'signal' }, async ($, e, next) => {
155    const done = await next(e)
156    const heard = done.value?.isSet === true ? readSignal(e.value) : null
157
158    if (heard?.kind === 'event' && heard.name === 'roll-call' && Date.now() - sent.answeredAt >= ROLL_CALL_MS) {
159      sent.answeredAt = Date.now()
160      await emit($, 'announce', 'announce', SELF)
161    }
162
163    if (heard?.kind === 'command' && heard.to === MOD) {
164      await obey($, heard.name)
165    }
166
167    return done
168  }).catch((_, e, next) => next(e))
169
170  // The mod's API: `$.taskBars` for any other mod. Each method is answered by its
171  // hook below, but `statusChanged`, which is the other mods' to hook.
172  on('engine.create', async ($, e, next) => ({
173    ...(await next(e)),
174    taskBars: {
175      open: async () => false,
176      close: async () => false,
177      toggle: async () => false,
178      isOpen: async () => false,
179      list: async () => [],
180      getStatus: async () => null,
181      statusChanged: async () => undefined,
182    },
183  }))
184
185  on('taskBars.isOpen', async $ => ({ value: !(await read($, isHidden)) }))
186
187  on('taskBars.open', async $ => ({ value: await setHidden($, false) }))
188
189  on('taskBars.close', async $ => ({ value: await setHidden($, true) }))
190
191  on('taskBars.toggle', async $ => ({ value: await setHidden($, !(await read($, isHidden))) }))
192
193  on('taskBars.list', async $ => ({
194    value: (await read($, tasks)).map(({ id, name, status, progress }) => ({ id, name, status, progress })),
195  }))
196
197  on('taskBars.getStatus', async ($, e) => {
198    const list = await read($, tasks)
199
200    return { value: (list.find(one => one.id === e.task) ?? list.find(one => one.name === e.task))?.status ?? null }
201  })
202
203  on('session.start', async ($, e, next) => {
204    await $.command.register({
205      name: COMMAND,
206      description: 'Show or hide the task progress bars above the prompt',
207    })
208
209    await $.command.register({
210      name: DEMO_COMMAND,
211      description: 'Add or remove sample tasks at different percentages in the task bars',
212    })
213
214    await $.tool.register(SET_TASKS)
215    await emit($, 'announce', 'announce', SELF)
216
217    return next(e)
218  })
219
220  on('tool.call', { tool: 'mcp__task-bars__set_tasks' }, async ($, e) => {
221    const input = { ...e }
222
223    if (declared([], input) === undefined) {
224      return { deny: `tasks must be a list of { name, status, progress? }, status one of ${TASK_STATUSES.join(', ')}.` }
225    }
226
227    await change($, list => declared(list, input) ?? list)
228
229    return { result: 'The task bars are up to date.' }
230  })
231
232  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
233    await change($, list => agentCalled(list, e.tool_use_id, { ...e }))
234    const ran = await next(e)
235    await change($, list =>
236      agentAnswered(list, e.tool_use_id, ran.result, ran.deny !== undefined || ran.isError === true),
237    )
238
239    return ran
240  })
241
242  on('classic.SubagentStop', async ($, e, next) => {
243    await change($, list => agentStopped(list, e.agent_id))
244
245    return next(e)
246  })
247
248  on('command.run', { command: DEMO_COMMAND }, async $ => {
249    const isShown = hasDemo(await read($, tasks))
250    await change($, isShown ? withoutDemo : withDemo)
251
252    if (!isShown) {
253      await $.taskBars.open()
254    }
255
256    return { text: isShown ? 'Sample tasks removed.' : 'Sample tasks added.' }
257  })
258
259  on('command.run', { command: COMMAND }, async $ => {
260    // The command is one caller of the mod's API among others.
261    return { text: (await $.taskBars.toggle()) ? 'Task bars shown.' : 'Task bars hidden.' }
262  })
263
264  on('prompt.compose', async ($, e, next) => {
265    const composed = await next(e)
266
267    return { sections: [...composed.sections, PROGRESS_SECTION] }
268  })
269
270  on('prompt.submit', async ($, e, next) => {
271    await change($, cleared)
272
273    return next(e)
274  })
275
276  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
277    const ran = await next(e)
278    await change($, list => created(list, ran.result))
279
280    return ran
281  })
282
283  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
284    const ran = await next(e)
285    await change($, list => updated(list, { ...e }, ran.result))
286
287    return ran
288  })
289
290  on('tool.call', { tool: 'TaskList' }, async ($, e, next) => {
291    const ran = await next(e)
292    await change($, list => listed(list, ran.result))
293
294    return ran
295  })
296
297  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
298    const ran = await next(e)
299
300    if (ran.deny === undefined && ran.isError !== true) {
301      await change($, list => todos(list, { ...e }))
302    }
303
304    return ran
305  })
306
307  on('turn.complete', async ($, e, next) => {
308    if (e.reason === 'aborted') {
309      await change($, interrupted)
310    }
311
312    return next(e)
313  })
314
315  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
316    const list = await read($, tasks)
317
318    if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) {
319      return next(e)
320    }
321
322    const { Box, Button, Text } = $.ui.resolve(e)
323    const { bodyColumns } = e.props
324    const columns = bodyColumns >= MIN_CELL_COLUMNS * 2 + GAP_COLUMNS ? 2 : 1
325    const cellColumns = Math.max(
326      1,
327      Math.min(
328        MAX_CELL_COLUMNS,
329        Math.floor((bodyColumns - GAP_COLUMNS * (columns - 1)) / columns) - PIN_COLUMNS,
330      ),
331    )
332    const room = Math.max(1, Math.min(MAX_ROWS, e.props.maxRows - 1)) * columns
333    const rows = fitting(list, room)
334    const lines = Math.ceil(rows.length / columns)
335
336    return (
337      <Box flexDirection="column">
338        {Array.from({ length: lines }, (_, line) => (
339          <Box>
340            {/* Column by column: the list reads down the first, then the second. */}
341            {Array.from({ length: columns }, (_, column) => rows[column * lines + line]).flatMap(row => {
342              if (row === undefined) {
343                return []
344              }
345
346              const tone = toneOf(row, list)
347              const cell = cellOf(row, list, cellColumns)
348
349              return [
350                <Box marginRight={GAP_COLUMNS}>
351                  <Box width={PIN_COLUMNS}>
352                    <Button
353                      key={`pin-${row.id}`}
354                      plain
355                      dimColor={row.isPinned !== true}
356                      label={row.isPinned === true ? PINNED : LOOSE}
357                      onPress={() => change($, rows => toggled(rows, row.id))}
358                    />
359                  </Box>
360                  {cell.filled.length > 0 && (
361                    <Text backgroundColor={tone} color="inverseText">
362                      {cell.filled}
363                    </Text>
364                  )}
365                  {cell.rest.length > 0 && <Text color={tone}>{cell.rest}</Text>}
366                </Box>,
367              ]
368            })}
369          </Box>
370        ))}
371        {rows.length < list.length && <Text dimColor>+{list.length - rows.length} altre</Text>}
372      </Box>
373    )
374  })
375}
376
hooks/demo.ts 25 lines
1import type { TaskRow } from '../types'
2
3const DEMO_PREFIX = 'demo-'
4
5const SAMPLES: readonly Omit<TaskRow, 'id'>[] = [
6  { name: 'Migrazione schema database', status: 'completed', blockedBy: [], progress: null },
7  { name: 'Refactor client API', status: 'in_progress', blockedBy: [], progress: 82 },
8  { name: 'Test componenti UI', status: 'in_progress', blockedBy: [], progress: 47 },
9  { name: 'Audit permessi di accesso', status: 'in_progress', blockedBy: [], progress: 15 },
10  { name: 'Aggiornare documentazione', status: 'pending', blockedBy: [], progress: null },
11  { name: 'Deploy su staging', status: 'pending', blockedBy: [`${DEMO_PREFIX}1`], progress: null },
12  { name: 'Build di release', status: 'stopped', blockedBy: [], progress: 63 },
13]
14
15export const hasDemo = (list: TaskRow[]): boolean => list.some(row => row.id.startsWith(DEMO_PREFIX))
16
17export const withoutDemo = (list: TaskRow[]): TaskRow[] =>
18  list.filter(row => !row.id.startsWith(DEMO_PREFIX))
19
20/** The sample rows, one per state the band can draw, after the real ones. */
21export const withDemo = (list: TaskRow[]): TaskRow[] => [
22  ...withoutDemo(list),
23  ...SAMPLES.map((sample, index) => ({ ...sample, id: `${DEMO_PREFIX}${index}` })),
24]
25
hooks/model.ts 325 lines
1import type { TaskBarsChange, TaskRow, TaskStatus } from '../types'
2
3type Bag = Record<string, unknown>
4
5export type Tone = 'success' | 'warning' | 'error'
6
7const TODO_PREFIX = 'todo-'
8const PLAN_PREFIX = 'plan-'
9const CALL_PREFIX = 'call-'
10const AGENT_PREFIX = 'agent-'
11/** Rows the task list does not hold: todos, declared tasks, subagents, samples. */
12const OWN_PREFIXES = [TODO_PREFIX, PLAN_PREFIX, CALL_PREFIX, AGENT_PREFIX, 'demo-']
13const isOwn = (row: TaskRow): boolean => OWN_PREFIXES.some(prefix => row.id.startsWith(prefix))
14/** A started task whose progress the model never reported sits at the middle. */
15const UNREPORTED_PERCENT = 50
16
17const isBag = (value: unknown): value is Bag =>
18  typeof value === 'object' && value !== null && !Array.isArray(value)
19
20const asText = (value: unknown): string | undefined =>
21  typeof value === 'string' && value.length > 0 ? value : undefined
22
23const asIds = (value: unknown): string[] =>
24  Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string') : []
25
26const asStatus = (value: unknown): TaskStatus | undefined =>
27  value === 'pending' || value === 'in_progress' || value === 'completed' ? value : undefined
28
29const asProgress = (metadata: unknown): number | undefined => {
30  const value = isBag(metadata) ? metadata.progress : undefined
31
32  return typeof value === 'number' && Number.isFinite(value)
33    ? Math.min(100, Math.max(0, Math.round(value)))
34    : undefined
35}
36
37/**
38 * One source's rows replaced by its new whole list. A pinned row is locked:
39 * it keeps its pin when the list names it again, and stays when it does not.
40 */
41const replaced = (list: TaskRow[], isSource: (row: TaskRow) => boolean, rows: TaskRow[]): TaskRow[] => {
42  const fresh = new Map(rows.map(row => [row.id, row]))
43  const merged = [...fresh.values()].map(row =>
44    list.some(one => one.id === row.id && one.isPinned === true) ? { ...row, isPinned: true } : row,
45  )
46
47  return [...list.filter(row => !isSource(row) || (row.isPinned === true && !fresh.has(row.id))), ...merged]
48}
49
50/** The person's click on a task's pin: pinned becomes loose, loose pinned. */
51export const toggled = (list: TaskRow[], id: string): TaskRow[] =>
52  list.map(row => (row.id === id ? { ...row, isPinned: row.isPinned !== true } : row))
53
54/** TaskCreate's result adds one awaiting row. */
55export const created = (list: TaskRow[], result: unknown): TaskRow[] => {
56  const task = isBag(result) ? result.task : undefined
57  const id = isBag(task) ? asText(task.id) : undefined
58
59  if (!isBag(task) || id === undefined || list.some(row => row.id === id)) {
60    return list
61  }
62
63  return [
64    ...list,
65    { id, name: asText(task.subject) ?? id, status: 'pending', blockedBy: [], progress: null },
66  ]
67}
68
69/** TaskUpdate's input, once its result says it took, changes or removes one row. */
70export const updated = (list: TaskRow[], input: Bag, result: unknown): TaskRow[] => {
71  const id = asText(input.taskId)
72
73  if (id === undefined || !isBag(result) || result.success !== true) {
74    return list
75  }
76
77  if (input.status === 'deleted') {
78    return list.filter(row => row.id !== id || row.isPinned === true)
79  }
80
81  const known = list.find(row => row.id === id)
82  const row: TaskRow = known ?? { id, name: id, status: 'pending', blockedBy: [], progress: null }
83  const next: TaskRow = {
84    ...row,
85    name: asText(input.subject) ?? row.name,
86    status: asStatus(input.status) ?? row.status,
87    blockedBy: [...new Set([...row.blockedBy, ...asIds(input.addBlockedBy)])],
88    progress: asProgress(input.metadata) ?? row.progress,
89  }
90
91  return known ? list.map(one => (one.id === id ? next : one)) : [...list, next]
92}
93
94/** TaskList's result is the whole task list: it replaces every row of that list. */
95export const listed = (list: TaskRow[], result: unknown): TaskRow[] => {
96  const tasks = isBag(result) && Array.isArray(result.tasks) ? result.tasks : undefined
97
98  if (tasks === undefined) {
99    return list
100  }
101
102  const rows = tasks.flatMap((task): TaskRow[] => {
103    const id = isBag(task) ? asText(task.id) : undefined
104
105    if (!isBag(task) || id === undefined) {
106      return []
107    }
108
109    const known = list.find(row => row.id === id)
110    const status = asStatus(task.status) ?? 'pending'
111
112    return [
113      {
114        id,
115        name: asText(task.subject) ?? id,
116        // TaskList does not know a turn was interrupted: a stopped row stays stopped.
117        status: known?.status === 'stopped' && status === 'in_progress' ? 'stopped' : status,
118        blockedBy: asIds(task.blockedBy),
119        progress: known?.progress ?? null,
120      },
121    ]
122  })
123
124  return replaced(list, row => !isOwn(row), rows)
125}
126
127/** TodoWrite's input is the whole todo list: it replaces every todo row. */
128export const todos = (list: TaskRow[], input: Bag): TaskRow[] => {
129  if (!Array.isArray(input.todos)) {
130    return list
131  }
132
133  const rows = input.todos.flatMap((todo): TaskRow[] => {
134    const name = isBag(todo) ? asText(todo.content) : undefined
135
136    return isBag(todo) && name !== undefined
137      ? [
138          {
139            id: `${TODO_PREFIX}${name}`,
140            name,
141            status: asStatus(todo.status) ?? 'pending',
142            blockedBy: [],
143            progress: null,
144          },
145        ]
146      : []
147  })
148
149  return replaced(list, row => row.id.startsWith(TODO_PREFIX), rows)
150}
151
152/**
153 * The whole list the model declared through the mod's own tool: it replaces
154 * every declared row, a task it marks persistent pinned. Undefined when the
155 * input is no list of named tasks.
156 */
157export const declared = (list: TaskRow[], input: Bag): TaskRow[] | undefined => {
158  if (!Array.isArray(input.tasks)) {
159    return undefined
160  }
161
162  const rows: TaskRow[] = []
163
164  for (const task of input.tasks) {
165    const name = isBag(task) ? asText(task.name) : undefined
166    const status = isBag(task) ? (task.status === 'blocked' ? 'blocked' : asStatus(task.status)) : undefined
167
168    if (!isBag(task) || name === undefined || status === undefined) {
169      return undefined
170    }
171
172    rows.push({
173      id: `${PLAN_PREFIX}${name}`,
174      name,
175      status,
176      blockedBy: [],
177      progress: asProgress({ progress: task.progress }) ?? null,
178      ...(task.persistent === true ? { isPinned: true } : {}),
179    })
180  }
181
182  return replaced(list, row => row.id.startsWith(PLAN_PREFIX), rows)
183}
184
185/** A subagent the model launched is a running row, named as the call describes it. */
186export const agentCalled = (list: TaskRow[], callId: string, input: Bag): TaskRow[] => [
187  ...list,
188  {
189    id: `${CALL_PREFIX}${callId}`,
190    name: asText(input.description) ?? asText(input.subagent_type) ?? 'subagent',
191    status: 'in_progress',
192    blockedBy: [],
193    progress: null,
194  },
195]
196
197/**
198 * The launch's answer settles the row: done when the subagent ran to its end,
199 * stopped when the call failed, still running under the agent's id when it
200 * went to the background.
201 */
202export const agentAnswered = (list: TaskRow[], callId: string, result: unknown, isFailed: boolean): TaskRow[] =>
203  list.map((row): TaskRow => {
204    if (row.id !== `${CALL_PREFIX}${callId}`) {
205      return row
206    }
207
208    if (isFailed || !isBag(result)) {
209      return { ...row, status: 'stopped' }
210    }
211
212    const agentId = asText(result.agentId)
213
214    return result.status === 'completed' || agentId === undefined
215      ? { ...row, status: 'completed' }
216      : { ...row, id: `${AGENT_PREFIX}${agentId}` }
217  })
218
219/** A background subagent that stopped is done. */
220export const agentStopped = (list: TaskRow[], agentId: string): TaskRow[] =>
221  list.map(row => (row.id === `${AGENT_PREFIX}${agentId}` ? { ...row, status: 'completed' } : row))
222
223/** An interrupted turn leaves what was running stopped. */
224export const interrupted = (list: TaskRow[]): TaskRow[] =>
225  list.map(row => (row.status === 'in_progress' ? { ...row, status: 'stopped' } : row))
226
227/** Once every loose task is done they all go; the pinned ones stay. */
228export const cleared = (list: TaskRow[]): TaskRow[] => {
229  const loose = list.filter(row => row.isPinned !== true)
230
231  return loose.length > 0 && loose.every(row => row.status === 'completed')
232    ? list.filter(row => row.isPinned === true)
233    : list
234}
235
236export const isBlocked = (row: TaskRow, list: TaskRow[]): boolean =>
237  row.status === 'blocked' ||
238  (row.status !== 'completed' &&
239    row.blockedBy.some(id => list.some(one => one.id === id && one.status !== 'completed')))
240
241export const percentOf = (row: TaskRow): number => {
242  if (row.status === 'completed') {
243    return 100
244  }
245
246  if (row.status === 'pending' || row.status === 'blocked') {
247    return row.progress ?? 0
248  }
249
250  return row.progress ?? UNREPORTED_PERCENT
251}
252
253export const labelOf = (row: TaskRow, list: TaskRow[]): string => {
254  if (row.status === 'completed') {
255    return 'fine'
256  }
257
258  if (row.status === 'stopped') {
259    return 'stopped'
260  }
261
262  if (isBlocked(row, list)) {
263    return 'blocked'
264  }
265
266  return row.status === 'pending' ? 'awaiting' : 'in corso'
267}
268
269/** Green while it runs and when done, orange while it waits, red blocked or stopped. */
270export const toneOf = (row: TaskRow, list: TaskRow[]): Tone => {
271  if (row.status === 'stopped' || isBlocked(row, list)) {
272    return 'error'
273  }
274
275  return row.status === 'pending' ? 'warning' : 'success'
276}
277
278/** Below this width a cell drops the state's label and keeps the percentage. */
279const LABEL_MIN_COLUMNS = 34
280
281/**
282 * One task as a bar `width` cells wide with its text inside: the name at the
283 * left, the state and the percentage at the right, cut where the fill ends.
284 */
285export const cellOf = (row: TaskRow, list: TaskRow[], width: number): { filled: string; rest: string } => {
286  const percent = percentOf(row)
287  const tail = width >= LABEL_MIN_COLUMNS ? `${labelOf(row, list)} ${percent}%` : `${percent}%`
288  const room = Math.max(0, width - tail.length - 3)
289  const name = row.name.length > room ? `${row.name.slice(0, Math.max(0, room - 1))}…` : row.name
290  const text = ` ${name.padEnd(room)} ${tail} `.slice(0, width).padEnd(width)
291  const cells = Math.min(width, Math.max(0, Math.round((percent / 100) * width)))
292
293  return { filled: text.slice(0, cells), rest: text.slice(cells) }
294}
295
296/** The rows that fit: when there are too many, finished loose ones give way first. */
297export const fitting = (list: TaskRow[], room: number): TaskRow[] => {
298  if (list.length <= room) {
299    return list
300  }
301
302  const open = list.filter(row => row.status !== 'completed' || row.isPinned === true)
303  const kept = new Set(
304    (open.length >= room ? open.slice(0, room) : [...open, ...list.filter(row => !open.includes(row))].slice(0, room)).map(
305      row => row.id,
306    ),
307  )
308
309  return list.filter(row => kept.has(row.id))
310}
311
312/** The changes of status between two states of the list, tasks that appeared or left included. */
313export const changesOf = (before: TaskRow[], after: TaskRow[]): TaskBarsChange[] => {
314  const moved = after.flatMap(row => {
315    const from = before.find(one => one.id === row.id)?.status ?? null
316
317    return from === row.status ? [] : [{ id: row.id, name: row.name, from, to: row.status }]
318  })
319  const gone = before
320    .filter(row => !after.some(one => one.id === row.id))
321    .map(row => ({ id: row.id, name: row.name, from: row.status, to: null }))
322
323  return [...moved, ...gone]
324}
325
hooks/mod-signals/kit/signals.ts 110 lines
1/**
2 * The Mod Signals kit, for version 0.1 of the standard. A mod imports this
3 * file as it is: it is the same in every mod, and is not to be edited there.
4 *
5 * Mod Signals 0.1, the part every mod carries: what a signal is, how one is
6 * made and how one is read. A mod emits by writing a signal under its own state
7 * key `signal`, and listens by hooking `state.set` on that key, whoever writes.
8 */
9export type SignalKind = 'event' | 'command' | 'announce'
10
11export type Signal = {
12  v: 1
13  kind: SignalKind
14  name: string
15  to?: string
16  data?: Record<string, unknown>
17  /** What kind of signal this is, for whoever picks signals by kind: `['info', 'completion']`. */
18  tags?: string[]
19  id: string
20}
21
22/** What a mod says of itself in an `announce`. */
23export type Announce = {
24  title: string
25  accepts: string[]
26  emits: string[]
27}
28
29/** The most characters of a `name`, an `id` or a `to`. */
30const NAME_LENGTH = 64
31
32/** The most tags of a signal. */
33const TAGS_LENGTH = 8
34
35/**
36 * The tags every mod shares; any other is its mod's own, written with a
37 * namespace: `citroen.low-fuel`.
38 */
39export const TAGS = ['info', 'warning', 'error', 'success', 'completion', 'important', 'progress'] as const
40
41/** A mod answers the roll-calls of others no more often than this. */
42export const ROLL_CALL_MS = 1000
43
44const KINDS: readonly SignalKind[] = ['event', 'command', 'announce']
45
46/** Lowercase words joined by `-`, `.` between namespaces. */
47const NAME = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/
48
49const isRecord = (value: unknown): value is Record<string, unknown> =>
50  typeof value === 'object' && value !== null && !Array.isArray(value)
51
52const isShort = (value: unknown): value is string =>
53  typeof value === 'string' && value.length > 0 && value.length <= NAME_LENGTH
54
55const isName = (value: unknown): value is string => isShort(value) && NAME.test(value)
56
57/** A signal of this mod's, the `count`-th it emits: its id is unique in the session. */
58export const signalOf = (
59  kind: SignalKind,
60  name: string,
61  count: number,
62  now: number,
63  rest: { to?: string; data?: Record<string, unknown>; tags?: string[] } = {},
64): Signal => ({
65  v: 1,
66  kind,
67  name,
68  id: `${now.toString(36)}-${count}`,
69  ...(rest.to === undefined ? {} : { to: rest.to }),
70  ...(rest.data === undefined ? {} : { data: rest.data }),
71  ...(rest.tags === undefined || rest.tags.length === 0 ? {} : { tags: rest.tags }),
72})
73
74/**
75 * The signal a written value holds, or null: an unknown version or kind, a name
76 * off the grammar, a command with no receiver, a receiver on anything else.
77 */
78export const readSignal = (value: unknown): Signal | null => {
79  if (!isRecord(value) || value.v !== 1 || !isName(value.name) || !isShort(value.id)) {
80    return null
81  }
82
83  const kind = KINDS.find(one => one === value.kind)
84  const isAddressed = value.to !== undefined
85
86  if (kind === undefined || (isAddressed && !isShort(value.to)) || isAddressed !== (kind === 'command')) {
87    return null
88  }
89
90  if ((kind === 'announce') !== (value.name === 'announce') || (value.data !== undefined && !isRecord(value.data))) {
91    return null
92  }
93
94  if (value.tags !== undefined && !(Array.isArray(value.tags) && value.tags.length <= TAGS_LENGTH && value.tags.every(isName))) {
95    return null
96  }
97
98  return value as Signal
99}
100
101/** Whether the signal carries the tag. */
102export const hasTag = (signal: Signal, tag: string): boolean => (signal.tags ?? []).includes(tag)
103
104/** What an `announce` says its sender accepts, or null when it does not hold. */
105export const acceptsOf = (signal: Signal): string[] | null => {
106  const accepts = signal.kind === 'announce' ? signal.data?.accepts : undefined
107
108  return Array.isArray(accepts) && accepts.every(isShort) ? accepts : null
109}
110
types/index.d.ts 77 lines
1export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'stopped'
2
3export type TaskRow = {
4  id: string
5  name: string
6  status: TaskStatus
7  /** Ids of the tasks this one waits for. */
8  blockedBy: string[]
9  /** Pinned by the person's click or by the model: no clean-up removes it. */
10  isPinned?: boolean
11  /** 0-100 as the model reported it in `metadata.progress`; null when it never did. */
12  progress: number | null
13}
14
15/** One task as the model declares it through the mod's own tool. */
16export type DeclaredTask = {
17  name: string
18  status: 'pending' | 'in_progress' | 'completed' | 'blocked'
19  progress?: number
20  /** Pins the task: it stays until the person unpins it. */
21  persistent?: boolean
22}
23
24/** A signal of Mod Signals 0.1, as the mod writes it under `signal` for every other mod to hear. */
25export type TaskBarsSignal = {
26  v: 1
27  kind: 'event' | 'command' | 'announce'
28  name: string
29  to?: string
30  data?: Record<string, unknown>
31  /** What kind of signal this is, for whoever picks signals by kind: `['info', 'completion']`. */
32  tags?: string[]
33  id: string
34}
35
36/** A task as another mod reads it. */
37export type TaskBarsTask = { id: string; name: string; status: TaskStatus; progress: number | null }
38
39/** One task's change of status: `from` null when it appears, `to` null when it leaves the list. */
40export type TaskBarsChange = { id: string; name: string; from: TaskStatus | null; to: TaskStatus | null }
41
42/**
43 * What any other mod calls on `$.taskBars`, and the event it hooks to follow
44 * the tasks: `on('taskBars.statusChanged', hook)`.
45 */
46export type TaskBars = {
47  /** Shows the bars; answers whether they are shown once the call is done, as the next three do. */
48  open: () => Promise<boolean>
49  close: () => Promise<boolean>
50  toggle: () => Promise<boolean>
51  isOpen: () => Promise<boolean>
52  list: () => Promise<TaskBarsTask[]>
53  /** The status of the task of that id, else of that name; null when there is none. */
54  getStatus: (ref: { task: string }) => Promise<TaskStatus | null>
55  /** Called by the mod itself at every change of status: hook it, do not call it. */
56  statusChanged: (change: TaskBarsChange) => Promise<void>
57}
58
59declare module 'claude-code' {
60  interface EngineInterface {
61    taskBars: TaskBars
62  }
63
64  interface McpToolInputs {
65    'mcp__task-bars__set_tasks': { tasks: DeclaredTask[] }
66  }
67
68  interface PluginState {
69    'task-bars': {
70      tasks: TaskRow[]
71      isHidden: boolean
72      /** The mod's signals: `announce`, `opened`, `closed`, `status-changed`. */
73      signal: TaskBarsSignal | null
74    }
75  }
76}
77