SLOPSHOPPER

office

A pixel-art office of your running Claude Code sessions: one desk per session, animated by what it is doing, with speech bubbles, layouts, characters and drag…

newpaneguardcommandtoastprocess
v0.9.0no licenseupdated 2026-10-02adamentwistle/claude-code-mods/plugins/office
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · office
│ ┃ Office ✕ › fix the failing auth test and add an audit log call │ ┃ Nobody is in yet. no herdr: 0 live sessions │ ┃ 0 recent transcripts ⏺ Read(src/auth.ts) │ ┃ No sessions found: none running this mod, ⎿ Read 6 lines │ ┃ and no transcript written in the last half ⏺ Update(src/auth.ts) │ ┃ hour. ⎿ 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 │ │ › /office │ ⎿ office: Office opened. Press a desk's key to jump to it. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Office
Nobody is in yet. no herdr: 0 live sessions running this mo transcripts No sessions found: none running this mod, and no transcript written in the last half hour.
README

office

A pixel-art office of every busy terminal in herdr. /office opens a pane (a sidebar when the terminal is wide enough) with a desk for every agent pane, and for every plain pane running something. Each worker's animation shows what the pane is doing, so you can read the room at a glance.

Fewer, bigger desks

By default only Claude sessions get a desk (show: claude). Other coding agents, terminals running something (as robots) and idle shells are opt-in: agents, busy, all. At most 8 desks show at once (desks, key d: 4, 6, 8, 12 or all), the most relevant first: needs you, working, done, idle. The header says +N more, and m pages on.

Fewer desks are drawn bigger. The office and war room use the largest whole-number scale at which every desk fits, so 8 desks in a full-screen pane are twice the size. Name tags widen with them, and the whole workstation is the drag target. The grid uses its large cards wherever they fit, sidebar included.

Words in the bubbles

Each desk's bubble says what its session is doing:

  • Working: "Thinking...", "Writing...", "Coding..." (Edit, Write, Bash and the like), "Reading...", "Searching..." (Grep, Glob, web) or "Delegating..." (subagents).
  • Waiting on you: "Help please".
  • Finished: "Done", and "Done!" until you look.
  • Idle: no words, just the coffee.

A session running this mod records its activity in its registry entry, from the model's own stream (thinking or text) and its tool calls. For other sessions it comes from the tail of the transcript: the last assistant block. That is at most one tail -c 32768 per working session whose transcript changed, and at most 3 a poll. The words are text cells in every view (and the grid tag's second line).

Hybrid (the default)

render (key g) is hybrid or cells. In hybrid the cell office or war room stays a Raster, and each person is a small real picture (the detailed Kenney people, or the mod's robots, critters and animals) in its own keyed Image. Its background is cropped from the very cells beneath it, pixel for pixel, so no box shows. cells never draws pictures.

  • What it needs. Inside herdr, launch Claude Code with CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1. herdr then passes kitty graphics through to Ghostty.
  • Sharp, not blurred. A mod cannot know how many pixels a cell is, so a picture sent at its own size (one pixel per half cell) is stretched by the terminal with smoothing. Instead each picture is enlarged 16 times, pixel for pixel, and sent as a PNG: the terminal only ever shrinks it, so edges stay hard. Rows are filtered so the enlarged pixels deflate to back-references: 4 to 7 KB of PNG for a character (18 x 20 to 28 x 24 pixels), against 1.4 to 2.7 KB of raw RGBA that drew blurred.
  • When pictures are sent. Only when a desk changes or moves, at most once a second, never while still.
  • Sidebars too. A desk too small for a 16-pixel person gets a picture drawn 2 or 3 times finer (2 or 3 pixels per half cell), its background still the room's own pixels, so a docked sidebar shows pictures as well.
  • Fallback. One probe blit tells whether pictures draw. Where they draw their alt text, everything stays cells with bold characters, and the header says why: pictures off: this terminal draws no images, desks too small here, the pane is too small for the room or the character art did not load.

Bold characters, critters first

Where pictures do not draw (or render is cells), the office is all block cells. Its people are critters (characters), and they are drawn bold (style, key b). Bold characters are chunky blocks with a thick outline, eyes two pixels tall and one feature each: hair for humans, a visor for robots, ears for cats and dogs, tufts for owls. They are made for cell resolution rather than shrunk from detailed art. detailed brings back the finer v3 to v6 characters.

Tiles

The tiles layout is the grid's cards, each with a small real picture of its character (16 x 24 pixels, sent as a 4 KB PNG enlarged as in hybrid) in its own keyed Image over the card. The rest of the card stays cells.

  • When it is sent. A tile's picture is sent again only when that tile changes or moves, at most once a second, and never while it is still.
  • Limits. At most 24 pictures in one drawing; further cards draw their characters in cells.
  • Fallback. One probe blit tells whether pictures draw. If not, every card gets bold cell characters.

Outside herdr

When herdr is missing or fails, the office still works. herdr is asked again a minute later, and the header says which source is in use.

Claude Code's own records come first. These are <config>/sessions/<pid>.json under $CLAUDE_CONFIG_DIR, ~/.claude-work and ~/.claude. Only the *.json files are read: the .key files beside them are secrets and are never opened.

  • Live. A session is live when its pid runs (one ps -o pid= -p a,b,c per poll) or its record was updated in the last two minutes.
  • State. busy is working, idle is idle, and anything waiting on permission or input needs you. A session that also runs this mod adds its richer state and activity from its registry entry.
  • Header. The source is named "Claude Code sessions".

Only when there are no such records does the office fall back to:

  1. A sessions registry. Every session running this mod keeps one small file, <config>/plugins/data/office-registry/<session id>.json, holding its id, cwd, project, state (working, idle, needs-you or done), subagent count and last heartbeat. It is written on start and on each state change: turn start, turn end, AskUserQuestion and permission prompts. A heartbeat rewrites it every 30 s, and it is left at session end. An entry not heard from for 2 minutes has expired. It is a file per session rather than $.store because the store is one JSON file per plugin, and sessions writing their own keys into it at once would overwrite each other's copies.
  2. Recent transcripts. Sessions that do not run the mod come from <config>/projects/*/*.jsonl written in the last 30 minutes: idle if written in the last 5, done before that. A few project folders are listed per poll, in turn.

Outside herdr there is no pane to jump to. Clicking a desk, or pressing its key, shows the session's cwd and id and copies claude --resume <id>. bin/office-monitor reads the same registry and transcripts straight from disk, and shows the resume command in its footer.

Layouts

LayoutWhat it is
grid (default)One tile per desk, side view, the v2 look. The tile shrinks so everyone fits.
officeA room: a back wall with windows whose sky follows the time of day, a bookshelf, a wall clock showing the real time, a server rack with blinking LEDs, a water cooler, plants and a poster. Plank floor in perspective, rugs, light pools from the ceiling lamps. Desks are a long bench for 1 to 6 sessions, then pods of four or rows, in a regular or a small size, so 1 to about 40 sessions fit.
war-roomEveryone round one big table: the far side faces you, the near side has its back to you with the laptop screen showing. A second table opens when one is full.
stripOne compact row for a narrow pane. When even that is too wide, each session becomes a head over a coloured state bar.

/office full (or f) asks Claude Code for as much of the screen as a pane can have: the terminal width minus 30 columns, and up to 200 rows.

States

Each state has its own pose and accent colour, and the accent repeats on the desk stripe, the name tag and the header strip.

StateAt the deskAccent
needs youStanding and waving both arms, a red and amber beacon flashing, red light on the room, ! on the screenred
workingTyping with the hands alternating, code scrolling, key flashes and sparks, the screen glow lighting the facegreen
running (plain terminal)A robot at a green terminal, named after the process (npm run dev)orange
idleFeet up on the desk, coffee in hand with steam, a sip now and then, the chair swivelling, a screensaverblue
doneHead down on the desk, monitor off, zZ floating upgrey
just finishedCheering with confetti for 4 seconds, then a stretch for 3, round again, until you look at it (its pane is focused in herdr, or it starts working again). Then it sleeps. The "not looked at yet" mark is kept in $.store, so a reload goes on celebrating.
helpersOne minion per active subagent around the desk, and x<count>
this sessionA small gold crown

Settings

Press s in the office, or run /office settings. Each option has a key that cycles it, and the choice is saved in the mod's $.store for every session. The userConfig values in plugin.json (/config, or /plugin configure) are the defaults the store overrides.

KeyOptionValues
llayoutgrid, tiles, office, war-room, strip
ccharacterscritters (the default: small orange blocky mascots), humans (varied skin, hair and clothes, the same per session every time), robots, animals (cats, dogs, owls), mixed by project
bstylebold (the default in cells), detailed
tlightingclock, day, night
ppalettenightshade (from the Nightshade Ghostty theme), warm, cool, mono
wwhich panesclaude (the default), agents, busy (adds busy terminals), all (adds idle shells as empty desks)
ddesks at a time4, 6, 8 (the default), 12, all; never more than fit the pane
hhide done after0 (never), 5, 15, 30, 60 minutes
oseatingstate (the default: needs you, working, done, idle, then busy terminals), stable (a session keeps its place), project, workspace (one rug and label per group)
manimation speedslow, normal, fast
rreduced motionslower frames, no particles, confetti or flashing
ntoastsneeds-you, changes, off
grenderhybrid (the default: people as pictures where they draw), cells
usounda short chime (sounds/needs-you.wav) when a session starts needing you. Off by default

Under the options you can pick a session and give it a label, a character (human, robot, critter, cat, dog or owl) or a place in the order (1 is the first desk). A pinned desk is put back at that place in every seating mode; with fewer desks than that it goes last. z forgets that session's settings. Per-session settings are keyed by the Claude session id, or by the pane for a plain terminal.

Seats are packed. Desks fill from the top left in order, one per session, with no gaps and no rows of empty desks. Stable seating (an option; state order is the default) keeps each session's place in that order while it runs; when one leaves, the others close up. The order is remembered in $.store. Before v10 a drag pinned an absolute seat number (25, 49) and every seat up to it was drawn; those old numbers are dropped once on load, names and characters kept.

Arrange by dragging

Press a desk with the mouse, drag it onto another desk, and let go: the desk you carried takes that place in the order and the ones between move up one. It is pinned to that place in $.store, in every seating mode. While you drag, a ghost with the desk's name follows the pointer, the desk you picked up is outlined in grey and the one under the pointer in yellow. A press and release without moving jumps to that desk's pane. The drag layer is a Client laid over the picture (hooks/office-drag.mjs). A Client holds the pointer from press to release and can't draw a Raster, so the picture stays a Raster underneath it. The layer names the desk under the pointer itself, from the same rectangles it was drawn with, and the floor never scrolls (see Keys), so the outlines land where the pointer is. Pins can also be set by number in the settings.

Busy terminals sort after idle sessions: they are long-running servers and watchers, rarely news.

Keys

In the office view, letters control the view: l layout, d desks at a time, c characters, b style, t lighting, p palette, g pictures or cells (render), m next page, s settings, f full size or sidebar, x close. The header strip shows them all; in a sidebar they show their values alone, to keep it short.

The desks always fit. desks (key d, the settings view, or the plugin's config) caps how many are drawn in every layout and render mode, pane or full. On top of that the floor gets only the rows the header leaves, and a page holds no more desks than fit them, so the pane never scrolls: the rest are +N more, a page away with m. A room too short for even one desk is drawn as the grid. A value changed in the plugin's config wins over an older one saved from the settings view. bin/office-monitor uses the same letters, plus q to quit.

Name tags are the buttons

Each tag reads <key>: <name>. Desks take digits only: 1-9, then 0 for the tenth. Any other desk is reached by clicking it or dragging it. Pressing the key, or clicking the tag, jumps to that pane. For an agent pane the jump is herdr workspace focus, then tab focus, then agent focus. x closes the office.

Demo mode

For a screen recording: OFFICE_DEMO=1 in the environment, or /office demo (again to leave), or bin/office-monitor --demo. The header says demo . Eight scripted sessions replace herdr and the session records, on a 60-second loop that starts at 0 each time demo mode turns on or the office opens, so takes repeat exactly. Nothing is asked of herdr, and nothing of the demo is saved to the store.

SecondWhat happens
0api-refactor (crowned, this session) coding, docs-site writing, bug-triage searching, release-notes writing, test-suite thinking, security-review delegating to 3 helpers; db-migration and onboarding idle
8 to 14api-refactor reading, then thinking; docs-site searching (12)
15bug-triage needs you: "Help please", the beacon, a toast
20db-migration wakes up and thinks, then codes (28)
27bug-triage back to work
30release-notes finishes: "Done!" and the celebration loop; security-review down to 2 helpers
40release-notes looked at: it settles to "Done"
45security-review's helpers finish; it writes
46db-migration idle again
60the loop starts over

A desk's key or click toasts demo: would jump to <name>. Dragging reorders the demo desks. Layouts, characters (mixed gives one of each kind), desks at a time, pictures and settings work as usual.

A monitor with no Claude session

mods/office/bin/office-monitor --help                   # the flags and keys
mods/office/bin/office-monitor --layout office          # q quits; m pages; l d c b t p cycle the view
mods/office/bin/office-monitor --pictures               # the people as pictures (kitty graphics), as in hybrid
mods/office/bin/office-monitor --once --size 200x56     # print one frame and exit
mods/office/bin/office-monitor --demo                   # the scripted demo loop, for a recording

The monitor draws half blocks by default. With --pictures (or g) the office and war room put each person in as a sharp PNG over the cells, as the pane's hybrid view does, for terminals that draw kitty graphics (Ghostty, kitty).

It runs on node (or bun) and uses the same painter as the pane: hooks/office-model.mjs and hooks/office-scene.mjs. Every frame is one synchronized update (CSI ? 2026), drawn over the last with no clear. With pictures, each desk has two kitty image ids used in turn: the new picture goes to the spare one, is placed over the box, and only then is the old one freed, so a person is never missing for a frame. A picture that did not change is not sent.

How it is built

  • hooks/office-pictures.mjs, hooks/office-art-hd.mjs, assets/: the character pictures and their PNG encoder, the mod's own characters in the same style, and the atlas cut from Kenney's CC0 packs by tools/build-atlas.py (see assets/CREDITS.txt). The atlas is one byte per pixel with a palette of 61 colours, 36 KB. Run python3 tools/build-atlas.py <folder of unzipped packs> to rebuild it.
  • hooks/office-art.mjs: every sprite as a palette-indexed pixel map, edited as text. This covers heads and backs of heads per character, hair styles, torsos, plants, the cooler, the mug and minions, plus skin, hair, clothes and fur colours, room palettes and the sky for each hour.
  • hooks/office-scene.mjs: the room and the layouts. The static background is built once per size, seating, palette and quarter-hour of light. Each frame copies it and paints the sprites, the clock hands, the rack LEDs and the stars on top.
  • hooks/office-model.mjs: herdr parsing, desks, options, seating, state changes, and the grid painter.
  • hooks/office.mjs: the hooks. One Raster is repainted with $.ui.blit at 90-260 ms per frame. The render hook runs again only when a desk, a state, a count or an option changes. Nothing polls or animates while the pane is closed.

Data comes from one herdr pane list per poll (every 3 s, every 4 s at full size), and nothing else is spawned. A plain pane counts as busy when its terminal title is a command rather than a prompt (user@host:path, a bare path or a shell's name). Idle shells drawn as empty desks with "which panes: all" are counted as idle shells, never as done. Subagent counts come from the transcripts under <config>/projects/<folder>/<session>/subagents/*.jsonl written to in the last 60 s. The folder is found with $.fs alone: the pane's cwd or a folder above it, checked against one listing of each config folder (every 10 minutes). Both found and missing lookups are cached.

Tests

claude plugin validate --strict mods/office
claude plugin test mods/office

The tests cover parsing, busy-shell detection and subagent counts. They mount every layout on the terminal and check the tag buttons and the herdr argv a press sends. They also cover:

  • every layout painting 40 desks at three sizes inside the Raster limits
  • option precedence and cycling
  • the settings view keys saving to the store
  • per-session rename, character and pin
  • seating stability
  • state-change tracking, the needs-you toast and chime
  • reduced motion and hiding done sessions
  • the desktop list
  • the herdr failure lines
  • timers stopping after close

Tested on Claude Code 2.1.287 with herdr API protocol 22.

Limits

  • There is no isometric layout. A three-quarter front view was the most legible option at terminal resolution.
  • herdr does not tell thinking apart from working.
  • When the regular desks do not fit (more than 12 sessions in a 200 x 56 terminal, for example), the office uses small sprites, which have far less detail.
  • A plain terminal is focused only through its workspace and tab.
  • Names keep printable ASCII only.
  • The name-tag buttons and the drag layer are laid over the Raster with absolute positioning. Clicks and drags at every tag are checked in the test kit from 40 to 200 columns in every layout, not yet in a live docked pane.
Source 9 files
hooks/office.mjs 1418 lines
1// Office: a pixel-art office of every busy terminal in herdr. One desk per
2// agent pane (and per plain pane running something), each worker animated by
3// its pane's state, minions for a session's active subagents. Name tags are
4// buttons: a desk's hotkey jumps to its pane. `/office` opens it, `/office
5// full` makes it as big as a pane can be, `s` opens the settings, `/office
6// close` closes it.
7//
8// herdr is polled, subagent folders listed and the animation run only while
9// the pane is on screen; every timer stops when it closes.
10
11import {
12  ACCENT,
13  CHARACTER_KINDS,
14  FRAME_MS,
15  HOTKEYS,
16  OPTIONS,
17  STATUS_WORD,
18  STATUSES,
19  applyOverrides,
20  VIEW_KEYS,
21  activityFromTail,
22  activityOfTool,
23  bubbleFor,
24  buildDesks,
25  pageOf,
26  busyFromTitle,
27  countActiveSubagents,
28  cycleOption,
29  deskKey,
30  hideStaleDone,
31  parsePaneList,
32  projectDirName,
33  resolveOptions,
34  seatDesks,
35  tally,
36  toBase64,
37  trackChanges,
38} from "./office-model.mjs";
39import { characterBox, loadAtlas, portraitMoves, sharpPng } from "./office-pictures.mjs";
40import { demoPanes } from "./office-demo.mjs";
41import { hitRects, paintPlan, paintRoomCanvas, planScene } from "./office-scene.mjs";
42import {
43  HEARTBEAT_MS,
44  REGISTRY_TTL_MS,
45  alivePids,
46  claudeSessionPanes,
47  isSessionRecord,
48  liveEntries,
49  recentTranscripts,
50  recordPids,
51  registryDir,
52  registryEntry,
53  resumeHint,
54  sessionPanes,
55} from "./office-sessions.mjs";
56
57const PANE = "office";
58const RASTER = "office-floor";
59// One `herdr pane list` per poll and nothing else spawned: every 3 s, every 4 s full size.
60const POLL_MS = 3000;
61const POLL_FULL_MS = 4000;
62const HELPERS_EVERY = 3; // polls between subagent scans
63const ACTIVE_MS = 60000;
64const DIR_RETRY_MS = 600000;
65const SOUND = "sounds/needs-you.wav";
66const PROBE_MS = 400;
67// Outside herdr: project folders listed for recent transcripts per poll.
68const SCAN_PER_POLL = 25;
69const HERDR_RETRY_MS = 60000;
70const PORTRAIT_MS = 1000;
71// Transcript tails read per poll, at most, to tell thinking from coding.
72const TAILS_PER_POLL = 3;
73
74// Module state. A reload starts it over and drops the timers with it; the
75// pane's next draw starts them again. What must outlive a reload is in $.store.
76const state = {
77  userConfig: {},
78  stored: {},
79  options: resolveOptions(),
80  overrides: {},
81  seatMemory: {},
82  changeMemory: {},
83  unseen: {},
84  isLoaded: false,
85  panes: [],
86  procs: {},
87  helpers: {},
88  sessionDirs: new Map(),
89  projectNames: new Map(),
90  roots: null,
91  desks: [],
92  seats: [],
93  idleShells: 0,
94  error: null,
95  hasData: false,
96  isFull: false,
97  view: "office",
98  picked: null,
99  polls: 0,
100  selfPaneId: undefined,
101  isPolling: false,
102  poll: null,
103  pollMs: 0,
104  frame: null,
105  isBlitting: false,
106  frameMs: 0,
107  tick: 0,
108  minute: "",
109  lastColumns: 0,
110  plan: null,
111  atlas: null,
112  isAtlasTried: false,
113  // Where the desks come from: herdr's panes, or the sessions registry.
114  source: "herdr",
115  sourceNote: "",
116  herdrMissingUntil: 0,
117  scans: new Map(),
118  recent: new Map(),
119  // This session's own registry entry, and its heartbeat.
120  reg: null,
121  heartbeat: null,
122  // Tiles: what each tile's picture last showed, and whether pictures draw.
123  portraitSent: new Map(),
124  portraitTimer: null,
125  tileSupport: "unknown",
126  // Paging through the desks, a few at a time.
127  page: 0,
128  pageInfo: { page: 0, pages: 1, total: 0 },
129  // How many desks fit the pane without scrolling (0: all of a page), and the
130  // header's height as last measured.
131  // Demo mode: a scripted minute instead of herdr (`OFFICE_DEMO=1`, `/office
132  // demo`): when it started, and the real memories it set aside.
133  demo: null,
134  isDemoEnvRead: false,
135  fitCap: 0,
136  ordered: [],
137  pageSize: 0,
138  headerRows: 2,
139  // Why the people are cells where pictures were asked for, shown in the header.
140  pictureNote: "",
141  pictureDeny: "",
142  // What each session is doing (thinking, coding...), and the transcripts read for it.
143  activity: new Map(),
144  transcripts: new Map(),
145};
146
147const hex = (n) => `#${n.toString(16).padStart(6, "0")}`;
148
149async function load($) {
150  if (state.isLoaded) return;
151  state.isLoaded = true;
152  const [stored, overrides, seats, unseen, seenConfig] = await Promise.all([
153    $.store.get("options").catch(() => undefined),
154    $.store.get("sessions").catch(() => undefined),
155    $.store.get("seats").catch(() => undefined),
156    $.store.get("unseen").catch(() => undefined),
157    $.store.get("optionsConfig").catch(() => undefined),
158  ]);
159  state.unseen = unseen && typeof unseen === "object" ? unseen : {};
160  state.stored = stored && typeof stored === "object" ? stored : {};
161  // A setting changed in the plugin's config since the settings view saved
162  // its own value wins: the stored one is dropped. Without a record of the
163  // config (before v10), the config in force now is taken as the one seen.
164  const was = seenConfig && typeof seenConfig === "object" ? seenConfig : configSnapshot();
165  const now = configSnapshot();
166  const kept = { ...state.stored };
167  for (const o of OPTIONS) if (String(was[o.field] ?? "") !== String(now[o.field] ?? "")) delete kept[o.key];
168  if (Object.keys(kept).length !== Object.keys(state.stored).length) {
169    state.stored = kept;
170    await $.store.set("options", kept).catch(() => {});
171  }
172  if (JSON.stringify(was) !== JSON.stringify(now) || !seenConfig) await $.store.set("optionsConfig", now).catch(() => {});
173  state.overrides = overrides && typeof overrides === "object" ? overrides : {};
174  state.seatMemory = seats && typeof seats === "object" ? seats : {};
175  state.options = resolveOptions(state.userConfig, state.stored);
176  await migrateSeats($);
177}
178
179// The plugin's config values for the options, as they are now.
180function configSnapshot() {
181  const out = {};
182  for (const o of OPTIONS) if (state.userConfig?.[o.field] !== undefined) out[o.field] = state.userConfig[o.field];
183  return out;
184}
185
186// Before v10 a dragged desk was pinned to an absolute seat number (25, 49),
187// and the office drew every seat up to it, mostly empty. Seats are packed
188// now and a pin is a place in that order: the old numbers are dropped once,
189// names and characters kept.
190async function migrateSeats($) {
191  const version = await $.store.get("seatingVersion").catch(() => undefined);
192  if (version === 2) return;
193  const next = {};
194  for (const [k, o] of Object.entries(state.overrides)) {
195    if (!o || typeof o !== "object") continue;
196    const { seat, ...rest } = o;
197    if (Object.keys(rest).length) next[k] = rest;
198  }
199  state.overrides = next;
200  await $.store.set("sessions", next).catch(() => {});
201  await $.store.set("seatingVersion", 2).catch(() => {});
202}
203
204function signature() {
205  if (state.error) return state.error;
206  if (state.source !== "herdr" && !state.seats.length) return `none:${state.sourceNote}`;
207  const seats = state.seats.map((d) => (d ? `${d.paneId}:${d.kind}:${d.status}:${d.name}:${d.helpers}:${d.character ?? ""}:${d.unseenDone ? 1 : 0}` : "-")).join("|");
208  return `${seats}#${state.idleShells}#${state.isFull ? state.minute : ""}`;
209}
210
211async function runQuiet($, argv) {
212  try {
213    return await $.process.run(argv, { timeoutMs: 5000 });
214  } catch {
215    return null;
216  }
217}
218
219// The Claude config folders to look in for session transcripts.
220async function configRoots($) {
221  if (state.roots) return state.roots;
222  const home = (await $.env.get("HOME")) ?? "";
223  const custom = (await $.env.get("CLAUDE_CONFIG_DIR")) ?? "";
224  const roots = [custom, home && `${home}/.claude-work`, home && `${home}/.claude`].filter(Boolean);
225  state.roots = [...new Set(roots.map((r) => r.replace(/\/+$/, "")))];
226  return state.roots;
227}
228
229// A session's transcript folder. Claude Code names it after the folder the
230// session started in: the pane's cwd, or one above it. Looked up with $.fs
231// alone, and remembered either way (a miss is tried again after ten minutes).
232async function sessionDir($, sessionId, cwd, now) {
233  const cached = state.sessionDirs.get(sessionId);
234  if (cached && (cached.dir || now - cached.at < DIR_RETRY_MS)) return cached.dir;
235  let dir = null;
236  const roots = await configRoots($);
237  const parts = String(cwd ?? "").split("/").filter(Boolean);
238  const names = [];
239  for (let i = parts.length; i >= 1 && names.length < 6; i--) names.push(projectDirName(`/${parts.slice(0, i).join("/")}`));
240  search: for (const root of roots) {
241    // Only the folders that exist: one listing per config folder, kept a while.
242    const known = await projectNames($, root, now);
243    for (const name of names.filter((n) => known.has(n))) {
244      const candidate = `${root}/projects/${name}/${sessionId}`;
245      if (await $.fs.exists(candidate).catch(() => false)) {
246        dir = candidate;
247        break search;
248      }
249    }
250  }
251  state.sessionDirs.set(sessionId, { dir, at: now });
252  return dir;
253}
254
255// The project folder names under a config folder, listed once every ten minutes.
256async function projectNames($, root, now) {
257  const cached = state.projectNames.get(root);
258  if (cached && now - cached.at < DIR_RETRY_MS) return cached.names;
259  const entries = await $.fs.list(`${root}/projects`).catch(() => []);
260  const names = new Set(entries.filter((e) => e.kind === "dir").map((e) => e.name));
261  state.projectNames.set(root, { names, at: now });
262  return names;
263}
264
265// Subagent transcripts written to in the last minute, per session.
266async function scanHelpers($, panes, now) {
267  const agents = panes.filter((p) => p.agent === "claude" && p.sessionId);
268  const counts = await Promise.all(
269    agents.map(async (p) => {
270      const dir = await sessionDir($, p.sessionId, p.cwd, now);
271      if (!dir) return [p.sessionId, 0];
272      const entries = await $.fs.list(`${dir}/subagents`).catch(() => []);
273      return [p.sessionId, countActiveSubagents(entries, now, ACTIVE_MS)];
274    }),
275  );
276  state.helpers = Object.fromEntries(counts);
277}
278
279// From the last poll to the seats on screen: which panes, overrides, state
280// changes, hiding, seating. Returns the state changes since last time.
281async function recompute($, now) {
282  const o = state.options;
283  const built = buildDesks(state.panes, state.procs, state.helpers, o.show);
284  const tracked = trackChanges(applyOverrides(built.desks, state.overrides), state.changeMemory, now, state.unseen);
285  state.changeMemory = tracked.memory;
286  // Finished sessions not yet looked at, kept so a reload goes on celebrating them.
287  if (JSON.stringify(tracked.unseen) !== JSON.stringify(state.unseen)) {
288    state.unseen = tracked.unseen;
289    if (!state.demo) await $.store.set("unseen", tracked.unseen).catch(() => {});
290  }
291  const shown = hideStaleDone(tracked.desks, Number(o.hideDoneMinutes) || 0, now).map((d) => ({ ...d, activity: d.activity ?? state.activity.get(d.sessionId)?.activity ?? null }));
292  // Packed in order from the first desk, then the page on show: at most
293  // `desks` at a time, and no more than fit the pane without scrolling.
294  const seated = seatDesks(shown, o.seating, state.seatMemory, now);
295  const cap = Number(o.desks) || Infinity;
296  const size = Math.min(cap, state.fitCap || Infinity);
297  const page = pageOf(seated.seats, Number.isFinite(size) ? size : "all", state.page, true);
298  state.page = page.page;
299  state.pageInfo = page;
300  state.desks = shown;
301  state.ordered = seated.seats;
302  state.pageSize = Number.isFinite(size) ? size : seated.seats.length;
303  state.seats = page.desks;
304  state.idleShells = built.idleShells;
305  if (JSON.stringify(seated.memory) !== JSON.stringify(state.seatMemory)) {
306    state.seatMemory = seated.memory;
307    if (!state.demo) await $.store.set("seats", seated.memory).catch(() => {});
308  }
309  return tracked.changes;
310}
311
312// Toasts and the chime for state changes, as the options say. The chime is
313// not awaited, so the poll never waits on a sound.
314function announce($, changes) {
315  const o = state.options;
316  for (const ch of changes) {
317    const needsYou = ch.to === "blocked";
318    if (needsYou && o.notify !== "off") $.ui.toast(`${ch.desk.name} needs you`);
319    else if (o.notify === "changes") $.ui.toast(`${ch.desk.name}: ${STATUS_WORD[ch.from] ?? ch.from} to ${STATUS_WORD[ch.to] ?? ch.to}`);
320    if (needsYou && o.sound) $.audio.play({ asset: SOUND }).catch(() => {});
321  }
322}
323
324// One poll. Returns true when what the office shows changed.
325async function refresh($) {
326  if (state.isPolling) return false;
327  state.isPolling = true;
328  const before = state.hasData ? signature() : null;
329  try {
330    await load($);
331    if (state.selfPaneId === undefined) state.selfPaneId = (await $.env.get("HERDR_PANE_ID")) ?? null;
332    const now = await $.clock.now();
333    if (!state.isDemoEnvRead) {
334      state.isDemoEnvRead = true;
335      if ((await $.env.get("OFFICE_DEMO")) === "1") startDemo(now);
336    }
337    // Demo mode: the scripted minute, and nothing asked of herdr or the disk.
338    if (state.demo) {
339      const demo = demoPanes(now - state.demo.start, state.options.characters);
340      state.source = "demo";
341      state.sourceNote = "";
342      state.error = null;
343      state.procs = {};
344      state.helpers = demo.helpers;
345      state.panes = demo.panes;
346      state.polls++;
347      const changes = await recompute($, now);
348      if (state.hasData) announce($, changes);
349      const d = new Date(now);
350      state.minute = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
351      return signature() !== before;
352    }
353    // Without herdr (missing, or failing), the sessions registry and recent
354    // transcripts stand in; herdr is asked again a minute later.
355    const run = now < state.herdrMissingUntil ? null : await runQuiet($, ["herdr", "pane", "list"]);
356    let parsed = null;
357    let why = "";
358    if (!run) why = "herdr is not available";
359    else if (run.exitCode !== 0) why = `herdr pane list failed: ${String(run.stderr || run.stdout || "").trim().split("\n")[0].slice(0, 80)}`;
360    else {
361      parsed = parsePaneList(run.stdout, state.selfPaneId);
362      if (parsed.error) why = parsed.error;
363    }
364    if (why) {
365      if (run !== null || now >= state.herdrMissingUntil) state.herdrMissingUntil = now + HERDR_RETRY_MS;
366      state.source = "sessions";
367      state.sourceNote = why;
368      const fallback = await readSessions($, now);
369      state.error = null;
370      state.helpers = fallback.helpers;
371      state.procs = {};
372      state.panes = fallback.panes;
373      state.polls++;
374      const changes = await recompute($, now);
375      if (state.hasData) announce($, changes);
376      return signature() !== before;
377    }
378    state.source = "herdr";
379    state.sourceNote = "";
380    state.error = null;
381    // Busy terminals from their titles: no process spawned per pane.
382    state.procs = Object.fromEntries(parsed.panes.filter((p) => !p.agent).map((p) => [p.paneId, busyFromTitle(p.terminalTitle)]));
383    // Not on the first poll: the first draw waits for it.
384    if (state.polls % HELPERS_EVERY === 1) await scanHelpers($, parsed.panes, now).catch(() => {});
385    await readActivities($, parsed.panes, now).catch(() => {});
386    state.polls++;
387    state.panes = parsed.panes;
388    const changes = await recompute($, now);
389    if (state.hasData) announce($, changes);
390    const d = new Date(now);
391    state.minute = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
392  } finally {
393    state.isPolling = false;
394    state.hasData = true;
395  }
396  return signature() !== before;
397}
398
399// ---- sessions without herdr ---------------------------------------------------
400
401// The registry's live entries and the recent transcripts, read with $.fs.
402async function readSessions($, now) {
403  const roots = await configRoots($);
404  const ownEntries = await readRegistry($, roots, now);
405  const selfId = state.reg?.id ?? null;
406  // Claude Code's own records of its running sessions, when there are any.
407  const records = await readClaudeSessions($, roots);
408  if (records.length) {
409    const pids = recordPids(records);
410    const ps = pids.length ? await runQuiet($, ["ps", "-o", "pid=", "-p", pids.join(",")]) : null;
411    const result = claudeSessionPanes(records, alivePids(ps?.stdout), now, selfId, ownEntries);
412    await addTailActivities($, result.panes, now);
413    state.sourceKind = "claude";
414    return result;
415  }
416  state.sourceKind = "registry";
417  const texts = [];
418  for (const root of roots) {
419    const dir = registryDir(root);
420    const entries = await $.fs.list(dir).catch(() => []);
421    for (const e of entries) {
422      if (e.kind !== "file" || !e.name.endsWith(".json") || now - Number(e.mtimeMs || 0) > REGISTRY_TTL_MS) continue;
423      const text = await $.fs.read(`${dir}/${e.name}`).catch(() => null);
424      if (typeof text === "string") texts.push(text);
425    }
426  }
427  await scanTranscripts($, roots, now);
428  return sessionPanes(liveEntries(texts, now), state.recent.values(), now, selfId);
429}
430
431// `<config>/sessions/*.json`: Claude Code's record of each running session.
432// The folder also holds .key files, which are secrets: only *.json is read.
433async function readClaudeSessions($, roots) {
434  const records = [];
435  for (const root of roots) {
436    const dir = `${root}/sessions`;
437    const entries = await $.fs.list(dir).catch(() => []);
438    for (const e of entries) {
439      if (e.kind !== "file" || !isSessionRecord(e.name)) continue;
440      const text = await $.fs.read(`${dir}/${e.name}`).catch(() => null);
441      try {
442        if (typeof text === "string") records.push(JSON.parse(text));
443      } catch {
444        // a record being rewritten: next poll
445      }
446    }
447  }
448  return records;
449}
450
451// This mod's registry entries: richer state, and what each session is doing.
452async function readRegistry($, roots, now) {
453  const texts = [];
454  for (const root of roots) {
455    const dir = registryDir(root);
456    const entries = await $.fs.list(dir).catch(() => []);
457    for (const e of entries) {
458      if (e.kind !== "file" || !e.name.endsWith(".json") || now - Number(e.mtimeMs || 0) > REGISTRY_TTL_MS) continue;
459      const text = await $.fs.read(`${dir}/${e.name}`).catch(() => null);
460      if (typeof text === "string") texts.push(text);
461    }
462  }
463  return liveEntries(texts, now);
464}
465
466// In herdr: what each working session is doing, from its registry entry when
467// it runs this mod, else from the tail of its transcript.
468async function readActivities($, panes, now) {
469  const roots = await configRoots($);
470  const entries = await readRegistry($, roots, now);
471  for (const e of entries) if (e.activity) state.activity.set(e.id, { activity: e.activity, at: now });
472  await addTailActivities($, panes.filter((p) => p.agent === "claude" && p.sessionId && !entries.some((e) => e.id === p.sessionId && e.activity)), now);
473}
474
475// One `tail` of a transcript per working session whose transcript changed,
476// a few per poll at most: the last assistant block says thinking, writing or a tool.
477async function addTailActivities($, panes, now) {
478  let budget = TAILS_PER_POLL;
479  for (const p of panes) {
480    if (p.status !== "working" || !p.sessionId || p.activity) continue;
481    const path = await transcriptPath($, p.sessionId, p.cwd, now);
482    if (!path) continue;
483    const stat = await $.fs.stat(path).catch(() => null);
484    if (!stat) continue;
485    const known = state.transcripts.get(p.sessionId);
486    if (known && known.mtimeMs === stat.mtimeMs) {
487      p.activity = known.activity;
488      continue;
489    }
490    if (budget-- <= 0) continue;
491    const run = await runQuiet($, ["tail", "-c", "32768", path]);
492    const activity = run?.exitCode === 0 ? activityFromTail(run.stdout) : null;
493    state.transcripts.set(p.sessionId, { mtimeMs: stat?.mtimeMs ?? 0, activity, path });
494    if (activity) state.activity.set(p.sessionId, { activity, at: now });
495    p.activity = activity;
496  }
497}
498
499// A session's transcript: `<config>/projects/<folder>/<id>.jsonl`, the folder
500// named after the cwd or a folder above it. Cached, misses included.
501async function transcriptPath($, sessionId, cwd, now) {
502  const cached = state.transcripts.get(`path:${sessionId}`);
503  if (cached && (cached.path || now - cached.at < DIR_RETRY_MS)) return cached.path;
504  let path = null;
505  const roots = await configRoots($);
506  const parts = String(cwd ?? "").split("/").filter(Boolean);
507  const names = [];
508  for (let i = parts.length; i >= 1 && names.length < 6; i--) names.push(projectDirName(`/${parts.slice(0, i).join("/")}`));
509  search: for (const root of roots) {
510    const known = await projectNames($, root, now);
511    for (const name of names.filter((n) => known.has(n))) {
512      const candidate = `${root}/projects/${name}/${sessionId}.jsonl`;
513      if (await $.fs.exists(candidate).catch(() => false)) {
514        path = candidate;
515        break search;
516      }
517    }
518  }
519  state.transcripts.set(`path:${sessionId}`, { path, at: now });
520  return path;
521}
522
523// A few project folders per poll, round the list, for transcripts written in
524// the last half hour: all of them every few minutes, never all at once.
525async function scanTranscripts($, roots, now) {
526  for (const root of roots) {
527    let scan = state.scans.get(root);
528    let perPoll = SCAN_PER_POLL;
529    if (!scan || now - scan.at > DIR_RETRY_MS) {
530      // Newest folders first (a new transcript touches its folder), so recent
531      // sessions show on the first poll; one stat per folder, every ten minutes.
532      const entries = await $.fs.list(`${root}/projects`).catch(() => []);
533      const dirs = entries.filter((e) => e.kind === "dir").map((e) => e.name);
534      const stamped = await Promise.all(dirs.map(async (name) => [name, (await $.fs.stat(`${root}/projects/${name}`).catch(() => null))?.mtimeMs ?? 0]));
535      stamped.sort((a, b) => b[1] - a[1]);
536      scan = { names: stamped.map(([name]) => name), at: now, i: 0 };
537      state.scans.set(root, scan);
538      perPoll = SCAN_PER_POLL * 2;
539    }
540    for (let k = 0; k < Math.min(perPoll, scan.names.length); k++) {
541      const name = scan.names[scan.i % scan.names.length];
542      scan.i++;
543      const files = await $.fs.list(`${root}/projects/${name}`).catch(() => []);
544      for (const r of recentTranscripts(name, files, now)) state.recent.set(r.id, r);
545    }
546  }
547  for (const [id, r] of state.recent) if (now - r.mtimeMs > 30 * 60000) state.recent.delete(id);
548}
549
550// This session's own entry in the registry, so other sessions' offices see it.
551async function registryRoot($) {
552  const custom = (await $.env.get("CLAUDE_CONFIG_DIR")) ?? "";
553  const home = (await $.env.get("HOME")) ?? "";
554  return custom || (home ? `${home}/.claude` : "");
555}
556
557// Writes the entry as it stands when called: a state that moves on while the
558// write waits does not change what this write says.
559// Writes go out one after another, so the last word written is the last said.
560let regWrites = Promise.resolve();
561function writeEntry($, isLeaving = false) {
562  const reg = state.reg ? { ...state.reg } : null;
563  if (!reg?.root) return regWrites;
564  regWrites = regWrites
565    .then(async () => {
566      const now = isLeaving ? 0 : await $.clock.now();
567      await $.fs.write(`${registryDir(reg.root)}/${reg.id}.json`, JSON.stringify(registryEntry({ id: reg.id, cwd: reg.cwd, state: reg.state, helpers: reg.helpers, activity: reg.activity ?? null, now })));
568    })
569    .catch(() => {});
570  return regWrites;
571}
572
573function setRegState($, next, activity) {
574  if (!state.reg) return;
575  const nextActivity = next === "working" ? activity ?? state.reg.activity ?? null : null;
576  if (state.reg.state === next && state.reg.activity === nextActivity) return;
577  state.reg.state = next;
578  state.reg.activity = nextActivity;
579  writeEntry($).catch(() => {});
580}
581
582// ---- pictures -------------------------------------------------------------------
583
584async function ensureAtlas($) {
585  if (state.atlas || state.isAtlasTried) return state.atlas;
586  state.isAtlasTried = true;
587  try {
588    const [json, bin] = await Promise.all([
589      $.fs.read(`${$.plugin.root}/assets/office-atlas.json`),
590      $.fs.read(`${$.plugin.root}/assets/office-atlas.bin`, { as: "bytes" }),
591    ]);
592    state.atlas = loadAtlas(json, Uint8Array.fromBase64(bin.base64));
593  } catch {
594    state.atlas = null;
595  }
596  return state.atlas;
597}
598
599// Sends one frame of the current plan, never two at once: a slow terminal
600// skips frames instead of piling them up. A plan replaced meanwhile (a new
601// layout, size or seating) is not sent.
602async function pushFrame($) {
603  const plan = state.plan;
604  if (!plan || state.view !== "office" || state.isBlitting) return;
605  state.isBlitting = true;
606  try {
607    const now = await $.clock.now();
608    if (state.plan !== plan) return;
609    state.tick++;
610    await $.ui.blit({ requestId: PANE, key: RASTER, columns: plan.columns, rows: plan.rows, cells: toBase64(new Uint8Array(paintPlan(plan, state.tick, now).buffer)) });
611  } catch {
612    // a frame lost: the next one follows
613  } finally {
614    state.isBlitting = false;
615  }
616}
617
618function seatIn(plan, x, y) {
619  const h = hitRects(plan).find((r) => x >= r.left && x < r.left + r.width && y >= r.top && y < r.top + r.height);
620  return h ? h.index : -1;
621}
622
623// ---- tile pictures -----------------------------------------------------------
624
625// A tile's picture, kept as last sent while it shows the same thing: a redraw
626// then sends nothing, and only a changed or moving tile is sent again.
627function portraitSource(desk, p, now, isForTree, bg) {
628  const key = `${p.isHybrid ? "h" : "t"}:${p.index}:${desk.paneId}:${p.columns}x${p.rows}`;
629  const moves = portraitMoves(desk, state.options.reducedMotion);
630  const sig = `${desk.status}:${desk.unseenDone ? 1 : 0}:${desk.isSelf ? 1 : 0}:${state.options.characters}:${desk.character ?? ""}:${moves ? state.tick : 0}`;
631  const last = state.portraitSent.get(key);
632  if (last && (isForTree || last.sig === sig)) return last.source;
633  // The hybrid view's picture carries the room's own pixels under it; a
634  // tile's, the card's colour. Enlarged pixel for pixel, so it stays sharp.
635  const room = p.isHybrid ? (bg ?? paintRoomCanvas(state.plan, state.tick, now)) : null;
636  const pic = characterBox(state.atlas, desk, state.options.characters, state.tick, now, state.options.reducedMotion, p, room);
637  const source = { png: toBase64(sharpPng(pic)) };
638  state.portraitSent.set(key, { sig, source });
639  return source;
640}
641
642// Once a second at most, each tile whose picture changed or moves; none when still.
643function startPortraits($) {
644  if (state.portraitTimer) return;
645  state.portraitTimer = $.clock.every(PORTRAIT_MS, () => {
646    pushPortraits($).catch(() => {});
647  });
648}
649
650async function pushPortraits($) {
651  const plan = state.plan;
652  // One blit in flight at a time, the office's frames and the tiles' together.
653  if (!plan?.portraits?.length || state.view !== "office" || state.isBlitting || state.tileSupport === "no") return;
654  state.isBlitting = true;
655  try {
656    const now = await $.clock.now();
657    let bg = null;
658    for (const p of plan.portraits) {
659      if (state.plan !== plan) return;
660      const desk = plan.isHybrid ? plan.desks[p.index]?.seat : plan.seats[p.index];
661      if (!desk) continue;
662      const key = `${p.isHybrid ? "h" : "t"}:${p.index}:${desk.paneId}:${p.columns}x${p.rows}`;
663      const before = state.portraitSent.get(key)?.source;
664      if (p.isHybrid && !bg) bg = paintRoomCanvas(plan, state.tick, now);
665      const source = portraitSource(desk, p, now, false, bg);
666      if (source === before) continue;
667      await $.ui.blit({ requestId: PANE, key: `portrait-${p.index + 1}`, source });
668    }
669  } finally {
670    state.isBlitting = false;
671  }
672}
673
674// The first tile picture tells whether pictures draw here.
675async function probeTiles($) {
676  const plan = state.plan;
677  const p = plan?.portraits?.[0];
678  if (!p || state.tileSupport !== "unknown") return;
679  if (state.isBlitting) {
680    $.clock.after(PROBE_MS, () => probeTiles($).catch(() => {}));
681    return;
682  }
683  const desk = plan.isHybrid ? plan.desks[p.index]?.seat : plan.seats[p.index];
684  state.isBlitting = true;
685  let r;
686  try {
687    r = await $.ui.blit({ requestId: PANE, key: `portrait-${p.index + 1}`, source: portraitSource(desk, p, await $.clock.now(), true) });
688  } catch (err) {
689    r = { deny: String(err?.message ?? err) };
690  } finally {
691    state.isBlitting = false;
692  }
693  if (!r?.deny) state.tileSupport = "yes";
694  else if (/alt/i.test(r.deny)) {
695    state.tileSupport = "no";
696    state.pictureDeny = String(r.deny).slice(0, 60);
697    $.ui.invalidate("ui.render");
698  }
699}
700
701function startTimers($) {
702  // The demo's script moves by the second: it is read every second.
703  const pollMs = state.demo ? 1000 : state.isFull ? POLL_FULL_MS : POLL_MS;
704  if (state.poll && state.pollMs !== pollMs) {
705    state.poll.cancel();
706    state.poll = null;
707  }
708  if (!state.poll) {
709    state.pollMs = pollMs;
710    state.poll = $.clock.every(pollMs, () => {
711      refresh($)
712        .then((changed) => {
713          if (changed) $.ui.invalidate("ui.render");
714        })
715        .catch(() => {});
716    });
717  }
718  const ms = FRAME_MS[state.options.speed] ?? FRAME_MS.normal;
719  if (state.frame && state.frameMs !== ms) {
720    state.frame.cancel();
721    state.frame = null;
722  }
723  if (!state.frame) {
724    state.frameMs = ms;
725    state.frame = $.clock.every(ms, () => {
726      const plan = state.plan;
727      if (!plan || state.view !== "office") return;
728      pushFrame($).catch(() => {});
729    });
730  }
731}
732
733function stopTimers() {
734  state.portraitTimer?.cancel();
735  state.portraitTimer = null;
736  state.poll?.cancel();
737  state.frame?.cancel();
738  state.poll = null;
739  state.frame = null;
740  state.plan = null;
741}
742
743// Focus a desk's pane: its workspace, its tab, then the pane itself.
744async function jumpTo($, desk) {
745  if (state.demo) {
746    $.ui.toast(`demo: would jump to ${desk.name}`);
747    return;
748  }
749  // Outside herdr there is no pane to focus: copy the command that resumes it.
750  if (desk.source) {
751    const hint = resumeHint(desk);
752    await $.ui.copy({ text: hint.text }).catch(() => {});
753    $.ui.toast(`${desk.name} (${desk.sessionId}) in ${hint.where}: copied "${hint.text}"`);
754    return;
755  }
756  const steps = [];
757  if (desk.workspaceId) steps.push(["herdr", "workspace", "focus", desk.workspaceId]);
758  if (desk.tabId) steps.push(["herdr", "tab", "focus", desk.tabId]);
759  // An agent pane is focused as the agent; a plain pane has no agent to name.
760  if (desk.kind === "agent") steps.push(["herdr", "agent", "focus", desk.paneId]);
761  for (const argv of steps) {
762    const run = await runQuiet($, argv);
763    if (!run || run.exitCode !== 0) {
764      const why = run ? String(run.stderr || "").trim().split("\n")[0].slice(0, 100) : "herdr is not available";
765      await $.ui.toast(`Could not jump to ${desk.name}${why ? `: ${why}` : "."}`);
766      return;
767    }
768  }
769}
770
771// Demo mode on: the loop starts at 0 now, from a clean slate (no pins,
772// no remembered seats or finishes), the real ones set aside until it ends.
773function startDemo(now) {
774  if (!state.demo) state.demo = { saved: { overrides: state.overrides, seatMemory: state.seatMemory, changeMemory: state.changeMemory, unseen: state.unseen } };
775  state.demo.start = now;
776  state.overrides = {};
777  state.seatMemory = {};
778  state.changeMemory = {};
779  state.unseen = {};
780  state.page = 0;
781  state.plan = null;
782}
783
784function stopDemo() {
785  if (!state.demo) return;
786  Object.assign(state, state.demo.saved);
787  state.demo = null;
788  state.page = 0;
789  state.plan = null;
790}
791
792async function openOffice($, isFull, columns) {
793  state.isFull = isFull;
794  // A take starts at second 0 each time the office opens.
795  if (state.demo) {
796    startDemo(await $.clock.now());
797    await refresh($).catch(() => {});
798  }
799  const wide = Math.max(60, (columns || 120) - 30);
800  const placed = await $.ui.open(
801    isFull
802      ? { id: PANE, title: "Office", focus: true, columns: wide, rows: 200 }
803      : { id: PANE, title: "Office", focus: true, columns: 66, rows: 28 },
804  );
805  $.ui.invalidate("ui.render");
806  return placed;
807}
808
809async function closeOffice($) {
810  stopTimers();
811  state.view = "office";
812  await $.ui.close({ id: PANE });
813}
814
815// ---- arranging by drag ----------------------------------------------------------
816
817// Moves the desk at seat `from` to the place of seat `to` (both on the page
818// on show): the desks between shift up one, nothing is left empty. The moved
819// desk is pinned to that place in the packed order, so it stays there while
820// the others reorder by state; nobody else keeps a pin on the same place.
821async function moveDesk($, from, to) {
822  const a = state.seats[from];
823  if (!a || from === to || !state.seats[to]) return;
824  const place = state.page * (state.pageSize || state.seats.length) + to + 1;
825  const keyA = deskKey(a);
826  const next = { ...state.overrides };
827  for (const [k, o] of Object.entries(next)) {
828    if (k !== keyA && o.seat === place) {
829      const { seat, ...rest } = o;
830      if (Object.keys(rest).length) next[k] = rest;
831      else delete next[k];
832    }
833  }
834  next[keyA] = { ...(next[keyA] ?? {}), seat: place };
835  state.overrides = next;
836  if (!state.demo) await $.store.set("sessions", next);
837  await recompute($, await $.clock.now());
838  $.ui.invalidate("ui.render");
839}
840
841const cell = (v) => Array.isArray(v) && v.length === 2 && v.every((n) => Number.isInteger(n) && Math.abs(n) < 2000);
842
843// What the drag layer posted: a hover to light up, a drop to act on, a click.
844// The drag layer names the seats itself, from the very hits it was drawn
845// with; the cells are the fallback.
846const seatOf = (plan, seat, at) => (Number.isInteger(seat) && seat >= -1 && seat < state.seats.length ? seat : cell(at) ? seatIn(plan, at[0], at[1]) : -1);
847
848async function onDragMessage($, data) {
849  const plan = state.plan;
850  if (!plan || !data || typeof data !== "object") return;
851  if (data.type === "hover" && cell(data.from) && cell(data.at)) {
852    const next = { from: seatOf(plan, data.fromSeat, data.from), over: seatOf(plan, data.atSeat, data.at) };
853    if (plan.drag?.from !== next.from || plan.drag?.over !== next.over) {
854      plan.drag = next;
855      plan.isDirty = true;
856      await pushFrame($);
857    }
858  } else if (data.type === "drop" && cell(data.from) && cell(data.to)) {
859    plan.drag = null;
860    plan.isDirty = true;
861    const from = seatOf(plan, data.fromSeat, data.from);
862    const to = seatOf(plan, data.toSeat, data.to);
863    if (from >= 0 && to >= 0) await moveDesk($, from, to);
864    else $.ui.invalidate("ui.render");
865  } else if (data.type === "click" && cell(data.at)) {
866    const desk = state.seats[seatOf(plan, data.atSeat, data.at)];
867    if (desk) await jumpTo($, desk);
868  }
869}
870
871async function nextPage($) {
872  state.page = (state.page + 1) % Math.max(1, state.pageInfo.pages);
873  state.plan = null;
874  await recompute($, await $.clock.now());
875  $.ui.invalidate("ui.render");
876}
877
878// ---- settings -----------------------------------------------------------------
879
880async function setOption($, key) {
881  // A frame of the old scene may be in flight: it is dropped, not sent.
882  state.plan = null;
883  if (key === "render") {
884    state.tileSupport = "unknown";
885    state.pictureDeny = "";
886  }
887  state.options = cycleOption(state.options, key);
888  state.stored = { ...state.stored, [key]: state.options[key] };
889  await $.store.set("options", state.stored);
890  await $.store.set("optionsConfig", configSnapshot());
891  await recompute($, await $.clock.now());
892  state.plan = null;
893  if (key === "speed") startTimers($);
894  $.ui.invalidate("ui.render");
895}
896
897async function setOverride($, key, patch) {
898  const next = { ...(state.overrides[key] ?? {}), ...patch };
899  for (const k of Object.keys(next)) if (next[k] === undefined || next[k] === "") delete next[k];
900  state.overrides = { ...state.overrides };
901  if (Object.keys(next).length) state.overrides[key] = next;
902  else delete state.overrides[key];
903  if (!state.demo) await $.store.set("sessions", state.overrides);
904  await recompute($, await $.clock.now());
905  $.ui.invalidate("ui.render");
906}
907
908function settingsView($, e) {
909  const { Box, Text, Button, Select, Input } = $.ui.resolve(e);
910  const o = state.options;
911  const rows = OPTIONS.map((opt) =>
912    Box({
913      key: `opt-${opt.key}`,
914      flexDirection: "row",
915      columnGap: 1,
916      children: [
917        Button({ key: `set-${opt.key}`, label: opt.title, hotkey: opt.hotkey, plain: true, onPress: () => setOption($, opt.key) }),
918        Text({ color: "#76ea6a", children: String(o[opt.key]) }),
919      ],
920    }),
921  );
922  const desks = state.desks;
923  const pickedKey = state.picked && desks.some((d) => deskKey(d) === state.picked) ? state.picked : desks[0] ? deskKey(desks[0]) : null;
924  const picked = desks.find((d) => deskKey(d) === pickedKey);
925  const own = (pickedKey && state.overrides[pickedKey]) || {};
926  const perSession = picked
927    ? [
928        Text({ bold: true, children: "This session" }),
929        Select && Select({
930          key: "session",
931          label: "Session",
932          value: pickedKey,
933          options: desks.map((d) => ({ value: deskKey(d), label: `${d.name} (${STATUS_WORD[d.status]})` })),
934          onSelect: (value) => {
935            state.picked = value;
936            $.ui.invalidate("ui.render");
937          },
938        }),
939        Select && Select({
940          key: "character",
941          label: "Character",
942          value: own.character ?? "default",
943          options: [{ value: "default", label: `default (${o.characters})` }, ...CHARACTER_KINDS.map((k) => ({ value: k, label: k }))],
944          onSelect: (value) => setOverride($, pickedKey, { character: value === "default" ? undefined : value }),
945        }),
946        Input && Input({
947          key: "label",
948          label: "Label",
949          placeholder: picked.name,
950          value: own.label ?? "",
951          onSubmit: (value) => setOverride($, pickedKey, { label: value.trim() || undefined }),
952        }),
953        Input && Input({
954          key: "seat",
955          label: "Pin to a place in the order, 1 first (empty to unpin)",
956          value: own.seat ? String(own.seat) : "",
957          onSubmit: (value) => {
958            const n = Number.parseInt(value, 10);
959            return setOverride($, pickedKey, { seat: Number.isInteger(n) && n > 0 ? n : undefined });
960          },
961        }),
962        Button({ key: "reset-session", label: "Forget this session's settings", hotkey: "z", plain: true, dimColor: true, onPress: () => setOverride($, pickedKey, { label: undefined, character: undefined, seat: undefined }) }),
963      ].filter(Boolean)
964    : [Text({ dimColor: true, children: "No sessions to set up yet." })];
965  return Box({
966    flexDirection: "column",
967    children: [
968      Box({
969        flexDirection: "row",
970        columnGap: 2,
971        children: [
972          Text({ bold: true, children: "Office settings" }),
973          Button({ key: "back", label: "Back to the office", hotkey: "s", plain: true, onPress: () => showView("office", $) }),
974        ],
975      }),
976      Text({ dimColor: true, children: "Press a key to cycle an option. Saved for every session." }),
977      ...rows,
978      Text({ children: " " }),
979      ...perSession,
980    ],
981  });
982}
983
984function showView(view, $) {
985  state.view = view;
986  state.plan = null;
987  $.ui.invalidate("ui.render");
988}
989
990// ---- the office ---------------------------------------------------------------
991
992// The header strip: a coloured count per state, idle shells, the clock in full mode.
993function headerStrip(Box, Text, Button, $, columns = 0) {
994  const t = tally(state.desks);
995  const isNarrow = columns > 0 && columns < 100;
996  const parts = STATUSES.filter((s) => t[s] > 0 && s !== "unknown").map((s) =>
997    Text({ color: hex(ACCENT[s]), bold: s === "blocked", children: `■ ${t[s] + (s === "idle" ? t.unknown : 0)} ${STATUS_WORD[s]}` }),
998  );
999  if (t.unknown && !t.idle) parts.push(Text({ color: hex(ACCENT.unknown), children: `■ ${t.unknown} idle` }));
1000  if (state.idleShells) parts.push(Text({ dimColor: true, children: `${state.idleShells} idle shell${state.idleShells === 1 ? "" : "s"}` }));
1001  if (!parts.length) parts.push(Text({ dimColor: true, children: "Nobody is in yet." }));
1002  if (state.isFull && state.minute) parts.push(Text({ bold: true, children: state.minute }));
1003  if (state.demo) parts.unshift(Text({ bold: true, inverse: true, children: " demo " }));
1004  if (state.source === "demo") {
1005    // Said once, at the front.
1006  } else if (state.source !== "herdr" && state.sourceKind === "claude") {
1007    parts.push(Text({ dimColor: true, children: `no herdr: Claude Code sessions (${state.desks.length} running)` }));
1008  } else if (state.source !== "herdr") {
1009    const live = state.desks.filter((d) => d.source === "registry").length;
1010    const recent = state.desks.filter((d) => d.source === "transcript").length;
1011    parts.push(Text({ dimColor: true, children: `no herdr: ${live} live session${live === 1 ? "" : "s"} running this mod, ${recent} recent transcript${recent === 1 ? "" : "s"}` }));
1012  }
1013  if (state.pictureNote) parts.push(Text({ color: "#e0a040", children: `pictures off: ${state.pictureNote}` }));
1014  // More desks than a page: say how many, and page on with m.
1015  const info = state.pageInfo;
1016  const pageButton =
1017    info.pages > 1
1018      ? [Button({ key: "page", label: `+${info.total - state.seats.filter(Boolean).length} more (page ${info.page + 1}/${info.pages})`, hotkey: "m", plain: true, onPress: () => nextPage($) })]
1019      : [];
1020  return Box({
1021    key: "header",
1022    flexDirection: "row",
1023    flexWrap: "wrap",
1024    columnGap: 2,
1025    children: [
1026      ...parts,
1027      ...pageButton,
1028      // The view's own keys: letters, never a desk's.
1029      // In a sidebar the keys show their values alone, to keep the header short.
1030      ...Object.entries(VIEW_KEYS).map(([key, option]) =>
1031        Button({ key: `view-${option}`, label: isNarrow ? String(state.options[option]) : `${option} ${state.options[option]}`, hotkey: key, plain: true, dimColor: true, onPress: () => setOption($, option) }),
1032      ),
1033      Button({ key: "full", label: state.isFull ? "Sidebar" : "Full", hotkey: "f", plain: true, dimColor: true, onPress: () => openOffice($, !state.isFull, state.lastColumns) }),
1034      Button({ key: "settings", label: "Settings", hotkey: "s", plain: true, dimColor: true, onPress: () => showView("settings", $) }),
1035      Button({ key: "close", label: "Close", hotkey: "x", plain: true, dimColor: true, onPress: () => closeOffice($) }),
1036    ],
1037  });
1038}
1039
1040/**
1041 * How many rows the header wraps to in `columns`, from its items' widths
1042 * and the 2-column gap. A plain Button reads `k: label`. Wide glyphs (the
1043 * state squares) count as two, so the guess errs tall, never short.
1044 */
1045function headerRowsFor(widths, columns) {
1046  if (!columns) return 2;
1047  let rows = 1;
1048  let used = 0;
1049  for (const w of widths) {
1050    const need = used ? used + 2 + w : w;
1051    if (need > columns && used) {
1052      rows++;
1053      used = Math.min(w, columns);
1054    } else used = need;
1055  }
1056  return rows;
1057}
1058
1059function textWidth(text) {
1060  let n = 0;
1061  for (const ch of String(text)) {
1062    const c = ch.codePointAt(0) ?? 0;
1063    n += (c >= 0x2580 && c <= 0x25ff) || c >= 0x2e80 ? 2 : 1;
1064  }
1065  return n;
1066}
1067
1068// The widths of a header tree's items, for headerRowsFor. An element keeps
1069// its children beside its props (a Text's strings, a Box's elements).
1070function headerWidths(header) {
1071  const textOf = (el) => {
1072    if (typeof el === "string" || typeof el === "number") return String(el);
1073    const kids = el?.children ?? el?.props?.children ?? [];
1074    return Array.isArray(kids) ? kids.map(textOf).join("") : String(kids);
1075  };
1076  return (header?.children ?? header?.props?.children ?? []).map((el) => {
1077    const p = el?.props ?? {};
1078    if (typeof p.label === "string") return textWidth(p.hotkey ? `${p.hotkey}: ${p.label}` : p.label);
1079    return textWidth(textOf(el));
1080  });
1081}
1082
1083export function register(on, options) {
1084  state.userConfig = options ?? {};
1085  state.options = resolveOptions(state.userConfig, state.stored);
1086
1087  on("session.start", async ($, e, next) => {
1088    const r = await next(e);
1089    await $.command.register({
1090      name: "office",
1091      description: "Open a pixel-art office of every busy herdr pane (\"full\" for the big view, \"settings\", \"demo\" for a scripted loop, \"close\")",
1092      argumentHint: "[full|settings|demo|close]",
1093    });
1094    // Into the sessions registry, so offices outside herdr see this session.
1095    try {
1096      const root = await registryRoot($);
1097      if (root) {
1098        state.reg = { root, id: await $.session.id(), cwd: await $.session.cwd(), state: "idle", helpers: 0 };
1099        await writeEntry($);
1100        state.heartbeat?.cancel();
1101        state.heartbeat = $.clock.every(HEARTBEAT_MS, () => {
1102          $.agent
1103            .list()
1104            .then((agents) => {
1105              if (state.reg) state.reg.helpers = agents.length;
1106            })
1107            .catch(() => {})
1108            .finally(() => writeEntry($).catch(() => {}));
1109        });
1110      }
1111    } catch {
1112      state.reg = null;
1113    }
1114    return r;
1115  });
1116
1117  // The registry follows this session's own state. Every hook here passes its
1118  // event straight on, and none waits on a write.
1119  on("turn.start", ($, e, next) => {
1120    if (!e.agentId) setRegState($, "working");
1121    return next(e);
1122  });
1123
1124  on("turn.complete", async ($, e, next) => {
1125    const r = await next(e);
1126    if (!e.agentId) setRegState($, "done");
1127    return r;
1128  });
1129
1130  on("tool.call", async ($, e, next) => {
1131    if (e.agentId || !state.reg) return next(e);
1132    const asks = e.tool === "AskUserQuestion";
1133    setRegState($, asks ? "needs-you" : "working", activityOfTool(e.tool));
1134    const r = await next(e);
1135    if (asks) setRegState($, "working");
1136    return r;
1137  });
1138
1139  // Thinking or writing, from the model's own stream; every chunk passed on as it came.
1140  on("turn.step", async function* ($, e, next) {
1141    const stream = next(e);
1142    if (e.agentId || !state.reg) return yield* stream;
1143    let step = await stream.next();
1144    while (!step.done) {
1145      const kind = step.value?.kind;
1146      if (kind === "thinking") setRegState($, "working", "thinking");
1147      else if (kind === "text") setRegState($, "working", "writing");
1148      yield step.value;
1149      step = await stream.next();
1150    }
1151    return step.value;
1152  });
1153
1154  on("classic.Notification", ($, e, next) => {
1155    if (e.notification_type === "permission_prompt") setRegState($, "needs-you");
1156    return next(e);
1157  });
1158
1159  on("command.run", { command: "office" }, async ($, e) => {
1160    const arg = String(e.args ?? "").trim().toLowerCase();
1161    if (arg === "close") {
1162      await closeOffice($);
1163      return { text: "Office closed." };
1164    }
1165    if (arg === "demo") {
1166      await load($);
1167      state.isDemoEnvRead = true;
1168      if (state.demo) stopDemo();
1169      else startDemo(await $.clock.now());
1170      state.hasData = false;
1171      state.lastColumns = e.presentation?.columns ?? 0;
1172      state.view = "office";
1173      // Opening restarts the loop at 0; closing demo mode goes back to herdr.
1174      if (state.demo) await openOffice($, state.isFull, e.presentation?.columns);
1175      else {
1176        await refresh($).catch(() => {});
1177        $.ui.invalidate("ui.render");
1178      }
1179      return { text: state.demo ? "Office demo mode on: a scripted 60-second loop, starting now." : "Office demo mode off." };
1180    }
1181    state.lastColumns = e.presentation?.columns ?? 0;
1182    state.view = arg === "settings" ? "settings" : "office";
1183    const placed = await openOffice($, arg === "full", e.presentation?.columns);
1184    if (placed?.isPlaced === false) return { text: "The office pane is waiting for room on screen." };
1185    if (arg === "settings") return { text: "Office settings opened." };
1186    return { text: arg === "full" ? "Office opened full size. Press a desk's key to jump to it." : "Office opened. Press a desk's key to jump to it." };
1187  });
1188
1189  on("ui.message", async ($, e, next) => {
1190    if (e.requestId !== PANE || e.element !== "drag") return next(e);
1191    await onDragMessage($, e.data);
1192    return {};
1193  });
1194
1195  // The person's close only: the office's own $.ui.close stops the timers
1196  // itself, so this hook is never raised from inside its own close.
1197  on("ui.close", { id: PANE, origin: { kind: "person" } }, ($, e, next) => {
1198    stopTimers();
1199    return next(e);
1200  });
hooks/office-model.mjs 1211 lines
1// Office: the pure half. Parses herdr's JSON, builds the desk list, lays out
2// the grid and paints each frame as the packed cells a Raster takes. Nothing
3// here touches `$`, so the tests and bin/office-monitor import it directly.
4
5import { BOLD_SMALL_HEADS, FURS, HAIR_COLOURS, SKIN_TONES, SMALL_HEADS } from "./office-art.mjs";
6
7export const GAP = 1;
8export const MAX_PER_ROW = 12;
9
10// Three sizes of desk. `pw` x `ph` is the sprite in pixels, `k` how many
11// terminal cells across (and half-cells down) one pixel takes.
12export const SCALES = {
13  large: { name: "large", pw: 20, ph: 18, k: 2, tagRows: 2 },
14  normal: { name: "normal", pw: 20, ph: 18, k: 1, tagRows: 2 },
15  compact: { name: "compact", pw: 10, ph: 8, k: 1, tagRows: 2 },
16};
17
18// Most interesting first.
19// The order desks sort in: needs you, working, done (not yet looked at, or
20// resting), idle, then busy terminals, which are long-running and rarely news.
21export const STATUSES = ["blocked", "working", "done", "idle", "unknown", "running"];
22const RANK = Object.fromEntries(STATUSES.map((s, i) => [s, i]));
23
24export const STATUS_WORD = {
25  blocked: "needs you",
26  working: "working",
27  running: "running",
28  idle: "idle",
29  unknown: "idle?",
30  done: "done",
31};
32
33// Each state's accent: the desk stripe, the header dot, the name tag.
34export const ACCENT = {
35  blocked: 0xff4d4d,
36  working: 0x4ade80,
37  running: 0xf59e0b,
38  idle: 0x60a5fa,
39  unknown: 0x94a3b8,
40  done: 0x8b8fa8,
41};
42export const TAG_BG = {
43  blocked: 0x6b1515,
44  working: 0x14532d,
45  running: 0x5a3a06,
46  idle: 0x1e3a64,
47  unknown: 0x2a2f3a,
48  done: 0x26262f,
49};
50const TAG_FG = { done: 0xa1a1aa };
51
52const DEFAULT_COLOR = 0x01000000;
53const UPPER_HALF = 0x2580; // '▀': the top pixel is the fg, the bottom the bg
54
55const C = {
56  gap: 0x0d0e14,
57  wall: 0x1a1c29,
58  wallDark: 0x13141d,
59  floor: 0x121219,
60  floorEdge: 0x1d1d27,
61  lampWarm: 0xffc874,
62  lampShade: 0xd9973a,
63  bulbOn: 0xfff4cf,
64  bulbOff: 0x5b4a2e,
65  frame: 0x2d3240,
66  frameHi: 0x3b4152,
67  screenBg: 0x0b1020,
68  screenOff: 0x05060a,
69  screenGlare: 0x1c2030,
70  termBg: 0x050a06,
71  code: [0x60a5fa, 0x93c5fd, 0xc4b5fd, 0x4ade80, 0xfbbf24, 0xf472b6],
72  termGreen: [0x4ade80, 0x22c55e, 0x166534],
73  saver: 0x0d1b36,
74  saverDot: 0x7dd3fc,
75  keys: 0x4b5060,
76  keysHi: 0x6b7184,
77  spark: 0xfff3a0,
78  mug: 0xeeeae2,
79  mugShade: 0xb9b2a5,
80  coffee: 0x5b3a22,
81  steam: 0xc9cedb,
82  deskTop: 0x9a6640,
83  deskTopHi: 0xb57a4d,
84  deskFront: 0x6b4429,
85  deskShadow: 0x3e2717,
86  chair: 0x394050,
87  chairDark: 0x232833,
88  skin: 0xf1c27d,
89  skinShade: 0xc99a5b,
90  eye: 0x1b1b22,
91  pants: 0x2b3245,
92  shoe: 0x111116,
93  crown: 0xfacc15,
94  zzz: 0xc4b5fd,
95  alertRed: 0xff3b3b,
96  alertAmber: 0xffb020,
97  alertWall: 0x3a1418,
98  white: 0xf8fafc,
99  metal: 0xa3aab8,
100  metalDark: 0x6b7280,
101  metalDeep: 0x4b5160,
102  led: 0x22d3ee,
103  minion: 0xfacc15,
104  minionShade: 0xd4a90f,
105  goggle: 0xe5e7eb,
106  overalls: 0x2563eb,
107};
108const CONFETTI_GRID = [0xff5c8a, 0xffd23f, 0x4ade80, 0x60a5fa, 0xc084fc, 0xff8c42];
109const SHIRTS = [0x4f7cac, 0xa05a7c, 0x5f9e6e, 0xc07a3e, 0x7a6fc0, 0x3fa0a0, 0xb0524f, 0x8a8f3c];
110const HAIRS = [0x3b2a20, 0x1f1a17, 0x8c5a2b, 0xc9a25a, 0x6b3a2a, 0x9a9a9a, 0x5a2e5e];
111const SHELLS = new Set(["zsh", "-zsh", "bash", "-bash", "fish", "-fish", "sh", "-sh", "dash", "login", "nu", "tmux"]);
112const RUNTIMES = new Set(["node", "python", "python3", "Python", "bun", "deno", "ruby", "perl", "java", "npx", "uv", "npm", "pnpm", "yarn", "make", "cargo", "go"]);
113
114// ---- herdr data -----------------------------------------------------------
115
116/** The status a desk animates: herdr's own, anything new read as unknown. */
117export function statusOf(raw) {
118  return Object.prototype.hasOwnProperty.call(RANK, raw) && raw !== "running" ? raw : "unknown";
119}
120
121function printable(s) {
122  return String(s ?? "")
123    .replace(/[^\x20-\x7e]/g, "")
124    .replace(/\s+/g, " ")
125    .trim();
126}
127
128function baseName(path) {
129  return String(path ?? "").split("/").filter(Boolean).pop() ?? "";
130}
131
132/** A printable name: herdr's name or title, else the cwd's last folder. */
133export function shortName(pane) {
134  return printable(pane.name) || printable(pane.title) || printable(baseName(pane.cwd)) || String(pane.pane_id ?? "?");
135}
136
137export function hash(text) {
138  let h = 2166136261;
139  for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
140  return h >>> 0;
141}
142
143/**
144 * Reads `herdr pane list` into plain panes, in herdr's order.
145 * Returns `{ panes }` or `{ error }`.
146 */
147export function parsePaneList(stdout, selfPaneId) {
148  let doc;
149  try {
150    doc = JSON.parse(stdout);
151  } catch {
152    return { error: "herdr pane list returned something that is not JSON." };
153  }
154  const raw = doc?.result?.panes;
155  if (!Array.isArray(raw)) return { error: "herdr pane list returned no panes." };
156  const panes = raw
157    .filter((p) => p && typeof p.pane_id === "string")
158    .map((p, order) => ({
159      paneId: p.pane_id,
160      tabId: typeof p.tab_id === "string" ? p.tab_id : "",
161      workspaceId: typeof p.workspace_id === "string" ? p.workspace_id : "",
162      agent: typeof p.agent === "string" && p.agent ? p.agent : null,
163      status: statusOf(p.agent_status),
164      name: shortName(p),
165      terminalTitle: printable(p.terminal_title_stripped ?? p.terminal_title ?? ""),
166      cwd: typeof p.cwd === "string" ? p.cwd : "",
167      sessionId: typeof p.agent_session?.value === "string" ? p.agent_session.value : null,
168      isSelf: Boolean(selfPaneId) && p.pane_id === selfPaneId,
169      isFocused: p.focused === true,
170      order,
171    }));
172  return { panes };
173}
174
175/**
176 * Reads `herdr pane process-info --pane <id>`: whether the pane's shell is
177 * running something in the foreground, and a short name for it.
178 */
179export function parseProcessInfo(stdout) {
180  let info;
181  try {
182    info = JSON.parse(stdout)?.result?.process_info;
183  } catch {
184    return { isRunning: false, name: "" };
185  }
186  const procs = Array.isArray(info?.foreground_processes) ? info.foreground_processes : [];
187  if (!procs.length || info.foreground_process_group_id === info.shell_pid) return { isRunning: false, name: "" };
188  const busy = procs
189    .filter((p) => !SHELLS.has(String(p.name ?? p.argv0 ?? "")))
190    .sort((a, b) => (a.pid ?? 0) - (b.pid ?? 0));
191  if (!busy.length) return { isRunning: false, name: "" };
192  const lead = busy.find((p) => p.pid === info.foreground_process_group_id) ?? busy[0];
193  const argv = Array.isArray(lead.argv) ? lead.argv : String(lead.cmdline ?? lead.argv0 ?? "").split(" ");
194  let name = baseName(argv[0] || lead.argv0 || lead.name);
195  // `node vite`, `npm run dev`: the arguments say more than the runtime.
196  if (RUNTIMES.has(name)) {
197    const rest = argv.slice(1).filter((a) => a && !a.startsWith("-"));
198    name = [name, ...rest.slice(0, 2).map(baseName)].join(" ").slice(0, 32);
199  }
200  return { isRunning: true, name: printable(name) || printable(lead.name) || "process" };
201}
202
203/**
204 * Whether a plain pane's shell is running something, read from its terminal
205 * title alone (no process spawned): shells title an idle prompt as
206 * `user@host:path`, a path, or the shell's name, and a running command as
207 * the command. Returns `{ isRunning, name }` as parseProcessInfo does.
208 */
209export function busyFromTitle(title) {
210  const t = printable(title);
211  if (!t) return { isRunning: false, name: "" };
212  const idle =
213    /^[\w.-]+@[\w.-]+(:|\s|$)/.test(t) || // user@host:~/path
214    /^(~|\/)[^\s]*$/.test(t) || // a bare path
215    SHELLS.has(t) ||
216    SHELLS.has(t.split(/\s/)[0]) && t.split(/\s/).length === 1;
217  if (idle) return { isRunning: false, name: "" };
218  return { isRunning: true, name: t.slice(0, 32) };
219}
220
221/** Claude Code's folder name for a project: every non-alphanumeric is a dash. */
222export function projectDirName(cwd) {
223  return String(cwd ?? "").replace(/[^a-zA-Z0-9]/g, "-");
224}
225
226/** Subagent transcripts written to within `windowMs` of `now`. */
227export function countActiveSubagents(entries, now, windowMs = 60000) {
228  let n = 0;
229  for (const e of entries ?? []) {
230    if (e && e.kind !== "dir" && /\.jsonl$/.test(String(e.name)) && now - Number(e.mtimeMs || 0) <= windowMs) n++;
231  }
232  return n;
233}
234
235/**
236 * The desks, most interesting first. `show` says which panes get one:
237 * `claude` (the default) only Claude sessions; `agents` any coding agent;
238 * `busy` also plain panes whose shell runs something (`procs` by pane id);
239 * `all` also idle shells, as empty desks. Idle shells are counted in
240 * `idleShells` either way.
241 */
242export function buildDesks(panes, procs = {}, helpers = {}, show = "claude") {
243  const desks = [];
244  let idleShells = 0;
245  for (const p of panes) {
246    const look = hash(p.paneId);
247    const project = baseName(p.cwd);
248    if (p.agent && show === "claude" && p.agent !== "claude") continue;
249    if (p.agent) {
250      desks.push({
251        ...p,
252        project,
253        kind: "agent",
254        detail: p.agent === "claude" ? "" : p.agent,
255        helpers: helpers[p.sessionId] ?? 0,
256        look,
257      });
258    } else if (procs[p.paneId]?.isRunning) {
259      if (show !== "busy" && show !== "all") continue;
260      desks.push({
261        ...p,
262        project,
263        kind: "process",
264        status: "running",
265        name: procs[p.paneId].name,
266        detail: baseName(p.cwd),
267        helpers: 0,
268        look,
269      });
270    } else {
271      idleShells++;
272      // Shown as an empty desk only when asked for.
273      if (show === "all" && !procs[p.paneId]?.isRunning) {
274        desks.push({ ...p, project, kind: "shell", status: "done", name: project || p.name, detail: "idle shell", helpers: 0, look });
275      }
276    }
277  }
278  desks.sort((a, b) => rankOf(a) - rankOf(b) || a.order - b.order);
279  return { desks, idleShells };
280}
281
282/** Counts per status, for the header strip. */
283export function tally(desks) {
284  const out = Object.fromEntries(STATUSES.map((s) => [s, 0]));
285  // An idle shell drawn as an empty desk is no session: not counted as done.
286  for (const d of desks) if (d.kind !== "shell") out[d.status]++;
287  return out;
288}
289
290// Desk hotkeys: digits only, 0 for the tenth. Letters are the view's own keys.
291export const HOTKEYS = [..."1234567890"];
292
293// The view's keys, the same in the pane and in bin/office-monitor.
294export const VIEW_KEYS = { l: "layout", d: "desks", c: "characters", b: "style", t: "lighting", p: "palette", g: "render" };
295
296// What a desk's bubble says: what the session is doing, in words.
297export const ACTIVITY_WORDS = {
298  thinking: "Thinking...",
299  writing: "Writing...",
300  coding: "Coding...",
301  reading: "Reading...",
302  searching: "Searching...",
303  running: "Running...",
304  delegating: "Delegating...",
305};
306
307/** The bubble's words for a desk, and its kind (for the colours), or null. */
308export function bubbleFor(desk) {
309  if (!desk || desk.kind === "shell") return null;
310  if (desk.status === "blocked") return { text: "Help please", tone: "alert" };
311  if (desk.status === "done") return { text: desk.unseenDone ? "Done!" : "Done", tone: desk.unseenDone ? "party" : "quiet" };
312  if (desk.status === "working") return { text: ACTIVITY_WORDS[desk.activity] ?? "Working...", tone: "work" };
313  return null;
314}
315
316/** An activity from a tool's name: what the bubble says while it runs. */
317export function activityOfTool(tool) {
318  const t = String(tool ?? "");
319  if (/^(Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell)$/.test(t)) return "coding";
320  if (/^(Read|NotebookRead)$/.test(t)) return "reading";
321  if (/^(Grep|Glob|WebFetch|WebSearch|LS)$/.test(t)) return "searching";
322  if (/^(Agent|Task)$/.test(t)) return "delegating";
323  return t ? "running" : null;
324}
325
326/**
327 * What a session was doing, from the tail of its transcript: the last
328 * assistant block (thinking, text, or a tool by name). Lines that do not
329 * parse (the first, cut mid-way) are skipped.
330 */
331export function activityFromTail(text) {
332  const lines = String(text ?? "").split("\n").filter(Boolean).reverse();
333  for (const line of lines) {
334    let row;
335    try {
336      row = JSON.parse(line);
337    } catch {
338      continue;
339    }
340    const content = row?.message?.content;
341    if (row?.type !== "assistant" || !Array.isArray(content) || !content.length) continue;
342    const last = content[content.length - 1];
343    if (last?.type === "thinking" || last?.type === "redacted_thinking") return "thinking";
344    if (last?.type === "text") return "writing";
345    if (last?.type === "tool_use") return activityOfTool(last.name);
346  }
347  return null;
348}
349
350/**
351 * The page of desks on show, `size` at a time: in the order given when
352 * `isOrdered` (seatDesks's), else the most relevant first (needs you,
353 * working, done, idle). Returns the desks, the page and how many pages.
354 */
355export function pageOf(desks, size, page = 0, isOrdered = false) {
356  const sorted = isOrdered ? [...desks] : [...desks].sort((a, b) => rankOf(a) - rankOf(b) || (a.order ?? 0) - (b.order ?? 0));
357  if (size === "all" || !Number(size)) return { desks: sorted, page: 0, pages: 1, total: sorted.length };
358  const n = Number(size);
359  const pages = Math.max(1, Math.ceil(sorted.length / n));
360  const p = ((page % pages) + pages) % pages;
361  return { desks: sorted.slice(p * n, p * n + n), page: p, pages, total: sorted.length };
362}
363
364/** Where a desk sorts: by its state, empty shell desks last. */
365export function rankOf(d) {
366  return d.kind === "shell" ? STATUSES.length : RANK[d.status] ?? STATUSES.length;
367}
368
369// ---- layout ---------------------------------------------------------------
370
371function tileOf(scale) {
372  const artRows = (scale.ph * scale.k) / 2;
373  return { tileW: scale.pw * scale.k, artRows, tileH: artRows + scale.tagRows };
374}
375
376/**
377 * Picks the largest desk size at which every desk fits the body, and cuts the
378 * grid. Nothing is left out: when even compact desks overflow the height, the
379 * pane scrolls.
380 */
381export function layoutFor(count, bodyColumns, bodyRows, options = {}) {
382  const width = Math.max(10, Math.floor(bodyColumns || 0));
383  const height = Math.max(6, Math.floor(bodyRows || 30));
384  // The biggest desks that fit, in a sidebar too.
385  const order = ["large", "normal", "compact"];
386  let pick = null;
387  for (const name of order) {
388    const scale = SCALES[name];
389    const t = tileOf(scale);
390    const perRow = Math.max(1, Math.min(MAX_PER_ROW, Math.floor((width + GAP) / (t.tileW + GAP))));
391    const deskRows = Math.max(1, Math.ceil(count / perRow));
392    pick = { scale, ...t, perRow, deskRows };
393    if (deskRows * t.tileH <= height && (name !== "large" || t.tileW <= width)) break;
394  }
395  // A Raster is at most 512 x 256 cells; past that (hundreds of desks) the rest are cut.
396  const maxRows = Math.floor(256 / pick.tileH);
397  const shown = Math.min(count, pick.perRow * maxRows);
398  const perRow = Math.max(1, Math.min(pick.perRow, shown || 1));
399  const deskRows = Math.max(1, Math.ceil(shown / perRow));
400  return {
401    scale: pick.scale,
402    tileW: pick.tileW,
403    tileH: pick.tileH,
404    artRows: pick.artRows,
405    shown,
406    perRow,
407    deskRows,
408    columns: Math.min(512, perRow * pick.tileW + (perRow - 1) * GAP),
409    rows: deskRows * pick.tileH,
410  };
411}
412
413/** Where desk `i`'s name tag sits in the grid, in cells. */
414export function tagPlace(layout, i) {
415  return {
416    left: (i % layout.perRow) * (layout.tileW + GAP),
417    top: Math.floor(i / layout.perRow) * layout.tileH + layout.artRows,
418  };
419}
420
421/**
422 * The two lines of a desk's name tag. The first starts `1: ` for a desk with
423 * a hotkey, as a plain Button draws its label; a long name runs on into the
424 * second line, else the second line says what the desk is doing.
425 */
426export function tagLines(desk, i, width) {
427  const key = HOTKEYS[i];
428  const prefix = key ? `${key}: ` : "";
429  const room = Math.max(1, width - prefix.length);
430  const name = desk.name;
431  const first = name.slice(0, room);
432  let second;
433  if (name.length > room) {
434    const rest = name.slice(room).trimStart();
435    second = rest.length > width ? `${rest.slice(0, width - 1)}~` : rest;
436  } else {
437    const bits = [bubbleFor(desk)?.text ?? STATUS_WORD[desk.status]];
438    if (desk.kind === "process" && desk.detail) bits.push(desk.detail);
439    else if (desk.detail) bits.push(desk.detail);
440    if (desk.helpers) bits.push(`${desk.helpers} helper${desk.helpers === 1 ? "" : "s"}`);
441    if (desk.isSelf) bits.push("you");
442    second = bits.join(" / ").slice(0, width);
443  }
444  return { hotkey: key, label: first, first: `${prefix}${first}`, second };
445}
446
447// ---- painting -------------------------------------------------------------
448
449function blend(a, b, t) {
450  const k = Math.max(0, Math.min(1, t));
451  const ch = (s) => {
452    const x = (a >> s) & 255;
453    const y = (b >> s) & 255;
454    return Math.round(x + (y - x) * k) << s;
455  };
456  return ch(16) | ch(8) | ch(0);
457}
458
459// A deterministic flicker, so a frame is a function of the tick.
460function noise(a, b) {
461  return hash(`${a}:${b}`) & 255;
462}
463
464/**
465 * Paints one frame: `[codePoint, fg, bg]` per cell, row-major, as a
466 * Uint32Array. `desks` are in grid order; `tick` advances the animation.
467 */
468export function paintWords(layout, desks, tick, opts = {}) {
469  const W = layout.columns;
470  const H = layout.rows * 2;
471  const px = new Uint32Array(W * H).fill(C.gap);
472  const glyphs = new Map();
473
474  desks.slice(0, layout.shown).forEach((desk, i) => {
475    if (!desk) return;
476    const ox = (i % layout.perRow) * (layout.tileW + GAP);
477    const oyRow = Math.floor(i / layout.perRow) * layout.tileH;
478    if (ox + layout.tileW > W) return;
479    paintDesk(px, glyphs, W, layout, ox, oyRow, desk, opts.reducedMotion ? Math.floor(tick / 4) : tick, opts);
480    paintTag(glyphs, W, layout, ox, oyRow + layout.artRows, desk, i);
481    // The bubble's words along the card's top, over the wall.
482    const bubble = bubbleFor(desk);
483    if (bubble && layout.tileW >= 10) {
484      const text = ` ${bubble.text} `.slice(0, layout.tileW - 1);
485      const [fg, bg] = { alert: [0xffffff, 0xd8352a], work: [0x1e1a26, 0xf4f2ee], party: [0x1e1a26, 0xffd23f], quiet: [0x4a4658, 0xc8c4d4] }[bubble.tone];
486      const col = ox + layout.tileW - text.length - 1;
487      for (let x = 0; x < text.length; x++) glyphs.set(oyRow * W + col + x, { ch: text[x], fg, bg });
488    }
489  });
490
491  const words = new Uint32Array(W * layout.rows * 3);
492  for (let r = 0; r < layout.rows; r++) {
493    for (let c = 0; c < W; c++) {
494      const at = (r * W + c) * 3;
495      const g = glyphs.get(r * W + c);
496      if (g) {
497        words[at] = g.ch.codePointAt(0);
498        words[at + 1] = g.fg;
499        words[at + 2] = g.bg;
500      } else {
501        words[at] = UPPER_HALF;
502        words[at + 1] = px[2 * r * W + c];
503        words[at + 2] = px[(2 * r + 1) * W + c];
504      }
505    }
506  }
507  return words;
508}
509
510/** paintWords packed as RasterProps `cells`. */
511export function paintFrame(layout, desks, tick, opts) {
512  return toBase64(new Uint8Array(paintWords(layout, desks, tick, opts).buffer));
513}
514
515export function toBase64(bytes) {
516  if (typeof bytes.toBase64 === "function") return bytes.toBase64();
517  let s = "";
518  for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
519  return btoa(s);
520}
521
522function paintTag(glyphs, W, layout, ox, row, desk, i) {
523  const { first, second } = tagLines(desk, i, layout.tileW);
524  const bg = TAG_BG[desk.status];
525  const fg = TAG_FG[desk.status] ?? C.white;
526  const key = HOTKEYS[i];
527  const lines = [first, second];
528  for (let l = 0; l < layout.scale.tagRows; l++) {
529    const text = (lines[l] ?? "").padEnd(layout.tileW, " ");
530    for (let x = 0; x < layout.tileW; x++) {
531      const isKey = l === 0 && key && x < key.length;
532      const color = isKey ? ACCENT[desk.status] : l === 1 ? blend(fg, bg, 0.3) : fg;
533      glyphs.set((row + l) * W + ox + x, { ch: text[x], fg: color, bg });
534    }
535  }
536}
537
538// One desk's art. `set` and `glyph` take sprite pixels; the scale maps them.
539function paintDesk(px, glyphs, W, layout, ox, oyRow, desk, tick, opts = {}) {
540  const { pw, ph, k } = layout.scale;
541  const oy = oyRow * 2;
542  const set = (x, y, color) => {
543    if (x < 0 || x >= pw || y < 0 || y >= ph) return;
544    for (let dy = 0; dy < k; dy++) for (let dx = 0; dx < k; dx++) px[(oy + y * k + dy) * W + ox + x * k + dx] = color;
545  };
546  const get = (x, y) => px[(oy + Math.max(0, Math.min(ph - 1, y)) * k) * W + ox + Math.max(0, Math.min(pw - 1, x)) * k];
547  // A character over the cell holding sprite pixel (x, y).
548  const glyph = (x, y, ch, fg) => {
549    if (x < 0 || x >= pw || y < 0 || y >= ph) return;
550    const col = ox + x * k;
551    const row = oyRow + Math.floor((y * k) / 2);
552    const bg = px[row * 2 * W + col];
553    glyphs.set(row * W + col, { ch, fg, bg: bg ?? DEFAULT_COLOR });
554  };
555  const line = (x0, y0, x1, y1, color) => {
556    const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1);
557    for (let s = 0; s <= n; s++) set(Math.round(x0 + ((x1 - x0) * s) / n), Math.round(y0 + ((y1 - y0) * s) / n), color);
558  };
559  const look = {
560    shirt: SHIRTS[desk.look % SHIRTS.length],
561    hair: HAIRS[(desk.look >>> 8) % HAIRS.length],
562  };
563  // A tile whose character is a picture leaves the chair empty for it.
564  const kind = desk.kind === "shell" ? "empty" : desk.hasPortrait ? "none" : kindFor(desk, opts.characters);
565  const api = { set, get, glyph, line, look, tick, desk, kind, isBold: opts.style !== "detailed" };
566  if (pw <= 10) paintCompact(api);
567  else paintFull(api);
568}
569
570/** Which creature sits at a desk: its override, the character set, or a robot for a terminal. */
571export const CHARACTER_KINDS = ["human", "robot", "critter", "cat", "dog", "owl"];
572export function kindFor(desk, characters) {
573  if (desk.character && CHARACTER_KINDS.includes(desk.character)) return desk.character;
574  if (desk.kind === "process") return "robot";
575  const h = hash(desk.sessionId || desk.paneId || desk.name || "");
576  if (characters === "robots") return "robot";
577  if (characters === "critters") return "critter";
578  if (characters === "animals") return ["cat", "dog", "owl"][h % 3];
579  if (characters === "mixed") {
580    const p = hash(desk.project || desk.cwd || "");
581    return ["human", "robot", "critter", ["cat", "dog", "owl"][h % 3]][p % 4];
582  }
583  return "human";
584}
585
586// A 6 x 6 front head for the grid's workers: every kind in bold, the
587// non-human ones in detailed.
588function smallHead(set, x, y, kind, desk, isBold) {
589  const map = (isBold ? BOLD_SMALL_HEADS : SMALL_HEADS)[kind];
590  if (!map) return;
591  const furs = FURS[kind];
592  const [f, d] = furs ? furs[desk.look % furs.length] : [0xde7a4a, 0xb35a30];
593  const skin = SKIN_TONES[desk.look % SKIN_TONES.length];
594  const hair = HAIR_COLOURS[(desk.look >>> 4) % HAIR_COLOURS.length];
595  const pal = {
596    o: 0x1a1420, e: 0x17131c, F: f, D: d, W: f, y: 0xf2c94c, b: 0xe0a030, n: 0x3a2a2a, m: 0x5a3030, p: 0xf0a0b4, q: 0xde7a4a, Q: 0xb35a30,
597    s: skin[0], S: skin[1], r: hair[0], R: hair[1],
598    ...(kind === "robot" ? { m: 0xa9b0be, M: 0x6e7584, v: desk.kind === "process" ? 0xffa53a : 0x5ee7ff, V: 0xffffff, a: 0xff5c5c } : {}),
599  };
600  map.forEach((row, j) => [...row].forEach((k, i) => k !== "." && pal[k] != null && set(x + i, y + j, pal[k])));
601}
602
603// ---- the 20 x 18 desk -------------------------------------------------------
604
605function paintFull({ set, get, glyph, line, look, tick, desk, kind, isBold }) {
606  const s = desk.status;
607  const isRobot = kind === "robot";
608  const isEmpty = kind === "empty";
609  const noWorker = kind === "none";
610  const blink = tick % 4 < 2;
611  const fast = tick % 2 === 0;
612  const lampOn = s !== "done";
613
614  // Wall, warmed by the desk lamp on the right; floor below.
615  for (let y = 0; y < 18; y++) {
616    for (let x = 0; x < 20; x++) {
617      if (y >= 13) {
618        set(x, y, y === 13 ? C.floorEdge : C.floor);
619        continue;
620      }
621      const d = Math.hypot(x - 9, (y - 1) * 1.2);
622      let c = blend(C.wall, C.wallDark, y / 26);
623      if (lampOn && d < 9) c = blend(c, C.lampWarm, (1 - d / 9) * 0.28);
624      set(x, y, c);
625    }
626  }
627
628  // Needs you: the alert light on the monitor throws red and amber on the wall.
629  if (s === "blocked") {
630    const hue = blink ? C.alertRed : C.alertAmber;
631    for (let y = 0; y < 13; y++) {
632      for (let x = 0; x < 20; x++) {
633        const d = Math.hypot(x - 13, (y - 2) * 1.1);
634        if (d < 10) set(x, y, blend(get(x, y), hue, (1 - d / 10) * (blink ? 0.55 : 0.3)));
635      }
636    }
637    for (let x = 0; x < 20; x++) set(x, 17, blink ? C.alertWall : C.floor);
638  }
639
640  // Desk: top, the state's accent stripe, a front panel, two legs.
641  for (let x = 8; x < 20; x++) {
642    set(x, 12, x === 8 ? C.deskTopHi : C.deskTop);
643    set(x, 13, x % 4 === 1 ? blend(C.deskFront, ACCENT[s], 0.15) : ACCENT[s]);
644    set(x, 14, C.deskFront);
645  }
646  for (let y = 15; y <= 17; y++) {
647    set(9, y, C.deskShadow);
648    set(19, y, C.deskShadow);
649  }
650
651  // A pendant lamp over the keyboard.
652  set(8, 0, C.lampShade);
653  set(9, 0, C.lampShade);
654  set(10, 0, C.lampShade);
655  set(9, 1, lampOn ? C.bulbOn : C.bulbOff);
656
657  // Monitor: frame, stand, keyboard.
658  for (let y = 4; y <= 10; y++) for (let x = 10; x <= 16; x++) set(x, y, y === 4 ? C.frameHi : C.frame);
659  set(13, 11, C.frame);
660  set(12, 11, C.frameHi);
661  set(14, 11, C.frameHi);
662  for (let x = 8; x <= 11; x++) set(x, 11, (x + tick) % 5 === 0 && s === "working" ? C.keysHi : C.keys);
663  paintScreen(set, isEmpty ? "done" : s === "done" && desk.unseenDone ? "celebrate" : s, isRobot && desk.kind === "process", tick, desk);
664
665  // Mug on the right of the monitor (in hand while idle).
666  if (s !== "idle" && s !== "unknown") {
667    set(18, 10, C.mug);
668    set(18, 11, C.mugShade);
669    set(19, 10, C.mugShade);
670    set(18, 9, C.coffee);
671  }
672
673  // The worker.
674  if (isEmpty) chairUpright(set);
675  else if (noWorker) {
676    // The picture sits here.
677  } else if (isRobot) paintRobot({ set, line, tick, s });
678  else if (s === "working") paintTyping({ set, line, look, tick });
679  else if (s === "idle" || s === "unknown") paintLeaning({ set, line, look, tick, s });
680  else if (s === "done" && desk.unseenDone) paintWaving({ set, line, look, blink, fast });
681  else if (s === "done") paintAsleep({ set, look });
682  else if (s === "blocked") paintWaving({ set, line, look, blink, fast });
683  // Critters and animals (and in bold, everyone): their own head on the same body.
684  if (!isEmpty && !noWorker && (isBold || (!isRobot && kind !== "human"))) {
685    const at = isRobot ? [3, 0] : desk.status === "done" && desk.unseenDone ? [4, 0] : ({ working: [3, 1], idle: [1, 1], unknown: [1, 1], done: [6, 6], blocked: [4, 0] }[s] ?? [3, 1]);
686    smallHead(set, at[0], at[1], kind, desk, isBold);
687  }
688
689  // Steam curling off the mug in hand while idle.
690  if ((s === "idle" || s === "unknown") && !isRobot && !isEmpty) {
691    const sip = tick % 24 >= 20;
692    const [mx, my] = sip ? [6, 4] : [7, 8];
693    for (let p = 0; p < 4; p++) {
694      const phase = (tick + p * 3) % 12;
695      const y = my - 1 - Math.floor(phase * 0.5);
696      const x = mx + Math.round(Math.sin((tick + p * 5) / 2.5));
697      if (y >= 1 && x >= 6) set(x, y, blend(C.steam, get(x, y), phase / 14));
698    }
699  }
700
701  // Confetti over a session that finished and has not been looked at yet.
702  if (s === "done" && desk.unseenDone && !isEmpty) {
703    for (let k = 0; k < 10; k++) {
704      const h = hash(`cf${desk.paneId}${k}`);
705      set((h % 20) + Math.round(Math.sin((tick + k) / 2)), ((h >> 5) + tick * (1 + (k % 3))) % 13, CONFETTI_GRID[k % CONFETTI_GRID.length]);
706    }
707  }
708  // zZ floating up off a sleeper.
709  if (s === "done" && !isEmpty && !desk.unseenDone) {
710    for (let p = 0; p < 3; p++) {
711      const phase = (Math.floor(tick / 2) + p * 4) % 12;
712      const y = 7 - Math.floor(phase * 0.6);
713      const x = 7 - Math.floor(phase / 4);
714      if (y >= 0) glyph(x, y, phase < 5 ? "z" : "Z", blend(C.zzz, C.wall, phase / 13));
715    }
716  }
717
718  // The alert light, and a "!" over the worker's head.
719  if (s === "blocked") {
720    set(12, 3, C.metalDark);
721    set(13, 3, blink ? C.alertRed : C.alertAmber);
722    set(14, 3, C.metalDark);
723    set(13, 2, blink ? C.white : C.alertRed);
724    glyph(3, 0, "!", blink ? C.alertAmber : C.alertRed);
725  }
726
727  if (desk.isSelf && !isRobot && !noWorker) paintCrown(set, s);
728
729  // Helpers: a minion per active subagent in front of the desk, and a count.
730  const n = desk.helpers || 0;
731  if (n > 0) {
732    // Two on the floor in front, a third up on the monitor.
733    const spots = [
734      [11, 17],
735      [15, 17],
736      [15, 3],
737    ];
738    spots.slice(0, Math.min(3, n)).forEach(([x, foot], i) => paintMinion(set, x, foot - ((tick + i) % 3 === 0 ? 1 : 0), tick + i));
739    const label = `x${n}`;
740    for (let i = 0; i < label.length; i++) glyph(20 - label.length + i, 0, label[i], C.minion);
741  }
742}
743
744function paintScreen(set, s, isRobot, tick, desk) {
745  for (let y = 5; y <= 9; y++) {
746    for (let x = 11; x <= 15; x++) {
747      let c = C.screenOff;
748      if (s === "celebrate") {
749        c = CONFETTI_GRID[(x + y + tick) % CONFETTI_GRID.length];
750      } else if (isRobot) {
751        // A terminal: green output scrolling up.
752        const ln = y - 5 + Math.floor(tick / 2);
753        const h = hash(`${desk.paneId}:${ln}`);
754        const len = 1 + (h % 5);
755        c = x - 11 < len ? C.termGreen[(h >> 4) % 3] : C.termBg;
756        if (y === 9 && x === 11 + (len % 5) && tick % 2) c = C.termGreen[0];
757      } else if (s === "working") {
758        // Code scrolling up a line a frame.
759        const ln = y - 5 + tick;
760        const h = hash(`${desk.paneId}:${ln}`);
761        const indent = h % 3;
762        const len = 1 + ((h >> 3) % (5 - indent));
763        const col = x - 11;
764        c = col >= indent && col < indent + len ? C.code[(h >> 7) % C.code.length] : C.screenBg;
765      } else if (s === "idle" || s === "unknown") {
766        // A screensaver: one dot drifting round the screen.
767        const t = Math.floor(tick / 2);
768        const dx = Math.abs((t % 8) - 4);
769        const dy = Math.abs(((t + 2) % 8) - 4);
770        c = x - 11 === dx && y - 5 === dy ? C.saverDot : C.saver;
771      } else if (s === "blocked") {
772        const on = tick % 4 < 2;
773        const bang = x === 13 && (y <= 7 || y === 9);
774        c = bang ? C.white : on ? 0x8f1d1d : 0x5a1414;
775      } else {
776        c = x === 15 && y === 5 ? C.screenGlare : C.screenOff;
777      }
778      set(x, y, c);
779    }
780  }
781  // A working screen lights the wall above it, flickering.
782  if (s === "working" || isRobot) {
783    const glow = isRobot ? C.termGreen[0] : C.code[0];
784    const amount = noise(tick, 31) & 1 ? 0.22 : 0.12;
785    for (let x = 10; x <= 16; x++) {
786      set(x, 3, blend(C.wall, glow, amount));
787      set(x, 2, blend(C.wall, glow, amount / 2));
788    }
789  }
790}
791
792function chairUpright(set) {
793  for (let y = 5; y <= 11; y++) set(1, y, C.chair);
794  for (let y = 6; y <= 10; y++) set(2, y, C.chairDark);
795  for (let x = 1; x <= 6; x++) set(x, 12, C.chair);
796  set(4, 13, C.chairDark);
797  set(4, 14, C.chairDark);
798  set(4, 15, C.chairDark);
799  for (let x = 2; x <= 6; x++) set(x, 16, C.chairDark);
800  set(2, 17, C.shoe);
801  set(6, 17, C.shoe);
802}
803
804function seatedLegs(set) {
805  for (let x = 3; x <= 7; x++) set(x, 11, C.pants);
806  for (let y = 12; y <= 15; y++) set(7, y, C.pants);
807  set(7, 16, C.shoe);
808  set(8, 16, C.shoe);
809}
810
811function paintTyping({ set, line, look, tick }) {
812  chairUpright(set);
813  seatedLegs(set);
814  // Head leaning in to the screen.
815  set(4, 2, look.hair);
816  set(5, 2, look.hair);
817  set(6, 2, look.hair);
818  set(4, 3, look.hair);
819  set(4, 4, look.hair);
820  for (let y = 3; y <= 5; y++) for (let x = 5; x <= 7; x++) set(x, y, C.skin);
821  set(7, 4, C.eye);
822  set(6, 5, C.skinShade);
823  for (let y = 6; y <= 10; y++) for (let x = 3; x <= 6; x++) set(x, y, look.shirt);
824  // Two arms, hands alternating on the keys every frame.
825  const nearDown = tick % 2 === 0;
826  const far = blend(look.shirt, 0x000000, 0.25);
827  const farHand = nearDown ? [11, 7] : [11, 10];
828  const nearHand = nearDown ? [9, 10] : [9, 7];
829  line(6, 7, farHand[0] - 1, farHand[1], far);
830  set(farHand[0], farHand[1], C.skinShade);
831  line(5, 8, nearHand[0] - 1, nearHand[1], look.shirt);
832  set(nearHand[0], nearHand[1], C.skin);
833  // A key flash under whichever hand is down.
834  const down = nearDown ? nearHand : farHand;
835  if (down[1] === 10) set(down[0], 11, C.spark);
836}
837
838function paintLeaning({ set, line, look, tick, s }) {
839  // Chair tipped back.
840  set(0, 4, C.chair);
841  set(0, 5, C.chair);
842  set(0, 6, C.chair);
843  set(1, 7, C.chair);
844  set(1, 8, C.chair);
845  set(1, 9, C.chair);
846  set(2, 10, C.chair);
847  set(2, 11, C.chair);
848  for (let x = 2; x <= 6; x++) set(x, 12, C.chair);
849  set(4, 13, C.chairDark);
850  set(4, 14, C.chairDark);
851  set(4, 15, C.chairDark);
852  for (let x = 2; x <= 6; x++) set(x, 16, C.chairDark);
853  seatedLegs(set);
854  // Head back, hands behind it, a slow blink.
855  set(2, 2, look.hair);
856  set(3, 2, look.hair);
857  set(4, 2, look.hair);
858  set(2, 3, look.hair);
859  for (let y = 3; y <= 5; y++) for (let x = 3; x <= 5; x++) set(x, y, C.skin);
860  const isBlinking = tick % 18 >= 16;
861  set(5, 4, isBlinking ? C.skinShade : C.eye);
862  set(5, 5, C.skinShade);
863  for (let y = 6; y <= 10; y++) for (let x = 2; x <= 5; x++) set(x, y, look.shirt);
864  // Coffee in hand, lifted for a sip now and then.
865  const sip = tick % 24 >= 20;
866  if (sip) {
867    line(4, 7, 5, 5, look.shirt);
868    set(5, 5, C.skin);
869    set(6, 4, C.mug);
870    set(6, 5, C.mugShade);
871  } else {
872    line(4, 7, 6, 9, look.shirt);
873    set(6, 9, C.skin);
874    set(7, 8, C.mug);
875    set(7, 9, C.mugShade);
876    set(8, 8, C.mugShade);
877  }
878  if (s === "unknown") set(5, 4, C.skinShade);
879}
880
881function paintAsleep({ set, look }) {
882  chairUpright(set);
883  seatedLegs(set);
884  // Slumped forward, head on folded arms.
885  for (let y = 7; y <= 10; y++) for (let x = 3; x <= 6; x++) set(x, y, look.shirt);
886  for (let x = 6; x <= 10; x++) set(x, 11, look.shirt);
887  set(7, 8, look.hair);
888  set(8, 8, look.hair);
889  set(9, 8, look.hair);
890  set(7, 9, look.hair);
891  set(8, 9, C.skin);
892  set(9, 9, C.skin);
893  set(7, 10, C.skin);
894  set(8, 10, C.skin);
895  set(9, 10, C.skinShade);
896}
897
898function paintWaving({ set, line, look, blink, fast }) {
899  // Chair pushed back, standing, both arms up and waving.
900  for (let y = 7; y <= 12; y++) set(0, y, C.chair);
901  set(1, 12, C.chair);
902  set(2, 12, C.chair);
903  set(1, 16, C.chairDark);
904  for (let y = 13; y <= 15; y++) set(1, y, C.chairDark);
905  set(5, 1, look.hair);
906  set(6, 1, look.hair);
907  set(7, 1, look.hair);
908  for (let y = 2; y <= 4; y++) for (let x = 5; x <= 7; x++) set(x, y, C.skin);
909  set(5, 2, look.hair);
910  set(6, 3, C.eye);
911  set(7, 3, C.eye);
912  set(6, 4, blink ? C.eye : C.skinShade);
913  for (let y = 5; y <= 9; y++) for (let x = 4; x <= 7; x++) set(x, y, look.shirt);
914  for (let y = 10; y <= 15; y++) {
915    set(5, y, C.pants);
916    set(6, y, C.pants);
917  }
918  set(4, 16, C.shoe);
919  set(5, 16, C.shoe);
920  set(6, 16, C.shoe);
921  set(7, 16, C.shoe);
922  const left = fast ? [2, 1] : [1, 3];
923  const right = fast ? [10, 4] : [9, 2];
924  line(4, 5, left[0], left[1] + 1, look.shirt);
925  set(left[0], left[1], C.skin);
926  line(7, 5, right[0], right[1] + 1, look.shirt);
927  set(right[0], right[1], C.skin);
928}
929
930function paintRobot({ set, line, tick, s }) {
931  chairUpright(set);
932  // Legs: metal struts.
933  for (let x = 3; x <= 7; x++) set(x, 11, C.metalDeep);
934  for (let y = 12; y <= 15; y++) set(7, y, C.metalDeep);
935  set(7, 16, C.metalDark);
936  set(8, 16, C.metalDark);
937  // Antenna with a blinking tip.
938  set(5, 0, tick % 6 < 3 ? 0xef4444 : 0x22c55e);
939  set(5, 1, C.metalDark);
940  // Square head, scanning eye.
941  for (let y = 2; y <= 5; y++) for (let x = 3; x <= 7; x++) set(x, y, y === 2 ? C.metal : C.metalDark);
942  const eye = 5 + (Math.floor(tick / 2) % 3);
943  set(eye, 3, C.led);
944  set(4, 5, C.metalDeep);
945  set(6, 5, C.metalDeep);
946  // Body with a chest light.
947  for (let y = 6; y <= 10; y++) for (let x = 3; x <= 6; x++) set(x, y, C.metalDark);
948  set(4, 8, tick % 2 ? C.led : 0x0e7490);
949  set(5, 8, s === "running" ? 0xf59e0b : C.metalDeep);
950  // Piston arms, typing.
951  const down = tick % 2 === 0;
952  line(6, 7, 9, down ? 10 : 9, C.metal);
953  line(6, 8, 11, down ? 9 : 10, C.metalDeep);
954  set(9, down ? 10 : 9, C.led);
955}
956
957function paintCrown(set, s) {
958  // This session: a small gold crown on the worker's head.
959  const spots = {
960    working: [4, 1],
961    idle: [2, 1],
962    unknown: [2, 1],
963    done: [7, 7],
964    blocked: [5, 0],
965  }[s] ?? [4, 1];
966  const [x, y] = spots;
967  set(x, y, C.crown);
968  set(x + 2, y, C.crown);
969  set(x + 1, y, blend(C.crown, C.wall, 0.4));
970}
971
972function paintMinion(set, x, footY, t) {
973  // 3 wide, 4 tall: yellow head with a goggle, blue overalls, feet.
974  const y = footY - 3;
975  set(x - 1, y, C.minion);
976  set(x, y, C.minion);
977  set(x + 1, y, C.minion);
978  set(x - 1, y + 1, C.minionShade);
979  set(x, y + 1, t % 8 === 0 ? C.minion : C.goggle);
980  set(x + 1, y + 1, C.minionShade);
981  set(x - 1, y + 2, C.overalls);
982  set(x, y + 2, C.overalls);
983  set(x + 1, y + 2, C.overalls);
984  set(x - 1, y + 3, C.shoe);
985  set(x + 1, y + 3, C.shoe);
986}
987
988// ---- the 10 x 8 desk --------------------------------------------------------
989
990function paintCompact({ set, glyph, look, tick, desk, kind }) {
991  const s = kind === "empty" ? "done" : desk.status;
992  const isRobot = kind === "robot";
993  const blink = tick % 4 < 2;
994  for (let y = 0; y < 8; y++) for (let x = 0; x < 10; x++) set(x, y, y >= 6 ? C.floor : C.wall);
995  if (s === "blocked") for (let x = 0; x < 10; x++) set(x, 0, blink ? C.alertRed : C.alertAmber);
996  // Desk with its accent stripe, monitor.
997  for (let x = 4; x < 10; x++) {
998    set(x, 5, C.deskTop);
999    set(x, 6, ACCENT[s]);
1000  }
1001  for (let y = 1; y <= 4; y++) for (let x = 5; x <= 8; x++) set(x, y, C.frame);
1002  for (let y = 2; y <= 3; y++) {
1003    for (let x = 6; x <= 7; x++) {
1004      let c = C.screenOff;
1005      if (isRobot) c = (x + y + tick) % 2 ? C.termGreen[0] : C.termBg;
1006      else if (s === "working") c = C.code[noise(tick, x * 5 + y) % C.code.length];
1007      else if (s === "idle" || s === "unknown") c = (Math.floor(tick / 2) + x + y) % 4 === 0 ? C.saverDot : C.saver;
1008      else if (s === "blocked") c = blink ? C.alertRed : 0x5a1414;
1009      set(x, y, c);
1010    }
1011  }
1012  // Worker or robot; a tile's picture takes the worker's place.
1013  if (kind === "none") return;
1014  if (kind === "empty") {
1015    set(0, 4, C.chair);
1016    set(1, 4, C.chair);
1017    set(0, 5, C.chair);
1018    return;
1019  }
1020  if (kind !== "human" && !isRobot) {
1021    const furs = FURS[kind];
1022    look = { ...look, hair: furs ? furs[desk.look % furs.length][1] : 0xb35a30 };
1023  }
1024  set(0, 2, C.chair);
1025  set(0, 3, C.chair);
1026  set(0, 4, C.chair);
1027  set(0, 5, C.chair);
1028  const head = isRobot ? C.metal : kind === "human" ? C.skin : kind === "critter" ? 0xde7a4a : (FURS[kind]?.[desk.look % FURS[kind].length]?.[0] ?? C.skin);
1029  const body = isRobot ? C.metalDark : look.shirt;
1030  if (s === "done" && desk.unseenDone && !isRobot) {
1031    // Finished and not looked at: arms up, the screen a party.
1032    set(2, 1, look.hair);
1033    set(2, 2, head);
1034    set(2, 3, body);
1035    set(2, 4, body);
1036    set(blink ? 1 : 3, 0, C.skin);
1037    set(blink ? 3 : 1, 1, C.skin);
1038    set(6, 2, CONFETTI_GRID[tick % 6]);
1039    set(7, 3, CONFETTI_GRID[(tick + 2) % 6]);
1040    set(6 + (tick % 3), 0, CONFETTI_GRID[(tick + 4) % 6]);
1041  } else if (s === "done" && !isRobot) {
1042    set(1, 3, body);
1043    set(2, 4, body);
1044    set(3, 4, look.hair);
1045    set(4, 4, look.hair);
1046    set(1, 4, body);
1047    if (Math.floor(tick / 3) % 2) glyph(4, 0, "z", C.zzz);
1048  } else if (s === "blocked" && !isRobot) {
1049    set(2, 1, look.hair);
1050    set(2, 2, head);
1051    set(2, 3, body);
1052    set(2, 4, body);
1053    set(blink ? 1 : 3, 0, C.skin);
1054    set(blink ? 3 : 1, 1, C.skin);
1055  } else {
1056    const back = s === "idle" || s === "unknown" ? 1 : 2;
1057    set(back, 1, isRobot ? C.metal : look.hair);
1058    set(back, 2, head);
1059    if (isRobot) set(back + 1, 2, C.led);
1060    set(back, 3, body);
1061    set(back, 4, body);
1062    set(back + 1, 3, body);
1063    if (s === "working" || isRobot) set(tick % 2 ? 3 : 4, 4, isRobot ? C.metal : C.skin);
1064    if (s === "idle" || s === "unknown") {
1065      set(9, 4, C.mug);
1066      set(9, 3 - (tick % 3), blend(C.steam, C.wall, (tick % 3) / 3));
1067    }
1068  }
1069  if (desk.isSelf && !isRobot) set(2, 0, C.crown);
1070  const n = desk.helpers || 0;
1071  for (let i = 0; i < Math.min(5, n); i++) set(5 + i, 7, (tick + i) % 4 === 0 ? C.minionShade : C.minion);
1072  if (n > 5) glyph(9, 0, n > 9 ? "+" : String(n), C.minion);
1073}
1074
1075// ---- options ----------------------------------------------------------------
1076
1077// Every option: its manifest field, the values the settings view cycles
1078// through, the default, and the key that cycles it there.
1079export const OPTIONS = [
1080  { key: "layout", field: "layout", title: "Layout", values: ["grid", "tiles", "office", "war-room", "strip"], fallback: "grid", hotkey: "l" },
1081  { key: "characters", field: "characters", title: "Characters", values: ["critters", "humans", "robots", "animals", "mixed"], fallback: "critters", hotkey: "c" },
1082  { key: "style", field: "style", title: "Character style in cells", values: ["bold", "detailed"], fallback: "bold", hotkey: "b" },
1083  { key: "lighting", field: "lighting", title: "Lighting", values: ["clock", "day", "night"], fallback: "clock", hotkey: "t" },
1084  { key: "palette", field: "palette", title: "Palette", values: ["nightshade", "warm", "cool", "mono"], fallback: "nightshade", hotkey: "p" },
1085  { key: "show", field: "show", title: "Show", values: ["claude", "agents", "busy", "all"], fallback: "claude", hotkey: "w" },
1086  { key: "desks", field: "desks", title: "Desks at a time", values: [4, 6, 8, 12, "all"], fallback: 8, hotkey: "d" },
1087  { key: "hideDoneMinutes", field: "hide_done_minutes", title: "Hide done after (min)", values: [0, 5, 15, 30, 60], fallback: 0, hotkey: "h" },
1088  { key: "seating", field: "seating", title: "Seating", values: ["state", "stable", "project", "workspace"], fallback: "state", hotkey: "o" },
1089  { key: "speed", field: "speed", title: "Animation speed", values: ["slow", "normal", "fast"], fallback: "normal", hotkey: "m" },
1090  { key: "reducedMotion", field: "reduced_motion", title: "Reduced motion", values: [false, true], fallback: false, hotkey: "r" },
1091  { key: "notify", field: "notify", title: "Toasts", values: ["needs-you", "changes", "off"], fallback: "needs-you", hotkey: "n" },
1092  { key: "render", field: "render", title: "Characters as pictures (hybrid) or cells", values: ["hybrid", "cells"], fallback: "hybrid", hotkey: "g" },
1093  { key: "sound", field: "sound", title: "Sound when someone needs you", values: [false, true], fallback: false, hotkey: "u" },
1094];
1095
1096export const FRAME_MS = { slow: 260, normal: 150, fast: 90 };
1097
1098/**
1099 * The options in force: what the settings view stored, else the manifest's
1100 * userConfig value, else the built-in default. A value outside the list is ignored.
1101 */
1102export function resolveOptions(userConfig = {}, stored = {}) {
1103  const out = {};
1104  for (const o of OPTIONS) {
1105    const pick = [stored?.[o.key], userConfig?.[o.field]].find((v) => o.values.some((x) => String(x) === String(v)));
1106    const value = pick === undefined ? o.fallback : o.values.find((x) => String(x) === String(pick));
1107    out[o.key] = value;
1108  }
1109  return out;
1110}
1111
1112/** The next value of an option, as its settings key cycles it. */
1113export function cycleOption(options, key) {
1114  const o = OPTIONS.find((x) => x.key === key);
1115  if (!o) return options;
1116  const i = o.values.findIndex((v) => String(v) === String(options[key]));
1117  return { ...options, [key]: o.values[(i + 1) % o.values.length] };
1118}
1119
1120// ---- sessions: keys, overrides, changes, seats --------------------------------
1121
1122/** What a desk is remembered by: its Claude session, else its pane. */
1123export function deskKey(desk) {
1124  return desk.sessionId ? `s:${desk.sessionId}` : `p:${desk.paneId}`;
1125}
1126
1127/** Applies per-session settings: a label, a character, a pinned seat. */
1128export function applyOverrides(desks, overrides = {}) {
1129  return desks.map((d) => {
1130    const o = overrides[deskKey(d)];
1131    if (!o) return d;
1132    return {
1133      ...d,
1134      name: typeof o.label === "string" && o.label.trim() ? o.label.trim().slice(0, 40) : d.name,
1135      character: CHARACTER_KINDS.includes(o.character) ? o.character : d.character,
1136      pin: Number.isInteger(o.seat) && o.seat > 0 ? o.seat : undefined,
1137    };
1138  });
1139}
1140
1141/**
1142 * Notes when each desk's state last changed, and whether a finished session
1143 * has been looked at. Returns the desks with `changedAt`, `prevStatus` and
1144 * `unseenDone`, the memory to keep, the changes since last time (a desk seen
1145 * for the first time is not a change), and `unseen`: the finished sessions
1146 * nobody has looked at yet, by key, to keep across reloads.
1147 *
1148 * A session becomes unseen-done when it moves to done; it is seen once herdr
1149 * reports its pane focused, or it moves on from done. A session first met
1150 * already done counts as seen, unless `unseen` (from before a reload) says not.
1151 */
1152export function trackChanges(desks, memory = {}, now = 0, unseen = {}) {
1153  const next = {};
1154  const changes = [];
1155  const stillUnseen = {};
1156  const out = desks.map((d) => {
1157    const key = deskKey(d);
1158    const was = memory[key];
1159    let entry;
1160    if (!was) {
1161      const wasUnseen = d.status === "done" && unseen[key] != null;
1162      entry = { status: d.status, at: wasUnseen ? Number(unseen[key]) : now, prev: wasUnseen ? "working" : null, unseen: wasUnseen };
1163    } else if (was.status !== d.status) {
1164      entry = { status: d.status, at: now, prev: was.status, unseen: d.status === "done" };
1165      changes.push({ desk: d, from: was.status, to: d.status });
1166    } else {
1167      entry = { ...was };
1168    }
1169    if (entry.unseen && (d.status !== "done" || d.isFocused)) entry.unseen = false;
1170    next[key] = entry;
1171    if (entry.unseen) stillUnseen[key] = entry.at;
1172    return { ...d, changedAt: entry.at, prevStatus: entry.prev, unseenDone: entry.unseen };
1173  });
1174  return { desks: out, memory: next, changes, unseen: stillUnseen };
1175}
1176
1177/** Leaves out sessions that have been done for longer than `minutes`. */
1178export function hideStaleDone(desks, minutes, now) {
1179  if (!minutes) return desks;
1180  return desks.filter((d) => d.kind === "shell" || d.status !== "done" || now - (d.changedAt ?? now) < minutes * 60000);
1181}
1182
1183const FORGET_MS = 24 * 3600000;
1184
1185/**
1186 * Puts the desks in order, packed from the first seat with no gaps: the
1187 * returned `seats` has exactly one entry per desk. `state` (the default):
1188 * needs you, working, done, idle. `project`, `workspace`: grouped, then by
1189 * state. `stable`: the order each session last had (remembered in `seats`),
1190 * newcomers after, by state. A desk dragged to a place (`pin`, from 1) is
1191 * put back at that place in the packed order, or last when fewer desks are
1192 * left, so a pin never opens a gap. Returns the order and the memory to keep.
1193 */
1194export function seatDesks(desks, mode = "state", seats = {}, now = 0) {
1195  const memory = {};
1196  for (const [k, v] of Object.entries(seats ?? {})) if (v && now - (v.seen ?? 0) < FORGET_MS) memory[k] = v;
1197  const keyOf = mode === "project" ? (d) => d.project || "" : mode === "workspace" ? (d) => d.workspaceId || "" : () => "";
1198  const was = (d) => {
1199    const m = memory[deskKey(d)];
1200    return mode === "stable" && m && Number.isFinite(m.seat) ? m.seat : Infinity;
hooks/office-pictures.mjs 321 lines
1// Office: the picture characters. Kenney's CC0 people (assets/office-atlas.*)
2// and the mod's own robots, critters and animals, each drawn as a small
3// picture for one box of cells: the hybrid view's people and the tiles'
4// portraits. Pure: the pane and bin/office-monitor both call it.
5//
6// Sharpness: a mod cannot learn a cell's size in pixels, so a picture is
7// composed at one pixel per half cell, then enlarged pixel for pixel by a
8// large whole number and sent as a PNG. The terminal then only ever shrinks
9// a crisp picture, never smooths up a tiny one; enlarged pixels compress to
10// almost nothing.
11
12import { CAT, CONFETTI_HD, CRITTER, CROWN, DOG, HD_FURS, OWL, ROBOT } from "./office-art-hd.mjs";
13import { mix } from "./office-art.mjs";
14import { hash, kindFor } from "./office-model.mjs";
15import { poseFor } from "./office-scene.mjs";
16
17// ---- atlas ------------------------------------------------------------------
18
19/**
20 * The sprite atlas from its JSON (text or parsed) and its index bytes: every
21 * tile by name (`chr:0,0`), each pixel a palette index, 0 clear.
22 */
23export function loadAtlas(json, bytes) {
24  const meta = typeof json === "string" ? JSON.parse(json) : json;
25  if (!meta || !Array.isArray(meta.palette) || !meta.sprites) throw new Error("not an office atlas");
26  if (!bytes || bytes.length !== meta.width * meta.height) throw new Error("atlas size does not match its bytes");
27  const rgb = meta.palette.map(([r, g, b, a]) => (a ? (r << 16) | (g << 8) | b : -1));
28  return { width: meta.width, height: meta.height, idx: bytes, rgb, sprites: meta.sprites };
29}
30
31// ---- a small picture ----------------------------------------------------------------
32
33// Pixels as 0xRRGGBB, -1 for clear.
34class Pic {
35  constructor(w, h) {
36    this.w = w;
37    this.h = h;
38    this.px = new Int32Array(w * h).fill(-1);
39  }
40  set(x, y, c) {
41    x |= 0;
42    y |= 0;
43    if (x < 0 || y < 0 || x >= this.w || y >= this.h || c == null || c < 0) return;
44    this.px[y * this.w + x] = c;
45  }
46  rect(x, y, w, h, c) {
47    for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, c);
48  }
49  tile(atlas, name, x, y, rows = 16) {
50    const r = atlas.sprites[name];
51    if (!r) return;
52    const [sx, sy, w, h] = r;
53    for (let j = 0; j < Math.min(h, rows); j++) {
54      for (let i = 0; i < w; i++) {
55        const k = atlas.idx[(sy + j) * atlas.width + sx + i];
56        if (k) this.set(x + i, y + j, atlas.rgb[k]);
57      }
58    }
59  }
60  map(rows, pal, x, y) {
61    rows.forEach((row, j) => [...row].forEach((k, i) => k !== "." && pal[k] != null && this.set(x + i, y + j, pal[k])));
62  }
63}
64
65// ---- looks ------------------------------------------------------------------------
66
67/**
68 * A person's layers, picked from the session's hash so a session always
69 * looks the same: body (skin), trousers, shoes, top, hair, maybe a beard.
70 */
71export function pictureLook(desk, characters) {
72  const seed = hash(`hd:${desk.sessionId || desk.paneId || desk.name}`);
73  const kind = kindFor(desk, characters);
74  const pick = (n, shift) => (seed >>> shift) % n;
75  const leg = 1 + pick(9, 3);
76  const hairCol = [19, 20, 23, 24][pick(4, 14)];
77  const hairRow = [0, 1, 4, 5, 8, 9][pick(6, 17)];
78  const beard = pick(5, 21) === 0 ? `chr:${hairCol < 23 ? 21 : 25},${hairRow - (hairRow % 4)}` : null;
79  const furs = HD_FURS[kind];
80  return {
81    kind,
82    layers: [`chr:${pick(2, 0)},${pick(3, 1)}`, `chr:3,${leg}`, `chr:4,${leg}`, `chr:${6 + pick(12, 7)},${[0, 1, 2, 5, 6, 7][pick(6, 11)]}`, `chr:${hairCol},${hairRow}`, ...(beard ? [beard] : [])],
83    fur: furs ? furs[pick(furs.length, 24)] : null,
84  };
85}
86
87function creaturePal(look, desk) {
88  const isProcess = desk.kind === "process";
89  switch (look.kind) {
90    case "robot": {
91      const visor = isProcess ? 0xffa53a : desk.status === "blocked" ? 0xff4d4d : 0x5ee7ff;
92      return isProcess
93        ? { m: 0xd98a4a, M: 0xa8622c, v: visor, V: 0xffffff, c: 0xe8a060, L: 0xfff1a0, a: 0xff5c5c }
94        : { m: 0xb4bcc8, M: 0x7d8594, v: visor, V: 0xffffff, c: 0x96a0b0, L: 0x5fcf55, a: 0xff5c5c };
95    }
96    case "critter":
97      return { q: 0xd97757, Q: 0xa8553a, e: 0x2a1a14, h: 0xf09a78 };
98    default: {
99      const [f, d] = look.fur ?? [0xd2a676, 0x8a5c38];
100      return { F: f, D: d, E: mix(d, 0x000000, 0.2), w: 0xf6f0e6, e: 0x1e1a1c, n: 0x3a2a2a, m: 0x8a4a4a, p: 0xf0a0b4, y: 0xf2c94c, b: 0xe0a030 };
101    }
102  }
103}
104
105const CREATURES = { robot: ROBOT, critter: CRITTER, cat: CAT, dog: DOG, owl: OWL };
106
107// One person, 16 x 20, chair behind, in the pose of the moment.
108function personPic(atlas, desk, characters, tick, nowMs, reducedMotion) {
109  const one = new Pic(16, 20);
110  const look = pictureLook(desk, characters);
111  const pose = poseFor(desk, nowMs, reducedMotion);
112  const t = reducedMotion ? 0 : tick;
113  const dy = { type: t % 2, wave: [0, -1, -2, -1][t % 4], celebrate: [0, -2, -1][t % 3], sleep: 3 }[pose] ?? 0;
114  one.tile(atlas, "ind:0,8", 0, 5);
115  if (look.kind === "human") for (const name of look.layers) one.tile(atlas, name, 0, 4 + dy);
116  else one.map(CREATURES[look.kind] ?? CRITTER, creaturePal(look, desk), 0, 4 + dy);
117  if (pose === "stretch" || pose === "celebrate" || pose === "wave") {
118    const sk = look.kind === "human" ? 0xe5c49d : creaturePal(look, desk).F ?? 0xd97757;
119    const up = t % 2;
120    one.rect(0, 1 + dy + up, 2, 7, sk);
121    one.rect(14, 1 + dy + (1 - up), 2, 7, sk);
122  }
123  if (desk.isSelf) one.map(CROWN, { y: 0xffd23f, Y: 0xe0a810 }, 5, 1 + dy);
124  if (pose === "celebrate" && t) {
125    for (let k = 0; k < 6; k++) {
126      const h = hash(`pc${desk.paneId}${k}`);
127      one.set(h % 16, ((h >>> 5) + t * (1 + (k % 2))) % 20, CONFETTI_HD[k % CONFETTI_HD.length]);
128    }
129  }
130  return one;
131}
132
133/**
134 * A character for one box of cells, at one pixel per half cell (finer for a
135 * box smaller than a person): the box's
136 * background (the room's own pixels beneath it, from `bg`, or a plain
137 * `fill`), the person laid in at the largest whole-number scale that fits,
138 * standing on the box's bottom edge (the desk top). Returns the small picture.
139 */
140export function characterBox(atlas, desk, characters, tick, nowMs, reducedMotion, box, bg, fill = 0x1d1f2c) {
141  // A box smaller than a person (a sidebar's small desks, a tile) is drawn
142  // a whole number of times finer, keeping its shape: `m` pixels per half
143  // cell. The background is the room's pixel beneath, repeated m x m, so it
144  // still lines up exactly with the cells around the box.
145  const m = Math.max(1, Math.ceil(Math.max(16 / box.columns, 16 / (box.rows * 2))));
146  const W = box.columns * m;
147  const H = box.rows * 2 * m;
148  const pic = new Pic(W, H);
149  for (let y = 0; y < H; y++) {
150    for (let x = 0; x < W; x++) {
151      const by = Math.min(bg ? bg.h - 1 : 0, box.top * 2 + Math.floor(y / m));
152      const bx = Math.min(bg ? bg.w - 1 : 0, box.left + Math.floor(x / m));
153      pic.px[y * W + x] = bg ? bg.px[by * bg.w + bx] & 0xffffff : fill;
154    }
155  }
156  const one = personPic(atlas, desk, characters, tick, nowMs, reducedMotion);
157  const scale = Math.max(1, Math.floor(Math.min(W / 16, (H + 4) / 20)));
158  const ox = Math.floor((W - 16 * scale) / 2);
159  // The last four rows (legs) go below the box: the desk hides them.
160  const oy = H - 16 * scale;
161  for (let y = 0; y < 20; y++) {
162    for (let x = 0; x < 16; x++) {
163      const c = one.px[y * 16 + x];
164      if (c < 0) continue;
165      for (let j = 0; j < scale; j++) for (let i = 0; i < scale; i++) pic.set(ox + x * scale + i, oy + y * scale + j, c);
166    }
167  }
168  return pic;
169}
170
171/** Whether a desk's picture moves (and so is sent again now and then). */
172export function portraitMoves(desk, reducedMotion) {
173  if (reducedMotion || !desk) return false;
174  return desk.status === "working" || desk.status === "blocked" || desk.status === "running" || Boolean(desk.unseenDone);
175}
176
177// ---- sharp PNGs -----------------------------------------------------------------------
178
179/**
180 * How much to enlarge a box's picture. A mod cannot know a cell's size in
181 * pixels; a half cell on a Retina screen is about 16 by 17, so 16 times keeps
182 * the terminal shrinking the picture, never stretching it, at most 640 wide.
183 */
184export function sharpFactor(pic) {
185  return Math.max(4, Math.min(16, Math.floor(640 / Math.max(pic.w, pic.h))));
186}
187
188/**
189 * The picture enlarged `factor` times pixel for pixel, as a whole PNG. Each
190 * row is filtered against its left neighbour, or the row above when equal,
191 * so enlarged pixels become runs of zero bytes, which the deflate below
192 * stores as back-references: 3 to 11 KB for a character.
193 */
194export function sharpPng(pic, factor = sharpFactor(pic)) {
195  const W = pic.w * factor;
196  const H = pic.h * factor;
197  const stride = W * 4 + 1;
198  const raw = new Uint8Array(stride * H);
199  const row = new Uint8Array(W * 4);
200  for (let y = 0; y < pic.h; y++) {
201    for (let x = 0; x < pic.w; x++) {
202      const c = pic.px[y * pic.w + x];
203      const r = c < 0 ? 0 : (c >> 16) & 255;
204      const g = c < 0 ? 0 : (c >> 8) & 255;
205      const b = c < 0 ? 0 : c & 255;
206      const a = c < 0 ? 0 : 255;
207      for (let i = 0; i < factor; i++) {
208        const at = (x * factor + i) * 4;
209        row[at] = r;
210        row[at + 1] = g;
211        row[at + 2] = b;
212        row[at + 3] = a;
213      }
214    }
215    // First copy of the row: Sub filter (each byte less the one four before).
216    const first = y * factor * stride;
217    raw[first] = 1;
218    for (let i = 0; i < row.length; i++) raw[first + 1 + i] = (row[i] - (i >= 4 ? row[i - 4] : 0)) & 255;
219    // The repeats: Up filter, all zeros.
220    for (let j = 1; j < factor; j++) raw[first + j * stride] = 2;
221  }
222  return png(W, H, deflate(raw));
223}
224
225// zlib, one fixed-Huffman block, literals and distance-1 runs.
226function deflate(data) {
227  const out = [];
228  let bits = 0;
229  let nbits = 0;
230  const put = (value, n) => {
231    bits |= value << nbits;
232    nbits += n;
233    while (nbits >= 8) {
234      out.push(bits & 255);
235      bits >>>= 8;
236      nbits -= 8;
237    }
238  };
239  // Huffman codes go most significant bit first.
240  const code = (c, n) => {
241    let r = 0;
242    for (let i = 0; i < n; i++) r |= ((c >> i) & 1) << (n - 1 - i);
243    put(r, n);
244  };
245  const literal = (v) => (v < 144 ? code(0x30 + v, 8) : code(0x190 + v - 144, 9));
246  const symbol = (s) => (s < 280 ? code(s - 256, 7) : code(0xc0 + s - 280, 8));
247  const LBASE = [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];
248  const LEXTRA = [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];
249  const length = (len) => {
250    let i = LBASE.length - 1;
251    while (LBASE[i] > len) i--;
252    symbol(257 + i);
253    if (LEXTRA[i]) put(len - LBASE[i], LEXTRA[i]);
254    code(0, 5); // distance 1
255  };
256  out.push(0x78, 0x01);
257  put(1, 1);
258  put(1, 2);
259  let i = 0;
260  while (i < data.length) {
261    if (i > 0) {
262      let run = 0;
263      while (run < 258 && i + run < data.length && data[i + run] === data[i - 1]) run++;
264      if (run >= 3) {
265        length(run);
266        i += run;
267        continue;
268      }
269    }
270    literal(data[i]);
271    i++;
272  }
273  symbol(256);
274  if (nbits) out.push(bits & 255);
275  let a = 1;
276  let b = 0;
277  for (let k = 0; k < data.length; k++) {
278    a = (a + data[k]) % 65521;
279    b = (b + a) % 65521;
280  }
281  out.push((b >>> 8) & 255, b & 255, (a >>> 8) & 255, a & 255);
282  return Uint8Array.from(out);
283}
284
285const CRC = (() => {
286  const t = new Int32Array(256);
287  for (let n = 0; n < 256; n++) {
288    let c = n;
289    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
290    t[n] = c;
291  }
292  return t;
293})();
294
295function png(W, H, idat) {
296  const chunk = (type, data) => {
297    const out = new Uint8Array(12 + data.length);
298    const dv = new DataView(out.buffer);
299    dv.setUint32(0, data.length);
300    for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
301    out.set(data, 8);
302    let c = -1;
303    for (let i = 4; i < 8 + data.length; i++) c = CRC[(c ^ out[i]) & 255] ^ (c >>> 8);
304    dv.setUint32(8 + data.length, (c ^ -1) >>> 0);
305    return out;
306  };
307  const ihdr = new Uint8Array(13);
308  const dv = new DataView(ihdr.buffer);
309  dv.setUint32(0, W);
310  dv.setUint32(4, H);
311  ihdr.set([8, 6, 0, 0, 0], 8);
312  const parts = [Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))];
313  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
314  let at = 0;
315  for (const p of parts) {
316    out.set(p, at);
317    at += p.length;
318  }
319  return out;
320}
321
hooks/office-demo.mjs 62 lines
1// Office: demo mode. A scripted minute of eight sessions, in place of herdr
2// and the session records, for a screen recording: every state, bubble and
3// helper shows within one 60-second loop, the same on every take.
4//
5// Each session's script is a list of steps [fromSecond, status, activity,
6// helpers]; a step lasts until the next one. `seen` is the second the person
7// "looks at" a finished session, which ends its celebration.
8
9export const DEMO_LOOP_MS = 60_000;
10
11const SCRIPT = [
12  { name: "api-refactor", isSelf: true, steps: [[0, "working", "coding"], [8, "working", "reading"], [14, "working", "thinking"], [20, "working", "coding"], [30, "working", "writing"], [38, "working", "coding"]] },
13  { name: "docs-site", steps: [[0, "working", "writing"], [12, "working", "searching"], [18, "working", "writing"], [44, "working", "reading"], [52, "working", "writing"]] },
14  { name: "bug-triage", steps: [[0, "working", "searching"], [10, "working", "reading"], [15, "blocked", null], [27, "working", "thinking"], [35, "working", "coding"]] },
15  { name: "release-notes", seen: 40, steps: [[0, "working", "writing"], [30, "done", null]] },
16  { name: "test-suite", steps: [[0, "working", "thinking"], [6, "working", "coding"], [20, "working", "reading"], [26, "working", "coding"], [44, "working", "thinking"], [50, "working", "coding"]] },
17  { name: "db-migration", steps: [[0, "idle", null], [20, "working", "thinking"], [28, "working", "coding"], [46, "idle", null]] },
18  { name: "security-review", steps: [[0, "working", "delegating", 3], [30, "working", "delegating", 2], [45, "working", "writing", 0]] },
19  { name: "onboarding", steps: [[0, "idle", null]] },
20];
21
22// Under `characters: mixed`, one of each kind, so the cast shows the range.
23const MIXED = ["human", "robot", "critter", "cat", "human", "dog", "owl", "robot"];
24
25/** The demo's names, in script order. */
26export const DEMO_NAMES = SCRIPT.map((s) => s.name);
27
28/**
29 * The demo sessions at `elapsedMs` since demo mode turned on, as panes in
30 * the shape herdr's give (plus `activity`), and the helper counts by session.
31 * The loop repeats every 60 s. With `characters` "mixed" each gets a kind of
32 * its own; any other value is left to the option, as for real sessions.
33 */
34export function demoPanes(elapsedMs, characters = "") {
35  const t = ((Math.max(0, elapsedMs) % DEMO_LOOP_MS) + DEMO_LOOP_MS) % DEMO_LOOP_MS / 1000;
36  const helpers = {};
37  const panes = SCRIPT.map((s, i) => {
38    let step = s.steps[0];
39    for (const st of s.steps) if (st[0] <= t) step = st;
40    const [, status, activity, n = 0] = step;
41    const sessionId = `demo-${i + 1}-${s.name}`;
42    if (n) helpers[sessionId] = n;
43    return {
44      paneId: `demo:p${i + 1}`,
45      tabId: `demo:t${i + 1}`,
46      workspaceId: "demo",
47      agent: "claude",
48      status,
49      name: s.name,
50      cwd: `/demo/${s.name}`,
51      sessionId,
52      isSelf: Boolean(s.isSelf),
53      // Looked at: a finished session's celebration ends.
54      isFocused: s.seen !== undefined && t >= s.seen,
55      order: i,
56      activity: status === "working" ? activity : null,
57      ...(characters === "mixed" ? { character: MIXED[i] } : {}),
58    };
59  });
60  return { panes, helpers, second: Math.floor(t) };
61}
62
hooks/office-scene.mjs 1501 lines
1// Office: the scene painter. Lays out a room for every layout but `grid`
2// (which office-model.mjs paints), builds its static background once per
3// size, seating and time of day, and paints the animated sprites over it
4// each frame. Pure: the pane and bin/office-monitor both call it.
5
6import {
7  BOLD_HEADS,
8  BOLD_SMALL_HEADS,
9  CLOTHES,
10  CONFETTI,
11  COOLER,
12  FURS,
13  HAIR_COLOURS,
14  HAIRS,
15  HEAD_BACKS,
16  HEADS,
17  MINION,
18  MUG,
19  PALETTES,
20  PLANT,
21  SKIN_TONES,
22  SMALL_HAIRS,
23  SMALL_HEADS,
24  SMALL_MINION,
25  SMALL_TORSO,
26  TORSO,
27  mix,
28  skyAt,
29} from "./office-art.mjs";
30import { CHARACTER_KINDS, HOTKEYS, bubbleFor, hash, kindFor, layoutFor, paintWords, tagLines } from "./office-model.mjs";
31
32const UPPER_HALF = 0x2580;
33const OUTLINE = 0x1a1420;
34const EYE = 0x17131c;
35export { CHARACTER_KINDS, kindFor };
36
37// ---- canvas ---------------------------------------------------------------
38
39/** A pixel canvas two pixels per cell high, with text cells laid over it. */
40export class Canvas {
41  constructor(w, h, fill = 0) {
42    this.w = w;
43    this.h = h;
44    this.px = new Uint32Array(w * h).fill(fill);
45    this.glyphs = new Map();
46    // 0 lit, towards 1 dark: what night does to everything not emissive.
47    this.dark = 0;
48    this.nightTint = 0x0a0c1e;
49  }
50  shade(c) {
51    return this.dark > 0 ? mix(c, this.nightTint, this.dark) : c;
52  }
53  set(x, y, c, isEmissive = false) {
54    x |= 0;
55    y |= 0;
56    if (x < 0 || y < 0 || x >= this.w || y >= this.h || c == null) return;
57    this.px[y * this.w + x] = isEmissive ? c : this.shade(c);
58  }
59  get(x, y) {
60    x = Math.max(0, Math.min(this.w - 1, x | 0));
61    y = Math.max(0, Math.min(this.h - 1, y | 0));
62    return this.px[y * this.w + x];
63  }
64  // Mixes `c` into what is there: light, glow, shadow.
65  tint(x, y, c, a) {
66    x |= 0;
67    y |= 0;
68    if (x < 0 || y < 0 || x >= this.w || y >= this.h || a <= 0) return;
69    const i = y * this.w + x;
70    this.px[i] = mix(this.px[i], c, a);
71  }
72  rect(x, y, w, h, c, isEmissive = false) {
73    for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, c, isEmissive);
74  }
75  line(x0, y0, x1, y1, c, isEmissive = false) {
76    const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1);
77    for (let s = 0; s <= n; s++) this.set(Math.round(x0 + ((x1 - x0) * s) / n), Math.round(y0 + ((y1 - y0) * s) / n), c, isEmissive);
78  }
79  glow(cx, cy, r, c, a) {
80    for (let y = Math.floor(cy - r); y <= cy + r; y++) {
81      for (let x = Math.floor(cx - r * 1.4); x <= cx + r * 1.4; x++) {
82        const d = Math.hypot((x - cx) / 1.4, y - cy) / r;
83        if (d < 1) this.tint(x, y, c, a * (1 - d) * (1 - d));
84      }
85    }
86  }
87  /** Draws a palette-indexed map; `pal` maps a key to a colour. */
88  sprite(map, x, y, pal, opts = {}) {
89    for (let j = 0; j < map.length; j++) {
90      const row = map[j];
91      for (let i = 0; i < row.length; i++) {
92        const k = row[opts.flip ? row.length - 1 - i : i];
93        if (k === "." || k === " ") continue;
94        const c = pal[k];
95        if (c == null) continue;
96        this.set(x + i, y + j, c, opts.isEmissive);
97      }
98    }
99  }
100  /** A character over a cell; `bg` absent takes the pixel beneath. */
101  glyph(col, row, ch, fg, bg) {
102    if (col < 0 || row < 0 || col >= this.w || row * 2 >= this.h) return;
103    this.glyphs.set(row * this.w + col, { ch, fg, bg });
104  }
105  text(col, row, str, fg, bg) {
106    for (let i = 0; i < str.length; i++) this.glyph(col + i, row, str[i], fg, bg);
107  }
108  copyFrom(other) {
109    this.px.set(other.px);
110    this.glyphs = new Map(other.glyphs);
111  }
112  /** `[codePoint, fg, bg]` per cell, row-major. */
113  pack() {
114    const rows = this.h >> 1;
115    const W = this.w;
116    const words = new Uint32Array(W * rows * 3);
117    for (let r = 0; r < rows; r++) {
118      for (let c = 0; c < W; c++) {
119        const at = (r * W + c) * 3;
120        const g = this.glyphs.get(r * W + c);
121        const top = this.px[2 * r * W + c];
122        if (g) {
123          words[at] = g.ch.codePointAt(0);
124          words[at + 1] = g.fg;
125          words[at + 2] = g.bg ?? mix(top, this.px[(2 * r + 1) * W + c], 0.5);
126        } else {
127          words[at] = UPPER_HALF;
128          words[at + 1] = top;
129          words[at + 2] = this.px[(2 * r + 1) * W + c];
130        }
131      }
132    }
133    return words;
134  }
135}
136
137// ---- looks ------------------------------------------------------------------
138
139/** A stable look per session: the same seed always draws the same person. */
140export function lookFor(desk, characters, style) {
141  const seed = hash(`look:${desk.sessionId || desk.paneId || desk.name}`);
142  const kind = kindFor(desk, characters);
143  const isBold = style !== "detailed";
144  const pick = (list, shift) => list[(seed >>> shift) % list.length];
145  return {
146    kind,
147    skin: pick(SKIN_TONES, 0),
148    hair: pick(HAIR_COLOURS, 4),
149    hairStyle: pick(Object.keys(HAIRS), 8),
150    clothes: pick(CLOTHES, 12),
151    fur: kind in FURS ? pick(FURS[kind], 16) : null,
152    isBold,
153  };
154}
155
156function palFor(look, status, isProcess) {
157  const [cl, cs, ck] = look.clothes;
158  const base = { o: OUTLINE, e: EYE, c: cl, C: cs, k: ck };
159  switch (look.kind) {
160    case "robot": {
161      const visor = isProcess ? 0xffa53a : status === "blocked" ? 0xff4d4d : 0x5ee7ff;
162      const body = isProcess ? [0xc0703a, 0x8f4f26, 0xdd925a] : [0x7d8494, 0x5b6170, 0x9aa1b0];
163      return { ...base, m: 0xa9b0be, M: 0x6e7584, h: 0xd5dbe5, v: visor, V: 0xffffff, a: 0xff5c5c, s: 0xa9b0be, S: 0x6e7584, c: body[0], C: body[1], k: body[2] };
164    }
165    case "critter":
166      return { ...base, q: 0xde7a4a, Q: 0xb35a30, s: 0xde7a4a, S: 0xb35a30, h: 0xf09a6a, c: 0xde7a4a, C: 0xb35a30, k: 0xf09a6a };
167    case "cat":
168    case "dog":
169    case "owl": {
170      const [f, d] = look.fur;
171      return { ...base, F: f, D: d, W: f, p: 0xf0a0b4, n: 0x3a2a2a, m: 0x5a3030, y: 0xf2c94c, b: 0xe0a030, s: f, S: d, h: mix(f, 0xffffff, 0.25) };
172    }
173    default:
174      return { ...base, s: look.skin[0], S: look.skin[1], h: look.skin[2], m: 0x9a4a4a, r: look.hair[0], R: look.hair[1] };
175  }
176}
177
178// ---- time and light -----------------------------------------------------------
179
180/** The hour the room is lit for: the clock, or noon/midnight when fixed. */
181export function lightingHour(lighting, nowMs) {
182  if (lighting === "day") return 12;
183  if (lighting === "night") return 23;
184  const d = new Date(nowMs);
185  return d.getHours() + d.getMinutes() / 60;
186}
187
188function darknessAt(hour) {
189  const sky = skyAt(hour);
190  if (hour >= 7.5 && hour <= 17.5) return 0;
191  if (sky.isNight) return 0.55;
192  return 0.25;
193}
194
195// ---- plans --------------------------------------------------------------------
196
197/**
198 * Lays out a scene: where every desk sits, where its name tag goes, and the
199 * key its background is cached under. `seats` is the desk list in seat order;
200 * a `null` is an empty seat. `cols` x `rows` is the drawing size in cells.
201 */
202export function planScene(layout, seats, cols, rows, opts = {}) {
203  const W = Math.max(20, Math.min(512, Math.floor(cols)));
204  const Hrows = Math.max(6, Math.min(256, Math.floor(rows)));
205  // A room needs some width; a narrow pane gets the grid instead.
206  if (layout === "tiles") return planTiles(seats, W, Hrows, opts);
207  if (layout === "grid" || ((layout === "office" || layout === "war-room") && W < 48)) return planGrid(seats, W, Hrows, opts);
208  if (layout === "strip") return planStrip(seats, W, opts);
209  const plan = planRoomScaled(layout === "war-room" ? planWarRoom : planOffice, seats, W, Hrows, opts);
210  if (opts.hybrid) markHybrid(plan);
211  // A Raster is at most 256 rows: past that the room is cut, tags with it.
212  if (plan.rows > 256) {
213    plan.rows = 256;
214    plan.tags = plan.tags.filter((t) => t.top < 256);
215  }
216  return plan;
217}
218
219/**
220 * The hybrid view: which desks get their person as a picture, and the box of
221 * cells each picture covers, from the top of the chair to the desk top. A
222 * box smaller than a 16-pixel person gets a finer picture (office-pictures).
223 */
224function markHybrid(plan) {
225  const k = plan.k ?? 1;
226  const portraits = [];
227  plan.desks.forEach((d, i) => {
228    if (!d.seat || d.seat.kind === "shell" || portraits.length >= MAX_PORTRAITS) return;
229    let box;
230    if (plan.layout === "war-room") {
231      if (d.side !== "far") return;
232      box = plan.size === "small" ? { x: d.x - 1, y: d.y, w: 14, h: 12 } : { x: d.x - 1, y: d.y - 2, w: 18, h: 20 };
233    } else {
234      box = plan.size === "small" ? { x: d.x + 6, y: d.y, w: 12, h: 10 } : { x: d.x + 11, y: d.y, w: 18, h: 18 };
235    }
236    // Too small even for a finer picture (a 16-pixel person in under 6 x 3 cells).
237    if (box.w * k < 6 || box.h * k < 6) return;
238    d.isHybrid = true;
239    portraits.push({ index: i, left: box.x * k, top: (box.y * k) >> 1, columns: Math.min(255, box.w * k), rows: Math.min(255, (box.h * k) >> 1), isHybrid: true });
240  });
241  plan.portraits = portraits;
242  plan.isHybrid = portraits.length > 0;
243}
244
245// The room at the largest whole-number scale at which every desk fits: few
246// desks in a big pane get big desks, big names and big drag targets.
247function planRoomScaled(build, seats, W, Hrows, opts) {
248  let best = null;
249  for (const k of [3, 2, 1]) {
250    const w = Math.floor(W / k);
251    const h = Math.floor(Hrows / k);
252    if (k > 1 && (w < 60 || h < 14)) continue;
253    const inner = build(seats, w, h, opts);
254    const fits = inner.rows <= h;
255    const size = (inner.size === "small" ? 18 : 30) * k;
256    if ((fits || k === 1) && (!best || size > best.size)) best = { inner, k, size };
257  }
258  const { inner, k } = best;
259  if (k === 1) return { ...inner, k: 1 };
260  const tags = inner.tags.map((t) => {
261    const desk = inner.seats[t.index];
262    const width = t.width * k;
263    return { ...t, left: t.left * k, top: t.top * k + k - 1, width, ...tagLines(desk, t.index, width), second: undefined };
264  });
265  return { ...inner, k, logicalColumns: inner.columns, logicalRows: inner.rows, columns: Math.min(512, inner.columns * k), rows: inner.rows * k, tags };
266}
267
268function planGrid(seats, W, Hrows, opts) {
269  const g = layoutFor(seats.length, W, Hrows, { isFull: opts.isFull });
270  const tags = [];
271  seats.slice(0, g.shown).forEach((desk, i) => {
272    if (!desk) return;
273    const lines = tagLines(desk, i, g.tileW);
274    tags.push({
275      index: i,
276      left: (i % g.perRow) * (g.tileW + 1),
277      top: Math.floor(i / g.perRow) * g.tileH + g.artRows,
278      width: g.tileW,
279      ...lines,
280    });
281  });
282  return { layout: "grid", grid: g, columns: g.columns, rows: g.rows, seats, tags, opts };
283}
284
285// At most this many tile pictures in one drawing; past it, cells characters.
286export const MAX_PORTRAITS = 24;
287
288/**
289 * Tiles: the grid's cards, each with a box where the character's own little
290 * picture goes (`portraits`), in cells. Where pictures cannot be drawn
291 * (`opts.portraits === false`) it is the grid with bold characters.
292 */
293function planTiles(seats, W, Hrows, opts) {
294  const base = planGrid(seats, W, Hrows, opts);
295  if (opts.portraits === false) return { ...base, layout: "grid", isTiles: true };
296  const g = base.grid;
297  const box = { compact: [0, 0, 4, 3], normal: [1, 1, 8, 6], large: [2, 2, 16, 12] }[g.scale.name];
298  const portraits = [];
299  const marked = seats.map((desk, i) => {
300    if (!desk || desk.kind === "shell" || i >= g.shown || portraits.length >= MAX_PORTRAITS) return desk;
301    const [dx, dy, cols, rows] = box;
302    portraits.push({ index: i, left: (i % g.perRow) * (g.tileW + 1) + dx, top: Math.floor(i / g.perRow) * g.tileH + dy, columns: Math.min(255, cols), rows: Math.min(255, rows) });
303    return { ...desk, hasPortrait: true };
304  });
305  return { ...base, seats: marked, portraits, isTiles: true };
306}
307
308function planStrip(seats, W, opts) {
309  const n = Math.max(1, seats.length);
310  if (n * 11 - 1 <= W) {
311    const tile = 10;
312    const grid = { scale: { name: "compact", pw: 10, ph: 8, k: 1, tagRows: 2 }, tileW: tile, tileH: 6, artRows: 4, shown: n, perRow: n, deskRows: 1, columns: n * 11 - 1, rows: 6 };
313    const tags = seats.map((desk, i) => (desk ? { index: i, left: i * 11, top: 4, width: tile, ...tagLines(desk, i, tile) } : null)).filter(Boolean);
314    return { layout: "grid", grid, columns: grid.columns, rows: 6, seats, tags, opts };
315  }
316  // Too many for desks: a pip per session, a head over its state bar.
317  const per = Math.max(1, Math.min(n, Math.floor((W + 1) / 7)));
318  const lines = Math.ceil(n / per);
319  const tags = seats
320    .map((desk, i) => (desk ? { index: i, left: (i % per) * 7, top: Math.floor(i / per) * 5 + 4, width: 6, hotkey: HOTKEYS[i], label: desk.name.slice(0, 3), first: `${HOTKEYS[i] ?? " "}:${desk.name.slice(0, 4)}` } : null))
321    .filter(Boolean);
322  return { layout: "pips", per, columns: per * 7 - 1, rows: lines * 5, seats, tags, opts };
323}
324
325// Workstation sizes, in pixels: the desk sprite and the slot it sits in.
326const WS = {
327  regular: { w: 30, h: 28, slotW: 32, slotH: 32 },
328  small: { w: 18, h: 16, slotW: 21, slotH: 20 },
329};
330
331function planOffice(seats, W, Hrows, opts) {
332  const H = Hrows * 2;
333  const wallH = Math.max(18, Math.min(46, Math.round(H * 0.3))) & ~1;
334  const floorTop = wallH + 6;
335  const floorH = H - floorTop - 2;
336  const n = Math.max(1, seats.length);
337  const tries = [];
338  for (const size of ["regular", "small"]) {
339    for (const style of n <= 6 ? ["bench", "rows"] : ["pods", "rows"]) tries.push([size, style]);
340  }
341  let pick = null;
342  for (const [size, style] of tries) {
343    const p = arrange(n, size, style, W, floorH);
344    if (p.fits) {
345      pick = p;
346      break;
347    }
348    pick = pick ?? p;
349  }
350  if (!pick.fits) pick = arrange(n, "small", "rows", W, floorH);
351  const rowsNeeded = Math.max(Hrows, Math.ceil((floorTop + pick.height + 2) / 2));
352  // Centre the arrangement in the floor, leaving the aisle at the back.
353  const drop = Math.max(0, Math.floor((floorH - pick.height) / 2)) & ~1;
354  const desks = pick.spots.map((s, i) => ({ ...s, y: s.y + floorTop + drop, seat: seats[i] ?? null }));
355  const tags = [];
356  desks.forEach((d, i) => {
357    if (!d.seat) return;
358    const width = Math.max(6, pick.step - 1);
359    const left = Math.max(0, Math.min(W - width, d.x - Math.floor((pick.step - pick.ws.w) / 2)));
360    const lines = tagLines(d.seat, i, width);
361    tags.push({ index: i, left, top: (d.y + pick.ws.h) >> 1, width, ...lines, second: undefined });
362  });
363  const zones = zonesFor(desks, pick.ws, opts);
364  return {
365    layout: "office",
366    size: pick.size,
367    style: pick.style,
368    ws: pick.ws,
369    columns: W,
370    rows: rowsNeeded,
371    wallH,
372    desks,
373    zones,
374    tags,
375    seats,
376    opts,
377  };
378}
379
380// Places `n` workstations in a floor `W` x `floorH` pixels: a long bench, rows,
381// or pods of four with an aisle between pods.
382function arrange(n, size, style, W, floorH) {
383  const ws = WS[size];
384  const spots = [];
385  if (style === "pods") {
386    const podW = ws.slotW * 2 + 8;
387    const podH = ws.slotH * 2;
388    const podsPerRow = Math.max(1, Math.floor((W - 4) / podW));
389    const pods = Math.ceil(n / 4);
390    const podRows = Math.ceil(pods / podsPerRow);
391    const podGap = podRows > 1 ? Math.max(4, Math.min(12, Math.floor((floorH - podRows * podH) / (podRows - 1)))) & ~1 : 4;
392    const usedW = Math.min(pods, podsPerRow) * podW;
393    const x0 = Math.floor((W - usedW) / 2) + 4;
394    for (let i = 0; i < n; i++) {
395      const p = Math.floor(i / 4);
396      const q = i % 4;
397      const px = x0 + (p % podsPerRow) * podW + (q % 2) * ws.slotW;
398      const py = Math.floor(p / podsPerRow) * (podH + podGap) + Math.floor(q / 2) * ws.slotH;
399      spots.push({ x: px, y: py & ~1, pod: p });
400    }
401    const height = podRows * podH + (podRows - 1) * podGap;
402    return { size, style, ws, step: ws.slotW, spots, height, fits: height <= floorH && podW <= W };
403  }
404  const gap = style === "bench" ? 0 : ws.slotW - ws.w;
405  const step = ws.w + gap;
406  const perRow = Math.max(1, Math.floor((W - 4 + gap) / step));
407  const rowsN = Math.ceil(n / perRow);
408  const inRow = Math.min(n, perRow);
409  const x0 = Math.floor((W - (inRow * step - gap)) / 2);
410  const vGap = rowsN > 1 ? Math.max(0, Math.min(10, Math.floor((floorH - rowsN * ws.slotH) / (rowsN - 1)))) & ~1 : 0;
411  for (let i = 0; i < n; i++) {
412    spots.push({ x: x0 + (i % perRow) * step, y: (Math.floor(i / perRow) * (ws.slotH + vGap)) & ~1, pod: Math.floor(i / perRow) });
413  }
414  const height = rowsN * ws.slotH + (rowsN - 1) * vGap;
415  return { size, style, ws, step, spots, height, fits: height <= floorH };
416}
417
418// Rugs: one per pod or bench row; with seating by project or workspace, one
419// per group, labelled.
420function zonesFor(desks, ws, opts) {
421  const groups = new Map();
422  desks.forEach((d) => {
423    const key = opts.zoneBy && d.seat ? String(d.seat[opts.zoneBy] ?? "") : `pod${d.pod}`;
424    if (!groups.has(key)) groups.set(key, []);
425    groups.get(key).push(d);
426  });
427  let i = 0;
428  return [...groups.entries()].map(([key, list]) => {
429    const x0 = Math.min(...list.map((d) => d.x)) - 3;
430    const y0 = Math.min(...list.map((d) => d.y)) + Math.floor(ws.h * 0.45);
431    const x1 = Math.max(...list.map((d) => d.x + ws.w)) + 3;
432    const y1 = Math.max(...list.map((d) => d.y + ws.h)) + 1;
433    return { key, label: opts.zoneBy ? key : "", x: x0, y: y0, w: x1 - x0, h: y1 - y0, colour: i++ };
434  });
435}
436
437function planWarRoom(seats, W, Hrows, opts) {
438  const H = Hrows * 2;
439  const wallH = Math.max(18, Math.min(46, Math.round(H * 0.3))) & ~1;
440  const n = Math.max(1, seats.length);
441  const size = n > 16 || W < 120 ? "small" : "regular";
442  const seatW = size === "regular" ? 24 : 14;
443  const perSide = Math.max(1, Math.min(Math.ceil(n / 2), Math.floor((W - 16) / seatW)));
444  const tables = Math.ceil(n / (perSide * 2));
445  const tableW = perSide * seatW + 10;
446  const farH = size === "regular" ? 18 : 12;
447  const nearH = size === "regular" ? 20 : 12;
448  const tableH = size === "regular" ? 12 : 8;
449  const blockH = farH + tableH + nearH + 6;
450  const top = wallH + 6;
451  const tx = Math.floor((W - tableW) / 2);
452  const desks = [];
453  for (let i = 0; i < n; i++) {
454    const t = Math.floor(i / (perSide * 2));
455    const j = i % (perSide * 2);
456    const isFar = j < perSide;
457    const k = isFar ? j : j - perSide;
458    const by = top + t * blockH;
459    desks.push({
460      x: tx + 5 + k * seatW,
461      y: (isFar ? by : by + farH + tableH) & ~1,
462      side: isFar ? "far" : "near",
463      table: t,
464      seat: seats[i] ?? null,
465    });
466  }
467  const tags = [];
468  desks.forEach((d, i) => {
469    if (!d.seat) return;
470    const width = seatW - 1;
471    const y = d.side === "far" ? d.y - 2 : d.y + nearH;
472    tags.push({ index: i, left: Math.max(0, d.x - 1), top: Math.max(0, y >> 1), width, ...tagLines(d.seat, i, width), second: undefined });
473  });
474  const rowsNeeded = Math.max(Hrows, Math.ceil((top + tables * blockH + 4) / 2));
475  return {
476    layout: "war-room",
477    size,
478    columns: W,
479    rows: rowsNeeded,
480    wallH,
481    seatW,
482    farH,
483    nearH,
484    tableH,
485    tables: Array.from({ length: tables }, (_, t) => ({ x: tx, y: top + t * blockH + farH, w: tableW, h: tableH })),
486    desks,
487    tags,
488    seats,
489    opts,
490  };
491}
492
493// ---- painting ---------------------------------------------------------------
494
495/**
496 * One frame of a plan as `[codePoint, fg, bg]` words. `nowMs` drives the
497 * clock, the sky and the after-a-change animations; `tick` the rest.
498 */
499export function paintPlan(plan, tick, nowMs = Date.now()) {
500  const words = paintBase(plan, tick, nowMs);
501  // While a desk is dragged: its own place dimmed, the place under the pointer lit.
502  if (plan.drag) {
503    const hits = hitRects(plan);
504    const from = hits.find((h) => h.index === plan.drag.from);
505    const over = hits.find((h) => h.index === plan.drag.over);
506    if (from) outline(words, plan.columns, plan.rows, from, 0x8a8494);
507    if (over && over !== from) outline(words, plan.columns, plan.rows, over, 0xffd23f);
508  }
509  return words;
510}
511
512/**
513 * Where each seat sits, in cells: `{ index, left, top, width, height }`,
514 * art and name tag together, empty seats included. What a pointer is
515 * tested against.
516 */
517export function hitRects(plan) {
518  if (plan.hits) return plan.hits;
519  const out = [];
520  if (plan.layout === "grid") {
521    const g = plan.grid;
522    for (let i = 0; i < Math.min(plan.seats.length, g.shown); i++) {
523      out.push({ index: i, left: (i % g.perRow) * (g.tileW + 1), top: Math.floor(i / g.perRow) * g.tileH, width: g.tileW, height: g.tileH });
524    }
525  } else if (plan.layout === "pips") {
526    plan.seats.forEach((_, i) => out.push({ index: i, left: (i % plan.per) * 7, top: Math.floor(i / plan.per) * 5, width: 6, height: 5 }));
527  } else if (plan.layout === "war-room") {
528    const k = plan.k ?? 1;
529    plan.desks.forEach((d, i) => {
530      const top = d.side === "far" ? Math.max(0, (d.y >> 1) - 2) : (d.y >> 1);
531      out.push({ index: i, left: Math.max(0, d.x - 1) * k, top: top * k, width: (plan.seatW - 1) * k, height: (plan.size === "small" ? 8 : 12) * k });
532    });
533  } else {
534    const k = plan.k ?? 1;
535    // The whole workstation and its tag: a big target to drag.
536    plan.desks.forEach((d, i) => out.push({ index: i, left: d.x * k, top: (d.y >> 1) * k, width: plan.ws.w * k, height: ((plan.ws.h >> 1) + 1) * k }));
537  }
538  plan.hits = out.map((h) => ({ ...h, width: Math.max(1, Math.min(h.width, plan.columns - h.left)), height: Math.max(1, Math.min(h.height, plan.rows - h.top)) }));
539  return plan.hits;
540}
541
542/** The seat under a cell, or -1. */
543export function seatAt(plan, x, y) {
544  const h = hitRects(plan).find((r) => x >= r.left && x < r.left + r.width && y >= r.top && y < r.top + r.height);
545  return h ? h.index : -1;
546}
547
548// A one-pixel frame round a rect of cells, drawn into packed words.
549function outline(words, cols, rows, r, colour) {
550  const put = (c, row, part) => {
551    if (c < 0 || row < 0 || c >= cols || row >= rows) return;
552    const at = (row * cols + c) * 3;
553    if (words[at] !== UPPER_HALF) {
554      words[at + 2] = colour;
555      return;
556    }
557    if (part !== "bottom") words[at + 1] = colour;
558    if (part !== "top") words[at + 2] = colour;
559  };
560  for (let c = r.left; c < r.left + r.width; c++) {
561    put(c, r.top, "top");
562    put(c, r.top + r.height - 1, "bottom");
563  }
564  for (let row = r.top; row < r.top + r.height; row++) {
565    put(r.left, row, "both");
566    put(r.left + r.width - 1, row, "both");
567  }
568}
569
570function paintBase(plan, tick, nowMs) {
571  if (plan.layout === "grid") return paintWords(plan.grid, plan.seats.slice(0, plan.grid.shown), tick, plan.opts);
572  const canvas = new Canvas(plan.columns, plan.rows * 2, 0);
573  if (plan.layout === "pips") {
574    paintPips(canvas, plan, tick);
575    return canvas.pack();
576  }
577  const final = paintRoomCanvas(plan, tick, nowMs);
578  paintBubbles(final, plan);
579  paintTags(final, plan);
580  return final.pack();
581}
582
583/**
584 * The room's picture without its words, at the plan's full size: drawn at
585 * the logical size, then each pixel made a k x k block. What the hybrid
586 * view crops a character's background from.
587 */
588export function paintRoomCanvas(plan, tick, nowMs) {
589  const k = plan.k ?? 1;
590  const lw = plan.logicalColumns ?? plan.columns;
591  const lh = plan.logicalRows ?? plan.rows;
592  const canvas = new Canvas(lw, lh * 2, 0);
593  const hour = lightingHour(plan.opts.lighting, nowMs);
594  const bgKey = `${plan.layout}:${lw}x${lh}:${Math.floor(hour * 4)}:${plan.opts.palette}:${plan.desks.map((d) => (d.seat ? `${d.x},${d.y}` : "-")).join(";")}`;
595  if (!plan.bg || plan.bgKey !== bgKey) {
596    plan.bg = new Canvas(lw, lh * 2, 0);
597    paintRoom(plan.bg, plan, hour);
598    plan.bgKey = bgKey;
599  }
600  canvas.copyFrom(plan.bg);
601  canvas.dark = darknessAt(hour) * 0.8;
602  const t = plan.opts.reducedMotion ? Math.floor(tick / 4) : tick;
603  paintRoomLife(canvas, plan, t, nowMs, hour);
604  if (plan.layout === "war-room") paintWarRoomPeople(canvas, plan, t, nowMs);
605  else for (const d of plan.desks) paintWorkstation(canvas, plan, d, t, nowMs);
606  return k === 1 ? canvas : upscaleCanvas(canvas, k, plan.columns, plan.rows);
607}
608
609// Each pixel a k x k block; a character over a cell lands in the middle of its block.
610function upscaleCanvas(src, k, columns, rows) {
611  const out = new Canvas(columns, rows * 2, 0);
612  for (let y = 0; y < out.h; y++) {
613    const sy = Math.min(src.h - 1, Math.floor(y / k));
614    for (let x = 0; x < out.w; x++) out.px[y * out.w + x] = src.px[sy * src.w + Math.min(src.w - 1, Math.floor(x / k))];
615  }
616  for (const [i, g] of src.glyphs) {
617    const col = (i % src.w) * k + Math.floor(k / 2);
618    const row = Math.floor(i / src.w) * k + Math.floor(k / 2);
619    out.glyph(col, row, g.ch, g.fg, g.bg);
620  }
621  return out;
622}
623
624// The bubbles' words, in cells over the picture: what each session is doing.
625const BUBBLE_TONES = {
626  alert: [0xffffff, 0xd8352a],
627  work: [0x1e1a26, 0xf4f2ee],
628  party: [0x1e1a26, 0xffd23f],
629  quiet: [0x4a4658, 0xc8c4d4],
630};
631
632export function bubbleSpots(plan) {
633  const k = plan.k ?? 1;
634  const out = [];
635  if (plan.layout === "pips" || plan.layout === "grid") return out;
636  plan.desks.forEach((d, i) => {
637    const bubble = d.seat && bubbleFor(d.seat);
638    if (!bubble) return;
639    if (plan.layout === "war-room" && d.side === "near" && d.seat.status !== "blocked") return;
640    const isSmall = plan.size === "small";
641    const headX = plan.layout === "war-room" ? d.x + 2 : d.x + (isSmall ? 8 : 13);
642    const headY = plan.layout === "war-room" ? (d.side === "far" ? d.y - 4 : d.y - 2) : d.y - 1;
643    // Above a hybrid picture, never under it.
644    const box = plan.portraits?.find((p) => p.index === i);
645    const row = Math.floor((headY * k) / 2);
646    out.push({ index: i, col: Math.max(0, headX * k), row: Math.max(0, box ? Math.min(row, box.top - 1) : row), ...bubble });
647  });
648  return out;
649}
650
651function paintBubbles(c, plan) {
652  for (const b of bubbleSpots(plan)) {
653    const [fg, bg] = BUBBLE_TONES[b.tone];
654    const text = ` ${b.text} `;
655    const col = Math.max(0, Math.min(c.w - text.length, b.col));
656    c.text(col, b.row, text, fg, bg);
657  }
658}
659
660function pal(plan) {
661  return PALETTES[plan.opts.palette] ?? PALETTES.nightshade;
662}
663
664// ---- the room ---------------------------------------------------------------
665
666function paintRoom(c, plan, hour) {
667  const P = pal(plan);
668  const W = c.w;
669  const H = c.h;
670  const wallH = plan.wallH;
671  const sky = skyAt(hour);
672  c.dark = darknessAt(hour);
673
674  // Back wall: a soft vertical gradient, a dado rail, the baseboard.
675  for (let y = 0; y < wallH; y++) {
676    const col = mix(P.wallShade, P.wall, Math.min(1, y / (wallH * 0.7)));
677    for (let x = 0; x < W; x++) c.set(x, y, (x + y * 3) % 17 === 0 ? mix(col, P.wallShade, 0.3) : col);
678  }
679  for (let x = 0; x < W; x++) {
680    c.set(x, Math.floor(wallH * 0.62), P.trim);
681    c.set(x, wallH - 3, P.trim);
682    c.set(x, wallH - 2, P.baseboard);
683    c.set(x, wallH - 1, P.baseboard);
684  }
685
686  // Floor: wooden planks in perspective toward a point above the room.
687  const vx = W / 2;
688  const vy = wallH - (H - wallH) * 1.6;
689  let y = wallH;
690  let row = 0;
691  while (y < H) {
692    const depth = (y - wallH) / Math.max(1, H - wallH);
693    const ph = 2 + Math.round(depth * 3);
694    for (let j = 0; j < ph && y + j < H; j++) {
695      const yy = y + j;
696      // Plank seams: lines from the vanishing point, staggered per row.
697      for (let x = 0; x < W; x++) {
698        const fx = vx + ((x - vx) * (wallH - vy)) / (yy - vy);
699        const seamW = 9;
700        const lane = Math.floor((fx + (row % 2) * (seamW / 2)) / seamW);
701        const tone = hash(`${lane}:${row}`) % 3;
702        let col = tone === 0 ? P.floorA : tone === 1 ? P.floorB : mix(P.floorA, P.floorB, 0.5);
703        const onSeam = Math.abs(((fx + (row % 2) * (seamW / 2)) % seamW)) < 0.6;
704        if (onSeam || j === ph - 1) col = P.floorSeam;
705        else if (j === 0) col = mix(col, 0xffffff, 0.06);
706        c.set(x, yy, mix(col, P.dark, 0.25 * (1 - depth)));
707      }
708    }
709    y += ph;
710    row++;
711  }
712  // Skirting shadow where floor meets wall.
713  for (let x = 0; x < W; x++) {
714    c.tint(x, wallH, P.dark, 0.5);
715    c.tint(x, wallH + 1, P.dark, 0.25);
716  }
717
718  // Windows, with the sky of the hour.
719  const props = wallProps(plan);
720  for (const w of props.windows) paintWindow(c, w, sky, hour, P);
721  // Sun patches below the windows by day.
722  if (!sky.isNight && hour > 7 && hour < 18) {
723    const slant = (hour - 12.5) * 1.2;
724    for (const w of props.windows) {
725      for (let j = 0; j < Math.min(14, H - wallH); j++) {
726        for (let i = 0; i < w.w; i++) c.tint(Math.round(w.x + i + slant * j * 0.4), wallH + 2 + j, 0xfff1c8, 0.16 * (1 - j / 14));
727      }
728    }
729  }
730
731  // Wall furniture.
732  if (props.shelf) paintBookshelf(c, props.shelf, P);
733  if (props.poster) paintPoster(c, props.poster, P);
734  if (props.clock) paintClockFace(c, props.clock, P);
735  if (props.rack) paintRack(c, props.rack, P);
736  if (props.cooler) c.sprite(COOLER, props.cooler.x, props.cooler.y, { o: OUTLINE, w: 0x8fd3ff, W: 0xc8ecff, x: 0xdedede, X: 0xb4b4b4, b: 0x3b82f6, r: 0xef4444 });
737  for (const p of props.plants) c.sprite(PLANT, p.x, p.y, { o: OUTLINE, g: 0x4f9a4f, G: 0x3a7a3e, L: 0x7cc46c, P: 0xb8693e, p: 0xd88a5a });
738
739  // Rugs (pods, benches, or one per workspace or project).
740  if (plan.layout === "office") {
741    for (const z of plan.zones) {
742      const col = P.rugs[z.colour % P.rugs.length];
743      for (let j = 0; j < z.h; j++) {
744        for (let i = 0; i < z.w; i++) {
745          const edge = i === 0 || j === 0 || i === z.w - 1 || j === z.h - 1;
746          const inner = i === 2 || j === 2 || i === z.w - 3 || j === z.h - 3;
747          c.set(z.x + i, z.y + j, edge ? mix(col, P.dark, 0.4) : inner ? mix(col, 0xffffff, 0.15) : col);
748        }
749      }
750      if (z.label) c.text(Math.max(0, z.x + 2), (z.y + z.h + 1) >> 1, ` ${z.label.slice(0, Math.max(1, z.w - 4))} `, 0xe8e6ef, mix(col, P.dark, 0.4));
751    }
752    // Desks' static parts and their floor shadows.
753    for (const d of plan.desks) paintDeskStatic(c, plan, d, P);
754  } else {
755    for (const t of plan.tables) paintTable(c, t, P);
756  }
757
758  // Ceiling lights: warm pools over the desks, stronger at night.
759  const pools = plan.layout === "office" ? plan.zones.map((z) => [z.x + z.w / 2, z.y + z.h / 2, Math.max(z.w, z.h) * 0.55]) : plan.tables.map((t) => [t.x + t.w / 2, t.y + t.h / 2, t.w * 0.45]);
760  const strength = sky.isNight ? 0.3 : 0.08;
761  for (const [x, yc, r] of pools) c.glow(x, yc, r, 0xffd9a0, strength);
762  c.dark = 0;
763}
764
765// Where the windows and the wall furniture go for this width.
766function wallProps(plan) {
767  const W = plan.columns;
768  const wallH = plan.wallH;
769  const out = { windows: [], plants: [] };
770  const winH = Math.max(8, Math.floor(wallH * 0.5));
771  const winY = Math.max(2, Math.floor(wallH * 0.12));
772  // Left: bookshelf; right: server rack; middle: windows around a clock.
773  const shelfW = W >= 70 ? 22 : 0;
774  const rackW = W >= 50 ? 11 : 0;
775  if (shelfW) out.shelf = { x: 4, y: Math.max(1, wallH - 3 - Math.floor(wallH * 0.75)), w: shelfW, h: Math.floor(wallH * 0.75) };
776  if (rackW) out.rack = { x: W - rackW - 3, y: Math.max(1, wallH - Math.floor(wallH * 0.9) + 2), w: rackW, h: Math.floor(wallH * 0.9) + 2 };
777  const left = 4 + shelfW + 6;
778  const right = W - rackW - 8 - (W >= 90 ? 14 : 0);
779  const span = right - left;
780  const nWin = Math.max(1, Math.min(4, Math.floor(span / 46)));
781  const winW = Math.min(34, Math.floor(span / nWin) - 10);
782  const clockAt = W >= 60 ? Math.floor(nWin / 2) : -1;
783  for (let i = 0; i < nWin; i++) {
784    const slot = left + Math.floor((span * (i + 0.5)) / nWin);
785    out.windows.push({ x: slot - Math.floor(winW / 2) + (clockAt >= 0 && i >= clockAt ? 6 : clockAt >= 0 ? -6 : 0), y: winY, w: Math.max(8, winW), h: winH });
786  }
787  if (clockAt >= 0) out.clock = { x: left + Math.floor((span * clockAt) / nWin), y: winY + Math.floor(winH / 2), r: 4 };
788  if (W >= 90) out.poster = { x: right + 3, y: winY + 1, w: 10, h: 13 };
789  if (W >= 60) out.cooler = { x: 4 + shelfW + 1, y: wallH - 9 };
790  out.plants.push({ x: 0, y: wallH - 8 });
791  if (W >= 50) out.plants.push({ x: W - rackW - 15, y: wallH - 8 });
792  plan.props = out;
793  return out;
794}
795
796function paintWindow(c, w, sky, hour, P) {
797  c.rect(w.x - 1, w.y - 1, w.w + 2, w.h + 3, P.trim);
798  for (let j = 0; j < w.h; j++) {
799    const col = mix(sky.top, sky.bottom, j / w.h);
800    for (let i = 0; i < w.w; i++) c.set(w.x + i, w.y + j, col, true);
801  }
802  // City skyline at the bottom of the window.
803  for (let i = 0; i < w.w; i++) {
804    const bh = 2 + (hash(`b${Math.floor((w.x + i) / 3)}`) % Math.max(2, Math.floor(w.h / 3)));
805    for (let j = 0; j < bh; j++) {
806      const y = w.y + w.h - 1 - j;
807      const lit = sky.isNight && hash(`l${w.x + i},${j}`) % 7 === 0;
808      c.set(w.x + i, y, lit ? 0xffd27a : mix(sky.bottom, 0x1a1a2e, sky.isNight ? 0.75 : 0.45), true);
809    }
810  }
811  if (sky.isNight) {
812    for (let k = 0; k < Math.floor(w.w / 4); k++) {
813      const h = hash(`s${w.x},${k}`);
814      c.set(w.x + (h % w.w), w.y + ((h >> 8) % Math.max(1, w.h - 5)), 0xf2f0ff, true);
815    }
816    if (w.x % 3 === 0) {
817      c.rect(w.x + w.w - 6, w.y + 2, 3, 3, 0xf4efd0, true);
818      c.set(w.x + w.w - 6, w.y + 2, mix(sky.top, 0xf4efd0, 0.3), true);
819    }
820  } else if (hour >= 6 && hour < 19) {
821    // Clouds.
822    for (let k = 0; k < 2; k++) {
823      const h = hash(`c${w.x},${k}`);
824      const cx = w.x + (h % Math.max(1, w.w - 8));
825      const cy = w.y + 2 + ((h >> 6) % Math.max(1, Math.floor(w.h / 3)));
826      c.rect(cx + 1, cy, 4, 1, 0xffffff, true);
827      c.rect(cx, cy + 1, 7, 1, 0xf2f6ff, true);
828    }
829  }
830  // Mullions and a sill.
831  const mid = w.x + Math.floor(w.w / 2);
832  for (let j = 0; j < w.h; j++) c.set(mid, w.y + j, P.trim);
833  for (let i = 0; i < w.w; i++) c.set(w.x + i, w.y + Math.floor(w.h / 2), P.trim);
834  for (let i = -2; i < w.w + 2; i++) {
835    c.set(w.x + i, w.y + w.h + 1, mix(P.trim, 0xffffff, 0.2));
836    c.set(w.x + i, w.y + w.h + 2, P.baseboard);
837  }
838}
839
840function paintBookshelf(c, s, P) {
841  c.rect(s.x, s.y, s.w, s.h, P.woodDark);
842  c.rect(s.x + 1, s.y + 1, s.w - 2, s.h - 2, mix(P.woodDark, 0x000000, 0.35));
843  const shelves = Math.max(2, Math.floor(s.h / 6));
844  const sh = Math.floor((s.h - 2) / shelves);
845  const spines = [0xb5442b, 0x2f6f9a, 0xd9b25e, 0x4f8a4f, 0x7a4f9a, 0xe0e0d0, 0x9a6a3a, 0x3a3a4a];
846  for (let k = 0; k < shelves; k++) {
847    const by = s.y + 1 + (k + 1) * sh - 1;
848    c.rect(s.x + 1, by, s.w - 2, 1, P.wood);
849    let x = s.x + 2;
850    while (x < s.x + s.w - 3) {
851      const h = hash(`book${s.x},${k},${x}`);
852      const bw = 1 + (h % 2);
853      const bh = Math.max(2, sh - 1 - ((h >> 3) % 3));
854      if (h % 11 === 0) {
855        x += 2;
856        continue;
857      }
858      const col = spines[(h >> 5) % spines.length];
859      c.rect(x, by - bh, bw, bh, col);
860      c.set(x, by - bh, mix(col, 0xffffff, 0.3));
861      x += bw;
862    }
863  }
864}
865
866function paintPoster(c, p, P) {
867  c.rect(p.x, p.y, p.w, p.h, P.trim);
868  c.rect(p.x + 1, p.y + 1, p.w - 2, p.h - 2, 0x24222c);
869  // A small pixel mascot: the orange critter.
870  c.sprite(HEADS.critter, p.x, p.y + 2, { o: 0x24222c, q: 0xde7a4a, Q: 0xb35a30, e: 0x1a1420 });
871}
872
873function paintClockFace(c, k, P) {
874  for (let y = -k.r - 1; y <= k.r + 1; y++) {
875    for (let x = -k.r - 1; x <= k.r + 1; x++) {
876      const d = Math.hypot(x, y);
877      if (d <= k.r + 0.6) c.set(k.x + x, k.y + y, d > k.r - 0.6 ? P.dark : 0xf2efe6);
878    }
879  }
880}
881
882function paintRack(c, r, P) {
883  c.rect(r.x, r.y, r.w, r.h, 0x1b1c22);
884  c.rect(r.x + 1, r.y + 1, r.w - 2, r.h - 2, 0x26272f);
885  for (let y = r.y + 2; y < r.y + r.h - 2; y += 3) {
886    c.rect(r.x + 2, y, r.w - 4, 2, 0x31333d);
887    c.set(r.x + 2, y, 0x454857);
888  }
889  c.rect(r.x, r.y + r.h, r.w, 1, P.dark);
890}
891
892function paintTable(c, t, P) {
893  c.rect(t.x + 2, t.y + t.h, t.w - 4, 3, mix(P.dark, 0x000000, 0.2));
894  c.rect(t.x, t.y, t.w, t.h, P.woodTop);
895  for (let i = 0; i < t.w; i++) {
896    c.set(t.x + i, t.y, P.woodLight);
897    c.set(t.x + i, t.y + t.h - 1, P.woodDark);
898  }
899  for (let j = 1; j < t.h - 1; j++) {
900    c.set(t.x, t.y + j, P.woodDark);
901    c.set(t.x + t.w - 1, t.y + j, P.woodDark);
902    for (let i = 4; i < t.w - 4; i += 11) c.set(t.x + i + (j % 3), t.y + j, mix(P.woodTop, P.woodDark, 0.25));
903  }
904  // Papers, a pen pot and a plant on the table.
905  for (let k = 0; k < Math.floor(t.w / 30); k++) {
906    const h = hash(`paper${t.x},${t.y},${k}`);
907    const px = t.x + 6 + (h % Math.max(1, t.w - 14));
908    const py = t.y + 4 + ((h >> 6) % Math.max(1, t.h - 7));
909    c.rect(px, py, 3, 2, 0xece8de);
910    c.set(px + 2, py + 1, 0xc9c3b4);
911  }
912  const mid = t.x + Math.floor(t.w / 2);
913  c.rect(mid - 1, t.y + Math.floor(t.h / 2) - 1, 3, 2, 0xb8693e);
914  c.set(mid, t.y + Math.floor(t.h / 2) - 2, 0x4f9a4f);
915  c.set(mid - 1, t.y + Math.floor(t.h / 2) - 3, 0x7cc46c);
916  c.set(mid + 1, t.y + Math.floor(t.h / 2) - 3, 0x3a7a3e);
917}
918
919function paintDeskStatic(c, plan, d, P) {
920  const s = plan.size === "small";
921  const ws = plan.ws;
922  // Shadow under the desk.
923  for (let i = 1; i < ws.w - 1; i++) {
924    c.tint(d.x + i, d.y + ws.h, P.dark, 0.45);
925    c.tint(d.x + i, d.y + ws.h + 1, P.dark, 0.2);
926  }
927  if (!d.seat) {
928    // An empty seat: the desk alone, chair tucked in, screen off.
929    paintDesk(c, d.x, d.y, s, P, 0x555566);
930    paintMonitor(c, d.x, d.y, s, null, 0);
931  }
932}
933
934// ---- workstations -------------------------------------------------------------
935
936// The desk: a top in light wood seen a little from above, the front panel,
937// the state's accent stripe, legs.
938function paintDesk(c, x, y, isSmall, P, accent) {
939  const w = isSmall ? 18 : 30;
940  const top = isSmall ? 10 : 18;
941  const depth = isSmall ? 2 : 4;
942  const front = isSmall ? 3 : 5;
943  for (let i = 0; i < w; i++) {
944    for (let j = 0; j < depth; j++) c.set(x + i, y + top + j, j === depth - 1 ? P.woodLight : i === 0 || i === w - 1 ? P.wood : P.woodTop);
945    for (let j = 0; j < front; j++) c.set(x + i, y + top + depth + j, j === 1 ? accent : i === 0 || i === w - 1 ? P.woodDark : j === front - 1 ? P.woodDark : P.wood);
946  }
947  c.set(x, y + top, OUTLINE);
948  c.set(x + w - 1, y + top, OUTLINE);
949  const legY = y + top + depth + front;
950  for (let j = 0; j < (isSmall ? 1 : 2); j++) {
951    c.set(x + 1, legY + j, P.woodDark);
952    c.set(x + w - 2, legY + j, P.woodDark);
953  }
954}
955
956// A monitor three-quarters on, its screen showing the state.
957function paintMonitor(c, x, y, isSmall, desk, t, status) {
958  const sx = isSmall ? 1 : 2;
959  const sy = isSmall ? 3 : 7;
960  const sw = isSmall ? 5 : 8;
961  const sh = isSmall ? 6 : 9;
962  c.rect(x + sx - 1, y + sy - 1, sw + 2, sh + 2, 0x23252e);
963  for (let j = 0; j < sh + 1; j++) c.set(x + sx + sw + 1, y + sy + j, 0x15161c);
964  c.set(x + sx - 1, y + sy - 1, 0x3a3d4a);
965  c.rect(x + sx + Math.floor(sw / 2) - 1, y + sy + sh + 1, 2, 1, 0x2b2d36);
966  c.rect(x + sx + Math.floor(sw / 2) - 2, y + sy + sh + 2, 4, 1, 0x3a3d4a);
967  if (!desk) {
968    c.rect(x + sx, y + sy, sw, sh, 0x0b0c10);
969    c.set(x + sx + sw - 2, y + sy + 1, 0x262a36);
970    return;
971  }
972  screenPixels(c, x + sx, y + sy, sw, sh, desk, t, status);
973}
974
975export function screenPixels(c, x, y, w, h, desk, t, status) {
976  const isRobot = desk.kind === "process";
977  for (let j = 0; j < h; j++) {
978    for (let i = 0; i < w; i++) {
979      let col = 0x07080c;
980      if (isRobot) {
981        const ln = j + Math.floor(t / 2);
982        const hh = hash(`${desk.paneId}:${ln}`);
983        col = i < 1 + (hh % (w - 1)) ? [0x4ade80, 0x22c55e, 0x86efac][(hh >> 4) % 3] : 0x041008;
984      } else if (status === "working") {
985        const ln = j + t;
986        const hh = hash(`${desk.paneId}:${ln}`);
987        const indent = hh % 3;
988        const len = 1 + ((hh >> 3) % Math.max(1, w - indent - 1));
989        col = i >= indent && i < indent + len ? [0x7ab8ff, 0xb3d4ff, 0xc4a8ff, 0x7ef0a0, 0xffd27a, 0xff9ac8][(hh >> 7) % 6] : 0x0d1428;
990      } else if (status === "idle" || status === "unknown") {
991        const k = Math.floor(t / 3);
992        const dx = Math.abs((k % (2 * (w - 1))) - (w - 1));
993        const dy = Math.abs(((k + 3) % (2 * (h - 1))) - (h - 1));
994        col = i === dx && j === dy ? 0x9be7ff : 0x13254a;
995      } else if (status === "blocked") {
996        const on = t % 4 < 2;
997        const midX = Math.floor(w / 2);
998        const bang = i === midX && (j < h - 3 || j === h - 2);
999        col = bang ? 0xffffff : on ? 0xb91c1c : 0x6b1414;
1000      } else if (status === "celebrate") {
1001        col = CONFETTI[(i + j + t) % CONFETTI.length];
1002      }
1003      c.set(x + i, y + j, col, true);
1004    }
1005  }
1006}
1007
1008function screenColour(desk, status) {
1009  if (desk.kind === "process") return 0x4ade80;
1010  return { working: 0x6aa8ff, idle: 0x3a6fd0, unknown: 0x3a6fd0, blocked: 0xff3b3b, celebrate: 0xffd23f }[status] ?? null;
1011}
1012
1013// What a desk is doing this frame, with the after-a-change moments.
1014// A finished session nobody has looked at yet keeps celebrating: 4 s of
1015// cheering, 3 s of stretching, round again, until its pane is focused.
1016export function poseFor(desk, nowMs, reducedMotion) {
1017  if (desk.status === "done" && desk.unseenDone) {
1018    if (reducedMotion) return "stretch";
1019    const since = Math.max(0, nowMs - (desk.changedAt ?? nowMs));
1020    return since % 7000 < 4000 ? "celebrate" : "stretch";
1021  }
1022  if (desk.kind === "process") return "type";
1023  return { working: "type", idle: "lean", unknown: "lean", done: "sleep", blocked: "wave" }[desk.status] ?? "lean";
1024}
1025
1026function paintWorkstation(c, plan, d, t, nowMs) {
1027  const desk = d.seat;
1028  if (!desk) return;
1029  const P = pal(plan);
1030  const isSmall = plan.size === "small";
1031  const status = desk.status;
1032  const pose = poseFor(desk, nowMs, plan.opts.reducedMotion);
1033  const look = lookFor(desk, plan.opts.characters, plan.opts.style);
1034  const palette = palFor(look, status, desk.kind === "process");
1035  const accent = P.accent[status] ?? P.accent.unknown;
1036  const x = d.x;
1037  const y = d.y;
1038  const blink = t % 4 < 2;
1039  const screenStatus = pose === "celebrate" ? "celebrate" : status === "done" ? "done" : status;
1040
1041  // Red light thrown round a desk that needs you.
1042  if (status === "blocked") c.glow(x + (isSmall ? 9 : 15), y + (isSmall ? 8 : 12), isSmall ? 12 : 20, blink ? 0xff2a2a : 0xff9a1a, blink ? 0.4 : 0.22);
1043
1044  // In the hybrid view the person is a picture laid over this spot.
1045  const drawsPerson = !d.isHybrid;
1046  if (drawsPerson) paintChair(c, x, y, isSmall, pose, t, plan.opts.reducedMotion);
1047  if (drawsPerson && pose !== "sleep") paintPerson(c, x, y, isSmall, pose, look, palette, t, desk);
1048  paintDesk(c, x, y, isSmall, P, accent);
1049  if (drawsPerson && pose === "sleep") paintSleeper(c, x, y, isSmall, look, palette);
1050  if (!isSmall) {
1051    if (pose !== "lean" || !drawsPerson) c.sprite(MUG, x + 25, y + 15, { o: OUTLINE, w: 0xeeeae2, c: 0x5b3a22 });
1052    paintKeyboard(c, x, y, pose, t);
1053    if (drawsPerson && pose === "lean") paintFeetUp(c, x, y, look, t, plan.opts.reducedMotion);
1054  }
1055  if (drawsPerson && (pose === "type" || pose === "lean" || pose === "stretch" || pose === "celebrate")) paintArms(c, x, y, isSmall, pose, palette, t, plan.opts.reducedMotion);
1056  paintMonitor(c, x, y, isSmall, desk, t, screenStatus);
1057
1058  // Screen light on the face and the desk.
1059  const glowC = screenColour(desk, screenStatus);
1060  if (glowC && pose !== "sleep") {
1061    const fx = x + (isSmall ? 9 : 15);
1062    const fy = y + (isSmall ? 4 : 8);
1063    c.glow(fx, fy, isSmall ? 4 : 7, glowC, 0.22 + (t % 3 === 0 && status === "working" ? 0.08 : 0));
1064    c.glow(x + (isSmall ? 4 : 7), y + (isSmall ? 11 : 19), isSmall ? 4 : 7, glowC, 0.18);
1065  }
1066
1067  // Life: typing sparks, steam, zZ, beacon, confetti.
1068  const reduced = plan.opts.reducedMotion;
1069  if (pose === "type" && !reduced && !isSmall) {
1070    for (let k = 0; k < 2; k++) {
1071      const h = hash(`spark${desk.paneId}${t}${k}`);
1072      if (h % 3 === 0) c.set(x + 13 + (h % 10), y + 17 - ((h >> 4) % 3), [0xfff3a0, 0x9be7ff, 0xffffff][(h >> 8) % 3], true);
1073    }
1074  }
1075  if (pose === "lean" && !reduced) {
1076    const sip = t % 24 >= 20;
1077    const mx = isSmall ? x + 13 : sip ? x + 19 : x + 23;
1078    const my = isSmall ? y + 6 : sip ? y + 9 : y + 13;
1079    for (let k = 0; k < 4; k++) {
1080      const ph = (t + k * 3) % 12;
1081      const sy = my - 1 - Math.floor(ph * 0.6);
1082      const sx = mx + Math.round(Math.sin((t + k * 5) / 2.5));
1083      if (sy > y - 2) c.tint(sx, sy, 0xe8ecf5, 0.75 * (1 - ph / 12));
1084    }
1085  }
1086  if (pose === "sleep") {
1087    const cols = [x + (isSmall ? 12 : 22), x + (isSmall ? 13 : 24), x + (isSmall ? 14 : 26)];
1088    for (let k = 0; k < 3; k++) {
1089      const ph = (Math.floor(t / 2) + k * 4) % 12;
1090      const gy = y + (isSmall ? 6 : 12) - Math.floor(ph * 0.9);
1091      if (gy >= 0 && !reduced) c.glyph(cols[k], gy >> 1, ph < 5 ? "z" : "Z", mix(0xd8ccff, 0x6a6a88, ph / 13));
1092    }
1093    if (reduced) c.glyph(cols[1], (y + (isSmall ? 2 : 6)) >> 1, "z", 0xd8ccff);
1094  }
1095  if (status === "blocked") {
1096    const bx = x + (isSmall ? 3 : 6);
1097    const by = y + (isSmall ? 0 : 2);
1098    c.rect(bx - 1, by + 2, 4, 1, 0x3a3d4a);
1099    c.rect(bx, by, 2, 2, blink || reduced ? 0xff3b3b : 0xffb020, true);
1100    c.set(bx, by, 0xffffff, true);
1101    c.glyph(x + (isSmall ? 15 : 26), (y + (isSmall ? 0 : 1)) >> 1, "!", blink ? 0xffb020 : 0xff3b3b);
1102  }
1103  if (pose === "celebrate" && !reduced) {
1104    for (let k = 0; k < (isSmall ? 6 : 14); k++) {
1105      const h = hash(`conf${desk.paneId}${k}`);
1106      const cx = x + (h % (isSmall ? 18 : 30));
1107      const cy = y - 4 + ((h >> 5) + t * (1 + (k % 3))) % (isSmall ? 18 : 28);
1108      c.set(cx + Math.round(Math.sin((t + k) / 2)), cy, CONFETTI[k % CONFETTI.length], true);
1109    }
1110  }
1111
1112  // Helpers: minions around the desk, and a count.
1113  const n = desk.helpers || 0;
1114  if (n > 0) {
1115    const spots = isSmall ? [[-3, 12], [19, 12], [-3, 7]] : [[-5, 21], [30, 21], [-5, 14], [30, 14]];
1116    spots.slice(0, Math.min(spots.length, n)).forEach(([dx, dy], i) => {
1117      const hop = !reduced && (t + i * 2) % 6 === 0 ? 1 : 0;
1118      if (isSmall) c.sprite(SMALL_MINION, x + dx, y + dy - hop, { y: 0xfacc15, u: 0x2563eb });
1119      else c.sprite(MINION, x + dx, y + dy - hop, { y: 0xfacc15, G: 0xe5e7eb, g: (t + i) % 9 === 0 ? 0xfacc15 : 0x1f2937, u: 0x2563eb, o: OUTLINE });
1120    });
1121    const label = `x${n}`;
1122    c.text(x + (isSmall ? 18 : 29) - label.length, y >> 1, label, 0xfacc15, null);
1123  }
1124  if (desk.isSelf && drawsPerson) {
1125    const cx = x + (isSmall ? 11 : 18);
1126    const cy = y + (pose === "wave" ? 0 : isSmall ? 1 : 3);
1127    c.set(cx, cy, 0xfacc15, true);
1128    c.set(cx + 2, cy, 0xfacc15, true);
1129    c.set(cx + 4, cy, 0xfacc15, true);
1130    c.rect(cx, cy + 1, 5, 1, 0xe0b010, true);
1131  }
1132}
1133
1134function paintChair(c, x, y, isSmall, pose, t, reduced) {
1135  const swivel = pose === "lean" && !reduced ? Math.round(Math.sin(t / 6)) : 0;
1136  const pushed = pose === "wave" ? 3 : 0;
1137  const cx = x + (isSmall ? 8 : 13) + swivel + pushed;
1138  const cy = y + (isSmall ? 4 : 8) + pushed;
1139  const w = isSmall ? 9 : 13;
1140  const h = isSmall ? 7 : 11;
1141  for (let j = 0; j < h; j++) {
1142    for (let i = 0; i < w; i++) {
1143      const corner = (j === 0 || j === h - 1) && (i === 0 || i === w - 1);
1144      if (corner) continue;
1145      const edge = i === 0 || i === w - 1 || j === 0;
1146      c.set(cx + i, cy + j, edge ? 0x1e2129 : i < 2 ? 0x454b5c : j < 2 ? 0x4c5366 : 0x363b49);
1147    }
1148  }
1149}
1150
1151function paintPerson(c, x, y, isSmall, pose, look, palette, t, desk) {
1152  const isLean = pose === "lean";
1153  const isWave = pose === "wave";
1154  const lift = isWave ? (isSmall ? 3 : 5) : 0;
1155  if (isSmall) {
1156    const hx = x + 9 + (isLean ? 1 : 0);
1157    const hy = y + 2 - lift + (isLean ? -1 : 0);
1158    c.sprite(SMALL_TORSO, x + 8, y + 7 - lift, palette);
1159    paintHead(c, hx, hy, look, palette, pose, t, true, desk, look.isBold);
1160    if (isWave) {
1161      const up = t % 2 === 0;
1162      c.line(x + 8, y + 7 - lift, x + (up ? 6 : 7), y + (up ? 0 : 2) - lift, palette.c);
1163      c.line(x + 15, y + 7 - lift, x + (up ? 17 : 16), y + (up ? 2 : 0) - lift, palette.c);
1164      c.set(x + (up ? 6 : 7), y + (up ? -1 : 1) - lift, palette.s);
1165      c.set(x + (up ? 17 : 16), y + (up ? 1 : -1) - lift, palette.s);
1166    }
1167    return;
1168  }
1169  const hx = x + 14 + (isLean ? 1 : 0);
1170  const hy = y + 3 - lift + (isLean ? -1 : 0);
1171  c.sprite(TORSO, x + 13, y + 12 - lift, palette);
1172  paintHead(c, hx, hy, look, palette, pose, t, false, desk, look.isBold);
1173  if (isWave) {
1174    // Both arms up, waving out of step.
1175    const a = t % 2 === 0;
1176    const L = a ? [10, -1] : [8, 2];
1177    const R = a ? [28, 2] : [27, -1];
1178    for (const [sx, [hx2, hy2]] of [[14, L], [23, R]]) {
1179      c.line(x + sx, y + 13 - lift, x + hx2, y + hy2 + 2 - lift, OUTLINE);
1180      c.line(x + sx, y + 12 - lift, x + hx2, y + hy2 + 1 - lift, palette.c);
1181      c.rect(x + hx2 - 1, y + hy2 - lift, 2, 2, palette.s);
1182    }
1183  }
1184}
1185
1186function paintHead(c, x, y, look, palette, pose, t, isSmall, desk, isBold) {
1187  const kind = look.kind;
1188  const p = { ...palette };
1189  if (isBold) {
1190    // Bold: one chunky block per kind, the hair or ears part of it.
1191    if (pose === "stretch" || pose === "celebrate" || (pose === "lean" && t % 18 >= 16)) p.e = p.S ?? p.e;
1192    if (kind === "robot") p.v = t % 6 < 3 ? p.v : mix(p.v, 0xffffff, 0.4);
1193    c.sprite((isSmall ? BOLD_SMALL_HEADS : BOLD_HEADS)[kind] ?? BOLD_HEADS.critter, x, y, p);
1194    return;
1195  }
1196  const blinkNow = (t + (desk.look ?? 0)) % 23 === 0;
1197  if (pose === "stretch" || pose === "celebrate" || blinkNow || (pose === "lean" && t % 18 >= 16)) p.e = p.S ?? p.e;
1198  if (kind === "robot") p.v = t % 6 < 3 ? p.v : mix(p.v, 0xffffff, 0.4);
1199  if (isSmall) {
1200    c.sprite(SMALL_HEADS[kind] ?? SMALL_HEADS.human, x, y, p);
hooks/office-sessions.mjs 190 lines
1// Office: sessions without herdr. Every session running this mod keeps a
2// small file in a shared registry folder (its id, cwd, project, state,
3// helpers, last heartbeat); recently written transcripts fill in sessions
4// that do not run the mod. Pure: the pane and bin/office-monitor both use it.
5//
6// A file per session, not $.store: the store is one JSON file per plugin,
7// and sessions writing their own keys into it at once would overwrite each
8// other's copies.
9
10export const REGISTRY_TTL_MS = 2 * 60000;
11export const HEARTBEAT_MS = 30000;
12export const RECENT_MS = 30 * 60000;
13const IDLE_MS = 5 * 60000;
14
15// The registry's states, as the office's statuses.
16const STATUS_OF = { working: "working", idle: "idle", "needs-you": "blocked", done: "done" };
17
18/** Where the registry lives under a Claude config folder. */
19export function registryDir(root) {
20  return `${String(root).replace(/\/+$/, "")}/plugins/data/office-registry`;
21}
22
23/** A project's name from Claude Code's folder for it (`-Users-me-Projects-qube` is qube). */
24export function projectFromDir(dir) {
25  const parts = String(dir ?? "").split("-").filter(Boolean);
26  return parts[parts.length - 1] ?? String(dir ?? "");
27}
28
29function baseName(path) {
30  return String(path ?? "").split("/").filter(Boolean).pop() ?? "";
31}
32
33/** One registry entry, the session's own word, as a file's text. */
34export function registryEntry({ id, cwd, state, helpers = 0, activity = null, now }) {
35  return { id, cwd, project: baseName(cwd), state, helpers, activity, lastSeen: now };
36}
37
38/**
39 * Parses registry files (text) into live entries: well formed, heard from in
40 * the last two minutes, not ended.
41 */
42export function liveEntries(texts, now) {
43  const out = new Map();
44  for (const text of texts) {
45    let e;
46    try {
47      e = JSON.parse(text);
48    } catch {
49      continue;
50    }
51    if (!e || typeof e.id !== "string" || !STATUS_OF[e.state]) continue;
52    if (!(now - Number(e.lastSeen || 0) < REGISTRY_TTL_MS)) continue;
53    const was = out.get(e.id);
54    if (!was || was.lastSeen < e.lastSeen) out.set(e.id, e);
55  }
56  return [...out.values()];
57}
58
59/**
60 * The panes the office draws when herdr is not there: the registry's live
61 * sessions, then recent transcripts of sessions not in it (idle when written
62 * in the last five minutes, done before that). `recent` holds
63 * `{ id, dir, mtimeMs }` per transcript.
64 */
65export function sessionPanes(entries, recent, now, selfId) {
66  const panes = [];
67  const helpers = {};
68  const seen = new Set();
69  for (const e of entries) {
70    seen.add(e.id);
71    helpers[e.id] = Math.max(0, Number(e.helpers) || 0);
72    panes.push({
73      paneId: `session:${e.id}`,
74      tabId: "",
75      workspaceId: "",
76      agent: "claude",
77      status: STATUS_OF[e.state],
78      name: e.project || baseName(e.cwd) || e.id.slice(0, 8),
79      cwd: e.cwd ?? "",
80      sessionId: e.id,
81      isSelf: e.id === selfId,
82      isFocused: false,
83      order: panes.length,
84      source: "registry",
85      activity: e.activity ?? null,
86      terminalTitle: "",
87    });
88  }
89  for (const r of [...recent].sort((a, b) => b.mtimeMs - a.mtimeMs)) {
90    if (seen.has(r.id) || now - r.mtimeMs > RECENT_MS) continue;
91    seen.add(r.id);
92    panes.push({
93      paneId: `session:${r.id}`,
94      tabId: "",
95      workspaceId: "",
96      agent: "claude",
97      status: now - r.mtimeMs < IDLE_MS ? "idle" : "done",
98      name: projectFromDir(r.dir),
99      cwd: "",
100      sessionId: r.id,
101      isSelf: r.id === selfId,
102      isFocused: false,
103      order: panes.length,
104      source: "transcript",
105      projectDir: r.dir,
106      terminalTitle: "",
107    });
108  }
109  return { panes, helpers };
110}
111
112/** The transcripts in a project folder's listing written in the last half hour. */
113export function recentTranscripts(dir, entries, now) {
114  return (entries ?? [])
115    .filter((e) => e && e.kind === "file" && /^[0-9a-f-]{20,}\.jsonl$/i.test(String(e.name)) && now - Number(e.mtimeMs || 0) < RECENT_MS)
116    .map((e) => ({ id: String(e.name).replace(/\.jsonl$/, ""), dir, mtimeMs: Number(e.mtimeMs) }));
117}
118
119/** What to say about a session outside herdr, and the command that resumes it. */
120export function resumeHint(desk) {
121  const where = desk.cwd || (desk.projectDir ? `project ${projectFromDir(desk.projectDir)}` : "an unknown folder");
122  return { text: `claude --resume ${desk.sessionId}`, where };
123}
124
125// ---- Claude Code's own registry of running sessions ------------------------------
126// `<config>/sessions/<pid>.json`, one per running session. Only the .json
127// files are read: the .key files beside them are secrets, never opened.
128
129/** Whether a file in a sessions folder may be read: a .json record, nothing else. */
130export function isSessionRecord(name) {
131  return /^[0-9]+\.json$/.test(String(name));
132}
133
134/** The pids to ask `ps` about, from the records. */
135export function recordPids(records) {
136  return [...new Set(records.map((r) => Number(r?.pid)).filter((n) => Number.isInteger(n) && n > 0))];
137}
138
139/** Which of the asked pids `ps -o pid= -p a,b,c` says are running. */
140export function alivePids(stdout) {
141  return new Set(String(stdout ?? "").split(/\s+/).filter(Boolean).map(Number).filter(Number.isInteger));
142}
143
144// Claude Code's statuses, as the office's: busy is working; anything waiting
145// on the person (permission, input, a question) needs you.
146export function statusOfRecord(status) {
147  const s = String(status ?? "").toLowerCase();
148  if (s === "busy" || s === "working" || s === "running") return "working";
149  if (/wait|permission|input|ask|block|approv/.test(s)) return "blocked";
150  return "idle";
151}
152
153/**
154 * The panes the office draws from Claude Code's session records: live when
155 * the pid runs or the record was updated in the last two minutes. A session
156 * that also runs this mod adds its richer state and activity from `entries`.
157 */
158export function claudeSessionPanes(records, alive, now, selfId, entries = []) {
159  const byId = new Map(entries.map((e) => [e.id, e]));
160  const panes = [];
161  const helpers = {};
162  const seen = new Set();
163  for (const r of records) {
164    if (!r || typeof r.sessionId !== "string" || seen.has(r.sessionId)) continue;
165    const isLive = alive.has(Number(r.pid)) || now - Number(r.updatedAt || 0) < REGISTRY_TTL_MS;
166    if (!isLive) continue;
167    seen.add(r.sessionId);
168    const own = byId.get(r.sessionId);
169    helpers[r.sessionId] = Math.max(0, Number(own?.helpers) || 0);
170    const named = r.nameSource && r.nameSource !== "derived" && typeof r.name === "string" ? r.name : "";
171    panes.push({
172      paneId: `session:${r.sessionId}`,
173      tabId: "",
174      workspaceId: "",
175      agent: "claude",
176      status: own ? STATUS_OF[own.state] ?? statusOfRecord(r.status) : statusOfRecord(r.status),
177      name: named || baseName(r.cwd) || r.sessionId.slice(0, 8),
178      cwd: typeof r.cwd === "string" ? r.cwd : "",
179      sessionId: r.sessionId,
180      isSelf: r.sessionId === selfId,
181      isFocused: false,
182      order: panes.length,
183      source: "claude",
184      activity: own?.activity ?? null,
185      terminalTitle: "",
186    });
187  }
188  return { panes, helpers };
189}
190
hooks/office-drag.mjs 55 lines
1// Office: the drag layer. A Client laid over the office picture: it holds
2// the pointer from a press to its release, draws a ghost of the desk being
3// moved next to the pointer, and posts what happened to the hooks module,
4// which owns the seats. A press and release without moving is a click.
5//
6// props.hits: [left, top, width, height, label, seat] per seat, in cells.
7
8export default function OfficeDrag(props, surface) {
9  const { Box, Text } = surface.elements;
10  const hits = Array.isArray(props?.hits) ? props.hits : [];
11  const s = surface.state ?? {};
12  // The seat under a cell, from the hits this drawing was made with: the
13  // same rectangles the floor shows, so the hooks never guess from a plan
14  // drawn since. -1 off every desk.
15  const seatAt = (x, y) => {
16    const hit = hits.find((h) => x >= h[0] && x < h[0] + h[2] && y >= h[1] && y < h[1] + h[3]);
17    return hit && Number.isInteger(hit[5]) ? hit[5] : -1;
18  };
19
20  surface.onPointer((e) => {
21    const cur = surface.state ?? {};
22    if (e.type === "down" && e.button === "left") {
23      surface.setState({ x0: e.x, y0: e.y, x: e.x, y: e.y, isDown: true, isMoved: false });
24    } else if (e.type === "move" && cur.isDown) {
25      const isMoved = cur.isMoved || Math.abs(e.x - cur.x0) + Math.abs(e.y - cur.y0) > 1;
26      surface.setState({ ...cur, x: e.x, y: e.y, isMoved });
27      if (isMoved) surface.post({ type: "hover", from: [cur.x0, cur.y0], at: [e.x, e.y], fromSeat: seatAt(cur.x0, cur.y0), atSeat: seatAt(e.x, e.y) });
28    } else if (e.type === "up" && cur.isDown) {
29      surface.post(
30        cur.isMoved
31          ? { type: "drop", from: [cur.x0, cur.y0], to: [e.x, e.y], fromSeat: seatAt(cur.x0, cur.y0), toSeat: seatAt(e.x, e.y) }
32          : { type: "click", at: [cur.x0, cur.y0], atSeat: seatAt(cur.x0, cur.y0) },
33      );
34      surface.setState({});
35    }
36  });
37
38  const children = [];
39  if (s.isDown && s.isMoved) {
40    const hit = hits.find((h) => s.x0 >= h[0] && s.x0 < h[0] + h[2] && s.y0 >= h[1] && s.y0 < h[1] + h[3]);
41    const label = ` ${hit ? String(hit[4]).slice(0, 24) : "desk"} `;
42    const left = Math.max(0, Math.min(s.x + 1, (surface.columns || 80) - label.length));
43    children.push(
44      Box({
45        key: "ghost",
46        position: "absolute",
47        top: Math.max(0, s.y),
48        left,
49        children: [Text({ inverse: true, bold: true, children: label })],
50      }),
51    );
52  }
53  return Box({ flexDirection: "column", children });
54}
55
hooks/office-art.mjs 499 lines
1// Office: the artwork. Every sprite is a palette-indexed pixel map: one string
2// per row, one character per pixel, `.` transparent. The key letters name a
3// colour role, which a look (skin, hair, clothes) or a theme fills in, so a
4// map is edited here without touching the painter.
5//
6// Roles used across the maps:
7//   o outline      s skin/limb    S limb shade   h limb highlight
8//   e eye          m mouth        c clothes      C clothes shade   k clothes light
9//   r hair         R hair shade   q critter      Q critter shade
10//   m metal (robots use M for shade), v visor, a antenna
11//   F fur          D fur dark     p pink         n nose            W feather
12//   y owl eye ring b beak
13
14// ---- heads, front view, 10 x 10 (the face sits in rows 2-9) -------------------
15
16export const HEADS = {
17  human: [
18    "..........",
19    "..........",
20    "..oooooo..",
21    ".osssssso.",
22    ".osshssso.",
23    ".osesseso.",
24    ".osssssso.",
25    ".oSssmsSo.",
26    "..oSSSSo..",
27    "...oooo...",
28  ],
29  robot: [
30    "....a.....",
31    "....o.....",
32    "..oooooo..",
33    ".ohmmmmmo.",
34    ".omvvvvMo.",
35    ".omvvvvMo.",
36    ".ommmmmMo.",
37    ".oMmMMmMo.",
38    "..oMMMMo..",
39    "...oooo...",
40  ],
41  critter: [
42    "..........",
43    "..........",
44    ".oooooooo.",
45    "oqqqqqqqqo",
46    "oqeqqqqeqo",
47    "oqeqqqqeqo",
48    "oqqqqqqqqo",
49    "oqqqqqqqQo",
50    "oQQQQQQQQo",
51    ".oooooooo.",
52  ],
53  cat: [
54    ".o......o.",
55    "oFo....oFo",
56    "oFpoooopFo",
57    "oFFFFFFFFo",
58    "oFeFFFFeFo",
59    "oFFFFFFFFo",
60    "oFFFnnFFFo",
61    ".oFFFFFFo.",
62    "..oDDDDo..",
63    "...oooo...",
64  ],
65  dog: [
66    "..........",
67    "..........",
68    "..oooooo..",
69    ".DFFFFFFD.",
70    "DDFFFFFFDD",
71    "DDFeFFeFDD",
72    "DDFFFFFFDD",
73    ".DFFnnFFD.",
74    "..FFmmFF..",
75    "...oooo...",
76  ],
77  owl: [
78    ".W......W.",
79    ".WW....WW.",
80    ".WWWWWWWW.",
81    "WWyyWWyyWW",
82    "WyeyWWyeyW",
83    "WWyyWWyyWW",
84    "WWWWbbWWWW",
85    ".WWWWbWWW.",
86    "..WWWWWW..",
87    "...oooo...",
88  ],
89};
90
91// The back of each head (war-room seats facing the table, and a head laid
92// down on the desk).
93export const HEAD_BACKS = {
94  human: [
95    "..........",
96    "..........",
97    "..oooooo..",
98    ".orrrrrro.",
99    ".orRrrrro.",
100    ".orrrrRro.",
101    ".orrrrrro.",
102    ".oRrrrrRo.",
103    "..oSSSSo..",
104    "...oooo...",
105  ],
106  robot: [
107    "....a.....",
108    "....o.....",
109    "..oooooo..",
110    ".ohmmmmmo.",
111    ".ommmmmMo.",
112    ".omMvMmMo.",
113    ".ommmmmMo.",
114    ".oMmmmmMo.",
115    "..oMMMMo..",
116    "...oooo...",
117  ],
118  critter: [
119    "..........",
120    "..........",
121    ".oooooooo.",
122    "oqqqqqqqqo",
123    "oqqqqqqqqo",
124    "oqqqqqqqQo",
125    "oqqqqqqqQo",
126    "oqqqqqqqQo",
127    "oQQQQQQQQo",
128    ".oooooooo.",
129  ],
130  cat: [
131    ".o......o.",
132    "oFo....oFo",
133    "oFFooooFFo",
134    "oFFFFFFFFo",
135    "oFDFFFFDFo",
136    "oFFFFFFFFo",
137    "oFFDFFDFFo",
138    ".oFFFFFFo.",
139    "..oDDDDo..",
140    "...oooo...",
141  ],
142  dog: [
143    "..........",
144    "..........",
145    "..oooooo..",
146    ".DFFFFFFD.",
147    "DDFFFFFFDD",
148    "DDFFFFFFDD",
149    "DDFFFFFFDD",
150    ".DFFFFFFD.",
151    "..FFFFFF..",
152    "...oooo...",
153  ],
154  owl: [
155    ".W......W.",
156    ".WW....WW.",
157    ".WWWWWWWW.",
158    "WWWWWWWWWW",
159    "WWDWWWWDWW",
160    "WWWWWWWWWW",
161    "WWDWWWWDWW",
162    ".WWWWWWWW.",
163    "..WWWWWW..",
164    "...oooo...",
165  ],
166};
167
168// Hair styles drawn over the human front head, same 10 x 10 frame.
169export const HAIRS = {
170  short: [
171    "..........",
172    "..........",
173    "..RRRRRR..",
174    ".RrrrrrrR.",
175    ".Rr....rR.",
176    ".R......R.",
177  ],
178  long: [
179    "..........",
180    "..........",
181    "..RRRRRR..",
182    ".RrrrrrrR.",
183    ".Rrr..rrR.",
184    ".Rr....rR.",
185    "Rr......rR",
186    "Rr......rR",
187    "Rr......rR",
188    ".R......R.",
189  ],
190  bun: [
191    "....RR....",
192    "...RrrR...",
193    "..RRRRRR..",
194    ".RrrrrrrR.",
195    ".R......R.",
196  ],
197  curly: [
198    "..RrRrRr..",
199    ".RrRrRrRr.",
200    "RrRrRrRrRr",
201    "Rr......rR",
202    "R........R",
203    "R........R",
204  ],
205  ponytail: [
206    "..........",
207    "..........",
208    "..RRRRRR..",
209    ".RrrrrrrRR",
210    ".Rr....rRr",
211    ".R......Rr",
212    ".........r",
213    ".........R",
214  ],
215  bald: [
216    "..........",
217    "..........",
218    "..........",
219    "....hh....",
220  ],
221};
222
223// ---- small heads for crowded rooms, 6 x 6 ---------------------------------
224
225export const SMALL_HEADS = {
226  human: [".oooo.", "osssso", "oesseo", "osssso", ".oSSo.", "..oo.."],
227  robot: ["..a...", ".oooo.", "omvvMo", "ommmMo", ".oMMo.", "..oo.."],
228  critter: ["oooooo", "oqqqqo", "oeqqeo", "oqqqqo", "oQQQQo", ".oooo."],
229  cat: ["o....o", "oFooFo", "oeFFeo", "oFnnFo", ".oDDo.", "..oo.."],
230  dog: [".oooo.", "DFFFFD", "DeFFeD", "DFnnFD", ".FmmF.", "..oo.."],
231  owl: ["W....W", "WWWWWW", "yeWWey", "WWbbWW", ".WWWW.", "..oo.."],
232};
233export const SMALL_HAIRS = {
234  short: ["RRRRRR", "R....R"],
235  long: ["RRRRRR", "R....R", "R....R", "R....R"],
236  bun: [".RRRR.", "R....R"],
237  curly: ["RrRrRr", "R....R", "R....R"],
238  ponytail: ["RRRRRR", "R....RR", ".....R"],
239  bald: ["......"],
240};
241
242// ---- torsos, front view: 12 wide; arms are drawn by the painter -------------
243
244export const TORSO = [
245  "..oooooooo..",
246  ".okkcccccCo.",
247  "okccccccccCo",
248  "okccccccccCo",
249  "occcccccccCo",
250  "occcccccccCo",
251  "occcccccccCo",
252  "oCCCCCCCCCCo",
253];
254export const SMALL_TORSO = [".oooooo.", "okcccCCo", "occccCCo", "oCCCCCCo"];
255
256// ---- props ----------------------------------------------------------------
257
258export const PLANT = [
259  "....g.G.....",
260  "..gGg.gGg...",
261  ".gGLgGgLGg..",
262  "gGLggGLggGg.",
263  ".gGgLgGgLGg.",
264  "..gGgGLgGg..",
265  "...gGgGgg...",
266  "....oPPo....",
267  "...oPpPPo...",
268  "...oPPPPo...",
269  "....oooo....",
270];
271
272export const COOLER = [
273  "..owwwwo..",
274  ".owWwwwWo.",
275  ".owwwwwwo.",
276  ".owWwwwWo.",
277  "..owwwwo..",
278  "..oxxxxo..",
279  ".oxXxxxxo.",
280  ".oxxbxrxo.",
281  ".oxxxxxxo.",
282  ".oxXxxxxo.",
283  ".oxxxxxxo.",
284  ".oXXXXXXo.",
285];
286
287export const RACK_W = 9;
288
289export const MUG = ["occo.", "owwoo", "owwoo", ".oo.."];
290export const MINION = [".yyy.", "yGgGy", ".yyy.", ".uuu.", "o...o"];
291export const SMALL_MINION = ["yy", "uu"];
292
293// Confetti and sparks colours, cycled.
294export const CONFETTI = [0xff5c8a, 0xffd23f, 0x4ade80, 0x60a5fa, 0xc084fc, 0xff8c42];
295
296// ---- character looks ------------------------------------------------------
297
298export const SKIN_TONES = [
299  [0xf6d3b3, 0xd9ab88, 0xffe6d0],
300  [0xeec39a, 0xcc9a6e, 0xfbd9b8],
301  [0xd6a175, 0xb07c52, 0xe8bb93],
302  [0xb57d55, 0x8d5a37, 0xc99670],
303  [0x8d5a3b, 0x6b3f26, 0xa36e4c],
304  [0x5e3a26, 0x452818, 0x734a33],
305];
306export const HAIR_COLOURS = [
307  [0x2b1d16, 0x1a110c],
308  [0x4a2f1d, 0x2f1d12],
309  [0x8c5a2b, 0x6b4220],
310  [0xd9b25e, 0xb08a3f],
311  [0xb5442b, 0x8a321f],
312  [0x9aa0a8, 0x737880],
313  [0x5c3d8c, 0x432c66],
314  [0x2f8a7a, 0x226657],
315];
316export const CLOTHES = [
317  [0x4f7cac, 0x3b5f85, 0x6d97c4],
318  [0xa05a7c, 0x7d4561, 0xbd7898],
319  [0x5f9e6e, 0x497a55, 0x7fbb8d],
320  [0xc07a3e, 0x9a6030, 0xd99a62],
321  [0x7a6fc0, 0x5d5499, 0x968cd8],
322  [0x3fa0a0, 0x2f7d7d, 0x62bcbc],
323  [0xb0524f, 0x8a3d3b, 0xc9716e],
324  [0x8a8f3c, 0x6b6f2c, 0xa6ab58],
325  [0x4b4f5c, 0x363944, 0x666b7a],
326];
327export const FURS = {
328  cat: [
329    [0xe39a4f, 0xb87635],
330    [0x8a8f98, 0x60656d],
331    [0x2e2b30, 0x1c1a1e],
332    [0xf0e6da, 0xc9bcae],
333  ],
334  dog: [
335    [0xc89b6d, 0x7a5233],
336    [0xf2e8d8, 0x8a6a48],
337    [0x6b4a32, 0x3d2a1c],
338    [0xd9b47a, 0x9c7a46],
339  ],
340  owl: [
341    [0x9a7b5a, 0x6b5238],
342    [0xd8d2c4, 0xa69f8f],
343    [0x6b5a4a, 0x483b2f],
344  ],
345};
346
347// ---- room palettes --------------------------------------------------------
348
349// `nightshade` follows the Nightshade Ghostty theme (violet charcoal, moss
350// green and lilac); the others are presets.
351export const PALETTES = {
352  nightshade: {
353    wall: 0x3a3546, wallShade: 0x2b2833, trim: 0x524a63, baseboard: 0x211f28,
354    floorA: 0x5a4a5e, floorB: 0x50425a, floorSeam: 0x3a3043,
355    rugs: [0x4a3a6b, 0x2f5246, 0x5e3a4a, 0x2f4466, 0x5a4a2a],
356    wood: 0x8a6548, woodTop: 0xa77c58, woodLight: 0xc49670, woodDark: 0x5e4231,
357    metal: 0x6b6577, dark: 0x1d1b23,
358    accent: { blocked: 0xf0655f, working: 0x76ea6a, running: 0xf5a524, idle: 0x8bbcff, unknown: 0xa8a2b8, done: 0x8c8699 },
359  },
360  warm: {
361    wall: 0x6b5444, wallShade: 0x55412f, trim: 0x8a6a4a, baseboard: 0x3a2a1e,
362    floorA: 0x9a6c45, floorB: 0x8c613e, floorSeam: 0x6b4a2c,
363    rugs: [0x8a3a2e, 0x2e5a4a, 0x7a5a2a, 0x3a4a7a, 0x6a3a5a],
364    wood: 0x7a5232, woodTop: 0x96683f, woodLight: 0xb8865a, woodDark: 0x4e331f,
365    metal: 0x6e6a66, dark: 0x241a14,
366    accent: { blocked: 0xff4d4d, working: 0x4ade80, running: 0xf59e0b, idle: 0x60a5fa, unknown: 0x94a3b8, done: 0x8b8fa8 },
367  },
368  cool: {
369    wall: 0x3d4c5e, wallShade: 0x2e3a49, trim: 0x55677d, baseboard: 0x1f2832,
370    floorA: 0x5b6878, floorB: 0x52606f, floorSeam: 0x3c4756,
371    rugs: [0x2f4f6f, 0x3f6a5a, 0x5a4a7a, 0x6a4a4a, 0x4a5a3a],
372    wood: 0x7c6a5a, woodTop: 0x96826f, woodLight: 0xb3a08c, woodDark: 0x4f4337,
373    metal: 0x6f7c8a, dark: 0x161c24,
374    accent: { blocked: 0xff4d4d, working: 0x4ade80, running: 0xf59e0b, idle: 0x60a5fa, unknown: 0x94a3b8, done: 0x8b8fa8 },
375  },
376  mono: {
377    wall: 0x4a4a4a, wallShade: 0x383838, trim: 0x5e5e5e, baseboard: 0x222222,
378    floorA: 0x5a5a5a, floorB: 0x525252, floorSeam: 0x3c3c3c,
379    rugs: [0x444444, 0x4c4c4c, 0x3e3e3e, 0x484848, 0x404040],
380    wood: 0x707070, woodTop: 0x888888, woodLight: 0xa0a0a0, woodDark: 0x4a4a4a,
381    metal: 0x6a6a6a, dark: 0x1a1a1a,
382    accent: { blocked: 0xff4d4d, working: 0x4ade80, running: 0xf59e0b, idle: 0x60a5fa, unknown: 0x94a3b8, done: 0x9a9a9a },
383  },
384};
385
386// ---- skies by time of day --------------------------------------------------
387
388/** Sky colours for an hour (0-24, fractional): top, bottom, and whether it is night. */
389export function skyAt(hour) {
390  const stops = [
391    [0, 0x0b1026, 0x1a2147, true],
392    [5, 0x1a2147, 0x3b3366, true],
393    [6, 0x5b5a9a, 0xf2a07a, false],
394    [7.5, 0x6fa8e8, 0xf6d6a8, false],
395    [10, 0x4f94e0, 0xa8d4f5, false],
396    [16, 0x5a9be0, 0xb8dcf5, false],
397    [18, 0x4a5aa0, 0xf09a6a, false],
398    [19.5, 0x2a2a66, 0xc0607a, false],
399    [21, 0x101536, 0x2a2a5a, true],
400    [24, 0x0b1026, 0x1a2147, true],
401  ];
402  for (let i = 0; i < stops.length - 1; i++) {
403    const [h0, t0, b0, n0] = stops[i];
404    const [h1, t1, b1] = stops[i + 1];
405    if (hour >= h0 && hour <= h1) {
406      const k = (hour - h0) / (h1 - h0 || 1);
407      return { top: mix(t0, t1, k), bottom: mix(b0, b1, k), isNight: n0 && k < 0.5 ? true : hour >= 20.5 || hour < 5.5 };
408    }
409  }
410  return { top: stops[0][1], bottom: stops[0][2], isNight: true };
411}
412
413export function mix(a, b, t) {
414  const k = Math.max(0, Math.min(1, t));
415  const ch = (s) => {
416    const x = (a >> s) & 255;
417    const y = (b >> s) & 255;
418    return Math.round(x + (y - x) * k) << s;
419  };
420  return ch(16) | ch(8) | ch(0);
421}
422
423// ---- bold: chunky, high-contrast characters made for cells ---------------------
424// Every kind drawn as the critter is: one block with a thick outline, eyes
425// two pixels tall, a feature or two (hair, ears, visor, beak) and nothing finer.
426
427export const BOLD_HEADS = {
428  critter: HEADS.critter,
429  human: [
430    "..........",
431    ".oooooooo.",
432    "orrrrrrrro",
433    "orrsssssro",
434    "osesssseso",
435    "osesssseso",
436    "osssssssso",
437    "osssmmsSso",
438    "oSSSSSSSSo",
439    ".oooooooo.",
440  ],
441  robot: [
442    "....a.....",
443    ".oooooooo.",
444    "ommmmmmmmo",
445    "omvvvvvvmo",
446    "omvVvvVvmo",
447    "omvvvvvvmo",
448    "ommmmmmmmo",
449    "omMmMMmMmo",
450    "oMMMMMMMMo",
451    ".oooooooo.",
452  ],
453  cat: [
454    ".oo....oo.",
455    "oFFo..oFFo",
456    "oFFFooFFFo",
457    "oFFFFFFFFo",
458    "oFeFFFFeFo",
459    "oFeFFFFeFo",
460    "oFFFnnFFFo",
461    "oFFFFFFFFo",
462    "oDDDDDDDDo",
463    ".oooooooo.",
464  ],
465  dog: [
466    "..........",
467    ".oooooooo.",
468    "oDFFFFFFDo",
469    "oDFFFFFFDo",
470    "oDeFFFFeDo",
471    "oDeFFFFeDo",
472    "oDFFnnFFDo",
473    "oDFFmmFFDo",
474    "oDDDDDDDDo",
475    ".oooooooo.",
476  ],
477  owl: [
478    "oo......oo",
479    "oWo....oWo",
480    "oWWooooWWo",
481    "oyyyWWyyyo",
482    "oyeyWWyeyo",
483    "oyyyWWyyyo",
484    "oWWWbbWWWo",
485    "oWWWWbWWWo",
486    "oDDDDDDDDo",
487    ".oooooooo.",
488  ],
489};
490
491export const BOLD_SMALL_HEADS = {
492  critter: SMALL_HEADS.critter,
493  human: ["oooooo", "orrrro", "oesseo", "oesseo", "oSmmSo", ".oooo."],
494  robot: ["..a...", "oooooo", "ovVVvo", "ommmmo", "oMMMMo", ".oooo."],
495  cat: ["o....o", "oFooFo", "oeFFeo", "oeFFeo", "oDnnDo", ".oooo."],
496  dog: ["oooooo", "DFFFFD", "DeFFeD", "DeFFeD", "DFnnFD", ".oooo."],
497  owl: ["W....W", "oWWWWo", "yeyyey", "yyWWyy", "oWbbWo", ".oooo."],
498};
499
hooks/office-art-hd.mjs 129 lines
1// Office pictures: the characters drawn for this mod, in the flat, outline-free style
2// of Kenney's Roguelike packs (16 x 16, a darker shade for edges, no black).
3// Each map is one string per row, one key per pixel, `.` clear; a palette
4// names the colours, and a look (fur, metal) fills the rest in.
5
6// ---- characters, front view, 16 x 16 like Kenney's people ---------------------
7
8export const ROBOT = [
9  ".......a........",
10  ".......m........",
11  ".....MMMMMM.....",
12  "....MmmmmmmM....",
13  "....MvvvvvvM....",
14  "....MvVvvVvM....",
15  ".MMMMmmmmmmMMMM.",
16  "MmmmMmmmmmmMmmmM",
17  "MmmmmccccccmmmmM",
18  "MmmmmcLccLcmmmmM",
19  "MmmmccccccccmmmM",
20  "MmmMMMMMMMMMMmmM",
21  "MmmMmmmmmmmmMmmM",
22  "MmmMmmmmmmmmMmmM",
23  ".MMMmmMMMMmmMMM.",
24  "...MmmM..MmmM...",
25];
26
27export const CRITTER = [
28  "................",
29  "................",
30  "..QQQQQQQQQQQQ..",
31  ".QqqqqqqqqqqqqQ.",
32  ".QqqeeqqqqeeqqQ.",
33  ".QqqeeqqqqeeqqQ.",
34  ".QqqqqqqqqqqqqQ.",
35  "QqqqqqqqqqqqqqqQ",
36  "QqhqqqqqqqqqqhqQ",
37  ".QqqqqqqqqqqqqQ.",
38  ".QqqqqqqqqqqqqQ.",
39  "..QQQQQQQQQQQQ..",
40  "..Q.Q.Q..Q.Q.Q..",
41  "..Q.Q.Q..Q.Q.Q..",
42  "................",
43  "................",
44];
45
46export const CAT = [
47  "....D......D....",
48  "...DpD....DpD...",
49  "...DFFDDDDFFD...",
50  "..DFFFFFFFFFFD..",
51  "..DFeFFFFFFeFD..",
52  "..DFFFFnnFFFFD..",
53  "...DFFFmmFFFD...",
54  "..DFFFFFFFFFFD..",
55  ".DFFFwwwwwwFFFD.",
56  ".DFFwwwwwwwwFFD.",
57  ".DFFwwwwwwwwFFD.",
58  ".DFFFwwwwwwFFFD.",
59  ".DDFFFFFFFFFFDD.",
60  "..DDFFDDDDFFDD..",
61  "...wwF....Fww...",
62  "................",
63];
64
65export const DOG = [
66  "................",
67  "....DFFFFFFD....",
68  "...DFFFFFFFFD...",
69  "..EEFFFFFFFFEE..",
70  "..EEFeFFFFeFEE..",
71  "..EEFFFwwFFFEE..",
72  "...EFFwnnwFFE...",
73  "....FFwmmwFF....",
74  "..DFFFFFFFFFFD..",
75  ".DFFFwwwwwwFFFD.",
76  ".DFFwwwwwwwwFFD.",
77  ".DFFwwwwwwwwFFD.",
78  ".DFFFwwwwwwFFFD.",
79  "..DDFFFFFFFFDD..",
80  "...FFF....FFF...",
81  "................",
82];
83
84export const OWL = [
85  "...D........D...",
86  "...DD......DD...",
87  "...DFFFFFFFFD...",
88  "..DFyyyFFyyyFD..",
89  "..DyyeyFFyeyyD..",
90  "..DFyyyFFyyyFD..",
91  "..DFFFFbbFFFFD..",
92  ".DFFFFFFbFFFFFD.",
93  ".DDFFwwwwwwFFDD.",
94  ".DDFwwDwwDwwFDD.",
95  ".DDFwwwwwwwwFDD.",
96  ".DDFwwDwwDwwFDD.",
97  "..DDFwwwwwwFDD..",
98  "...DDDDDDDDDD...",
99  "....b......b....",
100  "................",
101];
102
103// ---- details -----------------------------------------------------------
104
105export const CROWN = ["y.y.y", "yyyyy", "YYYYY"];
106
107// Fur and feather colours, the pairs a look picks from.
108export const HD_FURS = {
109  cat: [
110    [0xe8a35a, 0xb8763a],
111    [0x9a9fa8, 0x6e737c],
112    [0x4a454f, 0x2e2b33],
113    [0xf2ebe0, 0xc9bfb0],
114  ],
115  dog: [
116    [0xd2a676, 0x8a5c38],
117    [0xf2e8d8, 0x9a7a58],
118    [0x7a5438, 0x4a3222],
119    [0xe0bb80, 0xa07c48],
120  ],
121  owl: [
122    [0xa88660, 0x6e5438],
123    [0xd8d2c4, 0x9a9384],
124    [0x7a6a58, 0x4e4236],
125  ],
126};
127
128export const CONFETTI_HD = [0xff5c8a, 0xffd23f, 0x5fcf55, 0x6fa8f5, 0xc084fc, 0xff8c42];
129