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

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.
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.
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.
| Tool | What it does |
|---|---|
orb_start | Starts 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_send | Sends a follow-up message to an orb, whether it is running or finished. |
orb_status | One 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_wait | Waits until orbs end their turn or open a pull request. The default timeout is 30 minutes and the maximum is 2 hours. |
orb_result | Returns an orb's latest final message, up to its last 3,000 characters. |
orb_archive | Archives 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.
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.
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.
amp keeps failing, orbit backs off to one read every 5 minutes and shows the error at the top of the pane.gh, pull requests show – instead of a CI result. Without /usr/bin/jq, threads larger than 4 MiB can't be read.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.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.
| File | What's in it |
|---|---|
hooks/poller.ts | The one timer that reads Amp, plus toasts and notifications |
hooks/scheduler.ts | When to read the list and which threads to fetch |
hooks/cli.ts | The amp, gh and jq calls |
hooks/model.ts, hooks/steps.ts | Turning Amp's output into orbs and steps |
hooks/sphere.ts | The globe and graph SVGs |
hooks/tools.ts, hooks/actions.ts | Claude's tools and the pane's buttons |
types/index.d.ts | The 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>.
MIT
hooks/register.tsx 261 lines1import { 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}
261hooks/actions.ts 85 lines1// 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}
85hooks/activities.ts 125 lines1// 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()
125hooks/icons.ts 177 lines1// 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}
177hooks/model.ts 186 lines1// 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}
186hooks/poller.ts 342 lines1// 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}
342hooks/ports.ts 26 lines1// 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}
26hooks/sphere.ts 89 lines1// 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}
89hooks/tools.ts 254 lines1// 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}
254hooks/elapsed.tsx 40 lines1// 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
40hooks/cli.ts 187 lines1// 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}
187hooks/steps.ts 65 lines1// 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