SLOPSHOPPER

orbit

See your Amp orbs in Claude Code and let Claude start and steer them

newpaneguardcommandtoasttool
★ 1v0.1.0MITupdated 2026-10-06vayungodara/orbit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orbit
│ ┃ orbit ✕ › fix the failing auth test and add an audit log call │ ┃ No active orbs. │ ┃ ⏺ Read(src/auth.ts) │ ┃ amp threads list did not answer JSON ⎿ 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 │ │ › /orbs │ ⎿ orbit: orbit opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · orbit
No active orbs. amp threads list did not answer JSON
README

orbit

See your Amp orbs from inside Claude Code, and let Claude run them for you.

<img src="docs/orbit.svg" width="620" alt="Five orbs drawn as globes: two working, one waiting on you, one done and one failed">

orbit is a mod for Claude Code. Each Amp orb shows up as a small globe in a side pane, you get a toast when one finishes or opens a pull request, and Claude gets tools to start orbs, message them and wait for their results. It talks to Amp and GitHub through the public amp and gh command-line tools, so it needs no API keys of its own.

Install

You need Claude Code 2.1.286 or later and the amp CLI, signed in. gh is optional. With it, pull requests show their CI status. Desktop notifications work on macOS only.

git clone https://github.com/vayungodara/orbit ~/.claude/mods/orbit

Then add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. If you load other mods the same way, separate the folders with :.

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/orbit" } }

Claude Code reads this variable when it starts, so restart it afterwards. In the desktop app that means quitting and reopening it.

The desktop app starts sessions with a bare PATH, so orbit looks for amp in ~/.local/bin and ~/.amp/bin, and for gh in the Homebrew folders, before it falls back to PATH.

Using it

Type /orbs to open the pane, or /orbs T-… to jump straight to one orb.

The ring around each globe shows what the orb is doing. Orange means it's working, blue means it's waiting on you, green means it's done and red means it failed. A working globe slowly turns, and bigger orbs get bigger globes. Under each one you'll see its title and current step, or its pull request and CI result once it has one.

The pane keeps only what needs your attention: orbs that are working, waiting or failed, plus finished ones for an hour. In a terminal session the globes become coloured dots.

Click an orb's name to open its drawer. It lists the last six steps the orb took, the pull requests it linked, what it has cost so far, and small CPU and memory graphs. Once the orb stops, the drawer also shows the last line of its final message. From there you can send the orb a message, open it in Amp or archive it. Archiving asks for a second press.

You also get a toast when an orb finishes, needs you, fails or opens a pull request. On macOS, failures and new pull requests send a notification as well.

Tools for Claude

ToolWhat it does
orb_startStarts an orb on a project (owner/name) from a title and a prompt, in mode ultra at size a1.small unless told otherwise. Returns the thread ID and link. If amp exits without printing a link, it warns that the orb may have started anyway.
orb_sendSends a follow-up message to an orb, whether it is running or finished.
orb_statusOne line per orb with its state, mode, size, current step, pull requests with CI and CodeRabbit status, and the end of its last message.
orb_waitWaits until orbs end their turn or open a pull request. The default timeout is 30 minutes and the maximum is 2 hours.
orb_resultReturns an orb's latest final message, up to its last 3,000 characters.
orb_archiveArchives an orb.

A status line looks like this:

T-0f0e0d0c-0001-7000-8000-000000000001 fix flaky login test · working · ultra/small · running tests cargo · PR #28 CI✓ CR…

Claude reads these tool definitions on every turn, so all six together are kept to about 1,100 characters.

Permissions

Tools that come from a mod skip Claude Code's own permission prompt, so orbit checks your permission rules itself. orb_start, orb_send and orb_archive change things, so they only run when a rule allows them. In default mode, add mcp__orbit__* to your allow rules. The read-only tools run unless a rule denies them.

Staying light

Each amp call takes about a quarter of a second of CPU and over 200 MB of memory while it runs, so orbit calls it as rarely as it can.

  • When no orb is being tracked, nothing runs. The only exception is one check when an interactive session starts.
  • While an orb is working, orbit reads the thread list once a minute, or every 30 seconds for ten minutes after you open the pane or message an orb. When things go quiet it slows to every 5 minutes, and an hour after the last change it stops.
  • An orb's full thread is read only after the list shows it changed. That happens at most every 5 minutes per orb, or every 30 seconds for the orb open in the drawer.
  • Pull request status and usage are only fetched while the pane is open, at most once a minute.
  • If amp keeps failing, orbit backs off to one read every 5 minutes and shows the error at the top of the pane.
  • The globes are animated SVG, so there's no timer redrawing the pane.

Limits

  • There's no stop button, because Amp's CLI can't interrupt an orb.
  • An orb keeps the mode it was started in, even if a later message asks for another.
  • Each open Claude session polls Amp and shows toasts on its own. macOS notifications are only sent once.
  • orbit sees your 30 most recent Amp threads. An orb that hasn't changed in 24 hours drops out of the pane.
  • "Waiting on you" is a guess, based on whether the orb's last message ends with a question.
  • Orb size is only known for orbs orbit started or whose drawer you opened. Other orbs are drawn at medium size.
  • Without gh, pull requests show – instead of a CI result. Without /usr/bin/jq, threads larger than 4 MiB can't be read.

Settings

You can change two settings in /config:

  • notify turns macOS notifications for failures and new pull requests on or off. It's on by default.
  • notifyAfter is how many minutes old an orb has to be before it can send a notification. The default is 5.

Development

claude plugin validate ~/.claude/mods/orbit
claude plugin test ~/.claude/mods/orbit

hooks/register.tsx holds the state, the /orbs command, the tools and the pane. The mod engine only follows $ inside the file where it is used, so the other modules receive a Ports object (hooks/ports.ts) that register.tsx builds for them.

FileWhat's in it
hooks/poller.tsThe one timer that reads Amp, plus toasts and notifications
hooks/scheduler.tsWhen to read the list and which threads to fetch
hooks/cli.tsThe amp, gh and jq calls
hooks/model.ts, hooks/steps.tsTurning Amp's output into orbs and steps
hooks/sphere.tsThe globe and graph SVGs
hooks/tools.ts, hooks/actions.tsClaude's tools and the pane's buttons
types/index.d.tsThe state other mods can read

The step icons (activities.ts, classify.ts, icons.ts and elapsed.tsx) are copied from my status bar mod so the two look the same. Other mods can read orbit's orbs state too, so a status bar can show its own chip for each orb and open the drawer with /orbs <id>.

License

MIT

Source 15 files
hooks/register.tsx 261 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Orb, OrbitError, OrbUsage } from '../types'
5import { boxKey, openPane, pressArchive, select, sendMessage } from './actions'
6import { CATEGORIES, type CategoryId } from './activities'
7import { GLYPH, iconSvg } from './icons'
8import { MARK, shortTitles, tintFor } from './model'
9import { configure, rearm, start } from './poller'
10import { PANE, type Ports } from './ports'
11import { radiusOf, RING, sparkSvg, sparkText, sphereSvg, STATE_KEY, stillSvg } from './sphere'
12import { serveTool, TOOLS } from './tools'
13
14// The engine reads state through atoms it can see declared as consts of this file, and follows `$`
15// only inside this file, so every atom and every `$` call lives here and the other modules take
16// the closures of `portsOf($)` instead.
17const orbsAtom = atom({ plugin: 'orbit', key: 'orbs' } as const, [] as Orb[])
18const selectedAtom = atom({ plugin: 'orbit', key: 'selected' } as const, null as string | null)
19const usageAtom = atom({ plugin: 'orbit', key: 'usage' } as const, null as OrbUsage | null)
20const errorAtom = atom({ plugin: 'orbit', key: 'error' } as const, null as OrbitError | null)
21const armedAtom = atom({ plugin: 'orbit', key: 'armed' } as const, null as string | null)
22const paneAtom = atom({ plugin: 'orbit', key: 'isPaneOpen' } as const, false)
23
24function portsOf($: EngineInterface): Ports {
25  return {
26    run: (argv, init) => $.process.run(argv, init),
27    home: () => $.env.get('HOME'),
28    exists: path => $.fs.exists(path),
29    now: () => $.clock.now(),
30    after: (ms, fn) => $.clock.after(ms, fn),
31    toast: text => $.ui.toast(text),
32    openPane: () => $.ui.open({ id: PANE, title: 'orbit' }),
33    checkTool: (tool, input) => $.tool.check({ tool, input } as never),
34    store: { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) },
35    orbs: { get: () => read($, orbsAtom), set: change => update($, orbsAtom, change) },
36    selected: { get: () => read($, selectedAtom), set: change => update($, selectedAtom, change) },
37    usage: { get: () => read($, usageAtom), set: change => update($, usageAtom, change) },
38    error: { get: () => read($, errorAtom), set: change => update($, errorAtom, change) },
39    armed: { get: () => read($, armedAtom), set: change => update($, armedAtom, change) },
40    isPaneOpen: { get: () => read($, paneAtom), set: change => update($, paneAtom, change) },
41  }
42}
43
44// The pane's drawing rules. The cell is one orb's column in the constellation; a title is clipped to
45// it so the pane never overflows, and a message that fails is toasted rather than thrown.
46const CELL = 16
47const MAX_PRS = 5
48const FRESH_DONE_MS = 3_600_000
49
50const clip = (text: string, max: number) => ([...text].length > max ? `${[...text].slice(0, max - 1).join('')}…` : text)
51const reason = (error: unknown) => (error instanceof Error ? error.message : String(error))
52
53function lineOf(o: Orb): string {
54  if (o.state === 'working') return o.steps[o.steps.length - 1]?.verb ?? 'working'
55  if (o.state === 'waiting') return 'waiting on you'
56  if (o.state === 'failed') return 'failed'
57  const pr = o.prs[0]
58
59  return pr ? `PR #${pr.number} · CI ${MARK[pr.ci]}` : 'done'
60}
61
62function headOf(orbs: readonly Orb[]): string {
63  return (['working', 'waiting', 'done', 'failed'] as const)
64    .map(state => ({ state, n: orbs.filter(o => o.state === state).length }))
65    .filter(x => x.n > 0)
66    .map(x => `${x.n} ${x.state}`)
67    .join(' · ')
68}
69
70export const register: Register = (on, options) => {
71  configure({ notify: options.notify !== false, notifyAfterMs: Math.max(0, Number(options.notifyAfter ?? 5)) * 60_000 })
72
73  on('session.start', async ($, e, next) => {
74    const result = await next(e)
75    for (const tool of TOOLS) await $.tool.register(tool)
76    await $.command.register({ name: 'orbs', description: 'Show your Amp orbs (optionally: a thread id to open)', argumentHint: '[thread id]', immediate: true })
77    // Headless runs (claude -p, scheduled jobs) never poll on their own: a tool call reads Amp when asked.
78    if (e.isInteractive !== false) void start(portsOf($)).catch(() => undefined)
79
80    return result
81  })
82
83  // A reload (a /config change, say) cancels the plugin's timers. The engine raises session.start
84  // again for a reloaded plugin, and this is the backstop when it does not: the session is measured
85  // after every turn, and that re-arms a lapsed read timer (two cheap calls, no timer when idle).
86  on('session.measure', async ($, e, next) => {
87    const result = await next(e)
88    await rearm(portsOf($)).catch(() => undefined)
89
90    return result
91  })
92
93  on('command.run', { command: 'orbs' }, async ($, e) => {
94    const id = e.args.trim()
95    await openPane(portsOf($), /^T-[0-9a-f-]{8,}$/.test(id) ? id : undefined)
96
97    return { text: 'orbit opened.' }
98  })
99
100  on('ui.close', async ($, e, next) => {
101    if (e.id === PANE) {
102      await update($, paneAtom, () => false)
103      await update($, armedAtom, () => null)
104    }
105
106    return next(e)
107  })
108
109  on('tool.call', { tool: /^mcp__orbit__orb_/ }, async ($, e, next) => {
110    try {
111      return await serveTool(portsOf($), e as unknown as Record<string, unknown>, next.signal)
112    } catch (error) {
113      return { result: `orbit: ${reason(error)}` }
114    }
115  })
116
117  // The matcher spells the pane's id (PANE, 'orbit') as a literal: the scan only reads a matcher it can
118  // resolve inside this file, and the tests check that /orbs opens the pane under this very id.
119  on('ui.render', { component: 'Pane', requestId: 'orbit' }, async ($, e) => {
120    const elements = $.ui.resolve(e)
121    const { Box, Text, Button, Link } = elements
122    const Svg = e.surface !== 'terminal' && 'Svg' in elements ? elements.Svg : undefined
123    const Input = 'Input' in elements ? elements.Input : undefined
124    const Client = (e.surface === 'terminal' || e.surface === 'desktop') && 'Client' in elements ? elements.Client : undefined
125    const now = await $.clock.now()
126    const all = (await read($, orbsAtom)).filter(o => o.state !== 'stale')
127    // Only what needs attention: working, waiting and failed orbs, and done ones for an hour.
128    const orbs = all.filter(o => o.state !== 'done' || now - o.changedAt < FRESH_DONE_MS)
129    const selectedId = await read($, selectedAtom)
130    const selected = all.find(o => o.id === selectedId) ?? null
131    const armed = await read($, armedAtom)
132    const usage = await read($, usageAtom)
133    const error = await read($, errorAtom)
134    const width = Math.max(24, e.props.bodyColumns)
135    const shorts = shortTitles(orbs.map(o => o.title))
136
137    // Neither rejects: a failure is a toast. A send can run for minutes, so the Input fires it and
138    // moves on (`void`); archive is quick, so its press waits for it.
139    const send = (id: string, value: string) =>
140      sendMessage(portsOf($), id, value, 'pane')
141        .then(result => (result === 'sent' ? $.ui.toast('◉ message sent') : result === 'already sending' ? $.ui.toast('◉ still sending the last message') : undefined))
142        .catch(err => $.ui.toast(`◉ ${reason(err)}`))
143    const archive = (id: string) =>
144      pressArchive(portsOf($), id)
145        .then(result => (result === 'archived' ? $.ui.toast('◉ archived') : undefined))
146        .catch(err => $.ui.toast(`◉ ${reason(err)}`))
147
148    // Skyline's own category colours, so the pane and the bar read as one system.
149    const tintOf = (category: string) => CATEGORIES[category as CategoryId]?.tint ?? 'inactive'
150    const meta = selected ? [selected.mode, selected.size?.replace('a1.', '')].filter(Boolean).join(' · ') : ''
151    // The verbs share one column so the targets line up; a narrow pane clips them rather than overflow.
152    // A step repeated back to back (a poll, a wait) shows once, as its latest run.
153    const steps = selected ? selected.steps.filter((step, i, list) => i === list.length - 1 || list[i + 1]!.verb !== step.verb || list[i + 1]!.target !== step.target) : []
154    const verbWidth = Math.min(Math.max(8, Math.floor(width / 2)), Math.max(0, ...steps.map(step => [...step.verb].length)))
155    const shownUsage = selected && usage?.id === selected.id ? usage : null
156    const last = selected && selected.state !== 'working' ? (selected.finalText.split('\n').map(l => l.trim()).filter(Boolean).pop() ?? '') : ''
157    const hasFacts = !!selected && (selected.steps.length > 0 || selected.prs.length > 0 || !!shownUsage || !!last)
158    const cpuNow = shownUsage?.cpu[shownUsage.cpu.length - 1] ?? 0
159    const memNow = shownUsage?.memMiB[shownUsage.memMiB.length - 1] ?? 0
160
161    return (
162      <Box flexDirection="column" gap={1} width={width}>
163        <Box key="head">
164          <Text dimColor>{headOf(orbs) || 'No active orbs.'}</Text>
165        </Box>
166        {error && <Text key="error" color="warning" wrap="truncate">{error.text}</Text>}
167        {orbs.length > 0 && (
168          <Box key="sky" flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1}>
169            {orbs.map((o, i) => {
170              const px = (radiusOf(o.size) + 6) * 2
171
172              return (
173                <Box key={`orb-${o.id}`} flexDirection="column" alignItems="center" width={CELL} overflow="hidden">
174                  {Svg ? (
175                    <Box key={`sphere-${o.id}`} width={Math.ceil(px / 8)} height={Math.ceil(px / 17)} alignItems="center" justifyContent="center">
176                      <Svg source={sphereSvg({ tint: tintFor(o.id), state: o.state, radius: radiusOf(o.size), isSelected: o.id === selectedId })} alt={`${shorts[i]} ${o.state}`} width={px} height={px} />
177                    </Box>
178                  ) : (
179                    <Text key={`sphere-${o.id}`} color={STATE_KEY[o.state]} bold>{o.id === selectedId ? '◉' : '●'}</Text>
180                  )}
181                  <Button key={`pick-${o.id}`} label={clip(shorts[i] ?? o.title, CELL)} plain onPress={() => select(portsOf($), o.id === selectedId ? null : o.id)} />
182                  <Text key={`line-${o.id}`} dimColor wrap="truncate">{clip(lineOf(o), CELL)}</Text>
183                </Box>
184              )
185            })}
186          </Box>
187        )}
188        {selected && (
189          <Box key="drawer" flexDirection="column" gap={1}>
190            <Box key="top" flexDirection="column">
191              <Text key="rule" color="subtle" wrap="truncate">{'─'.repeat(Math.min(width, 80))}</Text>
192              <Text key="title" bold wrap="truncate">{selected.title}</Text>
193              <Box key="meta" flexDirection="row" columnGap={1}>
194                <Text color={STATE_KEY[selected.state]}>{selected.state}</Text>
195                {meta ? <Text dimColor>{meta}</Text> : null}
196                {Client && selected.state === 'working' && selected.createdAt > 0 ? <Client key="orbit-elapsed" module="./elapsed.tsx" props={{ since: selected.createdAt, after: 0, color: 'inactive' }} /> : null}
197              </Box>
198            </Box>
199            {hasFacts && (
200              <Box key="facts" flexDirection="column">
201                {steps.map(step => (
202                  <Box key={`step-${step.id}`} flexDirection="row" columnGap={1}>
203                    <Box key={`icon-${step.id}`} width={2} height={1} alignItems="center" justifyContent="center">
204                      {Svg ? (
205                        <Svg source={step.isRunning ? iconSvg(step.category as CategoryId) : stillSvg(iconSvg(step.category as CategoryId))} alt={step.verb} width={12} height={12} />
206                      ) : (
207                        <Text color={tintOf(step.category)}>{GLYPH[step.category as CategoryId] ?? '›'}</Text>
208                      )}
209                    </Box>
210                    <Box key={`verb-${step.id}`} width={verbWidth}>
211                      {step.isOk ? <Text bold={step.isRunning} wrap="truncate">{step.verb}</Text> : <Text color="error" wrap="truncate">{step.verb}</Text>}
212                    </Box>
213                    <Text dimColor wrap="truncate">{step.target}</Text>
214                  </Box>
215                ))}
216                {selected.prs.slice(0, MAX_PRS).map(pr => (
217                  <Box key={`pr-${pr.url}`} flexDirection="row" columnGap={1}>
218                    <Link href={pr.url} label={`#${pr.number}`} />
219                    <Text dimColor wrap="truncate">{`${pr.state} · CI ${MARK[pr.ci]} · CodeRabbit ${MARK[pr.review]}`}</Text>
220                  </Box>
221                ))}
222                {selected.prs.length > MAX_PRS ? <Text key="more-prs" dimColor>{`+${selected.prs.length - MAX_PRS} more`}</Text> : null}
223                {shownUsage && (
224                  <Box key="usage" flexDirection="row" flexWrap="wrap" columnGap={1}>
225                    <Text dimColor>{[shownUsage.cost, `cpu ${Math.round(cpuNow)}%`].filter(Boolean).join(' · ')}</Text>
226                    {Svg && shownUsage.cpu.length > 0 ? (
227                      <Box key="spark-cpu" width={14} height={1}>
228                        <Svg source={sparkSvg(shownUsage.cpu, 100, 14, RING.working.dark)} alt="cpu" width={100} height={14} />
229                      </Box>
230                    ) : null}
231                    {!Svg && shownUsage.cpu.length > 0 ? <Text key="spark-cpu" color="claude">{sparkText(shownUsage.cpu)}</Text> : null}
232                    <Text dimColor>{`mem ${Math.round(memNow)} MiB`}</Text>
233                    {Svg && shownUsage.memMiB.length > 0 ? (
234                      <Box key="spark-mem" width={14} height={1}>
235                        <Svg source={sparkSvg(shownUsage.memMiB, 100, 14, RING.waiting.dark)} alt="memory" width={100} height={14} />
236                      </Box>
237                    ) : null}
238                  </Box>
239                )}
240                {last ? <Text key="final" dimColor wrap="truncate-end">{last}</Text> : null}
241              </Box>
242            )}
243            <Box key="controls" flexDirection="column">
244              {Input && <Input key={boxKey(selected.id)} placeholder="Message this orb" submitLabel="send" onSubmit={value => void send(selected.id, value)} />}
245              <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={2}>
246                <Link href={selected.url} label="open in Amp" />
247                {armed === selected.id ? (
248                  <Button key="archive" label="confirm archive" variant="primary" onPress={() => archive(selected.id)} />
249                ) : (
250                  <Button key="archive" label="archive" plain dimColor onPress={() => archive(selected.id)} />
251                )}
252                <Button key="close" label="close" plain dimColor onPress={() => select(portsOf($), null)} />
253              </Box>
254            </Box>
255          </Box>
256        )}
257      </Box>
258    )
259  })
260}
261
hooks/actions.ts 85 lines
1// What the pane's controls and /orbs do. It takes a `Ports` instead of `$`: the engine follows `$`
2// only inside the file that holds it, so register.tsx hands these the closures of `portsOf($)`.
3import { ampArchive, ampSend } from './cli'
4import { markSent, pollFresh, pollNow, want } from './poller'
5import type { Ports } from './ports'
6
7const quiet = () => undefined
8
9export async function openPane(p: Ports, id?: string): Promise<void> {
10  await p.openPane()
11  await p.isPaneOpen.set(() => true)
12  if (id) {
13    await p.selected.set(() => id)
14    await p.armed.set(() => null)
15  }
16  // The pane's PR badges and the selected orb's usage are only read while it is open, so read once
17  // now (unless a read just finished) rather than leave a stale drawing until the next tick.
18  await want(p)
19  void pollNow(p, 15_000).catch(quiet)
20}
21
22export async function select(p: Ports, id: string | null): Promise<void> {
23  await p.selected.set(() => id)
24  await p.armed.set(() => null)
25  if (!id) return
26  // The 30 second cadence only runs while someone asks for it, and the drawer wants its usage now.
27  await want(p)
28  void pollNow(p).catch(quiet)
29}
30
31// Orbs with a send under way. The engine's Input keeps its text after Enter until it is drawn anew,
32// and `ampSend` can run for minutes, so a second Enter would send a duplicate instruction to a live
33// agent. Module state, like the poller's: a reload starts it empty.
34const sending = new Set<string>()
35// The pane's sends per orb. The message box is drawn under a key made of the orb and this count, so
36// each orb has its own box and a send from the pane draws a new, empty one. A send by Claude
37// (orb_send) leaves the box, and whatever is being typed in it, alone.
38const boxes = new Map<string, number>()
39
40/** The key of an orb's message box in the pane. */
41export const boxKey = (id: string) => `msg-${id}-${boxes.get(id) ?? 0}`
42
43/** Forget every send under way, as a reload does. For tests, which share this module. */
44export function resetSending(): void {
45  sending.clear()
46  boxes.clear()
47}
48
49/** Sends `text` to the orb and answers one line: `sent`, or why nothing was sent. One send at a time per orb. */
50export async function sendMessage(p: Ports, id: string, text: string, from: 'pane' | 'tool' = 'tool'): Promise<string> {
51  const message = text.trim()
52  if (!message) return 'nothing to send'
53  // No await between the check and the add, so two submits in the same tick cannot both pass.
54  if (sending.has(id)) return 'already sending'
55  sending.add(id)
56  try {
57    p.toast('◉ sending…')
58    await ampSend(p, id, message)
59  } finally {
60    sending.delete(id)
61  }
62  if (from === 'pane') boxes.set(id, (boxes.get(id) ?? 0) + 1)
63  // Its write of the orbs draws the pane again, with the new box.
64  await markSent(p, id)
65  await want(p)
66  void pollFresh(p).catch(quiet)
67
68  return 'sent'
69}
70
71/** The first press arms the orb, the second archives it. */
72export async function pressArchive(p: Ports, id: string): Promise<'armed' | 'archived'> {
73  if ((await p.armed.get()) !== id) {
74    await p.armed.set(() => id)
75
76    return 'armed'
77  }
78  await p.armed.set(() => null)
79  await ampArchive(p, id)
80  await p.orbs.set(list => list.filter(o => o.id !== id))
81  if ((await p.selected.get()) === id) await p.selected.set(() => null)
82
83  return 'archived'
84}
85
hooks/activities.ts 125 lines
1// Copy of skyline/hooks/activities.ts, so orbit's step icons match the bar. Keep it in sync by copying again.
2// What Claude can be doing, as a fixed table: a category (the colour and the group) and a verb
3// (the words the bar shows). classify.ts maps every tool call onto one of these ids; keeping the
4// table apart from the matching means the words and colours can change without touching a rule.
5
6export type CategoryId =
7  | 'think' | 'read' | 'search' | 'edit' | 'test' | 'build' | 'git' | 'github' | 'packages' | 'shell'
8  | 'web' | 'browser' | 'x' | 'computer' | 'image' | 'oracle' | 'agents' | 'plan' | 'ask' | 'data' | 'deploy'
9
10/** The tint is a theme key, not a raw colour, so each surface resolves it for its own theme. */
11export type Category = { id: CategoryId; tint: string }
12
13export type Activity = { id: string; category: CategoryId; verb: string }
14
15export const CATEGORIES: Record<CategoryId, Category> = {
16  think: { id: 'think', tint: 'permission' },
17  read: { id: 'read', tint: 'suggestion' },
18  search: { id: 'search', tint: 'suggestion' },
19  edit: { id: 'edit', tint: 'claude' },
20  test: { id: 'test', tint: 'success' },
21  build: { id: 'build', tint: 'claude' },
22  git: { id: 'git', tint: 'warning' },
23  github: { id: 'github', tint: 'warning' },
24  packages: { id: 'packages', tint: 'claude' },
25  shell: { id: 'shell', tint: 'inactive' },
26  web: { id: 'web', tint: 'suggestion' },
27  browser: { id: 'browser', tint: 'suggestion' },
28  x: { id: 'x', tint: 'claude' },
29  computer: { id: 'computer', tint: 'suggestion' },
30  image: { id: 'image', tint: 'warning' },
31  oracle: { id: 'oracle', tint: 'permission' },
32  agents: { id: 'agents', tint: 'permission' },
33  plan: { id: 'plan', tint: 'inactive' },
34  ask: { id: 'ask', tint: 'error' },
35  data: { id: 'data', tint: 'suggestion' },
36  deploy: { id: 'deploy', tint: 'warning' },
37}
38
39// One line per activity, id → verb, so a search for an id finds its words. The id's prefix is
40// its category.
41const VERBS: Record<string, string> = {
42  'think.request': 'sending request',
43  'think.thinking': 'thinking',
44  'think.reply': 'writing a reply',
45  'read.file': 'reading',
46  'read.skim': 'skimming',
47  'read.image': 'looking at',
48  'read.pdf': 'reading PDF',
49  'search.grep': 'searching',
50  'search.glob': 'finding files',
51  'search.lsp': 'looking up',
52  'search.shell': 'searching',
53  'edit.file': 'editing',
54  'edit.write': 'writing',
55  'edit.notebook': 'editing notebook',
56  'edit.shell': 'editing',
57  'test.run': 'running tests',
58  'test.types': 'type-checking',
59  'test.lint': 'linting',
60  'build.run': 'building',
61  'build.format': 'formatting',
62  'build.dev': 'starting dev server',
63  'git.commit': 'committing',
64  'git.diff': 'reading diff',
65  'git.status': 'checking status',
66  'git.branch': 'switching branch',
67  'git.sync': 'syncing',
68  'git.rewrite': 'rewriting history',
69  'github.create': 'opening PR',
70  'github.read': 'reading GitHub',
71  'github.ci': 'checking CI',
72  'github.comment': 'commenting',
73  'github.merge': 'merging PR',
74  'packages.install': 'installing packages',
75  'shell.run': 'running',
76  'shell.files': 'moving files',
77  'shell.delete': 'deleting files',
78  'shell.read': 'reading',
79  'shell.process': 'checking processes',
80  'shell.media': 'converting media',
81  'web.search': 'searching the web',
82  'web.fetch': 'reading',
83  'web.docs': 'reading docs',
84  'browser.navigate': 'browsing',
85  'browser.interact': 'clicking around',
86  'browser.inspect': 'inspecting page',
87  'x.search': 'searching X',
88  'x.read': 'reading post',
89  'x.thread': 'reading thread',
90  'x.user': 'reading',
91  'x.feed': 'scrolling X',
92  'computer.codex': 'using the computer',
93  'computer.drive': 'driving',
94  'computer.sim': 'testing on simulator',
95  'image.paint': 'painting',
96  'oracle.ask': 'consulting the oracle',
97  'agents.spawn': 'briefing',
98  'agents.workflow': 'running a workflow',
99  'agents.message': 'messaging an agent',
100  'plan.todo': 'planning',
101  'plan.skill': 'loading skill',
102  'plan.tools': 'loading tools',
103  'ask.question': 'waiting for you',
104  'data.db': 'querying database',
105  'data.docs': 'reading docs',
106  'data.mail': 'checking mail',
107  'data.calendar': 'checking calendar',
108  'deploy.ship': 'deploying',
109  'deploy.publish': 'publishing page',
110  // The last resort when nothing matches: a tool the table has never heard of.
111  'other.tool': 'using',
112}
113
114function build(): Record<string, Activity> {
115  const table: Record<string, Activity> = {}
116  for (const [id, verb] of Object.entries(VERBS)) {
117    const prefix = id.split('.')[0] ?? ''
118    table[id] = { id, category: prefix === 'other' ? 'shell' : (prefix as CategoryId), verb }
119  }
120
121  return table
122}
123
124export const ACTIVITIES: Record<string, Activity> = build()
125
hooks/icons.ts 177 lines
1// Copy of skyline/hooks/icons.ts, so orbit's step icons match the bar. Keep it in sync by copying again.
2// The activity line's pictures: a small animated SVG per category for the desktop app, a static
3// trail of the turn's steps, and a one-cell glyph per category for the terminal. Strings only,
4// built from module-level tables, so drawing an icon costs a template fill.
5
6import type { CategoryId } from './activities'
7
8// Claude Code's own theme values for each category, dark theme. An SVG drawn as a picture
9// cannot read the theme, so each icon strokes in the dark value and a media query swaps in the
10// light partner from LIGHT.
11export const STROKE: Record<CategoryId, string> = {
12  think: '#B1B9F9',
13  read: '#B1B9F9',
14  search: '#B1B9F9',
15  edit: '#D97757',
16  test: '#4EBA65',
17  build: '#D97757',
18  git: '#FFC107',
19  github: '#FFC107',
20  packages: '#D97757',
21  shell: '#999999',
22  web: '#B1B9F9',
23  browser: '#B1B9F9',
24  x: '#D97757',
25  computer: '#B1B9F9',
26  image: '#FFC107',
27  oracle: '#B1B9F9',
28  agents: '#B1B9F9',
29  plan: '#999999',
30  ask: '#FF6B80',
31  data: '#B1B9F9',
32  deploy: '#FFC107',
33}
34
35const LIGHT: Record<string, string> = {
36  '#D97757': '#C6613F',
37  '#B1B9F9': '#5769F7',
38  '#4EBA65': '#2C7A39',
39  '#FFC107': '#966C1E',
40  '#999999': '#666666',
41  '#FF6B80': '#AB2B3F',
42}
43
44export const GLYPH: Record<CategoryId, string> = {
45  think: '✻',
46  read: '▤',
47  search: '⌕',
48  edit: '✎',
49  test: '⚗',
50  build: '⚒',
51  git: '⑂',
52  github: '⇄',
53  packages: '▣',
54  shell: '›',
55  web: '◍',
56  browser: '▭',
57  x: '𝕏',
58  computer: '◈',
59  image: '◆',
60  oracle: '⟡',
61  agents: '⁂',
62  plan: '☰',
63  ask: '?',
64  data: '⛁',
65  deploy: '⇪',
66}
67
68const EASE = '.42 0 .58 1'
69
70// One looping SMIL animation, eased in and out between each pair of values (the app's pulse
71// curve) unless `more` brings its own calcMode. translate, rotate and scale animate the
72// transform; anything else is an attribute.
73function anim(attr: string, values: string, dur: number, more = ''): string {
74  const tag = /^(translate|rotate|scale)$/.test(attr) ? `animateTransform attributeName="transform" type="${attr}"` : `animate attributeName="${attr}"`
75  const ease = more.includes('calcMode') ? '' : ` calcMode="spline" keySplines="${values.split(';').slice(1).map(() => EASE).join(';')}"`
76
77  return `<${tag} values="${values}" dur="${dur}s"${ease}${more ? ` ${more}` : ''} repeatCount="indefinite"/>`
78}
79
80// A hop of one unit running `lag` seconds behind the first, so a row of them reads as a wave; the
81// begin is negative so every one is already moving on the first frame. Nothing fades: every icon
82// keeps its brightness, and moves by transform, position or a stroke drawing on.
83const bob = (dur: number, lag = 0) => anim('translate', '0 0;0 -1;0 0', dur, `begin="${lag - dur}s"`)
84
85// A stroke that draws on between keyTimes `from` and `to` of a 3 s loop, holds, then wipes off
86// forward in the last fifth. Offsets of ±1.05 (past the path's length of 1) keep the round caps
87// hidden while it is off. The loop starts in its hold, so the icon arrives whole.
88const draw = (d: string, from: number, to: number) =>
89  `<path d="${d}" pathLength="1" stroke-dasharray="1 1.1">${anim('stroke-dashoffset', '1.05;1.05;0;0;-1.05', 3, `keyTimes="0;${from};${to};.8;1" begin="-1.8s"`)}</path>`
90
91const EYE = 'M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8z'
92const SHUT = 'M1.5 8s2.4 1.5 6.5 1.5S14.5 8 14.5 8s-2.4 1.5-6.5 1.5S1.5 8 1.5 8z'
93
94// Each motif drawn on a 16 px grid, with its motion. Dots are zero-length round-capped strokes,
95// so they take the stroke colour and its light-theme swap without a fill.
96const PATHS: Record<CategoryId, string> = {
97  // A four-point spark (not Anthropic's) turning slowly and swelling in ten 120 ms steps.
98  think: `<g transform="translate(8 8)"><g>${anim('rotate', '0;90', 3, 'calcMode="linear"')}<path d="M0-6.75Q1.9-1.9 6.75 0Q1.9 1.9 0 6.75Q-1.9 1.9-6.75 0Q-1.9-1.9 0-6.75z">${anim('scale', '.86;.9;.95;1;1;1;.95;.9;.86;.86', 1.2, 'calcMode="discrete"')}</path></g></g>`,
99  // A page whose lines write themselves in.
100  read: `<path d="M9.5 1.75h-5A1.5 1.5 0 0 0 3 3.25v9.5a1.5 1.5 0 0 0 1.5 1.5h7a1.5 1.5 0 0 0 1.5-1.5v-7.5zM9.5 1.75v3.5H13"/>${draw('M5.5 5.5H7', .05, .2)}${draw('M5.5 8.5h5', .2, .4)}${draw('M5.5 11.5h5', .4, .6)}`,
101  // A magnifier circling a little, looking around.
102  search: `<g><animateMotion path="M-.9 0a.9.9 0 1 0 1.8 0a.9.9 0 1 0-1.8 0" dur="2.4s" repeatCount="indefinite"/><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3"/></g>`,
103  // A pencil pivoting on its end so the nib scribbles.
104  edit: `<g>${anim('rotate', '0 12 4;4 12 4;-3 12 4;3 12 4;0 12 4', 1.2)}<path d="M2.25 13.75l.9-3.4 7.6-7.6a1.75 1.75 0 0 1 2.5 2.5l-7.6 7.6zM3.15 10.35l2.5 2.5M9.35 4.15l2.5 2.5"/></g>`,
105  // A flask with bubbles rising through the liquid and shrinking away toward the neck.
106  test: `<path d="M5.5 1.75h5M6.5 1.75V6l-3.6 6.6a1 1 0 0 0 .9 1.4h8.4a1 1 0 0 0 .9-1.4L9.5 6V1.75M4.5 10h7"/><g stroke-width="1.75">${[0, .9].map((lag, i) => `<path d="M${7.25 + 1.5 * i} 13h0">${anim('translate', '0 0;0 -6', 1.8, `calcMode="linear" begin="${lag - 1.8}s"`)}${anim('stroke-width', '0;1.75;1.75;0', 1.8, `keyTimes="0;.1;.7;1" begin="${lag - 1.8}s"`)}</path>`).join('')}</g>`,
107  // A hammer winding back and tapping down.
108  build: `<g transform="rotate(45 8 8)"><g>${anim('rotate', '0 8 15.5;-14 8 15.5;0 8 15.5;0 8 15.5', 1.2, 'keyTimes="0;.35;.5;1"')}<rect x="4" y="4" width="8" height="3.75" rx="1"/><path d="M8 7.75V16"/></g></g>`,
109  // A branch with a commit travelling from the trunk to the tip, growing out of one and shrinking
110  // into the other (none at rest, where nothing moves it off the corner).
111  git: `<path d="M4.5 2v8.25M11.5 6.25c0 3.2-2.2 5.75-5.25 5.75"/><circle cx="4.5" cy="12" r="1.75"/><circle cx="11.5" cy="4.5" r="1.75"/><path d="M0 0h0" stroke-width="0"><animateMotion path="M4.5 12h1.75c3.05 0 5.25-2.55 5.25-5.75V4.5" dur="1.8s" calcMode="spline" keyPoints="0;1" keyTimes="0;1" keySplines="${EASE}" repeatCount="indefinite"/>${anim('stroke-width', '0;2.5;2.5;0', 1.8, 'keyTimes="0;.15;.85;1"')}</path>`,
112  // A pull request whose arrow nudges toward the base.
113  github: `<circle cx="4" cy="4" r="1.75"/><circle cx="12" cy="12" r="1.75"/><path d="M4 5.75V14M9 4h1.5A1.5 1.5 0 0 1 12 5.5v4.75"/><g>${anim('translate', '0 0;-1.25 0;0 0;0 0', 1.8, 'keyTimes="0;.2;.45;1"')}<path d="M9.75 2 7.75 4l2 2M7.75 4h2.5"/></g>`,
114  // A box whose lid hops and settles.
115  packages: `<g>${anim('translate', '0 0;0 -1.25;0 0;0 -.4;0 0;0 0', 1.8, 'keyTimes="0;.12;.26;.34;.42;1"')}<rect x="2" y="2.25" width="12" height="3.25" rx="1"/></g><path d="M3 5.5v6.75a1.5 1.5 0 0 0 1.5 1.5h7a1.5 1.5 0 0 0 1.5-1.5V5.5M6.5 8.75h3"/>`,
116  // A prompt with a command typing itself out.
117  shell: `<path d="m2.75 3.5 4 4-4 4"/>${draw('M8 12.5h5.25', .05, .45)}`,
118  // A turning globe: three meridians, a third of a turn apart, sweep from limb to limb
119  // (sine-eased, as the projection moves); at either limb a meridian lies on the outline, so its
120  // jump back is hidden.
121  web: `<circle cx="8" cy="8" r="6.25"/><path d="M1.75 8h12.5"/>${[0, -1, -2].map((begin) => `<path d="M8 1.75v12.5">${anim('d', 'M8 1.75c-8.33 0-8.33 12.5 0 12.5;M8 1.75c8.33 0 8.33 12.5 0 12.5', 3, `calcMode="spline" keySplines=".37 0 .63 1" begin="${begin}s"`)}</path>`).join('')}`,
122  // A window whose page loads, line by line.
123  browser: `<rect x="1.75" y="2" width="12.5" height="12" rx="2"/><path d="M1.75 6.25h12.5M4 4.1h0M6.25 4.1h0"/>${draw('M4.5 9.25h7', .05, .3)}${draw('M4.5 11.5h4.5', .3, .5)}`,
124  // An X hopping, whole and at full strength throughout.
125  x: `<g>${bob(1.8)}<path d="M3 3l10 10M13 3 3 13"/></g>`,
126  // A pointer clicking: rays burst out from its tip, then settle back.
127  computer: `<path d="M5.5 5.5l8.5 3.5-3.5 1.5-1.5 3.5z"/><g transform="translate(5.5 5.5)"><g>${anim('scale', '.75;1.15;.75;.75', 1.2, 'keyTimes="0;.15;.5;1"')}<path d="M0-2.25V-4M-2.25 0H-4M-1.6-1.6-2.8-2.8"/></g></g>`,
128  // A picture with the sun sweeping across its sky, rising and setting.
129  image: `<rect x="2" y="2" width="12" height="12" rx="2"/><path d="m2.5 13 3.75-3.75 2.5 2.5 1.75-1.75 3 3"/><circle cx="5" cy="6.25" r="1"><animateMotion path="M0 0Q3-2.5 6 0" dur="3s" begin="-1.5s" repeatCount="indefinite"/>${anim('r', '0;1;1;0', 3, 'keyTimes="0;.2;.8;1" begin="-1.5s"')}</circle>`,
130  // An eye that blinks now and then.
131  oracle: `<path d="${EYE}">${anim('d', `${EYE};${EYE};${SHUT};${EYE}`, 3, 'keyTimes="0;.88;.94;1"')}</path><circle cx="8" cy="8" r="1.9">${anim('r', '1.9;1.9;0;1.9', 3, 'keyTimes="0;.88;.94;1"')}</circle>`,
132  // Two figures bobbing in turn.
133  agents: [4.5, 11.5].map((x, i) => `<g>${anim('translate', '0 0;0 -1;0 0', 1.2, `begin="${-0.6 * i}s"`)}<circle cx="${x}" cy="5" r="1.75"/><path d="M${x - 2.5} 12.5v-.5a2.5 2.5 0 0 1 5 0v.5"/></g>`).join(''),
134  // A checklist ticking itself off.
135  plan: `<path d="M8.5 3.5h5.25M8.5 8h5.25M8.5 12.5h5.25"/>${draw('m2.25 3.5 1.25 1.25 2.5-2.5', .05, .2)}${draw('m2.25 8 1.25 1.25 2.5-2.5', .25, .4)}${draw('m2.25 12.5 1.25 1.25 2.5-2.5', .45, .6)}`,
136  // A speech bubble with someone typing.
137  ask: `<path d="M4 2.75h8a1.75 1.75 0 0 1 1.75 1.75v5a1.75 1.75 0 0 1-1.75 1.75H7.5l-2.75 2.25v-2.25H4a1.75 1.75 0 0 1-1.75-1.75v-5A1.75 1.75 0 0 1 4 2.75z"/><g stroke-width="2"><path d="M5.25 7h0">${bob(1.8)}</path><path d="M8 7h0">${bob(1.8, .3)}</path><path d="M10.75 7h0">${bob(1.8, .6)}</path></g>`,
138  // A database with a ring sweeping down its drum, from the lid's rim to the base, where it lies
139  // on a ring already drawn, so its jump back is hidden.
140  data: `<path d="M2.5 3.75v8.5M13.5 3.75v8.5"/><ellipse cx="8" cy="3.75" rx="5.5" ry="2"/><path d="M2.5 12.25a5.5 2 0 0 0 11 0"/><path d="M2.5 8a5.5 2 0 0 0 11 0">${anim('translate', '0 -4.25;0 4.25', 1.8)}</path>`,
141  // A rocket lifting on a flickering flame.
142  deploy: `<g>${anim('translate', '0 0;0 -1;0 0', 1.2)}<path d="M8 2C9.6 3.25 10.25 5.25 10.25 7.5v3.75h-4.5V7.5C5.75 5.25 6.4 3.25 8 2zM5.75 8.25 4 10v2.25l1.75-1M10.25 8.25 12 10v2.25l-1.75-1"/><circle cx="8" cy="6.25" r="1"/><path d="M8 13v1.25">${anim('d', 'M8 13v1.25;M8 13v.5;M8 13v1;M8 13v.25', 0.48, 'calcMode="discrete"')}</path></g>`,
143}
144
145/**
146 * The category's animated icon, at full strength throughout. SMIL runs where the surface
147 * animates an SVG image; elsewhere the icon stands whole.
148 */
149// ponytail: SMIL ignores prefers-reduced-motion and CSS cannot pause it, so the icons move even
150// for people who asked for less motion; the motion is kept small and slow instead. Honouring it
151// needs the caller to pass a static flag and drop the animate elements.
152export function iconSvg(category: CategoryId, size = 14): string {
153  const stroke = STROKE[category]
154
155  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="${size}" height="${size}" fill="none" stroke="${stroke}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><style>@media (prefers-color-scheme: light){svg{stroke:${LIGHT[stroke]}}}</style>${PATHS[category]}</svg>`
156}
157
158// The trail's ticks fill in their dark colours; one rule per colour swaps in the light partner.
159const TRAIL_LIGHT = `<style>@media (prefers-color-scheme: light){${Object.entries(LIGHT).map(([dark, light]) => `[fill="${dark}"]{fill:${light}}`).join('')}}</style>`
160
161/**
162 * The turn's steps as a static row of ticks, newest on the right: 3 px wide with 2 px gaps,
163 * a third of the height when done and full height while running. Done steps are dimmed; a
164 * failed one stays bright red, since its clay or amber neighbours are close in hue at .55.
165 * Older steps that do not fit the width are left off.
166 */
167export function trailSvg(ticks: readonly { category: CategoryId; ok: boolean; isRunning: boolean }[], width: number, height: number): string {
168  const shown = ticks.slice(Math.max(0, ticks.length - Math.floor((width + 2) / 5)))
169  const rects = shown.map((tick, i) => {
170    const h = tick.isRunning ? height : height / 3
171
172    return `<rect x="${width - (shown.length - i) * 5 + 2}" y="${(height - h) / 2}" width="3" height="${h}" rx="1.5" fill="${tick.ok ? STROKE[tick.category] : '#FF6B80'}"${tick.isRunning || !tick.ok ? '' : ' opacity=".55"'}/>`
173  })
174
175  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${TRAIL_LIGHT}${rects.join('')}</svg>`
176}
177
hooks/model.ts 186 lines
1// Pure readers of Amp's CLI output and the rules for an orb's state. No `$` here, so each
2// rule is tested on its own.
3import type { Orb, OrbCheck, OrbPr, OrbSize, OrbState, OrbStep } from '../types'
4import { stepOf } from './steps'
5
6export const QUIET_MS = 90_000
7export const STALE_MS = 86_400_000
8const MAX_STEPS = 6
9const MAX_FINAL = 4000
10const ORB_TREE = 'file:///home/user/workspace'
11
12export type ListRow = { id: string; title: string; updatedAt: number; messageCount: number; isOrb: boolean }
13
14export function parseList(stdout: string): ListRow[] {
15  let raw: unknown
16  try {
17    raw = JSON.parse(stdout)
18  } catch {
19    throw new Error('amp threads list did not answer JSON')
20  }
21  if (!Array.isArray(raw)) throw new Error('amp threads list did not answer a list')
22
23  return raw.flatMap((row): ListRow[] => {
24    if (!row || typeof row !== 'object') return []
25    const r = row as Record<string, unknown>
26    const id = typeof r.id === 'string' ? r.id : ''
27    if (!/^T-[0-9a-f-]{8,}$/.test(id)) return []
28    const updatedAt = Date.parse(String(r.updated ?? ''))
29
30    return [{
31      id,
32      title: typeof r.title === 'string' && r.title ? r.title : id,
33      updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
34      messageCount: Number(r.messageCount ?? 0) || 0,
35      isOrb: typeof r.tree === 'string' && r.tree.startsWith(ORB_TREE),
36    }]
37  })
38}
39
40type Block = Record<string, unknown>
41type Message = { role?: string; state?: { type?: string; stopReason?: string }; content?: unknown }
42
43const blocks = (m: Message): Block[] => (Array.isArray(m.content) ? (m.content as unknown[]).filter((b): b is Block => !!b && typeof b === 'object') : [])
44
45function isFailedRun(run: unknown): boolean {
46  if (!run || typeof run !== 'object') return false
47  const r = run as Record<string, unknown>
48
49  return r.status === 'error' || (r.error !== undefined && r.error !== null)
50}
51
52export type ExportView = { agentState: string; mode: string | null; createdAt: number; steps: OrbStep[]; prs: OrbPr[]; finalText: string; isEnded: boolean; isErrored: boolean }
53
54export function parseExport(stdout: string): ExportView {
55  const d = JSON.parse(stdout) as Record<string, unknown>
56  const meta = (d.meta ?? {}) as Record<string, unknown>
57  const agent = (meta.lastKnownAgentState ?? {}) as Record<string, unknown>
58  const agentState = String(agent.state ?? '')
59  const messages = Array.isArray(d.messages) ? (d.messages as Message[]) : []
60  const results = new Map<string, boolean>()
61  for (const m of messages) {
62    for (const b of blocks(m)) {
63      if (b.type === 'tool_result' && typeof b.toolUseID === 'string') results.set(b.toolUseID, !isFailedRun(b.run))
64    }
65  }
66  const assistant = messages.filter(m => m.role === 'assistant')
67  const uses = assistant.flatMap(m => blocks(m).filter(b => b.type === 'tool_use'))
68  const steps = uses.slice(-MAX_STEPS).map(b => stepOf(String(b.name ?? ''), (b.input ?? {}) as Record<string, unknown>, String(b.id ?? ''), results))
69  const last = assistant[assistant.length - 1]
70  const textOf = (m: Message) => blocks(m).filter(b => b.type === 'text').map(b => String(b.text ?? '')).join('\n')
71  const finalText = last ? textOf(last).trim() : ''
72  // The agent has the last word only when the thread's last row is its own. A user row after it
73  // (a follow-up, a fresh orb's prompt, tool results) is a turn under way, whatever the lagging
74  // agent state or the previous turn's end_turn say. Rows of other roles do not count.
75  const turnRows = messages.filter(m => m.role === 'user' || m.role === 'assistant')
76  const isAgentLast = turnRows[turnRows.length - 1]?.role === 'assistant'
77
78  return {
79    agentState,
80    mode: typeof d.agentMode === 'string' ? d.agentMode : null,
81    createdAt: Number(d.created ?? 0) || 0,
82    steps,
83    prs: prLinks(assistant.map(textOf).join('\n')),
84    finalText: finalText.length > MAX_FINAL ? `…${finalText.slice(-MAX_FINAL)}` : finalText,
85    isEnded: isAgentLast && (last?.state?.stopReason === 'end_turn' || agentState === 'idle'),
86    // An error counts only for the turn it ended: after a follow-up it is the last turn's news.
87    isErrored: isAgentLast && (last?.state?.type === 'error' || /error|fail/i.test(agentState)),
88  }
89}
90
91const PR = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/g
92
93export function prLinks(text: string): OrbPr[] {
94  const seen = new Map<string, OrbPr>()
95  for (const m of text.matchAll(PR)) {
96    const url = `https://github.com/${m[1]}/pull/${m[2]}`
97    if (!seen.has(url)) seen.set(url, { url, repo: m[1]!, number: Number(m[2]), state: 'unknown', ci: 'none', review: 'none' })
98  }
99
100  return [...seen.values()]
101}
102
103export function endsWithQuestion(text: string): boolean {
104  const flat = text.trim().replace(/[*_`\s]+$/, '')
105  const last = flat.split(/(?<=[.!?])\s+/).pop() ?? ''
106
107  return last.trim().endsWith('?')
108}
109
110export type StateInput = Pick<Orb, 'agentState' | 'isEnded' | 'isErrored' | 'finalText' | 'changedAt' | 'exportedAt'> & { isListOnly?: boolean }
111
112export function stateOf(o: StateInput, now: number): OrbState {
113  if (now - o.changedAt > STALE_MS) return 'stale'
114  // The one orb that is not working before its first read: it was quiet for over an hour when first seen.
115  if (o.isListOnly) return 'done'
116  // Only a reading of the thread taken since its last change can end a turn: until one lands (or
117  // while it keeps failing) the orb is working, however long the change has been quiet.
118  if (o.exportedAt < o.changedAt) return 'working'
119  if (o.isErrored) return 'failed'
120  // A turn that has not ended is working, whatever the agent state says.
121  if (!o.isEnded) return 'working'
122
123  return endsWithQuestion(o.finalText) ? 'waiting' : 'done'
124}
125
126function prefixOf(title: string): string {
127  return /^[^:]{2,40}:\s/.exec(title)?.[0] ?? ''
128}
129
130export function shortTitles(titles: readonly string[]): string[] {
131  const counts = new Map<string, number>()
132  for (const t of titles) {
133    const p = prefixOf(t)
134    if (p) counts.set(p, (counts.get(p) ?? 0) + 1)
135  }
136
137  return titles.map(t => {
138    const p = prefixOf(t)
139
140    return p && (counts.get(p) ?? 0) >= 2 ? t.slice(p.length).trim() : t
141  })
142}
143
144export function sizeOfCores(cores: number | null): OrbSize | null {
145  return cores === 2 ? 'a1.small' : cores === 4 ? 'a1.medium' : cores === 8 ? 'a1.large' : null
146}
147
148// A PR check's mark, in the pane and in orb_status.
149export const MARK: Record<OrbCheck, string> = { pass: '✓', fail: '✗', pending: '…', none: '–' }
150
151// Line colours for the globe, picked by thread id so an orb keeps its colour.
152const TINTS = ['#7dd3c0', '#b1b9f9', '#f0a6ca', '#f5c97a', '#9ad1f5', '#c3e88d']
153
154export function tintFor(id: string): string {
155  let h = 0
156  for (const c of id) h = (h * 31 + c.charCodeAt(0)) >>> 0
157
158  return TINTS[h % TINTS.length]!
159}
160
161export type UsageView = { cost: string; cpu: number[]; memMiB: number[]; cores: number | null; memTotalMiB: number | null }
162
163export function parseUsage(text: string): UsageView {
164  const cost = /^Cost:\s*(\S+)/m.exec(text)?.[1] ?? ''
165  const cpu: number[] = []
166  const memMiB: number[] = []
167  let cores: number | null = null
168  let memTotalMiB: number | null = null
169  for (const m of text.matchAll(/^\|\s*([\d.]+)% \/ (\d+) cores \|\s*(\d+) MiB \/ (\d+) MiB/gm)) {
170    cpu.push(Number(m[1]))
171    memMiB.push(Number(m[3]))
172    cores = Number(m[2])
173    memTotalMiB = Number(m[4])
174  }
175
176  return { cost, cpu: cpu.slice(-40), memMiB: memMiB.slice(-40), cores, memTotalMiB }
177}
178
179export function blankOrb(id: string): Orb {
180  return {
181    id, title: id, url: `https://ampcode.com/threads/${id}`, size: null, mode: null, state: 'working',
182    createdAt: 0, updatedAt: 0, changedAt: 0, messageCount: 0, steps: [], prs: [], finalText: '',
183    agentState: '', isEnded: false, isErrored: false, exportedAt: 0, seenTurn: '', isStartedHere: false, isListOnly: false,
184  }
185}
186
hooks/poller.ts 342 lines
1// The one place that reads Amp on a timer. It writes `orbs`, and the pane, tools and skyline read
2// it. Module variables (timer, backoff) start over on a reload; the state itself is the host's.
3// It takes a `Ports` instead of `$`: the engine follows `$` only inside the file that holds it.
4import type { Orb, OrbPr } from '../types'
5import { ampExport, ampList, ampUsage, ghPr } from './cli'
6import { blankOrb, parseExport, parseUsage, shortTitles, sizeOfCores, STALE_MS, stateOf } from './model'
7import type { Ports } from './ports'
8import { EXPORT_GAP_MS, exportsDue, FAST_MS, GH_GAP_MS, listDelay, MAX_EXPORTS, QUIET_STOP_MS } from './scheduler'
9
10const USAGE_GAP_MS = 60_000
11const MAX_PR_CALLS = 8
12const NOTIFY = ['-e', 'on run argv', '-e', 'display notification (item 1 of argv) with title (item 2 of argv)', '-e', 'end run']
13// The store key of the notifications sent, `{ [event]: when }`, and how long an event is kept.
14const NOTIFIED = 'notified'
15const NOTIFIED_MS = 86_400_000
16// A thread that still cannot be read this long after its change shows as failed, with this line.
17const UNREADABLE_MS = 3_600_000
18const UNREADABLE = 'cannot read this thread'
19
20let timer: ReturnType<Ports['after']> | null = null
21// When the pending timer fires. Only read while `timer` is set and still in the future.
22let dueAt = 0
23// The ports of the session's start: every timer runs on them, whichever dispatch asked for it. A
24// timer armed on a tool call's or a press's ports belongs to that dispatch, and a cancelled
25// dispatch drops it. Until a session start (or a measurement, after a reload) brings them, a
26// caller's own ports stand in.
27let session: Ports | null = null
28// The cadence last in force, for the retry when a schedule's own read fails.
29let cadence = FAST_MS
30let failures = 0
31let wantedUntil = 0
32let lastPollAt = 0
33let lastGhAt = 0
34let polling: Promise<void> | null = null
35// When an export last failed, per orb: a failing orb is retried at most every EXPORT_GAP_MS.
36const failedExports = new Map<string, number>()
37// When a message was last sent to an orb from here. Until a reading taken after it lands, the orb
38// is working, whatever a read that started before the send says when it lands.
39const sentAt = new Map<string, number>()
40let notify = true
41let notifyAfterMs = 300_000
42// Notifications go out one at a time, so two in one pass never write over each other's key.
43let notifying: Promise<void> = Promise.resolve()
44
45export function configure(o: { notify: boolean; notifyAfterMs: number }): void {
46  notify = o.notify
47  notifyAfterMs = o.notifyAfterMs
48}
49
50export async function start(p: Ports): Promise<void> {
51  session = p
52  // A poll that throws must not end the watch: pollNow schedules the next read whatever happened.
53  await pollNow(p).catch(() => undefined)
54}
55
56/**
57 * From a frequent, short hook (the session's measurement after each turn): a reload cancels the
58 * plugin's timers, and if no session start follows it, this arms the next read again. Idle, it
59 * arms nothing.
60 */
61export async function rearm(p: Ports): Promise<void> {
62  session ??= p
63  await schedule(p)
64}
65
66/** Forget the timer and the backoff, as a reload does. For tests, which share this module. */
67export function resetPoller(): void {
68  timer?.cancel()
69  timer = null
70  dueAt = 0
71  session = null
72  cadence = FAST_MS
73  failures = 0
74  wantedUntil = 0
75  lastPollAt = 0
76  lastGhAt = 0
77  polling = null
78  failedExports.clear()
79  sentAt.clear()
80  notifying = Promise.resolve()
81}
82
83export async function want(p: Ports, forMs = 600_000): Promise<void> {
84  wantedUntil = Math.max(wantedUntil, (await p.now()) + forMs)
85  await schedule(p)
86}
87
88export async function pollNow(p: Ports, maxAgeMs = 0): Promise<void> {
89  try {
90    // A read already under way is the freshest there is: wait for it rather than answer from before it.
91    if (polling) return await polling
92    if ((await p.now()) - lastPollAt < maxAgeMs) return
93    polling ??= pollOnce(p).finally(() => {
94      polling = null
95    })
96
97    return await polling
98  } finally {
99    // Whoever asked, the next read is scheduled: a read after the idle stop may have found work.
100    await schedule(p)
101  }
102}
103
104/** A read that starts after this call: one already under way may predate what the caller just did, so it is waited out first. */
105export async function pollFresh(p: Ports): Promise<void> {
106  if (polling) await polling.catch(() => undefined)
107
108  return pollNow(p)
109}
110
111export async function addStarted(p: Ports, orb: Orb): Promise<void> {
112  await p.orbs.set(list => [orb, ...list.filter(o => o.id !== orb.id)])
113}
114
115/** A message reached the orb: it is working from now, so waits and labels see the new turn at once. */
116export async function markSent(p: Ports, id: string): Promise<void> {
117  sentAt.set(id, await p.now())
118  await p.orbs.set(list => list.map(afterSend))
119}
120
121// The orb as a send from here leaves it: a change at the send's time that no reading has seen yet.
122function afterSend(o: Orb): Orb {
123  const at = sentAt.get(o.id)
124  if (at === undefined || o.exportedAt >= at) return o
125
126  return { ...o, state: 'working', changedAt: Math.max(o.changedAt, at), isEnded: false, isListOnly: false }
127}
128
129// A toast or a notification is news only for an orb whose thread was read, or that was started
130// here: an orb seen for the first time, or never read since, may have ended its turn long ago.
131const isKnown = (o: Orb) => o.exportedAt > 0 || o.isStartedHere
132
133async function schedule(p: Ports): Promise<void> {
134  const host = session ?? p
135  try {
136    // Every await comes first. From the cancel to the assignment there is none, so overlapping calls
137    // (start, want, a timer firing) can never leave a second timer behind that nothing can cancel.
138    const now = await host.now()
139    const delay = listDelay(now, await host.orbs.get(), wantedUntil, failures)
140    if (delay !== null) cadence = delay
141    // A pending timer that fires no later than this one stays: re-arming a full delay on every call
142    // would starve a caller that asks more often than the cadence (the pane, an orb_wait loop).
143    if (delay !== null && timer && dueAt > now && dueAt <= now + delay) return
144    timer?.cancel()
145    timer = null
146    if (delay === null) return
147    arm(host, delay, now + delay)
148  } catch {
149    // A read that fails must not end the chain: unless a timer is pending, try again at the cadence
150    // last in force. Its due time is unknown, so the next schedule that can read replaces it.
151    try {
152      if (!timer) arm(host, cadence, Infinity)
153    } catch {
154      // No clock at all: the next pollNow, want or measurement schedules again.
155    }
156  }
157}
158
159function arm(host: Ports, delay: number, at: number): void {
160  dueAt = at
161  const own = host.after(delay, () => {
162    // Only its own: a callback that comes late must not forget the timer that replaced it.
163    if (timer === own) timer = null
164    void pollNow(session ?? host).catch(() => undefined)
165  })
166  timer = own
167}
168
169function mergePrs(old: readonly OrbPr[], found: readonly OrbPr[]): OrbPr[] {
170  const byUrl = new Map(old.map(pr => [pr.url, pr]))
171
172  return found.map(pr => byUrl.get(pr.url) ?? pr)
173}
174
175async function pollOnce(p: Ports): Promise<void> {
176  const now = await p.now()
177  lastPollAt = now
178  let rows
179  try {
180    rows = await ampList(p)
181  } catch (error) {
182    failures += 1
183    const text = error instanceof Error ? error.message : String(error)
184    const old = await p.error.get()
185    if (old?.text !== text) await p.error.set(() => ({ text, at: now }))
186
187    return
188  }
189  failures = 0
190  const before = await p.orbs.get()
191  const byId = new Map(before.map(o => [o.id, o]))
192  const changed = new Set<string>()
193  const next: Orb[] = []
194  for (const row of rows) {
195    if (!row.isOrb) continue
196    const old = byId.get(row.id)
197    if (!old && now - row.updatedAt > STALE_MS) continue
198    const isChanged = !old || old.updatedAt !== row.updatedAt || old.messageCount !== row.messageCount
199    if (isChanged) changed.add(row.id)
200    next.push(afterSend({
201      ...(old ?? blankOrb(row.id)),
202      title: row.title,
203      updatedAt: row.updatedAt,
204      messageCount: row.messageCount,
205      changedAt: !isChanged ? old!.changedAt : old ? now : row.updatedAt,
206      // Quiet for over an hour when first seen: not read until it changes or is selected.
207      isListOnly: old ? old.isListOnly && !isChanged : now - row.updatedAt > QUIET_STOP_MS,
208    }))
209  }
210  for (const old of before) {
211    if (old.isStartedHere && !next.some(o => o.id === old.id) && now - old.changedAt < STALE_MS) next.push(afterSend(old))
212  }
213  const selected = await p.selected.get()
214  const isPaneOpen = await p.isPaneOpen.get()
215  // A few per pass: the rest keep their unread change and are read on a later pass.
216  const due = exportsDue(now, next, changed, selected, isPaneOpen).filter(id => now - (failedExports.get(id) ?? -Infinity) >= EXPORT_GAP_MS)
217  for (const id of due.slice(0, MAX_EXPORTS)) {
218    const i = next.findIndex(o => o.id === id)
219    const orb = next[i]!
220    try {
221      const view = parseExport(await ampExport(p, id))
222      failedExports.delete(id)
223      if ((sentAt.get(id) ?? Infinity) <= now) sentAt.delete(id)
224      next[i] = { ...orb, mode: view.mode ?? orb.mode, createdAt: view.createdAt || orb.createdAt, steps: view.steps, prs: mergePrs(orb.prs, view.prs), finalText: view.finalText, agentState: view.agentState, isEnded: view.isEnded, isErrored: view.isErrored, exportedAt: now, isListOnly: false }
225    } catch {
226      // Keep the last good reading, and wait a full gap before trying this orb again.
227      failedExports.set(id, now)
228      // After an hour it stops spinning: failed, and read as of now until it changes again, so the
229      // reads wind down. Quietly (its turn counts as seen): orbit cannot read it, which is no news of the orb.
230      if (!orb.isListOnly && now - orb.changedAt >= UNREADABLE_MS) {
231        next[i] = { ...orb, isErrored: true, finalText: UNREADABLE, exportedAt: now, seenTurn: `${id}:${orb.messageCount}` }
232      }
233    }
234  }
235  for (let i = 0; i < next.length; i += 1) next[i] = { ...next[i]!, state: stateOf(next[i]!, now) }
236  // Only an orb read before can end a turn: a first read (a session's staggered start-up) is no news.
237  const isTurnEnd = next.some(o => {
238    const old = byId.get(o.id)
239
240    return !!old && isKnown(old) && old.state === 'working' && o.state !== 'working'
241  })
242  if (isPaneOpen || isTurnEnd) await refreshPrs(p, next, now, isTurnEnd)
243  if (isPaneOpen && selected) await refreshUsage(p, next, selected, now)
244  announce(p, before, next, now)
245  // The exports and gh calls above took minutes at worst, and an orb may have been added meanwhile
246  // (orb_start's addStarted): keep what appeared since `before`, and apply the same rule to whatever
247  // the cell holds at write time, so a set that lands between this read and the write is not lost.
248  // A message sent while this read was under way started a turn this read cannot have seen.
249  const known = new Set([...before, ...next].map(o => o.id))
250  const withAdded = (current: Orb[]): Orb[] => [...current.filter(o => !known.has(o.id)), ...next.map(afterSend)]
251  const current = await p.orbs.get()
252  if (JSON.stringify(current) !== JSON.stringify(withAdded(current))) await p.orbs.set(withAdded)
253  if ((await p.error.get()) !== null) await p.error.set(() => null)
254}
255
256async function refreshPrs(p: Ports, orbs: Orb[], now: number, isForced: boolean): Promise<void> {
257  if (!isForced && now - lastGhAt < GH_GAP_MS) return
258  lastGhAt = now
259  let calls = 0
260  for (let i = 0; i < orbs.length; i += 1) {
261    const orb = orbs[i]!
262    if (orb.state === 'stale' || orb.prs.length === 0) continue
263    const prs = [...orb.prs]
264    for (let j = 0; j < prs.length && calls < MAX_PR_CALLS; j += 1) {
265      if (prs[j]!.state === 'merged' || prs[j]!.state === 'closed') continue
266      calls += 1
267      try {
268        prs[j] = { ...prs[j]!, ...(await ghPr(p, prs[j]!.url)) }
269      } catch {
270        // gh missing or offline: badges stay as they were.
271      }
272    }
273    orbs[i] = { ...orb, prs }
274  }
275}
276
277async function refreshUsage(p: Ports, orbs: Orb[], id: string, now: number): Promise<void> {
278  const old = await p.usage.get()
279  if (old?.id === id && now - old.fetchedAt < USAGE_GAP_MS) return
280  try {
281    const u = parseUsage(await ampUsage(p, id))
282    await p.usage.set(() => ({ id, ...u, fetchedAt: now }))
283    const i = orbs.findIndex(o => o.id === id)
284    const size = sizeOfCores(u.cores)
285    if (i >= 0 && size && !orbs[i]!.size) orbs[i] = { ...orbs[i]!, size }
286  } catch {
287    // Usage is optional detail.
288  }
289}
290
291function announce(p: Ports, before: readonly Orb[], next: Orb[], now: number): void {
292  const prev = new Map(before.map(o => [o.id, o]))
293  const shorts = shortTitles(next.map(o => o.title))
294  next.forEach((orb, i) => {
295    const old = prev.get(orb.id)
296    if (!old || !isKnown(old)) return
297    const name = shorts[i] ?? orb.title
298    for (const pr of orb.prs) {
299      if (!old.prs.some(q => q.url === pr.url)) {
300        p.toast(`◉ ${name} opened PR #${pr.number}`)
301        ping(p, orb, `opened PR #${pr.number}`, `${orb.id}:${pr.url}`, now)
302      }
303    }
304    const turn = `${orb.id}:${orb.messageCount}`
305    if (old.state !== 'working' || orb.state === 'working' || orb.seenTurn === turn) return
306    next[i] = { ...orb, seenTurn: turn }
307    if (orb.state === 'failed') {
308      p.toast(`◉ ${name} failed`)
309      ping(p, orb, 'failed', turn, now)
310    } else if (orb.state === 'waiting') {
311      p.toast(`◉ ${name} is waiting on you`)
312    } else if (orb.state === 'done') {
313      p.toast(`◉ ${name} finished`)
314    }
315  })
316}
317
318function ping(p: Ports, orb: Orb, what: string, key: string, now: number): void {
319  if (!notify || !orb.createdAt || now - orb.createdAt < notifyAfterMs) return
320  const title = Array.from(orb.title.replace(/[\u0000-\u001f\u007f]/g, ' ')).slice(0, 120).join('')
321  notifying = notifying.then(() => notifyOnce(p, key, `${title} ${what}`, now)).catch(() => undefined)
322}
323
324// A macOS notification is system-wide, and every open session polls on its own and sees the same
325// event: the store, which the sessions share, remembers each event notified (`key`, for a day), so
326// it is sent once. A toast stays per session, since it shows in that session's window.
327async function notifyOnce(p: Ports, key: string, text: string, now: number): Promise<void> {
328  try {
329    const raw = await p.store.get(NOTIFIED)
330    const seen: Record<string, number> = {}
331    if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
332      for (const [k, at] of Object.entries(raw)) if (typeof at === 'number' && now - at < NOTIFIED_MS) seen[k] = at
333    }
334    if (key in seen) return
335    seen[key] = now
336    await p.store.set(NOTIFIED, seen)
337  } catch {
338    // The store cannot be read or written: notify anyway, at worst once per session.
339  }
340  await p.run(['osascript', ...NOTIFY, '--', text, 'orbit'], { timeoutMs: 10_000 })
341}
342
hooks/ports.ts 26 lines
1// Every way orbit's modules reach the engine. The engine follows `$` only inside the file that
2// holds it, so register.tsx builds these closures from literal `$.noun.event(...)` calls, and the
3// other modules take a Ports instead of `$`.
4import type { Orb, OrbitError, OrbUsage } from '../types'
5import type { Host } from './cli'
6
7export const PANE = 'orbit'
8
9export type Cell<T> = { get: () => Promise<T>; set: (change: (value: T) => T) => Promise<T> }
10
11export type Ports = Host & {
12  now: () => Promise<number>
13  after: (ms: number, fn: () => void) => { cancel: () => void }
14  toast: (text: string) => void
15  openPane: () => Promise<unknown>
16  checkTool: (tool: string, input: Record<string, unknown>) => Promise<{ decision: 'allow' | 'ask' | 'deny'; reason?: string }>
17  // The plugin's own store: kept on disk and shared by every session of the user.
18  store: { get: (key: string) => Promise<unknown>; set: (key: string, value: unknown) => Promise<void> }
19  orbs: Cell<Orb[]>
20  selected: Cell<string | null>
21  usage: Cell<OrbUsage | null>
22  error: Cell<OrbitError | null>
23  armed: Cell<string | null>
24  isPaneOpen: Cell<boolean>
25}
26
hooks/sphere.ts 89 lines
1// An orb drawn the way Amp draws its own: a dark globe with thin latitude and meridian lines in
2// the thread's colour. The meridians turn and the status ring's gap travels only while the orb
3// works. The animation is SMIL inside the SVG, so no timer in the mod ever ticks for it. Ring
4// colours are Claude Code's theme values (an SVG cannot read the theme), with a light-mode swap.
5import type { OrbSize, OrbState } from '../types'
6
7export const RING: Record<OrbState, { dark: string; light: string }> = {
8  working: { dark: '#D97757', light: '#C6613F' },
9  waiting: { dark: '#B1B9F9', light: '#5769F7' },
10  done: { dark: '#4EBA65', light: '#2C7A39' },
11  failed: { dark: '#FF6B80', light: '#AB2B3F' },
12  stale: { dark: '#999999', light: '#666666' },
13}
14
15export const STATE_KEY: Record<OrbState, string> = { working: 'claude', waiting: 'suggestion', done: 'success', failed: 'error', stale: 'inactive' }
16
17export function radiusOf(size: OrbSize | null): number {
18  return size === 'a1.small' ? 28 : size === 'a1.large' ? 40 : 34
19}
20
21const f = (n: number) => Number(n.toFixed(1))
22
23export function sphereSvg(o: { tint: string; state: OrbState; radius: number; isSelected: boolean }): string {
24  const r = o.radius
25  const pad = 6
26  const size = (r + pad) * 2
27  const c = size / 2
28  const ring = RING[o.state]
29  const isSpinning = o.state === 'working'
30  const ringR = r + 3
31  const around = f(2 * Math.PI * ringR)
32  // At rest the two meridians stand at different turns, so a still globe shows both.
33  const meridian = (begin: string, rx: number) =>
34    `<ellipse cx="${c}" cy="${c}" rx="${f(r * rx)}" ry="${r}">${
35      isSpinning ? `<animate attributeName="rx" values="${f(r * 0.95)};${f(r * 0.05)};${f(r * 0.95)}" dur="4s" begin="${begin}" repeatCount="indefinite" calcMode="spline" keySplines=".42 0 .58 1;.42 0 .58 1"/>` : ''
36    }</ellipse>`
37
38  return (
39    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">` +
40    `<style>.ring{stroke:${ring.dark}}@media (prefers-color-scheme: light){.ring{stroke:${ring.light}}}</style>` +
41    `<defs><radialGradient id="b" cx="42%" cy="38%" r="65%"><stop offset="0" stop-color="#2b3a3c"/><stop offset="1" stop-color="#0c1516"/></radialGradient>` +
42    `<clipPath id="k"><circle cx="${c}" cy="${c}" r="${r}"/></clipPath></defs>` +
43    `<circle cx="${c}" cy="${c}" r="${r}" fill="url(#b)"/>` +
44    `<g clip-path="url(#k)" fill="none" stroke="${o.tint}" stroke-width="1" stroke-opacity="0.55">` +
45    `<ellipse cx="${c}" cy="${c}" rx="${r}" ry="${f(r * 0.28)}"/>` +
46    `<ellipse cx="${c}" cy="${f(c - r * 0.5)}" rx="${f(r * 0.87)}" ry="${f(r * 0.16)}"/>` +
47    `<ellipse cx="${c}" cy="${f(c + r * 0.5)}" rx="${f(r * 0.87)}" ry="${f(r * 0.16)}"/>` +
48    meridian('0s', 0.45) +
49    meridian('-2s', 0.85) +
50    `</g>` +
51    `<circle class="ring" cx="${c}" cy="${c}" r="${ringR}" fill="none" stroke-width="${o.isSelected ? 2.5 : 1.5}" stroke-linecap="round"${
52      isSpinning ? ` stroke-dasharray="${f(around * 0.84)} ${f(around * 0.16)}"` : ''
53    }>${isSpinning ? `<animateTransform attributeName="transform" type="rotate" from="0 ${c} ${c}" to="360 ${c} ${c}" dur="2.4s" repeatCount="indefinite"/>` : ''}</circle>` +
54    `</svg>`
55  )
56}
57
58const STROKE = 1.5
59
60// Inset by the stroke width, so the peak, the floor and the ends are drawn whole. One sample is a flat line.
61export function sparkSvg(values: readonly number[], width: number, height: number, color: string): string {
62  const series = values.length === 1 ? [values[0]!, values[0]!] : values
63  const max = Math.max(1, ...series)
64  const step = series.length > 1 ? (width - 2 * STROKE) / (series.length - 1) : 0
65  const points = series.map((v, i) => `${f(STROKE + i * step)},${f(height - STROKE - (v / max) * (height - 2 * STROKE))}`).join(' ')
66
67  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${
68    series.length ? `<polyline fill="none" stroke="${color}" stroke-width="${STROKE}" stroke-linejoin="round" points="${points}"/>` : ''
69  }</svg>`
70}
71
72const BARS = '▁▂▃▄▅▆▇█'
73
74/** The terminal's sparkline: the last twelve samples as bars, scaled to their peak. */
75export function sparkText(values: readonly number[]): string {
76  const last = values.slice(-12)
77  const max = Math.max(1, ...last)
78
79  return last.map(v => BARS[Math.round((Math.max(0, v) / max) * 7)]).join('')
80}
81
82// SMIL elements, self-closing or with children (an animateMotion's mpath).
83const SMIL = /<(animate(?:Transform|Motion)?)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1\s*>)/g
84
85/** The same drawing without its animation: a finished step's icon stands still. */
86export function stillSvg(svg: string): string {
87  return svg.replace(SMIL, '')
88}
89
hooks/tools.ts 254 lines
1// Claude's native orb tools. They share the poller's cache. Waits sleep in a host `sleep` process,
2// because a `$.clock.sleep` would count against the hook's own time budget.
3// It takes a `Ports` instead of `$`: the engine follows `$` only inside the file that holds it, so
4// register.tsx serves these with `serveTool(portsOf($), e, next.signal)`.
5import type { Orb } from '../types'
6import { pressArchive, sendMessage } from './actions'
7import { ampExport, ampStart, isThreadId, StartUnsure } from './cli'
8import { blankOrb, MARK, parseExport, shortTitles } from './model'
9import { addStarted, pollNow, want } from './poller'
10import type { Ports } from './ports'
11import { WATCH_MS } from './scheduler'
12
13const MODES = ['low', 'medium', 'high', 'ultra']
14const SIZES = ['a1.small', 'a1.medium', 'a1.large']
15const UNTIL = ['idle', 'pr', 'any']
16// A wait's step is one tiny `sleep` process, short enough that a cancelled call is noticed inside
17// the engine's linger. The poller owns every Amp read: a wait only asks it to keep its watched
18// cadence going (`want`), so waiting adds no reads of its own.
19const WAIT_STEP_MS = 5_000
20const WAIT_DEFAULT_MIN = 30
21const WAIT_MAX_MIN = 120
22// A read this fresh answers a status call or an archive without another one.
23const FRESH_MS = 15_000
24// orb_status lists, by default, the orbs that are active or changed this recently.
25const RECENT_MS = 3_600_000
26// The most of a stopped orb's last message orb_status shows, and of a report orb_result returns.
27const TAIL = 160
28const RESULT_MAX = 3_000
29const MAX_PRS = 3
30// Plugin tools answered in a hook never reach the engine's permission prompt, so these three, which
31// change something, run only on an explicit allow. The read-only tools stop only on an explicit deny.
32const GATED = ['orb_start', 'orb_send', 'orb_archive']
33
34type ToolSpec = { name: string; description: string; inputSchema: Record<string, unknown> }
35type Served = { result: string } | { deny: string }
36
37// What Claude reads on every turn of every session, so each word earns its place.
38const STR = { type: 'string' }
39const IDS = { type: 'array' }
40const object = (properties: Record<string, unknown>, ...required: string[]) => ({ type: 'object', properties, ...(required.length ? { required } : {}) })
41const ONE = object({ id: STR }, 'id')
42
43export const TOOLS: ToolSpec[] = [
44  { name: 'orb_start', description: 'Start an Amp orb on project owner/name. Defaults: mode ultra, size a1.small. Put the whole task in prompt.', inputSchema: object({ title: STR, prompt: STR, project: STR, mode: { enum: MODES }, size: { enum: SIZES } }, 'title', 'prompt', 'project') },
45  { name: 'orb_send', description: 'Message an orb.', inputSchema: object({ id: STR, message: STR }, 'id', 'message') },
46  { name: 'orb_status', description: 'Orbs active or changed in the last hour; all: every orb. URL ampcode.com/threads/<id>', inputSchema: object({ ids: IDS, all: { type: 'boolean' } }) },
47  { name: 'orb_wait', description: `Wait until orbs are idle (default), each has a new pr, or any ends a turn or opens a PR. Default ${WAIT_DEFAULT_MIN} min, max ${WAIT_MAX_MIN}.`, inputSchema: object({ ids: IDS, until: { enum: UNTIL }, timeoutMinutes: { type: 'number' } }, 'ids') },
48  { name: 'orb_result', description: "An orb's final message.", inputSchema: ONE },
49  { name: 'orb_archive', description: 'Archive an orb.', inputSchema: ONE },
50]
51
52const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
53const list = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
54const reason = (error: unknown) => (error instanceof Error ? error.message : String(error))
55
56// One line per orb: the id (the other tools need it), what it is doing or how it ended, its PRs.
57function lineOf(o: Orb, title: string): string {
58  const step = o.state === 'working' ? o.steps[o.steps.length - 1] : undefined
59  // Marks only for a PR gh has checked: one never checked (state unknown) is its number alone.
60  const prs = o.prs.slice(0, MAX_PRS).map(pr => (pr.state === 'unknown' ? `#${pr.number}` : pr.state === 'open' ? `#${pr.number} CI${MARK[pr.ci]} CR${MARK[pr.review]}` : `#${pr.number} ${pr.state}`))
61  const flat = o.state === 'working' ? '' : o.finalText.replace(/\s+/g, ' ').trim()
62
63  return [
64    `${o.id} ${title.replace(/\s+/g, ' ').trim()}`,
65    o.state,
66    [o.mode, o.size?.replace('a1.', '')].filter(Boolean).join('/'),
67    step ? `${step.verb} ${step.target}`.trim() : '',
68    prs.length ? `PR ${prs.join(', ')}${o.prs.length > MAX_PRS ? ` +${o.prs.length - MAX_PRS}` : ''}` : '',
69    flat ? `"${flat.length > TAIL ? `…${flat.slice(1 - TAIL).trimStart()}` : flat}"` : '',
70  ].filter(Boolean).join(' · ')
71}
72
73async function statusText(p: Ports, o: { ids?: readonly string[]; all?: boolean } = {}): Promise<string> {
74  const now = await p.now()
75  const orbs = await p.orbs.get()
76  const titles = shortTitles(orbs.map(x => x.title))
77  const isShown = (x: Orb) => (o.ids?.length ? o.ids.includes(x.id) : o.all || ['working', 'waiting', 'failed'].includes(x.state) || now - x.changedAt < RECENT_MS)
78  const lines = orbs.flatMap((x, i) => (isShown(x) ? [lineOf(x, titles[i] ?? x.title)] : []))
79  const error = await p.error.get()
80  if (error) lines.unshift(`amp failing: ${error.text}`)
81
82  return lines.join('\n') || (orbs.length ? `no orbs to show (${orbs.length} tracked; all: true lists them)` : 'no orbs tracked')
83}
84
85// `seenWorking` holds every orb seen working since the wait began, so `any` also catches a turn
86// that started during the wait (a message sent from the pane, or by Claude just before).
87type Baseline = { prs: Map<string, number>; seenWorking: Set<string> }
88
89function isMet(until: string, orbs: readonly Orb[], wanted: readonly string[], before: Baseline): boolean {
90  const mine = wanted.map(id => orbs.find(o => o.id === id))
91  const hasNewPr = (o: Orb) => o.prs.length > (before.prs.get(o.id) ?? 0)
92  if (until === 'pr') return mine.every(o => !!o && hasNewPr(o))
93  if (until === 'any') return mine.some(o => !!o && (hasNewPr(o) || (before.seenWorking.has(o.id) && o.state !== 'working')))
94
95  return mine.every(o => !!o && o.state !== 'working')
96}
97
98async function denial(p: Ports, e: Record<string, unknown>, tool: string): Promise<string | null> {
99  const input = Object.fromEntries(Object.entries(e).filter(([key]) => key !== 'tool' && key !== 'tool_use_id'))
100  const rule = `Add mcp__orbit__${tool} (or mcp__orbit__*) to your allow rules.`
101  let decision: string
102  try {
103    const check = await p.checkTool(String(e.tool), input)
104    if (check.decision === 'deny') return `orbit: ${check.reason ?? 'denied by permission settings'}`
105    decision = check.decision
106  } catch (error) {
107    // A check that cannot answer does not stop a read-only tool, and never lets a gated one through.
108    return GATED.includes(tool) ? `orbit: ${tool} was not run, its permission check failed (${reason(error)}). ${rule}` : null
109  }
110
111  return GATED.includes(tool) && decision !== 'allow' ? `orbit: ${tool} needs permission. ${rule}` : null
112}
113
114// Every read in one hook run sees one moment, however long the run lasts, and only a write of the
115// run's own moves it on (`$.state.get`). A wait sleeps between reads, so it would never see what the
116// poller's timer found meanwhile. Writing the list back unchanged hands back the list as it stands now.
117const current = (p: Ports) => p.orbs.set(list => list)
118
119// Resolves when the call is cancelled. A signal that cannot be listened to is still checked by
120// `aborted` at every step, so a cancel is then noticed at the next one.
121function onAbort(signal: AbortSignal): { aborted: Promise<void>; off: () => void } {
122  let off = () => {}
123  const aborted = new Promise<void>(resolve => {
124    if (signal.aborted) return resolve()
125    if (typeof signal.addEventListener !== 'function') return
126    const listener = () => resolve()
127    signal.addEventListener('abort', listener, { once: true })
128    off = () => signal.removeEventListener?.('abort', listener)
129  })
130
131  return { aborted, off: () => off() }
132}
133
134async function wait(p: Ports, e: Record<string, unknown>, signal: AbortSignal): Promise<Served> {
135  const given = list(e.ids)
136  const wanted = given.filter(isThreadId)
137  if (wanted.length === 0 || wanted.length < given.length) return { deny: 'orb_wait: ids must list Amp thread ids like T-01a1…' }
138  const until = e.until === undefined ? 'idle' : str(e.until)
139  if (!UNTIL.includes(until)) return { deny: `orb_wait: until must be one of ${UNTIL.join(', ')}` }
140  const minutes = Math.min(WAIT_MAX_MIN, Math.max(1, Number(e.timeoutMinutes ?? WAIT_DEFAULT_MIN) || WAIT_DEFAULT_MIN))
141  const deadline = (await p.now()) + minutes * 60_000
142  // The baseline is what was known before this wait read Amp, so a PR the first read finds counts as new.
143  const first = await p.orbs.get()
144  const before: Baseline = { prs: new Map(first.map(o => [o.id, o.prs.length])), seenWorking: new Set(first.filter(o => o.state === 'working').map(o => o.id)) }
145  const stopped = async (why: string) => ({ result: `${why}\n${await statusText(p, { ids: wanted })}` })
146  const cancel = onAbort(signal)
147  try {
148    // The one read the wait asks for, and only when the cache is older than the watched cadence.
149    if (!signal.aborted) await pollNow(p, WATCH_MS)
150    for (;;) {
151      if (signal.aborted) return await stopped('stopped waiting.')
152      // Keeps the poller's 30 second cadence alive; it lapses about a minute after the wait ends.
153      await want(p, 2 * WATCH_MS)
154      const orbs = await current(p)
155      if (isMet(until, orbs, wanted, before)) return { result: await statusText(p, { ids: wanted }) }
156      for (const o of orbs) if (o.state === 'working') before.seenWorking.add(o.id)
157      // The read that just landed either failed or listed what Amp has: say so now, not at the timeout.
158      const error = await p.error.get()
159      // The status below leads with the error itself.
160      if (error) return await stopped('stopped waiting: Amp could not be read.')
161      const missing = wanted.filter(id => !orbs.some(o => o.id === id))
162      if (until === 'any' ? missing.length === wanted.length : missing.length > 0) {
163        return await stopped(`stopped waiting: ${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not tracked (not in Amp's recent orbs, or archived).`)
164      }
165      const now = await p.now()
166      if (now >= deadline) return await stopped(`timed out after ${minutes} min.`)
167      if (signal.aborted) return await stopped('stopped waiting.')
168      // The last step is cut to the time left, so a wait does not overrun its deadline.
169      const stepMs = Math.min(WAIT_STEP_MS, deadline - now)
170      const slept = p
171        .run(['sleep', String(stepMs / 1000)], { timeoutMs: stepMs + 10_000 })
172        .then(out => (out.exitCode === 0 ? null : `sleep exited ${out.exitCode}`), error => reason(error))
173      // A cancel ends the step at once; the `sleep` process is left to finish by itself.
174      const outcome = await Promise.race([slept, cancel.aborted.then(() => 'cancelled' as const)])
175      // Without a working sleep this would spin until the deadline: say so and stop instead.
176      if (outcome !== null && outcome !== 'cancelled') return await stopped(`stopped waiting: ${outcome}.`)
177    }
178  } finally {
179    cancel.off()
180  }
181}
182
183export async function serveTool(p: Ports, e: Record<string, unknown>, signal: AbortSignal): Promise<Served> {
184  const tool = String(e.tool).replace('mcp__orbit__', '')
185  const denied = await denial(p, e, tool)
186  if (denied) return { deny: denied }
187
188  if (tool === 'orb_start') {
189    const title = str(e.title)
190    const prompt = str(e.prompt)
191    const project = str(e.project)
192    const mode = str(e.mode) || 'ultra'
193    const size = str(e.size) || 'a1.small'
194    if (!title || !prompt || !project) return { deny: 'orb_start: title, prompt and project are required' }
195    if (!MODES.includes(mode)) return { deny: `orb_start: mode must be one of ${MODES.join(', ')}` }
196    if (!SIZES.includes(size)) return { deny: `orb_start: size must be one of ${SIZES.join(', ')}` }
197    let started
198    try {
199      started = await ampStart(p, { title, prompt, project, mode, size })
200    } catch (error) {
201      if (!(error instanceof StartUnsure)) throw error
202      // amp ran, so an orb may be up: say so, rather than a plain error that invites a second one.
203      await want(p)
204      const seen = error.urls.length > 0 ? ` amp printed: ${error.urls.join(' ')}` : ''
205
206      return { result: `orb_start: the orb may have started; check orb_status before retrying (${error.message}).${seen}` }
207    }
208    const now = await p.now()
209    await addStarted(p, { ...blankOrb(started.id), title, mode, size: size as Orb['size'], createdAt: now, updatedAt: now, changedAt: now, isStartedHere: true })
210    await want(p)
211
212    return { result: `Started ${started.id} (${mode}, ${size}): ${started.url}. orbit is watching it; use orb_wait to wait for it.` }
213  }
214
215  if (tool === 'orb_send' || tool === 'orb_result' || tool === 'orb_archive') {
216    const id = str(e.id)
217    if (!isThreadId(id)) return { deny: `${tool}: id must be an Amp thread id like T-01a1…` }
218    if (tool === 'orb_send') {
219      const message = str(e.message)
220      if (!message) return { deny: 'orb_send: message is empty' }
221      const sent = await sendMessage(p, id, message)
222
223      return sent === 'sent' ? { result: `sent to ${id}` } : { deny: `orb_send: ${sent} to ${id}` }
224    }
225    if (tool === 'orb_result') {
226      const view = parseExport(await ampExport(p, id))
227      if (!view.finalText) return { result: '(no final message yet)' }
228      const text = view.finalText.length > RESULT_MAX ? `(the last ${RESULT_MAX} characters; the whole report is in the thread)\n${view.finalText.slice(-RESULT_MAX)}` : view.finalText
229
230      // Mid-turn text is not a report: say so rather than hand it over as one.
231      return { result: view.isEnded ? text : `(still working, latest message so far)\n${text}` }
232    }
233    // A tool call is already an explicit decision, so this presses archive until it has gone through
234    // (the pane asks twice; an orb the pane had already armed needs only one press).
235    if ((await pressArchive(p, id)) === 'armed') await pressArchive(p, id)
236    // A read that started before the archive may still list the orb and write it back when it ends:
237    // wait that read out (or read, if the last one is stale), then take the orb off the list again.
238    await pollNow(p, FRESH_MS)
239    await p.orbs.set(orbs => orbs.filter(o => o.id !== id))
240
241    return { result: `archived ${id}` }
242  }
243
244  if (tool === 'orb_status') {
245    await pollNow(p, FRESH_MS)
246
247    return { result: await statusText(p, { ids: list(e.ids), all: e.all === true }) }
248  }
249
250  if (tool === 'orb_wait') return wait(p, e, signal)
251
252  return { deny: `orbit: unknown tool ${tool}` }
253}
254
hooks/elapsed.tsx 40 lines
1// Copy of skyline/hooks/elapsed.tsx, so orbit's step icons match the bar. Keep it in sync by copying again.
2import type { ClientModule } from 'claude-code'
3
4// How long the current step has run, counted on the surface's own frame clock so the hooks
5// module never ticks. A quick step needs no clock, so nothing shows for the first few seconds.
6
7const started = new WeakSet<object>()
8
9/** "45s", "2m 10s", "1h 5m": seven characters at most, below a hundred hours. */
10export function seconds(ms: number): string {
11  const s = Math.max(0, Math.floor(ms / 1000))
12  if (s >= 3600) return `${Math.floor(s / 3600)}h ${Math.floor(s / 60) % 60}m`
13
14  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
15}
16
17const Elapsed: ClientModule<{ since: number; after?: number; color?: string; width?: number }, number> = (props, surface) => {
18  // The state only counts ticks to ask for a redraw; the time itself is read from the wall
19  // clock, so a step that changes under the same instance counts from its own start. Written
20  // as "at least `after`", a clock the surface withholds (NaN) draws nothing rather than NaN.
21  if (!started.has(surface)) {
22    started.add(surface)
23    surface.every(1000, () => surface.setState((surface.state ?? 0) + 1))
24  }
25  // Until then it draws nothing and takes no columns; from then on exactly the `width` the bar
26  // kept for it, so "59s" turning "1m 0s" neither moves what follows nor overflows the line.
27  const ms = Date.now() - props.since
28  const { Box, Text } = surface.elements
29
30  return ms >= (props.after ?? 3000) ? (
31    <Box width={props.width}>
32      <Text color={props.color ?? 'inactive'}>{seconds(ms)}</Text>
33    </Box>
34  ) : (
35    <Text>{''}</Text>
36  )
37}
38
39export default Elapsed
40
hooks/cli.ts 187 lines
1// The `amp`, `gh` and `jq` calls (the jq pipe runs under /bin/bash, which has pipefail on macOS and Linux alike; poller.ts runs osascript and
2// tools.ts runs sleep). Every call has a timeout, argv is never built by a shell (the jq pipe's
3// script takes everything as positional arguments), and thread ids are checked before they reach
4// argv. Values that reach amp as options use the `--name=value` form, so a value that starts with
5// "-" is never read as a flag of its own.
6//
7// The host follows `$` only into functions declared in the file that holds it, never across an
8// import, so these functions take a `Host` (closures over `$`) instead of `$` itself: the Ports
9// that register.tsx builds are one.
10import type { OrbCheck, OrbPr } from '../types'
11import { parseList, type ListRow } from './model'
12
13export type Host = {
14  run: (argv: readonly string[], init: { timeoutMs: number; stdin?: string }) => Promise<{ exitCode: number; stdout: string; stderr: string; isStdoutTruncated: boolean }>
15  home: () => Promise<string | undefined>
16  exists: (path: string) => Promise<boolean>
17}
18
19export class CliError extends Error {}
20
21/**
22 * amp was launched for `orb_start` and did not say which thread it started: it timed out, or ended
23 * without a thread URL. The orb may be running anyway. `urls` is what it printed that looks like a link.
24 */
25export class StartUnsure extends CliError {
26  constructor(message: string, readonly urls: string[]) {
27    super(message)
28  }
29}
30
31const THREAD = /^T-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/
32export const isThreadId = (id: string) => THREAD.test(id)
33
34// Only a path that was found is remembered: a failed lookup must not pin the bare name forever.
35let amp: string | null = null
36let gh: string | null = null
37let hasJq = false
38
39export function resetBins(): void {
40  amp = null
41  gh = null
42  hasJq = false
43}
44
45const JQ = '/usr/bin/jq'
46
47// The desktop app starts sessions with a bare PATH, so look in the usual install places first.
48async function bin(host: Host, name: 'amp' | 'gh'): Promise<string> {
49  if (name === 'amp' && amp) return amp
50  if (name === 'gh' && gh) return gh
51  let home = ''
52  try {
53    home = (await host.home()) ?? ''
54  } catch {
55    // No HOME: only the fixed paths are tried.
56  }
57  const fixed = name === 'amp' ? [] : ['/opt/homebrew/bin/gh', '/usr/local/bin/gh']
58  const homed = name === 'amp' && home.startsWith('/') ? [`${home}/.local/bin/amp`, `${home}/.amp/bin/amp`] : []
59  for (const path of [...homed, ...fixed]) {
60    if (await host.exists(path).catch(() => false)) {
61      if (name === 'amp') amp = path
62      else gh = path
63
64      return path
65    }
66  }
67
68  return name
69}
70
71async function exec(host: Host, argv: string[], label: string, timeoutMs: number, stdin?: string): Promise<string> {
72  let out
73  try {
74    out = await host.run(argv, stdin === undefined ? { timeoutMs } : { timeoutMs, stdin })
75  } catch (error) {
76    throw new CliError(`${label}: ${error instanceof Error ? error.message : String(error)}`)
77  }
78  if (out.exitCode !== 0) {
79    const first = out.stderr.split('\n').map(line => line.trim()).find(Boolean)
80    throw new CliError(first ?? `${label} exited ${out.exitCode}`)
81  }
82  if (out.isStdoutTruncated) throw new CliError(`${label} output was cut at 4 MiB`)
83
84  return out.stdout
85}
86
87async function run(host: Host, name: 'amp' | 'gh', args: string[], timeoutMs: number, stdin?: string): Promise<string> {
88  return exec(host, [await bin(host, name), ...args], `${name} ${args.slice(0, 2).join(' ')}`, timeoutMs, stdin)
89}
90
91function checkId(id: string): void {
92  if (!isThreadId(id)) throw new CliError(`not an Amp thread id: ${id.slice(0, 60)}`)
93}
94
95export async function ampList(host: Host): Promise<ListRow[]> {
96  return parseList(await run(host, 'amp', ['threads', 'list', '--json', '--limit', '30'], 20_000))
97}
98
99// Real exports run past the 4 MiB cap on `process.run`'s stdout (a 12-message thread was 5.1 MB), so
100// when the system jq is there it shrinks the JSON at the source: long tool output is dropped and
101// long inputs are clipped, and what `parseExport` reads is kept as it was.
102export const EXPORT_FILTER =
103  '{id, created, agentMode, meta: {lastKnownAgentState: .meta.lastKnownAgentState}, messages: [.messages[] | {role, state, content: [(.content // [])[]? | if .type == "text" then (if .text then {type, text} else empty end) elif .type == "tool_use" then {type, id, name, input: ((.input // {}) | with_entries(.value |= (tostring | .[0:300])))} elif .type == "tool_result" then {type, toolUseID, run: {status: .run.status, error: (if .run.error then (.run.error | tostring | .[0:200]) else null end)}} else empty end]}]}'
104
105// Everything is a positional argument; nothing is interpolated into the script.
106const EXPORT_PIPE = `set -o pipefail; "$1" threads export "$2" | ${JQ} -c "$3"`
107
108export async function ampExport(host: Host, id: string): Promise<string> {
109  checkId(id)
110  if (!hasJq) hasJq = await host.exists(JQ).catch(() => false)
111  if (!hasJq) return run(host, 'amp', ['threads', 'export', id], 60_000)
112
113  return exec(host, ['/bin/bash', '-c', EXPORT_PIPE, 'bash', await bin(host, 'amp'), id, EXPORT_FILTER], 'amp threads export', 60_000)
114}
115
116export async function ampUsage(host: Host, id: string): Promise<string> {
117  checkId(id)
118
119  return run(host, 'amp', ['threads', 'usage', id], 30_000)
120}
121
122const PROJECT = /^[\w.][\w.-]*\/[\w.-]+$/
123
124const THREAD_URL = /https:\/\/ampcode\.com\/threads\/(T-[0-9a-f-]+)/
125
126// What amp printed that looks like a link, for a start whose thread is not known: a few, clipped.
127function linksIn(text: string): string[] {
128  return [...new Set(text.match(/https?:\/\/[^\s"'<>]+/g) ?? [])].slice(0, 3).map(url => url.slice(0, 200))
129}
130
131// Once amp is launched, an orb may start whatever comes back, so every failure after the launch is a
132// StartUnsure, never a plain error that invites starting it again. The URL is looked for on stdout,
133// then on stderr.
134export async function ampStart(host: Host, o: { title: string; prompt: string; project: string; mode: string; size: string }): Promise<{ id: string; url: string }> {
135  if (!PROJECT.test(o.project)) throw new CliError(`not an Amp project (owner/name): ${o.project.slice(0, 60)}`)
136  const args = ['-ox', `--project=${o.project}`, `--title=${o.title}`, `--mode=${o.mode}`, `--orb-size=${o.size}`, '--no-archive-after-execute']
137  const argv = [await bin(host, 'amp'), ...args]
138  let out
139  try {
140    out = await host.run(argv, { timeoutMs: 120_000, stdin: o.prompt })
141  } catch (error) {
142    throw new StartUnsure(`amp -ox: ${error instanceof Error ? error.message : String(error)}`, [])
143  }
144  const id = [out.stdout, out.stderr].map(text => THREAD_URL.exec(text)?.[1]).find(found => !!found && isThreadId(found))
145  if (id) return { id, url: `https://ampcode.com/threads/${id}` }
146  const first = out.stderr.split('\n').map(line => line.trim()).find(Boolean)
147  const why = out.exitCode !== 0 ? (first ?? `amp -ox exited ${out.exitCode}`) : 'amp did not print a thread URL'
148
149  throw new StartUnsure(why, linksIn(`${out.stdout}\n${out.stderr}`))
150}
151
152export async function ampSend(host: Host, id: string, message: string): Promise<void> {
153  checkId(id)
154  await run(host, 'amp', ['threads', 'continue', id, '--orb-execute', `--execute=${message}`], 120_000, '')
155}
156
157export async function ampArchive(host: Host, id: string): Promise<void> {
158  checkId(id)
159  await run(host, 'amp', ['threads', 'archive', id], 30_000)
160}
161
162const FAILED = ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE']
163const PASSED = ['SUCCESS', 'SKIPPED', 'NEUTRAL']
164
165export function checksOf(rollup: unknown): { ci: OrbCheck; review: OrbCheck } {
166  const items = Array.isArray(rollup) ? (rollup as Record<string, unknown>[]) : []
167  const verdict = (xs: Record<string, unknown>[]): OrbCheck => {
168    if (xs.length === 0) return 'none'
169    const states = xs.map(x => String(x.conclusion || x.state || x.status || '').toUpperCase())
170    if (states.some(s => FAILED.includes(s))) return 'fail'
171    if (states.every(s => PASSED.includes(s))) return 'pass'
172
173    return 'pending'
174  }
175  const isRabbit = (x: Record<string, unknown>) => /coderabbit/i.test(String(x.name ?? x.context ?? ''))
176
177  return { ci: verdict(items.filter(x => !isRabbit(x))), review: verdict(items.filter(isRabbit)) }
178}
179
180export async function ghPr(host: Host, url: string): Promise<Pick<OrbPr, 'state' | 'ci' | 'review'>> {
181  if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+$/.test(url)) throw new CliError('not a GitHub PR URL')
182  const data = JSON.parse(await run(host, 'gh', ['pr', 'view', url, '--json', 'state,statusCheckRollup'], 20_000)) as { state?: string; statusCheckRollup?: unknown }
183  const state = String(data.state ?? '').toLowerCase()
184
185  return { state: state === 'open' || state === 'merged' || state === 'closed' ? state : 'unknown', ...checksOf(data.statusCheckRollup) }
186}
187
hooks/steps.ts 65 lines
1// Turns one Amp tool call into a step for the drawer: skyline's activity table gives the
2// category (colour and icon) and verb, and shell commands go through skyline's own classifier.
3import type { OrbStep } from '../types'
4import { ACTIVITIES } from './activities'
5import { classifyCommand } from './classify'
6
7const BY_TOOL: Record<string, string> = {
8  apply_patch: 'edit.file',
9  edit_file: 'edit.file',
10  create_file: 'edit.write',
11  Read: 'read.file',
12  read_file: 'read.file',
13  finder: 'search.grep',
14  Grep: 'search.grep',
15  glob: 'search.glob',
16  tool_search: 'plan.tools',
17  skill: 'plan.skill',
18  todo_write: 'plan.todo',
19  web_search: 'web.search',
20  read_web_page: 'web.fetch',
21  Task: 'agents.spawn',
22  code_exec: 'shell.run',
23  shell_command_status: 'shell.process',
24}
25const MAX = 32
26const FALLBACK = ACTIVITIES['other.tool']!
27
28const str = (value: unknown) => (typeof value === 'string' ? value : '')
29
30function clip(text: string): string {
31  const flat = text.replace(/\s+/g, ' ').trim()
32  const points = [...flat]
33
34  return points.length > MAX ? `${points.slice(0, MAX - 1).join('').trimEnd()}…` : flat
35}
36
37function patchTarget(patch: string): string {
38  const found = /\*\*\* (?:Update|Add|Delete) File: (\S+)/.exec(patch)?.[1] ?? ''
39
40  return found.split('/').pop() ?? found
41}
42
43export function stepOf(tool: string, input: Record<string, unknown>, id: string, results: ReadonlyMap<string, boolean>): OrbStep {
44  let activity = BY_TOOL[tool] ?? 'other.tool'
45  let target = ''
46  if (tool === 'shell_command') {
47    const classified = classifyCommand(str(input.command) || str(input.cmd))
48    activity = classified.activity
49    target = classified.target
50  } else if (tool === 'apply_patch') {
51    target = patchTarget(str(input.patch) || str(input.input))
52  } else if (tool === 'skill') {
53    target = str(input.name)
54  } else if (tool === 'Task') {
55    target = str(input.description) || str(input.prompt)
56  } else if (tool === 'finder' || tool === 'Grep') {
57    target = str(input.query) || str(input.pattern)
58  } else if (activity === 'other.tool') {
59    target = tool
60  }
61  const known = ACTIVITIES[activity] ?? FALLBACK
62
63  return { id, category: known.category, verb: known.verb, target: clip(target), isRunning: !results.has(id), isOk: results.get(id) ?? true }
64}
65