# Chat On Steroids — the agent map **QUALITY >> QUANTITY. Delete before adding.** Improve the underlying decision/ownership structure and replace obsolete logic; never stack a fallback, watcher, state machine or special case around a bad invariant. Prefer one deterministic source of truth, fewer branches and reuse of existing mechanisms. Every change should make the affected system smaller, simpler and easier to reason about where practical. Fix problems from the ground up; do not grow the codebase to hide them. **No build-up: rewrite the affected subsystem with the new feature/invariant in mind.** Do not keep the old architecture and bolt a new path beside it merely because that is the smallest diff. When a new implementation supersedes an old assumption, recode that specific area around the new source of truth, delete the obsolete branches/state/fallbacks it replaces, and converge callers/tests on that one design. This is not permission for broad rewrites: change only the affected subsystem, but leave it architecturally cleaner and preferably smaller than before. **Rule: no build up; rewrite with the new feature in mind.** **Browser efficiency rules.** Opening the app, a suspended MV3 wake socket and a maintenance alarm are not permission to open a tab. Reuse a suitable existing document. One operation owns one elected tab across provider navigation and MV3 suspension; a missing receipt or a user-closed tab must not create a new opening attempt. Transfer opening authority at handout, not after page hydration. Keep waiting chats and reusable sleeping workers open for follow-ups. Only terminal, blocked or superseded conversations and proven duplicates grant tab-close authority, subject to the existing draft/generation/document checks. Read bounded account-evaluated model metadata and installed tool declarations through the existing MAIN-world bridge. Do not infer availability from English labels or fixed release names, sweep every effort to discover a catalog, or add polling/fallback openers around an uncertain observation. Native attachments have one staging owner in `session/input-attachments.ts`: immutable originals, bounded thumbnails, serialized quota/pruning/admission and opaque IDs. The outbox owns membership; the bridge serves bounded chunks only to that exact pre-send browser claim. Never expose source paths or inject a file reference as though its bytes reached ChatGPT. Final Send must recheck the same text, attachment nodes and navigation epoch after every asynchronous authorization step. Pending plan stages are projected from the first durable input until its receipt materializes the queue. Temporary planner tabs retire after capture/cancellation only with exact idle/draft proof. **This tree is usually dirty and shared with the user and other agents — never `reset`, `checkout`, `clean`, reformat, or overwrite work you did not do.** The single orientation document for this repository. Read it before changing anything. **How to use it.** §1–§3 is the mental model; read those once, in order. §4 is "where is the thing" plus the mechanism ledger: every durable fact, owner, lifetime and publication boundary that matters across subsystems. §5–§17 is one section per subsystem, each with the same shape — what it owns, its files, its flow, **what must hold**, how it fails, which tests cover it. §18 is the fastest entry point when you have a symptom and no theory. §19–§22 is how to work here. **One file, complete.** This replaces the old `AGENTS.md` + `agent.md` split, which duplicated roughly 60% of its content and had already drifted between copies. It is sized for completeness rather than for any tool's default project-document budget; if your harness truncates long project docs, raise its limit rather than cutting this down. --- ## 1. The app in sixty seconds A **Windows/macOS/Linux Electron app** that hands ChatGPT a deliberately small set of local computer capabilities over MCP. It is a desktop chat workspace, bridge and permission layer. ChatGPT still hosts model execution; the extension transports desktop input and observes the provider UI. It also ships a Chrome extension that watches ChatGPT itself, so the app can record conversations, prove which conversation issued which tool call, replace generic tool rows with what actually happened, compact a long chat into a fresh one, and run worker chats. Core is portable; the Desktop/computer-use surface has native Windows and macOS backends behind one protocol and must be absent from live Linux capability/discovery state. Four runtime planes, only two of which are servers: ```text ── PUBLIC / CHATGPT SIDE ────────────────────────────── ChatGPT model ChatGPT web page │ MCP over HTTPS │ ▼ ├─ chatgpt-dom.js selectors only ┌──────────────┐ ┌──────────────┐ ├─ content.js isolated-world │ Core │ │ Desktop │ │ recorder + UI │ files/term/ │ │ screen/input/│ └─ fiber.js MAIN-world React │ session/ │ │ clipboard │ evidence │ agents │ │ │ │ └──────┬───────┘ └──────┬───────┘ ▼ └────────┬────────┘ background.js MV3 worker, journal, │ tunnel tab↔conversation registry ▼ │ HTTP 8765-8769 127.0.0.1 MCP server ▼ secret tokenized path per surface bridge.ts │ │ server.ts → tools.ts → kernel.ts ├→ recorder / correlation │ ├→ Compact & Resume ┌────────┴────────┐ └→ agent bootstrap Core tools Desktop tools │ │ ── ELECTRON RENDERER ── sandbox + computer/* renderer → preload (fixed API) codex/* ports → ipc.ts → main services │ files + processes ``` **The MCP server and the browser bridge are two different servers with two different threat models.** MCP is the model's capability endpoint. The bridge exists only for the Chrome extension and has no arbitrary filesystem, command or permission authority. Its attachment route serves only immutable user-selected staging bytes belonging to an exact claimed input. Never merge their lifecycles or their auth. The extension never executes a tool. It observes ChatGPT and reports evidence. **The app is the only authority on what a local tool actually did.** The renderer has no Node, no filesystem, no command, no network authority; it crosses preload through named IPC. ## 2. Where the bugs actually are Almost nothing hard here is a local algorithm bug. The hard ones live on six boundaries: | Boundary | The two things people confuse | | --- | --- | | Discovery vs. enforcement | a schema ChatGPT cached vs. a permission that is live *now* | | Path spelling | `/project/src/a.ts` vs. a native `C:\work\...` or `/home/...` path — same decision required | | Request vs. conversation | HTTP `x-request-id` vs. the ChatGPT conversation that owns it | | Process lifetime | content script (document) vs. service worker (suspends) vs. app (restarts) | | Durable vs. frontend identity | local session id vs. the ChatGPT conversation attached to it | | Async vs. selection | a load started for A vs. the B the user has since selected | If a bug looks like four subsystems failing at once, it is one of these, once. Find the **earliest wrong identity or state transition** — not the last UI that displayed it. ### Name the identity, then find where it is lost Every boundary above is a place where one specific identity is supposed to survive. Before reading any code, say which one this bug is about. If you cannot state it, you have not found the real boundary yet. | Plane | The identity that must survive | | --- | --- | | filesystem | approved root + canonical real path | | MCP call | normalized request id | | tool ownership | request id -> conversation id | | browser observation | conversation id + navigation epoch + message/turn identity | | agent | conversation id -> prime or worker slot | | workspace | conversation/agent key -> cwd | | terminal | proven owner -> exec session id | | session | local session id + conversation lineage | | compaction | continuation token + from/to conversation | | renderer load | selected session id + load generation | | connection | tunnel/endpoint generation | | desktop coordinates | screenshot frame id | Then classify which plane produced the **first** wrong fact — MCP transport/discovery, permission/sandbox/tool runtime, browser observation/identity, bridge/session/agent orchestration, renderer presentation, or tunnel/packaging. Do not start in the file where the symptom is displayed. Three policies apply everywhere and are not repeated per section: - **Fail closed** when a guess could cause cross-root access, cross-chat attribution, cross-agent terminal control, wrong workspace mutation, wrong compaction target, unsafe rendered HTML, or invalid image content reaching the model. For presentation-only degradation, keep the UI usable and label the uncertainty instead. - **Scope every async result to the epoch that requested it** — navigation epoch, load generation, connection generation, endpoint lifetime. Id equality alone is not enough: an A → B → A navigation defeats it. - **Bound every representation of large output** — bytes, tokens, decoded pixels, base64, structured fields. Not just the visible text, and not just the compressed input. ## 3. What is authoritative Sources disagree here because the architecture moved fast. Precedence: 1. current implementation **plus a reproducible test or live repro**; 2. current declarations: `mcp/surfaces.ts`, `mcp/tools-core.ts`, `mcp/tools-desktop.ts`, `shared/types.ts`, `package.json`, `main/version.ts`, `extension/manifest.json`; 3. `README.md`; 4. public design references such as `docs/tool-surface.md`. Internal working notes and security reproductions are maintainer-only; a public clone should treat §5–§18 of this file as the architecture and design record. **Code comments in this project are unusually load-bearing.** Many name the exact live failure that motivated a guard. Read the comment before deleting the guard or "simplifying" the state machine. Code and current tests still win when a comment has drifted. ### Baseline Release numbers are authoritative in `package.json`, `src/main/version.ts` and `extension/manifest.json`; the bridge protocol is `version.ts::BRIDGE_PROTOCOL`. Tests assert the app/extension versions stay in sync, so this architecture guide deliberately does not copy a release number that can drift. Core is cross-platform; main process is TypeScript; extension is plain MV3 JavaScript with no build step; Vitest; `node-pty` is the main native terminal dependency. Desktop automation is available through native Windows and macOS backends. Fresh-install defaults from `config.ts` — **Core tool permissions on except opt-in ChatGPT file saving**, **read-only off**, **recording on**, session advisory/limit **400k/533k** estimated tokens, **auto-compaction on at 400k and level-based with live-work gating**, **multi-agent on** with `maxWorkers` 2 (hard max 8). Fresh multi-agent also starts with `allowUnattributedCalls=true`; `recoverAgentTabs` starts **off** everywhere, because Goal/Loop chats are recovered regardless of it (see §11). That `true` is `FIRST_LAUNCH_MULTI_AGENT` only; `DEFAULT_MULTI_AGENT` — the schema/migration baseline that fills the field in when an older config never wrote it — stays `false`. Fresh installs also start with **zero approved roots**: enabled permissions are not usable filesystem/command authority until the user approves a root, and `connection.ts` refuses to publish a root-requiring Core surface until one exists. The limit is derived, never typed: the Chat panel offers one threshold and writes `limit = threshold × 4/3`, so the defaults have to satisfy that relation or the first save in that panel moves the red line. Existing configs keep explicit user choices; conservative migration defaults do not widen omitted legacy permissions merely because the fresh-install defaults are broader. Windows also enables the Desktop capability group. macOS has the native backend but a fresh install starts that group **off** (`config.ts::firstLaunchCapabilities`): the user switches it on and grants Screen Recording and Accessibility. Linux masks the group off at runtime while preserving stored choices so a config moved to a supported host does not lose them. ### Stale-doc traps Do not "restore" these from an older document: - `view_image` is its own Core tool, not a mode of `read`. - Live tool counts derive from `mcp/surfaces.ts` and current exposure: `find` and the exec pair are mutually exclusive, `session_finish` is conditional, and Desktop is a separate surface. Never restore a hardcoded count from older releases. - `session` has exactly two actions, `search` and `read`. Search discovers recordings; read requires an explicit local session id and returns cursor-paged history **without silently truncating authored user/assistant rows**. Tool rows are intentionally compact headlines with exact args/results behind separately cursor-paged `T…` detail reads, and any pre-existing recorder overflow loss is surfaced explicitly. Compact & Resume is app/browser orchestration — there is no model-visible `save_handoff`. - Extension pairing is silent loopback `/pair` bearer provisioning. The six-digit flow is gone. - Canonical messages live in `messages/*.json`, one replaceable shard per logical id; legacy `messages.json` is read during lazy migration. They are not appended forever to `events.jsonl`. - `computer` carries **13** action variants, not 11. - Fresh-install multi-agent is **enabled** by `defaultConfig()` through `FIRST_LAUNCH_MULTI_AGENT`; the older prose comment in `shared/types.ts` that says the feature is disabled by default is simply stale against fresh-install behavior. `config.ts::DEFAULT_MULTI_AGENT` remains the conservative schema/migration baseline; `defaultConfig()` is the fresh-install authority. - Reusable workers normally **sleep after `finish` and are meant to be messaged again**. Server instructions and `agents` results must tell primes to reuse a suitable sleeping worker with `action=message` before spawning a replacement. Only terminal workers whose own context reached the 400k ceiling need replacing. - `mcp/instructions.ts::coreInstructions()` also still carries a root example shaped like `${firstRoot}/src/main.ts`. That silently assumes the approved root *is* the project. Live Core tool contracts in `tools-core.ts` are the authority: an approved root is often the **parent** of the project, so every intermediate folder remains explicit (`/me/projects/app/...`, not `/me/src/...`). There is no model-visible `list_roots` fallback that makes the old example safe; fix the instruction text/tests rather than teaching tools to guess a missing project level. - Ordinary Goal continues **completed final answers only**; Astra is finish-tool-only (see §17). Older README/working-note wording that says an `interrupted` turn is automatically continued is stale against `content.js::GOAL_CONTINUABLE`. - The older prose comment near `config.ts` auto-compaction defaults still calls the trigger edge-based. Live authority is `store.ts::autoCompactionReady()` + `bridge.ts::chatIsWorking()`: **level above threshold + live work**, with no durable one-shot edge. Likewise, a background.js comment that implies browser recovery closes duplicate tabs is stale: recovery deterministically elects/reloads one exact tab but does not own duplicate-tab cleanup. - A separate `background.js` maintenance comment still says an unattributed repair is armed after twenty seconds. That mixes two different clocks. Live authority is conditional: a call carrying an unresolved request id may wait up to the recorder's **20-second request-id grace**; a call with no request id has no ownership proof to await and lands Unattributed immediately. Either resulting **Unattributed verdict** then starts `bridge.ts::UNATTRIBUTED_REPAIR_MS`, a separate **60-second incident** before repair candidates are queued. The extension's 30-second alarm is only the MV3 wake-up floor for collecting already-decided work; it is not either recovery deadline. Several `content.js` comments still carry old recorder timings: the Fiber request-id note says **15s**, `streamTurnGroups()` says **5s**, and the compaction `TOOL_SETTLE_MS` rationale says **15s**. Live authority is `recorder.ts::REQUEST_ID_GRACE_MS = evidenceWindow(20_000)`. That last comment also predates the running-vs-settling split: Compact & Resume waits app-reported **running local tools**, not the recorder's attribution tail. Treat all three as comment drift, not alternate timers. - `multiAgent.recoverAgentTabs` is one switch with one meaning, read through `bridge.ts::tabRecoveryWanted()`: whether **silence** and **no-tab** recovery may bring back a chat that the Goal/Loop switch is *not* driving — workers, primes, plain chats with recorded tool calls. A Goal/Loop chat (`goalActiveFor()`) is always brought back. Unattributed, assistant-error, Goal watch and compaction pickups are reloads of a broken page and are not gated by it. - `bridge.ts` still has two misleading comments near the live recovery/command structures. The `lastBrowserRecoveryAt` comment says one cooldown covers errors, silence and missing tabs, but `queueBrowserRecovery()` explicitly gives `silence` and `goal` no cooldown and applies the 3m floor only to `unattributed` / `assistant-error` / `no-tab`. `Command.owner` also says a restored command has no waiting page and is memory-only; live `DurableCommandRecord` serializes/restores exact `owner` + `claimedAt` for valid leases. Trust the record/restore code and tests, not those comments. - Goal source comments have two current semantic drifts too. `goal.ts` still says a per-chat Off row can never be revived by any later app-wide change, but deliberate app-wide master-Off clears those rows. `bridge.ts::inspectOwedGoals()` still says a pending Goal reply “travels to the replacement” during Compact & Resume; production moves objective + chat switch only and currently leaves the reply row on A. Treat both comments as stale against the mechanisms documented below. - `test/update.test.ts` still contains prose that the next retry schedule is only “the next time the app opens”. Production `update.ts::startUpdateChecks()` now runs an immediate pass plus an unreferenced six-hour schedule. Current update tests cover the immediate/unreferenced timer lifetime and staging; do not regress production to satisfy the old comment. - Exec custody is keyed by the durable local `sessionId` carried in `RequestCorrelation`, not the replaceable ChatGPT conversation id. Compact & Resume therefore needs no process-owner move: B resolves to the same principal and may continue A's live `write_stdin(session_id=...)`, while a different session and the anonymous non-adoptable bucket remain fenced. Do not restore the retired `moveExecConversationOwners(A,B)` representation or add a resume-only adoption path. ## 4. Repository map ```text ── shell / config ───────────────────────────────────────────────────────── src/main/index.ts Electron startup, window/tray, shutdown, security shell src/main/shutdown.ts ordered teardown phases, each bounded, ending in the exit src/main/config.ts validated settings, migrations, defaults, read-only caps src/main/platform.ts host capability projection; Desktop exists only on Windows and macOS (ScreenCaptureKit floor 12.3, below the 13.0 app floor) src/main/connection.ts MCP + tunnel lifecycle, per-surface publication & status src/main/ipc.ts every renderer→main operation and main→renderer push src/preload/index.ts the complete renderer-facing API allowlist src/main/secrets.ts Electron safeStorage-backed secret storage src/main/logger.ts redacted operational log: 500-entry ring + userData/app.log mirror (not the session store) src/main/durable.ts small named JSON state files under userData/state src/main/diagnostics.ts the UI self-test chain, hop by hop src/main/update.ts process-lifetime updater: startup + 6h checks, one in-flight pass; apply only on ordinary quit src/main/browser.ts Chrome/Chromium discovery + preferred-browser opener for orchestration/session chat URLs src/main/window-lifecycle.ts single-instance/bootstrap/activation lifetime gates src/main/window-layout.ts work-area-bounded BrowserWindow geometry src/main/window-icon.ts packaged Linux native window icon decision src/main/tray-image.ts platform-aware tray/menu-bar image + stable macOS tray identity src/main/extension-path.ts transactional stable materialization of the unpacked extension ── MCP ──────────────────────────────────────────────────────────────────── src/main/mcp/server.ts HTTP transport, secret paths, body bounds, exposure cache src/main/mcp/tools.ts builds exactly one surface's server; refuses foreign names src/main/mcp/surfaces.ts Core/Desktop discovery boundaries + declared tool names src/main/mcp/kernel.ts dispatch, live guards, caller/workspace identity, agent inbox src/main/mcp/tools-core.ts Core registration + connector wrappers src/main/mcp/tools-desktop.ts Desktop registration + wrappers src/main/mcp/inbound.ts x-request-id extraction and normalization src/main/mcp/call-context.ts AsyncLocalStorage per call + in-flight accounting src/main/mcp/instructions.ts model-facing server instructions ── filesystem / execution ───────────────────────────────────────────────── src/main/sandbox.ts approved-root authority; virtual↔native containment src/main/workspace.ts per-chat/agent learned project cwd (convenience, not auth) src/main/rawfs.ts raw Node fs, bypassing Electron's asar interception src/main/fsops.ts shared bounded file/image/text helpers src/main/search.ts connector search implementation src/main/ripgrep.ts bundled-first rg locator, then host PATH fallback src/main/env.ts one OS-correct environment model for every spawned child src/main/toolchain.ts conservative Windows JAVA_HOME/GOROOT discovery src/main/exec-hints.ts narrow shell rewrites + recovery hints; abstains on ambiguity src/main/diffstat.ts bounded exact/approximate line-delta accounting for activity rows src/main/text-match.ts shared newline/Unicode-aware unique text matching src/main/codex/tool-specs.ts model-visible Codex contract text src/main/codex/unified-exec.ts exec_command / write_stdin runtime src/main/codex/unified-exec-constants.ts yield deadlines, buffer and token policy src/main/codex/exec-output.ts model-facing exec serialization src/main/codex/shell.ts host shell selection, quoting, launch src/main/codex/ownership.ts exec-session caller ownership + background obligation/attendance projection src/main/codex/manager.ts the one process-manager lifetime shared by exec/write_stdin src/main/codex/command-batch.ts sequential same-shell `cmds` framing + per-command exit parsing src/main/codex/head-tail-buffer.ts bounded output head+tail retention, omission accounting src/main/codex/truncate.ts Codex-compatible UTF-8 byte/token output truncation src/main/codex/filesystem.ts ported low-level Codex fs primitives (no policy) src/main/codex/read-backend.ts connector read semantics over those primitives src/main/codex/view-image.ts image load/validate + MCP content adaptation src/main/codex/apply-patch/* V4A parser / matcher / runtime / shell interception ── sessions ─────────────────────────────────────────────────────────────── src/main/session/store.ts durable sessions, messages, assets, handoffs src/main/session/input.ts durable user input, browser claims and tool/finish delivery src/main/session/finish.ts exact-turn Astra finish notices and Loop-based injected continuation src/main/session/recorder.ts merges MCP truth with browser observations src/main/session/correlation.ts requestId → conversationId proof registry src/main/session/blocked-chats.ts user-blocked conversations; the app's only stop for a rogue turn src/main/session/continuation.ts transactional Compact & Resume rebind src/main/session/resume-gate.ts tiny pre-commit gate preventing resume shadow sessions src/main/session/handoff.ts validates/prepares the brief; continuation publishes it src/main/session/handoff-prompt.ts the brief injected into the old chat src/main/session/retention.ts startup + six-hour coarse pruning maintenance src/main/session/summarize.ts human-readable activity summaries src/main/mcp/session-tool.ts model-facing search/read projection over recorded sessions src/shared/chronology.ts timeline ordering and folding src/shared/session.ts session/activity/swarm wire types src/shared/goal.ts Goal prompts (continuation + specific goal) and their bounds src/shared/capabilities.ts root-required vs rootless capability classification src/shared/types.ts config/app/IPC types and Capabilities ── browser ──────────────────────────────────────────────────────────────── src/main/bridge.ts extension HTTP bridge + compaction/worker orchestration src/main/goal.ts Goal/Loop LLM driver (OpenRouter default, custom endpoint optional), durable obligations, one draft per turn src/main/agents.ts the one global star-topology multi-agent broker extension/manifest.json MV3 composition root: service worker, isolated scripts/CSS, MAIN-world Fiber, popup, host/extension permissions extension/chatgpt-dom.js EVERY ChatGPT selector and DOM-shape assumption extension/content.js page recorder, turn lifecycle, Overwrite, compact UI extension/fiber.js MAIN-world React/Fiber evidence reader (least trusted) extension/background.js service worker: token, journal, tab↔conversation registry extension/overlay.css every CLF-owned surface injected into the ChatGPT page extension/popup.html/.css/.js extension status/reconnect UI only; no tool/session authority ── other ────────────────────────────────────────────────────────────────── src/renderer/main.ts setup/settings/connection/activity UI src/renderer/chat.ts session timeline, handoff, swarm UI src/renderer/dom.ts shared text-only renderer DOM/icon/toast/IPC-result helpers; no app state or innerHTML src/main/computer/index.ts Desktop action policy, frame/ref lifetimes, batching and postconditions src/main/computer/helper.ts Windows PowerShell/Win32/UIA helper protocol; no model text in argv src/main/computer/browser-chords.ts pure: which chords manage browser tabs/windows, which processes are browsers native/macos-desktop-helper/* shared ScreenCaptureKit, AXUIElement and CGEvent Swift source native/macos-desktop-addon/* N-API bridge that runs that Swift backend in the Electron process src/main/tunnel/* index.ts lifecycle · health.ts metrics · locate.ts binaries test/*.test.ts 77 tracked Vitest suites, named for the subsystem/boundary they cover vitest.config.ts test runtime/safety boundary: Node, 30s limits, isolated bridge ports + short in-process evidence wait electron.vite.config.ts exact main/preload/renderer bundle entrypoints; extension is not bundled here scripts/* build-time icon / tunnel-client / ripgrep fetchers electron-builder.yml Windows/macOS/Linux package contents and target policy ``` `exec.ts` remains as the shared low-level process/environment primitive used by unified exec, the Windows desktop helper, macOS in-process worker and tunnels. The retired connector-native managed-process and patch stacks were removed after production moved to `codex/unified-exec.ts` and `codex/apply-patch/*`; do not recreate parallel runtimes beside those live owners. ### 4.1 Mechanism ledger — one fact, one owner, one lifetime Use this table before inventing state. If the fact you need already has an owner here, extend that owner or derive from it; do not mirror it in another module. “Durable” means it survives an app restart. Browser `storage.session` survives MV3 service-worker suspension but **not** a browser restart; `storage.local` survives the browser restart. A memory-only field is allowed only when a durable or externally re-observable fact can reconstruct it. | Fact / mechanism | Authoritative owner | Lifetime / durable form | Consumers / invariant | | --- | --- | --- | --- | | approved roots + permissions + feature toggles | `config.ts` | `userData/config.json`, atomic temp→rename; validated/migrated on every load | `effectiveCapabilities()` is the live permission projection; malformed existing config recovers conservatively, never as fresh-install consent | | host capability availability | `platform.ts` + `shared/capabilities.ts` | derived, not stored | Desktop capabilities are impossible off Windows/macOS; every newly added capability is root-required until explicitly classified rootless | | secrets | `secrets.ts` | OS `safeStorage`; never config/log/renderer | OpenAI, bridge and OpenRouter credentials never cross into untrusted renderer/page state | | small cross-restart control state | `durable.ts` | named `userData/state/*.json`; temp→rename; debounced generations + explicit `writeDurableNow` barriers | swarm, continuations, correlations, bridge commands, Goal ledgers; a failed file must not poison later files or publish a rejected generation | | MCP surface shape | `mcp/surfaces.ts` + `server.ts` exposure cache | endpoint lifetime | discovery is a cached schema promise; live permission enforcement is separate and current | | one MCP request identity | `mcp/inbound.ts` | request lifetime in AsyncLocalStorage | normalize `x-request-id` before any higher-level routing | | one in-flight call's mutable evidence | `mcp/call-context.ts` | request lifetime | tool outcome/changes/assets/caller travel with the call; wider “settling” lifetime includes attribution + recording after the handler returned | | request→conversation proof | `session/correlation.ts` | durable named state | only exact request-id evidence is authoritative for modern attribution; every other placement is explicitly weaker/legacy and never a substitute for identity-sensitive routing | | user-blocked conversations | `session/blocked-chats.ts` | durable named state, released only by the user | the only stop this app can make on a rogue ChatGPT turn. Stored per **conversation**, matched through the existing exact request-id proof, and enforced on nothing else: a call with no proven owner is never blocked | | local session identity | `session/store.ts` | `sessions//meta.json` + `events.jsonl` + canonical shards/assets/handoffs | ChatGPT conversation id is a frontend binding; Compact & Resume moves it, never copies the local session | | canonical authored message identity | `store.ts` + `shared/session.ts` | one replaceable `messages/*.json` shard per logical id | streaming revisions replace the same logical message; event chronology keeps the original anchor/seq | | mutable timeline progress/activity identity | `shared/session.ts::foldProgress` + recorder/store origins | append snapshots from page/native **or app-owned** progress, folded on read by namespaced `progressId` / page-tool `messageId` | newest content stays at the earliest logical position; unknown identity is never guessed into a fold; app recovery status uses this same mechanism rather than a parallel status store | | session retention | `session/retention.ts` | process timer, current config read each sweep | startup prune + one coarse six-hour sweep; retention applies to existing history even with recording off | | model-facing session cursor | `mcp/session-tool.ts` | opaque cursor carried by the caller | cursors pin snapshot/filter/range/open-message checkpoints; stale boundaries fail explicitly rather than silently skipping/repeating history | | browser pairing / presence | `extension/background.js` + `bridge.ts` | token/intent in extension `storage.local` and app secret store; presence memory-only/re-observed | pairing token never reaches content/page; “browser absent” and “one chat absent” are different facts | | shared broken-page recovery | `bridge.ts` `activeUntil` + `repairsInFlight` + `unattributedIncident` + `goalWatch` | **process memory**, not a recovery WAL; handout tokens/cooldowns/episode ids are ephemeral and re-earned from live evidence | restart must not resurrect an old browser action merely because it was once queued. A durable Goal reply obligation is separate truth and may cause a new Goal watch after this run observes/accepts eligible work; the old repair token itself never survives as authority | | browser observation custody | `extension/background.js` journal | `storage.session` until app `/events` accepts it | content-script success means “journal owns it”, not “app stored it”; an acknowledged observation must never vanish on worker suspension | | real conversation lifetime in a tab | `extension/background.js` tab registry | `storage.session` | document reload/pagehide is not conversation close; tab removal/navigation away decides closure | | active agent tab discard policy | `agents.ts` live state projected by `bridge.ts`; applied by `extension/background.js` | exact conversation ids derived per `/status`; extension-owned tab ids in `storage.session` | active Prime and active/waking/detached Worker tabs are non-auto-discardable; sleeping/terminal chats restore only policy this extension changed | | browser document + navigation identity | `background.js` document/epoch registry + `content.js` epoch | browser-session state + per-document memory | stale documents/epochs may observe but may not mutate current conversation state | | browser command intent | `bridge.ts` `CommandSpec` | durable `bridge-commands` snapshot | exactly three semantic intents: fresh worker, exact-chat revive, fresh resume; the spec owns identity, not a URL/tab/document | | browser command lease | `bridge.ts` command record | durable queued/leased phase including `claimedAt` + exact `owner`; restore preserves a valid leased owner | `/commands/redeem` is the arbitration cut; worker/revive are exclusive to that page owner. Resume alone may transfer a pre-dispatch lease to another destination document while its durable send checkpoint still proves nothing was dispatched | | irreversible browser command result | `background.js` command ACK outbox → `bridge.ts` command/receipt semantics | ACK outbox is mirrored to `storage.local` for browser-restart durability; app command/receipt state survives app restart | fresh worker/resume terminal ACK retires into a receipt. A revive `sent` ACK proves the user message crossed ChatGPT, but the command intentionally stays leased until exact worker liveness or the 30s revival deadline resolves broker state | | deferred worker revival | `background.js::deferredRevivals` | `storage.local` marker only | survives browser restart; actual prime text stays app-side; bridge redeem remains the authority, so stale markers are harmless | | worker lifecycle + ownership | `agents.ts` | swarm snapshot + retained/dormant prime-owned history | conversation binding is identity; `invited/active/detached/waking/sleeping/finished/failed` describe broker state, not page decoration | | worker slot accounting | `agents.ts::occupiesSlot` | derived from broker state | sleeping workers free slots; waking reserves one before the browser acts; terminal rows never revive | | agent message delivery | `agents.ts` | durable broker queue | at-least-once until authenticated acknowledgement; `offered` is not `delivered`; revival-delivered user messages are never re-offered in tool results | | per-chat workspace | `workspace.ts` | memory derived/learned from proven identity + roots, moved with ownership | convenience state only; never authorization; missing trustworthy workspace fails instead of choosing the first root | | exec session custody | `codex/ownership.ts` + singleton manager | process lifetime | running or exited-unread process id belongs to the proven durable local session principal; A→B keeps custody without adoption, while another session/worker cannot poll or write it | | background exec obligations | `codex/ownership.ts::backgroundExecObligations` + `UnifiedExecProcessManager::backgroundState` | retained process rows until the owner reads/releases them | completed unread output is data owed to that durable session, never GC fodder; four unread completed results block that session from spawning another child before any process is started | | Compact & Resume transaction | `session/continuation.ts` | durable continuation WAL + session metadata commit | one local session, one open continuation per session, one claimant/commit; source keeps ownership until durable rebind lands | | source/destination send ambiguity | continuation send checkpoints | durable `not-attempted` / `attempted-unresolved` / `dispatched-unresolved` / resolved message identity | pre-dispatch ambiguity is replayable; post-dispatch ambiguity is **not** permission to click again; ChatGPT's marked message resolves it | | resume shadow suppression | `session/resume-gate.ts` | short memory claim bounded to 60s | recorder waits briefly for the already-authoritative continuation instead of inventing a second local session for the replacement chat | | resumed first-answer Goal provenance | `content.js` `resumeGoalPending` / `maybeRecoverResumeGoalTurn()` | tab-local `sessionStorage` (`clf-resume-goal-v1`) + live memory only | armed only by this document's real resume bootstrap plus acknowledged A→B continuation; waits for B's post-commit Goal policy, can recover exactly that one completed first answer, then is consumed. It is provenance for the page trigger, never continuation/session/Goal authority | | Goal objective | `goal.ts` objective ledger | durable per-conversation named state | chat-local finish line; moved by Compact & Resume; restore alone never starts work | | Goal / Loop chat switch | `goal.ts` `goal-switches` ledger | durable per-conversation `{enabled, mode, at}`, bounded to 400 decisions; absence inherits app-wide config | exact-chat stop/mode choice survives reload/restart and moves with Compact & Resume; an existing row outranks ordinary app-wide changes. Deliberate app-wide **On→Off** is the master stop and clears all rows, so a later global On again reaches chats whose overrides were intentionally discarded | | Goal terminal reply obligation | `goal.ts` reply ledger | durable, one row per conversation, TTL + cap | the recorder freezes whether a stable final reply still requires one Goal decision before page races/reloads can lose it | | Goal draft | `goal.ts` draft map | memory; tied to durable obligation | at most one draft per conversation/turn; browser client owns acknowledgement; only `ready` text may be typed and `no-reply` is a real terminal decision | | renderer authority | `ipc.ts` + preload allowlist | process lifetime | renderer never receives generic Node/invoke authority; async reads paint only when their selection/generation still matches | | connection/tunnel generation | `connection.ts` + `tunnel/*` | process lifetime, re-observed from process/metrics | stale callbacks from replaced tunnels are ignored; `/readyz` + readable poll metrics establish runtime readiness, while a completed/fresh poll timestamp separately verifies external-link age and detects later loss | | child process environment | `env.ts` | rebuilt per child | Windows env names are case-insensitive; never write `PATH` by raw object indexing; preserve the inherited environment unless a narrow repair is proven | | Windows build-tool discovery | `toolchain.ts` | process memoization | fill missing/unreachable JAVA_HOME/GOROOT only; never override an explicit or already-reachable toolchain | | shell compatibility repair | `exec-hints.ts` | per command | rewrite only when intent is provable; unsupported/ambiguous shell syntax passes through untouched; hints are preferred to semantic guessing | | command output budget | `head-tail-buffer.ts` + `truncate.ts` + `exec-output.ts` | per process/result | collection cap and model-visible truncation are different bounds; preserve head+tail and explicitly count omitted middle bytes/tokens | | Desktop frame/ref identity | `computer/index.ts` | bounded process caches | physical coordinates are meaningful only against the captured frame/window geometry; semantic refs are meaningful only against their UIA snapshot | | extension install path | `extension-path.ts` | stable `userData/extension` for packaged builds | stage/fingerprint/rename/rollback; Chrome never points at an AppImage's temporary mount or a half-copied update | | app update | `update.ts` | startup + unreferenced six-hour checks, one in-flight pass; versioned verified artifacts under `userData/updates` | Restart adopts an existing file only after a fresh published-digest check. Changed release selection retires old staged authority. Quit rechecks bytes before handoff; explicit Install requests relaunch. | | tunnel-client run ownership | `tunnel/index.ts::ClientRun` + `current` | one process-generation object at a time; old child may exist only while `retirement` joins its teardown | every callback checks `current === run`; `restart()` is the CAS-like ownership cut, clears current before retirement, and only the retirement owner may schedule the successor | | app-window lifetime | `window-lifecycle.ts` | process lifetime | only the single-instance lock owner touches shared userData; activation is gated until bootstrap/security/IPC are ready and permanently disabled once quit begins | If two rows appear to own the same semantic decision, treat that as an architecture bug until proved otherwise. Mirrored **presentation** is fine; mirrored **authority** is not. **What “durable” means here.** `durable.ts` is the app's process/crash/restart transaction layer, not a claim of database-grade fsync/power-loss durability. Named state writes are serialized, generation-fenced and published by temp-file→rename; `writeDurableNow()` is the barrier used when a later side effect must not happen until the control intent is on disk. A failed background write stays pending/retryable and cannot poison the serialization chain for other names. But `readDurable()` deliberately turns a missing, unreadable or unparsable control file into `null` after logging it: corrupt auxiliary state may cost pending orchestration work, **never the app's ability to start**. If a mechanism needs stronger recovery than that, its independently durable source (for example session metadata/history) must be able to reconstruct the projection. ### 4.2 Runtime jump points — open the owner, not the symptom This is the shortest path from a production symptom to the state machine that owns it. The subsystem sections below explain the invariants; this table answers **which function do I open first?** | Mechanism | Start here | Then follow | | --- | --- | --- | | app bootstrap / restart order | `index.ts` `app.whenReady().then(...)` | Goal/correlation restore → retired workers/swarm → continuations → IPC/window → bridge/retention/connection/update; quit starts at the `will-quit` handler and enters `shutdown.ts` | | MCP request identity | ingress: `mcp/inbound.ts::requestIdFromHeader()` → `kernel.ts::callerConversation()` / `recorder.ts::awaitFreshCallOrigin()`; browser proof publication: `recorder.ts::noteCallEvidence()` | `correlation.ts::observeRequestCorrelations()` writes exact URL/Fiber-agreed ownership; `requestCorrelation()` is then read by caller/workspace guards → `recordToolCall()`. The MCP request never creates its own ownership proof | | live browser request-id ownership handshake | `content.js::confirmLiveRequestOwners()` | background `correlate()` → bridge POST `/correlations` → recorder exact call evidence → correlation registry read-back/`confirmed[]` | | broken ChatGPT page auto-recovery | all evidence converges on `bridge.ts::queueBrowserRecovery()` | silence: `armSilenceSweep()` → `inspectSilentChats()`; assistant-error: `noteRecoveryObservations()`; unattributed: `noteCallAttribution(null)` → `repairUnattributedChat()`; no-tab: `queueMissingTab()`; Goal: `inspectOwedGoals()` → current `takePendingRepair()` → `background.js::maintain()` → `confirmRepair()` / `failRepairAttempt()` | | Goal / Loop after a final answer | bridge POST `/events` durable `acceptGoalReplyNow()` + `content.js::noteGoalTurn()` | `watchGoalTurn()` → `/goal/draft` → `goal.ts::startGoalDraft(...deferStart:true)` → `beginGoalDraft()` / `requestDrivingDecision()` → `content.js::maybeSendGoalReply()` | | automatic compaction | bridge `considerAutomaticCompaction()` (from `grantActivity()`): `store.ts::autoCompactionReady()` + `chatIsWorking()` + worker/blocked fence → `continuation.ts::openContinuationNow(automatic)` | page reads the ticket as `job` → `content.js::maybeResumePendingCompaction()` (raises its tab) → `startCompact()` → `stopAndSettle()` → bridge `/compact`; pickups by phase in `inspectOwedCompactions()`: asking 2 min × 5 then abort, writing 5 min × 3, opening 15 min × 3, each in front | | Compact & Resume restart recovery | `continuation.ts::restoreContinuations()` | session metadata ownership → `commitContinuationResult()`/projection repair; browser send ambiguity is resolved by the continuation's durable source/destination send checkpoints | | resumed first answer missed by Goal | `content.js::rememberResumeGoalPending()` / `bindResumeGoalTurn()` | `maybeRecoverResumeGoalTurn()` → exact single resume-user-turn + final/Fiber proof → ordinary `noteGoalTurn()`; synthetic `g-resume-` is only a stable local turn id when no observed generation id exists | | worker lifecycle | `tools-core.ts` `agents` action dispatch | `agents.ts::stageSpawn()` / `stageMessages()` / `stageFinishAgent()` → `persistCriticalSwarmNow()` → staged commit/rollback → bridge worker/revive commands → `background.js::recoverDeferredRevivals()` → exact page liveness back into `agents.ts` | | browser command delivery | worker producers `bridge.ts::queueWorkerBootstrap()` / `queueWorkerRevival()`; **resume production** is bridge POST `/compact` after `continuation.ts::attachSummary()` → private `queueResumeCommand()` | durable command owner/lease → `/commands/redeem` / `/commands/ack` → `background.js::redeemCommand()` / `ackCommand()` → content send → receipt/recovery in `restoreCommands()`; exported `queueResume()` is a test/older-caller convenience wrapper, and private generic `queue()` is storage plumbing — neither is the semantic resume entrypoint | | which browser opens a fresh chat | `bridge.ts::offerPlacement()` / `pendingBrowserPlacement()` → `background.js::placeSuccessorChat()` | the `/compact` reply that produced the command carries `placement`, and chat A's own browser creates chat B in chat A's window; `pendingBrowserPlacement()` spends opening authority on handout, before hydration; no timer may issue a second OS open. `openFreshChatInBrowser()` handles a resume with no waiting browser collector | | extension document/conversation identity | `background.js::authorizeDocument()` / `registerDocument()` / `ownsDocument()` | `noteTabConversation()` + `chatgpt-dom.js::conversationFromPath()` / `conversationId()`; React-only evidence begins at `fiber.js::scan()` | | page observation commit | bridge POST `/events` | exact lost-worker-ACK recovery + `noteAgentAlive()` → `recorder.ts::recordChatObservations()` → durable Goal reply obligation → browser-recovery activity → context ceiling → staged/durable worker final → HTTP 200 lets extension journal retire the batch | | Overwrite / native ChatGPT presentation | `content.js::renderStreams()` | `websiteRenderForTurn()` + `completeReplacementForTurn()` + `hasUnrepresentedFiberCall()` → `chatgpt-dom.js::replaceActivity()` / `hideProgress()`; exact Fiber/page identities decide whether local activity is complete enough to replace native activity, while ChatGPT always keeps answer/code/actions | | session evidence / mutable website messages | `store.ts::appendEvent()` / `upsertMessageEvent()` | `recorder.ts` attribution/repair → canonical message shards + rebuildable `meta.json` projection | | background terminal result custody | `codex/ownership.ts::backgroundExecObligations()` / exec-session owner map | `tools-core.ts::execSession()` resolves the durable local principal for admission → `UnifiedExecProcessManager::backgroundState()` → owner drains/releases via `write_stdin` | | connector connect/disconnect/settings | `connection.ts::enqueueLifecycle()` | `connectImpl()` → Core MCP/tunnel → `startDesktopTunnel()`; settings enter `applySettingsImpl()`, ordinary stop enters `disconnectImpl()`, final stop enters `shutdownConnection()` | | tunnel health / restart | `tunnel/index.ts::startOpenAiTunnel()` | `ClientRun` → `routeObservation()` → single-owner `restart()`; `connection.ts` and `diagnostics.ts` consume this report rather than supervising the child | | app update | `update.ts::startUpdateChecks()` → `checkForUpdates()` | `runPass()` → `stagedArtifact()` (packaged installs only) → `download()` + SHA-256 publication → `applyStagedUpdate()` at ordered shutdown | | Chats “Open Chat” | IPC `sessions:openChat` | re-read session truth → `browser.ts::openInPreferredBrowser(chatUrl(id))`; preload `openSessionChat()` is the narrow renderer boundary and `renderer/chat.ts::sessionRow()` is presentation only | When a row crosses files, keep following the **same identity** through the arrows. Do not jump to a later fallback/UI symptom merely because its function name contains the error the user saw. --- ## 5. Startup and shutdown — `index.ts` ```text single-instance lock → only the lock owner may touch userData/bootstrap state → init config/secrets/session/durable paths → load + validate config → restore Goal objective → per-chat switch → reply-obligation ledgers → restore request correlations BEFORE bridge traffic can race in → wire recorder↔agent identity callbacks + preferred-browser opener → wire swarm persistence sinks even if multi-agent currently off → restore retired-worker fences → restore active+dormant swarm history → if feature is off: pause live execution, preserve history, persist the safe projection → restore continuations AFTER swarm, because recovery may need to repair prime ownership → install renderer CSP + deny browser permissions → register fixed IPC → enable the native window activation gate → create/show window + tray → queue legacy attribution repair asynchronously → start bridge if recording OR multi-agent → start session-retention maintenance independently of recording admission → auto-connect MCP/tunnel if configured → start non-blocking updater lifetime: immediate pass + unreferenced six-hour recheck schedule ``` `ui.chatBrowser` owns the Chrome/Edge choice for OS-originated ChatGPT launches. `browser.ts` reads it at launch time and tries only that family's installations; failures propagate to the request owner instead of opening the system default browser. The setting does not select a profile or override connected-extension delivery/source-tab placement. Legacy configs use Chrome. The **window activation gate** is a real lifetime boundary, not UI polish. Electron may deliver `second-instance` after its own `ready` event while this app is still restoring durable state and before CSP/permission/IPC setup is complete. `window-lifecycle.ts` therefore drops/folds early focus requests until bootstrap enables the gate. Once `before-quit` disables it, that disable is terminal: an old async startup continuation must never re-enable window creation during teardown. The losing single-instance process also sets `quitting` immediately. `app.quit()` does not stop module evaluation, so **every** shared-userData bootstrap path must be guarded by `shouldBeginAppBootstrap()` rather than assuming a secondary process disappeared synchronously. **Must hold.** The window keeps context isolation on, Node integration off, renderer sandbox on, navigation and window creation constrained, permission requests denied unless explicitly supported. Never weaken that to solve a renderer convenience problem. Every new long-lived process, timer, listener, queue or durable writer names its shutdown owner — teardown covers tunnels, both listeners, process sessions, then flushes session and durable state. `will-quit` calls `preventDefault()` and owns the decision to quit from then on, and it destroys the tray before teardown starts. So teardown is not merely ordered, it is **bounded**: `shutdown.ts` gives each phase its own budget and always ends the process. A task that never settles would otherwise strand an invisible main process holding the single-instance lock, and every later launch of the app would silently do nothing. Per-task bounds are not a substitute for that — "each piece is bounded" is a different claim from "the sequence ends". Ending it is `app.exit(0)`, never `app.quit()`, and that is not interchangeable. Electron drops a quit raised from the promise continuation that finishes teardown: on Windows the call returns without even emitting `before-quit`, while the same call one macrotask later quits normally. `shutdown.ts` therefore owns the exit itself rather than trusting its caller to remember. The shutdown phases are also semantic ordering, not just cleanup aesthetics: 1. **admission/drain** — stop MCP + bridge from accepting work and let already accepted requests reach their own bounded drains; 2. **process cleanup** — only after request handlers stop may PTYs and the Windows helper be killed; 3. **recorder flush** — recorder work may enqueue session and named durable writes; 4. **durable flush** — session store and named-state store are independent writers and both get a last attempt even if one fails; 5. **update handoff** — a verified staged update is the final effect, after every stateful owner has finished, because the next process start is meant to be the new version. ### App update is deliberately boring — `update.ts` There is no `electron-updater`, renderer-owned download state or forced restart. One deduplicated `checkForUpdates()` pass runs after startup and nobody awaits it; `startUpdateChecks()` repeats it on an unreferenced six-hour timer, because this app lives in the tray and "once per start" is in practice "never" for an installation nobody restarts. A pass asks GitHub only for the newest tag, decides whether this exact installation can self-apply an artifact, downloads at most that one file, verifies it against the release's `SHA256SUMS.txt`, publishes it from `.part` by rename, and waits for the user's ordinary quit. A repeat pass over an already staged release stops at the release call. The state machine is intentionally one record: `UpdateStatus {current, latest, stage, error, checkedAt}` with `stage = idle|checking|downloading|ready|failed`. `checkedAt` is set the moment the release API answers and is the *only* thing separating "checked, nothing to install" from "has not asked yet" — both are `{latest: null, stage: 'idle'}` otherwise, and the UI may not claim the first without it. `checkForUpdates()` owns one in-flight `pass` promise, so duplicate callers join the same check/download. `stagedArtifact()` is the platform policy function and refuses an unpackaged run outright — a dev tree is permanently "behind" and quitting `electron-vite dev` must never run an installer over the maintainer's real install. `download()` is the checksum/publication boundary; `applyStagedUpdate()` is the only installer/swap boundary. `onUpdateChange()` notifies `ipc.ts`, which republishes the ordinary application state; `renderer/main.ts::updateSummary()` turns that record plus the bridge's extension version into one sentence and one tone, which `paintUpdate()` renders in three places and nowhere else: the header notice bar (only for what the user can act on), the Activity panel's `#updateLine` (every state, green when current, red when a check or download failed), and exactly one toast per window. The renderer never checks GitHub, chooses assets, hashes bytes or decides install eligibility. - Windows x64/ARM64 stages the matching NSIS installer and launches it detached with `/S` during shutdown; installer is per-user and needs no elevation. - Linux **AppImage** can stage the matching AppImage; shutdown copies to `.new`, chmods and renames over the path so the mounted old inode can finish running safely. - Linux **DEB** is package-manager-owned; macOS has ad-hoc signing, not Developer ID notarization. Both may show a newer-version notice but do not silently self-replace. - An unpackaged run (`npm run dev`, a working tree) is told what is published and stages nothing. - A failed check/download leaves the running version fully usable and the next six-hour pass (or restart) checks again. Apply happens only inside ordinary shutdown: `applyStagedUpdate()` consumes the in-memory staged row before handoff, so an apply failure has no same-run retry and the next app start performs a fresh check. There is no retry state machine around either path. - Update network waits are bounded at the owner: latest-release/checksum HTTP uses a 15-second ceiling, while the artifact download may take up to 10 minutes. Expiry is just a failed one-pass check/stage; it does not start a same-run retry daemon or force an app restart. - Staged authority is process memory; the versioned artifact survives restart. The next pass asks for the release and its published checksum, then `adopt()` reuses a matching file without another download. A changed/withdrawn release retires the previous authority. Quit hashes the artifact again before execution; a directory containing an executable alone never grants install authority. - Explicit Install uses the ordinary drained shutdown and requests relaunch (`--force-run` on Windows, `app.relaunch` for AppImage); an ordinary quit does not reopen the app. Recheck UI stays in Checking, never briefly claims a manual update. A release must be strictly newer than the installed version; replacing bytes under the same tag cannot trigger an upgrade. The update module does **not** own extension-version truth. `bridgeStatus()` learns that from the authenticated extension header; duplicating it in the updater would create two authorities. ## 6. MCP surfaces and discovery — `surfaces.ts`, `tools.ts`, `server.ts` ChatGPT discovers **one server's entire tool list as a unit**: a no-query `list_resources` returns every schema that server advertises. Splitting into separate servers is therefore the only mechanism that actually bounds the worst case. Core and Desktop retain their existing contracts. The optional Plugins surface publishes external MCP tools. **Core** (`chat-on-steroids-core`, required): | Tool | Live when | Implementation | | --- | --- | --- | | `read` | `read` \| `browse` \| `metadata` | `tools-core.ts` → `codex/read-backend.ts` | | `view_image` | `read` | `tools-core.ts` → `codex/view-image.ts` | | `find` | `search` **and not** `command` | `tools-core.ts` → `search.ts` | | `apply_patch` | any of `create`/`edit`/`move`/`deleteFile` | `codex/apply-patch/*` | | `exec_command`, `write_stdin` | `command` | `codex/unified-exec.ts` | | `download_artifact` | `saveArtifact` | `tools-core.ts` → `artifact-fetch.ts` + `artifact-target.ts` | | `session` | recording enabled | session subsystem | | `agents` | multi-agent enabled | `agents.ts` | **Desktop** (`chat-on-steroids-desktop`, optional, **Windows/macOS**): `observe` needs `screen`; `computer` registers on `control` **or** either clipboard permission, then re-checks each of its 13 actions at runtime. The surface is offered at all only when one of those four permissions exists on a supported host — an empty or impossible connector is worse than no connector. **Plugins** (`chat-on-steroids-plugins`, optional): `plugins/manager.ts` owns installed external stdio/Streamable HTTP servers and enabled tools. `mcp/tools-plugins.ts` preserves upstream JSON schemas/results through the SDK and existing attribution/recording dispatcher. Installation and credentials stay in the main process; external processes do not inherit the CoS folder sandbox. Discovery is bounded to 64 tools / 250 KB. Stale calls check live plugin policy. See `docs/plugins.md`. Only installed and enabled plugins own a running connection; no catalog recipe is preinstalled. Enabled connections restore in the background on app startup and remain alive until disabled, uninstalled, failed, restarted or app shutdown. Tool inactivity does not retire their process state. OAuth remotes use `plugins/oauth.ts` with the MCP SDK for discovery, DCR, PKCE and refresh. Credentials are encrypted per installation and exact endpoint. Startup/reconnect may refresh saved credentials but never open a browser or register a new client; explicit **Sign in** owns the bounded loopback callback. Cancellation, replacement and shutdown revoke that flow. Unauthenticated cached tools remain unpublished. Blender also requires the open editor and its running MCP addon. **Core/Desktop exposure is monotonic per endpoint lifetime.** ChatGPT caches schemas, and yanking one from under a cached snapshot surfaces as a transport-level UNKNOWN failure. So `server.ts` remembers what this endpoint has ever exposed. A permission revoked after exposure leaves the schema registered and its handler returns `TOOL_DISABLED`. The `find`-vs-exec choice is frozen the same way, at first discovery. **Must hold.** Two separate concepts, never collapsed: *exposed* (a schema may exist because it was visible earlier) and *live* (the operation is allowed now). **Schema visibility is never the security boundary** — `config.ts::effectiveCapabilities()` and the live guards are. A server registers only tools its surface declares and answers anything else with a protocol-level unknown-tool error; there is no merged list and no hidden acceptance. A deliberate reconnect is the clean boundary for changing the shape. **Tests.** `mcp.test.ts`, `config.test.ts`, `mcp-shutdown.test.ts`. ## 7. One MCP call, end to end ```text tunnel request → server.ts loopback Host/Origin, secret tokenized path, bounded body, x-request-id read + normalized (split before '/') → tools.ts build only the requested surface → kernel.ts AsyncLocalStorage call context resolve exact caller from correlation evidence resolve agent identity if a swarm is active wait for identity when the operation genuinely needs it enforce the live capability / read-only guard → tool handler sandbox any model path, execute, attach structured evidence (changes, counts, exit code, session id, assets) → recorder.ts exact args/result/outcome; attach ONLY on proven ownership → kernel agent inbox offer/ack bookkeeping → response ``` `server.ts` manually reads and bounds chunked / no-`Content-Length` POST bodies before handing parsed JSON to the MCP adapter. **Do not regress that to a `Content-Length`-only guard.** `inbound.ts` captures the raw header because the MCP library's higher-level context has not reliably exposed it. `call-context.ts` deliberately exposes **three lifetimes**, because "not completely accounted for" is not the same as "can still mutate the machine": - `runningToolCalls()` — dispatch has not returned; this is the **Compact & Resume safety barrier**, because commands/edits may still be changing the machine. - `settlingToolCalls()` / `inFlightToolCalls()` — handler result is already released, but an unattributed durable record may still be waiting for late request-id evidence. Useful for diagnostics/shutdown/orphan accounting, **not** a reason to stall compaction for the recorder's grace window. - `inFlightMcpRequests()` — widest request lifetime, including identity wait and durable recording; orphan cleanup uses it so post-handler bookkeeping never looks like global idleness. An unresolved call is conservatively visible to every conversation until ownership lands; a proven worker call does not block an unrelated prime. Tool failure reporting has four semantic outcomes in `shared/session.ts::ToolOutcome`, and the distinction feeds both UI tone and reliability metrics: | Outcome | Meaning | Reliability defect? | | --- | --- | --- | | `ok` | tool completed normally | no | | `process_exit_nonzero` | the spawned program returned a real non-zero exit; the tool transport/runtime itself worked | no | | `tool_rejected` | the app intentionally refused the operation (permission, validation, ownership, policy, etc.) | no | | `tool_internal_error` | connector/tool implementation/runtime failed to perform its own contract | **yes** | `call-context.ts::noteOutcome()` keeps the strongest/more-specific outcome so a generic wrapper cannot overwrite a timeout/internal failure with a weaker classification. `noteExec()` classifies ordinary non-zero child exits separately from tool timeouts, and `store.ts` increments the session reliability numerator only for `tool_internal_error`. `normalizedToolOutcome()` preserves the self-proving legacy rows without guessing ambiguous old `error` records. Do not collapse these back to success/error: a broken build is not proof the connector is unreliable, and a policy rejection is not a tool crash. ## 8. Filesystem containment — `sandbox.ts` The authority for every model-supplied path. Approved folders get virtual roots such as `/project`; native absolute paths are also accepted when they resolve inside an approved root. The **root name does not mean “this is the repository”**. Users commonly approve a parent folder that contains several projects, so a live root like `/workspace` says only which folder was approved. The model-visible `read` description deliberately gives the live root names but invents no suffix such as `/workspace/src/main.ts`; it tells the caller to name every real folder between the approved root and the file, using the same project-relative shape it would pass as `exec_command.workdir`. Reading the root itself is the discovery primitive: it lists one level deep. Do not reintroduce worked paths that silently promise the approved root is the project. **Must hold.** - Every model filesystem path converges on `Sandbox.resolve()` or an already-validated wrapper. "It is only a read" is not an exemption — reads are confidentiality-sensitive. - **Virtual and native spellings receive identical authorization.** Test both the virtual spelling and the host spelling (`C:\approved\project\src\a.ts` on Windows, `/home/me/project/src/a.ts` or `/Users/me/project/src/a.ts` on POSIX). Never "improve" native normalization by letting it collapse traversal the virtual spelling rejects. - Containment covers root selection, host-invalid/path-trick rejection, canonical checks on existing targets, deepest-existing-ancestor validation for missing targets, reserved virtual root names, and symlink/reparse/junction handling as applicable to that OS. - Authorization must remain valid at the point of filesystem use; avoid designs that rely only on an earlier pathname check when the underlying target can change. - Native filesystem error text must not leak hidden physical root paths back to the model. **Not contained: shell commands.** `exec_command` is arbitrary code execution as the logged-in user. Its *starting cwd* is restricted to an approved folder; the command is not. That is why `command` is the strongest permission and why read-only mode disables it outright. Never claim approved roots contain arbitrary commands — they contain the app's filesystem tools. Read-only derives from the complete write-capability list, so a new write capability must become read-only-blocked automatically. **Tests.** `sandbox.test.ts`, plus retained bughunt repros. ## 9. Workspaces — `workspace.ts` Two ideas that are easy to confuse: **approved roots** are the security boundary the user configured; a **workspace** is convenience state saying which project *this exact chat or agent* is working in. Keyed by exact chat/agent identity, learned from proven absolute paths and project markers, inherited by spawned workers, moved by Compact & Resume. **Must hold.** A relative path or omitted `workdir` with no trustworthy workspace **fails** rather than mutating a guessed project. When caller identity is unresolved during a swarm, never silently fall back to the first approved root — that turns an attribution failure into a wrong-target mutation. Moving a workspace is state continuity, never a new permission; the target still has to be legal. **Tests.** `workspace.test.ts`, `swarm.test.ts`. ## 10. The Codex-derived tools — `src/main/codex/*` Selected public Codex behavior ported into TypeScript. **It does not launch a Codex model or require a Codex installation.** **`exec_command` / `write_stdin`.** `unified-exec.ts` ports session ids, output draining, head/tail buffering, yield deadlines, output token policy, interactive stdin, and sessions that outlive the call that created them. Windows adaptations (quoting, interrupt) live beside the port and stay explicit and tested against model-facing behavior. There is a known Ctrl+C vs. natural-exit race worth keeping a regression for. The local MCP adaptation also accepts `cmds` to run related commands sequentially in one labeled shell session, and an empty `write_stdin` poll returns on first output instead of holding Codex's full collection window. Start at `tools-core.ts` → `unified-exec.ts` → `shell.ts` → `ownership.ts` → `exec-output.ts`. The local execution wrapper adds several mechanisms around that port. They are not generic "helpfulness" and must stay fail-safe: - **One process manager, app lifetime.** `codex/manager.ts` is the singleton that makes an exec session id meaningful across later `write_stdin` calls. Never create a second manager per tool registration or conversation; caller isolation is enforced by `ownership.ts`, not by separate process pools. - **One OS-correct child environment.** Every spawned process converges on `env.ts`. Windows environment keys are case-insensitive even though JavaScript object keys are not, so raw `env.PATH = ...` beside an inherited `Path` can erase the user's real PATH when CreateProcess folds the two spellings together. Read/write through `envValue`/`setEnvValue`, normalize before spawn, and only append the minimal Windows system directories when the inherited path is truly unusable. - **Toolchain repair fills; it never chooses for the user.** `toolchain.ts` looks for a Windows JDK or Go installation only when `JAVA_HOME`/`GOROOT` is absent **and** the corresponding executable is unreachable. Java proves `javac.exe`, not merely `java.exe`, so a JRE cannot become a fake build JDK. Discovery is process-memoized because it sits on every command path. - **Shell compatibility is abstention-first.** `exec-hints.ts` may repair a narrowly proven PowerShell quoting/glob mismatch, classify a documented search exit-1 as "no matches", or add an actionable recovery hint. If tokenization/flag arity/control flow is ambiguous, the original command runs untouched. A guessed rewrite that succeeds at the wrong command is worse than a visible failure. - PowerShell native-program glob expansion is deliberately bounded by the **actual command-line constraint**, not by what makes a readable note. One proven relative directory may expand to at most 128 names; the command receives every name, while `listExpandedNames()` shows only the first 12 plus a count. Above the bound the command is left untouched — silently searching a shortened list would be a wrong answer. Globs after a prior statement are not expanded because that statement may have changed cwd; textual brace expansion has no such cwd dependency. - Exit 1 is classified benign only for a search whose status semantics are proved. Bare ripgrep still needs the existing executable/path proof; `git grep` is separately understood because this runtime launches PowerShell with `-NoProfile` and can safely identify `git` plus the exact `grep` subcommand. Predicate flags such as `--quiet`/`--exit-code`, ambiguous global git options (`git -C …`), a status-deciding later pipeline stage, or `fatal:`/`error:` output withhold the exemption. `git diff`/`apply`/`push` exit 1 remains a real non-zero process result. This is classification, never permission to rewrite a failing git command into success. - **`cmds` is one shell, not N processes.** `command-batch.ts` composes sequential commands in the same shell so cwd/environment changes survive between them. It keeps running after ordinary non-zero exits, frames each section with a random marker that command output cannot spoof by accident, and returns the first non-zero exit after all sections ran. Recovery hints consume only completed nonzero sections and name that command: a later parse failure must not imply earlier commands did not run or invite repeating their successful mutations. - **Collection and model output are different budgets.** `head-tail-buffer.ts` bounds what a process can accumulate while keeping a stable head, rolling tail and exact omitted-byte count. `truncate.ts`/`exec-output.ts` separately bound what is serialized to the model in UTF-8 byte / approximate-token space. Do not collapse the collection ceiling into the default response budget: an explicit larger `max_output_tokens` is supposed to work up to the collection cap. - **A finished background command still owns an unread result.** `UnifiedExecProcessManager` no longer evicts exited sessions to make room. `backgroundState()` projects the caller-owned `running` and `exitedUnread` rows, and `ownership.ts::backgroundExecObligations()` is the one caller-scoped view used by notices and admission. Before `exec_command` allocates or spawns anything, `tools-core.ts::execConversation()` resolves the exact conversation once and refuses a fifth unread completed result with `EXEC_RESULTS_UNREAD`; the user/model must drain those exact session ids through `write_stdin`. Capacity is therefore **admission, not garbage collection**: never recover room by deleting output the owning conversation has not observed. - **A still-running background command gets one caller-scoped attendance reminder, not a watchdog.** `ownership.ts::noteExecOwner()` starts its attendance clock and `noteExecAttended()` refreshes it around `write_stdin`; after `UNATTENDED_EXEC_NOTICE_MS = 120s`, `backgroundExecRecoveryNotices()` may append one "running unpolled" reminder for that exact owned session to the next MCP result delivered to the same conversation. `kernel.ts:: withBackgroundExecRecovery()` is the single consuming path — merely inspecting runtime state must not spend the notice. A live dev server/tail does **not** consume the four-result unread admission budget and is never killed or auto-polled by this mechanism. Once the live-session reminder was delivered it does not nag again; if the process later exits, the separate `exitedUnread` reminder repeats on later calls until the owning chat actually drains the terminal output. **`apply_patch`.** Model syntax is Codex V4A. MCP cannot expose a true freeform tool, so the raw patch rides inside the `patch` string while the grammar lives in the description. Engine under `apply-patch/`; the wrapper adds capability checks (per hunk kind — add needs `create`, delete needs `deleteFile`, content change needs `edit`, rename needs `move`), sandbox resolution, workspace behavior, recorder evidence. **Shell interception** also exists so a model emitting `apply_patch` as a shell command still reaches the port — if the failure involves `cd`, quoting, `&&` or other control flow, the bug is above the parser. Multi-file patch failure has a concurrency fence: the wrapper snapshots bounded pre-edit state, and rollback restores only paths that still match the state **this patch itself produced**. If an external editor changed a path after the partial patch, rollback refuses to clobber that newer work. The rollback budget is intentionally bounded; this is a recovery guarantee, not permission to snapshot arbitrarily large repositories into memory. Inside `codex/apply-patch/`, keep the layers distinct: | File | Mechanism it owns | | --- | --- | | `parser.ts` | whole-input boundaries and the intentionally lenient heredoc wrapper; normalizes Rust `lines()` CRLF/trailing-newline behavior before handing text to the grammar engine | | `streaming-parser.ts` | the actual V4A grammar state machine: begin/end markers, optional environment id, add/delete/update/move hunks, change-context/chunk construction, line-numbered parse failures | | `hunk.ts` | immutable semantic hunk/chunk shapes and marker constants; path spelling is preserved for summaries while source resolution is a separate operation | | `seek-sequence.ts` | ordered context search: exact → trailing-whitespace-insensitive → trim-insensitive → limited Unicode punctuation/space normalization; EOF hunks prefer the actual file end | | `file-update.ts` | turns ordered chunks into non-overlapping replacements, including repeated updates to one file; replacements apply from the end so earlier edits cannot shift later indices | | `text-file.ts` | line-ending-preserving source representation: untouched lines keep their exact CR/LF/CRLF, inserted lines use the file's first/preferred ending | | `mode.ts` | explicit reconstruction policy (`normalize_to_lf` vs `preserve_line_endings`); upstream-compatible default is still normalization unless the caller opts into preservation | | `errors.ts` | typed parse/I/O/replacement/path/implicit-invocation failures whose model-visible strings intentionally match upstream Codex behavior | The fuzzy seek ladder is **matching policy, not authorization**. Paths are still sandboxed before mutation, and a successful fuzzy content match never relaxes filesystem ownership or capability checks. **`read`.** Deliberately four layers: `tools-core.ts` owns the model contract and multi-path behavior; `read-backend.ts` owns decoding/listing semantics; `filesystem.ts` is primitives only; `sandbox.ts` is policy. **Do not push authorization down into `filesystem.ts` and assume the public tool became safe.** **`view_image`.** 8 MiB transport ceiling. PNG gets a real decode check; JPEG/GIF/WebP validation has documented limits and does not yet match upstream's full-decoder guarantee. Synchronous validation of an adversarial compressed payload is a main-process resource risk. An invalid `image` content block can break an entire model turn — **prefer rejection over optimistic decoding.** **Tests.** `codex-runtime-parity`, `codex-apply-patch-parity`, `codex-apply-patch-invocation-parity`, `codex-view-image-parity`, `mcp`. ## 11. Identity — the spine of the whole project An MCP payload contains **no trustworthy ChatGPT conversation id**. There is exactly one accepted proof chain: ```text HTTP x-request-id (inbound.ts, normalized before '/') ≡ page message.metadata.request_id → fiber.js emits allowlisted request evidence from the MAIN world → content.js pins the concrete route + exact live page/Fiber owner confirmLiveRequestOwners(requestIds, conversationId) → background.js correlate() re-checks current document/epoch + tab conversation → POST /correlations → bridge.ts ensures/reuses that conversation session, files only unresolved exact ids, then READS THE MAPPING BACK before ACK → correlation.ts proves requestId → conversationId → consumed by: kernel · recorder · agents · workspace · terminal ownership ``` This acknowledged `/correlations` operation is deliberately **separate from transcript `/events`**. A fresh ChatGPT chat can expose `metadata.request_id` while its newest React/Fiber turn still carries a provisional client thread id, then converge on the real `/c/` route shortly after. For the one page turn this document locally owns, `content.js` may retain that provisional Fiber descriptor long enough to send the pair `{concrete route conversationId, requestId}` through `confirmLiveRequestOwners()`. Historical/mismatched Fiber objects get no such exception. The app refuses any id already owned by another conversation, stores unresolved exact pairs through the existing recorder/correlation path, and returns `confirmed[]`; the page marks an id app-confirmed **only if that exact id comes back mapped to that exact conversation**. Batch `complete=false` does not erase other individually confirmed ids. No tool name, current tab, clock or nearest-turn guess is part of this handshake. **Never substitute** active tab, timing, tool name, most-recent chat, only-generating chat, worker payload, or arrival order. If proof is missing the safe state is **Unattributed**, no workspace, or refusal for identity-sensitive work. Guessing is worse than losing attribution: it routes commands, files, messages and history into the *wrong* chat. `multiAgent.allowUnattributedCalls` relaxes only the **ambiguity fences**, not exact ownership. Set to `false` — the migration baseline, and any install where the user turned it off — `mcp/kernel.ts` refuses an unidentified call when a retired-worker lease could own it, a dormant-worker lease could own it, or an active swarm needs exact workspace/terminal identity. `needsWorkspaceIdentity()` makes relative `read`/`find`, every `apply_patch`, and swarm/defaulted/relative `exec_command` identity-sensitive. Turning the setting on permits those otherwise-unidentified calls to proceed, but an exact known `dormantWorker`, `retiredWorker` or `endedWorker` is **still refused**. Do not document this as “disable attribution checks” or “all unattributed calls run”: it changes what happens when identity is absent, never who owns a chat that the app positively knows is a worker/fence. This one chain explains symptoms that look unrelated — worker `WORKER_IDENTITY_LOST`, calls piling into Unattributed, false worker stalls, wrong or absent project cwd, terminal polling crossing chats, agent messages stopping, Overwrite having no local activity to render. When several appear together, **debug the chain, not the symptoms**, in this order: ```text server.ts/inbound.ts did x-request-id arrive and normalize? fiber.js did the page model expose a matching metadata.request_id? content.js did refreshFiber accept the exact current descriptor and call confirmLiveRequestOwners for the concrete route? background.js did correlate() still own that document/epoch and POST /correlations? bridge.ts did /correlations return this request id in confirmed[] for that chat? correlation.ts was requestId→conversationId stored, and restored after restart? kernel.ts/recorder.ts did the call wait for, find and use the exact proof? ``` Agent routing is *downstream* of this. Do not start there. `correlation.ts` is stricter than a cache and more bounded than a permanent database: - the first exact proof for a request id wins; a later conversation claiming that id is refused without modifying or waking the original owner; - the stored `sessionId` is the **local session epoch at first proof**, not merely the current conversation. A stale old page cannot drag an in-flight request into a newer local session epoch after Compact & Resume; - proven owners have **no time TTL** and are reconciled from already-recorded request-id tool calls on startup, but the in-memory/durable registry is bounded to the **50,000 most recently observed request ids**. Do not describe it as literally unbounded/permanent storage; - the durable snapshot is a fast index, not stronger than session history. Because its writes are debounced independently from attributed JSONL, startup reconciles recorded proof even when a non-empty snapshot already exists. ### 11.1 Block Chat — the one stop this app can make A wedged ChatGPT page can leave a turn running with no working Stop control: the model keeps issuing connector calls, the user cannot see the turn's messages and cannot cancel it, and every one of those calls arrives here correctly attributed. **The app does not try to end that turn** — nothing it can reach owns it. It owns whether the turn touches this machine, and refusing every tool is enough: `kernel.ts` returns `BLOCKED_CHAT_REFUSAL` (`CHAT_BLOCKED: …`), which tells the model to abandon the task, make no further tool calls and answer, so the turn ends itself. `session/blocked-chats.ts` is a durable set of **conversation ids**, released only by the user. Conversation, not request id, because `correlation.ts` already proves `requestId → conversationId` and one ChatGPT turn issues all of its connector calls under one request id: a list of request ids would ban the turn the user was looking at and nothing the same chat did a second later. - The refusal is the **first** branch of the dispatch chain in `kernel.ts::dispatchTracked`, above every worker-lifecycle verdict, and applies to every tool on every surface — `agents` finish included. A blocked chat has nothing left to finish. - A blocked chat's **worker slot is released from the block itself.** `bridge.ts::sweepStaleSwarm()` sleeps every slot-holding worker (`occupiesSlot()`) whose conversation `isChatBlocked()`, on every 30-second pass and once more immediately from `sessions:block`. It reads only the durable block: the silence grant it used to wait for is process memory, and a restart after the block left the restored worker `active` with no grant to expire, holding the swarm's one slot for an hour and refusing the next prime with `AGENTS_BUSY` (2026-09-02, worker-3). No browser recovery is ever attempted for a blocked chat; its recorded open turn stays open until real evidence ends it. - It is **exact-identity only**, and never waits for evidence. It refuses a call whose *proven* owner is blocked and never one whose owner is merely unknown. That costs nothing: the user blocks a chat they can already watch making attributed calls, so its request id is proven and every later call in that turn resolves from the registry immediately. Do not "strengthen" this into blocking unattributed calls — that refuses an innocent chat's read to punish a different chat's turn. - It survives restart, because the turn can. It is released by the user's own press, or by deleting the session whose row carries that button (`ipc.ts::sessions:delete`) — otherwise a block could outlive the only UI able to lift it. Renderer: `chat.ts::sessionRow()` draws the toggle beside Open Chat, and `sessionBadges()` marks the row `blocked`. The blocked set rides `sessions:list` as live policy — it is keyed by conversation and must never be written into a session's `meta.json`. Preload exposes only `setSessionBlocked(id, blocked)`; `ipc.ts` re-reads and validates that session's stored conversation id, exactly as `sessions:openChat` does, so the renderer can never name a conversation of its own. **Tests.** `correlation.test.ts`, `mcp-inbound.test.ts`, `fiber.test.ts`, `content-script.test.ts`, `swarm.test.ts`, `blocked-chats.test.ts`, `mcp.test.ts` ("blocked chats"), `ipc.test.ts`. ## 12. Session recording — `recorder.ts`, `store.ts` Two independent producers, one durable timeline, neither replaceable by the other: 1. **MCP/app truth** — exact tool, arguments, result, outcome, file changes, duration, assets. 2. **Browser observation** — authored messages, turn lifecycle, native progress, visible errors, conversation identity, page request evidence. The app knows *what the tool did*. The browser knows *which conversation and turn showed it*. ```text userData/sessions// events.jsonl append-oriented tool/turn/error/activity events messages/*.json canonical user/assistant messages, one shard per logical id messages.json legacy canonical map, read during lazy migration meta.json atomically rewritten projection assets/ screenshots and large/binary material handoffs/.json saved compaction briefs ``` **Must hold.** Streaming website messages are mutable snapshots of one logical message, so Canonical message shards **replace by stable identity** — never turn that back into blind appends. Structured activity stays append-oriented. Large values bound inline and spill to assets; never fix a display-size problem by discarding the durable source. Durable state is the authority across restart, and `meta.json` must never claim events that `events.jsonl` does not contain. Unattributed is a **first-class state**, not a bug to paper over. Distinct from `logger.ts`, which is small, redacted, RAM-only and operational. ### The store has three write models, because the data has three different semantics `store.ts` is not “JSON files on disk” in the abstract. It deliberately uses a different commit mechanism for evidence, mutable website messages and derived metadata: 1. **Structured evidence — serialized append.** Each open session has one operation queue. Sequence assignment, complete JSONL append and in-memory projection update happen in that order on the queue; memory does not advance before the line exists. A crash-torn final line is detected/sealed before a later append so two JSON objects can never be concatenated into one apparently valid record. `events.jsonl` is therefore the history authority for structured activity, not `meta.json`. 2. **Canonical ChatGPT messages — atomic replacement by stable website identity.** Streaming and final revisions use one shard under `messages/`; the shard is temp→rename and a terminal final revision cannot later regress to streaming. Legacy append-only message snapshots remain readable but are suppressed when a canonical shard for that identity exists. Lazy migration overlays new shards on the old `messages.json` map rather than rewriting all history up front. 3. **`meta.json` — rebuildable projection.** Ordinary event ticks mark metadata dirty and coalesce rewrites; ownership/transaction boundaries that need a durable decision write it immediately. A validated `meta.backup.json` protects the last good checkpoint. On load, if metadata lags or is unusable, the store rebuilds history-derived counts/tokens/turn state from the journal + canonical messages while preserving metadata-only facts it can still trust. It refuses to turn an unrecoverable session into an invented empty one. That distinction also explains why **per-session serialization** is enough for many reads/writes: `flushSession(id)` joins only that session's queue rather than flushing every open session. A poll of one chat must not force metadata rewrites for dozens of unrelated generating chats. Large data has bounds at every representation. Inline tool args/results are 8k chars; ordinary assistant-message inline text is 12k, user messages may be much larger, and overflow text can spill to a content-addressed asset up to the explicit overflow ceiling. Individual session assets are limited to 8 MiB, with 192 MiB per-session and 2 GiB global asset quotas. Recent/tail readers have their own row/byte ceilings. **A bound is part of the mechanism that owns that representation** — raising a UI budget is not permission to remove the durable-store or transport bound underneath it. ### Attribution has one wait, then a first-class Unattributed landing For a tool call that **has a request id** whose owner is not yet in the registry, `recorder.ts` waits the current **20-second `REQUEST_ID_GRACE_MS`** for the browser's exact evidence. A headerless/no-id call has no exact ownership proof to wait for and proceeds directly to the Unattributed verdict. The grace does not delay every later write behind one global timer: calls with unresolved ids start their attribution waits independently, but each call synchronously reserves its eventual position on `recordChain`, so invocation order is preserved when the waits resolve at different times. Already-proven calls skip the wait but not the ordered write/quit-flush discipline. If the exact proof still has not arrived, the call lands in the Unattributed session. That is a durable truthful state, not a final guess. A later exact proof queues deterministic repair: ```text scan bounded Unattributed source snapshot → group only calls whose own requestId now has exact proof → copy referenced assets first → append the same callId/evidence into the proved destination session epoch → rewrite only the scanned source prefix, preserving concurrent later appends ``` Mixed buckets are split call-by-call; timing, tool name, current tab and "only chat generating" never participate. When correlation names an older `sessionId`, recorder searches that exact conversation lineage historically and **refuses to downgrade into a newer owner** just because it is easier to find. One proven outcome is deliberately terminal in that stream: `superseded` means the request id proved a conversation whose durable session attachment has already moved elsewhere. The proof is kept on the call, but repair must leave it isolated; exact historical identity is not current execution authority. Browser observations serialize **per conversation**, not globally. Closing a browser conversation also does not invent a turn ending: if durable metadata says a turn is still open, closure drops the live page mapping and leaves turn recovery to the mechanism that can actually prove its outcome. Reload recovery may synthesize a missing `turn_end` only against that durable open-turn ledger, never merely because a content document disappeared. The recovery proof is intentionally **state-based, not page-turn-id-based**. A reloaded page can replay old final messages carrying historical turn ids, so `recorder.ts` scans for the newest final assistant observation whose exact `turnId` is still present in the durable `openTurns` set and has no explicit end in the same batch. Only that `recoveredFinal` may append a synthetic `turn_end(completed)`. Other historical finals remain transcript backfill. **A completed end the page reported is withdrawn by the server turn calling on.** ChatGPT's request id is minted per server turn and outlives the page — reload, lost stream, Stop click. The recorder remembers the request ids an open turn called under and, when the page reports that turn `completed`, keeps them with the end. A call under one of those ids that *starts* after the reported end (`reopenFalselyEndedTurn`) proves the end was the page's, not ChatGPT's: the recorder appends an app-authored `turn_start` for the same id (with `detail`), takes the id out of `knownTurnEnds` so the real end is accepted later, publishes it as `activeTurnId` again, and the bridge retires whatever Goal was drafting for it (`retireGoalDraftsFor` + `forgetGoalWatch`). A call that started before the end is an in-flight call finishing late and proves nothing; a different request id is a different turn; only `completed` ends are reopened — a stop is the user's, the failure outcomes belong to recovery. The proof is process memory: an app restart inside such a turn leaves it closed as reported. Live 2026-09-02: a reload mid-turn closed the adopted turn after four seconds, the same request id called tools for twenty-four more minutes, and Goal typed the next message against an answer never given. For Goal, a newer stable final may also strengthen an earlier uncertain (`unknown|failed|interrupted|stalled`) turn boundary when its authored time is at/after that turn's durable start; that marks the canonical assistant message `goalEligible` **without fabricating another turn end**. Canonical message upsert keeps `goalEligible=true` monotonic so a later sparse replay/503 cannot retract the durable Goal obligation. **Tests.** `session.test.ts`, `chronology.test.ts`, `resume.test.ts`. ### The model-facing `session` tool is a projection, not the store `mcp/session-tool.ts` intentionally accepts an explicit `session_id`; it does **not** infer the caller's current chat. Cross-chat recovery and observing a concurrently running worker are core uses. The cursor is part of the data model: - an initial read pins the maximum sequence observed as a snapshot; - `older` and timeline continuation cursors keep that snapshot fixed, so later appends cannot reshuffle pages the model is already consuming; - an `update_cursor` advances from `after` and carries up to four unfinished assistant `{id, chars, hash}` checkpoints. If the same message only grows, the next page returns the suffix; if its prefix changed, it says **ASSISTANT REPLACED** so the caller knows to discard the unfinished version it already read; - `T…` tool-detail cursors pin sequence, offset and hash, so a changed detail fails as stale instead of splicing two different versions together; - calls to `session` are still recorded for audit, but the session tool hides those self-reads from its own projection/search. Otherwise polling an update cursor would create an endless transcript of previous polls. Result budgets reserve footer/cursor room **before** exact recorded text is appended. The final bound check throws rather than cutting a cursor or silently shortening a user/assistant message. **Retention is independent of recording admission.** `session/retention.ts` runs one prune at startup and then a coarse six-hour sweep using the current `retainDays`. Turning recording off does not exempt history already on disk from its retention policy. ## 13. The Chrome extension — `extension/*` `manifest.json` is the composition root. It installs `background.js` as the MV3 **module service worker**; injects `chatgpt-dom.js` then `content.js` plus `overlay.css` in the isolated world at `document_idle`; injects `fiber.js` separately in the **MAIN** world; and points the toolbar action at `popup.html`. Its only extension permissions are `storage`, `scripting` and `alarms`; host access is ChatGPT plus the loopback bridge ports 8765–8769. When debugging “which context even loaded this code?”, start here before reading runtime logic. Three execution contexts with **three different lifetimes**: | File | World / lifetime | Owns | | --- | --- | --- | | `chatgpt-dom.js` | isolated, document | every selector and DOM-shape assumption | | `content.js` | isolated, document | observation, turn lifecycle, Overwrite, compact UI | | `fiber.js` | **MAIN**, document | React/Fiber evidence the DOM does not reveal | | `background.js` | MV3 worker, **suspends freely** | bridge token, journal, tab↔conversation registry | Plus `chrome.storage.session` — survives worker sleep, dies with the browser session — and tab↔conversation binding, which follows tab lifetime and explicit navigation. The service worker has several intentionally different durability classes; do not collapse them into one "extension storage" bucket: - **`storage.local`:** pairing port/token + explicit disconnect intent, `deferredRevivals`, and restart-surviving command-ACK recovery material. These are facts that may still matter after the whole browser restarts. - **`storage.session`:** observation journal, close outbox, live tab/conversation/document state, and the command-ACK outbox used for MV3 worker suspension. These belong to the current browser session but must outlive a sleeping service worker. - **content-script memory:** one document only — epoch, observers, seen identities, paint state, command attempt state. Reload destroys it by design. `background.js::load()` is itself serialized by one `loading` promise. Cold-start races are normal for MV3: two tabs must never independently restore an old storage snapshot and let the later load overwrite an event the first caller already acknowledged. The observation journal is **bounded but loss-accounting**, not infinitely durable. Current caps are 4,000 rows and roughly 4 MiB inside the extension's `storage.session` budget. Under pressure the service worker first discards replaceable/nonessential progress, and if it must drop stronger evidence it inserts an explicit same-route gap record. If Chrome refuses the journal write even after compaction, the caller gets `durable:false` and a durability-gap `chat_error` is retained as far as storage allows. Likewise, a batch leaves the journal only after the app returns success; an irreducible malformed/oversize client batch becomes explicit gap evidence instead of a silent splice in history. "Accepted by the extension" therefore means **either the observation or an honest record of the gap remains under extension custody** — not that storage has infinite room. Irreversible command ACKs outrank ordinary transcript draining for the same conversation. While a send ACK is waiting in `commandAckOutbox`, `nextJournalBatch` will not let later observations from that route overtake it. Otherwise the app could record the post-send assistant turn before it knew the worker/resume user message had actually crossed its semantic boundary. `commandAckOutbox` is also deliberately **browser-restart durable**. `persistLive()` mirrors the bounded outbox to both `storage.session` and `storage.local`; `loadOnce()` prefers the local copy and uses the session copy only as a migration fallback. `ackCommand()` inserts/replaces the command-id row and awaits that persistence **before** trying the bridge network call. Therefore “ChatGPT accepted this irreversible send” survives content-script death, MV3 suspension and a whole browser restart; the retryable thing is only delivering the receipt to the app. Do not weaken this back to session-only storage just because most other live tab state belongs to the browser session. **`chatgpt-dom.js`** groups logical turns, extracts authored text, finds buttons/errors/tool rows, and strips CLF-owned surfaces before reading so rendered replacements do not feed back into recording. When ChatGPT changes markup, fix it here. **Never scatter emergency selectors into `content.js`.** **`content.js`** owns per-document memory: conversation epoch, seen-message identities, live turn state, Fiber cache, rendered replacement state, pre-service-worker queue. Conversation routing is not limited to `/c/`. `chatgpt-dom.js::conversationFromPath()` / `conversationId()` and `background.js::conversationFromUrl()` deliberately recognize the one supported Project shape `/g//c/` as the **same conversation id**. Every place that asks "which chat is this?" must use those shared route rules; teaching only the recorder about a Project URL while the service worker/recovery path still sees no conversation splits ownership. Fiber conversation ownership includes the mounted `props.conversation.id` shape, alongside the older direct conversation/client-thread fields. All observed IDs must agree. Helper completion still requires its exact acknowledged user, current scan, terminal assistant ID and bounded canonical raw text; missing identity is never replaced with rendered JSON. Queued desktop inputs follow the durable local session across Compact & Resume. The outbox publishes a superseded source ID only for still-queued input whose current session has moved. The service worker may transfer that election to an existing successor tab once, persisting its target conversation; it may not open a missing successor or replace a user-closed elected target. Tab retirement protects that current queued-input owner. Already handed-out browser claims continue protecting their exact original document until the send outcome is known. **A turn opens from authored-user evidence, not from the Stop button.** A newly observed stable ChatGPT **user** message opens one local generation and emits `turn_start`. On reload the content script may adopt the durable `activeTurnId` the app already knows, but must not emit a second start. Stop-button presence is downstream liveness evidence only: hydration/flicker/phase changes must never manufacture a user turn. Turn closing is deliberately asymmetric: - manual Stop is `stopped` and is never upgraded into success; - a visible error/interruption/stall keeps its exact non-success outcome; - when Fiber is healthy, the current response's model-backed terminal/end-turn evidence is the strongest completion authority; - Stop disappearing merely opens a settle window. If Stop returns, text/native activity changes, unanswered connector work remains, or the terminal message does not belong to this exact turn, completion is withdrawn; - the degraded no-Fiber rule — visible prose after the settle window means `completed` — applies only to a generation this document has seen running (`unwitnessedGeneration`). A turn adopted from the app on reload has no document-side evidence until the Stop control has been seen for it; until then only the page model, an error, a user stop, a new send or the stall budget may close it. Live 2026-09-02: the committed interim prose of a running turn closed the adopted turn four seconds after the reload; - a genuinely newer authored user message is a hard boundary for the previous turn, while page unload/`closeConversation` alone never invents its outcome. The concrete page clocks matter because they define what the recovery layer is allowed to infer. `TURN_SETTLE_MS = 4000`: Stop first disappearing starts that quiet window, Stop returning cancels it with the same local generation intact, and `unknown` **never** becomes terminal merely because four seconds elapsed. Manual Stop closes immediately. Independently, a Fiber descriptor whose exact active turn exposes `endMessageId`/`end_turn:true` is stronger than a stale Stop control and may close that exact local generation immediately; if the page outcome was otherwise still `unknown`, that model-backed terminal proof supplies `completed`. The visible `data-interrupted` marker alone is only an outcome candidate, not a terminal boundary, because ChatGPT transiently uses it between tool/reasoning phases. `STALL_MS = 10m` is likewise observation, not browser action. While ChatGPT still reports the turn as generating, ten minutes without visible progress emits one app-visible `chat_error` and lets `endOutcome()` classify a proven terminal boundary as `stalled`; that synthetic stall notice is not marked recoverable and `content.js` does **not** reload the page. Reload/open authority stays in the shared bridge→service-worker recovery machine. After any turn ends, `FIBER_SETTLE_MS = 90s` is only a ceiling for late request-id/owner evidence: the content script keeps scanning the just-settled turn while exact call ownership is incomplete and stops early as soon as the required Fiber evidence is complete. It is not a 90-second delay before turn completion and not another recovery timer. This is the conceptual reason **partial vs final** bugs are identity bugs. Commentary/progress, native tool rails and final assistant answer are different semantic objects even when the DOM renders them close together. Stable message/turn identity comes from ChatGPT's model/Fiber evidence where available; DOM shape is presentation/action fallback, not permission to merge those roles. **`fiber.js` is intentionally least trusted.** It emits a strict **allowlist** (not copied props minus a denylist), never tool argument values, validates the exact CLF connector names, and fails closed on unfamiliar React shapes. Its `postMessage` output is page-controlled evidence useful for joining page to local truth — **never a credential**. Its protocol version and the content-side expectations move together. **Overwrite owns activity ordering, not the assistant answer.** `content.js::renderStreams()` no longer rebuilds ChatGPT's answer subtree from captured HTML. ChatGPT remains the sole renderer for assistant prose, code/document blocks and response actions; the companion mounts one sibling stream containing only app-owned activity rows and hides only the native progress/tool rows that stream proves it fully replaces. `websiteRenderForTurn()` joins a visible Fiber turn to durable activity by exact ChatGPT message/thought/request identities — never by time, DOM position or a tail guess — and `completeReplacementForTurn()` requires every page-authored object the Fiber descriptor names to be represented before native activity is hidden. A connector call without request identity, a missing assistant/thought row, contradictory explicit turn ownership, or no usable Fiber descriptor leaves that turn native rather than showing a partial local reconstruction. Presentation identity is deliberately stricter than a remembered DOM attribute. React may reuse an assistant section for the next response, so `priorStreamRootCompatible()` keeps an old sibling root only when current stable **message/activity** ids overlap the root's stored strong ids; request ids are excluded because ChatGPT can reuse them across retries/turns. `hasUnrepresentedFiberCall()` also breaks the short replacement grace immediately when Fiber already exposes a new call the app stream cannot show. Otherwise a recently complete root may survive one brief feed/Fiber race to prevent visible native↔synthetic flicker. During active user scrolling the repaint is frozen, and the next pass restores a visible turn's viewport anchor after layout changes. These are presentation guards, not alternate identity or recorder fallbacks; durable session data is never rewritten to make the render fit. The `/activity` reader is also a small liveness scheduler, not a fixed poll. `content.js:: activityPullDelay()` chooses 750 ms for visible generation/Goal drafting/final-presentation debt, 2 s for other active work (and hidden generation), 10 s for visible idle and 30 s for hidden idle. `pendingPresentation` is one exact final assistant revision that already crossed the extension journal but has not yet returned through the app-owned activity projection; setting it calls `expediteActivityPull()`, and only the same final message id + exact text coming back clears it. This is why a hidden/background tab cannot drop to the slow cadence while Goal is drafting or while Overwrite still owes the user the final revision. Reuse `armNextActivityPull()` rather than adding another hidden-tab timer/watch loop. The service worker's document model is also **current implementation, not an aspirational one**. Today `background.js` still carries separate `tabDocuments`, `tabEpochs`, bounded `retiredDocuments` (last 8 per tab) and speculative `terminalDocuments`, all in `storage.session`. Authority begins with Chrome's `MessageSender.documentId`, never a body field. A retired sender is `stale_document`; the exact current document may advance `tabEpochs` but a lower epoch is `stale_navigation`; `ownsDocument()` additionally refuses retired/terminal senders. A different non-retired sender can be adopted by `authorizeDocument()` itself — `register_document` is **not** the sole replacement path in today's code — while `registerDocument()` is the one bypass-authorized bootstrap that can adopt fresh-reload provisional journal evidence before retiring the old current document. `onUpdated(status='loading')` marks the current document terminal speculatively and keeps the conversation binding only across a reload of the chat's **own** URL; a full load of any other ChatGPT URL (the root, another chat, a project page), a leave to another site, or tab removal releases the chat there and then (2026-09-03: typing chatgpt.com into a Prime's tab left it bound until some later chat was given an id there, so the app never heard the page was gone). An SPA move fires no `loading` status and stays the content script's to prove. A current terminal sender gets `tab_closed` unless `terminalPredictionWrong()` positively re-reads the same Chrome tab as ChatGPT, no longer loading, with no `pendingUrl`, and the sender still current; only then is the false terminal stamp cleared. Do not document or implement against a hypothetical collapsed owner record until code/tests move together. Irreversible browser actions always re-check this current document+epoch authority. **Extension reload recovery is health-based, not install-event-based.** An unpacked extension reload can leave the old isolated-world JavaScript globals alive while invalidating its `chrome.runtime`, so a boolean “content script already ran” marker is not liveness. `content.js` publishes a versioned `__CLF_CONTENT_RECORDER__` handle with `healthy()` + `stop()`: a healthy current-version incumbent wins, while a dead/stale predecessor is stopped and replaced. `background.js::restoreChatgptTab()` pings that handle; a healthy recorder still causes Fiber to be re-injected independently, while a missing/stale recorder gets `chatgpt-dom.js` → MAIN-world `fiber.js` → `content.js` → CSS rebuilt in that exact tab. `restoreOpenChatgptTabs()` runs both from install/update hooks **and unconditionally when the service worker module starts**, because `chrome://extensions` Reload is not guaranteed to deliver the install event. Service-worker wake alone therefore does one cheap health ping per open ChatGPT tab and injects only when health/version proves repair is needed. **Must hold.** ChatGPT is an SPA: every async result proves it still belongs to its navigation epoch before mutating state. `pagehide` is **not** proof a conversation ended — reload and bfcache fire it too; real closure is decided at the service-worker layer from tab removal and navigation away. **Reload is not conversation close.** Content-script acceptance means *handed to the journal*, not *stored by the app*, and the journal must never silently lose something it already acknowledged as durable. Recovery must validate **every** context whose health it needs — proving the isolated recorder is alive says nothing about a dead MAIN-world Fiber helper. Recorder takeover is total ownership transfer: the predecessor must disconnect MutationObservers and DOM/window handlers **and** unregister extension-level `chrome.runtime.onMessage` / `chrome.storage.onChanged` listeners. An `alive=false` predecessor must never answer a health check, compete for a worker-revival command, or repaint Overwrite after the successor owns the document. **No wait in `content.js` may depend on the tab being in front:** Chrome runs a hidden tab's chained timers once a minute after five minutes hidden, so every periodic loop and every `sleep()` goes through `later()`, whose MessageChannel hop keeps the timer chain at level one; a new `setTimeout`/`setInterval` loop in the recorder is a regression. **Tests.** `content-script.test.ts`, `fiber.test.ts`, `extension.test.ts`. ## 14. The browser bridge — `bridge.ts` Worker/resume markers have independent durable claims and receipts. A leased resume cannot block an unrelated worker invitation. Only never-handed commands enter the opener; expiry never turns a spent lease back into opening authority. Shared command-ledger snapshots commit serially, including claim renewal and final receipts. A bound worker's leased transport remains until its receipt or deadline settles it; sibling maintenance cannot retire it during ACK persistence. A second loopback HTTP service on the first free port of **8765–8769**. The extension finds it with `/hello`, silently provisions a bearer token with `/pair`, then uses authenticated routes: `/status`, `/correlations`, `/events`, `/closed`, `/activity`, `/compact`, `/goal/draft`, `/goal/ack`, `/goal/objective`, `/goal/open`, `/settings` (GET and POST), `/commands/redeem`, `/commands/ack`. `/settings` is the one deliberately tiny config-write surface available to the page: its POST accepts the flat boolean `autoCompact`, optionally one flat `goal` **or** `loop` boolean (never both), plus optional `conversationId` for chat scoping. `goal.ts::applyGoalSwitch()` is the pure pair reducer, not a bridge-owned policy function. With a concrete `conversationId`, the bridge persists the result via `goal.ts::setGoalSwitchNow()` into that chat's `goal-switches` row; only a New Chat with no concrete id changes the app-wide `config.goal` default. Goal and Loop are therefore mutually exclusive by construction, not two independent feature flags, and a page-local stop does not silently rewrite every other chat. `/goal/objective` and `/goal/open` accept the same two words as an optional `mode`, which is the *other* way that per-chat switch is written — see "A chat's own goal". None grants filesystem/process/Desktop reach. GET exists for the one composer with no conversation to read `/activity` for: a New Chat. **Must hold.** The token never enters the ChatGPT page — the service worker holds it in extension-owned state and the app keeps its counterpart out of config and log surfaces. The bridge exposes **no** filesystem, command, permission-widening or arbitrary-config route. Do not turn `/settings` into a generic config escape hatch merely because two non-capability toggles are already there. Protocol mismatch against `BRIDGE_PROTOCOL` warns once rather than spamming. Concurrent startup must not race on listener ownership. Because this is where browser-observed lifecycle meets recorder, agents, continuation and workspace state, a `bridge.ts` bug presents as a session, extension, or agent bug depending on which end you inspect. POST **`/events` is the cross-subsystem commit coordinator**, not merely “write these page rows”. The extension journal treats HTTP 200 as permission to retire that batch, so every consequence that would be unrecoverable after the row disappears must cross its own durable boundary first. Current order is load-bearing: ```text exact (agent, commandId) lost-worker-ACK recovery if present → exact page / turn_start liveness back into agents.ts → reconstruct worker origin from durable broker ownership when needed → recorder.ts::recordChatObservations() (per-conversation serialized journal/message writes) → acceptGoalReplyNow() for every stable goalEligible final ← durable before 200 → noteRecoveryObservations() + activity/terminal deadlines → update worker context-token ceiling from the durable session → if a worker final is now proven across journal batches: stageWorkerConversationFinish() → persistCriticalSwarmNow() ← durable before 200 → commit report / wake queued sleeper / release run → 200; only now may background.js drop the observation batch ``` A storage/broker failure at either Goal or worker-final barrier returns retryable 503, preserving the browser-owned journal row for replay. This is why worker completion, Goal exactly-once and broken-page recovery can all consume the same observation without inventing three independent receipt systems. Browser recovery now has **one per-conversation queue** in `bridge.ts::queueBrowserRecovery()`; silence, recoverable assistant transport errors, missing mid-turn tabs and broken request-id joins converge there instead of each owning a reload/open loop. The queued record is fenced by a stable episode + receipt token and moves `queued -> handed -> done`; only a confirmed browser action is "done", and meaningful new activity or turn completion retires only the repair kinds for which that fact is actually authoritative. **An adopted turn is bound only to a section below its question.** `seedResumeBaseline()` treats the newest assistant section as the resumed turn's own only when it follows the newest user message; a section above the question is a previous, finished answer, and binding the adopted turn to it let that answer's end-turn bit close the live turn nine seconds after every reload of the 2026-09-03 prime. With no section after the question the turn has no page evidence yet and stays open. The `unwitnessedGeneration` flag likewise clears only when Stop is seen over a section `generationTurn()` can bind, not on hydration's one-second Stop over an empty transcript. **The page's word that a turn ended is not enough to write the next Goal message on.** A reloaded page reads the transcript's last `end_turn` bit as a finished answer while the same request is still calling tools, and a "Message delivery timed out" error closes the local turn the same way (2026-09-03: the loop drafted twenty seconds after such an end and the chat ran two requests at once). `/goal/draft` therefore files the obligation but refuses the draft with `chat_still_working` while the recorder still has the chat's turn open (`chatIsWorking()` — a reopened turn stays open until the page reports a real end, and the 2026-09-03 banner reported none), while `runningToolCalls()` is non-zero, or while the chat's last attributed call is under `GOAL_QUIET_MS` (one minute) old. The page keeps its claim and asks again every `GOAL_RETRY_MS` on the plain wait, with no backoff, until the app says the chat has finished. Only the silence ticket (`g-silence-*`) is drafted over an open turn: it is the app's own finding that the answer is over. A call that proves the ended turn is still running (`recorder.ts::reopenFalselyEndedTurn`) withdraws the obligation outright via `retireGoalDraftsFor()`. The silence path is the other half: a Goal/Loop chat gets its silence reload, then `GOAL_SILENCE_LISTEN_MS` (one minute) of listening, and only a minute with no tool call, no interim row, no message files the `g-silence-*` ticket; any of those re-arms the two-minute clock so a chat that stops again later gets the same reload and minute again. **A reload the page has not come back from is not answered with another.** A 300k-token chat takes minutes to load — three, for the 2026-09-03 prime — and `inspectSilentChats()` used to reload it again 23 seconds after an unattributed reload had, restarting the load. A chat in `awaitingReturn` (set by `confirmRepair()`, cleared by the next `grantActivity()`) keeps its silence deadline pushed to `lastBrowserRecoveryAt + BROWSER_RECOVERY_COOLDOWN_MS`; only a page that never returns is reloaded when that runs out. **The DOM layer reads each transcript section once.** `chatgpt-dom.js` keeps a per-section memo (`sectionCache`: rows, markdown parts, tool blocks, progress boxes, `interrupted`) that its own MutationObserver drops on any subtree, text or relevant attribute change, draining pending records synchronously (`takeRecords()`) before every read. Before it, the one-second tick walked the whole transcript six to eight times and took every message's text each time — most of a second of main thread per second on a 300-turn chat, and the freeze behind the 2026-09-03 prime. Never read message text or tool rows around the cache; add a field to the memo instead. An ordinary open semantic turn grants its **conversation** a two-minute silence deadline in `activeUntil`. Known Pro models instead use ten minutes from meaningful work/tool-start evidence. A late exact picker or MCP identity promotes an unknown grant without resetting its evidence timestamp. A real turn-end removes activity immediately; a genuinely newer exact MCP call can revive it. Pro never receives an inactivity-generated Goal or automatic compaction. Picker presence alone is not work. `grantActivity()` arms/pushes it from accepted current-turn evidence and attributed calls; `endActivity()` removes it only on a real terminal. `armSilenceSweep()` owns one timer for the earliest deadline across all chats, so a 30-second maintenance tick cannot silently add another half-minute to the contract. Expiry queues one receipt-tracked browser recovery for an ordinary chat, Prime or Worker alike; a confirmed one-shot repair is not repeated for the same episode. **A resumed chat is armed at the commit** (`armResumedChat()`, called from both commit sites — the `/compact` destination-marker route and the `/commands/ack` resume receipt): the moment S names B, B gets the same grant an accepted turn would have earned. The 2026-09-02 automatic handover showed why. B never reported its turn or bound its first request id, so its calls went Unattributed and B, with no grant and no known turn, was not a repair candidate — the incident had nothing to reload and B stayed stuck at its first tool call. With the grant, the silence and unattributed sweeps cover B from the first second, exactly as they would a chat that had proved itself. Unattributed recovery is separate evidence feeding the same queue. One recorder verdict that a call is **Unattributed** opens a 60-second `unattributedIncident`; later attributed calls add their exact conversations to the incident's `proven` set. At expiry, `repairUnattributedChat()` looks only at chats this app can still prove are mid-turn (`repairCandidates()`), excludes those that proved their join, and queues one recovery for each remaining broken chat. It does not choose "the one likely chat", and agent role is not an eligibility gate. The incident carries the **request ids** of the unattributed calls that opened and fed it; once it has reloaded anybody those ids go into `unattributedReloadedRequests`, and a later unattributed call under one of them opens nothing — the reload is tried once per server turn, however the reloaded page re-labels its local turn, and a *different* request id (the user's next message from the phone, say) is a different turn with its own reload. Any `chat_error` the page shows queues an `assistant-error` repair — whatever the DOM classifier said about it and whether or not the page could name its turn — even while attributed MCP calls continue server-side. A final-tab close may queue `no-tab` when `tabRecoveryWanted()` holds (Goal/Loop chat, or `recoverAgentTabs` on) and this app is owed a running turn in that chat. "Owed" is the app's own fact, read in `/closed` before the close forgets it: the page's open turn **or** a live `activeUntil` grant (an attributed call or current-turn observation inside the silence window), or a Worker slot the close itself detached — a document being torn down has no Stop control and reports "completed" whatever the server is doing (2026-09-03: worker-1 said so one second before its `/closed` while its request id went on calling tools). A Prime or plain chat needs the working fact; a plain chat also needs durable `toolCalls>0`; a sleeping worker's tab closing is not an event. `queueMissingTab()` logs every verdict, reopen or not, with its reason. Silence reads the same `tabRecoveryWanted()` predicate; a silent chat it refuses is spent, not reloaded. The extension owns the final **reload existing exact tab vs open exact conversation** choice at action time, so stale app-side tab guesses cannot create a duplicate. `assistant-error` starts even earlier at the selector boundary: `chatgpt-dom.js::errors()` records visible ChatGPT errors, but only a **whole normalized notice** matching `transportFailure()` receives `recoverable:true`; ordinary assistant prose that merely quotes those words is not an error. Hidden live-region noise and CLF-owned surfaces are ignored, while a generic visible alert may still enter the transcript as evidence without granting browser-repair authority. A visible failure card may carry no alert role or assistant markdown; the exact Retry button is then the semantic anchor, and the DOM reader climbs only to the nearest ancestor whose **whole** normalized text matches that same transport-failure vocabulary. It never searches arbitrary page prose for those words. When a transport notice lives inside an assistant turn, `content.js` may put it in recorder chronology only after exact section-node ownership proves which **local generation id** owns it; a reused ChatGPT page turn id is never enough. A top-level banner, which has no page-turn identity of its own, uses the local generation in which its node first appeared. If an in-turn occurrence cannot prove that mapping, it is recorded **unscoped**: ChatGPT's raw page turn id never enters `SessionEvent.turnId`. Neither `recoverable` nor the turn id gates the reload any more: `bridge.ts::noteRecoveryObservations()` queues `assistant-error` for every `chat_error` observation (the page's own de-duplication in `unreportedError()` is what keeps a banner still on screen from re-queueing), and what rations it is the **once-per-user-turn budget** below. The five recovery reasons are intentionally different **evidence**, but not different action machines: | Reason | Who is allowed to create it | What proves the episode over | | --- | --- | --- | | `silence` | `inspectSilentChats()` after the semantic-turn `activeUntil` deadline expires — the only qualification is two minutes with no tool call and no page change on a chat that had activity | meaningful new current-turn activity before handoff cancels it; a confirmed reload re-grants the chat `CHAT_SILENCE_MS` (the reload is its chance; a model writing a long answer makes no durable progress until it lands) or, for a Goal/Loop chat, `GOAL_SILENCE_LISTEN_MS` (one minute), and only a second silent window after that spends the one-shot: the stale sweep sleeps a worker, and `fileSilenceGoalTickets()` files a **Goal ticket** for a Goal/Loop chat — the same durable `goal-replies` obligation a finished answer files, under a `g-silence-