Ctrl K
ComunidadGuías…

DESIGN.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Agent 007 — Design System

## Identity

Agent 007 is a pixel office for managing AI terminal agents. The aesthetic is
dark, terminal-native, with spy/espionage personality. UI should feel like a
mission control dashboard — calm, information-dense, utility-first.

**Logo:** Golden martini glass (filled, with olive) placed inline between
"Agent" and "007" in the app title. Favicon uses the same martini glass SVG.

**Brand color:** Gold `#d4a847` — used as the accent color throughout.

## Naming Conventions

**Agents** — Spy codenames from a fixed pool:
`Shadow, Phantom, Viper, Cipher, Raven, Onyx, Echo, Spectre, Falcon, Ghost, Dagger, Mirage, Cobra, Apex, Ember`

Random assignment, recycled on agent death. User can override with custom name
at spawn, or rename a running agent later (double-click its tab, or the pencil
button in the terminal header; owner only, max 40 characters, must be unique
among live agents and orphans). A rename changes only the label: the worktree
directory and branch keep the original codename, since moving the directory
under a live PTY would strand the agent's cwd. The new name reaches every
connected client and the office, explorer and job card at once, and survives
a server restart.

**Branches** — Cocktail names with `{git-username}/` prefix:
`bill/vesper, bill/martini, bill/gimlet, bill/negroni, bill/sidecar, bill/daiquiri, bill/manhattan, bill/mojito, bill/paloma, bill/sazerac, bill/aviation, bill/bellini, bill/spritz, bill/collins, bill/julep, bill/highball, bill/rickey, bill/fizz`

Git username read from `git config user.name`, lowercased, spaces to hyphens.
Falls back to `agent` if not configured. User can override with custom branch name.

**Start point** — every agent branch starts from `origin/<base>`, fetched first,
where `<base>` comes from the remote's own HEAD (falling back to `main`, then
`master`). Not from HEAD of the main checkout: that silently started agents on a
stale local base, or worse, on whatever unrelated branch the user had checked
out, whose half-finished work then rode along in the agent's PR. Degrades
cleanly — no network or no remote falls back to the local base branch, and a repo
with neither falls back to HEAD. Overridable per spawn via Advanced -> "Start
from" for the "branch off what I'm working on" case.

## Color System

Two themes: dark (default) and light. Gold accent in both. Persisted to localStorage.

### Dark Theme (default)
```css
--bg-dark:       #090a0c    /* deepest: headers, panel tops */
--bg-panel:      #0f1114    /* panels: explorer, terminal */
--bg-office:     #131519    /* pixel office canvas */
--bg-terminal:   #07080a    /* terminal viewport */
--bg-tabs:       #0d0e11    /* tab bar */
--border:        #1f2228
--text:          #d8dce4    /* primary text */
--text-muted:    #9ca3af    /* secondary text (agent names, buttons) */
--text-dim:      #6b7280    /* tertiary text (branches, labels) */
--accent:        #d4a847    /* gold: interactive elements, brand */
```

### Light Theme (warm low-glare paper, Flexoki-style neutrals)
```css
--bg-dark:       #e6e4d9    /* headers, sidebars */
--bg-panel:      #f2f0e5    /* content panels */
--bg-office:     #dad8ce    /* pixel office */
--bg-terminal:   #f2f0e5    /* terminal */
--bg-tabs:       #e6e4d9    /* tab bar */
--border:        #c6c3b6
--text:          #1c1b1a    /* warm near-black ink */
--text-muted:    #62615c
--text-dim:      #6b6a64
--accent:        #7d611f    /* darker gold: 4.5:1+ on panel/header creams, 4.1:1 non-text on the office canvas */
--accent-contrast: #fff     /* ink on accent fills and message-state chips (#111 on dark) */
--hover:         rgba(0,0,0,0.05)   /* row-hover overlay (white-tint on dark) */
--state-working:      #8c6200
--state-waiting:      #1a7f37
--state-message:      #aa540f
--state-idle:         #7b7973
--state-disconnected: #c8222e
--state-recording:    #c8222e
```
Surfaces stay in the cream band (~L*90) rather than near-white so long
terminal sessions don't glare. `test/theme-tokens.test.js` guards every hex
value in the fence above against the CSS tokens in `style.css`, checks that
every `:root` token has a light override, and keeps the terminal's
background/foreground/cursor on the same values. The 16-color ANSI palette
below is hand-picked and not auto-guarded.

### State indicators (dark theme values; light values in the fence above)
```css
--state-working:      #d4a847    /* yellow: agent is producing output */
--state-waiting:      #7fbc6a    /* green: agent is at a prompt */
--state-message:      #e0853a    /* orange: agent needs attention */
--state-idle:         #4a4e58    /* gray: no activity */
--state-disconnected: #c44040    /* red: process exited */
--state-recording:    #c44040    /* red: voice input mic is live (same red as disconnected) */
```

### Terminal ANSI Colors (dark theme — GitHub dark palette)
```
black: #6e7681    red: #ff7b72     green: #3fb950    yellow: #d29922
blue: #58a6ff     magenta: #bc8cff cyan: #76d9e6     white: #c9d1d9
brightBlack: #8b949e  brightRed: #ffa198   brightGreen: #56d364  brightYellow: #e3b341
brightBlue: #79c0ff   brightMagenta: #d2a8ff  brightCyan: #a5d6ff  brightWhite: #f0f3f6
```

### Terminal ANSI Colors (light theme — GitHub light on warm paper)
```
black: #24292f    red: #cf222e     green: #1a7f37    yellow: #7d4e00
blue: #0969da     magenta: #8250df cyan: #1b7c83     white: #5c6570
brightBlack: #57606a  brightRed: #a40e26   brightGreen: #1a7f37  brightYellow: #633c01
brightBlue: #0969da   brightMagenta: #8250df  brightCyan: #3192aa  brightWhite: #24292f
```
White and the brights are darkened from stock GitHub-light, which assumes a
#ffffff ground; bold blue/magenta deliberately reuse the normal-weight colors
because the stock brights wash out on the cream ground.

## Typography

- **Font stack:** `'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace`
- **Sizes:**
  - 10px — branch labels, diff viewer, loading text
  - 11px — explorer items (files, agents), form labels, panel labels (REPOS,
    the job columns, Finished; uppercase, letter-spacing: 2px), the job board
    toolbar's labels. 11px is the floor for labels that name a field or a
    section; 10px stays on the branch names and diff/loading text above, and
    on job card metadata, chips and actions (11px on a phone); 9px on some
    badges and form hints
  - 12px — terminal tabs, form inputs, repo/branch in terminal header
  - 13px — terminal header agent info
  - 14px — app title (bold, letter-spacing: 1px)
- **Uppercase** goes on a label's own words, never on a container: the job
  form's labels wrap their word in `.job-form-label` so the select, input or
  hint nested in the `<label>` keeps its case

## Text Hierarchy

Three levels of text prominence:
- `--text` — Primary content, headings, repo names, app title, buttons
- `--text-muted` — Secondary content, agent names, branch names, labels
- `--text-dim` — Hints, placeholders, inactive items, panel section labels

## Layout

Three-panel layout with per-panel headers:

```
┌─────────────┬──────────────┬────────────────────────┐
│ Agent🍸007  │ + Agent + Job│ Repo: 007bot  ⚡ main ☀│
│ REPOS [+↻<] │              │                        │
├─────────────┼──────────────┼────────────────────────┤
│             │              │ [tab1] [tab2] [tab3]   │
│  Explorer   │  Pixel       │────────────────────────│
│  (file      │  Office      │                        │
│   tree)     │  (canvas)    │  Terminal (xterm.js)    │
│             │              │                        │
└─────────────┴──────────────┴────────────────────────┘
```

- **Panel headers:** All use `--bg-dark` background for visual continuity.
  Fixed heights (36px panel headers, 32px REPOS row) with 4px of vertical
  padding, so the bordered "+ Agent"/"+ Job" buttons (24px) and icon buttons
  sit inset from the bottom border rather than against it
- **Dividers:** Gradient top (matches header bg) + border below. Gold on hover.
- **Explorer:** Two-row header (logo row + REPOS row), collapsible via Cmd+E.
  Collapse and divider drags change its width instantly: a width transition
  made the panel trail the cursor while dragging and re-laid-out the office
  and terminal on every frame of a collapse
- **Office:** Centered "+ Agent" and "+ Job" buttons
- **Terminal:** "Repo:" label + repo name + branch icon + branch name + Settings gear + theme toggle in the header, plus a pencil rename button when the viewer owns the agent; the voice-input mic floats at the viewport's bottom-right, next to the prompt line
- **Terminal tabs:** Draggable for reordering, order persisted to localStorage; double-click renames the agent (owner only)
- **Jobs tab:** Pinned first in the tab bar, not draggable and not closable. Shows
  an orange count badge when any in-progress job needs the user. Selecting it swaps
  the terminal viewport for the board; selecting any agent tab swaps back to that
  terminal, so `activeSessionId` is deliberately left untouched while the board shows
- Panel widths persisted to localStorage
- Below 900px: explorer auto-hidden
- Below 700px: one panel at a time, chosen by `body[data-view]` from a
  Files / Office / Terminal bar at the bottom. Only user navigation sets the
  view (`setView` in `state.js`): a tap on a desk or whiteboard goes to the
  terminal panel, opening a diff goes to the office panel and closing it back
  to files. `switchToSession` and `showJobBoard` deliberately do not, since
  spawns, exits and the reconnect replay reach them too. The office loop
  skips drawing while its canvas has no client box, and a walk-in queued
  while hidden expires rather than playing when the office next shows. The
  visual viewport, not the layout viewport, sizes the body on phones so the
  terminal refits above the software keyboard

## Pixel Office

Canvas-rendered pixel art workstations at Z=3 scale factor (character sprites
draw at 2x — 16px art in a 32px-tile office).

The canvas is sized from its own CSS box (`flex: 1` under the office header,
`min-height: 0`), never from the panel: the panel height includes the header,
so a panel-sized canvas hung below the panel edge and clipped the sofa and
corner plants (fixed in v0.3.21.2). `renderOffice`, click-to-focus, and the
walk-out animation all lay out against that same box, and the last real size
is kept while the diff viewer hides the canvas so nothing captured meanwhile
lays out against a 0x0 room.

### Per-repo pods (since v0.3.18.0)
- Desks are grouped into pods, one per repo, each on its own rug with the repo
  name above it; agents with no repo (bash terminals) share a final unlabeled
  pod. Within a pod agents keep spawn order, so an unrelated spawn/exit never
  reshuffles desks inside a pod.
- Cell: 32w x 36h Z units (96 x 108 screen px); gap within a pod: 12 x 18;
  gap between pods: 20 x 26
- Max 4 columns per pod, responsive to panel width; the column count
  reserves the rug padding, so a max-width pod's rug never clips at the
  panel edges
- Rug pads the desk block by 4 Z units each side, 7 above (repo label) and
  12 below (agent name tags); rug rects double as the keep-out zone for
  ambient decor
- Rug colour is the brand gold (`GOLD_RGB` in `office.js`, 10% fill with
  32% outer and 16% inner borders), fixed in both themes like the walls and
  floor: the light theme's darker accent is nearly the floor colour, so a
  theme-tinted rug vanished in light mode (fixed in v0.3.21.1). The repo
  label keeps the theme's text colour
- Pods flow left-to-right and wrap when a row would overflow the panel; the
  arrangement is anchored near the top of the floor (first rug 8 Z below
  `FLOOR_TOP`, `POD_TOP_MARGIN`) so a sparse office leaves its empty wood at
  the bottom for decor. A nearly-full floor still centers, clamped to
  `FLOOR_TOP` (pure layout in `computePodLayout`, `public/modules/office.js`)
- Screenshots: `docs/office-pods-sparse.png` (2 agents) and
  `docs/office-pods-after.png` (6 agents, 2 repos) show the top-anchored
  layout at the default 440px panel; `docs/office-pods-before.png` is the
  pre-pod uniform grid

### Desk grid (all desks face front)
- Within a pod, desks sit in a plain classroom grid, every one in the default
  orientation: `cols = min(agentCount, maxCols)` (maxCols derived from the
  panel width, clamped 1..4), agent `i` at column `i % cols`, row
  `floor(i / cols)`. The row pitch keeps a full aisle (`WS_H + WS_GAP_Y`) so a
  back row never occludes the row in front.
- Every screen faces the viewer, so its content (activity lines while
  WORKING, +adds/-dels while WAITING, message lines on MESSAGE) is always
  readable, and the character's facing carries state: back to the viewer =
  working, facing the viewer = idle/waiting. Facing pairs (v0.3.19.0) were
  removed because a flipped desk hid its screen and made a typing agent face
  the same way as a waiting one.
- A narrow panel degrades to fewer columns (down to 1) rather than changing
  desk orientation.

### Workstation anatomy (back-to-front draw order)
1. **Monitor** — 14w x 9h, 1px uniform bezel, content varies by state
2. **Monitor stand** — 2w centered, 1px tall neck + 4w base
3. **Desk** — sprite-based (desk.png / desk2.png), top 13 rows cropped
4. **Character** — sprite-based (pixel-agents sheets `char_0.png`–`char_5.png`,
   16x32 frames drawn at 2x; variant picked by hashing the session id so an
   agent keeps its look across renders; the desk sprite variant hashes the
   same way, so a desk's style sticks to its agent, not its slot).
   State-dependent pose, anchored at sy+13:
   - WORKING: seated typing, 2-frame cycle (static under
     `prefers-reduced-motion`), back to the viewer
   - WAITING/MESSAGE: chair turned around, facing the viewer
   - IDLE: standing, facing the viewer
   - DISCONNECTED: pixel art X pattern, no character
5. **Name tag** — bold 11px monospace, centered below character
   - Active: gold `#d4a847` with pulsing glow animation
   - Inactive: `--text-muted` with 0.7 opacity

### Monitor screen states
- **WORKING:** Animated colored code lines scrolling
- **WAITING:** Shows +additions/-removals (green/red) if changes exist, else prompt chevron with blinking cursor
- **MESSAGE:** Accent-colored text lines with thought bubble (three dots)
- **IDLE:** Same as WAITING
- **DISCONNECTED:** Dark screen

### Message bubble
- Accent-colored dots (not hardcoded orange), positioned relative to character
- Only shown for MESSAGE state (agent needs user input)

### Transient motion
- **Walk in/out:** a newly spawned agent walks in at the left edge of the
  floor, straight across an empty row, then up or down its desk column
  (`entryRoute`). There is no door: the entrance is the left end of that row,
  so it sits at a different height for a desk in the top pod row than for one
  near the bottom, and no leg ever runs along the left edge to reach it. The
  row is picked from the frame's furniture rects (`walkObstacles`: pod rugs,
  occupied and spare desks, decor and the conference set with its chair
  overhang): their vertical spans inverted into the gaps between them, the gap
  nearest the desk that fits a whole character winning (`corridorY`). Spans
  count full width, so a rug half-way across still rules its rows out — a
  scan, not a search. This office is dense enough that on most panels no gap
  fits: between the rugs, the spare row and the chat areas they run 18-54px
  against a 64px character. So when none fits, the widest gap takes it and the
  walker's feet ride its floor, its body overlapping the furniture above —
  drawn in front of that furniture, and never down on the chat areas the way
  the old bottom-edge fallback was. With no gap at all (a pod grid that
  overflows the panel) it crosses on the desk's own row. The vertical leg is
  the approach, and it picks its column (`approachX`): the desk's own when the
  band between it and the corridor is clear, else the nearest column clear of
  everything but the rects the walker is standing in — its own pod rug and its
  own desk — reached by a sidestep along the desk's own row, so a descent from
  a distant corridor drops down an aisle instead of stepping over the rugs,
  plants and sofas in between. The occupied desks are in the obstacle list for
  that sidestep's sake: the rug is one rect over the whole pod, so without
  them the leg along the desk row would sweep across the colleagues seated in
  it. Candidates are the edges of the rects in that band, blockers and the
  walker's own floor alike, a scan like the row pick, ranked by what the
  detour costs — out to the column and on to wherever the crossing leg is
  headed, so of two equal columns the one the walker does not double back over
  wins — and clamped to columns the character fits inside on both edges of the
  panel. When none is clear on both legs it falls back to the desk's own
  column: swept over 6,825 desk positions that is 12% of them, against 40%
  fewer furniture crossings and 48% fewer walked-over colleagues than the
  straight-column route. Spare desks drop a column and the conference set
  yields rather than reach into the entry strip (`ENTRY_STRIP_W`) — a leftover
  from the fixed-door entrance, since the row scan ignores x and already
  clears the crossing. A departing agent walks out the same way before its
  desk disappears — from its conference seat, or from its current point
  mid-wander, if it was away from the desk. A walk-out that starts inside the
  set's footprint steps out sideways to the lane just outside the chairs
  (`confLanes`) before descending, and that lane goes through `approachX` too,
  measured from the lane rather than from the seat: from the seat the set is
  the walker's own floor and every column reads clear, from the lane the set is
  a blocker, which both vets the lane and keeps any wider column it falls back
  to outside the chair overhang. While a walk plays, the motion
  overlay draws the character instead of the seated pass.
- **Idle wander:** an agent at rest claims a free seat, walks over and sits
  there until its state changes, then walks back to its desk. **At rest is
  IDLE *or* WAITING**: every TUI agent this board spawns short-circuits to
  WAITING in `detectState`, so IDLE is unreachable for them and the wander
  gated on IDLE alone never fired at all. Either state has to be quiet past
  the same window the board calls "stalled" before the agent leaves its desk:
  `detectState` moves an agent out of WORKING after 3s of silence and the walk
  to a seat takes 7-11s, so without the window a pause between commands starts
  a walk that the next line of output cancels. Guarding WAITING alone (before
  v0.3.31.1) left plain shells -- not a TUI, prompt not in `PROMPT_PATTERNS`,
  so IDLE -- pacing between desk and sofa forever.
  - **The seats are pooled** (`wanderSeats`): the conference seats when the set
    places, plus the chat-area sofas (`chatSeats`) — one seat on the front sofa
    above each coffee table, facing down over it like the conference head seat,
    and one on each side sofa facing in. The conference set only places on a
    canvas about 972px tall, which most laptop windows are not; the sofas are
    always there, so the wander works at any size. Conference sitters draw
    interleaved with the chairs (`drawConference`); sofa sitters go down after
    `drawDecor` laid the sofas out (`drawChatSitters`).
  - **Facing:** at the conference table, the viewer at the head, sideways on the
    side chairs, away at the foot; on the sofas, down over the coffee table on
    the front one and inward on the two sides.
  - **Pose:** a sitter draws the sheet's seated frame (column 3 — torso only,
    no legs, the same frame the desk pass animates as typing), not the standing
    one, which on a chair read as standing *on* it. The seat carries the column
    (`CHAR_COL_SIT`) alongside its lift onto the cushion, so the two halves of
    that decision live together and `drawCharFrame` just draws what the seat
    says. The lifts: `CHAT_FRONT_SIT_RISE` on the front sofa, whose 16px of art
    is short enough that the seated frame's lower bounding box fell past its
    front edge, and `CONF_SIDE_SIT_RISE` on the four side chairs, larger
    because the chairs draw at 3x while characters draw at 2x, so the seat line
    sits high against the body. The two side sofas need no lift — drawn at the
    character's own 2x, the seated art already lands on the cushion. The
    conference head and foot seats keep the standing frame and carry no column
    at all (`drawCharFrame` defaults to standing): the tabletop covers one set
    of legs and the chair-back the other, and both compositions rely on it.
  - **The route** (`wanderRoute`) descends `approachX`'s clear column to a
    corridor row (`corridorY`, the same scan the walk in/out crossing uses, so
    the crossing leg has a row it can actually stand in), crosses, then descends
    to the seat down a lane. Each seat carries its own preferences: a conference
    seat takes the row just above the set and a lane just outside the chairs (the
    head seat down its own column, the side and foot seats via `confLanes`); a
    sofa seat carries both itself — its own column, and the row just above the
    chat rug, a whole character up rather than the set's 24px, because a walker
    standing closer would sweep the sofas it is heading for. Both are only
    preferences: `approachX` and `corridorY` vet them either way. The lane goes
    through `approachX` measured from the lane rather than from the seat — from
    the seat the furniture is the walker's own floor and every column reads
    clear, from the lane it is a blocker, which both vets the lane and keeps any
    wider column it falls back to outside the chair overhang. Walkers never
    cross the tabletop; the last step in, from the lane to the seat, goes between
    the chairs by design.
  - **Claims** (`seatClaims`) index the pooled list, not `conf.seats`, and the
    pool lists the conference seats first so `drawConference` can key on a seat's
    own `confIndex` instead of aliasing a sofa claim onto a chair. Seats fill in
    pool order; a full pool leaves late arrivals at their desks, pre-existing
    resting agents render seated on connect (no replay walk), and read-only
    colleagues stay at their desks (where their dimming and owner label live).
    Every claim clears when the pool changes *shape* — its length or its first
    seat's kind, since a conference set with the chat areas suppressed is also
    six seats (`updateWander`) — and characters simply render at their desks
    again. Clicking a seated character switches to its terminal, same as
    clicking its desk.
- **Dispatch paper:** when the board hands a job to a fresh agent, a small
  paper arcs from that job's whiteboard column down to the new desk.
- All motion is client-side and time-based: animations never replay on page
  load (the connect sync renders pre-existing agents seated), are not queued
  while the tab is hidden, and are disabled under `prefers-reduced-motion`
  (queried live, so flipping the OS setting applies to the next animation,
  not the next reload).

### Room elements
- **Walls:** Fixed warm cream plaster, independent of UI theme
- **Floor:** Warm wood planks, fixed base `#4a3525`
- **Windows:** Two windows with day/night cycle based on local time
- **Job boards:** Three freestanding whiteboards on A-frame stands (not wall-mounted), standing almost against the back wall, one in each wall section (either side of the windows and between them). Titled TO DO / IN PROGRESS / REVIEW after the columns of the `## Job Board` feature below, and kept in step with `JOB_STATES` by a test. Since v0.3.16.0 the pinned posts are live: each board pins one paper per job in its column (a fixed grid of pin slots, filled left to right then top to bottom), draws a "+N" corner chip for jobs beyond the slots, shows a clean board when its column is empty, and repaints on every `jobs-list` broadcast. Since v0.3.31.0 a board is also a click target: clicking one (frame or stand, `hitJobBoard`) opens the Jobs tab, checked before the seat and desk hit tests since the boards sit above `FLOOR_TOP`
- **Bookshelves:** A low bookshelf run under each window — three short
  Antea bookcase units tiled edge-to-edge at 2x, centred on the window and
  narrower than it, feet tight against the wall with a contact shadow
  (`computeBookshelfRuns`). Drawn before the boards so an easel overlaps a
  shelf, never the reverse; none when the panel is too narrow for boards
- **Spare desks:** While the office has a single row of real pods, a row of
  up to three empty workstations (dark screens, no character, name or rug)
  sits on the pod grid one row-pitch below it, so a sparse office reads as
  unassigned seats. Only when the row clears the decor and the panel bottom
  by the decor margin (`computeSpareDesks`); it vanishes as rows are added
- **Ambient decor:** The bottom of the floor fills from a fixed candidate
  list — a chat area in each bottom corner on a label-less pod-style rug
  (side sofas left and right of a coffee table with the coffee pot on it, the
  right one mirrored to face it, and a front-facing sofa above) at the 2x
  character scale, with the leafy plant stacked above the cactus between them
  as a small divider (1.25x, so they read as a hedge, not sentinels).
  A candidate only draws when it clears every pod rug by a margin
  (`computeDecorPlacement`), so decor yields and disappears as desks crowd the
  room. `docs/office-decor.png` shows a one-row office at a 458px panel. The
  three sofa seats in each chat area are wander seats (`chatSeats`), derived
  from the same layout maths `drawDecor` places the furniture with
- **Conference table:** A vertical boardroom table (the pixel-agents
  `table_front` sprite stretched taller) centred in the open band between the
  desks — pod rugs and spare desks — and the chat areas, with cushioned
  chairs at thirds down both sides, one at the foot, and a free seat at the
  head. Placed only when the band fits the whole set plus the decor margin
  (`computeConference`), so it yields on short panels just like the decor — in
  practice it needs a canvas about 972px tall, which most windows are not, and
  that is why the sofas carry the idle wander at ordinary sizes
- **Particles:** 5 ambient dust motes

## Job Board

Three columns in the terminal panel: To do, In progress, Review. Card state is
durable (persisted in `config.json`). What a live agent is *doing* is derived;
what the board could not do — a PR check that failed — is stored, because it is
a fact about the board, not about a PTY.

The board runs from a fresh install (an install with saved board settings
keeps them). While it is stopped and To do holds cards, a line at the top of
To do (`.job-board-stopped`) says "The board is stopped: cards wait here until
you press Start", with a Start button beside it.

### How a one-time card moves

| Event | Card | Agent and worktree |
|---|---|---|
| Dispatched | To do → In progress | Created |
| The agent calls `finish_job`, or the PR poll finds its PR | → Review | Kept |
| Its PR merges or is closed, or you move it to Done | → Done | Retired, worktree removed |
| You move it back to To do | → To do | Retired, worktree removed |

Each one-time card says whether its work ends in a pull request
(`requiresPr`, default true). A card that requires one gets the `/ship` hint in
its prompt and must hand `finish_job` the open PR on its own branch; the board
checks that before moving it. A card that requires none gets no `/ship` hint —
telling a research job to ship pushed it into inventing a change — and must
hand `finish_job` a summary instead, which the Review card shows. The PR poll is
the fallback for an agent that opened its PR but forgot to call `finish_job`.

Review keeps the agent because the work is finished but may not be right: the
reviewer can click straight into its terminal. The per-repo cap only counts In
progress, so a kept agent idling in Review does not hold a slot. A restart
kills every agent, so a Review card may reach Done with no live agent; its
worktree then comes back as an orphan record, and Done releases it through that.

Each scan considers a finite queue snapshot and tries each card at most once.
A failed spawn keeps its card and error, then the scan can try the next eligible
card with the unused capacity. Eligibility and live capacity are checked before
each attempt and again before claiming its worker. If an abandoned worker cannot
be cleaned up, that repo waits for the next scan rather than starting another.

### The fourth state has no column

A job whose PR has merged is `done`, and `done` is the one state with no
column. A Review column that accumulates merged work stops meaning
"needs your review" — the only column whose job is to hold a short list of
things a human still has to look at becomes an archive nobody reads.

The sweep covers In progress as well as Review: a PR can open and merge inside
one scan interval, and `--state open` cannot see it afterwards, so a
Review-only sweep would leave that card in In progress forever reading "agent
gone" for work that had shipped. Finishing retires the agent from either column.

The job itself is kept, never deleted: it is the record of what an agent did and
where the PR is. It is reached through **View finished jobs** in the toolbar,
which swaps the columns for the archive rather than sitting beside them, so a
list that only grows can never squeeze the live work.

**Done is terminal.** A finished card cannot be moved back onto the board; the
only thing that can happen to it is deletion, and the manual `✓ Done` button
asks before filing a card away because of it. An earlier design let a card
return to Review, and it could not be made safe: the card keeps the PR that
finished it, and `reviewAt` is the sweep's time floor, so a job walked back to
In progress carried a spent PR of record into its new attempt. The sweep matched
that same old merge on the very next scan and filed the card away again — and
because finishing retires an agent, it killed whatever terminal had been
re-adopted on the branch. Clearing those fields instead would trade the
bug for a card whose history is gone, which defeats the point of keeping it.
Work that follows a merged PR is a new job; the archive keeps the old one to
point at.

Four rules keep the transition honest:

- **A merged or closed PR finishes a job.** A card in Review whose own PR
  (asked by number, `gh pr view`) was closed without merging goes to Done
  marked `prClosedAt`, and the archive says "PR closed without merging"
  rather than "merged". Three guards come first: an open PR on the same branch
  becomes the card's PR instead (the agent closed and replaced it); the closed
  reading must hold on two scans (`prClosedSeenAt`), so a close-and-reopen
  never files it; and an agent that is working or asking waits until it is
  quiet. A PR reopened after that does not revive the card — Done is terminal,
  so re-post the job. Closing a PR is already someone's decision, and a card
  left in Review would hold a live agent and worktree for work nobody will
  land; on a schedule's run it would also hold the schedule off. The agent is
  retired; unpushed work stays behind as an orphan. Follow-up work is a new
  job, as after a merge.
- **Only THIS card's merge finishes it.** `gh pr list --head <branch>` matches
  the head ref *name*, and that name outlives the branch: a merged PR stays in
  the listing forever, and board branch names are reused once the branch is
  deleted at retirement. So the sweep matches on the card's own PR number when
  it has one, and otherwise only accepts a merge that happened after this
  attempt started. Without that, a stale merge files a card away while its real
  pull request is still open — and overwrites the card's PR number on the way
  out, so the open one is no longer recorded anywhere.
- **`prMergedAt` is written once and never cleared.** Nothing leaves done, so
  nothing needs to unwind it. It is also what the archive reads to tell a merge
  from a card filed away by hand: a manual `✓ Done` stamps `doneAt` but not
  `prMergedAt`, because the board is recording that the *user* called the job
  finished, which is not a claim about GitHub.
- **Only cards that require a PR are swept.** One that requires none has no PR
  of its own, and a merge on a matching branch name proves nothing about it;
  it reaches Done by hand.

### One-time and scheduled cards

Two kinds of card share those three columns.

A **one-time** job is dispatched once, crosses the board, stops in Review when
its agent calls `finish_job` (or the board spots its pull request), and leaves
at Done. A **scheduled** card is a *schedule*, not a job (since v0.4.9.0): it
stays in To do and is never dispatched itself. Each time it comes due it posts
a one-time **run** card (`scheduleId` points back at it) carrying its title,
detail, repo, agent, permission mode and `requiresPr`, and that run goes
through the one-time lifecycle like any other card. Cards written before types
existed carry no `type` at all, so every read goes through `jobType()` — a
missing type is one-time.

Before v0.4.9.0 a scheduled card was dispatched itself and cycled To do -> In
progress -> To do. That made it a second lifecycle: the board had to guess when
a run was over (the agent exiting or going quiet), keep the last run's terminal
open as its only output, exempt the card from the cap, and special-case it in
every sweep. A run never waited in Review, because the card was also the
schedule and had to be back in To do for its next firing. Splitting the
schedule from its runs removes all of that: a run ends when its agent calls
`finish_job`, its result is read in Review, and a stuck run is an ordinary In
progress card saying "needs you" instead of a schedule silently losing firings.

- **At most one unfinished run per schedule** (`scheduleHold`). So an hourly
  job nobody reads cannot fill the board:
  - a run still in To do or In progress: the firing is skipped. Two runs at
    once would race each other.
  - a run in Review that opened a PR: skipped until that PR is merged or
    closed, or the card is done. A second dependency-bump PR on an unmerged
    one is noise.
  - a run in Review with no PR: the schedule fires, and the new run
    **supersedes** the old one once it reaches Review itself.

  A skip moves `nextRunAt` on like a firing does, so it is not replayed, and
  the schedule card says why it held off.
- **Superseding keeps the newest result waiting to be read.** Each scan,
  `supersedeRuns` files every no-PR Review run of a schedule except its newest
  to Done through `moveJob`, so its agent and worktree are released the same
  way a person pressing Done releases them. The archived run keeps its summary
  and `supersededBy`; the newest counts how many it replaced ("replaced 3
  earlier runs nobody had read"). PR runs are never superseded, because the
  schedule never fires past one.
- **The archive keeps a schedule's newest 50 finished runs** (`runsToPrune`,
  `MAX_FINISHED_RUNS`). Every run is a card in `config.json` and in each board
  broadcast, and an hourly schedule posts ~8,760 a year, so each scan deletes
  the oldest finished runs past 50, attachment folders included. The schedule's
  run count keeps counting. One-time cards are not pruned.
- **A schedule's runs report a summary by default.** A schedule's
  `requiresPr` defaults to false (`defaultRequiresPr`): a recurring job was
  never expected to open a PR, so a recurring code change has to be asked for.
  Changing a card's type without choosing resets it to the new type's default.
- **Runs count toward the per-repo cap.** They are ordinary cards. A run
  waiting behind the cap waits in To do, and its schedule holds off
  meanwhile, since a run not yet started is still unfinished.
- **A schedule never moves.** `moveJob` refuses every move of one; Pause and
  Delete are its controls. Pausing holds the next firing and leaves a run
  already posted alone; resuming re-arms `nextRunAt` from that moment, so a
  firing missed while paused is not replayed. Deleting a schedule leaves its
  runs on the board as ordinary cards.
- **A run shares its schedule's attachments.** The run's prompt names the
  schedule's file paths; clearing a finished run's attachments only forgets
  them, because the run's own attachment directory holds nothing.
- **Upgrading.** A schedule sitting in To do needs nothing. One saved mid-run
  by an older server goes back to To do on load, with a note naming the dead
  run's branch, which is what that server's own restart did.
- **Cron granularity is bounded by the scan interval.** The dispatcher only acts
  on a scan (five minutes by default, floor 30s), so `* * * * *` means "every
  scan", not every minute. Times are the server's local time — the schedule is
  written by the person sitting in front of the machine the agents run on.
- **A five-field parser, not a dependency** (`lib/cron.js`). Ranges, lists,
  steps, `7` for Sunday, and the `@hourly`/`@daily`/... shorthands, plus the one
  genuinely surprising rule: when both day fields are restricted they are ORed.
  `nextCronTime` walks whole months and days rather than minute by minute, and
  gives up after four years — enough for 29 February, bounded for an expression
  like `0 0 30 2 *` that parses fine and can never match. Such a card is stored
  and says "never fires again" rather than being refused, because refusing it
  would mean the parser having to know about calendars — and `isJobDue` treats
  it as never due, since "no next run time" would otherwise read as "due now"
  and fire the card on every scan. Local arithmetic is also how the walk moves,
  so an hour that a daylight-saving transition skips simply does not fire that
  day (what cron does), and a step that fails to advance ends the walk rather
  than spinning.

### Card states and colors
- Left border and status pill follow the agent's live state, reusing the shared
  `--state-*` tokens rather than introducing a second vocabulary:
  - `running` -> `--state-working` (agent is producing output)
  - `needs you` -> `--state-message` (agent is at a prompt or dialog; the dot
    pulses, and only this one pulses -- a merely quiet card must not compete for
    attention with one that is actually blocking)
  - `quiet -- may need you` -> `--state-idle` (parked at a prompt past the window)
  - `agent gone` -> `--state-disconnected` (session ended)
- Clicking anywhere on an in-progress card switches to that agent's terminal;
  the status pill does the same, and stays the keyboard path since the card
  itself is not a tab stop. Card buttons, the PR link, and a click that ends
  a text selection on the card are all excluded.
- A card that carries its own permission mode wears it as a chip; a card left on
  the board default wears nothing, because the chip's job is to say "this one is
  different". What runs the card is the exception to that rule: every card
  opens its chips with one agent·model pill, "claude · opus-5-5" or
  "codex · gpt-5.5" ("claude · default" / "codex · default" on the CLI's default model;
  the redundant "claude-" prefix is dropped from the label and kept in the
  tooltip). Chipping only the non-default CLI left a Claude card with a bare
  model id and a Codex card with two pills. `read_job` still says
  `runs on: codex` only for a Codex card, since Claude Code is the default. `bypassPermissions` takes `--state-disconnected` instead of the
  routine chip gold, on the card and on both selects, since it is the one mode
  with nothing reviewing the agent and it otherwise reads as just another entry
  in a list of six. The colour sits on the closed select, not on the `<option>`
  (Safari ignores option colours), so the warning survives the dropdown closing.
- Card actions are hidden until hover/focus-within, so a full board stays scannable.
  A touch screen (`hover: none`) has no hover, so there they always show, and
  under 700px they are 40px tall for a thumb.
- Order on a card depends on its column (`ORDER` in `public/modules/jobs.js`),
  as agreed with the owner. The job's type pill (recurring, scheduled, run)
  rides inline on the title line, so the card says what kind of job it is at
  the very top. To do: title, repo, when it runs, who posted it
  and when ("posted by Billion · 2h ago"), then the smaller kind chips
  (the agent·model pill first, then no PR, then a permission mode) and any blocker
  warning, then detail and attachments. In progress: title, status badge,
  repo · branch, agent · started, then any error, the chips and the rest.
  Review and Finished: title, PR pill, repo · branch, agent · started, the
  chips (so the agent·model pill reads straight after the agent), then the
  agent's summary on a no-PR card. The lower chip row always opens with the
  agent·model pill; no PR (or PR runs on a schedule) and a permission mode
  follow it. Actions are always
  last. The repo is bold with a repo glyph and the branch beside it carries a
  branch glyph, so the pair reads as "repo, branch" without a label.
- Every To do card says when it runs: "runs Tue 8:00 PM · in 5h" for a
  schedule or a one-time card with a start time, "runs in 45s · held so it
  can be edited" after a send-back, "paused", and for an ordinary card its
  place in its repo's queue: "runs next" while the repo has a free slot,
  "runs when a slot frees (N ahead)" behind a full one, "queued · board
  stopped" while the dispatcher is off. The queue is worked out on the client
  the way `selectDispatchableJobs` orders it (oldest first, per-repo cap,
  live agents only).
- A long repo name or branch truncates with an ellipsis and keeps the full
  text in its tooltip (beside a branch the repo takes at most 70%); a title never truncates. Chips keep their case, since
  model ids and permission modes are case-sensitive identifiers.
- Card text sits on `--text-muted`, never `--text-dim` and never under an
  opacity: dim misses 4.5:1 on the card ground in both themes. Status and
  error text is the state colour mixed a fifth of the way toward `--text`,
  for the same reason; the border keeps the raw state colour.
- Two error lines can appear, and they are independent. `lastError` carries what
  happened to the job (a dispatch failure, or "agent lost" after a restart);
  `prCheckError` carries why the board cannot check for the pull request at all.
  Both can be true at once — the agent is gone AND this repo is invisible to the
  signed-in `gh` account — so neither may silence the other.
- The agent line renders whichever of agent name, branch and start time are
  known. The branch is a fact about the job, not about the agent: gating it on
  the name left a finished card showing nothing once the name was lost.

### Derived, never stored
"Needs you" describes a PTY as it is right now. It is computed on both sides --
the server derives it per broadcast, the client recomputes from its own agent map
on every state change -- and never persisted, since a stored copy would be stale
the moment the server restarted. The same reasoning is why `prCheckError` IS
stored: it describes the board's own ability to reach GitHub, which outlives any
session and is not re-derivable from one.

### How the server reads a PTY's state
`detectState` (`lib/helpers.js`) runs on every pty read and once a second per
session. An exited process is DISCONNECTED; output within the last 3s is
WORKING; after that, a dialog matching `MESSAGE_PATTERNS` is MESSAGE ("needs
you"), a `PROMPT_PATTERNS` match or any TUI agent is WAITING, and a plain shell
that matches nothing is IDLE. The dialog patterns are each CLI's own wording
(Codex, Claude Code 2.1, Gemini CLI, aider), word-bounded and phrase-shaped:
until v0.4.2.3 a bare `/approve|deny|allow|reject/` also matched Codex's
permission picker describing itself ("choose what Codex is allowed to do") and
any summary line an agent wrote with "allowed" in it, and a false MESSAGE keeps
a card orange. Claude Code positions words
with cursor moves, so its phrases match with the spaces gone
("Yes,andalwaysallow"); its 2.1 permission dialogs are captured raw in
`test/fixtures/claude-permission-dialogs.js`.

What the patterns run over is the last line plus a five-line window of whole
lines (`recentStrippedLines`). For a Codex session the last synchronized-output
frame (DEC mode 2026, `trackSyncFrames`) stands in for the window: Codex draws
its whole bottom pane inside such frames and redraws it with cursor moves rather
than newlines, so an answered dialog's text sat in the window until five more
real lines arrived, bubble lit the whole time, while the last frame is the pane
as it stands -- the dialog while open, the bare prompt once answered. A frame
is the whole pane only when it carries the composer placeholder or the status
row (which names the working directory); any other frame (the option rows as
you arrow through a picker, the history Codex re-inserts after a resize) merges
onto the pane it held. Frame bytes never enter the line stream, a marker cut by
a read boundary is still found, a whole line printed outside any frame since the
frame closed outranks it (a shell tab that ran Codex and then Claude Code), and
a frame open past 1s is abandoned. Frames are trusted for Codex only: Claude
Code draws none today, and its repaints are diffs, so a frame of its would be
one row, not the pane. The full answer is a screen model; TODOS.md has it.

### Identity across restarts
No session survives a restart, so every job's `agentSessionId` is cleared on
load. Session ids also carry a per-process prefix: the counter alone restarts at
zero while job records outlive the process, so a stored `session-5` would
otherwise resolve to whatever `session-5` is in the next generation — an
unrelated agent on a different branch.

`agentName` is kept. It is history, not a live link: "Phantom did this work"
stays true across a restart, and it is the credit the card exists to show.

The BRANCH is the durable identity. It is created per job, not reused while it
exists, and survives on both the orphan record and the job — so it is what
reconnects a re-adopted agent to its card, and what finds the agent to retire
when the stored link is gone.

Re-adopting an orphan runs the CLI that left the conversation: each CLI keeps
its own transcripts, so a Codex agent revived with `claude --continue` has
nothing to continue and dies at once. `orphanResumePlan` in `server/jobs.js`
asks three witnesses in order: the orphan record's `agent` note (written when a
session is saved or closed, and only for a command that is literally `claude`
or `codex`, so a shell tab or a guessed re-spawn leaves it blank rather than
hardening a guess into a fact); the job card on its branch; and last, whichever
CLI left the newer transcript for that worktree under its own home
(`server/agent-transcripts.js`, honouring `CLAUDE_CONFIG_DIR` and `CODEX_HOME`;
only interactive Codex sessions count, the ones a resume can reach). A Codex
agent resumes by id, `codex resume <id>` on that worktree's newest session:
`--last` scopes by repo, so sibling worktrees would take each other's
conversations; with no session to name it opens the picker instead.
With none of those it is Claude Code, the board's default. When the card is
known its permission mode rides along too, since neither CLI remembers the
sandbox it was dispatched under. An agent spawned by hand has no card, so the
session records the permission flags it was started with (`permissionFlags`,
read off the command through an allowlist of each CLI's own flags and values
in `lib/jobs.js`; scanning stops at `--`, so prompt text can never come back
as a permission), the orphan inherits them, and the re-spawn puts them back —
the card's mode still wins when both are known, being the board's current
word. Only a session that owns its flags records them: a board dispatch
records none, because its card's mode is re-resolved against the board at
every re-spawn, and freezing the dispatch-time flags would let a bypass card
retired long ago come back as a bypass agent after the board was tightened.
Records carry an `origin` ('board' or 'user') that survives re-adopts, so a
board agent whose card is done or deleted still resumes under the board's
current mode rather than the CLI's default. A session resumed under a card's
or the board's mode likewise records none. The allowlist is
the only route onto the argv: a stored record passes through it again on the
way back, so nothing hand-edited into `config.json` can smuggle a second flag.

### Board-spawned agents
A dispatched agent is an ordinary agent. Its tab opens without taking focus,
because an unattended dispatcher firing every five minutes would otherwise
move the user's cursor mid-sentence. The server marks `session-created` with
`focus: true` only on the copy sent to the window that spawned or adopted the
agent. A dispatch has no such window, so every browser adds the tab quietly
(so does every browser but the spawner's, for a person's spawn). A window
showing no terminal takes the new tab, unless it is showing the job board.
Its tab dot carries a faint outline to show where it came from, and the tab is
disposed automatically when the agent is retired.

A one-time job's agent is retired when its card reaches Done — its PR merged
or closed, or the user filed it away — or is moved back to To do. Review keeps it (see
"How a one-time card moves"). A schedule's runs are one-time cards, so the
same rule covers them; a superseded run is retired as it is filed to Done.

### Agent-posted jobs
An agent you are talking to can put a card on the board when you ask it to, so
"add that to the job board" does not mean leaving the conversation to type it.
The board exposes MCP tools — `post_job`, `list_jobs`, `read_job`, `edit_job`,
and `finish_job` for a dispatched agent to report its own card done — over streamable HTTP from the app's own Express server
(`server/mcp.js`), and every spawned Claude Code or Codex agent is connected to it by
`server/agent-mcp.js`. Claude reads `--mcp-config`; Codex gets a per-launch
`-c mcp_servers.agent-007-board=...` override that starts a local stdio bridge.

- **Only the linked agent can finish a card.** `finish_job` acts on the card
  whose `agentSessionId` is the caller's session, never on an id it is handed,
  so no agent can close out another's work. On a card that requires a PR the
  URL must be the open PR on that card's own branch, since it lands on the card
  as the link a reviewer follows.
- **Reading is its own tool, so asking costs nothing.** `list_jobs` answers
  "what is on the board?" without a card being posted to find out, and returns
  the id of each so `read_job` and `edit_job` have something to name. The board
  is one shared wall — every connected client already sees every card — so these
  show the whole board rather than only what the asking agent posted. Finished
  cards stay in the archive the board itself keeps them in, with their count
  said out loud so a board that looks empty is never mistaken for one that is.
- **A card stops being editable when it leaves To do, for everyone.** Its agent
  was handed the title, detail and attachment paths in its prompt at dispatch,
  so a later edit changes nothing about the run and leaves the card describing
  work nobody was asked to do. The board's own form has only ever offered Edit
  on a To do card, so `updateJob` now enforces what the interface always
  implied, at the store rather than at the button — one rule, one message,
  whether a person or an agent is asking. The cost is real and accepted:
  retitling a card in Review for the archive's sake is no longer possible.
- **An agent may read the whole board but only rewrite its owner's cards.** A To
  do card's detail is not text about work, it IS the next agent's prompt:
  `buildJobPrompt` hands it verbatim to an unattended `claude` or `codex`, in
  whatever permission mode the card or the board names (`auto` unless someone
  changed it). Every board-dispatched agent holds one of these tokens, so without an
  ownership rule an agent working a hostile repo could rewrite a card queued for
  a different repo and have the board run its text there. `edit_job` therefore
  refuses a card whose `postedBy` is not the calling session's owner, the same
  rule `ws.js` applies to terminals, and null on a single-player board so it
  only bites once identities exist. Reading stays board-wide: `jobsPayload`
  already sends every card to every connected browser, and a tool that could
  only see its own cards could not answer "what is queued?".
- **An agent cannot choose the mode its card will run in.** `postJobForAgent`
  and `editJobForAgent` drop `permissionMode` entirely, so a card filed or
  rewritten through the MCP door always inherits the board's setting. The field
  decides how much the next dispatched agent may do unasked; letting an agent
  set it would be a way around every gate its own session runs under, and the
  ownership rule above would not catch it — an agent is allowed to edit its
  owner's cards.
- **An agent may pick the CLI, but not widen the mode through it.** `post_job`
  takes an `agent` (`claude` or `codex`, defaulting to the CLI the poster is
  itself running on), and Codex has no `--permission-mode`. If a Codex card
  simply ignored the board's mode, posting `agent: codex` would be the way
  around the rule above: a read-only board would dispatch a Codex that writes
  files. So `CODEX_MODE_FLAGS` in `lib/jobs.js` maps every Claude mode onto the
  nearest thing Codex's own sandbox and approval flags say — `plan` is
  `--sandbox read-only`, `manual` is that same sandbox with `--ask-for-approval
  on-request`, so every write comes back as a question (Codex's `untrusted`
  policy, the mapping before v0.4.2.1, is gone from codex-cli 0.153 and was
  rejected at parse time), `dontAsk` never asks, `bypassPermissions` is
  `--dangerously-bypass-approvals-and-sandbox`,
  and `auto`/`acceptEdits` are Codex's default (workspace-write, approval on
  request) — and a strict board binds a Codex card too. Nearest, not
  equivalent: that default runs a command it judges safe inside the sandbox
  without asking, where Claude's `acceptEdits` would have prompted. The form
  offers a Codex card only board default, `auto` and `bypassPermissions`; the
  rest are reached through the board setting, and a card already holding one
  keeps it when the form opens, so an unrelated edit cannot quietly widen it.
- **An edit that lands says so and leaves a name.** The hazard above is an edit
  nobody sees, so `edit_job` toasts the way `post_job` does and stamps
  `editedByAgent`/`editedAt`, which the card renders beside "via" — a card an
  agent rewrote must not go on reading as the work of whoever queued it.
- **Basenames, never absolute paths.** `read_job` reports a card's repo the way
  `resolveRepoRef`'s errors do, and does not report worktree paths at all: the
  reply describes every card on the board, including other people's, to whatever
  agent asked, and the layout of the user's disk is not part of the answer.
- **`edit_job` replaces, never appends.** A tool that merged text would have an
  agent guessing at what is already on the card; the description says so, so an
  agent meaning to add to a detail reads it first.
- **A tool, not a command on PATH.** An agent does not enumerate its `PATH`, so
  a binary sitting there is invisible — the first attempt at this feature put a
  CLI on every agent's `PATH` and the only thing that ever told an agent it
  existed was a line appended to every dispatched job's prompt, which is an
  instruction to queue work rather than a capability to use when asked. A tool
  arrives in the agent's tool list with a description. That is real discovery,
  and it is passive: nothing anywhere tells an agent to post jobs.
- **One HTTP endpoint.** Claude Code connects directly. Codex starts a small
  stdio bridge that reads the same credential file and forwards JSON-RPC to
  that endpoint, including notifications and tool errors.
- **Merged with the user's own MCP servers.** Claude gets `--mcp-config`
  without `--strict-mcp-config`. Codex gets a per-invocation `-c` override for
  only `mcp_servers.agent-007-board`, leaving other servers and global config
  intact. The bridge uses the app's Node executable and an absolute script path.
- **Claude Code and Codex.** Both receive the board automatically, including
  Windows launchers. Other commands pass through untouched. `POST /api/jobs`
  remains a plain HTTP door to the same action.
- **The token is a file, not an environment variable.** An env var is inherited
  by every child process the agent starts — a test run, an install script in a
  repo under review — and any of them could read it. The token lives in a
  mode-0600 config file that the MCP client opens at startup and that is deleted
  when the PTY exits. It identifies one live agent, never a user, and it stops
  resolving the moment that session ends.
- **`requireUser` is the default; agent access is opt-in.** Routes an agent
  token may reach are registered ABOVE the `requireUser` gate in `setupRoutes`,
  and that placement is the whole access-control decision. The earlier shape had
  it the other way round — agents allowed unless a route remembered to exclude
  them — which meant `/api/browse` had to be retrofitted the day the credential
  was introduced.
- **Claude Code asks the user before the call goes through — under the default
  mode.** Measured: under the default mode and under `--permission-mode auto`, a
  call to one of these tools waits for approval (measured on `post_job`; the
  gate is per tool call, not per tool). That is the right outcome and not
  something to design around — a card is filed, read out or rewritten because a
  person said yes. It does mean an unattended agent cannot post one, which is
  exactly why nothing instructs dispatched agents to.

  Under `bypassPermissions` it does not ask, and the board can be put in that
  mode: `DEFAULT_PERMISSION_MODE` is `auto`, but the toolbar exposes the board
  setting (v0.3.33.0) and a card can
  override it, because auto mode needs a classifier that is not available on
  every provider the board runs against and silently degrades to Manual where it
  is missing. So this is a choice a user can make, with what follows.

  What follows is larger than the board, and the honest answer belongs here
  rather than in a tooltip. `bypassPermissions` means the agent runs Bash
  unprompted, as the user, from the moment that worktree is trusted. For a
  Claude Code worker the server does that itself before the spawn (v0.6.2.0,
  `server/claude-trust.js`), and for a Codex worker a per-run `-c projects=`
  override does the same (v0.6.5.0), so read this as automatic; only
  `TRUST_BOARD_WORKTREES=0` puts one click back in front of it. And `MCP_CONFIG_DIR` (`~/.agent-007/mcp/<port>/`) is
  a SIBLING of `WORKTREE_DIR` (`~/.agent-007/worktrees/`), where every board
  agent's cwd lives. One `cat ../../mcp/<port>/*.json` yields every live
  session's bearer token; `resolveAgentToken` maps a token to its session and
  `http.js` derives the MCP caller's identity from that session's `ownerId`, so
  a stolen token makes the caller that other user and `editJobForAgent`'s
  `job.postedBy !== asker` check passes. `USERS_PATH` is a sibling too, and
  `loadUsers()` re-reads on mtime with no restart. The 0600/0700 modes are
  same-UID and stop none of it. Nor is this list exhaustive: `git worktree add`
  leaves `.git` pointing into the real repository, and ungated Bash is the
  universal way around any file-tool rule.

  So in that mode the ownership rule is a guard against an agent that
  misbehaves, not against one that is hostile — against a hostile agent there is
  no in-app boundary left, and there never really was one below Claude Code's
  prompts: the prompts ARE the boundary. Which is the argument for `auto` as the
  default and not merely as a fallback. The classifier is the only thing that
  reviews a dispatched agent's actions before they run, and a board agent's
  prompt is card text plus whatever it reads out of the repo. A user who turns
  it off is choosing the operational boundary instead: run the board on
  repositories whose contents you trust, the same standard the auth section sets
  for handing out a token. Two levers, deliberately: the board setting so a
  machine without the classifier sets it once, and a per-card mode so a single
  job asking for more does not lower the bar for the queue behind it. If a real
  gate is wanted back for the bypassed case, the lever is withholding the board
  credential from dispatched sessions in `server/pty.js` and moving
  `MCP_CONFIG_DIR` out from beside the worktrees — not the permission mode,
  which has to stay changeable on every provider.
- **Attribution is two facts, not one.** `postedByAgent` (which agent typed it)
  is stored beside `postedBy`/`postedByName` (whose work it is), and the card
  shows the agent as an accent-tinted `via <name>`. Folding them into one field
  would make a machine-queued card indistinguishable from a hand-typed one.

### Branch naming
Board branches are named from the job title rather than a cocktail:
`{git-username}/add-rate-limiting`. Slugged to `[a-z0-9-]` and length-bounded,
which side-steps every git ref rule at once instead of enumerating them. Two jobs
may share a title, so collisions use a fresh UUID suffix; names taken on the
remote count as collisions too, since a finished job leaves its remote branch
alive (that is the open PR). If the remote cannot be checked, start with a UUID
suffix too. Git checks local collisions atomically; naming attempts are bounded
at ten and never delete an existing branch. A branch name typed by the user
still fails on collision instead of being silently renamed.

### Attachments
A card can carry screenshots and files: picked with **Attach files**, or pasted
into Details (a paste that lands in the job form is the form's, not the active
terminal's). They ride inline on the `job-create`/`job-update` message as base64,
the same shape as the terminal's upload, and land under
`<config dir>/attachments/<job id>/`.

- **Outside the repository, by construction.** The agent is handed absolute
  paths in its prompt, so nothing can be committed by accident; the spawn
  command adds `--add-dir` for that directory so an unattended Claude Code job
  does not stop at a permission prompt on its first screenshot. A Codex job
  gets no such flag: every Codex sandbox, read-only included, reads anywhere
  on disk and gates only writes, and attachments are only ever read.
- **Served sandboxed, to people only.** `/api/jobs/:id/attachments/:name` sits
  below the `requireUser` gate and sends `Content-Security-Policy: sandbox`,
  `nosniff` and `no-referrer`: an uploaded HTML file must not run as this
  origin, where the token lives. A card link cannot send a header, so the
  token rides in the query for exactly this route.
- **Only changeable in To do**, because the whole card is: `updateJob` closes
  a card to every edit the moment it leaves To do (see Agent-posted jobs), and
  a running agent was handed these paths in its prompt at dispatch.
- **Refused, never silently dropped.** An unusable name, two names that one
  filesystem would fold together, more than 20 files, more than 10MB each or
  50MB per save all fail the whole edit, and a refused edit changes nothing,
  title included. New files are written to `~part` and renamed into place only
  once every write succeeded.
- **Paths from `config.json` are not trusted.** A stored path is served or
  deleted only if it resolves inside the attachments dir. `safeFilename`
  (`lib/helpers.js`) is the one sanitiser for every client-supplied name,
  terminal uploads included; it also prefixes Windows device names.
- **Freed at Done.** A card is a few lines of JSON and stays forever; its
  files can be megabytes and were inputs to a run that is now over. When a
  card reaches Done — by hand or by the merge sweep — its files and their
  links are removed, except while a live agent is still attached (one whose
  retirement at Done failed keeps the paths its prompt named). The merge sweep retries
  stragglers every scan, so a deferred or OS-refused removal is reclaimed
  later instead of leaking. Deleting a card earlier removes its directory too.

## The Billion tab

The owner's chat with Billion, in a pinned tab right after Jobs: same
`.board-tab` shape (muted until active, then `--accent` with the usual
underline), a chat bubble followed by "Billion": it is a conversation, not
notifications. Open questions show as the same orange `.board-tab-badge`
sitting on the bubble's top-right corner (`.chat-badge`, ringed in the tab
bar's background), "9+" above 9. With nothing open the bubble is dimmed and
has no badge. Like Jobs it has no close control, and it is the tab a page
opens on (a remembered terminal tab wins on reload). Billion's own terminal
tab is hidden until the owner clicks Billion in the office or its explorer
row; closing it hides it again, and Billion keeps running. The tab's
`aria-label` carries the count ("Talk to Billion, 3 open questions", or "Talk
to Billion"); the tooltip is "Talk to Billion". On a phone the bottom bar has
a fourth button, the same bubble and badge followed by *Billion*; it opens the
terminal panel on this tab.

- Three parts, top to bottom: a strip of open questions, the thread
  (`role="log"`), and the text box, both centred at up to 760px.
- Bubbles in `--bg-dark` with a hairline border and 8px radius: Billion's on
  the left, the owner's on the right tinted with `--accent`, the corner nearest
  the speaker squared off. Time in `--text-dim` under the text; text is never
  rendered as HTML; Telegram voice notes are marked *(voice)*.
- Each pending owner message contains its own progress box (`role="status"`,
  `aria-live="polite"`); only the current request pulses while Billion is working.
  Summaries wrap on narrow screens. Its answer keeps those summaries in a
  closed **Work details** disclosure; manual expansion survives chat updates.
  The disclosure has a visible keyboard focus ring, and reduced motion stops
  the activity pulse.
- An open question's bubble has an `--state-message` left stripe. Head: `Q3`
  in accent (a bold "!" before it when blocking, dimmed when low). Choices
  are outline buttons, the recommended one first with an accent border and a
  small uppercase "recommended" tag, then *Reply* (an underlined accent
  link) and *Dismiss*. Once answered the buttons collapse to "you answered:
  ..." in italic `--text-muted`, with an *Undo* link for a minute.
- One bar (`.billion-bar`, `--bg-dark`, hairline bottom border) tops both
  views: *Briefing | Chat* as a segmented control (`--bg-panel` track, the
  picked segment accent text on a 14% accent tint), then the status line
  (12px `--text-muted`, a 7px state dot), then, in Chat only, the open
  questions chip and the speaker. Bar controls carry `.billion-bar-control`
  (`--bar-control-h`: 32px, 40px on phones). When the tab is narrower than
  720px (`@container billion`), the status line drops to a full-width line
  under the controls, two lines at most.
- The open questions chip (`.chat-strip`) is a pill: the count in bold
  accent, "open questions" (just "open" on a phone), a "!" when one is
  blocking, an accent-tinted fill and border; filled accent while the panel
  is open. Hidden when none are open. "New messages ↓" (an accent pill) appears when a
  message arrives while scrolled up.
- The text box: a textarea with an accent Send button. Enter sends,
  Shift+Enter is a new line. It sends a plain message unless the owner
  tapped *Reply* on a question: then a line above it says "Answers Q3: ..."
  with × to send as a plain message instead.
- After a send or tap the controls dim until the server answers; a refusal
  ("Billion is not running") shows in `--state-disconnected` and the text
  stays in the box.
- The terminal's floating voice mic is hidden on this tab. The tab has its
  own: `.chat-mic`, a square outline button beside the textarea that pulses `--state-recording` red while listening (`mic-pulse`).
  What is being heard shows greyed and italic in a line above the box
  (`.chat-voice`), with the same red dot; notices and errors drop the dot.
- One control height and one gap for the composer row: the textarea, mic
  and Send all carry `.chat-control` (`min-height: var(--chat-control-h)`,
  40px, 44px on phones) and sit `--chat-gap` (6px) apart. The composer row
  is bottom-aligned: a multi-line message grows the box upward and mic and
  Send stay beside its last line, where the caret is. The mic's glyph is
  16px at a 1.05 stroke, the same 1.2px line as the header's gear and theme
  icons.
- The bar's end (`.chat-head`) holds the chip, *Resume reading (N new)* as an
  accent pill when the browser wants a tap before it speaks (after a
  reload), and a round speaker button (`#chat-autoread`) that is the *read
  new messages aloud* switch itself: one tap on, one off. There is no Stop:
  a tap while anything is being read stops it and leaves the switch off. Icon plus "Read aloud" when the tab is 720px
  or wider, icon only (round) below that. Its name and tooltip are "Read new
  messages aloud" and `aria-pressed` carries the state; accent on a 14%
  accent tint while on. Each tap shows a toast (`.chat-toast`: a `--bg-panel`
  pill with a soft shadow, centred at the top of the thread, `role=status`,
  1.8s) saying "Read aloud: on" or "Read aloud: off".
- The voice picker lives in Settings (the gear), under its own "Read aloud"
  head (`.voice-settings`, hairline top border like Telegram's): "Voice for
  reading messages aloud" above a select ("Auto · Evan" or a named voice), 32px, 16px type and 40px on
  phones so iOS does not zoom. Hidden while the browser offers one voice or
  none.
- Each message from Billion has a speaker button (`.chat-speak`, 40px hit
  area, `--text-dim`) in its time row; it turns accent and becomes a stop
  square while that message is spoken.
- `prefers-reduced-motion: reduce` stops the mic pulse and the dot.
- Empty: "Nothing here yet. Say something to Billion." in `--text-dim`,
  hidden while the notice bar says Billion cannot hear you.
- Phones: choices and the `.chat-control` rows are 44px tall; the input is
  16px so iOS does not zoom, and Send and the header's labels 13px beside it;
  bubbles go up to 88% wide.

## Interactive Behaviors

### Icon buttons
- Shared `.icon-btn` primitive (formerly `.theme-toggle`): Settings gear (Lucide
  "settings" cog, 24-box at 14px, stroke 2 ≈ 1.2px), theme
  toggle and the rename pencil in the terminal header; the voice mic reuses it with the
  `.voice-fab` overlay class

### Voice input
- Mic button floats bottom-right of the terminal viewport (`.voice-fab`,
  34px, shadowed, clears the xterm scrollbar) so it sits next to the prompt
  line being dictated into
- Mic button pulses `--state-recording` red while listening (`mic-pulse`)
- Transcript pill is anchored to the mic (right-aligned with the fab, one
  row ABOVE it, growing leftward) so the reaction appears where the user
  clicked without ever covering the terminal's last row — that row is where
  dictated keystrokes echo; it slides up from the mic (`pill-in`) on show
- Fab/pill/presence geometry all derive from `--voice-fab-*` custom
  properties on `:root`; the presence pill is stacked above the voice column
  (`bottom: var(--voice-column-top)`) so it can never sit on the mic and eat
  its clicks, and resizing the fab moves the whole column together
- The recording signals (button pulse, live dot, screen-reader "started")
  turn on only after `getUserMedia` grants — the requesting phase uses the
  dot-less notice variant
- `prefers-reduced-motion: reduce` disables the pulse and slide animations
  (that block sits after the `.voice-indicator-dot` rule so it wins the cascade)
- **Audit exception, `pulsing-dot`:** the design audit flags
  `.voice-indicator-dot`'s infinite `mic-pulse` as decorative liveness. It is
  kept: the dot is only in the DOM flow while the pill's `live` state shows,
  which `beginListening()` sets after the mic is granted; `notice` and
  `error` hide it and stopping hides the pill, so it pulses only while audio
  is actually being recorded, and not at all under reduced motion. The
  detector can only waive this rule for all of `public/index.html`, so no
  ignore is configured; this note is the record
- The pill's pulsing dot means "recording" — hidden on notice/error variants
  (the mic is off there; a red dot would be an inverted privacy signal)
- Screen-reader announcements go to a visually-hidden `.sr-only` live region

### Theme toggle
- Sun/moon SVG icons in terminal panel header
- Toggles between dark and light themes
- Terminal colors update in real time (full ANSI palette swap)
- The office canvas repaints once on toggle — the animation loop skips frames
  when no agents are alive, so without it the canvas kept the old theme's colors

### Reconnect in place, reload on a server restart
- A dropped WebSocket (a proxy's idle timeout, a network blip) reconnects
  without reloading: the server's welcome carries `sessionIds`, so tabs closed
  meanwhile go, and the terminal on screen asks for its scrollback again
- The welcome also carries a per-start `bootId`; a different one means the
  server restarted or updated, and the page reloads once for the new code

### Clipboard paste
- Cmd+V with image data in clipboard uploads as screenshot
- Uses capture phase to intercept before xterm.js
- Skips a paste that lands in the job form, which becomes a card attachment
  instead (see Job Board, Attachments)

### New agent spawning
- Preset buttons at the top of the spawn dialog (Claude Code, Codex, Gemini,
  and a shell) fill the Command field, which lives inside the collapsed
  Advanced section; the shell preset follows the server's OS — PowerShell on
  win32, Bash elsewhere — using the `platform` field the welcome message
  carries, and re-renders if the form was opened before the socket connected
- Newly spawned agent auto-activates (switches terminal tab)
- Empty repos get automatic initial commit

### Branch sync
- Branch name updates in real time in explorer and terminal header
- Server polls branch on every file tree scan cycle