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

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.
workstream (the key), purpose, scope, focus, goals, status, blocked, blocked_since, note and pinned, then a record of dated entries as the body.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./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.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.$.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.claude plugin marketplace add ikstewa/workstreams
claude plugin install workstreams@ikstewa
The /plugin command inside Claude Code does the same.
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.
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.
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:
| Input | Action |
|---|---|
| Click a sidebar row | Open the row's session in the right pane, or reset a stale one from its ↻ |
| Option+j / Option+k | Open the next or previous session without starting one |
| Option+n | Open the session that most needs you |
| Option+m | Menu of every row |
| Option+e | Edit 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.
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.
A personal tool built on Claude Code's early-access mods API, which can change between releases.
hooks/mod.ts 354 lines1import 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