SLOPSHOPPER

orch-session

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

newpanebandguardcommandtoast
v0.1.0Apache-2.0updated 2026-10-05severinlindenmann/orch-core/plugins/orch-session
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orch-session
│ ┃ orch · this session ✕ › fix the failing auth test and add an audit log call │ ┃ ✕ orch gave output this plugin cannot read. │ ┃ ○ This session holds no ticket. ⏺ 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 │ │ › /orch │ ⎿ orch-session: orch pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · orch · this session
✕ orch gave output this plugin cannot read. ○ This session holds no ticket.
README

orch-session

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.

  • Status line: ◐ L-0001 working ● L-0002 Answer Q1, one entry per claimed ticket.
  • Band above the prompt: one row with the move that matters most, yours first: ● 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.
  • Toasts on hand-overs only: your move starts or ends, the ticket is blocked, a verdict, a claim gained or lost.

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.

Install

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.

Develop

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/fixtures
Source 3 files
hooks/register.tsx 258 lines
1import { 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}
258
hooks/tickets.ts 279 lines
1// 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}
279
types/index.d.ts 54 lines
1// 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