SLOPSHOPPER

colony

Side pane showing agents as pixel creatures

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-09openrory/colony-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · colony
│ ┃ Colony ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ▄ ▄▄▄▄▄▄▀▄▄ │ ┃ ▀▄▄▀███▀▀▄▀▀ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ██████ │ ┃ █▀▀▀██ ✻ Worked for 42s · done 4:20 PM │ ┃ █ ██ │ ┃ › /colony │ ┃ main ⎿ colony: Colony pane shown. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Colony
▄ ▄▄▄▄▄▄▀▄▄ ▀▄▄▀███▀▀▄▀▀ ██████ █▀▀▀██ █ ██ main
README

colony

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.

What you see

StateLookMeaning
Idlehead down, floating zthe agent is asleep / has nothing to do
In progresseyes open, movingthe agent is working
Pendingarms up, blinking !the agent needs you: a question, or an approve/reject
Completedarms in a V, tickthe agent finished
Failed / cancelledfallen figure / boxed dotthe agent ended with an error or was stopped
Listeningantenna pulsing, [source] tagthe agent is waiting for an outside event (see limits)
Unknownoutline with ?the mod cannot tell what the agent is doing
  • Size follows the model. A Haiku agent is the smallest figure; Sonnet, Opus and Fable (rarely used) draw progressively bigger. A figure whose model is not known yet is the smallest size.
  • A darker fill shows context. The body fills from the feet up in a darker shade of its state colour as the agent's context window fills. The fill is an estimate: the engine does not report a subagent's context window while it runs, so the mod estimates it from the agent's transcript and assumes a 200k window (1M for a [1m] model and for the 5.5 models).
  • Hover for details. Point at a figure to see a card with its full title, state, model, tokens used and context estimate. The label under the figure is cut to fit; the card is not. Cost per agent is not shown, because the engine only reports cost for the whole session.
  • Working agents wander. Agents that are working drift slowly side to side, sleeping and finished ones stay put, and agents that are talking walk toward each other.
  • Subagents appear under the agent that started them, with a connector. Reusable agents (named agents and teammates) rest as a sleeping figure when they finish instead of being removed. Agents no orchestrator here started (a teammate in its own pane, for example) have no connector.
  • Eyes show on awake figures; a sleeping figure's eyes are shut, drawn as two dashes, and a failed figure's are crossed.
  • Agents that are talking (a subagent starting or returning a result, a message between agents, a parent waiting on a subagent) move closer together and exchange a stream of 0s and 1s. They drift apart when it ends.
  • Many agents: older finished agents collapse into a +N done row. Agents that need you are never hidden.
  • States are told apart by shape and pose, not only colour, and there is a reduced-motion option.

The mod only watches. It never changes your tools, prompts, permissions or output.

Install

Type this at the prompt of a Claude Code terminal session:

/plugin install colony --marketplace openrory/colony-mod

Then:

  1. Answer y to Add marketplace? (github:openrory/colony-mod).
  2. Choose a scope. User keeps the mod active in every later session.
  3. Set the options when asked (or accept the defaults).

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.

Try it without installing

git clone https://github.com/openrory/colony-mod.git
claude --plugin-dir ./colony-mod

Use

  • The pane opens by itself the first time a subagent appears or an agent needs your answer.
  • /colony shows or hides the pane. If you close it yourself it stays closed until you run /colony again.
  • When Claude Code cannot seat the pane (a narrow terminal, roughly under 144 columns), the mod does not draw it. It then shows a summary on the status line, such as colony: 2 working, 1 needs you, and a toast when something needs you. Widen the terminal, or run /colony, to see the pane.

Options

OptionDefaultWhat it does
Reduced motionoffstatic frames only; no animation timer
Retention seconds30how long finished agents stay in the pane before they are removed
Other sessions folderemptyabsolute 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.

Update and uninstall

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.

Troubleshooting

MessageCause
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 appearsthe terminal may be narrower than the pane needs; widen it or run /colony

Known limits

  • Listening is only what the mod can see. The mods API cannot list triggers configured before the session. An agent shows as listening after it starts a 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.
  • Which subagent is asking? If the engine does not say which subagent raised a permission request, the mod attributes it to the most recently active agent.
  • Failed vs. completed subagents depend on the engine's agent list being up to date when a subagent stops.
  • The mods API is early access and may change between Claude Code releases.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

Design documents live in specs/001-agent-colony-pane/.

Source 14 files
hooks/register.tsx 342 lines
1import { 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}
342
src/model.ts 536 lines
1import 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}
536
src/peers.ts 48 lines
1// 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}
48
src/observe.ts 26 lines
1// 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}
26
src/layout.ts 365 lines
1// 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}
365
src/runtime.ts 94 lines
1// 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}
94
src/signals.ts 292 lines
1// 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}
292
src/context.ts 13 lines
1// 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}
13
src/view.tsx 201 lines
1// 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}
201
src/priority.ts 27 lines
1import 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}
27
src/sprites.ts 302 lines
1// 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}
302
src/tiers.ts 107 lines
1// 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