SLOPSHOPPER

orbit

Durable tasks, sandboxed parallel runs, and PR-gated delivery for AI coding agents. Adds Orbit's MCP tools and workflow skills to Claude Code.

panebandguardcommandtoaststatus
★ 16v0.33.0MITupdated 2026-10-26constellation-works/orbit/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orbit
│ ┃ Orbit · Ship ✕ › fix the failing auth test and add an audit log call │ ┃ [ Board ] [ Ship ] g: Refresh Close │ ┃ ⏺ Read(src/auth.ts) │ ┃ Orbit unavailable: no Orbit workspace at ⎿ Read 6 lines │ ┃ /work/app; register it, register its owner ⏺ Update(src/auth.ts) │ ┃ with `orbit host add <ssh-target>`, or set ⎿ Added 2 lines, removed 1 line │ ┃ the plugin'… ⏺ 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 │ │ › /orbit-board │ ⎿ orbit: Orbit board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Orbit · Ship
[ Board ] [ Ship ] g: Refresh Close Orbit unavailable: no Orbit workspace at /work/app; register it, register its owner with `orbit host add <ssh-target>`, or set the plugin'…
README

<a href="https://orbit-cli.com"> <img src="docs/assets/orbit-lockup-on-light.svg" alt="Orbit" width="340" /> </a>

<h3 align="center">Agents write. Orbit delivers.</h3>

<a href="https://github.com/constellation-works/orbit/releases"><img src="https://img.shields.io/github/v/release/constellation-works/orbit" alt="Release" /></a> <a href="https://www.npmjs.com/package/@orbit-tools/cli"><img src="https://img.shields.io/npm/v/@orbit-tools/cli" alt="npm" /></a> <a href="LICENSE.md"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License: MIT" /></a> <a href="https://orbit-cli.com"><img src="https://img.shields.io/badge/docs-orbit--cli.com-informational" alt="Docs" /></a>

<img src="docs/assets/orbit-demo.gif" alt="Animated tour of the real Orbit dashboard while Orbit's agents work on Orbit itself: a proposed task waiting for approval, 16 tasks running at once across two hosts with 100 files locked so overlapping work waits its turn, an auto-drain with 12 running and 19 admitted that pauses new starts while the host runs hot, and auto-concurrency forecasting CPU and memory for each admission; a Codex pull-request pipeline run from isolated worktree through implement, commit, validate, review gate and push to an open pull request, 21 steps in 24 minutes 15 seconds; routines on a host clock and 22 auto-task definitions that keep the queue fed; analytics showing 3,277 tasks done in 30 days, 167% more than the 30 days before, and each model's implementation times; and the codebase view ranking the paths agents struggle with across 6,002 tasks." width="880" />

Orbit is a local-first runtime for coding agents. Keep using your authenticated agent CLI; Orbit adds a durable task queue, sandboxed worktrees, file locks for parallel runs, a gated delivery pipeline, and an audit log.

Why: fast agents make planning, review, and traceability easy to lose. Orbit keeps the prompt, plan, and review behind every change, with a task ID on every workflow commit.

  • Single binary, no cloud. State lives in ~/.orbit and .orbit/; Orbit sends no telemetry.
  • Bring your own agents. Use the provider CLIs you already have authenticated, without giving Orbit API keys.
  • MIT licensed. No paid tier or hosted offering.

How it works

You:    The fsProfile lookup is undocumented. Get that fixed.
Agent:  Files a proposed task with acceptance criteria. Approve it and ship?
You:    Yes.
Agent:  Queues it, then runs plan → execute → review in a locked, isolated worktree.
        Opens a pull request. Review and merge it, then approve the task to close it.

Nothing starts without approval; PR runs stop for your review unless you authorize --complete. The task record keeps the prompt, plan, execution trace, and review. For larger specs, ask your agent to orchestrate: the bundled orbit-orchestrate skill splits the work, queues it once approved, runs it in parallel, and diagnoses failures (delivery workflows).

Quick start

You need: macOS or Linux (x64 or arm64; Windows uses WSL2), Node 18+, an authenticated agent CLI, and authenticated gh for pull requests.

npm install -g @orbit-tools/cli
orbit init    # machine name, task-ID prefix, agent skills, and Linux sandbox setup

Open your agent in the repo and ask it to “set up Orbit for this repo”. The bundled orbit-setup skill registers it, connects MCP, runs orbit doctor, and asks for choices such as the PR target branch. Start a fresh agent session, then ask: “Add a hello.txt file containing hello; file it as an Orbit task.” Approve and ship the task when prompted, review and merge the PR, then approve the task to close it. Watch with orbit web serve.

Prefer a plugin? Install Orbit for Claude Code, Codex, or Cursor. Every MCP tool has a CLI twin; for the same loop without an agent, see your first task from the terminal.

cd <repo> && orbit workspace init --mcp    # add --ship-mode local to skip PRs
orbit doctor
orbit web serve

Review and commit the checkout files listed by workspace init, including MCP client files, before the first ship. Local delivery refuses tracked changes, merge conflicts, and untracked paths overlapping incoming changes.

orbit doctor checks CLI presence for default, system, and complexity-routed crews and warns when no MCP client is registered; it does not check provider sign-in or MCP connectivity. Use orbit doctor providers for all executor definitions. See Linux sandbox readiness and distro coverage.

To…Run
Inspect a task or runorbit task show <ID> · orbit run show <RUN_ID>
Open the dashboardorbit web serve (remote: orbit web connect <host>)
Open the dashboard in a desktop windowBuild the app in desktop/
Pick the default crew (provider and model)orbit config set workflow.default_crew <crew>
Route tasks to crews by complexityorbit config set --global workflow.hard_complexity_crews '["opus"]' (more)
Upgradenpm install -g @orbit-tools/cli@latest (orbit update --check shows what's new)

Features

Crews and complexity routing

Crews name a provider, model, and effort level; complexity pools choose one for a task without an explicit crew. See crew definitions, init's seeded pools, and complexity routing, weighting, and overrides.

MCP and authority

Connect your agent explains operator and agent-only authority; federated MCP connects multiple machines.

Where state lives

Scoping rules explain machine and workspace state. See the runbooks for backups, recovery, and upgrades.

Learn more

Contributing

Pull requests are welcome, from typo fixes to new executors. Small fixes can go straight to a PR; bigger changes start with an issue. See CONTRIBUTING.md.

License

MIT

Source 9 files
hooks/mod/register.tsx 570 lines
1// The Orbit mod for Claude Code: a band above the prompt, a status line,
2// toasts, and the Board / Ship pane, all read from the `orbit` CLI;
3// plus two hooks: task cards for mentioned task ids, and a `Task:` trailer
4// on commits made while this session works a task.
5//
6// Every function that takes `$` lives in this file: the engine follows `$`
7// only into functions declared beside the hook that holds it.
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, ProcessRunResult, Register, Timer } from 'claude-code'
11
12import type { OrbitShip, OrbitView } from '../../types'
13import { argvFor, destinationsArgv, failure, federatedHosts, localArgv, OrbitError, ownerHost, readShown, reason, targetFrom, type Target } from './cli'
14import {
15  card,
16  changes,
17  findRunId,
18  mentions,
19  parseRunEvents,
20  parseShipRun,
21  parseTasks,
22  PIPELINE,
23  preflight,
24  recentlyDone,
25  statusLine,
26  stepStates,
27  toastFor,
28  withTaskTrailer,
29  type ShipRun,
30} from './model'
31import { band } from './views/band'
32import { DEFAULT_OPEN } from './views/board'
33import type { Actions } from './views/kit'
34import { pane } from './views/pane'
35
36const PANE = 'orbit'
37const AFTER_TURN_MIN_MS = 30_000
38const RETRY_EMPTY_MS = 5000
39const RETRY_FAILED_MS = 30_000
40const DEFAULT_COLUMNS = 100
41const SHIP_POLL_MS = 5000
42const TASK_UPDATE = /(^|__)orbit_task_update$/
43const TASK_ID = /^[A-Z][A-Z0-9]{1,11}-\d{1,9}$/
44// Every terminal run state (JobRunState in orbit-types) and the ship phase it ends in; a state not listed is still in flight.
45const OUTCOME: Record<string, OrbitShip['phase']> = {
46  success: 'landed',
47  succeeded: 'landed',
48  failed: 'failed',
49  timeout: 'failed',
50  cancelled: 'failed',
51  interrupted: 'failed',
52  held: 'held',
53  skipped: 'skipped',
54}
55const TITLES: Record<OrbitView, string> = { board: 'Orbit · Board', ship: 'Orbit · Ship' }
56
57const COMMANDS = [
58  { name: 'orbit-board', description: 'Open the Orbit board for this workspace', argumentHint: '[task id]' },
59  { name: 'orbit-ship', description: 'Preflight and ship an Orbit task through the PR pipeline', argumentHint: '[task id]' },
60  { name: 'orbit-band', description: 'Show or hide the Orbit band above the prompt' },
61]
62
63type Settings = { host: string | null; refreshMs: number; commitTrailer: boolean; band: 'on' | 'compact' | 'off' }
64
65const isInFlight = (ship: OrbitShip | null): boolean => ship !== null && (ship.phase === 'launching' || ship.phase === 'flying')
66
67// The session values the mod keeps with Claude Code, declared in
68// plugin/types/index.d.ts; a reload of the module keeps them.
69const snapshotAtom = atom({ plugin: 'orbit', key: 'snapshot' } as const, null)
70const errorAtom = atom({ plugin: 'orbit', key: 'error' } as const, null)
71const isBandHiddenAtom = atom({ plugin: 'orbit', key: 'isBandHidden' } as const, false)
72const activeAtom = atom({ plugin: 'orbit', key: 'active' } as const, null)
73const shipAtom = atom({ plugin: 'orbit', key: 'ship' } as const, null)
74const viewAtom = atom({ plugin: 'orbit', key: 'view' } as const, 'board')
75const selectedAtom = atom({ plugin: 'orbit', key: 'selected' } as const, null)
76const flashAtom = atom({ plugin: 'orbit', key: 'flash' } as const, null)
77const openLanesAtom = atom({ plugin: 'orbit', key: 'openLanes' } as const, null)
78
79let settings: Settings = { host: null, refreshMs: 180_000, commitTrailer: true, band: 'on' }
80let configError: string | null = null
81let cwd = ''
82let target: Target | null = null
83let locating: Promise<Target> | null = null
84let inFlight = false
85let lastRefreshAt = 0
86let shipTimer: Timer | null = null
87let shipTimerRunId: string | null = null
88let shipPolling = false
89let refreshTimer: Timer | null = null
90
91/** Which workspace this session's checkout belongs to, and where its tasks are read. */
92async function locate($: EngineInterface): Promise<Target> {
93  if (target !== null) return target
94  locating ??= findTarget($).finally(() => {
95    locating = null
96  })
97  return locating
98}
99
100async function findTarget($: EngineInterface): Promise<Target> {
101  let shown: { name: string; role: string } | null = null
102  try {
103    const ran = await $.process.run(localArgv(['workspace', 'show', '--format', 'json']), { cwd, timeoutMs: 10_000 })
104    if (ran.exitCode === 0) shown = readShown(ran.stdout)
105  } catch {
106    shown = null
107  }
108  if (shown !== null && shown.role !== 'replica') {
109    target = targetFrom(shown, cwd, null, cwd)
110    return target
111  }
112  let root = cwd
113  if (shown === null) {
114    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 5000 }).catch(() => null)
115    if (top !== null && top.exitCode === 0) root = top.stdout.trim()
116  }
117  const hosts = settings.host !== null ? [settings.host] : await federated($)
118  if (hosts.length <= 1) {
119    target = targetFrom(shown, cwd, hosts[0] ?? null, root)
120    return target
121  }
122  // Several federated owners: the first that answers for the workspace owns it.
123  let failed = ''
124  for (const host of hosts) {
125    const candidate = targetFrom(shown, cwd, host, root)
126    const probe = await $.process.run(argvFor(candidate, ['task', 'list', '--status', 'review', '--json', '--limit', '1']), { cwd, timeoutMs: 15_000 }).catch(() => null)
127    if (probe !== null && probe.exitCode === 0) {
128      target = candidate
129      return target
130    }
131    failed = probe === null ? `${host} timed out` : (failure(candidate, ['task'], probe.exitCode, probe.stdout, probe.stderr) ?? '')
132  }
133  throw new OrbitError(`no federated owner answered for ${shown?.name ?? root}: ${failed}`)
134}
135
136/** The owners Orbit's federated MCP already reaches, when the ownerHost option names none. */
137async function federated($: EngineInterface): Promise<string[]> {
138  const ran = await $.process.run(destinationsArgv, { cwd, timeoutMs: 5000 }).catch(() => null)
139  return ran !== null && ran.exitCode === 0 ? federatedHosts(ran.stdout) : []
140}
141
142async function orbit($: EngineInterface, args: readonly string[], timeoutMs = 20_000): Promise<ProcessRunResult> {
143  const where = await locate($)
144  const ran = await $.process.run(argvFor(where, args), { cwd: where.cwd, timeoutMs })
145  const failed = failure(where, args, ran.exitCode, ran.stdout, ran.stderr)
146  if (failed !== null) throw new OrbitError(failed)
147  return ran
148}
149
150async function publishStatus($: EngineInterface): Promise<void> {
151  $.ui.status(statusLine(await read($, snapshotAtom), await read($, activeAtom), await read($, shipAtom)))
152}
153
154/** Reads the workspace's open tasks and recent completions; toasts what moved since the last read. */
155async function refresh($: EngineInterface): Promise<void> {
156  if (inFlight || configError !== null) return
157  inFlight = true
158  lastRefreshAt = await $.clock.now()
159  try {
160    await bind($)
161    const [open, done] = await Promise.all([
162      orbit($, ['task', 'list', '--status', 'proposed,backlog,in-progress,review,blocked', '--json', '--limit', '500']),
163      orbit($, ['task', 'list', '--status', 'done', '--json', '--limit', '40']),
164    ])
165    const where = await locate($)
166    const at = await $.clock.now()
167    const next = { workspace: where.workspace, host: where.host, tasks: parseTasks(open.stdout), doneRecently: recentlyDone(parseTasks(done.stdout), at), at }
168    const before = await read($, snapshotAtom)
169    for (const change of changes(before, next)) $.ui.toast(toastFor(change), { timeoutMs: 6000 })
170    await update($, snapshotAtom, () => next)
171    await update($, errorAtom, () => null)
172  } catch (thrown) {
173    target = null
174    await update($, errorAtom, () => reason(thrown))
175  } finally {
176    inFlight = false
177    await publishStatus($)
178  }
179}
180
181/**
182 * The session's checkout and the refresh timer, from whichever hook runs first:
183 * a resumed desktop session can draw the band and the pane before this load
184 * sees session.start.
185 */
186async function bind($: EngineInterface): Promise<void> {
187  if (cwd === '') cwd = await $.session.cwd()
188  refreshTimer ??= $.clock.every(settings.refreshMs, () => void refresh($))
189}
190
191/**
192 * Reads the workspace while the board is empty. The read session.start starts
193 * can finish before the session is bound and its write go nowhere, and an
194 * error stored by an earlier load outlives it in the session's state, so the
195 * band, the pane and the commands each ask again: soon while nothing is
196 * known, every 30 seconds while the last read failed.
197 */
198async function ensureLoaded($: EngineInterface): Promise<void> {
199  if (inFlight || configError !== null) return
200  if ((await read($, snapshotAtom)) !== null) return
201  const wait = (await read($, errorAtom)) === null ? RETRY_EMPTY_MS : RETRY_FAILED_MS
202  if ((await $.clock.now()) - lastRefreshAt < wait) return
203  void refresh($)
204}
205
206const columnsOf = (bodyColumns: number | undefined): number => (typeof bodyColumns === 'number' && bodyColumns > 0 ? bodyColumns : DEFAULT_COLUMNS)
207
208async function say($: EngineInterface, text: string | null): Promise<void> {
209  await update($, flashAtom, () => text)
210}
211
212async function confirm($: EngineInterface, question: string, yes: string): Promise<boolean> {
213  try {
214    return (await $.ui.ask(question, [yes, 'Cancel'])) === yes
215  } catch {
216    return false
217  }
218}
219
220async function write($: EngineInterface, args: string[], done: string): Promise<void> {
221  try {
222    await orbit($, args)
223    await say($, done)
224  } catch (thrown) {
225    await say($, `Failed: ${reason(thrown)}`)
226  }
227  await refresh($)
228}
229
230async function openPane($: EngineInterface, next: OrbitView): Promise<boolean> {
231  await update($, viewAtom, () => next)
232  const opened = await $.ui.open({ id: PANE, title: TITLES[next] })
233  return opened.isPlaced
234}
235
236async function setActive($: EngineInterface, taskId: string | null): Promise<void> {
237  await update($, activeAtom, () => taskId)
238  await publishStatus($)
239}
240
241/** Loads a task's preflight into the Ship view; a ship in flight stays put. */
242async function prepareShip($: EngineInterface, taskId: string): Promise<void> {
243  const flying = await read($, shipAtom)
244  if (flying !== null && isInFlight(flying)) {
245    await say($, `${flying.taskId} is still in flight; let it land before preparing another.`)
246    return
247  }
248  const snap = await read($, snapshotAtom)
249  if (snap === null) return
250  let readiness: unknown = null
251  try {
252    readiness = JSON.parse((await orbit($, ['run', 'readiness', taskId, '--json'])).stdout)
253  } catch {
254    readiness = null
255  }
256  const pending: OrbitShip = {
257    taskId,
258    phase: 'preflight',
259    runId: null,
260    checks: preflight(snap.tasks.find(item => item.id === taskId), snap, readiness).checks,
261    steps: PIPELINE.map(() => 'pending'),
262    startedAt: null,
263    finishedAt: null,
264    message: null,
265  }
266  await update($, shipAtom, () => pending)
267  await update($, selectedAtom, () => taskId)
268}
269
270/** `orbit run ship <id>` once the person confirms; then the track follows the run. */
271async function launch($: EngineInterface): Promise<void> {
272  const ready = await read($, shipAtom)
273  if (ready === null || ready.phase !== 'preflight') return
274  if (!(await confirm($, `Ship ${ready.taskId} through the PR pipeline?`, 'Ship it'))) return
275  const now = await $.clock.now()
276  await update($, shipAtom, (prior): OrbitShip | null => (prior ? { ...prior, phase: 'launching', startedAt: now, message: null } : prior))
277  await publishStatus($)
278  try {
279    const ran = await orbit($, ['run', 'ship', ready.taskId, '--json'], 60_000)
280    const runId = findRunId(ran.stdout)
281    await update($, shipAtom, (prior): OrbitShip | null => (prior ? { ...prior, phase: 'flying', runId, message: runId ? null : 'Launched; Orbit reported no run id to follow.' } : prior))
282    if (runId !== null) watchShip($, runId)
283  } catch (thrown) {
284    await update($, shipAtom, (prior): OrbitShip | null => (prior ? { ...prior, phase: 'failed', message: reason(thrown) } : prior))
285  }
286  await publishStatus($)
287}
288
289/** Follows an existing run of a task on the Ship view. */
290async function track($: EngineInterface, taskId: string, runId: string): Promise<void> {
291  const now = await $.clock.now()
292  const tracked: OrbitShip = { taskId, phase: 'flying', runId, checks: [], steps: PIPELINE.map(() => 'pending'), startedAt: now, finishedAt: null, message: null }
293  await update($, shipAtom, () => tracked)
294  watchShip($, runId)
295  await pollShip($)
296}
297
298function watchShip($: EngineInterface, runId: string | null): void {
299  shipTimer?.cancel()
300  shipTimerRunId = runId
301  shipTimer = $.clock.every(SHIP_POLL_MS, () => {
302    if (shipTimerRunId === runId) void pollShip($)
303  })
304}
305
306function stopShipWatch(runId?: string | null): void {
307  if (runId !== undefined && shipTimerRunId !== runId) return
308  shipTimer?.cancel()
309  shipTimer = null
310  shipTimerRunId = null
311}
312
313/** Resolves only this launch's descendants, so an older task run cannot supply its steps. */
314async function pipelineRun($: EngineInterface, runId: string, run: ShipRun, taskId: string, seen = new Set<string>()): Promise<{ runId: string; run: ShipRun } | null> {
315  if (seen.has(runId)) return null
316  seen.add(runId)
317  if (run.job === 'task_pr_pipeline') return { runId, run }
318  for (const childId of run.children) {
319    if (seen.has(childId)) continue
320    const child = parseShipRun((await orbit($, ['run', 'show', childId, '--json'])).stdout)
321    if (!child.taskIds.includes(taskId)) continue
322    const leaf = await pipelineRun($, childId, child, taskId, seen)
323    if (leaf !== null) return leaf
324  }
325  return null
326}
327
328async function pollShip($: EngineInterface): Promise<void> {
329  if (shipPolling) return
330  const flying = await read($, shipAtom)
331  if (flying === null || flying.runId === null || flying.phase !== 'flying') {
332    stopShipWatch()
333    return
334  }
335  const runId = flying.runId
336  shipPolling = true
337  try {
338    const run = parseShipRun((await orbit($, ['run', 'show', runId, '--json'])).stdout)
339    const runState = run.state
340    const outcome = OUTCOME[runState]
341    const isOver = outcome !== undefined
342    const isFailed = outcome === 'failed'
343    // A launch returns task_auto_pipeline; its own events contain no PR steps.
344    // Dispatch records appear before the parent waits, and may arrive on a later poll.
345    const leaf = run.job === 'task_auto_pipeline' || run.job === 'task_gate_pipeline'
346      ? await pipelineRun($, runId, run, flying.taskId)
347      : { runId, run }
348    const steps = leaf === null
349      ? flying.steps
350      : stepStates(parseRunEvents((await orbit($, ['run', 'events', leaf.runId, '--json'])).stdout), isFailed || OUTCOME[leaf.run.state] === 'failed')
351    const now = await $.clock.now()
352    const message = isFailed && run.error !== null ? (run.error.split('\n')[0] ?? '').slice(0, 240) : null
353    await update($, shipAtom, (prior): OrbitShip | null =>
354      prior && prior.runId === runId
355        ? {
356            ...prior,
357            steps,
358            startedAt: run.startedAt ?? prior.startedAt,
359            phase: outcome ?? 'flying',
360            finishedAt: isOver ? now : null,
361            message,
362          }
363        : prior,
364    )
365    const stillTracked = (await read($, shipAtom))?.runId === runId
366    if (isOver && stillTracked) {
367      stopShipWatch(runId)
368      const verdict = outcome === 'landed' ? `✓ ${flying.taskId} landed` : isFailed ? `✗ ${flying.taskId} ship ${runState}` : `■ ${flying.taskId} ship ${runState}`
369      $.ui.toast(`${verdict} · ${runId}`, { timeoutMs: 8000 })
370      void refresh($)
371    }
372  } catch (thrown) {
373    await update($, shipAtom, (prior): OrbitShip | null => (prior && prior.runId === runId ? { ...prior, message: `Tracking paused: ${reason(thrown)}` } : prior))
374  } finally {
375    shipPolling = false
376  }
377  await publishStatus($)
378}
379
380/** The handlers a drawing binds to its Buttons: each a press, never run unasked. */
381function actionsFor($: EngineInterface): Actions {
382  return {
383    refresh: () => void refresh($),
384    setView: next => void openPane($, next),
385    select: taskId => void update($, selectedAtom, () => taskId),
386    toggleLane: async section => {
387      const snapshot = await read($, snapshotAtom)
388      const selected = await read($, selectedAtom)
389      const task = [...(snapshot?.tasks ?? []), ...(snapshot?.doneRecently ?? [])].find(task => task.id === selected)
390      const hasSelection = task?.status === section
391      await update($, openLanesAtom, prior => {
392        const open = prior ?? [...DEFAULT_OPEN]
393        return hasSelection || open.includes(section) ? open.filter(id => id !== section) : [...open, section]
394      })
395      if (hasSelection) await update($, selectedAtom, () => null)
396    },
397    clearFlash: () => void say($, null),
398    hideBand: () => void update($, isBandHiddenAtom, () => true),
399    closePane: () => void $.ui.close({ id: PANE }),
400    approve: taskId => void write($, ['task', 'update', taskId, '--approve'], `${taskId} approved: proposed → backlog`),
401    accept: async taskId => {
402      if (await confirm($, `Accept ${taskId} and mark it done?`, 'Mark done')) await write($, ['task', 'update', taskId, '--approve'], `${taskId} accepted: review → done`)
403    },
404    reject: async taskId => {
405      if (await confirm($, `Reject ${taskId}?`, 'Reject')) await write($, ['task', 'update', taskId, '--status', 'rejected'], `${taskId} rejected`)
406    },
407    workHere: async taskId => {
408      if (!TASK_ID.test(taskId)) return
409      await setActive($, taskId)
410      await say($, `Working on ${taskId} in this session.`)
411      await $.prompt.submit({ text: `Pick up Orbit task ${taskId} in this session: read it, start it with the orbit skill, and work it to review.` })
412    },
413    rescue: async taskId => {
414      if (!TASK_ID.test(taskId)) return
415      await setActive($, taskId)
416      await say($, `Rescuing ${taskId} in this session.`)
417      await $.prompt.submit({ text: `Orbit task ${taskId} is blocked. Diagnose its last run with the orbit-orchestrate skill, then finish the work here and hand it to review.` })
418    },
419    ship: async taskId => {
420      await openPane($, 'ship')
421      await prepareShip($, taskId)
422    },
423    launch: () => void launch($),
424    track: async (taskId, runId) => {
425      await openPane($, 'ship')
426      await track($, taskId, runId)
427    },
428    resetShip: () => void update($, shipAtom, (prior): OrbitShip | null => (isInFlight(prior) ? prior : null)),
429  }
430}
431
432export const register: Register = (on, options) => {
433  try {
434    settings = {
435      host: ownerHost(options.ownerHost),
436      refreshMs: Math.max(1, Number(options.refreshMinutes) || 3) * 60_000,
437      commitTrailer: options.commitTrailer !== false,
438      band: options.band === 'compact' || options.band === 'off' ? options.band : 'on',
439    }
440  } catch (thrown) {
441    configError = reason(thrown)
442  }
443
444  on('session.start', async ($, e, next) => {
445    const started = await next(e)
446    for (const command of COMMANDS) await $.command.register(command)
447    if (configError !== null) {
448      $.ui.toast(`orbit: ${configError}`)
449      return started
450    }
451    cwd = e.cwd
452    target = null
453    // An error stored by an earlier load (another cwd, options since changed) is not this load's.
454    await update($, errorAtom, () => null)
455    void refresh($)
456    const ship = await read($, shipAtom)
457    if (isInFlight(ship)) watchShip($, ship?.runId ?? null)
458    return started
459  })
460
461  on('turn.complete', async ($, e, next) => {
462    const result = await next(e)
463    if ((await read($, snapshotAtom)) === null || (await $.clock.now()) - lastRefreshAt > AFTER_TURN_MIN_MS) void refresh($)
464    return result
465  })
466
467  on('command.run', { command: 'orbit-board' }, async ($, e) => {
468    const id = e.args.trim()
469    if (TASK_ID.test(id)) await update($, selectedAtom, () => id)
470    const isPlaced = await openPane($, 'board')
471    await ensureLoaded($)
472    return { text: isPlaced ? 'Orbit board opened.' : 'The Orbit board opens once the terminal is wide enough.' }
473  })
474
475  on('command.run', { command: 'orbit-ship' }, async ($, e) => {
476    const id = e.args.trim()
477    await openPane($, 'ship')
478    await ensureLoaded($)
479    if (!TASK_ID.test(id)) return { text: 'Orbit ship pane opened.' }
480    await prepareShip($, id)
481    return { text: `Ship preflight for ${id} is in the Orbit pane.` }
482  })
483
484  on('command.run', { command: 'orbit-band' }, async $ => {
485    const wasHidden = await read($, isBandHiddenAtom)
486    await update($, isBandHiddenAtom, () => !wasHidden)
487    if (wasHidden) void refresh($)
488    return { text: wasHidden ? 'Orbit band shown.' : 'Orbit band hidden; /orbit-band brings it back.' }
489  })
490
491  // A mentioned task's card rides along with the prompt, from the last snapshot.
492  on('prompt.submit', async ($, e, next) => {
493    const snap = await read($, snapshotAtom)
494    if (snap === null) return next(e)
495    const known = [...snap.tasks, ...snap.doneRecently]
496    const cards = mentions(e.text, snap).flatMap(id => {
497      const task = known.find(item => item.id === id)
498      return task ? [card(task, snap.workspace)] : []
499    })
500    if (cards.length === 0) return next(e)
501    return next({ ...e, context: [...(e.context ?? []), cards.join('\n\n')] })
502  })
503
504  on('tool.call', async ($, e, next) => {
505    // A commit made while this session works a task carries the task's trailer.
506    if (e.tool === 'Bash' && settings.commitTrailer) {
507      const command = (e as { command?: unknown }).command
508      const taskId = await read($, activeAtom)
509      const rewritten = taskId !== null && typeof command === 'string' ? withTaskTrailer(command, taskId) : null
510      if (rewritten !== null) return next({ ...e, command: rewritten } as typeof e)
511    }
512
513    // Starting or finishing a task through Orbit's MCP tools moves the band with it.
514    if (TASK_UPDATE.test(e.tool)) {
515      const input = e as { id?: unknown; status?: unknown }
516      const ran = await next(e)
517      if (ran.deny === undefined && ran.isError !== true && typeof input.id === 'string') {
518        if (input.status === 'in-progress') await setActive($, input.id)
519        if ((input.status === 'done' || input.status === 'rejected') && (await read($, activeAtom)) === input.id) await setActive($, null)
520        void refresh($)
521      }
522      return ran
523    }
524    return next(e)
525  })
526
527  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
528    if (e.props.hasSurvey || settings.band === 'off' || (await read($, isBandHiddenAtom))) return next(e)
529    const snapshot = await read($, snapshotAtom)
530    const error = configError ?? (await read($, errorAtom))
531    if (snapshot === null) await ensureLoaded($)
532    if (snapshot === null && error === null) return next(e)
533    return band(
534      $.ui.resolve(e),
535      {
536        snapshot,
537        error,
538        active: await read($, activeAtom),
539        ship: await read($, shipAtom),
540        now: await $.clock.now(),
541        columns: columnsOf(e.props.bodyColumns),
542        isCompact: settings.band === 'compact',
543      },
544      actionsFor($),
545    )
546  })
547
548  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
549    const table = $.ui.resolve(e)
550    await ensureLoaded($)
551    return pane(
552      table,
553      {
554        snapshot: await read($, snapshotAtom),
555        error: configError ?? (await read($, errorAtom)),
556        view: await read($, viewAtom),
557        selected: await read($, selectedAtom),
558        openLanes: await read($, openLanesAtom),
559        active: await read($, activeAtom),
560        ship: await read($, shipAtom),
561        flash: await read($, flashAtom),
562        now: await $.clock.now(),
563        columns: columnsOf(e.props.bodyColumns),
564      },
565      actionsFor($),
566      'Svg' in table ? table.Svg : undefined,
567    )
568  })
569}
570
hooks/mod/cli.ts 93 lines
1// How the Orbit mod reaches Orbit: locally, or over SSH to the `ownerHost`
2// host for a replica checkout. Pure helpers only; register.tsx runs the commands they build.
3
4import { firstLine } from './model'
5
6/** Where the workspace's tasks are read: its registered name, the checkout, and the SSH host (null: this machine). */
7export type Target = { workspace: string; cwd: string; host: string | null }
8
9const HOST = /^[A-Za-z0-9][A-Za-z0-9._@-]*$/
10const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
11
12export class OrbitError extends Error {}
13
14/** A host the person configured, or null; refuses anything SSH could read as an option. */
15export function ownerHost(value: unknown): string | null {
16  if (typeof value !== 'string' || value.trim() === '') return null
17  const host = value.trim()
18  if (!HOST.test(host)) throw new OrbitError(`ownerHost "${host}" is not a host name or SSH alias`)
19  return host
20}
21
22/**
23 * The SSH hosts Orbit's federated MCP muxes: the `orbit host add` file
24 * (`~/.orbit/hosts.toml`, one `ssh = "<host>"` per `[[hosts]]`) or, before its
25 * first migration, the legacy `~/.orbit/mcp-destinations.toml`. In file order;
26 * hosts SSH could read as an option are dropped.
27 */
28export function federatedHosts(toml: string): string[] {
29  const hosts = [...toml.matchAll(/^\s*ssh\s*=\s*"([^"]*)"\s*(?:#.*)?$/gm)].map(match => match[1] ?? '')
30  return [...new Set(hosts.filter(host => HOST.test(host)))]
31}
32
33/** Reads `~/.orbit/hosts.toml`, else the legacy `~/.orbit/mcp-destinations.toml`; empty when neither exists. */
34export const destinationsArgv: readonly string[] = [
35  '/bin/sh',
36  '-c',
37  'cat "$HOME/.orbit/hosts.toml" 2>/dev/null || cat "$HOME/.orbit/mcp-destinations.toml" 2>/dev/null || true',
38]
39
40/** `orbit` from PATH, or from ~/.orbit/bin where the installer puts it. */
41export const localArgv = (args: readonly string[]): string[] => ['/bin/sh', '-c', 'PATH="$HOME/.orbit/bin:$PATH" exec orbit "$@"', 'orbit', ...args]
42
43const quote = (arg: string): string => `'${arg.replace(/'/g, `'\\''`)}'`
44
45export const remoteArgv = (host: string, args: readonly string[]): string[] => [
46  'ssh',
47  '-o',
48  'BatchMode=yes',
49  '-o',
50  'ConnectTimeout=5',
51  '--',
52  host,
53  `PATH="$HOME/.orbit/bin:$PATH" orbit ${args.map(quote).join(' ')}`,
54]
55
56/** The argv that runs `orbit <args> --workspace <name>` for the target. */
57export const argvFor = (target: Target, args: readonly string[]): string[] => {
58  const full = [...args, '--workspace', target.workspace]
59  return target.host === null ? localArgv(full) : remoteArgv(target.host, full)
60}
61
62/** The workspace `orbit workspace show --format json` reports, or null when the checkout is not registered. */
63export function readShown(stdout: string): { name: string; role: string } | null {
64  const parsed: unknown = JSON.parse(stdout)
65  if (typeof parsed !== 'object' || parsed === null) return null
66  const record = parsed as { workspace?: { name?: unknown }; checkout?: { role?: unknown }; registered?: unknown }
67  const name = record.workspace?.name
68  if (record.registered === false || typeof name !== 'string') return null
69  return { name, role: typeof record.checkout?.role === 'string' ? record.checkout.role : 'owner' }
70}
71
72/** Decides the target from what `workspace show` said about the checkout. */
73export function targetFrom(shown: { name: string; role: string } | null, cwd: string, host: string | null, root: string): Target {
74  if (shown !== null && shown.role !== 'replica') return { workspace: shown.name, cwd, host: null }
75  if (shown !== null) {
76    if (host === null) throw new OrbitError(`${shown.name} is a replica here; register its owner with \`orbit host add <ssh-target>\` or set the plugin's ownerHost option`)
77    return { workspace: shown.name, cwd, host }
78  }
79  if (host === null) throw new OrbitError(`no Orbit workspace at ${root}; register it, register its owner with \`orbit host add <ssh-target>\`, or set the plugin's ownerHost option`)
80  const name = root.split('/').filter(Boolean).pop() ?? ''
81  if (!NAME.test(name)) throw new OrbitError(`no Orbit workspace for ${root}`)
82  return { workspace: name, cwd, host }
83}
84
85/** The failure of a finished `orbit` run, or null when it succeeded. */
86export function failure(target: Target, args: readonly string[], exitCode: number, stdout: string, stderr: string): string | null {
87  if (exitCode === 0) return null
88  if (target.host !== null && exitCode === 255) return `${target.host} unreachable`
89  return firstLine(stderr || stdout) || `orbit ${args[0] ?? ''} exited ${exitCode}`
90}
91
92export const reason = (thrown: unknown): string => (thrown instanceof Error ? firstLine(thrown.message) : String(thrown))
93
hooks/mod/model.ts 444 lines
1// Pure logic of the Orbit mod: parsing `orbit` CLI output and deriving what
2// the band, panes and hooks show. Nothing here touches `$`.
3
4import type { OrbitShip, OrbitSnapshot, OrbitStepState, OrbitTask } from '../../types'
5
6export const OPEN_STATUSES = ['proposed', 'backlog', 'in-progress', 'review', 'blocked'] as const
7
8export type OpenStatus = (typeof OPEN_STATUSES)[number]
9
10export type Lane = { status: OpenStatus; name: string; glyph: string; color: string }
11
12/** Board order, left to right. Glyphs carry status as well as color. */
13export const LANES: readonly Lane[] = [
14  { status: 'proposed', name: 'Proposed', glyph: '○', color: 'gray' },
15  { status: 'backlog', name: 'Backlog', glyph: '·', color: 'white' },
16  { status: 'in-progress', name: 'In progress', glyph: '▶', color: 'cyan' },
17  { status: 'review', name: 'Review', glyph: '◎', color: 'yellow' },
18  { status: 'blocked', name: 'Blocked', glyph: '■', color: 'red' },
19]
20
21export const ACCENT = '#F5A524'
22export const DONE_COLOR = 'green'
23
24export const laneOf = (status: string): Lane | undefined => LANES.find(lane => lane.status === status)
25
26/** The top-level steps of `task_pr_pipeline`, in the order a ship runs them. */
27export const PIPELINE: readonly { id: string; label: string }[] = [
28  { id: 'worktree', label: 'worktree' },
29  { id: 'implement_bundle', label: 'implement' },
30  { id: 'commit', label: 'commit' },
31  { id: 'prepare_branch', label: 'branch' },
32  { id: 'sync_base', label: 'sync' },
33  { id: 'review_gate_admit', label: 'gate in' },
34  { id: 'review', label: 'review' },
35  { id: 'review_gate_settle', label: 'gate out' },
36  { id: 'push', label: 'push' },
37  { id: 'pr_open', label: 'PR' },
38  { id: 'promote_tasks', label: 'promote' },
39  { id: 'complete_pr', label: 'complete' },
40]
41
42const TASK_ID = /\b[A-Z][A-Z0-9]{1,11}-\d{1,9}\b/g
43const RUN_ID = /\bjrun-[A-Za-z0-9-]+\b/
44
45const isRecord = (value: unknown): value is Record<string, unknown> =>
46  typeof value === 'object' && value !== null && !Array.isArray(value)
47
48const text = (value: unknown): string => (typeof value === 'string' ? value : '')
49
50const strings = (value: unknown): string[] =>
51  Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
52
53/** Reads `orbit task list --json` output into slim tasks; tolerates either a bare array or `{ tasks }`. */
54export function parseTasks(stdout: string): OrbitTask[] {
55  const parsed: unknown = JSON.parse(stdout)
56  const rows = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.tasks) ? parsed.tasks : []
57
58  return rows.filter(isRecord).flatMap(row => {
59    const id = text(row.id)
60    if (id === '') return []
61    const deps = strings(row.resolved_dependencies).length > 0 ? strings(row.resolved_dependencies) : strings(row.dependencies)
62
63    return [
64      {
65        id,
66        title: text(row.title),
67        status: text(row.status),
68        priority: text(row.priority) || 'medium',
69        complexity: text(row.complexity) || null,
70        type: text(row.type) || 'feature',
71        crew: text(row.resolved_crew) || text(row.crew) || null,
72        deps,
73        criteria: strings(row.acceptance_criteria).slice(0, 12),
74        runId: text(row.job_run_id) || null,
75        updatedAt: text(row.updated_at),
76      },
77    ]
78  })
79}
80
81/** The tasks of a done listing that reached done within `windowMs` of `now`. */
82export function recentlyDone(done: OrbitTask[], now: number, windowMs = 24 * 3600_000): OrbitTask[] {
83  return done.filter(task => {
84    const at = Date.parse(task.updatedAt)
85    return Number.isFinite(at) && now - at <= windowMs
86  })
87}
88
89export type Counts = Record<OpenStatus, number>
90
91export function tally(tasks: readonly OrbitTask[]): Counts {
92  const counts: Counts = { proposed: 0, backlog: 0, 'in-progress': 0, review: 0, blocked: 0 }
93  for (const task of tasks) {
94    if (task.status in counts) counts[task.status as OpenStatus] += 1
95  }
96  return counts
97}
98
99const PRIORITY_RANK: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 }
100
101/** Highest priority first, then most recently updated. */
102export function byUrgency(a: OrbitTask, b: OrbitTask): number {
103  const rank = (PRIORITY_RANK[a.priority] ?? 2) - (PRIORITY_RANK[b.priority] ?? 2)
104  return rank !== 0 ? rank : b.updatedAt.localeCompare(a.updatedAt)
105}
106
107export const lane = (tasks: readonly OrbitTask[], status: OpenStatus): OrbitTask[] =>
108  tasks.filter(task => task.status === status).sort(byUrgency)
109
110export type Change = { id: string; title: string; to: 'blocked' | 'review' | 'done' }
111
112/** What moved between two snapshots of the same workspace that is worth a toast. */
113export function changes(before: OrbitSnapshot | null, after: OrbitSnapshot): Change[] {
114  if (before === null || before.workspace !== after.workspace || before.host !== after.host) return []
115  const was = new Map(before.tasks.map(task => [task.id, task.status]))
116  const open = new Set(after.tasks.map(task => task.id))
117  const found: Change[] = []
118
119  for (const task of after.tasks) {
120    const prior = was.get(task.id)
121    if (prior === undefined || prior === task.status) continue
122    if (task.status === 'blocked' || task.status === 'review') found.push({ id: task.id, title: task.title, to: task.status })
123  }
124  const announced = new Set(before.doneRecently.map(task => task.id))
125  for (const task of after.doneRecently) {
126    if (was.has(task.id) && !open.has(task.id) && !announced.has(task.id)) found.push({ id: task.id, title: task.title, to: 'done' })
127  }
128  return found
129}
130
131export function toastFor(change: Change): string {
132  if (change.to === 'blocked') return `■ ${change.id} blocked · ${clip(change.title, 60)}`
133  if (change.to === 'review') return `◎ ${change.id} ready for review · ${clip(change.title, 60)}`
134  return `✓ ${change.id} done · ${clip(change.title, 60)}`
135}
136
137/** Task ids a prompt mentions that the snapshot knows, first three, in order. */
138export function mentions(prompt: string, snapshot: OrbitSnapshot): string[] {
139  const known = new Set([...snapshot.tasks, ...snapshot.doneRecently].map(task => task.id))
140  const found: string[] = []
141  for (const match of prompt.matchAll(TASK_ID)) {
142    const id = match[0]
143    if (known.has(id) && !found.includes(id)) found.push(id)
144    if (found.length === 3) break
145  }
146  return found
147}
148
149/** The context block a mentioned task attaches to the prompt. */
150export function card(task: OrbitTask, workspace: string): string {
151  const lines = [
152    `Orbit task ${task.id} (workspace ${workspace}): ${task.title}`,
153    `Status ${task.status} · priority ${task.priority} · ${task.type}${task.complexity ? ` · ${task.complexity}` : ''}${task.crew ? ` · crew ${task.crew}` : ''}`,
154  ]
155  if (task.deps.length > 0) lines.push(`Depends on: ${task.deps.join(', ')}`)
156  if (task.runId) lines.push(`Last run: ${task.runId}`)
157  if (task.criteria.length > 0) lines.push('Acceptance criteria:', ...task.criteria.map(item => `- ${item}`))
158  lines.push('Read the full record with the Orbit task show tool before changing the task.')
159  return lines.join('\n')
160}
161
162// Sticky: tried only where a command can start. The subcommand must end at
163// whitespace or a shell separator, so `commit-tree` and `commit-graph` miss.
164const COMMIT =
165  /(?:(?:[A-Za-z_]\w*=\S*|if|elif|while|until|then|else|do|time|!)\s+)*git(?:\s+-[cC]\s+\S+)*\s+commit(?=[\s;&|()<>]|$)/y
166const COMMAND_START = new Set([';', '&', '|', '(', ')', '{', '\n'])
167
168type Heredoc = { delimiter: string; stripTabs: boolean; end: number }
169
170/** Read a heredoc delimiter as a shell word, removing quotes and escapes. */
171function heredocAt(command: string, start: number): Heredoc | null {
172  if (command.slice(start, start + 2) !== '<<' || command[start + 2] === '<' || command[start - 1] === '<') return null
173  let i = start + 2
174  const stripTabs = command[i] === '-'
175  if (stripTabs) i++
176  while (command[i] === ' ' || command[i] === '\t') i++
177  const wordStart = i
178  let quote: string | null = null
179  let delimiter = ''
180  for (; i < command.length; i++) {
181    const ch = command[i]
182    if (quote === null && /[\s;&|()<>]/.test(ch)) break
183    if (ch === quote) quote = null
184    else if (quote === null && (ch === "'" || ch === '"')) quote = ch
185    else if (ch === '\\' && quote !== "'" && (quote === null || /[$`"\\\n]/.test(command[i + 1] ?? ''))) {
186      const escaped = command[++i]
187      if (escaped === undefined) return null
188      if (escaped !== '\n') delimiter += escaped
189    } else delimiter += ch
190  }
191  return i > wordStart && quote === null ? { delimiter, stripTabs, end: i } : null
192}
193
194/** Bodies start after the command line and are consumed in redirection order. */
195function afterHeredocs(command: string, start: number, heredocs: Heredoc[]): number | null {
196  for (const { delimiter, stripTabs } of heredocs) {
197    let found = false
198    while (start < command.length) {
199      const newline = command.indexOf('\n', start)
200      const end = newline < 0 ? command.length : newline
201      const line = command.slice(start, end)
202      start = newline < 0 ? command.length : newline + 1
203      if ((stripTabs ? line.replace(/^\t+/, '') : line) === delimiter) {
204        found = true
205        break
206      }
207    }
208    if (!found) return null
209  }
210  return start
211}
212
213/** End index of the first `git commit` at a command position outside quotes. */
214function commitEnd(command: string): number | null {
215  let quote: string | null = null
216  let arithmeticDepth = 0
217  let atStart = true
218  const heredocs: Heredoc[] = []
219  for (let i = 0; i < command.length; i++) {
220    const ch = command[i]
221    if (quote !== null) {
222      if (ch === '\\' && quote === '"') i++
223      else if (ch === quote) quote = null
224      continue
225    }
226    // In arithmetic, << is a shift rather than a heredoc redirection.
227    if (arithmeticDepth > 0) {
228      if (ch === '(') arithmeticDepth++
229      else if (ch === ')') arithmeticDepth--
230      else if (ch === '\\') i++
231      else if (ch === "'" || ch === '"') quote = ch
232      continue
233    }
234    if (ch === '(' && command[i + 1] === '(') {
235      arithmeticDepth = 2
236      i++
237      atStart = false
238      continue
239    }
240    if (ch === '\n') {
241      if (heredocs.length > 0) {
242        const end = afterHeredocs(command, i + 1, heredocs)
243        if (end === null) return null
244        i = end - 1
245        heredocs.length = 0
246      }
247      atStart = true
248      continue
249    }
250    // A removed backslash-newline does not change the command position.
251    if (ch === '\\' && command[i + 1] === '\n') {
252      i++
253      continue
254    }
255    if (/\s/.test(ch)) continue
256    if (ch === '#' && (i === 0 || /[\s;&|()<>]/.test(command[i - 1]))) {
257      const newline = command.indexOf('\n', i)
258      if (newline < 0) return null
259      i = newline - 1
260      continue
261    }
262    if (ch === '<') {
263      const heredoc = heredocAt(command, i)
264      if (heredoc !== null) {
265        heredocs.push(heredoc)
266        i = heredoc.end - 1
267        atStart = false
268        continue
269      }
270    }
271    if (atStart) {
272      COMMIT.lastIndex = i
273      if (COMMIT.test(command)) return COMMIT.lastIndex
274    }
275    atStart = COMMAND_START.has(ch)
276    if (ch === '\\') i++
277    else if (ch === "'" || ch === '"') quote = ch
278  }
279  return null
280}
281
282/**
283 * The command with a `Task:` trailer on its first `git commit`, or null when
284 * the command makes no commit there (quoted text and other `git commit-*`
285 * subcommands and heredoc bodies do not count) or already names a trailer or
286 * a task line.
287 */
288export function withTaskTrailer(command: string, taskId: string): string | null {
289  const end = commitEnd(command)
290  if (end === null || /--trailer\b|\bTask:/.test(command)) return null
291  if (/\s--amend\b/.test(command)) return null
292  return `${command.slice(0, end)} --trailer 'Task: ${taskId}'${command.slice(end)}`
293}
294
295export const findRunId = (output: string): string | null => RUN_ID.exec(output)?.[0] ?? null
296
297export type ShipRun = {
298  job: string
299  state: string
300  error: string | null
301  startedAt: number | null
302  taskIds: string[]
303  children: string[]
304}
305
306/** Reads the run and durable gate/delivery dispatches from `orbit run show --json`. */
307export function parseShipRun(stdout: string): ShipRun {
308  const parsed: unknown = JSON.parse(stdout)
309  const run = isRecord(parsed) && isRecord(parsed.run) ? parsed.run : {}
310  const pipeline = isRecord(parsed) && isRecord(parsed.pipeline_state) ? parsed.pipeline_state : {}
311  const dispatches = Array.isArray(pipeline.child_dispatches) ? pipeline.child_dispatches.filter(isRecord) : []
312  const startedAt = Date.parse(text(run.started_at))
313  return {
314    job: text(run.job_id),
315    state: text(run.state) || 'running',
316    error: text(run.error_message) || null,
317    startedAt: Number.isFinite(startedAt) ? startedAt : null,
318    taskIds: strings(run.task_ids),
319    children: dispatches
320      .filter(child => child.job_name === 'task_gate_pipeline' || child.job_name === 'task_pr_pipeline')
321      .map(child => text(child.child_run_id))
322      .filter(id => id !== ''),
323  }
324}
325
326export type RunEvent = { type: string; step: string | null; outcome: string | null }
327
328/** Reads `orbit run events --json` into the step transitions the ship track needs. */
329export function parseRunEvents(stdout: string): RunEvent[] {
330  const parsed: unknown = JSON.parse(stdout)
331  const rows = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.events) ? parsed.events : []
332  return rows.filter(isRecord).map(row => ({
333    type: text(row.event_type) || text(row.type),
334    step: text(row.step_id) || null,
335    outcome: text(row.outcome) || null,
336  }))
337}
338
339/** Latest step transitions; only successful finishes count as done. */
340export function stepStates(events: readonly RunEvent[], isFailed: boolean): OrbitStepState[] {
341  const states = new Map<string, OrbitStepState>()
342  for (const event of events) {
343    if (event.step === null) continue
344    if (event.type === 'step.started') states.set(event.step, isFailed ? 'failed' : 'active')
345    if (event.type === 'step.skipped') states.set(event.step, 'skipped')
346    if (event.type === 'step.finished') {
347      const state = event.outcome === 'success' ? 'done'
348        : event.outcome === 'failed' || event.outcome === 'error' ? 'failed'
349        : event.outcome === 'skipped' ? 'skipped'
350        : 'stopped'
351      states.set(event.step, state)
352    }
353  }
354  return PIPELINE.map(({ id }) => states.get(id) ?? 'pending')
355}
356
357/** The step a ship is on (or stopped at), and how many it has finished. */
358export function shipPosition(ship: OrbitShip): { label: string; done: number } {
359  const done = ship.steps.filter(state => state === 'done').length
360  const at = ship.steps.findIndex(state => state === 'active' || state === 'failed' || state === 'stopped')
361  const label = at >= 0 ? (PIPELINE[at]?.label ?? '') : ship.phase === 'landed' ? 'landed' : ship.phase
362  return { label, done }
363}
364
365export const STEP_GLYPH: Record<OrbitStepState, string> = { done: '●', active: '◉', failed: '✗', stopped: '■', skipped: '↷', pending: '○' }
366
367/** One-line track: `●━●━◉─○─…`. */
368export const track = (steps: readonly OrbitStepState[]): string =>
369  steps.map((state, index) => `${index === 0 ? '' : steps[index - 1] === 'done' ? '━' : '─'}${STEP_GLYPH[state]}`).join('')
370
371export function bar(done: number, total: number, width: number): { filled: string; empty: string } {
372  const cells = total <= 0 ? 0 : Math.round((done / total) * width)
373  return { filled: '━'.repeat(cells), empty: '─'.repeat(Math.max(0, width - cells)) }
374}
375
376export function clock(ms: number): string {
377  const seconds = Math.max(0, Math.floor(ms / 1000))
378  const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
379  const ss = String(seconds % 60).padStart(2, '0')
380  return `T+${mm}:${ss}`
381}
382
383export const ago = (ms: number): string =>
384  ms < 60_000
385    ? 'just now'
386    : ms < 3600_000
387      ? `${Math.floor(ms / 60_000)}m ago`
388      : ms < 48 * 3600_000
389        ? `${Math.floor(ms / 3600_000)}h ago`
390        : `${Math.floor(ms / (24 * 3600_000))}d ago`
391
392export function clip(value: string, width: number): string {
393  if (width <= 1) return value.slice(0, Math.max(0, width))
394  return value.length <= width ? value : `${value.slice(0, width - 1)}…`
395}
396
397/** The first line of a failure, trimmed for a band or a toast. */
398export function firstLine(value: string, width = 120): string {
399  const line = value.split('\n').find(part => part.trim() !== '') ?? ''
400  return clip(line.trim().replace(/^error:\s*/i, ''), width)
401}
402
403/** Status line text: the ship in flight, else the task this session is on, else blocked work. */
404export function statusLine(snapshot: OrbitSnapshot | null, active: string | null, ship: OrbitShip | null): string | undefined {
405  if (ship !== null && (ship.phase === 'launching' || ship.phase === 'flying')) {
406    const { label, done } = shipPosition(ship)
407    return `orbit ⇡ ${ship.taskId} · ${label} ${done}/${PIPELINE.length}`
408  }
409  if (active !== null) {
410    const task = snapshot?.tasks.find(item => item.id === active)
411    return `orbit ◉ ${active}${task ? ` · ${task.status}` : ''}`
412  }
413  const blocked = snapshot ? tally(snapshot.tasks).blocked : 0
414  return blocked > 0 ? `orbit ■ ${blocked} blocked` : undefined
415}
416
417/** Preflight checks for shipping a task, from the snapshot and `orbit run readiness`. */
418export function preflight(task: OrbitTask | undefined, snapshot: OrbitSnapshot, readiness: unknown): { checks: { label: string; isOk: boolean; detail: string }[] } {
419  if (task === undefined) return { checks: [{ label: 'Task is open', isOk: false, detail: 'not in this workspace’s open tasks' }] }
420  const open = new Map(snapshot.tasks.map(item => [item.id, item.status]))
421  const unmet = task.deps.filter(dep => open.has(dep))
422  const explained = isRecord(readiness) && Array.isArray(readiness.tasks) ? readiness.tasks.filter(isRecord).find(row => row.task_id === task.id) : undefined
423  const conflicts = explained && Array.isArray(explained.conflicts) ? explained.conflicts.filter(isRecord) : []
424  const holders = [...new Set(conflicts.map(row => text(row.locking_task_id)).filter(id => id !== ''))]
425
426  const checks = [
427    { label: 'Task is in backlog', isOk: task.status === 'backlog', detail: `${task.id} is ${task.status}` },
428    { label: 'Dependencies are done', isOk: unmet.length === 0, detail: unmet.length === 0 ? (task.deps.length === 0 ? 'none' : task.deps.join(', ')) : `waiting on ${unmet.join(', ')}` },
429    { label: 'No ship already running', isOk: task.runId === null || task.status !== 'in-progress', detail: task.runId ?? 'no run' },
430  ]
431  if (explained !== undefined) {
432    const reason = text(explained.reason)
433    checks.push({
434      label: 'Files are free to lock',
435      isOk: holders.length === 0,
436      detail: holders.length === 0 ? 'no conflicting task' : `held by ${holders.join(', ')}`,
437    })
438    checks.push({ label: 'Ready to start', isOk: explained.eligible === true, detail: explained.eligible === true ? 'eligible now' : reason.replace(/_/g, ' ') || 'not eligible' })
439  } else {
440    checks.push({ label: 'Readiness', isOk: true, detail: 'not reported; the run waits for locks if needed' })
441  }
442  return { checks }
443}
444
hooks/mod/views/band.tsx 193 lines
1// The band above the prompt: the workspace's counts, the task this session
2// is on, a ship in flight, and the first blocked task.
3
4import type { OrbitShip, OrbitSnapshot } from '../../../types'
5import type { Actions } from './kit'
6import { ACCENT, ago, bar, clip, clock, PIPELINE, shipPosition, tally } from '../model'
7import type { Kit } from './kit'
8
9export type BandData = {
10  snapshot: OrbitSnapshot | null
11  error: string | null
12  active: string | null
13  ship: OrbitShip | null
14  now: number
15  columns: number
16  isCompact: boolean
17}
18
19const RECENT_LANDING_MS = 10 * 60_000
20
21export function band(kit: Kit, data: BandData, act: Actions) {
22  const { Box, Button, Text } = kit
23  const { snapshot, error, active, ship, now, columns } = data
24
25  const hide = <Button key="hide" label="Hide" plain dimColor onPress={act.hideBand} />
26  const board = <Button key="board" label="Board" plain dimColor onPress={() => act.setView('board')} />
27
28  if (snapshot === null) {
29    return (
30      <Box>
31        <Text dimColor wrap="truncate-end">
32          orbit · {error ?? 'reading…'}{' '}
33        </Text>
34        {hide}
35      </Box>
36    )
37  }
38
39  const counts = tally(snapshot.tasks)
40  const isFlying = ship !== null && (ship.phase === 'launching' || ship.phase === 'flying')
41  const isRecentLanding = ship !== null && (ship.phase === 'landed' || ship.phase === 'failed' || ship.phase === 'held' || ship.phase === 'skipped') && ship.finishedAt !== null && now - ship.finishedAt < RECENT_LANDING_MS
42
43  if (data.isCompact || columns < 80) {
44    return (
45      <Box>
46        <Text wrap="truncate-end">
47          <Text bold>orbit</Text> <Text color="cyan">▶{counts['in-progress']}</Text>{' '}
48          <Text color={counts.blocked > 0 ? 'red' : undefined} dimColor={counts.blocked === 0}>
49            ■{counts.blocked}
50          </Text>{' '}
51          <Text color="yellow">◎{counts.review}</Text>
52          {isFlying && ship ? <Text color={ACCENT}> ⇡{ship.taskId}</Text> : null}
53          {!isFlying && active ? <Text color={ACCENT}> ◉{active}</Text> : null}{' '}
54        </Text>
55        {board}
56      </Box>
57    )
58  }
59
60  const where = `${snapshot.workspace}${snapshot.host ? `@${snapshot.host}` : ''}`
61  const age = error ? `stale: ${error}` : ago(now - snapshot.at)
62  const blocked = snapshot.tasks.find(task => task.status === 'blocked')
63  const focus = active ? snapshot.tasks.find(task => task.id === active) : undefined
64  const isBusy = isFlying || isRecentLanding || active !== null
65  const titleRoom = Math.max(12, columns - 70)
66
67  const lead =
68    (isFlying || isRecentLanding) && ship ? (
69      shipLead(kit, ship, now)
70    ) : active !== null ? (
71      <Text>
72        <Text bold color={ACCENT}>
73          ◉ {active}
74        </Text>{' '}
75        {clip(focus?.title ?? 'not in the open tasks', titleRoom)}
76        <Text dimColor> · {focus?.status ?? 'closed'} </Text>
77      </Text>
78    ) : null
79
80  return (
81    <Box flexDirection="column">
82      <Box>
83        {lead}
84        <Text wrap="truncate-end">
85          {isBusy ? null : <Text bold>orbit</Text>}
86          {isBusy ? null : <Text dimColor> {where} </Text>}
87          <Text color={counts['in-progress'] > 0 ? 'cyan' : undefined} dimColor={counts['in-progress'] === 0}>
88            {isBusy ? ` ▶${counts['in-progress']}` : ` ▶ ${counts['in-progress']} running`}
89          </Text>
90          <Text color={counts.blocked > 0 ? 'red' : undefined} dimColor={counts.blocked === 0}>
91            {isBusy ? ` ■${counts.blocked}` : `  ■ ${counts.blocked} blocked`}
92          </Text>
93          <Text color={counts.review > 0 ? 'yellow' : undefined} dimColor={counts.review === 0}>
94            {isBusy ? ` ◎${counts.review}` : `  ◎ ${counts.review} review`}
95          </Text>
96          <Text dimColor>{isBusy ? '  ' : `   ${counts.backlog} backlog · ${counts.proposed} proposed · ${age}  `}</Text>
97        </Text>
98        {isFlying || isRecentLanding ? <Button key="track" label="Track" plain dimColor onPress={() => act.setView('ship')} /> : null}
99        {isFlying || isRecentLanding ? <Text> </Text> : null}
100        {board}
101        <Text> </Text>
102        {hide}
103      </Box>
104      {blocked ? (
105        <Box>
106          <Text wrap="truncate-end">
107            <Text bold color="red">
108              ■ {blocked.id} blocked
109            </Text>{' '}
110            {clip(blocked.title, Math.max(12, columns - 48))}{' '}
111          </Text>
112          <Button
113            key="open-blocked"
114            label="Open"
115            plain
116            dimColor
117            onPress={() => {
118              act.select(blocked.id)
119              act.setView('board')
120            }}
121          />
122          <Text> </Text>
123          <Button key="rescue" label="Rescue here" plain onPress={() => void act.rescue(blocked.id)} />
124        </Box>
125      ) : null}
126    </Box>
127  )
128}
129
130function shipLead(kit: Kit, ship: OrbitShip, now: number) {
131  const { Text } = kit
132  const { label, done } = shipPosition(ship)
133  const { filled, empty } = bar(done, PIPELINE.length, 16)
134  const elapsed = ship.startedAt === null ? '' : `  ${clock((ship.finishedAt ?? now) - ship.startedAt)}`
135
136  if (ship.phase === 'landed') {
137    return (
138      <Text>
139        <Text bold color="green">
140          ✓ {ship.taskId} landed
141        </Text>
142        <Text dimColor>
143          {' '}
144          {ship.runId ?? ''}
145          {elapsed}{' '}
146        </Text>
147      </Text>
148    )
149  }
150  if (ship.phase === 'failed') {
151    return (
152      <Text>
153        <Text bold color="red">
154          ✗ {ship.taskId} ship failed
155        </Text>
156        <Text dimColor> at {label} </Text>
157      </Text>
158    )
159  }
160  if (ship.phase === 'held' || ship.phase === 'skipped') {
161    return (
162      <Text>
163        <Text bold color={ship.phase === 'held' ? 'yellow' : undefined}>
164          ■ {ship.taskId} ship {ship.phase}
165        </Text>
166        <Text dimColor>
167          {' '}
168          {ship.runId ?? ''}
169          {elapsed}{' '}
170        </Text>
171      </Text>
172    )
173  }
174  return (
175    <Text>
176      <Text bold color={ACCENT}>
177        ⇡ shipping {ship.taskId}
178      </Text>
179      <Text dimColor> {ship.runId ?? 'launching'} </Text>
180      <Text color="cyan">{label}</Text>
181      <Text dimColor>
182        {' '}
183        {done}/{PIPELINE.length}{' '}
184      </Text>
185      <Text color={ACCENT}>{filled}</Text>
186      <Text dimColor>
187        {empty}
188        {elapsed}{' '}
189      </Text>
190    </Text>
191  )
192}
193
hooks/mod/views/board.tsx 160 lines
1// The Board view: one collapsible section per status, most urgent first, each
2// task a full-width row whose details open beneath it when pressed.
3
4import type { OrbitSnapshot, OrbitTask } from '../../../types'
5import type { Actions } from './kit'
6import { ACCENT, ago, DONE_COLOR, lane, LANES, laneOf, type OpenStatus } from '../model'
7import type { Kit } from './kit'
8
9export type BoardData = {
10  snapshot: OrbitSnapshot
11  selected: string | null
12  active: string | null
13  /** Sections the person opened; null keeps the defaults (every one but Done). */
14  openLanes: string[] | null
15  now: number
16}
17
18type Section = { id: string; name: string; glyph: string; color: string; tasks: OrbitTask[] }
19
20/** Attention first: what is stuck, then what is moving, then what waits. */
21const ORDER: readonly OpenStatus[] = ['blocked', 'in-progress', 'review', 'proposed', 'backlog']
22export const DONE_SECTION = 'done'
23export const DEFAULT_OPEN: readonly string[] = ORDER
24const ROW_LIMIT = 20
25/** Priorities worth a color; medium and low read as the default. */
26const PRIORITY_COLOR: Record<string, string> = { critical: 'red', high: 'yellow' }
27
28export const isOpen = (openLanes: string[] | null, id: string): boolean => (openLanes ?? DEFAULT_OPEN).includes(id)
29
30export function boardView(kit: Kit, data: BoardData, act: Actions) {
31  const { Box, Text } = kit
32  const { snapshot } = data
33  const sections: Section[] = [
34    ...ORDER.map(status => {
35      const spec = LANES.find(item => item.status === status)
36      return { id: status, name: spec?.name ?? status, glyph: spec?.glyph ?? '·', color: spec?.color ?? 'white', tasks: lane(snapshot.tasks, status) }
37    }),
38    { id: DONE_SECTION, name: 'Done today', glyph: '✓', color: DONE_COLOR, tasks: snapshot.doneRecently },
39  ]
40
41  return (
42    <Box flexDirection="column" rowGap={1}>
43      {snapshot.tasks.length === 0 ? <Text dimColor>No open tasks in {snapshot.workspace}.</Text> : null}
44      {sections.map(section => sectionView(kit, section, data, act))}
45    </Box>
46  )
47}
48
49function sectionView(kit: Kit, section: Section, data: BoardData, act: Actions) {
50  const { Box, Button, Text } = kit
51  const count = section.tasks.length
52  const isQuiet = count === 0
53  const selected = section.tasks.find(task => task.id === data.selected)
54  // An empty section is its header alone, whatever the person left it as.
55  const open = !isQuiet && (selected !== undefined || isOpen(data.openLanes, section.id))
56  const rows = section.tasks.slice(0, ROW_LIMIT)
57  // Keep the resource bound while giving direct-ID selection a visible card.
58  if (selected && !rows.includes(selected)) rows[ROW_LIMIT - 1] = selected
59
60  return (
61    <Box key={`section:${section.id}`} flexDirection="column">
62      <Box columnGap={1}>
63        <Text dimColor>{isQuiet ? ' ' : open ? '▾' : '▸'}</Text>
64        <Text color={isQuiet ? undefined : section.color} dimColor={isQuiet}>
65          {section.glyph}
66        </Text>
67        <Button key={`lane:${section.id}`} label={section.name} plain dimColor={isQuiet} onPress={() => act.toggleLane(section.id)} />
68        <Text dimColor>{count}</Text>
69      </Box>
70      {open ? (
71        <Box flexDirection="column" paddingLeft={2} rowGap={0}>
72          {rows.map(task => taskRow(kit, task, data, act))}
73          {count > ROW_LIMIT ? <Text dimColor>{`+${count - ROW_LIMIT} more · /orbit-board <id> opens one`}</Text> : null}
74        </Box>
75      ) : null}
76    </Box>
77  )
78}
79
80function taskRow(kit: Kit, task: OrbitTask, data: BoardData, act: Actions) {
81  const { Box, Button, Text } = kit
82  const isSelected = data.selected === task.id
83  const isMine = data.active === task.id
84  const updated = Date.parse(task.updatedAt)
85  const urgent = PRIORITY_COLOR[task.priority]
86  const meta = [task.type, task.crew, Number.isFinite(updated) ? ago(data.now - updated) : null].filter(Boolean).join(' · ')
87
88  return (
89    <Box key={`row:${task.id}`} flexDirection="column" marginTop={isSelected ? 1 : 0} marginBottom={isSelected ? 1 : 0}>
90      <Box columnGap={1}>
91        <Text color={ACCENT}>{isSelected ? '▾' : isMine ? '◉' : ' '}</Text>
92        <Button key={`card:${task.id}`} label={task.id} plain dimColor={!isSelected && !isMine} onPress={() => act.select(isSelected ? null : task.id)} />
93        <Box flexDirection="column" flexShrink={1}>
94          <Text bold={isSelected} wrap="wrap">
95            {task.title}
96          </Text>
97          <Text wrap="truncate-end">
98            {urgent ? <Text color={urgent}>{task.priority} · </Text> : null}
99            <Text dimColor>{meta}</Text>
100          </Text>
101        </Box>
102      </Box>
103      {isSelected ? details(kit, task, data, act) : null}
104    </Box>
105  )
106}
107
108function details(kit: Kit, task: OrbitTask, data: BoardData, act: Actions) {
109  const { Box, Button, Text } = kit
110  const spec = laneOf(task.status)
111  const facts = [task.complexity ? `complexity ${task.complexity}` : null, task.deps.length > 0 ? `depends on ${task.deps.join(', ')}` : null, task.runId ? `last run ${task.runId}` : null].filter(Boolean)
112  const buttons: { key: string; label: string; hotkey: string; isPrimary?: boolean; press: () => void }[] = []
113
114  if (task.status === 'proposed') {
115    buttons.push({ key: 'approve', label: 'Approve', hotkey: 'a', isPrimary: true, press: () => act.approve(task.id) })
116    buttons.push({ key: 'reject', label: 'Reject', hotkey: 'x', press: () => void act.reject(task.id) })
117  }
118  if (task.status === 'backlog') {
119    buttons.push({ key: 'ship', label: 'Ship…', hotkey: 's', isPrimary: true, press: () => void act.ship(task.id) })
120    buttons.push({ key: 'work', label: 'Work here', hotkey: 'w', press: () => void act.workHere(task.id) })
121  }
122  if (task.status === 'in-progress') {
123    if (task.runId) buttons.push({ key: 'track', label: 'Track run', hotkey: 't', isPrimary: true, press: () => void act.track(task.id, task.runId ?? '') })
124    if (data.active !== task.id) buttons.push({ key: 'work', label: 'Work here', hotkey: 'w', press: () => void act.workHere(task.id) })
125  }
126  if (task.status === 'review') {
127    buttons.push({ key: 'accept', label: 'Accept → done', hotkey: 'a', isPrimary: true, press: () => void act.accept(task.id) })
128    buttons.push({ key: 'work', label: 'Work here', hotkey: 'w', press: () => void act.workHere(task.id) })
129  }
130  if (task.status === 'blocked') {
131    buttons.push({ key: 'rescue', label: 'Rescue here', hotkey: 'r', isPrimary: true, press: () => void act.rescue(task.id) })
132    if (task.runId) buttons.push({ key: 'track', label: 'Open run', hotkey: 't', press: () => void act.track(task.id, task.runId ?? '') })
133  }
134
135  return (
136    <Box key="details" flexDirection="column" marginLeft={2} marginTop={1} borderStyle="round" borderColor={spec?.color} borderDimColor paddingX={1}>
137      {facts.length > 0 ? (
138        <Text dimColor wrap="wrap">
139          {facts.join(' · ')}
140        </Text>
141      ) : null}
142      {task.criteria.length > 0 ? <Text bold>Acceptance criteria</Text> : <Text dimColor>No acceptance criteria recorded.</Text>}
143      {task.criteria.slice(0, 8).map((item, index) => (
144        <Box key={`criterion:${index}`} columnGap={1}>
145          <Text dimColor>○</Text>
146          <Text wrap="wrap">{item}</Text>
147        </Box>
148      ))}
149      {task.criteria.length > 8 ? <Text dimColor>+{task.criteria.length - 8} more criteria</Text> : null}
150      {buttons.length > 0 ? (
151        <Box marginTop={1} columnGap={1} flexWrap="wrap">
152          {buttons.map(button => (
153            <Button key={button.key} label={button.label} hotkey={button.hotkey} variant={button.isPrimary ? 'primary' : undefined} onPress={button.press} />
154          ))}
155        </Box>
156      ) : null}
157    </Box>
158  )
159}
160
hooks/mod/views/kit.ts 31 lines
1import type { BoxProps, ButtonProps, ElementConstructor, TextProps } from 'claude-code'
2
3import type { OrbitView } from '../../../types'
4
5/** The elements every surface draws that the Orbit views use. */
6export type Kit = {
7  Box: ElementConstructor<BoxProps>
8  Text: ElementConstructor<TextProps>
9  Button: ElementConstructor<ButtonProps>
10}
11
12/** What the views' Buttons run: each a press, never run unasked. */
13export type Actions = {
14  refresh: () => void
15  setView: (view: OrbitView) => void
16  select: (taskId: string | null) => void
17  toggleLane: (section: string) => void
18  clearFlash: () => void
19  hideBand: () => void
20  closePane: () => void
21  approve: (taskId: string) => void
22  accept: (taskId: string) => Promise<void>
23  reject: (taskId: string) => Promise<void>
24  workHere: (taskId: string) => Promise<void>
25  rescue: (taskId: string) => Promise<void>
26  ship: (taskId: string) => Promise<void>
27  launch: () => void
28  track: (taskId: string, runId: string) => Promise<void>
29  resetShip: () => void
30}
31
hooks/mod/views/pane.tsx 77 lines
1// The Orbit pane: a tab row (Board, Ship), a one-line notice, and the
2// view the person picked.
3
4import type { ElementConstructor, SvgProps } from 'claude-code'
5
6import type { OrbitShip, OrbitSnapshot, OrbitView } from '../../../types'
7import type { Actions } from './kit'
8import { ACCENT, ago } from '../model'
9import { boardView } from './board'
10import type { Kit } from './kit'
11import { shipView } from './ship'
12
13export type PaneData = {
14  snapshot: OrbitSnapshot | null
15  error: string | null
16  view: OrbitView
17  selected: string | null
18  openLanes: string[] | null
19  active: string | null
20  ship: OrbitShip | null
21  flash: string | null
22  now: number
23  columns: number
24}
25
26const TABS: readonly { view: OrbitView; label: string; hotkey: string }[] = [
27  { view: 'board', label: 'Board', hotkey: '1' },
28  { view: 'ship', label: 'Ship', hotkey: '2' },
29]
30
31export function pane(kit: Kit, data: PaneData, act: Actions, Svg?: ElementConstructor<SvgProps>) {
32  const { Box, Button, Text } = kit
33  const { snapshot, error, view } = data
34  const where = snapshot ? `${snapshot.workspace}${snapshot.host ? `@${snapshot.host}` : ''} · ${error ? `stale: ${error}` : ago(data.now - snapshot.at)}` : ''
35
36  let body
37  if (snapshot === null) {
38    body = <Text dimColor>{error ? `Orbit unavailable: ${error}` : 'Reading the workspace…'}</Text>
39  } else if (view === 'ship') {
40    body = shipView(kit, { snapshot, ship: data.ship, now: data.now, columns: data.columns }, act, Svg)
41  } else {
42    body = boardView(kit, { snapshot, selected: data.selected, active: data.active, openLanes: data.openLanes, now: data.now }, act)
43  }
44
45  return (
46    <Box flexDirection="column">
47      <Box columnGap={1} flexWrap="wrap">
48        {TABS.map(tab => (
49          <Button
50            key={`tab:${tab.view}`}
51            label={tab.label}
52            hotkey={tab.view === view ? undefined : tab.hotkey}
53            variant={tab.view === view ? 'primary' : undefined}
54            onPress={() => act.setView(tab.view)}
55          />
56        ))}
57        <Text dimColor wrap="truncate-end">
58          {where}
59        </Text>
60        <Button key="refresh" label="Refresh" hotkey="g" plain dimColor onPress={act.refresh} />
61        <Button key="close" label="Close" role="dismiss" plain dimColor onPress={act.closePane} />
62      </Box>
63      {data.flash ? (
64        <Box columnGap={1}>
65          <Text color={ACCENT} wrap="truncate-end">
66            {data.flash}
67          </Text>
68          <Button key="dismiss-flash" label="×" plain dimColor onPress={act.clearFlash} />
69        </Box>
70      ) : null}
71      <Box marginTop={1} flexDirection="column">
72        {body}
73      </Box>
74    </Box>
75  )
76}
77
hooks/mod/views/ship.tsx 182 lines
1// The Ship view: preflight checks and the launch, then the run's progress
2// through the twelve steps of the PR pipeline.
3
4import type { ElementConstructor, SvgProps } from 'claude-code'
5
6import type { OrbitShip, OrbitSnapshot, OrbitStepState } from '../../../types'
7import type { Actions } from './kit'
8import { ACCENT, clip, clock, lane, PIPELINE, shipPosition, STEP_GLYPH } from '../model'
9import type { Kit } from './kit'
10
11export type ShipData = {
12  snapshot: OrbitSnapshot
13  ship: OrbitShip | null
14  now: number
15  columns: number
16}
17
18const STEP_COLOR: Record<OrbitStepState, string | undefined> = { done: ACCENT, active: 'cyan', failed: 'red', stopped: 'yellow', skipped: undefined, pending: undefined }
19
20const PHASE: Record<OrbitShip['phase'], { label: string; color: string | undefined }> = {
21  preflight: { label: 'READY', color: undefined },
22  launching: { label: 'LAUNCHING', color: 'cyan' },
23  flying: { label: 'IN FLIGHT', color: 'cyan' },
24  landed: { label: 'LANDED', color: 'green' },
25  failed: { label: 'FAILED', color: 'red' },
26  held: { label: 'HELD', color: 'yellow' },
27  skipped: { label: 'SKIPPED', color: undefined },
28}
29
30export function shipView(kit: Kit, data: ShipData, act: Actions, Svg?: ElementConstructor<SvgProps>) {
31  const { Box, Button, Text } = kit
32  const { snapshot, ship, now, columns } = data
33
34  if (ship === null) {
35    const ready = lane(snapshot.tasks, 'backlog')
36    return (
37      <Box flexDirection="column">
38        <Text>Choose a backlog task to ship.</Text>
39        {ready.length === 0 ? <Text dimColor>The backlog is empty.</Text> : null}
40        {ready.slice(0, 12).map(task => (
41          <Button key={`pick:${task.id}`} label={clip(`${task.id} ${task.title}`, Math.max(12, columns - 4))} plain dimColor onPress={() => void act.ship(task.id)} />
42        ))}
43      </Box>
44    )
45  }
46
47  const task = [...snapshot.tasks, ...snapshot.doneRecently].find(item => item.id === ship.taskId)
48  const phase = PHASE[ship.phase]
49  const elapsed = ship.startedAt === null ? 'T+00:00' : clock((ship.finishedAt ?? now) - ship.startedAt)
50  const meta = [task?.crew ? `crew ${task.crew}` : null, task?.priority, snapshot.workspace, ship.runId].filter(Boolean).join(' · ')
51
52  return (
53    <Box flexDirection="column">
54      <Box justifyContent="space-between">
55        <Text bold color={ACCENT}>
56          SHIP · {ship.taskId}
57        </Text>
58        <Text>
59          <Text color={phase.color} bold>
60            {phase.label}
61          </Text>
62          <Text dimColor> {elapsed}</Text>
63        </Text>
64      </Box>
65      <Text bold>{task?.title ?? 'not in the open tasks'}</Text>
66      <Text dimColor wrap="truncate-end">
67        {meta}
68      </Text>
69      {ship.phase === 'preflight' ? preflightBlock(kit, ship, act) : flightBlock(kit, ship, columns, act, Svg)}
70    </Box>
71  )
72}
73
74function preflightBlock(kit: Kit, ship: OrbitShip, act: Actions) {
75  const { Box, Button, Text } = kit
76  const isClear = ship.checks.slice(0, 2).every(check => check.isOk)
77  return (
78    <Box flexDirection="column" marginTop={1}>
79      <Text dimColor>Preflight</Text>
80      {ship.checks.map(check => (
81        <Text wrap="truncate-end">
82          <Text color={check.isOk ? 'green' : 'red'}>{check.isOk ? '✓' : '✗'}</Text> {check.label}
83          <Text dimColor> · {check.detail}</Text>
84        </Text>
85      ))}
86      <Box marginTop={1} columnGap={1}>
87        {isClear ? <Button key="launch" label="Launch" hotkey="l" variant="primary" onPress={act.launch} /> : null}
88        <Button key="back" label="Back to board" hotkey="b" onPress={() => act.setView('board')} />
89        <Button key="clear" label="Clear" hotkey="c" onPress={act.resetShip} />
90      </Box>
91      {isClear ? (
92        <Text dimColor>Launch runs orbit run ship {ship.taskId} after you confirm.</Text>
93      ) : (
94        <Text color="red">Only a backlog task with its dependencies done can ship.</Text>
95      )}
96    </Box>
97  )
98}
99
100function flightBlock(kit: Kit, ship: OrbitShip, columns: number, act: Actions, Svg?: ElementConstructor<SvgProps>) {
101  const { Box, Button, Text } = kit
102  const { label, done } = shipPosition(ship)
103  const perRow = Math.max(1, Math.min(4, Math.floor(columns / 16)))
104  const rows: number[][] = []
105  for (let start = 0; start < PIPELINE.length; start += perRow) rows.push(PIPELINE.slice(start, start + perRow).map((_, offset) => start + offset))
106  const isOver = ship.phase === 'landed' || ship.phase === 'failed' || ship.phase === 'held' || ship.phase === 'skipped'
107
108  return (
109    <Box flexDirection="column" marginTop={1}>
110      {Svg ? <Svg source={trajectory(ship.steps)} alt={`Ship track: ${done} of ${PIPELINE.length} steps done, at ${label}`} width={Math.min(720, columns * 8)} /> : null}
111      <Text wrap="truncate-end">
112        {ship.steps.map((state, index) => (
113          <Text color={STEP_COLOR[state]} dimColor={state === 'pending'}>
114            {index === 0 ? '' : ship.steps[index - 1] === 'done' ? '━' : '─'}
115            {STEP_GLYPH[state]}
116          </Text>
117        ))}
118        <Text dimColor>
119          {'  '}
120          {label} {done}/{PIPELINE.length}
121        </Text>
122      </Text>
123      <Box flexDirection="column" marginTop={1}>
124        {rows.map(row => (
125          <Box>
126            {row.map(index => {
127              const state = ship.steps[index] ?? 'pending'
128              return (
129                <Box width={16}>
130                  <Text color={STEP_COLOR[state]} dimColor={state === 'pending'}>
131                    {STEP_GLYPH[state]} {PIPELINE[index]?.label}
132                  </Text>
133                </Box>
134              )
135            })}
136          </Box>
137        ))}
138      </Box>
139      {ship.message ? <Text color={ship.phase === 'failed' ? 'red' : undefined}>{ship.message}</Text> : null}
140      {ship.phase === 'landed' ? <Text color="green">Landed. The task moves on to review; its PR is open against the base branch.</Text> : null}
141      {ship.phase === 'held' ? <Text color="yellow">Held. Delivery is waiting on external review evidence; the run is over and the task stays as it is.</Text> : null}
142      {ship.phase === 'skipped' ? <Text dimColor>Skipped. The run was skipped and is no longer tracked.</Text> : null}
143      <Box marginTop={1} columnGap={1}>
144        {ship.phase === 'failed' ? <Button key="rescue" label="Rescue here" hotkey="r" variant="primary" onPress={() => void act.rescue(ship.taskId)} /> : null}
145        <Button key="back" label="Back to board" hotkey="b" onPress={() => act.setView('board')} />
146        {isOver ? <Button key="clear" label="Clear" hotkey="c" onPress={act.resetShip} /> : null}
147      </Box>
148      {ship.phase === 'flying' ? <Text dimColor>Safe to keep working; the band and status line follow the run.</Text> : null}
149    </Box>
150  )
151}
152
153/** The ship track as a rising trajectory, for surfaces that draw SVG. */
154export function trajectory(steps: readonly OrbitStepState[]): string {
155  const width = 720
156  const height = 170
157  const count = PIPELINE.length
158  const point = (index: number) => {
159    const f = index / (count - 1)
160    return { x: 24 + f * (width - 48), y: 140 - 110 * Math.sin((f * Math.PI) / 2) }
161  }
162  const path = (upTo: number) =>
163    Array.from({ length: upTo + 1 }, (_, index) => point(index))
164      .map((p, index) => `${index === 0 ? 'M' : 'L'}${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
165      .join(' ')
166  const reached = steps.reduce((last, state, index) => (state === 'pending' ? last : index), -1)
167  const fill: Record<OrbitStepState, string> = { done: ACCENT, active: '#0B0E14', failed: '#FF7A66', stopped: '#E9C46A', skipped: 'none', pending: 'none' }
168  const ring: Record<OrbitStepState, string> = { done: ACCENT, active: '#4CC9F0', failed: '#FF7A66', stopped: '#E9C46A', skipped: '#6E788C', pending: '#6E788C' }
169
170  const nodes = steps
171    .map((state, index) => {
172      const p = point(index)
173      const labelY = index < 4 ? p.y + 22 : p.y - 14
174      const name = PIPELINE[index]?.label ?? ''
175      return `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="${state === 'active' ? 8 : 6}" fill="${fill[state]}" stroke="${ring[state]}" stroke-width="2"/><text x="${p.x.toFixed(1)}" y="${labelY.toFixed(1)}" text-anchor="middle" font-family="ui-monospace, monospace" font-size="11" fill="${state === 'pending' ? '#8A93A6' : ring[state]}">${name}</text>`
176    })
177    .join('')
178
179  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><path d="${path(count - 1)}" fill="none" stroke="#6E788C" stroke-opacity="0.5" stroke-width="2" stroke-dasharray="4 6"/>${reached > 0 ? `<path d="${path(reached)}" fill="none" stroke="${ACCENT}" stroke-width="3" stroke-linecap="round"/>` : ''}${nodes}</svg>`
180}
181
182
types/index.d.ts 65 lines
1// State contract of the Orbit mod (plugin/hooks/mod): the values the band,
2// status line and panes draw from, held by Claude Code for the session.
3
4/** One open or recently finished task, slimmed from `orbit task list --json`. */
5export type OrbitTask = {
6  id: string
7  title: string
8  status: string
9  priority: string
10  complexity: string | null
11  type: string
12  crew: string | null
13  deps: string[]
14  criteria: string[]
15  runId: string | null
16  updatedAt: string
17}
18
19/** The checkout's workspace as last read, and where it was read from. */
20export type OrbitSnapshot = {
21  workspace: string
22  /** SSH host the workspace was read through; null when read locally. */
23  host: string | null
24  tasks: OrbitTask[]
25  /** Tasks that reached `done` in the last 24 hours. */
26  doneRecently: OrbitTask[]
27  at: number
28}
29
30export type OrbitStepState = 'pending' | 'active' | 'done' | 'failed' | 'stopped' | 'skipped'
31
32export type OrbitCheck = { label: string; isOk: boolean; detail: string }
33
34/** A ship this session is preparing, flying or has landed. */
35export type OrbitShip = {
36  taskId: string
37  /** `held` and `skipped` are terminal run states that neither landed nor failed. */
38  phase: 'preflight' | 'launching' | 'flying' | 'landed' | 'failed' | 'held' | 'skipped'
39  runId: string | null
40  checks: OrbitCheck[]
41  steps: OrbitStepState[]
42  startedAt: number | null
43  finishedAt: number | null
44  message: string | null
45}
46
47export type OrbitView = 'board' | 'ship'
48
49declare module 'claude-code' {
50  interface PluginState {
51    orbit: {
52      snapshot: OrbitSnapshot | null
53      error: string | null
54      isBandHidden: boolean
55      active: string | null
56      ship: OrbitShip | null
57      view: OrbitView
58      selected: string | null
59      /** Board sections the person has open; null keeps the defaults. */
60      openLanes: string[] | null
61      flash: string | null
62    }
63  }
64}
65