Shows the orch tickets this session holds: status line, band above the prompt, /orch pane and hand-over toasts.

Shows the orch tickets this session holds, inside Claude Code. Nothing else: no backlog, no queue.
Everything leads with whose move it is, as orch-core says it (move in orch show --json, the dashboard's own rules) and in its design system's colours: pink ● only for your move (answer a question, approve a plan, re-approve a changed gate, do your task, give the verdict), blue ◐ while the agent works, amber ▲ when the ticket is blocked, red ✕ when orch cannot be read. Every status is an icon and a word, never colour alone.
◐ L-0001 working ● L-0002 Answer Q1, one entry per claimed ticket.● L-0002 0/1 Your move: Answer Q1 · Which one?./orch: a pane with one card per ticket: whose move and what, an open question with its options, the recommended one and the orch answer command to run in your own terminal, every task (doing and blocked first), the epic it belongs to, the last verdict and the PR.It only reads: orch list --mine --json with the session's id and orch show <id> --json for each ticket. It refreshes after every orch command the agent runs and every 20 seconds while things change (60 seconds after three quiet polls, 5 minutes where there is no orch workspace), one refresh at a time, without holding up the tool call. A refresh in which any read fails keeps the last complete state and says so in the pane. It never runs a command that writes or that only a human may run; it shows the orch answer command, it does not run it.
What it shows. orch list --mine lists only the claims held by this session's own id, so a fresh claude shows nothing until the agent runs orch claim. Epics cannot be claimed: an epic appears only as "in epic DEMO-0031" on its claimed children, without progress of its own (open it in Mission Control for that). In a folder without an orch workspace, /orch shows a short toast instead of a pane, and the refresh slows to every 5 minutes.
The status line is drawn by Claude Code, which currently styles every plugin status like a warning (⚠ orch-session: ...) and ignores plugin colours there. That is the host's behaviour, so the line is kept short.
The plugin has no rules of its own for whose move it is. An orch that predates move (orch-core schema below 1.4.0) shows "Update orch-core" on each ticket and an amber note in the pane, never a guess.
Needs the orch CLI on the PATH, orch-core with schema 1.4.0 or newer (see orch-core's README, "The CLI in your own terminal"), and a Claude Code build with function-hook plugins (early access; the API may change between releases).
claude plugin install orch-session@orch-core
It is a separate plugin on purpose: if a Claude Code update breaks the hooks API, orch-core's guard and session-start hooks keep working.
node --test plugins/orch-session/test/*.spec.ts # the logic, against real orch output; no claude CLI needed
claude plugin validate plugins/orch-session
claude plugin test plugins/orch-session # the hooks inside the engine
claude --plugin-dir plugins/orch-session # load it into a session
The fixtures in test/fixtures are real orch-core output. After a change to what orch list or orch show print, make them again (CI also runs the tests against freshly made ones):
uv run --project plugins/orch-core python plugins/orch-session/test/make_fixtures.py plugins/orch-session/test/fixtureshooks/register.tsx 258 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Problem, Role, Task, Ticket } from '../types'
5import { ICON, active, byUrgency, changes, loadTickets, ordered, serial, statusLine } from './tickets'
6
7const PANE = 'orch-session'
8const TIMEOUT_MS = 15000
9
10// orch-core's design tokens (static/tokens.json, role fg): pink only for the human's move, blue for the agent at
11// work, amber for a warning, red for an error, mint green for done. `neu` draws dim.
12const PALETTE: Record<'dark' | 'light', Record<Role, string | undefined>> = {
13 dark: { you: '#FF8FC4', info: '#9CC0FF', warn: '#F5B544', err: '#FF8A80', ok: '#A9F7C0', neu: undefined },
14 light: { you: '#B8136A', info: '#1F5FD1', warn: '#7A4E00', err: '#B42318', ok: '#0B4F3D', neu: undefined },
15}
16let tone = PALETTE.dark
17
18// Polling: 20 s while things change, 60 s after three quiet polls, 5 min where there is no orch workspace (every
19// refresh runs 1 + N `orch` processes; ~1.8 CPU-s with 8 tickets). An orch command, `/orch` or a change speeds it up again.
20const FAST_MS = 20000
21let quiet = 0
22let gone = 0
23let sig = ''
24const delay = () => (gone >= 2 ? 300000 : quiet >= 3 ? 60000 : FAST_MS)
25const signature = (list: Ticket[]) => list.map(t => [t.id, t.status, t.move.what, t.move.ref, t.closed, t.total, t.doing].join('|')).join(';')
26
27const WHO: Record<string, string> = { you: 'YOUR MOVE', agent: 'AGENT WORKING', nobody: '' }
28// stale/blocked are the agent's move too, but the header must not say "working" over a stale claim
29const WHO_KIND: Record<string, string> = { stale: 'STALE CLAIM', blocked: 'BLOCKED' }
30const needsGate = (what: string) => /^(approve|re-approve)/.test(what)
31const PROBLEM: Record<Problem, string> = {
32 'no-workspace': 'No orch workspace here.',
33 missing: 'orch is not on the PATH.',
34 timeout: 'orch did not answer in time.',
35 failed: 'orch reported an error.',
36 unreadable: 'orch gave output this plugin cannot read.',
37 outdated: 'This orch does not say whose move it is: update orch-core (schema 1.4.0 or newer).',
38}
39
40const tickets = atom({ plugin: 'orch-session', key: 'tickets' } as const, null)
41const problem = atom({ plugin: 'orch-session', key: 'problem' } as const, null)
42
43async function refreshOnce($: EngineInterface) {
44 const env = { CLAUDE_CODE_SESSION_ID: await $.session.id() }
45 const got = await loadTickets(argv => $.process.run(argv, { env, timeoutMs: TIMEOUT_MS }))
46 if ('problem' in got) {
47 gone = got.problem === 'no-workspace' ? gone + 1 : 0
48 // Keep the last whole state; without a workspace there is nothing to keep.
49 await update($, problem, () => got.problem)
50 if (got.problem === 'no-workspace') {
51 await update($, tickets, () => null)
52 $.ui.status(undefined)
53 }
54 return
55 }
56 gone = 0
57 const now = signature(got.tickets)
58 quiet = now === sig ? quiet + 1 : 0
59 sig = now
60 const before = await read($, tickets)
61 if (before) for (const text of changes(before, got.tickets)) $.ui.toast(text, { timeoutMs: 6000 })
62 await update($, tickets, () => got.tickets)
63 // an orch without `move`: the tickets show "Update orch-core" instead of a guess
64 const outdated = got.tickets.some(t => t.move.what === 'outdated')
65 await update($, problem, () => (outdated ? 'outdated' : null))
66 $.ui.status(statusLine(got.tickets))
67}
68
69// One refresh at a time (serial), set up per session start.
70let refresh: () => Promise<void> = () => Promise.resolve()
71// an event the human can see (an orch command, /orch) is a reason to look again soon
72const wake = () => {
73 quiet = 0
74 gone = 0
75 void refresh()
76}
77
78function count(t: Ticket): string {
79 return t.total > 0 ? `${t.closed}/${t.total}` : ''
80}
81
82const TASK_ROLE: Record<string, Role> = { doing: 'info', blocked: 'warn', done: 'ok', skipped: 'neu', todo: 'neu' }
83const TASK_ICON: Record<string, string> = { doing: '◐', blocked: '▲', done: '✓', skipped: '–', todo: '○' }
84
85export const register: Register = on => {
86 on('session.start', async ($, e, next) => {
87 const started = await next(e)
88 refresh = serial(() => refreshOnce($))
89 await $.command.register({ name: 'orch', description: 'Show the tickets this session holds' })
90 try {
91 const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
92 tone = String(theme ?? '').includes('light') ? PALETTE.light : PALETTE.dark
93 } catch {
94 // the dark palette
95 }
96 void refresh()
97 // ponytail: polls (back-off above); watch orchestrator/.state/events.jsonl if that ever feels slow
98 const tick = () => $.clock.after(delay(), () => void refresh().finally(tick))
99 tick()
100 return started
101 })
102
103 // An orch command the agent just ran is the moment something changed. The refresh runs on its own: the tool's
104 // result never waits for it.
105 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
106 const ran = await next(e)
107 if (/\borch\s/.test(e.command)) wake()
108 return ran
109 })
110
111 on('command.run', { command: 'orch' }, async $ => {
112 // Outside an orch workspace there is one line to say: a toast, not a half-width pane.
113 quiet = 0
114 gone = 0
115 await refresh()
116 if ((await read($, problem)) === 'no-workspace') {
117 $.ui.toast(PROBLEM['no-workspace'], { timeoutMs: 4000 })
118 return { text: PROBLEM['no-workspace'] }
119 }
120 await $.ui.open({ id: PANE, title: 'orch · this session' })
121 return { text: 'orch pane opened.' }
122 })
123
124 // One row: the move that matters most right now.
125 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
126 const list = await read($, tickets)
127 const t = list && active(list)
128 // The band is for what needs a look: the human's move, a stale claim, a blocked ticket. The agent working is
129 // already in the status line, so no row above the prompt is spent on it.
130 if (e.props.hasSurvey || !list || !t || (t.move.role !== 'you' && t.move.role !== 'warn' && t.move.role !== 'err')) return next(e)
131 const { Text } = $.ui.resolve(e)
132 const m = t.move
133 const others = list.length - 1
134 return (
135 <Text wrap="truncate-end">
136 <Text color={tone[m.role]} dimColor={!tone[m.role]} bold>{ICON[m.role]} {t.id}</Text>
137 {others > 0 ? <Text dimColor> (+{others})</Text> : ''}
138 {t.total > 0 ? <Text dimColor> {count(t)}</Text> : ''}{' '}
139 <Text color={tone[m.role]} dimColor={!tone[m.role]}>
140 {m.who === 'you' ? `Your move: ${m.label}` : m.label}
141 {t.detail ? ` · ${t.detail}` : ''}
142 </Text>
143 {others > 0 ? <Text dimColor> · /orch</Text> : ''}
144 </Text>
145 )
146 })
147
148 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
149 const { Box, Link, Text } = $.ui.resolve(e)
150 const list = await read($, tickets)
151 const trouble = await read($, problem)
152 // A failed read is an error (the last good state stays); an orch too old to say whose move is a warning.
153 const bannerRole: Role = trouble === 'outdated' ? 'warn' : 'err'
154 const banner = trouble && trouble !== 'no-workspace' && (
155 <Text color={tone[bannerRole]} wrap="wrap">
156 {ICON[bannerRole]} {PROBLEM[trouble]}{list && trouble !== 'outdated' ? ' Showing the last good state.' : ''}
157 </Text>
158 )
159 if (!list || list.length === 0) {
160 return (
161 <Box flexDirection="column">
162 {banner}
163 <Text dimColor>
164 {ICON.neu} {trouble === 'no-workspace' ? PROBLEM['no-workspace'] : 'This session holds no ticket.'}
165 </Text>
166 </Box>
167 )
168 }
169
170 const taskLine = (task: Task) => {
171 const role = TASK_ROLE[task.state] ?? 'neu'
172 const closed = task.state === 'done' || task.state === 'skipped'
173 return (
174 <Text key={task.id} color={closed ? undefined : tone[role]} dimColor={closed || !tone[role]} wrap="truncate-end">
175 {TASK_ICON[task.state] ?? '○'} {task.id} {task.state} · {task.text}
176 {task.owner === 'human' ? ' (yours)' : ''}
177 {task.state === 'blocked' && task.why ? ` — ${task.why}` : ''}
178 </Text>
179 )
180 }
181
182 // One card per ticket: whose move, the one action, the work, the PR.
183 return (
184 <Box flexDirection="column">
185 {banner}
186 {byUrgency(list).map(t => {
187 const m = t.move
188 const color = tone[m.role]
189 // an agent at work with nothing to decide: one row, so the cards that need you stay in view
190 if (m.who === 'agent' && m.role === 'info') {
191 return (
192 <Text key={t.id} wrap="truncate-end">
193 <Text color={color} bold>{ICON[m.role]} {t.id}</Text>
194 <Text dimColor> {count(t) ? `${count(t)} ` : ''}</Text>
195 {t.title}
196 <Text dimColor> · {t.detail || m.label}</Text>
197 </Text>
198 )
199 }
200 const who = WHO_KIND[m.what] || WHO[m.who] || m.label.toUpperCase()
201 return (
202 <Box
203 key={t.id}
204 flexDirection="column"
205 borderStyle="round"
206 borderColor={color}
207 borderDimColor={!color}
208 paddingX={1}
209 marginBottom={1}
210 >
211 <Box justifyContent="space-between">
212 <Text>
213 <Text color={color} dimColor={!color} bold>{ICON[m.role]} {t.id} {who}</Text>
214 <Text dimColor> · {t.status}</Text>
215 </Text>
216 <Text dimColor>{count(t)}</Text>
217 </Box>
218 <Text bold wrap="truncate-end">{t.title}</Text>
219 {t.epic && <Text dimColor wrap="truncate-end">in epic {t.epic}</Text>}
220 <Text color={color} dimColor={!color} wrap="wrap">
221 {m.label}{t.detail ? `: ${t.detail}` : ''}
222 </Text>
223 {t.question && t.question.options.map(o => (
224 <Text key={o.key} dimColor wrap="truncate-end">
225 {' '}{o.key} {o.label}{o.key === t.question?.recommended ? ' (recommended)' : ''}
226 </Text>
227 ))}
228 {t.question && (
229 <Text dimColor wrap="wrap">{' '}In your own terminal: {t.question.command}</Text>
230 )}
231 {t.confirm.length > 0 && (
232 <Text dimColor wrap="truncate-end">
233 {ICON.neu} {t.confirm.join(', ')}: the agent went ahead on its recommendation; confirm when you can
234 </Text>
235 )}
236 {needsGate(m.what) && (
237 <Text dimColor wrap="truncate-end">
238 gates: requirements {t.gates.requirements === 'approved' ? '✓' : '●'} {t.gates.requirements} · plan{' '}
239 {t.gates.plan === 'approved' ? '✓' : '●'} {t.gates.plan}
240 </Text>
241 )}
242 {ordered(t.tasks).map(taskLine)}
243 {t.verdict && t.status !== 'testing' && (
244 <Text dimColor wrap="truncate-end">last verdict: {t.verdict}</Text>
245 )}
246 {t.pr && (
247 <Text dimColor wrap="truncate-end">
248 <Link href={t.pr.url}>{`${t.pr.label} ↗`}</Link>
249 </Text>
250 )}
251 </Box>
252 )
253 })}
254 </Box>
255 )
256 })
257}
258hooks/tickets.ts 279 lines1// What the views draw, from `orch list --mine --json` and `orch show <id> --json`. No engine imports: the plugin's
2// logic runs under `node --test` too (test/tickets.spec.ts).
3import type { Move, Problem, Question, Role, Task, Ticket } from '../types'
4
5// orch-core's role icons (orch.dashboard.data.cards.ICONS): every status is an icon and a word, never colour alone.
6export const ICON: Record<Role, string> = { ok: '✓', info: '◐', you: '●', warn: '▲', err: '✕', neu: '○' }
7
8// The only commands a refresh runs (after `orch`), each with --json. Nothing that writes, nothing human-only.
9export const READS: readonly (readonly string[])[] = [['list', '--mine'], ['show']]
10
11// Control characters (C0, DEL, C1) and bidi/zero-width marks, which could restyle the terminal or reorder text.
12const HIDDEN = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f---]/g
13
14// Ticket text as one safe line.
15export function clean(value: unknown, max = 200): string {
16 const s = String(value ?? '').replace(/[\n\t\r]+/g, ' ').replace(HIDDEN, '').trim()
17 return s.length > max ? s.slice(0, max - 1) + '…' : s
18}
19
20const list = (v: unknown): any[] => (Array.isArray(v) ? v : [])
21const unanswered = (q: any) => q?.answer === null || q?.answer === undefined || q?.answer === ''
22const WHO = new Set(['you', 'agent', 'nobody'])
23
24// The design-system role of orch-core's move: pink only when it is the human's.
25export function roleOf(who: string, kind: string): Role {
26 if (who === 'you') return 'you'
27 if (kind === 'done') return 'ok'
28 if (kind === 'blocked' || kind === 'stale') return 'warn'
29 return kind === 'working' ? 'info' : 'neu'
30}
31
32// Shown for an orch that predates `move` in its JSON (orch-core < schema 1.4.0): no guessing.
33export const OUTDATED: Move = { who: 'nobody', what: 'outdated', label: 'Update orch-core', role: 'neu', ref: null, why: null }
34
35// Whose move it is, as orch-core decides it (`move` in `orch show --json`, its dashboard's rules).
36export function moveOf(d: any): Move {
37 const m = d?.move
38 if (!m || typeof m !== 'object' || !WHO.has(m.who) || typeof m.kind !== 'string' || typeof m.label !== 'string') return OUTDATED
39 return {
40 who: m.who,
41 what: clean(m.kind, 40),
42 label: clean(m.label, 80),
43 role: roleOf(m.who, m.kind),
44 ref: m.ref === null || m.ref === undefined ? null : clean(m.ref, 40),
45 why: m.why ? clean(m.why) : null,
46 }
47}
48
49// A shell word the human can paste: plain when safe, else single-quoted.
50function word(s: string): string {
51 return /^[\w.,:@%+=-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`
52}
53
54export function answerCommand(id: string, qid: string, recommended: string | null): string {
55 return `orch answer ${id} ${qid} ${recommended ? word(recommended) : '<answer>'}`
56}
57
58function question(d: any, id: string): Question | null {
59 const q = list(d.meta?.questions).find(x => x && (x.blocking ?? true) && unanswered(x))
60 if (!q) return null
61 const rec = Array.isArray(q.recommended) ? q.recommended.join(',') : q.recommended
62 const recommended = rec === null || rec === undefined || rec === '' ? null : clean(rec, 80)
63 const qid = clean(q.id, 20)
64 return {
65 id: qid,
66 text: clean(q.text),
67 options: list(q.options).filter(o => o && o.key !== undefined).map(o => ({ key: clean(o.key, 20), label: clean(o.label, 80) })),
68 recommended,
69 command: answerCommand(id, qid, recommended),
70 }
71}
72
73function firstPr(meta: any): Ticket['pr'] {
74 const p = list(meta.prs).find(x => x && typeof x.url === 'string')
75 if (!p || !/^https?:\/\//i.test(p.url)) return null
76 const n = /\/(?:pull|pulls|pr|merge_requests)\/(\d+)(?:[/?#]|$)/.exec(p.url)?.[1]
77 return { label: n ? `PR #${n}` : 'PR', url: p.url }
78}
79
80function taskOf(x: any): Task {
81 return { id: clean(x.id, 20), state: String(x.state ?? ''), text: clean(x.text), why: x.why ? clean(x.why) : null, owner: String(x.owner ?? 'agent') }
82}
83
84// The one thing that happens next, in words: orch's own reason for the human's move, the agent's work otherwise.
85function detailOf(d: any, move: Move, tasks: Task[], q: Question | null): string {
86 const t = d.tasks ?? {}
87 const find = (id: unknown) => tasks.find(x => x.id === id)
88 if (move.what === 'outdated') return 'This orch does not say whose move it is'
89 if (move.what === 'answer' && q) return q.text
90 if (move.what === 'task' && find(move.ref)) return find(move.ref)!.text
91 if (move.who !== 'agent') return move.why ?? ''
92 for (const g of ['requirements', 'plan']) {
93 if (d.meta?.gates?.[g]?.changes_requested && d.gates?.[g] !== 'approved') return `Revise the ${g}: changes requested`
94 }
95 const doing = find(t.doing)
96 if (doing) return `${doing.id} ${doing.text}`
97 if (!tasks.length) return 'No task list yet'
98 if (t.can_move_to_testing === true || !list(t.open).length) return 'All tasks closed: testing next'
99 const next = find(t.next)
100 if (next) return `Next ${next.id} ${next.text}`
101 const blocked = tasks.find(x => x.state === 'blocked')
102 if (blocked) return `${blocked.id} blocked${blocked.why ? `: ${blocked.why}` : ''}`
103 return ''
104}
105
106export function toTicket(d: any): Ticket {
107 const meta = d.meta ?? {}
108 const id = clean(d.id, 40)
109 const tasks = list(d.tasks?.tasks).filter(Boolean).map(taskOf)
110 const move = moveOf(d)
111 const q = question(d, id)
112 const verdict = meta.gates?.verify?.verdict
113 return {
114 id,
115 title: clean(d.title),
116 status: clean(d.status, 20),
117 epic: meta.parent ? clean(meta.parent, 40) : null,
118 move,
119 detail: clean(detailOf(d, move, tasks, q)),
120 gates: { requirements: String(d.gates?.requirements ?? 'pending'), plan: String(d.gates?.plan ?? 'pending') },
121 verdict: verdict ? clean(verdict, 20) : null,
122 tasks,
123 doing: d.tasks?.doing ? clean(d.tasks.doing, 20) : null,
124 closed: Number(d.tasks?.summary?.closed ?? 0),
125 total: Number(d.tasks?.summary?.total ?? 0),
126 question: q,
127 confirm: list(meta.questions).filter(x => x && x.blocking === false && unanswered(x)).map(x => clean(x.id, 20)),
128 pr: firstPr(meta),
129 }
130}
131
132// The status-line word for a move: the human's move by its label, the rest by who holds it.
133function short(m: Move): string {
134 if (m.who === 'you') return m.label
135 if (m.what === 'outdated') return 'update orch-core'
136 return ['blocked', 'done', 'stale'].includes(m.what) ? m.what : 'working'
137}
138
139// Most urgent first: the human's move, then what is stale or blocked, then the agent's work (stable inside a group).
140const RANK: Record<Role, number> = { you: 0, err: 1, warn: 1, info: 2, neu: 2, ok: 3 }
141export function byUrgency(tickets: Ticket[]): Ticket[] {
142 return tickets
143 .map((t, i) => ({ t, i }))
144 .sort((a, b) => RANK[a.t.move.role] - RANK[b.t.move.role] || a.i - b.i)
145 .map(x => x.t)
146}
147
148const entry = (t: Ticket) => `${ICON[t.move.role]} ${t.id} ${short(t.move)}`
149
150// Up to 3 tickets are named. Beyond that the host cuts the line at the terminal width from the right, so the
151// human's moves are named first (two at most) and the rest is counted: `● DEMO-4 Approve plan ● +2 your move ◐ 4 working`.
152export function statusLine(tickets: Ticket[]): string | undefined {
153 if (tickets.length === 0) return undefined
154 const sorted = byUrgency(tickets)
155 if (sorted.length <= 3) return sorted.map(entry).join(' ')
156 const you = sorted.filter(t => t.move.who === 'you')
157 const warn = sorted.filter(t => t.move.who !== 'you' && (t.move.role === 'warn' || t.move.role === 'err'))
158 const rest = sorted.filter(t => !you.includes(t) && !warn.includes(t))
159 const parts = you.slice(0, 2).map(entry)
160 if (you.length > 2) parts.push(`${ICON.you} +${you.length - 2} your move`)
161 if (warn.length === 1) parts.push(entry(warn[0]))
162 else if (warn.length > 1) parts.push(`${ICON.warn} ${warn.length} ${warn.every(t => t.move.what === 'stale') ? 'stale' : 'blocked or stale'}`)
163 if (rest.length) parts.push(`${ICON.info} ${rest.length} ${rest.every(t => t.move.who === 'agent') ? 'working' : 'other'}`)
164 return parts.join(' ')
165}
166
167// The ticket the band leads with: the human's move first, then a stale claim, then the agent's work, then the rest.
168export function active(tickets: Ticket[]): Ticket | undefined {
169 return (
170 tickets.find(t => t.move.who === 'you') ??
171 tickets.find(t => t.move.what === 'stale') ??
172 tickets.find(t => t.move.who === 'agent') ??
173 tickets[0]
174 )
175}
176
177// The exception first (doing, blocked), then what is left, then what is closed.
178const ORDER: Record<string, number> = { doing: 0, blocked: 1, todo: 2, done: 3, skipped: 4 }
179
180export function ordered(tasks: Task[]): Task[] {
181 return [...tasks].sort((a, b) => (ORDER[a.state] ?? 2) - (ORDER[b.state] ?? 2))
182}
183
184// Hand-overs between the agent and the human: one toast per ticket and change.
185export function changes(before: Ticket[], after: Ticket[]): string[] {
186 const out: string[] = []
187 for (const a of after) {
188 const b = before.find(x => x.id === a.id)
189 if (!b) {
190 out.push(`◐ ${a.id} claimed by this session`)
191 continue
192 }
193 const m = a.move
194 if (m.who === 'you' && (b.move.who !== 'you' || b.move.label !== m.label)) {
195 out.push(`● ${a.id} your move: ${m.label}`)
196 } else if (b.move.who === 'you' && m.who !== 'you') {
197 out.push(`✓ ${a.id} ${b.move.label}: done, the agent continues`)
198 } else if (m.what === 'blocked' && b.move.label !== m.label) {
199 out.push(`▲ ${a.id} ${m.label}`)
200 } else if (a.verdict && a.verdict !== b.verdict) {
201 out.push(`${a.verdict === 'done' ? '✓' : '▲'} ${a.id} verdict: ${a.verdict}`)
202 } else if (a.status !== b.status) {
203 out.push(`→ ${a.id} moved to ${a.status}`)
204 }
205 }
206 for (const b of before) {
207 if (!after.some(a => a.id === b.id)) out.push(`○ ${b.id} no longer held by this session`)
208 }
209 return out
210}
211
212export type Run = (argv: readonly string[]) => Promise<{ exitCode: number; stdout: string }>
213export type Loaded = { tickets: Ticket[] } | { problem: Problem }
214
215class Stop extends Error {
216 problem: Problem
217 constructor(problem: Problem) {
218 super(problem)
219 this.problem = problem
220 }
221}
222
223async function orchJson(run: Run, args: string[]): Promise<any> {
224 let res
225 try {
226 res = await run(['orch', ...args, '--json'])
227 } catch (err) {
228 throw new Stop(/still running|time/i.test(String((err as Error)?.message)) ? 'timeout' : 'missing')
229 }
230 let data
231 try {
232 data = JSON.parse(res.stdout)
233 } catch {
234 throw new Stop(res.exitCode === 0 ? 'unreadable' : 'failed')
235 }
236 if (res.exitCode !== 0) {
237 const noWorkspace = data?.error === 'UsageError' && /config\.json/.test(String(data?.message))
238 throw new Stop(noWorkspace ? 'no-workspace' : 'failed')
239 }
240 return data
241}
242
243// One whole read: this session's tickets, or why not. A partial read would look like a lost claim, so any failed
244// read gives a problem and the caller keeps its last whole state.
245export async function loadTickets(run: Run): Promise<Loaded> {
246 try {
247 const rows = list(await orchJson(run, ['list', '--mine']))
248 const ids = rows.map(r => String(r?.id ?? '')).filter(Boolean)
249 const shown = await Promise.all(ids.map(id => orchJson(run, ['show', id])))
250 if (shown.some(d => !d || typeof d !== 'object' || Array.isArray(d))) return { problem: 'unreadable' }
251 return { tickets: shown.map(toTicket) }
252 } catch (err) {
253 if (err instanceof Stop) return { problem: err.problem }
254 return { problem: 'unreadable' }
255 }
256}
257
258// Runs `fn` one at a time; a call during a run makes it run once more afterwards, so the newest state wins.
259// A failing run is swallowed: the next call starts afresh.
260export function serial(fn: () => Promise<void>): () => Promise<void> {
261 let running: Promise<void> | null = null
262 let again = false
263 return () => {
264 if (running) {
265 again = true
266 return running
267 }
268 running = (async () => {
269 do {
270 again = false
271 await fn().catch(() => undefined)
272 } while (again)
273 })().finally(() => {
274 running = null
275 })
276 return running
277 }
278}
279types/index.d.ts 54 lines1// The roles of orch-core's design system (DESIGN.md): `you` (pink) is the human's move and nothing else.
2export type Role = 'you' | 'info' | 'warn' | 'err' | 'ok' | 'neu'
3
4// Whose move it is, as orch-core says (`move` in `orch show --json`): who, what (orch's `kind`), its label, ref and
5// why; `role` is the design-system role drawn for it. `what: 'outdated'` is an orch without the field.
6export type Move = {
7 who: 'you' | 'agent' | 'nobody'
8 what: string
9 label: string
10 role: Role
11 ref: string | null
12 why: string | null
13}
14
15export type Task = { id: string; state: string; text: string; why: string | null; owner: string }
16
17export type Question = {
18 id: string
19 text: string
20 options: { key: string; label: string }[]
21 recommended: string | null
22 // `orch answer …` for the human to run in their own terminal; the plugin never runs it
23 command: string
24}
25
26export type Ticket = {
27 id: string
28 title: string
29 status: string
30 epic: string | null
31 move: Move
32 // the one thing that happens next, in words
33 detail: string
34 gates: { requirements: string; plan: string }
35 verdict: string | null
36 tasks: Task[]
37 doing: string | null
38 closed: number
39 total: number
40 question: Question | null
41 // unanswered non-blocking questions: the agent went ahead on its recommendation
42 confirm: string[]
43 pr: { label: string; url: string } | null
44}
45
46// Why the last refresh kept the previous state (or shows nothing): never a path, a message or stderr.
47export type Problem = 'no-workspace' | 'missing' | 'timeout' | 'failed' | 'unreadable' | 'outdated'
48
49declare module 'claude-code' {
50 interface PluginState {
51 'orch-session': { tickets: Ticket[] | null; problem: Problem | null }
52 }
53}
54