From 8e4f01ea6d6dbd602b307449b4671b9dc226f93e Mon Sep 17 00:00:00 2001 From: Peetu Kupiainen Date: Wed, 4 Nov 2026 02:34:57 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(cell):=20make=20the=20cell=20provider?= =?UTF-8?q?=20work=20and=20report=20clearly=20against=20the=20login-era=20?= =?UTF-8?q?cell=20=E2=80=94=20a=20refused=20token=20ends=20the=20session's?= =?UTF-8?q?=20reading,=20judged=20for=20every=20answer=20in=20the=20one=20?= =?UTF-8?q?request=20function=20and=20the=20socket's=20close=20under=20the?= =?UTF-8?q?=20event=20permit,=20without=20a=20reconnect=20loop;=20the=20te?= =?UTF-8?q?nant's=20allowance=20as=20the=20usage=20limit=20with=20its=20re?= =?UTF-8?q?set=20time,=20the=20runs=20bound=20and=20run=20bounds=20as=20re?= =?UTF-8?q?adable=20failures,=20and=20each=20run's=20usage=20as=20the=20tu?= =?UTF-8?q?rn's=20token=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-applies 8e98fbf729, 2cc5586e40 and 823a00de5b (PR #5 rounds 1-3) on the upstream sync dd2d0849fb, inside its Effect 4.1 / Effect.fn adapter and under upstream's lint rules. A Stop's cancel refines #6's rule: 401 is access loss, any other 4xx a refusal (403 with the cell's reason), every 5xx unconfirmed. One notice-node emission (#6's); the live revoke check requires no socket after the 4401; the allowance check requires the prompt-time usage_limit that vim main sends since vim #53. Co-Authored-By: Claude Opus 5.5 --- apps/server/scripts/live-cell-api.mjs | 11 +- apps/server/scripts/live-cell-driver.ts | 118 +++- apps/server/scripts/live-cell-probe.ts | 53 +- docs/internals/provider-cell.md | 223 +++++- .../provider-cell/src/server/adapter.test.ts | 637 +++++++++++++++++- packages/provider-cell/src/server/adapter.ts | 403 ++++++++--- packages/provider-cell/src/server/errors.ts | 75 +++ .../provider-cell/src/server/protocol.test.ts | 41 +- packages/provider-cell/src/server/protocol.ts | 38 +- packages/provider-cell/src/server/status.ts | 27 +- .../provider-cell/src/server/transport.ts | 22 +- .../src/testing/fakeCellServer.ts | 130 +++- .../src/testing/replayTransport.ts | 7 +- 13 files changed, 1624 insertions(+), 161 deletions(-) diff --git a/apps/server/scripts/live-cell-api.mjs b/apps/server/scripts/live-cell-api.mjs index 79ab6d9e43d94..806d2f5d0a93b 100644 --- a/apps/server/scripts/live-cell-api.mjs +++ b/apps/server/scripts/live-cell-api.mjs @@ -1,7 +1,7 @@ // A scripted OpenAI Responses API for the live cell run: point a cell's OPENAI_BASE_URL at it. // node scripts/live-cell-api.mjs [port=9815] [holdMs=20000] (CHILD_DELAY_MS=30000 for delete-delegate) // The latest user message picks the answer; the prompt table is in docs/internals/provider-cell.md (Testing). -// Every request is logged with its step (function calls since the prompt) and prompt. +// Every request is logged with its step and prompt; every answer reports usage (bytes / 4). import * as NodeHttp from "node:http"; const port = Number(process.argv[2] ?? 9815); @@ -58,7 +58,14 @@ NodeHttp.createServer((req, res) => { .find((item) => item.type === "message" && item.role === "assistant"); const previousText = previous?.content?.find?.((part) => part.type === "output_text")?.text ?? ""; - const ok = (output) => json(200, { id: `resp_${n}`, status: "completed", output }); + const usage = (output) => ({ + input_tokens: Math.ceil(data.length / 4), + input_tokens_details: { cached_tokens: 0 }, + output_tokens: Math.ceil(JSON.stringify(output).length / 4), + output_tokens_details: { reasoning_tokens: 0 }, + }); + const ok = (output) => + json(200, { id: `resp_${n}`, status: "completed", output, usage: usage(output) }); console.log( `${stamp()} [api] #${n} step ${step} model=${body.model} prompt=${JSON.stringify(prompt.split("\n")[0].slice(0, 100))}`, ); diff --git a/apps/server/scripts/live-cell-driver.ts b/apps/server/scripts/live-cell-driver.ts index 53d36065c49bd..f60d53206037e 100644 --- a/apps/server/scripts/live-cell-driver.ts +++ b/apps/server/scripts/live-cell-driver.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off globalDateInEffect:off globalConsole:off preferSchemaOverJson:off globalFetchInEffect:off - a live driver: it runs the real orchestrator and adapter against a cell on celld, logs on the wall clock, and bounds its own reads of the cell with an AbortSignal. +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off globalDateInEffect:off globalConsole:off preferSchemaOverJson:off globalFetchInEffect:off globalFetch:off - a live driver: it runs the real orchestrator and adapter against a cell on celld, logs on the wall clock, bounds its own reads of the cell with an AbortSignal, and posts the operator's request and other projects' prompts itself. /** * Drives the real orchestrator and cell adapter against a live cell for the scenarios the recorder * cannot express, logging every HTTP exchange and socket frame with a wall-clock stamp. @@ -101,6 +101,11 @@ const PROMPTS = { reconnect: "Hold: Respond with exactly: reconnect ok", restart: "Hold: Respond with exactly: RESUMED", attachRetry: "Respond with exactly: attach retry ok", + access: "Respond with exactly: access ok", + revoke: + "Do not answer immediately. First run the local shell command `sleep 35`, then respond with exactly: revoked run should not finish.", + afterRevoke: "Respond with exactly: after the revocation", + holder: "Hold: Respond with exactly: holder", } as const; /** The adapter runs on the test clock; the cell on the wall clock. */ @@ -122,9 +127,11 @@ const makeWebSocket: Socket.WebSocketConstructor["Service"] = (url, options) => const ordinal = socketOrdinal; latestSocket = socket; socket.on("open", () => log(`ws#${ordinal} open ${url}`)); - socket.on("close", (code, reason) => - log(`ws#${ordinal} closed code=${code} reason=${String(reason)}`), - ); + socket.on("close", (code, reason) => { + log(`ws#${ordinal} closed code=${code} reason=${String(reason)}`); + if (code === 4401 && socketsAfterClose === undefined) socketsAfterClose = 0; + }); + if (socketsAfterClose !== undefined) socketsAfterClose += 1; socket.on("error", (error) => log(`ws#${ordinal} error ${error.message}`)); return socket as unknown as globalThis.WebSocket; }; @@ -157,6 +164,7 @@ const loggingTransport = (live: CellTransport): CellTransport => ({ log( `>> ${input.method} ${input.url} headers=${JSON.stringify(redact(input.headers))}${input.body === undefined ? "" : ` body=${JSON.stringify(input.body)}`}`, ); + if (input.method === "POST" && input.url.endsWith("/prompt")) promptsPosted += 1; const isTaskCancel = input.method === "POST" && /\/tasks\/[^/]+\/cancel$/.test(input.url); if (isTaskCancel) onTaskCancelSent?.({ url: input.url, headers: input.headers }); const response = yield* live.request(input).pipe( @@ -227,6 +235,30 @@ const loggingTransport = (live: CellTransport): CellTransport => ({ }), }); +/** The login-era scenarios check what the thread shows; each exits non-zero on a miss. */ +const errorsOf = (projection: OrchestrationV2ThreadProjection | undefined) => + (projection?.turnItems ?? []).flatMap((item) => (item.type === "error" ? [item.failure] : [])); +const check = (ok: boolean, what: string) => + ok + ? Effect.sync(() => log(`${scenarioName}: check passed: ${what}`)) + : Effect.die(new Error(`${scenarioName}: check failed: ${what}`)); +/** A POST of the driver's own, beside the adapter: the operator's request, or another project's prompt. */ +const postNow = async (path: string, body: string, headers: Record) => { + const response = await fetch(`${baseUrl}${path}`, { method: "POST", headers, body }); + const text = await response.text(); + log(`driver POST ${path} -> ${response.status} ${text.slice(0, 300)}`); + return response.status; +}; +const post = (path: string, body: string, headers: Record) => + Effect.promise(() => postNow(path, body, headers)); +const operateNow = (token: string) => + postNow("/login/operator", new URLSearchParams({ token }).toString(), { + "content-type": "application/x-www-form-urlencoded", + }); +const operate = (token: string) => Effect.promise(() => operateNow(token)); +let promptsPosted = 0; +let socketsAfterClose: number | undefined; + const describeProjection = (projection: OrchestrationV2ThreadProjection) => [ ...projection.runs.map( @@ -327,6 +359,17 @@ const stepsFor = (name: string): ReadonlyArray => return [{ type: "message", text: PROMPTS.attachRetry }]; case "delete-delegate": return [{ type: "message", text: PROMPTS.delegate }]; + case "access": + case "runs-bound": + return [{ type: "message", text: PROMPTS.access }]; + // Two model requests, so an allowance that covers the first runs out at the second. + case "allowance": + return [{ type: "message", text: PROMPTS.shell }]; + case "revoke": + return [ + { type: "message", text: PROMPTS.revoke }, + { type: "message", text: PROMPTS.afterRevoke }, + ]; default: throw new Error(`Unknown scenario ${name}`); } @@ -378,6 +421,27 @@ const main = Effect.gen(function* () { }; } + const authorization: Record = settings.token + ? { authorization: `Bearer ${settings.token}` } + : {}; + if (scenarioName === "revoke") { + const suspend = readArgValue("--suspend"); + if (suspend === undefined) + return yield* Effect.die(new Error("revoke needs --suspend ")); + onPromptAccepted = () => { + onPromptAccepted = undefined; + log("revoke: prompt accepted; the operator suspends the tenant"); + void operateNow(suspend); + }; + } + const holder = `${runTag.replace(/[^A-Za-z0-9._-]/g, "-")}-holder`.slice(0, 60); + if (scenarioName === "runs-bound") { + yield* post(`/cell/${holder}/prompt`, PROMPTS.holder, { + ...authorization, + "x-request-id": `holder-${runTag}`, + }); + } + if (scenarioName === "delete-delegate") { const workspace = yield* checkpointWorkspace("live-delete-delegate"); const threadId = materialized.projectionThreadIds[0]!; @@ -536,6 +600,52 @@ const main = Effect.gen(function* () { ); log(`attach-retry: check passed: ${socketOrdinal} sockets, the prompt completed`); } + const [projection] = result.projections.values(); + const failures = errorsOf(projection); + const first = failures[0]; + if (scenarioName === "access") { + yield* check( + projection?.runs[0]?.status === "failed" && + /Settings → Providers|not serving requests|refused T3's request/.test( + first?.message ?? "", + ), + `the run failed with the tenant's refusal and what to do: ${first?.message}`, + ); + yield* check(promptsPosted === 0, `no prompt was posted (${promptsPosted})`); + } + if (scenarioName === "revoke") { + const resume = readArgValue("--resume"); + yield* check( + projection?.runs[0]?.status === "failed" && + first?.code === "cell_unauthorized" && + /suspended/.test(first.message), + `the run failed with the refusal naming the suspension: ${first?.message}`, + ); + yield* check( + projection?.runs[1]?.status === "failed" && /Access token/.test(failures[1]?.message ?? ""), + `the next message failed at once with the refusal: ${failures[1]?.message}`, + ); + yield* check( + socketsAfterClose === 0, + `no socket opened after the 4401: ${socketsAfterClose ?? "no 4401 seen"}`, + ); + if (resume !== undefined) yield* operate(resume); + } + if (scenarioName === "allowance") { + // vim's cell names the usage limit and its reset time on a prompt's 409 since vim #53. + yield* check( + first?.class === "usage_limit" && typeof first.resetAt === "string" && first.resetAt !== "", + `the run failed as the usage limit with its reset time: ${JSON.stringify(first)}`, + ); + } + if (scenarioName === "runs-bound") { + yield* post(`/cell/${holder}/cancel`, "", authorization); + yield* check( + projection?.runs[0]?.status === "failed" && + /send the prompt again/.test(first?.message ?? ""), + `the run failed saying to send the prompt again: ${first?.message}`, + ); + } return; } diff --git a/apps/server/scripts/live-cell-probe.ts b/apps/server/scripts/live-cell-probe.ts index cc7400e8944b1..96600baa2d53d 100644 --- a/apps/server/scripts/live-cell-probe.ts +++ b/apps/server/scripts/live-cell-probe.ts @@ -1,9 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalConsole:off globalConsoleInEffect:off preferSchemaOverJson:off - a live probe driver that prints what it sees. /** - * Runs the cell provider's status probe (the one Settings → Providers shows) - * against a live tenant and against stand-ins for an old-protocol tenant, a - * protocol-2 tenant, a non-cell HTTP server and a closed port. - * node scripts/live-cell-probe.ts --url http://127.0.0.1:9884 + * Runs the cell provider's status probe against a live tenant (with --token, and without one) and + * stand-ins for old protocols, a non-cell server, a closed port, a 401 and a failing-closed 503. + * node scripts/live-cell-probe.ts --url http://127.0.0.1:9884 [--token t] */ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -15,18 +14,28 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as NodeHttp from "node:http"; -const liveUrl = process.argv[process.argv.indexOf("--url") + 1] ?? "http://127.0.0.1:9884"; +const argValue = (name: string) => { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +}; +const liveUrl = argValue("--url") ?? "http://127.0.0.1:9884"; +const liveToken = argValue("--token") ?? ""; const decode = Schema.decodeUnknownEffect(CellSettings); function serve( - answer: (path: string) => { status: number; type: string; body: string }, + answer: (path: string) => { + status: number; + type: string; + body: string; + headers?: Record; + }, ): Promise<{ url: string; close: () => void }> { return new Promise((resolve) => { const requests: Array = []; const server = NodeHttp.createServer((req, res) => { requests.push(`${req.method} ${req.url}`); - const { status, type, body } = answer(req.url ?? ""); - res.writeHead(status, { "content-type": type }); + const { status, type, body, headers } = answer(req.url ?? ""); + res.writeHead(status, { "content-type": type, ...headers }); res.end(body); }); server.listen(0, "127.0.0.1", () => { @@ -60,17 +69,19 @@ const main = Effect.gen(function* () { const probe = Effect.fn("LiveCellProbe.probe")(function* ( label: string, baseUrl: string, + token = "", projectPrefix = "t3-", ) { - const settings = yield* decode({ enabled: true, baseUrl, token: "", projectPrefix }); + const settings = yield* decode({ enabled: true, baseUrl, token, projectPrefix }); const result = yield* checkCellProviderStatus(settings, transport); console.log(`${label}: ${baseUrl}`); console.log( ` status=${result.status} installed=${result.installed} version=${JSON.stringify(result.version)} models=${JSON.stringify(result.models.map((m) => m.slug))}`, ); - console.log(` message=${JSON.stringify(result.message)}`); + console.log(` auth=${result.auth.status} message=${JSON.stringify(result.message)}`); }); - yield* probe("live cell", liveUrl); + yield* probe(liveToken ? "live cell with the token" : "live cell", liveUrl, liveToken); + if (liveToken) yield* probe("live cell without a token", liveUrl); const legacy = yield* Effect.promise(() => serve(() => ({ status: 200, type: "application/json", body: oldState({}) })), ); @@ -100,6 +111,26 @@ const main = Effect.gen(function* () { ); yield* probe("protocol-3 tenant without an API key", unconfigured.url); unconfigured.close(); + const refusing = yield* Effect.promise(() => + serve(() => ({ + status: 401, + type: "text/plain; charset=utf-8", + headers: { "www-authenticate": 'Bearer realm="acme"' }, + body: "Unauthorized: the session was reset by a new invite code; log in at /login, or send the token of POST /login/token as Authorization: Bearer", + })), + ); + yield* probe("tenant refusing a stale token (401)", refusing.url, "stale-token"); + refusing.close(); + const failingClosed = yield* Effect.promise(() => + serve(() => ({ + status: 503, + type: "text/plain; charset=utf-8", + headers: { "retry-after": "60" }, + body: "Service unavailable: SESSION_SECRET is not set, so no request to a cell can be checked for a login; set SESSION_SECRET and INVITE_SECRET as secrets of this deployment (docs/cell-edition.md, Login)", + })), + ); + yield* probe("tenant with a base domain and no SESSION_SECRET (503)", failingClosed.url); + failingClosed.close(); yield* probe("closed port", "http://127.0.0.1:9"); }); diff --git a/docs/internals/provider-cell.md b/docs/internals/provider-cell.md index 98b3f0eeb0929..56bb2af6a8bd9 100644 --- a/docs/internals/provider-cell.md +++ b/docs/internals/provider-cell.md @@ -20,13 +20,16 @@ the cell as the whole agent plus the whole workspace, and T3's local project directory as nothing more than the thread's home in the sidebar. - **Instance.** A provider instance holds the tenant's base URL - (`https://.vim.app`), an optional bearer token, and the prefix for - project names. Two tenants are two instances. There is no default instance, + (`https://.vim.app`), a bearer token, and the prefix for project + names. Two tenants are two instances. There is no default instance, because an instance without a base URL cannot do anything, so the user adds one in Settings → Providers. The token is stored as a settings secret and sent as `authorization: Bearer ` on every request, including the - WebSocket handshake. celld has no authentication today, so the field is - optional; the adapter is ready for the day it is required. + WebSocket handshake. A deployed tenant has its login on and refuses every + request without a token; a cell under `celld dev` without the login's + secrets needs none, so the field may stay empty there. How to make a token, + and what T3 shows when the tenant refuses it, is under + [The login](#the-login). - **Thread.** One T3 thread is one cell project. The project name is the instance prefix plus 20 hex characters of a SHA-256 over the whole thread id: opaque on the tenant, fixed in width so child cells keep room for their @@ -140,7 +143,11 @@ shapes. What level 3 guarantees, and what the adapter builds on: is `{ runId, error }`: a different request id while a run is in progress answers 409 with `runId` naming that run; an empty prompt, a tenant without a model API key and a cell busy with a fork or a reset answer 409 with - `runId: null` and the reason in `error`. + `runId: null` and the reason in `error`. A refusal may name its kind as a + `done` error does, `code: "usage_limit"` with `resetAt` for a tenant whose + daily model allowance is spent; the adapter reports such a refusal as the + usage limit with its reset time (see [The login](#the-login) for what the + cell sends today). - **A cancel names its run.** `POST cancel` takes an optional `x-run-id` header. When the run in progress is another one, the cell answers 409 `{ runId }` naming it and cancels nothing; with no run in progress it @@ -169,7 +176,13 @@ shapes. What level 3 guarantees, and what the adapter builds on: - **`done` is structured.** A failed run's `done` carries `{ code, status?, message, resetAt? }` with `code` one of `cancelled`, `usage_limit`, `provider_error`, `transport_error` or `tool_error`; a - successful run's `done` carries no error. + successful run's `done` carries no error. Either carries `usage`, what the + run used: `{ requests, inputTokens, cachedTokens, outputTokens, + reasoningTokens }`. The adapter reports it as the turn's token usage + (`turnTokenUsage`: complete for a run that completed, partial otherwise, + the input counting cached reads), and reads it leniently: a `usage` that is + not all non-negative integers reports nothing and leaves the `done` as + readable as before, since a count is reporting, not protocol. `GET state` is read once per session when the thread is attached, which is where the level is checked, the tenant's model learned and a misnaming @@ -263,8 +276,10 @@ confirms with a cancelled `done`, and the adapter waits for that before reporting the turn interrupted. Both stay inside the session manager's 15 s bound on an interrupt: the cancel has 5 s (less if the adapter's request timeout is shorter), the confirmation 8 s. Only a 4xx answer refuses the -cancel: 409 is read as below, any other 4xx is reported as -`ProviderAdapterInterruptError` and the turn goes on. Everything else is +cancel: 409 is read as below, 401 is access loss (below: the turn ends with +the refusal and Stop has nothing left to do), any other 4xx (a 403 with the +cell's reason) is reported as `ProviderAdapterInterruptError` and the turn +goes on. Everything else is unconfirmed — no answer within the bound, a transport failure, any 5xx (the cell's own or a gateway's: neither says whether the run was cancelled), or an accepted cancel with no `done` in time. Then Stop fails with @@ -362,11 +377,96 @@ validation error. The status probe reads `GET state` of a dedicated probe project (`probe`) and never posts a prompt, so a background refresh cannot start a run, spend tokens, or create work. The probe creates one empty Durable Object per tenant; that is the cost of a probe without a dedicated -health endpoint. `configured: false` is a warning (the tenant has no model API +health endpoint. The probe sends the instance's token, so a tenant with the +login on answers it as it answers the threads: a 401, 403 or 503 is an error +with the message described under [The login](#the-login) (auth +`unauthenticated` for a 401), and a state read with a token reports auth +`authenticated`. `configured: false` is a warning (the tenant has no model API key); an unreachable origin or a non-JSON answer is an error. There is no version advisory beyond the manifest row: the cell reports its own `version`, and nothing on the environment updates it. +## The login + +A deployed tenant runs with its login on (vim.app's `docs/deploy.md`, stage +2): every request to a cell needs a session of the tenant, a browser's +cookie or a tool's API token. T3 is a tool. Connecting it to a deployed cell: + +1. The base URL is the tenant's origin, `https://.`; + the apex, `https://`, is the tenant `default`. +2. Log in at `/login` with the tenant's invite code and a + passphrase the first time, the passphrase after that. +3. Choose **Create an API token** on the page that follows. The page shows + the token once. Only a browser session makes a token; a token cannot make + another. +4. In T3, Settings → Providers → the cell instance: the base URL, and the + token in **Access token**. Changing the settings rebuilds the instance, so + the next message opens a session with the new token. + +A token is a session of 90 days. It ends earlier when the tenant's +passphrase is reset with a new invite code and when the operator suspends the +tenant; a resume lets the tenant log in again, but the sessions from before, +tokens included, stay ended. A token that has ended never holds again, so +every refusal of it says the same thing: make a new one. + +What the tenant answers, and what T3 shows: + +| The tenant answers | T3 shows | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 401 to any request, no token or one that ended | The status probe: an error, auth unauthenticated. A thread: the attach fails, nothing is posted. Either message names the tenant, says the token is missing or was refused, gives the steps above, and quotes the cell's reason (no session came, the session expired, was reset by a new invite code, the tenant is suspended). | +| 401 while a session reads the cell: to any request it makes (a prompt, a Stop, a replay, a task's cancel), or a socket closed with 4401 or not opened and then refused | Access loss, below: the turn in progress fails with that message (`provider_error`, code `cell_unauthorized`), the session reports the error, queued wakes are dropped (their runs stay in the cell's transcript), nothing more is read, requested or reconnected, and every later turn fails at once with the same message until the instance has a new token. | +| 403 | The refusal with the cell's reason (code `cell_unauthorized`), failing the turn or the Stop; the session reads on. T3 sends no `Origin` and no `Sec-Fetch-Site` (Node's fetch and the `ws` client send neither), so the cell's rule against other sites' pages never refuses it; a proxy that adds an `Origin` would be refused this way. | +| 503 from a tenant with a base domain and no `SESSION_SECRET` | The probe and a thread: the tenant is not serving requests and whoever runs it has to finish its setup, with the cell's text naming the missing secrets (code `cell_unavailable`). The socket keeps its backoff, so a fixed deployment is picked up. | +| A run stopped because its session ended (`done` `tool_error`, "The run was stopped: …") | Read with a token that holds (a new one, taking the run back on resume): a failed turn with the cell's message. With the token the run worked under, the socket closes 4401 before that `done` is sent, which is the row above. | +| The daily allowance spent at the prompt: 409 `{ runId: null, error }` | A failed turn with the cell's words, which say when the allowance renews. When the refusal names `code: "usage_limit"` and `resetAt`, it is T3's usage limit with the reset time, as a `done` reports it. | +| The daily allowance spent mid-run: `done` `usage_limit` with `resetAt` | The usage limit with the reset time. | +| The tenant's runs bound (`TENANT_MAX_RUNS`, 4): the prompt is accepted and the run's `done` is `tool_error`, "The run did not start: … send the prompt again once one of them has ended" | A failed turn with that message; the thread stays reusable, and the next message runs once a place is free. | +| A run bound: the run's time limit, an output cap or a deadline per model attempt (`done`), a conversation past its limit (409 at the prompt) | A failed turn with the cell's message, which says what was reached and what to do. | +| `usage` in `done` | The turn's token usage. | + +The cell learns of a reset or a suspension within 30 seconds (its policy +cache), so a model request already in flight may finish and its `done` reach +T3 before anything is refused; an idle cell's socket hears nothing more and +stays open, deaf, until the next prompt is refused 401. A run that outlives +the cache is stopped, and its socket closed with 4401, within those 30 +seconds. Either way the next request ends the session's reading, so T3 never +loops on a token that will not hold again. + +**Access loss** is the terminal state of a session's reader. It is entered +once, by one function, under the event permit (the lock under which every +cell event, prompt answer and reconciliation is applied), and it is judged in +two places only. The session's one request function classifies every answer +the cell gives before its caller sees it: a 401 to any request (the attach's +`GET state`, a prompt, the `GET state` after a prompt whose answer was lost, +a `GET events` page of a replay, a Stop's cancel, a deleted thread's task +cancels) enters access loss there, directly when the requesting fiber holds +the permit and by taking it otherwise, whether or not the turn that asked is +still in progress, and the caller only learns that access is lost. A turn +that had already ended (a reset, its `done`) gets no second terminal; the +session reports the error all the same. The socket's close handling is the +other place: a socket closed with 4401, or not opened, is followed by one +`GET state` that the event consumer makes behind that connection's frames, +so a `done` received before the close is applied first; a 401 there is the +request function's, and a 403 after such a close is the token refused too. +Entering it discards what is buffered: +the turn's candidates (events that arrived before its prompt's answer) and +the queued wakes. After it no wake is queued, offered or dispatched (the one +function that queues a wake, the offer and a continuation's dispatch each +check the state), nothing already received is read, no request leaves, the +socket is not reopened, and an attach is refused at once. The turn in +progress ends with exactly one terminal, failed with the refusal; every later +turn ends at once with it. A 403 or 503 refuses one request, not the token: +the turn fails with the refusal's message, or the Stop does (a 503 leaves it +unconfirmed, as every 5xx to a cancel does), and the session reads on. + +Since the cell's prompt route reads the account before it starts a run, a +prompt refused for a spent allowance, not a run's `done`, is how a spent +allowance usually shows. vim's cell names the usage limit on that refusal +(`{ runId: null, error, code: "usage_limit", resetAt }`, vim main since +#53), and T3 shows it as a `done` reporting it would. A cell from before +that sends the words only, which T3 shows as a failed turn; they name when +the allowance renews. + ## MCP T3 injects its orchestrator MCP server into every thread so agents can @@ -450,6 +550,12 @@ adapter reconciles what it was tracking against the snapshot: could be used fail the attach naming the last reason, inside the attach's own timeout. A failed attach leaves nothing behind, so the same session attaches on its next try from that try's first snapshot. +- A connection the cell closed with 4401, its session no longer holding, or + one that could not be opened (a client learns nothing of a refused + handshake), is followed by one `GET state`. A 401 or 403 there ends the + session's reading as described under [The login](#the-login), instead of a + reconnect that every attempt would see refused; any other answer, or none, + reconnects with the backoff as before. - Recovery is bounded. Connections dropped for not keeping the protocol (a frame before the roster, no roster within ten seconds of `state`, a roster numbered elsewhere than its `state`, an unreadable event, a replay that @@ -613,7 +719,12 @@ adapter stopped itself for policy reasons is `permission_error` or `validation_error`. A turn T3 asks a session to start while another of its turns is still active is refused before anything reaches the cell, as a protocol error with the shared `previous_turn_active` reason, so the failure -T3 shows says the previous turn is still running. +T3 shows says the previous turn is still running. A tenant that refuses T3 +itself (401, 403, 503) fails the turn as `provider_error` with code +`cell_unauthorized` or `cell_unavailable` and a message that says what to +do; a 401 also ends the session's reading, as described under +[The login](#the-login). A prompt refused for a spent allowance that names +the code is `usage_limit` with its `resetAt`. The failed terminal is the failure's only report: it reserves the error row's place after the turn's items, and T3 writes the row from it, so a failed turn @@ -713,7 +824,10 @@ responded 500: " and the error body; `cell_wake_after_failure` asserts only that the message carries the API's text). All seven checked-in transcripts were recorded live (`generatedBy: live-cell-recorder`, version `0.3.0`) against celld dev running the cell of upperformance/vim `main`, which speaks -level 3, with a scripted Responses API behind it, and verified by replay. +level 3, with a scripted Responses API behind it, and verified by replay; +`cell_wake_after_failure` was recorded again against vim `main` 2e7f884 +with the login on, whose cell retries the API's 500 four times, each retry +a run-scoped `error`, before the run fails. The fake cell is for a recording without a tenant. The recorder writes to the path `--out` gives, to the checked-in transcript with `--write-fixture`, and with neither to a new scratch directory it prints. @@ -772,10 +886,23 @@ waiting for the answer before the cell carries the cancel out: it passes when the cell lists the task as cancelled or no longer lists it, and its failure says whether the cancel was never sent or was sent and what, if anything, the cell answered. The two scenarios check their outcome and exit -non-zero when it is not met. `live-cell-probe.ts --url ` runs the -status probe Settings shows against the live cell and against stand-ins for -an old-protocol tenant, a protocol-2 tenant, an HTML server, a 404, a tenant -without a key and a closed port. +non-zero when it is not met. `live-cell-probe.ts --url [--token t]` runs +the status probe Settings shows against the live cell (with the token and, +when one is given, without it) and against stand-ins for an old-protocol +tenant, a protocol-2 tenant, an HTML server, a 404, a tenant without a key, +a tenant refusing a stale token (401), one failing closed (503) and a closed +port. The login era adds four scenarios, each checking its outcome and +exiting non-zero on a miss: `access` (whatever `--token`, none or a stale +one, against a tenant that refuses it or fails closed: the run fails with the +refusal and what to do, and no prompt is posted), `revoke --suspend [--resume ]` (a run that outlives the cell's 30 s +session cache, the tenant suspended once the prompt is accepted: the run +fails with the refusal naming the suspension, no socket is opened after the +one refused, a second message fails at once, and the resume is posted at the +end), `allowance` (a tenant whose `TENANT_DAILY_TOKENS` runs out: the usage +limit with its reset time) and `runs-bound` (another project of +the tenant holds the only place of `TENANT_MAX_RUNS=1`; the driver starts and +then cancels that run: the turn fails saying to send the prompt again). To repeat the run (celld and every driver or recorder run leave small directories under `TMPDIR`: celld's asset cache, and empty @@ -831,6 +958,43 @@ set `TMPDIR` to a scratch path for all of them): delegation (`cell_wake` records in about 33 s instead of 7 s), so restart the API without it before anything else. +With the login on, as a deployed tenant runs (vim.app's `docs/deploy.md`, +stage 2), the same run takes a token: + +1. Add the login's secrets to `dist/cell/.dev.vars` beside the two lines + above: `CELL_BASE_DOMAIN=vim.test`, `SESSION_SECRET=` and + `INVITE_SECRET=`. `127.0.0.1` is then the apex, the tenant `default`; + other tenants are `Host: .vim.test:`, which Node's WebSocket + will not send, so the drivers use the apex. +2. Start celld as above, print the tenant's invite code with + `INVITE_SECRET= node cell/invite.mjs default` from vim, log in, and + create a token: `POST /login` with the form fields `invite` and + `passphrase` (a 303 and a `cell_session` cookie), then `POST /login/token` + with that cookie (the page carries the token in `
`), or do the
+   same in a browser at `http://127.0.0.1:/login`. Five failed logins
+   within 30 s refuse the client for 30 s.
+3. Pass `--token ` to the probe, to every driver scenario and to the
+   recorder. Run `access` without it, and with a made-up one. Six of the
+   seven fixtures record this way; `turn_interrupt` records only with the
+   login off. Its input, shared by every provider, presses Stop as soon as
+   the prompt is accepted, and with the login on that lands while the run's
+   first model request waits for its reservation from the tenant's account,
+   so the cell cancels the run before the shell call and the fixture's check
+   for an interrupted shell row fails. The driver's `interrupt` scenario,
+   which waits for the shell row, passes with the login on.
+4. `revoke` needs the operator's two tokens, printed by
+   `INVITE_SECRET= CELL_BASE_DOMAIN=vim.test node cell/invite.mjs suspend default --origin http://127.0.0.1:`
+   (and `resume`), each good for ten minutes and once. The token is dead after
+   it, resume or not: log in with the passphrase and make a new one.
+5. `allowance` needs celld restarted (`--clean`) with
+   `TENANT_DAILY_TOKENS=1000` in `.dev.vars`, which refuses the prompt, or
+   with a value that covers one request but not two (41500 with the scripted
+   API), which stops the run at its second request; `runs-bound` with
+   `TENANT_MAX_RUNS=1`. A clean start forgets the tenant: log in with a fresh
+   invite code.
+6. For a tenant failing closed, write `CELL_BASE_DOMAIN=vim.test` without
+   `SESSION_SECRET` and run the probe and `access`.
+
 Refreshing the checked-in transcripts is a deliberate step of its own:
 `--write-fixture --version ` in place of
 `--out` (the recorder refuses `--write-fixture` against a cell without
@@ -839,6 +1003,35 @@ run's opening event, and that order lands in the transcript, so a refresh
 usually changes bytes, in five of six fixtures on the last run, without
 changing what the transcript means; commit one only for a reason.
 
+The fake cell speaks the login era when given a `token`: a request or a
+socket handshake without it is refused 401 in the cell's words (a refused
+handshake is answered, not dropped), `revokeToken` stops the run in progress
+with the cell's "The run was stopped" `done` after closing its sockets with
+4401, as the cell does at the next event, and leaves an idle project's
+sockets open and deaf; `loginUnset` answers everything 503; every `done`
+reports `usage`; `spendAllowance` refuses prompts naming `code:
+"usage_limit"` and `resetAt`. The login-era tests run the adapter over it: the
+probe's answers to a missing and a refused token and to a tenant failing
+closed, an attach refused before anything is posted, a token revoked during a
+run failing the turn with no socket opened again, a prompt refused 401 on a
+deaf socket, a reconnect refused after a revocation, a spent allowance as the
+usage limit, the runs bound as a readable failure the thread recovers from,
+and a run's usage as the turn's token usage; a scripted socket checks that a
+4401 close is asked about once over HTTP and nothing is sent after the
+refusal. A hand-driven socket sweeps every request path
+answering 401 (`GET state` after a lost prompt, `GET events` during a
+replay, 4401 and `GET state`, a task's cancel, the attach, a prompt, a Stop)
+and checks that the state is entered once, each turn in progress gets one
+terminal, no wake is offered or dispatched afterwards (including one offered
+before the refusal and one buffered during the prompt), nothing more is
+requested and no socket is opened; the same holds when the turn had already
+ended before the 401 came (a prompt or a Stop left unanswered across a reset
+that ended the turn, a replay page refused after the page whose reset ended
+it) with no second terminal, and a run's `done` followed by a 4401 close
+completes the turn before access is lost; a Stop refused 403 says why, one
+answered 503 is unconfirmed, and the run goes on. The recorder keeps a socket's close code in `ws_closed`, so a 4401
+replays as one.
+
 The adapter's own tests cover the rules above: the rejected prompt racing
 another run's events, a 202 overtaking the previous run's tail, a stranger
 after the run began ending the turn whether it arrived live or among the
diff --git a/packages/provider-cell/src/server/adapter.test.ts b/packages/provider-cell/src/server/adapter.test.ts
index d4deab494a11d..9c38a3356d372 100644
--- a/packages/provider-cell/src/server/adapter.test.ts
+++ b/packages/provider-cell/src/server/adapter.test.ts
@@ -25,6 +25,7 @@ import * as Logger from "effect/Logger";
 import * as Option from "effect/Option";
 import * as Queue from "effect/Queue";
 import * as Random from "effect/Random";
+import * as Result from "effect/Result";
 import * as Schema from "effect/Schema";
 import * as Scope from "effect/Scope";
 import * as Stream from "effect/Stream";
@@ -78,12 +79,14 @@ const fakeCell = (
     readonly configured?: boolean;
     readonly model?: string;
     readonly delegateDelayMs?: number;
+    readonly loginUnset?: boolean;
   } = {},
 ) =>
   Effect.acquireRelease(
     Effect.promise(() =>
       startFakeCellServer({
         token: "secret-token",
+        ...(options.loginUnset === undefined ? {} : { loginUnset: options.loginUnset }),
         ...(options.script ? { script: options.script } : {}),
         ...(options.configured === undefined ? {} : { configured: options.configured }),
         ...(options.model === undefined ? {} : { model: options.model }),
@@ -189,6 +192,7 @@ const openHarness = Effect.fnUntraced(function* (
     allEvents,
     collector,
     takeContinuation: Queue.take(made.continuations),
+    pollContinuation: Queue.poll(made.continuations),
   };
 });
 
@@ -1452,6 +1456,241 @@ describe("CellAdapterV2 over the fake cell", () => {
   );
 });
 
+describe("CellAdapterV2 against a tenant with the login on", () => {
+  /** The live transport, counting the sockets the adapter opens. */
+  const countingConnects = (transport: CellTransport) => {
+    let connects = 0;
+    const counted: CellTransport = {
+      request: transport.request,
+      connect: (input) =>
+        Effect.suspend(() => {
+          connects += 1;
+          return transport.connect(input);
+        }),
+    };
+    return { transport: counted, connects: () => connects };
+  };
+  const lastSession = (events: ReadonlyArray) =>
+    events.findLast((event) => event.type === "provider_session.updated");
+  /** A turn after the refusal ends at once with it, posting nothing. */
+  const refusedTurn = Effect.fnUntraced(function* (harness: Harness, ordinal: number) {
+    yield* harness.runtime.startTurn(
+      yield* turnInput(harness.providerThread, "Hello again", ordinal),
+    );
+    const terminal = yield* harness.takeEvent(
+      "turn.terminal",
+      (event) => event.runOrdinal === ordinal,
+    );
+    return failureOf(terminal);
+  });
+
+  it.live(
+    "the status probe says what a missing or refused token and a tenant without its login secret need",
+    () =>
+      Effect.gen(function* () {
+        const transport = yield* liveTransport;
+        const fake = yield* fakeCell();
+        const missing = yield* checkCellProviderStatus(settingsFor(fake.baseUrl, ""), transport);
+        assert.equal(missing.status, "error");
+        assert.equal(missing.auth.status, "unauthenticated");
+        assert.include(missing.message ?? "", "needs an access token");
+        assert.include(missing.message ?? "", `${fake.baseUrl}/login`);
+        assert.include(missing.message ?? "", "Settings → Providers → Cell → Access token");
+        assert.include(missing.message ?? "", "no session of tenant fake came with this request");
+        const wrong = yield* checkCellProviderStatus(settingsFor(fake.baseUrl, "stale"), transport);
+        assert.equal(wrong.status, "error");
+        assert.include(wrong.message ?? "", "refused T3's access token");
+        assert.include(wrong.message ?? "", "the session is not signed by this server");
+        const good = yield* checkCellProviderStatus(settingsFor(fake.baseUrl), transport);
+        assert.equal(good.status, "ready");
+        assert.equal(good.auth.status, "authenticated");
+        const unset = yield* fakeCell({ loginUnset: true });
+        const closed = yield* checkCellProviderStatus(settingsFor(unset.baseUrl), transport);
+        assert.equal(closed.status, "error");
+        assert.isTrue(closed.installed, "a tenant failing closed is a cell tenant all the same");
+        assert.include(closed.message ?? "", "not serving requests");
+        assert.include(closed.message ?? "", "SESSION_SECRET is not set");
+      }).pipe(Effect.scoped, Effect.provide(liveLayer)),
+  );
+
+  it.live("a thread without a token the tenant takes fails to attach saying what to do", () =>
+    Effect.gen(function* () {
+      const fake = yield* fakeCell();
+      const transport = yield* liveTransport;
+      const failed = yield* makeHarness(transport, fake.baseUrl, {
+        adapterOptions: { settings: settingsFor(fake.baseUrl, "") },
+      }).pipe(Effect.flip);
+      const cause = (
+        failed as { readonly cause?: { readonly _tag: string; readonly message: string } }
+      ).cause;
+      assert.equal(cause?._tag, "CellAccessError");
+      assert.include(cause?.message ?? "", "needs an access token");
+      assert.include(cause?.message ?? "", "Create an API token");
+      const unset = yield* fakeCell({ loginUnset: true });
+      const closed = yield* makeHarness(transport, unset.baseUrl).pipe(Effect.flip);
+      assert.include(
+        (closed as { readonly cause?: { readonly message: string } }).cause?.message ?? "",
+        "SESSION_SECRET is not set",
+      );
+      const project = yield* cellProjectName("t3-", THREAD_ID);
+      assert.deepEqual(fake.prompts(project), [], "nothing is posted to a tenant that refused T3");
+    }).pipe(Effect.scoped, Effect.provide(liveLayer)),
+  );
+
+  it.live(
+    "a token revoked during a run fails the turn with what to do and the socket is not reopened",
+    () =>
+      Effect.gen(function* () {
+        const fake = yield* fakeCell({
+          script: () => [{ type: "shell_until_cancel", command: "sleep 30" }],
+        });
+        const counted = countingConnects(yield* liveTransport);
+        const harness = yield* makeHarness(counted.transport, fake.baseUrl);
+        yield* harness.runtime.startTurn(yield* turnInput(harness.providerThread, "Work"));
+        yield* harness.takeEvent(
+          "turn_item.updated",
+          (event) => event.turnItem.type === "command_execution",
+        );
+        fake.revokeToken();
+        const terminal = yield* harness.takeEvent("turn.terminal");
+        assert.equal(terminal.status, "failed");
+        assert.equal(terminal.threadDisposition, "reusable");
+        assert.equal(failureOf(terminal)?.class, "provider_error");
+        assert.equal(failureOf(terminal)?.code, "cell_unauthorized");
+        assert.include(failureOf(terminal)?.message ?? "", "refused T3's access token");
+        assert.include(
+          failureOf(terminal)?.message ?? "",
+          "the session was reset by a new invite code",
+        );
+        const session = lastSession(harness.allEvents);
+        assert.equal(
+          session?.type === "provider_session.updated" ? session.providerSession.status : null,
+          "error",
+        );
+        // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- proves that no reconnect happens; an absence has no event to await.
+        yield* Effect.sleep("1500 millis");
+        assert.equal(counted.connects(), 1, "a refused token is not retried by reconnecting");
+        const next = yield* refusedTurn(harness, 2);
+        assert.equal(next?.code, "cell_unauthorized");
+        assert.equal(counted.connects(), 1);
+        assert.include(next?.message ?? "", "Create an API token");
+        assert.isFalse(yield* harness.runtime.hasPendingBackgroundWork!);
+      }).pipe(Effect.scoped, Effect.provide(liveLayer)),
+  );
+
+  it.live(
+    "a prompt refused 401 on a socket that heard nothing fails the turn and ends the reading",
+    () =>
+      Effect.gen(function* () {
+        const fake = yield* fakeCell();
+        const counted = countingConnects(yield* liveTransport);
+        const harness = yield* makeHarness(counted.transport, fake.baseUrl);
+        // An idle cell sends its socket nothing more, so only the prompt learns of it.
+        fake.revokeToken("tenant fake is suspended by the operator");
+        yield* harness.runtime.startTurn(yield* turnInput(harness.providerThread, "Hello"));
+        const terminal = yield* harness.takeEvent("turn.terminal");
+        assert.equal(failureOf(terminal)?.code, "cell_unauthorized");
+        assert.include(
+          failureOf(terminal)?.message ?? "",
+          "tenant fake is suspended by the operator",
+        );
+        const project = yield* cellProjectName("t3-", THREAD_ID);
+        assert.deepEqual(fake.prompts(project), []);
+        // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- proves that no reconnect happens; an absence has no event to await.
+        yield* Effect.sleep("1 second");
+        assert.equal(counted.connects(), 1);
+      }).pipe(Effect.scoped, Effect.provide(liveLayer)),
+  );
+
+  it.live("a socket refused after the token was revoked ends the reading without a turn", () =>
+    Effect.gen(function* () {
+      const fake = yield* fakeCell();
+      const counted = countingConnects(yield* liveTransport);
+      const harness = yield* makeHarness(counted.transport, fake.baseUrl);
+      fake.revokeToken();
+      // The cell moves: the reconnect's handshake is refused, and GET state says why.
+      fake.dropSockets();
+      const session = yield* harness.takeEvent(
+        "provider_session.updated",
+        (event) => event.providerSession.status === "error",
+      );
+      assert.include(session.providerSession.lastError ?? "", "refused T3's access token");
+      // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- proves that no reconnect happens; an absence has no event to await.
+      yield* Effect.sleep("1500 millis");
+      assert.equal(counted.connects(), 2, "one refused reconnect, then none");
+      const next = yield* refusedTurn(harness, 1);
+      assert.equal(next?.code, "cell_unauthorized");
+      const project = yield* cellProjectName("t3-", THREAD_ID);
+      assert.deepEqual(fake.prompts(project), [], "a turn after the refusal posts nothing");
+    }).pipe(Effect.scoped, Effect.provide(liveLayer)),
+  );
+
+  it.live("a prompt refused for a spent daily allowance is the usage limit with its renewal", () =>
+    Effect.gen(function* () {
+      const fake = yield* fakeCell();
+      const harness = yield* makeHarness(yield* liveTransport, fake.baseUrl);
+      fake.spendAllowance("2026-11-05T00:00:00.000Z");
+      yield* harness.runtime.startTurn(yield* turnInput(harness.providerThread, "Hello"));
+      const terminal = yield* harness.takeEvent("turn.terminal");
+      assert.equal(failureOf(terminal)?.class, "usage_limit");
+      assert.equal(failureOf(terminal)?.code, "usage_limit");
+      assert.equal(failureOf(terminal)?.resetAt, "2026-11-05T00:00:00.000Z");
+      assert.include(failureOf(terminal)?.message ?? "", "renews at 2026-11-05T00:00:00.000Z.");
+      assert.notInclude(failureOf(terminal)?.message ?? "", "..");
+    }).pipe(Effect.scoped, Effect.provide(liveLayer)),
+  );
+
+  it.live(
+    "a run the tenant's run bound kept from starting fails readably and the thread goes on",
+    () =>
+      Effect.gen(function* () {
+        const bound =
+          "The run did not start: tenant fake has 4 runs in progress in its projects, the most it may have at once (TENANT_MAX_RUNS); send the prompt again once one of them has ended.";
+        const fake = yield* fakeCell({
+          script: (_prompt, turn) =>
+            turn === 1
+              ? [{ type: "fail", code: "tool_error", error: bound }]
+              : [{ type: "assistant", text: "ok" }],
+        });
+        const harness = yield* makeHarness(yield* liveTransport, fake.baseUrl);
+        yield* harness.runtime.startTurn(yield* turnInput(harness.providerThread, "Hello"));
+        const first = yield* harness.takeEvent("turn.terminal");
+        assert.equal(failureOf(first)?.class, "provider_error");
+        assert.equal(failureOf(first)?.message, bound);
+        assert.equal(first.threadDisposition, "reusable");
+        yield* harness.runtime.startTurn(yield* turnInput(harness.providerThread, "Again", 2));
+        const second = yield* harness.takeEvent("turn.terminal", (event) => event.runOrdinal === 2);
+        assert.equal(second.status, "completed");
+      }).pipe(Effect.scoped, Effect.provide(liveLayer)),
+  );
+
+  it.live("reports what a run used as the turn's token usage", () =>
+    Effect.gen(function* () {
+      const fake = yield* fakeCell({
+        script: () => [
+          { type: "shell", command: "ls", output: "a\n", exitCode: 0 },
+          { type: "assistant", text: "done" },
+        ],
+      });
+      const harness = yield* makeHarness(yield* liveTransport, fake.baseUrl);
+      yield* harness.runtime.startTurn(yield* turnInput(harness.providerThread, "Hello"));
+      const completed = yield* harness.takeEvent(
+        "provider_turn.updated",
+        (event) => event.providerTurn.status === "completed",
+      );
+      assert.deepEqual(completed.providerTurn.turnTokenUsage, {
+        usageScope: "main_agent",
+        usageStatus: "complete",
+        inputTokens: 2_000,
+        cachedInputTokens: 0,
+        outputTokens: 100,
+        reasoningTokens: 0,
+        hasSubagents: false,
+      });
+    }).pipe(Effect.scoped, Effect.provide(liveLayer)),
+  );
+});
+
 describe("CellAdapterV2 against a tenant below cell protocol 3", () => {
   /** Answers `GET state` as a tenant of the stated level would, and must never be asked for a socket. */
   const olderTenant = (state: Record): CellTransport => ({
@@ -1771,6 +2010,44 @@ describe("CellAdapterV2 over a scripted socket", () => {
     }).pipe(Effect.scoped, Effect.provide(scriptedLayer)),
   );
 
+  it.effect(
+    "a socket closed 4401 is asked about over HTTP once, and a refused token ends the turn",
+    () =>
+      Effect.gen(function* () {
+        const said =
+          "Unauthorized: the session was reset by a new invite code; log in at /login, or send the token of POST /login/token as Authorization: Bearer";
+        // Nothing follows the refusal: a reconnect would be an outbound frame the transcript lacks.
+        const controller = scripted("session-close", [
+          ...prelude(),
+          expectHttp("POST", "/cell//prompt", "Hi"),
+          httpResponse(202, '{"runId":"run-1","seq":1}'),
+          ws({
+            type: "user",
+            text: "Hi",
+            requestId: "",
+            runId: "run-1",
+            seq: 1,
+            origin: "user",
+          }),
+          { type: "emit_inbound", frame: { kind: "ws_closed", code: 4401 } },
+          expectHttp("GET", "/cell//state"),
+          httpResponse(401, said),
+        ]);
+        const harness = yield* makeHarness(controller.transport, "https://cell.test");
+        yield* harness.runtime.startTurn(yield* turnInput(harness.providerThread, "Hi"));
+        const terminal = yield* harness.takeEvent("turn.terminal");
+        assert.equal(terminal.status, "failed");
+        assert.equal(failureOf(terminal)?.code, "cell_unauthorized");
+        assert.include(failureOf(terminal)?.message ?? "", "https://cell.test/login");
+        assert.include(
+          failureOf(terminal)?.message ?? "",
+          "The tenant said: Unauthorized: the session was reset by a new invite code",
+        );
+        assert.notInclude(failureOf(terminal)?.message ?? "", "REDACTED");
+        controller.assertComplete();
+      }).pipe(Effect.scoped, Effect.provide(scriptedLayer)),
+  );
+
   it.effect("rejects attachments as a validation failure instead of dropping them", () =>
     Effect.gen(function* () {
       const controller = scripted("attachments", prelude());
@@ -2068,6 +2345,8 @@ describe("CellAdapterV2 over a hand-driven socket", () => {
     readonly send: (frames: ReadonlyArray>) => Effect.Effect;
     /** Ends the current connection as a dropped socket; the next one opens with `opensWith`. */
     readonly close: Effect.Effect;
+    /** Ends the current connection as the cell closing it with `code`. */
+    readonly closeWith: (code: number) => Effect.Effect;
     readonly opensWith: (frames: ReadonlyArray>) => void;
     /**
      * What `GET events` answers from now on: the whole log in one complete
@@ -2080,12 +2359,18 @@ describe("CellAdapterV2 over a hand-driven socket", () => {
     ) => void;
     /** What `GET state` answers from now on, over the idle default. */
     readonly states: (over: Record) => void;
+    /** `GET state` or `GET events` answers this status and body from now on. */
+    readonly refuses: (path: "/state" | "/events", status: number, body?: string) => void;
+    /** Every request the adapter made, as `METHOD /path`, in order. */
+    readonly requests: Array;
     /** Answers the next `POST prompt`; given before the prompt, it answers at once. */
     readonly answerPrompt: (status: number, body?: string) => Effect.Effect;
     /** Fails the next `POST prompt` at the transport, as a dropped socket would. */
     readonly failPrompt: (detail: string) => Effect.Effect;
     /** Answers the next `POST cancel`; without one queued, a cancel answers 204. Status 0 fails it at the transport. */
     readonly answerCancel: (status: number, body?: string) => Effect.Effect;
+    /** From now on a `POST cancel` waits for its `answerCancel` instead of answering 204. */
+    readonly parksCancels: () => void;
     /** Leaves the next `POST cancel` unanswered for good. */
     readonly holdCancel: Effect.Effect;
     readonly replayRequests: Array;
@@ -2113,7 +2398,9 @@ describe("CellAdapterV2 over a hand-driven socket", () => {
    */
   const driven = () =>
     Effect.gen(function* () {
-      const incoming = yield* Queue.unbounded | "close">();
+      const incoming = yield* Queue.unbounded<
+        ReadonlyArray | { readonly close: number | undefined }
+      >();
       const answers = yield* Queue.unbounded();
       const cancelAnswers = yield* Queue.unbounded();
       let opening: ReadonlyArray> = [];
@@ -2123,6 +2410,9 @@ describe("CellAdapterV2 over a hand-driven socket", () => {
       const replayRequests: Array = [];
       const promptRequestIds: Array = [];
       const cancels: Array = [];
+      const requests: Array = [];
+      const refusals = new Map();
+      let cancelsParked = false;
       let connects = 0;
       let state: Record = {
         protocol: 3,
@@ -2142,6 +2432,9 @@ describe("CellAdapterV2 over a hand-driven socket", () => {
       const transport: CellTransport = {
         request: (request) => {
           const url = new URL(request.url);
+          requests.push(`${request.method} ${url.pathname.replace(/^\/cell\/[^/]+/, "")}`);
+          const refusal = [...refusals].find(([path]) => url.pathname.endsWith(path))?.[1];
+          if (refusal !== undefined) return Effect.succeed(refusal);
           if (request.method === "POST" && url.pathname.endsWith("/prompt")) {
             promptRequestIds.push(request.headers["x-request-id"]);
             // Status 0 stands for no answer at all.
@@ -2155,8 +2448,12 @@ describe("CellAdapterV2 over a hand-driven socket", () => {
           }
           if (request.method === "POST" && url.pathname.endsWith("/cancel")) {
             cancels.push(request.headers["x-run-id"]);
-            return Queue.poll(cancelAnswers).pipe(
-              Effect.map(Option.getOrElse((): Answer | "hold" => ({ status: 204, body: "" }))),
+            const next = cancelsParked
+              ? Queue.take(cancelAnswers)
+              : Queue.poll(cancelAnswers).pipe(
+                  Effect.map(Option.getOrElse((): Answer | "hold" => ({ status: 204, body: "" }))),
+                );
+            return next.pipe(
               Effect.flatMap((answer) =>
                 answer === "hold"
                   ? Effect.never
@@ -2186,11 +2483,12 @@ describe("CellAdapterV2 over a hand-driven socket", () => {
             const pull: Effect.Effect, CellTransportError> = Effect.gen(
               function* () {
                 const batch = yield* Queue.take(incoming);
-                if (batch === "close") {
+                if ("close" in batch) {
                   return yield* new CellTransportError({
                     operation: "read",
                     detail: "closed",
                     closed: true,
+                    ...(batch.close === undefined ? {} : { closeCode: batch.close }),
                   });
                 }
                 return batch;
@@ -2202,7 +2500,8 @@ describe("CellAdapterV2 over a hand-driven socket", () => {
       const cell: DrivenCell = {
         transport,
         send,
-        close: Queue.offer(incoming, "close").pipe(Effect.asVoid),
+        close: Queue.offer(incoming, { close: undefined }).pipe(Effect.asVoid),
+        closeWith: (code) => Queue.offer(incoming, { close: code }).pipe(Effect.asVoid),
         opensWith: (frames) => {
           opening = frames;
         },
@@ -2212,12 +2511,19 @@ describe("CellAdapterV2 over a hand-driven socket", () => {
         states: (over) => {
           state = { ...state, ...over };
         },
+        refuses: (path, status, body = "") => {
+          refusals.set(path, { status, body });
+        },
+        requests,
         answerPrompt: (status, body = "") =>
           Queue.offer(answers, { status, body }).pipe(Effect.asVoid),
         failPrompt: (detail) =>
           Queue.offer(answers, { status: 0, body: detail }).pipe(Effect.asVoid),
         answerCancel: (status, body = "") =>
           Queue.offer(cancelAnswers, { status, body }).pipe(Effect.asVoid),
+        parksCancels: () => {
+          cancelsParked = true;
+        },
         holdCancel: Queue.offer(cancelAnswers, "hold").pipe(Effect.asVoid),
         replayRequests,
         promptRequestIds,
@@ -3163,11 +3469,11 @@ describe("CellAdapterV2 over a hand-driven socket", () => {
         );
         const nodeIds = new Set(
           harness.allEvents.flatMap((event) =>
-            event.type === "node.updated" ? [event.node.id] : [],
+            event.type === "node.updated" && event.node.kind === "system" ? [event.node.id] : [],
           ),
         );
         for (const notice of itemsOfType(harness.allEvents, "system_notice")) {
-          assert.isTrue(nodeIds.has(notice.nodeId!), "a notice's node is published with it");
+          assert.isTrue(nodeIds.has(notice.nodeId!), "a notice's system node is published with it");
         }
         yield* cell.answerPrompt(202, '{"runId":"B","seq":6}');
         yield* harness.runtime.startTurn(yield* turnInput(harness.providerThread, "Again", 2));
@@ -3722,4 +4028,321 @@ describe("CellAdapterV2 over a hand-driven socket", () => {
       assert.deepEqual(assistantTexts(harness.allEvents), ["REST"]);
     }).pipe(Effect.scoped, Effect.provide(drivenLayer)),
   );
+
+  /**
+   * Access loss: one session error, one failed terminal per turn in progress, none for `settled`
+   * ones, then nothing read, queued, offered, dispatched, requested or reconnected; later turns fail.
+   */
+  const assertAccessLost = Effect.fnUntraced(function* (
+    harness: Harness,
+    cell: DrivenCell,
+    label: string,
+    turnsInProgress: number,
+    settled = 0,
+  ) {
+    yield* harness.takeEvent(
+      "provider_session.updated",
+      (event) => event.providerSession.status === "error",
+    );
+    yield* settle;
+    const terminals = harness.allEvents.filter((event) => event.type === "turn.terminal");
+    assert.lengthOf(
+      terminals,
+      settled + turnsInProgress,
+      `${label}: one terminal per turn in progress, none more for a settled one`,
+    );
+    for (const terminal of terminals.slice(settled)) {
+      assert.equal(terminal.status, "failed", label);
+      assert.equal(failureOf(terminal)?.code, "cell_unauthorized", label);
+      assert.include(failureOf(terminal)?.message ?? "", "refused T3's access token", label);
+    }
+    const errors = harness.allEvents.filter(
+      (event) =>
+        event.type === "provider_session.updated" && event.providerSession.status === "error",
+    );
+    assert.lengthOf(errors, 1, `${label}: the state is entered once`);
+    const connects = cell.connects();
+    const requests = cell.requests.length;
+    // What the cell sends after the refusal, on a socket still open, is not read.
+    yield* cell.send([
+      { type: "wake", text: "[Delegated task late done]", runId: "Z", seq: 90, origin: "wake" },
+      { type: "done", runId: "Z", seq: 91 },
+    ]);
+    // Past the reconnect backoff's first two delays.
+    yield* Effect.sleep("1600 millis");
+    assert.equal(cell.connects(), connects, `${label}: no new socket`);
+    assert.isTrue(Option.isNone(yield* harness.pollContinuation), `${label}: no wake offered`);
+    assert.isFalse(yield* harness.runtime.hasPendingBackgroundWork!, label);
+    yield* harness.runtime.startTurn(yield* turnInput(harness.providerThread, "Again", 9));
+    const next = yield* harness.takeEvent("turn.terminal", (event) => event.runOrdinal === 9);
+    assert.equal(failureOf(next)?.code, "cell_unauthorized", `${label}: a later turn ends at once`);
+    assert.equal(cell.requests.length, requests, `${label}: no request leaves after the refusal`);
+  });
+  const refusedBody = "Unauthorized: tenant cell is suspended";
+  /** A turn whose run the cell named and started. */
+  const startAccepted = Effect.fnUntraced(function* (harness: Harness, cell: DrivenCell) {
+    yield* cell.answerPrompt(202, '{"runId":"A","seq":1}');
+    yield* harness.runtime.startTurn(yield* turnInput(harness.providerThread, "Mine"));
+    yield* cell.send([
+      { type: "user", text: "Mine", runId: "A", seq: 1, origin: "user" },
+      marker(2),
+    ]);
+    yield* awaitMarker(harness);
+  });
+
+  it.live("review r1: a cancel refused 401 ends the turn with the refusal and the reading", () =>
+    Effect.gen(function* () {
+      const cell = yield* driven();
+      cell.opensWith(prelude());
+      const harness = yield* makeHarness(cell.transport, "https://cell.test");
+      yield* startAccepted(harness, cell);
+      yield* cell.answerCancel(401, refusedBody);
+      const stop = yield* harness.runtime
+        .interruptTurn({
+          providerThread: harness.providerThread,
+          providerTurnId: activeTurnId(harness.allEvents),
+        })
+        .pipe(Effect.result);
+      assert.isTrue(Result.isSuccess(stop), "the turn ended; nothing is left to stop");
+      assert.deepEqual(cell.cancels, ["A"]);
+      yield* assertAccessLost(harness, cell, "cancel", 1);
+    }).pipe(Effect.scoped, Effect.provide(drivenLayer)),
+  );
+
+  it.live("a cancel refused 403 says why, a 503 leaves it unconfirmed, and the run goes on", () =>
+    Effect.gen(function* () {
+      for (const status of [403, 503] as const) {
+        const cell = yield* driven();
+        cell.opensWith(prelude());
+        const harness = yield* makeHarness(cell.transport, "https://cell.test");
+        yield* startAccepted(harness, cell);
+        yield* cell.answerCancel(status, "Refused");
+        const stop = yield* harness.runtime
+          .interruptTurn({
+            providerThread: harness.providerThread,
+            providerTurnId: activeTurnId(harness.allEvents),
+          })
+          .pipe(Effect.flip);
+        if (status === 403) {
+          const cause = (
+            stop as { readonly cause?: { readonly _tag: string; readonly message: string } }
+          ).cause;
+          assert.equal(stop._tag, "ProviderAdapterInterruptError");
+          assert.equal(cause?._tag, "CellAccessError");
+          assert.include(cause?.message ?? "", "refused T3's request");
+        } else {
+          // A 5xx says nothing about the run: unconfirmed, so the user decides on Force stop.
+          assert.equal(stop._tag, "ProviderAdapterInterruptTimeoutError");
+        }
+        assert.isUndefined(
+          harness.allEvents.find((event) => event.type === "turn.terminal"),
+          `${status}: no terminal before the run's done`,
+        );
+        yield* cell.send([{ type: "done", runId: "A", seq: 3 }]);
+        const terminal = yield* harness.takeEvent("turn.terminal");
+        assert.equal(terminal.status, "completed", `${status}: the session reads on`);
+      }
+    }).pipe(Effect.scoped, Effect.provide(drivenLayer)),
+  );
+
+  it.live(
+    "review r1: a prompt refused 401 discards the wakes it buffered and the ones already offered",
+    () =>
+      Effect.gen(function* () {
+        const cell = yield* driven();
+        cell.opensWith(prelude());
+        const harness = yield* makeHarness(cell.transport, "https://cell.test");
+        // A wake the cell started while idle: queued and offered before the prompt.
+        yield* cell.send([
+          { type: "wake", text: "[Delegated task a done]", runId: "W", seq: 1, origin: "wake" },
+        ]);
+        const early = yield* harness.takeContinuation;
+        const started = yield* startParked(harness, "Mine");
+        // Another wake while the prompt is unanswered: a candidate of the turn.
+        yield* cell.send([
+          { type: "wake", text: "[Delegated task b done]", runId: "B", seq: 2, origin: "wake" },
+          { type: "assistant", text: "WAKE", runId: "B", seq: 3 },
+          { type: "done", runId: "B", seq: 4 },
+          marker(5),
+        ]);
+        yield* awaitMarker(harness);
+        yield* cell.answerPrompt(401, refusedBody);
+        yield* Fiber.join(started);
+        const dispatched = yield* early.dispatchIfCurrent!(Effect.succeed("after the 401"));
+        assert.isTrue(Option.isNone(dispatched), "a wake offered before the 401 is not dispatched");
+        yield* assertAccessLost(harness, cell, "prompt", 1);
+      }).pipe(Effect.scoped, Effect.provide(drivenLayer)),
+  );
+
+  it.live("every cell request answered 401 enters access loss once, by one rule", () =>
+    Effect.gen(function* () {
+      // GET state after a prompt whose answer was lost.
+      {
+        const cell = yield* driven();
+        cell.opensWith(prelude());
+        const harness = yield* makeHarness(cell.transport, "https://cell.test");
+        const started = yield* startParked(harness, "Mine");
+        cell.refuses("/state", 401, refusedBody);
+        yield* cell.failPrompt("socket hang up");
+        yield* Fiber.join(started);
+        yield* assertAccessLost(harness, cell, "GET state after a lost prompt", 1);
+      }
+      // GET events while a reconnect reads the gap back.
+      {
+        const cell = yield* driven();
+        cell.opensWith(prelude());
+        const harness = yield* makeHarness(cell.transport, "https://cell.test");
+        yield* startAccepted(harness, cell);
+        cell.refuses("/events", 401, refusedBody);
+        cell.opensWith(prelude({ running: true, runId: "A", seq: 6 }));
+        yield* cell.close;
+        yield* assertAccessLost(harness, cell, "GET events", 1);
+      }
+      // A socket the cell closed with 4401, then GET state.
+      {
+        const cell = yield* driven();
+        cell.opensWith(prelude());
+        const harness = yield* makeHarness(cell.transport, "https://cell.test");
+        yield* startAccepted(harness, cell);
+        cell.refuses("/state", 401, refusedBody);
+        yield* cell.closeWith(4401);
+        yield* assertAccessLost(harness, cell, "4401 and GET state", 1);
+      }
+      // A delegated task's cancel, with no turn in progress.
+      {
+        const cell = yield* driven();
+        cell.opensWith(prelude());
+        const harness = yield* makeHarness(cell.transport, "https://cell.test");
+        yield* cell.send([marker(1)]);
+        yield* awaitMarker(harness);
+        yield* cell.answerCancel(401, refusedBody);
+        yield* harness.runtime.cancelBackgroundWork!({
+          threadId: THREAD_ID,
+          reason: "Thread deleted.",
+        });
+        assert.deepEqual(cell.cancels, [undefined]);
+        yield* assertAccessLost(harness, cell, "task cancel", 0);
+      }
+      // The attach itself: the thread fails to attach, and a retried attach asks nothing.
+      {
+        const cell = yield* driven();
+        cell.opensWith(prelude());
+        cell.refuses("/state", 401, refusedBody);
+        const made = yield* makeAdapter(cell.transport, "https://cell.test");
+        const runtime = yield* made.adapter.openSession({
+          threadId: THREAD_ID,
+          providerSessionId: SESSION_ID,
+          modelSelection: defaultModel,
+          runtimePolicy: fullAccess,
+        });
+        const retry = {
+          threadId: THREAD_ID,
+          modelSelection: defaultModel,
+          runtimePolicy: fullAccess,
+        };
+        assert.equal(
+          (
+            (yield* runtime.ensureThread(retry).pipe(Effect.flip)) as {
+              readonly cause?: { readonly _tag: string };
+            }
+          ).cause?._tag,
+          "CellAccessError",
+        );
+        const asked = cell.requests.length;
+        yield* runtime.ensureThread(retry).pipe(Effect.flip);
+        assert.equal(cell.requests.length, asked, "attach: a retried attach asks nothing");
+        assert.equal(cell.connects(), 0, "attach: no socket");
+      }
+    }).pipe(Effect.scoped, Effect.provide(drivenLayer)),
+  );
+
+  it.live(
+    "a 401 to a request whose turn had already ended still enters access loss, with no second terminal",
+    () =>
+      Effect.gen(function* () {
+        /** The reset event that ends the turn in progress, and checks it ended so. */
+        const resetEnds = Effect.fnUntraced(function* (
+          harness: Harness,
+          cell: DrivenCell,
+          seq: number,
+        ) {
+          yield* cell.send([{ type: "reset", seq }]);
+          const ended = yield* harness.takeEvent("turn.terminal");
+          assert.include(failureOf(ended)?.message ?? "", "reset");
+        });
+        // A prompt left unanswered, a reset that ends its turn, then the prompt answered 401.
+        {
+          const cell = yield* driven();
+          cell.opensWith(prelude());
+          const harness = yield* makeHarness(cell.transport, "https://cell.test");
+          const started = yield* startParked(harness, "Mine");
+          yield* awaitRequests(() => cell.promptRequestIds.length, 1);
+          yield* resetEnds(harness, cell, 1);
+          yield* cell.answerPrompt(401, refusedBody);
+          yield* Fiber.join(started);
+          yield* assertAccessLost(harness, cell, "prompt after a reset", 0, 1);
+          assert.equal(cell.promptRequestIds.length, 1, "prompt after a reset: one prompt");
+        }
+        // A Stop's cancel left unanswered, a reset that ends its turn, then the cancel answered 401.
+        {
+          const cell = yield* driven();
+          cell.opensWith(prelude());
+          const harness = yield* makeHarness(cell.transport, "https://cell.test");
+          yield* startAccepted(harness, cell);
+          cell.parksCancels();
+          const stop = yield* harness.runtime
+            .interruptTurn({
+              providerThread: harness.providerThread,
+              providerTurnId: activeTurnId(harness.allEvents),
+            })
+            .pipe(Effect.result, Effect.forkScoped);
+          yield* awaitRequests(() => cell.cancels.length, 1);
+          yield* resetEnds(harness, cell, 3);
+          yield* cell.answerCancel(401, refusedBody);
+          assert.isTrue(
+            Result.isSuccess(yield* Fiber.join(stop)),
+            "cancel: nothing is left to stop",
+          );
+          yield* assertAccessLost(harness, cell, "cancel after a reset", 0, 1);
+        }
+        // A replay whose first page carries a reset that ends the turn, and whose next page is refused 401.
+        {
+          const cell = yield* driven();
+          cell.opensWith(prelude());
+          const harness = yield* makeHarness(cell.transport, "https://cell.test");
+          yield* startAccepted(harness, cell);
+          cell.replays((after) => {
+            cell.refuses("/events", 401, refusedBody);
+            return {
+              events: [{ type: "reset", seq: after + 1 }],
+              nextAfter: after + 1,
+              complete: false,
+            };
+          });
+          cell.opensWith(prelude({ running: true, runId: "A", seq: 6 }));
+          yield* cell.close;
+          const ended = yield* harness.takeEvent("turn.terminal");
+          assert.include(failureOf(ended)?.message ?? "", "reset");
+          yield* assertAccessLost(harness, cell, "GET events after a reset", 0, 1);
+          assert.deepEqual(cell.replayRequests, ["/events?after=2&limit=1000"]);
+        }
+        // A run's done, then its socket closed 4401: the close is judged behind the frames before it.
+        {
+          const cell = yield* driven();
+          cell.opensWith(prelude());
+          const harness = yield* makeHarness(cell.transport, "https://cell.test");
+          yield* startAccepted(harness, cell);
+          cell.refuses("/state", 401, refusedBody);
+          yield* cell.send([{ type: "done", runId: "A", seq: 3 }]);
+          yield* cell.closeWith(4401);
+          const ended = yield* harness.takeEvent("turn.terminal");
+          assert.equal(
+            ended.status,
+            "completed",
+            "4401 after done: the run's done is applied first",
+          );
+          yield* assertAccessLost(harness, cell, "4401 after the run's done", 0, 1);
+        }
+      }).pipe(Effect.scoped, Effect.provide(drivenLayer)),
+  );
 });
diff --git a/packages/provider-cell/src/server/adapter.ts b/packages/provider-cell/src/server/adapter.ts
index 4e1012416cddf..c833d8bb2c70a 100644
--- a/packages/provider-cell/src/server/adapter.ts
+++ b/packages/provider-cell/src/server/adapter.ts
@@ -24,6 +24,7 @@ import {
 } from "@t3tools/contracts";
 import * as Cause from "effect/Cause";
 import * as Clock from "effect/Clock";
+import * as Data from "effect/Data";
 import * as DateTime from "effect/DateTime";
 import * as Deferred from "effect/Deferred";
 import * as Effect from "effect/Effect";
@@ -48,7 +49,14 @@ import {
 import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
 
 import type { CellSettings } from "../settings.ts";
-import { CellModelError, CellRuntimeModeError } from "./errors.ts";
+import {
+  CELL_ACCESS_STATUSES,
+  CELL_SESSION_CLOSE,
+  CellModelError,
+  CellRuntimeModeError,
+  cellAccessError,
+  type CellAccessError,
+} from "./errors.ts";
 import {
   CELL_PROVIDER,
   CellEvent,
@@ -64,7 +72,9 @@ import {
   readCellRefusal,
   readCellState,
   readPromptAccepted,
+  readRunUsage,
   type CellRunEvent,
+  type CellRunUsage,
   type CellTask,
 } from "./protocol.ts";
 import { CellTransportError, type CellTransport } from "./transport.ts";
@@ -300,6 +310,8 @@ interface ActiveTurn {
    * output, then each applied event's number. Undefined until the head names the run.
    */
   head: number | undefined;
+  /** What the run used, as its `done` reported it. */
+  usage: CellRunUsage | undefined;
 }
 
 /** A run of the cell's agent loop: its id and the number of its opening event. */
@@ -320,7 +332,9 @@ interface CellSnapshot {
 type CatchUp =
   | { readonly kind: "complete" }
   | { readonly kind: "truncated"; readonly after: number; readonly upTo: number }
-  | { readonly kind: "failed"; readonly detail: string };
+  | { readonly kind: "failed"; readonly detail: string }
+  /** The log answered 401: the session's reading has ended, and the turn with it. */
+  | { readonly kind: "access_lost" };
 
 /** The rows a task is published to: its subagent node and item, and its place among the turn's items. */
 interface TaskRows extends OpenItem {
@@ -402,7 +416,21 @@ type Inbox =
   | { readonly type: "connected"; readonly broken: Deferred.Deferred }
   | { readonly type: "frame"; readonly text: string }
   | { readonly type: "roster_timeout"; readonly connection: number }
-  | { readonly type: "disconnected"; readonly detail: string };
+  | { readonly type: "disconnected"; readonly detail: string }
+  /**
+   * A connection the cell closed with 4401, or one that never opened, ended:
+   * `GET state` decides, behind the connection's frames, whether the session
+   * still reads. `decided` is completed whatever the answer.
+   */
+  | { readonly type: "check_access"; readonly decided: Deferred.Deferred };
+
+/**
+ * A cell request's outcome once the tenant refused the token: the request
+ * itself entered access loss (or found it entered), so its caller only stops.
+ */
+class CellAccessLost extends Data.TaggedError("CellAccessLost")<{
+  readonly refused: CellAccessError;
+}> {}
 
 const decodeFrame = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
 const decodeEvent = Schema.decodeUnknownEffect(CellEvent);
@@ -454,6 +482,9 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
       detail,
       ...(payload === undefined ? {} : { payload }),
     });
+  /** The tenant's refusal of T3 itself (401, 403, 503, a 4401 close), in words that say what to do. */
+  const refusedAccess = (status: number, body: string) =>
+    cellAccessError({ baseUrl, token, status, body });
   const nativeRef = (
     nativeId: string | null,
     strength: OrchestrationV2ProviderRef["strength"],
@@ -519,6 +550,21 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
       const inbox = yield* Queue.unbounded();
       const commands = yield* Semaphore.make(1);
       const eventPermit = yield* Semaphore.make(1);
+      /** The fiber holding the event permit, so a request it makes enters access loss without asking for the permit again. */
+      let permitHolder: Fiber.Fiber | undefined;
+      /** The event permit: every cell event, prompt answer, reconciliation and access loss is applied under it. */
+      const underPermit = (effect: Effect.Effect): Effect.Effect =>
+        eventPermit.withPermits(1)(
+          Effect.withFiber((fiber) => {
+            permitHolder = fiber;
+            return Effect.ensuring(
+              effect,
+              Effect.sync(() => {
+                permitHolder = undefined;
+              }),
+            );
+          }),
+        );
       let closed = false;
       let project: string | undefined;
       let tenantModel: string | undefined;
@@ -552,7 +598,14 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
        * entry leaves when that turn starts or the orchestrator clears it.
        */
       const resetWakes = new Set();
-      let ready: Deferred.Deferred | undefined;
+      let ready:
+        | Deferred.Deferred
+        | undefined;
+      /**
+       * The tenant refused this session's token (401), which never holds again. Set once, by
+       * `loseAccess`, never cleared: the session's reader is over until the instance gets a new token.
+       */
+      let accessLost: CellAccessError | undefined;
       const tasks = new Map();
       const finishedBackground: Array = [];
       const wakes: Array = [];
@@ -633,38 +686,69 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
           },
         });
       });
+      /**
+       * Access loss once a request was answered 401, entered under the event
+       * permit: directly when the requesting fiber holds it, else by taking
+       * it. The caller learns only that access is lost.
+       */
+      const enterAccessLoss = (refused: CellAccessError) =>
+        Effect.withFiber((fiber) =>
+          fiber === permitHolder ? loseAccess(refused) : underPermit(loseAccess(refused)),
+        ).pipe(
+          Effect.andThen(
+            Effect.suspend(() =>
+              Effect.fail(new CellAccessLost({ refused: accessLost ?? refused })),
+            ),
+          ),
+        );
+      /**
+       * Every cell request of the session, and the one place its answer is judged for access: a 401,
+       * whatever asked and whether or not its turn is still in progress, enters access loss before the
+       * caller sees it (`CellAccessLost`). A shorter bound goes in `timeoutMs`, never around the call.
+       */
       const request = (
         method: "GET" | "POST",
         path: string,
-        body?: string,
-        extraHeaders: Readonly> = {},
+        extra: {
+          readonly body?: string;
+          readonly headers?: Readonly>;
+          readonly timeoutMs?: number;
+        } = {},
       ) =>
-        options.transport
-          .request({
-            method,
-            url: cellApiUrl(baseUrl, project ?? "", path),
-            headers: {
-              ...headers,
-              "x-client": `t3-${input.providerSessionId}`,
-              ...extraHeaders,
-            },
-            ...(body === undefined ? {} : { body }),
-          })
-          .pipe(
-            Effect.timeout(requestTimeoutMs),
-            Effect.mapError((cause) =>
-              protocolError(
-                cause._tag === "TimeoutError"
-                  ? `Cell ${method} ${path} timed out`
-                  : `Cell ${method} ${path} failed: ${cause.detail}`,
-                cause,
-              ),
-            ),
-          );
+        Effect.suspend(() =>
+          accessLost !== undefined
+            ? Effect.fail(new CellAccessLost({ refused: accessLost }))
+            : options.transport
+                .request({
+                  method,
+                  url: cellApiUrl(baseUrl, project ?? "", path),
+                  headers: {
+                    ...headers,
+                    "x-client": `t3-${input.providerSessionId}`,
+                    ...extra.headers,
+                  },
+                  ...(extra.body === undefined ? {} : { body: extra.body }),
+                })
+                .pipe(
+                  Effect.timeout(extra.timeoutMs ?? requestTimeoutMs),
+                  Effect.mapError((cause) =>
+                    protocolError(
+                      cause._tag === "TimeoutError"
+                        ? `Cell ${method} ${path} timed out`
+                        : `Cell ${method} ${path} failed: ${cause.detail}`,
+                      cause,
+                    ),
+                  ),
+                  Effect.filterOrElse(
+                    (response) => response.status !== 401,
+                    (response) => enterAccessLoss(refusedAccess(401, response.body)),
+                  ),
+                ),
+        );
       const readState = Effect.fnUntraced(function* () {
         const response = yield* request("GET", "/state");
-        if (response.status === 401 || response.status === 403)
-          return yield* protocolError("The cell tenant rejected the access token");
+        if (CELL_ACCESS_STATUSES.has(response.status))
+          return yield* refusedAccess(response.status, response.body);
         if (response.status !== 200)
           return yield* protocolError(`The cell answered ${response.status} to GET state`);
         const state = yield* readCellState(response.body).pipe(
@@ -1090,7 +1174,31 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
         if (active !== turn) return;
         const completedAt = yield* DateTime.now;
         yield* closeOpenItems(turn, status === "completed" ? "failed" : status);
-        turn.providerTurn = { ...turn.providerTurn, status, completedAt };
+        // What the run used, as its `done` said: complete for a run that
+        // completed, partial for one that ended otherwise, whose last request
+        // may have been cut off before its usage was read.
+        const usage = turn.usage;
+        turn.providerTurn = {
+          ...turn.providerTurn,
+          status,
+          completedAt,
+          ...(usage === undefined
+            ? {}
+            : {
+                turnTokenUsage: {
+                  usageScope: "main_agent" as const,
+                  usageStatus:
+                    status === "completed" ? ("complete" as const) : ("partial" as const),
+                  inputTokens: usage.inputTokens,
+                  cachedInputTokens: usage.cachedTokens,
+                  outputTokens: usage.outputTokens,
+                  reasoningTokens: usage.reasoningTokens,
+                  hasSubagents: [...tasks.values()].some(
+                    (record) => record.owner?.providerTurnId === turn.scope.providerTurnId,
+                  ),
+                },
+              }),
+        };
         lastProviderTurn = turn.providerTurn;
         yield* emit({
           type: "provider_turn.updated",
@@ -1161,12 +1269,14 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
           driver: CELL_PROVIDER,
           nativeItemId: `${options.instanceId}:${project}:${kind === "wake" ? "run" : "restored"}:${runId}`,
         });
-      const enqueueWake = Effect.fnUntraced(function* (opening: CellRunEvent) {
+      /** The one place a wake is queued; a session whose token was refused queues none. */
+      const queueWake = (kind: PendingWake["kind"], runId: string, firstSeq: number) => {
+        if (accessLost !== undefined) return undefined;
         const wake: PendingWake = {
-          kind: "wake",
-          messageId: wakeMessageId("wake", opening.runId),
-          runId: opening.runId,
-          firstSeq: opening.seq,
+          kind,
+          messageId: wakeMessageId(kind, runId),
+          runId,
+          firstSeq,
           events: [],
           ended: false,
           lost: false,
@@ -1175,7 +1285,11 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
           dispatched: false,
         };
         wakes.push(wake);
-        yield* offerWake(wake);
+        return wake;
+      };
+      const enqueueWake = Effect.fnUntraced(function* (opening: CellRunEvent) {
+        const wake = queueWake("wake", opening.runId, opening.seq);
+        if (wake !== undefined) yield* offerWake(wake);
       });
       /**
        * The run the last session owned is queued like a wake, so what the cell
@@ -1185,22 +1299,13 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
        */
       const restoreRun = (run: RestoredRun) =>
         Effect.sync(() => {
-          wakes.push({
-            kind: "restored",
-            messageId: wakeMessageId("restored", run.runId),
-            runId: run.runId,
-            firstSeq: run.cursor,
-            events: [],
-            ended: false,
-            lost: false,
-            reset: false,
-            offered: false,
-            dispatched: false,
-          });
-          resumeCursor = run.cursor;
+          if (queueWake("restored", run.runId, run.cursor) !== undefined) {
+            resumeCursor = run.cursor;
+          }
         });
       const offerWake = Effect.fnUntraced(function* (wake: PendingWake) {
         wake.offered = true;
+        if (accessLost !== undefined) return;
         if (!thread?.appThreadId || !options.continuationRequests) return;
         const notification = backgroundWorkNotification(finishedBackground.splice(0));
         yield* options.continuationRequests.offer({
@@ -1212,9 +1317,9 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
           messageId: wake.messageId,
           ...(notification ? { notification } : {}),
           dispatchIfCurrent: (dispatch) =>
-            eventPermit.withPermits(1)(
+            underPermit(
               Effect.suspend(() =>
-                wakes.includes(wake) && !closed
+                wakes.includes(wake) && !closed && accessLost === undefined
                   ? dispatch.pipe(
                       Effect.tap(() =>
                         Effect.sync(() => {
@@ -1228,7 +1333,7 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
               ),
             ),
           clearIfCurrent: () =>
-            eventPermit.withPermits(1)(
+            underPermit(
               dropWake(wake).pipe(
                 Effect.tap(() =>
                   Effect.sync(() => {
@@ -1315,6 +1420,7 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
           case "error":
             return yield* publishNotice(turn, "Cell error", event.text);
           case "done": {
+            turn.usage = readRunUsage(event.usage);
             const outcome = classifyCellDone(event.error);
             if (outcome.kind === "completed") return yield* finish(turn, "completed");
             if (outcome.kind === "cancelled")
@@ -1357,6 +1463,31 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
       const releaseCandidates = Effect.fnUntraced(function* (turn: ActiveTurn) {
         for (const event of turn.candidates.splice(0)) yield* routeUnowned(event);
       });
+      /**
+       * Access loss, the terminal state of the session's reader (docs/internals/provider-cell.md):
+       * entered once, under the event permit, from `request` or a refused socket. Buffered wakes and
+       * candidates are discarded, the turn in progress gets the refusal as its one terminal.
+       */
+      const loseAccess = Effect.fnUntraced(function* (refused: CellAccessError) {
+        if (accessLost !== undefined) return;
+        accessLost = refused;
+        wakes.splice(0);
+        if (ready !== undefined) yield* Deferred.fail(ready, refused);
+        const turn = active;
+        if (turn !== undefined) {
+          turn.candidates.splice(0);
+          yield* finish(turn, "failed", refused.message, "provider_error", "reusable", {
+            code: refused.code,
+          });
+        }
+        yield* updateSession("error", refused.message);
+        yield* Effect.logWarning(
+          `The cell tenant refused the token of thread ${input.threadId}; the session stops reading the cell ${project ?? "?"}`,
+        );
+        const fiber = socketFiber;
+        socketFiber = undefined;
+        if (fiber) yield* Fiber.interrupt(fiber);
+      });
       const runsRun = (snapshot: CellSnapshot, runId: string) =>
         snapshot.running && snapshot.runId === runId;
       /**
@@ -1554,7 +1685,9 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
               `/events?after=${cursor}&limit=${EVENTS_PAGE_LIMIT}`,
             ).pipe(Effect.result);
             if (Result.isFailure(response)) {
-              return { kind: "failed", detail: response.failure.detail };
+              return response.failure._tag === "CellAccessLost"
+                ? { kind: "access_lost" }
+                : { kind: "failed", detail: response.failure.detail };
             }
             if (response.success.status !== 200) {
               return {
@@ -1653,6 +1786,7 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
         }
         const caughtUp: CatchUp =
           snapshot.seq > lastSeq ? yield* catchUp(lastSeq, snapshot.seq) : { kind: "complete" };
+        if (caughtUp.kind === "access_lost") return;
         if (caughtUp.kind === "failed") {
           if (!first) {
             // The cursor stays where the replay stopped; the next connection's snapshot presents the rest of the gap again.
@@ -1772,7 +1906,8 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
       yield* Stream.fromQueue(inbox).pipe(
         Stream.runForEach((entry) =>
           Effect.gen(function* () {
-            if (closed) return;
+            // A session whose token was refused reads nothing more, not even what was already received.
+            if (closed || accessLost !== undefined) return;
             switch (entry.type) {
               case "connected":
                 connections += 1;
@@ -1811,6 +1946,8 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
                 }
                 return yield* Effect.logDebug(`Cell socket closed: ${entry.detail}`);
               }
+              case "check_access":
+                return yield* checkAccess;
               case "frame": {
                 // A frame that is not an event of the protocol (not JSON, a
                 // known type with a bad number or a missing field) is a
@@ -1826,16 +1963,33 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
                 );
               }
             }
-          }).pipe(eventPermit.withPermits(1)),
+          }).pipe(
+            underPermit,
+            Effect.ensuring(
+              entry.type === "check_access"
+                ? Deferred.succeed(entry.decided, undefined)
+                : Effect.void,
+            ),
+          ),
         ),
         Effect.forkIn(scope),
       );
 
+      /**
+       * `GET state` after a socket closed with 4401 or never opened, under the event permit behind
+       * its frames. A 401 entered access loss in `request`; a 403 here is the token refused too.
+       */
+      const checkAccess: Effect.Effect = Effect.suspend(() => request("GET", "/state")).pipe(
+        Effect.flatMap((response) =>
+          response.status === 403 ? loseAccess(refusedAccess(403, response.body)) : Effect.void,
+        ),
+        Effect.ignore,
+      );
       const runSocket = Effect.fnUntraced(function* (projectName: string) {
         const url = cellSocketUrl(baseUrl, projectName, `t3-${input.providerSessionId}`);
         let failures = 0;
         for (;;) {
-          if (closed) return;
+          if (closed || accessLost !== undefined) return;
           let connectedAt: number | undefined;
           const broken = yield* Deferred.make();
           const outcome = yield* Effect.scoped(
@@ -1871,6 +2025,15 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
             ? `${outcome.failure.detail} (${url})`
             : `socket ended (${url})`;
           yield* Queue.offer(inbox, { type: "disconnected", detail });
+          // A socket closed with 4401, or never opened, asks over HTTP once, behind its frames: a refused
+          // token ends the reading instead of a reconnect loop. Anything else reconnects.
+          const closeCode = Result.isFailure(outcome) ? outcome.failure.closeCode : undefined;
+          if (connectedAt === undefined || closeCode === CELL_SESSION_CLOSE) {
+            const decided = yield* Deferred.make();
+            yield* Queue.offer(inbox, { type: "check_access", decided });
+            yield* Deferred.await(decided);
+            if (accessLost !== undefined) return;
+          }
           const now = yield* Clock.currentTimeMillis;
           const healthy = connectedAt !== undefined && now - connectedAt >= HEALTHY_CONNECTION_MS;
           failures = healthy ? 0 : failures + 1;
@@ -1896,7 +2059,7 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
           yield* Queue.shutdown(inbox);
           // `end`, not `shutdown`: the terminal just emitted must still reach the consumer.
           yield* Queue.end(events);
-        }).pipe(eventPermit.withPermits(1)),
+        }).pipe(underPermit),
       );
 
       const checkModel = (model: string): Effect.Effect =>
@@ -1907,6 +2070,8 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
         args: ProviderAdapter.ProviderAdapterV2EnsureThreadInput,
       ) {
         if (closed) return yield* protocolError("Cell session is closed; reopen it");
+        // Nothing reattaches a session whose token was refused, so a retried attach asks nothing.
+        if (accessLost !== undefined) return yield* accessLost;
         if (active) return yield* protocolError("Cannot change cell threads during an active turn");
         yield* checkRuntimeMode(args.runtimePolicy);
         const existing = args.existingProviderThread;
@@ -1926,6 +2091,8 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
         if (thread) return yield* protocolError("Open a new session to attach another cell");
         project = requested;
         const state = yield* readState().pipe(
+          // A 401 entered access loss in `request`: the attach fails with the refusal.
+          Effect.catchTags({ CellAccessLost: (lost) => Effect.fail(lost.refused) }),
           Effect.onError(() =>
             Effect.sync(() => {
               project = undefined;
@@ -1991,7 +2158,7 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
             };
         const connected = yield* Deferred.make<
           void,
-          ProviderAdapter.ProviderAdapterProtocolError
+          ProviderAdapter.ProviderAdapterProtocolError | CellAccessError
         >();
         ready = connected;
         socketFiber = yield* runSocket(requested).pipe(Effect.forkIn(scope));
@@ -2055,6 +2222,21 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
           return yield* accept(turn, { id: echoed.runId, firstSeq: echoed.seq });
         }
         const state = yield* readState().pipe(Effect.result);
+        // A 401 entered access loss in `request`, which ended the turn.
+        if (Result.isFailure(state) && state.failure._tag === "CellAccessLost") return;
+        if (Result.isFailure(state) && state.failure._tag === "CellAccessError") {
+          yield* releaseCandidates(turn);
+          return yield* finish(
+            turn,
+            "failed",
+            state.failure.message,
+            "provider_error",
+            "reusable",
+            {
+              code: state.failure.code,
+            },
+          );
+        }
         if (Result.isFailure(state)) {
           yield* releaseCandidates(turn);
           return yield* finish(
@@ -2066,6 +2248,7 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
         }
         if (state.success.seq > lastSeq) {
           const caughtUp = yield* catchUp(lastSeq, state.success.seq);
+          if (caughtUp.kind === "access_lost") return;
           if (caughtUp.kind !== "complete") {
             yield* releaseCandidates(turn);
             return yield* finish(
@@ -2154,6 +2337,7 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
           candidates: [],
           snapshot: undefined,
           head: undefined,
+          usage: undefined,
         };
         yield* Effect.gen(function* () {
           thread = turnInput.providerThread;
@@ -2175,6 +2359,14 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
           yield* updateSession("running");
           for (const record of republish) yield* publishTask(record);
           republish.clear();
+          // The tenant refused this session's token: the turn ends with the
+          // refusal, which says how to make a new token, and nothing is sent.
+          if (accessLost !== undefined) {
+            yield* finish(turn, "failed", accessLost.message, "provider_error", "reusable", {
+              code: accessLost.code,
+            });
+            return yield* updateSession("error", accessLost.message);
+          }
           if (turnInput.runtimePolicy.runtimeMode !== FULL_ACCESS) {
             return yield* finish(
               turn,
@@ -2225,19 +2417,26 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
           // naming it.
           const restored = wakes.find((wake) => wake.kind === "restored" && !wake.offered);
           if (restored !== undefined) yield* offerWake(restored);
-        }).pipe(eventPermit.withPermits(1));
+        }).pipe(underPermit);
         // `active` is read through a call: the guard above narrowed the captured
         // variable to undefined for the rest of this function, which is stale.
         // A turn that adopted a run has nothing to post.
         if (continuation || !isActive(turn) || turn.run !== undefined) return;
-        const outcome = yield* request("POST", "/prompt", turnInput.message.text, {
-          "x-request-id": turn.requestId,
+        const outcome = yield* request("POST", "/prompt", {
+          body: turnInput.message.text,
+          headers: { "x-request-id": turn.requestId },
         }).pipe(Effect.result);
         yield* Effect.gen(function* () {
+          // Access loss, entered by `request` on a 401 or by any other path
+          // while the prompt was in flight, has ended the turn and discarded
+          // what it buffered: nothing of the answer is read.
+          if (accessLost !== undefined) return;
           // A turn that ended while the prompt was in flight leaves its candidates to the unowned path.
           if (!isActive(turn)) return yield* releaseCandidates(turn);
           if (Result.isFailure(outcome)) {
-            return yield* resolveLostPrompt(turn, outcome.failure.detail);
+            return outcome.failure._tag === "CellAccessLost"
+              ? undefined
+              : yield* resolveLostPrompt(turn, outcome.failure.detail);
           }
           const response = outcome.success;
           if (response.status === 202) {
@@ -2257,9 +2456,32 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
               `the cell answered ${response.status} to the prompt`,
             );
           }
+          // A 403 or 503 refuses this request, not the token (a 401 never gets here): the turn fails, the session reads on.
+          if (refusal === undefined && CELL_ACCESS_STATUSES.has(response.status)) {
+            const refused = refusedAccess(response.status, response.body);
+            yield* releaseCandidates(turn);
+            return yield* finish(turn, "failed", refused.message, "provider_error", "reusable", {
+              code: refused.code,
+            });
+          }
           // Nothing broadcast before the refusal was T3's run.
           yield* releaseCandidates(turn);
-          const reason = refusal?.error ?? response.body.trim().slice(0, 500);
+          const reason = (refusal?.error ?? response.body.trim().slice(0, 500)).replace(
+            /[.\s]+$/,
+            "",
+          );
+          // A prompt refused because the tenant's daily allowance is spent is
+          // the usage limit a run's `done` reports, with when it renews.
+          if (refusal?.code === "usage_limit") {
+            return yield* finish(
+              turn,
+              "failed",
+              `The cell did not start the turn: ${reason}.`,
+              "usage_limit",
+              "reusable",
+              { code: "usage_limit", resetAt: refusal.resetAt },
+            );
+          }
           if (response.status === 409) {
             return yield* finish(
               turn,
@@ -2275,11 +2497,9 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
             turn,
             "failed",
             `The cell answered ${response.status} to the prompt${reason ? `: ${reason}` : ""}`,
-            refusal !== undefined || response.status === 401 || response.status === 403
-              ? "provider_error"
-              : "transport_error",
+            refusal !== undefined ? "provider_error" : "transport_error",
           );
-        }).pipe(eventPermit.withPermits(1));
+        }).pipe(underPermit);
       });
 
       const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
@@ -2288,16 +2508,24 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
         providerSessionId: input.providerSessionId,
         providerSession: session,
         events: Stream.fromQueue(events),
+        // A session whose token was refused reads nothing more, so it waits on nothing.
         hasPendingBackgroundWork: Effect.sync(
           () =>
-            wakes.length > 0 ||
-            [...tasks.values()].some((record) => taskStatus(record.task.status) === "running"),
+            accessLost === undefined &&
+            (wakes.length > 0 ||
+              [...tasks.values()].some((record) => taskStatus(record.task.status) === "running")),
         ),
         // A deleted thread's running tasks are asked to stop, best effort (the deletion settled their
         // rows). Each cancel has its own bound and a few go out at a time, so one unanswered cancel
-        // does not hold the others back.
+        // does not hold the others back. A refused token stops them: `request` sends nothing then.
         cancelBackgroundWork: Effect.fn("CellAdapterV2.cancelBackgroundWork")(function* (args) {
-          if (closed || project === undefined || thread?.appThreadId !== args.threadId) return;
+          if (
+            closed ||
+            accessLost !== undefined ||
+            project === undefined ||
+            thread?.appThreadId !== args.threadId
+          )
+            return;
           const running = [...tasks.values()].filter(
             (record) => taskStatus(record.task.status) === "running",
           );
@@ -2305,19 +2533,16 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
             running,
             (record) =>
               Effect.gen(function* () {
+                if (accessLost !== undefined) return;
                 const taskId = record.task.taskId;
                 const response = yield* request(
                   "POST",
                   `/tasks/${encodeURIComponent(taskId)}/cancel`,
-                ).pipe(
-                  Effect.timeoutOrElse({
-                    duration: Math.min(requestTimeoutMs, TASK_CANCEL_TIMEOUT_MS),
-                    orElse: () =>
-                      Effect.fail(protocolError(`Cell POST cancel of task ${taskId} timed out`)),
-                  }),
-                  Effect.result,
-                );
+                  { timeoutMs: Math.min(requestTimeoutMs, TASK_CANCEL_TIMEOUT_MS) },
+                ).pipe(Effect.result);
                 if (Result.isFailure(response)) {
+                  // A 401 entered access loss in `request`: it is the session's error already.
+                  if (response.failure._tag === "CellAccessLost") return;
                   yield* Effect.logWarning(
                     `Could not ask the cell to cancel task ${taskId} (${args.reason}): ${response.failure.message}`,
                   );
@@ -2392,7 +2617,7 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
             // is in progress: a replay that has just ended the turn leaves
             // nothing to cancel, and the run the cancel names keeps the cell
             // from ending a later run instead.
-            const target = yield* eventPermit.withPermits(1)(
+            const target = yield* underPermit(
               Effect.sync(() => {
                 const turn = active;
                 if (turn === undefined || turn.providerTurn.id !== args.providerTurnId) {
@@ -2418,19 +2643,22 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
                 providerTurnId: args.providerTurnId,
                 timeoutMs,
               });
-            // Only the cell's 4xx refuses a cancel. A 5xx (the cell's or a gateway's), a lost or
-            // hung request, or a missing `done` leaves it unconfirmed.
+            // A 401 is access loss (entered in `request`, which ends the turn with the refusal), any
+            // other 4xx refuses the cancel, and a 5xx, a lost or hung request, or a missing `done`
+            // leaves it unconfirmed.
             const cancelMs = Math.min(requestTimeoutMs, STOP_CANCEL_TIMEOUT_MS);
             const runId = turn.run.id;
-            const response = yield* request("POST", "/cancel", undefined, {
-              "x-run-id": runId,
+            const response = yield* request("POST", "/cancel", {
+              headers: { "x-run-id": runId },
+              timeoutMs: cancelMs,
             }).pipe(
-              Effect.timeout(cancelMs),
+              Effect.catchTags({ CellAccessLost: () => Effect.succeed("access lost" as const) }),
               Effect.tapError((cause) =>
                 Effect.logWarning(`The cell did not answer the cancel of run ${runId}`, cause),
               ),
               Effect.mapError(() => unconfirmed(cancelMs)),
             );
+            if (response === "access lost") return;
             if (response.status === 409) {
               // The cell runs another run now, so this turn's has ended; its
               // `done` reaches the turn live or by replay and settles it.
@@ -2442,6 +2670,9 @@ export const makeCellAdapterV2 = Effect.fn("makeCellAdapterV2")(function* (
                 `The cancel of run ${runId} got ${response.status}, so it is unconfirmed`,
               );
               return yield* unconfirmed(cancelMs);
+            } else if (response.status === 403) {
+              // A 403 refuses the Stop, not the token: say why; the run goes on.
+              return yield* refusedAccess(response.status, response.body);
             } else if (response.status >= 400) {
               return yield* protocolError(`The cell answered ${response.status} to cancel`);
             }
diff --git a/packages/provider-cell/src/server/errors.ts b/packages/provider-cell/src/server/errors.ts
index 2a72e5c4a06dc..7d151523c3aea 100644
--- a/packages/provider-cell/src/server/errors.ts
+++ b/packages/provider-cell/src/server/errors.ts
@@ -26,3 +26,78 @@ export class CellModelError extends Schema.TaggedError()("CellMo
     return `This cell runs ${this.available} and cannot switch to ${this.requested}. Pick Default or ${this.available} in the model picker and send the message again.`;
   }
 }
+
+/**
+ * How a tenant refuses T3 rather than one request: 401 no session (no token, or an ended one), 403
+ * judged another site's or not allowed, 503 a base domain without SESSION_SECRET. 4401 is a socket's 401.
+ */
+export const CELL_ACCESS_STATUSES: ReadonlySet = new Set([401, 403, 503]);
+/** The close code a cell gives a socket whose session no longer holds. */
+export const CELL_SESSION_CLOSE = 4401;
+
+function tenantName(baseUrl: string): string {
+  try {
+    return new URL(baseUrl).host;
+  } catch {
+    return baseUrl;
+  }
+}
+
+const TOKEN_STEPS = (baseUrl: string) =>
+  `Log in at ${baseUrl.replace(/\/+$/, "")}/login, choose Create an API token, and paste the token into Settings → Providers → Cell → Access token.`;
+
+/**
+ * The cell's own words: the body's first line, bounded, without the 401's note on how to log in
+ * (the message gives T3's steps, and redaction would mangle "Authorization: Bearer"). HTML or empty: null.
+ */
+function cellRefusalText(body: string): string | null {
+  const line = (body.trim().split("\n", 1)[0] ?? "")
+    .replace(/;\s*log in at \/login\b.*$/, "")
+    .trim();
+  if (line === "" || line.startsWith("<")) return null;
+  return line.length > 400 ? `${line.slice(0, 399)}…` : line;
+}
+
+/**
+ * The tenant refused T3's credentials or serves nothing: what the cell said
+ * and what the user does about it. One message for the status probe, a
+ * thread that cannot attach, a prompt refused and a socket the cell closed.
+ */
+export class CellAccessError extends Schema.TaggedError()("CellAccessError", {
+  baseUrl: Schema.String,
+  status: Schema.Number,
+  /** Whether the instance has a token to send; a 401 without one says so. */
+  tokenSet: Schema.Boolean,
+  /** The cell's text, see `cellRefusalText`. */
+  said: Schema.NullOr(Schema.String),
+}) {
+  /** The failure code T3 records with the turn. */
+  get code(): string {
+    return this.status === 503 ? "cell_unavailable" : "cell_unauthorized";
+  }
+
+  override get message(): string {
+    const said = this.said === null ? ` (HTTP ${this.status})` : ` The tenant said: ${this.said}`;
+    // Named by its host: T3's failure redaction rewrites a bare origin with a trailing slash.
+    const tenant = tenantName(this.baseUrl);
+    if (this.status === 503)
+      return `The cell tenant ${tenant} is not serving requests; whoever runs it has to finish its setup.${said}`;
+    if (this.status === 403) return `The cell tenant ${tenant} refused T3's request.${said}`;
+    if (!this.tokenSet)
+      return `The cell tenant ${tenant} needs an access token. ${TOKEN_STEPS(this.baseUrl)}${said}`;
+    return `The cell tenant ${tenant} refused T3's access token. A token ends after 90 days, when the tenant's passphrase is reset with a new invite code, and when the operator suspends the tenant. ${TOKEN_STEPS(this.baseUrl)}${said}`;
+  }
+}
+
+export const cellAccessError = (input: {
+  readonly baseUrl: string;
+  readonly token: string;
+  readonly status: number;
+  readonly body: string;
+}) =>
+  new CellAccessError({
+    baseUrl: input.baseUrl.replace(/\/+$/, ""),
+    status: input.status === CELL_SESSION_CLOSE ? 401 : input.status,
+    tokenSet: input.token !== "",
+    said: cellRefusalText(input.body),
+  });
diff --git a/packages/provider-cell/src/server/protocol.test.ts b/packages/provider-cell/src/server/protocol.test.ts
index 9c14a894263b8..353d4dc8ee72d 100644
--- a/packages/provider-cell/src/server/protocol.test.ts
+++ b/packages/provider-cell/src/server/protocol.test.ts
@@ -14,6 +14,7 @@ import {
   readCellRefusal,
   readCellState,
   readPromptAccepted,
+  readRunUsage,
 } from "./protocol.ts";
 
 describe("cellProjectName", () => {
@@ -147,10 +148,23 @@ describe("prompt and cancel answers", () => {
     expect(readPromptAccepted('{"runId":"run-7","seq":-1}')).toBeUndefined();
     expect(readPromptAccepted('{"runId":"run-7","seq":1.5}')).toBeUndefined();
     expect(readPromptAccepted("")).toBeUndefined();
-    expect(readCellRefusal('{"runId":"run-3"}')).toEqual({ runId: "run-3", error: null });
+    const plain = { code: null, resetAt: null };
+    expect(readCellRefusal('{"runId":"run-3"}')).toEqual({ runId: "run-3", error: null, ...plain });
     expect(readCellRefusal('{"runId":null,"error":"The prompt is empty"}')).toEqual({
       runId: null,
       error: "The prompt is empty",
+      ...plain,
+    });
+    // A refusal may name its kind as a `done` error does: the spent allowance.
+    expect(
+      readCellRefusal(
+        '{"runId":null,"error":"Tenant acme has used its tokens","code":"usage_limit","resetAt":"2026-11-05T00:00:00.000Z"}',
+      ),
+    ).toEqual({
+      runId: null,
+      error: "Tenant acme has used its tokens",
+      code: "usage_limit",
+      resetAt: "2026-11-05T00:00:00.000Z",
     });
     // A proxy's error page is nobody's refusal, and neither is a gateway's
     // JSON error without the run, nor a body naming no run and no reason.
@@ -162,10 +176,35 @@ describe("prompt and cancel answers", () => {
     expect(readCellRefusal('{"runId":"run-3","error":"busy"}')).toEqual({
       runId: "run-3",
       error: "busy",
+      ...plain,
     });
   });
 });
 
+describe("readRunUsage", () => {
+  it("reads a done's usage when every count is a non-negative integer, and nothing otherwise", () => {
+    const usage = {
+      requests: 2,
+      inputTokens: 1_200,
+      cachedTokens: 300,
+      outputTokens: 40,
+      reasoningTokens: 8,
+    };
+    expect(readRunUsage(usage)).toEqual(usage);
+    expect(readRunUsage({ ...usage, later: "ignored" })).toEqual(usage);
+    expect(readRunUsage(undefined)).toBeUndefined();
+    expect(readRunUsage(null)).toBeUndefined();
+    expect(readRunUsage({ ...usage, outputTokens: -1 })).toBeUndefined();
+    expect(readRunUsage({ ...usage, inputTokens: 1.5 })).toBeUndefined();
+    expect(readRunUsage({ requests: 1 })).toBeUndefined();
+  });
+
+  it("keeps a done readable whatever its usage carries", () => {
+    const decode = Schema.decodeUnknownOption(CellEvent);
+    assert.isTrue(Option.isSome(decode({ type: "done", runId: "r", seq: 3, usage: "odd" })));
+  });
+});
+
 describe("CellEvent", () => {
   const decode = Schema.decodeUnknownOption(CellEvent);
 
diff --git a/packages/provider-cell/src/server/protocol.ts b/packages/provider-cell/src/server/protocol.ts
index 02453e877e7fb..9be8d6b9524b6 100644
--- a/packages/provider-cell/src/server/protocol.ts
+++ b/packages/provider-cell/src/server/protocol.ts
@@ -183,9 +183,11 @@ export const CellEvent = Schema.Union([
     kind: Schema.optional(Schema.String),
     ...sequenced,
   }),
+  /** `usage` is what the run used, read leniently (`readRunUsage`): a count is reporting, not protocol. */
   Schema.Struct({
     type: Schema.Literal("done"),
     error: Schema.optional(Schema.NullOr(CellDoneError)),
+    usage: Schema.optional(Schema.Unknown),
     ...runScoped,
   }),
   Schema.Struct({ type: Schema.Literal("reset"), ...sequenced }),
@@ -225,11 +227,20 @@ export type CellPromptAccepted = typeof CellPromptAccepted.Type;
 export const CellRefusal = Schema.Struct({
   runId: Schema.NullOr(Schema.String),
   error: Schema.optional(Schema.String),
+  code: Schema.optional(Schema.String),
+  resetAt: Schema.optional(Schema.String),
 });
 
+/**
+ * A refusal may name its kind as a `done` error does: `code: "usage_limit"`
+ * with `resetAt` is a prompt refused because the tenant's daily model
+ * allowance is spent until then.
+ */
 export interface CellRefusal {
   readonly runId: string | null;
   readonly error: string | null;
+  readonly code: string | null;
+  readonly resetAt: string | null;
 }
 
 /**
@@ -262,7 +273,32 @@ export function readCellRefusal(body: string): CellRefusal | undefined {
   const refusal = Option.getOrUndefined(decodeRefusal(body));
   if (refusal === undefined) return undefined;
   if (refusal.runId === null && refusal.error === undefined) return undefined;
-  return { runId: refusal.runId, error: refusal.error ?? null };
+  return {
+    runId: refusal.runId,
+    error: refusal.error ?? null,
+    code: refusal.code ?? null,
+    resetAt: refusal.resetAt ?? null,
+  };
+}
+
+/**
+ * What a run used, as `done` reports it: the model replies read and the
+ * tokens they billed. The input counts cached reads, the output reasoning.
+ */
+const CellRunUsage = Schema.Struct({
+  requests: Seq,
+  inputTokens: Seq,
+  cachedTokens: Seq,
+  outputTokens: Seq,
+  reasoningTokens: Seq,
+});
+export type CellRunUsage = typeof CellRunUsage.Type;
+
+const decodeRunUsage = Schema.decodeUnknownOption(CellRunUsage);
+
+/** A `done`'s `usage` when every count is a non-negative integer; anything else reports nothing. */
+export function readRunUsage(usage: unknown): CellRunUsage | undefined {
+  return Option.getOrUndefined(decodeRunUsage(usage));
 }
 
 export type CellDoneOutcome =
diff --git a/packages/provider-cell/src/server/status.ts b/packages/provider-cell/src/server/status.ts
index d1b0e1ab1d8db..33af2564c82d4 100644
--- a/packages/provider-cell/src/server/status.ts
+++ b/packages/provider-cell/src/server/status.ts
@@ -1,6 +1,8 @@
 /**
  * The status probe reads `GET state` of a dedicated probe project and never
  * posts a prompt, so a background refresh cannot start a run or spend tokens.
+ * It sends the instance's token, so a tenant with the login on answers it as
+ * it will answer the threads: a refusal shows here with what to do.
  */
 import type { ServerProviderModel } from "@t3tools/contracts";
 import * as DateTime from "effect/DateTime";
@@ -20,6 +22,7 @@ import {
   cellProbeProjectName,
   readCellState,
 } from "./protocol.ts";
+import { CELL_ACCESS_STATUSES, cellAccessError } from "./errors.ts";
 import type { CellTransport } from "./transport.ts";
 
 const CELL_PRESENTATION = {
@@ -110,13 +113,21 @@ export const checkCellProviderStatus = Effect.fn("checkCellProviderStatus")(func
     });
   }
   const { status, body } = response.success.value;
-  if (status === 401 || status === 403) {
+  // The tenant refused the token, the request, or everything: a cell tenant
+  // all the same, whose answer says what to do.
+  if (CELL_ACCESS_STATUSES.has(status)) {
+    const refused = cellAccessError({
+      baseUrl: settings.baseUrl,
+      token: settings.token,
+      status,
+      body,
+    });
     return snapshot({
       installed: true,
       version: null,
       status: "error",
-      auth: { status: "unknown" },
-      message: "The cell tenant rejected the access token. Update it in Settings → Providers.",
+      auth: { status: status === 401 ? "unauthenticated" : "unknown" },
+      message: refused.message,
     });
   }
   const state = yield* readCellState(body).pipe(Effect.result);
@@ -140,21 +151,21 @@ export const checkCellProviderStatus = Effect.fn("checkCellProviderStatus")(func
     });
   }
   const version = state.success.version ?? null;
+  // A state read with the token is the token accepted; without one the
+  // tenant has no login and nothing is known about accounts.
+  const auth = { status: settings.token ? "authenticated" : "unknown" } as const;
   if (!state.success.configured) {
     return snapshot(
       {
         installed: true,
         version,
         status: "warning",
-        auth: { status: "unknown" },
+        auth,
         message:
           "The tenant has no model API key, so prompts will be refused until OPENAI_API_KEY is configured on celld.",
       },
       state.success.model,
     );
   }
-  return snapshot(
-    { installed: true, version, status: "ready", auth: { status: "unknown" } },
-    state.success.model,
-  );
+  return snapshot({ installed: true, version, status: "ready", auth }, state.success.model);
 });
diff --git a/packages/provider-cell/src/server/transport.ts b/packages/provider-cell/src/server/transport.ts
index 7c378a9a5c445..3c1b2c488537a 100644
--- a/packages/provider-cell/src/server/transport.ts
+++ b/packages/provider-cell/src/server/transport.ts
@@ -36,6 +36,8 @@ export class CellTransportError extends Schema.TaggedError()
     operation: Schema.String,
     detail: Schema.String,
     closed: Schema.optional(Schema.Boolean),
+    /** The close code the socket ended with, when it was closed rather than lost. */
+    closeCode: Schema.optional(Schema.Number),
     cause: Schema.optional(Schema.Defect()),
   },
 ) {
@@ -113,15 +115,17 @@ export const makeHttpCellTransport: Effect.Effect<
           Effect.map((frames) =>
             frames.map((frame) => (typeof frame === "string" ? frame : decoder.decode(frame))),
           ),
-          Effect.mapError(
-            (cause) =>
-              new CellTransportError({
-                operation: `read ${input.url}`,
-                detail: errorDetail(cause),
-                closed: true,
-                cause,
-              }),
-          ),
+          Effect.mapError((cause) => {
+            const closeCode =
+              cause.reason._tag === "SocketCloseError" ? cause.reason.code : undefined;
+            return new CellTransportError({
+              operation: `read ${input.url}`,
+              detail: errorDetail(cause),
+              closed: true,
+              ...(closeCode === undefined ? {} : { closeCode }),
+              cause,
+            });
+          }),
         ),
       };
     }),
diff --git a/packages/provider-cell/src/testing/fakeCellServer.ts b/packages/provider-cell/src/testing/fakeCellServer.ts
index 2e2e360d3df24..88e3fcec21d69 100644
--- a/packages/provider-cell/src/testing/fakeCellServer.ts
+++ b/packages/provider-cell/src/testing/fakeCellServer.ts
@@ -1,7 +1,8 @@
 // @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off preferSchemaOverJson:off - a plain Node stand-in for celld, used by recorders and tests.
 /**
  * A stand-in for a celld tenant speaking cell protocol 3 on a local port, with a scripted model:
- * it answers the shared fixture prompts with the real protocol's shape (docs/internals/provider-cell.md).
+ * it answers the shared fixture prompts with the real protocol's shape, and with a `token` the
+ * login era's refusals, revocation, fail-closed 503 and spent allowance (docs/internals/provider-cell.md).
  */
 import * as NodeHttp from "node:http";
 import { WebSocketServer, type WebSocket } from "ws";
@@ -66,6 +67,13 @@ export interface FakeCellServer {
   readonly onceConnected: (project: string) => Promise;
   /** Resolves when the project's current run ends, or at once when it is idle. */
   readonly onceIdle: (project: string) => Promise;
+  /**
+   * The token stops holding (new invite code, suspension): later requests are refused 401, a run in
+   * progress stops, its sockets close 4401. An idle project's sockets stay open, as in the cell.
+   */
+  readonly revokeToken: (reason?: string) => void;
+  /** The tenant's daily allowance is spent until `resetAt`: prompts are refused as the usage limit. */
+  readonly spendAllowance: (resetAt: string | null) => void;
   /** Ends the project's run as the cell's own page would, without going through the client. */
   readonly cancelRun: (project: string) => void;
   /** Starts a run as another client of the same cell would; T3's prompt then answers 409. */
@@ -92,6 +100,8 @@ interface Run {
   /** The `x-request-id` the prompt was posted with; a wake or a socket prompt has none. */
   readonly requestId: string | null;
   cancel: () => void;
+  /** Ends the run as the cell ends one whose session no longer holds. */
+  stop: (reason: string) => void;
 }
 
 type PersistedEvent = Record & { readonly seq: number };
@@ -128,6 +138,13 @@ const EVENT_RETENTION = 20_000;
 const RUN_RETENTION = 200;
 const PAGE_LIMIT = 1_000;
 const REQUEST_ID_MAX_LENGTH = 128;
+/** The close of a socket whose session no longer holds; a close reason holds at most 123 bytes. */
+const SESSION_CLOSE = 4401;
+const CLOSE_REASON_BYTES = 123;
+const BEARER_NOTE =
+  "log in at /login, or send the token of POST /login/token as Authorization: Bearer";
+const LOGIN_UNSET =
+  "Service unavailable: SESSION_SECRET is not set, so no request to a cell can be checked for a login; set SESSION_SECRET and INVITE_SECRET as secrets of this deployment (docs/cell-edition.md, Login)";
 
 /**
  * Answers the shared fixture prompts: exact replies, a shell that waits for Stop, the resume prompt,
@@ -189,6 +206,8 @@ export async function startFakeCellServer(
     readonly configured?: boolean;
     readonly version?: string;
     readonly token?: string;
+    /** Every request is answered 503, as a tenant with a base domain and no SESSION_SECRET. */
+    readonly loginUnset?: boolean;
     /** How long a delegated child works after its parent's run ended. */
     readonly delegateDelayMs?: number;
   } = {},
@@ -199,6 +218,10 @@ export async function startFakeCellServer(
   const projects = new Map();
   const timers = new Set();
   let rejectSockets = false;
+  /** Why the token no longer holds, once it was revoked. */
+  let revoked: string | undefined;
+  /** When the spent daily allowance renews, while it is spent. */
+  let allowanceResetAt: string | null | undefined;
   const project = (name: string): Project => {
     let existing = projects.get(name);
     if (!existing) {
@@ -278,8 +301,15 @@ export async function startFakeCellServer(
     send(target, sequenced);
     return seq;
   };
-  const authorized = (request: NodeHttp.IncomingMessage) =>
-    !options.token || request.headers.authorization === `Bearer ${options.token}`;
+  /** Why a request is refused 401, in the cell's words, or undefined when it holds a session. */
+  const unauthorized = (request: NodeHttp.IncomingMessage): string | undefined => {
+    if (!options.token) return undefined;
+    const header = request.headers.authorization;
+    if (header === undefined) return "no session of tenant fake came with this request";
+    if (header !== `Bearer ${options.token}`) return "the session is not signed by this server";
+    return revoked;
+  };
+  const refusalText = (reason: string) => `Unauthorized: ${reason}; ${BEARER_NOTE}`;
   const later = (ms: number, run: () => void) => {
     const timer = setTimeout(() => {
       timers.delete(timer);
@@ -336,6 +366,8 @@ export async function startFakeCellServer(
     const runId = `run-${++target.runs}`;
     const scope = { runId };
     let cancelled = false;
+    let stopped: string | undefined;
+    let requests = 0;
     let wakeCancel: (() => void) | undefined;
     const opening = target.seq + 1;
     const run: Run = {
@@ -346,6 +378,11 @@ export async function startFakeCellServer(
         cancelled = true;
         wakeCancel?.();
       },
+      stop: (reason) => {
+        stopped = reason;
+        cancelled = true;
+        wakeCancel?.();
+      },
     };
     target.run = run;
     broadcast(
@@ -361,6 +398,8 @@ export async function startFakeCellServer(
       let failure: Extract | undefined;
       for (const step of steps) {
         if (cancelled) break;
+        // Every step but a hold or a failure is one model reply read.
+        if (step.type !== "hold" && step.type !== "fail") requests += 1;
         switch (step.type) {
           case "assistant":
             target.answers.push(step.text);
@@ -462,19 +501,31 @@ export async function startFakeCellServer(
         }
         if (failure !== undefined) break;
       }
-      const error = cancelled
-        ? { code: "cancelled", message: "Cancelled" }
-        : failure === undefined
-          ? undefined
-          : {
-              code: failure.code ?? "provider_error",
-              message: failure.error,
-              ...(failure.status === undefined ? {} : { status: failure.status }),
-              ...(failure.resetAt === undefined ? {} : { resetAt: failure.resetAt }),
-            };
+      const error = stopped
+        ? { code: "tool_error", message: `The run was stopped: ${stopped}` }
+        : cancelled
+          ? { code: "cancelled", message: "Cancelled" }
+          : failure === undefined
+            ? undefined
+            : {
+                code: failure.code ?? "provider_error",
+                message: failure.error,
+                ...(failure.status === undefined ? {} : { status: failure.status }),
+                ...(failure.resetAt === undefined ? {} : { resetAt: failure.resetAt }),
+              };
+      // What the run used, as the cell counts it: the replies read and the tokens they billed.
+      const usage = {
+        requests,
+        inputTokens: 1_000 * requests,
+        cachedTokens: 0,
+        outputTokens: 50 * requests,
+        reasoningTokens: 0,
+      };
       broadcast(
         target,
-        error === undefined ? { type: "done", ...scope } : { type: "done", error, ...scope },
+        error === undefined
+          ? { type: "done", usage, ...scope }
+          : { type: "done", error, usage, ...scope },
       );
       if (target.run === run) target.run = null;
       target.lastRun = run;
@@ -496,7 +547,12 @@ export async function startFakeCellServer(
       response.end(JSON.stringify(body));
     };
     if (!match) return text(404, "Not found");
-    if (!authorized(request)) return text(401, "Unauthorized");
+    if (options.loginUnset) {
+      response.writeHead(503, { "content-type": "text/plain; charset=utf-8", "retry-after": "60" });
+      return response.end(LOGIN_UNSET);
+    }
+    const refused = unauthorized(request);
+    if (refused !== undefined) return text(401, refusalText(refused));
     const target = project(decodeURIComponent(match[1]!));
     const path = match[2] ?? "/";
     if (path === "/state" && request.method === "GET") {
@@ -520,6 +576,16 @@ export async function startFakeCellServer(
       );
       if (repeated) return json(202, { runId: repeated.runId, seq: repeated.seq });
       if (target.run) return json(409, { runId: target.run.runId });
+      // The cell words a spent allowance in its refusal; it names the limit
+      // and its renewal as a `done` error does.
+      if (allowanceResetAt !== undefined && body.trim()) {
+        return json(409, {
+          runId: null,
+          error: `Tenant fake has used its model tokens for today (UTC). The allowance renews at ${allowanceResetAt ?? "midnight UTC"}.`,
+          code: "usage_limit",
+          ...(allowanceResetAt === null ? {} : { resetAt: allowanceResetAt }),
+        });
+      }
       const refusal = refusalOf(target, body);
       if (refusal !== undefined) return json(409, { runId: null, error: refusal });
       const accepted = startRun(target, body, "user", undefined, requestId);
@@ -588,7 +654,23 @@ export async function startFakeCellServer(
   server.on("upgrade", (request, socket, head) => {
     const url = new URL(request.url ?? "/", "http://fake-cell");
     const match = /^\/cell\/([^/]+)\/ws$/.exec(url.pathname);
-    if (!match || !authorized(request) || rejectSockets) {
+    // A refused handshake is answered as the cell answers it, with the
+    // status and its text, which a WebSocket client never shows its caller.
+    const refused = options.loginUnset
+      ? { status: "503 Service Unavailable", body: LOGIN_UNSET }
+      : (() => {
+          const reason = unauthorized(request);
+          return reason === undefined
+            ? undefined
+            : { status: "401 Unauthorized", body: refusalText(reason) };
+        })();
+    if (match && refused !== undefined && !rejectSockets) {
+      socket.end(
+        `HTTP/1.1 ${refused.status}\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: ${Buffer.byteLength(refused.body)}\r\nconnection: close\r\n\r\n${refused.body}`,
+      );
+      return;
+    }
+    if (!match || rejectSockets) {
       socket.destroy();
       return;
     }
@@ -655,6 +737,22 @@ export async function startFakeCellServer(
         if (target.run === null) resolve();
         else target.idleWaiters.push(resolve);
       }),
+    revokeToken: (reason = "the session was reset by a new invite code") => {
+      revoked = reason;
+      const closeReason = Buffer.from(`Unauthorized: ${reason}`)
+        .subarray(0, CLOSE_REASON_BYTES)
+        .toString()
+        .replace(/\uFFFD$/, "");
+      for (const target of projects.values()) {
+        if (target.run === null) continue;
+        for (const ws of target.sockets) ws.close(SESSION_CLOSE, closeReason);
+        target.sockets.clear();
+        target.run.stop(reason);
+      }
+    },
+    spendAllowance: (resetAt) => {
+      allowanceResetAt = resetAt;
+    },
     cancelRun: (name) => projects.get(name)?.run?.cancel(),
     startForeignRun: (name, prompt, steps) =>
       startRun(project(name), prompt, "user", steps) !== undefined,
diff --git a/packages/provider-cell/src/testing/replayTransport.ts b/packages/provider-cell/src/testing/replayTransport.ts
index 8876a37597b7f..92d1a83607b11 100644
--- a/packages/provider-cell/src/testing/replayTransport.ts
+++ b/packages/provider-cell/src/testing/replayTransport.ts
@@ -283,6 +283,7 @@ export class CellReplayController {
             operation: `read ${url}`,
             detail: `socket closed${code === undefined ? "" : ` (${code})`}`,
             closed: true,
+            ...(code === undefined ? {} : { closeCode: code }),
           });
           wakeWaiter();
         },
@@ -471,7 +472,11 @@ export function makeRecordingCellTransport(
           const frames = yield* connection.pull.pipe(
             Effect.tapError((error) =>
               Effect.sync(() => {
-                if (error.closed) record("emit_inbound", { kind: "ws_closed" });
+                if (error.closed)
+                  record("emit_inbound", {
+                    kind: "ws_closed",
+                    ...(error.closeCode === undefined ? {} : { code: error.closeCode }),
+                  });
               }),
             ),
           );

From e8022322be797606682268f6b1b16789f89c597a Mon Sep 17 00:00:00 2001
From: Peetu Kupiainen 
Date: Wed, 4 Nov 2026 02:52:40 +0200
Subject: [PATCH 2/2] docs(cell): say the checked-in cell_wake_after_failure
 transcript is the sync's recording, not one made with the login on

Co-Authored-By: Claude Opus 5.5 
---
 docs/internals/provider-cell.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/docs/internals/provider-cell.md b/docs/internals/provider-cell.md
index 56bb2af6a8bd9..23c5ebaff4499 100644
--- a/docs/internals/provider-cell.md
+++ b/docs/internals/provider-cell.md
@@ -825,9 +825,9 @@ that the message carries the API's text). All seven checked-in transcripts
 were recorded live (`generatedBy: live-cell-recorder`, version `0.3.0`)
 against celld dev running the cell of upperformance/vim `main`, which speaks
 level 3, with a scripted Responses API behind it, and verified by replay;
-`cell_wake_after_failure` was recorded again against vim `main` 2e7f884
-with the login on, whose cell retries the API's 500 four times, each retry
-a run-scoped `error`, before the run fails.
+`cell_wake_after_failure` was recorded again against vim `main` 2e7f884,
+whose cell retries the API's 500 four times, each retry a run-scoped
+`error`, before the run fails.
 The fake cell is for a recording without a tenant. The recorder
 writes to the path `--out` gives, to the checked-in transcript with
 `--write-fixture`, and with neither to a new scratch directory it prints.