SLOPSHOPPER

workstreams

Workstream board, charters, tmux sidebar and active-session dispatch

newguardprompttoolprocesstimer
v0.1.0no licenseupdated 2026-10-09ikstewa/workstreams
A shopper browsing a rack in a slop shop
README

workstreams

A Claude Code plugin for people who run several sessions at once. It groups sessions into workstreams, gives each workstream a charter file, and shows all of them on a live board in a tmux sidebar. The plugin is a Claude Code mod (TypeScript) plus a stdlib-only Python board script. It is for one person working across many long-running efforts, not for teams.

Key ideas

  • Workstream. A named effort that runs through many sessions. Its charter is one Markdown file: frontmatter for workstream (the key), purpose, scope, focus, goals, status, blocked, blocked_since, note and pinned, then a record of dated entries as the body.
  • Binding. A session belongs to the workstream whose workstream: key equals the session's name. A session named control is the control session. A name of the form KEY work: <task> is a worker of KEY.
  • The mod. In a bound session it appends the charter and the newest part of the record as a conversation row at start, after /clear and after a compaction. Rows over the record budget roll to <KEY>/record-archive.md beside the charter, and done goals are left out of what a session reads. It records board state from turn, ask, answer and sub-agent events. It registers the mcp__workstreams__charter tool, the session's only way to write its charter (focus, record entries, goals, block). It keeps the charter's goals in step with the session's task list: completing a goal's task ticks the goal.
  • The board. board.py render prints every workstream grouped as Control, Pinned, Active, Blocked, Idle, Unassigned and Done, with a stale line for workstreams that have had no session for a while. bin/ws runs it in a tmux sidebar; the /workstreams:board skill prints it in a session and ranks what to do next.

Requirements

  • Claude Code with the mods API ($.tool.register and the session, turn and tool events). It is developed against 2.1.289 to 2.1.291.
  • tmux, for the sidebar.
  • uv, which runs board.py with its own Python (uv run --no-project). The script uses only the standard library.
  • gh, only for /workstreams:board --deep, which also reads Jira through an MCP server when one is configured.

Install

claude plugin marketplace add ikstewa/workstreams
claude plugin install workstreams@ikstewa

The /plugin command inside Claude Code does the same.

Setup and use

Charters

Charters live in ~/.claude/projects/<project>/workstreams/<KEY>.md. <project> is the project directory with every character other than letters, digits and - replaced by -, so /Users/me/dev/myproject becomes -Users-me-dev-myproject. A .worktrees/<name> suffix is dropped, so a worktree shares its project's charters. The board finds a charter by its workstream: line; the file name is a convention.

A minimal charter:

---
workstream: PAYMENTS_API
purpose: Move invoice creation to the new payments API.
scope:
  in: [invoice creation, refunds]
  out: [subscription billing]
focus: Client library merged; next is the refund endpoint.
status: active
goals:
  - "[x] Client library for the payments API"
  - "[ ] Refund endpoint"
  - "[ ] Retire the old invoice path"
---
- **10-02** Client library merged. Refund endpoint design agreed.

Set status: done or status: archived to take a workstream off the board's active groups; an archived workstream leaves the board. Do not edit a bound session's charter by hand while the session runs; the session writes it through its charter tool. Only you write note: and pinned:. You set the note from the sidebar. You set the pin by clicking the ☆/★ on the key row of the sidebar's panel, or with board.py pin <KEY>, and a pinned workstream sits in the board's Pinned group.

Start a bound session

Name the session after the charter key. From the project directory:

claude -n PAYMENTS_API

/rename in a running session binds it the same way. If the session does not show its charter, its name does not match any charter's workstream: key.

Open the board

bin/ws creates a tmux session named ws: the board's tree sidebar (40 columns) on the left and claude agents --permission-mode auto on the right, then attaches to it. Run it from the project directory, since the sidebar's directory selects the project:

/path/to/workstreams/bin/ws

Put it on your PATH or alias it. It re-applies its tmux bindings on every run, so a reattach picks up changes.

Inside the ws session:

InputAction
Click a sidebar rowOpen the row's session in the right pane, or reset a stale one from its ↻
Option+j / Option+kOpen the next or previous session without starting one
Option+nOpen the session that most needs you
Option+mMenu of every row
Option+eEdit the note on the open workstream

Outside ws these keys pass through to the pane unchanged.

In a session, /workstreams:board prints the same board, ranks the decisions it raises and asks about the first. /workstreams:board --deep adds open pull requests from gh; /workstreams:board <WORKSTREAM> shows one workstream.

Development

uv run --no-project -m unittest discover -s tests
claude plugin test .
claude plugin validate .

tsconfig.json extends ./.claude-plugin/types/tsconfig.json, which is not committed. claude plugin test does not create it, so editor type checking of hooks/mod.ts needs that directory from elsewhere. claude plugin validate . passes with warnings about hooks in mod.ts that have no .catch.

The design is in docs/spec.html.

Status

A personal tool built on Claude Code's early-access mods API, which can change between releases.

Source 1 files
hooks/mod.ts 354 lines
1import type { ApiMessage, EngineInterface, Register, ToolCallResult, ToolSpec } from 'claude-code'
2
3// A bound session's charter, as a user row of its conversation; the charter tool, its one way to write that charter; its goals kept in
4// step with its task list; and every session's board record. board.py builds the text, makes the writes and keeps the record's state
5// machine; this module decides when each runs and forwards the events the record moves on.
6// Every $-taking helper is a top-level function: the validator lets $ into no other.
7
8const HEAD = '# Workstream charter ('
9const TOOL = 'mcp__workstreams__charter'
10
11// What the model reads to learn the tool: BOUND_RULES in board.py say when to write, this says how.
12const CHARTER: ToolSpec = {
13  name: 'charter',
14  // One line per paragraph and per field: a source line wrapped inside one would reach the model as a break mid-sentence.
15  description: [
16    'Writes the charter of your workstream. The charter is the file named in the header of the charter row in your conversation. ' +
17      'Use this tool for every change to the charter. Do not edit the charter file yourself.',
18    '',
19    'Give one or more of the fields below. One call can carry several fields. The tool applies them in the order below. ' +
20      'The result has one line for each change, then the number of open goals. If the tool refuses a call, the error says why.',
21    '- focus: Replaces the focus line. Say what the workstream is on now: the progress of the plan, the work in flight, and the next ' +
22      'step. The tool changes each newline to a space.',
23    '- record: Adds one entry at the end of the record, as a new top-level bullet. The tool puts "- " and a bold date, "**MM-DD** ", ' +
24      'in front of the first line. If the first line starts with a bold date of your own, such as **10-05, Ian: "go".**, the tool adds ' +
25      'no date. Put each nested bullet on a new line, indented by two spaces.',
26    '- add_goals: Adds each text as an open goal at the end of the goals list. The tool also adds a task for each new goal to your task ' +
27      'list. The tool skips a goal that the list already has, open or ticked.',
28    '- block: Sets blocked: to the thing that the work waits on. Use it only for a wait outside this session and not on Ian, such as a ' +
29      'review, feedback or a merge. The tool also sets blocked_since: to the time now. If you set a block again, its age starts again.',
30    '- clear_block: true removes blocked: and blocked_since:. Do not send clear_block and block in the same call.',
31    '',
32    'This tool does not tick or untick a goal. To tick a goal, complete its task with TaskUpdate. This tool cannot change note:, which ' +
33      'is Ian\'s line. It cannot change any other line of the charter. Only the main session can use this tool. A sub-agent cannot.',
34  ].join('\n'),
35  inputSchema: {
36    type: 'object',
37    properties: {
38      focus: { type: 'string', description: 'The new focus, as one line: progress, in flight, next.' },
39      record: { type: 'string', description: 'One record entry. The tool adds the bullet and the date.' },
40      add_goals: { type: 'array', items: { type: 'string' }, description: 'New open goals, one text each.' },
41      block: { type: 'string', description: 'What the work waits on, outside this session and not on Ian.' },
42      clear_block: { type: 'boolean', description: 'true removes the block.' },
43    },
44    additionalProperties: false,
45  },
46}
47
48type Charter = { registry?: string; name?: string | null; key?: string; text?: string }
49
50// What `board.py event` applies to the record, each with what it carries.
51type Board =
52  | { event: 'start' | 'turn.start' | 'answered' }
53  | { event: 'turn.complete'; reason: string; answer: string }
54  | { event: 'ask'; kind: 'permission' | 'question' }
55  | { event: 'child.start'; agent_id: string; name: string }
56  | { event: 'child.stop'; agent_id: string }
57  | { event: 'end'; reason: string }
58
59// One board.py run, `board.py <cmd> <session id> [...args]`, its stdin built from the session's cwd as it starts; done hands what it
60// printed, or why it failed, to a caller that waits for it. `what` leads its debug line when it fails.
61type Job = { cmd: 'event' | 'write' | 'mirror' | 'tick'; args?: string[]; stdin?: (cwd: string) => string; sid?: string; timeoutMs: number; what: string; done: (out: Out) => void }
62type Out = { stdout: string } | { failed: string }
63
64// The registry file and name the last board.py read reported, and what is owed at the next prompt or main-loop model request.
65// restart: the new session id of a /clear or a /resume owes its record a start. asks: calls that went to the mode's decider and
66// have not returned. kids: the sub-agents whose run the record shows. queue: board.py runs not yet made; draining: one makes them.
67type State = {
68  registry?: string
69  name?: string | null
70  owed?: 'full' | 'start'
71  restart?: boolean
72  asks: Set<string>
73  kids: Set<string>
74  queue: Job[]
75  draining: boolean
76}
77
78// How often, and how many times, to look for the session id a /clear or a /resume goes on under: 5 s in all. The live check saw
79// the new id's settings SessionStart 0.12 s after session.end.
80const FOLLOW_MS = 50
81const FOLLOW_TRIES = 100
82
83// Read in the API form: the rows form leaves out meta rows, and the charter row is one. There it is a text block of a user
84// message, among the reminders and the prompt merged into it. The declarations say content is always blocks; a string is taken too.
85const holdsCharter = (m: ApiMessage) => {
86  const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : m.content
87  return m.role === 'user' && blocks.some(b => b.type === 'text' && String(b.text).startsWith(HEAD))
88}
89
90const reason = (err: unknown) => (err instanceof Error ? err.message : String(err))
91const exited = (run: { exitCode: number; stderr: string }) => new Error(`board.py exited ${run.exitCode}: ${run.stderr.trim().split('\n').at(-1) ?? ''}`)
92
93async function inject($: EngineInterface, st: State, mode: 'full' | 'refresh'): Promise<void> {
94  let key: string | undefined
95  try {
96    const cwd = await $.session.cwd()
97    const argv = ['uv', 'run', '--no-project', `${$.plugin.root}/hooks/board.py`, 'charter', await $.session.id()]
98    // CLAUDE_PROJECT_DIR set to the cwd, as a settings hook started there would see it: an inherited one could name another project.
99    const run = await $.process.run(mode === 'refresh' ? [...argv, '--refresh'] : argv, { cwd, env: { CLAUDE_PROJECT_DIR: cwd }, timeoutMs: 10_000 })
100    if (run.exitCode !== 0) throw exited(run)
101    const out = JSON.parse(run.stdout) as Charter
102    if (out.registry) Object.assign(st, { registry: out.registry, name: out.name ?? null })
103    if (!out.text) return
104    key = out.key
105    await offer($)
106    const kept = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: out.text }] } })
107    if (kept.deny !== undefined) throw new Error(`append refused: ${kept.deny}`)
108  } catch (err) {
109    $.ui.log(`charter${key ? ` of ${key}` : ''} not appended (${mode}): ${reason(err)}`, { to: 'debug' })
110  }
111}
112
113// The charter tool, for a session board.py has just found bound. Registering it again replaces it with itself, so every bound read
114// registers it, a /clear's or a /rename's included; an unbound session never has it.
115async function offer($: EngineInterface): Promise<void> {
116  try {
117    await $.tool.register(CHARTER)
118  } catch (err) {
119    $.ui.log(`charter tool not registered: ${reason(err)}`, { to: 'debug' })
120  }
121}
122
123// Queues a board.py run; it resolves with what the run printed, or why it failed. One queue, run one at a time in the order queued,
124// since two runs at once would each write the record or the charter over the other's change. It runs from a $.clock callback, so no
125// hook's dispatch owns the run: one abandoned (an Esc at a dialog, an interrupt) cannot abort it.
126function queue($: EngineInterface, st: State, job: Omit<Job, 'done'>): Promise<Out> {
127  return new Promise<Out>(done => {
128    st.queue.push({ ...job, done })
129    // Each run asks for a drain while none runs, so a timer that never fires costs only the wait for the next run.
130    if (!st.draining) $.clock.after(0, () => void drain($, st))
131  })
132}
133
134// A board.py event the record moves on. No hook but session.end waits for one.
135function record($: EngineInterface, st: State, ev: Board, sid?: string, timeoutMs = 10_000): Promise<Out> {
136  return queue($, st, { cmd: 'event', stdin: cwd => JSON.stringify({ ...ev, cwd }), sid, timeoutMs, what: `board ${ev.event} not recorded` })
137}
138
139// The task list from the charter's goals, for the session that `sid` names or the one running.
140function mirror($: EngineInterface, st: State, sid?: string): Promise<Out> {
141  return queue($, st, { cmd: 'mirror', sid, timeoutMs: 10_000, what: 'goals not mirrored' })
142}
143
144// ponytail: a run still queued when a /clear moves the session to its new id is made under the new one; a queued run waits
145// milliseconds, and a /clear never comes mid-turn
146async function drain($: EngineInterface, st: State): Promise<void> {
147  if (st.draining) return
148  st.draining = true
149  for (let job = st.queue.shift(); job; job = st.queue.shift()) {
150    let out: Out = { failed: 'not run' }
151    try {
152      const cwd = await $.session.cwd()
153      const id = job.sid ?? (await $.session.id())
154      const run = await $.process.run(['uv', 'run', '--no-project', `${$.plugin.root}/hooks/board.py`, job.cmd, id, ...(job.args ?? [])],
155        { cwd, env: { CLAUDE_PROJECT_DIR: cwd }, ...(job.stdin && { stdin: job.stdin(cwd) }), timeoutMs: job.timeoutMs })
156      if (run.exitCode !== 0) throw exited(run)
157      out = { stdout: run.stdout }
158    } catch (err) {
159      out = { failed: reason(err) }
160      $.ui.log(`${job.what}: ${out.failed}`, { to: 'debug' })
161    } finally {
162      job.done(out)
163    }
164  }
165  st.draining = false
166}
167
168// What a write printed, as the tool's answer: its summary, or a refusal the model reads as an error.
169function answer(out: Out): ToolCallResult {
170  if ('failed' in out) return { deny: `the charter was not written: ${out.failed}` }
171  try {
172    const said = JSON.parse(out.stdout) as { ok?: boolean; summary?: string; error?: string }
173    if (said.ok === true) return { result: said.summary ?? '' }
174    return { deny: said.error ?? 'board.py answered neither ok nor error' }
175  } catch (err) {
176    return { deny: `the charter write answered no JSON: ${reason(err)}` }
177  }
178}
179
180// The record starts over with the session, and the sub-agents and waits this module tracks go with the run they belonged to.
181function begin($: EngineInterface, st: State, sid?: string): Promise<Out> {
182  st.kids.clear()
183  st.asks.clear()
184  return record($, st, { event: 'start' }, sid)
185}
186
187// No event fires on the id a /clear or a /resume goes on under, so look for it from $.clock, which outlives session.end's dispatch,
188// and start its record once it shows. A prompt that comes first starts it instead, and so does the next prompt after the last look.
189async function follow($: EngineInterface, st: State, ended: string, tries: number): Promise<void> {
190  let id: string | undefined
191  try { id = await $.session.id() } catch {}
192  if (!st.restart) return
193  if (id !== undefined && id !== ended) {
194    st.restart = false
195    void begin($, st, id)
196    void mirror($, st, id)
197  } else if (tries > 1) $.clock.after(FOLLOW_MS, () => void follow($, st, ended, tries - 1))
198}
199
200// A sub-agent shows from its run's first model request. Only an agent the session lists is one: the engine's own forks (compaction,
201// memory) make requests under ids no list names. Its name is its type, as SubagentStart's agent_type was.
202async function child($: EngineInterface, st: State, agentId: string): Promise<void> {
203  let type: string | undefined
204  try {
205    type = (await $.agent.list()).find(a => a.id === agentId)?.type
206  } catch (err) {
207    $.ui.log(`agents not listed: ${reason(err)}`, { to: 'debug' })
208  }
209  if (type === undefined) return
210  st.kids.add(agentId)
211  void record($, st, { event: 'child.start', agent_id: agentId, name: type })
212}
213
214// A transcript that already holds a charter row is a resume, a reopen or a reload: the session keeps the record, so only the refresh.
215async function start($: EngineInterface, st: State): Promise<void> {
216  let held = false
217  try { held = (await $.session.messages({ as: 'api' })).some(holdsCharter) } catch {}
218  await inject($, st, held ? 'refresh' : 'full')
219}
220
221async function settle($: EngineInterface, st: State): Promise<void> {
222  const owed = st.owed
223  st.owed = undefined
224  await (owed === 'full' ? inject($, st, 'full') : start($, st))
225}
226
227// A /rename shows as a new name in the session's registry file, read in process; board.py runs only once it changed.
228async function renamed($: EngineInterface, st: State): Promise<boolean> {
229  if (!st.registry) return false
230  try {
231    const name = (JSON.parse(await $.fs.read(st.registry)) as { name?: string | null }).name ?? null
232    if (name === st.name) return false
233    st.name = name
234    await inject($, st, 'full')
235    return true
236  } catch (err) {
237    $.ui.log(`registry not read: ${reason(err)}`, { to: 'debug' })
238    return false
239  }
240}
241
242export const register: Register = on => {
243  const st: State = { asks: new Set(), kids: new Set(), queue: [], draining: false }
244
245  // The goals are mirrored at a start, at each prompt and at a main-loop turn's end, as the settings hooks did before the mod.
246  on('session.start', async ($, e, next) => {
247    const r = await next(e)
248    void begin($, st)
249    void mirror($, st)
250    await start($, st)
251    // No registry file named the session yet: look once more at the first prompt.
252    if (!st.registry) st.owed ??= 'start'
253    return r
254  })
255
256  // No session.start follows a /clear, which starts an empty conversation, or a /resume or /branch (reason resume), which load a held one.
257  on('session.end', async ($, e, next) => {
258    if (e.reason === 'clear') st.owed = 'full'
259    else if (e.reason === 'resume') st.owed = 'start'
260    if (e.reason === 'clear' || e.reason === 'resume') st.restart = true
261    const r = await next(e)
262    // The ending id, not the one the process goes on under. The one hook that waits for its run: an exit ends the process after this
263    // chain, which shares one short bound.
264    const ended = record($, st, { event: 'end', reason: e.reason }, e.sessionId, Math.max(100, Math.min(10_000, next.budget.remainingMs)))
265    if (e.reason === 'clear' || e.reason === 'resume') $.clock.after(FOLLOW_MS, () => void follow($, st, e.sessionId, FOLLOW_TRIES))
266    await ended
267    return r
268  })
269
270  // Ahead of next(e), so the row lands ahead of the prompt and the record's start is queued ahead of the turn's. A prompt typed over
271  // a running turn starts no record over: the turn's next event brings a new name from the registry anyway.
272  on('prompt.submit', async ($, e, next) => {
273    let named = false
274    if (st.owed) await settle($, st)
275    else named = await renamed($, st)
276    if ((st.restart || named) && e.turnId === undefined) {
277      st.restart = false
278      void begin($, st)
279    }
280    void mirror($, st)
281    return next(e)
282  })
283
284  // Any hook may still rewrite a compaction's messages on the way up, so the conversation becomes them only after this chain returns:
285  // a row appended inside it would join the conversation being replaced. The full row is owed instead, and paid at the next prompt
286  // or main-loop model request, whichever comes first. A precompute installs nothing.
287  // ponytail: the charter rows are summarized with the rest: e.messages is declared in the rows form, which leaves meta rows out
288  // (measured on $.session.messages()), and the event offers no other form to drop them from
289  on('session.compact', async ($, e, next) => {
290    const r = await next(e)
291    if (e.agentId === undefined && r.messages && e.trigger !== 'precompute') st.owed = 'full'
292    return r
293  })
294
295  // Every main-loop turn, a prompt's or one begun without one (a task notification, a peer's message, a sub-agent's handback).
296  on('turn.start', async ($, e, next) => {
297    const r = await next(e)
298    void record($, st, { event: 'turn.start' })
299    return r
300  })
301
302  on('turn.step', async function* ($, e, next) {
303    if (e.agentId !== undefined && !st.kids.has(e.agentId)) await child($, st, e.agentId)
304    if (st.owed && e.agentId === undefined) await settle($, st)
305    return yield* next(e)
306  })
307
308  // A sub-agent's run raises no turn.start, and its turn.complete carries its id.
309  on('turn.complete', async ($, e, next) => {
310    const r = await next(e)
311    if (e.agentId === undefined) {
312      st.asks.clear()   // the turn's end ends the record's wait: a call still out returns to none
313      void record($, st, { event: 'turn.complete', reason: e.reason, answer: e.answer })
314      void mirror($, st)
315    } else if (st.kids.delete(e.agentId)) void record($, st, { event: 'child.stop', agent_id: e.agentId })
316    return r
317  })
318
319  // The wait shows the moment a call goes to the mode's decider, a dialog or the auto-mode classifier, which then reads as a dialog
320  // that answered itself. A query ($.tool.check) carries no call id and asks no one.
321  on('tool.check', async ($, e, next) => {
322    const r = await next(e)
323    if (r.decision === 'ask' && e.tool_use_id !== undefined) {
324      st.asks.add(e.tool_use_id)
325      void record($, st, { event: 'ask', kind: e.tool === 'AskUserQuestion' ? 'question' : 'permission' })
326    }
327    return r
328  })
329
330  // A call that asked returns once it is answered: run, refused, or Esc at the dialog. The wait ends when no such call is still out.
331  on('tool.call', async ($, e, next) => {
332    const r = await next(e)
333    if (e.tool_use_id !== undefined && st.asks.delete(e.tool_use_id) && st.asks.size === 0) void record($, st, { event: 'answered' })
334    return r
335  })
336
337  // The charter tool, answered here so core never runs it: no check, no dialog. The write waits on the one queue, behind the record's
338  // events, and the call waits for that write alone. That wait is no $ call, so it counts against the hook's 10 s budget, past which
339  // core would answer the call with a failure of its own: the write's run stops at 8 s, so a timeout still reaches the model as its why.
340  // ponytail: runs queued ahead of the write count against the same budget; each takes a fraction of a second
341  on('tool.call', { tool: TOOL }, async ($, e) => {
342    if (e.agentId !== undefined) return { deny: 'only the main session writes the charter' }
343    const fields = { focus: e.focus, record: e.record, add_goals: e.add_goals, block: e.block, clear_block: e.clear_block }
344    return answer(await queue($, st, { cmd: 'write', stdin: () => JSON.stringify(fields), timeoutMs: 8_000, what: 'charter not written' }))
345  })
346
347  // A goal task's status onto its goal once the update has run, queued, so two updates in parallel tick one after the other.
348  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
349    const r = await next(e)
350    if (r.deny === undefined && r.isError !== true) void queue($, st, { cmd: 'tick', args: [e.taskId], timeoutMs: 10_000, what: `goal of task ${e.taskId} not ticked` })
351    return r
352  })
353}
354