Side pane showing agents as pixel creatures

A Claude Code mod that shows a side pane with every agent and subagent in your session as a small pixel creature. At a glance you can see who is sleeping, who is working, who needs your answer, who has finished, and who is waiting for an outside event, and you can watch agents hand work to each other.
| State | Look | Meaning |
|---|---|---|
| Idle | head down, floating z | the agent is asleep / has nothing to do |
| In progress | eyes open, moving | the agent is working |
| Pending | arms up, blinking ! | the agent needs you: a question, or an approve/reject |
| Completed | arms in a V, tick | the agent finished |
| Failed / cancelled | fallen figure / boxed dot | the agent ended with an error or was stopped |
| Listening | antenna pulsing, [source] tag | the agent is waiting for an outside event (see limits) |
| Unknown | outline with ? | the mod cannot tell what the agent is doing |
[1m] model and for the 5.5 models).0s and 1s. They drift apart when it ends.+N done row. Agents that need you are never hidden.The mod only watches. It never changes your tools, prompts, permissions or output.
Type this at the prompt of a Claude Code terminal session:
/plugin install colony --marketplace openrory/colony-mod
Then:
y to Add marketplace? (github:openrory/colony-mod).You should see Installed colony. Plugin is now active. and it is active right away, with no restart.
The install command is typed in a terminal session. The desktop app's Code tab answers that the command is not available there, but a mod installed from a terminal at user scope also draws in the desktop app.
git clone https://github.com/openrory/colony-mod.git
claude --plugin-dir ./colony-mod
/colony shows or hides the pane. If you close it yourself it stays closed until you run /colony again.colony: 2 working, 1 needs you, and a toast when something needs you. Widen the terminal, or run /colony, to see the pane.| Option | Default | What it does |
|---|---|---|
| Reduced motion | off | static frames only; no animation timer |
| Retention seconds | 30 | how long finished agents stay in the pane before they are removed |
| Other sessions folder | empty | absolute path of your Claude Code session files (for example /Users/you/.claude/sessions). When set, your other running sessions also appear as figures (their busy or idle state, name and folder on hover). Empty means the mod never reads other sessions |
Other sessions are shown as one figure each. Their own subagents are not visible from here, and a session that has quit can linger until its file is 6 hours old, because nothing in the file says whether it is still alive. The file format is not documented by Claude Code, so this may stop working after an update.
Use the plugin manager of your Claude Code build (/plugin) to update colony from the colony-mod marketplace or to uninstall it. To remove the marketplace as well, remove colony-mod from the marketplaces list in the same menu.
| Message | Cause |
|---|---|
Marketplace file not found at … | the repository you named has no .claude-plugin/marketplace.json; check <owner>/<repo> |
Plugin "colony" not found in marketplace … | the name after /plugin install does not match the plugin name in the marketplace file |
| nothing appears | the terminal may be narrower than the pane needs; widen it or run /colony |
Monitor or RemoteTrigger, and an inbound event (for example from GitHub) labels its source. A scheduled wake-up is the agent's own timer and does not count.claude plugin validate .
claude plugin test .
claude --plugin-dir .
Design documents live in specs/001-agent-colony-pane/.
hooks/register.tsx 342 lines1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface } from 'claude-code'
3
4import { initialColony, reduce, REACT_MS, type Peer, type Signal } from '../src/model'
5import { parsePeer } from '../src/peers'
6import { guarded } from '../src/observe'
7import { layout } from '../src/layout'
8import {
9 animates,
10 attentionOf,
11 CLOCK_MS,
12 contextFor,
13 DEFAULT_RETENTION_SECONDS,
14 nextRecentAgent,
15 pendingIdsOf,
16 readOptions,
17 shouldAutoOpen,
18 shouldToastPending,
19 statusFor,
20} from '../src/runtime'
21import { reconcileFromList, toSignals, type ListedAgent } from '../src/signals'
22import { estimateContextTokens } from '../src/context'
23import { renderPane } from '../src/view'
24
25const PANE = 'colony'
26const PANE_TITLE = 'Colony'
27const HIDDEN_KEY = 'isHiddenByUser'
28const DEFAULT_RETENTION_MS = DEFAULT_RETENTION_SECONDS * 1000
29
30// One colony per session. Its retention is set from options at session.start.
31export const colonyAtom = atom(
32 { plugin: 'colony', key: 'colony' } as const,
33 initialColony(DEFAULT_RETENTION_MS),
34)
35
36// Module state for this load of the mod. Lost on reload, which is acceptable (see T018 notes).
37let reducedMotion = false
38let retentionMs = DEFAULT_RETENTION_MS
39let sessionsDirectory = ''
40let peerTimer: { cancel: () => void } | null = null
41let recentAgentId: string | null = null
42let clockTimer: { cancel: () => void } | null = null
43let firstAutoDone = false
44let lastStatus: string | undefined = undefined
45let lastPendingCount = 0
46
47type Dollar = EngineInterface
48
49// Fold one signal into the colony.
50export function dispatch($: Dollar, signal: Signal): Promise<unknown> {
51 return applySignals($, [signal])
52}
53
54async function applySignals($: Dollar, signals: Signal[]): Promise<void> {
55 if (signals.length === 0) return
56 await update($, colonyAtom, c => signals.reduce(reduce, c))
57}
58
59// The engine's agent list; an unreadable list reads as empty so the colony falls back to events.
60async function listAgents($: Dollar): Promise<ListedAgent[]> {
61 try {
62 return (await $.agent.list()) as ListedAgent[]
63 } catch {
64 return []
65 }
66}
67
68// Reconcile with the engine's list: its status wins over inferred state (contracts/event-mapping.md).
69async function reconcileAgents($: Dollar): Promise<void> {
70 const listed = await listAgents($)
71 await applySignals($, reconcileFromList(listed, await $.clock.now()))
72}
73
74// Run the clock only while something animates (never in reduced motion).
75async function syncClock($: Dollar): Promise<void> {
76 const colony = await read($, colonyAtom)
77 const wanted = animates(colony, reducedMotion)
78 if (wanted && !clockTimer) {
79 clockTimer = $.clock.every(CLOCK_MS, () => {
80 void tick($).catch(() => {})
81 })
82 } else if (!wanted && clockTimer) {
83 clockTimer.cancel()
84 clockTimer = null
85 }
86}
87
88async function tick($: Dollar): Promise<void> {
89 const at = await $.clock.now()
90 await applySignals($, [{ type: 'tick', at }])
91 await syncClock($)
92}
93
94async function paneState($: Dollar): Promise<{ paneOpen: boolean; placed: boolean }> {
95 const pane = (await $.ui.panes()).find(p => p.id === PANE)
96 return { paneOpen: pane !== undefined, placed: pane?.isPlaced ?? false }
97}
98
99// Keeps the status line and the pending toast in step with the colony (narrow-terminal fallback).
100async function refreshPane($: Dollar): Promise<void> {
101 const colony = await read($, colonyAtom)
102 const { paneOpen, placed } = await paneState($)
103 const status = statusFor(colony, paneOpen, placed)
104 if (status !== lastStatus) {
105 $.ui.status(status)
106 lastStatus = status
107 }
108 const pendingCount = pendingIdsOf(colony).length
109 if (
110 shouldToastPending({ pendingCount, previousPendingCount: lastPendingCount, paneOpen, placed })
111 ) {
112 const who = Object.values(colony.agents).find(a => a.state === 'pending')
113 $.ui.toast(`colony: ${who?.label ?? 'an agent'} needs you`)
114 }
115 lastPendingCount = pendingCount
116}
117
118// Other sessions: read the session files in the folder the person named. Nothing is read unless the
119// option is set; a file that cannot be read or parsed is skipped.
120const PEER_POLL_MS = 5000
121
122async function readPeers($: Dollar): Promise<Peer[]> {
123 const now = await $.clock.now()
124 const self = await Promise.resolve($.session.id()).catch(() => null)
125 const peers: Peer[] = []
126 for (const entry of await $.fs.list(sessionsDirectory)) {
127 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
128 try {
129 const text = await $.fs.read(`${sessionsDirectory.replace(/\/$/, '')}/${entry.name}`)
130 const peer = parsePeer(typeof text === 'string' ? text : '', self ?? null, now)
131 if (peer) peers.push(peer)
132 } catch {
133 // Unreadable or vanished between listing and reading: skip it.
134 }
135 }
136 return peers
137}
138
139async function pollPeers($: Dollar): Promise<void> {
140 if (!(await paneState($)).paneOpen) return stopPeers()
141 try {
142 const peers = await readPeers($)
143 await applySignals($, [{ type: 'peers', peers, at: await $.clock.now() }])
144 await syncClock($)
145 } catch {
146 // The folder is missing or unreadable: no other sessions are shown.
147 }
148}
149
150function stopPeers(): void {
151 peerTimer?.cancel()
152 peerTimer = null
153}
154
155async function startPeers($: Dollar): Promise<void> {
156 if (!sessionsDirectory || peerTimer) return
157 peerTimer = $.clock.every(PEER_POLL_MS, () => {
158 void pollPeers($).catch(() => {})
159 })
160 await pollPeers($)
161}
162
163async function showPane($: Dollar): Promise<void> {
164 await reconcileAgents($)
165 await $.ui.open({ id: PANE, title: PANE_TITLE })
166 await startPeers($)
167 await refreshPane($)
168}
169
170// Auto-open the first time a subagent appears or an agent needs the user (FR-001).
171async function autoOpen($: Dollar): Promise<void> {
172 if (firstAutoDone) return
173 const colony = await read($, colonyAtom)
174 const attention = attentionOf(colony)
175 // Nothing to show: no engine calls on the hot path (every tool call passes through here).
176 if (attention === 0) return
177 const { paneOpen } = await paneState($)
178 const hidden = Boolean(await $.store.get(HIDDEN_KEY))
179 if (!shouldAutoOpen({ attention, hidden, paneOpen, firstAutoDone })) return
180 firstAutoDone = true
181 await showPane($)
182}
183
184// Apply one event's signals, then settle the clock and the pane.
185async function fold($: Dollar, eventName: string, e: Record<string, unknown>): Promise<void> {
186 const colony = await read($, colonyAtom)
187 const at = await $.clock.now()
188 // A reaction flash is motion, so reduced motion skips it (the figure goes straight to in-progress).
189 const signals = toSignals(eventName, e, contextFor(colony, recentAgentId, at, reducedMotion ? 0 : REACT_MS))
190 recentAgentId = nextRecentAgent(recentAgentId, eventName, e)
191 await applySignals($, signals)
192 // Reduced motion never runs the clock, so retention expiry and easing ride on events instead.
193 if (reducedMotion) await applySignals($, [{ type: 'tick', at }])
194 await autoOpen($)
195 await syncClock($)
196 await refreshPane($)
197}
198
199// Observer hooks (FR-016): fold the event into the colony, then pass it on unchanged, even when
200// folding fails. Each hook runs its task through guarded() (src/observe.ts, the body of observe()).
201// The engine follows $ only into functions declared in this file, so $ stays inside these hooks.
202const foldEvent = (eventName: string, $: Dollar, e: unknown) =>
203 fold($, eventName, e as Record<string, unknown>)
204
205// The engine's list row for a subagent, if it has one yet.
206async function listedRow($: Dollar, id: unknown): Promise<ListedAgent | undefined> {
207 return (await listAgents($)).find(row => row.id === id)
208}
209
210async function sessionStart($: Dollar, e: unknown): Promise<void> {
211 await $.command.register({
212 name: 'colony',
213 description: 'Show or hide the agent colony pane',
214 })
215 await update($, colonyAtom, c => ({ ...c, retentionMs }))
216 await fold($, 'session.start', e as Record<string, unknown>)
217 await reconcileAgents($)
218 // The pane opens on the first pending or first subagent (FR-001), or when the person runs /colony.
219}
220
221async function turnComplete($: Dollar, e: unknown): Promise<void> {
222 await foldEvent('turn.complete', $, e)
223 await reconcileAgents($)
224}
225
226// agent.spawn answers the new agent's id only after the spawn, so the id comes from the result.
227async function spawnSeen($: Dollar, e: unknown, out: unknown): Promise<void> {
228 const agentId = (out as { agentId?: unknown } | undefined)?.agentId
229 if (typeof agentId !== 'string') return
230 await fold($, 'agent.spawn', { ...(e as Record<string, unknown>), agentId })
231}
232
233// SubagentStart and SubagentStop carry no parent or status: the engine's list supplies them.
234async function subagentStart($: Dollar, e: unknown): Promise<void> {
235 const payload = e as Record<string, unknown>
236 const row = await listedRow($, payload.agent_id)
237 await fold($, 'classic.SubagentStart', {
238 ...payload,
239 parentId: row?.parentId,
240 description: row?.description ?? payload.description,
241 })
242}
243
244async function subagentStop($: Dollar, e: unknown): Promise<void> {
245 const payload = e as Record<string, unknown>
246 const row = await listedRow($, payload.agent_id)
247 await fold($, 'classic.SubagentStop', {
248 ...payload,
249 parentId: row?.parentId,
250 status: row?.status,
251 })
252}
253
254// Messages in an agent's transcript when its context was last estimated; throttles the estimate.
255const estimatedAt = new Map<string, number>()
256const ESTIMATE_EVERY = 4
257
258// A model request is about to go out. Count it for its agent, and now and then estimate that agent's
259// context from its transcript. Light on purpose: no pane or status work, this fires on every step.
260async function stepSeen($: Dollar, e: Record<string, unknown>): Promise<void> {
261 const at = await $.clock.now()
262 const colony = await read($, colonyAtom)
263 await applySignals($, toSignals('turn.step', e, contextFor(colony, recentAgentId, at)))
264 const id = typeof e.agentId === 'string' && e.agentId ? e.agentId : 'main'
265 const count = typeof e.messageCount === 'number' ? e.messageCount : 0
266 if (count - (estimatedAt.get(id) ?? -ESTIMATE_EVERY) < ESTIMATE_EVERY) return
267 estimatedAt.set(id, count)
268 try {
269 const args = id === 'main' ? { as: 'api' as const } : { agentId: id, as: 'api' as const }
270 const messages = await $.session.messages(args as never)
271 if (!Array.isArray(messages)) return
272 await applySignals($, [
273 { type: 'agent-meta', id, contextTokens: estimateContextTokens(messages), at },
274 ])
275 } catch {
276 // The transcript can be unreadable (a finished agent, another process): the estimate just waits.
277 }
278}
279
280async function uiClosed($: Dollar, e: unknown): Promise<void> {
281 // Closing the pane with the person's own mark or key counts as hiding it (FR-001).
282 const close = e as { id: string; origin: { kind: string } }
283 if (close.id === PANE) stopPeers()
284 if (close.id === PANE && close.origin.kind === 'person') await $.store.set(HIDDEN_KEY, true)
285}
286
287export const register: Register = (on, options) => {
288 const parsed = readOptions(options as Readonly<Record<string, unknown>>)
289 reducedMotion = parsed.reducedMotion
290 retentionMs = parsed.retentionMs
291 sessionsDirectory = parsed.sessionsDirectory
292
293 on('session.start', ($, e, next) => guarded(() => sessionStart($, e), () => next(e)))
294
295 on('command.run', { command: 'colony' }, async $ => {
296 const paneOpen = (await $.ui.panes()).some(p => p.id === PANE)
297 if (paneOpen) {
298 await $.store.set(HIDDEN_KEY, true)
299 await $.ui.close({ id: PANE })
300 return { text: 'Colony pane hidden.' }
301 }
302 await $.store.set(HIDDEN_KEY, false)
303 await showPane($)
304 return { text: 'Colony pane shown.' }
305 })
306
307 on('ui.close', ($, e, next) => guarded(() => uiClosed($, e), () => next(e)))
308
309 on('turn.start', ($, e, next) => guarded(() => foldEvent('turn.start', $, e), () => next(e)))
310 on('turn.complete', ($, e, next) => guarded(() => turnComplete($, e), () => next(e)))
311 // Streaming event: observe the request, then pass the stream through untouched.
312 on('turn.step', async function* ($, e, next) {
313 await guarded(() => stepSeen($, e as unknown as Record<string, unknown>), () => undefined)
314 return yield* next(e)
315 })
316 on('tool.call', ($, e, next) => guarded(() => foldEvent('tool.call', $, e), () => next(e)))
317 on('classic.PermissionRequest', ($, e, next) => guarded(() => foldEvent('classic.PermissionRequest', $, e), () => next(e)))
318 on('classic.Elicitation', ($, e, next) => guarded(() => foldEvent('classic.Elicitation', $, e), () => next(e)))
319 on('classic.ElicitationResult', ($, e, next) => guarded(() => foldEvent('classic.ElicitationResult', $, e), () => next(e)))
320 on('classic.Notification', ($, e, next) => guarded(() => foldEvent('classic.Notification', $, e), () => next(e)))
321 on('prompt.submit', ($, e, next) => guarded(() => foldEvent('prompt.submit', $, e), () => next(e)))
322
323 on('agent.spawn', async ($, e, next) => {
324 const out = await next(e)
325 await guarded(() => spawnSeen($, e, out), () => out)
326 return out
327 })
328 on('classic.SubagentStart', ($, e, next) => guarded(() => subagentStart($, e), () => next(e)))
329 on('classic.SubagentStop', ($, e, next) => guarded(() => subagentStop($, e), () => next(e)))
330 on('classic.TeammateIdle', ($, e, next) => guarded(() => foldEvent('classic.TeammateIdle', $, e), () => next(e)))
331 on('session.send', ($, e, next) => guarded(() => foldEvent('session.send', $, e), () => next(e)))
332 on('session.receive', ($, e, next) => guarded(() => foldEvent('session.receive', $, e), () => next(e)))
333
334 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
335 const { Box, Text } = $.ui.resolve(e)
336 const colony = await read($, colonyAtom)
337 const viewport = { rows: e.viewport?.rows ?? 24, columns: e.viewport?.columns ?? 80 }
338 const scene = layout(colony, viewport, reducedMotion ? 0 : colony.tick)
339 return renderPane(scene, { Box, Text })
340 })
341}
342src/model.ts 536 lines1import type { Agent, AgentState, Colony, Exchange, Usage } from '../types'
2import { resolveState } from './priority'
3
4export const MAIN_ID = 'main'
5// Ids of figures that stand for another session (not an agent of this one) start with this.
6export const PEER_PREFIX = 'session:'
7// How long a spawn / message / result burst keeps its exchange alive (ms).
8export const BURST_MS = 1500
9// Silence after which a working agent with no list status becomes unknown (ms).
10export const SILENCE_MS = 60_000
11// How long main keeps its completed look before it returns to idle (ms).
12export const MAIN_DONE_MS = 5_000
13// Largest proximity shift (columns) an exchange pulls two figures together by (FR-008).
14export const MAX_SHIFT = 2
15// How long a listening figure shows its reaction after an inbound event (ms, FR-011).
16export const REACT_MS = 1000
17// Furthest a working figure drifts from its home cell (columns), and how fast (columns per tick).
18export const WANDER_MAX = 2
19export const WANDER_STEP = 0.25
20
21export type Peer = { id: string; label: string; state: AgentState; note: string }
22
23export type ExchangeReason = Exchange['reasons'][number]
24
25export type Signal =
26 | { type: 'agent-added'; id: string; label: string; parentId: string | null; at: number }
27 | { type: 'agent-state'; id: string; state: AgentState; listenSource?: string | null; at: number }
28 | {
29 type: 'agent-ended'
30 id: string
31 state: 'completed' | 'failed' | 'cancelled'
32 at: number
33 }
34 // An inbound event reaches a figure: a reaction flash, then in-progress (flashMs 0 skips the flash).
35 | { type: 'agent-react'; id: string; source: string; flashMs: number; at: number }
36 | {
37 type: 'exchange-open'
38 fromId: string
39 toId: string
40 reason: ExchangeReason
41 at: number
42 }
43 | {
44 type: 'exchange-close'
45 fromId: string
46 toId: string
47 reason: ExchangeReason
48 at: number
49 }
50 // Facts about an agent that arrive apart from its state: model, origin, context estimate.
51 | {
52 type: 'agent-meta'
53 id: string
54 at: number
55 model?: string
56 addressable?: boolean
57 independent?: boolean
58 spawnedBy?: string | null
59 contextTokens?: number
60 }
61 // A finished turn's token counts, added to the agent's total; its run of steps starts over.
62 | { type: 'agent-usage'; id: string; usage: Usage; at: number }
63 // One more model request in the agent's current run.
64 | { type: 'agent-step'; id: string; model?: string; at: number }
65 // The other sessions on this machine, as the pane last read them; replaces the figures for them.
66 | { type: 'peers'; peers: Peer[]; at: number }
67 | { type: 'tick'; at: number }
68 | {
69 type: 'reconcile'
70 at: number
71 listed: Array<{ id: string; label: string; parentId: string | null; state: AgentState }>
72 }
73
74export const TERMINAL_STATES: ReadonlyArray<AgentState> = ['completed', 'failed', 'cancelled']
75const WORKING_STATES: ReadonlyArray<AgentState> = ['in-progress', 'pending', 'listening']
76const BURST_REASONS: ReadonlyArray<ExchangeReason> = ['spawn', 'message', 'result']
77
78export function initialColony(retentionMs: number): Colony {
79 return { agents: {}, exchanges: {}, tick: 0, retentionMs }
80}
81
82export function exchangeId(fromId: string, toId: string): string {
83 return `${fromId}>${toId}`
84}
85
86// An agent no list names (an engine fork such as compaction or memory, or a workflow's agent) is
87// known only by its id; the prefix tells it from a subagent.
88function unlistedLabel(id: string): string {
89 return `bg ${id}`
90}
91
92function newBackgroundAgent(id: string, at: number): Agent {
93 return { ...newAgent(id, unlistedLabel(id), null, at), background: true }
94}
95
96function newAgent(id: string, label: string, parentId: string | null, at: number): Agent {
97 return {
98 id,
99 label,
100 parentId,
101 state: 'idle',
102 listenSource: null,
103 reactUntil: null,
104 startedAt: at,
105 endedAt: null,
106 lastEventAt: at,
107 listed: false,
108 shift: 0,
109 model: null,
110 usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
111 steps: 0,
112 contextTokens: null,
113 wander: 0,
114 wanderTarget: 0,
115 addressable: false,
116 independent: false,
117 background: false,
118 spawnedBy: null,
119 note: null,
120 }
121}
122
123export function isPeer(agent: Pick<Agent, 'id'>): boolean {
124 return agent.id.startsWith(PEER_PREFIX)
125}
126
127function isFinishedSubagent(agent: Agent): boolean {
128 return agent.id !== MAIN_ID && TERMINAL_STATES.includes(agent.state)
129}
130
131function setAgents(colony: Colony, agents: Record<string, Agent>): Colony {
132 return { ...colony, agents }
133}
134
135// The state a figure is drawn in (FR-013): a reaction flash outranks the stored state, and a listening
136// registration shows only while the agent is otherwise at rest (FR-010: an idle agent); pending outranks both.
137const AT_REST: ReadonlyArray<AgentState> = ['idle', 'completed', 'unknown']
138export function shownStateOf(agent: Agent): AgentState {
139 if (agent.reactUntil !== null) return resolveState([agent.state, 'listening'])
140 if (agent.listenSource !== null && AT_REST.includes(agent.state)) return 'listening'
141 return agent.state
142}
143
144// Silent working agents become unknown (FR-018). Terminal and idle agents are not "working".
145// The main agent is never judged by silence (a long tool call is not unknown), and an agent the
146// engine's list named at the last reconcile is trusted to that status instead of silence.
147function markSilent(agents: Record<string, Agent>, at: number): Record<string, Agent> {
148 let out = agents
149 for (const agent of Object.values(agents)) {
150 if (agent.id === MAIN_ID || agent.listed || isPeer(agent)) continue
151 if (WORKING_STATES.includes(agent.state) && at - agent.lastEventAt >= SILENCE_MS) {
152 if (out === agents) out = { ...agents }
153 out[agent.id] = { ...agent, state: 'unknown' }
154 }
155 }
156 return out
157}
158
159// Burst reasons (spawn, message, result) last only while their burst animates.
160function pruneBursts(
161 reasons: ExchangeReason[],
162 burstUntil: number,
163 at: number,
164): ExchangeReason[] {
165 if (burstUntil > at) return reasons
166 return reasons.filter(r => !BURST_REASONS.includes(r))
167}
168
169function dropDanglingExchanges(
170 exchanges: Record<string, Exchange>,
171 agents: Record<string, Agent>,
172): Record<string, Exchange> {
173 const out: Record<string, Exchange> = {}
174 for (const [key, ex] of Object.entries(exchanges)) {
175 if (agents[ex.fromId] && agents[ex.toId]) out[key] = ex
176 }
177 return out
178}
179
180// Target proximity shift for one agent (0, 1 or 2 columns), from the exchanges it is in.
181// A live burst pulls it two columns, a plain wait one, nothing none.
182function targetShiftOf(agentId: string, exchanges: Record<string, Exchange>): number {
183 let target = 0
184 for (const ex of Object.values(exchanges)) {
185 if (ex.fromId !== agentId && ex.toId !== agentId) continue
186 if (ex.reasons.length === 0) continue
187 const bursting = ex.reasons.some(r => BURST_REASONS.includes(r))
188 target = Math.max(target, bursting ? MAX_SHIFT : 1)
189 }
190 return target
191}
192
193// Each figure moves one column toward its target per tick, so the pull eases in and back out.
194function easeShifts(agents: Record<string, Agent>, exchanges: Record<string, Exchange>): Record<string, Agent> {
195 let out = agents
196 for (const agent of Object.values(agents)) {
197 const target = targetShiftOf(agent.id, exchanges)
198 const shift = agent.shift < target ? agent.shift + 1 : agent.shift > target ? agent.shift - 1 : agent.shift
199 if (shift === agent.shift) continue
200 if (out === agents) out = { ...agents }
201 out[agent.id] = { ...agent, shift }
202 }
203 return out
204}
205
206// A small stable hash, so wandering is deterministic and the reducer stays pure.
207function hashOf(text: string, n: number): number {
208 let h = 2166136261 ^ n
209 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619)
210 return (h >>> 0) % 1000
211}
212
213const WANDERS: ReadonlyArray<AgentState> = ['in-progress', 'listening']
214
215// Working figures drift to a new spot every so often; resting ones, and any in an exchange, drift home.
216function wanderAgents(
217 agents: Record<string, Agent>,
218 exchanges: Record<string, Exchange>,
219 tick: number,
220): Record<string, Agent> {
221 let out = agents
222 for (const agent of Object.values(agents)) {
223 if (agent.id === MAIN_ID) continue
224 const talking = targetShiftOf(agent.id, exchanges) > 0
225 const roams = WANDERS.includes(shownStateOf(agent)) && !talking
226 let target = agent.wanderTarget
227 if (!roams) target = 0
228 else if ((tick + hashOf(agent.id, 0)) % 16 === 0) {
229 target = (hashOf(agent.id, tick) % (2 * WANDER_MAX + 1)) - WANDER_MAX
230 }
231 const gap = target - agent.wander
232 const wander = Math.abs(gap) <= WANDER_STEP ? target : agent.wander + Math.sign(gap) * WANDER_STEP
233 if (wander === agent.wander && target === agent.wanderTarget) continue
234 if (out === agents) out = { ...agents }
235 out[agent.id] = { ...agent, wander, wanderTarget: target }
236 }
237 return out
238}
239
240function isEasing(ex: Exchange, agents: Record<string, Agent>): boolean {
241 return (agents[ex.fromId]?.shift ?? 0) > 0 || (agents[ex.toId]?.shift ?? 0) > 0
242}
243
244export function reduce(colony: Colony, signal: Signal): Colony {
245 switch (signal.type) {
246 case 'agent-added': {
247 const existing = colony.agents[signal.id]
248 if (existing) {
249 // Idempotent: a repeat add refreshes the label only.
250 return setAgents(colony, {
251 ...colony.agents,
252 [signal.id]: {
253 ...existing,
254 label: signal.label,
255 parentId: signal.parentId ?? existing.parentId,
256 lastEventAt: signal.at,
257 },
258 })
259 }
260 return setAgents(colony, {
261 ...colony.agents,
262 [signal.id]: newAgent(signal.id, signal.label, signal.parentId, signal.at),
263 })
264 }
265
266 case 'agent-state': {
267 const known = colony.agents[signal.id]
268 // A finished subagent stays finished: a late event must not revive it (it would linger as a ghost).
269 if (known && isFinishedSubagent(known) && signal.state !== 'listening') return colony
270 const existing = known ?? newBackgroundAgent(signal.id, signal.at)
271 // A listening registration is an overlay: it sets the source and leaves the stored state alone.
272 if (signal.state === 'listening') {
273 const registered: Agent = {
274 ...existing,
275 listenSource: signal.listenSource ?? 'external',
276 lastEventAt: signal.at,
277 }
278 return setAgents(colony, { ...colony.agents, [signal.id]: registered })
279 }
280 const next: Agent = {
281 ...existing,
282 state: signal.state,
283 // Re-entering a live state clears the end marker; the retention timer restarts on the next end.
284 endedAt: TERMINAL_STATES.includes(signal.state) ? (existing.endedAt ?? signal.at) : null,
285 lastEventAt: signal.at,
286 }
287 return setAgents(colony, { ...colony.agents, [signal.id]: next })
288 }
289
290 case 'agent-react': {
291 const existing = colony.agents[signal.id]
292 // Pending outranks listening (FR-013): an agent waiting on the user does not react.
293 if (existing?.state === 'pending') return colony
294 if (existing && isFinishedSubagent(existing)) return colony
295 const agent = existing ?? newBackgroundAgent(signal.id, signal.at)
296 return setAgents(colony, {
297 ...colony.agents,
298 [signal.id]: {
299 ...agent,
300 listenSource: signal.source,
301 reactUntil: signal.at + signal.flashMs,
302 endedAt: null,
303 lastEventAt: signal.at,
304 },
305 })
306 }
307
308 case 'agent-ended': {
309 const existing =
310 colony.agents[signal.id] ?? newBackgroundAgent(signal.id, signal.at)
311 return setAgents(colony, {
312 ...colony.agents,
313 [signal.id]: {
314 ...existing,
315 // A reusable agent that finishes a task rests; it is not removed.
316 state: existing.addressable && signal.state === 'completed' ? 'idle' : signal.state,
317 listenSource: null,
318 reactUntil: null,
319 // A repeat stop keeps the first end time, so it cannot extend retention.
320 endedAt:
321 existing.addressable && signal.state === 'completed'
322 ? null
323 : isFinishedSubagent(existing)
324 ? (existing.endedAt ?? signal.at)
325 : signal.at,
326 lastEventAt: signal.at,
327 },
328 })
329 }
330
331 case 'agent-meta': {
332 const existing = colony.agents[signal.id]
333 if (!existing) return colony
334 return setAgents(colony, {
335 ...colony.agents,
336 [signal.id]: {
337 ...existing,
338 model: signal.model ?? existing.model,
339 addressable: signal.addressable ?? existing.addressable,
340 independent: signal.independent ?? existing.independent,
341 spawnedBy: signal.spawnedBy === undefined ? existing.spawnedBy : signal.spawnedBy,
342 contextTokens: signal.contextTokens ?? existing.contextTokens,
343 },
344 })
345 }
346
347 case 'agent-usage': {
348 const existing = colony.agents[signal.id]
349 if (!existing) return colony
350 const u = existing.usage
351 return setAgents(colony, {
352 ...colony.agents,
353 [signal.id]: {
354 ...existing,
355 usage: {
356 input: u.input + signal.usage.input,
357 output: u.output + signal.usage.output,
358 cacheRead: u.cacheRead + signal.usage.cacheRead,
359 cacheWrite: u.cacheWrite + signal.usage.cacheWrite,
360 },
361 // One step is the exact window (input side of the last response); more is an estimate the
362 // caller supplies through agent-meta, so only the single-step case is settled here.
363 contextTokens:
364 existing.steps <= 1
365 ? signal.usage.input + signal.usage.cacheRead + signal.usage.cacheWrite + signal.usage.output
366 : existing.contextTokens,
367 steps: 0,
368 },
369 })
370 }
371
372 case 'agent-step': {
373 const existing = colony.agents[signal.id]
374 if (!existing) return colony
375 return setAgents(colony, {
376 ...colony.agents,
377 [signal.id]: { ...existing, steps: existing.steps + 1, model: signal.model ?? existing.model },
378 })
379 }
380
381 case 'peers': {
382 const agents: Record<string, Agent> = {}
383 for (const [id, agent] of Object.entries(colony.agents)) if (!isPeer(agent)) agents[id] = agent
384 for (const peer of signal.peers) {
385 const existing = colony.agents[peer.id] ?? newAgent(peer.id, peer.label, null, signal.at)
386 agents[peer.id] = {
387 ...existing,
388 label: peer.label,
389 state: peer.state,
390 independent: true,
391 note: peer.note,
392 lastEventAt: signal.at,
393 }
394 }
395 return setAgents(colony, agents)
396 }
397
398 case 'exchange-open': {
399 const id = exchangeId(signal.fromId, signal.toId)
400 const existing = colony.exchanges[id]
401 const reasons = new Set(existing?.reasons ?? [])
402 reasons.add(signal.reason)
403 const isBurst = BURST_REASONS.includes(signal.reason)
404 const burstUntil = isBurst
405 ? Math.max(existing?.burstUntil ?? 0, signal.at + BURST_MS)
406 : (existing?.burstUntil ?? 0)
407 // A message to a finished subagent resumes it (a reusable agent), unlike a stray late event.
408 const target = colony.agents[signal.toId]
409 const agents =
410 signal.reason === 'message' && target && isFinishedSubagent(target)
411 ? { ...colony.agents, [target.id]: { ...target, state: 'in-progress' as const, endedAt: null, lastEventAt: signal.at } }
412 : colony.agents
413 return {
414 ...colony,
415 agents,
416 exchanges: {
417 ...colony.exchanges,
418 [id]: {
419 id,
420 fromId: signal.fromId,
421 toId: signal.toId,
422 reasons: [...reasons],
423 burstUntil,
424 },
425 },
426 }
427 }
428
429 case 'exchange-close': {
430 const id = exchangeId(signal.fromId, signal.toId)
431 const existing = colony.exchanges[id]
432 if (!existing) return colony
433 const reasons = pruneBursts(
434 existing.reasons.filter(r => r !== signal.reason),
435 existing.burstUntil,
436 signal.at,
437 )
438 // Dropped only once no reason is left and both figures are back at rest (easing finishes first).
439 if (reasons.length === 0 && !isEasing(existing, colony.agents)) {
440 const { [id]: _removed, ...rest } = colony.exchanges
441 return { ...colony, exchanges: rest }
442 }
443 return {
444 ...colony,
445 exchanges: { ...colony.exchanges, [id]: { ...existing, reasons } },
446 }
447 }
448
449 case 'tick': {
450 // A reaction flash that has run its course ends the listening: the agent goes to in-progress.
451 let agents: Record<string, Agent> = colony.agents
452 for (const agent of Object.values(colony.agents)) {
453 if (agent.reactUntil === null || signal.at < agent.reactUntil) continue
454 if (agents === colony.agents) agents = { ...colony.agents }
455 agents[agent.id] = {
456 ...agent,
457 state: agent.state === 'pending' ? 'pending' : 'in-progress',
458 listenSource: null,
459 reactUntil: null,
460 }
461 }
462 agents = markSilent(agents, signal.at)
463 // Main's completed look fades back to idle after MAIN_DONE_MS (main is never removed).
464 const main = agents[MAIN_ID]
465 if (
466 main &&
467 main.state === 'completed' &&
468 main.endedAt !== null &&
469 signal.at - main.endedAt >= MAIN_DONE_MS
470 ) {
471 agents = { ...agents, [MAIN_ID]: { ...main, state: 'idle', endedAt: null } }
472 }
473 for (const agent of Object.values(agents)) {
474 // FR-015: every terminal subagent (completed, failed, cancelled) is removed; main never is.
475 const expired =
476 agent.id !== MAIN_ID &&
477 !isPeer(agent) &&
478 !agent.addressable &&
479 TERMINAL_STATES.includes(agent.state) &&
480 agent.endedAt !== null &&
481 signal.at - agent.endedAt >= colony.retentionMs
482 if (expired) {
483 if (agents === colony.agents) agents = { ...colony.agents }
484 delete agents[agent.id]
485 }
486 }
487 let exchanges: Record<string, Exchange> = {}
488 for (const [key, ex] of Object.entries(colony.exchanges)) {
489 const reasons = pruneBursts(ex.reasons, ex.burstUntil, signal.at)
490 exchanges[key] = reasons.length === ex.reasons.length ? ex : { ...ex, reasons }
491 }
492 exchanges = dropDanglingExchanges(exchanges, agents)
493 agents = easeShifts(agents, exchanges)
494 agents = wanderAgents(agents, exchanges, colony.tick)
495 // An exchange with no reason left is kept only while its figures are still easing back.
496 for (const [key, ex] of Object.entries(exchanges)) {
497 if (ex.reasons.length === 0 && !isEasing(ex, agents)) {
498 if (exchanges === colony.exchanges) exchanges = { ...colony.exchanges }
499 delete exchanges[key]
500 }
501 }
502 return { ...colony, agents, exchanges, tick: colony.tick + 1 }
503 }
504
505 case 'reconcile': {
506 // The engine's list status wins over inferred state (contracts/event-mapping.md).
507 const agents: Record<string, Agent> = { ...colony.agents }
508 const listedIds = new Set<string>()
509 for (const item of signal.listed) {
510 listedIds.add(item.id)
511 const existing = agents[item.id] ?? newAgent(item.id, item.label, item.parentId, signal.at)
512 const state = existing.addressable && item.state === 'completed' ? 'idle' : item.state
513 agents[item.id] = {
514 ...existing,
515 label: item.label,
516 parentId: item.parentId,
517 state,
518 listed: true,
519 background: false,
520 listenSource: existing.listenSource,
521 endedAt: TERMINAL_STATES.includes(state) ? (existing.endedAt ?? signal.at) : null,
522 lastEventAt: signal.at,
523 }
524 }
525 // Agents the list does not name are judged by silence alone.
526 const unlisted: Record<string, Agent> = {}
527 for (const [id, agent] of Object.entries(agents)) {
528 if (!listedIds.has(id)) unlisted[id] = { ...agent, listed: false }
529 }
530 const silent = markSilent(unlisted, signal.at)
531 for (const [id, agent] of Object.entries(silent)) agents[id] = agent
532 return setAgents(colony, agents)
533 }
534 }
535}
536src/peers.ts 48 lines1// Other Claude sessions on this machine, read from the folder of session files the person points the
2// mod at (option `sessionsDirectory`). Session-level only: another session's own subagents are not
3// visible from here. Pure parsing; hooks/register.tsx does the reading.
4import { PEER_PREFIX, type Peer } from './model'
5import type { AgentState } from '../types'
6
7// A session file older than this is taken as a session that has gone (nothing says it is alive).
8export const PEER_STALE_MS = 6 * 60 * 60 * 1000
9
10function ago(ms: number): string {
11 const minutes = Math.max(0, Math.round(ms / 60_000))
12 if (minutes < 1) return 'just now'
13 if (minutes < 90) return `${minutes}m ago`
14 return `${Math.round(minutes / 60)}h ago`
15}
16
17function stateOf(status: unknown): AgentState {
18 if (status === 'busy') return 'in-progress'
19 if (status === 'idle') return 'idle'
20 return 'unknown'
21}
22
23// One session file's text -> a peer, or null when it is not a usable record.
24export function parsePeer(text: string, selfId: string | null, now: number): Peer | null {
25 let raw: unknown
26 try {
27 raw = JSON.parse(text)
28 } catch {
29 return null
30 }
31 if (!raw || typeof raw !== 'object') return null
32 const r = raw as Record<string, unknown>
33 const sessionId = typeof r.sessionId === 'string' ? r.sessionId : ''
34 if (!sessionId || sessionId === selfId) return null
35 const updatedAt = typeof r.updatedAt === 'number' ? r.updatedAt : typeof r.startedAt === 'number' ? r.startedAt : 0
36 if (updatedAt === 0 || now - updatedAt > PEER_STALE_MS) return null
37 const cwd = typeof r.cwd === 'string' ? r.cwd : ''
38 const folder = cwd.split('/').filter(Boolean).pop() ?? ''
39 const name = typeof r.name === 'string' && r.name ? r.name : folder || sessionId.slice(0, 8)
40 const where = cwd ? ` in ${cwd}` : ''
41 return {
42 id: `${PEER_PREFIX}${sessionId}`,
43 label: name,
44 state: stateOf(r.status),
45 note: `another session${where}, updated ${ago(now - updatedAt)}`,
46 }
47}
48src/observe.ts 26 lines1// Observer wrapper (FR-016): the mod never changes what a hook passes on.
2// The task may update colony state; whatever it does, the hook returns passOn() unchanged.
3
4export type Passthrough<D, E, R> = (dollar: D, e: E, next: (e: E) => R | Promise<R>) => Promise<R>
5
6// Runs the observer task, swallowing any error, then answers with passOn() (normally next(e)).
7export async function guarded<R>(
8 task: () => unknown,
9 passOn: () => R | Promise<R>,
10 onError: (error: unknown) => void = () => {},
11): Promise<R> {
12 try {
13 await task()
14 } catch (error) {
15 onError(error)
16 }
17 return passOn()
18}
19
20export function observe<D, E, R>(
21 handler: (dollar: D, e: E) => unknown,
22 onError: (error: unknown) => void = () => {},
23): Passthrough<D, E, R> {
24 return (dollar, e, next) => guarded(() => handler(dollar, e), () => next(e), onError)
25}
26src/layout.ts 365 lines1// Colony + viewport + tick -> Scene (data-model.md). Pure: positions, labels, frames and streams.
2import type { Agent, AgentState, Colony, Exchange } from '../types'
3import { isPeer, MAIN_ID, MAX_SHIFT, shownStateOf } from './model'
4import { frameOf } from './sprites'
5import { detailsOf, fillOf, pixelsOf, tierOf, type Tier } from './tiers'
6
7// The smallest figure: an 8-column sprite (4 text rows) with one label row beneath it. Bigger models
8// draw bigger (tiers.ts); these are the floor, and the viewport size below which only a summary fits.
9export const FIGURE_WIDTH = 8
10export const FIGURE_HEIGHT = 5
11// Columns between neighbouring cells in a row. Two figures pulled toward each other by MAX_SHIFT and
12// wandering toward each other by WANDER_MAX still keep columns between them (and layout() pushes
13// apart any that would touch).
14export const GAP = 8
15// Below each grid row: 2 stream lanes, then 1 connector row above the next row.
16const LANES = 2
17const CONNECTOR_ROWS = 3
18
19export type Viewport = { rows: number; columns: number }
20
21export type Figure = {
22 agentId: string
23 x: number
24 y: number
25 state: AgentState
26 // The frame to draw this tick: pixel rows of '#' and '.'.
27 grid: string[]
28 // Label, truncated with an ellipsis to the figure width.
29 label: string
30 isMain: boolean
31 // Size: the sprite's width in columns, and its height in rows with the label row.
32 width: number
33 height: number
34 tier: Tier | null
35 // How full its context window is (0..1), or null when unknown. Drawn as a darker fill from the feet.
36 fill: number | null
37 // A fork or workflow agent no list names: drawn muted so active agents stand out.
38 background: boolean
39 // The full, untruncated title, and the lines of its hover card.
40 title: string
41 details: string[]
42}
43
44// A 0/1 stream between two exchanging figures, on a lane row of its own (never overlapping another).
45export type Stream = {
46 fromId: string
47 toId: string
48 row: number
49 x: number
50 width: number
51}
52
53// One connector glyph, drawn in the gap above a child figure.
54export type Connector = { x: number; y: number }
55
56export type Scene = {
57 figures: Figure[]
58 streams: Stream[]
59 connectors: Connector[]
60 // Agents that did not fit the viewport.
61 hidden: number
62 // Completed agents folded into the `+N done` overflow line.
63 collapsed: number
64 // The row the overflow line sits on, or -1 when nothing collapsed.
65 overflowRow: number
66 // The tick the frames were chosen for; the view draws streams from it.
67 tick: number
68 // True when the colony has no agents at all.
69 empty: boolean
70 // True when the viewport is too small to draw figures; draw `summary` instead.
71 tooSmall: boolean
72 summary: string
73 // The viewport the scene was laid out for.
74 viewport: Viewport
75}
76
77export function truncate(text: string, width: number): string {
78 const chars = [...text]
79 if (chars.length <= width) return text
80 if (width <= 0) return ''
81 if (width === 1) return '…'
82 return chars.slice(0, width - 1).join('') + '…'
83}
84
85// A listening figure is labelled with its source tag instead of its name (FR-010): `[github]`.
86export function listenTagOf(source: string | null): string {
87 return truncate(`[${source ?? 'external'}]`, FIGURE_WIDTH)
88}
89
90// The figure's own copy of each agent: the state it is drawn in (FR-013), not the stored one.
91function shownAgents(agents: Record<string, Agent>): Record<string, Agent> {
92 const out: Record<string, Agent> = {}
93 for (const agent of Object.values(agents)) out[agent.id] = { ...agent, state: shownStateOf(agent) }
94 return out
95}
96
97function figureFor(agent: Agent, x: number, y: number, tick: number, px: number): Figure {
98 const reacting = agent.state === 'listening' && agent.reactUntil !== null
99 return {
100 agentId: agent.id,
101 x,
102 y,
103 state: agent.state,
104 grid: frameOf(agent.state, tick, reacting, px),
105 label:
106 agent.state === 'listening'
107 ? listenTagOf(agent.listenSource)
108 : truncate(agent.label, px),
109 isMain: agent.id === MAIN_ID,
110 width: px,
111 height: px / 2 + 1,
112 tier: tierOf(agent.model),
113 fill: fillOf(agent),
114 background: agent.background,
115 title: agent.label,
116 details: detailsOf(agent, agent.state),
117 }
118}
119
120// One short line for the pane, the status line, or a tiny viewport.
121export function summaryOf(agents: Agent[]): string {
122 if (agents.length === 0) return 'colony: no agents'
123 // Other sessions are drawn but not counted: the summary speaks for this session.
124 const shown = agents.filter(a => !isPeer(a)).map(a => shownStateOf(a))
125 const working = shown.filter(s => s === 'in-progress').length
126 const needs = shown.filter(s => s === 'pending').length
127 const parts = [`${working} working`]
128 if (needs > 0) parts.push(`${needs} needs you`)
129 return `colony: ${parts.join(', ')}`
130}
131
132// Subagents in family order: each agent's children follow it, so a family shares a block of the grid.
133// Children whose parent is not in the colony join main's children.
134export function familyOrder(agents: Agent[]): Agent[] {
135 const ids = new Set(agents.map(a => a.id))
136 const byParent = new Map<string, Agent[]>()
137 for (const agent of agents) {
138 if (agent.id === MAIN_ID) continue
139 const parent = agent.parentId && ids.has(agent.parentId) ? agent.parentId : MAIN_ID
140 const list = byParent.get(parent) ?? []
141 list.push(agent)
142 byParent.set(parent, list)
143 }
144 const byStart = (a: Agent, b: Agent) =>
145 (a.startedAt ?? 0) - (b.startedAt ?? 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
146 const out: Agent[] = []
147 const visit = (parent: string, seen: Set<string>) => {
148 for (const child of (byParent.get(parent) ?? []).sort(byStart)) {
149 if (seen.has(child.id)) continue
150 seen.add(child.id)
151 out.push(child)
152 visit(child.id, seen)
153 }
154 }
155 const seen = new Set<string>()
156 visit(MAIN_ID, seen)
157 // Agents caught in a parent cycle (or their own parent) are unreachable from main: draw them last.
158 for (const agent of agents.slice().sort(byStart)) {
159 if (agent.id === MAIN_ID || out.includes(agent)) continue
160 out.push(agent)
161 seen.add(agent.id)
162 }
163 return out
164}
165
166// How many grid figures fit in `avail` rows of the viewport, for cells `maxH` rows tall.
167function capacityFor(cols: number, maxH: number, avail: number): number {
168 if (avail < maxH) return 0
169 return cols * (Math.floor((avail - maxH) / (maxH + CONNECTOR_ROWS)) + 1)
170}
171
172function gridColumns(columns: number, maxW: number): number {
173 return Math.max(1, Math.floor((columns + GAP) / (maxW + GAP)))
174}
175
176// Overflow: past capacity, the oldest completed figures fold into the `+N done` line. Pending and
177// running figures are never folded. Returns the figures to lay out and how many were folded.
178function fitOthers(
179 others: Agent[],
180 cols: number,
181 maxH: number,
182 avail: number,
183): { kept: Agent[]; collapsed: number; reserveLine: boolean } {
184 const full = capacityFor(cols, maxH, avail)
185 if (others.length <= full) return { kept: others, collapsed: 0, reserveLine: false }
186 const room = capacityFor(cols, maxH, avail - 1)
187 const excess = others.length - room
188 const completed = others
189 .filter(a => a.state === 'completed')
190 .sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0) || (a.id < b.id ? -1 : 1))
191 const folded = new Set(completed.slice(0, Math.max(0, excess)).map(a => a.id))
192 const kept = others.filter(a => !folded.has(a.id))
193 if (folded.size === 0) return { kept, collapsed: 0, reserveLine: false }
194 return { kept, collapsed: folded.size, reserveLine: true }
195}
196
197// Exchanges that draw a stream: they carry a burst or a wait (an exchange only easing back draws none).
198function streamExchanges(exchanges: Record<string, Exchange>): Exchange[] {
199 return Object.keys(exchanges)
200 .sort()
201 .map(key => exchanges[key]!)
202 .filter(ex => ex.reasons.length > 0)
203}
204
205// The pull (in columns) a figure feels toward its partner: its shift, toward the partner's column.
206function shiftFor(
207 agent: Agent,
208 exchanges: Record<string, Exchange>,
209 base: Map<string, { x: number; y: number }>,
210): number {
211 const magnitude = Math.min(MAX_SHIFT, Math.max(0, agent.shift))
212 if (magnitude === 0) return 0
213 for (const ex of Object.values(exchanges)) {
214 const partner = ex.fromId === agent.id ? ex.toId : ex.toId === agent.id ? ex.fromId : null
215 if (partner === null) continue
216 const me = base.get(agent.id)
217 const other = base.get(partner)
218 if (!me || !other) continue
219 const dx = other.x - me.x
220 if (dx === 0) continue
221 return Math.sign(dx) * Math.min(magnitude, Math.abs(dx))
222 }
223 return 0
224}
225
226export function layout(colony: Colony, viewport: Viewport, tick: number): Scene {
227 const byId = shownAgents(colony.agents)
228 const agents = Object.values(byId)
229 const summary = summaryOf(agents)
230 const none = { figures: [], streams: [], connectors: [], hidden: 0, collapsed: 0, overflowRow: -1, tick, viewport }
231 if (agents.length === 0) {
232 return { ...none, empty: true, tooSmall: false, summary }
233 }
234 if (viewport.columns < FIGURE_WIDTH || viewport.rows < FIGURE_HEIGHT) {
235 return { ...none, empty: false, tooSmall: true, summary }
236 }
237
238 // Each figure's size follows its model, shrunk to fit a small viewport (never below the base figure).
239 const fits = Math.max(FIGURE_WIDTH, 2 * Math.floor(Math.min(viewport.columns, 2 * (viewport.rows - 1)) / 2))
240 const pxOf = (agent: Agent) => Math.max(FIGURE_WIDTH, Math.min(pixelsOf(agent.model), fits))
241 // Grid cells fit the biggest of the other agents; main stands alone on the row above them.
242 const others = agents.filter(a => a.id !== MAIN_ID)
243 const maxW = Math.max(FIGURE_WIDTH, ...others.map(pxOf))
244 const maxH = maxW / 2 + 1
245 const rowStep = maxH + CONNECTOR_ROWS
246
247 const base = new Map<string, { x: number; y: number; rowTop: number; rowBottom: number }>()
248 const placed: Array<{ agent: Agent; x: number; y: number }> = []
249
250 // Main figure on top, centred.
251 const main = byId[MAIN_ID]
252 let top = 0
253 if (main) {
254 const px = pxOf(main)
255 const height = px / 2 + 1
256 const x = Math.floor((viewport.columns - px) / 2)
257 base.set(MAIN_ID, { x, y: 0, rowTop: 0, rowBottom: height })
258 placed.push({ agent: main, x, y: 0 })
259 top = height + CONNECTOR_ROWS
260 }
261
262 // Everyone else in family order in a grid beneath.
263 const cols = gridColumns(viewport.columns, maxW)
264 const { kept, collapsed, reserveLine } = fitOthers(familyOrder(agents), cols, maxH, viewport.rows - top)
265 const capacity = capacityFor(cols, maxH, viewport.rows - top - (reserveLine ? 1 : 0))
266 // Past capacity, drop from the end of family order but never a pending figure (the user must see it).
267 let overflow = Math.max(0, kept.length - capacity)
268 const dropped = new Set<string>()
269 for (let i = kept.length - 1; i >= 0 && overflow > 0; i--) {
270 if (kept[i]!.state === 'pending') continue
271 dropped.add(kept[i]!.id)
272 overflow--
273 }
274 // Only if pending figures alone exceed capacity are some of them cut.
275 const shown = kept.filter(a => !dropped.has(a.id)).slice(0, capacity)
276 const hidden = kept.length - shown.length
277 shown.forEach((agent, i) => {
278 const px = pxOf(agent)
279 const rowTop = top + Math.floor(i / cols) * rowStep
280 const x = (i % cols) * (maxW + GAP) + Math.floor((maxW - px) / 2)
281 const y = rowTop + (maxH - (px / 2 + 1))
282 base.set(agent.id, { x, y, rowTop, rowBottom: rowTop + maxH })
283 placed.push({ agent, x, y })
284 })
285
286 // Figures: the base position moved toward the exchange partner by the eased shift, then by the
287 // wander drift of a working figure.
288 const figures: Figure[] = placed.map(({ agent, x, y }) => {
289 const drift = agent.id === MAIN_ID ? 0 : Math.round(agent.wander)
290 const x2 = x + shiftFor(agent, colony.exchanges, base) + drift
291 return figureFor(agent, x2, y, tick, pxOf(agent))
292 })
293
294 // Keep every row's figures apart (a drift or pull never makes two touch) and inside the pane.
295 const rows = new Map<number, Figure[]>()
296 for (const f of figures) {
297 const rowTop = base.get(f.agentId)!.rowTop
298 rows.set(rowTop, [...(rows.get(rowTop) ?? []), f])
299 }
300 for (const row of rows.values()) {
301 row.sort((a, b) => a.x - b.x)
302 let edge = 0
303 for (const f of row) {
304 f.x = Math.max(f.x, edge)
305 edge = f.x + f.width + 1
306 }
307 let limit = viewport.columns
308 for (let i = row.length - 1; i >= 0; i--) {
309 const f = row[i]!
310 f.x = Math.max(0, Math.min(f.x, limit - f.width))
311 limit = f.x - 1
312 }
313 }
314 const at = new Map(figures.map(f => [f.agentId, f]))
315
316 // Connectors: one glyph per child in the gap directly above it, clear of every figure. An agent no
317 // orchestrator here started (a teammate, an independent agent) has none.
318 const connectors: Connector[] = []
319 for (const figure of figures) {
320 const agent = byId[figure.agentId]!
321 if (figure.isMain || agent.independent || !agent.parentId || !at.has(agent.parentId)) continue
322 const cx = figure.x + Math.floor(figure.width / 2)
323 const rowTop = base.get(figure.agentId)!.rowTop
324 for (let y = Math.max(0, rowTop - CONNECTOR_ROWS); y < figure.y; y++) connectors.push({ x: cx, y })
325 }
326
327 // Streams: one per active exchange, on a lane row below the upper figure's row; lanes never overlap.
328 const lanes = new Map<number, Array<[number, number]>>()
329 const streams: Stream[] = []
330 for (const ex of streamExchanges(colony.exchanges)) {
331 const a = at.get(ex.fromId)
332 const b = at.get(ex.toId)
333 if (!a || !b) continue
334 const upper = a.y < b.y || (a.y === b.y && a.x <= b.x) ? a : b
335 const x0 = Math.min(a.x + Math.floor(a.width / 2), b.x + Math.floor(b.width / 2))
336 const x1 = Math.max(a.x + Math.floor(a.width / 2), b.x + Math.floor(b.width / 2))
337 const span: [number, number] = [x0, x1]
338 const below = base.get(upper.agentId)!.rowBottom
339 for (let lane = 0; lane < LANES; lane++) {
340 const row = below + lane
341 if (row >= viewport.rows) continue
342 const taken = lanes.get(row) ?? []
343 if (taken.some(([s, e]) => x0 <= e && s <= x1)) continue
344 taken.push(span)
345 lanes.set(row, taken)
346 streams.push({ fromId: ex.fromId, toId: ex.toId, row, x: x0, width: x1 - x0 + 1 })
347 break
348 }
349 }
350
351 return {
352 figures,
353 streams,
354 connectors,
355 hidden,
356 collapsed,
357 overflowRow: collapsed > 0 ? viewport.rows - 1 : -1,
358 tick,
359 empty: false,
360 tooSmall: false,
361 summary,
362 viewport,
363 }
364}
365src/runtime.ts 94 lines1// Pure helpers behind hooks/register.tsx: option parsing, signal context, clock and pane decisions.
2// No engine imports, so each rule is unit-tested without the engine.
3import { activityOf, type SignalContext } from './signals'
4import { summaryOf } from './layout'
5import { isPeer, REACT_MS, shownStateOf } from './model'
6import type { Agent, AgentState, Colony } from '../types'
7
8export const DEFAULT_RETENTION_SECONDS = 30
9export const CLOCK_MS = 250
10
11// States whose figures animate; while any is present (or an exchange is open) the clock runs.
12const ANIMATED: ReadonlyArray<AgentState> = ['in-progress', 'pending', 'completed', 'listening']
13
14export type ColonyOptions = { reducedMotion: boolean; retentionMs: number; sessionsDirectory: string }
15
16// userConfig values as the engine hands them to register(on, options). Anything unusable falls back.
17export function readOptions(options: Readonly<Record<string, unknown>> | undefined): ColonyOptions {
18 const raw = Number(options?.retentionSeconds)
19 const seconds =
20 options?.retentionSeconds !== undefined && Number.isFinite(raw) && raw >= 0
21 ? raw
22 : DEFAULT_RETENTION_SECONDS
23 return {
24 reducedMotion: options?.reducedMotion === true || options?.reducedMotion === 'true',
25 retentionMs: Math.round(seconds * 1000),
26 // Empty (the default) means other sessions are never read.
27 sessionsDirectory: typeof options?.sessionsDirectory === 'string' ? options.sessionsDirectory.trim() : '',
28 }
29}
30
31export function pendingIdsOf(colony: Colony): string[] {
32 return Object.values(colony.agents)
33 .filter(a => a.state === 'pending')
34 .map(a => a.id)
35}
36
37// The clock runs only while a figure animates or an exchange is open, and never in reduced motion.
38export function animates(colony: Colony, reducedMotion: boolean): boolean {
39 if (reducedMotion) return false
40 if (Object.keys(colony.exchanges).length > 0) return true
41 // A figure still drifting (or easing home after it stopped working) keeps the clock running too.
42 return Object.values(colony.agents).some(
43 (a: Agent) => ANIMATED.includes(shownStateOf(a)) || a.wander !== a.wanderTarget || a.wanderTarget !== 0,
44 )
45}
46
47// The most recently active agent, kept across events (see activityOf); unchanged by other events.
48export function nextRecentAgent(previous: string | null, eventName: string, e: Record<string, unknown>): string | null {
49 return activityOf(eventName, e) ?? previous
50}
51
52export function contextFor(
53 colony: Colony,
54 recentAgentId: string | null,
55 at: number,
56 reactFlashMs: number = REACT_MS,
57): SignalContext {
58 const agents = Object.values(colony.agents).map(a => ({ id: a.id, label: a.label }))
59 return { at, recentAgentId, pendingIds: pendingIdsOf(colony), agents, reactFlashMs }
60}
61
62// The status line text, or undefined when the pane is placed (or not open at all).
63export function statusFor(colony: Colony, paneOpen: boolean, placed: boolean): string | undefined {
64 if (!paneOpen || placed) return undefined
65 return summaryOf(Object.values(colony.agents))
66}
67
68// What justifies opening the pane: pending agents plus subagents (main alone never does).
69export function attentionOf(colony: Colony): number {
70 const pending = pendingIdsOf(colony).length
71 const subagents = Object.values(colony.agents).filter(a => a.id !== 'main' && !isPeer(a)).length
72 return pending + subagents
73}
74
75// Auto-open the first time an agent needs the user or a subagent appears, unless the user hid the pane (FR-001).
76export function shouldAutoOpen(input: {
77 attention: number
78 hidden: boolean
79 paneOpen: boolean
80 firstAutoDone: boolean
81}): boolean {
82 return input.attention > 0 && !input.hidden && !input.paneOpen && !input.firstAutoDone
83}
84
85// Toast when a new pending appears while the open pane is not placed (narrow terminal).
86export function shouldToastPending(input: {
87 pendingCount: number
88 previousPendingCount: number
89 paneOpen: boolean
90 placed: boolean
91}): boolean {
92 return input.paneOpen && !input.placed && input.pendingCount > input.previousPendingCount
93}
94src/signals.ts 292 lines1// Engine event -> colony signals (contracts/event-mapping.md). Pure: no engine imports.
2// Hooks in hooks/register.tsx enrich an event with what only the engine's agent list knows
3// (a parent id, a description, an end status) before calling toSignals.
4import { MAIN_ID, REACT_MS, TERMINAL_STATES, type Signal } from './model'
5import type { AgentState, Usage } from '../types'
6
7// What attribution needs beyond the event itself.
8export type SignalContext = {
9 at: number
10 // The most recently active agent (see activityOf), or null before any activity.
11 recentAgentId: string | null
12 // Agents currently pending, i.e. waiting on the user.
13 pendingIds: string[]
14 // Known agents, so a recipient or teammate name can be resolved by id or label.
15 agents: Array<{ id: string; label: string }>
16 // Length of a reaction flash (ms); 0 under reduced motion. Defaults to REACT_MS.
17 reactFlashMs?: number
18}
19
20// Tools that register an external trigger (R5). ScheduleWakeup and CronCreate are the agent's own
21// timers and are deliberately absent.
22const LISTENING_TOOLS: Readonly<Record<string, string>> = {
23 Monitor: 'watch',
24 RemoteTrigger: 'webhook',
25}
26
27type EventInput = Record<string, unknown>
28
29// Notification types that mean the agent is waiting on the user (research R3).
30const PROMPT_NOTIFICATIONS: ReadonlyArray<string> = ['permission_prompt', 'idle_prompt']
31
32// One row of `$.agent.list()` as the mod reads it (AgentInfo, reduced to what the pane needs).
33export type ListedAgent = {
34 id: string
35 description?: string
36 type?: string
37 parentId?: string
38 status: string
39 name?: string
40 teammateId?: string
41 spawnedBy?: string
42}
43
44// The token counts of a turn.complete `usage` (ModelUsage), or null when it carries none.
45export function usageOf(value: unknown): Usage | null {
46 if (!value || typeof value !== 'object') return null
47 const u = value as Record<string, unknown>
48 const n = (k: string) => (typeof u[k] === 'number' && Number.isFinite(u[k]) ? (u[k] as number) : 0)
49 return {
50 input: n('input_tokens'),
51 output: n('output_tokens'),
52 cacheRead: n('cache_read_input_tokens'),
53 cacheWrite: n('cache_creation_input_tokens'),
54 }
55}
56
57function text(value: unknown): string {
58 return typeof value === 'string' ? value : ''
59}
60
61// The agent a hook fired from: `agentId` on engine events, `agent_id` on classic hooks; null = main.
62export function agentOf(e: EventInput): string | null {
63 const id = e.agentId ?? e.agent_id
64 return typeof id === 'string' && id.length > 0 ? id : null
65}
66
67// The agent an event shows as active, for recentAgentId tracking. Null when the event is not activity.
68export function activityOf(eventName: string, e: EventInput): string | null {
69 if (eventName === 'tool.call') return agentOf(e) ?? MAIN_ID
70 if (eventName === 'turn.start') return MAIN_ID
71 return null
72}
73
74// Pending attribution: the event's own agent, else the most recently active agent, else main.
75function pendingOwner(e: EventInput, ctx: SignalContext): string {
76 return agentOf(e) ?? ctx.recentAgentId ?? MAIN_ID
77}
78
79function clearPending(ctx: SignalContext): Signal[] {
80 return ctx.pendingIds.map(id => ({
81 type: 'agent-state' as const,
82 id,
83 state: 'in-progress' as const,
84 at: ctx.at,
85 }))
86}
87
88// The ending state of a subagent from its engine status (AgentStatus). The SubagentStop hook itself
89// carries no outcome, so a status the list gives decides, and anything else reads as completed.
90export function endStateOf(status: string | undefined): 'completed' | 'failed' | 'cancelled' {
91 if (status === 'failed') return 'failed'
92 if (status === 'killed' || status === 'cancelled') return 'cancelled'
93 return 'completed'
94}
95
96// The pane state an engine AgentStatus shows as (contracts/event-mapping.md, R2).
97export function listStateOf(status: string): AgentState {
98 switch (status) {
99 case 'running':
100 case 'waiting':
101 return 'in-progress'
102 case 'completed':
103 return 'completed'
104 case 'failed':
105 return 'failed'
106 case 'killed':
107 return 'cancelled'
108 default:
109 // pending (not started) and idle (between turns)
110 return 'idle'
111 }
112}
113
114// Resolve a recipient or teammate name against known agents: an id, or a label that names exactly one.
115export function resolveAgent(to: unknown, agents: SignalContext['agents']): string | null {
116 const key =
117 typeof to === 'string'
118 ? to
119 : to && typeof to === 'object' && typeof (to as { agentId?: unknown }).agentId === 'string'
120 ? (to as { agentId: string }).agentId
121 : ''
122 if (!key) return null
123 if (agents.some(a => a.id === key)) return key
124 const named = agents.filter(a => a.label === key)
125 return named.length === 1 ? named[0]!.id : null
126}
127
128// A new subagent: it is added under its parent, and the parent opens a spawn burst and a wait.
129function spawnSignals(parentId: string, childId: string, label: string, at: number): Signal[] {
130 return [
131 { type: 'agent-added', id: childId, label, parentId, at },
132 { type: 'exchange-open', fromId: parentId, toId: childId, reason: 'spawn', at },
133 { type: 'exchange-open', fromId: parentId, toId: childId, reason: 'waiting', at },
134 ]
135}
136
137export function toSignals(eventName: string, e: EventInput, ctx: SignalContext): Signal[] {
138 switch (eventName) {
139 case 'session.start':
140 return [{ type: 'agent-added', id: MAIN_ID, label: 'main', parentId: null, at: ctx.at }]
141
142 case 'turn.start':
143 return [{ type: 'agent-state', id: MAIN_ID, state: 'in-progress', at: ctx.at }]
144
145 // A turn that ran in a subagent's loop (it carries that agent's id) is that agent's usage only;
146 // the main agent completes when its own turn does.
147 case 'turn.complete': {
148 const id = agentOf(e)
149 const usage = usageOf(e.usage)
150 const model = text((e.usage as { model?: unknown } | undefined)?.model)
151 const out: Signal[] = []
152 if (id === null) out.push({ type: 'agent-state', id: MAIN_ID, state: 'completed', at: ctx.at })
153 if (usage) {
154 out.push({ type: 'agent-usage', id: id ?? MAIN_ID, usage, at: ctx.at })
155 if (model) out.push({ type: 'agent-meta', id: id ?? MAIN_ID, model, at: ctx.at })
156 }
157 return out
158 }
159
160 // A model request is about to go out: the model it names, and one more step in the run.
161 case 'turn.step':
162 return [{ type: 'agent-step', id: agentOf(e) ?? MAIN_ID, model: text(e.model) || undefined, at: ctx.at }]
163
164 case 'tool.call': {
165 // A tool call is the agent working; it also resolves that agent's pending state.
166 const id = agentOf(e) ?? MAIN_ID
167 const working: Signal = { type: 'agent-state', id, state: 'in-progress', at: ctx.at }
168 const source = typeof e.tool === 'string' ? LISTENING_TOOLS[e.tool] : undefined
169 if (source === undefined) return [working]
170 // A watch or webhook registration: the agent now listens for that trigger (FR-010).
171 return [working, { type: 'agent-state', id, state: 'listening', listenSource: source, at: ctx.at }]
172 }
173
174 // An inbound delivery with an event: the target reacts under the event's source (FR-011).
175 case 'session.receive': {
176 if (!e.event || typeof e.event !== 'object') return []
177 const event = e.event as { source?: unknown }
178 const source = typeof event.source === 'string' && event.source.length > 0 ? event.source : 'external'
179 return [
180 {
181 type: 'agent-react',
182 id: agentOf(e) ?? MAIN_ID,
183 source,
184 flashMs: ctx.reactFlashMs ?? REACT_MS,
185 at: ctx.at,
186 },
187 ]
188 }
189
190 case 'classic.PermissionRequest':
191 case 'classic.Elicitation':
192 return [{ type: 'agent-state', id: pendingOwner(e, ctx), state: 'pending', at: ctx.at }]
193
194 case 'classic.Notification': {
195 const kind = typeof e.notification_type === 'string' ? e.notification_type : ''
196 if (!PROMPT_NOTIFICATIONS.includes(kind)) return []
197 return [{ type: 'agent-state', id: pendingOwner(e, ctx), state: 'pending', at: ctx.at }]
198 }
199
200 case 'classic.ElicitationResult':
201 case 'prompt.submit':
202 return clearPending(ctx)
203
204 // Spawn: the engine answers the new agent's id only after the spawn, so the hook passes it in.
205 case 'agent.spawn': {
206 const childId = text(e.agentId)
207 if (!childId) return []
208 const parent = text(e.parentAgentId) || MAIN_ID
209 const label = text(e.description) || text(e.subagentType) || childId
210 const model = text(e.model) || text(e.parentModel)
211 return [
212 ...spawnSignals(parent, childId, label, ctx.at),
213 { type: 'agent-meta', id: childId, model: model || undefined, addressable: e.isTeammate === true, at: ctx.at },
214 ]
215 }
216
217 case 'classic.SubagentStart': {
218 const childId = text(e.agent_id)
219 if (!childId) return []
220 const parent = text(e.parentId) || MAIN_ID
221 const label = text(e.description) || text(e.agent_type) || childId
222 return [
223 ...spawnSignals(parent, childId, label, ctx.at),
224 ...(text(e.model) ? [{ type: 'agent-meta' as const, id: childId, model: text(e.model), at: ctx.at }] : []),
225 ]
226 }
227
228 // Stop: the wait ends, the agent ends by its status, and its result burst returns to the parent.
229 case 'classic.SubagentStop': {
230 const childId = text(e.agent_id)
231 if (!childId) return []
232 const parent = text(e.parentId) || MAIN_ID
233 return [
234 { type: 'exchange-close', fromId: parent, toId: childId, reason: 'waiting', at: ctx.at },
235 { type: 'agent-ended', id: childId, state: endStateOf(text(e.status)), at: ctx.at },
236 { type: 'exchange-open', fromId: parent, toId: childId, reason: 'result', at: ctx.at },
237 ]
238 }
239
240 case 'session.send': {
241 const sender = agentOf(e) ?? MAIN_ID
242 const recipient = resolveAgent(e.to, ctx.agents)
243 if (!recipient || recipient === sender) return []
244 return [{ type: 'exchange-open', fromId: sender, toId: recipient, reason: 'message', at: ctx.at }]
245 }
246
247 case 'classic.TeammateIdle': {
248 const id = agentOf(e) ?? resolveAgent(e.teammate_name, ctx.agents)
249 if (!id) return []
250 return [{ type: 'agent-state', id, state: 'idle', at: ctx.at }]
251 }
252
253 default:
254 return []
255 }
256}
257
258// The engine's agent list as a reconcile: list status wins (contracts/event-mapping.md). Children of
259// an agent that has ended lose their waiting exchange, since their SubagentStop may never arrive.
260export function reconcileFromList(listed: ListedAgent[], at: number): Signal[] {
261 const items = listed.map(info => ({
262 id: info.id,
263 label: info.description || info.type || info.id,
264 parentId: info.parentId || MAIN_ID,
265 state: listStateOf(info.status),
266 }))
267 // What the list knows about an agent's origin: a name or teammate address makes it reusable, and a
268 // teammate or an agent a plugin started has no orchestrator among the agents drawn here.
269 const metas: Signal[] = listed.flatMap(info => {
270 const addressable = Boolean(info.name || info.teammateId)
271 const independent = Boolean(info.teammateId)
272 if (!addressable && !info.spawnedBy) return []
273 return [
274 {
275 type: 'agent-meta' as const,
276 id: info.id,
277 addressable: addressable || undefined,
278 independent: independent || undefined,
279 spawnedBy: info.spawnedBy ?? undefined,
280 at,
281 },
282 ]
283 })
284 const closes: Signal[] = []
285 for (const item of items) {
286 if (TERMINAL_STATES.includes(item.state)) {
287 closes.push({ type: 'exchange-close', fromId: item.parentId, toId: item.id, reason: 'waiting', at })
288 }
289 }
290 return [{ type: 'reconcile', at, listed: items }, ...metas, ...closes]
291}
292src/context.ts 13 lines1// A rough count of the tokens in a conversation, from its text. The engine does not report a
2// subagent's context window while it runs, so the pane estimates it (about 3.5 characters a token).
3const CHARS_PER_TOKEN = 3.5
4
5export function estimateContextTokens(messages: ReadonlyArray<{ content?: unknown }>): number {
6 let chars = 0
7 for (const message of messages) {
8 const content = message.content
9 chars += typeof content === 'string' ? content.length : JSON.stringify(content ?? '').length
10 }
11 return Math.round(chars / CHARS_PER_TOKEN)
12}
13src/view.tsx 201 lines1// Scene -> element tree (contracts/pane-render.md). JSX compiles to the engine's global `h`;
2// this module neither declares nor imports it. Box and Text come from $.ui.resolve(e).
3//
4// Two layers: the background (connectors, streams, the `+N done` line) is rows of text, and every
5// figure sits on top as an absolutely positioned box so it can carry a hover. The hover card is its
6// own box at the top level, in the figure's hover group, so no later figure can paint over it.
7import type { Figure, Scene } from './layout'
8import { stream } from './particles'
9import { BACKGROUND_COLOUR, BACKGROUND_COLOUR_DARK, renderCells, STATE_COLOUR, STATE_COLOUR_DARK } from './sprites'
10import { wrap } from './tiers'
11
12// An element constructor from the engine's element table. Typed loosely: the engine checks its props.
13// eslint-disable-next-line @typescript-eslint/no-explicit-any
14type Tag = (props: any) => any
15export type Tags = { Box: Tag; Text: Tag }
16
17// One screen cell: a character plus the style it is drawn in.
18type Cell = { ch: string; color?: string; dim?: boolean }
19
20const BLANK: Cell = { ch: ' ' }
21
22// The eye colour: bright against both the body colour and its darker fill.
23export const EYE_COLOUR = 'white'
24
25// Paint the background layer onto a cell grid: connectors, then streams, then the overflow line.
26export function paint(scene: Scene): Cell[][] {
27 const { columns, rows } = scene.viewport
28 const width = Math.max(
29 0,
30 ...scene.streams.map(s => s.x + s.width),
31 ...scene.connectors.map(c => c.x + 1),
32 ...scene.figures.map(f => f.x + f.width),
33 )
34 const height = Math.max(
35 0,
36 ...scene.figures.map(f => f.y + f.height),
37 ...scene.streams.map(s => s.row + 1),
38 ...scene.connectors.map(c => c.y + 1),
39 scene.overflowRow + 1,
40 )
41 const grid: Cell[][] = Array.from({ length: Math.min(height, rows) }, () =>
42 Array.from({ length: Math.min(width, columns) }, () => BLANK),
43 )
44
45 for (const c of scene.connectors) if (grid[c.y]?.[c.x]) grid[c.y]![c.x] = { ch: '│', dim: true }
46
47 for (const s of scene.streams) {
48 const glyphs = [...stream(s.width, scene.tick)]
49 glyphs.forEach((ch, i) => {
50 if (grid[s.row]?.[s.x + i]) grid[s.row]![s.x + i] = { ch, color: 'cyan' }
51 })
52 }
53
54 if (scene.overflowRow >= 0 && grid[scene.overflowRow]) {
55 const line = `+${scene.collapsed} done`
56 ;[...line].forEach((ch, i) => {
57 if (grid[scene.overflowRow]![i]) grid[scene.overflowRow]![i] = { ch, dim: true }
58 })
59 }
60 return grid
61}
62
63// Consecutive cells with the same style become one Text element.
64function runsOf(cells: Cell[]): Cell[][] {
65 const runs: Cell[][] = []
66 for (const cell of cells) {
67 const last = runs[runs.length - 1]
68 if (last && last[0]!.color === cell.color && last[0]!.dim === cell.dim) last.push(cell)
69 else runs.push([cell])
70 }
71 return runs
72}
73
74// A text run of one figure row: same foreground and background colour.
75type Run = { text: string; color?: string; background?: string }
76
77// The rows of a figure's sprite as coloured runs. The fraction of the context window in use fills the
78// body from the feet up in the darker shade of the state colour; eyes stay bright.
79export function spriteRuns(figure: Figure): Run[][] {
80 const cells = renderCells(figure.grid)
81 const body = figure.background ? BACKGROUND_COLOUR : STATE_COLOUR[figure.state]
82 const dark = figure.background ? BACKGROUND_COLOUR_DARK : (STATE_COLOUR_DARK[figure.state] ?? body)
83 const filledRows =
84 figure.fill === null ? 0 : figure.fill <= 0 ? 0 : Math.max(1, Math.round(figure.fill * cells.length))
85 return cells.map((row, r) => {
86 const ink = r >= cells.length - filledRows ? dark : body
87 const colourOf = (kind: 'ink' | 'eye' | null) => (kind === 'eye' ? EYE_COLOUR : kind === 'ink' ? ink : undefined)
88 const runs: Run[] = []
89 for (const cell of row) {
90 const color = colourOf(cell.fg)
91 const background = colourOf(cell.bg)
92 const last = runs[runs.length - 1]
93 if (last && last.color === color && last.background === background) last.text += cell.ch
94 else runs.push({ text: cell.ch, color, background })
95 }
96 return runs
97 })
98}
99
100const CARD_MAX_WIDTH = 44
101const CARD_MIN_WIDTH = 16
102
103// The hover card's lines and size: the full title wrapped, then the details.
104export function cardOf(figure: Figure, columns: number): { title: string[]; details: string[]; width: number; height: number } {
105 const inner = Math.max(8, Math.min(CARD_MAX_WIDTH, columns) - 4)
106 const title = wrap(figure.title, inner)
107 const details = figure.details.flatMap(line => wrap(line, inner))
108 const longest = Math.max(...title.map(l => [...l].length), ...details.map(l => [...l].length))
109 const width = Math.min(columns, Math.max(CARD_MIN_WIDTH, longest + 4))
110 return { title, details, width, height: title.length + details.length + 2 }
111}
112
113export function renderPane(scene: Scene, { Box, Text }: Tags) {
114 if (scene.empty) {
115 return (
116 <Box flexDirection="column">
117 <Text dimColor>No agents yet.</Text>
118 </Box>
119 )
120 }
121 if (scene.tooSmall) {
122 return (
123 <Box flexDirection="column">
124 <Text>{scene.summary}</Text>
125 </Box>
126 )
127 }
128
129 const { columns, rows } = scene.viewport
130 const grid = paint(scene)
131 const height = Math.max(grid.length, ...scene.figures.map(f => f.y + f.height))
132
133 return (
134 <Box flexDirection="column" width={columns} height={Math.min(rows, Math.max(height, 1))}>
135 {grid.map(cells => {
136 if (cells.length === 0) return <Text> </Text>
137 return (
138 <Box flexDirection="row">
139 {runsOf(cells).map(run => (
140 <Text color={run[0]!.color} dimColor={run[0]!.dim}>
141 {run.map(c => c.ch).join('')}
142 </Text>
143 ))}
144 </Box>
145 )
146 })}
147 {scene.figures.map((figure, i) => (
148 <Box
149 key={`f${i}`}
150 position="absolute"
151 left={figure.x}
152 top={figure.y}
153 width={figure.width}
154 height={figure.height}
155 flexDirection="column"
156 hover={{ scope: `fig${i}` }}
157 >
158 {spriteRuns(figure).map(runs => (
159 <Box flexDirection="row">
160 {runs.map(run => (
161 <Text color={run.color} backgroundColor={run.background}>
162 {run.text}
163 </Text>
164 ))}
165 </Box>
166 ))}
167 <Text dimColor>{figure.label.padEnd(figure.width)}</Text>
168 </Box>
169 ))}
170 {scene.figures.map((figure, i) => {
171 const card = cardOf(figure, columns)
172 const left = Math.max(0, Math.min(figure.x, columns - card.width))
173 // Below the figure when it fits, else above it.
174 const below = figure.y + figure.height
175 const top = below + card.height <= rows ? below : Math.max(0, figure.y - card.height)
176 return (
177 <Box
178 position="absolute"
179 left={left}
180 top={top}
181 width={card.width}
182 display="none"
183 hover={{ display: 'flex', scope: `fig${i}` }}
184 flexDirection="column"
185 borderStyle="round"
186 paddingX={1}
187 >
188 {card.title.map(line => (
189 <Text bold>{line}</Text>
190 ))}
191 {card.details.map(line => (
192 <Text dimColor>{line}</Text>
193 ))}
194 </Box>
195 )
196 })}
197 {scene.hidden > 0 && <Text dimColor>+{scene.hidden} not shown</Text>}
198 </Box>
199 )
200}
201src/priority.ts 27 lines1import type { AgentState } from '../types'
2
3// Highest precedence first (FR-013). failed and cancelled share a rank.
4const RANK: Record<AgentState, number> = {
5 pending: 7,
6 listening: 6,
7 'in-progress': 5,
8 failed: 4,
9 cancelled: 4,
10 completed: 3,
11 idle: 2,
12 unknown: 1,
13}
14
15export function resolveState(candidates: AgentState[]): AgentState {
16 let best: AgentState = 'unknown'
17 let bestRank = 0
18 for (const candidate of candidates) {
19 const rank = RANK[candidate]
20 if (rank > bestRank) {
21 best = candidate
22 bestRank = rank
23 }
24 }
25 return best
26}
27src/sprites.ts 302 lines1// Original pixel-art sprites (FR-004). Each state is a set of frames; a frame is a grid of
2// '#' (ink), 'o' (an eye, drawn bright) and '.' (empty) rows. Shape and pose carry the state; colour
3// is a separate hint. A sleeping figure's shut eyes are two bright dashes ('oo'); a fallen or hollow
4// figure has no 'o': its eyes are crossed or it has none.
5import type { AgentState } from '../types'
6
7export type SpriteSet = {
8 // Frames in playback order; a static state has one frame.
9 frames: string[][]
10 // Shown instead of the frames while a figure reacts to an inbound event (listening only).
11 reaction?: string[]
12}
13
14// 8 columns by 8 pixel rows: two pixel rows make one text row (renderSprite).
15export const SPRITES: Partial<Record<AgentState, SpriteSet>> = {
16 // Head down, eyes shut as two dashes, drifting z.
17 idle: {
18 frames: [
19 [
20 '....###.',
21 '......#.',
22 '.######.',
23 '.oo##oo.',
24 '.######.',
25 '..####..',
26 '..#..#..',
27 '..#..#..',
28 ],
29 [
30 '.....###',
31 '......#.',
32 '.######.',
33 '.oo##oo.',
34 '.######.',
35 '..####..',
36 '..#..#..',
37 '..#..#..',
38 ],
39 ],
40 },
41 // Eyes open, arms swinging while typing; legs take a walk step. 'o' is an eye pixel.
42 'in-progress': {
43 frames: [
44 [
45 '........',
46 '..####..',
47 '.######.',
48 '.#o##o#.',
49 '.######.',
50 '..####..',
51 '..#..#..',
52 '..#..#..',
53 ],
54 [
55 '........',
56 '..####..',
57 '.######.',
58 '.#o##o#.',
59 '.######.',
60 '..####..',
61 '.#....#.',
62 '#......#',
63 ],
64 ],
65 },
66 // Arms raised beside the head and a tall bang above; the bang blinks. Eyes wide open.
67 pending: {
68 frames: [
69 [
70 '......#.',
71 '......#.',
72 '#.####.#',
73 '##o##o##',
74 '#.####.#',
75 '..####..',
76 '..#..#..',
77 '..#..#..',
78 ],
79 [
80 '........',
81 '......#.',
82 '#.####.#',
83 '##o##o##',
84 '#.####.#',
85 '..####..',
86 '..#..#..',
87 '..#..#..',
88 ],
89 ],
90 },
91 // Arms up in a V with a tick mark; the tick sparkles. Happy open eyes.
92 completed: {
93 frames: [
94 [
95 '......#.',
96 '#.####.#',
97 '.#o##o#.',
98 '..####..',
99 '..####..',
100 '..#..#..',
101 '..#..#..',
102 '........',
103 ],
104 [
105 '.......#',
106 '#.####.#',
107 '.#o##o#.',
108 '..####..',
109 '..####..',
110 '..#..#..',
111 '..#..#..',
112 '........',
113 ],
114 ],
115 },
116 // Fallen flat: cracked body spread across the floor with x-shaped eyes. Static.
117 failed: {
118 frames: [
119 [
120 '........',
121 '........',
122 '........',
123 '.#.##.#.',
124 '..####..',
125 '########',
126 '########',
127 '........',
128 ],
129 ],
130 },
131 // Stopped inside a box with a dot: the run was cut short by the user or the system. Static.
132 cancelled: {
133 frames: [
134 [
135 '........',
136 '........',
137 '.######.',
138 '.#....#.',
139 '.#.##.#.',
140 '.######.',
141 '........',
142 '........',
143 ],
144 ],
145 },
146 // Antenna raised and bobbing (pulse over two frames); a reaction frame with the antenna flared
147 // out and both ears up, shown for the flash after an inbound event.
148 listening: {
149 frames: [
150 [
151 '....#...',
152 '....#...',
153 '.######.',
154 '.#o##o#.',
155 '..####..',
156 '..####..',
157 '..#..#..',
158 '..#..#..',
159 ],
160 [
161 '...#....',
162 '...#....',
163 '.######.',
164 '.#o##o#.',
165 '..####..',
166 '..####..',
167 '..#..#..',
168 '..#..#..',
169 ],
170 ],
171 reaction: [
172 '#......#',
173 '.#....#.',
174 '.######.',
175 '.#o##o#.',
176 '..####..',
177 '..####..',
178 '..#..#..',
179 '..#..#..',
180 ],
181 },
182 // Hollow figure with a question mark; still, never animated.
183 unknown: {
184 frames: [
185 [
186 '....###.',
187 '.....#..',
188 '....#...',
189 '........',
190 '..####..',
191 '..#..#..',
192 '..####..',
193 '..#..#..',
194 ],
195 ],
196 },
197}
198
199// Colour names per state, kept apart from the grids (the grids never depend on colour).
200export const STATE_COLOUR: Partial<Record<AgentState, string>> = {
201 idle: 'gray',
202 'in-progress': 'cyan',
203 pending: 'yellow',
204 completed: 'green',
205 failed: 'red',
206 cancelled: 'magenta',
207 listening: 'blue',
208 unknown: 'gray',
209}
210
211// The darker shade of each state colour, for the part of a figure that shows a filled context window.
212export const STATE_COLOUR_DARK: Partial<Record<AgentState, string>> = {
213 idle: '#5a5a5a',
214 'in-progress': '#00757f',
215 pending: '#8a6d00',
216 completed: '#1d7a1d',
217 failed: '#8a1f1f',
218 cancelled: '#7a1f7a',
219 listening: '#2c3fa6',
220 unknown: '#5a5a5a',
221}
222
223// Muted shades for background agents (engine forks and workflow agents), so they sit behind the rest.
224export const BACKGROUND_COLOUR = '#4a4a4a'
225export const BACKGROUND_COLOUR_DARK = '#383838'
226
227// Resample a square pixel grid to `size` by nearest neighbour, so one drawing serves every model size.
228export function scaleGrid(grid: string[], size: number): string[] {
229 const rows = grid.length
230 const cols = grid[0]?.length ?? 0
231 if (rows === size && cols === size) return grid
232 const out: string[] = []
233 for (let y = 0; y < size; y++) {
234 const src = grid[Math.min(rows - 1, Math.floor(((y + 0.5) * rows) / size))]!
235 let line = ''
236 for (let x = 0; x < size; x++) line += src[Math.min(cols - 1, Math.floor(((x + 0.5) * cols) / size))]
237 out.push(line)
238 }
239 return out
240}
241
242// The sprite for a state; states without a drawing yet fall back to unknown.
243export function spriteFor(state: AgentState): SpriteSet {
244 return SPRITES[state] ?? SPRITES.unknown!
245}
246
247// Ticks per animation frame (the clock runs about 4 fps, so this is about 2 frames per second).
248export const TICKS_PER_FRAME = 2
249
250// The grid drawn this tick: the reaction frame while reacting (when the state has one), else the
251// frame the tick's animation step lands on.
252export function frameOf(state: AgentState, tick: number, reacting: boolean, size = 8): string[] {
253 const set = spriteFor(state)
254 if (reacting && set.reaction) return scaleGrid(set.reaction, size)
255 const index = Math.floor(tick / TICKS_PER_FRAME) % set.frames.length
256 return scaleGrid(set.frames[index]!, size)
257}
258
259// Pixel rows to half-block text rows: each text row covers two pixel rows.
260// top + bottom ink -> █, top only -> ▀, bottom only -> ▄, neither -> space.
261export function renderSprite(grid: string[]): string[] {
262 const out: string[] = []
263 for (let y = 0; y < grid.length; y += 2) {
264 const top = grid[y]!
265 const bottom = grid[y + 1] ?? '.'.repeat(top.length)
266 let line = ''
267 for (let x = 0; x < top.length; x++) {
268 const t = top[x] === '#' || top[x] === 'o'
269 const b = bottom[x] === '#' || bottom[x] === 'o'
270 line += t && b ? '█' : t ? '▀' : b ? '▄' : ' '
271 }
272 out.push(line)
273 }
274 return out
275}
276
277// One text cell of a drawn sprite: the character, and which pixel kind shows in its top (foreground)
278// and bottom (background) half. An eye over ink is drawn as a bright foreground on an ink background.
279export type SpriteCell = { ch: string; fg: 'ink' | 'eye' | null; bg: 'ink' | 'eye' | null }
280
281// Like renderSprite, but keeps what each half-block is made of so the view can colour eyes apart.
282export function renderCells(grid: string[]): SpriteCell[][] {
283 const kind = (c: string | undefined): 'ink' | 'eye' | null => (c === '#' ? 'ink' : c === 'o' ? 'eye' : null)
284 const out: SpriteCell[][] = []
285 for (let y = 0; y < grid.length; y += 2) {
286 const top = grid[y]!
287 const bottom = grid[y + 1] ?? '.'.repeat(top.length)
288 const line: SpriteCell[] = []
289 for (let x = 0; x < top.length; x++) {
290 const t = kind(top[x])
291 const b = kind(bottom[x])
292 if (t === null && b === null) line.push({ ch: ' ', fg: null, bg: null })
293 else if (t === b) line.push({ ch: '█', fg: t, bg: null })
294 else if (t === null) line.push({ ch: '▄', fg: b, bg: null })
295 else if (b === null) line.push({ ch: '▀', fg: t, bg: null })
296 else line.push({ ch: '▀', fg: t, bg: b })
297 }
298 out.push(line)
299 }
300 return out
301}
302src/tiers.ts 107 lines1// What a figure's size and shading say about its agent: the model it runs on (size) and how full
2// its context window is (a darker fill rising from the feet). Pure helpers; no engine imports.
3import type { Agent, AgentState } from '../types'
4
5export type Tier = 'haiku' | 'sonnet' | 'opus' | 'fable'
6
7// Sprite size in pixels per side, per model tier: a bigger model is a bigger figure. The drawings are
8// 8 pixels, so the smallest tier is the drawing itself and the others are scaled up from it.
9export const TIER_PIXELS: Readonly<Record<Tier, number>> = { haiku: 8, sonnet: 10, opus: 12, fable: 16 }
10
11// A model id or alias -> its tier; null when the model is unknown or not one of the four.
12export function tierOf(model: string | null): Tier | null {
13 const name = (model ?? '').toLowerCase()
14 if (name.includes('haiku')) return 'haiku'
15 if (name.includes('sonnet')) return 'sonnet'
16 if (name.includes('opus')) return 'opus'
17 if (name.includes('fable')) return 'fable'
18 return null
19}
20
21// The sprite size for a model: its tier's, or the base drawing when the tier is unknown.
22export function pixelsOf(model: string | null): number {
23 const tier = tierOf(model)
24 return tier === null ? TIER_PIXELS.haiku : TIER_PIXELS[tier]
25}
26
27const DEFAULT_WINDOW = 200_000
28const LARGE_WINDOW = 1_000_000
29
30// The context window assumed for a model: 1M when its id says so or it is a 5.5 model, else 200k. The engine does not
31// report a subagent's window, so this is an assumption the hover card marks with a tilde.
32export function windowOf(model: string | null): number {
33 return /\[1m\]|-1m\b|-5-5\b/i.test(model ?? '') ? LARGE_WINDOW : DEFAULT_WINDOW
34}
35
36// How full the agent's context is, 0..1, or null while nothing is known.
37export function fillOf(agent: Pick<Agent, 'model' | 'contextTokens'>): number | null {
38 if (agent.contextTokens === null) return null
39 return Math.max(0, Math.min(1, agent.contextTokens / windowOf(agent.model)))
40}
41
42// 12345 -> 12.3k, 1234567 -> 1.2M.
43export function compact(n: number): string {
44 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
45 if (n >= 10_000) return `${Math.round(n / 1000)}k`
46 if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
47 return String(Math.round(n))
48}
49
50const STATE_WORD: Readonly<Record<AgentState, string>> = {
51 idle: 'asleep',
52 'in-progress': 'working',
53 pending: 'needs you',
54 listening: 'listening',
55 completed: 'done',
56 failed: 'failed',
57 cancelled: 'cancelled',
58 unknown: 'unknown',
59}
60
61// Wrap text to a width on word boundaries; a word longer than the width is split.
62export function wrap(text: string, width: number): string[] {
63 const lines: string[] = []
64 let line = ''
65 for (const word of text.split(/\s+/).filter(Boolean)) {
66 let w = word
67 while ([...w].length > width) {
68 if (line) {
69 lines.push(line)
70 line = ''
71 }
72 lines.push([...w].slice(0, width).join(''))
73 w = [...w].slice(width).join('')
74 }
75 if (!line) line = w
76 else if ([...line].length + 1 + [...w].length <= width) line += ` ${w}`
77 else {
78 lines.push(line)
79 line = w
80 }
81 }
82 if (line) lines.push(line)
83 return lines.length > 0 ? lines : ['']
84}
85
86// The hover card for one agent: its full title, then what it is and what it has used.
87export function detailsOf(agent: Agent, shown: AgentState): string[] {
88 const lines: string[] = [STATE_WORD[shown]]
89 if (agent.note) lines.push(agent.note)
90 const tier = tierOf(agent.model)
91 const model = agent.model ? (tier ? `${agent.model} (${tier})` : agent.model) : 'model unknown'
92 lines.push(model)
93 const u = agent.usage
94 if (u.input + u.output + u.cacheRead + u.cacheWrite > 0) {
95 lines.push(`tokens ${compact(u.input + u.cacheWrite)} in, ${compact(u.output)} out`)
96 if (u.cacheRead > 0) lines.push(`cache read ${compact(u.cacheRead)}`)
97 } else {
98 lines.push('tokens not reported yet')
99 }
100 const fill = fillOf(agent)
101 if (fill !== null) lines.push(`context ~${Math.round(fill * 100)}% of ${compact(windowOf(agent.model))}`)
102 if (agent.spawnedBy) lines.push(`started by ${agent.spawnedBy}`)
103 else if (agent.independent) lines.push('not started by an agent here')
104 if (agent.addressable) lines.push('reusable')
105 return lines
106}
107