# 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/structure.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 `isExtensionEnabled` / `ENV_DISABLED_EXTENSIONS` for the package-wide disable list. |
| [`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), 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. |
| [`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 and grace — 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`, and `envelopeRefusal` — 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 `randomPort` — the per-session band `serve` draws the session's one port from, so the range is one number and every doc states it), [`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. |
| [`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`, `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, and `WORKSPACE_READERS` — the built-in readers plus `read_doc` that plan, rewind, and subagent compose their allowlists from. |
| [`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 one that offers it: `READ_ONLY_SUMMARY` and `PLAN_CONSENT` (one wording for the read-only guarantee), the `PLAN_ENTER_REQUEST` channel with its `PlanEnterRequest` payload, and `requestPlanMode(pi, ctx)` — a request/response over `pi.events` that resolves `false` 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. |
| [`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). |
| [`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`, and `isCard`, the one gate a file read passes. [`board/rules.ts`](./board/rules.ts) is the lifecycle: `Actor`, `CardOp` (including `edit`, whose fields are each optional because an omitted one must be left alone), `createCard`, the pure `applyOp` over the card *and* the board index (every transition, its owner rule, the dependency gates a claim, a move, and a drop pass, `archive` — the one op that moves no field, because the pile a finished card's file lies in is the whole of it — and the sentence a refusal carries, with a no-op reported as `changed: false`), and `holds`. [`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 (doing)`, 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/session.ts`](./board/session.ts) is the session a call works as (`BoardSession`, `TeamStanding` — what the last join did, so a claim joins once and `/board status` can say — and `openBoardSession`: one store per working directory, the seat it serves under, the id and team name a card records, and one session per host so the seat `board` sets is the name `team` writes). [`board/store.ts`](./board/store.ts) is the backend: `BoardStore` over `<agentDir>/board/<repo-key>/cards/*.json`, with `createStore(dir, { root })` 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 — `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 before age, `scan` is one pass over the pile answering both the cards and the files it refused — 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, blocked, idle, 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`, `plural`), so the tool's result and `team`'s `/team` cannot describe one board two ways. [`board/view.ts`](./board/view.ts) is the page's snapshot (`buildView` → lanes, counts, rollup, bytes, signature, the repository's name and root, the store's path, and the session reading it), 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). |
| [`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. |
| [`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). 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. |
| [`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`, `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, including `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`), 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. The one exception is Pi's tool-rendering vocabulary
  (`toolOutput`, `toolTitle`), which stays direct: those tokens style a tool
  block, and no role maps to them.
