# lib — shared extension helpers

Small modules imported by two or more extensions. Pi loads each extension with
an **isolated module cache**, so these are *value-only* libraries: pure
functions, constants, and classes. Never keep cross-extension state here — a
module-level variable would have one copy per extension, not one shared copy.
`test/boundaries.test.ts` fails a module-scope `let`, `var`, `Map`, `WeakMap`, or
empty array, so this is a test and not a convention. The single exception is the
event-bus pattern in `rails.ts`, where the state lives in the caller's closure,
not in this module.

This directory sits outside `extensions/` on purpose: nothing here is a
loadable extension entrypoint, and every module is a contract shared *between*
extensions rather than private to one. A helper used by only one extension
belongs in that extension instead — see `extensions/web-access/http.ts` and
`extensions/plan/path-guard.ts`.

| Module | Responsibility |
|---|---|
| [`browser.ts`](./browser.ts) | Cross-platform default-browser opener shared by `file-browse` and `plan`: pure `browserCommand(url, platform)` plus best-effort `openInBrowser(url, { platform, spawn })` with an injectable spawn. |
| [`bus-request.ts`](./bus-request.ts) | The request/response handshake over `pi.events` for a question one extension asks another: `BusAnswer` (a value, or the promise of one an owner is still producing), `BusRequest` (the channel, the payload built around the helper's one-shot `respond`, the `unclaimed` sentence for a session where nothing listens, and an optional `failed`), and `requestOverBus`, which settles exactly once — on the claimant's answer, on the microtask fallback when nobody claims, or on `failed` when a claim's promise rejects. Shared by [`plan-entry.ts`](./plan-entry.ts) (enter plan mode), [`http/mount.ts`](./http/mount.ts) (a page's prefix), and [`session-tool.ts`](./session-tool.ts) (join the team), so one guarantee has one body. |
| [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` / `writeConfigValuesAt` / `unsetConfigValueAt` (the leaf writers preserve siblings and metadata, `writeConfigValuesAt` applies several leaves in one document pass, and the last removes one and prunes what it emptied). Includes `clampInteger` / `clampNumber` / `cleanString` / `optionalString` / `asBoolean` / `stringList` / `asRecord` / `asNumber` (the untrusted-JSON narrowing pair) / `readJson` / `readJsonResult`. |
| [`env.ts`](./env.ts) | Env-var contracts shared across extensions: the worktree environment (`ENV_ROOT`, `worktreeRoot`, `resolveEffectiveCwd`) used by worktree, file-browse, job, and rewind, plus `extensionEnablement` for the package-wide disable list (`ENV_DISABLED_EXTENSIONS`) and the opt-in extensions only `ENV_ENABLED_EXTENSIONS` can load (`OPT_IN_EXTENSIONS`). |
| [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters, plus `decodeCodePoint`, the other half of the same boundary — one numeric character reference as text, with a surrogate or out-of-range value decoded to U+FFFD. |
| [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` / `JobFinishedEvent` (published by `job`, consumed by bytefly's notify capability), `SUBAGENT_FINISHED_CHANNEL` / `SubagentFinishedEvent` (published by `subagent`, consumed by bytefly's footer roll-up), `WORKTREE_CHANGED_CHANNEL` / `WorktreeChangedEvent` (published by `worktree` on enter and exit, consumed by `file-browse` so an open page re-roots instead of serving the checkout the session left), and `WEB_SEARCH_PROVIDER_CHANNEL` / `WebSearchProviderEvent` (published by `web-access` at session start, consumed by bytefly's doctor so it can report the resolved provider without importing the extension — the key itself is never in the payload). |
| [`notice.ts`](./notice.ts) | The shared transcript notice: `NOTICE_TYPE`, the `NoticeDetails` / `NoticeItem` shape, `noticeMessage` (the `display: true` message both send paths accept), `isNotice`, and `pruneStaleNotices` — posted by `job`, `watch`, and `session-bus`, rendered by bytefly — plus `burstText`, the one assembly of the model-facing text for a burst of pending outcomes (marker, counted headline, rows, an optional `… N more`, an optional hint). |
| [`report.ts`](./report.ts) | The pending-report channel: `createPendingReporter` with `wake` / `pending` / `markReported` / `buildNotice` / `wakeRequested` / `source` / `onWoke`, returning the handlers that report at the next turn, raise at most one triggered turn per wake window, retry a wake the running turn suppressed once `agent_settled` reports an unaborted settle, and prune only their own notices; and `registerPendingChannel`, which binds those handlers to `session_start` / `session_tree` / `agent_start` / `agent_settled` / `before_agent_start` / `context` / `session_shutdown` around a `load` / `setStatus?` / `shutdown` runtime, so the order a session starts and shuts down is written once. A `load` that resolves a repository may return a promise, which is awaited before the trigger is armed; a runtime that publishes no chip omits `setStatus`. `job`, `watch`, and `session-bus` supply only their own arming in `onSessionStart` / `onSessionShutdown` and their own `wakeRequested`. |
| [`errors.ts`](./errors.ts) | `messageOf`: the one boundary that turns an unknown thrown value into a user-facing line (an `Error` yields its message, anything else is stringified), and `hasErrorCode` for the branch a caller takes on one errno code (`EEXIST` means another writer won). |
| [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `formatCount`, `formatDuration` (`450ms`, `3.2s`, `1m05s`, `2h03m`), `formatSize` (`68 B`, `12.4 kB`, `1.5 MB`), `boundedTail` (the end of a joined tail with a `truncated` flag), `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — `lineOf` (a value read as one sanitized line), `sanitizeUntrustedLine` / `sanitizeUntrustedText` with `MAX_OUTPUT_LINE` for **program output** (OSC/CSI escapes removed whole, `\r` progress resolved, clipped), and `shortenPath`. |
| [`timer.ts`](./timer.ts) | The three timer seams and their production defaults, each cancellable and each built so a pending timer never holds the process open: `ArmTimer` / `defaultTimer` (one shot), `RepeatTimer` / `defaultInterval` (repeating), and `abortableSleep` (resolve after a delay, reject `Error("aborted")` when a signal wins). Shared by `wechat`'s bridge and prompt channel, plan's review page, `job`'s and `watch`'s repaint clocks, `bytefly`'s working tick, the browse live channel, and the search throttle — so a test drives a deadline instead of waiting on one. |
| [`lines.ts`](./lines.ts) | `LineSplitter`, the byte-safe splitter for a stream of program output: buffers bytes and decodes only at a newline, so a UTF-8 character split across two reads survives, and caps an over-long line instead of dropping it. Shared by `job`, `watch`, `check`, and `subagent`. |
| [`pool.ts`](./pool.ts) | `mapConcurrent`: one async read per entry with at most `limit` calls in flight and results in input order, so a directory listing or a status sweep fans out without an unbounded burst or a per-entry round trip. Shared by `file-browse` and `worktree`. |
| [`qr.ts`](./qr.ts) | The QR seam shared by `wechat`'s login and `plan`'s shared review link: `qrMatrix` (the module matrix), `qrLines` (Unicode half-block terminal lines of a fixed width), and `qrSvg` (inline markup a page can paste); the `qrcode` package is imported on first use, never at registration. |
| [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote transport marks on the UI context it owns (`INTERACTION_TURN_KEY`), the `InteractionChannel` interface (`request` / `post` / `postFile`, plus an optional `setPromptTimeout` scope for a dialog the human must read something before answering), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file, fallback text, digest preface, and prompt `timeoutMs`) plus the shared “Other” label. |
| [`lazy.ts`](./lazy.ts) | The one-load seam for a surface a command needs but registration does not: `lazy(load)` returns `get()` (concurrent callers share one load) and `peek()` (the loaded value, or `undefined`, so a shutdown path never starts a load the session never used). State lives in the returned closure; a rejected load is forgotten so a transient failure can be retried. Shared by `plan`'s review server and `board`'s page. |
| [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `gitCommonDir`, `revParse`, `revParseMany` for several revisions in one call, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
| [`highlight.ts`](./highlight.ts) | First-party, escape-first syntax tokenizer (`detectLanguage`, `fenceLanguage`, `highlightLines`, `HighlightLanguage`, `TokenClass`) for js/ts, json, css, html, shell, python, java, yaml, and sql; shared by `file-browse` and plan's code blocks. |
| [`http/`](./http/) | The loopback HTTP layer shared by `file-browse`, `plan`, and `board`: [`http/serve.ts`](./http/serve.ts) (the one server entry, `serveHttp` — one socket per session, dispatch to the longest matching mount prefix, the body cap of whichever mount was reached, `HEAD` of a stream, the SSE head, one idle grace per mount, and an idempotent close that the last mount's release reaches on its own — so an extension supplies routes and no extension binds a socket), [`http/mount.ts`](./http/mount.ts) (the contract a page and that server agree on: `HTTP_MOUNT_CHANNEL`, the `MountSpec` it asks with — its session, prefix, handler, cap, grace, and the timer that grace is armed on — the `HttpMount` it gets back (`base`, `origin`, `release`, and `share` for one LAN address), `MountOutcome`, and `requestMount`, whose handshake is *claimed* synchronously so a bind that is still starting is not mistaken for a session with no server), [`http/sink.ts`](./http/sink.ts) (`EventSink`, the one thing a route writes, which owns liveness — a write after the end is a no-op — plus the open-stream registry and the idle grace — armed when the last one goes, and, for a mount that asks, from the mount so a page no tab ever opened cannot hold its prefix), [`http/headers.ts`](./http/headers.ts) (the page policy `PAGE_CSP` and the `pageHeaders` / `scriptHeaders` / `jsonHeaders` sets, plus `eventHeaders` for the one long-lived stream), [`http/response.ts`](./http/response.ts) (`ServeResponse` — a buffered body, a `filePath` stream, or an SSE `stream` — plus `htmlResponse` / `jsonResponse` / `jsonError` / `textResponse`), [`http/router.ts`](./http/router.ts) (`parseRequestUrl`, and `isLocalHostHeader` with the `required` mode each page chooses), [`http/guard.ts`](./http/guard.ts) (the request guards a served page shares: `postOnly`, `checkOrigin`, `requireJsonBody`, `envelopeRefusal`, and `isLoopbackAddress` — this machine by peer address, so an owner-only route can refuse the network) — the method allowlist, then the fail-closed `Host` rule, then the origin check on every state-changing method), [`http/token.ts`](./http/token.ts) (`mintToken`, 128 random bits as hex, and `tokenMatches`, the constant-time comparison a route trusts), [`http/port.ts`](./http/port.ts) (`PORT_BASE`, `PORT_RANGE`, and `projectPort` — the band `serve` takes a session's one port from, and the number a project keeps in it, its repository key hashed, so a bookmark survives a restart and every doc states the range), [`http/server.ts`](./http/server.ts) (the socket primitives `serveHttp` is built from — `bindHost` with the accept-error listener, `respond` for HEAD / streamed file / body, `openEventStream`, and the idempotent `createCloser` — layer-internal, so the barrel does not export them), and the [`http/index.ts`](./http/index.ts) barrel, which is the served-page kit: a registration graph may reach [`http/mount.ts`](./http/mount.ts) and nothing else in this directory, so the rest loads with the page that needs it. |
| [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface, whose conventions are its own doc, [`tui/README.md`](./tui/README.md). Modules: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (the docked-list frame `dockListFrame`, the `screenFooter` that closes a screen's own rows, `screenHint`, `detailBlockRows` (the rows its detail block costs), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow` with its `align` option for a value held beside its label, `statusRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`, `byRunningFirst` for a live list's order), [`tui/columns.ts`](./tui/columns.ts) (`splitRow` for two cells at the two edges, `inlineRow` for two cells held together, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`, `labelLine` / `clipLabel` with `LABEL_WIDTH`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job`, `watch`, and `subagent` share, with the attached-context `notify` and a `detach` that clears its keys), [`tui/dock-screen.ts`](./tui/dock-screen.ts) (`DockListScreen`, the two-mode docked list + detail-pane screen with navigation, wheel, range, and confirm), [`tui/confirm.ts`](./tui/confirm.ts) (`ConfirmState`, the y/N layer behind every destructive screen action), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, `callLine` — the one headline every renderer draws — `detailsOrLine` (a result's details, or the one `errorLine` / `rejectionLine` a failure shows), sanitized `errorLine` / `rejectionLine`, `moreHint` / `overflowHint` / `earlierHint`, the `TailResult` transcript block and `isTailResult` (the guard that puts back the row type `instanceof` erases, so an extension reuses its block without a cast), `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo — `lines()` for a component that derives its lines from another component's output, `keyed()` for one with no inner component that describes its own inputs — so a repeat frame is one comparison), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `SHIFT_TAB` / `isShiftTab` (the raw legacy sequence included), `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
| [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, doc, and plan guards share, and the canonical-path helper the worktree registry keys records by. |
| [`pdf.ts`](./pdf.ts) | The optional `unpdf` seam shared by `web-access` and `file-read`: `pdfGuidance`, `PdfUnavailableError`, `pdfTextFromResult`, and the lazy `loadPdfExtractor` / `extractPdfText` (an injected extractor wins), so a missing package and a merged-pages result read the same way through either surface. |
| [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
| [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny, `WORKSPACE_READERS` — the built-in readers plus `read_doc` that plan, rewind, and subagent compose their allowlists from — and `isBoardRead`, which asks `board/actions.ts` rather than keeping an action list of its own. |
| [`plan-state.ts`](./plan-state.ts) | The plan-mode flag replayed from the session branch: `PLAN_MODE_ENTRY_TYPE` and `isPlanModeEnabled(entries)`, so `rewind` can refuse a write while planning without importing the plan extension. |
| [`plan-entry.ts`](./plan-entry.ts) | Plan-mode entry shared by the extension that owns the mode and the ones that ask for it: `READ_ONLY_SUMMARY` and `PLAN_CONSENT` (one wording for the read-only guarantee), the `PLAN_ENTER_REQUEST` and `PLAN_TOGGLE_REQUEST` channels with their one `PlanModeRequest` payload, `isPlanExtensionLoaded(pi)` so every caller answers availability the same way, and `requestPlanMode(pi, ctx)` / `requestPlanToggle(pi, ctx)` — request/response over `pi.events` that resolve `false`/`undefined` instead of hanging when `plan` is not loaded, so a command context (which has no `executeTool`) can still enter the mode after asking the user once, and bytefly's editor can flip it from Shift+Tab. |
| [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
| [`repo-key.ts`](./repo-key.ts) | The per-repository storage key that outlives one checkout: `repoKeyOf(value)` hashes an identity to `REPO_KEY_LENGTH` hex characters, and `repoKeyFor(pi, cwd)` returns `{ root, key, fromGit }` from the repository's *common* git directory, so every worktree of one repository files state under the same key, while a directory outside git keys on itself. Shared by `session-bus` (sessions, mail, claims) and `plan` (plans). |
| [`plans/paths.ts`](./plans/paths.ts) | Where a plan lives and which paths are one, for the two extensions that have to agree about the file a card is executed against: `PLANS_DIR_NAME` / `plansDir(agentDir, key?)` (`<agent-dir>/plans/`, keyed by the repository, unkeyed outside one — the `fromGit` branch both stores read off `repoKeyFor`), `isPlanFileName` (markdown, never a dotfile), the symlink-safe `isContainedPlan`, and `resolvePlanArg` (a bare name inside the store, the `~/…` form the tools display, or an absolute path). Read by `plan` before it writes and by `board` before it records a plan path, so a copy that dropped the symlink half cannot be the one that ships. |
| [`screen-session.ts`](./screen-session.ts) | `withPolledScreen`: mount one `ctx.ui.custom` dock screen — rails suppressed for its lifetime, an optional repaint tick through an injected `RepeatTimer`, and one teardown — handing the mount the host's `tui`, `theme`, `requestRender`, `close`, and `viewportRows`. Shared by `/jobs`, `/watch`, and `/memory`, whose rails window and timer window were the same one. |
| [`board/`](./board/) | The shared feature board. [`board/card.ts`](./board/card.ts) is the record: `CARD_VERSION`, `Card` / `CardStatus` / `CardOwner` / `CardNote`, the field caps `MAX_TITLE` / `MAX_PATHS` / `MAX_PLAN` / `MAX_NOTE` / `MAX_NOTES`, the `normalize*` boundary the model's arguments pass (`normalizeTitle`, `normalizePathHints` — repo-relative hints, never claims —, `normalizeNote`, `normalizePlanPath`, `normalizeCardId`, `normalizePriority`), the priority (`PRIORITIES`, `priorityOf`, `priorityRank`), the lane clock (`CardTransition`, `MAX_LOG`, and `laneSince` — when the card entered the lane it is in, or nothing when its history does not say) and the links (`dependsOf`), `matchesQuery` — the rule `list`'s `query` and the page's filter box both answer, spliced into the bundle as `MATCHES_QUERY_JS` so it cannot become two rules — `cardRef`, `signatureOf`, `holds` (the id decides when both sides have one), `isCard` — the shape a read is held to, whose ownership invariant comes off the lane table — and the `CardBlock` record a blocked card carries. [`board/read.ts`](./board/read.ts) is the reader: `readCard`, the one gate a file read passes, which maps a v1 file's `status` / `plan` / `blockedBy`+`blocker` onto this build's `lane` / `planPath` / `blocked`, reads the four-lane log's `todo` / `doing` as `backlog` / `execute`, and stamps `CARD_VERSION`, so an older card reads rather than disappearing. [`board/rules.ts`](./board/rules.ts) is the lifecycle: `Actor`, `CardOp`, `createCard`, and the pure dispatch — `mayOp` (the same decision without the write, which is what `team` asks before it sends a handoff) and `applyOp`, one switch over the card *and* the board index with a no-op reported as `changed: false`; the one write (`write`) that bumps the revision, appends the lane log, and clears the fields a lane left behind; and the operations themselves are `board/steps.ts`. [`board/links.ts`](./board/links.ts) is the dependency graph that index answers: `BoardIndex` / `indexOf`, `blockersOf` (what is unfinished) and `blockersText` (the same list as `#2 (execute)`, one wording for a refusal and for the page), `dependentsOf` / `dependedOn` (what a drop would strand), `wouldCycle`, and `normalizeDepends` — one edge stored once, as an id in the waiting card's file. [`board/join.ts`](./board/join.ts) is the one join both tools ask for (`JoinSession` — the standing, the clock, and the bus's directory, which is all the rule reads — and `joinOnce` — ask while the standing says nothing good, and again while the roster holds no `live` record for this session id, so a claim after the bus stopped re-joins instead of writing under a name peers no longer resolve; a join the user asked to leave disarmed is asked again the same way, and a failed join never undoes the card it came after) , so a claim through `board` and one through `team` cannot answer it differently; [`board/session.ts`](./board/session.ts) is the session a call works as (`BoardSession`, `TeamStanding` — what the last join did, so a claim joins and `/board status` can say — and `openBoardSession`: one store per working directory, the seat it serves under, the id a card records and the name the bus already holds for it — else the id-derived one —, and one session per host so the seat `board` sets is the name `team` writes), plus `busDirFor(agentDir, store)` — the bus's directory for a repository, built from the key the store carries, so the roster cannot be moved by regrouping the board's own files — and `planDir(cwd)`, this repository's plan store through `plans/paths.ts`, which is what a card's plan file has to be inside. [`board/store.ts`](./board/store.ts) is the backend: `BoardStore` over `<agent-dir>/board/<repo-key>/cards/*.json`, with `createStore(dir, { root, key, config })` and the one construction point `openBoard(pi, cwd)` — `root()` and `name()` say which repository a hashed board belongs to, which is the one part of its identity a reader can say out loud, and `key` is the hash it was filed under, carried rather than read back off `dir`'s last segment because `session-bus` files this repository's records under that same key — `create` allocates the id by hard-linking a temp file onto the name (so two adds cannot share one), `mutate` re-reads the board and re-checks an operation whose card changed underneath instead of overwriting it, `list` orders a lane by priority, then by id, `scan` is one pass over the pile answering the cards, the files it refused, and the bytes they occupy — so a page's frame reads the board once, and `list` and `skipped` are its two halves —, `archive` renames a finished card's file into `archive/` beside the cards without rewriting it — so `read` finds a card in either pile and writes it back to that one, and `list` still reads the board alone —, `archived()` / `archivedCount()` are the pile on request and its count, `nextCardId` spends an archived number as well as an ended one, and neither pile is pruned by a janitor: `archive` and `drop` are the two ways a card leaves the board. [`board/round.ts`](./board/round.ts) is the lead's rollup (`buildRound`, `IDLE_MS` — owners, unassigned, unplanned (a contract still missing a line, or a plan never recorded), unapproved, unreviewed (every card in `execute` until somebody has spot-checked it, so the second gate is owed from the lane change rather than from a clock), blocked, unwritten, done, read from the cards alone so it works with the bus disabled), and [`board/round-text.ts`](./board/round-text.ts) is that rollup as the sentences a reader gets (`formatRound` — which folds the owners a bus has no record of and that hold nothing into one line, given the roster, and counts the closes made by instruction on the done count —, `plural`, and the fold's own two bodies, `isHistoryOwner` and `historyLine`, spliced into the page as `HISTORY_OWNER_JS` / `HISTORY_LINE_JS` the way `PLURAL_JS` is), so the tool's result, `team`'s `/team`, and the Team tab cannot describe one board two ways; the same module carries what a session is holding (`holdingOf` — `#162 plan`, `#162 plan +2`, or `idle` when it holds nothing — and `holdingLine`, the label and its holding as one sentence), so `/team`'s presence line and the Team tab's teammate row say what a named session is doing from one body, the page receiving `holdingOf` as `HOLDING_OF_JS`; and [`board/skipped.ts`](./board/skipped.ts) is the sentence for the card files a board refused (`skippedText`, over the store's `Skip`), which the tool prints under a listing and the page carries on its footer's hover title so the two name the same files. [`board/view.ts`](./board/view.ts) is the page's snapshot (`buildView` → lanes (each carrying its words, colour, limit, and count), the total, the rollup, the signature, the repository's name and root, and the store's path), the one shape a client is allowed to know: each card arrives as a `ViewCard` carrying the priority it acts on, the moment it entered its lane (`since`), and, when something unfinished blocks it, `waits` — so the browser renders an answer instead of re-deciding one. [`board/page-pointer.ts`](./board/page-pointer.ts) is how a second session finds a page already serving (`PAGE_FILE`, the two routes a probe asks for (`PING_PATH`, `SNAPSHOT_PATH` — the router serves the same constants, so a route cannot move out from under the probe), `PagePointer`, `readPointer`, the exclusive `createPointer`, `clearPointer` for its own note only, `probePage` — the file is believed only while it answers, and it carries the page's one token, which reads and nothing else — and `settlePointer`, the ladder a starting page runs: write, attach to a winner that answers, take over a note proved dead, and name the file rather than blame a session when it cannot be read). |
| [`board/lanes.ts`](./board/lanes.ts) | The board's lane vocabulary and its gates, as data: `LANES` / `Lane` (backlog, plan, execute, blocked, done) and `LANE_RULES` — each lane's label, its empty word, its colour role, and the three facts the rest of the board reads off it (`ownerless`, the pool; `terminal`, finished; `pulled`, held to a WIP limit). `TRANSITIONS` is the other half: every lane change a `move` may make, with the `Requirement`s (contract, approval, review, artifact, blocker, waiting) in front of it, so `tools.ts` offers exactly the targets `steps.ts` accepts instead of carrying a second list that drifts. `isLane`, `laneIndex`, `targetsFrom`, `movableLanes`. |
| [`board/actions.ts`](./board/actions.ts) | The board's actions and which of them only read it: `BOARD_ACTIONS` / `BoardAction` — the list the tool publishes, in its description order — and `BOARD_ACTION_KIND`, a `Record` over every one of them, so an action added to the tuple does not compile until somebody says whether it reads. `BOARD_READ_ACTIONS` is the same answer for a reader that has to name the reads, `readActionsText` is that list as the phrase plan mode's refusal prints (both inflections tested, since only one action reads today), and `isBoardReadAction` answers for one unnormalized value, where anything unknown is not a read. One table, because the tool's enum and the read-only policy were once two: `lib/policy.ts`'s `isBoardRead` and `board`'s own schema both read this one, so a second read action cannot be admitted by one and refused by the other. |
| [`board/artifact.ts`](./board/artifact.ts) | Whether a path is a plan of this repository, which is the one thing the board asks of the plan store: `PlanArtifact` (the resolved path that was proved), `ArtifactCheck` (that path, or the sentence refusing it — one fact for the state machine to order), `planForContract` (a `.md` this repository's store holds and the file is there), and `planForExecute` (the plan a card recorded, still there, refused with the card named and `contract` as the way out). The rules come from [`plans/paths.ts`](./plans/paths.ts), so the board and the plan tool cannot disagree about what a plan path is; the board still never reads a plan. |
| [`board/contract.ts`](./board/contract.ts) | What a card promises before it may be executed: `Contract` (goal, acceptance, scopeOut, and the `Sign` that approved the wording), `CONTRACT_FIELDS` / `MAX_CONTRACT`, the boundary `normalizeContractField` applies to one line of a model's argument, `missingContractFields` / `contractComplete` / `contractApproved` — the gate `steps.ts` asks — `mergedContract`, which drops the sign because an edited line is an unapproved one, and `normalizeInstruction` — the one line a sign that overrode its gate says why, which `isSign` also holds a stored sign to. |
| [`board/steps.ts`](./board/steps.ts) | What each operation does, split from the table that dispatches it: `claim` pulls a pool card into `plan`, `assign` hands one over (and moves only a pool card's lane), `release` / `reclaim` return it, `move` walks `TRANSITIONS` and asks every gate on the way, `contractOp` writes the three lines and the plan path, `approve` / `review` record the two human gates (both refused to the card's own holder), `review` also signing a card that is already `done` when an `override` gives the one line saying why, and `note` / `edit` / `offer` / `archive` / `drop` keep their rules. Each answers a `Prepared`: a refusal sentence, a no-op, or the fields a write would apply. |
| [`board/limits.ts`](./board/limits.ts) | How many cards a pulled lane may hold, and the sentence that refuses the next one: `WipLimits` (plan, execute), `DEFAULT_WIP` (3 and 5), `MAX_WIP`, `normalizeWip` over `clampInteger`, `limitOf` / `countIn` (what a lane's requirement asks, all the empty-object and one-object answers included), and `wipRefusal` — the refusal that names the count, the limit, and the ways out. The arithmetic only: where the numbers are read from is `board/config.ts`. |"
| [`board/config.ts`](./board/config.ts) | The `board` section of `bytefly.json`, read once: `BoardConfig` (`wip` and `lead`), `DEFAULT_BOARD_CONFIG` (3 and 5, and the lead `lead`), `normalizeBoardConfig` — each key falling back on its own, so a file that sets one keeps the other — and `readBoardConfig`, the project's section over the global one. Handed to `applyOp` whole, so `rules.ts` stays pure; `extensions/bytefly` imports it too, so the settings screen shows what the loader keeps rather than a copy of the default. |"
| [`board/lead.ts`](./board/lead.ts) | Who may sign a card's two gates: `Lead` (a team name), `normalizeLead` over `session-label.ts`'s rule, `isLead` (a case-insensitive label compare, which is not `holds`), and the person at the keyboard — `PERSON_LABEL` (`you`), `PERSON_ID` (`person:you`), `personActor`, `isPerson`, which is the actor `/board approve` and `/board review` write as. `signRefusal` / `holderRefusal` / `missingSign` are the three sentences the gates and a lane's requirement are refused with, so the words of each sign live in one table, and `askLead` is that table's one phrase for how a session asks the lead (`session send`, which `team`'s handoff prints too). `signedText` is what a sign sends the card's owner: who signed, and the lane it opened. Pure: a name compare and a sentence. |
| [`board/seat.ts`](./board/seat.ts) | The seat a page is served as: `SEAT_ID_PREFIX` (`board:`) and `pageActor` — the actor a page reads as, built from the board's lead (`board.lead`), with an id derived from the label so a handover does not change it. The name arrives normalized by `config.ts`, so there is no second label rule here and no default name: a constant would be a second answer to what the config already says. Pure, and the page writes nothing, so the id decides no ownership. |
| [`board/tone.ts`](./board/tone.ts) | What colour a board surface wears, stated once: `BoardTone` (the page kit's own colour words, plus `dim`, so a tone is the name of a CSS property), `BOARD_TONES`, `REASON_TONE` — the tone of every reason kind, `waiting` included because the card face invents it — and `DECISION_KINDS` / `DECISION_TONE`, the Team queue's kinds in rank order with the tone each wears, so a row's position and its colour cannot disagree. `DECISION_RANK_JS` / `DECISION_TONE_JS` / `REASON_TONE_JS` are those tables as the browser bundle's own source, spliced in like every other rule the page shares. The terminal's vocabulary is not here: `extensions/board/tone.ts` maps a tone to a `TuiRole`. |
| [`board/reason.ts`](./board/reason.ts) | Why a card sits where it does, as one rule: `reasonFor(card, ago)` returns a `Reason` (`kind` plus one sentence) for an unanswered offer, a blocked card's recorded reason, a contract's missing line, an approval nobody gave, a spot-check nobody did, and a finished card's commit — the card face, the lead's queue, and the transcript all read it. Spliced into the page as `REASON_FOR_JS`, which is why it takes its clock as a parameter and calls nothing else. |
| [`session-roster.ts`](./session-roster.ts) | Who is on this repository's bus, read as data: `readRoster(dir)` returns `RosterSession` (`id`, `label`, `mode`, `lastSeen`, `mail`, `pid` and `startToken` when the record names them, and `watching` when it says) or `undefined` when the bus has never been used here, `rosterStateOf` answers `live` / `quiet` / `gone` — a dead pid outranks a fresh heartbeat, and the start token settles a reused pid — and `BUS_DIR_NAME` / `SESSIONS_DIR_NAME` / `INBOX_DIR_NAME` / `SESSION_TTL_MS` are the one copy of the layout, imported by `session-bus` and read by `board`'s page so the Team tab can list teammates without the board depending on the bus extension. `job` asks `rosterStateOf` too, over a session marker rather than a bus record, so "is that session still there" has one answer for ownership as well as for display. |
| [`session-tool.ts`](./session-tool.ts) | The `session` tool as the other extensions call it: `join(ctx, {label?, wake?, listen?})`, `peers(ctx)`, and `send(ctx, {kind, to, text})`, each a typed result that never throws (`{ ok, error }` or `undefined` for a read the bus cannot answer), plus `MAX_MESSAGE_TEXT`, the send cap `session-bus` re-exports as `MAX_TEXT`. One copy, because the alternative is two extensions knowing the tool's details shape. |
| [`session-label.ts`](./session-label.ts) | The label rules a session is addressed by and a card records: `MAX_LABEL`, `normalizeLabel` (one line, capped, with a fallback), `shortId`, `defaultLabel` (the Pi session name, else the working directory's basename, else the id — the bus's own fallback for a session that asked for no name), and the team's vocabulary, `TEAM_AGENT_PREFIX` / `agentLabel` (the id's tail, so a name cannot drift on a rename or collide) and `TEAMMATE_NAMES` / `presetNameFor(id, taken)` (the familiar name a session with none of its own draws: the id hashes to a start, a taken name walks on, and every name taken is `undefined` so the caller keeps `agentLabel`). Shared by `session-bus` (which settles the unique one) and `board` (which writes and joins under it), so a card's owner names a target the bus answers to. |
| [`requirements.ts`](./requirements.ts) | Per-extension runtime prerequisite table plus a pure `probeExtension(seams)` over injected seams (exec, config reads, optional imports, the published search provider, env, and a refresh-only reachability probe): bytefly's doctor surface reports on it and the extensions that own a dependency format their errors from it. A check kind is either a prerequisite (`binary`, `module`, `state`, `invocation`, `notify`, `provider`) or a `safety` switch whose off value trades a protection away, read from `bytefly.json` global-then-project. |
| [`child.ts`](./child.ts) | A spawned command's process lifecycle, shared by `job` and `watch`: `ChildSlot` (the caller's `pid`/`child`/`killTimer`, mutated in place), `ChildSink` (`onData` for merged output, `onSettled` for a close or a failed spawn), and `createChildSupervisor` — `start` over `SpawnFn` with the caller's `SpawnOptions`, `terminate` as the signal the caller asked for then SIGKILL after `graceMs`, `kill`, `clearTimer`, and a bounded `wait`. One escalation body and one default grace (`KILL_GRACE_MS`), with each owner passing its configured one, and no state of its own. |
| [`shell.ts`](./shell.ts) | Shell escape rule shared by the worktree isolation guard and plan mode's read-only git guard: `SHELL_ESCAPABLE`, `isShellEscapable`, `hasShellEscape`. |
| [`spawn.ts`](./spawn.ts) | The process seam for extensions that run a shell command: `SpawnedProcess`, `SpawnFn`, `KillTreeFn`, the pure `spawnOptions` / `killPlan`, `applyKillPlan` over injected seams, the liveness pair `isPidAlive` / `startTokenOf` (the second settles a pid reused by another process), and the `defaultSpawn` / `defaultKillTree` that execute them (process group on POSIX, `taskkill /T` on Windows). Shared by `job`, `watch`, `check`, `worktree`, `wechat`, and the session roster. |
| [`goal-state.ts`](./goal-state.ts) | The published `goal` state replayed from the session branch: `PublishedGoal`, `PublishedGoalStatus` (`planning` / `active` / `achieved`), `GOAL_ENTRY_TYPE`, `MAX_GOAL_OBJECTIVE`, `isPursuedGoal(goal)` and `lastGoal(entries)` (the last valid goal, skipping a rejected call and honouring an explicit clear), so `plan` can restate a pursued goal while planning, promote a `planning` one at approval, and offer to record one when a plan is approved with none, and `goal` itself reads the branch through the same replay — with no extension importing another's modules. |
| [`todo-state.ts`](./todo-state.ts) | The todo item contract, its parser, and the published state replayed from the session branch: `TODO_STATUSES` / `TodoStatus`, `ExistingTodo`, `MAX_TODOS` / `MAX_CONTENT`, `normalizeTodos`, and `lastTodos(entries)` (the last write that fully validates, skipping a rejected call; a malformed write leaves the previous list). `plan` merges steps into that list, `goal` counts the items still open, and `todo` itself replays through it — so one rule decides which writes are state writes, with no extension importing another's modules. |
| [`stray-fields.ts`](./stray-fields.ts) | The refusal for a tool call that carries a field its action does not act on: `ActionFields` is the per-action table the tool itself keeps, and `strayField(action, args, fields)` is the one sentence — `add does not act on dependsOn: \`edit\` takes it.` — that names every stray field and the action that does take it. Shared by `board` and `team`, whose flat parameter objects both used to drop such a field in silence. |
| [`tool-names.ts`](./tool-names.ts) | Tool names and one parameter name that more than one extension must agree on, so an orchestrating `ctx.executeTool()` call or schema probe breaks the build on a rename. |
| [`tool-result.ts`](./tool-result.ts) | The tool-result envelope: `textResult` builds the one text block, the caller's `details`, and the optional `structuredContent` mirror; `failedResult` is the same shape marked `isError`, keeping the previous state for a retry; `toolError` builds the rejected-call content plus the caller's details, and `errorResult` is the action-and-message form `job` and `watch` share. |
| [`usage.ts`](./usage.ts) | The usage counters an accumulator is measured in: `USAGE_COUNTERS`, `UsageCounter`, `UsageCounts`, and `zeroUsage` — shared by bytefly's session and `↳` roll-up accounting and by `subagent`'s fold of a child's `message_end` events, so two extensions cannot drift on which numbers a run is made of. `turns` and the context size are not counters and stay with each owner. |
| [`web/`](./web/) | The Bytefly web brand system shared by the browser surfaces, whose conventions are its own doc, [`web/README.md`](./web/README.md). Modules: [`web/tokens.ts`](./web/tokens.ts) (palette derived from the Bytefly terminal themes, scales, the `tok-*` map, and the derived `--on-accent` / `--scrim` / `--shadow` overlays), [`web/base.ts`](./web/base.ts) (reset, one focus ring, the print and forced-colours layers), [`web/components.ts`](./web/components.ts) (the `bf-*` kit, including the dialog, shortcut-sheet, and `bf-kbd` rules), [`web/layout.ts`](./web/layout.ts) (the app shell and its one breakpoint), [`web/client-runtime.ts`](./web/client-runtime.ts) (the browser runtime every page bundle splices in: `el`, `typing`, `paintStatus`, `paintLive`, `postJson`, `ClientNode` — the node shape those answer — and `CLIENT_RUNTIME_JS`), [`web/runtime.ts`](./web/runtime.ts) (theme storage and bootstrap, `THEME_JS_PATH`, `THEME_TOGGLE_JS`, `dialogScript`, and `keysDialogScript`), [`web/markup.ts`](./web/markup.ts) (the HTML atoms: the toolbar's `pageSubject` / `liveChip` / `reloadChip` / `keysButton` / `toolbarChrome`, `pageFooter`, `keysSheet` and its `KeyHint` rows, the mark's one geometry, which `brandLockup` draws and `markIcon` links as the tab icon, and `tabTitle`, the one shape the served pages' tab titles take), [`web/names.ts`](./web/names.ts) (a page's class names as one table: `ClassNames`, the `KEBAB` / `IDENTIFIER` shape a name must have to survive being spliced into a bundle, and `namesScript`), [`web/markdown.ts`](./web/markdown.ts) (the inline Markdown renderer both served pages splice: `safeHref`'s one link policy — `http`, `https`, `mailto`, no credentials, no disguised relative target — and `renderInline`, which appends text nodes and `code` / `strong` / `em` / `a` only, as `INLINE_MARKDOWN_JS`), and the [`web/index.ts`](./web/index.ts) barrel. |

## Conventions

- **No dependencies on an extension.** Shared code imports only `node:*`, host
  packages, and other `lib/` modules; extensions import from `lib/`.
- **Inject the host seam.** Modules that need a host capability take a function
  (`RunGit`, `HttpRunner`) rather than the whole `ExtensionAPI`, so the logic is
  testable without a runtime.
- **Constants that must agree across both sides live in `tui/glyphs.ts`** — a
  glyph and the `setStatus` key are one contract, not two literals. Tool names
  shared by an orchestrating tool and the tool it calls — and parameter names
  one extension probes on another — live in `tool-names.ts`.
- **A helper that exists here must be imported, not redefined.**
  `test/shared-helpers.test.ts` fails a top-level extension declaration whose
  name is also exported by `lib/`, and a body copied from `lib/` or from any
  other file — a second extension, a sibling module, or `lib/` itself. A
  deliberate wrapper goes on that test's reasoned exception list; a copy belongs
  here with its declared capability suite.
- **A color is a role, not a token.** The rules and the primitives are in
  [`tui/README.md`](./tui/README.md#color-is-a-role): draw through
  [`tui/tokens.ts`](./tui/tokens.ts) (`paint(theme, role, text)`) so a theme swap
  re-skins every surface. `TUI_TOKENS` is the one place a Pi token is named, Pi's
  tool vocabulary (`toolTitle`, `toolOutput`) included — a renderer names the
  role, and a theme that maps the token elsewhere is respected.
