// Pure data: defaults, reducers, formatting and layout math. Nothing here touches `$`, so every // behaviour is testable directly (the test kit cannot raise a subagent's tool call or a // permission check inside a call; these functions are what the hooks apply). import type { AgentCard, Architect, Bucket, Check, Gate, Layout, LogLine, Loop, Main, Moment, Receipt, Roster, Tally, ToolNote, Turn, Usage, View, } from '../types' export const SCHEMA_VERSION = 2 // ---------------------------------------------------------------- defaults export const DEFAULT_MAIN: Main = { model: '', effort: '', mode: '', steps: 0, isRunning: false, speed: null } export const DEFAULT_USAGE: Usage = { pct: null, tokens: null, window: 0, compactWindow: null, compactFor: 0, compactMisses: 0, costUsd: null, limits: [], compactions: 0, lastCompactAt: null, } export const DEFAULT_ARCHITECT: Architect = { consults: [], ids: [], seen: [], lastAdvice: '' } const ZERO: Tally = { rule: 0, ask: 0, cleared: 0, deny: 0 } export const DEFAULT_GATE: Gate = { recent: [], totals: { file: ZERO, shell: ZERO, other: ZERO } } export const DEFAULT_TURN: Turn = { edits: 0, errorStreak: 0, errors: 0, isReviewing: false, startedAt: 0, costAtStart: null } export const DEFAULT_VIEW: View = { expanded: null, gateOpen: null, layout: null } export const DEFAULT_ROSTER: Roster = { architectTypes: [] } const isObject = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v) /** A stored object merged over its defaults, so a value saved under an older shape still reads. */ export const normalize = (def: T, stored: unknown): T => isObject(stored) ? ({ ...def, ...stored } as T) : def /** A stored list, or empty when what is stored is not a list. */ export const listOf = (stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : []) export const normalizeGate = (stored: unknown): Gate => { const g = normalize(DEFAULT_GATE, stored) const totals = normalize(DEFAULT_GATE.totals, g.totals) return { recent: listOf(g.recent), totals: { file: normalize(ZERO, totals.file), shell: normalize(ZERO, totals.shell), other: normalize(ZERO, totals.other) }, } } export const normalizeCard = (stored: unknown): AgentCard => normalize( { id: '', type: 'agent', model: '', description: '', status: 'running', spawnedAt: 0, endedAt: null, ctx: 0, out: 0, steps: 0, lastStop: null, tools: [], answer: '', }, stored, ) export const normalizeLog = (stored: unknown): LogLine[] => listOf>(stored).map(l => ({ at: typeof l.at === 'number' ? l.at : 0, who: String(l.who ?? ''), text: String(l.text ?? ''), agentId: typeof l.agentId === 'string' ? l.agentId : null, kind: l.kind === 'error' || l.kind === 'consult' || l.kind === 'done' ? l.kind : 'info', })) // ---------------------------------------------------------------- config export type Panel = 'main' | 'architect' | 'gate' | 'agents' | 'loops' | 'receipt' | 'log' const PANELS: readonly Panel[] = ['main', 'architect', 'gate', 'agents', 'loops', 'receipt', 'log'] export type Config = { architect: RegExp architectLabel: string gateLabel: string panels: Panel[] motion: boolean moments: boolean matchDescriptions: boolean maxCards: number paneColumns: number layout: Layout palette: Palette openOnStart: boolean statusLine: boolean } const safeRegExp = (source: string, fallback: string) => { try { return new RegExp(source || fallback, 'i') } catch { return new RegExp(fallback, 'i') } } /** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */ export const parseConfig = (o: Readonly>): Config => { const str = (k: string, d: string) => (typeof o[k] === 'string' && o[k] !== '' ? (o[k] as string) : d) const bool = (k: string, d: boolean) => (typeof o[k] === 'boolean' ? (o[k] as boolean) : d) const panels = str('panels', PANELS.join(',')) .split(',') .map(s => s.trim()) .filter((p): p is Panel => (PANELS as readonly string[]).includes(p)) const layout = str('layout', 'auto') const max = typeof o.maxCards === 'number' ? Math.round(o.maxCards) : 3 const cols = typeof o.paneColumns === 'number' && Number.isFinite(o.paneColumns) ? Math.round(o.paneColumns) : 66 return { architect: safeRegExp(str('architectPattern', ''), 'advisor|architect'), architectLabel: str('architectLabel', 'ARCHITECT'), gateLabel: str('gateLabel', 'GATE'), panels: panels.length > 0 ? [...new Set(panels)] : [...PANELS], motion: str('motion', 'while-active') !== 'off', moments: bool('moments', true), matchDescriptions: bool('matchDescriptions', false), maxCards: Math.min(6, Math.max(1, max)), // The body clamps its drawing at 40 columns, so a narrower dock would only clip it. paneColumns: Math.max(40, cols), layout: layout === 'compact' || layout === 'wide' || layout === 'mini' ? layout : 'auto', palette: str('palette', 'theme') === 'pastel' ? 'pastel' : 'theme', openOnStart: bool('openOnStart', true), statusLine: bool('statusLine', true), } } // ---------------------------------------------------------------- palette export type Palette = 'theme' | 'pastel' export type Colors = Record<'main' | 'agent' | 'gate' | 'cleared' | 'arch' | 'amber' | 'warn' | 'dim' | 'faint' | 'text', string> /** * `theme` names the person's own theme colours (they follow light, dark and colour-blind themes); * `pastel` is fixed hex tuned for dark terminals. */ export const PALETTES: Record = { theme: { main: 'claude', agent: 'suggestion', gate: 'success', cleared: 'permission', arch: 'merged', amber: 'warning', warn: 'error', dim: 'inactive', faint: 'subtle', text: 'text', }, pastel: { main: '#7dd3fc', agent: '#93c5fd', gate: '#86efac', cleared: '#5eead4', arch: '#c4b5fd', amber: '#fcd34d', warn: '#fca5a5', dim: '#6b7280', faint: '#3f4654', text: '#e5e7eb', }, } /** SVG cannot name theme keys: mid-tone colours that read on light and dark backgrounds. */ export const SVG_COLORS = { running: '#3b82f6', done: '#16a34a', failed: '#dc2626', other: '#8b5cf6', label: '#6b7280' } // ---------------------------------------------------------------- formatting /** `claude-opus-5-5` → `Opus 5.5`; anything else is shown as given. */ /** * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`, * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` → * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters. */ export const prettyModel = (id: string) => { if (!id) return '—' const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1) const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : '' const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id) if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}` const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id) if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}` return shorten(id, 22) } export const shorten = (s: string, n: number) => { const one = s.replace(/\s+/g, ' ').trim() return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one } export const kTokens = (n: number) => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n) export const fmtDuration = (ms: number) => { const s = Math.max(0, Math.round(ms / 1000)) if (s < 60) return `${s}s` const m = Math.floor(s / 60) return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m` } /** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */ export const fmtTimer = (ms: number) => { const s = Math.max(0, Math.floor(ms / 1000)) const m = Math.floor(s / 60) return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}` } export const fmtClock = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 8) : '--:--:--') /** `1 error`, `2 errors`. */ export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}` export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`) /** * How soon a request began to answer: from when it was sent to its first piece of response; null when * none came. No output rate: the reported tokens include thinking, which may arrive unstreamed before * that first piece, so tokens over the visible stream read in the thousands per second. */ export const stepSpeed = (sentAt: number, firstAt: number | null) => (firstAt === null ? null : { ttftMs: Math.max(0, firstAt - sentAt) }) /** `ttft 0.8s`. */ export const fmtSpeed = (s: { ttftMs: number }) => `ttft ${(s.ttftMs / 1000).toFixed(1)}s` /** The compaction window a context breakdown reports: its `rawMaxTokens` when auto-compaction is on and that is smaller than the model's `window`; else null. */ export const compactWindowOf = (b: { rawMaxTokens: number; isAutoCompactEnabled: boolean } | null | undefined, window: number): number | null => b && b.isAutoCompactEnabled && b.rawMaxTokens > 0 && b.rawMaxTokens < window ? b.rawMaxTokens : null /** * What the context gauge measures: the compaction window when one is set smaller than the model's, * else the model's window, and the fill of it from the real `tokens` reading. No gauge (null) without one. */ export const contextFill = (u: Pick): { pct: number | null; window: number } => { const window = u.compactWindow !== null && u.compactWindow > 0 && u.compactWindow < u.window ? u.compactWindow : u.window return { window, pct: u.tokens !== null && window > 0 ? (u.tokens * 100) / window : null } } /** Reads of the compaction window that may miss (no session bound yet) before a model window stops asking. */ export const COMPACT_TRIES = 3 /** * A measurement of model window `window`: whether it reads the compaction window (not read for this * window yet, fewer than COMPACT_TRIES misses), and the usage with that read claimed. A new window * starts its misses afresh. */ export const claimCompactRead = (u: Usage, window: number): { usage: Usage; isClaimed: boolean } => { const misses = u.window === window ? u.compactMisses : 0 const isClaimed = window > 0 && u.compactFor !== window && misses < COMPACT_TRIES return { usage: { ...u, compactFor: isClaimed ? window : u.compactFor, compactMisses: misses }, isClaimed } } /** * The usage after a compaction-window read for model window `window`. Dropped when a newer claim took * its place (a model switch); a read that failed or came back with no breakdown is a miss, asked * again at the next measurement. */ export const afterCompactRead = ( u: Usage, window: number, b: { rawMaxTokens: number; isAutoCompactEnabled: boolean } | null | undefined, ): Usage => u.compactFor !== window ? u : b ? { ...u, compactWindow: compactWindowOf(b, window), compactMisses: 0 } : { ...u, compactWindow: null, compactFor: 0, compactMisses: u.compactMisses + 1 } /** A new main-loop model may compact in another window (`modelSettings`): read afresh at the next measurement. */ export const forgetCompactRead = (u: Usage): Usage => ({ ...u, compactWindow: null, compactFor: 0, compactMisses: 0 }) /** A gauge of `width` cells: ▰ filled, ▱ empty. */ export const gauge = (pct: number, width: number) => { const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width))) return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) } } /** A rate-limit reading as the pane keeps it, its ISO reset time turned into ms since the epoch. */ export const limitOf = (r: { kind: string; percentUsed: number; resetsAt?: string }) => { const at = r.resetsAt ? Date.parse(r.resetsAt) : NaN return { kind: r.kind, pct: r.percentUsed, resetsAt: Number.isFinite(at) ? at : null } } /** Time left until a window resets, its two largest units: `25m`, `3h10m`, `4d2h`; empty when unknown or past. */ export const fmtResetIn = (resetsAt: number | null | undefined, now: number) => { if (resetsAt === null || resetsAt === undefined || resetsAt <= now) return '' const m = Math.max(1, Math.floor((resetsAt - now) / 60_000)) const d = Math.floor(m / 1440) const h = Math.floor((m % 1440) / 60) if (d > 0) return `${d}d${h}h` return h > 0 ? `${h}h${String(m % 60).padStart(2, '0')}m` : `${m}m` } /** A rate-limit window's short name: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */ export const limitLabel = (kind: string) => kind .replace(/five[_ -]?hours?/i, '5h') .replace(/seven[_ -]?days?/i, '7d') .replace(/[_-]+/g, ' ') .trim() // ---------------------------------------------------------------- redaction const SECRETS: [RegExp, string][] = [ [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'], [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'], [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'], [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'], [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'], [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'], [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'], ] /** What the gate drill-down may store: credentials masked before anything is written to state. */ export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s) // ---------------------------------------------------------------- tools and gate const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit', 'Glob', 'Grep']) const SHELL_TOOLS = new Set(['Bash', 'PowerShell']) export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']) export const bucketOf = (tool: string): Bucket => FILE_TOOLS.has(tool) ? 'file' : SHELL_TOOLS.has(tool) ? 'shell' : 'other' /** One line saying what a call was about: its command, path or pattern; redacted. */ export const describeInput = (tool: string, input: unknown) => { const i = isObject(input) ? input : {} const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : '' const what = typeof i.command === 'string' ? i.command : path || (typeof i.pattern === 'string' ? i.pattern : typeof i.url === 'string' ? i.url : typeof i.description === 'string' ? i.description : '') return redact(what ? `${tool} → ${what}` : tool) } /** Keeps the last `max` checks, but never drops a pending ask: its settle must still find it. */ export const trimRecent = (list: Check[], max: number): Check[] => { let extra = list.length - max if (extra <= 0) return list const out: Check[] = [] for (const c of list) { if (extra > 0 && c.verdict !== 'ask') { extra -= 1 continue } out.push(c) } return out.slice(-max * 2) } export const recordCheck = (g: Gate, c: Check): Gate => { const t = g.totals[c.bucket] return { recent: trimRecent([...g.recent, c], 80), totals: { ...g.totals, [c.bucket]: { ...t, [c.verdict]: t[c.verdict] + 1 } }, } } /** An `ask` settled by the call that followed it: it ran (cleared) or was refused (deny). */ export const settleCheck = (g: Gate, id: string, didRun: boolean): Gate => { const c = g.recent.find(r => r.id === id && r.verdict === 'ask') if (!c) return g const verdict = didRun ? 'cleared' : 'deny' const t = g.totals[c.bucket] return { recent: g.recent.map(r => (r === c ? { ...r, verdict } : r)), totals: { ...g.totals, [c.bucket]: { ...t, ask: Math.max(0, t.ask - 1), [verdict]: t[verdict] + 1 } }, } } export const gateSummary = (g: Gate) => { const all = (['file', 'shell', 'other'] as const).reduce( (s, k) => ({ rule: s.rule + g.totals[k].rule, ask: s.ask + g.totals[k].ask, cleared: s.cleared + g.totals[k].cleared, deny: s.deny + g.totals[k].deny, }), { ...ZERO }, ) return { ...all, total: all.rule + all.ask + all.cleared + all.deny } } // ---------------------------------------------------------------- turn and architect /** * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its * own); edits count from every loop, so delegated work still reaches "before done". */ export const afterCall = (t: Turn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): Turn => ({ ...t, errorStreak: c.inSubagent ? t.errorStreak : c.hasFailed ? t.errorStreak + 1 : 0, errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0), edits: t.edits + (c.isEdit ? 1 : 0), }) /** Which of the architect's three moments a consult falls at: an inference over this turn so far. */ export const momentOf = (t: Pick): Moment => t.errorStreak >= 2 ? 'error repeats' : t.edits === 0 ? 'before a plan' : 'before done' export const startConsult = (a: Architect, c: { id: string; at: number; moment: Moment; via: string }): Architect => a.consults.some(x => x.id === c.id) ? a : { ...a, consults: [...a.consults, { ...c, endAt: null }].slice(-40) } /** Ends the open consult (the latest without an end), or the one named. */ export const endConsult = (a: Architect, at: number, advice: string | null, id?: string): Architect => { const open = [...a.consults].reverse().find(c => c.endAt === null && (id === undefined || c.id === id)) return { ...a, consults: a.consults.map(c => (c === open ? { ...c, endAt: at } : c)), lastAdvice: advice ?? a.lastAdvice, } } export const isAdvising = (a: Architect) => a.consults.some(c => c.endAt === null) /** A one-row timeline of consults across `width` cells: ◆ a consult, ━ while it ran. */ export const consultTimeline = (a: Architect, now: number, width: number) => { if (a.consults.length === 0 || width < 4) return '─'.repeat(Math.max(0, width)) const first = a.consults[0]?.at ?? now const span = Math.max(1, now - first) const cells = Array.from({ length: width }, () => '─') for (const c of a.consults) { const from = Math.min(width - 1, Math.floor(((c.at - first) / span) * (width - 1))) const to = Math.min(width - 1, Math.floor((((c.endAt ?? now) - first) / span) * (width - 1))) for (let i = from + 1; i <= to; i += 1) cells[i] = '━' cells[from] = '◆' } return cells.join('') } export const receiptOf = (t: Turn, o: { durationMs: number; agentsSince: number; costNow: number | null; reason: string }): Receipt => ({ durationMs: o.durationMs, agents: o.agentsSince, edits: t.edits, errors: t.errors, costDelta: o.costNow !== null && t.costAtStart !== null && o.costNow - t.costAtStart >= 0.005 ? o.costNow - t.costAtStart : null, reason: o.reason, }) // ---------------------------------------------------------------- agents and loops type StepUsage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null /** A card after one of its model requests: its context is the latest step's whole input; output adds up. */ export const applyStep = (c: AgentCard, s: { model: string; usage: StepUsage; stopReason: string | null }): AgentCard => { const u = s.usage ?? {} const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) return { ...c, model: c.model || s.model, steps: c.steps + 1, ctx: ctx > 0 ? ctx : c.ctx, out: c.out + (u.output_tokens ?? 0), lastStop: s.stopReason, } } export const noteTool = (c: AgentCard, n: ToolNote): AgentCard => ({ ...c, tools: [...c.tools, n].slice(-3) }) /** A surface module's fault key, `desktop:rail.tsx`, from `hooks/rail.tsx` or `hooks\rail.tsx` alike. */ export const faultKey = (surface: string, module: string) => `${surface}:${module.split(/[\\/]/).pop()}` /** A card whose agent was resumed after it finished (SendMessage): running again, its clock from its first spawn. */ export const resumeCard = (c: AgentCard): AgentCard => ({ ...c, status: 'running', endedAt: null }) /** * A spawned card joins the list. An id already there is that agent resumed: its card runs again * where it stands, with its tallies and the new task and model, so no two cards share keys. */ export const addCard = (cards: AgentCard[], card: AgentCard): AgentCard[] => cards.some(c => c.id === card.id) ? cards.map(c => (c.id === card.id ? resumeCard({ ...c, description: card.description || c.description, model: card.model || c.model }) : c)) : [...cards, card].slice(-24) export const stepLoop = (loops: Loop[], id: string, at: number): Loop[] => { const found = loops.find(l => l.id === id) const next = found ? loops.map(l => (l === found ? { ...l, steps: l.steps + 1, lastAt: at } : l)) : [...loops, { id, steps: 1, firstAt: at, lastAt: at, isDone: false }] return next.slice(-60) } export const LOOP_ACTIVE_MS = 15_000 export const isLoopActive = (l: Loop, now: number) => !l.isDone && now - l.lastAt < LOOP_ACTIVE_MS /** Swimlane geometry: each agent's bar on one shared axis from the first spawn to now. */ export const lanes = (cards: AgentCard[], now: number, width: number) => { const start = Math.min(...cards.map(c => c.spawnedAt).filter(n => n > 0), now) const span = Math.max(1, now - start) return cards.map(c => { const from = Math.floor(((Math.max(c.spawnedAt, start) - start) / span) * width) const to = Math.max(from + 1, Math.ceil((((c.endedAt ?? now) - start) / span) * width)) return { id: c.id, before: Math.min(from, width), bar: Math.min(to, width) - Math.min(from, width), after: width - Math.min(to, width) } }) } // ---------------------------------------------------------------- layout /** How many log lines fit: what the other panels leave, never fewer than 4 nor more than 8. */ export const logRows = (bodyRows: number, used: number) => Math.max(4, Math.min(8, bodyRows - used - 3)) /** Legend items that fit on one row of `width` cells, in order; the rest are dropped. */ export const fitLegend = (items: T[], width: number) => { const out: T[] = [] let used = 0 for (const it of items) { const w = it.label.length + 4 if (used + w > width) break out.push(it) used += w } return out } /** The cells a string takes in a terminal: one per character. */ export const cellWidth = (s: string) => [...s].length /** * How many pieces of a line fit in `room` cells, in order from the start: each piece is `lead` extra cells * then `width`, and pieces sit `gap` cells apart. The rest are left out, as the legend's items are. */ export const piecesThatFit = (pieces: readonly { width: number; lead?: number }[], room: number, gap: number) => { let used = 0 for (let i = 0; i < pieces.length; i += 1) { const p = pieces[i]! const next = used + (i > 0 ? gap : 0) + (p.lead ?? 0) + p.width if (next > room) return i used = next } return pieces.length } /** * How many marks (■ ✗ ● ○ ✓) a row of `cells` columns holds. A terminal draws each mark in one * cell; a graphical surface draws them wider (■ measured about 1.4 cells on the desktop app), so * there a row holds fewer and its newest marks still show. */ export const marksIn = (cells: number, isTerminal: boolean) => (isTerminal ? cells : Math.floor(cells / 1.5)) /** A card's title row: the task in the agent's own words, the type only when there is none. */ export const cardTitle = (c: AgentCard) => c.description || c.type /** A title split over two rows at a word boundary: `first` cells on row one, `rest` on row two. */ export const titleLines = (title: string, first: number, rest: number): [string, string] => { const t = title.replace(/\s+/g, ' ').trim() if (t.length <= first) return [t, ''] const cut = t.lastIndexOf(' ', first) const at = cut > 0 ? cut : first return [t.slice(0, at).trim(), shorten(t.slice(at), rest)] } /** * What a turn's opening text was, for the log: the person's words, or for a turn the engine * opened with a tagged message (a subagent's hand-back, a task notification), that message's kind. */ export const promptLine = (text: string): { who: string; text: string } => { const tag = /^\s*<([a-z][\w-]*)/i.exec(text)?.[1] if (!tag) return { who: 'you', text: shorten(text, 70) } const from = /\bfrom="([^"]+)"/.exec(text)?.[1] return { who: 'engine', text: shorten(`${tag.replace(/[-_]/g, ' ')}${from ? ` from ${from.slice(0, 8)}` : ''}`, 70) } } /** * A subagent's hand-back message: who sent it and the first line of what it said. The report * follows a framing header in the message; without one, the first line after the opening tag. */ export const handbackOf = (text: string): { from: string; body: string } | null => { const from = /^\s*]*>/, '') const body = rest .split('\n') .map(l => l.trim()) .find(l => l && !l.startsWith('[') && !l.startsWith('<') && !l.startsWith(' { const first = report.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('[') && !l.startsWith('<')) ?? '' return shorten(first.replace(/\*\*|__/g, '').replace(/^[#>*\s-]+/, ''), 160) } export const elapsedOf = (c: AgentCard, now: number) => (c.endedAt ?? now) - c.spawnedAt