# 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/ `, fetched first,
where ` ` 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 `` 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 ` 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 ``
(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 ` 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//`) is
a SIBLING of `WORKTREE_DIR` (`~/.agent-007/worktrees/`), where every board
agent's cwd lives. One `cat ../../mcp//*.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 `. 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
`/attachments//`.
- **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