SLOPSHOPPER

agent-swarm

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

newpanebandguardcommandtoast
v0.2.0MITupdated 2026-10-14rahimi/claude-code-swarm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-swarm
│ ┃ swarm ✕ › fix the failing auth test and add an audit log call │ ┃ [ view-list ] ▣ │ ┃ ◉ SWARM no team yet 08:53 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ▌ LEAD ▲ 0 woken ▽ 0 held back ⏺ Update(src/auth.ts) │ ┃ ▌ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /swarm │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · swarm
[ view-list ] ▣ ◉ SWARM no team yet 08:53:20 ● live ▌ LEAD ▲ 0 woken ▽ 0 held back ▌ wake: a: all m: mentions d: digest o: off
README

swarm

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.

For AI agents

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:

  • stamps every teammate-to-teammate message with one global order (#412 · 17:04:12), so a duplicate [CLAIM] is settled by the lower stamp, and tells a later claimant who holds the slice;
  • nudges the claim holder when the swarm goes quiet, and wakes you only if that does not help;
  • tells you when a deliverable has no builder;
  • wakes a teammate when the file its background command writes changes ([WAIT] <path>) or at a time ([WAIT] until <ISO time>), so nobody polls;
  • writes every message to .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: ….

For humans

https://github.com/user-attachments/assets/6c096b84-152b-4982-863a-38c02b2a3531

The pane

/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).

What we measured

One project, a handful of runs: treat these as data points.

  • Benchmark. On code, a builder with independent reviewers beat the swarm: peer reviewers inside the swarm shared the builders' blind spots. The swarm was the fastest and the cheapest, and the best at research. A 17-minute swarm review produced 21 findings; of 10 checked, 6 held, 3 held with the impact overstated, 1 was trivial, 0 were wrong.
  • Film trial. A four-agent swarm made the owner's favourite film. A single agent tied it at about 1/3 of the cost.

Install

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.

Use

/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: ….

Wake modes, and why the default is mentions

The 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:

ModeWakes the lead
mentions (default)for peer messages containing @lead, @orchestrator, @main or @team-lead, coalesced over 3 s
allfor every peer message, coalesced over 10 s so a burst is one wake
digestat most once per 60 s, with a list of the new peer messages
offnever (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.

Reference

PartWhat
Message logObserves 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 stampsEach 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.
Rosteragent.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 agentsAgents 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.
HistoryOn 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 paneLook 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 levelbasic 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 listRoster 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.
BandOne 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 lineWhile 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 toastOnce 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 teamA 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 activityA 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 actionEach 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.
SwarmThe 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 filterquiet 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.
WakeWakes 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

Source 12 files
hooks/register.ts 1038 lines
1import 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}
1038
hooks/core.ts 1471 lines
1// 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 lines
1// 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}
575
hooks/pane-draw.ts 120 lines
1// 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}
120
hooks/replay.ts 525 lines
1// 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}
525
hooks/grid.ts 158 lines
1// 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}
158
hooks/sidecar.ts 193 lines
1// 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}
193
hooks/looks.ts 251 lines
1// 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 }
251
hooks/look-a.ts 335 lines
1// 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
335
hooks/look-b.ts 804 lines
1// 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}
804
hooks/pixels.ts 180 lines
1// 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}
180
hooks/png.ts 211 lines
1// 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