Agent-team swarm: message log, roster, /swarm pane, lead wake modes, and the swarm protocol skill

A Claude Code mod plus a skill for leading a self-organising agent team: teammates message each other directly with SendMessage, and the lead sets the goal and is woken only when it is needed.
Status: experimental. Needs CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 and Claude Code ≥ 2.1.287; built and tested against 2.1.296. Mod events and methods change between releases.
This section is written for AI agents: orchestrators looking for a way to lead a Claude Code agent team. Humans: skip to For humans.
What it does for you as the lead. When you orchestrate an agent team, every message a teammate sends you, every idle notice and every protocol message ([PROPOSE], [CLAIM], [HANDOFF], [REVIEW]) costs you a full turn over your whole context. This mod sits inside Claude Code and keeps that traffic out of your context budget: coordination happens between teammates, and you hear only @lead mentions, [ASK], [BLOCK], [DONE], untyped text, idle notices with a failure reason and real alarms. It runs in Claude Code's own hooks, with no extra runtime, server or queue. It also:
#412 · 17:04:12), so a duplicate [CLAIM] is settled by the lower stamp, and tells a later claimant who holds the slice;[WAIT] <path>) or at a time ([WAIT] until <ISO time>), so nobody polls;.swarm/log.jsonl and the team to .swarm/roster.json, which you can read.When to use it. A team of 3–7 teammates under CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 working on research, review, exploration or a creative build. For shipping code, one builder with independent reviewers did better (see What we measured).
Measured benefit. In a 7-agent run the lead was woken once while 61 messages to it were held. Claims are ordered by stamps, stalls are nudged before the lead hears of them, and teammates use [WAIT] instead of polling.
Install and check (shell; then start a new session):
claude plugin install agent-swarm --marketplace rahimi/claude-code-swarm
claude plugin list # agent-swarm is listed and enabled
export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
In the session, /swarm status answers with one line (swarm: yes/no, quiet: on, the log path). Without installing: git clone https://github.com/rahimi/claude-code-swarm and start claude --plugin-dir ./claude-code-swarm.
The instructions for the lead are the skill swarm-protocol (agent-swarm:swarm-protocol, skills/swarm-protocol/SKILL.md): the brief, self-chosen code names, [TYPE] subject messages, claims, reviewer veto, [LOCK], [WAIT] and a fresh agent for the final synthesis. Load it before you spawn the team, e.g. Use the swarm-protocol skill. Goal: … Budget: 4 agents, 30 min. Kill criterion: … Done when: ….
https://github.com/user-attachments/assets/6c096b84-152b-4982-863a-38c02b2a3531
/swarm opens a pane with two looks. Look A (the default, key 1) is the working view: one card per teammate with its state, time in it, current action and tool-call count, the file it last touched, and a bar of its messages over time; the claim cards; the lead's wakes, delivered and held; the message list. Look B (key 2) shows the team as a star map around the lead, each message flying as a comet, for watching and demos. Both draw in any 24-bit colour terminal and add pictures where the terminal draws them (/swarm level auto|basic|rich).
◉ S W A R M LIVE · t1–t2 T+6:12 01:21:40 ● live
A G E N T S 2 · 1 running C L A I M S
▌Atlas t1 ↑3 ↓4 opus ╭ src/parser.ts ─────────── BUILDING ╮
▌● RUNNING 0:42 ✎ src/parser.ts │ build Atlas │
▌▁▃█▂▁▁▁▅▂▁▁▁▁▃ │ review ◦Sage │
▌Sage t2 ↑2 ↓3 sonnet ╰────────────────────────────────────╯
▌● IDLE 2:03 ◦ README.md
▌▁▂▁▁▄▁▁▁▁▁▁▁▁▁
LEAD ▲ 2 woken ▽ 5 held back
M E S S A G E S
01:21:40 Atlas → Sage HANDOFF src/parser.ts
01:17:02 Sage → Atlas CLAIM review src/parser.ts
/swarm replay .swarm/log.jsonl plays a finished run back in the pane. While a swarm runs, the status line reads ◇ swarm 3 live · 1 done; when it ends, a toast gives its totals (swarm stood down: 4 agents · 52 messages · 12m · lead woken 3, held 18).
One project, a handful of runs: treat these as data points.
One session: CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 claude --plugin-dir <path to this directory>.
Permanently (Claude Code 2.1.287+), from your terminal:
claude plugin install agent-swarm --marketplace rahimi/claude-code-swarm
or inside Claude Code:
/plugin marketplace add rahimi/claude-code-swarm
/plugin install agent-swarm@claude-code-swarm
Look B's rich sky uses python3 with numpy for smooth motion; without them it falls back to a still PNG sky (/swarm status says which). Nothing else needs Python.
Add .swarm/ to the project's .gitignore.
/swarm open the pane
/swarm wake all|mentions|digest|off
/swarm quiet on|off lead filter, default on
/swarm stall <minutes> 0 turns the watchdog off
/swarm claim <minutes> claim watchdog delay, 0 turns it off
/swarm level auto|basic|rich pane level, default auto
/swarm deliverable <path> adds a deliverable the claim watchdog checks for a builder
/swarm status one line of text (works where nothing draws, e.g. claude -p)
/swarm replay <log.jsonl> [--look A|B] [--level basic|rich] [--speed N] [--from <ISO>] [--to <ISO>]
/swarm replay stop
/swarmctl is a second name for the same command, for sessions where another plugin also registers /swarm.
If another plugin already uses /swarm, this one still loads and answers on /swarmctl; /swarmctl status and the /swarmctl help say /swarm is taken by <plugin>; use /swarmctl. In an interactive session it asks you once, a second after start (or after the lead's turn ends): keep both (stored, never asked again), see the command that removes the other plugin, or ask again next session. It never uninstalls anything. Two plugins with the same name cannot both load.
Wake mode, the lead filter, stall and claim minutes and the pane level persist in the plugin's store across sessions. replay plays a recorded .swarm/log.jsonl in the pane at --speed (default 30×, a quiet stretch at most 1.5 s), one run per team (teammates that messaged each other, from 30 s before their first peer message); without --from/--to it plays the most recent run; its reply lists the runs with their --from/--to, ▶ on the one playing. A replay sends nothing. A finished replay holds its last frame for 3 s, marked replay finished, then the pane shows the live view. When a live team spawns a teammate or sends a peer message during a replay, the header says live swarm running and a [ live ] button (0) stops the replay and shows it. Start a swarm by asking the lead to use the swarm-protocol skill, e.g. Use the swarm-protocol skill. Goal: … Budget: 4 agents, 30 min. Kill criterion: … Done when: ….
mentionsThe team mailbox already delivers every message addressed to the lead (and idle notifications) as a new lead turn. Waking the lead again for those would double each one, so no mode ever wakes for a message to or from the lead. The modes govern only teammate-to-teammate traffic, which the lead otherwise never sees:
| Mode | Wakes the lead |
|---|---|
mentions (default) | for peer messages containing @lead, @orchestrator, @main or @team-lead, coalesced over 3 s |
all | for every peer message, coalesced over 10 s so a burst is one wake |
digest | at most once per 60 s, with a list of the new peer messages |
off | never (the watchdog still runs unless /swarm stall 0) |
Why not all: every wake is a full lead turn over the lead's whole context. With 4 chatty teammates, all turns a peer conversation into N extra lead turns, which multiplies cost and pulls the lead back into relaying, the opposite of a self-organising swarm. mentions keeps the lead out until a teammate asks for it; digest is the middle ground for watching.
A wake is never submitted while the lead is mid-turn: it waits in the queue and goes out 250 ms after the lead's turn completes. Per the 2.1.296 types, $.prompt.submit from a plugin "runs once idle" and resolves when its turn starts, so the mod never awaits it inside a hook (a session.receive hook awaiting its own submit hangs) and sends one wake at a time.
| Part | What |
|---|---|
| Message log | Observes every message agents send each other (session.send, session.receive, and tool.call on SendMessage as a fallback) and appends {ts, from, to, summary, length, type, via} to <cwd>/.swarm/log.jsonl. A delivery whose text a recorded send carried (one of the last 200, from the same sender or to the same recipient) is that send's echo and is not logged again, however late the receiver takes it. The mod's own sends (stall nudges, wait asks) never reach its own hooks; it logs each one itself as from swarm, and they are never read as claims. Every hook calls next except the [WAIT] refusal below, and each has a fail-open .catch. |
| Order stamps | Each message a teammate sends another teammate reaches it with #<n> · HH:MM:SS (UTC) on its own first line: n counts the session's teammate-to-teammate messages as the mod logs them (kept in roster.json and the log's stamp field across reloads), so every recipient sees one order; the lower stamp came first. Sends to the lead, broadcasts (*, which reach the lead too) and the mod's own carry none. |
| Roster | agent.spawn (name, model, teammate flag) + $.agent.list() every 4 s (status) → <cwd>/.swarm/roster.json, readable by any agent. A teammate's first-line [PROPOSE] hello <Name> sets its codeName (casing as written); roster.json, the pane and the band then show it as Atlas (t1). A later message it sends renames it: another hello, or I (t3) rename to kite, renamed Kite, (renamed kite), (now Kite), call me Kite, now called Kite, I am now Kite, I am Kite (the last two need a capital). A delivery received later never reverts a name. Two live agents with the same code name (any case) both show ⚠ dup. A teammate whose hello keeps its placeholder (hello t1) shows as t1 (no code name). |
| Workflow agents | Agents a Workflow script starts (agent.spawn's workflow.runId, type workflow-subagent) are never in $.agent.list(). Each is marked completed when its turn.complete arrives; on reload one left pending for over 10 min is completed. The pane shows one line per run, ✓ workflow dev-team · 16 agents (dimmed under other agents) or ● … · 3 running · 5 pending; the name comes from the Workflow tool call's result (workflowName, runId). Any other agent that $.agent.list() stops reporting is marked completed. |
| History | On load or reload the mod reads roster.json and the last 500 lines of log.jsonl. Lines and agents written in this session (both files carry session-<first 8 of the session id>) count as before; those from a previous session or team show dimmed under ── earlier ── and as (N earlier) in the header. |
/swarm pane | Look A by default: one card per agent of the current team with its full name, model, sent/received counts, state (running, idle, waiting on a [WAIT], stalled) and time in it, the file it last edited, wrote or read (✎ hooks/core.ts, ◦ README.md), and a bar of its messages per time slot coloured by its state, on one time axis for all cards; the claim cards; the lead's wakes, delivered and held; the message list newest first, names in full. A message moves through the cells for 0.7 s as it lands; nothing animates in between. The pane opens with the keyboard (/swarm from an empty prompt; Escape hands it back and closes the pane). Buttons, each also a key while the pane has the keyboard: an agent's name filters to its threads; keys 1 (look A) and 2 (look B, the team as a star map around the lead, wakes counted beside it, for watching and demos) and the [ list ] button (l, the text list below) switch the view; a m d o set the wake mode. A replay's row has [ live ] (0) while a live team is active, the agents, then [ nudge ] (n), [ pause ] (p) and [ stop ] (s); 1 and 2 switch its look. The current team is the latest set of teammates that messaged each other; before one exists the cards show the live teammates. |
| Pane level | basic draws text cells only; look B's basic sky (for terminals without pictures, such as Apple Terminal and tmux) paints the star map in half-block cells with 24-bit colour and braille edges: each message flies as a comet with its type riding quietly behind it (a burst as claim ×5, at most 6 words at once), its edge lighting up behind it, a held comet splashing at the membrane and a delivered one flaring its receiver, frames only while something moves. rich adds pictures (the bars, the wake strip, the star map), each drawn again only when what it shows changed. The bars and the wake strip are raw pixels, at most once a second and 100,000 characters of base64 (about 75 KB) each. The star map is a PNG drawn at 10 × 20 pixels a cell (the types give a mod no cell size), at most 1,000,000 pixels and 300,000 characters of base64, at most once every 2 s; one that would pass the cap is drawn again smaller, never skipped. A message lights its edge on the star map with a comet at the receiver in the next sky, a step dimmer in each of the next two, gone after 6 s; every message of a burst lights its own edge. Where python3 with numpy runs, look B rich hands its sky to hooks/sky.py, a renderer process started the first time look B rich is shown and paused while it is not (another look, the list, a replay restarting); it leaves after 2 min without a scene and starts again when wanted: the mod writes the scene (agents, edges with their weights, lit edges, wake counts and comets, in the picture's pixels) to a temp folder ($TMPDIR/swarm-<session>) when it changes, and draws no picture itself; the renderer paints the whole star map, the backdrop once per size, at up to 30 frames a second while a comet flies or a lit edge fades (about 10 ms a frame at 860 × 620), and writes each frame to a file, and the pane shows each frame by naming that file, so the terminal reads the pixels itself; idle, no frame is drawn. The renderer removes its files when it ends. When python3 or numpy is missing, the renderer crashes twice, or the terminal says it cannot read files or draw pictures, the pane falls back for the session to the PNG sky below; /swarm status says which. The sky stands on its own dark colour, so a terminal that fits it inside its box shows no grey bars. A comet's edge lights up behind it as it flies, a pair's first edge drawn only as far as its first comet got; a comet the lead filter holds stops at the membrane with a ripple, one that reaches the lead flares the sun. When a comet lands, its type shows under the receiver's name for 1.5 s, a burst bundled (claim ×3 · handoff). Without the renderer, look B rich shows the still PNG sky, drawn again only when an agent, its state or an edge changes and at most once every 10 s, its lit edges drawn without fade, and the arrivals; nothing flies. When one PNG sky takes the mod longer than the skyMaxDrawMs setting (default 50 ms; in /config) to draw, look B turns basic for the session (/swarm status says so). A terminal whose cells are not 1:2 fits it with thin bars. On recorded runs played live that came to 0.04–0.34 MB a minute for look A and 0.29–2.32 MB for look B; look B's ceiling is 9 MB a minute. With /swarm level auto (the default) the pane draws one 1×1 picture and goes rich when the terminal shows it (a blit taken), basic when the terminal says it draws no placeholder images; any other refusal (no image id free) keeps it basic and is asked again, at most three times. |
| Text list | Roster with live status, model as family and version (opus 5.5; – when agent.spawn never reported one), sent/received counts and waits N · 40s ago (an agent without a name shows its spawn description); a teammate idle past the stall minutes while holding an open claim shows ! … stalled. Active teammates come first; agents outside the current team, completed, failed or killed, or pending for over 10 min, show dimmed under ── other agents ──. The last N messages newest first (from → to: first line), a previous session's below them under ── earlier ──, time since the last message, wake-mode buttons (a m d o). Lines are cut to the pane's bodyColumns. |
| Band | One line above the prompt once a swarm is active: swarm: 4 agents · 12 msgs · last 8s · wake: mentions · wakes 3 · held 9 · Atlas (t1), Sage (t2); the agent count is the current team's agents that have not ended, the names are the current team's only. |
| Status line | While the current team is a swarm, the mod's status line under the prompt reads ◇ swarm 3 live · 1 done (· 1 failed when any failed), counted over the team's teammates and pinned again only when it changes (checked every 2 s); it clears when the team ends. |
| Stand-down toast | Once the last teammate of a swarm has ended, one toast with its totals: swarm stood down: 4 agents · 52 messages · 12m · lead woken 3, held 18 (messages to or from its teammates since its start; the lead's wakes and held deliveries during it). Once per team, and only for a team that was a swarm. |
| Ended team | A teammate the agent list reports as completed, failed or killed (a stop), or no longer lists, has ended. Once every teammate of the current team has, the live pane shows it as ended: ENDED · f1–f4 · ran 26m in the header (look B: ■ ended · ran 26m), every agent as ENDED on its card and in the count (4 · 4 ended), the clock and the idle counters held at the team's last activity, no claim cards; the claims board is cleared once per team, even while other teammates of the lead stay idle. |
| File activity | A teammate's Edit, Write, MultiEdit, NotebookEdit and Read calls reach the mod's tool.call hook with the teammate's agentId (in-process teammates). When the file a teammate last touched changes, its card shows it and the mod appends one {type: "FILE", from, summary: "✎ hooks/core.ts"} line to the log; repeated calls on the same file write nothing, and a read of a file the teammate read in the last 5 min shows on the card only. Teams are worked out from messages alone, so file lines never push a team out of the pane's memory. The lead's own calls are not logged. |
| Current action | Each tool call a teammate makes shows on its look A card in plain words (Reading README.md, Editing hooks/core.ts, Running npm test, Searching 'claimOf', Messaging t2, else Using <tool>), cut to the card, kept after the call ends and dim unless the teammate is running, with its call count in the card header (12×); it replaces the file on that line. The mod logs it as a type: "ACT" line with the count so far, at most one per teammate per 2 s (a call in between shows when that time is up, the latest winning), so a replay shows actions too. |
| Swarm | The stall watchdog, the claim watchdog and the lead filter act only once the current team is a swarm: two of its teammates have messaged each other and one of them has not ended. Named agents that only report to the lead (a builder, a checker) are no swarm. /swarm status shows swarm: yes/no. |
| Lead filter | quiet on (default), once the team is a swarm: a delivery to the lead from a live teammate of the current team is logged, counted and run through every watchdog, then taken with session.receive → { consumed } when it is an idle notice whose reason is available or absent, or a [PROPOSE], [CLAIM], [HANDOFF] or [REVIEW] that neither mentions @lead nor has a line starting [ASK] or [BLOCK]. Idle notices from a swarm's teammate that ended in the last minute (a team the lead just stopped) are taken the same way. So is an idle notice from any teammate, swarm or not, whose message reached the lead in the last 60 s: it repeats that report. Everything else reaches the lead: [ASK], [BLOCK], [DONE], idle notices with a failure reason, untyped text, other sessions' messages and the mod's own wakes. The band shows wakes N · held M. |
| Wake | Wakes the lead with $.prompt.submit (framed as a message from the plugin, not the user). Modes below. |
| Claims | [CLAIM] <role> <artefact or slice> messages (the first word must be build, review or research; any other is no claim) and task-list owners → claims in roster.json and a CLAIMS section in the pane: one line per deliverable or slice, #1 · build Atlas (t1) · review Sage (t2) ✓approve (build unclaimed, ⚠ duplicate; a first-line [REVIEW] #n … approve sets ✓approve, a [BLOCK] #n sets ✗block, on the build when the builder sent it), then one other · Atlas (t1): final gate line with each teammate's task-list claims whose subject starts with no role word. Deliverables come from Deliverable: <path> lines in teammate spawn prompts or /swarm deliverable <path>; a claim names one by path, file name, trailing segment or #<n> ([CLAIM] build #155 (…) names repo#155). A [CLAIM] later in a message (inside a hello) counts. A claim is judged on its target token (the first word after the role word, or the inside of a quoted or bracketed start): when that is empty, holds a space or a ", or says please, the claim is a sentence fragment and no claim. A reload restores this session's claims, deliverables and fired watchdog alerts from roster.json and the log. Once every teammate has ended (completed, failed, killed, or gone from $.agent.list()), the mod clears that team's message claims, verdicts, deliverables and fired alerts (claimsSince in roster.json; a reload replays only claims logged since); a task-list owner claims only while it is a live teammate. |
| Claim conflicts | Inform, never enforce. A [CLAIM] build or research on a target another teammate already holds (the same #<n>, a path that ends the other at a folder boundary, or a file name and a path ending in it, so design.md and docs/design.md match but docs/notes.md and src/notes.md do not; other words only when the whole targets are equal) is recorded under the first claim, shown with ⚠ duplicate while both still hold it (a holder that ended or sent [DONE] or [REVIEW] after its claim no longer does), and its sender gets one [ASK] <target> is already claimed by <name> (<id>, since <time>, #<stamp>). Talk to them, split it, or pick another slice. from the mod (kept across a reload in roster.json askedTaken); the holder is the lowest stamp. A teammate's Edit, Write, MultiEdit or NotebookEdit on a file other teammates hold a build clai
hooks/register.ts 1038 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code'
2
3import {
4 MEMORY_CAP,
5 ClaimBoard,
6 commandHolder,
7 DEFAULT_CLAIM_MIN,
8 DEFAULT_STALL_MIN,
9 ENDED,
10 LEAD,
11 MOD,
12 NUDGE_COOLDOWN_MIN,
13 MessageLog,
14 REPORTS,
15 Roster,
16 WAKE_MODES,
17 WakePolicy,
18 actionOf,
19 ago,
20 bandText,
21 bareName,
22 claimAlerts,
23 claimOf,
24 claimWakeText,
25 deliverablesOf,
26 fileOf,
27 firstLine,
28 hydrateClaims,
29 hydrateLog,
30 idleNotice,
31 isLeadAddress,
32 isHeldFromLead,
33 isStalled,
34 jsonl,
35 messageText,
36 namesText,
37 nudgeText,
38 openTaskCount,
39 paneRows,
40 parseLog,
41 parseTasks,
42 workflowRunOf,
43 isWaitDue,
44 waitPathOf,
45 waitTimeText,
46 waitUntilOf,
47 waitWakeText,
48 reviewOf,
49 sameTarget,
50 shortModel,
51 stallText,
52 stampLine,
53 typeOf,
54 writtenPath,
55} from './core'
56import type { AgentRow, ClaimRef, LogEntry, UnclaimedWorker, Via, Wait, WakeMode } from './core'
57import { PANE, buttonsOf, isReplaying, liveActivity, ownSky, paneClosed, setSkyMaxDrawMs, skyStatus, liveEvent, liveView, renderLive, renderReplay, replayCommand, setLevel, showView } from './pane'
58import { viewControls } from './pane-draw'
59import type { LevelSetting, LiveSource } from './pane'
60import { LEAD_IN_MS, LiveScene, isTeammateName, runTitle } from './replay'
61import type { ModelFamily } from './replay'
62
63const TICK_MS = 2_000
64const LIST_EVERY_TICKS = 2
65const TASKS_EVERY_TICKS = 15
66const FLUSH_MS = 1_000
67const STALE_TURN_MS = 60 * 60_000
68const MAX_TASK_FILES = 200
69const WAIT_EVERY_TICKS = 5
70/** A read of a file the teammate read this recently shows live but writes no FILE line. */
71const READ_LOG_MS = 5 * 60_000
72/** How long after a swarm's teammate ended its idle notices still count as the swarm's for the lead filter. */
73const STOPPED_GRACE_MS = 60_000
74/** How long after a teammate's message reached the lead its idle notice is held as a duplicate of that message. */
75const REPORT_GRACE_MS = 60_000
76/** The least time between two ACT lines of one teammate; a call in between is shown when that time is up. */
77const ACT_EVERY_MS = 2_000
78/** How long a writer is not warned again about the same claimed file. */
79const OVERWRITE_WARN_MS = 2 * 60_000
80const LEVELS: readonly LevelSetting[] = ['auto', 'basic', 'rich']
81/** The command's names: `/swarm`, and `/swarmctl` for sessions where another mod also registers `/swarm`. */
82const COMMANDS = ['swarm', 'swarmctl'] as const
83
84const roster = new Roster()
85const log = new MessageLog()
86const policy = new WakePolicy()
87const board = new ClaimBoard()
88board.isLive = name => !roster.hasTeammate(name) || roster.isLiveTeammate(name)
89
90let cwd: string | null = null
91let session = ''
92let isHydrated = false
93let unwritten: LogEntry[] = []
94let isFlushScheduled = false
95let isRosterDirty = false
96let ticks = 0
97let lastActivityAt = 0
98/** When this load's session started. */
99let loadedAt = 0
100let stallMin = DEFAULT_STALL_MIN
101/** Whether the lead filter holds idle notices and protocol traffic back from the lead. */
102let quiet = true
103/** When the current team was last seen to be a swarm. */
104let swarmSeenAt = -Infinity
105/** When each sender's latest message other than an idle notice reached the lead. */
106const reachedLeadAt = new Map<string, number>()
107/** The team whose end last cleared the claims board, as `<names>@<first peer message>`. */
108let clearedTeam: string | undefined
109/** The status line last pinned, undefined while none is. */
110let shownStatus: string | undefined
111/** The swarm being watched for its stand-down: its names, start, and the lead's wakes and held deliveries when it began. */
112let standing: { key: string; names: string[]; from: number; wakes: number; held: number } | undefined
113let hasStallFired = false
114/** The open claims the stall watchdog has nudged, as `<holder> <claim key> <when it was sent>`; a re-sent claim is a new one. */
115const nudged = new Set<string>()
116/** When the stall watchdog last nudged each teammate, by bare team name. */
117const nudgedAt = new Map<string, number>()
118let openTasks: number | null = null
119let claimMin = DEFAULT_CLAIM_MIN
120let sawDoneOrHandoff = false
121const firedClaimAlerts = new Set<string>()
122/** When the claim board was last cleared because its team ended; logged claims before it are not replayed. */
123let claimsSince = -Infinity
124/** Each teammate's latest `[WAIT]`, a path or a time, by bare team name. */
125const waits = new Map<string, Wait>()
126const openTurns = new Map<string, number>()
127/** The plugin holding `/swarm` when this plugin's registration of it was refused. */
128let swarmTakenBy: string | undefined
129/** Whether the user is still to be asked this session how to live with the other `/swarm`. */
130let isConflictAskDue = false
131const KEEP_BOTH = "Keep both: I'll use /swarmctl"
132const SHOW_REMOVE = 'Show me how to remove the other plugin'
133const ASK_AGAIN = 'Ask me again next session'
134const sendsBy = new Map<string, number>()
135const scene = new LiveScene()
136/** The file each teammate last showed, by bare team name. */
137const lastFile = new Map<string, string>()
138/** When each teammate's read of a file was last logged, by `<name> <shown>`. */
139const readLoggedAt = new Map<string, number>()
140/** The order stamp of the session's latest teammate-to-teammate send. */
141let lastStamp = 0
142/** Later claimants already told a target is taken, as `<name> <claim key>`. */
143const askedTaken = new Set<string>()
144/** Each teammate's tool calls, by bare team name: how many, when its last ACT line went out, and the one waiting to. */
145const acts = new Map<string, { calls: number; at: number; pending?: LogEntry }>()
146/** When each writer was last warned about each claimed file, by `<writer> <path>`. */
147const warnedWrite = new Map<string, number>()
148
149/** Whether a name can be a teammate's: a teammate the roster knows, or a name it knows no agent by. */
150function isMate(name: string): boolean {
151 return roster.hasTeammate(name) || ![...roster.agents.values()].some(a => bareName(a.name) === name)
152}
153
154/** What the mate rule reads from the roster: each agent's name and teammate flag. */
155function rosterVersion(): string {
156 let out = ''
157 for (const a of roster.agents.values()) out += a.name + (a.isTeammate ? '+' : '-')
158 return out
159}
160
161/** Whether the current team is a swarm: its teammates have messaged each other and one of them has not ended. */
162function isSwarm(): boolean {
163 return scene.team(isMate, rosterVersion())?.names.some(n => roster.isLiveTeammate(n)) === true
164}
165
166/** The latest status of the teammate going by `name` (bare team name), or undefined. */
167function statusOf(name: string): string | undefined {
168 return [...roster.agents.values()].filter(a => a.isTeammate && bareName(a.name) === name).pop()?.status
169}
170
171/** The status line while the current team is a swarm: `◇ swarm 3 live · 1 done`, with `· 1 failed` when any failed. */
172function swarmStatus(): string | undefined {
173 if (!isSwarm()) return undefined
174 let live = 0
175 let done = 0
176 let failed = 0
177 for (const n of teamNames()) {
178 if (roster.isLiveTeammate(n)) live++
179 else if (statusOf(n) === 'failed') failed++
180 else done++
181 }
182 return `◇ swarm ${live} live · ${done} done${failed > 0 ? ` · ${failed} failed` : ''}`
183}
184
185/** Pins the swarm's status line, or clears it, when it changed. */
186function showStatus($: EngineInterface) {
187 const text = swarmStatus()
188 if (text === shownStatus) return
189 shownStatus = text
190 $.ui.status(text)
191}
192
193/**
194 * Follows the current swarm and, once its last teammate has ended, toasts its totals once:
195 * `swarm stood down: 4 agents · 52 messages · 12m · lead woken 3, held 18`.
196 */
197function watchStandDown($: EngineInterface, now: number) {
198 const team = scene.team(isMate, rosterVersion())
199 if (isSwarm() && team !== undefined) {
200 const key = team.names.join(',') + '@' + team.firstAt
201 if (standing?.key !== key) {
202 const starts = team.names.map(n => roster.startedAt(n)).filter((t): t is number => t !== undefined)
203 standing = { key, names: team.names, from: Math.min(team.firstAt, ...starts), wakes: policy.wakes, held: policy.held }
204 }
205 standing.names = team.names
206 return
207 }
208 const s = standing
209 if (s === undefined || s.names.some(n => roster.isLiveTeammate(n))) return
210 standing = undefined
211 const messages = log.entries.filter(e => e.ts >= s.from && (s.names.includes(e.from) || s.names.includes(e.to))).length
212 $.ui.toast(`swarm stood down: ${s.names.length} agents · ${messages} messages · ${ago(now - s.from)} · lead woken ${policy.wakes - s.wakes}, held ${policy.held - s.held}`)
213}
214
215/** The current team's names: the swarm's, else the teammates that have not ended. */
216function teamNames(): string[] {
217 const team = scene.team(isMate, rosterVersion())
218 if (team !== undefined) return team.names
219 const names = [...roster.agents.values()].filter(a => a.isTeammate && !ENDED.includes(a.status)).map(a => bareName(a.name))
220 return [...new Set(names)]
221}
222
223/** The model family of the live agent going by `name`. */
224function modelOf(name: string): ModelFamily | undefined {
225 const family = shortModel(roster.liveEntry(name)?.model).split(' ')[0]
226 return family === 'opus' || family === 'sonnet' || family === 'haiku' || family === 'fable' ? family : undefined
227}
228
229/** Whether the current team (the latest whose teammates messaged each other) exists and every one of its teammates has ended. */
230function isTeamOver(): boolean {
231 const team = scene.team(isMate, rosterVersion())
232 return team !== undefined && team.names.every(n => roster.hasTeammate(n) && !roster.isLiveTeammate(n))
233}
234
235/** The current team's last activity: its last peer message, or a later message or idle notice from one of its teammates. */
236function lastActivityOf(names: readonly string[], lastPeerAt: number): number {
237 return Math.max(lastPeerAt, ...names.map(n => Math.max(roster.lastMessageAt.get(n) ?? -Infinity, roster.idle.get(n)?.lastAt ?? -Infinity)))
238}
239
240/**
241 * What the live pane draws: the current team from its first peer message (less the lead-in), else the live teammates from
242 * their start; a team whose teammates have all ended with its clock held at its last activity.
243 */
244function liveSource(): LiveSource {
245 const team = scene.team(isMate, rosterVersion())
246 const names = teamNames()
247 const starts = names.map(n => roster.startedAt(n)).filter((t): t is number => t !== undefined)
248 const from = team !== undefined ? team.firstAt - LEAD_IN_MS : starts.length > 0 ? Math.min(...starts) : loadedAt
249 const ended = team !== undefined && isTeamOver() ? { endedAt: lastActivityOf(team.names, team.lastPeerAt) } : {}
250 return { scene: scene.scene(names, from, modelOf, stallMin * 60_000), title: names.length > 0 ? runTitle(names) : 'no team yet', from, ...ended }
251}
252
253function logPath(): string | null {
254 return cwd === null ? null : cwd + '/.swarm/log.jsonl'
255}
256
257function rosterPath(): string | null {
258 return cwd === null ? null : cwd + '/.swarm/roster.json'
259}
260
261/** Records one message, queues it for the file and the wake policy, and asks for a redraw. */
262async function note($: EngineInterface, m: { from: string; to: string; text: string; via: Via; fromId?: string; toId?: string; stamp?: number }) {
263 const ts = await $.clock.now()
264 if (roster.announce(m.from, m.text, m.via)) markRosterDirty($)
265 roster.noteMessage(m.from, ts)
266 const claim = claimOf(m.text)
267 if (claim !== undefined && !isLeadAddress(m.from) && bareName(m.from) !== MOD) {
268 if (board.claim(m.from, claim, m.via !== 'receive', { stamp: m.stamp, at: ts })) markRosterDirty($)
269 if (m.via !== 'receive' && m.fromId !== undefined) void tellTaken($, m.from, m.fromId, claim)
270 }
271 const verdict = reviewOf(m.text)
272 if (verdict !== undefined && !isLeadAddress(m.from) && board.review(m.from, verdict)) markRosterDirty($)
273 const type = typeOf(m.text)
274 if (type !== undefined && REPORTS.includes(type) && !isLeadAddress(m.from) && board.report(m.from, type, m.via !== 'receive')) markRosterDirty($)
275 const waitPath = waitPathOf(m.text)
276 const until = waitUntilOf(m.text)
277 if ((waitPath !== undefined || until !== undefined) && m.via !== 'receive' && !isLeadAddress(m.from)) {
278 const name = bareName(m.from)
279 const since = Math.max(roster.idle.get(name)?.lastAt ?? -Infinity, roster.liveEntry(name)?.spawnedAt ?? -Infinity)
280 const prior = waits.get(name)
281 const wokenFor = prior?.path === waitPath && prior?.until === until ? prior?.wokenFor : undefined
282 waits.set(name, { ...(waitPath === undefined ? { until, since: 0 } : { path: waitPath, since: since > -Infinity ? since : ts }), ...(wokenFor === undefined ? {} : { wokenFor }) })
283 markRosterDirty($)
284 }
285 const entry = log.record({ ...m, from: bareName(m.from) || m.from, to: bareName(m.to) || m.to, ts })
286 if (!entry) return
287 if (session) entry.session = session
288 if (m.stamp !== undefined) entry.stamp = m.stamp
289 if ((entry.type === 'DONE' || entry.type === 'HANDOFF') && !isLeadAddress(entry.from)) sawDoneOrHandoff = true
290 lastActivityAt = ts
291 hasStallFired = false
292 unwritten.push(entry)
293 scene.push(entry)
294 policy.offer(entry, m.text)
295 scheduleFlush($)
296 $.ui.invalidate('ui.render')
297 void liveEvent($)
298 if (entry.from !== entry.to && [entry.from, entry.to].every(n => isTeammateName(n) && isMate(n))) liveActivity($)
299}
300
301/** Counts an idle notification and writes it to the log file as `type: "IDLE"`; it is no message and wakes nobody. */
302async function noteIdle($: EngineInterface, from: string, to: string, reason: string | undefined, text: string) {
303 const ts = await $.clock.now()
304 const name = bareName(from) || from
305 roster.noteIdle(name, reason, ts)
306 const entry: LogEntry = { ts, from: name, to: bareName(to) || to, summary: firstLine(text), length: text.length, type: 'IDLE', via: 'receive' }
307 if (reason !== undefined) entry.idleReason = reason
308 if (session) entry.session = session
309 unwritten.push(entry)
310 scene.push(entry)
311 markRosterDirty($)
312 void liveEvent($)
313}
314
315/**
316 * Shows a teammate's file tool call on its card when the file changes, and logs it as one `type: "FILE"` line: an edit or
317 * write always, a read only when the teammate has not read that file in the last `READ_LOG_MS`.
318 */
319async function noteFile($: EngineInterface, agentId: string, shown: string) {
320 const a = roster.agents.get(agentId)
321 if (a === undefined || !a.isTeammate) return
322 const name = bareName(a.name)
323 if (lastFile.get(name) === shown) return
324 lastFile.set(name, shown)
325 const ts = await $.clock.now()
326 const entry: LogEntry = { ts, from: name, to: '', summary: shown, length: 0, type: 'FILE', via: 'tool' }
327 if (session) entry.session = session
328 scene.push(entry)
329 const key = name + ' ' + shown
330 if (!shown.startsWith('◦') || ts - (readLoggedAt.get(key) ?? -Infinity) >= READ_LOG_MS) {
331 if (shown.startsWith('◦')) readLoggedAt.set(key, ts)
332 unwritten.push(entry)
333 scheduleFlush($)
334 }
335 $.ui.invalidate('ui.render')
336}
337
338/**
339 * Counts a teammate's tool call and shows it on its card as an ACT line (logged too), at most one per `ACT_EVERY_MS`; a call
340 * inside that time waits for its end, the latest one winning.
341 */
342async function noteAction($: EngineInterface, agentId: string, action: string) {
343 const a = roster.agents.get(agentId)
344 if (a === undefined || !a.isTeammate) return
345 const name = bareName(a.name)
346 const ts = await $.clock.now()
347 const s = acts.get(name) ?? { calls: 0, at: -Infinity }
348 acts.set(name, s)
349 s.calls++
350 const entry: LogEntry = { ts, from: name, to: '', summary: action, length: 0, type: 'ACT', via: 'tool', calls: s.calls }
351 if (session) entry.session = session
352 if (ts - s.at >= ACT_EVERY_MS) return sendAction($, s, entry)
353 if (s.pending === undefined) {
354 $.clock.after(s.at + ACT_EVERY_MS - ts, () => {
355 const next = s.pending
356 s.pending = undefined
357 if (next !== undefined) sendAction($, s, next)
358 })
359 }
360 s.pending = entry
361}
362
363function sendAction($: EngineInterface, s: { at: number }, entry: LogEntry) {
364 s.at = entry.ts
365 scene.push(entry)
366 unwritten.push(entry)
367 scheduleFlush($)
368 $.ui.invalidate('ui.render')
369}
370
371function scheduleFlush($: EngineInterface) {
372 if (isFlushScheduled) return
373 isFlushScheduled = true
374 $.clock.after(FLUSH_MS, () => {
375 isFlushScheduled = false
376 void flush($)
377 })
378}
379
380/** Appends unwritten entries to the JSONL file and rewrites roster.json when it changed. */
381async function flush($: EngineInterface) {
382 const path = logPath()
383 if (path !== null && unwritten.length > 0) {
384 const batch = unwritten
385 unwritten = []
386 try {
387 const r = await $.process.run(
388 ['/bin/sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'swarm-log', path],
389 { stdin: jsonl(batch), timeoutMs: 10_000 },
390 )
391 if (r.exitCode !== 0) $.ui.log('log append failed: ' + r.stderr.trim())
392 } catch (err) {
393 $.ui.log('log append failed: ' + String(err))
394 }
395 }
396 const rpath = rosterPath()
397 if (rpath !== null && isRosterDirty) {
398 isRosterDirty = false
399 try {
400 const json = { ...roster.toJSON(await $.clock.now(), session || undefined), stamp: lastStamp, askedTaken: [...askedTaken], deliverables: board.deliverables, claims: board.all(), unclaimedTasks: board.unclaimedTasks, firedAlerts: [...firedClaimAlerts], ...(claimsSince > -Infinity ? { claimsSince } : {}), workflows: Object.fromEntries(roster.workflowNames), waits: Object.fromEntries(waits) }
401 await $.fs.write(rpath, JSON.stringify(json, null, 2) + '\n')
402 } catch (err) {
403 $.ui.log('roster write failed: ' + String(err))
404 }
405 }
406}
407
408/** Reads the log file's tail and roster.json once per load, so the pane shows what came before a reload. */
409async function hydrate($: EngineInterface) {
410 if (isHydrated) return
411 isHydrated = true
412 const rpath = rosterPath()
413 const lpath = logPath()
414 try {
415 if (rpath !== null && (await $.fs.exists(rpath))) {
416 const json = JSON.parse(await $.fs.read(rpath)) as { session?: unknown; stamp?: unknown; askedTaken?: unknown; firedAlerts?: unknown; claimsSince?: unknown; workflows?: unknown; waits?: unknown }
417 roster.hydrate(json, session, await $.clock.now())
418 if (json.session === session && json.workflows && typeof json.workflows === 'object') {
419 for (const [id, name] of Object.entries(json.workflows)) if (typeof name === 'string') roster.workflowNames.set(id, name)
420 }
421 if (json.session === session) {
422 if (typeof json.stamp === 'number') lastStamp = Math.max(lastStamp, json.stamp)
423 if (Array.isArray(json.askedTaken)) for (const k of json.askedTaken) if (typeof k === 'string') askedTaken.add(k)
424 board.restore(json)
425 if (typeof json.claimsSince === 'number') claimsSince = json.claimsSince
426 if (Array.isArray(json.firedAlerts)) for (const k of json.firedAlerts) if (typeof k === 'string') firedClaimAlerts.add(k)
427 if (json.waits && typeof json.waits === 'object') {
428 for (const [n, w] of Object.entries(json.waits as Record<string, Partial<Wait>>)) if ((typeof w?.path === 'string' || typeof w?.until === 'number') && typeof w.since === 'number') waits.set(n, w as Wait)
429 }
430 }
431 }
432 } catch (err) {
433 $.ui.log('roster read failed: ' + String(err))
434 }
435 try {
436 if (lpath !== null && (await $.fs.exists(lpath))) {
437 const r = await $.process.run(['tail', '-n', String(MEMORY_CAP), lpath], { timeoutMs: 10_000 })
438 if (r.exitCode === 0) {
439 const lines = parseLog(r.stdout)
440 hydrateLog(log, roster, lines, session)
441 hydrateClaims(board, lines, session, claimsSince)
442 for (const e of lines) if (e.session === session) {
443 scene.push(e)
444 if (typeof e.stamp === 'number') lastStamp = Math.max(lastStamp, e.stamp)
445 }
446 }
447 }
448 } catch (err) {
449 $.ui.log('log read failed: ' + String(err))
450 }
451 $.ui.invalidate('ui.render')
452}
453
454function markRosterDirty($: EngineInterface) {
455 isRosterDirty = true
456 scheduleFlush($)
457 $.ui.invalidate('ui.render')
458}
459
460/** Submits a queued wake when the policy says it is due and the lead is idle. */
461async function tryWake($: EngineInterface) {
462 const text = policy.due(await $.clock.now(), roster.leadBusy)
463 if (text === null) return
464 submitWake($, text)
465}
466
467/** Starts a framed plugin prompt without awaiting it: it resolves only once the lead's turn starts. */
468function submitWake($: EngineInterface, text: string) {
469 policy.inFlight = true
470 policy.wakes++
471 $.prompt.submit({ text }).then(
472 () => {
473 policy.inFlight = false
474 },
475 (err: unknown) => {
476 policy.inFlight = false
477 $.ui.log('wake not submitted: ' + String(err))
478 },
479 )
480}
481
482async function pollAgents($: EngineInterface) {
483 try {
484 const rows = await $.agent.list()
485 const mapped: AgentRow[] = rows.map(r => ({
486 id: r.id,
487 name: r.name,
488 type: r.type,
489 status: r.status,
490 teammateId: r.teammateId,
491 description: r.description,
492 }))
493 if (roster.merge(mapped, await $.clock.now())) markRosterDirty($)
494 } catch (err) {
495 $.ui.log('agent list failed: ' + String(err))
496 }
497}
498
499/** Counts open tasks in this session's team task list; null when it cannot be read. */
500async function refreshTasks($: EngineInterface) {
501 try {
502 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? ((await $.env.get('HOME')) ?? '') + '/.claude'
503 const id = await $.session.id()
504 const dir = configDir + '/tasks/session-' + id.slice(0, 8)
505 if (!(await $.fs.exists(dir))) {
506 openTasks = null
507 return
508 }
509 const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.json')).slice(0, MAX_TASK_FILES)
510 const texts: string[] = []
511 for (const f of files) texts.push(await $.fs.read(dir + '/' + f.name))
512 openTasks = openTaskCount(texts)
513 board.setTasks(parseTasks(texts), owner => {
514 const name = roster.resolve(owner)
515 return roster.isLiveTeammate(name) ? name : undefined
516 })
517 } catch {
518 openTasks = null
519 }
520}
521
522/** The periodic pass: agent status, tasks, stale turns, wakes, the stall watchdog and the clock in the band. */
523async function tick($: EngineInterface) {
524 ticks++
525 const now = await $.clock.now()
526 if (isSwarm()) swarmSeenAt = now
527 if (ticks % LIST_EVERY_TICKS === 0) await pollAgents($)
528 showStatus($)
529 watchStandDown($, now)
530 if (ticks % TASKS_EVERY_TICKS === 1) await refreshTasks($)
531 for (const [id, at] of openTurns) if (now - at > STALE_TURN_MS) openTurns.delete(id)
532 roster.leadBusy = openTurns.size > 0
533 if (roster.activeCount() > 0) {
534 lastActivityAt = now
535 hasStallFired = false
536 }
537 await tryWake($)
538 const stalled = isSwarm() && isStalled({
539 now,
540 lastActivityAt,
541 stallMs: stallMin * 60_000,
542 activeAgents: roster.activeCount(),
543 liveAgents: roster.liveCount(),
544 openTasks,
545 leadBusy: roster.leadBusy || policy.inFlight,
546 alreadyFired: hasStallFired || stallMin <= 0,
547 })
548 if (stalled) {
549 hasStallFired = true
550 const nudges = await nudgeClaimHolders($)
551 if (nudges.delivered > 0) lastActivityAt = now
552 if (nudges.delivered > 0 || nudges.coolingDown > 0) hasStallFired = false
553 else submitWake($, stallText(stallMin, openTasks))
554 }
555 expireEndedTeam($, now)
556 checkClaims($, now)
557 if (ticks % WAIT_EVERY_TICKS === 0) await checkWaits($)
558 if (roster.agents.size > 0 || log.total > 0) $.ui.invalidate('ui.render')
559}
560
561/** The band's line: counts, wake mode, then the current team's announced code names. */
562function bandLine(now: number): string {
563 const names = namesText(roster, teamNames())
564 const dup = board.duplicates().map(c => c.target || c.role)
565 const missing = sawDoneOrHandoff || board.deliverables.length > 0 ? board.missingBuilders() : []
566 const warn = [...(missing.length ? ['no builder: ' + missing.join(', ')] : []), ...(dup.length ? ['dup claim: ' + dup.join(', ')] : [])]
567 return `${bandText(roster, log, now)} · wake: ${policy.mode} · wakes ${policy.wakes} · held ${policy.held}${warn.length ? ' · ⚠ ' + warn.join(' · ') : ''}${names ? ' · ' + names : ''}`
568}
569
570/** Teammates that are running or have sent a message, hold no claim, and have not ended, each with when it started. */
571function unclaimedWorkers(): UnclaimedWorker[] {
572 const claimants = board.claimants()
573 const out: UnclaimedWorker[] = []
574 for (const a of roster.agents.values()) {
575 if (!a.isTeammate || ENDED.includes(a.status)) continue
576 const name = bareName(a.name)
577 const working = a.status === 'running' || (log.sent.get(name) ?? 0) > 0
578 if (working && !claimants.has(name)) out.push({ label: roster.label(a.name), startedAt: roster.startedAt(a.name) })
579 }
580 return out
581}
582
583/**
584 * Once every teammate has ended, or once every teammate of the current team has (once per team), clears the claims,
585 * verdicts, deliverables, nudges, waits and fired alerts.
586 */
587function expireEndedTeam($: EngineInterface, now: number) {
588 const team = scene.team(isMate, rosterVersion())
589 const key = team === undefined ? undefined : team.names.join(',') + '@' + team.firstAt
590 const teamOver = key !== undefined && key !== clearedTeam && isTeamOver()
591 if (teamOver) clearedTeam = key
592 if (!board.hasMessageClaims() || !(roster.isTeamEnded() || teamOver)) return
593 board.clear()
594 askedTaken.clear()
595 firedClaimAlerts.clear()
596 nudged.clear()
597 nudgedAt.clear()
598 waits.clear()
599 sawDoneOrHandoff = false
600 claimsSince = now
601 markRosterDirty($)
602}
603
604/** Wakes the lead once per claim condition, once the team is a swarm; conditions that hold together go out as one wake. */
605function checkClaims($: EngineInterface, now: number) {
606 if (!isSwarm()) return
607 const alerts = claimAlerts({
608 now,
609 claimMs: claimMin * 60_000,
610 sawDoneOrHandoff,
611 missingBuilders: board.missingBuilders(),
612 unclaimedWorkers: unclaimedWorkers(),
613 }).filter(a => !firedClaimAlerts.has(a.key))
614 if (alerts.length === 0 || roster.leadBusy || policy.inFlight) return
615 for (const a of alerts) firedClaimAlerts.add(a.key)
616 markRosterDirty($)
617 submitWake($, claimWakeText(alerts))
618}
619
620/**
621 * Sends each idle teammate whose `[WAIT]` file changed after it registered the wait `[ASK] <path> changed: …`, once per
622 * change, and one whose `[WAIT] until` time came `[ASK] <time> reached: …`.
623 */
624async function checkWaits($: EngineInterface) {
625 for (const [name, w] of waits) {
626 const a = roster.liveEntry(name)
627 if (a === undefined || !a.isTeammate) continue
628 const path = w.path
629 const changedAt = path === undefined ? (w.until !== undefined && (await $.clock.now()) >= w.until ? w.until : undefined) : await $.fs.stat(path).then(st => st.mtimeMs, () => undefined)
630 if (!isWaitDue(w, roster.idleSince(name), changedAt)) continue
631 w.wokenFor = changedAt
632 markRosterDirty($)
633 await sendTo($, a.id, name, path === undefined ? waitTimeText(w.until!) : waitWakeText(path, await $.fs.read(path).catch(() => '')))
634 }
635}
636
637/**
638 * Sends each live teammate that holds an open claim not yet nudged one `[ASK] swarm quiet …`, unless it was nudged in the last
639 * `NUDGE_COOLDOWN_MIN`; returns how many were delivered and how many such teammates wait out their cooldown.
640 */
641async function nudgeClaimHolders($: EngineInterface): Promise<{ delivered: number; coolingDown: number }> {
642 let delivered = 0
643 let coolingDown = 0
644 const now = await $.clock.now()
645 for (const name of board.claimants()) {
646 const a = roster.liveEntry(name)
647 if (a === undefined || !a.isTeammate) continue
648 const ids = new Map(board.openClaims(name).map(c => [`${name} ${c.key} ${board.sentAt(c.key, name)}`, c]))
649 for (const id of ids.keys()) if (nudged.has(id)) ids.delete(id)
650 if (ids.size === 0) continue
651 if (now - (nudgedAt.get(name) ?? -Infinity) < NUDGE_COOLDOWN_MIN * 60_000) {
652 coolingDown++
653 continue
654 }
655 if (!(await sendTo($, a.id, name, nudgeText(stallMin, [...ids.values()], board.reports, n => roster.label(n))))) continue
656 for (const id of ids.keys()) nudged.add(id)
657 nudgedAt.set(name, now)
658 delivered++
659 }
660 return { delivered, coolingDown }
661}
662
663/** Sends one message from this plugin to an agent and logs it as from `swarm`; returns whether it was delivered. A failure is logged, never thrown. */
664async function sendTo($: EngineInterface, agentId: string, name: string, text: string): Promise<boolean> {
665 try {
666 const r = await $.session.send({ to: { agentId }, text })
667 const entry = log.record({ ts: await $.clock.now(), from: MOD, to: name, text, via: 'send' })
668 if (entry !== undefined) {
669 if (session) entry.session = session
670 unwritten.push(entry)
671 scene.push(entry)
672 scheduleFlush($)
673 }
674 if (!r.isDelivered) $.ui.log(`message to ${name} not delivered: ${r.reason}`)
675 return r.isDelivered
676 } catch (err) {
677 $.ui.log(`message to ${name} failed: ` + String(err))
678 return false
679 }
680}
681
682/** A teammate as `<code name> (<team name>`, or `<team name> (<team name>` without a code name, for the mod's asks. */
683function opened(name: string): string {
684 return `${roster.codeNames.get(name) ?? name} (${name}`
685}
686
687/** Tells a later claimant, once per claim, who already holds the build or research target it claimed; nothing is refused. */
688async function tellTaken($: EngineInterface, by: string, agentId: string, claim: ClaimRef) {
689 const name = bareName(by)
690 const held = board.heldBefore(name, claim)
691 if (held === undefined || askedTaken.has(name + ' ' + held.key)) return
692 askedTaken.add(name + ' ' + held.key)
693 markRosterDirty($)
694 const since = held.at === undefined ? '' : ', since ' + new Date(held.at).toISOString().slice(11, 19)
695 const stamp = held.stamp === undefined ? '' : `, #${held.stamp}`
696 await sendTo($, agentId, name, `[ASK] ${claim.target} is already claimed by ${opened(held.holder)}${since}${stamp}). Talk to them, split it, or pick another slice.`)
697}
698
699/**
700 * After a teammate wrote a file other live teammates hold a build claim on and it does not: tells the writer and, as a
701 * copy, the first holder, at most once per writer and file per `OVERWRITE_WARN_MS`. The write itself is never refused.
702 */
703async function warnOverwrite($: EngineInterface, agentId: string, path: string) {
704 const a = roster.agents.get(agentId)
705 if (a === undefined || !a.isTeammate) return
706 const writer = bareName(a.name)
707 const claims = board.all().filter(c => c.role === 'build' && sameTarget(c.target, path))
708 if (claims.some(c => c.by.includes(writer))) return
709 const holder = claims.flatMap(c => c.by).find(h => roster.isLiveTeammate(h))
710 if (holder === undefined) return
711 const now = await $.clock.now()
712 const key = writer + ' ' + path
713 if (now - (warnedWrite.get(key) ?? -Infinity) < OVERWRITE_WARN_MS) return
714 warnedWrite.set(key, now)
715 await sendTo($, agentId, writer, `[ASK] you wrote ${path}, claimed by ${opened(holder)}). Settle it with them.`)
716 const owner = roster.liveEntry(holder)
717 if (owner !== undefined) await sendTo($, owner.id, holder, `[ASK] ${opened(writer)}) wrote ${path}, which you claimed. Settle it with them.`)
718}
719
720/** Records the deliverables a brief names and redraws when one is new. */
721function noteDeliverables($: EngineInterface, list: readonly string[]) {
722 if (board.addDeliverables(list)) markRosterDirty($)
723}
724
725/** Asks once, while the lead is not mid-turn, how to live with the plugin holding `/swarm`; `Keep both` is stored, a dismissed or `-p` ask stores nothing. */
726async function askConflict($: EngineInterface) {
727 if (!isConflictAskDue || roster.leadBusy || swarmTakenBy === undefined) return
728 isConflictAskDue = false
729 const answer = await $.ui.ask(`Another plugin (${swarmTakenBy}) already uses /swarm. How do you want to use this one?`, [KEEP_BOTH, SHOW_REMOVE, ASK_AGAIN]).catch(() => undefined)
730 if (answer === KEEP_BOTH) await $.store.set('swarmConflict', 'keep')
731 if (answer !== SHOW_REMOVE) return
732 const enabled = (await $.settings.read().catch(() => ({}))) as { enabledPlugins?: Record<string, unknown> }
733 const id = Object.keys(enabled.enabledPlugins ?? {}).find(k => k.startsWith(swarmTakenBy + '@'))
734 $.ui.toast(`To remove ${swarmTakenBy}: ${id === undefined ? '/plugin, then uninstall it there' : '/plugin uninstall ' + id}, and restart Claude Code afterwards.`, { timeoutMs: 20_000 })
735}
736
737function statusText(now: number): string {
738 const tasks = openTasks === null ? 'unknown' : String(openTasks)
739 return `${swarmTakenBy === undefined ? '' : `/swarm is taken by ${swarmTakenBy}; use /swarmctl · `}${bandLine(now)} · swarm: ${isSwarm() ? 'yes' : 'no'} · sky renderer: ${skyStatus()} · quiet: ${quiet ? 'on' : 'off'} · stall: ${stallMin} min · claim watch: ${claimMin} min · open tasks: ${tasks} · log: ${logPath() ?? '(no cwd)'}`
740}
741
742export function register(on: On, options: PluginOptions) {
743 setSkyMaxDrawMs(typeof options.skyMaxDrawMs === 'number' ? options.skyMaxDrawMs : 50)
744 on('session.start', async ($, e, next) => {
745 try {
746 cwd = await $.session.cwd()
747 session = 'session-' + (await $.session.id()).slice(0, 8)
748 await hydrate($)
749 const now = await $.clock.now()
750 lastActivityAt = now
751 loadedAt = now
752 const savedMode = await $.store.get('wakeMode')
753 if (typeof savedMode === 'string' && (WAKE_MODES as readonly string[]).includes(savedMode)) policy.setMode(savedMode as WakeMode, now)
754 else policy.setMode('mentions', now)
755 const savedStall = await $.store.get('stallMin')
756 if (typeof savedStall === 'number' && savedStall >= 0) stallMin = savedStall
757 const savedQuiet = await $.store.get('quiet')
758 if (typeof savedQuiet === 'boolean') quiet = savedQuiet
759 const savedClaim = await $.store.get('claimMin')
760 if (typeof savedClaim === 'number' && savedClaim >= 0) claimMin = savedClaim
761 const savedLevel = await $.store.get('level')
762 if (typeof savedLevel === 'string' && (LEVELS as readonly string[]).includes(savedLevel)) setLevel(savedLevel as LevelSetting)
763 $.clock.every(TICK_MS, () => {
764 void tick($)
765 })
766 ownSky($)
767 for (const name of COMMANDS) {
768 await $.command.register({
769 name,
770 description: (swarmTakenBy === undefined ? '' : `/swarm is taken by ${swarmTakenBy}; use /swarmctl. `) + 'Swarm: open the pane, or set wake all|mentions|digest|off, quiet on|off, stall <min>, claim <min>, level auto|basic|rich, deliverable <path>, status; replay <log.jsonl> plays a recorded run',
771 argumentHint: '[wake all|mentions|digest|off | quiet on|off | stall <min> | claim <min> | level auto|basic|rich | deliverable <path> | status | replay <log.jsonl> [--speed N] [--look A|B] [--level basic|rich] [--from <ISO>] [--to <ISO>] | replay stop]',
772 immediate: true,
773 }).catch((err: unknown) => {
774 if (name === 'swarm') swarmTakenBy = commandHolder(err instanceof Error ? err.message : String(err))
775 $.ui.log(`swarm: /${name} not registered: ${String(err)}`)
776 })
777 }
778 isConflictAskDue = swarmTakenBy !== undefined && e.isInteractive && (await $.store.get('swarmConflict')) !== 'keep'
779 if (isConflictAskDue) $.clock.after(1_000, () => void askConflict($).catch(() => undefined))
780 } catch (err) {
781 $.ui.log('swarm start: ' + String(err))
782 }
783 return next(e)
784 })
785
786 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
787 const key = e.agentId ?? 'main'
788 const before = sendsBy.get(key) ?? 0
789 const result = await next(e)
790 try {
791 const refused = typeof result === 'object' && result !== null && 'deny' in result && result.deny !== undefined
792 if (!refused && (sendsBy.get(key) ?? 0) === before) {
793 const input = e as unknown as { to?: unknown; message?: unknown }
794 await note($, {
795 from: roster.nameOf(e.agentId),
796 to: String(input.to ?? '?'),
797 text: messageText(input.message),
798 via: 'tool',
799 fromId: e.agentId,
800 })
801 }
802 } catch (err) {
803 $.ui.log('swarm tool.call: ' + String(err))
804 }
805 return result
806 }).catch(async ($, e, next) => next(e))
807
808 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
809 const result = await next(e)
810 try {
811 const run = workflowRunOf(e, result)
812 if (run !== undefined && roster.workflowNames.get(run.runId) !== run.name) {
813 roster.workflowNames.set(run.runId, run.name)
814 markRosterDirty($)
815 }
816 } catch (err) {
817 $.ui.log('swarm Workflow call: ' + String(err))
818 }
819 return result
820 }).catch(async ($, e, next) => next(e))
821
822 on('tool.call', async ($, e, next) => {
823 const result = await next(e)
824 try {
825 const shown = e.agentId === undefined ? undefined : fileOf(e.tool, e, cwd)
826 const refused = typeof result === 'object' && result !== null && 'deny' in result && result.deny !== undefined
827 if (shown !== undefined && !refused) await noteFile($, e.agentId!, shown)
828 if (e.agentId !== undefined && !refused) await noteAction($, e.agentId, actionOf(e.tool, e, cwd))
829 const written = e.agentId === undefined || refused ? undefined : writtenPath(e.tool, e)
830 if (written !== undefined) await warnOverwrite($, e.agentId!, written)
831 } catch (err) {
832 $.ui.log('swarm file call: ' + String(err))
833 }
834 return result
835 }).catch(async ($, e, next) => next(e))
836
837 on('session.send', async ($, e, next) => {
838 let sent = e
839 try {
840 const key = e.agentId ?? 'main'
841 sendsBy.set(key, (sendsBy.get(key) ?? 0) + 1)
842 const isPeer = e.origin?.kind !== 'plugin' && e.agentId !== undefined && roster.agents.get(e.agentId)?.isTeammate === true && !isLeadAddress(e.to) && e.to.trim() !== '*' && bareName(e.to) !== MOD
843 const stamp = isPeer ? ++lastStamp : undefined
844 await note($, { from: roster.nameOf(e.agentId), to: e.to, text: e.text, via: 'send', fromId: e.agentId, ...(stamp === undefined ? {} : { stamp }) })
845 if (stamp !== undefined) {
846 markRosterDirty($)
847 sent = { ...e, text: stampLine(stamp, await $.clock.now()) + '\n' + e.text }
848 }
849 if (e.agentId !== undefined && isLeadAddress(e.to) && typeOf(e.text) === 'WAIT') {
850 const path = waitPathOf(e.text)
851 const until = waitUntilOf(e.text)
852 if (path === undefined && until === undefined) return { isDelivered: false, reason: `${MOD} could not read your [WAIT]: use an absolute path (e.g. [WAIT] /abs/dir/out.json) or until <ISO-8601 time> (e.g. [WAIT] until 2026-10-14T09:30:00Z).` }
853 return { isDelivered: false, reason: `${MOD} registered your wait and kept it from the lead; you get [ASK] ${path === undefined ? new Date(until!).toISOString() + ' reached' : path + ' changed'}: … once it ${path === undefined ? 'comes' : 'changes'} while you are idle.` }
854 }
855 } catch (err) {
856 $.ui.log('swarm session.send: ' + String(err))
857 }
858 return next(sent)
859 }).catch(async ($, e, next) => next(e))
860
861 on('session.receive', async ($, e, next) => {
862 try {
863 const o = e.origin
864 if (o.kind === 'peer' || o.kind === 'coordinator' || o.kind === 'peer-send-message') {
865 const from = 'teammate' in o ? o.teammate : o.kind === 'coordinator' ? LEAD : 'peer'
866 const idle = idleNotice(e.text)
867 if (idle !== undefined) await noteIdle($, idle.from ?? from, roster.nameOf(e.agentId), idle.idleReason, e.text)
868 else await note($, { from, to: roster.nameOf(e.agentId), text: e.text, via: 'receive', toId: e.agentId })
869 if (e.agentId === undefined) {
870 $.ui.invalidate('ui.render')
871 const now = await $.clock.now()
872 if (isSwarm()) swarmSeenAt = now
873 const sender = roster.liveEntry(idle?.from ?? from)
874 const fromSwarm = isSwarm() && sender?.isTeammate === true && !roster.isOther(sender, now)
875 const fromStopped = idle !== undefined && now - swarmSeenAt < STOPPED_GRACE_MS && roster.hasStoppedSince(idle.from ?? from, now - STOPPED_GRACE_MS)
876 const afterReport = idle !== undefined && now - (reachedLeadAt.get(idle.from ?? from) ?? -Infinity) < REPORT_GRACE_MS
877 if (quiet && (fromSwarm || fromStopped || afterReport) && isHeldFromLead(e.text, idle)) {
878 policy.held++
879 return { consumed: 'swarm quiet: held from the lead' }
880 }
881 if (idle === undefined) reachedLeadAt.set(from, now)
882 policy.wakes++
883 }
884 }
885 } catch (err) {
886 $.ui.log('swarm session.receive: ' + String(err))
887 }
888 return next(e)
889 }).catch(async ($, e, next) => next(e))
890
891 on('agent.spawn', async ($, e, next) => {
892 const result = await next(e)
893 try {
894 if (result.deny === undefined && roster.spawned(e, result, await $.clock.now())) markRosterDirty($)
895 if (result.deny === undefined && e.isTeammate === true) noteDeliverables($, deliverablesOf(e.prompt ?? ''))
896 if (result.deny === undefined && e.isTeammate === true) liveActivity($)
897 } catch (err) {
898 $.ui.log('swarm agent.spawn: ' + String(err))
899 }
900 return result
901 }).catch(async ($, e, next) => next(e))
902
903 on('turn.start', async ($, e, next) => {
904 openTurns.set(e.turnId, await $.clock.now())
905 roster.leadBusy = true
906 return next(e)
907 })
908
909 on('turn.complete', async ($, e, next) => {
910 if (e.agentId !== undefined && roster.settle(e.agentId, await $.clock.now())) markRosterDirty($)
911 if (e.agentId === undefined || openTurns.has(e.turnId)) {
912 openTurns.delete(e.turnId)
913 roster.leadBusy = openTurns.size > 0
914 if (!roster.leadBusy) {
915 $.clock.after(250, () => {
916 void tryWake($)
917 void askConflict($).catch(() => undefined)
918 })
919 }
920 }
921 return next(e)
922 })
923
924 for (const command of COMMANDS) on('command.run', { command }, async ($, e) => {
925 const [verb, arg] = e.args.trim().split(/\s+/)
926 if (verb === 'wake') {
927 if (arg === undefined || !(WAKE_MODES as readonly string[]).includes(arg)) return { text: 'usage: /swarm wake all|mentions|digest|off' }
928 policy.setMode(arg as WakeMode, await $.clock.now())
929 await $.store.set('wakeMode', arg)
930 $.ui.invalidate('ui.render')
931 return { text: 'wake mode: ' + arg }
932 }
933 if (verb === 'stall') {
934 const n = Number(arg)
935 if (arg === undefined || !Number.isFinite(n) || n < 0) return { text: 'usage: /swarm stall <minutes>, 0 turns the watchdog off' }
936 stallMin = n
937 hasStallFired = false
938 await $.store.set('stallMin', n)
939 return { text: n === 0 ? 'stall watchdog off' : `stall watchdog: ${n} min` }
940 }
941 if (verb === 'claim') {
942 const n = Number(arg)
943 if (arg === undefined || !Number.isFinite(n) || n < 0) return { text: 'usage: /swarm claim <minutes>, 0 turns the claim watchdog off' }
944 claimMin = n
945 await $.store.set('claimMin', n)
946 return { text: n === 0 ? 'claim watchdog off' : `claim watchdog: ${n} min` }
947 }
948 if (verb === 'quiet') {
949 if (arg !== 'on' && arg !== 'off') return { text: 'usage: /swarm quiet on|off' }
950 quiet = arg === 'on'
951 await $.store.set('quiet', quiet)
952 return { text: 'lead filter: ' + arg }
953 }
954 if (verb === 'level') {
955 if (arg === undefined || !(LEVELS as readonly string[]).includes(arg)) return { text: 'usage: /swarm level auto|basic|rich' }
956 setLevel(arg as LevelSetting)
957 await $.store.set('level', arg)
958 $.ui.invalidate('ui.render')
959 return { text: 'pane level: ' + arg }
960 }
961 if (verb === 'deliverable') {
962 const path = e.args.trim().slice('deliverable'.length).trim()
963 if (path === '') return { text: 'usage: /swarm deliverable <path>' }
964 noteDeliverables($, [path])
965 return { text: 'deliverables: ' + board.deliverables.join(', ') }
966 }
967 if (verb === 'status') return { text: statusText(await $.clock.now()) }
968 if (verb === 'replay') return replayCommand($, e.args.trim().slice('replay'.length))
969 await $.ui.open({ id: PANE, title: 'swarm', focus: true, closeOnEscape: true })
970 return {}
971 })
972
973 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
974 if (e.requestId !== PANE) return next(e)
975 const { Box, Text, Button } = $.ui.resolve(e)
976 const modeButton = (mode: WakeMode, hotkey: string) =>
977 Button({
978 key: 'wake-' + mode,
979 label: mode,
980 hotkey,
981 plain: true,
982 dimColor: policy.mode !== mode,
983 onPress: async () => {
984 policy.setMode(mode, await $.clock.now())
985 await $.store.set('wakeMode', mode)
986 $.ui.invalidate('ui.render')
987 },
988 })
989 const wakeRow = (...first: ReturnType<typeof Button>[]) =>
990 Box({
991 flexDirection: 'row',
992 columnGap: 2,
993 children: [...first, Text({ dimColor: true, children: ['wake:'] }), modeButton('all', 'a'), modeButton('mentions', 'm'), modeButton('digest', 'd'), modeButton('off', 'o')],
994 })
995 if (e.surface === 'terminal') {
996 if (isReplaying()) {
997 const tree = await renderReplay($, e)
998 if (tree !== undefined) return tree
999 }
1000 if (liveView() !== 'list') return renderLive($, e, liveSource, [wakeRow()])
1001 }
1002 const now = await $.clock.now()
1003 const claimRows = board.all().length + board.missingBuilders().length + board.unclaimedTasks.length + 3
1004 const fixedRows = roster.agents.size + roster.earlier.length + 12 + claimRows
1005 const maxMessages = Math.max(5, Math.min(40, e.props.scroll.bodyRows - fixedRows))
1006 const rows = paneRows(roster, log, policy, now, e.props.bodyColumns, maxMessages, board, stallMin * 60_000)
1007 const back = e.surface === 'terminal'
1008 ? buttonsOf($, e, viewControls('list', true), key => showView($, key.slice('view-'.length) as 'A' | 'B' | 'list'))
1009 : []
1010 return Box({
1011 flexDirection: 'column',
1012 children: [
1013 ...rows.map(r => Text({ bold: r.bold === true, dimColor: r.dim === true, wrap: 'truncate-end', children: [r.text === '' ? ' ' : r.text] })),
1014 Text({ children: [' '] }),
1015 wakeRow(...back),
1016 ],
1017 })
1018 })
1019
1020 on('ui.close', async ($, e, next) => {
1021 const result = await next(e)
1022 try {
1023 if (e.id === PANE) await paneClosed($)
1024 } catch (err) {
1025 $.ui.log('swarm ui.close: ' + String(err))
1026 }
1027 return result
1028 }).catch(async ($, e, next) => next(e))
1029
1030 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1031 if (e.props.hasSurvey || (roster.agents.size === 0 && log.total === 0)) return next(e)
1032 const { Box, Text } = $.ui.resolve(e)
1033 const theirs = await next(e)
1034 const line = Text({ dimColor: true, wrap: 'truncate-end', children: [bandLine(await $.clock.now())] })
1035 return Box({ flexDirection: 'column', children: [line, theirs] })
1036 })
1037}
1038hooks/core.ts 1471 lines1// Pure swarm state: message log, roster, wake policy and the text the pane
2// and band draw. No mods API here, so every rule is unit-testable.
3
4export type WakeMode = 'all' | 'mentions' | 'digest' | 'off'
5export const WAKE_MODES: readonly WakeMode[] = ['all', 'mentions', 'digest', 'off']
6
7export const LEAD = 'team-lead'
8/** The `from` of the messages this plugin sends and logs. */
9export const MOD = 'swarm'
10/** Whether a logged sender is this plugin: its name, and also the names older logs use. */
11export const isModName = (name: string): boolean => name === MOD || name === 'swarm-console'
12/** Message types that report on a claim. */
13export const REPORTS: readonly string[] = ['DONE', 'REVIEW', 'BLOCK']
14export const COALESCE_MS = { all: 10_000, mentions: 3_000 } as const
15export const DIGEST_MS = 60_000
16/** How many recent sends a delivery is checked against: a delivery reaches a busy receiver only at its next turn or tool round. */
17const SEND_MEMORY = 200
18export const MEMORY_CAP = 500
19export const DEFAULT_STALL_MIN = 5
20/** The plugin a refused command registration names as holding the command (`… the plugin <name> registered it already`), else the refusal's text. */
21export function commandHolder(refusal: string): string {
22 return /the plugin (\S+) registered it/.exec(refusal)?.[1] ?? refusal.replace(/^\w*Error: /, '')
23}
24export const DEFAULT_CLAIM_MIN = 3
25/** Minutes before the stall watchdog nudges the same teammate again. */
26export const NUDGE_COOLDOWN_MIN = 10
27/** Minutes without a message from its holder after which a claim shows as stale. */
28const CLAIM_TTL_MIN = 10
29const LABEL_MAX = 28
30const EARLIER = '── earlier ──'
31export const OTHER_AGENTS = '── other agents ──'
32const STALE_MS = 10 * 60_000
33/** The statuses of an agent that has ended. */
34export const ENDED: readonly string[] = ['completed', 'failed', 'killed']
35
36const LEAD_ALIASES = new Set(['team-lead', 'main', 'lead', 'orchestrator'])
37const MENTION = /(^|[^\w@])@(lead|orchestrator|main|team-lead)\b/i
38const TYPE_TAG = /^\s*\[(PROPOSE|CLAIM|LOCK|WAIT|ASK|REVIEW|BLOCK|HANDOFF|DONE)\]/i
39/** The order stamp line the mod puts before a teammate-to-teammate message (`#412 · 17:04:12`), after any envelope tag. */
40const STAMP_LINE = /^(\s*(?:<[^>]*>\s*)?)#\d+ · \d\d:\d\d:\d\d[^\S\n]*\n/
41const HELLO = /^(?:<[^>]*>\s*)?\[PROPOSE\]\s+hello\s+([A-Za-z][\w-]{0,23})\b/i
42const NAME = '([A-Za-z][\\w-]{0,23})\\b'
43const CAP_NAME = '([A-Z][\\w-]{0,23})\\b'
44const SELF = "\\bI(?:'m)?\\b(?:\\s*\\([^)]*\\))?"
45const RENAMES: readonly RegExp[] = [
46 new RegExp(SELF + '\\s+(?:am\\s+|have\\s+)?(?:now\\s+)?renam(?:e|ed|ing)\\s+(?:myself\\s+)?(?:to\\s+|as\\s+)?' + NAME, 'i'),
47 new RegExp('^renamed\\s+(?:to\\s+)?' + NAME, 'i'),
48 new RegExp('\\((?:now|renamed(?:\\s+to)?)\\s+' + NAME + '\\s*\\)', 'i'),
49 new RegExp('\\bcall\\s+me\\s+' + NAME, 'i'),
50 new RegExp('\\bnow\\s+(?:called|named|known\\s+as|going\\s+by)\\s+' + NAME, 'i'),
51 new RegExp(SELF + '\\s+(?:am\\s+)?now\\s+' + CAP_NAME),
52 new RegExp(SELF + '\\s+am\\s+' + CAP_NAME),
53 new RegExp("\\bI'm\\s+" + CAP_NAME),
54]
55const NOT_NAMES = new Set(['to', 'as', 'me', 'myself', 'the', 'a', 'an', 'now', 'not', 'ok', 'okay', 'done', 'ready', 'busy', 'free', 'here', 'back',
56 'also', 'still', 'idle', 'available', 'blocked', 'waiting', 'working', 'on', 'in', 'at', 'with', 'sure', 'going', 'it', 'this', 'that'])
57const MODEL_FAMILIES = ['opus', 'sonnet', 'haiku', 'fable']
58
59export type Via = 'send' | 'receive' | 'tool'
60
61export type LogEntry = {
62 ts: number
63 from: string
64 to: string
65 summary: string
66 length: number
67 type?: string
68 via: Via
69 /** The order stamp the mod gave a teammate-to-teammate send. */
70 stamp?: number
71 /** On an ACT line: how many tool calls the teammate had made by then. */
72 calls?: number
73 idleReason?: string
74 session?: string
75 fromId?: string
76 toId?: string
77}
78
79export type RosterEntry = {
80 id: string
81 name: string
82 model?: string
83 type?: string
84 status: string
85 isTeammate: boolean
86 teammateId?: string
87 description?: string
88 /** When `agent.spawn` returned it. */
89 spawnedAt?: number
90 /** When its status last changed. */
91 statusAt?: number
92 /** When `$.agent.list()` first reported it, for an agent whose spawn this load did not see. */
93 firstSeenAt?: number
94 /** Whether `$.agent.list()` has reported it; one it then stops reporting has ended. */
95 isListed?: boolean
96 /** The Workflow run that started it (`wf_…`), for a workflow script's agent. */
97 workflowRunId?: string
98}
99
100/** Whether an agent was started by a workflow script: `agent.spawn`'s `workflow`, or the type the engine gives such agents. */
101function isWorkflowAgent(a: RosterEntry): boolean {
102 return a.workflowRunId !== undefined || a.type === 'workflow-subagent'
103}
104
105export type AgentRow = {
106 id: string
107 name?: string
108 type: string
109 status: string
110 teammateId?: string
111 description: string
112}
113
114/** The address without its `@team` suffix, lower-cased. */
115export function bareName(address: string): string {
116 return (address.split('@')[0] ?? '').trim().toLowerCase()
117}
118
119export function isLeadAddress(address: string): boolean {
120 return LEAD_ALIASES.has(bareName(address))
121}
122
123/** A message without the order stamp line the mod put before it. */
124export function unstamped(text: string): string {
125 return text.replace(STAMP_LINE, '$1')
126}
127
128/** The stamp line put before a teammate-to-teammate message: its order `n` in the session and the time it was sent. */
129export function stampLine(n: number, ts: number): string {
130 return `#${n} · ${new Date(ts).toISOString().slice(11, 19)}`
131}
132
133export function firstLine(text: string, max = 160): string {
134 const line = unstamped(text).split('\n').find(l => l.trim() !== '') ?? ''
135 const trimmed = line.trim()
136 return trimmed.length > max ? trimmed.slice(0, max - 1) + '…' : trimmed
137}
138
139export function typeOf(text: string): string | undefined {
140 const m = TYPE_TAG.exec(unstamped(text))
141 return m?.[1]?.toUpperCase()
142}
143
144export type IdleNotice = { from?: string; idleReason?: string }
145
146/** The idle notification a delivery carries (a JSON body with `type: "idle_notification"`), or undefined. */
147export function idleNotice(text: string): IdleNotice | undefined {
148 if (!text.includes('idle_notification')) return undefined
149 const start = text.indexOf('{')
150 const end = text.lastIndexOf('}')
151 if (start < 0 || end <= start) return undefined
152 try {
153 const o = JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
154 if (o.type !== 'idle_notification') return undefined
155 const notice: IdleNotice = {}
156 if (typeof o.from === 'string') notice.from = o.from
157 if (typeof o.idleReason === 'string') notice.idleReason = o.idleReason
158 return notice
159 } catch {
160 return undefined
161 }
162}
163
164/** The code name a teammate announces on the first line as `[PROPOSE] hello <Name>`. */
165export function helloName(text: string): string | undefined {
166 return HELLO.exec(firstLine(text))?.[1]
167}
168
169/**
170 * The new code name a sender gives itself anywhere in a message: `I (t3) rename to kite`, `renamed Kite` at a line's start
171 * (after its type tag), `(renamed kite)`, `(now Kite)`, `call me Kite`, `now called Kite`, `I am now Kite`, `I am Kite`, `I'm Kite`.
172 * The last three need a capitalised name.
173 */
174export function renameName(text: string): string | undefined {
175 for (const raw of text.split('\n')) {
176 const line = raw.replace(/^\s*(?:<[^>]*>\s*)?(?:\[[A-Za-z]+\]\s*)?/, '').trim()
177 for (const re of RENAMES) {
178 const name = re.exec(line)?.[1]
179 if (name !== undefined && !NOT_NAMES.has(name.toLowerCase())) return name
180 }
181 }
182 return undefined
183}
184
185/** A model id as family plus version: `claude-opus-5-5[1m]` → `opus 5.5`, `claude-3-5-sonnet-20241022` → `sonnet 3.5`; `–` when unknown. */
186export function shortModel(id: string | undefined): string {
187 if (id === undefined || id.trim() === '') return '–'
188 const parts = id.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').replace(/^(?:[\w.]+\.)?anthropic\./, '').replace(/^claude-/, '').replace(/-v\d+(?::\d+)?$/, '').split('-')
189 const family = parts.find(p => MODEL_FAMILIES.includes(p)) ?? parts.find(p => /^[a-z]+$/.test(p))
190 const version = parts.filter(p => /^\d{1,2}$/.test(p)).join('.')
191 if (family === undefined) return id.trim()
192 return version === '' ? family : `${family} ${version}`
193}
194
195/**
196 * The run id and workflow name a Workflow tool call started, from its result (`runId`, `workflowName`, at any depth up to
197 * three) or its input (`name`, else the script file's name before `-wf_`); undefined without a run id.
198 */
199export function workflowRunOf(input: unknown, result: unknown): { runId: string; name: string } | undefined {
200 const find = (o: unknown, key: string, depth = 0): string | undefined => {
201 if (depth > 3 || o === null || typeof o !== 'object') return undefined
202 const v = (o as Record<string, unknown>)[key]
203 if (typeof v === 'string' && v !== '') return v
204 for (const child of Object.values(o as Record<string, unknown>)) {
205 const found = find(child, key, depth + 1)
206 if (found !== undefined) return found
207 }
208 return undefined
209 }
210 const text = typeof result === 'string' ? result : JSON.stringify(result ?? null)
211 const runId = find(result, 'runId') ?? /\bRun ID:\s*(wf_[\w-]+)/.exec(text)?.[1] ?? /"runId"\s*:\s*"(wf_[\w-]+)"/.exec(text)?.[1]
212 if (runId === undefined) return undefined
213 const i = (input ?? {}) as { name?: unknown; scriptPath?: unknown }
214 const script = typeof i.scriptPath === 'string' ? /([\w.-]+?)-wf_[\w-]+\.js$/.exec(i.scriptPath)?.[1] : undefined
215 const name = find(result, 'workflowName') ?? (typeof i.name === 'string' && i.name !== '' ? i.name : undefined) ?? script ?? runId
216 return { runId, name }
217}
218
219export function mentionsLead(text: string): boolean {
220 return MENTION.test(text)
221}
222
223/** Text of a SendMessage `message` argument, which may be a structured protocol object. */
224export function messageText(message: unknown): string {
225 if (typeof message === 'string') return message
226 if (message && typeof message === 'object' && 'type' in message) {
227 return '[' + String((message as { type: unknown }).type) + ']'
228 }
229 return ''
230}
231
232export class Roster {
233 readonly agents = new Map<string, RosterEntry>()
234 /** Code names by the agent's bare team name. */
235 readonly codeNames = new Map<string, string>()
236 /** Idle notifications by the agent's bare team name, with the last reason given. */
237 readonly idle = new Map<string, { count: number; lastReason?: string; lastAt?: number }>()
238 /** When each agent, by bare team name, was first seen sending a message. */
239 readonly firstMessageAt = new Map<string, number>()
240 /** When each agent, by bare team name, last sent a message. */
241 readonly lastMessageAt = new Map<string, number>()
242 /** Workflow names by run id, from the Workflow tool's calls. */
243 readonly workflowNames = new Map<string, string>()
244 /** Agents from a previous session's roster.json, shown under `earlier`. */
245 earlier: RosterEntry[] = []
246 /** Whether the lead is in a turn. */
247 leadBusy = false
248
249 /** Counts one idle notification from `sender`, received at `ts`. */
250 noteIdle(sender: string, reason?: string, ts?: number) {
251 const name = bareName(sender)
252 const prior = this.idle.get(name)
253 const lastAt = ts === undefined ? prior?.lastAt : Math.max(ts, prior?.lastAt ?? ts)
254 this.idle.set(name, { count: (prior?.count ?? 0) + 1, lastReason: reason ?? prior?.lastReason, lastAt })
255 }
256
257 /** Records that `sender` sent a message at `ts`; keeps the earliest and the latest. */
258 noteMessage(sender: string, ts: number) {
259 const name = bareName(sender)
260 if (name === '') return
261 const prior = this.firstMessageAt.get(name)
262 if (prior === undefined || ts < prior) this.firstMessageAt.set(name, ts)
263 if (ts > (this.lastMessageAt.get(name) ?? -Infinity)) this.lastMessageAt.set(name, ts)
264 }
265
266 /** The latest agent going by `name` (bare team name) that has not ended, or undefined. */
267 liveEntry(name: string): RosterEntry | undefined {
268 const n = bareName(name)
269 return [...this.agents.values()].filter(a => bareName(a.name) === n && !ENDED.includes(a.status)).pop()
270 }
271
272 /** When `name` went idle: its last idle notice, while it has sent nothing since and its live agent is not running; else undefined. */
273 idleSince(name: string): number | undefined {
274 const n = bareName(name)
275 const at = this.idle.get(n)?.lastAt
276 if (at === undefined || (this.lastMessageAt.get(n) ?? -Infinity) > at) return undefined
277 const entry = this.liveEntry(n)
278 return entry === undefined || entry.status === 'running' ? undefined : at
279 }
280
281 /**
282 * Stores the code name `sender` gives itself, with its casing as written: a first-line `[PROPOSE] hello <Name>` or a rename
283 * (see `renameName`). A received message sets a name only while the sender has none, since deliveries can arrive after a
284 * later rename. Returns whether it changed. The lead has none.
285 */
286 announce(sender: string, text: string, via: Via = 'send'): boolean {
287 const name = bareName(sender)
288 if (name === '' || isLeadAddress(name) || name === 'peer') return false
289 const code = helloName(text) ?? renameName(text)
290 if (code === undefined) return false
291 if (via === 'receive' && this.codeNames.has(name)) return false
292 if (this.codeNames.get(name) === code) return false
293 this.codeNames.set(name, code)
294 return true
295 }
296
297 /** Bare team names of current agents whose code name another current agent also uses, case-insensitively. */
298 duplicateCodes(): Set<string> {
299 const byCode = new Map<string, string[]>()
300 for (const a of this.agents.values()) {
301 if (ENDED.includes(a.status)) continue
302 const name = bareName(a.name)
303 const code = this.codeNames.get(name)?.toLowerCase()
304 if (code === undefined) continue
305 const names = byCode.get(code) ?? []
306 if (!names.includes(name)) names.push(name)
307 byCode.set(code, names)
308 }
309 const out = new Set<string>()
310 for (const names of byCode.values()) if (names.length > 1) for (const n of names) out.add(n)
311 return out
312 }
313
314 /** The team of the most recently added teammate (the part after `@` of its teammate id), or undefined without teammates. */
315 currentTeam(): string | undefined {
316 let team: string | undefined
317 for (const a of this.agents.values()) if (a.isTeammate) team = a.teammateId?.split('@')[1] ?? ''
318 return team
319 }
320
321 /**
322 * Whether an agent belongs under `other agents`: outside the current team while a team exists, completed, failed or
323 * killed, or pending for more than `STALE_MS`.
324 */
325 isOther(a: RosterEntry, now: number): boolean {
326 const team = this.currentTeam()
327 if (team !== undefined && (!a.isTeammate || (a.teammateId?.split('@')[1] ?? '') !== team)) return true
328 if (ENDED.includes(a.status)) return true
329 return a.status === 'pending' && a.statusAt !== undefined && now - a.statusAt > STALE_MS
330 }
331
332 /** When an agent started: its spawn, else its first message, else when the agent list first reported it; undefined when none was seen. */
333 startedAt(name: string): number | undefined {
334 const n = bareName(name)
335 const entry = [...this.agents.values()].find(a => bareName(a.name) === n)
336 return entry?.spawnedAt ?? this.firstMessageAt.get(n) ?? entry?.firstSeenAt
337 }
338
339 /**
340 * `Atlas (t1)` once t1 announced Atlas; for an agent without a name of its own
341 * (named by its id prefix) its spawn description, also when addressed by its full id; else the name itself.
342 */
343 label(name: string): string {
344 const code = this.codeNames.get(bareName(name))
345 if (code !== undefined) return code.toLowerCase() === bareName(name) ? `${name} (no code name)` : `${code} (${name})`
346 const entry = [...this.agents.values(), ...this.earlier].find(a => a.name === name || a.id === name)
347 if (entry !== undefined && entry.name === entry.id.slice(0, 8) && entry.description) return clip(entry.description.trim(), LABEL_MAX)
348 return name
349 }
350
351 /** Restores a roster.json: this session's agents rejoin the roster, a previous session's go to `earlier`. */
352 hydrate(json: unknown, session: string, now?: number) {
353 if (!json || typeof json !== 'object') return
354 const j = json as { session?: unknown; agents?: unknown }
355 if (!Array.isArray(j.agents)) return
356 const isCurrent = j.session === session
357 for (const raw of j.agents) {
358 if (!raw || typeof raw !== 'object' || typeof (raw as RosterEntry).id !== 'string') continue
359 const a = raw as RosterEntry & { codeName?: unknown; idleNotices?: unknown; lastIdleReason?: unknown; lastIdleAt?: unknown }
360 const entry: RosterEntry = {
361 id: a.id,
362 name: typeof a.name === 'string' ? a.name : a.id.slice(0, 8),
363 model: typeof a.model === 'string' ? a.model : undefined,
364 type: typeof a.type === 'string' ? a.type : undefined,
365 status: isCurrent && typeof a.status === 'string' ? a.status : 'earlier',
366 isTeammate: a.isTeammate === true,
367 teammateId: typeof a.teammateId === 'string' ? a.teammateId : undefined,
368 description: typeof a.description === 'string' ? a.description : undefined,
369 spawnedAt: typeof a.spawnedAt === 'number' ? a.spawnedAt : undefined,
370 statusAt: isCurrent && typeof a.statusAt === 'number' ? a.statusAt : undefined,
371 workflowRunId: typeof a.workflowRunId === 'string' ? a.workflowRunId : undefined,
372 }
373 if (isCurrent && now !== undefined && isWorkflowAgent(entry) && !ENDED.includes(entry.status) && (entry.statusAt === undefined || now - entry.statusAt > STALE_MS)) {
374 entry.status = 'completed'
375 entry.statusAt = now
376 }
377 if (typeof a.codeName === 'string' && !this.codeNames.has(bareName(entry.name))) this.codeNames.set(bareName(entry.name), a.codeName)
378 if (isCurrent) {
379 if (!this.agents.has(entry.id)) this.agents.set(entry.id, entry)
380 if (typeof a.idleNotices === 'number') {
381 this.idle.set(bareName(entry.name), {
382 count: a.idleNotices,
383 lastReason: typeof a.lastIdleReason === 'string' ? a.lastIdleReason : undefined,
384 lastAt: typeof a.lastIdleAt === 'number' ? a.lastIdleAt : undefined,
385 })
386 }
387 } else if (!this.earlier.some(e => e.id === entry.id)) {
388 this.earlier.push(entry)
389 }
390 }
391 }
392
393 /** The bare team name an agent goes by, given its team name or its code name. */
394 resolve(nameOrCode: string): string {
395 const n = bareName(nameOrCode)
396 for (const [name, code] of this.codeNames) if (code.toLowerCase() === n) return name
397 return n
398 }
399
400 /** Records an `agent.spawn` once `next` answered with the started agent. */
401 spawned(input: { name?: string; model?: string; subagentType?: string; isTeammate?: boolean; description?: string; workflow?: { runId?: string } },
402 result: { agentId?: string; model?: string; teammateId?: string }, now?: number): RosterEntry | undefined {
403 if (!result.agentId) return undefined
404 const prior = this.agents.get(result.agentId)
405 const entry: RosterEntry = {
406 id: result.agentId,
407 name: input.name ?? prior?.name ?? result.agentId.slice(0, 8),
408 model: result.model ?? input.model ?? prior?.model,
409 type: input.subagentType ?? prior?.type,
410 status: prior?.status ?? 'pending',
411 isTeammate: input.isTeammate === true,
412 teammateId: result.teammateId ?? prior?.teammateId,
413 description: input.description ?? prior?.description,
414 spawnedAt: prior?.spawnedAt ?? now,
415 statusAt: prior?.statusAt ?? now,
416 workflowRunId: input.workflow?.runId || prior?.workflowRunId,
417 }
418 this.agents.set(entry.id, entry)
419 return entry
420 }
421
422 /** Merges `$.agent.list()` rows seen at `now`; returns whether anything changed. */
423 merge(rows: readonly AgentRow[], now?: number): boolean {
424 let changed = false
425 const seen = new Set(rows.map(r => r.id))
426 for (const a of this.agents.values()) {
427 if (a.isListed === true && !seen.has(a.id) && !ENDED.includes(a.status)) {
428 this.agents.set(a.id, { ...a, status: 'completed', statusAt: now })
429 changed = true
430 }
431 }
432 for (const row of rows) {
433 const prior = this.agents.get(row.id)
434 const next: RosterEntry = {
435 ...prior,
436 isListed: true,
437 id: row.id,
438 name: row.name ?? prior?.name ?? row.id.slice(0, 8),
439 model: prior?.model,
440 type: prior?.type ?? row.type,
441 status: row.status,
442 isTeammate: prior?.isTeammate ?? (row.teammateId !== undefined || row.type === 'teammate'),
443 teammateId: row.teammateId ?? prior?.teammateId,
444 description: prior?.description ?? row.description,
445 spawnedAt: prior?.spawnedAt,
446 firstSeenAt: prior === undefined ? now : prior.firstSeenAt,
447 statusAt: prior !== undefined && prior.status === row.status ? prior.statusAt : now,
448 }
449 if (!prior || JSON.stringify(prior) !== JSON.stringify(next)) changed = true
450 this.agents.set(row.id, next)
451 }
452 return changed
453 }
454
455 /** Marks a workflow agent whose turn completed as completed; returns whether it changed. */
456 settle(agentId: string, now?: number): boolean {
457 const a = this.agents.get(agentId)
458 if (a === undefined || !isWorkflowAgent(a) || ENDED.includes(a.status)) return false
459 this.agents.set(agentId, { ...a, status: 'completed', statusAt: now })
460 return true
461 }
462
463 nameOf(agentId: string | undefined): string {
464 if (agentId === undefined) return LEAD
465 return this.agents.get(agentId)?.name ?? agentId.slice(0, 8)
466 }
467
468 /** Agents whose loop is doing or about to do work. */
469 activeCount(): number {
470 let n = 0
471 for (const a of this.agents.values()) if (a.status === 'running' || a.status === 'pending' || a.status === 'waiting') n++
472 return n
473 }
474
475 /** Whether teammates were spawned and every one has ended. */
476 isTeamEnded(): boolean {
477 const mates = [...this.agents.values()].filter(a => a.isTeammate)
478 return mates.length > 0 && mates.every(a => ENDED.includes(a.status))
479 }
480
481 /** Whether a teammate ever went by `name` (bare team name). */
482 hasTeammate(name: string): boolean {
483 const n = bareName(name)
484 return [...this.agents.values()].some(a => a.isTeammate && bareName(a.name) === n)
485 }
486
487 /** Whether a teammate that goes by `name` (bare team name) ended at `since` or later and none by that name is live. */
488 hasStoppedSince(name: string, since: number): boolean {
489 const n = bareName(name)
490 const mates = [...this.agents.values()].filter(a => a.isTeammate && bareName(a.name) === n)
491 return mates.length > 0 && mates.every(a => ENDED.includes(a.status)) && mates.some(a => (a.statusAt ?? -Infinity) >= since)
492 }
493
494 /** Whether a teammate that goes by `name` (bare team name) has not ended. */
495 isLiveTeammate(name: string): boolean {
496 const n = bareName(name)
497 return [...this.agents.values()].some(a => a.isTeammate && bareName(a.name) === n && !ENDED.includes(a.status))
498 }
499
500 /** Agents not ended (completed, failed, killed). */
501 liveCount(): number {
502 let n = 0
503 for (const a of this.agents.values()) if (!ENDED.includes(a.status)) n++
504 return n
505 }
506
507 toJSON(now: number, session?: string) {
508 return {
509 updatedAt: new Date(now).toISOString(),
510 ...(session === undefined ? {} : { session }),
511 lead: { name: LEAD, status: this.leadBusy ? 'running' : 'idle' },
512 agents: [...this.agents.values()].map(a => {
513 const codeName = this.codeNames.get(bareName(a.name))
514 const idle = this.idle.get(bareName(a.name))
515 return {
516 ...a,
517 ...(codeName === undefined ? {} : { codeName, label: this.label(a.name) }),
518 ...(idle === undefined ? {} : { idleNotices: idle.count, lastIdleReason: idle.lastReason, lastIdleAt: idle.lastAt }),
519 }
520 }),
521 }
522 }
523}
524
525export class MessageLog {
526 readonly entries: LogEntry[] = []
527 /** Messages from a previous session's log file, newest last. */
528 earlier: LogEntry[] = []
529 total = 0
530 lastAt: number | null = null
531 readonly sent = new Map<string, number>()
532 readonly received = new Map<string, number>()
533 private recentSends: { from: string; to: string; text: string }[] = []
534
535 /** Records one message; returns the entry, or undefined when it is the delivery of a recorded send. */
536 record(m: { ts: number; from: string; to: string; text: string; via: Via; fromId?: string; toId?: string }): LogEntry | undefined {
537 if (m.via === 'receive' && this.isEchoOfSend(m.from, m.to, m.text)) return undefined
538 if (m.via !== 'receive') {
539 this.recentSends.push({ from: m.from, to: m.to, text: m.text })
540 if (this.recentSends.length > SEND_MEMORY) this.recentSends.splice(0, this.recentSends.length - SEND_MEMORY)
541 }
542 const entry: LogEntry = {
543 ts: m.ts,
544 from: m.from,
545 to: m.to,
546 summary: firstLine(m.text),
547 length: m.text.length,
548 via: m.via,
549 }
550 const type = typeOf(m.text)
551 if (type) entry.type = type
552 if (m.fromId) entry.fromId = m.fromId
553 if (m.toId) entry.toId = m.toId
554 this.entries.push(entry)
555 if (this.entries.length > MEMORY_CAP) this.entries.splice(0, this.entries.length - MEMORY_CAP)
556 this.total++
557 this.lastAt = m.ts
558 this.sent.set(m.from, (this.sent.get(m.from) ?? 0) + 1)
559 this.received.set(m.to, (this.received.get(m.to) ?? 0) + 1)
560 return entry
561 }
562
563 /** Restores a logged line of this session as if it had just been recorded, without dedupe. */
564 restore(e: LogEntry) {
565 this.entries.push(e)
566 if (this.entries.length > MEMORY_CAP) this.entries.splice(0, this.entries.length - MEMORY_CAP)
567 this.total++
568 this.lastAt = this.lastAt === null ? e.ts : Math.max(this.lastAt, e.ts)
569 this.sent.set(e.from, (this.sent.get(e.from) ?? 0) + 1)
570 this.received.set(e.to, (this.received.get(e.to) ?? 0) + 1)
571 }
572
573 /** Whether a delivery carries a recorded send's text, from the same sender or to the same recipient. */
574 private isEchoOfSend(from: string, to: string, text: string): boolean {
575 const t = text.trim()
576 if (t === '') return false
577 return this.recentSends.some(s => {
578 const st = s.text.trim()
579 if (st === '') return false
580 const sameEnd = bareName(s.from) === bareName(from) || bareName(s.to) === bareName(to) || from === 'peer' || from === 'coordinator'
581 return sameEnd && (t.includes(st) || st.includes(t))
582 })
583 }
584
585 last(n: number): LogEntry[] {
586 return this.entries.slice(-n)
587 }
588}
589
590/** Whether a logged line is an idle notification. */
591export function isIdleLine(e: LogEntry): boolean {
592 return e.type === 'IDLE'
593}
594
595/** Whether a logged line records a teammate's tool call (`FILE`, `ACT`), not a message. */
596export function isToolLine(e: LogEntry): boolean {
597 return e.type === 'FILE' || e.type === 'ACT'
598}
599
600const WRITE_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
601
602/**
603 * What a FILE line shows for a file tool call: `✎ <path>` for Edit, Write, MultiEdit and NotebookEdit, `◦ <path>` for Read,
604 * relative to `cwd` when inside it, else its last two segments after `…/`; undefined for any other call.
605 */
606export function fileOf(tool: string, input: unknown, cwd: string | null): string | undefined {
607 const mark = tool === 'Read' ? '◦' : WRITE_TOOLS.includes(tool) ? '✎' : undefined
608 const path = pathOf(input)
609 if (mark === undefined || path === undefined) return undefined
610 return `${mark} ${shownPath(path, cwd)}`
611}
612
613/** A path as a card shows it: relative to `cwd` when inside it, else its last two segments after `…/`. */
614function shownPath(path: string, cwd: string | null): string {
615 if (cwd !== null && path.startsWith(cwd + '/')) return path.slice(cwd.length + 1)
616 const parts = path.split('/').filter(Boolean)
617 return `${parts.length > 2 ? '…/' : ''}${parts.slice(-2).join('/')}`
618}
619
620/** One line of a tool call's input, at most `max` characters. */
621function brief(value: unknown, max = 48): string {
622 const line = (typeof value === 'string' ? value : '').split('\n')[0]!.replace(/\s+/g, ' ').trim()
623 return line.length > max ? line.slice(0, max - 1) + '…' : line
624}
625
626/**
627 * A tool call in plain words for a card: `Reading README.md`, `Editing hooks/core.ts`, `Running npm test`,
628 * `Searching 'claimOf'`, `Messaging t2`; `Using <tool>` for a tool it has no words for.
629 */
630export function actionOf(tool: string, input: unknown, cwd: string | null): string {
631 const i = (input ?? {}) as Record<string, unknown>
632 const path = pathOf(input)
633 const file = path === undefined ? '' : shownPath(path, cwd)
634 switch (tool) {
635 case 'Read': return `Reading ${file}`
636 case 'Write': return `Writing ${file}`
637 case 'Edit':
638 case 'MultiEdit':
639 case 'NotebookEdit': return `Editing ${file}`
640 case 'Bash': return `Running ${brief(i.command)}`
641 case 'Grep': return `Searching '${brief(i.pattern, 32)}'`
642 case 'Glob': return `Finding ${brief(i.pattern, 32)}`
643 case 'SendMessage': return `Messaging ${brief(i.to, 24)}`
644 case 'WebFetch': return `Fetching ${/^\w+:\/\/([^/]+)/.exec(brief(i.url, 200))?.[1] ?? 'a page'}`
645 case 'WebSearch': return `Searching the web for '${brief(i.query, 32)}'`
646 case 'Agent':
647 case 'Task': return 'Starting an agent'
648 default: return /^Task/.test(tool) ? 'Updating tasks' : `Using ${tool}`
649 }
650}
651
652/** The file a tool call names (`file_path`, else `notebook_path`), or undefined. */
653function pathOf(input: unknown): string | undefined {
654 const i = (input ?? {}) as { file_path?: unknown; notebook_path?: unknown }
655 const path = typeof i.file_path === 'string' ? i.file_path : typeof i.notebook_path === 'string' ? i.notebook_path : undefined
656 return path === '' ? undefined : path
657}
658
659/** The file an Edit, Write, MultiEdit or NotebookEdit call writes, or undefined for any other call. */
660export function writtenPath(tool: string, input: unknown): string | undefined {
661 return WRITE_TOOLS.includes(tool) ? pathOf(input) : undefined
662}
663
664/** Log lines from JSONL text; lines that are not entries are skipped. */
665export function parseLog(text: string): LogEntry[] {
666 const out: LogEntry[] = []
667 for (const line of text.split('\n')) {
668 if (line.trim() === '') continue
669 try {
670 const e = JSON.parse(line) as LogEntry
671 if (typeof e.ts === 'number' && typeof e.from === 'string' && typeof e.to === 'string' && typeof e.summary === 'string') out.push(e)
672 } catch {
673 continue
674 }
675 }
676 return out
677}
678
679/**
680 * Restores the log file's tail: this session's lines count as messages, a previous session's go to `earlier`. This session's
681 * idle lines give each agent's wait count (the larger of roster.json's count and the lines') and the time of its last wait.
682 */
683export function hydrateLog(log: MessageLog, roster: Roster, lines: readonly LogEntry[], session: string) {
684 const waits = new Roster()
685 for (const e of lines) {
686 if (isToolLine(e)) continue
687 if (e.session === session) {
688 if (isIdleLine(e)) waits.noteIdle(e.from, e.idleReason, e.ts)
689 else {
690 log.restore(e)
691 roster.noteMessage(e.from, e.ts)
692 }
693 } else if (!isIdleLine(e)) {
694 log.earlier.push(e)
695 }
696 }
697 for (const [name, w] of waits.idle) {
698 const prior = roster.idle.get(name)
699 roster.idle.set(name, { count: Math.max(prior?.count ?? 0, w.count), lastReason: w.lastReason ?? prior?.lastReason, lastAt: Math.max(prior?.lastAt ?? 0, w.lastAt ?? 0) || undefined })
700 }
701 if (log.earlier.length > MEMORY_CAP) log.earlier = log.earlier.slice(-MEMORY_CAP)
702}
703
704export function jsonl(entries: readonly LogEntry[]): string {
705 return entries.map(e => JSON.stringify(e)).join('\n') + (entries.length ? '\n' : '')
706}
707
708export function ago(ms: number): string {
709 const s = Math.max(0, Math.floor(ms / 1000))
710 if (s < 60) return s + 's'
711 const m = Math.floor(s / 60)
712 if (m < 60) return m + 'm'
713 return Math.floor(m / 60) + 'h' + String(m % 60).padStart(2, '0')
714}
715
716/**
717 * Decides when the lead is woken. Messages addressed to the lead are already
718 * delivered to it by the team mailbox, so they never cause a wake here.
719 */
720export class WakePolicy {
721 mode: WakeMode = 'mentions'
722 pending: LogEntry[] = []
723 firstPendingAt: number | null = null
724 lastDigestAt = 0
725 inFlight = false
726 /** Deliveries that reached the lead plus the mod's own wakes. */
727 wakes = 0
728 /** Deliveries the lead filter kept from the lead. */
729 held = 0
730
731 /** Considers one logged message; returns whether it was queued for a wake. */
732 offer(entry: LogEntry, text: string): boolean {
733 if (this.mode === 'off') return false
734 if (entry.from === LEAD || isLeadAddress(entry.from)) return false
735 if (isLeadAddress(entry.to)) return false
736 if (this.mode === 'mentions' && !mentionsLead(text)) return false
737 if (this.pending.length === 0) this.firstPendingAt = entry.ts
738 this.pending.push(entry)
739 return true
740 }
741
742 setMode(mode: WakeMode, now: number) {
743 this.mode = mode
744 this.pending = []
745 this.firstPendingAt = null
746 this.lastDigestAt = now
747 }
748
749 /** The wake text to submit now, or null; clears the queue when it returns text. */
750 due(now: number, leadBusy: boolean): string | null {
751 if (this.mode === 'off' || leadBusy || this.inFlight) return null
752 if (this.mode === 'digest') {
753 if (now - this.lastDigestAt < DIGEST_MS) return null
754 this.lastDigestAt = now
755 if (this.pending.length === 0) return null
756 } else {
757 if (this.pending.length === 0 || this.firstPendingAt === null) return null
758 if (now - this.firstPendingAt < COALESCE_MS[this.mode]) return null
759 }
760 const text = wakeText(this.mode, this.pending)
761 this.pending = []
762 this.firstPendingAt = null
763 return text
764 }
765}
766
767const HELD_TYPES = ['PROPOSE', 'CLAIM', 'HANDOFF', 'REVIEW']
768
769/**
770 * Whether the lead filter keeps a teammate's delivery from the lead: an idle notice whose reason is `available` or absent;
771 * else a `[PROPOSE]`, `[CLAIM]`, `[HANDOFF]` or `[REVIEW]` that neither mentions `@lead` nor has a line starting `[ASK]` or `[BLOCK]`.
772 */
773export function isHeldFromLead(text: string, idle: IdleNotice | undefined): boolean {
774 if (idle !== undefined) return idle.idleReason === undefined || idle.idleReason === 'available'
775 if (mentionsLead(text) || /^\s*(?:<[^>]*>\s*)?\[(?:ASK|BLOCK)\]/im.test(text)) return false
776 const type = typeOf(text.replace(/^\s*<[^>]*>/, ''))
777 return type !== undefined && HELD_TYPES.includes(type)
778}
779
780function wakeText(mode: WakeMode, entries: readonly LogEntry[]): string {
781 const shown = entries.slice(-12)
782 const lines = shown.map(e => `- ${e.from} → ${e.to}: ${e.summary}`)
783 const more = entries.length > shown.length ? [`- … ${entries.length - shown.length} earlier`] : []
784 const head = mode === 'mentions'
785 ? `swarm: ${entries.length} message(s) between teammates mention you:`
786 : `swarm (${mode}): ${entries.length} new message(s) between teammates:`
787 return [head, ...more, ...lines,
788 'Act only if a decision, a tie, a blocker or a budget call needs you; teammates coordinate the rest. Full log: .swarm/log.jsonl.'].join('\n')
789}
790
791/** The stall watchdog's nudge to a teammate holding open claims: what it holds and who has reported. */
792export function nudgeText(minutes: number, claims: readonly Claim[], reports: ReadonlyMap<string, { type: string }>, label: (name: string) => string): string {
793 const reported = [...reports].map(([name, r]) => `${label(name)} ${r.type}`)
794 const who = reported.length > 0 ? 'reported: ' + reported.join(', ') : 'nobody has reported'
795 return `[ASK] swarm quiet ${minutes} min: you hold ${claims.map(c => c.key).join(', ')}; ${who}. Continue, or send [DONE]/[BLOCK].`
796}
797
798export function stallText(minutes: number, openTasks: number | null): string {
799 const tasks = openTasks === null ? 'task list unreadable' : `${openTasks} task(s) still open`
800 return `swarm stalled: no message for ${minutes} min, no agent running, ${tasks}. Check .swarm/roster.json and the task list; reassign, unblock or end the swarm.`
801}
802
803export type StallInput = {
804 now: number
805 lastActivityAt: number
806 stallMs: number
807 activeAgents: number
808 liveAgents: number
809 openTasks: number | null
810 leadBusy: boolean
811 alreadyFired: boolean
812}
813
814/** Whether the stall watchdog should wake the lead now. */
815export function isStalled(s: StallInput): boolean {
816 if (s.alreadyFired || s.leadBusy || s.activeAgents > 0) return false
817 if (s.now - s.lastActivityAt < s.stallMs) return false
818 if (s.openTasks !== null) return s.openTasks > 0
819 return s.liveAgents > 0
820}
821
822/** Counts open tasks in task files; a file whose status is not readable counts as open. */
823export function openTaskCount(files: readonly string[]): number {
824 let open = 0
825 for (const text of files) {
826 try {
827 const task = JSON.parse(text) as { status?: unknown }
828 if (task.status !== 'completed' && task.status !== 'deleted') open++
829 } catch {
830 open++
831 }
832 }
833 return open
834}
835
836function pad(s: string, n: number): string {
837 if (s.length > n) return s.slice(0, Math.max(0, n - 1)) + '…'
838 return s.padEnd(n)
839}
840
841function clip(s: string, n: number): string {
842 return s.length > n ? s.slice(0, Math.max(0, n - 1)) + '…' : s
843}
844
845/** The band's counts: agents of the current team that have not ended, messages, time since the last one. */
846export function bandText(roster: Roster, log: MessageLog, now: number): string {
847 const n = [...roster.agents.values()].filter(a => !roster.isOther(a, now)).length
848 const last = log.lastAt === null ? '—' : ago(now - log.lastAt)
849 const earlier = log.earlier.length ? ` (${log.earlier.length} earlier)` : ''
850 return `swarm: ${n} agent${n === 1 ? '' : 's'} · ${log.total} msg${log.total === 1 ? '' : 's'}${earlier} · last ${last}`
851}
852
853/** The announced code names of `names` (bare team names) as `Atlas (t1), Sage (t2)`, or '' before any announcement. */
854export function namesText(roster: Roster, names: readonly string[]): string {
855 return names.filter(n => roster.codeNames.has(bareName(n))).map(n => roster.label(n)).join(', ')
856}
857
858/**
859 * One line per workflow run for its agents: `✓ workflow dev-team · 16 agents` once all ended, else with the running and
860 * pending counts. A run whose name the mod never saw shows its run id, or nothing.
861 */
862function workflowGroups(roster: Roster, agents: readonly RosterEntry[], now: number): { text: string; isOther: boolean }[] {
863 const runs = new Map<string, RosterEntry[]>()
864 for (const a of agents) {
865 if (!isWorkflowAgent(a)) continue
866 const key = a.workflowRunId ?? ''
867 runs.set(key, [...(runs.get(key) ?? []), a])
868 }
869 return [...runs].map(([runId, list]) => {
870 const name = roster.workflowNames.get(runId) ?? runId
871 const isDone = list.every(a => ENDED.includes(a.status))
872 const failed = list.filter(a => a.status === 'failed' || a.status === 'killed').length
873 const running = list.filter(a => a.status === 'running').length
874 const pending = list.filter(a => !ENDED.includes(a.status) && a.status !== 'running').length
875 const head = `${isDone ? (failed ? '✗' : '✓') : '●'} workflow${name ? ' ' + name : ''} · ${list.length} agent${list.length === 1 ? '' : 's'}`
876 const tail = isDone ? (failed ? ` · ${failed} failed` : '') : ` · ${running} running · ${pending} pending`
877 return { text: head + tail, isOther: isDone || list.every(a => roster.isOther(a, now)) }
878 })
879}
880
881const STATUS_MARK: Record<string, string> = {
882 running: '●', pending: '◌', waiting: '◐', idle: '○', stalled: '!', completed: '✓', failed: '✗', killed: '✗',
883}
884
885export type PaneRow = { text: string; dim?: boolean; bold?: boolean }
886
887/**
888 * The pane's rows: the header bold; roster rows of active agents, then agents outside the current team or settled for
889 * `STALE_MS` dimmed under `── other agents ──`; messages newest first, a previous session's dimmed under `── earlier ──` at the bottom.
890 */
891export function paneRows(roster: Roster, log: MessageLog, policy: WakePolicy, now: number, cols: number, maxMessages: number, board?: ClaimBoard, stallMs = DEFAULT_STALL_MIN * 60_000): PaneRow[] {
892 const w = Math.max(20, cols)
893 const out: string[] = []
894 const dim = new Set<number>()
895 out.push(clip(`${bandText(roster, log, now)} · wake: ${policy.mode}${policy.pending.length ? ` (${policy.pending.length} queued)` : ''} · wakes ${policy.wakes} · held ${policy.held}`, w))
896 out.push('')
897 out.push(clip('ROSTER name · model · status · sent/recv · waits', w))
898 const nameW = Math.min(LABEL_MAX, Math.max(8, Math.floor(w * 0.3)))
899 const models = [...roster.agents.values(), ...roster.earlier].map(a => shortModel(a.model))
900 const modelW = w >= 48 ? Math.max(1, ...models.map(m => m.length)) : 0
901 const statusW = 10
902 const dups = roster.duplicateCodes()
903 const nameCell = (name: string, isCurrent = true) => {
904 const mark = isCurrent && dups.has(bareName(name)) ? ' ⚠ dup' : ''
905 return pad(clip(roster.label(name), nameW - mark.length) + mark, nameW)
906 }
907 const row = (name: string, model: string, status: string) => {
908 const idle = roster.idle.get(bareName(name))
909 const reason = idle?.lastReason !== undefined && idle.lastReason !== 'available' ? ` (${idle.lastReason})` : ''
910 const age = idle?.lastAt !== undefined ? ` · ${ago(now - idle.lastAt)} ago` : ''
911 const counts = `${log.sent.get(name) ?? 0}/${log.received.get(name) ?? 0}${idle ? ` · waits ${idle.count}${reason}${age}` : ''}`
912 const mark = STATUS_MARK[status] ?? '?'
913 return clip(`${mark} ${nameCell(name)} ${modelW ? model.padEnd(modelW) + ' ' : ''}${pad(status, statusW)} ${counts}`, w)
914 }
915 out.push(row(LEAD, '', roster.leadBusy ? 'running' : 'idle'))
916 const all = [...roster.agents.values()]
917 const agents = all.filter(a => !isWorkflowAgent(a))
918 const others = agents.filter(a => roster.isOther(a, now))
919 const groups = workflowGroups(roster, all, now)
920 const hasStalled = (name: string) => {
921 const since = roster.idleSince(name)
922 return stallMs > 0 && since !== undefined && now - since > stallMs && (board?.openClaims(name).length ?? 0) > 0
923 }
924 for (const a of agents) if (!others.includes(a)) out.push(row(a.name, shortModel(a.model), hasStalled(a.name) ? 'stalled' : a.status))
925 for (const g of groups) if (!g.isOther) out.push(clip(g.text, w))
926 if (others.length > 0 || groups.some(g => g.isOther)) {
927 out.push(OTHER_AGENTS)
928 for (const g of groups) {
929 if (!g.isOther) continue
930 dim.add(out.length)
931 out.push(clip(g.text, w))
932 }
933 for (const a of others) {
934 dim.add(out.length)
935 out.push(row(a.name, shortModel(a.model), a.status))
936 }
937 }
938 if (roster.earlier.length > 0) {
939 out.push(EARLIER)
940 for (const a of roster.earlier) {
941 dim.add(out.length)
942 out.push(clip(` ${nameCell(a.name, false)} ${modelW ? shortModel(a.model).padEnd(modelW) + ' ' : ''}`.trimEnd(), w))
943 }
944 }
945 if (board !== undefined) {
946 out.push('')
947 out.push('CLAIMS')
948 const isStale = (n: string) => now - (roster.lastMessageAt.get(bareName(n)) ?? now) > CLAIM_TTL_MIN * 60_000
949 const lines = claimLines(board, n => roster.label(n) + (isStale(n) ? ' stale' : ''))
950 if (lines.length === 0) out.push('(none yet)')
951 for (const l of lines) out.push(clip(l, w))
952 }
953 out.push('')
954 const current = log.last(maxMessages)
955 const earlier = log.earlier.slice(log.earlier.length - Math.max(0, maxMessages - current.length))
956 out.push(clip(`MESSAGES last ${current.length} of ${log.total}${log.earlier.length ? ` · ${log.earlier.length} earlier` : ''}`, w))
957 const line = (e: LogEntry) => clip(`${new Date(e.ts).toTimeString().slice(0, 8)} ${roster.label(e.from)} → ${roster.label(e.to)}: ${e.summary}`, w)
958 if (current.length === 0 && earlier.length === 0) out.push('(none yet)')
959 for (const e of [...current].reverse()) out.push(line(e))
960 if (earlier.length > 0) {
961 out.push(EARLIER)
962 for (const e of [...earlier].reverse()) {
963 dim.add(out.length)
964 out.push(line(e))
965 }
966 }
967 return out.map((text, i) => ({ text, ...(i === 0 ? { bold: true } : {}), ...(dim.has(i) ? { dim: true } : {}) }))
968}
969
970const CLAIM_TAG = /^(?:<[^>]*>\s*)?\[CLAIM\]\s*(.*)$/i
971const ROLE_WORDS: Record<string, string> = {
972 build: 'build', builder: 'build', builds: 'build', building: 'build',
973 review: 'review', reviewer: 'review', reviews: 'review',
974 research: 'research', researcher: 'research', researches: 'research',
975}
976const DELIVERABLE_LINE = /^[\s*_>-]*deliverables?[*_]*\s*:[*_]*\s*(.+)$/gim
977
978export type ClaimRef = { role: string; target: string }
979export type Claim = ClaimRef & { key: string; by: string[] }
980export type TaskRow = { subject: string; owner?: string; status: string }
981
982/** Role and target of a claim subject: `build var/gaps.md` → { role: 'build', target: 'var/gaps.md' }. */
983function parseClaimSubject(subject: string): ClaimRef {
984 const words = subject.trim().replace(/\s+/g, ' ').split(' ')
985 const head = (words[0] ?? '').toLowerCase().replace(/[:,]$/, '')
986 const role = ROLE_WORDS[head]
987 const rest = (role === undefined ? words : words.slice(1)).join(' ')
988 const target = rest.toLowerCase().replace(/^(of|the|for)\s+/, '').replace(/[`"'„“”]/g, '').replace(/[.,;:!]+$/, '').trim()
989 return { role: role ?? 'other', target }
990}
991
992/**
993 * Whether a claim subject is a sentence fragment, not a target. Judged on its target token, the first word after the role
994 * word, or the inside of a quoted or bracketed start: a fragment when that is empty, holds a space or a `"`, or says `please`.
995 */
996function isFragment(subject: string): boolean {
997 const rest = subject.trim().replace(/^(\S+)\s*/, (w, head: string) => (ROLE_WORDS[head.toLowerCase().replace(/[:,]$/, '')] === undefined ? w : ''))
998 const wrapped = /^(?:"([^"]*)"|`([^`]*)`|\(([^)]*)\))/.exec(rest)
999 const token = wrapped === null ? (rest.split(/\s/)[0] ?? '') : (wrapped[1] ?? wrapped[2] ?? wrapped[3] ?? '').trim()
1000 return token === '' || /[\s"]/.test(token) || /please/i.test(token)
1001}
1002
1003/**
1004 * The claim a message makes: `[CLAIM] <role> <artefact or slice>` on its first line, else the rest of the first line that
1005 * carries `[CLAIM]` anywhere (`Slice wanted: [CLAIM] build #158 (…)` inside a hello); none when the subject is a fragment
1006 * or its first word is no role (`build`, `review`, `research`).
1007 */
1008export function claimOf(text: string): ClaimRef | undefined {
1009 const subject = CLAIM_TAG.exec(firstLine(text, 400))?.[1] ?? /\[CLAIM\]\s*([^\n]*)/i.exec(text)?.[1]
1010 if (subject === undefined || isFragment(subject)) return undefined
1011 const ref = parseClaimSubject(subject)
1012 return ref.role === 'other' ? undefined : ref
1013}
1014
1015/** The `Deliverable: <path>` lines of a brief; several split on `,` or `;`. */
1016export function deliverablesOf(text: string): string[] {
1017 const out: string[] = []
1018 for (const m of text.matchAll(DELIVERABLE_LINE)) {
1019 for (const part of (m[1] ?? '').split(/[;,]/)) {
1020 const d = part.replace(/[`*_]/g, '').trim()
1021 if (d !== '' && !out.includes(d)) out.push(d)
1022 }
1023 }
1024 return out
1025}
1026
1027function baseName(path: string): string {
1028 return path.split('/').filter(Boolean).pop() ?? path
1029}
1030
1031/**
1032 * Whether a claim target names a deliverable: same path, same file name, the target contains its file name, or the target's
1033 * first word is the deliverable's trailing segment or its `#<n>` (`#155 (cache bug …)` names `repo#155`). An empty target names any.
1034 */
1035export function targetMatches(target: string, deliverable: string): boolean {
1036 const t = target.toLowerCase().trim()
1037 const d = deliverable.toLowerCase().trim()
1038 if (t === '' || d === '') return t === ''
1039 if (t === d || baseName(t) === baseName(d) || t.includes(baseName(d))) return true
1040 const head = (t.split(/[\s(,;]/)[0] ?? '').replace(/[.:!]+$/, '')
1041 const issue = /#\d+$/.exec(d)?.[0]
1042 return head !== '' && (head === baseName(d) || head === issue)
1043}
1044
1045/**
1046 * Whether two claim targets (or a target and a path) name the same thing, judged on their first words: equal targets; the
1047 * same `#<n>` (`#155`, `repo#155`); two paths where one ends the other at a segment boundary (`design/design.md`,
1048 * `/work/design/design.md`); a file name and a path ending in it (`notes.md`, `docs/notes.md`); or one file name. Other words
1049 * match only when the whole targets are equal.
1050 */
1051export function sameTarget(a: string, b: string): boolean {
1052 const x = a.toLowerCase().trim()
1053 const y = b.toLowerCase().trim()
1054 if (x === y) return x !== ''
1055 const head = (t: string) => (t.split(/[\s(,;]/)[0] ?? '').replace(/[.:!]+$/, '')
1056 const hx = head(x)
1057 const hy = head(y)
1058 if (hx === '' || hy === '') return false
1059 const ix = /#\d+$/.exec(hx)?.[0]
1060 const iy = /#\d+$/.exec(hy)?.[0]
1061 if (ix !== undefined || iy !== undefined) return ix === iy
1062 const isFile = (h: string) => /\.[a-z][a-z0-9]{0,7}$/.test(h)
1063 const ends = (long: string, short: string) => long === short || long.endsWith('/' + short.replace(/^\/+/, ''))
1064 if (hx.includes('/') && hy.includes('/')) return ends(hx, hy) || ends(hy, hx)
1065 if (hx.includes('/')) return isFile(hy) && ends(hx, hy)
1066 if (hy.includes('/')) return isFile(hx) && ends(hy, hx)
1067 return isFile(hx) && hx === hy
1068}
1069
1070/** Task rows from task files; fields the format does not carry stay empty. */
1071export function parseTasks(files: readonly string[]): TaskRow[] {
1072 const out: TaskRow[] = []
1073 for (const text of files) {
1074 try {
1075 const t = JSON.parse(text) as Record<string, unknown>
1076 const subject = [t.subject, t.title, t.content].find(v => typeof v === 'string') as string | undefined
1077 const owner = [t.owner, t.assignee].find(v => typeof v === 'string' && v.trim() !== '') as string | undefined
1078 out.push({ subject: subject ?? '', owner, status: typeof t.status === 'string' ? t.status : 'pending' })
1079 } catch {
1080 continue
1081 }
1082 }
1083 return out
1084}
1085
1086/** Who claimed which artefact or slice, from `[CLAIM]` messages and task-list owners. */
1087export class ClaimBoard {
1088 private readonly fromMessages = new Map<string, Claim>()
1089 private fromTasks = new Map<string, Claim>()
1090 unclaimedTasks: string[] = []
1091 deliverables: string[] = []
1092
1093 /** Review states by slice, then reviewer: the last `[REVIEW] … approve` or `[BLOCK]` each sent. */
1094 readonly reviews = new Map<string, Map<string, ReviewState>>()
1095 /** When each claimant last sent each claim, by `key` then claimant, as a running count. */
1096 private readonly lastSent = new Map<string, Map<string, number>>()
1097 /** Whether a claimant (bare team name) is still on the team; one that ended holds no claim against a later claimant. */
1098 isLive: (name: string) => boolean = () => true
1099 /** Each claimant's first claim by `key`: its order stamp and time, where known. */
1100 private readonly since = new Map<string, Map<string, { stamp?: number; at?: number }>>()
1101 /** Each teammate's latest `[DONE]`, `[REVIEW]` or `[BLOCK]`: its type and the running count when sent. */
1102 readonly reports = new Map<string, { type: string; at: number }>()
1103 private sent = 0
1104
1105 private static put(map: Map<string, Claim>, by: string, ref: ClaimRef): boolean {
1106 const key = ref.role + ' ' + ref.target
1107 const claim = map.get(key) ?? { ...ref, key, by: [] }
1108 map.set(key, claim)
1109 if (claim.by.includes(by)) return false
1110 claim.by.push(by)
1111 return true
1112 }
1113
1114 /** Records a review verdict for a slice; returns whether it changed. */
1115 review(by: string, verdict: { slice: string; state: ReviewState }): boolean {
1116 const name = bareName(by)
1117 const states = this.reviews.get(verdict.slice) ?? new Map<string, ReviewState>()
1118 this.reviews.set(verdict.slice, states)
1119 if (states.get(name) === verdict.state) return false
1120 states.set(name, verdict.state)
1121 return true
1122 }
1123
1124 /** The running count at which `by` last sent the claim `key`; 0 for task-list claims. */
1125 sentAt(key: string, by: string): number {
1126 return this.lastSent.get(key)?.get(by) ?? 0
1127 }
1128
1129 /** Restores a roster.json's `claims` and `deliverables`; returns whether anything was added. */
1130 restore(json: unknown): boolean {
1131 if (!json || typeof json !== 'object') return false
1132 const j = json as { claims?: unknown; deliverables?: unknown }
1133 let changed = false
1134 if (Array.isArray(j.claims)) {
1135 for (const c of j.claims as Partial<Claim>[]) {
1136 if (typeof c?.role !== 'string' || typeof c.target !== 'string' || !Array.isArray(c.by)) continue
1137 for (const by of c.by) if (typeof by === 'string' && this.claim(by, { role: c.role, target: c.target })) changed = true
1138 }
1139 }
1140 if (Array.isArray(j.deliverables)) changed = this.addDeliverables(j.deliverables.filter((d): d is string => typeof d === 'string')) || changed
1141 return changed
1142 }
1143
1144 /** The claim already on the board with the same role and a target `sameTarget` matches, else `ref` itself. */
1145 private canonical(ref: ClaimRef): ClaimRef {
1146 if (this.fromMessages.has(ref.role + ' ' + ref.target)) return ref
1147 const c = [...this.fromMessages.values()].find(x => x.role === ref.role && sameTarget(x.target, ref.target))
1148 return c === undefined ? ref : { role: c.role, target: c.target }
1149 }
1150
1151 /**
1152 * Records a claim message under the claim with the same role and a matching target, if any; `isSend` false (a delivery
1153 * seen later) leaves its order as sent; `at` is its order stamp and time. Returns whether it was new.
1154 */
1155 claim(by: string, given: ClaimRef, isSend = true, at: { stamp?: number; at?: number } = {}): boolean {
1156 const ref = this.canonical(given)
1157 const name = bareName(by)
1158 const key = ref.role + ' ' + ref.target
1159 const since = this.since.get(key) ?? new Map<string, { stamp?: number; at?: number }>()
1160 this.since.set(key, since)
1161 const prior = since.get(name)
1162 if (prior === undefined || (prior.stamp === undefined && prior.at === undefined)) since.set(name, at)
1163 const sent = this.lastSent.get(key) ?? new Map<string, number>()
1164 this.lastSent.set(key, sent)
1165 if (isSend || !sent.has(name)) sent.set(name, ++this.sent)
1166 return ClaimBoard.put(this.fromMessages, name, ref)
1167 }
1168
1169 /**
1170 * Who held a build or research claim before `by` claimed the same target: of the claimants that still hold it (see
1171 * `isActive`), the one with the lowest order stamp, or the first to claim when any claim has none; with when and as which
1172 * stamp. Undefined when `by` came first or nobody else still holds it.
1173 */
1174 heldBefore(by: string, given: ClaimRef): { key: string; target: string; holder: string; stamp?: number; at?: number } | undefined {
1175 if (given.role !== 'build' && given.role !== 'research') return undefined
1176 const ref = this.canonical(given)
1177 const key = ref.role + ' ' + ref.target
1178 const claim = this.fromMessages.get(key)
1179 if (claim === undefined) return undefined
1180 const name = bareName(by)
1181 const since = this.since.get(key)
1182 const rivals = claim.by.filter(n => n === name || this.isActive(claim, n))
1183 const stamps = rivals.map(n => since?.get(n)?.stamp)
1184 const first = stamps.every(s => s !== undefined) ? rivals[stamps.indexOf(Math.min(...(stamps as number[])))] : rivals[0]
1185 if (first === undefined || first === name) return undefined
1186 return { key, target: ref.target, holder: first, ...since?.get(first) }
1187 }
1188
1189 /**
1190 * Whether `name` still holds claim `c`: it is live and, for a message claim, has not sent a `[DONE]` or `[REVIEW]` as its
1191 * latest report after the claim.
1192 */
1193 isActive(c: Claim, name: string): boolean {
1194 if (!this.isLive(name)) return false
1195 const report = this.reports.get(name)
1196 if (!this.fromMessages.get(c.key)?.by.includes(name) || report === undefined || report.type === 'BLOCK') return true
1197 return this.sentAt(c.key, name) > report.at
1198 }
1199
1200 /** Records a `[DONE]`, `[REVIEW]` or `[BLOCK]` from `by`; `isSend` false (a delivery seen later) keeps an earlier one. Returns whether it changed. */hooks/pane.ts 575 lines1// The pane's stage at run time: the live team and `/swarm replay`, drawn by look A or B in bands of Rasters and Images;
2// cells move frame by frame while an event lands, Images are sent only when what they draw changed.
3
4import type { EngineInterface, RenderElement, RenderInputOf } from 'claude-code'
5
6import { parseLog } from './core'
7import { css } from './grid'
8import { newSidecar, pauseSidecar, quitSidecar, startSidecar, updateSidecar } from './sidecar'
9import type { LogEntry } from './core'
10import { FLASH_MS, PULSE_MS } from './looks'
11import type { Frame, ImageLayer, View } from './looks'
12import { IMAGE_MIN_MS, ImageShelf, LOOK_FNS, agentControls, bandsOf, controlsOf, shapeOf, viewControls } from './pane-draw'
13import type { Control } from './pane-draw'
14import { Scene, modelsOf, parseReplayArgs, runTitle, schedule, splitRuns, windowOf } from './replay'
15import type { Look, Level, ReplayArgs, Run } from './replay'
16
17export const PANE = 'swarm'
18/** Real ms between animation frames while a message travels or a change glows. */
19const FRAME_MS = 80
20/** How long after the latest event the animation keeps running. */
21const ANIMATE_MS = Math.max(PULSE_MS, FLASH_MS * 1.5)
22/** A 1×1 picture the pane draws once to learn whether the terminal shows pictures. */
23const PROBE_SOURCE = { rgba: 'AAAA/w==', width: 1, height: 1 }
24
25export type LevelSetting = Level | 'auto'
26
27/** The person's level choice; `auto` is rich where the terminal draws pictures. */
28let levelSetting: LevelSetting = 'auto'
29/** Whether a probe Image drew as a picture (true) or the terminal draws none (false); undefined until a blit tells. */
30let imagesDrawn: boolean | undefined
31/** Probe blits refused for another reason (no image id free, not yet mounted); the pane stops asking after `PROBE_TRIES`. */
32let probeRefusals = 0
33const PROBE_TRIES = 3
34
35export function setLevel(level: LevelSetting) {
36 levelSetting = level
37}
38
39/** The level a stage draws at: an explicit one, else the setting, `auto` resolved by the probe. */
40function levelOf(explicit?: Level): Level {
41 const l = explicit ?? levelSetting
42 return l === 'auto' ? (imagesDrawn === true ? 'rich' : 'basic') : l
43}
44
45/** What a stage draws now, at a size and a real time. */
46type FrameOf = (cols: number, rows: number, realNow: number) => Frame
47
48type Stage = {
49 look: Look
50 filter?: string
51 anim?: { cancel: () => void }
52 /** The next paint while an image still fades after the motion ended. */
53 settle?: { cancel: () => void }
54 /** The real ms the last frame's images still change, and its cells still move, by themselves. */
55 fadeMs: number
56 motionMs: number
57 lastEventReal: number
58 /** The stage size the pane last drew, and the bands' shape then. */
59 size?: { cols: number; rows: number }
60 shape?: string
61 /** The source each Image shows, sent at most once a second. */
62 images: ImageShelf
63}
64
65function newStage(look: Look): Stage {
66 return { look, lastEventReal: 0, images: new ImageShelf(), fadeMs: 0, motionMs: 0 }
67}
68
69type Replay = Stage & {
70 level?: Level
71 speed: number
72 run: Run
73 lines: LogEntry[]
74 realAt: number[]
75 scene: Scene
76 /** The next line to play. */
77 next: number
78 /** The real time playback would have started at, paused time left out. */
79 startReal: number
80 pausedAt?: number
81 /** When each played run time was played, in real ms. */
82 played: Map<number, number>
83 timer?: { cancel: () => void }
84 /** Whether it played its last line; it then hands the pane back to the live view after `REPLAY_HOLD_MS`. */
85 isFinished: boolean
86 /** Whether live team activity came while it played: the pane offers `[ live ]`. */
87 isLiveWaiting: boolean
88}
89
90/** How long a finished replay holds its end frame before the pane shows the live view again. */
91export const REPLAY_HOLD_MS = 3_000
92
93/** What the live stage draws: the current team's scene, its title and when it started. */
94/** What the live pane draws; `endedAt`, once every teammate of the team has ended: its last activity, where the clock holds. */
95export type LiveSource = { scene: Scene; title: string; from: number; endedAt?: number }
96
97export type LiveView = Look | 'list'
98
99let replay: Replay | undefined
100const live: Stage & { view: LiveView; source?: () => LiveSource } = { ...newStage('A'), view: 'A' }
101
102export function isReplaying(): boolean {
103 return replay !== undefined
104}
105
106/** Which live view the pane shows: look A (the default), look B, or the text list. */
107export function liveView(): LiveView {
108 return live.view
109}
110
111function stopReplay() {
112 replay?.timer?.cancel()
113 replay?.anim?.cancel()
114 replay?.settle?.cancel()
115 replay = undefined
116}
117
118function replayFrame(r: Replay): FrameOf {
119 return (cols, rows, realNow) => {
120 const last = r.lines[r.next - 1]
121 const view: View = {
122 cols,
123 rows,
124 level: lookLevel(r.look, levelOf(r.level)),
125 now: last?.ts ?? r.run.from,
126 runFrom: r.run.from,
127 runTo: r.run.to,
128 title: runTitle(r.run.names),
129 speed: r.speed,
130 isPaused: r.pausedAt !== undefined,
131 age: ts => {
132 const at = r.played.get(ts)
133 return at === undefined ? Infinity : realNow - at
134 },
135 ...(r.filter === undefined ? {} : { filter: r.filter }),
136 ...(r.isFinished || r.isLiveWaiting ? { note: [...(r.isFinished ? ['replay finished'] : []), ...(r.isLiveWaiting ? ['live swarm running'] : [])].join(' · ') } : {}),
137 }
138 return LOOK_FNS[r.look](r.scene, view)
139 }
140}
141
142function liveFrame(src: LiveSource): FrameOf {
143 return (cols, rows, realNow) => {
144 const now = src.endedAt ?? realNow
145 const view: View = {
146 cols,
147 rows,
148 level: lookLevel(live.look, levelOf()),
149 now,
150 runFrom: src.from,
151 runTo: Math.max(now, src.from + 1),
152 title: src.title,
153 isPaused: false,
154 ...(src.endedAt === undefined ? {} : { isEnded: true }),
155 age: ts => realNow - ts,
156 ...(live.filter === undefined ? {} : { filter: live.filter }),
157 }
158 return LOOK_FNS[live.look](src.scene, view)
159 }
160}
161
162/** The stage on screen now and how to draw it, or undefined when the pane shows no stage. */
163function current(): { stage: Stage; frame: FrameOf } | undefined {
164 if (replay !== undefined) return { stage: replay, frame: replayFrame(replay) }
165 if (live.view !== 'list' && live.source !== undefined) return { stage: live, frame: liveFrame(live.source()) }
166 return undefined
167}
168
169/** Plays every line that is due, schedules the next one, and animates when anything played; the last line draws the pane in full. */
170async function step($: EngineInterface) {
171 const r = replay
172 if (r === undefined || r.pausedAt !== undefined) return
173 r.timer = undefined
174 const now = await $.clock.now()
175 const elapsed = now - r.startReal
176 let played = false
177 while (r.next < r.lines.length && r.realAt[r.next]! <= elapsed) {
178 const e = r.lines[r.next]!
179 r.scene.apply(e)
180 r.played.set(e.ts, now)
181 r.next++
182 played = true
183 }
184 if (played) {
185 r.lastEventReal = now
186 animate($, r)
187 }
188 if (r.next < r.lines.length) {
189 r.timer = $.clock.after(Math.max(1, r.realAt[r.next]! - elapsed), () => {
190 void step($)
191 })
192 } else if (played) {
193 r.isFinished = true
194 $.ui.invalidate('ui.render')
195 $.ui.toast('swarm: replay finished')
196 r.timer = $.clock.after(REPLAY_HOLD_MS, () => {
197 if (replay !== r) return
198 stopReplay()
199 $.ui.invalidate('ui.render')
200 })
201 }
202}
203
204/** Live team activity (a teammate spawned, a peer message) while a replay shows: the pane offers `[ live ]` (key 0). */
205export function liveActivity($: EngineInterface) {
206 if (replay === undefined || replay.isLiveWaiting) return
207 replay.isLiveWaiting = true
208 $.ui.invalidate('ui.render')
209}
210
211/** A live event landed: the live stage animates while it moves on screen. */
212export async function liveEvent($: EngineInterface) {
213 if (replay !== undefined || live.size === undefined || live.view === 'list') return
214 live.lastEventReal = await $.clock.now()
215 animate($, live)
216}
217
218/** Runs frames while the latest event still moves on screen (a frame's `motionMs`), then stops; never a frame without an event. */
219function animate($: EngineInterface, s: Stage) {
220 if (s.anim !== undefined) return
221 void paint($)
222 s.anim = $.clock.every(FRAME_MS, () => {
223 void (async () => {
224 const now = await $.clock.now()
225 if (now - s.lastEventReal > ANIMATE_MS && s.motionMs <= 0) {
226 s.anim?.cancel()
227 s.anim = undefined
228 }
229 if (current()?.stage === s) await paint($)
230 if (s.anim === undefined) settle($, s)
231 })()
232 })
233}
234
235/** After the motion, paints once a second while the stage's images still fade, then stops. */
236function settle($: EngineInterface, s: Stage) {
237 s.settle?.cancel()
238 s.settle = undefined
239 if (s.fadeMs <= 0) return
240 s.settle = $.clock.after(IMAGE_MIN_MS, () => {
241 s.settle = undefined
242 if (s.anim !== undefined || current()?.stage !== s) return
243 void paint($).then(() => settle($, s))
244 })
245}
246
247/**
248 * Repaints the mounted stage: every Raster by blit, an Image only when its sig changed; a change of shape (an agent
249 * appeared, a resize) draws the pane again instead.
250 */
251async function paint($: EngineInterface) {
252 try {
253 await paintNow($)
254 } catch (err) {
255 $.ui.log('swarm paint: ' + String(err))
256 }
257}
258
259async function paintNow($: EngineInterface) {
260 const cur = current()
261 const size = cur?.stage.size
262 if (cur === undefined || size === undefined) return
263 const s = cur.stage
264 const now = await $.clock.now()
265 const frame = cur.frame(size.cols, size.rows, now)
266 s.fadeMs = frame.fadeMs ?? 0
267 s.motionMs = frame.motionMs ?? 0
268 await feedSky($, frame, now)
269 const bands = bandsOf(frame)
270 if (shapeOf(bands) !== s.shape) {
271 $.ui.invalidate('ui.render')
272 return
273 }
274 for (const b of bands) {
275 for (const p of b.pieces) {
276 if (p.kind === 'raster') {
277 await $.ui.blit({ requestId: PANE, key: p.key, cells: frame.grid.cells(p.x, b.y, p.cols, b.rows), columns: p.cols, rows: b.rows })
278 } else if (p.layer.stream === undefined || !skyActive()) {
279 const { source, isNew } = takeImage($, s, p.layer, now)
280 if (isNew && (await $.ui.blit({ requestId: PANE, key: p.layer.key, source })).deny !== undefined) s.images.forget(p.layer.key)
281 }
282 }
283 }
284}
285
286/** The look B sky renderer process, while a stage draws look B rich. */
287const sky = newSidecar(PANE, 'sky')
288let skyDir: string | undefined
289/**
290 * Starts the renderer through the session's `$`, on its clock: a child spawned through a render's or a paint's `$` is
291 * killed when that dispatch is abandoned (a render superseded, its budget spent). Set at `session.start`.
292 */
293let startSky: ((dir: string) => void) | undefined
294
295/** Makes the session's `$` the owner of the sky renderer. */
296export function ownSky($: EngineInterface) {
297 startSky = dir => {
298 $.clock.after(0, () => startSidecar($, sky, dir))
299 }
300}
301
302/**
303 * Keeps the sky renderer in step with a frame: started and handed the scene while the frame has a streamed sky, stopped
304 * when it has none; once failed, the frame's PNG sky is used.
305 */
306async function feedSky($: EngineInterface, frame: Frame, now: number) {
307 const layer = frame.images.find(im => im.stream !== undefined)
308 if (layer === undefined) return pauseSidecar($, sky)
309 if (sky.state === 'failed') return
310 if (sky.state === 'off') {
311 if (startSky === undefined) return
312 skyDir ??= `${(await $.env.get('TMPDIR')) ?? '/tmp'}`.replace(/\/+$/, '') + `/swarm-${(await $.session.id()).slice(0, 8)}`
313 sky.state = 'starting'
314 sky.dir = skyDir
315 startSky(skyDir)
316 return
317 }
318 await updateSidecar($, sky, now, layer.stream!)
319}
320
321/** The longest a PNG sky may take to draw (the `skyMaxDrawMs` option): one slower turns look B basic for the session; 0, the first one does. */
322let skyMaxDrawMs = 50
323
324export function setSkyMaxDrawMs(ms: number) {
325 skyMaxDrawMs = ms
326}
327/** How long the PNG sky took when it was too slow; set, look B draws at the basic level. */
328let slowSkyMs: number | undefined
329
330/** An Image's source from the stage's shelf; a new PNG sky past `skyMaxDrawMs` turns look B basic. */
331function takeImage($: EngineInterface, s: Stage, layer: ImageLayer, now: number) {
332 const t0 = performance.now()
333 const r = s.images.take(layer, now)
334 const ms = performance.now() - t0
335 if (r.isNew && layer.stream !== undefined && (skyMaxDrawMs === 0 || ms > skyMaxDrawMs) && slowSkyMs === undefined) {
336 slowSkyMs = ms
337 $.ui.log(`swarm: the PNG sky took ${Math.round(ms)} ms, look B draws basic`)
338 $.ui.invalidate('ui.render')
339 }
340 return r
341}
342
343/** The level look `look` draws at: basic for look B once its PNG sky proved too slow. */
344function lookLevel(look: Look, level: Level): Level {
345 return look === 'B' && slowSkyMs !== undefined ? 'basic' : level
346}
347
348/** Whether the renderer paints the sky: then the mod draws no PNG sky and shows only its `{ file }` frames. */
349function skyActive(): boolean {
350 return sky.state === 'starting' || sky.state === 'running'
351}
352
353/** What the sky Image shows until the renderer's first frame: one pixel of the sky's dark ground. */
354const SKY_WAIT = { rgba: 'BwYO/w==', width: 1, height: 1 }
355
356/** The sky renderer's state for `/swarm status`. */
357export function skyStatus(): string {
358 const ms = [...sky.renderMs].sort((a, b) => a - b)
359 const slow = slowSkyMs === undefined ? '' : `, PNG sky took ${Math.round(slowSkyMs)} ms, look B basic`
360 if (sky.state === 'failed') return 'failed (' + sky.failure + '), PNG sky' + slow
361 const notes = [...(sky.crash === undefined ? [] : ['crashed once: ' + sky.crash]), ...(ms.length > 0 ? [`${ms[ms.length >> 1]!.toFixed(1)} ms a frame`] : [])]
362 return sky.state + (notes.length > 0 ? ` (${notes.join('; ')})` : '') + slow
363}
364
365/**
366 * Blits the probe Image once it is mounted: taken, the terminal draws pictures; refused because the terminal draws no
367 * placeholder images, it does not. Any other refusal counts toward `PROBE_TRIES`.
368 */
369async function probe($: EngineInterface) {
370 if (imagesDrawn !== undefined || probeRefusals >= PROBE_TRIES) return
371 const res = await $.ui.blit({ requestId: PANE, key: 'probe', source: PROBE_SOURCE }).catch(() => ({ deny: 'no blit' }))
372 if (res.deny === undefined) imagesDrawn = true
373 else if (/no placeholder images/i.test(res.deny)) imagesDrawn = false
374 else if (++probeRefusals < PROBE_TRIES) return
375 $.ui.invalidate('ui.render')
376}
377
378/** The run to play: the window the flags give, else the most recent run. */
379function pickRun(lines: readonly LogEntry[], runs: readonly Run[], args: ReplayArgs): Run | undefined {
380 if (args.from !== undefined || args.to !== undefined) {
381 const from = args.from ?? -Infinity
382 const to = args.to ?? Infinity
383 const inside = windowOf(lines, from, to)
384 if (inside.length === 0) return undefined
385 const names = [...new Set(splitRuns(inside).flatMap(r => r.names))]
386 return { from: inside[0]!.ts, to: inside[inside.length - 1]!.ts, count: inside.length, names }
387 }
388 return [...runs].sort((a, b) => b.from - a.from)[0]
389}
390
391/** `/swarm replay …`: starts or stops a replay; the text lists the runs the log holds. */
392export async function replayCommand($: EngineInterface, rest: string): Promise<{ text: string }> {
393 const parsed = parseReplayArgs(rest)
394 if ('error' in parsed) return { text: parsed.error }
395 if ('stop' in parsed) {
396 const was = replay !== undefined
397 stopReplay()
398 $.ui.invalidate('ui.render')
399 return { text: was ? 'replay stopped; the pane shows the live swarm' : 'no replay running' }
400 }
401 const args = parsed.args
402 const path = args.path.startsWith('/') ? args.path : (await $.session.cwd()) + '/' + args.path
403 let text: string
404 try {
405 text = await $.fs.read(path)
406 } catch (err) {
407 return { text: `cannot read ${path}: ${String(err)}` }
408 }
409 const lines = parseLog(text)
410 const runs = splitRuns(lines)
411 const run = pickRun(lines, runs, args)
412 if (run === undefined) return { text: `no run in ${path}${args.from !== undefined || args.to !== undefined ? ' between those times' : ''}` }
413 let rosterJson: unknown = null
414 const rosterPath = path.replace(/[^/]*$/, 'roster.json')
415 try {
416 if (await $.fs.exists(rosterPath)) rosterJson = JSON.parse(await $.fs.read(rosterPath))
417 } catch {
418 rosterJson = null
419 }
420 stopReplay()
421 const win = windowOf(lines, run.from, run.to)
422 const scene = new Scene(modelsOf(rosterJson))
423 scene.seed(run.names, run.from)
424 replay = {
425 ...newStage(args.look),
426 ...(args.level === undefined ? {} : { level: args.level }),
427 speed: args.speed,
428 run,
429 lines: win,
430 realAt: schedule(win, args.speed),
431 scene,
432 next: 0,
433 startReal: await $.clock.now(),
434 played: new Map(),
435 isFinished: false,
436 isLiveWaiting: false,
437 }
438 await $.ui.open({ id: PANE, title: 'swarm replay', focus: true })
439 $.ui.invalidate('ui.render')
440 void step($)
441 const list = runs.map(r => ` ${r === run ? '▶' : ' '} ${runTitle(r.names).padEnd(12)} --from ${new Date(r.from).toISOString()} --to ${new Date(r.to).toISOString()} (${r.count} lines)`)
442 return { text: [`replaying ${runTitle(run.names)} at ${args.speed}× · look ${args.look} ${levelOf(args.level)}; stop with /swarm replay stop`, 'runs in this log:', ...list].join('\n') }
443}
444
445/**
446 * The stage's tree: the control row first, so a body the host cuts short never hides it, then the bands of Rasters and
447 * Images sized to the rows left, the caller's rows, and the claim paths when rich.
448 */
449async function renderStage($: EngineInterface, e: RenderInputOf<'Pane', 'terminal'>, s: Stage, level: Level, frameOf: FrameOf, controls: Control[], extra: RenderElement[]): Promise<RenderElement> {
450 const { Box, Text, Raster, Image, Link } = $.ui.resolve(e)
451 const frameRows = Math.max(3, Math.min(60, e.props.scroll.bodyRows - (level === 'rich' ? 2 : 1) - extra.length))
452 const cols = Math.max(20, e.props.bodyColumns)
453 const now = await $.clock.now()
454 const frame = frameOf(cols, frameRows, now)
455 s.motionMs = frame.motionMs ?? 0
456 await feedSky($, frame, now)
457 const bands = bandsOf(frame)
458 s.size = { cols, rows: frameRows }
459 s.shape = shapeOf(bands)
460 const stage = bands.map(b =>
461 Box({
462 key: 'band-' + b.y,
463 flexDirection: 'row',
464 height: b.rows,
465 children: b.pieces.map(p => {
466 if (p.kind === 'raster') return Raster({ key: p.key, columns: p.cols, rows: b.rows, cells: frame.grid.cells(p.x, b.y, p.cols, b.rows) })
467 const source = p.layer.stream !== undefined && skyActive() ? (sky.source ?? SKY_WAIT) : takeImage($, s, p.layer, now).source
468 const image = Image({ key: p.layer.key, columns: p.layer.cols, rows: p.layer.rows, source, alt: p.layer.alt })
469 if (p.layer.ground === undefined) return image
470 return Box({ key: p.layer.key + '-ground', width: p.layer.cols, height: p.layer.rows, backgroundColor: css(p.layer.ground), children: [image] })
471 }),
472 }),
473 )
474 const buttons = buttonsOf($, e, controls, key => press($, key))
475 if (levelSetting === 'auto' && imagesDrawn === undefined && probeRefusals < PROBE_TRIES) {
476 buttons.push(Image({ key: 'probe', columns: 1, rows: 1, source: PROBE_SOURCE, alt: ' ' }))
477 $.clock.after(50, () => {
478 void probe($)
479 })
480 }
481 const rows: RenderElement[] = [Box({ key: 'controls', flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children: buttons }), ...stage, ...extra]
482 if (level === 'rich' && frame.links.length > 0) {
483 rows.push(Text({ dimColor: true, children: ['open: ', ...frame.links.flatMap((l, i) => [...(i > 0 ? [' '] : []), Link({ href: l.href, label: l.label })])] }))
484 }
485 return Box({ flexDirection: 'column', children: rows })
486}
487
488/** The controls as Buttons; the hidden ones last, in a Box drawn `display: none`, so only their hotkeys act. */
489export function buttonsOf($: EngineInterface, e: RenderInputOf<'Pane', 'terminal'>, controls: readonly Control[], onPress: (key: string) => Promise<void> | void): RenderElement[] {
490 const { Box, Text, Button } = $.ui.resolve(e)
491 const button = (c: Control) =>
492 Button({
493 key: c.key,
494 ...(c.hotkey === undefined ? {} : { hotkey: c.hotkey }),
495 ...(c.plain === true ? { plain: true as const } : {}),
496 ...(c.dim === true ? { dimColor: true } : {}),
497 onPress: async () => onPress(c.key),
498 children: [Text({ ...(c.color === undefined ? {} : { color: c.color }), children: [c.label] })],
499 })
500 const hidden = controls.filter(c => c.hidden === true)
501 return [...controls.filter(c => c.hidden !== true).map(button), ...(hidden.length > 0 ? [Box({ key: 'views', display: 'none', children: hidden.map(button) })] : [])]
502}
503
504/** The replay pane, or undefined while no replay runs. */
505export async function renderReplay($: EngineInterface, e: RenderInputOf<'Pane', 'terminal'>): Promise<RenderElement | undefined> {
506 const r = replay
507 if (r === undefined) return undefined
508 return renderStage($, e, r, levelOf(r.level), replayFrame(r), controlsOf(r.scene, r.filter, r.look, r.pausedAt !== undefined, r.isLiveWaiting), [])
509}
510
511/** The live pane in look A or B: the agents and the view buttons, the stage, then the caller's rows. */
512export async function renderLive($: EngineInterface, e: RenderInputOf<'Pane', 'terminal'>, source: () => LiveSource, extra: RenderElement[]): Promise<RenderElement> {
513 live.source = source
514 const src = source()
515 const controls: Control[] = [...agentControls(src.scene, live.filter, live.look), ...viewControls(live.view, true)]
516 return renderStage($, e, live, levelOf(), liveFrame(src), controls, extra)
517}
518
519/** The pane closed: the replay stops, nothing animates or paints, and the sky renderer quits; the next open starts them again. */
520export async function paneClosed($: EngineInterface) {
521 stopReplay()
522 live.anim?.cancel()
523 live.anim = undefined
524 live.settle?.cancel()
525 live.settle = undefined
526 live.size = undefined
527 await quitSidecar($, sky)
528}
529
530/** Shows a view: a look in the replay while one plays, else a look or the text list live. */
531export function showView($: EngineInterface, view: LiveView) {
532 if (replay !== undefined && view !== 'list') replay.look = view
533 else {
534 live.view = view
535 if (view !== 'list') live.look = view
536 }
537 if (view !== 'B') void pauseSidecar($, sky)
538 $.ui.invalidate('ui.render')
539}
540
541/** A control-row press: an agent toggles the filter; a view button switches the view; nudge, pause and stop act on the replay. */
542async function press($: EngineInterface, key: string) {
543 const r = replay
544 const s: Stage = r ?? live
545 if (key.startsWith('agent-')) {
546 const name = key.slice('agent-'.length)
547 s.filter = s.filter === name ? undefined : name
548 $.ui.invalidate('ui.render')
549 } else if (key.startsWith('view-')) showView($, key.slice('view-'.length) as LiveView)
550 else if (r === undefined) return
551 else if (key === 'live') {
552 stopReplay()
553 $.ui.invalidate('ui.render')
554 }
555 else if (key === 'nudge') {
556 const target = r.filter === undefined ? 'the claim holders' : r.scene.label(r.filter)
557 $.ui.toast(`replay: a nudge would go to ${target}; a replay sends nothing`)
558 } else if (key === 'pause') {
559 const now = await $.clock.now()
560 if (r.pausedAt === undefined) {
561 r.pausedAt = now
562 r.timer?.cancel()
563 r.timer = undefined
564 } else {
565 r.startReal += now - r.pausedAt
566 r.pausedAt = undefined
567 void step($)
568 }
569 $.ui.invalidate('ui.render')
570 } else if (key === 'stop') {
571 stopReplay()
572 $.ui.invalidate('ui.render')
573 }
574}
575hooks/pane-draw.ts 120 lines1// The pane's drawing without the mods API: the look per letter, the control rows, the frame cut into bands of Rasters and
2// Images, and each Image's source kept by its signature.
3
4import { lookA } from './look-a'
5import { lookB } from './look-b'
6import { agentColor } from './looks'
7import type { Frame, ImageLayer, LookFn } from './looks'
8import { FLIGHT } from './look-a'
9import { NEBULA } from './look-b'
10import type { Look, Scene } from './replay'
11import { css } from './grid'
12import type { ImageSource } from 'claude-code'
13
14export const LOOK_FNS: Record<Look, LookFn> = { A: lookA, B: lookB }
15const PALETTES = { A: FLIGHT, B: NEBULA }
16
17/** One button of the control row: an agent to filter by, or an action; a `hidden` one is only its hotkey. */
18export type Control = { key: string; label: string; color?: string; dim?: boolean; plain?: boolean; hotkey?: string; hidden?: boolean }
19
20/** One button per agent, in its colour; pressed, it filters to that agent's threads. */
21export function agentControls(scene: Scene, filter: string | undefined, look: Look): Control[] {
22 const p = PALETTES[look]
23 return scene.order().map(a => ({
24 key: 'agent-' + a.name,
25 label: a.code ?? a.name,
26 color: css(agentColor(p, a)),
27 dim: filter !== undefined && filter !== a.name,
28 plain: true,
29 }))
30}
31
32/** The view controls: live, a `[ list ]` button (`l`), drawn bright while shown; looks A and B as hidden keys `1` and `2`. */
33export function viewControls(shown: Look | 'list', withList: boolean): Control[] {
34 return [
35 ...(withList ? [{ key: 'view-list', label: 'list', hotkey: 'l', dim: shown !== 'list' }] : []),
36 { key: 'view-A', label: 'A', hotkey: '1', hidden: true },
37 { key: 'view-B', label: 'B', hotkey: '2', hidden: true },
38 ]
39}
40
41/** The replay's control row: `[ live ]` while a live swarm waits, each agent, the views, then nudge, pause and stop. */
42export function controlsOf(scene: Scene, filter: string | undefined, look: Look, isPaused = false, liveWaiting = false): Control[] {
43 return [
44 ...(liveWaiting ? [{ key: 'live', label: 'live', hotkey: '0' }] : []),
45 ...agentControls(scene, filter, look),
46 ...viewControls(look, false),
47 { key: 'nudge', label: 'nudge', hotkey: 'n' },
48 { key: 'pause', label: isPaused ? 'play' : 'pause', hotkey: 'p' },
49 { key: 'stop', label: 'stop', hotkey: 's' },
50 ]
51}
52
53/** A horizontal band of the stage: rows [y, y + rows), cut at its images' columns into raster and image pieces. */
54export type Band = { y: number; rows: number; pieces: ({ kind: 'raster'; key: string; x: number; cols: number } | { kind: 'image'; layer: ImageLayer })[] }
55
56/**
57 * The frame as bands top to bottom: rows no image covers form raster-only bands; images sharing rows form one band, the
58 * cells beside them rasters of their own. Images in one band must share their rows; one that does not is dropped.
59 */
60export function bandsOf(frame: Frame): Band[] {
61 const { grid, images } = frame
62 const groups = new Map<number, ImageLayer[]>()
63 for (const im of images) {
64 if (im.y < 0 || im.y + im.rows > grid.rows || im.x < 0 || im.x + im.cols > grid.cols) continue
65 const list = groups.get(im.y) ?? []
66 if (list.length > 0 && list[0]!.rows !== im.rows) continue
67 list.push(im)
68 groups.set(im.y, list)
69 }
70 const starts = [...groups.keys()].sort((a, b) => a - b)
71 const out: Band[] = []
72 let y = 0
73 for (const s of starts) {
74 if (s < y) continue
75 const layers = groups.get(s)!.sort((a, b) => a.x - b.x).filter((im, i, arr) => i === 0 || im.x >= arr[i - 1]!.x + arr[i - 1]!.cols)
76 if (s > y) out.push({ y, rows: s - y, pieces: [{ kind: 'raster', key: `r${y}`, x: 0, cols: grid.cols }] })
77 const rows = layers[0]!.rows
78 const pieces: Band['pieces'] = []
79 let x = 0
80 for (const im of layers) {
81 if (im.x > x) pieces.push({ kind: 'raster', key: `r${s}x${x}`, x, cols: im.x - x })
82 pieces.push({ kind: 'image', layer: im })
83 x = im.x + im.cols
84 }
85 if (x < grid.cols) pieces.push({ kind: 'raster', key: `r${s}x${x}`, x, cols: grid.cols - x })
86 out.push({ y: s, rows, pieces })
87 y = s + rows
88 }
89 if (y < grid.rows) out.push({ y, rows: grid.rows - y, pieces: [{ kind: 'raster', key: `r${y}`, x: 0, cols: grid.cols }] })
90 return out
91}
92
93/** A string that changes when the bands' shape does: same shape, the pane repaints by blit; another, it draws again. */
94export function shapeOf(bands: readonly Band[]): string {
95 return bands.map(b => `${b.y}+${b.rows}:` + b.pieces.map(p => (p.kind === 'raster' ? `${p.key}/${p.cols}` : `${p.layer.key}@${p.layer.x}/${p.layer.cols}`)).join(',')).join(';')
96}
97
98/** The least real time between two sources of one Image: a change sooner waits, and changes in between merge into it. */
99export const IMAGE_MIN_MS = 1_000
100
101/** The source each Image key shows, its sig and when it was made; a new one waits the shorter of the kept and the new layer's `minMs`. */
102export class ImageShelf {
103 private readonly held = new Map<string, { sig: string; source: ImageSource; at: number; minMs: number }>()
104
105 /** The source to show for the layer at real time `now`, and whether it is a new one to send. */
106 take(layer: ImageLayer, now: number): { source: ImageSource; isNew: boolean } {
107 const kept = this.held.get(layer.key)
108 const minMs = layer.minMs ?? IMAGE_MIN_MS
109 if (kept !== undefined && (kept.sig === layer.sig || now - kept.at < Math.min(minMs, kept.minMs))) return { source: kept.source, isNew: false }
110 const source = layer.source?.() ?? layer.draw().source()
111 this.held.set(layer.key, { sig: layer.sig, source, at: now, minMs })
112 return { source, isNew: true }
113 }
114
115 /** Drops a key's source, so the next `take` makes and sends it again. */
116 forget(key: string) {
117 this.held.delete(key)
118 }
119}
120hooks/replay.ts 525 lines1// Replay of a recorded run: the command's arguments, the runs a log holds, the playback schedule, and the Scene a pane look draws.
2
3import {
4 ClaimBoard,
5 DEFAULT_STALL_MIN,
6 isModName,
7 bareName,
8 claimOf,
9 clauseOf,
10 helloName,
11 isHeldFromLead,
12 isIdleLine,
13 isToolLine,
14 isLeadAddress,
15 renameName,
16 reviewOf,
17 shortModel,
18 sliceOf,
19 typeOf,
20 REPORTS,
21} from './core'
22import type { LogEntry, ReviewState } from './core'
23
24export type Look = 'A' | 'B'
25export type Level = 'basic' | 'rich'
26const LOOKS: readonly Look[] = ['A', 'B']
27const DEFAULT_SPEED = 30
28/** The longest real pause between two replayed events: a quiet stretch of the run plays in at most this long. */
29const MAX_GAP_MS = 1_500
30/** Two teams founded this close together are one spawn burst: a peer line between them makes them one. */
31export const BURST_MS = 15_000
32/** A team silent between its own members this long starts over when its names talk again. */
33const REUSE_GAP_MS = 30 * 60_000
34
35/** `level` absent: the pane picks rich where the terminal draws images, else basic. */
36export type ReplayArgs = { path: string; speed: number; look: Look; level?: Level; from?: number; to?: number }
37
38/** The arguments after `replay`: `stop`, or a log path and its flags; an error text for anything it cannot read. */
39export function parseReplayArgs(rest: string): { stop: true } | { args: ReplayArgs } | { error: string } {
40 const words = rest.trim().split(/\s+/).filter(Boolean)
41 if (words[0] === 'stop') return { stop: true }
42 const out: ReplayArgs = { path: '', speed: DEFAULT_SPEED, look: 'A' }
43 for (let i = 0; i < words.length; i++) {
44 const w = words[i]!
45 const value = words[i + 1]
46 if (w.startsWith('--') && value === undefined) return { error: `${w} needs a value` }
47 if (w === '--speed') {
48 const n = Number(value)
49 if (!Number.isFinite(n) || n <= 0) return { error: '--speed takes a number above 0' }
50 out.speed = n
51 i++
52 } else if (w === '--look') {
53 const l = value!.toUpperCase()
54 if (!(LOOKS as readonly string[]).includes(l)) return { error: '--look takes A or B' }
55 out.look = l as Look
56 i++
57 } else if (w === '--level') {
58 if (value !== 'basic' && value !== 'rich') return { error: '--level takes basic or rich' }
59 out.level = value
60 i++
61 } else if (w === '--from' || w === '--to') {
62 const t = Date.parse(value!)
63 if (!Number.isFinite(t)) return { error: `${w} takes an ISO time, e.g. 2026-10-12T19:47:00Z` }
64 out[w === '--from' ? 'from' : 'to'] = t
65 i++
66 } else if (w.startsWith('--')) return { error: 'unknown flag ' + w }
67 else if (out.path === '') out.path = w
68 else return { error: 'one log path only' }
69 }
70 if (out.path === '') return { error: 'usage: /swarm replay <log.jsonl> [--speed N] [--look A|B] [--level basic|rich] [--from <ISO>] [--to <ISO>] | replay stop' }
71 return { args: out }
72}
73
74/** Whether an address is a teammate's: not the lead, this plugin, or an unnamed peer. */
75export function isTeammateName(name: string): boolean {
76 return name !== '' && !isLeadAddress(name) && !isModName(name) && name !== 'peer' && name !== 'coordinator'
77}
78
79export type Run = { from: number; to: number; count: number; names: string[] }
80
81/** How long before a run's first teammate line the replay starts: the lead's own lines before it are its brief, not the run. */
82export const LEAD_IN_MS = 30_000
83
84/** A team: the teammates that message each other, from its first peer message to its last. */
85export type Team = { names: string[]; firstAt: number; lastPeerAt: number }
86
87/**
88 * The teams a log holds, from teammate-to-teammate lines in time order. A peer line belongs to the team either end talked
89 * in within `REUSE_GAP_MS`; a line between two names with no such team founds a new one. The other end joins on that line,
90 * sender or receiver, unless it is still talking in another team; a line between two teams founded within `BURST_MS` of
91 * each other merges them. A team's names come in the order each first sent a peer line, as the looks place them. Agents that only write to the lead are in no team.
92 */
93export function teamsOf(lines: readonly LogEntry[], isMate: (name: string) => boolean = isTeammateName): Team[] {
94 const out: Team[] = []
95 const seen = new Map<string, { team: Team; at: number }>()
96 const firstSent = new Map<Team, Map<string, number>>()
97 for (const e of [...lines].sort((a, b) => a.ts - b.ts)) {
98 if (isIdleLine(e) || isToolLine(e)) continue
99 const from = bareName(e.from)
100 const to = bareName(e.to)
101 if (from === to || !isTeammateName(from) || !isTeammateName(to) || !isMate(from) || !isMate(to)) continue
102 const teamOf = (n: string) => {
103 const last = seen.get(n)
104 return last !== undefined && e.ts - last.at <= REUSE_GAP_MS ? last.team : undefined
105 }
106 let team = teamOf(from) ?? teamOf(to)
107 const other = teamOf(to)
108 if (team !== undefined && other !== undefined && other !== team && Math.abs(other.firstAt - team.firstAt) <= BURST_MS) {
109 for (const n of other.names) if (!team.names.includes(n)) team.names.push(n)
110 team.firstAt = Math.min(team.firstAt, other.firstAt)
111 for (const [n, last] of seen) if (last.team === other) seen.set(n, { team, at: last.at })
112 out.splice(out.indexOf(other), 1)
113 const mine = firstSent.get(team) ?? new Map<string, number>()
114 for (const [n, at] of firstSent.get(other) ?? []) mine.set(n, Math.min(at, mine.get(n) ?? Infinity))
115 firstSent.set(team, mine)
116 }
117 if (team === undefined) {
118 team = { names: [from, to], firstAt: e.ts, lastPeerAt: e.ts }
119 out.push(team)
120 } else {
121 team.lastPeerAt = e.ts
122 for (const n of [from, to]) if (!team.names.includes(n) && teamOf(n) === undefined) team.names.push(n)
123 }
124 for (const n of [from, to]) if (team.names.includes(n)) seen.set(n, { team, at: e.ts })
125 if (!firstSent.has(team)) firstSent.set(team, new Map())
126 if (!firstSent.get(team)!.has(from)) firstSent.get(team)!.set(from, e.ts)
127 }
128 for (const t of out) {
129 const sent = firstSent.get(t) ?? new Map<string, number>()
130 t.names.sort((a, b) => (sent.get(a) ?? Infinity) - (sent.get(b) ?? Infinity))
131 }
132 return out
133}
134
135/**
136 * The runs a log holds, one per team: from `LEAD_IN_MS` before its first peer message to the last line from or to one of
137 * its members before a later team that shares a name starts.
138 */
139export function splitRuns(lines: readonly LogEntry[]): Run[] {
140 const sorted = [...lines].sort((a, b) => a.ts - b.ts)
141 const teams = teamsOf(sorted)
142 return teams.map((t, i) => {
143 const next = teams.slice(i + 1).find(n => n.names.some(x => t.names.includes(x)))
144 const end = next?.firstAt ?? Infinity
145 const from = t.firstAt - LEAD_IN_MS
146 let to = t.lastPeerAt
147 for (const e of sorted) if (e.ts >= t.firstAt && e.ts < end && (t.names.includes(bareName(e.from)) || t.names.includes(bareName(e.to)))) to = Math.max(to, e.ts)
148 return { from, to, count: sorted.filter(e => e.ts >= from && e.ts <= to).length, names: t.names }
149 })
150}
151
152/** The lines inside a window, in time order. */
153export function windowOf(lines: readonly LogEntry[], from: number, to: number): LogEntry[] {
154 return lines.filter(e => e.ts >= from && e.ts <= to).sort((a, b) => a.ts - b.ts)
155}
156
157/** When each line plays, in real milliseconds from the start: the recorded gap over `speed`, at most `maxGapMs`. */
158export function schedule(lines: readonly LogEntry[], speed: number, maxGapMs = MAX_GAP_MS): number[] {
159 const out: number[] = []
160 let at = 0
161 for (let i = 0; i < lines.length; i++) {
162 if (i > 0) at += Math.min((lines[i]!.ts - lines[i - 1]!.ts) / speed, maxGapMs)
163 out.push(at)
164 }
165 return out
166}
167
168export type ModelFamily = 'opus' | 'sonnet' | 'haiku' | 'fable'
169
170/**
171 * The model family per teammate name from a roster.json, for a run starting at `at`: the latest entry of that name spawned
172 * by then (with a minute's grace), else its only entry; undefined when the roster does not say.
173 */
174export function modelsOf(rosterJson: unknown): (name: string, at: number) => ModelFamily | undefined {
175 const agents = (rosterJson as { agents?: unknown } | null)?.agents
176 const rows = Array.isArray(agents) ? (agents as { name?: unknown; model?: unknown; spawnedAt?: unknown }[]) : []
177 return (name, at) => {
178 const mine = rows.filter(r => typeof r.name === 'string' && bareName(r.name) === name && typeof r.model === 'string')
179 const before = mine.filter(r => typeof r.spawnedAt === 'number' && r.spawnedAt <= at + 60_000).sort((a, b) => (b.spawnedAt as number) - (a.spawnedAt as number))
180 const pick = before[0] ?? (mine.length === 1 ? mine[0] : undefined)
181 const family = pick === undefined ? undefined : shortModel(pick.model as string).split(' ')[0]
182 return family === 'opus' || family === 'sonnet' || family === 'haiku' || family === 'fable' ? family : undefined
183 }
184}
185
186export type LaneState = 'running' | 'idle' | 'waiting'
187export type AgentState = LaneState | 'stalled' | 'quiet' | 'ended'
188export type Segment = { state: LaneState; from: number; holdsClaim: boolean }
189
190export type SceneAgent = {
191 name: string
192 code?: string
193 model?: ModelFamily
194 /** Its place in first-seen order: the index of its colour. */
195 index: number
196 sent: number
197 received: number
198 /** When it sent each message. */
199 sends: number[]
200 segments: Segment[]
201 /** The file it last edited, wrote or read, as the log's FILE line gives it: `✎ hooks/core.ts`. */
202 file?: string
203 /** Its last tool call in plain words, as the log's ACT line gives it: `Running npm test`. */
204 action?: string
205 /** How many tool calls it had made by its latest ACT line. */
206 calls?: number
207}
208
209export type MessageKind = 'send' | 'receive' | 'idle'
210
211export type SceneMessage = {
212 ts: number
213 from: string
214 to: string
215 type?: string
216 summary: string
217 kind: MessageKind
218 /** For a delivery to the lead: whether the lead filter holds it back. */
219 held?: boolean
220}
221
222export type CardStatus = 'unclaimed' | 'open' | 'building' | 'done' | 'approved' | 'blocked'
223export type Card = {
224 slice: string
225 builders: string[]
226 reviewers: { name: string; state?: ReviewState }[]
227 others: string[]
228 status: CardStatus
229 isDuplicate: boolean
230 /** The last claim, verdict or report on it. */
231 at: number
232 /** A path it names, for a link. */
233 path?: string
234}
235
236/** What a look draws at one moment of a replay: built line by line from the log, never from live state. */
237export class Scene {
238 readonly agents = new Map<string, SceneAgent>()
239 readonly messages: SceneMessage[] = []
240 readonly edges = new Map<string, number>()
241 readonly wakes: { ts: number; held: boolean }[] = []
242 readonly board = new ClaimBoard()
243 private readonly cardAt = new Map<string, number>()
244 private readonly paths = new Map<string, string>()
245 private readonly done = new Map<string, number>()
246 /** The seeded team; once set, no other name becomes an agent and lines between non-members are left out. */
247 private members: Set<string> | undefined
248 startedAt: number | undefined
249 now = 0
250
251 constructor(
252 private readonly models: (name: string, at: number) => ModelFamily | undefined = () => undefined,
253 readonly stallMs = DEFAULT_STALL_MIN * 60_000,
254 ) {}
255
256 get delivered(): number {
257 return this.wakes.filter(w => !w.held).length
258 }
259
260 get held(): number {
261 return this.wakes.filter(w => w.held).length
262 }
263
264 /** The teammates in first-seen order. */
265 order(): SceneAgent[] {
266 return [...this.agents.values()].sort((a, b) => a.index - b.index)
267 }
268
269 /** Adds the run's teammates up front, in this order, so the layout holds still while they start. */
270 seed(names: readonly string[], at: number) {
271 this.startedAt ??= at
272 this.members = new Set(names)
273 for (const n of names) this.agent(n, at, true)
274 }
275
276 /** The agent of a name; a teammate not yet seen is added when `create` (it sent a line), else undefined. */
277 private agent(name: string, ts: number, create: boolean): SceneAgent | undefined {
278 if (!isTeammateName(name) || (this.members !== undefined && !this.members.has(name))) return undefined
279 let a = this.agents.get(name)
280 if (a === undefined) {
281 if (!create) return undefined
282 a = { name, index: this.agents.size, sent: 0, received: 0, sends: [], segments: [] }
283 const model = this.models(name, this.startedAt ?? ts)
284 if (model !== undefined) a.model = model
285 this.agents.set(name, a)
286 }
287 return a
288 }
289
290 private enter(a: SceneAgent, state: LaneState, ts: number) {
291 const last = a.segments[a.segments.length - 1]
292 if (last?.state === state) return
293 a.segments.push({ state, from: ts, holdsClaim: this.board.openClaims(a.name).length > 0 })
294 }
295
296 /** Whether a line repeats the previous one of the same sender and text within 3 s (a tool call and its send). */
297 private isRepeat(e: LogEntry, from: string, to: string): boolean {
298 for (let i = this.messages.length - 1; i >= 0; i--) {
299 const m = this.messages[i]!
300 if (e.ts - m.ts > 3_000) return false
301 if (m.kind === 'send' && m.from === from && m.to === to && m.summary === e.summary) return true
302 }
303 return false
304 }
305
306 /** Applies one log line. */
307 apply(e: LogEntry) {
308 this.startedAt ??= e.ts
309 this.now = Math.max(this.now, e.ts)
310 const from = bareName(e.from) || e.from
311 const to = bareName(e.to) || e.to
312 if (this.members !== undefined && !this.members.has(from) && !this.members.has(to)) return
313 if (e.type === 'FILE') {
314 const a = this.agent(from, e.ts, true)
315 if (a !== undefined) {
316 a.file = e.summary
317 this.enter(a, 'running', e.ts)
318 }
319 return
320 }
321 if (e.type === 'ACT') {
322 const a = this.agent(from, e.ts, true)
323 if (a !== undefined) {
324 a.action = e.summary
325 a.calls = e.calls ?? (a.calls ?? 0) + 1
326 this.enter(a, 'running', e.ts)
327 }
328 return
329 }
330 if (isIdleLine(e)) {
331 const a = this.agent(from, e.ts, true)
332 if (a !== undefined) this.enter(a, 'idle', e.ts)
333 if (isLeadAddress(to)) this.wakes.push({ ts: e.ts, held: isHeldFromLead('', { idleReason: e.idleReason }) })
334 this.messages.push({ ts: e.ts, from, to, type: 'IDLE', summary: e.idleReason ?? 'idle', kind: 'idle' })
335 return
336 }
337 const isSend = e.via !== 'receive'
338 if (isSend && this.isRepeat(e, from, to)) return
339 const sender = this.agent(from, e.ts, isSend)
340 const receiver = this.agent(to, e.ts, false)
341 const type = e.type ?? typeOf(e.summary)
342 const m: SceneMessage = { ts: e.ts, from, to, summary: e.summary, kind: isSend ? 'send' : 'receive' }
343 if (type !== undefined) m.type = type
344 if (isLeadAddress(to) && sender !== undefined) {
345 m.held = isHeldFromLead(e.summary, undefined)
346 if (isSend || !this.messages.some(x => x.kind === 'send' && x.from === from && isLeadAddress(x.to) && x.summary === e.summary)) this.wakes.push({ ts: e.ts, held: m.held })
347 }
348 this.messages.push(m)
349 if (sender !== undefined) {
350 const code = helloName(e.summary) ?? renameName(e.summary)
351 if (code !== undefined && isSend) sender.code = code
352 this.claims(sender.name, e, isSend)
353 }
354 if (isSend) {
355 if (sender !== undefined) {
356 sender.sent++
357 sender.sends.push(e.ts)
358 this.enter(sender, type === 'WAIT' ? 'waiting' : 'running', e.ts)
359 }
360 if (receiver !== undefined) receiver.received++
361 if ((sender !== undefined || isLeadAddress(from)) && (receiver !== undefined || isLeadAddress(to))) {
362 const key = (isLeadAddress(from) ? 'team-lead' : from) + '>' + (isLeadAddress(to) ? 'team-lead' : to)
363 this.edges.set(key, (this.edges.get(key) ?? 0) + 1)
364 }
365 }
366 }
367
368 private claims(by: string, e: LogEntry, isSend: boolean) {
369 const ref = claimOf(e.summary)
370 const touch = (target: string) => {
371 const slice = sliceOf(target, this.board.deliverables)
372 this.cardAt.set(slice, e.ts)
373 const path = /(?:^|\s)((?:\/|~\/|[\w.-]+\/)[\w./-]*\w|[\w-]+\.(?:md|ts|tsx|json|py|sql))\b/.exec(e.summary.replace(/^.*?\[CLAIM\]\s*\S+\s*/i, ''))?.[1]
374 if (path !== undefined && !this.paths.has(slice)) this.paths.set(slice, path)
375 }
376 if (ref !== undefined) {
377 const path = ref.role === 'build' ? /(?:^|\s)(\/\S+\/[^\s/]+\.\w+)/.exec(ref.target)?.[1] : undefined
378 if (path !== undefined) this.board.addDeliverables([path])
379 if (this.board.claim(by, ref, isSend) || isSend) touch(ref.target)
380 }
381 const verdict = reviewOf(e.summary)
382 if (verdict !== undefined && this.board.review(by, verdict)) this.cardAt.set(verdict.slice, e.ts)
383 const type = typeOf(e.summary)
384 if (type !== undefined && REPORTS.includes(type) && this.board.report(by, type, isSend) && type === 'DONE') this.done.set(by, e.ts)
385 }
386
387 /** An agent's state at `t`: its segment then, `stalled` once idle past the stall time while it held a claim, `quiet` before its first line. */
388 stateAt(name: string, t = this.now): AgentState {
389 const a = this.agents.get(name)
390 const seg = a === undefined ? undefined : [...a.segments].reverse().find(s => s.from <= t)
391 if (seg === undefined) return 'quiet'
392 if (seg.state === 'idle' && seg.holdsClaim && t - seg.from >= this.stallMs) return 'stalled'
393 return seg.state
394 }
395
396 /** How long, in ms, an agent has been in its state at `t`. */
397 stateFor(name: string, t = this.now): number {
398 const a = this.agents.get(name)
399 const seg = a === undefined ? undefined : [...a.segments].reverse().find(s => s.from <= t)
400 return seg === undefined ? 0 : t - seg.from
401 }
402
403 /** An agent's code name, with its team name when another agent of the run took the same one; the name itself for anyone else. */
404 label(name: string): string {
405 const code = this.agents.get(name)?.code
406 if (code === undefined) return name
407 const clash = [...this.agents.values()].some(a => a.name !== name && a.code?.toLowerCase() === code.toLowerCase())
408 return clash ? `${code}·${name}` : code
409 }
410
411 /** The claims board: one card per slice, newest activity first. */
412 cards(): Card[] {
413 const slices = new Map<string, Card>()
414 const at = (slice: string): Card => {
415 let c = slices.get(slice)
416 if (c === undefined) {
417 c = { slice, builders: [], reviewers: [], others: [], status: 'open', isDuplicate: false, at: this.cardAt.get(slice) ?? 0 }
418 const path = this.paths.get(slice)
419 if (path !== undefined) c.path = path
420 slices.set(slice, c)
421 }
422 return c
423 }
424 const holding = new Map<Card, Set<string>>()
425 for (const cl of this.board.all()) {
426 const card = at(sliceOf(cl.target, this.board.deliverables))
427 const list = cl.role === 'build' ? card.builders : cl.role === 'review' ? undefined : card.others
428 for (const b of cl.by) {
429 if (list !== undefined && !list.includes(b)) list.push(b)
430 if (cl.role === 'build' || cl.role === 'research') {
431 const held = holding.get(card) ?? new Set<string>()
432 holding.set(card, held)
433 if (this.board.isActive(cl, b)) held.add(cl.role + ' ' + b)
434 }
435 if (cl.role === 'review' && !card.reviewers.some(r => r.name === b)) card.reviewers.push({ name: b })
436 }
437 }
438 for (const [slice, states] of this.board.reviews) {
439 const card = at(slice)
440 for (const [by, state] of states) {
441 const r = card.reviewers.find(x => x.name === by)
442 if (r !== undefined) r.state = state
443 else if (!card.builders.includes(by)) card.reviewers.push({ name: by, state })
444 }
445 }
446 for (const card of slices.values()) {
447 const held = [...(holding.get(card) ?? [])]
448 card.isDuplicate = held.filter(h => h.startsWith('build ')).length > 1 || held.filter(h => h.startsWith('research ')).length > 1
449 const states = card.reviewers.map(r => r.state)
450 if (states.includes('block') || card.isDuplicate) card.status = 'blocked'
451 else if (states.includes('approve')) card.status = 'approved'
452 else if (card.builders.some(b => (this.done.get(b) ?? 0) >= card.at)) card.status = 'done'
453 else if (card.builders.length > 0) card.status = 'building'
454 else if (card.reviewers.length === 0 && card.others.length === 0) card.status = 'unclaimed'
455 }
456 return [...slices.values()].sort((a, b) => b.at - a.at)
457 }
458}
459
460/** How many of this session's lines the live scene keeps. */
461const LIVE_LINES = 5_000
462
463/**
464 * The live pane's scene, from this session's lines: built again when its team changes, else fed the lines since. Teams
465 * are worked out from the messages alone, kept apart from the FILE lines so a flood of those never pushes a team out.
466 */
467export class LiveScene {
468 private readonly lines: LogEntry[] = []
469 private readonly talk: LogEntry[] = []
470 private built: { key: string; scene: Scene; applied: number } | undefined
471 private teams: { key: string; team: Team | undefined } | undefined
472
473 push(e: LogEntry) {
474 if (!isToolLine(e) && !isIdleLine(e)) {
475 this.talk.push(e)
476 if (this.talk.length > LIVE_LINES) this.talk.splice(0, this.talk.length - LIVE_LINES)
477 }
478 this.lines.push(e)
479 if (this.lines.length > LIVE_LINES) {
480 this.lines.splice(0, this.lines.length - LIVE_LINES)
481 this.built = undefined
482 }
483 }
484
485 /** The current team: the latest whose members messaged each other; worked out again when a line or `version` changed. */
486 team(isMate: (name: string) => boolean, version = ''): Team | undefined {
487 const key = `${this.talk.length}:${this.talk.at(-1)?.ts}:${version}`
488 if (this.teams?.key !== key) this.teams = { key, team: teamsOf(this.talk, isMate).at(-1) }
489 return this.teams.team
490 }
491
492 /** The scene of `names` from `from`, with the lines since applied. */
493 scene(names: readonly string[], from: number, models: (name: string, at: number) => ModelFamily | undefined, stallMs: number): Scene {
494 const key = `${names.join(',')}@${from}/${stallMs}`
495 if (this.built?.key !== key) {
496 const scene = new Scene(models, stallMs)
497 scene.seed(names, from)
498 this.built = { key, scene, applied: 0 }
499 }
500 const b = this.built
501 for (; b.applied < this.lines.length; b.applied++) {
502 const e = this.lines[b.applied]!
503 if (e.ts >= from) b.scene.apply(e)
504 }
505 return b.scene
506 }
507}
508
509/** A short title for a run: its teammates as a range when they share a stem (`r1–r4`), else listed. */
510export function runTitle(names: readonly string[]): string {
511 const m = names.map(n => /^([a-z-]*?)(\d+)$/.exec(n))
512 const stem = m[0]?.[1]
513 if (names.length > 1 && m.every(x => x !== null && x[1] === stem)) {
514 const nums = m.map(x => Number(x![2])).sort((a, b) => a - b)
515 return `${stem}${nums[0]}–${stem}${nums[nums.length - 1]}`
516 }
517 return names.join(' ')
518}
519
520/** The clause a card shows for its slice: the first clause, at most `max` characters. */
521export function cardTitle(slice: string, max: number): string {
522 const s = clauseOf(slice).replace(/^\/.*\//, '')
523 return s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s
524}
525hooks/grid.ts 158 lines1// A cell grid for the terminal's Raster: glyph, foreground and background per cell, with text, braille dots and half-block pixels.
2
3/** The terminal's own colour in a Raster cell. */
4const DEFAULT = 0x01000000
5
6/** `#rrggbb` as `0xRRGGBB`. */
7export function hex(s: string): number {
8 return parseInt(s.replace('#', ''), 16) & 0xffffff
9}
10
11/** The colour `t` of the way from `a` to `b`, per channel; `t` is clamped to 0..1. */
12export function mix(a: number, b: number, t: number): number {
13 const k = Math.max(0, Math.min(1, t))
14 const ch = (s: number) => Math.round(((a >> s) & 255) * (1 - k) + ((b >> s) & 255) * k)
15 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
16}
17
18/** `0xRRGGBB` as `#rrggbb`. */
19export function css(c: number): string {
20 return '#' + (c & 0xffffff).toString(16).padStart(6, '0')
21}
22
23const BRAILLE_BIT = [
24 [0x01, 0x08],
25 [0x02, 0x10],
26 [0x04, 0x20],
27 [0x40, 0x80],
28]
29
30export class Grid {
31 readonly cols: number
32 readonly rows: number
33 readonly ch: Uint32Array
34 readonly fg: Uint32Array
35 readonly bg: Uint32Array
36 /** The braille dots set per cell (bits as U+2800 counts them); a cell with dots draws as braille. */
37 private readonly dots: Uint8Array
38
39 constructor(cols: number, rows: number, bg = DEFAULT) {
40 this.cols = Math.max(1, Math.floor(cols))
41 this.rows = Math.max(1, Math.floor(rows))
42 const n = this.cols * this.rows
43 this.ch = new Uint32Array(n).fill(32)
44 this.fg = new Uint32Array(n).fill(DEFAULT)
45 this.bg = new Uint32Array(n).fill(bg)
46 this.dots = new Uint8Array(n)
47 }
48
49 private at(x: number, y: number): number {
50 if (x < 0 || y < 0 || x >= this.cols || y >= this.rows) return -1
51 return y * this.cols + x
52 }
53
54 /** Sets one cell; an undefined colour keeps the cell's. */
55 put(x: number, y: number, ch: string | number, fg?: number, bg?: number) {
56 const i = this.at(Math.floor(x), Math.floor(y))
57 if (i < 0) return
58 const cp = typeof ch === 'number' ? ch : (ch.codePointAt(0) ?? 32)
59 this.ch[i] = cp > 0xffff || cp < 32 ? 0x3f : cp
60 this.dots[i] = 0
61 if (fg !== undefined) this.fg[i] = fg
62 if (bg !== undefined) this.bg[i] = bg
63 }
64
65 fill(x: number, y: number, w: number, h: number, ch: string, fg?: number, bg?: number) {
66 for (let r = y; r < y + h; r++) for (let c = x; c < x + w; c++) this.put(c, r, ch, fg, bg)
67 }
68
69 /** Paints the background of a rectangle, keeping glyphs. */
70 shade(x: number, y: number, w: number, h: number, bg: number) {
71 for (let r = y; r < y + h; r++) for (let c = x; c < x + w; c++) {
72 const i = this.at(c, r)
73 if (i >= 0) this.bg[i] = bg
74 }
75 }
76
77 /** Writes text from `x`, cut to `max` cells with `…`; returns the column after it. */
78 text(x: number, y: number, s: string, fg?: number, bg?: number, max = Infinity): number {
79 const chars = [...s].filter(c => (c.codePointAt(0) ?? 0) <= 0xffff)
80 const room = Math.min(max, this.cols - x)
81 if (room <= 0) return x
82 const shown = chars.length > room ? [...chars.slice(0, Math.max(0, room - 1)), '…'] : chars
83 shown.forEach((c, k) => this.put(x + k, y, c, fg, bg))
84 return x + shown.length
85 }
86
87 /** Writes text so that it ends at column `right` (exclusive). */
88 textRight(right: number, y: number, s: string, fg?: number, bg?: number): number {
89 const x = Math.max(0, right - [...s].length)
90 this.text(x, y, s, fg, bg, right - x)
91 return x
92 }
93
94 /** Sets one braille dot at sub-cell position (2 per column, 4 per row); its colour wins the cell's foreground. */
95 dot(dx: number, dy: number, fg: number) {
96 const x = Math.floor(dx)
97 const y = Math.floor(dy)
98 if (x < 0 || y < 0) return
99 const i = this.at(x >> 1, y >> 2)
100 if (i < 0) return
101 this.dots[i]! |= BRAILLE_BIT[y & 3]![x & 1]!
102 this.ch[i] = 0x2800 + this.dots[i]!
103 this.fg[i] = fg
104 }
105
106 /** A braille line between two dot positions. */
107 line(x0: number, y0: number, x1: number, y1: number, fg: number | ((t: number) => number), from = 0, to = 1) {
108 const n = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))))
109 for (let k = Math.floor(n * from); k <= Math.ceil(n * to); k++) {
110 const t = k / n
111 if (t < from || t > to) continue
112 this.dot(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, typeof fg === 'number' ? fg : fg(t))
113 }
114 }
115
116 /** The glyphs of one row, trailing spaces kept. */
117 rowText(y: number): string {
118 let s = ''
119 for (let x = 0; x < this.cols; x++) s += String.fromCharCode(this.ch[y * this.cols + x]!)
120 return s
121 }
122
123 /** The cells of a rectangle as a Raster's `cells`: base64 of u32 triplets, little-endian. */
124 cells(x = 0, y = 0, w = this.cols, h = this.rows): string {
125 const words = new Uint32Array(w * h * 3)
126 let k = 0
127 for (let r = y; r < y + h; r++) for (let c = x; c < x + w; c++) {
128 const i = this.at(c, r)
129 words[k++] = i < 0 ? 32 : this.ch[i]!
130 words[k++] = i < 0 ? DEFAULT : this.fg[i]!
131 words[k++] = i < 0 ? DEFAULT : this.bg[i]!
132 }
133 return base64(new Uint8Array(words.buffer))
134 }
135}
136
137const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
138
139/** Standard padded base64 of bytes. */
140export function base64(bytes: Uint8Array): string {
141 const parts: string[] = []
142 const CHUNK = 3 * 4096
143 for (let start = 0; start < bytes.length; start += CHUNK) {
144 const end = Math.min(bytes.length, start + CHUNK)
145 const codes: number[] = []
146 for (let i = start; i < end; i += 3) {
147 const a = bytes[i]!
148 const b = i + 1 < end ? bytes[i + 1]! : 0
149 const c = i + 2 < end ? bytes[i + 2]! : 0
150 codes.push(B64.charCodeAt(a >> 2), B64.charCodeAt(((a & 3) << 4) | (b >> 4)))
151 codes.push(i + 1 < end ? B64.charCodeAt(((b & 15) << 2) | (c >> 6)) : 61)
152 codes.push(i + 2 < end ? B64.charCodeAt(c & 63) : 61)
153 }
154 parts.push(String.fromCharCode(...codes))
155 }
156 return parts.join('')
157}
158hooks/sidecar.ts 193 lines1// The look B sky renderer process: hooks/sky.py paints the star map and writes each frame to a file the terminal reads
2// itself; the pane shows a frame by blitting its `{ file }` source, so no pixel crosses the engine and the mod draws no
3// picture of its own. The mod hands it the scene in DIR/scene.json. Any failure falls back for good.
4
5import type { EngineInterface, ImageSource } from 'claude-code'
6
7import type { SkyLine, SkyScene } from './looks'
8
9export type SidecarState = 'off' | 'starting' | 'running' | 'failed'
10
11/** Real ms before a frame the host held back (the sky Image not drawn now, the terminal busy) is offered again. */
12const RETRY_MS = 500
13
14/** The refusals that mean the terminal will never show a `{ file }` frame: no pictures at all, or no files read. */
15const CANNOT_SHOW = /draws no placeholder images|cannot read files on this machine/
16
17/** The renderer's state; the functions below act on it. */
18export type SkySidecar = {
19 requestId: string
20 key: string
21 state: SidecarState
22 /** Why it failed, once it did. */
23 failure: string | undefined
24 /** The latest frame as an Image source, once one came. */
25 source: ImageSource | undefined
26 /** The renderer's ms per frame, the last 100. */
27 renderMs: number[]
28 /** The newest frame not yet taken by the host. */
29 pending: ImageSource | undefined
30 retryTimer: { cancel: () => void } | undefined
31 /** How often it crashed this session, and the last crash: its exit and the end of its stderr. */
32 crashes: number
33 crash: string | undefined
34 /** Whether the running renderer said it ends on its own (`bye`), and the end of its stderr. */
35 isLeaving: boolean
36 stderr: string
37 dir: string
38 stream: AsyncGenerator<unknown, unknown> | undefined
39 seq: number
40 sent: string
41}
42
43export function newSidecar(requestId: string, key: string): SkySidecar {
44 return { requestId, key, state: 'off', failure: undefined, source: undefined, renderMs: [], crashes: 0, crash: undefined, isLeaving: false, stderr: '', pending: undefined, retryTimer: undefined, dir: '', stream: undefined, seq: 0, sent: '' }
45}
46
47/** Starts the renderer in `dir` while it is asked for (`starting`) and not yet spawned; its frames are blitted as they come. */
48export function startSidecar($: EngineInterface, sc: SkySidecar, dir: string) {
49 if (sc.state !== 'starting' || sc.stream !== undefined) return
50 sc.dir = dir
51 sc.sent = ''
52 sc.isLeaving = false
53 sc.stderr = ''
54 const stream = $.process.spawn({ argv: ['python3', '-I', `${$.plugin.root}/hooks/sky.py`, dir] })
55 sc.stream = stream
56 void readSidecar($, sc, stream)
57}
58
59/**
60 * Reads the renderer's lines until it ends. An end it announced (`bye`: told to quit, idle) or one we caused leaves it off,
61 * to start again when wanted; any other end is a crash: the first leaves it off with the crash noted, a second fails it.
62 */
63async function readSidecar($: EngineInterface, sc: SkySidecar, stream: AsyncGenerator<{ stream: string; text: string }, unknown>) {
64 let buf = ''
65 let end: { code?: number | null; signal?: string | null } | undefined
66 try {
67 for (let r = await stream.next(); ; r = await stream.next()) {
68 if (r.done === true) {
69 end = r.value as typeof end
70 break
71 }
72 if (sc.stream !== stream) break
73 if (r.value.stream === 'stderr') {
74 sc.stderr = (sc.stderr + r.value.text).slice(-400)
75 continue
76 }
77 buf += r.value.text
78 let nl: number
79 while ((nl = buf.indexOf('\n')) >= 0) {
80 const line = buf.slice(0, nl).trim()
81 buf = buf.slice(nl + 1)
82 await sidecarLine($, sc, line)
83 }
84 }
85 } catch (err) {
86 if (sc.stream === stream) failSidecar($, sc, 'renderer did not start: ' + String(err))
87 return
88 }
89 if (sc.stream !== stream) return
90 sc.stream = undefined
91 sc.source = undefined
92 sc.sent = ''
93 if (sc.isLeaving) {
94 sc.state = 'off'
95 return
96 }
97 const last = sc.stderr.trim().split('\n').pop() ?? ''
98 const crash = `exit ${end?.code ?? end?.signal ?? '?'}${last === '' ? '' : ': ' + last}`
99 if (++sc.crashes > 1) failSidecar($, sc, 'renderer crashed: ' + crash)
100 else {
101 sc.crash = crash
102 sc.state = 'off'
103 $.ui.log('swarm sky renderer crashed, starts again when wanted: ' + crash)
104 }
105}
106
107async function sidecarLine($: EngineInterface, sc: SkySidecar, line: string) {
108 const [kind, ...rest] = line.split(' ')
109 if (kind === 'ready') sc.state = 'running'
110 else if (kind === 'bye') sc.isLeaving = true
111 else if (kind === 'fail') failSidecar($, sc, rest.join(' '))
112 else if (kind === 'frame' && sc.state !== 'failed') {
113 const [file, gen, ms, w, h] = rest
114 sc.renderMs.push(Number(ms))
115 if (sc.renderMs.length > 100) sc.renderMs.shift()
116 sc.pending = { file: file!, format: 'rgb' as const, width: Number(w), height: Number(h), generation: Number(gen) }
117 await flushSidecar($, sc)
118 }
119}
120
121/**
122 * Blits the newest frame to the sky Image. A refusal that says the terminal cannot show it fails the renderer; any other
123 * (the Image not drawn now, the terminal busy) keeps the newest frame and offers it again in `RETRY_MS` and after each
124 * draw of the pane, for as long as it takes.
125 */
126async function flushSidecar($: EngineInterface, sc: SkySidecar) {
127 const source = sc.pending
128 if (source === undefined || sc.state === 'failed' || sc.state === 'off') return
129 const res = await $.ui.blit({ requestId: sc.requestId, key: sc.key, source }).catch((err: unknown) => ({ deny: String(err) }))
130 if (res.deny === undefined) {
131 if (sc.pending === source) sc.pending = undefined
132 sc.source = source
133 } else if (CANNOT_SHOW.test(res.deny)) failSidecar($, sc, 'frame refused: ' + res.deny)
134 else if (sc.retryTimer === undefined) {
135 sc.retryTimer = $.clock.after(RETRY_MS, () => {
136 sc.retryTimer = undefined
137 void flushSidecar($, sc)
138 })
139 }
140}
141
142/** Hands the renderer the scene when it changed, each lit edge and comet with its start in the mod's clock at `now`. */
143export async function updateSidecar($: EngineInterface, sc: SkySidecar, now: number, sky: SkyScene) {
144 if (sc.state === 'off' || sc.state === 'failed') return
145 const timed = ({ age, ...rest }: SkyLine) => ({ ...rest, start: Math.round(now - age) })
146 const scene = {
147 ...sky,
148 edges: sky.edges.map(({ age, ...e }) => (age === undefined ? e : { ...e, start: Math.round(now - age) })),
149 lit: sky.lit.map(timed),
150 comets: sky.comets.map(timed),
151 }
152 const sent = JSON.stringify(scene)
153 if (sent === sc.sent) return
154 sc.sent = sent
155 await $.fs.write(sc.dir + '/scene.json', JSON.stringify({ seq: ++sc.seq, now, ...scene }))
156}
157
158/** Ends the renderer cleanly, as when its pane closes: it reads `quit`, removes its files and exits; it starts again when wanted. */
159export async function quitSidecar($: EngineInterface, sc: SkySidecar) {
160 if (sc.state === 'off' || sc.state === 'failed') return
161 await endSidecar($, sc)
162 sc.state = 'off'
163}
164
165/** Pauses the renderer while look B rich is not drawn: it draws nothing until the next scene. */
166export async function pauseSidecar($: EngineInterface, sc: SkySidecar) {
167 if (sc.state === 'off' || sc.state === 'failed' || sc.sent === 'pause') return
168 sc.sent = 'pause'
169 sc.pending = undefined
170 await $.fs.write(sc.dir + '/scene.json', JSON.stringify({ seq: ++sc.seq, pause: true }))
171}
172
173function failSidecar($: EngineInterface, sc: SkySidecar, why: string) {
174 if (sc.state === 'failed') return
175 sc.failure = why
176 void endSidecar($, sc)
177 sc.state = 'failed'
178 $.ui.log('swarm sky renderer: ' + why)
179 $.ui.invalidate('ui.render')
180}
181
182async function endSidecar($: EngineInterface, sc: SkySidecar) {
183 const stream = sc.stream
184 sc.stream = undefined
185 sc.source = undefined
186 sc.sent = ''
187 sc.pending = undefined
188 sc.retryTimer?.cancel()
189 sc.retryTimer = undefined
190 await $.fs.write(sc.dir + '/scene.json', JSON.stringify({ seq: ++sc.seq, quit: true })).catch(() => undefined)
191 void stream?.return(undefined)
192}
193hooks/looks.ts 251 lines1// What every pane look shares: the view it draws, the frame it returns, palettes, badges and small drawing helpers.
2
3import { Grid, hex, mix } from './grid'
4import type { ImageSource } from 'claude-code'
5
6import type { Canvas } from './pixels'
7import type { AgentState, Card, CardStatus, Level, ModelFamily, Scene, SceneAgent, SceneMessage } from './replay'
8
9/** An agent's state in the view: `ended` for every agent once the team has ended, else its state at the view's time. */
10export function stateIn(scene: Scene, name: string, v: View): AgentState {
11 return v.isEnded === true ? 'ended' : scene.stateAt(name, v.now)
12}
13
14/** How long a message travels sender → receiver on screen, in real ms. */
15export const PULSE_MS = 700
16/** How long a state change or a card change glows, in real ms. */
17export const FLASH_MS = 900
18
19export type View = {
20 cols: number
21 rows: number
22 level: Level
23 /** The run's time now: the replayed time, or the clock live. */
24 now: number
25 runFrom: number
26 /** The run's end; live, the time now. */
27 runTo: number
28 title: string
29 /** The replay speed; absent live. */
30 speed?: number
31 /** Live, once every teammate of the team has ended: `now` and `runTo` hold at its last activity. */
32 isEnded?: boolean
33 isPaused: boolean
34 /** Real ms since the event of run time `ts` reached the pane; Infinity for one it never played. */
35 age: (ts: number) => number
36 /** The agent whose threads the person picked; the rest draw dim. */
37 filter?: string
38 /** A short dim note for the header: `replay finished`, `live swarm running`. */
39 note?: string
40}
41
42type XY = [number, number]
43type RGB = [number, number, number]
44
45/** A message on the sky, in the picture's pixels: from `a` bent toward `q` to `b`, `stop` of the way; `age`, real ms since it was sent. */
46export type SkyLine = { a: XY; q: XY; b: XY; stop: number; rgb: RGB; age: number; held?: boolean; lead?: boolean }
47
48/**
49 * Everything the sky renderer paints, in the picture's pixels: the backdrop's colours, the membrane, edges with their
50 * weights, leader lines, the lead's rays and beads, the lit edges and comets, the agents; `u` and `nu` scale strokes and
51 * marks, as the still sky's do.
52 */
53export type SkyScene = {
54 w: number
55 h: number
56 u: number
57 nu: number
58 centre: XY
59 membrane: number
60 ground: RGB
61 haze: RGB
62 star: RGB
63 membraneRgb: RGB
64 accent: RGB
65 dim: RGB
66 rule: RGB
67 stalled: RGB
68 /** `age`, while the first message between the two is still on its way: the edge grows behind it. */
69 edges: { a: XY; q: XY; b: XY; w: number; rgb: RGB; lit: number; age?: number }[]
70 leaders: { pts: XY[]; rgb: RGB; lit: number }[]
71 rays: number
72 beads: number
73 lit: SkyLine[]
74 comets: SkyLine[]
75 nodes: { x: number; y: number; rgb: RGB; state: string; lit: number }[]
76 litMs: number
77 flightMs: number
78 landMs: number
79}
80
81/**
82 * A picture over a box of cells: `sig` names everything it draws from, so a pane sends it again only when `sig` changes,
83 * at most once per `minMs` (default `IMAGE_MIN_MS`). `draw` makes the pixels, sent raw within `IMAGE_BUDGET`; a layer with
84 * `source` sends what that makes instead (a PNG within `SKY_BUDGET`).
85 */
86export type ImageLayer = {
87 key: string
88 x: number
89 y: number
90 cols: number
91 rows: number
92 sig: string
93 draw: () => Canvas
94 source?: () => ImageSource
95 minMs?: number
96 /** The colour of the cells under the picture, where a terminal that fits it inside its box leaves them bare. */
97 ground?: number
98 /** Set where a renderer process may paint this picture instead: the scene it paints. */
99 stream?: SkyScene
100 alt: string
101}
102
103/**
104 * A drawn frame; `fadeMs`, the real ms its images still change by themselves (a lit edge fading), and `motionMs`, the
105 * real ms its cells still move by themselves (a comet flying), when they do.
106 */
107export type Frame = { grid: Grid; images: ImageLayer[]; links: { label: string; href: string }[]; fadeMs?: number; motionMs?: number }
108export type LookFn = (scene: Scene, view: View) => Frame
109
110export type Palette = {
111 name: string
112 bg: number
113 panel: number
114 rule: number
115 text: number
116 dim: number
117 accent: number
118 agents: readonly number[]
119 state: Record<AgentState, number>
120 status: Record<CardStatus, number>
121 model: Record<ModelFamily, number>
122}
123
124export function palette(p: {
125 name: string
126 bg: string
127 panel: string
128 rule: string
129 text: string
130 dim: string
131 accent: string
132 agents: readonly string[]
133 state: Record<AgentState, string>
134 status: Record<CardStatus, string>
135 model: Record<ModelFamily, string>
136}): Palette {
137 const map = <K extends string>(r: Record<K, string>) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, hex(v as string)])) as Record<K, number>
138 return { ...p, bg: hex(p.bg), panel: hex(p.panel), rule: hex(p.rule), text: hex(p.text), dim: hex(p.dim), accent: hex(p.accent), agents: p.agents.map(hex), state: map(p.state), status: map(p.status), model: map(p.model) }
139}
140
141export const MODEL_GLYPH: Record<ModelFamily, string> = { opus: '◆', sonnet: '●', haiku: '▲', fable: '✦' }
142export const STATUS_WORD: Record<CardStatus, string> = { unclaimed: 'UNCLAIMED', open: 'OPEN', building: 'BUILDING', done: 'DONE', approved: 'APPROVED', blocked: 'BLOCKED' }
143
144export function agentColor(p: Palette, a: SceneAgent | undefined): number {
145 return a === undefined ? p.text : p.agents[a.index % p.agents.length]!
146}
147
148/** A colour for an address: the agent's, the accent for the lead, the dim text for anything else. */
149export function nameColor(p: Palette, scene: Scene, name: string): number {
150 const a = scene.agents.get(name)
151 if (a !== undefined) return agentColor(p, a)
152 return name === 'team-lead' || name === 'main' || name === 'lead' ? p.text : p.dim
153}
154
155/** Whether a message is one of the filtered agent's threads; every message is without a filter. */
156export function touches(filter: string | undefined, from: string, to: string): boolean {
157 return filter === undefined || from === filter || to === filter
158}
159
160/** `mm:ss` (or `h:mm:ss`) of a duration. */
161export function clock(ms: number): string {
162 const s = Math.max(0, Math.floor(ms / 1000))
163 const h = Math.floor(s / 3600)
164 const m = Math.floor((s % 3600) / 60)
165 const ss = String(s % 60).padStart(2, '0')
166 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
167}
168
169/** `HH:MM:SS` in UTC. */
170export function timeOfDay(ts: number): string {
171 return new Date(ts).toISOString().slice(11, 19)
172}
173
174/** The most base64 characters an Image source may carry (about 75 KB of RGBA pixels), so a redraw never stalls the console. */
175export const IMAGE_BUDGET = 100_000
176
177/** The most base64 characters a PNG sky source may carry. */
178export const SKY_BUDGET = 300_000
179/** The least real time between two sky sources. */
180export const SKY_MIN_MS = 2_000
181/** Pixels per terminal cell, across and down: the types give a mod no cell size, so the common one. */
182export const CELL_PX = { w: 10, h: 20 }
183
184/** Pixels per cell column for an image of `cols` × `rows` cells (a cell twice as tall as wide) inside the budget, at most `max`. */
185export function pixelScale(cols: number, rows: number, max = 8): number {
186 const pixels = Math.floor((IMAGE_BUDGET * 3) / 4 / 4)
187 return Math.min(max, Math.floor(Math.sqrt(pixels / Math.max(1, cols * rows * 2)) * 100) / 100)
188}
189
190const STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200].map(s => s * 1000)
191
192/** A round slot length so `slots` of them cover `span` ms: the slots hold still while a live run grows. */
193export function slotMs(span: number, slots: number): number {
194 const want = span / Math.max(1, slots)
195 return STEPS.find(s => s >= want) ?? Math.ceil(want / 3_600_000) * 3_600_000
196}
197
198/** The messages the look tells as events: sends and the mod's own, newest last. */
199export function sends(scene: Scene): SceneMessage[] {
200 return scene.messages.filter(m => m.kind === 'send')
201}
202
203/** Spells a word with a space between letters, for titles. */
204export function spaced(s: string): string {
205 return [...s].join(' ')
206}
207
208/** A brightness bump for an event `age` real ms old: full at once, gone after `ms`. */
209export function fade(age: number, ms = FLASH_MS): number {
210 return age < 0 || age >= ms ? 0 : (1 - age / ms) ** 2
211}
212
213/** The messages in flight now, each with how far it has travelled (0 → 1). */
214export function inFlight(scene: Scene, view: View): { m: SceneMessage; t: number }[] {
215 const out: { m: SceneMessage; t: number }[] = []
216 for (let i = scene.messages.length - 1; i >= 0; i--) {
217 const m = scene.messages[i]!
218 const age = view.age(m.ts)
219 if (age >= PULSE_MS) break
220 if (m.kind === 'send' && age >= 0) out.push({ m, t: age / PULSE_MS })
221 }
222 return out.reverse()
223}
224
225/** The display width of a string in cells, counting each BMP code point as one. */
226export function width(s: string): number {
227 return [...s].length
228}
229
230/** Counts per slot of `slot` ms from `from`, `slots` of them. */
231export function histogram(times: readonly number[], from: number, slot: number, slots: number): number[] {
232 const out = new Array<number>(Math.max(1, slots)).fill(0)
233 for (const t of times) {
234 const i = Math.floor((t - from) / slot)
235 if (i >= 0 && i < out.length) out[i]!++
236 }
237 return out
238}
239
240/** The absolute paths the claim cards name, as `file://` links, newest first. */
241export function cardLinks(cards: readonly Card[], max = 4): { label: string; href: string }[] {
242 const out: { label: string; href: string }[] = []
243 for (const c of cards) {
244 if (c.path === undefined || !c.path.startsWith('/') || out.some(l => l.href === 'file://' + c.path)) continue
245 out.push({ label: c.path.split('/').pop() ?? c.path, href: 'file://' + c.path })
246 }
247 return out.slice(0, max)
248}
249
250export { Grid, mix }
251hooks/look-a.ts 335 lines1// Look A, "mission control": agent cards with a state-coloured activity bar on one time axis, the claims board, lead wakes and the message list.
2
3import { ago } from './core'
4import { Canvas } from './pixels'
5import { cardTitle } from './replay'
6import type { AgentState, Card, Scene, SceneAgent } from './replay'
7import {
8 FLASH_MS,
9 Grid,
10 PULSE_MS,
11 STATUS_WORD,
12 agentColor,
13 clock,
14 fade,
15 cardLinks,
16 histogram,
17 inFlight,
18 mix,
19 nameColor,
20 palette,
21 pixelScale,
22 sends,
23 slotMs,
24 spaced,
25 stateIn,
26 timeOfDay,
27 touches,
28 width,
29} from './looks'
30import type { Frame, ImageLayer, View } from './looks'
31
32export const FLIGHT = palette({
33 name: 'Flight',
34 bg: '#0A0E14',
35 panel: '#121A24',
36 rule: '#1E2A38',
37 text: '#CAD5E2',
38 dim: '#5E6E80',
39 accent: '#FFB547',
40 agents: ['#4FD6FF', '#FF7AB6', '#3DDC97', '#B18CFF', '#F4E04D', '#FF8F5A', '#7AA2FF', '#9BE564'],
41 state: { running: '#3DDC97', idle: '#5E6E80', waiting: '#4FD6FF', stalled: '#FF5C6C', quiet: '#2A3646', ended: '#5E6E80' },
42 status: { unclaimed: '#5E6E80', open: '#7AA2FF', building: '#FFB547', done: '#4FD6FF', approved: '#3DDC97', blocked: '#FF5C6C' },
43 model: { opus: '#D9A6FF', sonnet: '#7FC8FF', haiku: '#9BE5A0', fable: '#FFD27A' },
44})
45
46const TYPE_COLOR: Record<string, string> = {
47 PROPOSE: '#7AA2FF',
48 CLAIM: '#FFB547',
49 REVIEW: '#B18CFF',
50 DONE: '#3DDC97',
51 BLOCK: '#FF5C6C',
52 HANDOFF: '#4FD6FF',
53 ASK: '#FF7AB6',
54 WAIT: '#4FD6FF',
55 LOCK: '#FFB547',
56}
57
58const P = FLIGHT
59
60function typeColor(type: string | undefined): number {
61 const c = type === undefined ? undefined : TYPE_COLOR[type]
62 return c === undefined ? P.text : parseInt(c.slice(1), 16)
63}
64
65/** The header bar: title, run, replay clock and speed. */
66function header(g: Grid, v: View, narrow: boolean) {
67 g.fill(0, 0, g.cols, 1, ' ', P.text, P.panel)
68 const speed = v.isEnded === true ? '■ ended' : v.speed === undefined ? '● live' : (v.isPaused ? '❚❚ ' : '▶ ') + v.speed + '×'
69 let r = g.textRight(g.cols - 1, 0, speed, P.accent)
70 r = g.textRight(r - 2, 0, timeOfDay(v.now), P.text)
71 if (!narrow) r = g.textRight(r - 2, 0, 'T+' + clock(v.now - v.runFrom) + ' / ' + clock(v.runTo - v.runFrom), P.dim)
72 let x = g.text(1, 0, '◉', P.accent)
73 x = g.text(x + 1, 0, narrow ? 'SWARM' : spaced('SWARM'), P.text)
74 const title = v.isEnded === true ? `ENDED · ${v.title} · ran ${ago(v.runTo - v.runFrom)}` : (v.speed === undefined ? 'LIVE · ' : 'REPLAY · ') + v.title
75 x = g.text(x + 2, 0, narrow ? v.title : title, P.dim, undefined, r - x - 4)
76 if (v.note !== undefined) g.text(x + 2, 0, v.note, P.dim, undefined, r - x - 4)
77}
78
79/** The shared time axis of the agent bars: `slots` slots of `slot` ms from the run's start, and the busiest slot's count. */
80type Axis = { from: number; slot: number; slots: number; peak: number }
81
82function axisOf(scene: Scene, v: View, slots: number): Axis {
83 const slot = slotMs(Math.max(1, v.runTo - v.runFrom), slots)
84 const peak = Math.max(1, ...scene.order().flatMap(a => histogram(a.sends, v.runFrom, slot, slots)))
85 return { from: v.runFrom, slot, slots, peak }
86}
87
88/** One slot of an agent's bar: its sends then and its state at the slot's end (or now); undefined after now. */
89type Slot = { count: number; state: AgentState } | undefined
90
91function slotsOf(scene: Scene, a: SceneAgent, v: View, ax: Axis): Slot[] {
92 const counts = histogram(a.sends, ax.from, ax.slot, ax.slots)
93 return counts.map((count, i) => {
94 const start = ax.from + i * ax.slot
95 if (start > v.now) return undefined
96 return { count, state: scene.stateAt(a.name, Math.min(v.now, start + ax.slot - 1)) }
97 })
98}
99
100/** The file line of a card: the mark and the path's end, cut from the left to `w` cells. */
101function fileText(file: string, w: number): string {
102 const chars = [...file]
103 if (chars.length <= w) return file
104 return chars[0] + ' …' + chars.slice(chars.length - Math.max(1, w - 3)).join('')
105}
106
107/**
108 * One agent card: full name, tool calls, message counts and model; state, time in it and its last action (dim unless
109 * running), else its file; its bar on the shared axis.
110 */
111function agentCard(g: Grid, scene: Scene, v: View, a: SceneAgent, x: number, y: number, w: number, ax: Axis, images: ImageLayer[]) {
112 const color = agentColor(P, a)
113 const isDim = v.filter !== undefined && v.filter !== a.name
114 const state = stateIn(scene, a.name, v)
115 const seg = a.segments[a.segments.length - 1]
116 const flash = seg === undefined ? 0 : fade(v.age(seg.from), FLASH_MS)
117 const pulse = inFlight(scene, v).find(p => p.m.from === a.name || p.m.to === a.name)
118 const ink = (c: number) => (isDim ? mix(c, P.bg, 0.65) : c)
119 const panel = mix(P.panel, color, 0.06 + 0.22 * flash)
120 g.shade(x, y, w, 3, panel)
121 const barGlow = pulse === undefined ? 0 : pulse.m.from === a.name ? 1 - pulse.t : pulse.t
122 for (let r = 0; r < 3; r++) g.put(x, y + r, '▌', ink(mix(color, 0xffffff, 0.5 * barGlow)), panel)
123 let right = x + w - 1
124 if (a.model !== undefined) {
125 const chip = ' ' + a.model + ' '
126 right = g.textRight(right, y, chip, ink(P.bg), ink(P.model[a.model])) - 1
127 }
128 right = g.textRight(right, y, `↑${a.sent} ↓${a.received}`, ink(P.dim)) - 1
129 if (a.calls !== undefined) right = g.textRight(right - 1, y, `${a.calls}×`, ink(P.dim)) - 1
130 const cx = g.text(x + 2, y, a.code ?? a.name, ink(color), undefined, right - x - 2)
131 if (a.code !== undefined && a.code !== a.name) g.text(cx + 1, y, a.name, ink(P.dim), undefined, right - cx - 1)
132 let sx = g.text(x + 2, y + 1, '●', ink(mix(P.state[state], 0xffffff, 0.6 * flash)))
133 sx = g.text(sx + 1, y + 1, state.toUpperCase(), ink(P.state[state]))
134 sx = g.text(sx + 1, y + 1, clock(scene.stateFor(a.name, v.now)), ink(P.dim))
135 if (a.action !== undefined) g.text(sx + 2, y + 1, a.action, ink(state === 'running' ? P.text : P.dim), undefined, x + w - sx - 3)
136 else if (a.file !== undefined) g.text(sx + 2, y + 1, fileText(a.file, x + w - sx - 3), ink(P.text))
137 const slots = slotsOf(scene, a, v, ax)
138 if (v.level === 'rich') {
139 const sig = slots.map(s => (s === undefined ? '-' : s.state[0]! + s.count)).join(',') + '/' + ax.peak + (isDim ? 'd' : '')
140 const still = mix(P.panel, color, 0.06)
141 images.push({ key: 'bar-' + a.name, x: x + 2, y: y + 2, cols: ax.slots, rows: 1, sig: sig + '/' + still, draw: () => richBar(slots, ax, ink, still), alt: ' ' })
142 return
143 }
144 slots.forEach((s, i) => {
145 const cx = x + 2 + i
146 if (s === undefined) g.put(cx, y + 2, '·', mix(P.panel, P.dim, 0.35))
147 else if (s.state === 'quiet') return
148 else {
149 const n = s.count === 0 ? 1 : Math.max(2, Math.round((s.count / ax.peak) * 8))
150 g.put(cx, y + 2, String.fromCharCode(0x2580 + n), ink(P.state[s.state]))
151 }
152 })
153}
154
155/** An agent's bar as pixels: one rounded column per slot, its height the sends, its colour the state; a thin line while quiet. */
156function richBar(slots: readonly Slot[], ax: Axis, ink: (c: number) => number, bg: number): Canvas {
157 const k = pixelScale(ax.slots, 1, 4)
158 const h = 2 * k
159 const c = new Canvas(ax.slots * k, h, bg)
160 slots.forEach((s, i) => {
161 if (s === undefined || s.state === 'quiet') return
162 const color = ink(P.state[s.state])
163 const top = s.count === 0 ? h - 1.2 : h - 1 - Math.max(2, (s.count / ax.peak) * (h - 2))
164 c.rect(i * k + 0.5, top, Math.max(1, k - 1), h - top, color, 0.9)
165 })
166 return c
167}
168
169/** The lead's wakes: delivered vs held back, then a strip of the latest ones in order. */
170function leadBlock(g: Grid, scene: Scene, v: View, x: number, y: number, w: number, images: ImageLayer[]) {
171 g.shade(x, y, w, 2, P.panel)
172 g.put(x, y, '▌', P.text, P.panel)
173 g.put(x, y + 1, '▌', P.text, P.panel)
174 let cx = g.text(x + 2, y, 'LEAD', P.text)
175 cx = g.text(cx + 2, y, '▲ ' + scene.delivered + ' woken', P.accent)
176 g.text(cx + 2, y, '▽ ' + scene.held + ' held back', P.dim)
177 const sw = w - 3
178 const last = scene.wakes.slice(-sw)
179 if (v.level === 'rich') {
180 const sig = last.map(wk => (wk.held ? 'h' : 'd')).join('')
181 images.push({ key: 'wakes', x: x + 2, y: y + 1, cols: sw, rows: 1, sig, draw: () => richWakes(last, sw), alt: `lead wakes ${scene.delivered} delivered, ${scene.held} held` })
182 return
183 }
184 last.forEach((wk, i) => {
185 const glow = fade(v.age(wk.ts))
186 g.put(x + 2 + i, y + 1, wk.held ? '·' : '▮', wk.held ? mix(P.dim, 0xffffff, glow) : mix(P.accent, 0xffffff, glow))
187 })
188}
189
190/** The wake strip as pixels: one per cell, delivered wakes as tall amber ticks, held ones as low dim dots. */
191function richWakes(last: readonly { held: boolean }[], cols: number): Canvas {
192 const k = pixelScale(cols, 1, 4)
193 const h = 2 * k
194 const c = new Canvas(cols * k, h, P.panel)
195 last.forEach((wk, i) => {
196 const x = i * k + k / 2
197 if (wk.held) c.disc(x, h - k / 2, Math.max(0.6, k / 6), P.dim)
198 else c.line(x, k / 3, x, h - k / 3, Math.max(0.5, k / 8), P.accent)
199 })
200 return c
201}
202
203/** One claim card: a rounded box in its status colour, the builders, the reviewers and their verdicts. */
204function claimCard(g: Grid, scene: Scene, v: View, card: Card, x: number, y: number, w: number) {
205 const sc = P.status[card.status]
206 const glow = fade(v.age(card.at), FLASH_MS * 1.5)
207 const isDim = v.filter !== undefined && ![...card.builders, ...card.reviewers.map(r => r.name), ...card.others].includes(v.filter)
208 const ink = (c: number) => (isDim ? mix(c, P.bg, 0.65) : c)
209 const edge = ink(mix(mix(sc, P.bg, 0.35), 0xffffff, 0.5 * glow))
210 g.shade(x, y, w, 4, mix(P.bg, sc, 0.05 + 0.15 * glow))
211 g.put(x, y, '╭', edge)
212 g.put(x + w - 1, y, '╮', edge)
213 g.put(x, y + 3, '╰', edge)
214 g.put(x + w - 1, y + 3, '╯', edge)
215 for (let i = 1; i < w - 1; i++) {
216 g.put(x + i, y, '─', edge)
217 g.put(x + i, y + 3, '─', edge)
218 }
219 for (let r = 1; r < 3; r++) {
220 g.put(x, y + r, '│', edge)
221 g.put(x + w - 1, y + r, '│', edge)
222 }
223 const word = STATUS_WORD[card.status]
224 g.text(x + 2, y, ' ' + cardTitle(card.slice, w - width(word) - 8) + ' ', ink(P.text))
225 g.textRight(x + w - 2, y, ' ' + word + ' ', ink(sc))
226 let cx = x + 2
227 if (card.builders.length > 0) {
228 cx = g.text(cx, y + 1, 'build', ink(P.dim))
229 for (const b of card.builders) cx = g.text(cx + 1, y + 1, scene.label(b), ink(nameColor(P, scene, b)), undefined, x + w - 2 - cx - 1)
230 if (card.isDuplicate) g.text(cx + 1, y + 1, '⚠ dup', ink(P.status.blocked), undefined, x + w - 2 - cx - 1)
231 } else if (card.others.length > 0) {
232 cx = g.text(cx, y + 1, 'research', ink(P.dim))
233 for (const b of card.others) cx = g.text(cx + 1, y + 1, scene.label(b), ink(nameColor(P, scene, b)), undefined, x + w - 2 - cx - 1)
234 } else g.text(cx, y + 1, 'no builder', ink(P.dim))
235 cx = x + 2
236 if (card.reviewers.length > 0) {
237 cx = g.text(cx, y + 2, 'review', ink(P.dim))
238 for (const r of card.reviewers) {
239 const mark = r.state === 'approve' ? '✓' : r.state === 'block' ? '✗' : '◦'
240 cx = g.text(cx + 1, y + 2, mark, ink(r.state === 'approve' ? P.status.approved : r.state === 'block' ? P.status.blocked : P.dim), undefined, x + w - 2 - cx - 1)
241 cx = g.text(cx, y + 2, scene.label(r.name), ink(nameColor(P, scene, r.name)), undefined, x + w - 2 - cx)
242 }
243 }
244}
245
246/** The message list: newest first, the one in flight lit, names in full. */
247function ticker(g: Grid, scene: Scene, v: View, x: number, y: number, w: number, rows: number) {
248 g.text(x, y, spaced('MESSAGES'), P.dim)
249 const all = sends(scene).filter(m => touches(v.filter, m.from, m.to))
250 g.textRight(x + w, y, `${all.length} msgs${v.filter ? ' · ' + scene.label(v.filter) : ''}`, P.dim)
251 const shown = all.slice(-(rows - 1)).reverse()
252 const nameW = Math.max(4, ...shown.flatMap(m => [width(scene.label(m.from)), width(scene.label(m.to))]))
253 shown.forEach((m, i) => {
254 const r = y + 1 + i
255 const t = v.age(m.ts) / PULSE_MS
256 const lit = t >= 0 && t < 1 ? 1 - t : 0
257 if (lit > 0) g.shade(x - 1, r, w + 2, 1, mix(P.bg, mix(P.panel, P.accent, 0.25), lit))
258 let cx = x
259 if (w >= 60) cx = g.text(cx, r, timeOfDay(m.ts), mix(P.dim, P.text, lit)) + 2
260 cx = g.text(cx, r, scene.label(m.from).padEnd(nameW), nameColor(P, scene, m.from))
261 cx = g.text(cx + 1, r, '→', lit > 0 ? P.accent : P.dim)
262 cx = g.text(cx + 2, r, scene.label(m.to).padEnd(nameW), nameColor(P, scene, m.to))
263 const type = m.type ?? ''
264 cx = g.text(cx + 1, r, type.padEnd(8).slice(0, 8), typeColor(m.type))
265 g.text(cx, r, m.summary.replace(/^\s*\[[A-Z]+\]\s*/, ''), mix(P.dim, P.text, 0.55 + 0.45 * lit), undefined, x + w - cx)
266 })
267}
268
269/** Look A at the view's size. */
270export function lookA(scene: Scene, v: View): Frame {
271 const g = new Grid(v.cols, v.rows, P.bg)
272 const images: ImageLayer[] = []
273 const narrow = v.cols < 72
274 header(g, v, narrow)
275 const agents = scene.order()
276 const cards = v.isEnded === true ? [] : scene.cards()
277 if (narrow) {
278 let y = 2
279 const w = g.cols - 2
280 const ax = axisOf(scene, v, w - 3)
281 for (const a of agents) {
282 if (y + 2 > g.rows - 6) break
283 agentCard(g, scene, v, a, 1, y, w, ax, images)
284 y += 3
285 }
286 leadBlock(g, scene, v, 1, y, w, images)
287 y += 3
288 for (const c of cards.slice(0, 3)) {
289 if (y >= g.rows - 4) break
290 g.text(1, y, '■', P.status[c.status])
291 const cx = g.text(3, y, cardTitle(c.slice, Math.floor(w / 2)), P.text)
292 g.text(cx + 1, y, c.builders.map(b => scene.label(b)).join(' ') || STATUS_WORD[c.status].toLowerCase(), P.dim, undefined, w - cx)
293 y++
294 }
295 ticker(g, scene, v, 1, y + 1, w, g.rows - y - 1)
296 return { grid: g, images, links: cardLinks(cards) }
297 }
298 const leftW = Math.max(34, Math.min(46, Math.floor(v.cols * 0.42)))
299 const rightX = leftW + 3
300 const rightW = v.cols - rightX - 1
301 g.text(1, 2, spaced('AGENTS'), P.dim)
302 const word = v.isEnded === true ? 'ended' : 'running'
303 g.textRight(leftW + 1, 2, `${agents.length} · ${agents.filter(a => stateIn(scene, a.name, v) === word).length} ${word}`, P.dim)
304 g.text(rightX, 2, spaced('CLAIMS'), P.dim)
305 const tickerRows = Math.max(5, Math.min(12, Math.floor(v.rows * 0.3)))
306 const bodyBottom = v.rows - tickerRows - 1
307 const gap = 3 + 3 * agents.length + 3 + agents.length <= bodyBottom ? 1 : 0
308 let y = 3
309 const ax = axisOf(scene, v, leftW - 3)
310 for (const a of agents) {
311 if (y + 3 > bodyBottom - 2) break
312 agentCard(g, scene, v, a, 1, y, leftW, ax, images)
313 y += 3 + gap
314 }
315 leadBlock(g, scene, v, 1, y, leftW, images)
316 const cardCols = rightW >= 54 ? 2 : 1
317 const cardW = Math.floor((rightW - (cardCols - 1)) / cardCols)
318 let cy = 3
319 let col = 0
320 for (const c of cards) {
321 if (cy + 4 > bodyBottom) break
322 claimCard(g, scene, v, c, rightX + col * (cardW + 1), cy, cardW)
323 col++
324 if (col === cardCols) {
325 col = 0
326 cy += 4
327 }
328 }
329 if (cards.length === 0) g.text(rightX, 3, v.isEnded === true ? 'claims cleared: the team ended' : 'no claims yet', P.dim)
330 for (let x = 1; x < v.cols - 1; x++) g.put(x, bodyBottom, '─', P.rule)
331 ticker(g, scene, v, 1, bodyBottom + 1, v.cols - 2, tickerRows)
332 return { grid: g, images, links: cardLinks(cards) }
333}
334
335hooks/look-b.ts 804 lines1// Look B, "constellation": the team as a star map around the lead, messages as comets along its edges.
2
3import type { ImageSource } from 'claude-code'
4import { ago } from './core'
5
6import { Canvas } from './pixels'
7import { pngSource } from './png'
8import { cardTitle } from './replay'
9import type { AgentState, Scene, SceneAgent, SceneMessage } from './replay'
10import {
11 FLASH_MS,
12 Grid,
13 MODEL_GLYPH,
14 agentColor,
15 cardLinks,
16 fade,
17 mix,
18 nameColor,
19 palette,
20 SKY_BUDGET,
21 SKY_MIN_MS,
22 CELL_PX,
23 sends,
24 spaced,
25 stateIn,
26 timeOfDay,
27 touches,
28 width,
29} from './looks'
30import type { Frame, ImageLayer, SkyScene, View } from './looks'
31
32export const NEBULA = palette({
33 name: 'Nebula',
34 bg: '#07060E',
35 panel: '#0E0C1F',
36 rule: '#2A2547',
37 text: '#EDE9FF',
38 dim: '#6E6890',
39 accent: '#FFE6A8',
40 agents: ['#FF6FA3', '#FFC65C', '#5FE3E0', '#9D8CFF', '#7CF5A0', '#FF9D5C', '#5CA8FF', '#F2F07A'],
41 state: { running: '#EDE9FF', idle: '#6E6890', waiting: '#5FE3E0', stalled: '#FF4F6D', quiet: '#2A2547', ended: '#6E6890' },
42 status: { unclaimed: '#6E6890', open: '#9D8CFF', building: '#FFC65C', done: '#5FE3E0', approved: '#7CF5A0', blocked: '#FF4F6D' },
43 model: { opus: '#C9B8FF', sonnet: '#8FD3FF', haiku: '#A8F0C0', fable: '#FFE6A8' },
44})
45
46const P = NEBULA
47const LEAD = 'team-lead'
48const MEMBRANE = 0x5a51a0
49const STATE_GLYPH: Record<AgentState, string> = { running: '◉', idle: '○', waiting: '◌', stalled: '⊗', quiet: '·', ended: '○' }
50
51type Node = { name: string; x: number; y: number; side: 'left' | 'right'; labelRow: number }
52
53/**
54 * Where each agent sits: evenly round an ellipse about the centre, turned a little off the axes so no two line up; in cell
55 * units. An agent left of the centre has its label in the left margin, the rest in the right.
56 */
57function layout(agents: readonly SceneAgent[], cx: number, cy: number, rx: number, ry: number): Node[] {
58 const n = agents.length
59 const start = -Math.PI / 2 - Math.PI / Math.max(1, n) + 0.3
60 return agents.map((a, k) => {
61 const ang = start + (k * 2 * Math.PI) / Math.max(1, n)
62 const x = cx + Math.cos(ang) * rx
63 return { name: a.name, x, y: cy + Math.sin(ang) * ry, side: x < cx ? 'left' : 'right', labelRow: 0 }
64 })
65}
66
67/** Spreads the label rows of one side so no two sit closer than `gap`, keeping them in order and inside [top, bottom]. */
68function spreadLabels(nodes: Node[], top: number, bottom: number, gap: number) {
69 for (const side of ['left', 'right'] as const) {
70 const list = nodes.filter(n => n.side === side).sort((a, b) => a.y - b.y)
71 let next = top
72 for (const n of list) {
73 n.labelRow = Math.max(next, Math.round(n.y))
74 next = n.labelRow + gap
75 }
76 let last = bottom
77 for (const n of [...list].reverse()) {
78 n.labelRow = Math.min(n.labelRow, last)
79 last = n.labelRow - gap
80 }
81 }
82}
83
84/** A smooth in-out easing of 0 → 1. */
85function ease(t: number): number {
86 return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
87}
88
89/** A deterministic pseudo-random 0..1 for a position: the star field never flickers between frames. */
90function hash(x: number, y: number): number {
91 let h = (x * 374761393 + y * 668265263) ^ 0x5bd1e995
92 h = Math.imul(h ^ (h >>> 13), 1274126177)
93 return ((h ^ (h >>> 16)) >>> 0) / 4294967296
94}
95
96type Geometry = { gx: number; gy: number; gw: number; gh: number; cx: number; cy: number; membrane: number; nodes: Node[] }
97
98function geometry(scene: Scene, v: View, wide: boolean): Geometry {
99 const margin = wide ? Math.min(18, Math.floor(v.cols * 0.2)) : 0
100 const legend = wide ? 0 : Math.ceil(scene.agents.size / 2)
101 const gx = margin
102 const gy = 1
103 const gw = v.cols - 2 * margin
104 const gh = Math.max(6, v.rows - 5 - legend)
105 const cx = Math.floor(gx + gw / 2) + 0.5
106 const cy = Math.floor(gy + gh / 2) + 0.5
107 const rx = Math.max(4, gw / 2 - 4)
108 const ry = Math.max(2, gh / 2 - 1.2)
109 const nodes = layout(scene.order(), cx, cy, rx, ry).map(n => ({ ...n, x: Math.floor(n.x) + 0.5, y: Math.floor(n.y) + 0.5 }))
110 spreadLabels(nodes, gy, gy + gh - 2, 2)
111 return { gx, gy, gw, gh, cx, cy, membrane: Math.max(2.5, Math.min(rx, ry * 2) * 0.24), nodes }
112}
113
114function pos(geo: Geometry, name: string): { x: number; y: number } | undefined {
115 if (name === LEAD || name === 'main' || name === 'lead') return { x: geo.cx, y: geo.cy }
116 return geo.nodes.find(n => n.name === name)
117}
118
119/** The weight of each edge, both directions together, as 0..1 of the busiest. */
120function edgeWeights(scene: Scene): Map<string, { a: string; b: string; w: number }> {
121 const out = new Map<string, { a: string; b: string; w: number }>()
122 for (const [key, n] of scene.edges) {
123 const [a, b] = key.split('>') as [string, string]
124 const k = a < b ? a + '|' + b : b + '|' + a
125 const e = out.get(k) ?? { a, b, w: 0 }
126 e.w += n
127 out.set(k, e)
128 }
129 const peak = Math.max(1, ...[...out.values()].map(e => e.w))
130 for (const e of out.values()) e.w /= peak
131 return out
132}
133
134/** Where a message travels: sender to receiver, stopping at the membrane (`stop` < 1) for one the lead filter holds back. */
135function pathOf(geo: Geometry, from: string, to: string, held: boolean | undefined) {
136 const a = pos(geo, from)
137 const b = pos(geo, to)
138 if (a === undefined || b === undefined) return undefined
139 if (held !== true) return { a, b, stop: 1 }
140 const len = Math.hypot((b.x - a.x) * 2, (b.y - a.y) * 4)
141 return { a, b, stop: Math.max(0.1, 1 - (geo.membrane * 2) / Math.max(1, len)) }
142}
143
144/** Colour channels are rounded to this step before a basic-sky cell is written, so a frame stays under the Raster's 1024 colour pairs. */
145const QUANT = 8
146/** The most labelled flights at once on the basic sky. */
147export const WORDS_MAX = 6
148/** How long a held comet's splash on the membrane and a delivered comet's flare at its receiver last, in real ms. */
149const SPLASH_MS = 400
150const FLARE_MS = 300
151/** The pixels of a comet's trail on the basic sky: a few cells, one pixel thick. */
152const TRAIL = 6
153
154function quant(c: number): number {
155 const q = (s: number) => Math.min(255, Math.round(((c >> s) & 255) / QUANT) * QUANT)
156 return (q(16) << 16) | (q(8) << 8) | q(0)
157}
158
159function luma(c: number): number {
160 return 0.3 * ((c >> 16) & 255) + 0.59 * ((c >> 8) & 255) + 0.11 * (c & 255)
161}
162
163/** A point of the quadratic curve a → b bent toward `c` by `bend`. */
164function curveAt(a: { x: number; y: number }, b: { x: number; y: number }, c: { x: number; y: number }, t: number, bend = 0.18): { x: number; y: number } {
165 const mx = (a.x + b.x) / 2
166 const my = (a.y + b.y) / 2
167 const qx = mx + (c.x - mx) * bend
168 const qy = my + (c.y - my) * bend
169 return { x: (1 - t) * (1 - t) * a.x + 2 * (1 - t) * t * qx + t * t * b.x, y: (1 - t) * (1 - t) * a.y + 2 * (1 - t) * t * qy + t * t * b.y }
170}
171
172/** The fraction of a flight (0 → 1) at which its eased head reaches `stop`. */
173function arrivalT(stop: number): number {
174 let lo = 0
175 let hi = 1
176 for (let i = 0; i < 24; i++) {
177 const mid = (lo + hi) / 2
178 if (ease(mid) < stop) lo = mid
179 else hi = mid
180 }
181 return hi
182}
183
184/** The word a message flies as: its type, else the bracketed tag of its summary, else `msg`. */
185function wordOf(m: SceneMessage): string {
186 return (m.type ?? /^\s*\[([A-Z]+)\]/.exec(m.summary)?.[1] ?? 'msg').toLowerCase()
187}
188
189/**
190 * Look B's basic sky: the sky as a bitmap of half cells, `▀` with a 24-bit colour above and below, glow from
191 * the colour of an area; braille only for curved edges and the membrane. Each message flies as a comet with its type
192 * riding quietly behind the head, a burst (one sender, one word) bundled as `claim ×5`, at most `WORDS_MAX` words at
193 * once. Its edge lights up behind it, a pair's first edge growing with its first comet. A comet the lead filter holds
194 * stops at the membrane with a splash; one that lands flares its receiver. Returns how long, in real ms, the cells still
195 * move by themselves.
196 */
197function drawBasicSky(g: Grid, scene: Scene, v: View, geo: Geometry, wide: boolean): number {
198 const W = geo.gw
199 const H = geo.gh * 2
200 const pt = (p: { x: number; y: number }) => ({ x: p.x - geo.gx, y: (p.y - geo.gy) * 2 })
201 const centre = pt({ x: geo.cx, y: geo.cy })
202 const c = new Canvas(W, H, P.bg)
203 c.glow(centre.x, centre.y, Math.min(W, H) * 0.5, 0x120f2e, 1.0)
204 const hs = Math.max(0.65, Math.min(1, geo.gw / 64))
205
206 type Fly = { m: SceneMessage; age: number; a: { x: number; y: number }; b: { x: number; y: number }; stop: number; color: number }
207 const flying: Fly[] = []
208 let motion = 0
209 for (let i = scene.messages.length - 1; i >= 0; i--) {
210 const m = scene.messages[i]!
211 const age = v.age(m.ts)
212 if (age >= FLIGHT_MS + ARRIVAL_MS) break
213 if (m.kind !== 'send' || age < 0) continue
214 const path = pathOf(geo, m.from, m.to, m.held)
215 if (path === undefined) continue
216 motion = Math.max(motion, FLIGHT_MS + ARRIVAL_MS - age)
217 flying.push({ m, age, a: path.a, b: path.b, stop: path.stop, color: nameColor(P, scene, m.from) })
218 }
219 flying.reverse()
220 const word = new Map<SceneMessage, string>()
221 const bursts: { from: string; w: string; ts: number; n: number; lead: SceneMessage }[] = []
222 for (const f of flying) {
223 const w = wordOf(f.m)
224 const b = bursts.find(x => x.from === f.m.from && x.w === w && f.m.ts - x.ts < BURST_MS)
225 if (b === undefined) bursts.push({ from: f.m.from, w, ts: f.m.ts, n: 1, lead: f.m })
226 else {
227 b.n++
228 if (f.m.to === LEAD || f.m.to === 'main') b.lead = f.m
229 }
230 }
231 for (const b of bursts.sort((x, y) => y.ts - x.ts).slice(0, WORDS_MAX)) word.set(b.lead, b.w + (b.n > 1 ? ' ×' + b.n : ''))
232
233 const flare = new Map<string, number>()
234 type Head = { x: number; y: number; dir: number; word: string; color: number }
235 const heads: Head[] = []
236 const rests: { x: number; y: number; word: string; color: number; k: number }[] = []
237 for (const f of flying) {
238 const A = pt(f.a)
239 const B = pt(f.b)
240 const t = Math.min(1, f.age / FLIGHT_MS)
241 const tLand = arrivalT(f.stop)
242 const head = Math.min(f.stop, ease(t))
243 const landedFor = f.age - tLand * FLIGHT_MS
244 if (landedFor < 0) {
245 const p = curveAt(A, B, centre, head)
246 // One pixel of head with a faint halo under a cell; a one-pixel trail of at most TRAIL pixels, fading fast.
247 const length = Math.max(1, Math.hypot(B.x - A.x, B.y - A.y))
248 for (let k = TRAIL; k >= 1; k--) {
249 const tt = head - k / length
250 if (tt < 0.02) continue
251 const q = curveAt(A, B, centre, tt)
252 c.blend(Math.floor(q.x), Math.floor(q.y), f.color, 0.75 * (1 - k / (TRAIL + 1)) ** 2)
253 }
254 c.glow(p.x, p.y, 0.6, f.color, 0.35)
255 c.blend(Math.floor(p.x), Math.floor(p.y), mix(f.color, 0xffffff, 0.7), 1)
256 heads.push({ x: p.x, y: p.y, dir: Math.sign(B.x - A.x) || 1, word: word.get(f.m) ?? '', color: f.color })
257 } else if (f.m.held === true) {
258 if (landedFor < SPLASH_MS) {
259 const k = 1 - landedFor / SPLASH_MS
260 const p = curveAt(A, B, centre, f.stop)
261 c.glow(p.x, p.y, 0.6 + 0.6 * (1 - k), mix(MEMBRANE, 0xffffff, 0.5 * k), 0.9 * k)
262 }
263 } else {
264 if (landedFor < FLARE_MS) flare.set(f.m.to === 'main' ? LEAD : f.m.to, Math.max(flare.get(f.m.to) ?? 0, 1 - landedFor / FLARE_MS))
265 const w = word.get(f.m)
266 if (!wide && w !== undefined && landedFor < ARRIVAL_MS) rests.push({ x: B.x, y: B.y, word: w, color: f.color, k: 1 - landedFor / ARRIVAL_MS })
267 }
268 }
269
270 const sun = flare.get(LEAD) ?? 0
271 // At rest the lead is one calm pixel with a faint halo under a cell; a delivered wake flares it, within two cells, for FLARE_MS.
272 c.glow(centre.x, centre.y, 0.6 + 1.0 * sun, P.accent, 0.25 + 0.65 * sun)
273 if (sun > 0) c.blend(Math.floor(centre.x), Math.floor(centre.y), mix(P.accent, 0xffffff, 0.5), sun)
274 const halos: { n: Node; state: AgentState; color: number; lit: number; bump: number }[] = []
275 for (const n of geo.nodes) {
276 const a = scene.agents.get(n.name)!
277 const state = stateIn(scene, a.name, v)
278 const color = state === 'stalled' ? P.state.stalled : agentColor(P, a)
279 const lit = v.filter !== undefined && v.filter !== n.name ? 0.3 : 1
280 const seg = a.segments[a.segments.length - 1]
281 const bump = Math.max(seg === undefined ? 0 : fade(v.age(seg.from), FLASH_MS), flare.get(n.name) ?? 0)
282 halos.push({ n, state, color, lit, bump })
283 if (state === 'quiet') continue
284 const p = pt(n)
285 const r = state === 'running' ? 1.15 : state === 'stalled' ? 1.25 : state === 'waiting' ? 0.85 : 0.65
286 const a0 = state === 'idle' || state === 'ended' ? 0.2 : state === 'waiting' ? 0.35 : 0.5
287 c.glow(p.x, p.y, (r + 0.4 * bump) * hs, color, (a0 + 0.3 * bump) * lit)
288 if (state === 'running' || bump > 0) c.blend(Math.floor(p.x), Math.floor(p.y), mix(color, 0xffffff, 0.5 + 0.5 * bump), lit)
289 }
290
291 const rgb = c.rgb()
292 const at = (x: number, y: number) => {
293 const i = (y * W + x) * 3
294 return quant((rgb[i]! << 16) | (rgb[i + 1]! << 8) | rgb[i + 2]!)
295 }
296 const bright = new Uint8Array(W * geo.gh)
297 for (let y = 0; y < geo.gh; y++) for (let x = 0; x < W; x++) {
298 const top = at(x, 2 * y)
299 const bot = at(x, 2 * y + 1)
300 if (top === bot) g.put(geo.gx + x, geo.gy + y, ' ', top, top)
301 else g.put(geo.gx + x, geo.gy + y, '▀', top, bot)
302 if (Math.max(luma(top), luma(bot)) > 60) bright[y * W + x] = 1
303 }
304 const cellBg = (x: number, y: number) => g.bg[y * g.cols + x]!
305 const isBright = (x: number, y: number) => x < geo.gx || y < geo.gy || x >= geo.gx + W || y >= geo.gy + geo.gh || bright[(y - geo.gy) * W + (x - geo.gx)] === 1
306 const dot = (dx: number, dy: number, color: number, k: number) => {
307 const x = Math.floor(dx) >> 1
308 const y = Math.floor(dy) >> 2
309 if (!isBright(x, y)) g.dot(dx, dy, quant(mix(cellBg(x, y), color, k)))
310 }
311
312 for (let y = geo.gy; y < geo.gy + geo.gh; y++) for (let x = geo.gx; x < geo.gx + geo.gw; x++) {
313 const r = hash(x, y)
314 if (r < 0.03) dot(x * 2 + ((r * 100) % 2), y * 4 + ((r * 1000) % 4), P.text, 0.2 + r * 10)
315 }
316 const rm = geo.membrane * 2
317 const ring = Math.max(12, Math.ceil(rm * 6))
318 for (let k = 0; k < ring; k++) {
319 if (Math.floor(k / 3) % 2 === 1) continue
320 const a = (k / ring) * Math.PI * 2
321 dot(geo.cx * 2 + Math.cos(a) * rm, geo.cy * 4 + Math.sin(a) * rm, mix(MEMBRANE, P.text, 0.25), 1)
322 }
323
324 // Edges as braille curves; a pair's edge whose first comet still flies shows only as far as that comet got.
325 const norm = (n: string) => (n === 'main' || n === 'lead' ? LEAD : n)
326 const pairOf = (x: string, y: string) => [norm(x), norm(y)].sort().join('|')
327 const first = new Map<string, SceneMessage>()
328 for (const m of scene.messages) if (m.kind === 'send' && !first.has(pairOf(m.from, m.to))) first.set(pairOf(m.from, m.to), m)
329 const sweep = (pa: { x: number; y: number }, pb: { x: number; y: number }, from: number, to: number, color: (t: number) => number, k: number) => {
330 const steps = Math.ceil(Math.hypot((pb.x - pa.x) * 2, (pb.y - pa.y) * 4)) + 4
331 for (let i = 0; i <= steps; i++) {
332 const t = i / steps
333 if (t < Math.max(0.06, from) || t > Math.min(0.94, to)) continue
334 const p = curveAt(pa, pb, { x: geo.cx, y: geo.cy }, t)
335 dot(p.x * 2, p.y * 4, color(t), k)
336 }
337 }
338 for (const e of edgeWeights(scene).values()) {
339 const m = first.get(pairOf(e.a, e.b))
340 const age = m === undefined ? Infinity : v.age(m.ts)
341 const young = age >= 0 && age < FLIGHT_MS
342 const [from, to] = young && norm(m!.from) !== e.a ? [e.b, e.a] : [e.a, e.b]
343 const pa = pos(geo, from)
344 const pb = pos(geo, to)
345 if (pa === undefined || pb === undefined) continue
346 const ca = nameColor(P, scene, from)
347 const cb = nameColor(P, scene, to)
348 const lit = touches(v.filter, e.a, e.b) ? 1 : 0.25
349 sweep(pa, pb, 0, young ? ease(age / FLIGHT_MS) : 1, t => mix(ca, cb, t), (0.4 + 0.6 * e.w) * lit)
350 }
351 // The edge behind a comet in flight lights up in the sender's colour, from the sender to the head.
352 for (const f of flying) {
353 const t = f.age / FLIGHT_MS
354 if (t >= 1) continue
355 sweep(f.a, f.b, 0, Math.min(f.stop, ease(t)), () => mix(f.color, 0xffffff, 0.25), 0.95)
356 }
357
358 if (geo.gx > 0) {
359 for (const n of geo.nodes) {
360 const color = agentColor(P, scene.agents.get(n.name))
361 const ink = v.filter !== undefined && v.filter !== n.name ? mix(color, P.bg, 0.6) : color
362 const edge = n.side === 'left' ? geo.gx * 2 : (geo.gx + geo.gw) * 2 - 1
363 const knee = n.side === 'left' ? n.x * 2 - 6 : n.x * 2 + 8
364 g.line(edge, n.labelRow * 4 + 2, knee, n.labelRow * 4 + 2, quant(mix(P.bg, ink, 0.3)))
365 g.line(knee, n.labelRow * 4 + 2, n.x * 2 + (n.side === 'left' ? -2 : 2), n.y * 4, quant(mix(P.bg, ink, 0.3)))
366 }
367 }
368
369 const sx = Math.floor(geo.cx)
370 const sy = Math.floor(geo.cy)
371 g.put(sx, sy, '◉', quant(mix(P.accent, 0xffffff, sun)), cellBg(sx, sy))
372 leadWakes(g, scene, v, sx, sy)
373 for (const h of halos) {
374 const x = Math.floor(h.n.x)
375 const y = Math.floor(h.n.y)
376 const fg = h.state === 'quiet' ? P.rule : mix(mix(h.color, 0xffffff, h.state === 'running' ? 0.6 : 0.2), 0xffffff, 0.5 * h.bump)
377 g.put(x, y, STATE_GLYPH[h.state], quant(h.state === 'quiet' ? P.rule : mix(P.bg, fg, h.lit)), cellBg(x, y))
378 }
379
380 // Words: quiet, behind the head; with no label margins, a landed word rests under its receiver.
381 const write = (x0: number, y: number, text: string, fg: (x: number) => number) => {
382 if (y < geo.gy || y >= geo.gy + geo.gh) return
383 for (let i = 0; i < width(text); i++) {
384 const x = x0 + i
385 if (x < geo.gx || x >= geo.gx + geo.gw) continue
386 const ch = g.ch[y * g.cols + x]!
387 if (ch === 0x2580 || ch === 32 || (ch >= 0x2800 && ch <= 0x28ff)) g.put(x, y, text[i]!, quant(fg(x)), cellBg(x, y))
388 }
389 }
390 for (const h of heads) {
391 if (h.word === '') continue
392 const hx = Math.floor(h.x) + geo.gx
393 const hy = Math.floor(h.y / 2) + geo.gy
394 const x0 = Math.max(geo.gx, Math.min(geo.gx + geo.gw - width(h.word), h.dir > 0 ? hx - 1 - width(h.word) : hx + 2))
395 write(x0, hy, h.word, x => mix(cellBg(x, hy), mix(h.color, P.text, 0.4), 0.55))
396 }
397 for (const r of rests) {
398 const hx = Math.floor(r.x) + geo.gx
399 const y = Math.floor(r.y / 2) + geo.gy + 1
400 write(Math.max(geo.gx, hx - (width(r.word) >> 1)), y, r.word, x => mix(cellBg(x, y), mix(r.color, P.text, 0.4), 0.6 * r.k))
401 }
402 return motion
403}
404
405/** The wakes beside the lead: `▲ n` delivered on its left, `▽ n` held on its right, each lit as one lands, and `LEAD` below. */
406function leadWakes(g: Grid, scene: Scene, v: View, sx: number, sy: number) {
407 const last = (held: boolean) => scene.wakes.filter(w => w.held === held).at(-1)
408 const lit = (held: boolean) => {
409 const w = last(held)
410 return w === undefined ? 0 : fade(v.age(w.ts), 1200)
411 }
412 g.textRight(sx, sy, ' ▲ ' + scene.delivered + ' ', mix(P.accent, 0xffffff, lit(false)))
413 g.text(sx + 1, sy, ' ▽ ' + scene.held + ' ', mix(P.dim, P.text, lit(true)))
414 g.text(sx - 1, sy + 1, 'LEAD', P.accent)
415}
416
417/** How long a message lights its edge on the rich sky, in real ms: three sky redraws, a step dimmer each. */
418export const LIT_MS = 3 * SKY_MIN_MS
419
420type Lit = { from: string; to: string; held?: boolean; level: number; age: number }
421
422/** The edges messages light now, by `from>to`: level 3 for one sent in the last sky interval, 2 and 1 for older; at most the newest per edge. */
423function litEdges(scene: Scene, v: View): { lit: Map<string, Lit>; fadeMs: number } {
424 const lit = new Map<string, Lit>()
425 let fadeMs = 0
426 for (let i = scene.messages.length - 1; i >= 0; i--) {
427 const m = scene.messages[i]!
428 const age = v.age(m.ts)
429 if (age >= LIT_MS) break
430 if (m.kind !== 'send' || age < 0) continue
431 fadeMs = Math.max(fadeMs, LIT_MS - age)
432 const key = m.from + '>' + m.to
433 const level = 3 - Math.floor(age / SKY_MIN_MS)
434 if ((lit.get(key)?.level ?? 0) < level) lit.set(key, { from: m.from, to: m.to, ...(m.held === undefined ? {} : { held: m.held }), level, age })
435 }
436 return { lit, fadeMs }
437}
438
439/** How long a comet takes from sender to receiver, in real ms. */
440export const FLIGHT_MS = 1_000
441/** How long a burst of messages animates the sky at most, in real ms: the flight and a short landing. */
442export const BURST_MS = 1_200
443type Flight = { t: number; comets: SceneMessage[] }
444
445/**
446 * The burst in flight now: messages played within `BURST_MS` of the burst's first, which started less than `BURST_MS`
447 * ago; all its comets fly together, `t` of the way (0 → 1 over `FLIGHT_MS`). Undefined when none flies.
448 */
449function flightOf(scene: Scene, v: View): Flight | undefined {
450 const recent: { m: SceneMessage; age: number }[] = []
451 for (let i = scene.messages.length - 1; i >= 0; i--) {
452 const m = scene.messages[i]!
453 const age = v.age(m.ts)
454 if (age >= 5 * BURST_MS) break
455 if (m.kind === 'send' && age >= 0) recent.push({ m, age })
456 }
457 let start: number | undefined
458 let comets: SceneMessage[] = []
459 for (const r of recent.reverse()) {
460 if (start === undefined || start - r.age >= BURST_MS) {
461 start = r.age
462 comets = []
463 }
464 comets.push(r.m)
465 }
466 if (start === undefined || start >= BURST_MS) return undefined
467 return { t: Math.min(1, start / FLIGHT_MS), comets }
468}
469
470/** What the PNG sky is drawn again for: the agents and their states, edge weights in quarters, the filter; lit edges and wakes wait for those. */
471function skySig(scene: Scene, v: View, geo: Geometry): string {
472 const states = scene.order().map(a => a.name + ':' + stateIn(scene, a.name, v)).join(',')
473 const edges = [...edgeWeights(scene).entries()].map(([k, e]) => k + Math.ceil(e.w * 4)).sort().join(',')
474 return [geo.gw, geo.gh, states, edges, v.filter ?? ''].join('/')
475}
476
477/** The least real time between two PNG skies, the fallback without the renderer: each costs the mod tens of ms. */
478export const FALLBACK_SKY_MS = 10_000
479
480/** The most pixels a sky is drawn with: a pane larger than about 1100 × 900 pixels gets a scaled-down sky. */
481const SKY_MAX_PX = 1_000_000
482
483let kept: { key: string; canvas: Canvas } | undefined
484
485/** The sky's still backdrop, its glow and stars, kept for the last size and centre. */
486function backdrop(W: number, H: number, ccx: number, ccy: number, u: number): Canvas {
487 const key = [W, H, ccx, ccy, u].join('/')
488 if (kept?.key === key) return kept.canvas
489 const c = new Canvas(W, H, P.bg)
490 c.glow(ccx, ccy, Math.min(W, H) * 0.45, 0x120f2e, 0.9)
491 for (let i = 0; i < (W * H) / (900 * u * u); i++) {
492 const b = hash(i, 29)
493 c.disc(hash(i, 7) * W, hash(i, 13) * H, (0.35 + b * 0.5) * u, mix(P.bg, P.text, 0.15 + b * 0.45))
494 }
495 kept = { key, canvas: c }
496 return c
497}
498
499/** The sky as a still picture: stars, edges, the membrane, the lead with its wakes as rays (delivered) and beads (held), the agents. */
500function drawRichSky(scene: Scene, v: View, geo: Geometry, lit: Map<string, Lit>, scale = 1): Canvas {
501 const kx = CELL_PX.w * scale
502 const ky = CELL_PX.h * scale
503 /** Pixel lengths below are given at 6 pixels a column; `u` scales them to this picture. */
504 const u = Math.max(0.4, kx / 6)
505 /** Nodes and the lead keep a size they read at. */
506 const nu = Math.max(0.75, u)
507 const W = geo.gw * kx
508 const H = geo.gh * ky
509 const px = (x: number) => (x - geo.gx) * kx
510 const py = (y: number) => (y - geo.gy) * ky
511 const ccx = px(Math.floor(geo.cx) + 0.5)
512 const ccy = py(Math.floor(geo.cy) + 0.5)
513 const c = backdrop(W, H, ccx, ccy, u).clone()
514 const rm = geo.membrane * kx
515 c.circle(ccx, ccy, rm, 0.7 * u, MEMBRANE, 0.9)
516 const node = (p: { x: number; y: number }) => ({ x: px(Math.floor(p.x) + 0.5), y: py(Math.floor(p.y) + 0.5) })
517 const bend = (a: { x: number; y: number }, b: { x: number; y: number }) => {
518 const mx = (a.x + b.x) / 2
519 const my = (a.y + b.y) / 2
520 return { x: mx + (ccx - mx) * 0.18, y: my + (ccy - my) * 0.18 }
521 }
522 for (const e of edgeWeights(scene).values()) {
523 const pa = pos(geo, e.a)
524 const pb = pos(geo, e.b)
525 if (pa === undefined || pb === undefined) continue
526 const a = node(pa)
527 const b = node(pb)
528 const lit = touches(v.filter, e.a, e.b) ? 1 : 0.2
529 const q = bend(a, b)
530 c.curve(a.x, a.y, q.x, q.y, b.x, b.y, (0.5 + 1.6 * e.w) * u, mix(nameColor(P, scene, e.a), nameColor(P, scene, e.b), 0.5), (0.08 + 0.3 * e.w) * lit, 0, 1, true)
531 }
532 if (geo.gx > 0) {
533 for (const n of geo.nodes) {
534 const color = agentColor(P, scene.agents.get(n.name))
535 const lit = v.filter !== undefined && v.filter !== n.name ? 0.3 : 1
536 const ex = n.side === 'left' ? 0 : W
537 const ey = py(n.labelRow + 0.5)
538 const p = node(n)
539 const kn = n.side === 'left' ? p.x - 2 * kx : p.x + 2 * kx
540 c.line(ex, ey, kn, ey, 0.45 * u, color, 0.35 * lit)
541 c.line(kn, ey, p.x, p.y, 0.45 * u, color, 0.35 * lit)
542 }
543 }
544 const rays = Math.min(scene.delivered, 24)
545 for (let i = 0; i < rays; i++) {
546 const a = -Math.PI / 2 + (i * 2 * Math.PI) / 24
547 c.line(ccx + Math.cos(a) * rm * 0.45, ccy + Math.sin(a) * rm * 0.45, ccx + Math.cos(a) * rm * 0.8, ccy + Math.sin(a) * rm * 0.8, 0.5 * u, P.accent, 0.9)
548 }
549 const beads = Math.min(scene.held, 24)
550 for (let i = 0; i < beads; i++) {
551 const a = -Math.PI / 2 + (i * 2 * Math.PI) / 24
552 c.disc(ccx + Math.cos(a) * rm, ccy + Math.sin(a) * rm, 1.1 * u, P.dim)
553 }
554 for (const l of lit.values()) {
555 const path = pathOf(geo, l.from, l.to, l.held)
556 if (path === undefined) continue
557 const color = nameColor(P, scene, l.from)
558 const a = node(path.a)
559 const b = node(path.b)
560 const q = bend(a, b)
561 c.curve(a.x, a.y, q.x, q.y, b.x, b.y, 1.2 * u, color, 0.3, 0, path.stop, true)
562 const t = Math.min(path.stop, 0.9)
563 const hx = (1 - t) * (1 - t) * a.x + 2 * (1 - t) * t * q.x + t * t * b.x
564 const hy = (1 - t) * (1 - t) * a.y + 2 * (1 - t) * t * q.y + t * t * b.y
565 c.glow(hx, hy, 6 * nu, color, 0.8)
566 c.disc(hx, hy, 1.6 * nu, 0xffffff)
567 }
568 c.glow(ccx, ccy, 9 * nu, P.accent, 0.7)
569 c.disc(ccx, ccy, 2.6 * nu, 0xffffff)
570 for (const n of geo.nodes) {
571 const a = scene.agents.get(n.name)!
572 const state = stateIn(scene, a.name, v)
573 const color = state === 'stalled' ? P.state.stalled : agentColor(P, a)
574 const lit = v.filter !== undefined && v.filter !== n.name ? 0.3 : 1
575 const { x: nx, y: ny } = node(n)
576 if (state === 'quiet') {
577 c.circle(nx, ny, 3 * nu, 0.5 * nu, P.rule)
578 continue
579 }
580 const halo = state === 'running' ? 9 : state === 'stalled' ? 8 : 5
581 c.glow(nx, ny, halo * nu, color, (state === 'idle' || state === 'ended' ? 0.25 : 0.6) * lit)
582 if (state === 'waiting') {
583 for (let k = 0; k < 12; k += 2) {
584 const a0 = (k * Math.PI) / 6
585 const a1 = ((k + 1) * Math.PI) / 6
586 c.line(nx + Math.cos(a0) * 7 * nu, ny + Math.sin(a0) * 7 * nu, nx + Math.cos(a1) * 7 * nu, ny + Math.sin(a1) * 7 * nu, 0.6 * nu, color, lit)
587 }
588 }
589 if (state === 'stalled') c.circle(nx, ny, 7 * nu, 0.9 * nu, P.state.stalled, lit)
590 if (state === 'idle' || state === 'ended') c.circle(nx, ny, 4 * nu, 0.7 * nu, color, 0.8 * lit)
591 else c.disc(nx, ny, 3 * nu, mix(color, 0xffffff, 0.55), lit)
592 }
593 return c
594}
595
596/**
597 * The sky as a PNG at a cell's pixel size, at most `SKY_MAX_PX` pixels; one past `SKY_BUDGET` is drawn again at the
598 * scale its size says fits, a tenth under, until it fits. Never skipped.
599 */
600function skySource(scene: Scene, v: View, geo: Geometry, lit: Map<string, Lit>): ImageSource {
601 let scale = Math.min(1, Math.sqrt(SKY_MAX_PX / (geo.gw * CELL_PX.w * geo.gh * CELL_PX.h)))
602 for (;;) {
603 const c = drawRichSky(scene, v, geo, lit, scale)
604 const src = pngSource(c.rgb(), c.w, c.h)
605 if (src.png.length <= SKY_BUDGET || c.w * c.h <= 64) return src
606 scale *= Math.sqrt(SKY_BUDGET / src.png.length) * 0.9
607 }
608}
609
610const rgbOf = (c: number): [number, number, number] => [(c >> 16) & 255, (c >> 8) & 255, c & 255]
611
612/**
613 * The sky as a scene for the renderer process, in the pixels of the still sky at the scale its PNG would start at: the
614 * same edges, leader lines, rays, beads, agents and lit edges, and the burst in flight as comets that set off together.
615 */
616function skyScene(scene: Scene, v: View, geo: Geometry, lit: Map<string, Lit>, flight: Flight | undefined): SkyScene {
617 const scale = Math.min(1, Math.sqrt(SKY_MAX_PX / (geo.gw * CELL_PX.w * geo.gh * CELL_PX.h)))
618 const kx = CELL_PX.w * scale
619 const ky = CELL_PX.h * scale
620 const u = Math.max(0.4, kx / 6)
621 const W = Math.max(1, Math.floor(geo.gw * kx))
622 const H = Math.max(1, Math.floor(geo.gh * ky))
623 const node = (p: { x: number; y: number }): [number, number] => [(Math.floor(p.x) + 0.5 - geo.gx) * kx, (Math.floor(p.y) + 0.5 - geo.gy) * ky]
624 const centre = node({ x: geo.cx, y: geo.cy })
625 const bend = (a: [number, number], b: [number, number]): [number, number] => {
626 const mx = (a[0] + b[0]) / 2
627 const my = (a[1] + b[1]) / 2
628 return [mx + (centre[0] - mx) * 0.18, my + (centre[1] - my) * 0.18]
629 }
630 const line = (from: string, to: string, held: boolean | undefined, age: number) => {
631 const path = pathOf(geo, from, to, held)
632 if (path === undefined) return []
633 const a = node(path.a)
634 const b = node(path.b)
635 const toLead = to === LEAD || to === 'main' || to === 'lead'
636 return [{ a, q: bend(a, b), b, stop: path.stop, rgb: rgbOf(nameColor(P, scene, from)), age, ...(toLead ? { lead: true, held: held === true } : {}) }]
637 }
638 const norm = (n: string) => (n === 'main' || n === 'lead' ? LEAD : n)
639 const pairOf = (x: string, y: string) => [norm(x), norm(y)].sort().join('|')
640 const first = new Map<string, SceneMessage>()
641 for (const m of scene.messages) if (m.kind === 'send' && !first.has(pairOf(m.from, m.to))) first.set(pairOf(m.from, m.to), m)
642 const edges = [...edgeWeights(scene).values()].flatMap(e => {
643 const m = first.get(pairOf(e.a, e.b))
644 const age = m === undefined ? Infinity : v.age(m.ts)
645 const young = age >= 0 && age < FLIGHT_MS
646 const [from, to] = young && norm(m!.from) !== e.a ? [e.b, e.a] : [e.a, e.b]
647 const pa = pos(geo, from)
648 const pb = pos(geo, to)
649 if (pa === undefined || pb === undefined) return []
650 const a = node(pa)
651 const b = node(pb)
652 return [{ a, q: bend(a, b), b, w: Math.ceil(e.w * 4) / 4, rgb: rgbOf(mix(nameColor(P, scene, e.a), nameColor(P, scene, e.b), 0.5)), lit: touches(v.filter, e.a, e.b) ? 1 : 0.2, ...(young ? { age } : {}) }]
653 })
654 const dimmed = (name: string) => (v.filter !== undefined && v.filter !== name ? 0.3 : 1)
655 const leaders = geo.gx === 0 ? [] : geo.nodes.map(n => {
656 const p = node(n)
657 const ey = (n.labelRow + 0.5 - geo.gy) * ky
658 const kn = n.side === 'left' ? p[0] - 2 * kx : p[0] + 2 * kx
659 return { pts: [[n.side === 'left' ? 0 : W, ey], [kn, ey], p] as [number, number][], rgb: rgbOf(agentColor(P, scene.agents.get(n.name))), lit: dimmed(n.name) }
660 })
661 const nodes = geo.nodes.map(n => {
662 const state = stateIn(scene, n.name, v)
663 const [x, y] = node(n)
664 return { x, y, rgb: rgbOf(state === 'stalled' ? P.state.stalled : agentColor(P, scene.agents.get(n.name))), state, lit: dimmed(n.name) }
665 })
666 return {
667 w: W,
668 h: H,
669 u,
670 nu: Math.max(0.75, u),
671 centre,
672 membrane: geo.membrane * kx,
673 ground: rgbOf(P.bg),
674 haze: rgbOf(0x120f2e),
675 star: rgbOf(P.text),
676 membraneRgb: rgbOf(MEMBRANE),
677 accent: rgbOf(P.accent),
678 dim: rgbOf(P.dim),
679 rule: rgbOf(P.rule),
680 stalled: rgbOf(P.state.stalled),
681 edges,
682 leaders,
683 rays: Math.min(scene.delivered, 24),
684 beads: Math.min(scene.held, 24),
685 lit: [...lit.values()].flatMap(l => line(l.from, l.to, l.held, l.age)),
686 comets: (flight?.comets ?? []).flatMap(m => line(m.from, m.to, m.held, flight!.t * FLIGHT_MS)),
687 nodes,
688 litMs: LIT_MS,
689 flightMs: FLIGHT_MS,
690 landMs: BURST_MS,
691 }
692}
693
694/** How long a landed message's type shows under its receiver's name, in real ms. */
695export const ARRIVAL_MS = 1_500
696
697/** The types of the messages that landed at `name` in the last `ARRIVAL_MS`, a burst bundled: `claim ×3 · handoff`. */
698function arrivals(scene: Scene, v: View, name: string): string {
699 const counts = new Map<string, number>()
700 for (let i = scene.messages.length - 1; i >= 0; i--) {
701 const m = scene.messages[i]!
702 const age = v.age(m.ts)
703 if (age >= 5 * BURST_MS + ARRIVAL_MS) break
704 if (m.kind !== 'send' || m.to !== name || age < FLIGHT_MS || age >= FLIGHT_MS + ARRIVAL_MS) continue
705 const type = (m.type ?? 'message').toLowerCase()
706 counts.set(type, (counts.get(type) ?? 0) + 1)
707 }
708 return [...counts].reverse().map(([t, n]) => (n > 1 ? `${t} ×${n}` : t)).join(' · ')
709}
710
711/** Look B at the view's size. */
712export function lookB(scene: Scene, v: View): Frame {
713 const g = new Grid(v.cols, v.rows, P.bg)
714 const images: ImageLayer[] = []
715 const wide = v.cols >= 72
716 const geo = geometry(scene, v, wide)
717 g.text(1, 0, '◎', P.accent)
718 const badge = v.isEnded === true ? `■ ended · ran ${ago(v.runTo - v.runFrom)}` : v.speed === undefined ? '● live' : (v.isPaused ? '❚❚ ' : '▶ ') + v.speed + '×'
719 const r = g.textRight(v.cols - 1, 0, badge, P.dim)
720 const rt = g.textRight(r - 2, 0, timeOfDay(v.now), P.text)
721 const tx = g.text(3, 0, spaced(v.title), P.text, undefined, Math.min(v.cols - 30, rt - 5))
722 if (v.note !== undefined) g.text(tx + 2, 0, v.note, P.dim, undefined, rt - tx - 4)
723 let fadeMs = 0
724 const flight = v.level === 'rich' ? flightOf(scene, v) : undefined
725 const lit = v.level === 'rich' ? litEdges(scene, v) : undefined
726 const stream = lit === undefined ? undefined : skyScene(scene, v, geo, lit.lit, flight)
727 if (lit !== undefined) {
728 const sky = lit
729 fadeMs = sky.fadeMs
730 images.push({
731 key: 'sky',
732 x: geo.gx,
733 y: geo.gy,
734 cols: geo.gw,
735 rows: geo.gh,
736 sig: skySig(scene, v, geo),
737 draw: () => drawRichSky(scene, v, geo, sky.lit),
738 source: () => skySource(scene, v, geo, sky.lit),
739 minMs: FALLBACK_SKY_MS,
740 ground: P.bg,
741 stream,
742 alt: `star map of ${scene.agents.size} agents around the lead`,
743 })
744 } else fadeMs = drawBasicSky(g, scene, v, geo, wide)
745 if (wide) {
746 for (const n of geo.nodes) {
747 const a = scene.agents.get(n.name)!
748 const state = stateIn(scene, a.name, v)
749 const isDim = v.filter !== undefined && v.filter !== n.name
750 const ink = (c: number) => (isDim ? mix(c, P.bg, 0.6) : c)
751 const name = (a.model === undefined ? '' : MODEL_GLYPH[a.model] + ' ') + (a.code ?? a.name)
752 const landed = arrivals(scene, v, n.name)
753 const sub = landed !== '' ? landed : `${a.code === undefined ? '' : a.name + ' · '}${state === 'quiet' ? 'not started' : state}`
754 const w = geo.gx - 2
755 const subInk = ink(landed !== '' ? P.accent : state === 'stalled' ? P.state.stalled : P.dim)
756 if (n.side === 'left') {
757 g.textRight(geo.gx - 1, n.labelRow, name.slice(0, w), ink(agentColor(P, a)))
758 g.textRight(geo.gx - 1, n.labelRow + 1, sub.slice(0, w), subInk)
759 } else {
760 g.text(geo.gx + geo.gw + 1, n.labelRow, name, ink(agentColor(P, a)), undefined, w)
761 g.text(geo.gx + geo.gw + 1, n.labelRow + 1, sub, subInk, undefined, w)
762 }
763 }
764 }
765 let y = geo.gy + geo.gh
766 if (!wide) {
767 const half = Math.floor(v.cols / 2)
768 scene.order().forEach((a, i) => {
769 const x = 1 + (i % 2) * half
770 const row = y + Math.floor(i / 2)
771 const state = stateIn(scene, a.name, v)
772 const cx = g.text(x, row, STATE_GLYPH[state], state === 'stalled' ? P.state.stalled : agentColor(P, a))
773 g.text(cx + 1, row, (a.model ? MODEL_GLYPH[a.model] + ' ' : '') + scene.label(a.name), agentColor(P, a), undefined, half - 3)
774 })
775 y += Math.ceil(scene.agents.size / 2)
776 }
777 const last = sends(scene).filter(m => touches(v.filter, m.from, m.to)).at(-1)
778 if (last !== undefined) {
779 const lit = fade(v.age(last.ts), 1200)
780 const parts: [string, number][] = [
781 [scene.label(last.from), nameColor(P, scene, last.from)],
782 [' → ', P.dim],
783 [scene.label(last.to), nameColor(P, scene, last.to)],
784 [' ' + (last.type ?? '') + ' ', mix(P.accent, 0xffffff, lit)],
785 [last.summary.replace(/^\s*\[[A-Z]+\]\s*/, ''), mix(P.dim, P.text, 0.5 + 0.5 * lit)],
786 ]
787 const total = parts.reduce((n, [s]) => n + width(s), 0)
788 let x = Math.max(1, Math.floor((v.cols - total) / 2))
789 for (const [s, c] of parts) x = g.text(x, y + 1, s, c, undefined, v.cols - 1 - x)
790 }
791 let x = 1
792 const cards = v.isEnded === true ? [] : scene.cards()
793 for (const card of cards.slice(0, 6)) {
794 const title = cardTitle(card.slice, 22)
795 if (x + width(title) + 4 > v.cols - 1) break
796 x = g.text(x, y + 2, '■', P.status[card.status])
797 x = g.text(x + 1, y + 2, title, mix(P.text, P.bg, 0.15)) + 3
798 }
799 let wx = g.text(1, y + 3, 'lead', P.dim)
800 wx = g.text(wx + 2, y + 3, '▲ ' + scene.delivered + ' woken', P.accent)
801 g.text(wx + 2, y + 3, '▽ ' + scene.held + ' held at the membrane', P.dim)
802 return { grid: g, images, links: cardLinks(cards), ...(fadeMs > 0 ? { fadeMs } : {}), ...(v.level === 'basic' && fadeMs > 0 ? { motionMs: fadeMs } : {}) }
803}
804hooks/pixels.ts 180 lines1// An RGBA pixel canvas for Image layers: anti-aliased lines, curves, discs and rings, additive glows.
2
3import { base64 } from './grid'
4
5export class Canvas {
6 readonly w: number
7 readonly h: number
8 /** Linear-ish working buffer, 0..255 per channel, RGB; alpha is 255 everywhere. */
9 private readonly px: Float32Array
10
11 constructor(w: number, h: number, bg: number) {
12 this.w = Math.max(1, Math.floor(w))
13 this.h = Math.max(1, Math.floor(h))
14 this.px = new Float32Array(this.w * this.h * 3)
15 this.rect(0, 0, this.w, this.h, bg, 1)
16 }
17
18 /** A copy of this canvas. */
19 clone(): Canvas {
20 const c = new Canvas(this.w, this.h, 0)
21 c.px.set(this.px)
22 return c
23 }
24
25 /** Mixes a colour into one pixel at `a` (0..1) over what is there. */
26 blend(x: number, y: number, c: number, a: number) {
27 if (a <= 0 || x < 0 || y < 0 || x >= this.w || y >= this.h) return
28 const i = (y * this.w + x) * 3
29 const k = Math.min(1, a)
30 this.px[i] = this.px[i]! * (1 - k) + ((c >> 16) & 255) * k
31 this.px[i + 1] = this.px[i + 1]! * (1 - k) + ((c >> 8) & 255) * k
32 this.px[i + 2] = this.px[i + 2]! * (1 - k) + (c & 255) * k
33 }
34
35 /** Adds a colour to one pixel at `a`, clamped at white: light on light gets brighter. */
36 add(x: number, y: number, c: number, a: number) {
37 if (a <= 0 || x < 0 || y < 0 || x >= this.w || y >= this.h) return
38 const i = (y * this.w + x) * 3
39 this.px[i] = Math.min(255, this.px[i]! + ((c >> 16) & 255) * a)
40 this.px[i + 1] = Math.min(255, this.px[i + 1]! + ((c >> 8) & 255) * a)
41 this.px[i + 2] = Math.min(255, this.px[i + 2]! + (c & 255) * a)
42 }
43
44 rect(x: number, y: number, w: number, h: number, c: number, a = 1) {
45 const x0 = Math.max(0, Math.floor(x))
46 const y0 = Math.max(0, Math.floor(y))
47 const x1 = Math.min(this.w, Math.ceil(x + w))
48 const y1 = Math.min(this.h, Math.ceil(y + h))
49 for (let py = y0; py < y1; py++) {
50 const cy = Math.min(py + 1, y + h) - Math.max(py, y)
51 for (let px = x0; px < x1; px++) {
52 const cx = Math.min(px + 1, x + w) - Math.max(px, x)
53 this.blend(px, py, c, a * cx * cy)
54 }
55 }
56 }
57
58 /** A line of half-width `r` (pixels), anti-aliased by distance to the segment. */
59 line(x0: number, y0: number, x1: number, y1: number, r: number, c: number, a = 1, additive = false) {
60 const minX = Math.max(0, Math.floor(Math.min(x0, x1) - r - 1))
61 const maxX = Math.min(this.w - 1, Math.ceil(Math.max(x0, x1) + r + 1))
62 const minY = Math.max(0, Math.floor(Math.min(y0, y1) - r - 1))
63 const maxY = Math.min(this.h - 1, Math.ceil(Math.max(y0, y1) + r + 1))
64 const dx = x1 - x0
65 const dy = y1 - y0
66 const len2 = dx * dx + dy * dy || 1
67 for (let py = minY; py <= maxY; py++) for (let px = minX; px <= maxX; px++) {
68 const qx = px + 0.5
69 const qy = py + 0.5
70 const t = Math.max(0, Math.min(1, ((qx - x0) * dx + (qy - y0) * dy) / len2))
71 const d = Math.hypot(qx - (x0 + dx * t), qy - (y0 + dy * t))
72 const cover = Math.max(0, Math.min(1, r + 0.5 - d))
73 if (cover > 0) additive ? this.add(px, py, c, a * cover) : this.blend(px, py, c, a * cover)
74 }
75 }
76
77 /** A quadratic curve from (x0,y0) to (x2,y2) bent toward (x1,y1), drawn from `from` to `to` of its length. */
78 curve(x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, r: number, c: number, a = 1, from = 0, to = 1, additive = false) {
79 const steps = Math.max(4, Math.ceil(Math.hypot(x2 - x0, y2 - y0) / 6))
80 const pts: number[] = []
81 for (let k = 0; k <= steps; k++) {
82 const t = from + ((to - from) * k) / steps
83 pts.push((1 - t) * (1 - t) * x0 + 2 * (1 - t) * t * x1 + t * t * x2, (1 - t) * (1 - t) * y0 + 2 * (1 - t) * t * y1 + t * t * y2)
84 }
85 this.stroke(pts, r, c, a, additive)
86 }
87
88 /** A polyline of half-width `r` through `pts` (x, y pairs), each pixel covered once however many segments touch it. */
89 stroke(pts: readonly number[], r: number, c: number, a = 1, additive = false) {
90 if (pts.length < 4) return
91 let minX = Infinity
92 let minY = Infinity
93 let maxX = -Infinity
94 let maxY = -Infinity
95 for (let i = 0; i < pts.length; i += 2) {
96 minX = Math.min(minX, pts[i]!)
97 maxX = Math.max(maxX, pts[i]!)
98 minY = Math.min(minY, pts[i + 1]!)
99 maxY = Math.max(maxY, pts[i + 1]!)
100 }
101 const x0 = Math.max(0, Math.floor(minX - r - 1))
102 const y0 = Math.max(0, Math.floor(minY - r - 1))
103 const x1 = Math.min(this.w - 1, Math.ceil(maxX + r + 1))
104 const y1 = Math.min(this.h - 1, Math.ceil(maxY + r + 1))
105 if (x1 < x0 || y1 < y0) return
106 const mw = x1 - x0 + 1
107 const mask = new Float32Array(mw * (y1 - y0 + 1))
108 for (let i = 0; i + 3 < pts.length; i += 2) {
109 const ax = pts[i]!
110 const ay = pts[i + 1]!
111 const dx = pts[i + 2]! - ax
112 const dy = pts[i + 3]! - ay
113 const len2 = dx * dx + dy * dy || 1
114 const sx0 = Math.max(x0, Math.floor(Math.min(ax, ax + dx) - r - 1))
115 const sx1 = Math.min(x1, Math.ceil(Math.max(ax, ax + dx) + r + 1))
116 const sy0 = Math.max(y0, Math.floor(Math.min(ay, ay + dy) - r - 1))
117 const sy1 = Math.min(y1, Math.ceil(Math.max(ay, ay + dy) + r + 1))
118 for (let py = sy0; py <= sy1; py++) for (let px = sx0; px <= sx1; px++) {
119 const qx = px + 0.5
120 const qy = py + 0.5
121 const t = Math.max(0, Math.min(1, ((qx - ax) * dx + (qy - ay) * dy) / len2))
122 const cover = Math.max(0, Math.min(1, r + 0.5 - Math.hypot(qx - (ax + dx * t), qy - (ay + dy * t))))
123 const k = (py - y0) * mw + (px - x0)
124 if (cover > mask[k]!) mask[k] = cover
125 }
126 }
127 for (let py = y0; py <= y1; py++) for (let px = x0; px <= x1; px++) {
128 const cover = mask[(py - y0) * mw + (px - x0)]!
129 if (cover > 0) additive ? this.add(px, py, c, a * cover) : this.blend(px, py, c, a * cover)
130 }
131 }
132
133 disc(cx: number, cy: number, r: number, c: number, a = 1) {
134 for (let py = Math.floor(cy - r - 1); py <= Math.ceil(cy + r + 1); py++) for (let px = Math.floor(cx - r - 1); px <= Math.ceil(cx + r + 1); px++) {
135 const d = Math.hypot(px + 0.5 - cx, py + 0.5 - cy)
136 this.blend(px, py, c, a * Math.max(0, Math.min(1, r + 0.5 - d)))
137 }
138 }
139
140 /** A ring of half-width `w` at radius `r`. */
141 circle(cx: number, cy: number, r: number, w: number, c: number, a = 1) {
142 for (let py = Math.floor(cy - r - w - 1); py <= Math.ceil(cy + r + w + 1); py++) for (let px = Math.floor(cx - r - w - 1); px <= Math.ceil(cx + r + w + 1); px++) {
143 const d = Math.abs(Math.hypot(px + 0.5 - cx, py + 0.5 - cy) - r)
144 this.blend(px, py, c, a * Math.max(0, Math.min(1, w + 0.5 - d)))
145 }
146 }
147
148 /** A soft additive halo: intensity falls off as a gaussian of radius `r`. */
149 glow(cx: number, cy: number, r: number, c: number, a = 1) {
150 const reach = r * 2.2
151 for (let py = Math.floor(cy - reach); py <= Math.ceil(cy + reach); py++) for (let px = Math.floor(cx - reach); px <= Math.ceil(cx + reach); px++) {
152 const d2 = (px + 0.5 - cx) ** 2 + (py + 0.5 - cy) ** 2
153 this.add(px, py, c, a * Math.exp(-d2 / (r * r)))
154 }
155 }
156
157 /** The pixels as an Image's `{ rgba, width, height }` source. */
158 source(): { rgba: string; width: number; height: number } {
159 return { rgba: base64(this.rgba()), width: this.w, height: this.h }
160 }
161
162 /** The pixels as RGB, 3 bytes each, rows top down. */
163 rgb(): Uint8Array {
164 const out = new Uint8Array(this.w * this.h * 3)
165 for (let i = 0; i < this.px.length; i++) out[i] = this.px[i]!
166 return out
167 }
168
169 rgba(): Uint8Array {
170 const out = new Uint8Array(this.w * this.h * 4)
171 for (let i = 0, j = 0; i < this.px.length; i += 3, j += 4) {
172 out[j] = this.px[i]!
173 out[j + 1] = this.px[i + 1]!
174 out[j + 2] = this.px[i + 2]!
175 out[j + 3] = 255
176 }
177 return out
178 }
179}
180hooks/png.ts 211 lines1// A PNG encoder for Image sources: RGB rows, each filtered Sub, packed with a fixed-Huffman deflate.
2
3import { base64 } from './grid'
4
5const CRC_TABLE = (() => {
6 const t = new Uint32Array(256)
7 for (let n = 0; n < 256; n++) {
8 let c = n
9 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
10 t[n] = c >>> 0
11 }
12 return t
13})()
14
15function crc32(bytes: Uint8Array, start: number, end: number): number {
16 let c = 0xffffffff
17 for (let i = start; i < end; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8)
18 return (c ^ 0xffffffff) >>> 0
19}
20
21/** Bits written least significant first, as deflate packs them. */
22class BitWriter {
23 private buf = new Uint8Array(1 << 16)
24 private len = 0
25 private acc = 0
26 private n = 0
27
28 bits(value: number, count: number) {
29 this.acc |= value << this.n
30 this.n += count
31 while (this.n >= 8) {
32 if (this.len === this.buf.length) {
33 const next = new Uint8Array(this.buf.length * 2)
34 next.set(this.buf)
35 this.buf = next
36 }
37 this.buf[this.len++] = this.acc & 0xff
38 this.acc >>>= 8
39 this.n -= 8
40 }
41 }
42
43 done(): Uint8Array {
44 if (this.n > 0) this.bits(0, 8 - this.n)
45 return this.buf.subarray(0, this.len)
46 }
47}
48
49/** `code` of `count` bits reversed, as deflate writes a Huffman code. */
50function reversed(code: number, count: number): number {
51 let r = 0
52 for (let i = 0; i < count; i++) r |= ((code >> i) & 1) << (count - 1 - i)
53 return r
54}
55
56/** The fixed-Huffman literal/length codes, reversed, with their lengths, by symbol (0–287). */
57const LIT_CODE = new Uint16Array(288)
58const LIT_BITS = new Uint8Array(288)
59for (let sym = 0; sym < 288; sym++) {
60 const [base, from, n] = sym < 144 ? [0x30, 0, 8] : sym < 256 ? [0x190, 144, 9] : sym < 280 ? [0, 256, 7] : [0xc0, 280, 8]
61 LIT_CODE[sym] = reversed(base + sym - from, n)
62 LIT_BITS[sym] = n
63}
64const DIST_CODE = Uint8Array.from({ length: 30 }, (_, d) => reversed(d, 5))
65
66const LEN_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
67const LEN_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
68const DIST_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]
69const DIST_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
70
71/** The table index whose base is the largest at most `v`, for every `v` up to `max`. */
72function indexTable(base: readonly number[], max: number): Uint8Array {
73 const t = new Uint8Array(max + 1)
74 let i = 0
75 for (let v = 0; v <= max; v++) {
76 while (i + 1 < base.length && base[i + 1]! <= v) i++
77 t[v] = i
78 }
79 return t
80}
81const LEN_INDEX = indexTable(LEN_BASE, 258)
82const DIST_INDEX = indexTable(DIST_BASE, 32768)
83
84const WINDOW = 32768
85const HASH_BITS = 15
86const MAX_CHAIN = 8
87const MAX_LEN = 258
88/** Positions inside a match past this many are not hashed: long matches are runs whose inner positions add nothing. */
89const INSERT_MAX = 8
90
91/**
92 * A zlib stream of `data`: one fixed-Huffman block. A run of the byte before takes a distance-1 match at once; anything
93 * else is matched through a hash chain over the last 32 KB.
94 */
95export function zlib(data: Uint8Array): Uint8Array {
96 const w = new BitWriter()
97 w.bits(0x78, 8)
98 w.bits(0x01, 8)
99 w.bits(1, 1)
100 w.bits(1, 2)
101 const n = data.length
102 const head = new Int32Array(1 << HASH_BITS).fill(-1)
103 const prev = new Int32Array(WINDOW)
104 const insert = (i: number) => {
105 if (i + 2 >= n) return
106 const h = ((data[i]! << 10) ^ (data[i + 1]! << 5) ^ data[i + 2]!) & ((1 << HASH_BITS) - 1)
107 prev[i & (WINDOW - 1)] = head[h]!
108 head[h] = i
109 }
110 const symbol = (sym: number) => w.bits(LIT_CODE[sym]!, LIT_BITS[sym]!)
111 let i = 0
112 while (i < n) {
113 let bestLen = 0
114 let bestDist = 0
115 const max = Math.min(MAX_LEN, n - i)
116 if (i > 0 && max >= 3) {
117 const b = data[i - 1]!
118 let l = 0
119 while (l < max && data[i + l] === b) l++
120 if (l >= 3) {
121 bestLen = l
122 bestDist = 1
123 }
124 }
125 if (bestLen < 3 && max >= 3) {
126 const h = ((data[i]! << 10) ^ (data[i + 1]! << 5) ^ data[i + 2]!) & ((1 << HASH_BITS) - 1)
127 let cand = head[h]!
128 for (let chain = 0; cand >= 0 && i - cand <= WINDOW && chain < MAX_CHAIN; chain++) {
129 let l = 0
130 while (l < max && data[cand + l] === data[i + l]) l++
131 if (l > bestLen) {
132 bestLen = l
133 bestDist = i - cand
134 if (l === max) break
135 }
136 const next = prev[cand & (WINDOW - 1)]!
137 if (next >= cand) break
138 cand = next
139 }
140 }
141 if (bestLen >= 3) {
142 const li = LEN_INDEX[bestLen]!
143 symbol(257 + li)
144 if (LEN_EXTRA[li]! > 0) w.bits(bestLen - LEN_BASE[li]!, LEN_EXTRA[li]!)
145 const di = DIST_INDEX[bestDist]!
146 w.bits(DIST_CODE[di]!, 5)
147 if (DIST_EXTRA[di]! > 0) w.bits(bestDist - DIST_BASE[di]!, DIST_EXTRA[di]!)
148 for (let k = 0; k < Math.min(bestLen, INSERT_MAX); k++) insert(i + k)
149 i += bestLen
150 } else {
151 symbol(data[i]!)
152 insert(i)
153 i++
154 }
155 }
156 symbol(256)
157 const body = w.done()
158 let a = 1
159 let b = 0
160 for (let k = 0; k < n; ) {
161 const end = Math.min(n, k + 5552)
162 for (; k < end; k++) {
163 a += data[k]!
164 b += a
165 }
166 a %= 65521
167 b %= 65521
168 }
169 const out = new Uint8Array(body.length + 4)
170 out.set(body)
171 const adler = ((b << 16) | a) >>> 0
172 out.set([adler >>> 24, (adler >>> 16) & 255, (adler >>> 8) & 255, adler & 255], body.length)
173 return out
174}
175
176/** RGB pixels (3 bytes each, rows top down) as a PNG file. */
177export function png(rgb: Uint8Array, width: number, height: number): Uint8Array {
178 const stride = width * 3
179 const raw = new Uint8Array((stride + 1) * height)
180 for (let y = 0; y < height; y++) {
181 const row = y * stride
182 const out = y * (stride + 1)
183 raw[out] = 1
184 for (let x = 0; x < stride; x++) raw[out + 1 + x] = (rgb[row + x]! - (x >= 3 ? rgb[row + x - 3]! : 0)) & 0xff
185 }
186 const idat = zlib(raw)
187 const chunks: [string, Uint8Array][] = [
188 ['IHDR', new Uint8Array([width >>> 24, (width >>> 16) & 255, (width >>> 8) & 255, width & 255, height >>> 24, (height >>> 16) & 255, (height >>> 8) & 255, height & 255, 8, 2, 0, 0, 0])],
189 ['IDAT', idat],
190 ['IEND', new Uint8Array(0)],
191 ]
192 const size = 8 + chunks.reduce((n, [, d]) => n + 12 + d.length, 0)
193 const file = new Uint8Array(size)
194 file.set([137, 80, 78, 71, 13, 10, 26, 10])
195 let p = 8
196 for (const [type, d] of chunks) {
197 file.set([d.length >>> 24, (d.length >>> 16) & 255, (d.length >>> 8) & 255, d.length & 255], p)
198 for (let k = 0; k < 4; k++) file[p + 4 + k] = type.charCodeAt(k)
199 file.set(d, p + 8)
200 const crc = crc32(file, p + 4, p + 8 + d.length)
201 file.set([crc >>> 24, (crc >>> 16) & 255, (crc >>> 8) & 255, crc & 255], p + 8 + d.length)
202 p += 12 + d.length
203 }
204 return file
205}
206
207/** A PNG as an Image's `{ png }` source. */
208export function pngSource(rgb: Uint8Array, width: number, height: number): { png: string } {
209 return { png: base64(png(rgb, width, height)) }
210}
211