diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c955b5a2..5b9708aea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,477 +7,255 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-This section lists what is on `main` and not yet on npm.
+What is on `main` and not yet on npm. Entries marked **Behavior change** can
+break working code; [Upgrading](docs/upgrading.md) lists each one with its
+opt-out.
+
+### Security
+- **Behavior change:** with route auth and no `authorizeSession`, a session belongs to the first principal that sends it a turn (reading claims nothing); any other principal gets `403 LOUSHO_SESSION_FORBIDDEN`. Owners are kept in memory per agent for the 100,000 most recently used sessions, so a stored session whose owner is not on record (after a restart, on another replica, past the cap) is refused to everyone: pass `authorizeSession` for multi-instance deployments and durable stores, or `authorizeSession: () => true` for the old behavior. A route behind one bearer token has a single caller and checks no owner. `POST /chat` with an empty `input` (and `POST /ui` without a user message) is a `400` that claims nothing, and a turn that stores nothing drops its claim, so an owner can no longer be evicted by flooding. See [Who may use a session](docs/auth.md#who-may-use-a-session).
+- **Behavior change:** `POST /chat/:sessionId/approvals/:id` answers `404` unless the approval belongs to that session, and by default only the caller the paused run acts for may decide it; allow others (a supervisor) with `authorizeApproval`.
+- **Behavior change:** the session routes (`createRouteHandler()`, `createDeployedServer()`, the Worker, `lousho dev`) refuse cross-site requests: a POST whose `Origin` is neither the server's own (`Host`, `X-Forwarded-Host` or `Forwarded: host=`) nor in the new `allowedOrigins`, or that the browser marked `Sec-Fetch-Site: cross-site`, is `403 LOUSHO_REQUEST_FORBIDDEN`, and a POST body must be `Content-Type: application/json` (`415 LOUSHO_UNSUPPORTED_MEDIA_TYPE`). Requests without `Origin` are unaffected. Opt out with `allowedOrigins: ['*']` and `requireJsonBody: false`; `allowedHosts` refuses unlisted `Host` headers. Built servers read `LOUSHO_ALLOWED_ORIGINS`, `LOUSHO_ALLOWED_HOSTS` and `LOUSHO_REQUIRE_JSON_BODY=false`. The React/Vue/Svelte bindings no longer send `Content-Type` twice and accept a `Headers` instance. See [Cross-site requests](docs/auth.md#cross-site-requests).
+- **Behavior change:** `lousho dev` answers `403` to a non-loopback `Host` (off loopback: an IP address or the `--host` name; new `--allowed-hosts a,b`), so a web page cannot reach it through DNS rebinding, and warns when bound off loopback. A channel without `verify` refuses a cross-site browser request (`403`), and `httpChannel()` without `verify` requires a JSON body (`415`).
+- **Behavior change:** the Cloudflare Worker refuses every `/chat` route (`403 LOUSHO_REQUEST_FORBIDDEN`) when no `LOUSHO_API_TOKEN` secret is set; set the var `LOUSHO_ALLOW_OPEN_SESSIONS` (or the earlier `LOUSHO_ALLOW_OPEN_TRANSCRIPTS`) to `"true"` to allow it. `/health`, the OAuth callback and channels stay open, and `lousho build --target=cloudflare-worker` warns about the secret.
+- **Behavior change:** `WebhookTriggerAdapter` binds `127.0.0.1` by default (was `0.0.0.0`), refuses a non-loopback `host` without `auth` unless `allowUnauthenticated: true` (`LOUSHO_TRIGGER_INVALID`), refuses a foreign `Host` or an `Origin` on loopback without `auth`, caps bodies at 1 MB (`413`) before auth, and no longer echoes handler errors. See [Webhook](docs/triggers.md#webhook-authentication).
+- **Behavior change:** MCP tools ask for approval by default (`approval: 'always'`); a server's `readOnlyHint` / `destructiveHint` count only with `approval: 'annotations'`. An MCP tool named like a local tool is refused with `LOUSHO_CONFIG_INVALID` instead of replacing it. See [MCP](docs/mcp.md#approval-for-mcp-tools).
+- Flows: a `toolCall` step passes the same gate as an agent's tool call (`permissions`, `permissionMode`, `onPermissionDecision` and a new `approve` option on `FlowExecutionContext`); a call that needs approval and is not approved fails with `LOUSHO_FLOW_TOOL_DENIED` without running. Step arguments are validated against the tool's schema (`LOUSHO_TOOL_ARGS_INVALID`) and a flow's declared `inputs` are enforced (`LOUSHO_VALIDATION_FAILED`). See [Flows](docs/flows.md#tool-calls).
+- Registry: the approval an install receipt requires for an `exec`, `needsApproval` or unattested item can no longer be waived from inside the agent directory (an `allow` rule, `acceptEdits` or the directory's own `approve` file); only a person or an `approve` passed in code decides it. Receipts now also cover a kit's `subagents//` tools. `coding-kit` / `coding-pi` refuse writes to their own config, tools and `.git/`, drop `git diff` from the shell allowlist and accept `node --test` only with test file paths (reinstall with `lousho add coding-kit --overwrite`). See [Registry](docs/registry.md).
+- Shell tool: an `allow` string pattern refuses arguments that escape the workspace (`..` segments, also in `--flag=../x`, `~`, absolute paths, quoted or escaped forms such as `cat "../x"`, globs that can expand to `..`, and `$`, `{`, `}`), and under cmd.exe `%` and `^` count as shell operators. Use a `CommandRule` or an anchored RegExp to allow such arguments.
+- Agent Forge (`lousho studio`) no longer answers any origin: it sends no CORS headers, refuses a foreign `Origin` or a non-loopback `Host`, and every API and WebSocket call needs the per-launch token printed in its URL. See [Agent Forge](docs/agent-forge.md#access-token).
+- Tracing: `redactContent` defaults to `true`, so prompts, instructions and tool output are no longer written to span attributes or `.lousho/traces` unless you pass `redactContent: false` (or `captureContent: true` for `gen_ai.*` content).
+- The session routes no longer send raw error text to clients: `error` events, `500`s and `provider.retry` / `provider.fallback` events carry the error's `name` and `code` with a generic message, and the full error is logged. Pass `exposeErrors: true` for the old behavior (`lousho dev` does). Channel `500`s no longer echo internal errors either.
+- `fromUIMessages()` (and `POST /ui`) keeps only `user` and `assistant` messages from the client; a client-sent `system` message is dropped.
+- OAuth: a principal without an issuer gets tokens namespaced by its authenticator, so the same id from Slack and GitHub no longer shares a token (docs/oauth.md has the one-time move); `telegramChannel` and `teamsChannel` send a sign-in link only in a 1:1 chat; the `[REDACTED]` net for `ctx.getToken()` tokens also covers thrown errors, object keys, URLs, class instances and base64 forms of the token.
+- `serveMcp` over HTTP validates `Host` and `Origin` against DNS rebinding (new `allowedHosts` / `allowedOrigins`), and loopback checks there and in `WebhookTriggerAdapter` accept exactly `localhost`, `127.x.x.x` and `::1` (`127.attacker.example` passed).
+- The `openrouter` provider without `apiKey` reads `OPENROUTER_API_KEY` and no longer sends `OPENAI_API_KEY` to openrouter.ai.
+- Slack and Discord signatures accept a timestamp at most a minute ahead (was five minutes either way), read in seconds; `discordChannel` refuses timestamps more than five minutes old. `githubChannel({ deliveries })` takes a shared `DeliveryStore` for `X-GitHub-Delivery` replay protection.
+- The `@modelcontextprotocol/sdk` peer range is `^1.31.0` (was `^1.30.1`): 1.30.x is affected by GHSA-6qxp-vccf-f47h, which MCP OAuth relies on.
+- `SubprocessSandbox`: `writeFile()` stays under the new `root` option (`LOUSHO_SANDBOX_PATH_DENIED`, also through symlinks), and containers run as `1000:1000` with all capabilities dropped, `no-new-privileges` and default limits (512 MiB, 256 pids, 1 CPU); pass `user: 'root'` for the old user. See [Guardrails](docs/guardrails.md#sandboxed-tools).
+
+### Changed
+- **Behavior change:** session turns keep the checkpoint history of the latest 10 committed turns (`agent.session({ id, turnHistoryLimit })`; `0` keeps none); storage used to grow with the square of the conversation. Excess history already in a store is deleted over the next commits, also when each turn uses a new process or store object. New `fileStore(dir).prune({ olderThanMs })` returns `checkpointHistory` too; `fileStore()` returns the new `FileStore` type.
+- **Behavior change:** a checkpointed run holds a run lease while it executes (new optional `CheckpointStore.acquireLease()`, `releaseLease()`, `renewLease()`, `saveIfLeased()`; file, SQLite, memory and best-effort KV stores). A second process, replica or store object that sends to the same session, resumes it or resolves its approval gets `LOUSHO_SESSION_BUSY` instead of running its tool calls again, and a stalled holder can no longer overwrite the run that took over. A dead holder on the same host is taken over at once; on another machine 30-60 s after its last heartbeat. Schedule fires are claimed the same way, so replicas fire a tick once. See [Run leases](docs/durable-execution.md#run-leases).
+- **Behavior change:** approvals run at most once: deciding one re-saves the turn as `'in-progress'` with the call in `Checkpoint.startedToolCallIds`, and resuming after a crash mid-call gives it a `kind: 'interrupted'` tool error instead of running it again. Resolving an approval whose run was deleted or replaced fails with `LOUSHO_APPROVAL_ORPHANED` and withdraws it.
+- **Behavior change:** a session turn paused for approval locks the session id: a new turn on it throws `SessionAwaitingApprovalError` (`LOUSHO_SESSION_AWAITING_APPROVAL`) until the approval is decided or `discardPending()` drops the turn.
+- **Behavior change:** a deterministic provider error records `lastError` on the turn's checkpoint, and later calls on that session fail fast with `LOUSHO_SESSION_TURN_FAILED` instead of replaying the failing turn; `resume()` retries it.
+- **Behavior change:** run ids (`send({ sessionId })`, a fork's `newSessionId`) are 1-200 characters of `A-Za-z0-9_.-` on every store and may not end in `.turn-`, `.paused` or `.fire`; `''` is refused. Session ids (`agent.session({ id })`, the routes) stay 1-128 characters of `A-Za-z0-9_-`. The error messages now say which of the two rules applies.
+- **Behavior change:** with a store, tools, `needsApproval`, permission rules and hooks get the session id as `ctx.sessionId`, not the checkpoint key `.turn-` (new `ExecuteOptions.contextSessionId`).
+- **Behavior change:** a model step with several calls that need approval pauses once, on all of them (`result.approvalIds`, `agent.approvals.resolveAll()`); calls that need no approval run before the pause. See [Several calls in one step](docs/approvals.md#several-calls-in-one-step).
+- **Behavior change:** `once()` no longer remembers a yes from a `createAgent({ approve })` callback, and `'defer'` is reserved: an approval note that is exactly `'defer'` defers.
+- **Behavior change:** `approvalTtlMs` and an `ask` rule's `ttlMs` above 100 years throw `LOUSHO_CONFIG_INVALID` (they meant "never expires").
+- **Behavior change:** memory items are keyed `#`, so two slots on one scope no longer share items. Run `migrateMemoryKeys(slot)` once to copy items stored under the old bare key. See [Memory](docs/memory.md#items-from-before-slot-name-keys).
+- **Behavior change:** a prompt schedule runs every fire in its own session `schedule--` (`defineSchedule({ sharedSession: true })` keeps one shared session), and a fire that does not finish `'stop'` reports the new `LOUSHO_SCHEDULE_RUN_INCOMPLETE`.
+- **Behavior change:** channels without a `store` keep transcripts in the agent's `createAgent({ store })` when it has `sessions`.
+- **Behavior change:** the routes let a session turn run to its end and save it when the client disconnects (`onDisconnect: 'abort'` for the old behavior; `waitUntil` keeps a serverless function alive). An aborted turn keeps the user's input, the tool calls that ran and the partial reply (`metadata: { interrupted: true }`), and its usage counts.
+- **Behavior change:** tool results are capped at `maxToolResultChars` (default `50_000`, `Infinity` for none) for every tool; MCP image/audio/blob data reaches the model as a placeholder and MCP tool descriptions are cut at 2,000 characters.
+- **Behavior change:** a user file part the provider cannot send rejects the call with `LOUSHO_UNSUPPORTED_CONTENT` (under fallback the next provider gets it) instead of a text note; `unsupportedFiles: 'text-note'` keeps the note. See [Multimodal input](docs/providers.md#multimodal-input).
+- **Behavior change:** `createAgent({ provider })` with a built-in or `fromAiSdk()` provider and no `retry` wraps it in `withRetry()` (provider's `maxRetries`, default 2), and `withRetry()` is the only retry layer: the built-in providers turn the `ai` SDK's own retries off (`retry: false` used to send 3 requests, `maxRetries: 3` sent 12).
+- **Behavior change:** a rate-limited call has the code `LOUSHO_PROVIDER_RATE_LIMITED`, and an out-of-credit account (`insufficient_quota`, 402) is the new non-retryable category `quota-exceeded`.
+- **Behavior change:** `OpenAIProvider` sends `store: false` on the Responses API unless `store: true` is set.
+- **Behavior change (types):** `LLMProviderConfig` has no index signature, so a misspelled option is a type error; `LLMProviderRegistry.register()` / `create()` take the provider's own config type (new `BuiltinProviderConfigs`). `ExecutionFinishReason` is a closed union, and an unknown provider finish reason is reported as `'other'`.
+- **Behavior change:** `agent.stream()` rethrows the run's error after the `error` and `run.done` events (`{ throwOnError: false }` for the old behavior).
+- **Behavior change (CLI):** every usage error exits 2 and 1 means a command ran and failed. `lousho eval`, `build`, `dev`, `chat`, `acp`, `mcp` and `doctor` load `./.env`. The fallback registry URL points at the installed version's release tag.
+- **Behavior change:** `connectMcp()` rethrows a server's start failure as `McpStartError` (`LOUSHO_MCP_START_FAILED`, with exit code and the last stderr lines) instead of wrapping it.
+- Evals: `t.maxCostUsd(n)` fails when a run reports no cost (`{ unknownCost: 'skip' }` for the old, now warning, behavior); `judge.model` and `llmJudge()` / `llmCritique()`'s `model` are optional.
+- Pruned tool results leave a stub naming the call and its arguments (`[pruned: search({"query":"deploy"}) result, 18234 chars; already read]`).
+- Agent Forge: a run on a real provider without an API key fails with `LOUSHO_PROVIDER_MISSING_API_KEY` (HTTP 422) instead of silently using the mock provider, and the provider pill shows what actually ran.
+- The examples import `@lousho/build-ai-agent` and its public subpaths instead of the repository's `src/`, so they can be copied into an app as they are.
### Added
-- The built-in providers take `timeout` (ms): the longest wait for a model response to start and between two of its chunks (a stalled stream fails with a retryable `TimeoutError`; an answer that keeps streaming is never cut off). Default 10 minutes; `0` or `Infinity` turns it off. It was typed and accepted before but ignored. See [Request timeout](docs/providers.md#request-timeout).
-- `defineTool({ timeoutMs })`: a call still running after it fails with a tool error the model sees, its `abortSignal` is aborted, and the run (or its abort) no longer waits for a tool that ignores the signal. See [Tools](docs/tools.md).
-- `LOUSHO_OPENAI_API=chat` selects Chat Completions for an `OpenAIProvider` without an explicit `api`, so `model: 'openai/'` with `OPENAI_BASE_URL` reaches vLLM, llama.cpp or Ollama's `/v1`.
-- Built-in prices and windows for `gpt-4-turbo`, `gpt-3.5-turbo`, `gpt-5-nano`, `o3-mini`, `claude-sonnet-4-5` (the Anthropic default model), `claude-sonnet-4-6`, and a context window for Ollama's `llama3`, so the default hosted models report `costUsd` and enforce `limits.maxCostUsd`. Local models have no built-in price (`costUsd` stays unknown and `maxCostUsd` only warns); register one with `registerModel()`, `0` included, to enforce it. OpenRouter `:free` variants cost 0; `:extended`, `:nitro`, `:floor`, `:thinking`, `:online`, `:exacto` and `:beta` keep the base model's window but no guessed price.
-- OpenTelemetry: `invoke_agent` spans carry `gen_ai.request.model` and the run's total `gen_ai.usage.input_tokens` / `output_tokens`; `chat` spans carry `gen_ai.response.id` when the provider reports one; `createOtelTraceExporter({ legacyAttributes: false })` leaves out the deprecated pre-semconv attribute names. `invoke_agent` totals repeat the `chat` spans' tokens, so a backend that sums `gen_ai.usage.*` over all spans over-counts; `createOtelTraceExporter({ agentUsageTotals: false })` leaves them off `invoke_agent` spans. See [Summing token usage over spans](docs/observability.md#summing-token-usage-over-spans). See [Observability](docs/observability.md).
-- MCP server entries (`mcpServers`, `connectMcp()`) take `optional: true`: a server that cannot connect is left out with a warning instead of failing every `agent.send()`. See [MCP](docs/mcp.md#use-mcp-servers-in-an-agent). Agent specs (`agent.yaml`, `loadSpec()`) keep the flag too; before, the spec schema dropped it.
-- Channels and the session routes decide each call of a step paused on several (Eve TOOLS-F12): `onApproval` is called once per call (with all of them in `ctx.approvals`; the http channel answers with `approvals`), a decision on any of them is accepted by `POST //approvals/` and `POST /chat/:sessionId/approvals/:id` (also after a restart), and a decision that leaves others pending is answered with what is left. See [Channels](docs/channels.md#approvals-and-questions).
-- A model step with several calls that need approval pauses once, on all of them: `result.approvalIds` lists them (`approvalId` stays the first), each is decided on its own in any order (or with the new `agent.approvals.resolveAll()`), and the step's approved calls run only once every one of them is decided; calls of the step that need no approval run before the pause instead of after it. Works for session turns, durable stores, `streamResolve()` and sub-agents. See [Several calls in one step](docs/approvals.md#several-calls-in-one-step) (Eve TOOLS-F12).
-- `ApprovalDecision` takes `remember: 'session'` (approve later calls of the tool with the same arguments in the session without asking) and `args` (approve with edited arguments, validated against the tool's schema; invalid ones throw `LOUSHO_TOOL_ARGS_INVALID` and leave the approval pending). `once()` no longer remembers a yes from a `createAgent({ approve })` callback: the callback is asked for each call. See [Approvals](docs/approvals.md#dont-ask-again-approve-with-edits). (Eve TOOLS-F19)
-- Flows can pause for approval: in a durable run (`checkpointStore` + `runId`), a `toolCall` whose `approve` returns `'defer'` (or that has no `approve`) pauses the flow with `result.status: 'awaiting-approval'`, `approvalId` and `pendingApproval`; `FlowExecutor.resume(flow, { ...context, approval: { approvalId, approved } })` continues it. `FlowExecutionResult.status` is new (`'completed' | 'failed' | 'awaiting-approval'`). A run that is not durable still refuses `'defer'`. See [Pausing for approval](docs/flows.md#pausing-for-approval) (Eve DUR-F17).
-- Partial-object streaming: an agent with an `output` schema emits `object.delta { object }` after each `text.delta` that changes the reply parsed so far (a dependency-free tolerant partial-JSON parser; not validated), and the UI reducer and the React / Vue / Svelte bindings expose it as `partialObject` (then `run.done`'s validated `object`). See [Structured output](docs/structured-output.md#streaming) (Eve CORE-F13).
-- Per-call `send()` / `stream()` options `maxSteps` (this run's step limit, validated like `createAgent`'s) and `instructions` (appended to the agent's instructions on every model call of the run, never stored in the transcript); `ExecuteOptions.appendInstructions` underneath. See [Configuration](docs/configuration.md#per-call-options) (Eve CORE-F13).
-- Flow nodes take `retry: { maxAttempts, backoffMs? }` (exponential backoff, a `step-retry` event per retried failure) and `timeoutMs` (the attempt fails with `LOUSHO_OPERATION_TIMEOUT` and its model/tool call is aborted). See [Retries and time limits](docs/flows.md#retries-and-time-limits) (Eve DUR-F17).
-- Memory can be corrected (Eve MEM-F2): `defineMemory({ expose: { forget: true } })` adds a `forget_({ id })` tool and shows item ids in the recalled `` block; `defineMemory({ itemSchema, itemKey })` makes structured state upsert on its key field(s) instead of piling up stale versions; `MemoryProvider` gains an optional `upsert(scopeKey, { id?, text, metadata? })`, implemented by every built-in provider. See [Correcting memory](docs/memory.md#correcting-memory).
-- Trajectory evals see sub-agent tool calls: `t.toolCalls` includes them with a `subagentPath`, `t.calledTool` / `t.notCalledTool` / `t.toolOrder` search the whole agent tree (or `{ subagent }` for one), `SendOptions.onEvent` receives a run's events, the classic `defineEval` forwards every `execute()` option (`subagents`, `skills`, ...), and `repeat` / `passAt` run a case n times for pass@k. See [Evals](docs/evals.md#sub-agents) (Eve MA-F9).
-- `migrateMemoryKeys(slot, { contexts?, removeLegacy? })` copies a memory slot's items from the bare scope key used up to alpha.19 (`global`, `session:s1`, ...) to its current `#` key; idempotent (dedupes on text). Documented in [Memory](docs/memory.md#items-from-before-slot-name-keys) and [Upgrading](docs/upgrading.md) (Eve MEM-F10).
-- Durable flows: `FlowExecutor.execute(flow, { ...context, checkpointStore, runId })` saves the run's variables, completed nodes and usage after every node, and `FlowExecutor.resume(flow, { ...context, checkpointStore, runId })` continues a crashed or failed run without re-running completed nodes (a `oneOf` keeps its branch). See [Durable runs](docs/flows.md#durable-runs) (Eve DUR-F17).
-- `createAgent({ promptCaching: 'auto' | false })` (also `ExecuteOptions.promptCaching`, `GenerateOptions.promptCaching`). The default, `'auto'`, makes the `anthropic` and `openrouter` providers mark Anthropic `cache_control` breakpoints on the system prompt, the last tool and the last user turn for Claude models, so later calls read the prefix at 10% of the input price. Measured on OpenRouter claude-haiku-4.5: the second call cost $0.00071 instead of $0.00686. See [Prompt caching](docs/models-and-cost.md#prompt-caching). (Eve PROV-F4)
-- `agent.pending()` lists the runs left unfinished in `store.checkpoints` across all sessions - interrupted turns and runs (`'in-progress'`, finish them with `agent.resume(sessionId)`) and ones paused on an approval - so a restarted process can recover them; built on a new optional `CheckpointStore.list({ status })` (file, SQLite, KV, in-memory stores). Recovery recipe in [Durable execution](docs/durable-execution.md#recovering-after-a-crash). (Eve DUR-F15)
-- `withRateLimit(provider, { requestsPerMinute?, tokensPerMinute?, maxConcurrent? })` and `createRateLimiter()`: client-side, abort-aware queueing of model calls; `createAgent({ rateLimit })` applies one budget to the agent's runs, sub-agents and handoff targets (Eve PROV-F14).
-- Cost: `ModelInfo` has `cachedInputCostPerMTok` and `cacheWriteCostPerMTok`, and usage has `cacheWriteTokens`; `estimateCost` and run `costUsd` price prompt-cache reads and writes at those rates (built-in OpenAI cached-input prices; Anthropic reads at 10%, writes at 125%) instead of at the full input price. Anthropic's cache tokens now count in `inputTokens` on `ai` 4 too, and OpenRouter's `prompt_tokens_details` cache counts are reported. See [Models, tokens and cost](docs/models-and-cost.md). (Eve PROV-F4)
-- `agent.approvals.list()` also lists the pauses a durable store holds from another process or from before a restart, through a new optional `ApprovalStore.list()` (implemented by the file, SQLite, KV and in-memory stores); `get()` asks the store first and `list()` drops a pause another process already decided, so neither reports a stale approval. (Eve TOOLS-F13, DUI-F15)
-- MCP stdio servers: new `cwd`, `stderr` (`'forward'` default, `'capture'`, `'inherit'`, `'ignore'`) and `connectTimeoutMs` (also on HTTP entries) on an `mcpServers` entry, and `McpConnections.reconnect()` reopens servers after `close()`. See [MCP](docs/mcp.md#use-mcp-servers-in-an-agent).
-- MCP servers: `tools: { include?, exclude? }` on an `mcpServers` entry (and `loadMcpTools(client, name, { tools })`) loads only some of a server's tools, by their MCP names; an `include` name the server does not offer is logged. An `approval` function now gets the call's `args` (`({ name, annotations, args }) => boolean`) and runs per call, so a meta-tool like `execute({ operation })` can be gated by its arguments (the loaded descriptor's `needsApproval` is then a predicate, not a boolean). See [MCP](docs/mcp.md#tool-names-and-filtering).
-- `OpenAIProvider({ api: 'chat' })` calls the Chat Completions API (`/v1/chat/completions`) instead of the Responses API, so OpenAI-compatible servers that only implement Chat Completions (llama.cpp's `llama-server`, vLLM, Ollama's `/v1`, Groq, DeepSeek, ...) work with `baseURL`. The default stays `'responses'`. With `'chat'` the built-in hosted tools (`webSearch()`, `codeInterpreter()`, `fileSearch()`) are refused with `LOUSHO_HOSTED_TOOL_UNSUPPORTED` (`hostedTool()` still passes through) and `reasoning` sends the effort without the Responses-only summary. New docs section [Local and OpenAI-compatible servers](docs/providers.md#local-and-openai-compatible-servers) covers LM Studio, Ollama, vLLM and llama.cpp, the `openai/` + `OPENAI_BASE_URL` route, `fromAiSdk()`, the dummy key and setting the context window.
-- `lousho doctor` shows `OPENAI_BASE_URL` when it is set (credentials and query stripped), and the new `--ping` flag calls `GET /models` on each configured provider (OpenAI or the `OPENAI_BASE_URL` server, Anthropic, OpenRouter) with a 5 s timeout, warning when the server is unreachable or rejects the key. Keys are never printed.
-- Structured output: `result.outputRepaired` is `true` when `object` came from the repair step. A repair can change values to make them validate (a refinement such as `total = subtotal + tax` may make the model rewrite a printed total), so check business rules on `result.object` after extraction. See [Structured output](docs/structured-output.md#repaired-output-and-business-rules).
-- Structured output: `output` accepts `{ schema, promptSchema: false }` (on `createAgent()` and `ExecuteOptions`). The JSON Schema is then sent only on `responseFormat`, not also inside the system prompt - for a provider that enforces it, that stops paying ~1k prompt tokens a call on a large schema. Keep the default (a bare `output: schema`) when the provider treats `responseFormat` as a JSON-mode hint only. See [The schema in the prompt](docs/structured-output.md#the-schema-in-the-prompt).
-- `send()` / `stream()` take `parentSpanId`: the run's `invoke_agent` span becomes a child of that span (e.g. the one a `withSpan()` callback gets), so several runs of a pipeline land in one trace and their cost rolls up into your span. See [Observability](docs/observability.md#span-tree).
-- `githubChannel({ pullRequestOpened: true })` starts a turn on `pull_request.opened` webhooks (same signature verification, human-author and `triggers` rules as comments), so PR-review bots no longer need a parallel `webhookChannel`. See [Channels](docs/channels.md#github).
-- `defineMemory({ itemSchema })` types a slot's `remember_` input (any Standard Schema — zod 3/4): the parsed value is stored as canonical JSON in `text` and as `metadata`, so structured companion/state memory validates at the tool boundary. New export `memoryKey(slot, ctx)` returns a slot's storage key (`#`) for seeding/inspecting provider items from code. Memory `add()` now dedupes identical `text` per scope key across all built-in providers (in-memory, file, sqlite, KV, vector). See [Memory](docs/memory.md).
-- `createAgent({ modelSettings })` sets the sampling options of every model call: `maxTokens`, `temperature`, `topP`, `frequencyPenalty`, `presencePenalty`, `stop` and `seed`. A `send()` / `stream()` call can pass its own `modelSettings`, which win key by key. Sub-agents and handoff targets use their own. Agent directories accept the same `modelSettings` key in `agent.json` (the Cloudflare Worker target too). New exported type `ModelSettings`. See [Model settings](docs/configuration.md#model-settings).
-- Agent directories: new `agent.*` config keys `permissionMode`, `permissions` (a serializable rule form: `tool` is a name, a list or - in a code config - a `RegExp`; `when` narrows the rule with a record of argument names to matchers - a regular expression string or an operator object (`eq`/`ne`, `lt`/`lte`/`gt`/`gte` on `Number(arg)`, `matches`), or a predicate in a code config; `action` is `allow` / `ask` / `deny`), `compaction`, `limits`, plus `hooks` and `approve`, each a path (relative to the directory) to a file default-exporting the hook(s) / the approver function, or the value inline in a code config. `instructions/.md` appends to `instructions.md` when the resolved model id contains `` (e.g. `openai`, `anthropic`). `lousho build --target=node-server` (and `--target=docker`) bundles a root `hooks.*` / `approve.*` file next to the config and resolves the compiled `.js` sibling; the Cloudflare Worker target carries `permissionMode`, `permissions`, `compaction` and `limits` over and accepts `hooks` / `approve` from a code config (a file path is refused, a Worker cannot import it). See [Agent directories](docs/agent-directories.md).
-- Registry: a new item `type` `kit` is a whole agent directory - its `files` may sit at any (still safe) relative path - so `lousho add --dir ` installs `agent.*`, `instructions.md`, tools, skills and sub-agents in one item, under one permission manifest, and records it in `lousho-registry.json` like any other item. The default registry's first kit is `coding-kit`, a port of the `examples/coding-harness` baseline: workspace tools with checkpoint rewind, an allow-listed shell, permission rules, a loop guard and output cap as hooks, a compaction setting, a `fix-failing-test` skill and a read-only `explorer` sub-agent (manifest: `exec`, `fs-write`; install with `lousho add coding-kit --dir --yes --allow exec,fs-write`). See [Registry](docs/registry.md).
-- The `pi` provider (H2): `createAgent({ model: 'pi//' })` routes calls through `@earendil-works/pi-ai` (`1.0.3`, a new optional peer - `npm install @earendil-works/pi-ai@1.0.3`), the engine `pi-coding-agent` runs on, so pi's whole catalog (OpenRouter, Anthropic, OpenAI, Google, ...) is usable natively; e.g. `pi/openrouter/openai/gpt-4o-mini` is OpenRouter's `openai/gpt-4o-mini` (nested slashes are kept: the segment after `pi/` is the pi provider, the rest the model id). Messages (including reasoning blocks, tool calls/results and image parts), tool `parameters` (raw JSON Schema, zod 3/4 or Standard Schema), streamed chunks and usage/cost map to the same surface the `ai`-SDK providers expose, and pi's own retries are pinned off so `retry`/`fallbackModels` stay the only retry layer. Credentials are per nested provider: pi reads each one's own env var (`pi/openrouter/...` reads `OPENROUTER_API_KEY`), and a missing one fails the call with `LOUSHO_PROVIDER_MISSING_API_KEY` naming it; a missing package fails with `MissingPeerDependencyError` naming `npm install @earendil-works/pi-ai@1.0.3`. New export `PiProvider` (+ `PiProviderConfig`, whose `models` injects custom/faux catalog entries). `pi` is Node-only: the `cloudflare-worker` target refuses `pi/...` specs. See docs/providers.md#the-pi-provider.
-- `piAgent()` (Build Harness 3, "pi-coder"): a Pi coding-agent session (`@earendil-works/pi-coding-agent`, a new optional peer) usable in-process as a `createAgent({ subagents })` entry - `subagents: { coder: piAgent({ cwd, model, description, permissions }) }`. Each `task` call is one Pi session under the task's `sessionId`, so `taskId` resumes and approval resumes reopen the same JSONL transcript even across a restart. `permissions` (the same `PermissionRule`s as `createAgent()`) gate every Pi tool call through a `tool_call` extension handler: `deny` refuses it, `ask` pauses the lead run durably for approval when the caller can pause and refuses it otherwise; approving re-issues that exact call once (Pi has no run-blocked-call API, so a decision prompt asks the model to repeat it and the gate lets that one call through), rejecting sends the note as the next turn. Pi usage rolls into the lead's `result.usage`, and aborts propagate to the Pi session. Node-only: the package is loaded lazily, so a consumer without it still typechecks and `run()` fails with `LOUSHO_PEER_MISSING`. Also new: `defineRemoteSubagent(impl)` wraps any `RemoteSubagent` implementation (same contract as `remoteAgent()`), and `SubagentApprovalPause` is exported for such adapters.
-- Agent directories: three more `agent.*` config keys wire the approval surface. `approvalTtlMs` is `createAgent`'s run-level default for how long a pause for approval stays decidable (milliseconds); a `permissions` `ask` rule may set `ttlMs`, bounding that rule's pauses and winning over `approvalTtlMs` (both positive finite numbers, rejected otherwise). `store` declares a durable store so paused approvals survive restarts ("draft today, approve tomorrow"): `{ "dir": "./.lousho" }` (resolved against the agent directory, staying inside it) becomes a `fileStore()` - sessions, checkpoints and approvals as JSON files, with `historyLimit` / `tokenKey` also declarable; a code config may set an `AgentStore` instance instead. `store` is Node-only: the Cloudflare Worker target rejects it. `loadAgentDir(dir, { approve: null })` / `{ hooks: null }` strip the directory's wiring (the file the config names is not even imported), where `undefined` keeps it. The generated node-server / docker server keeps a config-declared `store` unless `LOUSHO_STORE` is set. See [Agent directories](docs/agent-directories.md).
-- Agent directories: `"engine": "pi"` in a `subagents//` config makes that directory a `piAgent()` coding sub-agent instead of a nested `createAgent()` one - it lands in the parent's `subagents` map (the `task` tool) and its sessions run in the parent's directory, the workspace both share. Its `model` is the usual `pi//` id and its `permissions` gate the Pi session's own tool calls; the remaining config keys are ignored (Pi carries its own prompt and tools). `loadAgentDir()`'s new `piAgent` override injects `PiAgentOptions` into every such sub-agent (a faux `modelRuntime` in tests, a `sessionDir` for durable approvals); the directory always wins `cwd`, `name` and `description`. Cloudflare Worker targets reject `engine` - Pi needs the Node runtime. The default registry's second kit, `coding-pi`, is `coding-kit` on the full Pi stack: pi-provider lead, dir-declared pi coder, same safety rails (install `--allow exec,fs-write,network,env`). See [Agent directories](docs/agent-directories.md#sub-agents).
-- The `approve` callback may return `'defer'` to abstain: the call stays pending (listed by `agent.approvals.list()`, resolvable by `agent.approvals.resolve()` / `streamResolve()` / `answer()`) and the run surfaces the pause - `send()` resolves with `finishReason: 'awaiting-approval'`, as if the callback had never been asked - so an approver can decide the calls it trusts and hand the rest to a human. Note: `'defer'` is now reserved; an approval note that is exactly `'defer'` defers instead of approving. See [Deciding in code](docs/approvals.md#deciding-in-code).
-- Tool-call hook contexts carry `resumedAfterApproval`: `true` only when `preToolCall` / `postToolCall` fire for the post-approval re-run of a call the run paused on (`agent.approvals.resolve()`, `resumeAfterApproval()`). Stateful hooks (loop guards, audit counters) can skip the re-fire with `if (ctx.resumedAfterApproval) return;`. See [Hooks](docs/hooks.md).
-- New `ToolDescriptor.transient` marks a tool bound per run (a memory slot's `remember_*` / `recall_*` tools, bound to the run's scope keys): it is left out of the agent fingerprint, so an approval-resumed run - whose registry intentionally does not rebind them - no longer reports `tools removed` agent drift on every resolve.
-- Approvals can expire. `createAgent({ approvalTtlMs })` (validated: positive finite) sets a default deadline for every pause, `send()`/`stream()` take a per-call override, a `permissions` `ask` rule may set `ttlMs` (wins over the run default), and `ExecuteOptions.approvalTtlMs` covers `AgentExecutor.execute()`; sub-agents inherit the lead's TTL unless they set their own. A pending approval past `expiresAt` resolves as denied with `'approval expired'` - enforced at decision time, so a durable-store resume (a restart, another process) can never approve a stale pause - and `list()` keeps expired entries (marked by `expiresAt`) so `resolve()` still continues the run. `approval.requested` events and `UIPendingApproval` carry `expiresAt` for UI countdowns; expiry writes an audited `deny` decision to `onPermissionDecision`. See [Approvals](docs/approvals.md).
-- `llmCritique(config)`: like `llmJudge` but returns `{ score, feedback }` - one judge call, a `SCORE:`/`FEEDBACK:` prompt, graceful degradation on malformed output - so evaluator-optimizer loops get actionable revision guidance, not just a number. See [Evals](docs/evals.md).
-- Testing helpers: `testToolContext(overrides?)` (`@lousho/build-ai-agent/testing`) builds a valid `ToolExecutionContext` for direct `tool.execute()` calls - `getToken`/`requireAuth` fail loudly unless stubbed - and `toolResultText(message)` (root export) unwraps a `tool` message's JSON-encoded content to its result text.
-- Channels: `ChannelInbound.metadata` now reaches the run as `SendOptions.metadata` (memory scopes and `when` contexts can key on channel-supplied data; it is unauthenticated, unlike `principal`), and `webhookChannel({ principal: (body, req) => ... })` can derive the caller principal from a verified body/header - so a webhook intake (PagerDuty, Grafana) attributes the run to the alerting system. See [Channels](docs/channels.md).
-- New registry kits and examples replicating the most-built agent archetypes: `inbox-triage` (channel intake → classify → draft → approval-gated send, per-sender memory, `@hourly` sweep; install `lousho add inbox-triage --allow env,fs-write`) and `incident-response` (webhook alert → diagnose → risk-tiered approvals → remediate → verify → audit timeline; install `--allow env,fs-write`); examples `deep-research` (orchestrator-workers fan-out with cited synthesis), `support-desk` (triage → `handoff()` to specialist agents), `evaluator-loop` (writer ↔ `llmCritique` revision loop) and `data-analyst` (text-to-SQL with read-only enforcement). Each runs offline on mock models and accepts `OPENROUTER_API_KEY` for a live pass.
-- `claudeProject(dir)`: loads a repository's Claude-Code-conventional context into options that spread into `createAgent()` — `CLAUDE.md`/`AGENTS.md` + `.claude/rules/*.md` become `instructions`, `.claude/skills/*/SKILL.md` load through `loadSkills()` unchanged, `.claude/agents/*.md` frontmatter becomes `subagents` (frontmatter `model` selects the sub-agent's model; `provider`/`model` options are the fallback), and `.claude/scratchpad/` gets a `scratchpad` note tool (name-validated `*.md` writes). Nothing in the directory executes: markdown is read as data, and `.claude/commands`, `settings*.json` and `mcp.json` are listed on `manifest.ignored` rather than loaded. See [Claude Code projects](docs/claude-projects.md).
-- `decide()`: typed answers from the OpenAI Decisions API (`POST /v1/decisions`, public beta — `gpt-6-luna` only) — `predicate` questions return a probability, `choice` returns a value plus per-option `probabilities` and `confidence`, `score` returns a rubric's probability-weighted level index, and any question may come back a `refusal`. Input is a string or messages with `input_text`/`input_image` (inline base64) parts; `apiKey` (default `OPENAI_API_KEY`), `baseURL`, `timeoutMs`, `signal` and `fetch` are overridable. It is the fast path for typed decisions (route/gate/score) — no agent turn and no generated prose — and does not go through providers or OpenRouter. See [Decisions](docs/decisions.md).
-- Four new examples covering the agentic-patterns catalog: `waves` (Workers · Aggregate · Verify · Extend — bounded parallel workers, deterministic merge, a typed `{ verdict, gaps }` verifier, extension waves seeded by its gaps), `phase-pipeline` (discuss → spec → plan → implement → proof gate → report on `FlowBuilder`/`FlowExecutor`, with the implement↔check retry ladder as a `oneOf` chain), `coding-agent-workflows` (a coding agent routed by a typed decision — `output` schema `{ route, confidence, reason }`, a confidence floor escalating low-confidence calls to the careful path), and `connectors` (MCP connectors à la Claude — runs fully offline against a mock `serveMcp` stdio server exposing `docs_*`/`slack_*` tools; README maps Google Docs, Slack, GitHub and Notion). Plus `hostinger-deploy`: build an agent with `lousho build --target=docker`, ship it to a Hostinger VPS over SSH and smoke-test `/health` + bearer auth + approval pause/resume. See [Prompting and context techniques](docs/prompting-techniques.md), [Connectors](docs/connectors.md) and [Hostinger VPS](docs/hostinger-vps.md).
-- Shell tool: `allow` / `deny` accept a `CommandRule` object that controls arguments. `{ command: 'npm test' }` matches exactly `npm test`, and `{ command: 'node --test', args: (rest) => ... }` lets you validate what follows. A plain string pattern keeps its meaning (the command with any arguments); the docs now say plainly that a string pattern does not stop argument flags that write files, such as `git diff --output=../x`. `ShellProvider` gains an optional `shell` property (set by `NodeWorkspace` and `SandboxShell`), and the shell tool's description now tells the model which shell runs its commands (cmd.exe on Windows: double quotes, `%NAME%` variables). See [Workspace tools](docs/workspace-tools.md).
-- `compaction.reserveOutputTokens` (also on `createCompactionHook()` and `compactMessages()`): tokens kept free for the reply, so the compaction threshold is at most `contextWindow - reserveOutputTokens`. Unset, the threshold is unchanged. See [Compaction](docs/compaction.md).
-- `fireSchedule(agent, schedule, { name?, firedAt?, sessionId? })` is now exported: fire a schedule once, now (an ops "run now", a test), exactly as a tick would. `startSchedules()` takes `keepAlive: true` to keep a schedules-only script alive (the timers stay unref'd by default). See [Schedules](docs/schedules.md).
-- `createAgent({ compaction: { onCompaction } })`: the object form takes `onCompaction`, so you can read each summary's text without building the hook yourself.
-- `createRouteHandler()` and `createDeployedServer()` take `authorizeSession({ principal, sessionId, action })` (`action` is `'read'`, `'chat'` or `'approve'`) to stop one user reading or continuing another user's session (`403 LOUSHO_SESSION_FORBIDDEN`), and `authorizeApproval({ principal, sessionId, approval })` to say who may decide an approval (`403 LOUSHO_APPROVAL_FORBIDDEN`), e.g. supervisors only. `agent.approvals.list()` / `get()` report the approval's `sessionId`, and `AgentEvent` errors carry the error's `code`. See [Who may decide an approval](docs/auth.md#who-may-decide-an-approval).
-- `lousho eval --timeout ` and `defineEval({ timeoutMs })` set the time limit per eval case (`0` = none; `timeoutMs` wins). `recordReplay()` takes `rerecordHint`, the re-record instruction its mismatch errors end with. See [Time limits](docs/evals.md#time-limits).
-- The built-in providers (`OpenAIProvider`, `AnthropicProvider`, `OpenRouterProvider`, `OllamaProvider`) take a `fetch` option that their model calls go through, for example one with a longer undici `headersTimeout` so a slow local model's long generation is not cut off at Node's 300-second default. See [Slow local models](docs/providers.md#slow-local-models-a-custom-fetch).
-- `chat` spans of a retried model call carry `lousho.retry.count` and `lousho.retry.errors` (each failure's category and HTTP status), so a long span no longer hides its failed attempts. See [Observability](docs/observability.md).
-- Structured output: when `maxSteps` runs out while the model is still calling tools, a run with `output` set now makes one last model call with no tools offered ("answer now") and validates its reply - the result carries `object` (still `finishReason: 'max-steps'`) or `outputError` (`'output-invalid'`) instead of neither. The extra call is skipped when another budget limit (tokens, cost, duration) is already spent. See [Structured output](docs/structured-output.md).
+- Providers: a `timeout` option (default 10 minutes until the response starts and between chunks; `0` or `Infinity` turns it off) and a `fetch` option on every built-in provider. `OpenAIProvider({ api: 'chat' })` and `LOUSHO_OPENAI_API=chat` use Chat Completions for OpenAI-compatible servers (LM Studio, Ollama, vLLM, llama.cpp; the built-in hosted tools are then refused with `LOUSHO_HOSTED_TOOL_UNSUPPORTED`); see [Local and OpenAI-compatible servers](docs/providers.md#local-and-openai-compatible-servers). `withRateLimit()` / `createRateLimiter()` and `createAgent({ rateLimit })` queue model calls client-side. `promptCaching: 'auto'` (the default) marks Anthropic cache breakpoints for Claude on `anthropic` and `openrouter`. `withRetry({ idleTimeoutMs })` fails a stream that stalls between chunks (`timeoutMs` now bounds only the wait for the first chunk). `withFallback()` results carry `servedBy`.
+- Model settings: `createAgent({ modelSettings })` and per-call `modelSettings` (`maxTokens`, `temperature`, `topP`, penalties, `stop`, `seed`, `toolChoice`), range-checked up front, also in `agent.json`. Per-call `maxSteps` and `instructions` on `send()` / `stream()`. See [Configuration](docs/configuration.md#model-settings).
+- Cost: built-in prices for more default models (Ollama `llama3` window, OpenRouter variants), cache read/write prices (`cachedInputCostPerMTok`, `cacheWriteCostPerMTok`, `cacheWriteTokens`) and OpenRouter's reported `usage.cost` for `costUsd` and `limits.maxCostUsd`.
+- Approvals: `remember: 'session'` and edited `args` on a decision, `'defer'` from an `approve` callback, `approvalTtlMs` / `ttlMs` deadlines (`expiresAt` on events and UI state), `agent.approvals.list()` across processes (new optional `ApprovalStore.list()`), `agent.pending()` for unfinished runs (new optional `CheckpointStore.list()`), and `resumedAfterApproval` on tool-call hook contexts. See [Approvals](docs/approvals.md) and [Recovering after a crash](docs/durable-execution.md#recovering-after-a-crash).
+- Routes: `authorizeSession` and `authorizeApproval` on `createRouteHandler()` / `createDeployedServer()`; approvals report their `sessionId` and error events their `code`. Built node and Docker servers drain on SIGTERM (`shutdown({ timeoutMs })`, `LOUSHO_SHUTDOWN_TIMEOUT_MS`). Channel routes decide each call of a multi-call pause and accept a `sessionKey` after a restart.
+- Flows: durable runs (`checkpointStore` + `runId`, `FlowExecutor.resume()`), pausing for approval (`status: 'awaiting-approval'`), per-node `retry` and `timeoutMs`, cancellation through `signal`, and `usage` on the result. See [Flows](docs/flows.md).
+- Structured output: partial-object streaming (`object.delta`, `partialObject` in the UI bindings), `result.outputRepaired`, `{ schema, promptSchema: false }`, a forced no-tools answer when `maxSteps` or `limits.maxSteps` runs out, and `outputError.kind: 'truncated'`. Unions, records, tuples and optional fields now work on strict endpoints. See [Structured output](docs/structured-output.md).
+- Memory: `expose: { forget: true }`, `itemSchema` / `itemKey` upserts (new optional `MemoryProvider.upsert()`), `memoryKey()`, de-duplicated `add()`, and `migrateMemoryKeys()`. See [Memory](docs/memory.md#correcting-memory).
+- MCP: `optional: true`, `cwd`, `stderr`, `connectTimeoutMs`, `timeoutMs`, `tools: { include, exclude }`, per-call `approval({ args })`, `onToolsChanged` and `McpConnections.reconnect()`. See [MCP](docs/mcp.md).
+- Observability: `invoke_agent` spans carry the request model and token totals (`agentUsageTotals: false` leaves them off), `legacyAttributes: false`, `parentSpanId` on `send()` / `stream()`, retry attributes on `chat` spans, sub-agent names on their spans, and compaction calls as counted `chat` spans. See [Observability](docs/observability.md).
+- Tools and sub-agents: `defineTool({ timeoutMs })`, `subagentOptions: { background: false }`, `read_skill_file` for skill folders, `ToolDescriptor.transient`, `CommandRule` objects for the shell tool's `allow` / `deny`, and an optional `ShellProvider.shell` that tells the model which shell runs its commands.
+- Compaction: `reserveOutputTokens` and `onCompaction` on the object form; `GenerateHookContext.generate()` for counted side calls.
+- Evals: sub-agent tool calls in trajectory evals, `SendOptions.onEvent`, `repeat` / `passAt`, `{ agent }` for `setProviderInterceptor` callbacks, `lousho eval --timeout` and `defineEval({ timeoutMs })`, and `recordReplay({ rerecordHint })`. See [Evals](docs/evals.md).
+- Channels and schedules: `githubChannel({ pullRequestOpened: true })`, `webhookChannel({ principal })`, exported `fireSchedule()`, `startSchedules({ keepAlive: true })` and an awaitable `stop()`, and `mountFetchChannels({ waitUntil })`.
+- CLI: `lousho doctor --ping` and base URLs in its output, and `lousho --version`.
### Fixed
+- Durability: concurrent commits on one session no longer lose a turn (new optional `SessionStore.saveIf()` compare-and-swap; `KVStore` stays best effort), and session objects re-read the store instead of caching the transcript. Concurrent `send({ sessionId })` calls in one process run in order. Parallel tool calls are checkpointed as each settles, so a crash no longer re-runs finished calls. A restarted process that resolves a session's approval commits the turn instead of dropping it. `session.clear()` deletes every turn's checkpoints and history, finished turns stay forkable, `session.fork()` works for 122-128 character ids, and sub-agent task ids are never reused.
+- Stores: `fileStore` file names are case-safe on Windows and macOS (`Alice` and `alice` no longer share a file), long ids and device names are hashed, older long names are still read, writes are `fsync`ed and Windows `EPERM` / `EBUSY` is retried. A corrupt checkpoint fails with `LOUSHO_CHECKPOINT_CORRUPT` and can be discarded. `SqliteStore` waits 50 ms instead of freezing the event loop, retries locked writes without blocking (`LOUSHO_STORAGE_BUSY`) and `prune()` keeps paused turns. `KVStore` approval claims run a tool once in the common case (`ctx.approval.id` serves as an idempotency key). Image and file bytes survive every store. Taking over a stale file lock is serialized, and `KVCheckpointStore.list()` throws without a `list` binding instead of returning `[]`. `agent.approvals.get()` returns `undefined` for an id that cannot name an approval. `fileMemory` and `sqliteMemory` keep every item with several writers.
+- OAuth: concurrent `ctx.getToken()` calls share one refresh, and a refused refresh no longer deletes a newer token.
+- Approvals and memory: a run resumed after an approval or a crash keeps its memory tools and the scope it paused with; memory stays on after a handoff; recall errors skip the slot with a warning; invalid scopes fail with `LOUSHO_MEMORY_INVALID`; keyword recall matches whole words; `` returns scores and metadata.
+- Providers on `ai` 4, 6 and 7: tool errors are no longer wrapped twice on `ai` 7, malformed or truncated tool-call JSON reaches the model as a repairable `LOUSHO_TOOL_ARGS_INVALID` error (light repairs are tried first; `tool.start` has `rawArgs`), prefixed tool names (`functions.read_file`) resolve, unknown tools suggest the closest name, duplicate tool-call ids both run, a completion sent to a streaming request is replayed, mid-stream and `finish_reason: "error"` failures become retryable 502s, a stream that fails before any output is retried, and unset `temperature` / `maxTokens` are left out. `withFallback()` keeps its state per call, so concurrent calls no longer misreport fallbacks or book cost under the wrong model, and `withRateLimit()` over `withRetry()` streams reports the final attempt and holds its slot until the retried stream ends.
+- Provider errors: local runtimes' context overflows are `context-length-exceeded`, refused connections name the URL, status and message are read together, nested OpenRouter reasons are shown, a long `Retry-After` fails at once instead of sleeping, negative hints are ignored, and compacted errors keep their category through `createAgent()`. A missing provider package fails with `LOUSHO_PEER_MISSING`, and the routes send its `npm install` command to the client.
+- Provider features: `reasoning` reaches more models and warns once when it is dropped; image MIME types are sniffed; `OpenRouterProvider` honours `baseURL` and `OllamaProvider` sends `headers` and `apiKey`; `supportsTools()` knows current models; registered providers work in model strings; retired fallback models are replaced; price lookup handles OpenRouter spellings; the forced answer at `maxSteps` works on Claude.
+- Runs: abort no longer waits on a provider or tool that ignores the signal (`finishReason: 'aborted'`, `result.abortReason`; `mockModel` honours `signal`), aborted streams keep their text, a throwing event listener or trace exporter no longer fails the run, tool results with cycles, BigInts, Maps or Sets serialize, `send(result.messages)` does not repeat the system prompt, `send([])` throws `LOUSHO_CONFIG_INVALID`, a `finish_reason: tool_calls` reply without calls ends `'stop'`, a `load_skill` tool that clashes with `skills` throws, `maxSteps` must be a whole number >= 1, and handoffs work on local models and Anthropic (no mid-conversation system message).
+- Structured output: the repair and forced-answer prompts stay out of transcripts, near-miss replies (prose, `` blocks, fenced JSON) are accepted without a repair call, a reply that repeats the schema gets a clearer repair message, and unsupported root unions or zod 4 `z.date()` fields throw a `ConfigurationError` naming the fix. `defineTool()` rejects inputs no model can satisfy and accepts `z.preprocess` dates on zod 4.1+.
+- Compaction: summarizing splits oversized histories, counts tool definitions and the output schema, warns when it falls back to a 128,000-token window for an unknown model, caps `protectedTokens` at half the threshold, retries the summarizer after a rejection, counts its call in usage and budgets, and reports `appliedStrategy`.
+- Sub-agents and handoffs: dynamic sub-agent config functions get the lead's `sessionId`, `metadata` and `principal`; a failed sub-agent's spend counts; truncated answers say so; agent-directory and Worker `subagents/` run as native sub-agents; a handoff target's `toolSearch` applies.
+- Schedules: a fixed-hour cron job in a spring-forward gap runs once after the gap instead of being skipped.
+- Flows: a failed `parallel` branch aborts its siblings, `forEach` variables are per iteration, unnamed steps get unique ids, and `resume()` refuses a changed flow (`LOUSHO_FLOW_CHANGED`).
+- MCP: `Not connected` counts as a dropped connection and reconnects, `tools/list` follows every page, dropped servers report `'failed'` with their stderr, calls honour abort, text-only results are sent once, tool names are made valid for every provider, object schemas with a sibling `oneOf` keep their shape, `agent.ready()` reconnects after `close()`, and `serveMcp()` over HTTP cancels the run a client cancels or abandons (matched by its new `Mcp-Session-Id`) and rejects its pending approvals. `serveAcp` keeps serving after a malformed JSON line.
+- Channels: redelivered webhooks run once (Telegram, GitHub, Slack), concurrent decisions on one approval decide it once (`409 LOUSHO_APPROVAL_CONFLICT`, also on the routes), `webhookChannel` authenticates on Fetch hosts and its pause replies carry the prompt, `githubChannel` ignores `/approve` for unknown approvals, and channel `metadata` reaches the run. `POST /ui` answers 400 for an invalid id, and `auth: ''` is `LOUSHO_AUTH_CONFIG_INVALID` on `createRouteHandler()` and `serveFetch()`.
+- UI bindings (React, Vue, Svelte): remote mode keeps one session across turns, a stream that ends early becomes an error, each step starts a new paragraph, abnormal endings set `status: 'error'`, React gains `reset()`, server error messages are kept, Svelte no longer aborts on a transient subscribe, `formatUsage()` shows small costs, and `parseEventStream()` joins multi-line `data:` fields.
+- Tracing and OpenTelemetry: open spans are bounded without losing parents, queued background sub-agents stay in the lead's trace, background sub-agent cost rolls up, and `execute_tool` spans carry the error message.
+- Evals: replay matches calls by request, sub-agent cassettes are named after the agent, cassette names include a hash of the case label, re-recording unchanged exchanges leaves no diff, tool order no longer breaks replay, reasoning and full usage are recorded, judge scores parse more formats (the last `SCORE:` wins) and judges see the user's input at temperature 0, `lousho eval` gives live cases 10 minutes and its reports are never empty when vitest fails.
+- CLI: `lousho dev` reloads CommonJS tool files and recreated folders, tool files no longer print `MODULE_TYPELESS_PACKAGE_JSON`, TypeScript syntax that type stripping cannot erase gets an error suggesting `--experimental-transform-types`, every agent command loads `tools/*.ts` of an installed project (one ESM build, shared brands, `instanceof` across ESM and CJS copies), `lousho build` reports bundling failures as `LOUSHO_DEPLOY_FAILED`, warns about missing provider packages, merges an existing `wrangler.toml` (new default `compatibility_date` `2026-09-01`), no longer needs unused provider packages for a Worker and prints where it wrote, Docker images run as `node`, `lousho doctor` and `lousho mcp` accept agent directories, `lousho add` checks flags and target directories up front and falls back to GitHub when the default registry is down, `lousho init` templates and Windows instructions are fixed, `init` says when the provider's API key is missing and git-ignores an `--sdk-path` tarball, and help texts and messages are clearer.
+- Agent directories: every `createAgent()` override passed to `loadAgentDir()` is forwarded, `mock/` models work from every entry point, and import failures throw `LOUSHO_AGENT_DIR_INVALID` with an install hint. `loadSkills()` skips a loose `README.md`.
+- Workspace tools: `grep` refuses catastrophic regexes and runs with a time budget, `edit_file` refuses non-UTF-8 files instead of corrupting them, and `tool_search` ranks synonyms and word families.
+- Agent Forge: agents are read from and written to `.lousho/agents/`, narrow screens and keyboard and screen-reader use work, invalid names and imports show errors instead of a blank page, finished runs report `done`, and several drawing and status glitches are fixed.
+- Packaging and types: ESM consumers get `.d.mts` declarations (plus `typesVersions` for `node10`), declarations type-check with `skipLibCheck: false` on zod 3 and 4, the pi peers are caret ranges, `llms-full.txt` no longer ships and the Worker entry has no source maps.
+- Docs: corrections throughout (sessions vs runs under one id, schedules, nextjs approvals, memory, `engines.node`), the snippet check now also runs every snippet that builds its own mock provider and checks fences indented in lists, and the ops-pipeline example works as described.
- A checkpointed session turn that stalled past its run lease and then failed for good no longer stamps its `lastError` (`retryable: false`) on the checkpoint of the process that took the turn over, which bricked that turn forever with `LOUSHO_SESSION_TURN_FAILED`: the session now holds the turn's run lease itself (handed to the run as `HELD_RUN_LEASE`) and `recordFailure()` writes through `RunLease.save`, whose fenced save writes nothing once the lease is lost. (Eve2-F2)
- `agent.send(msg, { sessionId })` and a fresh `agent.session({ id })` no longer run beside a session turn that waits on an approval when its `.paused` marker was never written (a crash or a send between the executor's awaiting-approval checkpoint and `markPaused()`): `pausedSessionTurn` now also scans the store's `.turn-*` checkpoints for `status: 'awaiting-approval'` when the marker is absent, on stores that implement `list()`. (Eve2-F3)
-- Sessions: turn checkpoint history outside `turnHistoryLimit` leaked again when each turn used a new store object or process (serverless functions, CLI runs, a store per request, restarts): past about 42 turns every turn's history was kept, so storage grew with the square of the conversation. Every commit now deletes the turns that just left the window, whatever earlier commits of the process did; older backlog is still swept oldest first, up to 32 turns per commit. `fileStore(dir).prune()`'s result gains `checkpointHistory`, the number of checkpoint history files it deleted (they were deleted but not counted).
-- The published type declarations type-check with `skipLibCheck: false` under zod 4 as well as zod 3: `createTodoTools()`, `askQuestionTool()`, `createWebFetchTool()` / `webFetchTool`, `FsToolArgs` and `lousho add`'s registry types are spelled out instead of inferred zod-3 schema types (`ZodEffects`, three-argument `ZodObject`). Their tool `input` is typed as a `StandardSchemaV1` of the arguments. `npm run pack-smoke` now checks the packed declarations against zod 3 and zod 4.
-- `lousho dev` reloads an edited CommonJS tool file (`.cjs`, or `.js` without `"type": "module"`) and the CommonJS helpers it requires, and reports a broken one (`failed to reload ..., keeping the previous agent`) instead of logging "reloaded" and serving the cached copy. It also keeps reloading a file an editor replaces on save (write to a temp file, then rename), which on Linux stopped being watched after its first save.
-- `lousho build` warns when the agent's provider package (e.g. `@ai-sdk/openai` for `provider: openai`) is not installed. For node-server and docker builds the warning says the build's own `package.json` lists the package (install it there, or let the Docker image do it); for a Worker it must be installed before building.
-- `lousho dev` keeps watching an agent directory's subfolder that is deleted and created again (`git checkout`, a regenerated `tools/`): later edits and new files in it reload again, and the reload log no longer shows made-up paths such as `tools/tools`.
-- A model call whose provider package is missing fails with `LOUSHO_PEER_MISSING` (not `LOUSHO_PROVIDER_REQUEST_FAILED`), and the server routes (`lousho build` servers, `createRouteHandler()`, `serveFetch()`) send its message, the `npm install` command, to the client instead of the generic one.
-- An agent directory (or `createAgent({ model })`) with `model: mock/` works from every entry point; it failed with `LOUSHO_PROVIDER_UNKNOWN` when nothing had loaded the package root first (e.g. `lousho chat` on a directory without a `.ts` tool).
-- `instanceof` of every exported `SDKError` subclass (`ValidationError`, `TimeoutError`, `RateLimitError`, `LLMProviderError`, `MissingPeerDependencyError`, ...) holds across the ESM and CJS copies a mixed process loads, as it already did for `SDKError` and `HookRegistry`.
-- `lousho eval` exits 2 only for usage errors: vitest not installed, or a `--junit` / `--json` report that cannot be written, now exits 1. `lousho build --target=` without an agent path is a usage error (exit 2) whose message shows the usage.
-- The CLI no longer prints Node's `MODULE_TYPELESS_PACKAGE_JSON` warning each time it loads a `tools/*.ts` file in a project without `"type": "module"`.
-- A tool file with TypeScript syntax that Node's type stripping cannot erase (`enum`, `namespace`, parameter properties) gets an error that says so and suggests `NODE_OPTIONS=--experimental-transform-types`, instead of "this process cannot load TypeScript files".
-- `lousho dev --help` and `lousho --help` describe `--allowed-hosts`; an unexpected failure to load a CLI command is reported instead of an unhandled rejection.
-- `lousho init --sdk-path` adds the copied SDK tarball to the generated project's `.gitignore`.
-- A file part the provider cannot send rejects `send()` / `stream()` with `LOUSHO_UNSUPPORTED_CONTENT` itself, as docs/errors.md describes, instead of a `LOUSHO_PROVIDER_REQUEST_FAILED` whose `cause` had that code.
-- On ai 7 (`@ai-sdk/openai` 4) a tool error reached the model wrapped twice (`{"error":{"error":"ToolNotFoundError",...}}`); the OpenAI and OpenRouter providers now send it once, as on ai 4 and 6, and so does `fromAiSdk()` for every `@ai-sdk/openai` model, whatever its provider name (`createOpenAI({ name: 'groq' })`, `@ai-sdk/azure`). `@ai-sdk/openai-compatible` and the other OpenAI-wire packages do not wrap, so they still get the error flag.
-- An OpenAI-compatible server that answers a streaming request with a whole JSON completion (it ignores `stream: true`) no longer ends the run with empty text: the completion is replayed as a stream, so `stream()` and `send(input, { onEvent })` get its text, tool calls and usage.
-- An error chunk mid-stream keeps the upstream chunk as the failure's `responseBody` (on `error.cause`) again, instead of a synthesized body.
-- `LLMProviderRegistry.register()` and `create()` (and `ProviderFactory`) take the provider's own config type (`register('bedrock', (config: BedrockConfig) => ...)`, `create('bedrock', { apiKey, region })`), so the documented custom-provider route compiles without casts now that `LLMProviderConfig` has no index signature. A built-in name still checks its own config (`create('openai', { baseUrl })` is a type error; the map is the new `BuiltinProviderConfigs`).
-- OpenAI-compatible failures reported inside a 200 response now fail the call: `finish_reason: "error"` (OpenRouter's mid-generation upstream failure) and an `error` object in a stream chunk become a retryable 502 (`withRetry()` retries it while nothing was streamed; after streamed text the run fails) instead of a successful run with truncated text. The previous check compared the unified finish reason, which `@ai-sdk/openai` maps to `other`/`unknown`; it now reads the response itself (`OpenAIProvider`, `OpenRouterProvider`) and ai 6/7's `rawFinishReason` (`fromAiSdk()` too). Tested against the real `@ai-sdk/openai` and a local HTTP server.
-- On ai 4, a tool call with malformed JSON, wrong-typed arguments or an unknown tool name no longer throws out of the run: like on ai 6/7, the model gets a tool error it can repair from, and a streamed call whose arguments never complete still reaches the executor.
-- The forced-answer call at `maxSteps` (with `output`) sends the run's tools with `toolChoice: 'none'` when the transcript has tool calls; Anthropic rejected the tool-less request ("Requests which include `tool_use` or `tool_result` blocks must define tools"), so the salvage never worked on Claude.
-- `stream()` reports an unknown finish reason as `'other'`, like `send()`, instead of `'error'` with no error event.
-- A provider error already compacted by `withRetry()` (a `Retry-After` longer than `backoff.maxMs`) keeps its `category`, `statusCode` and `retryAfterMs` through `createAgent()` instead of being compacted again to `unknown`.
-- A tool result with a cycle, a `BigInt`, a `Map` or a `Set` no longer crashes the run with a raw `TypeError` (or silently becomes `{}`): cycles become `'[Circular]'`, a BigInt its decimal string, a Map an object and a Set an array.
-- ai 6: an image part without `mimeType` is sent with the type read from its bytes (or data URL) instead of `image/jpeg`, which Anthropic rejected for a PNG.
-- `OpenRouterProvider` honours `baseURL` (a gateway, proxy or regional endpoint); `getModels()` / `getModelInfo()` use the configured `baseURL`, `fetch` and headers. OpenRouter calls ask for `usage: { include: true }`, and the response is read as it streams instead of cloned and buffered whole.
-- `OllamaProvider` sends the configured `headers`, and `apiKey` as a bearer token (an authenticating proxy, Ollama's cloud API); `getModels()` uses the configured `fetch`.
-- A negative `Retry-After` (or `retry-after-ms`) no longer produces a negative backoff.
-- Malformed tool-call JSON reaches the model as invalid JSON on ai 6/7 instead of "Expected object, received string".
-- Two tool calls with the same id (some OpenAI-compatible servers, Gemini behind proxies) both run with their own arguments: the built-in OpenAI-compatible providers give a repeated id a `_2` suffix before `ai` 6/7 merge them, and a repeated id that still arrives is made unique.
-- A reply with `finish_reason: tool_calls` but no tool calls ends the run `'stop'`; a `load_skill` tool that clashes with `skills` throws at `createAgent()` like a `task` clash; `send([])` throws `LOUSHO_CONFIG_INVALID` instead of sending a request with no user turn; text parts of a system or assistant message are sent one per line.
-- An aborted streamed run's `result.text` is the text the cut-off model call streamed (it was `''`).
-- `withRateLimit()` over `withRetry()` on streams (what `createAgent({ rateLimit, retry })` builds): the result's `text`, `usage` and `finishReason` are the final attempt's, the `maxConcurrent` slot is held until the retried stream ends, and every retry counts against `requestsPerMinute`.
-- Error classification reads the status with the message: "authentication" in a 5xx body stays a retryable server failure, and "aborted" in a 4xx body is not a retryable timeout.
-- Summary compaction splits a history larger than half the summarizer model's window (when the registry knows it) into several summary calls, instead of overflowing it and falling back to pruning.
-- `fromAiSdk()` warns once that `reasoning` is not sent, instead of dropping it silently.
-- `supportsTools()` of the built-in providers no longer answers `false` for current models (`gpt-5`, `o3`, `qwen3`); `getModels()` fallbacks list current, priced models.
-- `lousho build --target=cloudflare-worker` merges into an existing `wrangler.toml` in the output directory instead of overwriting it: a rebuild updates only `name`, `main`, `no_bundle` and `[triggers] crons`, and keeps the rest (a `compatibility_date` moved forward, `[vars]`, a filled-in `[[kv_namespaces]]` binding, routes, comments). The default `compatibility_date` of a new `wrangler.toml` is now `2026-09-01` (was `2024-09-23`).
-- `createOtelTraceExporter()` keeps at most 10,000 ended spans of still-open trees for parenting, and never drops an open span to stay under that cap (a long run with more than 10,000 spans, or many concurrent runs, keeps its root span and its parenting). Past 100,000 open spans (a run abandoned mid-approval, whose root never ends) the oldest open span is ended with `lousho.span.evicted: true` and released, so the map no longer grows without bound.
-- MCP client: a call that fails with the MCP SDK's bare `Not connected` (the transport is gone but `onclose` has not fired yet) now counts as a dropped connection: the status turns `'failed'` and, with `lazy` (default), the call is sent once more on a new connection instead of every call failing until `onclose` fires.
-- `serveMcp()` over HTTP: a client timeout (`notifications/cancelled`, which the stateless transport receives in another `POST`) now aborts the agent run it names, as does a client disconnect; previously the run kept going and spending tokens. A run that pauses for approval no longer leaves its pending approvals in the agent's store: they are rejected under an aborted signal (no further model call). The listener answers `initialize` with an `Mcp-Session-Id` (it still keeps no session state) and matches a cancel to the client that sent it by that id, so concurrent clients sharing an address, token and `User-Agent` can each cancel their own call, and a late cancel cannot stop another client's request with the same id; a cancelled request is answered with a JSON-RPC error (`-32800`) instead of being left open.
-- Svelte `loushoAgent()`: a transient subscribe/unsubscribe in one turn (`get(store)` from `svelte/store`) no longer aborts the run in flight; only a subscriber that outlived its turn (a component's `$agent`) stops the run when it leaves last.
-- React / Vue / Svelte remote mode: a stream that ends before its `run.done` (proxy timeout, server restart, truncated body) now ends with `status: 'error'` and a `StreamEndedError` (or the run's own `error` event, when the server sent one before the stream ended) instead of staying `'streaming'` forever; `parseEventStream()` joins an SSE event's multi-line `data:` fields as the SSE spec says.
-- A trace exporter whose `onSpanStart` / `onSpanEnd` throws (or, for an `async` one, returns a promise that rejects) no longer fails the run or the process: the error is reported once per exporter with `console.warn` and ignored.
-- Durability: a checkpointed run holds a **run lease** on its checkpoint id while it executes (owner, expiry, heartbeat; new optional `CheckpointStore.acquireLease()` / `releaseLease()`, implemented by the file, SQLite, in-memory and - best effort - KV stores). A second process, replica or store object that `send()`s to the same session, `agent.resume()`s or resolves an approval of a live run now fails with `LOUSHO_SESSION_BUSY` before anything runs; before, it resumed the live turn, ran its tool calls a second time and appended a duplicate tool-call id to the transcript. A crashed process's lease expires after 30 s, or is taken over at once on the same host. `fireSchedule()` / `startSchedules()` claim each fire time the same way, so replicas fire a tick once. See [Run leases](docs/durable-execution.md#run-leases).
-- Durability, run leases: a lease is no longer taken over early from a live run of another container with the same hostname (another PID namespace), another worker thread of the process or another copy of the SDK. Early takeover now needs a run of this very realm that stopped or, on Linux, an exited process of the same boot and PID namespace (the lease records the boot id, PID namespace and process start time; the SQLite store gains three `leases` columns). Expiry waits one more lease period for clock skew (a dead holder on another machine is taken over 30-60 s after its last heartbeat). See [Run leases](docs/durable-execution.md#run-leases).
-- Durability, run leases: a run that stalled past its lease (a paused process, a blocked event loop) can no longer overwrite the progress of the process that took over, nor append its late turn to the session transcript: its heartbeat only renews its own lease (new optional `CheckpointStore.renewLease()`), and its checkpoints are saved only while it holds the lease (new optional `CheckpointStore.saveIfLeased()`, atomic in the file, SQLite and in-memory stores); it fails with `LOUSHO_SESSION_BUSY` instead.
-- `fileStore`: a session, checkpoint or approval whose id an earlier release stored under a long case-safe name (181-200 characters, now shortened to `~~`) is read again after the upgrade; it loaded as empty or missing, and a pending approval on it resolved to `LOUSHO_APPROVAL_ORPHANED`.
-- Sessions: turn checkpoint history outside `turnHistoryLimit` that is already in the store (from an earlier release, or a larger limit, or `0`) is deleted over the next commits, up to 32 turns per commit; it was never revisited. New `fileStore(dir).prune({ olderThanMs })` (like `SqliteStore.prune()`) deletes old transcripts, checkpoints and history files, keeping runs paused on a pending approval; `fileStore()` returns the new `FileStore` type.
-- `approvalTtlMs` (`createAgent()`, `send()`, agent directories) and an `ask` rule's `ttlMs` above 100 years throw `LOUSHO_CONFIG_INVALID`; `1e20` silently meant "never expires".
-- Run ids (`send({ sessionId })`, a fork's `newSessionId`) may not end in `.fire`, which a schedule's fire claim uses, so a caller-chosen run id cannot hold a tick's claim and make the replicas skip it.
-- **Behavior change:** approvals run at most once and never lose the turn: deciding an approval no longer deletes the paused checkpoint before the approved call runs. It is re-saved as `'in-progress'` with the call in the new `Checkpoint.startedToolCallIds`; after a crash mid-call the turn is still listed by `pending()`, and resuming it gives the call a `kind: 'interrupted'` error result (new `ToolErrorKind`) instead of running it again. Before, `kill -9` during an approved tool erased the turn and its record while the tool had run.
-- **Behavior change:** a stale approval no longer resurrects a deleted run: resolving an approval whose run's checkpoint was deleted or replaced by a newer run fails with `LOUSHO_APPROVAL_ORPHANED` and withdraws it (before, it overwrote the newer conversation). `session.discardPending()` withdraws the dropped turn's approvals, and an orphaned approval is no longer listed or refused forever.
-- A run continued by `agent.approvals.resolve()` keeps its memory tools and recall, bound to the scope (session, metadata, principal) it paused with; before, every memory slot vanished after an approval. A session turn (or run) resumed without `metadata` / `principal` binds memory to the ones it was saved with, so a crashed turn with metadata-scoped memory resumes instead of failing with `LOUSHO_RESUME_TOOL_MISSING`.
-- Session turns keep the checkpoint history of only the latest 10 committed turns (new `agent.session({ turnHistoryLimit })`, `0` keeps none); storage grew with the square of the conversation (2.2 GB for a 7 MB transcript after 120 turns, now 0.35 GB) and `clear()` reads only each turn's newest history entry (11.5 s to 1.9 s).
-- `agent.send()` / `agent.stream()` validate `sessionId` the same way on every store (1-200 characters of `A-Za-z0-9_.-`) and refuse ids ending in `.turn-` or `.paused`, which addressed another session's turn checkpoint (`sessionId: 'victim.turn-2'` overwrote that session's transcript). `agent.fork()` refuses a session's `.paused` pointer and such a `newSessionId`. KV ids can no longer name a history key (`#`). `''` is refused instead of silently running without a checkpoint.
-- `FlowExecutor.resume()` refuses a flow whose steps were added, removed or moved since the run was saved (`LOUSHO_FLOW_CHANGED`) instead of mapping saved results onto the wrong steps.
-- File stores: taking over a stale lock file is serialized, so two writers can no longer both win a `FileSessionStore.saveIf()` (or a `fileMemory` write) after a crash left a lock; a slow holder keeps its lock fresh and releases only its own. A `fileStore` approval claim a crashed resolver left is taken over after a minute.
-- A corrupt checkpoint (a truncated file or row) fails with `LOUSHO_CHECKPOINT_CORRUPT` instead of a raw `SyntaxError`, `agent.pending()` lists it (with that code in `lastError`) instead of hiding it, and `discardPending()` / `clear()` drop it (and a corrupt transcript) instead of throwing. A corrupt SQLite transcript row fails with `LOUSHO_SESSION_FILE_CORRUPT`. The file stores `fsync` each write and its directory, so a power loss cannot leave an empty file.
-- `fileStore` stores ids that are too long once case-safe (a 128-character uppercase session id) or that are Windows device names (`nul`) under a hashed name instead of failing with `ENAMETOOLONG`.
-- `KVStore` approval claims wait a 50 ms settle window and re-check the record, so two concurrent resolves at one location run the tool once (before: both in 25 of 300 trials). `KVCheckpointStore.list()` throws `LOUSHO_CONFIG_INVALID` when the binding has no `list` instead of returning `[]`.
-- `approvalTtlMs` too large for a `Date` (e.g. `1e20`) means no deadline instead of a `RangeError` when the run pauses.
-- `defineTool` under zod 4.1+: a `z.preprocess(..., z.date())` field is no longer rejected as an unwritable `z.date()` (zod 4.1+ renders a preprocess pipe's output schema), matching zod 3, where a preprocessed field is skipped (MAIN-HEALTH)
-- Abort no longer waits on a provider that ignores the signal: the model call is raced against the run's signal (its late reply and later streamed chunks are dropped) and the run ends `finishReason: 'aborted'` instead of `'stop'` after the provider answers; a run aborted during a non-cooperative tool ends `'aborted'` without another model call. `result.abortReason` (`{ name, message }`, e.g. `'TimeoutError'` for `AbortSignal.timeout()`) tells a timeout from a cancel, and `mockModel` now honours `signal` (`delayMs` ends early, streams stop between chunks; `ignoreSignal: true` simulates a provider without cancellation). See [Cancellation](docs/runs.md#cancellation) (Eve CORE-F15).
-- Studio: the primary (Save) button's white text on the dark-mode accent was 3.67:1; it now sits on a deeper `--accent-solid` fill (4.92:1), checked by the contrast test (MAIN-HEALTH)
-- OpenRouter: a call that did not ask for web search or reasoning again gets no `hosted-tool-call` or reasoning chunks/blocks added from the response; every call is still read for `usage.cost` and cache tokens (regression from PROV-F3, #450) (MAIN-HEALTH)
-- Docs: `docs/sessions.md` explains that `agent.session({ id })` and `agent.send(msg, { sessionId })` keep two unrelated conversations under one id (session store vs checkpoint store) and what `agent.resume(id)` picks when both exist; `docs/schedules.md` no longer says a schedule fire is readable through `GET /chat/` and shows reading it from `store.checkpoints` (Eve DUR-F16).
-- `lousho build --target=cloudflare-worker`: a bundling failure (an import that cannot be resolved) is one coded `LOUSHO_DEPLOY_FAILED` error that says what to install, like the node-server target since #488, instead of esbuild's raw log and a stack; a half-written `dist/` is removed. `LOUSHO_BUILD_VERBOSE=1` still shows the full log. (Eve E14, CLI-F7)
-- Sub-agents: a dynamic sub-agent's config functions (`instructions`, `model`, `tools`, ... as functions) now get the lead run's `sessionId`, `metadata` and `principal` in `ctx`, not only the task prompt as `input`, so per-tenant or per-plan config works for sub-agents too. (Eve CORE-F7)
-- `node-server` and `docker` builds drain on SIGTERM/SIGINT instead of cutting running turns off: the server stops accepting, stops its schedules, waits up to 10 s (`LOUSHO_SHUTDOWN_TIMEOUT_MS`) for in-flight requests, detached turns and schedule fires, then closes the agent. `createDeployedServer()` returns this as `shutdown({ timeoutMs? })`. See [Deployment](docs/deployment.md#node-server). (Eve DUR-F15, partial)
-- `SqliteStore`: checkpoint saves and deletes, approval saves and `resolve()`, and OAuth sign-in claims now run in `transactionAsync()`, so a write that meets another connection's lock retries with backoff (about 5 s, without blocking the event loop) instead of failing at once with `LOUSHO_STORAGE_BUSY`. (Eve E14, DUR-F14)
-- `LocalStorageCheckpointStore` and `StorageServiceApprovalStore` (the `StorageService`-backed stores) keep image and file bytes: `StorageService`'s JSON attachments now write a `Uint8Array` as `{ "$bytes": "" }` and read it back, like the file, SQLite and KV stores, instead of turning it into a `{ "0": 137, ... }` object. (Eve E14, DUR-F5)
-- Pressing Stop (aborting `send()` / `stream()`) during a model reply no longer erases the user's message from the session: the aborted turn keeps the user's input, the tool calls that ran and the reply streamed so far as an assistant message with `metadata: { interrupted: true }`, so `GET /chat/:id` and the next turn keep the context; the aborted model call's usage now counts (estimated when the provider reported none), so `maxCostUsd` is not undercounted (Eve CORE-F11).
-- Cloudflare Worker agent directories: `subagents/` now run as native sub-agents (the `task` tool, with `delegate_to_` kept as an alias), like `loadAgentDir()` since #478, instead of a separate agent behind a hand-built tool - so the lead's deny rules, approvals (a sub-agent pause pauses the lead) and usage cover them, and nested directories raise `maxSubagentDepth`. (Eve E14, MA-F5)
-- `fileMemory` keeps every item when several providers or processes share one `dir`: each change holds a `.lock` lock file (stale locks taken over after 30 s), and Windows `EPERM`/`EACCES`/`EBUSY` on read and replace are retried; `sqliteMemory` changes run in one write transaction, so providers on one database no longer drop each other's items. Docs list which providers are safe with several writers (Eve MEM-F8).
-- Sub-agents: `subagentOptions: { background: false }` (also on `withSubagentOptions()`) leaves out background tasks, so the lead gets only the `task` tool instead of four tools and their prompt text (about 840 tokens a request), and the `task` tool's `description` is optional. See [Sub-agents](docs/sub-agents.md#background-sub-agents). (Eve MA-F14)
-- Package size: `llms-full.txt` (1 MB of docs as text) no longer ships in the npm tarball (4.60 to 4.27 MB packed, 17.06 to 16.05 MB unpacked; `llms.txt` stays, `llms-full.txt` remains in the repo). `src/` stays published because `lousho build` bundles from it, and embedding sources in the maps instead measured larger (5.24 MB packed). The trade-off is stated in `docs/installation.md#package-size` (Eve F4).
-- `modelSettings` are range-checked at `createAgent()` and at each `send()` / `stream()` (`temperature` 0..2, `topP` 0..1, penalties -2..2, `maxTokens` a whole number >= 1, `seed` a whole number, unknown keys like `maxOutputTokens` rejected): a bad value throws `LOUSHO_CONFIG_INVALID` naming the key before any model call, instead of a provider round trip whose error names the AI SDK's parameter. `agent.json` gets the same ranges (Eve CORE-F13).
-- Docs: why `engines.node` is `>=22.19.0` (the `undici@8` dependency; type stripping needs 22.18 and `node:sqlite` 22.13). The floor is kept: lowering it means relaxing the `undici` dependency first, which was not verifiable without older Node builds (Eve F13).
-- README: the visual tool is named "Agent Forge" and launched with `lousho studio` in one consistent phrasing; the stray `packages/create-loushy-agent` directory the audit found was never tracked (an untracked leftover `dist/` and `node_modules/` in one checkout), so nothing in the repo needed removing (Eve F11).
-- The two test files that sat inside `docs/` (`docs-links.test.ts`, `buildACodingAgent.test.ts`) and the coding-agent cassette now live in `scripts/`, so `docs/` holds only pages (Eve F16).
-- `lousho init` next-steps print `copy` instead of `cp` on Windows, and the README, quick-start and generated-project README no longer give the POSIX-only `cp .env.example .env` as the only way to create `.env` (Eve F15).
-- The optional peers `@earendil-works/pi-ai` and `@earendil-works/pi-coding-agent` are caret ranges (`^1.0.3`, `^1.0.2`), no longer exact pins, so a consumer on a newer 1.x pi build gets no peer-mismatch noise (Eve F17).
-- `tool_search` default ranking (`rankToolsByKeywords`) now gives partial credit to synonyms from a small built-in table (`fetch`/`get`, `share`/`stock`/`equity`, `value`/`price`/`quote`, ...) and to the same word family (`sending` finds `send`), and folds `-es` plurals correctly (`statuses` finds `status`), so semantically-named tools are found; exact words still rank first (Eve MEM-F13).
-- Docs: small drifts corrected against the code - an expired approval's tool error has no `reason` field (only the audit entry does), `send({ sessionId })` needs a store, the handoff run-level-option warning fires on the first run, `provider.fallback` names providers not models, `GET /chat/:id` has no messages for a paused turn, the node-server dependency is a `^` range, and built-in providers do not check the `ai` version pair (Eve DUI-F23).
-- Docs: `docs:verify-snippets` now type-checks `tsx` fences (JSX) against `src/` and has a `--source-only` mode; the approvals, sessions, tools, api-overview, configuration and streaming pages state the placeholder names their snippets assume (`provider`, `emailTool`, `storage`, ...); `installation.md` no longer claims `globalToolRegistry` is exported from the root; two snippets that failed the check (channels, oauth) are fixed (Eve DUI-F19).
-- Docs: `nextjs.md` no longer documents an `approvers` option (there is none) or a no-self-approval default (the run's own user may approve); the approvals section is rewritten around `authorizeApproval` with a two-person-rule example (Eve DUI-F12).
-- `lousho build --target=cloudflare-worker` and the `/worker` entry build again after the file-store retry (`storage/fsRetry`, reached from `createAgent()`) leaked `node:fs/promises` into the bundle: it now goes through the Worker node shim, and a test checks the shim exports every binding the shimmed modules import. (Eve E14 follow-up)
-- Two processes (or two store objects over the same data) finishing a turn on one session at the same moment can no longer both commit, silently losing one turn: a session commits through the new optional `SessionStore.saveIf(id, expectedRevision, messages)` compare-and-swap (`transcriptRevision()`), implemented by `MemorySessionStore`, `FileSessionStore` (an exclusive lock file with a stale-lock timeout, `staleLockMs`) and `SqliteStore.sessions` (one `BEGIN IMMEDIATE` transaction), so the loser rejects with `LOUSHO_SESSION_BUSY`; `KVStore.sessions` stays best-effort (Eve DUR-F4).
-- `session.fork()` works for valid session ids of 122-128 characters (the default id falls back to `--fork-` instead of an invalid `-fork-`, Eve DUR-F19), and the returned fork's `messages` are its transcript without a `load()` (Eve DUI-F23).
-- Sub-agents: a task that fails no longer gives its taskId to a later task of the lead session (a resume could reach the wrong conversation), and allocating a taskId no longer reads the store once per earlier task: the session's last taskId is kept in the session store and reserved when the id is handed out. (Eve MA-F12)
-- Docs: `llms.txt` descriptions are now a page's frontmatter `description` or its opening paragraph taken sentence by sentence with balanced brackets (no more cuts at `?` inside `{ ... }` or a dangling "with"), internal `LOU-*` ticket ids are stripped, and Deployment and Agent Forge are listed under Docs, not Optional (Eve DUI-F20).
-- `fileStore()` and `FileSessionStore` no longer throw a raw Windows `EPERM` when two stores (or a store and a reader) use one directory at once: a rename or read that fails with `EPERM` / `EACCES` / `EBUSY` is retried with backoff (about 1.3 s), then reported as `LOUSHO_STORAGE_FAILED` with the error as `cause` (Eve DUR-F13).
-- Channels: `POST //approvals/` takes an optional `sessionKey`, so a pause this process forgot (a restart, another replica) can still be decided with durable stores - the route used to 404. `resolveApproval()` stays process-local, now documented (Eve CH-F10).
-- Docs: TypeDoc now covers every `package.json` `exports` entry, not just the root, and `npm run docs:ratchet` (run in CI) fails if the count of undocumented exports rises above `scripts/typedoc-baseline.json` (Eve DUI-F18).
-- `KVStore` approvals: deciding one approval twice at once (a double click, a retried webhook) no longer runs the approved tool twice in the common case - `resolve()` writes a claim marker (`approvals/#claim`) and reads it back before taking the record. KV has no compare-and-swap, so a residual race across edge locations remains; the approved tool now gets the approval id as `ctx.approval.id` to use as an idempotency key. See [Cloudflare Workers](docs/cloudflare-workers.md#deciding-one-approval-twice). (Eve DUR-F9)
-- Channels: a redelivered webhook runs its turn once - Telegram by `update_id`, GitHub by `X-GitHub-Delivery`, Slack by `event_id` (in-memory, one hour, bounded) - and a Slack retry of an event this process never received now runs instead of being dropped (Eve CH-F8). `discordChannel` refuses a signed request whose timestamp is more than five minutes from local time, so a captured interaction cannot be replayed (Eve CH-F13).
-- Memory: `recall_` returns a vector provider's `score` and a free-text slot's `metadata`, and caps `limit` at 100 (Eve MEM-F11). `remember_`'s description no longer promises later conversations for a `scope: 'session'` slot (Eve MEM-F15). Opening as well as closing `' failed: ...` next to the top-bar buttons instead of failing silently. (Eve DUI-F10, DUI-F11)
-- One deterministic provider error (e.g. a 400 context overflow after a tool step) no longer bricks a checkpointed session: the turn's checkpoint records `lastError` and `attempts`, and later `send()` / `stream()` calls fail fast with `LOUSHO_SESSION_TURN_FAILED` (naming `discardPending()`) instead of replaying the poisoned turn each time; `resume()` still retries it explicitly (Eve DUR-F11).
-- Sub-agents: a `task` result whose answer hit the sub-agent's output token limit (finish reason `'length'`) now ends with a line saying the answer was cut off and may be incomplete, so the lead does not take it for a whole answer. (Eve MA-F15)
-- `ExecutionFinishReason` (`result.finishReason`, `run.done` / `step.done` events) is now a closed union, so a typo such as `'max_steps'` fails to compile; a finish reason a provider reports outside the known set is reported as `'other'`. `step.done` lists `'steered'` in its type, which it could already carry. (Eve CORE-F14)
-- `send(result.messages)` (a `Message[]` that already starts with the agent's system prompt) no longer sends the system prompt twice; it is only prepended when the input does not start with the same system message. (Eve CORE-F4)
-- Structured output: a run that runs out of `limits.maxSteps` with `output` set now makes the forced no-tools answer call like `maxSteps` does, so it ends `'budget-exceeded'` with `object` instead of nothing. `maxSteps` (on `createAgent()` and `AgentExecutor.execute()`) must be a whole number >= 1; `0`, negatives, `NaN` and fractions throw `LOUSHO_CONFIG_INVALID` instead of making zero calls or rounding up. (Eve CORE-F5)
-- Handoffs: a handoff target's own `toolSearch` applies after a handoff, as [Tool search](docs/tool-search.md#how-loading-persists) says. The lead's value (even an unset one) used to overwrite it, so a target that defers its tools sent them all upfront. (Eve MA-F6)
-- MCP client: `tools/list` follows `nextCursor` through every page (up to 100), a server's `notifications/tools/list_changed` lists its tools again (`connectMcp().tools` updated in place, new `onToolsChanged` option; agents offer the new list from their next model call), and a server whose connection drops without `close()` (e.g. its process exited) reports `status()` `'failed'` instead of `'idle'` and logs a warning with the exit code and stderr tail. See [MCP](docs/mcp.md#share-servers-with-connectmcp). (Eve TOOLS-F18)
-- UI: React `useLoushoAgent()` now returns `reset()` like the Vue and Svelte bindings; a failed remote request keeps the server's `{ error }` message (`POST /api failed with 401: Invalid API key`); `formatUsage()` shows a tiny cost with significant digits (`$0.000042`) instead of `$0.0000` (Eve CORE-F17).
-- Structured output: the `[output-invalid]` repair prompt, the reply it rejected, and the "You are out of steps" forced-answer prompt are sent to their model call only. They are no longer kept in `result.messages` or stored as user messages in session transcripts, where later turns saw them. (Eve CORE-F3)
-- A provider added with `LLMProviderRegistry.register()` can now be named in a model string (`groq/llama-3.3-70b-versatile`); it returned `LOUSHO_PROVIDER_UNKNOWN`. The retired fallback models are replaced: `claude-3-5-sonnet-latest` by `claude-sonnet-4-5` (also the `ANTHROPIC_API_KEY` default), `gpt-4` by `gpt-4o-mini`, and `openai/gpt-3.5-turbo` by `openai/gpt-4o-mini` (Eve PROV-F13).
-- Agent Forge: the agent list now comes from the server's `.lousho/agents/` (new `HttpAgentStore` over `GET/PUT/DELETE /agents`) instead of browser `localStorage`. Agents already on disk show up, agents no longer leak between projects opened in the same browser, and a new agent is written to disk on create, so chat works on it without pressing Run first (chat also sends the canvas's current spec). `localStorage` only keeps a draft of an edit that could not reach the server, keyed by the workspace directory (new `GET /workspace`). An unreadable YAML file in `.lousho/agents/` is skipped instead of failing the list. (Eve DUI-F2, DUI-F7)
-- A session turn paused for approval now locks the session id: `agent.send(msg, { sessionId })` / `agent.stream()` and session objects over another transcript no longer run a new turn beside it but throw `SessionAwaitingApprovalError` (`LOUSHO_SESSION_AWAITING_APPROVAL`) until the approval is decided or `discardPending()` drops the turn, and `pending()` / `resume()` find the pause whatever the transcript length (Eve EVE-0 follow-up).
-- Skills: `loadSkills()` skips a loose `.md` file with no frontmatter (a `README.md`) with a warning instead of failing the load, and its errors now carry `LOUSHO_SKILL_INVALID`. A `/SKILL.md` skill records its `directory`: `load_skill` returns the folder and its file list, and a new `read_skill_file` tool reads bundled files confined to that folder (Eve MA-F13).
-- Compaction: the default `protectedTokens` (40,000) is capped at half the threshold, so `compaction: { contextWindow: 8_192 }` on a local model prunes instead of silently changing nothing; an explicit `protectedTokens` at or above the threshold is capped the same way with a one-time `console.warn` (Eve MEM-F5). A compaction that changes nothing reports `compaction.done` with `unchanged: true` once, and later no-op attempts on that transcript emit no `compaction.start` / `compaction.done` pair (Eve MEM-F14).
-- `reasoning` is now sent to Claude Fable (Anthropic and OpenRouter), `gpt-oss` and DeepSeek V3.1+ on OpenRouter, and a warning is logged once per provider and model when it is set but not sent (it was dropped silently) (Eve PROV-F10).
-- `SubprocessSandbox` is harder to escape: `writeFile()` writes only under the new `root` option (default `process.cwd()`), refusing a path outside it, also through a symlink, with `LOUSHO_SANDBOX_PATH_DENIED`; containers run as a non-root user (`user`, default `'1000:1000'`) with `CapDrop: ['ALL']`, `no-new-privileges` and `limits` (default 512 MiB memory, 256 pids, 1 CPU). Pass `user: 'root'` for the old user. See [Guardrails](docs/guardrails.md#sandboxed-tools). (Eve TOOLS-F17)
-- A model response that ends with finish reason `'error'` (OpenRouter reports an upstream failure after the response started as a 200) now throws a retryable provider error instead of resolving as a success with truncated text, and a finish reason the SDK does not know maps to the new `'other'` instead of `'error'` (Eve PROV-F8).
-- Schedules: a prompt schedule now runs every fire in its own session, `schedule--`, on both `startSchedules()` (which wrote no session at all) and the Worker `scheduled()` path (which reused one `schedule-` session forever, re-sending every earlier report); `defineSchedule({ sharedSession: true })` opts back into one shared `schedule-` session (Eve DUR-F10)
-- Memory: a provider error during recall at the start of a run (a corrupt `fileMemory` file, a store outage) no longer fails the run; the slot's block is skipped with a one-time `console.warn` (Eve MEM-F4). `defineMemory()` rejects a `scope` other than `'global'`, `'session'` or a function, an unknown `recall.query`, and a provider without `remove`. A scope function that throws, or returns a non-string, an empty string or a key like `user:undefined` / `user:[object Object]`, now fails with `LOUSHO_MEMORY_INVALID` instead of pooling every caller's memory under one key (Eve MEM-F6). The built-in providers reject a missing or empty scope key instead of storing under `undefined` (Eve MEM-F7).
-- Cron: a fixed-hour job whose wall time falls in a spring-forward gap (`30 2 * * *` in `America/New_York`) now runs once at the first instant after the gap instead of being skipped that day (Eve DUR-F20)
-- Memory: keyword recall (`inMemoryMemory`, `fileMemory`, `sqliteMemory`, worker memory) now matches whole words (plural `s` ignored) instead of substrings, ranks items by how many query words they match, and no longer returns every item for a query whose words are all shorter than three letters (`"AI"`, `"my id"`) (Eve MEM-F1).
-- `edit_file` no longer corrupts non-UTF-8 files: on a provider with the new optional `FsProvider.readFileBytes()` (`NodeWorkspace` has it), a file with a UTF-16 BOM or one a fatal UTF-8 decoder rejects (latin1, Windows-1252) is refused and left byte-for-byte unchanged, instead of being written back with U+FFFD on lines the edit never touched. A UTF-8 BOM is kept. (Eve TOOLS-F7)
-- `mountFetchChannels()` takes a `waitUntil` option for hosts that pass no `ctx` (Next.js with `@vercel/functions`, or `(turn) => void turn` on Bun/Deno/Node), so an early Slack/Discord ack is returned at once instead of after the whole turn; without any `waitUntil` the held ack now logs a one-time warning (Eve CH-F4).
-- Routes: `POST /ui` answers 400 for an invalid chat `id` (as `POST /chat` does) instead of 500, and `authorizeSession` only sees a valid id (Eve CH-F11). `auth: ''` is now `LOUSHO_AUTH_CONFIG_INVALID` on both `createRouteHandler()` (at construction; it used to lock every route) and `serveFetch()` (it used to serve every route open) (Eve CH-F16).
-- Agent Forge: an agent name with a space or `!` no longer blanks the studio. The "+ New agent" form now validates the name inline against the server's id rule, every runtime-client call rejects on a non-ok response instead of handing an `{ error }` body to the UI (the source of `n.filter is not a function`), and a top-level error boundary shows what broke with Try again / Reload. (Eve DUI-F3)
-- `agent.stream()`: when the run fails, the `for await` loop now rethrows the run's error after yielding the `error` and `run.done { finishReason: 'error' }` events (it used to end normally and hide the failure); pass `{ throwOnError: false }` to keep the old behaviour. See [Streaming](docs/streaming.md) (Eve CORE-F10).
-- Tracing: a sub-agent's `invoke_agent` span now says which sub-agent it is. An unnamed sub-agent uses its `subagents` key as `gen_ai.agent.name` and span name (every span used to read `invoke_agent agent`), and sub-agent runs carry `lousho.subagent.name`, `lousho.subagent.depth` and `lousho.task.id`. See [Observability](docs/observability.md#attributes). (Eve MA-F10)
-- Channels: a 500 from `mountChannels()` / `mountFetchChannels()` (a throwing `verify` or `parse`) no longer echoes the internal error message; the body is the session routes' generic text (plus the error `code`) and the error goes to `onError` (Eve CH-F6).
-- `lousho init` now says, after scaffolding, when the chosen provider's API key is not in the environment (`npm run dev` will fail until it is set in `.env`; `npm run test` runs offline), instead of ending on a next-steps list that dead-ends (Eve CLI-F19).
-- With a checkpointed session (`createAgent({ store })`), tools, `needsApproval`, permission rules and tool-call hooks got the turn's checkpoint key `.turn-` as `ctx.sessionId`; they now get the session id, as without a store (also after an approval). New `ExecuteOptions.contextSessionId` carries it next to the checkpoint `sessionId` (Eve DUI-F5).
-- `session.clear()` now deletes the checkpoint and checkpoint history of every turn (`.turn-`, including turns from before a compaction), not only the pending one. The history held full copies of the cleared transcript, and `agent.fork('.turn-')` could bring the conversation back. (Eve DUR-F8)
-- Channels: two near-simultaneous decisions on one approval (a double click while `onDecision` runs) decide it once; the loser gets `409 LOUSHO_APPROVAL_CONFLICT` (or nothing, once the surface was acknowledged) instead of a 500 or a false "Sorry, that request failed." reply (Eve CH-F7).
-- `lousho build --target=node-server` reports a bundling failure as a `LOUSHO_DEPLOY_FAILED` error (an unresolved package says to `npm install` it, like `lousho dev`) instead of esbuild's raw log with misleading advice, no longer reports success, and removes the half-written `dist/`; `LOUSHO_BUILD_VERBOSE=1` still shows the full log (Eve CLI-F7).
-- OTel: a queued background sub-agent (one that starts after its `task` tool span ended) now stays in the lead's trace instead of starting its own root trace; `createOtelTraceExporter()` keeps ended spans' contexts until the root span ends. (Eve MA-F3)
-- The `grep` workspace tool no longer lets a model's regex freeze the process: a pattern with nested repetition (`(a+)+`) is refused, each line is tested on its first 2,000 characters, and matching runs in a worker thread with a 10 s budget that a timeout or an abort terminates. See [Workspace tools](docs/workspace-tools.md). (Eve TOOLS-F6)
-- Evals: `lousho eval --record/--replay` match replayed calls by request instead of position, so parallel `task` calls to one sub-agent replay reliably, name a sub-agent's cassette after the agent (`-..json`; old `.2.json` files still replay), and a `CassetteMismatchError` inside a sub-agent now stops the run instead of becoming the task's failure text. `setProviderInterceptor` callbacks get `{ agent }` as a second argument (Eve MA-F7).
-- `lousho add` warns, before and after writing, when `--dir` is not an agent directory (nothing loads `tools/` or `skills/` there, e.g. a `lousho init` project), and a `kit` install creates a missing `--dir` instead of failing (Eve CLI-F3, F18).
-- `withRetry({ timeoutMs })` no longer aborts a healthy `stream()` partway through the body: it bounds only the wait for the first chunk. New `idleTimeoutMs` fails a stream that stalls between chunks (Eve PROV-F7).
-- `lousho mcp` accepts an agent directory or `agent.ts` module like `dev`/`chat`/`acp` (it crashed with a raw `EISDIR`), and its errors carry the `lousho mcp:` prefix (Eve CLI-F2).
-- Provider errors: an out-of-credit account (OpenAI's `insufficient_quota` 429, a 402 such as OpenRouter's "Insufficient credits") is the new non-retryable category `quota-exceeded` instead of a retried `rate-limit` (Eve PROV-F6).
-- Sub-agents: a sub-agent that fails (a provider error, say) after spending tokens now adds that spend to the lead's `result.usage` (totals, `byModel`, `usage.delegated`) and its `limits`. Before, it vanished, so retry loops could burn money the budget never saw. (Eve MA-F1)
-- `defineTool()` now throws `LOUSHO_CONFIG_INVALID` for an `input` no model can satisfy: a non-object root (`z.string()`, `z.array()`, ...), which providers reject, and `z.date()` / `z.bigint()` fields (zod 3 and 4), which fail on every call; the message points to `z.coerce.date()` / `z.coerce.bigint()`. See [Tools](docs/tools.md#definetool-options). (Eve TOOLS-F10)
-- `SqliteStore`: the driver's busy wait is now 50 ms instead of 5 s (it froze the whole event loop), lock contention surfaces as the coded `LOUSHO_STORAGE_BUSY` instead of a raw `ERR_SQLITE_ERROR`, and the connection gains `transactionAsync()` which retries with a non-blocking backoff (Eve DUR-F14)
-- Structured output: a final reply cut off at `maxTokens` (`finishReason: 'length'`) no longer gets a repair call that would be cut off the same way; the run ends `'output-invalid'` with `outputError.kind: 'truncated'` and a hint to raise `modelSettings.maxTokens`. `OutputError` gains `kind` (`'invalid'` or `'truncated'`). (Eve CORE-F12, PROV-F12)
-- Tracing: a background sub-agent's cost now rolls up into the lead's `invoke_agent` span `lousho.cost_usd` (a span that ends after its parent adds to the nearest open ancestor), and `listTraces()` / `lousho traces` report the sum of the chat spans' cost, so the cost matches the token counts and `result.usage.costUsd`. (Eve MA-F2)
-- `WebhookTriggerAdapter` reads the body through the 1 MB capped reader channels use and answers `413` instead of buffering any size before auth, and a failing handler's `500` no longer echoes the internal error message (Eve DUR-F18)
-- CLI: `lousho --version` / `-v` prints the version; the top-level `lousho --help` lists `doctor --ping` and `eval --timeout`; `lousho chat` and `lousho acp` name themselves (not `lousho dev:`) when the target path is missing or unsupported (Eve CLI-F8, F9, F10).
-- `withRetry()` no longer sleeps for a huge `Retry-After`: a hint above `backoff.maxMs` fails the call at once with `compacted.retryAfterMs` set; `retry-after-ms` is read, and `Retry-After` is honoured on a 503 (Eve PROV-F5).
-- `serveAcp` (and `lousho acp`) no longer crashes on a JSON line that is `null`, an array, a string or a number, or on a `null` `params`: it answers `-32600` (or reads `params` as `{}`) and keeps serving. (Eve TOOLS-F14)
-- Sessions: a session object no longer caches its transcript after the first read. Every call (`load()`, `send()`, `history()`, ...) reads the store again, so after another object for the same id (or `agent.resume()` / `agent.approvals.resolve()`) committed a turn, the old object sees it and keeps sending instead of failing `LOUSHO_SESSION_BUSY` on every call. A turn that still loses the commit race keeps the tool calls that ran (added after the other writer's turn) before it fails `LOUSHO_SESSION_BUSY`. (Eve DUR-F3)
-- Evals: `parseJudgeScore` reads the last `SCORE:`-labelled number and treats unlabelled prose as unparseable (it used to take the first number, so "1 of 5 ... Final: 0.2" scored 1). `llmJudge()` / `llmCritique()` now show the judge the user's input, ask for a final `SCORE:` line and default `temperature` to 0 (Eve MA-F8).
-- Chat UI reducer (`useLoushoAgent`, Vue, Svelte): each model step's text now starts a new paragraph instead of being glued to the previous one, a run with an `output` schema shows only its final reply, `UIMessage` and the state gain `finishReason` (and `object`), and `max-steps`, `output-invalid`, `budget-exceeded` and `guardrail` endings set `status: 'error'` with a `RunEndedError` error instead of looking like a normal idle finish (Eve CORE-F9).
-- Chat UI reducer (`useLoushoAgent`, Vue, Svelte): each model step's text now starts a new paragraph instead of being glued to the previous one, a run with an `output` schema shows only its final reply, `UIMessage` and the state gain `finishReason` (and `object`), and `max-steps`, `output-invalid`, `budget-exceeded` and `guardrail` endings set `status: 'error'` with a `LOUSHO_RUN_ENDED` error instead of looking like a normal idle finish (Eve CORE-F9).
-- `modelSettings.stop` now reaches the model as `stopSequences`, and `modelSettings.toolChoice` (`'auto'`, `'required'`, `'none'`, or a named function) is new and sent whenever the call has tools; before, `stop` was dropped on the way to the AI SDK (Eve PROV-F9).
-- `SqliteStore.prune()` no longer deletes a turn paused on an unresolved approval: the turn's checkpoint, its history and the session's transcript are kept however old, as the JSDoc promised. Resolving an approval whose paused session turn was deleted anyway now fails with the new `LOUSHO_APPROVAL_ORPHANED` before the tool runs, instead of running it and failing with `LOUSHO_SESSION_BUSY`. (Eve DUR-F6)
-- `githubChannel`: a `/approve` or `/deny` for an approval that is not pending (stale or made-up id) no longer posts "Approved by @x" before the failure; it is ignored silently (Eve E17 / channels F5).
-- Model price lookup: `claude-haiku-4.5` (OpenRouter spelling) now finds `claude-haiku-4-5`, and a snapshot match needs a date, `-latest` or a `:tag`, so `mistral-7b-instruct` no longer resolves to `mistral`; `gpt-4o-2024-05-13` has its own price (Eve PROV-F3).
-- Flows: when a `parallel` branch fails, its siblings are now aborted (no further step starts; running model/tool calls get the aborted signal) and the flow returns only after every branch settles, so tools no longer keep running and events are no longer appended to `result.events` after the failure is returned. `FlowExecutionResult` gains `usage`, the summed model usage of the run. (Eve DUR-F12 / MA-F11)
-- `npm run typecheck:examples` (`tsconfig.examples.json`) type-checks `examples/` under the strict test config and runs in CI; the ten errors it found are fixed (Eve DUI-F16).
-- UI remote mode (React, Vue, Svelte) now keeps the conversation: the runner generates a chat id, sends `{ input, sessionId }` with every turn and regenerates it on `reset()`; `{ url, sessionId }` resumes a stored session. Previously every browser message started a new server session (Eve CORE-F1).
-- MCP tools: an object input schema with a sibling `oneOf` / `anyOf` (e.g. GitHub's required-only branches `oneOf: [{ required: ['issue_number'] }, { required: ['title'] }]`) keeps its object shape instead of collapsing to `anyOf: [{}, {}]`, which providers rejected with a 400 on every request. A tool root that still converts to a non-object becomes a passthrough object (Eve TOOLS-F4).
-- Flows: each `forEach` iteration now has its own `item`/`index` variables, so two loops in `parallel` branches no longer overwrite each other's `{{item}}` across awaits (wrong arguments reached tools). The loop variables no longer leak into `result.variables` after the loop, and shadow an outer variable of the same name instead of overwriting it; other `setVariable`/`outputVariable` writes still reach the flow's variables. (Eve DUR-F1)
-- A throwing `onEvent` / `onAgentEvent` listener no longer rejects `send()` / `stream()` after the run's tools already ran: each listener call is isolated, and the first error of a run is reported once with `console.warn` (Eve CORE-F2).
-- `webhookChannel` authenticates requests on Fetch hosts (`mountFetchChannels`: Workers, Vercel, Bun, Deno): signed, bearer and custom auth read the channel's normalized headers instead of a Fetch `Headers` object, so correctly authenticated requests no longer all get 401 (Eve E17 / channels F2).
-- `limits.maxCostUsd` and `usage.costUsd` now work for every OpenRouter model: the cost OpenRouter reports (`usage.cost`) is used in place of the price-table estimate, and a warning is logged once when `maxCostUsd` is set but a call's cost is unknown (Eve PROV-F3).
-- examples/ops-pipeline: `npm run pipeline:demo` now binds `MONITOR_PORT` (8787) / `SLACK_PORT` (8788) so `pipeline:demo:trigger` works; README no longer tells you to overwrite the root `.env`; `.env.example` trimmed to variables actually read; the mock PR is logged (Eve DUI-F6).
-- Image and file bytes in a paused approval (`SqliteStore`, `KVStore`) or a KV checkpoint (`KVStore`, `KVCheckpointStore`) now come back as `Uint8Array` instead of `{ "0": 137, ... }`, so a resumed run sends the model the real image. Bytes are saved as `{ "$bytes": "" }`, like the session stores. (Eve DUR-F5)
-- OAuth: concurrent `ctx.getToken()` calls that need the same refresh now share one token request, and a refused refresh deletes the stored token only if it is still the one that failed (otherwise the newer token is used). With a provider that rotates refresh tokens, two parallel tool calls used to send the same single-use refresh token twice, and the refused one deleted the good new token. (Eve TOOLS-F16)
-- `agent.send(msg, { sessionId })` / `agent.stream(msg, { sessionId })`: concurrent calls with one `sessionId` now run one after another (per checkpoint store, in this process) instead of both continuing the same finished checkpoint, where the last save silently dropped the other turn. (Eve DUR-F2)
-- Structured output on strict endpoints (OpenAI, Anthropic): `z.discriminatedUnion`, a root `z.union`, `z.record` and `z.tuple` no longer fail with a 400, and Claude no longer answers a record with `{}`. The schema sent to the model rewrites `oneOf` to `anyOf`, wraps a non-object root as `{ result }`, sends a record as `{ key, value }[]` and a tuple as `{ _0.._n }`, and the reply is decoded back before validation; a root union with a non-object branch is accepted instead of throwing. See [Schema limits](docs/structured-output.md#schema-limits). (Eve PROV-F1, PROV-F2)
-- Channels: `mountChannels()` / `mountFetchChannels()` without a `store` now keep channel transcripts in the agent's `createAgent({ store })` (when it has `sessions`) instead of a private in-memory store, so `agent.resume(id)` refuses a paused channel turn with `SessionAwaitingApprovalError` instead of resolving `null`, transcripts no longer diverge from the durable store, and `createDeployedServer` channel sessions (and `ctx.hasSession`) survive a restart. (Eve EVE-0, Eve channels-server F1)
-- Agent-directory sub-agents (`subagents//`) now run through the native sub-agent runtime: each lands in the parent's `subagents` map (the `task` tool), and `delegate_to_` stays as an alias through `runSubagent()`. The parent's deny rules hold in the child, a child's approval pauses the parent instead of being dropped, its usage rolls up, and its events and spans are tagged and nested (Eve MA-F5).
-- `fileStore()` (sessions, checkpoints, approvals), `FileSessionStore` and `fileMemory()` write each uppercase letter of an id or scope key as `^` and the lowercase letter (`Alice` -> `^alice.json`), so ids that differ only in case (`alice`, `Alice`) no longer share a file, and read each other's transcripts or memories, on the case-insensitive filesystems of Windows and macOS. All-lowercase ids keep their file names; a file saved under an older mixed-case name is still read for that exact id and moved on its next save. (Eve DUR-F7, MEM-F3)
-- Resolving a session-paused approval from a restarted process (or any caller that never opened the session) no longer deletes the turn: the resume binds the pause's `snapshot.sessionId` back to its session — `.turn-` checkpoints name session `` — so the finished turn commits to the transcript instead of being discarded. Previously the tool ran, the run finished, and the transcript read empty.
-- Concurrent turns on one session: a second writer over the same transcript (another process, a second `fileStore` object, racing HTTP requests) can no longer silently overwrite a finished turn — committing now verifies the transcript is unchanged since the turn started and fails with `LOUSHO_SESSION_BUSY` when it isn't (the leftover checkpoint is dropped rather than replayed over the committed transcript).
-- Memory slots that resolve to the same scope key no longer share one item bucket: the provider key is now `#`, so two `defineMemory` slots on `scope: 'global'` (or one scope function) stay separate. Items stored under the old bare scope key are no longer read: run `migrateMemoryKeys(slot)` once to copy them (see [Memory](docs/memory.md#items-from-before-slot-name-keys)).
-- `createShellTool` `allow` string prefixes no longer accept workspace-escaping arguments: a command matched only by a string pattern is refused when it carries a `..` segment (including `--flag=../x`), a `~` path, or an absolute path (`/x`, `C:\x`, `\\share`). `CommandRule` and anchored `RegExp` patterns are unaffected — they are the documented escape hatches.
-- `compactProviderError` surfaces the upstream provider's own message: OpenRouter's `"Provider returned error"` now carries the nested `metadata.raw` reason (bounded), so e.g. a strict-`response_format` rejection names the real schema problem.
-- Finished session turns keep their checkpoint history (`.turn-`), so `agent.fork('.turn-', { fromStep })` can branch a completed `session.send()` turn exactly like a finished `send(msg, { sessionId })` run.
-- `webhookChannel` pause replies now include the computed `approvalPrompt` text instead of an empty `text` field.
-- Registry receipt enforcement now covers a kit's sub-agents. Tools in an installed kit's `subagents//` directories (e.g. `coding-kit`'s `explorer`) were never confined, because only the top-level directory's receipt was read. They are now held to the kit's receipt like its other tools: an `exec` kit's sub-agent tools wait for approval, and the sub-agent's own `approve` file may not decide them. The `approve` callback you pass to `loadAgentDir(dir, { approve })` decides them. Without one the delegated call does not run, and the lead gets a message that the sub-agent stopped for an approval instead of an empty answer.
-- A client that disconnects mid-turn no longer loses the turn. Before, closing the tab while a tool ran (a refund) aborted the turn and dropped it from the session, although the refund had happened, so the next message started with no record of it. `createRouteHandler()`, `createDeployedServer()` and the Worker now let a session turn run to its end and save it when its client goes away (`POST /chat`, approval decisions and the `useChat` endpoint), and stop writing to the closed stream. New options: `onDisconnect: 'abort'` keeps the old behaviour, and `waitUntil` keeps a serverless function alive until the turn is saved (the agent-directory Worker passes `ctx.waitUntil`). A session turn aborted with `signal` (or by leaving the `for await` loop) now keeps the tool calls that ran and their results in the transcript instead of dropping them. See [Next.js](docs/nextjs.md#routes) and [Sessions](docs/sessions.md).
-- `lousho add` works out of the box again. The default registry host `registry.lousho.com` does not resolve yet, so every `lousho add` without `--registry` failed with `LOUSHO_REGISTRY_UNREACHABLE`; when the default host is unreachable it now reads the same `registry/dist/` from the repository's `main` branch on GitHub. An explicit `--registry` or `LOUSHO_REGISTRY` never falls back, and when both are down the error names both and suggests `--registry `.
-- `lousho add` in a non-interactive shell checks `--yes` and `--allow` before printing anything, and fails with one line naming the exact flags the item needs (e.g. `pass --yes --allow exec,fs-write`) instead of printing the whole manifest first and then asking for one flag at a time.
-- `loadAgentDir()` / `resolveAgentDir()`: a file that fails to import now throws an `SDKError` with code `LOUSHO_AGENT_DIR_INVALID` instead of a bare `Error`. When a package import cannot be resolved (a kit installed into a folder with no `node_modules` above it), the hint names the package, says Node resolves it from `node_modules` next to the file or in a parent folder, and gives the `npm install` command.
-- `lousho doctor` shows the base URL from `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL` on the provider's line when it is set, so a local or OpenAI-compatible endpoint is visible (credentials and query string are left out).
-- Tool-call arguments that are not valid JSON no longer run the tool with `{}`: a truncated `{"path":"src"` used to make `list_dir` list the workspace root. The model now gets a `ToolArgumentsValidationError` (`LOUSHO_TOOL_ARGS_INVALID`) saying `arguments are not valid JSON: ; received: `, and can retry. Before failing, a small repair is tried: a surrounding markdown code fence, trailing commas and double-encoded JSON are fixed when the result is an object. Empty arguments still mean `{}`. `tool.start` has a new `rawArgs` field with the text the model sent, set when it was repaired or did not parse.
-- A tool name with a prefix some models add (`functions.read_file`, `tools.read_file`, `default_api.read_file`) now runs the registered tool when the rest of the name is a known tool. A tool-not-found error suggests the closest registered name and lists the available tools: `Tool 'read-file' not found. Did you mean 'read_file'? Available tools: ...`.
-- Structured output: a near-miss final reply no longer costs a repair call. When the reply is not JSON as a whole, `` blocks are removed and the first fenced block or balanced JSON object/array that validates is used, so a prose prefix, trailing prose after a fenced block, or a reasoning block before the JSON is accepted as is.
-- Structured output: when the model answers with the JSON Schema itself, the repair message now says so and shows an example object of the expected shape, instead of listing missing fields (models repeated the schema).
-- Structured output: a root-level union of objects (`z.union`, `z.discriminatedUnion`) is sent with `type: 'object'`; a root union with a non-object branch, and a zod 4 `z.date()` field (sent as `{}`, never valid), now throw a `ConfigurationError` from `createAgent()` / `AgentExecutor.execute()` that names the fix (`z.object({ result: z.union([...]) })`; `z.iso.date()`, `z.iso.datetime()`, or `z.iso.datetime().pipe(z.coerce.date())`).
-- `execute_tool` spans: the deprecated `error` attribute now holds the tool's error message, like on every other span, instead of `true`/`false`, and is absent when the tool succeeded. `error.type` and the span's error status are unchanged.
-- `agent.approvals.get(id)` with a `fileStore()` returns `undefined` for an id that could not name an approval file (`'bogus id!'`, `''`), as documented for unknown ids, instead of throwing `ConfigurationError`. Saving or resolving with such an id still throws.
-- Retries no longer stack. A provider instance passed to `createAgent()` kept the `ai` SDK's hidden 2 retries inside every SDK retry: `retry: false` still sent 3 requests and `retry: { maxRetries: 3 }` sent 12. `withRetry()` (and so `retry`) is now the only retry layer: the built-in providers send its calls with the `ai` SDK's own retries off, and `retry: false` sends each call once. A provider instance with no `retry` set keeps its `ai` SDK retries (default 2) as before.
-- A streamed model step that fails before any output is now retried. Agents stream their model calls whenever they have an event listener, and the stream was committed at its first chunk, so LM Studio's empty `reasoning-end` followed by a 500 error event was never retried. Reasoning chunks and empty text deltas no longer commit a stream; a failure after text, a tool call or the finish is still not retried. In an agent run the failed attempt's reasoning ends with `reasoning.done` before the `provider.retry` event, and the result's `reasoning` is the successful attempt's only. See [Provider retries and fallback](docs/configuration.md#provider-retries-and-fallback).
-- A model call no longer sends `temperature` or `maxTokens` as explicit `undefined` when neither is set. Before, a wrapping provider that added its own `maxTokens` to the request had it overwritten. Keys that are not set are now left out.
-- `loadAgentDir()` / `resolveAgentDir()` no longer silently drop `createAgent()` overrides. Only a hand-picked list (`provider`, `tools`, `permissions`, `maxSteps`, ...) reached the agent before; `guardrails`, `onEvent`, `retry`, `reasoning`, `redactContent`, `output`, `fallbackModels`, `handoffs` and the rest were ignored, so a guardrail passed this way never ran. Every override is now forwarded and wins over the directory's config. See [Agent directories](docs/agent-directories.md#overrides).
-- Shell tool: under cmd.exe, `%` (variable expansion) and `^` (escape) now count as shell operators for `allow` string patterns, so `node --test %COMSPEC%` no longer passes as `node --test`.
-- Provider errors from local runtimes are classified correctly. A llama.cpp / LM Studio context overflow ("request (N tokens) exceeds the available context size (M tokens)", `exceed_context_size_error`, "Context size has been exceeded") is now `context-length-exceeded` and not retryable, whether it comes back as a 400, a 500 or a stream error event, so it is no longer retried as a transient 5xx. A refused or failed connection now names the cause and URL (`connection refused (ECONNREFUSED) at http://localhost:1234/v1/responses - is the server running?`, also `ENOTFOUND`, `ECONNRESET` and undici's headers timeout) and is `timeout`/retryable. An object-valued stream error no longer reads `[object Object]`, and when the provider's error body could not be parsed (message just `Bad Request`) a short snippet of the body is appended. See [Errors](docs/errors.md#lousho_provider_request_failed).
-- Handoffs now work on local models (Qwen, Llama and Mistral chat templates in LM Studio, llama.cpp, vLLM, Ollama) and on Anthropic: model requests never carry a `system` message after the conversation has started. The handoff's routing note (and any system message a caller puts mid-history) is appended to the request's leading system prompt; the transcript still records the note where it happened, so `handoffFilters` and `inputFilter`s see it as before. Previously every handoff failed with "System message must be at the beginning". See [Handoffs](docs/handoffs.md#input-filters).
-- Memory stays on after a handoff: the lead's `memory` slots now follow the run to the handoff target, both in the handoff run and in every later session turn the target runs. The target gets the recalled `` blocks in its system prompt and the `remember_` / `recall_` tools, bound to the same scope keys (the same customer, user or session). A target's own `memory` option is still not used, and `createAgent()` now warns about it like the other run-level options. See [Handoffs](docs/handoffs.md#what-switches-and-what-stays).
-- Compaction and tool search now log a one-time `console.warn` when they fall back to a 128,000-token context window for a model the registry does not know (most local models), naming `compaction.contextWindow` / `toolSearch.contextWindow` and `registerModel()`. Before, a local model with an 8K window silently never compacted. See [Local and unknown models](docs/compaction.md#local-and-unknown-models).
-- The compaction hook's size estimate now counts the tool definitions and the output schema, not just the messages, and adds whatever the provider's reported prompt tokens exceeded the estimate by on the previous call. Before, an agent with many tools could overflow its window without compaction firing. `compaction.start` / `compaction.done` token counts are now these request sizes.
-- MCP tool calls now honour the run's abort signal: aborting a run cancels the call in flight instead of waiting for the server to answer. A new `timeoutMs` on an `mcpServers` entry (and `loadMcpTools(client, name, { timeoutMs })`) bounds each call; the default stays the MCP SDK's 60 seconds. See [MCP](docs/mcp.md).
-- An MCP tool result made only of text no longer reaches the model twice. It was serialized as both `text` and `content[].text`, which more than doubled the tool message (a 9.7k-character result became 21k). The model now gets `{ "text": ... }`; the result object still exposes `content` (non-enumerable) for code that reads it.
-- Schedules no longer swallow unfinished prompt turns: a turn ending with any `finishReason` other than `'stop'` (for example `'awaiting-approval'`, whose pending approval id the message names, or `'output-invalid'`) is reported to `startSchedules()`'s `onError`, logged by a Worker's `scheduled()`, and rejects `fireSchedule()`, as the new `LOUSHO_SCHEDULE_RUN_INCOMPLETE` error.
-- `startSchedules().stop()` now returns a promise that resolves once the runs in flight have finished, so shutdown can wait for them; calling it without `await` still works.
-- Compaction: a rejected summary no longer turns summarizing off for the rest of the run. The hook prunes until the transcript has twice as many messages as at the rejection, then lets the summarizer try again.
-- Compaction: the summarizer's model call now counts in the run. Its tokens are in `result.usage` (`byModel`, `modelCalls`), it gets a `chat` span tagged `lousho.call.purpose: 'compaction'`, and it counts toward `limits` budgets. New `GenerateHookContext.generate()` lets any `preGenerate` hook make such a counted side call.
-- `compaction.done.strategy` now matches `compaction.start.strategy` (the strategy that ran). A new `appliedStrategy` (also on `onCompaction`'s info) names the result that was applied when it differs, e.g. `'prune-tool-results'` after a rejected summary.
-- Two requests that decide the same approval at once: the loser now gets `409 LOUSHO_APPROVAL_CONFLICT` instead of a `200` stream that ends in an error.
-- `lousho eval` no longer fails live, `--record` and `--drift` cases on vitest's 5 s default test timeout: the generated config now gives each case 10 minutes (`--replay` keeps vitest's default; a `--config` keeps its own `testTimeout` unless `--timeout` is given). A case that times out is reported as an error naming the limit.
-- `lousho eval` JUnit and JSON reports are no longer empty when vitest itself fails: a timed-out case, an eval file that fails to load or a failing plain `test()` each get an `` testcase, so CI never shows a crashed run as green.
-- `lousho eval --record` no longer lets two cases share one cassette and silently overwrite each other. Cassette names now end in a short hash of the full case label (`-.json`), so labels that only differ after 45 characters get their own file, and two cases that would still collide (the same label twice) fail at record time. Cassettes under the old `.json` name still replay; the next `--record` writes the new name.
-- The re-record hints of `lousho eval` (missing cassette, replay mismatch) now name `npx lousho eval --record` with the `--config` the run used, instead of `LOUSHO_RECORD=1`, which `lousho eval` ignores.
-- Re-recording a cassette whose exchanges did not change keeps its `recordedAt` and `sdkVersion`, so the file shows no diff.
-- Flows can be cancelled: `FlowExecutionContext` has a new `signal`. It is checked before every step, so once it is aborted no further step starts and the flow fails with the signal's reason; a running `llmCall` gets it as the request's `signal`, and a `toolCall` as the tool's `ctx.abortSignal`. A signal passed before was ignored and the flow ran to the end. Steps without an `id` now get ids unique within the run (`step-1`, `step-2`, ...); they all got `step-${Date.now()}` before, so ids collided and step events could not be paired. See [Flows](docs/flows.md#cancelling-a-flow).
-- `recordReplay` now records and replays a `generate()` call's reasoning blocks and the full token usage (cached-input and reasoning tokens, provider-reported cost); before, a replayed run lost its reasoning and those counts. Existing cassettes still replay.
-- The judge score parser reads `Score: 0.9`, `**0.9**`, `9/10`, `7 out of 10`, `85%` and bare whole numbers out of 10 or 100 (`8` is 0.8). Before, the first two scored 0 and `9/10` only reached 1 by clamping.
-- Registering an agent's tools in another order no longer breaks replay with a mismatch pointing at `tools[0].description`: tools are matched by name, also against cassettes recorded before.
-- Parallel tool calls that finish behind a slower earlier call are now checkpointed as soon as they settle, keyed by `toolCallId`. Previously only the in-order prefix of finished calls was checkpointed, so a crash while an earlier call was still running made `resume()` re-execute every finished call behind it — side-effecting tools (charges, emails) ran twice. The transcript still records results in the model's call order.
- A streamed tool call whose deltas repeat its `id` while omitting `index` (the OpenAI-compatible / Gemini-proxy servers the tool-call id rewrite exists for) stays one call: each repeat no longer mints a `call_1_2`, `call_1_3`, … id that split the arguments JSON across phantom calls none of which parses; the call's slot in the delta's `tool_calls` stands in for the missing `index`. (Eve2-F7)
- A non-streamed request whose body carries a nested `"stream": true` (a `providerOptions` passthrough, tool arguments, gateway metadata) no longer has its JSON reply rewritten into an SSE stream the consumer cannot parse: whether a request asked for streaming is read from the parsed top-level `stream` field instead of a regex over the raw body. (Eve2-F8)
-
-### Security
- Session routes: two concurrent first-touch turns on one unowned session no longer both pass the owner check - the claim is atomic now, so the loser is refused (`403 LOUSHO_SESSION_FORBIDDEN`) instead of streaming a turn inside the winner's session; a claim lost while a turn ran clears what it stored and ends the stream on the same coded error. (Eve2-F1)
-- The `@modelcontextprotocol/sdk` peer range is now `^1.31.0` (was `^1.30.1`): 1.30.x is affected by GHSA-6qxp-vccf-f47h (its OAuth client could send credentials to an authorization server chosen by the MCP server), which MCP OAuth (`oauth` on an HTTP server entry) relies on. Upgrade with `npm install @modelcontextprotocol/sdk@^1.31.0`.
-- `fromUIMessages()` (and so `POST /ui`) keeps only `user` and `assistant` messages from the client: a client-sent `system` message (or any other role, or a malformed entry) is dropped instead of reaching the model as instructions. See [AI SDK UI](docs/ai-sdk-ui.md#inbound-conversion). (Eve TOOLS-F9)
-- **Behavior change:** MCP tools now ask for approval by default (`approval: 'always'`). A server's own `readOnlyHint` / `destructiveHint` no longer switch approval off or let a tool run in plan mode unless its entry opts in with `approval: 'annotations'` (loaded descriptors then carry `metadata.mcp.annotationsTrusted: false`). An MCP tool whose `__` name equals a local tool's is refused with `LOUSHO_CONFIG_INVALID` instead of silently replacing it and its approval gate. Set `approval: 'annotations'` on servers you trust to keep the old behavior. See [MCP](docs/mcp.md#approval-for-mcp-tools). (Eve TOOLS-F11)
-- OAuth token owners are namespaced by the authenticator (`issuer: 'authenticator:'`) when a principal has no issuer, so the same id from Slack and GitHub no longer shares a token. Tokens stored under the old key are not read across authenticators; docs/oauth.md has the one-time move. (Eve TOOLS-F15)
-- `createShellTool` `allow` string patterns no longer let a command read outside the workspace through shell syntax: a path is checked after quotes (and, under sh, backslash escapes) are removed, so `cat "../secret.txt"`, `cat '..'/secret.txt` and `type "c:\windows\win.ini"` are refused, as is a glob that can expand to `..` (`cat .?/x`). A command with `$`, `{` or `}` (`cat $HOME/.gitconfig`, `cat ${IFS}../x`, `cat {..,x}/secret.txt`) is refused too. Use a `CommandRule` or an anchored RegExp to allow such arguments. (Eve TOOLS-F1)
-- `telegramChannel` and `teamsChannel` no longer post an OAuth sign-in link in a group chat or channel, where any member could open it and bind their own account to the user who asked. In a group they post "please sign in from a private chat with me"; the link is sent only in a 1:1 chat (as GitHub already withholds it) (Eve TOOLS-F3). See docs/oauth.md.
-- Tracing: `redactContent` now defaults to `true`, so the deprecated `input`, `prompt`, `args` and `result` span attributes are off by default and `fileTraceExporter()` no longer writes prompts, system instructions and tool output to `.lousho/traces` unless you pass `redactContent: false` (or `captureContent: true` for the `gen_ai.*` content). (Eve MA-F4)
-- OAuth: the `[REDACTED]` safety net for tokens a tool got from `ctx.getToken()` now also covers a thrown error (message, stack, `cause` chain), object keys, `URL` and class instances (the result is checked in its JSON form) and base64/base64url encodings of the token, such as a `Basic` header. Before, only strings, arrays and plain objects in the returned value were scrubbed. (Eve TOOLS-F2)
-- `serveMcp` HTTP transport now validates `Host` and `Origin` against DNS rebinding: a loopback bind answers `403` unless `Host` is a loopback name, and a browser `Origin` that is not same-origin with `Host` gets `403`. New `allowedHosts` / `allowedOrigins` transport options widen this. See [MCP](docs/mcp.md) (Eve TOOLS-F5).
-- Agent Forge (`lousho studio`): the studio API no longer answers any origin. It sent `Access-Control-Allow-Origin: *` with no auth while storing provider keys, starting runs and running hook code. Now it sends no CORS headers, refuses (`403`) a foreign `Origin` or a non-loopback `Host`, and every API and WebSocket call needs a per-launch token that `lousho studio` prints in its URL (`http://127.0.0.1:4750/?token=...`). See [Agent Forge](docs/agent-forge.md#access-token). (Eve DUI-F1)
-- Registry: the approval an install receipt enforces for an `exec: true` or `needsApproval: true` item (and for an unattested one) can no longer be waived from inside the agent directory. Before, an `allow` permission rule, `permissionMode: 'acceptEdits'` or the directory's own `approve` file - all shipped in the same untrusted directory - let its tools run without asking. Now such a call always pauses: rules and modes may still deny it, the directory's `approve` file defers it, and only a person (`agent.approvals.resolve()`) or an `approve` callback passed in code (`loadAgentDir(dir, { approve })`) decides it. `lousho add` now also says an `exec` item's tools ask for approval, instead of "its tools run without asking". See [Registry](docs/registry.md).
-- The session routes no longer let a decision reach an approval of another session: `POST /chat/:sessionId/approvals/:id` answers `404` unless the approval belongs to `:sessionId`. By default, only the caller the paused run acts for may decide it, so an authenticated user can no longer decide another user's approval. If another caller (a lead, a supervisor) decides approvals over HTTP, allow them with `authorizeApproval`.
-- The session routes (`createRouteHandler()`, the deployed node server and Worker) no longer send raw error text to clients. An `error` event, a failed request's `500` and `provider.retry` / `provider.fallback` events carry the error's `name` and `code` with a generic message, and the full error is logged with `console.error`. Pass `exposeErrors: true` to keep the old behavior; `lousho dev` does.
-- Flows: a `toolCall` step no longer runs a tool the agent would not. It used to call the tool directly, so a `needsApproval` tool (a payment, say) ran with no approval. The step now passes the same gate as an agent's tool call: `permissions`, `permissionMode` and `onPermissionDecision` (new `FlowExecutionContext` options) apply, and a call that needs approval is decided by the new `approve` option, which has the same shape as `createAgent({ approve })`. Without `approve`, or when it returns `false` or `'defer'` (a flow cannot pause), the step fails with the new `LOUSHO_FLOW_TOOL_DENIED` code and the tool is not run. The step's arguments are also validated against the tool's input schema, as in an agent run: arguments that do not match fail the step with `LOUSHO_TOOL_ARGS_INVALID`, and the tool gets the parsed arguments (defaults applied, unknown keys stripped). A flow's declared `inputs` are now enforced too: a missing required input, or one of the wrong type, fails the flow with `LOUSHO_VALIDATION_FAILED` before any step runs. See [Flows](docs/flows.md#tool-calls).
-- **Behavior change:** the session routes (`createRouteHandler()`, `createDeployedServer()`, the Worker, `lousho dev`) refuse cross-site requests: a POST whose `Origin` is not the server's own (its `Host`, or the host a proxy reports in `X-Forwarded-Host` or `Forwarded: host=`, a default port ignored) and not in the new `allowedOrigins` option (entries compared as origins: case, a trailing slash and a default port do not matter), or that a browser marked `Sec-Fetch-Site: cross-site`, is `403 LOUSHO_REQUEST_FORBIDDEN`, and a POST body must be `Content-Type: application/json` (`415 LOUSHO_UNSUPPORTED_MEDIA_TYPE`). With cookie-based auth another site's page could start turns and decide approvals as the user. Requests without `Origin` (curl, servers) are unaffected. Opt out with `allowedOrigins: ['*']` and `requireJsonBody: false`; a new `allowedHosts` option refuses unlisted `Host` headers. A `Content-Type` sent twice with the JSON type (a client header next to the default one) is accepted, and the React/Vue/Svelte bindings no longer send it twice (they also accept a `Headers` instance in `headers`). The generated node server and Worker read the three options from `LOUSHO_ALLOWED_ORIGINS`, `LOUSHO_ALLOWED_HOSTS` and `LOUSHO_REQUIRE_JSON_BODY=false`. See [Cross-site requests](docs/auth.md#cross-site-requests), which now covers proxy setup.
-- **Behavior change:** with route auth and no `authorizeSession`, a session now belongs to the first principal that sends it a turn (a read claims nothing, so reading a predictable id cannot lock its user out); another principal gets `403 LOUSHO_SESSION_FORBIDDEN` (before, any authenticated caller who knew a session id could read and continue it). The owner is kept in memory per agent, for the 100,000 most recently used sessions. A session that is stored (a transcript or an unfinished turn) but whose owner is not on record, after a restart, on another replica or past that cap, is refused to every principal, its owner included: pass `authorizeSession` for multi-instance deployments and stores that outlive the process, or `authorizeSession: () => true` for the old behavior. A route guarded by one bearer token (`auth: ''`, `LOUSHO_API_TOKEN` alone, the Worker) has one caller and checks no owner. Approval decisions stay with `authorizeApproval`. docs/nextjs.md no longer mentions an `ownsSession` option, which never existed. See [Who may use a session](docs/auth.md#who-may-use-a-session).
-- The default session owner can no longer be taken over by evicting it. Before, a caller could push a session's owner out of the bounded in-memory record (100,000 `POST /chat { sessionId, input: [] }` requests, which ran no model call), and the forgotten session was then readable, and claimable, by any authenticated principal. Now a stored session without an owner on record is refused to everyone (see above), `POST /chat` with an empty `input` (and `POST /ui` without a user message) is a `400` that claims nothing, and a turn that stores nothing (it failed before saving) drops its claim. A cross-site POST to a channel without `verify` is still `403`, and the body now says to add a `verify` to accept such a page. The supervisor example in docs/nextjs.md and docs/auth.md no longer matches session ids by prefix (`alice` could reach `alice-x`'s sessions): the owner is the id up to its last `-`.
-- **Behavior change:** `lousho dev` answers `403` to a request whose `Host` is not a loopback name (or, off loopback, an IP address or the `--host` name), so a web page cannot reach the open dev server through DNS rebinding; `/health` and channels that `verify` their requests are not checked. New `--allowed-hosts a,b` flag, which adds to these defaults. A channel without `verify` (on any host) refuses a browser request from another site (`403 LOUSHO_REQUEST_FORBIDDEN`), and `httpChannel()` without `verify` refuses a body that is not `Content-Type: application/json` (`415`). `lousho dev --host 0.0.0.0` (any non-loopback host) now prints a warning that the server is unauthenticated.
-- The loopback checks of `serveMcp`'s HTTP transport and `WebhookTriggerAdapter` no longer accept any name starting with `127.` (`127.attacker.example` passed the DNS-rebinding `Host` check): loopback is now exactly `localhost`, a `127.x.x.x` address or `::1`, in one helper shared with `lousho dev`.
-- **Behavior change:** `WebhookTriggerAdapter` binds `127.0.0.1` by default (was `0.0.0.0`), throws `LOUSHO_TRIGGER_INVALID` for a non-loopback `host` without `auth` unless `allowUnauthenticated: true`, and without `auth` on loopback refuses (`403`) a foreign `Host` or a request with an `Origin` header. Pass `host: '0.0.0.0'` with `auth` to listen on all interfaces. See [Webhook](docs/triggers.md#webhook-authentication).
-- **Behavior change:** the Cloudflare Worker refuses every `/chat` route (a turn, a transcript read, an approval decision: `403 LOUSHO_REQUEST_FORBIDDEN`) when no `LOUSHO_API_TOKEN` secret is set, instead of letting anyone with the URL chat on the provider key and read or continue any session by its id; set the `LOUSHO_ALLOW_OPEN_SESSIONS` var (or the earlier `LOUSHO_ALLOW_OPEN_TRANSCRIPTS`) to `"true"` to allow it. `/health`, the OAuth callback and channels stay open. `lousho build --target=cloudflare-worker` now warns that the secret must be set and what happens without it. See [Cloudflare Workers](docs/cloudflare-workers.md).
-- The `openrouter` provider created without `apiKey` (a spec file's `provider: { type: openrouter }`) no longer sends `OPENAI_API_KEY` to openrouter.ai: it reads `OPENROUTER_API_KEY`, and without one fails with `LOUSHO_PROVIDER_MISSING_API_KEY` naming it (also on a Worker, instead of an "OpenAI API key is missing" message), a code an agent run now keeps instead of reporting `LOUSHO_PROVIDER_REQUEST_FAILED`.
-- Slack and Discord signature checks accept a timestamp at most a minute ahead of local time (was five minutes either way) and read it in seconds only (a milliseconds value was taken as "now"); the HMAC `timestampHeader` of `WebhookAuth` keeps its seconds-or-milliseconds reading with the same one-minute future limit.
-- `githubChannel({ deliveries })` takes a `DeliveryStore` (`seen(id)`), so `X-GitHub-Delivery` replay protection can be shared across replicas and restarts; the default stays in memory for an hour. See [GitHub](docs/channels.md).
-- The `@modelcontextprotocol/sdk` peer range is now `^1.31.0` (was `^1.30.1`), which has the fix for GHSA-6qxp-vccf-f47h that MCP OAuth relies on.
- Approve-with-edits on a sub-agent's *grouped* approval validates the edited args against the tool's schema like a top-level or single sub-agent decision does: before, `snapshot.subagent` stored `decision.args` raw, so an invalid edit (e.g. a string where `z.number()` wants a number) ran the tool with garbage args; it now throws `LOUSHO_TOOL_ARGS_INVALID` and leaves the approval pending. (Eve2-F4)
-- Aborting a run during a tool call no longer hangs `send()` / `stream()` / `approvals.resolve()` forever on a tool that ignores its `abortSignal`: the tool batch and the approved-tool wait of `resumeAfterApproval()` are raced against the run's signal the way the model call already was (Eve CORE-F15), the run ends `finishReason: 'aborted'` with a 'cancelled' result for calls still running (a result already landing as the signal fires is still kept), and the tool keeps running in the background. (Eve2-F5)
+- Aborting a run during a tool call no longer hangs `send()` / `stream()` / `approvals.resolve()` forever on a tool that ignores its `abortSignal`: the tool batch and the approved-tool wait of `resumeAfterApproval()` are raced against the run's signal the way the model call already was (Eve CORE-F15), the run ends `finishReason: 'aborted'` with a 'cancelled' result for calls still running past a short grace window (a result that lands within it is still kept), and the tool keeps running in the background. (Eve2-F5)
- A run that re-pauses on several approvals after a resume persists every pending approval id in its checkpoint: `markAwaitingApproval()` wrote only `approvalId`, so durable discovery (`pausedApprovalIds`, `agent.approvals.list()`) named 1 of N approvals; it now carries the group's `approvalIds` like `markGroupPending()` does. (Eve2-F6)
-### Changed
-- **Behavior change (CLI):** every `lousho` usage error exits 2 (an unknown command, an unknown flag, a missing or invalid value, an extra argument, a missing `--target`, a bad `lousho init` flag); 1 is left for a command that ran and failed. Before, only `eval` and `traces` exited 2 and the others 1. See [CLI](docs/cli.md).
-- `lousho eval` and `lousho build` load `./.env` like the other agent commands (judge evals and builds need provider keys).
-- `lousho add`'s fallback registry URL points at the release tag of the installed SDK (`raw.githubusercontent.com/LinuxDevil/agent-sdk/v/registry/dist/index.json`) instead of `main`.
-- **Behavior change:** a rate-limited model call (`compacted.category: 'rate-limit'`, e.g. a 429) now has the code `LOUSHO_PROVIDER_RATE_LIMITED`, as docs/errors.md described, instead of `LOUSHO_PROVIDER_REQUEST_FAILED`. It is still a `CompactedLLMProviderError` / `LLMProviderError`.
-- **Behavior change:** `createAgent({ provider })` with a built-in provider instance or a `fromAiSdk()` provider and no `retry` wraps it in `withRetry()` with the provider's `maxRetries` (default 2), like a model string: retries now emit `provider.retry` events, and `fromAiSdk()` retries (it made none).
-- **Behavior change:** `OpenAIProvider` sends `store: false` on the Responses API (ai 6/7) unless `store: true` is set; the SDK resends the transcript on every call and never refers to a stored response.
-- **Behavior change (types):** `LLMProviderConfig` no longer has a `[key: string]: unknown` index signature, so a misspelled option (`baseUrl`, `timeoutMs`) is a type error instead of silently ignored. A custom provider's config extends the interface with its own fields.
-- **Evals:** `t.maxCostUsd(n)` now fails when a run reports no cost (the model has no known price) instead of silently passing as skipped, so a budget gate is never a no-op. Pass `t.maxCostUsd(n, { unknownCost: 'skip' })` for the old behaviour, which now also warns. See [Evals](docs/evals.md).
-- Tool results are capped: `createAgent({ maxToolResultChars })` (also on `ExecuteOptions`, default `50_000`, `Infinity` for no cap) cuts a longer result to its head and tail with a truncation marker before it reaches the model and the transcript, for every tool (defineTool, MCP, resumed approvals). MCP image/audio `data` and resource `blob` reach the model as a short placeholder (the result parts keep the data), and MCP tool descriptions are cut at 2,000 characters. See [Tools](docs/tools.md#what-happens-when-the-model-calls-a-tool). (Eve TOOLS-F8)
-- Agent Forge: a run or chat on a real provider with no API key now fails with `LOUSHO_PROVIDER_MISSING_API_KEY` (HTTP 422) and a message naming the fix (add the key in Settings, set the env var, or switch the provider to `mock`). It used to swap in the mock provider silently and answer "This is a mock response.". The top bar's provider pill now shows the `provider/model` that actually ran, with a MOCK badge for mock agents. (Eve DUI-F4)
-- A user file part (e.g. a PDF) that the provider cannot send now rejects the call with the new error code `LOUSHO_UNSUPPORTED_CONTENT` instead of being replaced by a `[file ... not sent]` text note, so a model never answers (or, under fallback, invents an invoice) without the file it was given. Under `withFallback()` / `fallbackModels` the next provider gets the call. To keep the old note, set `unsupportedFiles: 'text-note'` in the built-in providers' config or in `fromAiSdk(model, { unsupportedFiles })`. The `pi` provider is unchanged. See [Multimodal input](docs/providers.md#multimodal-input).
-- Pruned tool results now leave a stub that names the call and its arguments: `[pruned: search({"query":"deploy"}) result, 18234 chars; already read]` (arguments past 120 characters are cut), instead of `[pruned: search result, 18234 chars]`. Small models stop re-issuing the same calls to see results again. Markers in the old form are still recognized.
-- `judge.model` (on `defineEval({ judge })`) and `llmJudge()` / `llmCritique()`'s `model` are optional: without one the judge runs on its provider's own model.
+## [1.0.0-alpha.19] - 2026-10-08
-### Fixed
-- `withFallback()` (and `fallbackModels`) kept one shared "active provider" for all calls, so concurrent calls reported wrong `onFallback` / `provider.fallback` transitions, could send the fallback's model id to the primary, and booked usage and cost of calls the fallback served under the primary's model (a 16x cost under-report in the audit repro). Fallback state is now per call; the wrapper's `name` and `defaultModel` are always the first provider's, and each result (and stream) carries the new `servedBy: { provider, model }`, under which the run books that call's `usage.byModel` and `costUsd`.
-- `createAgent({ output })` with an optional field (`.optional()`, `.default()`) no longer fails with HTTP 400 on OpenAI-strict structured-output endpoints (OpenAI, OpenRouter): the JSON Schema sent to the model now lists every property in `required` and makes each optional one nullable, at every level (nested objects, array items, union branches), for zod 3 and zod 4. A `null` the model returns for such a field is read back as an absent key before validation, so your `.optional()` schema still validates and `result.object` has no key for it; fields that accept `null` themselves (`.nullable()`, `.nullish()`) keep it. See [Structured output](docs/structured-output.md#optional-fields).
-- MCP tool names are now valid for every provider. A `__` name with characters outside `[a-zA-Z0-9_-]` (dots, spaces, slashes) has them replaced by `_`, a name over 64 characters is shortened with a hash, and collisions get a numeric suffix. Calls still reach the server under the tool's own name, kept in `metadata.mcp.tool`. Names that were already valid are unchanged.
-- Registry `coding-kit` / `coding-pi`: the agent can no longer rewrite its own configuration. Their workspace is the agent directory, so before this fix `edit_file approve.ts` could replace the kit's approver. The file tools now refuse writes to `agent.*`, `approve.*`, `hooks.*`, `instructions*`, `lousho-registry.json`, `.lousho/`, `tools/`, `skills/`, `subagents/`, `.git/` and `node_modules/`. The shell allowlist drops `git diff` (`--output` could write outside the project) and accepts `node --test` only with test file paths, not flags. The loop guard now counts repeats in the current turn of the conversation instead of per process (so separate runs and agents never share counts, and a pause for approval does not reset them), never counts `node --test`, and skips the post-approval re-fire, so the kit's own "run the tests before and after every change" no longer gets blocked on the third run. Reinstall the kit (`lousho add coding-kit --overwrite`) to pick this up.
-- A stdio MCP server that fails to start now says why. A missing command, an npm 404 or an exit code 3 all used to surface as `MCP error -32000: Connection closed`. They now throw `McpStartError` (`LOUSHO_MCP_START_FAILED`) with `exitCode`/`signal` and the last 20 stderr lines in `stderr` and in the message, and a server that never answers fails after `connectTimeoutMs` instead of hanging. A failed or timed-out start no longer leaves the process running. Behaviour change: `connectMcp()` rethrows this coded error instead of wrapping it in `connectMcp: MCP server '' failed to connect: ...`. See [LOUSHO_MCP_START_FAILED](docs/errors.md#lousho_mcp_start_failed).
-- `agent.ready()` after `agent.close()` reconnects the agent's MCP servers; it used to return the closed connections.
-- `lousho dev|chat|acp|mcp|doctor ` no longer rejects every `tools/*.ts` file of an installed project ("none of these is a defineTool() tool"). The `lousho` bin now loads the ESM build, the one a tool file's `import '@lousho/build-ai-agent'` resolves to, and the `defineTool()`, `defineSchedule()` and `remoteAgent()` brands are shared by every loaded copy of the SDK, so a tool made by the CJS build passes the ESM build's check and the other way round (a `.cjs` tool file, or a process that mixes `require` and `import`).
-- TypeScript ESM consumers get ESM declarations: each `exports` entry now has per-condition types (`import` -> `.d.mts`, `require` -> `.d.ts`), so `moduleResolution: node16`/`nodenext` with `skipLibCheck: false` no longer fails with TS1479 inside the SDK's declarations, and a `typesVersions` map lets `moduleResolution: node10` resolve the subpaths (`@lousho/build-ai-agent/testing`, ...).
-- The agent commands (`dev`, `chat`, `acp`, `mcp`) and `doctor` load `./.env` when it exists (variables already set win), so the `yaml` template's README instructions work. Scaffolded projects load `.env` in `src/index.ts` instead of `tsx --env-file-if-exists`, which printed ".env not found" twice.
-- `lousho init` templates: the `yaml` template's test no longer contains an SDK-internal import (it failed `npm run typecheck`), a project name like `123`, `true` or `null` is quoted in `agent.yaml`, the terminal chat (`src/index.ts`) reports a provider error and keeps going instead of exiting with a stack trace, and `vitest` is `^5.0.3` (the `^3.2.4` pin reported 2 critical advisories on every fresh project). Without the optional `prompts` package (e.g. `npx @lousho/build-ai-agent init`), `init` on a terminal uses your flags and the defaults instead of failing.
-- `lousho doctor ` loads the directory and checks its model's provider instead of failing with `EISDIR`; a missing spec's fix line says to check the path, and `doctor /agent.json` points at `doctor `.
-- `lousho build --target=cloudflare-worker` no longer needs `@ai-sdk/openai`, `@ai-sdk/anthropic` and `@modelcontextprotocol/sdk` installed for an agent that does not use them: a missing one becomes the coded missing-peer error at run time if the Worker ever reaches it.
-- `lousho build` prints where it wrote the build, warns when `--out` is not empty, and `--help` lists the targets; the test-only `stub` target is no longer registered. The docker target's image runs as the unprivileged `node` user and installs with `npm ci` when you keep a `package-lock.json` next to `package.json`.
-- CLI messages: `lousho --help` gives each command a one-line description, and `dev`, `chat`, `acp`, `mcp` and `doctor --help` describe their flags; a YAML or JSON syntax error in a spec names the file (`LOUSHO_SPEC_INVALID`); validation messages no longer repeat "AgentSpec validation failed" per field; a top-level `model:` in a spec points at `provider.model`; `lousho traces` prints the usage once for a bad flag and its columns read `MODEL CALLS` / `TOOL CALLS`; `--limit -1` style values say to use the `=` form; `lousho eval` with no eval files says so; `lousho dev --host ` warns that the dev server has no authentication; `lousho studio` from a tarball packed without Agent Forge says so instead of pointing at a monorepo script.
-- Packaging: `sideEffects` now names the `dist/index` files that register the built-in providers; the pre-bundled `@lousho/build-ai-agent/worker` entry ships without source maps (~1.6 MB); `npm pack` warns when Agent Forge is not built.
-
-## [1.0.0-rc.0] - 2026-10-04
-
-The first 1.0 release candidate. This version number was never published to
-npm: these changes reached npm in the `1.0.0-alpha.*` releases published after
-it (1.0.0-alpha.15 and later). Upgrading from a `1.0.0-alpha.*` release?
-Read the [upgrade guide](docs/upgrading.md): imports moved to subpaths, dead
-and superseded APIs were removed, and the patch guardrails are now patch
-checks. What 1.0 considers public is defined there (every `exports` entry and
-the checked-in `api/` reports).
+Everything published from 1.0.0-alpha.15 (2026-10-06) through 1.0.0-alpha.19
+(alpha.17 on 2026-10-07, alpha.18 and alpha.19 on 2026-10-08). Most of it was
+prepared on `main` under the version number 1.0.0-rc.0, which was never
+published. Upgrading from an earlier `1.0.0-alpha.*` release? Read the
+[upgrade guide](docs/upgrading.md): imports moved to subpaths, dead and
+superseded APIs were removed, and the patch guardrails are now patch checks.
+What 1.0 considers public is defined there (every `exports` entry and the
+checked-in `api/` reports).
### Breaking
-- Flows, the Jira / GitHub / Slack / email tools, encryption, `StorageService` and the validators moved out of the package root (A1). Import them from their subpath: `FlowBuilder`, `FlowExecutor`, `validateFlow` and every flow type (`AgentFlow`, `EditorStep`, `FlowExecutionEvent`, ...) from `@lousho/build-ai-agent/flows`; `createJiraTools`, `createGitHubTools`, `createSlackTool`, `slackTool`, `postSlackAlert`, `createEmailTool` and their types from `@lousho/build-ai-agent/integrations`; `EncryptionUtils`, `DTOEncryptionFilter`, `DecryptionError`, `sha256`, `generatePassword`, `StorageService`, `StorageServiceApprovalStore`, `LocalStorageCheckpointStore` and the validators (`validateWithSchema`, `safeValidate`, `isValidEmail`, ...) from `@lousho/build-ai-agent/utils`. The integrations are no longer in `@lousho/build-ai-agent/tools` and the flow types are no longer in `@lousho/build-ai-agent/types`. Nothing else changed: the classes and functions are the same. For file-backed stores with `createAgent()`, prefer `fileStore(dir)`.
+- Flows, the Jira / GitHub / Slack / email tools, encryption, `StorageService` and the validators moved out of the package root. Import them from their subpath: `FlowBuilder`, `FlowExecutor`, `validateFlow` and every flow type (`AgentFlow`, `EditorStep`, `FlowExecutionEvent`,...) from `@lousho/build-ai-agent/flows`; `createJiraTools`, `createGitHubTools`, `createSlackTool`, `slackTool`, `postSlackAlert`, `createEmailTool` and their types from `@lousho/build-ai-agent/integrations`; `EncryptionUtils`, `DTOEncryptionFilter`, `DecryptionError`, `sha256`, `generatePassword`, `StorageService`, `StorageServiceApprovalStore`, `LocalStorageCheckpointStore` and the validators (`validateWithSchema`, `safeValidate`, `isValidEmail`,...) from `@lousho/build-ai-agent/utils`. The integrations are no longer in `@lousho/build-ai-agent/tools` and the flow types are no longer in `@lousho/build-ai-agent/types`. Nothing else changed: the classes and functions are the same. For file-backed stores with `createAgent()`, prefer `fileStore(dir)`.
- Removed `AgentType`, `AgentTypeDescriptor`, `AgentConfig.agentType`, `AgentBuilder.setType()` and the agent-type registry (`agentTypesRegistry`, `getAgentTypeDescriptor`, `getAllAgentTypeDescriptors`, `isValidAgentType`, `validateAgentConfig`, `validateAgentTools`). They had no runtime effect since 1.0.0-alpha: delete the `setType(...)` call and the `agentType` field. Also removed, because nothing implemented or used them: the repository interfaces (`IRepository`, `IAgentRepository`, `ISessionRepository`, `IResultRepository`, `SessionData`, `ResultData`, `SDKRepositories`), `DataLoadingStatus`, `PaginationParams`, `PaginatedResponse`, `DeepPartial`, `Timestamped`, `IdEntity`, `AgentExecutionOptions`, `AgentExecutionResult`, `AgentDefinition` and `ToolSetting`. If your code used one of these types, copy its declaration into your project. A checkpoint saved with `agentType` still loads; the field is ignored.
-- Removed `ExecuteOptions.onEvent` and the `ExecutionEvent` / `ExecutionEventType` types, deprecated since LOU-D41. Pass `onAgentEvent` to `AgentExecutor.execute()` / `stream()` / `resumeAfterApproval()` instead (with `createAgent()`, `onEvent` already receives `AgentEvent`s and is unchanged). The mapping from the old event names to `AgentEvent` types is in docs/streaming.md#listening-without-iterating ("Migrating from `onEvent` / `ExecutionEvent`"). The one-time "onEvent is deprecated" warning is gone with it.
+- Removed `ExecuteOptions.onEvent` and the `ExecutionEvent` / `ExecutionEventType` types, deprecated in an earlier alpha. Pass `onAgentEvent` to `AgentExecutor.execute()` / `stream()` / `resumeAfterApproval()` instead (with `createAgent()`, `onEvent` already receives `AgentEvent`s and is unchanged). The mapping from the old event names to `AgentEvent` types is in docs/streaming.md#listening-without-iterating ("Migrating from `onEvent` / `ExecutionEvent`"). The one-time "onEvent is deprecated" warning is gone with it.
- Removed `createDelegateTool()`, `DelegateAgentOptions`, `DelegateAgentResult` and `DelegationDepthExceededError`. Use the `subagents` option instead, on `createAgent()` or `AgentExecutor.execute()`: create the child with `createAgent({ provider, instructions, description })` and pass `subagents: { billing }`. The model gets one `task` tool for every sub-agent, with the same inheritance (hooks, permissions, abort, usage roll-up) and a depth limit (`maxSubagentDepth`, default `1`). A run at the depth limit is not offered the `task` tool, so a delegation cycle can no longer start; `DelegationDepthExceededError` is gone. See docs/sub-agents.md.
- The executor API moved out of the package root to `@lousho/build-ai-agent/executor`: `AgentExecutor`, `AgentBuilder`, `ExecuteOptions`, `resumeAfterApproval`, `streamResumeAfterApproval`, `resumeRequest`, `ResumeExecuteOptions`, `ResumeRequest`, `ToolRegistry` and `globalToolRegistry`. Change `import { AgentExecutor, AgentBuilder } from '@lousho/build-ai-agent'` to `import { AgentExecutor, AgentBuilder } from '@lousho/build-ai-agent/executor'`. `ToolRegistry` is also still in `@lousho/build-ai-agent/tools`. The `@lousho/build-ai-agent/core` subpath is removed: import `AgentBuilder` from `@lousho/build-ai-agent/executor`. `createAgent()`, `ExecutionResult` and every type `createAgent()` uses stay in the root. To move off the executor API entirely, see docs/migrating-to-create-agent.md.
- The patch guardrails are now called patch checks, so "guardrail" only means the input/output/tool guardrails of `createAgent({ guardrails })`. Rename: `runGuardrails` to `runPatchChecks`, `Guardrail` to `PatchCheck`, `GuardrailResult` to `PatchCheckResult`, `ProposedAction` to `ProposedPatch`, `RunGuardrailsResult` to `RunPatchChecksResult`, `runGuardrailSafely` to `runPatchCheckSafely`, `secretScanGuardrail` to `secretScanCheck`, `createDiffSizeGuardrail` to `createDiffSizeCheck`, `createCommandGuardrail` to `createCommandCheck` (options type `CommandCheckOptions`), `createTestRunGuardrail` to `createTestRunCheck`, `createLintGuardrail` to `createLintCheck`. Arguments, results and behavior are unchanged. `SECRET_PATTERNS` keeps its name.
### Security
-- **Behavior change:** MCP tools now ask for approval by default (`approval: 'always'`). A server's own `readOnlyHint` / `destructiveHint` no longer switch approval off or let a tool run in plan mode unless its entry opts in with `approval: 'annotations'` (loaded descriptors then carry `metadata.mcp.annotationsTrusted: false`). An MCP tool whose `__` name equals a local tool's is refused with `LOUSHO_CONFIG_INVALID` instead of silently replacing it and its approval gate. Set `approval: 'annotations'` on servers you trust to keep the old behavior. See [MCP](docs/mcp.md#approval-for-mcp-tools). (Eve TOOLS-F11)
-- `openApiTools()` can refuse private destinations (#291): with the new option `privateAddresses: 'refuse'` (default `'allow'`, unchanged), every operation request and the document fetch, each redirect hop included, fails when its host is or resolves to a loopback, link-local, private or reserved address. Host names go through the pinned DNS lookup `http_request` and `web_fetch` use (one resolution per connection, every address checked, the socket connects to the checked address), so DNS rebinding cannot reach a private address; IP literals in every form `URL` normalizes (decimal, octal, hex, IPv4-mapped IPv6) and `localhost` / `*.localhost` are refused when the tools are made. `allowPrivate` lists hosts that may be private. `'refuse'` cannot be combined with a custom `fetch` (it throws a `ConfigurationError`: a custom fetch's connections cannot be pinned) and needs Node.js (`undici`, `node:dns`); where undici cannot load, making the tools throws a `ConfigurationError`. See docs/openapi-tools.md#security-rules.
-- The registry permission manifest is now enforced when an agent directory loads and runs (#272, follow-up to M7a). `loadAgentDir` re-hashes every file the `lousho-registry.json` receipt lists; an item with edited or missing files is reported `unattested` (a warning naming the item and files, and `manifest.registry` on `resolveAgentDir()`, which also gains the new `Attestation`, `RegistryItemStatus` and `RegistryStatus` types) without refusing the load. Every tool a receipt item owns then runs inside the manifest accepted at install: an `exec: true` or `needsApproval: true` item's tools - and any unattested item's tools - always wait for approval (a tool's own `deny` still denies); `fetch` while the tool runs is limited to the declared `network` hosts; `process.env` shows only the declared `env` names; and a `requiresSandbox` tool gets a narrowed adapter (`run()` refused without `exec`, `writeFile()` without `filesystem: "write"`, a command env of the non-secret base plus the declared variables only). The envelope stays as narrow as the last accepted manifest until `lousho add --overwrite` attests the current files. Limits, unchanged: the guards intercept `fetch` and `process.env`, not `http`/`net`/`axios`, the filesystem, module-scope captures or a container's own network policy; the manifest is still not a sandbox. See docs/registry.md#permission-manifest.
-- `http_request` is no longer open to DNS rebinding (N13a). It resolved a host name once to check it against the private-address list and then let `fetch` resolve it again to connect, so a name that answered a public address to the check and `127.0.0.1` (or a cloud metadata or intranet address) to the connection reached the private address; the docs and a comment in the Worker runtime described the check as rebinding-safe. Now the one resolution is the check: the in-process path connects through an undici `Agent` whose `connect.lookup` checks every address and hands the socket the address it checked (each redirect hop included), and the sandboxed path (`sandboxExecute`, which agents use) resolves and checks the host in the agent's process and has the sandboxed process connect to that address (node:http/https with a fixed lookup; the host name still goes in `Host` and TLS SNI). The private-address list is now the shared one (below), which adds `0.0.0.0/8`, `100.64.0.0/10`, `192.0.0.0/24`, `198.18.0.0/15`, multicast and reserved `224.0.0.0/3`, `::`, `ff00::/8`, NAT64 `64:ff9b::/96` and 6to4 `2002::/16` to what `http_request` refused. Behavior changes: a host name that cannot be resolved now fails with the resolver's error instead of being passed to `fetch`; `undici` is loaded on the first `http_request` request (not only with `validateSSL: false`). New option `createHttpTool({ allowPrivate })` for hosts that may resolve to private addresses. Cloudflare Workers have no DNS hook, so neither tool exists in the Worker build; docs/deployment.md now says what a Worker can and cannot check.
-- A run's principal is fixed for the whole run (N10b, #250): an approval resolved by another caller, a restart, `agent.resume()` or a session's pending turn continue as the caller that started the run, and the decider is recorded as the approver (`ctx.approval.by`), never swapped in as the run's principal, so a later caller cannot use an earlier caller's grant by approving their pending call. The principal comes only from route auth or a channel's verified sender; the approvals route and channels never read it from a body field. It is frozen before a tool, a policy or a hook sees it. Remote sub-agents are not told who the caller is.
+- **Behavior change:** MCP tools now ask for approval by default (`approval: 'always'`). A server's own `readOnlyHint` / `destructiveHint` no longer switch approval off or let a tool run in plan mode unless its entry opts in with `approval: 'annotations'` (loaded descriptors then carry `metadata.mcp.annotationsTrusted: false`). An MCP tool whose `__` name equals a local tool's is refused with `LOUSHO_CONFIG_INVALID` instead of silently replacing it and its approval gate. Set `approval: 'annotations'` on servers you trust to keep the old behavior. See [MCP](docs/mcp.md#approval-for-mcp-tools).
+- `openApiTools()` can refuse private destinations: with the new option `privateAddresses: 'refuse'` (default `'allow'`, unchanged), every operation request and the document fetch, each redirect hop included, fails when its host is or resolves to a loopback, link-local, private or reserved address. Host names go through the pinned DNS lookup `http_request` and `web_fetch` use (one resolution per connection, every address checked, the socket connects to the checked address), so DNS rebinding cannot reach a private address; IP literals in every form `URL` normalizes (decimal, octal, hex, IPv4-mapped IPv6) and `localhost` / `*.localhost` are refused when the tools are made. `allowPrivate` lists hosts that may be private. `'refuse'` cannot be combined with a custom `fetch` (it throws a `ConfigurationError`: a custom fetch's connections cannot be pinned) and needs Node.js (`undici`, `node:dns`); where undici cannot load, making the tools throws a `ConfigurationError`. See docs/openapi-tools.md#security-rules.
+- The registry permission manifest is now enforced when an agent directory loads and runs. `loadAgentDir` re-hashes every file the `lousho-registry.json` receipt lists; an item with edited or missing files is reported `unattested` (a warning naming the item and files, and `manifest.registry` on `resolveAgentDir()`, which also gains the new `Attestation`, `RegistryItemStatus` and `RegistryStatus` types) without refusing the load. Every tool a receipt item owns then runs inside the manifest accepted at install: an `exec: true` or `needsApproval: true` item's tools - and any unattested item's tools - always wait for approval (a tool's own `deny` still denies); `fetch` while the tool runs is limited to the declared `network` hosts; `process.env` shows only the declared `env` names; and a `requiresSandbox` tool gets a narrowed adapter (`run()` refused without `exec`, `writeFile()` without `filesystem: "write"`, a command env of the non-secret base plus the declared variables only). The envelope stays as narrow as the last accepted manifest until `lousho add --overwrite` attests the current files. Limits, unchanged: the guards intercept `fetch` and `process.env`, not `http`/`net`/`axios`, the filesystem, module-scope captures or a container's own network policy; the manifest is still not a sandbox. See docs/registry.md#permission-manifest.
### Changed
-- The published package is smaller (R4, #191, #316): ~873 files / ~13.5 MiB unpacked / ~3.65 MiB packed, down from ~902 / ~20.8 MiB / ~5.6 MiB. Test-only files that still shipped (`*.test-d.ts` type tests, `*.testkit.ts` helpers, `__snapshots__/` and the test-replay `__cassettes__/` directories) and the Agent Forge server source map no longer ship, and the SDK's own source maps no longer embed `sourcesContent` — their `sources` still resolve to the shipped `src/`, so `--enable-source-maps` stack traces keep working. `pack-smoke` now also runs `build:studio`, so it measures the tarball that is actually published, and it fails when a file `lousho build` or `lousho studio` needs is missing.
-- Behaviour and types (N13b, #256): a tool whose `execute` returns an async iterator that is also async iterable (what an `async function*` returns) is now iterated: its last yielded value is the result, instead of the iterator object itself (which used to reach the model as `{}`). `AgentEvent` gains `tool.partial` (`ToolPartialEvent`), `isToolEvent()` narrows to it too, `UIToolCall` gains `partial?`, the `tool-output-available` UI chunk type gains `preliminary?: true`, and `DefinedTool` gains a third, defaulted type parameter (what `execute` resolves to). Migration: a `switch` over `event.type` meant to be exhaustive needs a `'tool.partial'` case; code using the result of `isToolEvent()` as start/done/error only should check `event.type`; a tool that means to return an async generator as a value should wrap it (`{ items: generator }`) - a value that is only async iterable, such as a `ReadableStream`, is still returned as it is.
-- Types only (N6): `AgentEvent` gains the `handoff` event (`HandoffEvent`) and `ExecutionResult` the optional `agentName` (set on every result the executor returns: the agent of the final reply). Migration: a `switch` over `event.type` meant to be exhaustive needs a `handoff` case; a test comparing a whole `ExecutionResult` with `toEqual()` now sees `agentName`. Nothing changes at runtime for an agent without `handoffs`.
-- BREAKING (types only, N9c): `McpServerStatus` gains `'needs-auth'`, and `AgentOAuth` (the type of `agent.oauth`) gains `mcpSignInUrl(server)`. Migration: an exhaustive `switch` over `connectMcp(...).status()` values needs a `'needs-auth'` case (only servers with `oauth` ever report it); an object written by hand to satisfy `AgentOAuth` (a test double, say) needs an `mcpSignInUrl` method. Runtime behaviour without `oauth` is unchanged.
-- BREAKING (types only, N9b): `ToolExecutionContext` gains the methods `getToken(provider)` and `requireAuth(provider)`, and `ApprovalKind` gains `'sign-in'`. Migration: code that builds a tool's execute context by hand (for example to call `tool.execute(args, { toolCallId, messages })` in a unit test) must add the two methods or cast (`as ToolExecutionContext`); a `switch` over `PendingApproval.kind` / `approval.requested`'s `kind` that is meant to be exhaustive needs a `'sign-in'` case. Contexts the SDK builds have them already; nothing changes at runtime.
-- BREAKING: `send()` and `AgentExecutor.execute()` with a listener (`createAgent({ onEvent })`, `onAgentEvent`, or the deprecated `onEvent` option) now stream model calls (M9, #232): a step's text arrives as several `text.delta` events, as in `stream()`, instead of one `text.delta` per step after the step finished. What a listener receives otherwise does not change: the same event types in the same order, exactly one `text.done` per step with the step's whole text, the same `step.done` usage and `run.done`, the same `ExecutionResult` (text, tool calls, usage, steps). A provider without `stream()`, or whose `supportsStreaming(model)` is `false`, still gives one `text.delta` per step. Output guardrails on `send()` still check only the final reply (a `stream()` you iterate checks every step's text, as before). `send()` without a listener still calls `provider.generate()`. Hooks' `ctx.emit` is set whenever the run has listeners (it was already; the docs said "only when streamed"). Migration: concatenate `text.delta`, or listen to `text.done` for whole steps; `execute({ streamModelCalls: false })` (new option, default `true`) restores whole steps.
-- `MockLLMProvider` / `createMockProvider()`: `stream()` now streams the same step `generate()` returns (M9): the same tool calls (it streamed none, so a streamed mock run never called a tool), finish reason and usage, and text chunks that add up to exactly the generated text (the old chunks ended with an extra space: `'This is a mock response. '`). Runs on the mock provider with a listener (Agent Forge's, for one) therefore give the same results as before, and `stream()` runs on it now call tools as `send()` does.
-- Agent Forge (M9): runs now stream their model calls, so the run WebSocket (`WS /agents/:id/stream`) carries several `text.delta` events per step; the chat, logs and trace views are unchanged (they use `text.done` and the reconciled messages). Stop is checked between streamed chunks and after the last one, as it was after each `generate()` call.
-- CI peer matrix (LOU-M8, #231): the `typecheck-ai7` and `typecheck-zod4` jobs are replaced by one `peers` job with five entries, each installed for real on top of the default install and run through `tsc`, `test:types`, both builds and `npx vitest run`: `ai4-zod4`, `ai6-zod3`, `ai6-zod4`, `ai7-zod3`, `ai7-zod4`. The two zod 4 entries on `ai` 6/7 also install `ollama-ai-provider-v2` (3.x / 4.x), and the new `src/providers/ollamaV2.contract.test.ts` runs `OllamaProvider` against the real package and a local fake Ollama server (`generate()`, `stream()`, a tool-call turn through `createAgent().send()` and `.stream()`). The `ai-v6` dev alias replaces the hand-made `ai` 6 stand-in in `aiMajorPeers.test.ts`.
-- `lousho init --provider ollama` now scaffolds `ai@^7.0.0` with `ollama-ai-provider-v2@^4.0.0` and `zod@^4.0.0` (it was `ai@^4.3.19` with `ollama-ai-provider@^1.2.0` and zod 3). Existing projects are not touched; to stay on the old pairing keep `ai@^4.3.19` and `ollama-ai-provider@^1.2.0`.
-- A run paused inside a sub-agent compares the sub-agent with its current definition on resume, under the lead's `onAgentDrift` (M10c). Behaviour change: with `onAgentDrift: 'error'`, `agent.approvals.resolve()` (and `resumeAfterApproval()`) now also rejects with `LOUSHO_AGENT_DRIFT` when a paused sub-agent's instructions, model or tools changed; before, only tool and provider default-model changes of a sub-agent were seen, and always as a warning. This holds at any depth (a sub-agent of a sub-agent uses the lead's mode). With `'warn'` (still the default) the `agent.drift` event carries `subagent`. A rejected resume puts the lead's approval record and its `'awaiting-approval'` checkpoint back, so fixing the sub-agent and resolving again finishes the run; before, a sub-agent's missing-tool error became an error result of the `task` call and the run continued. Migration: none for `'warn'` / `'ignore'`; with `'error'`, resolve approvals paused before a sub-agent changed with the old definition, or use `'warn'`. See docs/durable-execution.md#resuming-with-a-changed-agent.
-- `lousho add` enforces the permission manifest at install (M7a). Behaviour change: an item whose code does not match its manifest is now refused with the new code `LOUSHO_REGISTRY_MANIFEST_MISMATCH`, and nothing is written. Before writing, every JavaScript / TypeScript file of the item is scanned (comments stripped) for `child_process` / `execa` / `shelljs` / `createShellTool` / `SubprocessSandbox` (needs `exec: true`), `fs` imports and `createFsTools` (needs `filesystem: "read"`), file-writing calls (needs `"write"`), `fetch(` and network modules (needs a non-empty `network`), `http(s)://` hosts in strings (each must match a `network` entry) and `process.env.NAME` reads (each must be in `env`); `eval(`, `new Function(`, a computed `process.env[...]` and a non-literal `import()` / `require()` are always refused. Each finding is listed as `:: ()`. `network` entries must now be host patterns and `env` entries variable names (`LOUSHO_REGISTRY_INVALID` otherwise). With `--yes`, an item asking for `exec: true`, `filesystem: "write"`, a `network` or an `env` is refused unless each is named in the new `--allow exec,fs-write,network,env` flag; the interactive prompt lists them, and the printed manifest marks them `[elevated: ...]`. After writing, `lousho add` records the item (type, registry, time, permissions, each file's sha256) in `/lousho-registry.json`. Migration: registry authors declare what their code uses; scripts that run `lousho add --yes` add `--allow` with the permissions they accept. It is a static check, not a sandbox; see docs/registry.md#permission-manifest. The example item in docs/registry.md now uses `input` (not `inputSchema`).
+- The published package is smaller: ~873 files / ~13.5 MiB unpacked / ~3.65 MiB packed, down from ~902 / ~20.8 MiB / ~5.6 MiB. Test-only files that still shipped (`*.test-d.ts` type tests, `*.testkit.ts` helpers, `__snapshots__/` and the test-replay `__cassettes__/` directories) and the Agent Forge server source map no longer ship, and the SDK's own source maps no longer embed `sourcesContent` — their `sources` still resolve to the shipped `src/`, so `--enable-source-maps` stack traces keep working. `pack-smoke` now also runs `build:studio`, so it measures the tarball that is actually published, and it fails when a file `lousho build` or `lousho studio` needs is missing.
- Test files are type-checked (`npm run typecheck:tests`, in CI); `pack-smoke` also checks the published declarations with `skipLibCheck: false`.
-- Behaviour change (N10b): continuing an unfinished checkpointed run (`send(message, { sessionId, principal })`, `AgentExecutor.execute({ sessionId, checkpointStore, principal })`) with a principal other than the one it was saved with now throws `LOUSHO_CONFIG_INVALID` instead of running the new input; without a principal (`agent.resume()`, a session's pending turn) the run continues as the saved caller. Migration: resume such a run without `principal`, or start a new session. `onPermissionDecision` is called with a second argument, `{ principal }` (callbacks that take one argument are unaffected). Checkpoints, approval snapshots and pending approvals now store the run's principal (claims included): treat those stores as personal data. Checkpoints and snapshots written before this release resume with no principal; no migration.
### Added
-- `lousho dev` and `lousho chat` take `--traces` (or `--traces=`) to write local traces with `fileTraceExporter()` (#282): `.lousho/traces` by default, the directory `lousho traces` reads, for spec files, agent directories and modules (`specToAgent()` now also accepts an `exporter` option). It is opt-in because trace files hold prompt and tool content even with `captureContent: false` (only `redactContent: true` removes it); the command prints where it writes. `lousho init` projects already ignore `.lousho/`.
-- Code mode (N14, #257): `createAgent({ codeMode: true | CodeModeOptions })` adds a `run_code` tool. The model writes the body of an async JavaScript function that calls the agent's tools as `await tools.(args)`, loops and combines their results, and returns one JSON value; `run_code` returns `{ result, logs, toolCalls }` and only that enters the transcript. Scripts run in a QuickJS WebAssembly isolate from the new optional peer `quickjs-emscripten` (`^0.32.0`, listed by `lousho doctor`; without it the first run of a `codeMode` agent fails with `MissingPeerDependencyError`): a fresh runtime per script, no `require`, `import`, `process`, `fetch`, timers or host objects, values passed as JSON copies, and the limits `timeoutMs` (30,000, whole script including awaited tool calls), `memoryLimitBytes` (64 MiB), `maxToolCalls` (50) and `maxOutputChars` (20,000, return value plus logs). `tools` picks the callable tools (default: all but `run_code`, `ask_question`, sub-agent tools, `tool_search`, `load_skill` and deferred tools); `exclusive: true` takes them off the model's tool list. Every inner call (id `:`) goes through the same gate as a direct call: validation, hooks, permission rules and modes (plan mode refuses non-read-only inner calls; `run_code` itself is read-only), tool guardrails (a block stops the run), `needsApproval`, sandbox routing and the principal. A call that would pause the run (approval, sign-in, a sub-agent's approval) throws inside the script instead and the run does not pause; `run_code` itself can be paused for approval by a rule and runs after the decision. A failed script's error lists its first five tool calls with their (clipped) results. New export: type `CodeModeOptions`. Types: `parentToolCallId?` on the `tool.start`, `tool.partial`, `tool.done` and `tool.error` events (set on inner calls; their `tool.done` / `tool.error` comes before `run_code`'s); inner calls get `execute_tool` spans under the `run_code` span with the attribute `lousho.tool.parent_call_id`. No migration needed: nothing changes without `codeMode`. See docs/code-mode.md.
-- Streaming partial tool results (N13b, #256): a tool's `execute` may be an `async function*`. Every `yield` is a complete snapshot of its output, streamed as the new `tool.partial` event (`{ toolCallId, toolName, output, index }`, `index` from 0) between the call's `tool.start` and `tool.done`; the last yield is the result the model gets (and `tool.done`'s `result`), a `return` value is ignored and a generator that yields nothing has the result `undefined`. Snapshots are not sent to the model, not added to the transcript, not checkpointed and not recorded in trace spans; hooks see only the result; tokens from `ctx.getToken()` are redacted in snapshots like in the result. The main loop, a streamed resume after an approval (`agent.approvals.streamResolve()`) and a flow's tool-call node iterate the generator; aborting the run stops reading it and calls its `return()`; `toolConcurrency` counts the whole generator run. A call that pauses for a sign-in mid-stream runs again from the start (index from 0); partial output is lost on a crash. `defineTool` types it: `ToolOutput` is the yielded type (new helper type `ToolResultOf`). The UI reducer (`useLoushoAgent()` and the Vue / Svelte bindings) keeps the latest snapshot as `toolCalls[i].partial`, and `toUIMessageStream()` sends snapshots as the AI SDK's preliminary tool output (`tool-output-available` with `preliminary: true`). See docs/tools.md#streaming-partial-results.
-- Tool search (N2, #213): tools marked `deferLoading` (`defineTool({ deferLoading: true })`, or every tool of an MCP server with `mcpServers..deferLoading: true`, also in spec files and agent directories) are withheld from the model, which finds them with a built-in `tool_search` tool (`{ query }` -> `{ loaded: [{ name, description }], more }`); the tools it finds are sent from the next step on, and `tool_search` stops being offered once everything is loaded. New option `toolSearch` on `createAgent()` and `AgentExecutor.execute()`: `{ thresholdPercent? (default 0.1 of the context window; 0 always defers), maxResults? (5), contextWindow?, search? }`, or `false` to send every tool upfront. Below the threshold nothing is deferred and no `tool_search` exists. Which tools are loaded is read from the transcript at each model call, so loading survives crash resume, approvals, forks and session turns; compaction that prunes a `tool_search` result unloads its tools; a handoff target or a sub-agent never inherits the lead's loaded tools. Skills, memory, `task`, `ask_question` and handoff tools are never deferred. New exports: `ToolSearchOptions`, `ToolSearchResult`, `rankToolsByKeywords()`. Types: `ToolDescriptor.deferLoading`, `DefineToolOptions.deferLoading`, `McpStdioServerSpec` / `McpHttpServerSpec.deferLoading`, `LoadMcpToolsOptions.deferLoading`, `SubagentSpec.toolSearch`, `ToolMetadata.mcp.server`. Behaviour change: tools loaded by `loadMcpTools()` / `connectMcp()` now carry their server name in `metadata.mcp.server`; a test comparing an MCP tool's whole `metadata` with `toEqual()` needs that key. A user tool named `tool_search` is refused (`LOUSHO_CONFIG_INVALID`) only when deferral is active. See docs/tool-search.md.
-- Handoffs (N6, #218): `createAgent({ handoffs: [billing, handoff(refunds, options)] })` lets an agent hand the whole conversation to another `createAgent()` agent (with a `name` and a `description`), offered to the model as one `transfer_to_` tool each. The handoff call is not run as a tool: once the other calls of its step are done, the run goes on in the same `send()` / `stream()` as the target, with its instructions (replacing the system prompt), model, tools, skills, sub-agents, reasoning and own handoffs, the lead's guardrails and permission rules before its own, on the transcript the handoff's `inputFilter` returns (`handoffFilters.removeToolCalls`, `handoffFilters.lastUserMessage`, or your own). Hooks, `limits` and spend, `maxSteps` (counted across agents), the signal, the approval, checkpoint and token stores, listeners, `output`, the permission mode and the principal stay the run's. `handoff()` options: `toolName`, `description`, `input` (validated; bad arguments get a tool error and no handoff), `inputFilter`, `onHandoff`, `isEnabled` (a `PerRun`). Only the first handoff call of a turn is honored; `maxHandoffs` (default 5) caps one run. The transcript marks each handoff with `metadata.handoff = { from, to }`; a session's next turn, `agent.resume()`, `session.resume()` and `agent.approvals.resolve()` continue as the agent it last handed off to (`agent.send()` without a session starts at the lead), and the resume drift check compares with that agent. Approvals remembered with `once()` are dropped at a handoff. A `transfer_to_*` call passes the run's gate like a tool call: `permissions` rules apply to it (`deny` gives the model a `kind: 'denied'` tool error and no handoff happens; `ask` pauses the run for approval and the decided call then hands off or reports the rejection), pre/post tool hooks may deny it or rewrite its arguments, and its decision is audited to `onPermissionDecision` / `permission.decision`. Each handoff appends a routing system note (`metadata.handoff`) carrying the validated call arguments to the transcript the target reads; `handoffFilters.removeToolCalls` keeps it and a custom `inputFilter` may drop or replace it. `createAgent()` warns once when a reachable handoff target was created with run-level options (`approve`, `approvalStore`, `permissionMode`, `approvalTtlMs`, `store`) - a run keeps the entry agent's. New `handoff` event (`from`, `to`, `toolCallId`) and `ExecutionResult.agentName`; `ExecuteOptions.handoffs` / `maxHandoffs` for the executor API. Remote agents cannot be handoff targets. See docs/handoffs.md.
-- OAuth for HTTP MCP servers (N9c, #248), per the MCP authorization spec: an `mcpServers` HTTP entry takes `oauth: { redirectUri, clientId?, clientSecret?, scopes?, clientName? }` (new type `McpOAuthOptions`; refused on stdio entries by the spec schema). The MCP SDK's client does discovery (RFC 9728 protected resource metadata, RFC 8414 authorization server metadata), dynamic client registration (RFC 7591) when no `clientId` is given, PKCE and resource indicators (RFC 8707), and refreshes an expired token; tokens are kept in the agent's encrypted token store as provider `mcp-`, owned by the app. An operator signs the agent in once with the new `agent.oauth.mcpSignInUrl(server)`; N9b's `agent.oauth.complete()` and `GET /oauth/callback` finish it. Without a usable token the server's status is the new `'needs-auth'` and connecting fails with the new error code `LOUSHO_MCP_AUTH_REQUIRED` (a tool call refused mid-run is a tool error with that code; nothing pauses). `connectMcp(servers, { tokens })` takes the token store. Security: `oauth` needs an https `url` (http only for localhost, 127.0.0.1 and [::1]); an `Authorization` header together with `oauth` is refused; with `oauth`, `headers` go only to the MCP server's origin, never to the authorization server; a stored grant is only used for the `url` it was obtained for (a changed `url` needs a new sign-in); no token, code, verifier or client secret appears in errors, logs, `status()`, events or results. Out of scope for now: per-user MCP grants, a `lousho mcp login` command, OAuth over stdio. See docs/oauth.md, "MCP servers with OAuth".
-- Parallel input guardrails (N5b, #217): `IoGuardrail.runInParallel` (default `false`) makes an `input` guardrail run while the first model call is already in flight, so a slow, model-backed check no longer delays the answer by its whole latency; `promptInjectionGuardrail({ runInParallel })` and `moderationGuardrail({ runInParallel })` take it as an option. The blocking input guardrails run first as before (rewrites included), then the parallel ones start together on that input. Until all have passed, the call's `text.delta`, `reasoning.*` and provider-run tool events are held in order (on the iterator and `onAgentEvent` alike), its tool calls do not run, and `onLLMResponse`, post-generate hooks, output guardrails, `text.done` and the checkpoint after the response wait. A trip aborts the call (its signal only, not the run's), drops the held events and ends the run with `finishReason: 'guardrail'`, `result.guardrail` and `guardrail.tripped` (or `GuardrailError` under `onTripped: 'throw'`), with nothing of the call in the transcript or a checkpoint; the call's usage counts in `result.usage` only when the provider reported it. A parallel check cannot rewrite (`action: 'rewrite'` blocks, the reason prefixed `runInParallel guardrails cannot rewrite: `), and a check that throws fails the run. Ignored on `output` and `tools` guardrails. No change for guardrails without the flag. See docs/guardrails.md#input-and-output-guardrails.
-- OAuth sign-in for tools (N9b, #247): `defineOAuthProvider({ name, displayName?, credentialOwner?, authorizationUrl, tokenUrl, clientId, clientSecret?, clientAuth?, scopes?, redirectUri, authorizationParams?, fetch? })`, and in a tool `ctx.getToken(provider)` and `ctx.requireAuth(provider)`. Without a usable token the run pauses durably like an approval (`finishReason: 'awaiting-approval'`, `kind: 'sign-in'`, `signIn: { provider, displayName?, url }` on the pending approval and the `approval.requested` event); the authorization URL uses PKCE S256 and a single-use 256-bit `state` that expires after 10 minutes, and the verifier is kept only in the encrypted pending record. The new route `GET /oauth/callback` (`lousho dev`, the node server, the Worker) and `GET /oauth/callback` (`createRouteHandler`) is not behind route auth (a browser redirect carries no API token): it calls the new `agent.oauth.complete({ state, code?, error? })`, which stores the token for the paused run's principal and answers a small HTML page (400 for an unknown, used or expired state). Approving the pause (`agent.approvals.resolve({ id, approved: true })`, or the approvals route) runs the tool call again; before the callback it throws `LOUSHO_SIGNIN_PENDING` (HTTP 409) and the run stays paused. Tokens within 60 s of expiry are refreshed; `requireAuth()` deletes a refused token and pauses. A user credential needs a principal (`LOUSHO_OAUTH_PRINCIPAL_REQUIRED`); an app credential (`credentialOwner: 'app'`) is never asked of a chat user (`LOUSHO_OAUTH_APP_SIGNIN_REQUIRED`) and is connected by the operator with `agent.oauth.signInUrl(provider)`. The `approve` callback is never asked about a sign-in. Slack sends the link with `chat.postEphemeral` and Discord as an ephemeral follow-up, both without buttons; GitHub never posts it in a public thread; behind `createDeployedServer()` and `lousho dev` the callback continues the paused channel turn. The UI state (`pendingApproval.signIn`), the `data-lousho-approval` part, `lousho dev`'s page, `lousho chat` and `lousho acp` show the link. A tool result that contains a token handed out during that call is redacted to `[REDACTED]` with a warning. New error codes `LOUSHO_OAUTH_PRINCIPAL_REQUIRED`, `LOUSHO_OAUTH_APP_SIGNIN_REQUIRED`, `LOUSHO_OAUTH_STORE_MISSING`, `LOUSHO_OAUTH_STATE_INVALID`, `LOUSHO_SIGNIN_PENDING`, `LOUSHO_OAUTH_TOKEN_EXCHANGE_FAILED`. See [OAuth](docs/oauth.md#tools-that-need-sign-in).
-- Hosted tools on Anthropic and OpenRouter (N1b, #212): `webSearch()` and `codeInterpreter()` now also run on the Anthropic provider (`ai` 6 with `@ai-sdk/anthropic` 3, `ai` 7 with `@ai-sdk/anthropic` 4): `webSearch()` is the newest `tools.webSearch_*()` the installed package exports (options `maxUses`, `allowedDomains`, `blockedDomains`, `userLocation`), `codeInterpreter()` the newest `tools.codeExecution_*()`; events and `usage.hostedToolCalls` keep the neutral name `code_interpreter`. `fileSearch()` on Anthropic, and `codeInterpreter()` / `fileSearch()` on OpenRouter, are `LOUSHO_HOSTED_TOOL_UNSUPPORTED`. `webSearch()` works with every `openrouter/...` model on any `ai` major: the provider adds OpenRouter's `openrouter:web_search` server tool to the request body (after the function tools, composing with the `reasoning` field; `maxUses`, `allowedDomains`, `blockedDomains` and `searchContextSize` map to its parameters) and reports one hosted call per step that searched, read from the response: the cited urls from `url_citation` annotations as `sources`, and `result: { requests }` when OpenRouter reports `usage.server_tool_use.web_search_requests`. OpenRouter bills each search on top of tokens (not in `costUsd`). `supportsHostedTool()` on both providers reflects this. The per-provider table in docs/hosted-tools.md is complete, and a replayable cassette from a live OpenRouter search is in the test suite (`npm run test:live` re-records it with `OPENROUTER_API_KEY`).
-- Hosted provider tools (N1a, #211): `webSearch()`, `codeInterpreter()`, `fileSearch()` and `hostedTool(name, aiSdkTool)` go in `createAgent({ tools })` next to local tools (array or record form; in the record form the key must be the tool's name), and the provider runs them inside the model request. OpenAI maps them to `openai.tools.webSearch()` / `.codeInterpreter()` / `.fileSearch()` on `ai` 6 with `@ai-sdk/openai` 3 and `ai` 7 with `@ai-sdk/openai` 4 (options it does not take, such as `maxUses`, are dropped with one warning); `hostedTool()` passes any AI SDK provider tool through on `ai` 6 or 7, including on `fromAiSdk()` models. Provider-executed calls are never handed to the executor as local tool calls (on `ai` 6/7 they used to come back as calls of an unknown tool): they are reported as `tool.start` / `tool.done` / `tool.error` with the new optional field `executedBy: 'provider'` (as they stream in, or after a non-streamed call in call order; results capped at 20,000 characters of JSON), kept on the step's assistant message as `metadata.hostedToolCalls` (with url sources; not sent back to the model), counted in `usage.hostedToolCalls` and `run.done` usage per tool name (fees not in `costUsd`), and named on the `chat` span as `lousho.hosted_tool_calls`. A step whose only calls the provider ran ends the run even when the provider reports `tool-calls`. No permission rule, guardrail, approval or hook runs for them; instead, in permission mode `'plan'` (N4) only the read-only `webSearch()` and `fileSearch()` are sent to the provider (`codeInterpreter()` and `hostedTool()` are left out of the request, read before every model call), and every other mode sends them all. New: `ExecuteOptions.hostedTools`, `GenerateOptions.hostedTools`, `GenerateResult.hostedToolCalls`, `HostedToolCall`, stream chunks `hosted-tool-call` / `hosted-tool-result`, optional `LLMProvider.supportsHostedTool(type)`, `mockModel` turns with `hostedToolCalls`, cassettes that keep hosted calls, error code `LOUSHO_HOSTED_TOOL_UNSUPPORTED` (ai 4, Ollama, a provider without `supportsHostedTool()`, or an `@ai-sdk/openai` without the tool). Sub-agents do not inherit the lead's hosted tools; the agent fingerprint includes them. Anthropic and OpenRouter mappings follow in N1b (below). See docs/hosted-tools.md.
-- `session.history()` and `session.fork({ fromStep, id?, patch? })` on `agent.session()` sessions (N3a, #214). `history()` lists the steps of the committed transcript (one per model response: `step` numbered from 1 across the session, `turn`, `messageIndex`, `text`, and `toolCalls` with `args` and `result`). `fork()` saves the transcript up to and including a step (`0` keeps nothing), optionally with one tool result replaced (`patch.toolResult`), under a new id in the same store (default `-fork-`) and returns a session of the same agent and options (with the permission mode the session has at that moment); the original is not changed. The spend recorded for `limits` up to that point carries over. A turn waiting on an approval or a question, or an interrupted checkpointed turn, stays with the source session, so it is resolved once; workspace files are not rewound. Works on every shipped store (memory, `fileStore()`, `SqliteStore`, `KVStore`), with one contract suite run against each. New types `SessionHistoryStep`, `SessionForkOptions` and `SessionSpawner`; new error codes `LOUSHO_SESSION_STEP_NOT_FOUND`, `LOUSHO_SESSION_EXISTS` and `LOUSHO_SESSION_FORK_UNSUPPORTED`; `AgentSession`'s constructor takes an optional fourth argument, the function that creates its forks. See [Forking a session](docs/sessions.md#forking-a-session).
-- Permission modes (N4, #215): `createAgent({ permissionMode })`, `send()` / `stream()` `{ permissionMode }`, `agent.session({ permissionMode })` and `session.setPermissionMode()` put an agent in `'plan'` (only read-only tools run; everything else is refused with `kind: 'denied'`, also when an `allow` rule matched, and a run that starts in plan mode gets one system-prompt paragraph saying so), `'acceptEdits'` (file edits that would ask run without asking) or `'dontAsk'` (a call that would ask is refused; nothing pauses). The modes are presets applied after hooks, permission rules, tool guardrails and `needsApproval`, so no mode turns a deny into a run. A function mode, and a session's mode, are read at every tool call, so a switch applies from the next call, also mid-turn and in a paused turn continued by `agent.approvals.resolve()`. Sub-agents run under the lead's mode unless it is `'default'`; a remote sub-agent is refused in plan mode. Audit: under a mode other than `'default'` every call is audited, `PermissionDecisionEntry.mode` says when the mode changed the outcome, and the new `onPermissionModeChange` records each switch. New: `PermissionMode` and `PermissionModeChange` types, `defineTool({ editsFiles })` (`metadata.editsFiles`, set on `write_file` and `edit_file`), `readOnlyHint: true` on `load_skill`, `recall_`, `agent_status` and `agent_await`. The mode is not saved in checkpoints. Example: `examples/plan-mode`. See [Permission modes](docs/permission-modes.md).
-- Semantic recall (N15, #258): a memory slot can recall by meaning. New `EmbeddingProvider` (`{ id, embed(texts) }`) and `aiSdkEmbedder(model, { id?, maxBatch? })`, which wraps any AI SDK embedding model through `embedMany` from the installed `ai` (4, 6 or 7; `ai` is loaded on first use, no new dependency), and two providers that rank by cosine similarity: `inMemoryVectorMemory({ embedder, maxItems?, minScore? })` from the root and `sqliteVectorMemory(store, options)` from `@lousho/build-ai-agent/sqlite` (a new `memory_vectors` table, added to an existing database file on open; `memory_items` and `sqliteMemory()` are unchanged). With a query, `list()` returns the scope key's items by score (floor `minScore`, default 0.2; score in `metadata.score`); without one it lists the newest first. Items embedded by another embedder id are skipped when ranking until `provider.reindex(scopeKey?)` re-embeds them. A `MemoryProvider` may set `ranking: 'relevance'`, which makes the `recall_` tool say "by meaning, most relevant first". `hashEmbedder({ dimensions? })` from `@lousho/build-ai-agent/testing` is a deterministic offline embedder for tests. `describeMemoryProviderContract` takes `{ query: 'filter' | 'rank' }`. docs/memory.md has a new `## Semantic recall` section at the end.
-- `teamsChannel({ appId, appPassword, tenantId?, name?, fetch?, approvers?, onError? })` (N11c, #253): an agent behind an Azure Bot in Microsoft Teams, mounted like the other channels (`POST /teams`). The inbound Bot Framework token is verified with the auth module (`oidc()`: RS256, the key set of the fixed Bot Framework OpenID metadata, issuer `https://api.botframework.com`, audience `appId`, 5-minute tolerance) before the body is parsed, and its `serviceurl` claim must equal the activity's `serviceUrl`; replies go only to that service URL (https). Personal chats always reach the agent, group chats and channels only on an `@mention` (stripped from the text); one session per conversation; Markdown replies split at 25,000 characters; tool approvals are Adaptive Cards with Approve and Deny buttons (`approvers` as on the other channels; the card updates to "Approved by ..." and names its conversation, so a copied or replayed reference decides nothing); `ask_question` is answered by the next message, also after a restart with durable stores. No `node:*` import. Telegram and GitHub now set the sender as the run's `principal` too (`authenticator: 'telegram'` with the user id, `'github'` with the login), as Slack and Discord do. See docs/channels.md#microsoft-teams.
-- Cloudflare Worker target builds agent directories (config, instructions, TypeScript tools, skills) (M3b): `npx lousho build ./my-agent --target=cloudflare-worker`. The build reads the directory on Node and generates `agent.module.ts` (static imports of `tools/*` and of an `agent.ts` config; `instructions.md`, a JSON/YAML config and the skills embedded as JSON), and the Worker builds the agent with the rules of `resolveAgentDir()` (the config check and the tool-export rules now live in Node-free modules both use). The config's `model` must name a Worker provider, or `agent.ts` sets a `provider` instance. `subagents/`, `schedules/`, `channels/`, `memory/`, `projectInstructions` and an unsupported provider are rejected with `LOUSHO_DEPLOY_FAILED`, naming what to remove. In a Worker build, an agent file's `@lousho/build-ai-agent` import resolves to a Worker-safe subset (`defineTool`, `defineSkill`, the Worker providers, `fromAiSdk`, errors, ...); another name fails the build with the list. The leak check now names the file that imported a Node builtin (`"node:fs" imported by my-agent/tools/files.ts`) instead of failing with esbuild's "Could not resolve". Tools run in the Worker's isolate with its rights: see docs/cloudflare-workers.md#build-and-deploy.
-- Workspace rewind (N7, #219): `createFsTools(fs, { checkpoints })` with a new `WorkspaceCheckpoints` backs up every file `write_file` and `edit_file` change (content and, where the provider has the new optional `FsProvider.getMode` / `chmod`, permission bits), grouped by session and turn. `checkpoints.list({ sessionId })` shows the changed paths per turn; `checkpoints.rewind(toTurn, { sessionId, dryRun, force })` restores every file changed in that turn or later, deletes files the agent created, and skips files changed since by something else (`'changed-since'`), files over `maxFileBytes` (`'too-large'`) and paths that are no longer files. Every path is checked before anything is written, and restores go through the provider, so `NodeWorkspace` keeps them inside its root. Retention: the newest `maxTurns` turns per session (default 20), the old content only for a turn's first write of a path, rewound turns dropped, `clear({ sessionId })`. Stores: `MemoryWorkspaceCheckpointStore` (default) and `FileWorkspaceCheckpointStore(dir)` (one JSON file per session, atomic writes). `NodeWorkspace` gains `getMode()` and `chmod()`. Without `checkpoints` the tools behave as before. See [Workspace tools](docs/workspace-tools.md#the-tools).
-- Route auth and principals (N10a, #249): a new subpath, `@lousho/build-ai-agent/auth`, with `jwt()`, `oidc()`, `basic()`, `apiToken()`, `anonymous()` and `routeAuth()`. A route takes an ordered list of entries: the first one that returns a `Principal` accepts the request, `null` skips to the next, `AuthError(401 | 403)` stops; when every entry skips the answer is a generic `401` with one `WWW-Authenticate` header per challenge (`Bearer`, `Basic realm="..."`), and the body never says which check failed. JWTs are verified with Web Crypto only (no new dependency, no `node:` import, so the helpers run on Workers and edge routes): `alg: none`, algorithms outside the configured list, a `crit` header and algorithm confusion (an HS token checked against a public key) are refused; `exp` is required, `nbf` / `iat` honored, `iss` / `aud` checked, with a clock tolerance of 60 s (at most 300); JWKS key sets are cached for 10 minutes and refetched at most once per 30 s, and a key-set URL is never taken from a token. `oidc()` uses the configured issuer's discovery document and requires it to name the same issuer. `basic()` and `apiToken()` compare in constant time. `createRouteHandler({ auth })` and `createDeployedServer(agent, { auth })` take the list (a token string and a boolean function work as before); the node server appends `apiToken(LOUSHO_API_TOKEN)` when that variable is set; an agent directory's `auth.ts` is bundled by `lousho build --target=node-server` / `docker` and guards the built server (`resolveAgentDir()` returns it as `auth`, the manifest has `auth: true`). The accepted principal reaches the run: `send()` / `stream()` / session turns take `principal`, and `RunConfigContext` (the `model` / `instructions` / `tools` functions) and `MemoryScopeContext` (memory scopes) carry it; Slack and Discord channels set the sender as the principal. `Principal` is also exported from the root. Bad helper options throw the new error code `LOUSHO_AUTH_CONFIG_INVALID` when the helper is created. `createRouteHandler()` without `auth` logs one warning when `NODE_ENV` is `production`. Route auth does not check session ownership, the principal does not reach tools or approval policies yet (N10b), and the Cloudflare Worker target keeps the `LOUSHO_API_TOKEN` token only. See [Route auth and principals](docs/auth.md).
-- OAuth token storage (N9a, #246): `AgentStore` gains a fourth optional part, `tokens` (`OAuthTokenStore`: `get` / `set` / `delete` / `list` per provider and credential owner, single-use pending sign-ins with `putPending` / `takePending`, and `getClient` / `setClient` for dynamically registered clients). Owners are `{ owner: 'app' }` or `{ owner: 'user', principalId, issuer? }`; `tokenStoreKey(provider, owner)` is the stable, percent-encoded record key (`|app`, `|user||`). `memoryStore()` keeps tokens in process memory; `fileStore(dir)`, `SqliteStore` and `KVStore` encrypt every record with AES-256-GCM (Web Crypto, a fresh 12-byte IV per write, the record key as additional authenticated data) under a 32-byte key the application supplies as `tokenKey` or `LOUSHO_TOKEN_KEY`; there is no default key. Several keys (newest first, or comma-separated in the variable) rotate: writes use the first, reads try each. New error codes `LOUSHO_TOKEN_KEY_MISSING` (first write, or a read of an existing record, without a key) and `LOUSHO_TOKEN_DECRYPT_FAILED` (wrong key or a changed record; names the provider, never the token). `list()` returns metadata only. `generateTokenKey()` makes a key. SQLite: schema migration 4 adds the `oauth_tokens` and `oauth_pending` tables, and `prune()` also deletes expired pending sign-ins (its result gains `oauthPending`). KV: keys under `oauth/`, pending sign-ins written with an `expirationTtl`; `KVBinding` gains an optional `list()` (used only by `tokens.list()`), and `KVListOptions` / `KVListResult` are exported from `/kv`. The generated Cloudflare Worker passes its `LOUSHO_TOKEN_KEY` secret to `KVStore`. Nothing signs in yet; the sign-in flow follows. New page [OAuth](docs/oauth.md); new `## OAuth` section at the end of docs/errors.md.
-- `githubChannel({ webhookSecret, botName, token?, app?, botLogin?, name?, apiUrl?, fetch?, triggers?, approvers?, onError? })` (N11b): an agent that answers GitHub issue, pull-request and review comments. `@` in a comment starts a turn and the reply is a new comment in the same thread (a review thread gets a reply under it; text over 60,000 characters is split); one session per issue or pull request and one per review thread, and later comments in a thread with a session are follow-ups. `X-Hub-Signature-256` is verified over the raw body in constant time before the body is parsed (401 otherwise); the webhook is acknowledged at once. Comments by bots, by the channel's own account and every comment the channel posts (a hidden marker) are ignored, and so are edits, deletions and other events. Replies are posted with `token` (a personal access token, or a function) or as a GitHub App (`app: { appId, privateKey }`: a PKCS#1 or PKCS#8 key signs an RS256 JWT with Web Crypto, exchanged for an installation token that is cached per installation until 5 minutes before it expires; `src/channels/githubAppAuth.ts`, not exported). Approvals are comments: the prompt asks for `/approve ` or `/deny ` (first line only, notes below it), and by default only a commenter whose `author_association` is `OWNER`, `MEMBER` or `COLLABORATOR` may decide, read from the command comment itself; `approvers` (logins, or a function that sees the association in `user.roles`) overrides it, and `triggers` (logins, or a function) restricts who may start a turn or answer a question (default: everyone who can comment, so restrict it on a public repository). A comment is untrusted input to the agent; see the security note in docs/channels.md. An `ask_question` is answered by the next comment in the thread and survives a restart given durable stores. Errors name the call and the HTTP status, never the token. The `LOUSHO_CHANNEL_INVALID` hint, docs/agent-directories.md and docs/errors.md now list `githubChannel()`. New section `## GitHub` at the end of docs/channels.md.
-- Agent Forge keeps the traces of its runs (M5b, #227): every run is written with `fileTraceExporter()` to `.lousho/agents//traces` (the files `lousho traces` reads), and the Trace tab gets a list of the agent's past runs (time, duration, model calls, tokens, cost, status) with a "Live" entry while a run is active; choosing one opens its spans in the waterfall, with span kind and error status. New server routes `GET /agents/:id/traces?limit=N` and `GET /agents/:id/traces/:traceId`; the server reads only inside the agent's trace folder. The live `span` WebSocket messages now carry `kind` and `status` (optional fields of `SpanEvent`). See [Agent Forge](docs/agent-forge.md).
-- A background sub-agent that needs approval pauses the lead when the lead awaits it (`agent_await`), and resumes with `approvals.resolve()` / `resume()` (M4, #225). The lead's result is `awaiting-approval` with the child's call (`subagentPath: ['']`) in `agent.approvals.list()`; the decision runs or rejects the child's call, the child finishes, and the continued lead gets its answer as the `agent_await` result. A child that pauses again, or several awaited tasks that are all paused, pause the lead one approval at a time. The child's usage is added to the lead's, and its conversation is saved under its `taskId`. The paused tasks are kept on the lead's approval record (`SubagentSuspension.background`, plain JSON), never in the tool arguments the model sees. Without `agent_await` nothing changes: the task reports `awaiting-approval` and is not resumable. The `task` and `agent_status` tool descriptions tell the model that a task awaiting approval continues only when it calls `agent_await` on it. Docs: docs/sub-agents.md ("When the lead run ends" and "Continuing a task").
-- Guardrail starter set (N5a): `piiGuardrail()` (email, phone, credit card with a Luhn check, IBAN with its mod-97 check, US SSN, IPv4; blocks, or rewrites each match to `[]`), `secretsGuardrail()` (private keys; Anthropic, OpenRouter, OpenAI, AWS, GitHub, Slack, Google and Stripe live keys; JWTs; `Authorization: Bearer` tokens; `extraPatterns`; rewrites to `[secret]` by default), `promptInjectionGuardrail()` (phrase, role-marker and Unicode-tag heuristics, plus an optional model check that must answer `SAFE` or `INJECTION: `) and `moderationGuardrail({ model })` (one model call that answers `NONE` or the categories that apply: `hate`, `harassment`, `self-harm`, `sexual`, `sexual-minors`, `violence`, `illicit`). The PII, secret and injection checks are heuristic pattern checks: they catch common cases and miss others, and are not a security boundary. Model-backed checks fail closed on a reply they cannot read. A guardrail result can now carry `info?: GuardrailTripInfo` (`pii`, `secret`, `prompt-injection`, `moderation` or `custom`), copied to `result.guardrail`, `guardrail.tripped` and `guardrail.rewrote`; it holds types, labels and offsets, never the matched text. Agent spec files can name them in `policy.guardrails` as `pii`, `secrets`, `prompt-injection` and `moderation`. New exported types `GuardrailTripInfo`, `PiiType`, `ModerationCategory`. Docs: [Guardrails](docs/guardrails.md#input-and-output-guardrails), [Configuration](docs/configuration.md#policy-policy), [Stream events](docs/stream-events.md).
-- `telegramChannel({ botToken, secretToken, botUsername?, name?, fetch?, approvers?, onError? })` (N11a): an agent behind a Telegram bot, mounted like the Slack and Discord channels (`POST &channels&telegram`). The webhook secret token header (`X-Telegram-Bot-Api-Secret-Token`) is compared in constant time (401 otherwise); updates are acknowledged with `200` and the turn runs after. A private chat is one session per chat; in a group only `&ask`, `&ask@`, an `@` mention or a reply to the bot wakes it, and a forum topic is its own session. Replies are plain text, split at 4096 characters. Tool approvals are an inline keyboard (Approve & Deny) that only `approvers` (default: the user who started the turn) can tap; the tap names its chat, so it works after a restart. An `ask_question` is sent with `force_reply`; a pending one survives a restart given durable stores, like Slack and Discord. The bot token is never put in an error message or log line. `src&channels&channelSupport.ts` gains the internal `secretsEqual()` (constant-time compare with Web Crypto). The `LOUSHO_CHANNEL_INVALID` hint, docs&agent-directories.md and docs&errors.md now list `discordChannel()` and `telegramChannel()`. New section `## Telegram` at the end of docs&channels.md (attachments are not read yet).&r
-- `todo.updated` stream event and `useTodos()` (React, Vue) / `loushoTodos()` (Svelte) (N12): every successful `todo_write` emits `todo.updated { todos, counts, toolCallId }` right after its `tool.done` (sub-agents too, with `subagent`). The shared UI reducer keeps the list in a new `AgentUIState.todos` (carried across turns, cleared by `reset()`), `useLoushoAgent()` / `loushoAgent()` expose it, `useTodos(agent)` / `loushoTodos(agent)` add counts, the current item and progress (`todoView()` computes the same view framework-free), and `toUIMessageStream()` sends a `data-lousho-todos` part for the AI SDK UI. See [Todos](docs/react.md#todos).
-- Cloudflare Worker target: the `openrouter` provider and the `http` tool (with a required `LOUSHO_HTTP_ALLOW` host allowlist) (M3a). `openrouter` reads its key from the `OPENROUTER_API_KEY` binding and is `@ai-sdk/openai` pointed at OpenRouter, so its bundle passes the `node:` leak check like `openai`. The Worker's `http` is `http_request` with the Node tool's input, over the platform `fetch()`: since a Worker cannot see or pin where a host name resolves, it reaches only the host names (`api.github.com`) and `*.` wildcards (subdomains only) listed in the comma-separated `LOUSHO_HTTP_ALLOW` binding, refuses IP-address hosts even when listed and any scheme but `http:`/`https:`, checks every redirect hop the same way, and refuses every request when the binding is unset or empty. An invalid entry fails every request with an error naming it. `wrangler.toml` gets a commented `[vars]` `LOUSHO_HTTP_ALLOW` line when the spec lists `http`. `ollama` and `web-fetch` stay unsupported on Workers. Internally, the request, redirect and response logic of `http_request` moved to the Node-free `src/tools/built-in/httpCore.ts`, shared by both tools; the Node tool's behaviour is unchanged. See docs/cloudflare-workers.md#providers-and-tools.
-- `fromAiSdk(model)`: any AI SDK `LanguageModel` (Google, Bedrock, Azure, Mistral, Gateway, ...) as `createAgent({ provider })`. Options `name` (default: the model's `provider` field), `fileMediaTypes`, `replaysReasoning` and `maxRetries` (default 0); exported with `FromAiSdkOptions` from the package root. Throws `LOUSHO_CONFIG_INVALID` for a model id string, for a model built for another `ai` major than the installed one, and for a call with another model id than the wrapped one. See docs/providers.md#any-ai-sdk-model-fromaisdk.
-- Two ready-made durable stores (R2). `@lousho/build-ai-agent/kv` is a new subpath exporting `KVStore`, `KVCheckpointStore`, `CHECKPOINT_KV_BINDING` and the types `KVStoreOptions`, `KVBinding`, `KVPutOptions`, so a hand-written Cloudflare Worker can use `createAgent({ provider, store: new KVStore(env.AGENT_KV) })`; nothing in its import graph touches `node:*`, so it bundles without shims (`assertSessionId` moved to the Node-free `src/session/sessionId.ts` and is still exported where it was). `fileStore(dir, { historyLimit? })` (root export) is an `AgentStore` of plain JSON files: `sessions/.json`, `checkpoints/.json`, `checkpoint-history/.json` and `approvals/.json`, each written to a temp file and renamed into place, with no lock files and no `StorageService`; resolving an approval claims it with an exclusive create, so of two processes resolving one approval only one gets it. It replaces combining `FileSessionStore`, `LocalStorageCheckpointStore` and `StorageServiceApprovalStore` by hand. See docs/sessions.md#choosing-a-store and docs/deployment.md.
-- `web_fetch` built-in tool (N13a): `webFetchTool` / `createWebFetchTool(options)`, and `web-fetch` in spec files. It `GET`s one public web page and returns `{ url, finalUrl, status, contentType, content, truncated }`: HTML converted to text by a small built-in converter (scripts and styles dropped, link URLs kept, entities decoded), JSON and `text/*` as sent, a note for other types; a 4xx/5xx is returned with its body. Loopback, private and link-local destinations are refused on every hop with the connection pinned to the checked address; redirects (10), bytes read (2 MiB), characters returned (50 000) and time (30 s) are capped; `allowedHosts` / `blockedHosts` are checked before DNS and `allowPrivate` exempts named hosts. Node only. See docs/tools.md#built-in-tools.
+- Agent directories: new `agent.*` config keys `permissionMode`, `permissions` (a serializable rule form: `tool` is a name, a list or - in a code config - a `RegExp`; `when` narrows the rule with a record of argument names to matchers - a regular expression string or an operator object (`eq`/`ne`, `lt`/`lte`/`gt`/`gte` on `Number(arg)`, `matches`), or a predicate in a code config; `action` is `allow` / `ask` / `deny`), `compaction`, `limits`, plus `hooks` and `approve`, each a path (relative to the directory) to a file default-exporting the hook(s) / the approver function, or the value inline in a code config. `instructions/.md` appends to `instructions.md` when the resolved model id contains `` (e.g. `openai`, `anthropic`). `lousho build --target=node-server` (and `--target=docker`) bundles a root `hooks.*` / `approve.*` file next to the config and resolves the compiled `.js` sibling; the Cloudflare Worker target carries `permissionMode`, `permissions`, `compaction` and `limits` over and accepts `hooks` / `approve` from a code config (a file path is refused, a Worker cannot import it). See [Agent directories](docs/agent-directories.md).
+- Registry: a new item `type` `kit` is a whole agent directory - its `files` may sit at any (still safe) relative path - so `lousho add --dir ` installs `agent.*`, `instructions.md`, tools, skills and sub-agents in one item, under one permission manifest, and records it in `lousho-registry.json` like any other item. The default registry's first kit is `coding-kit`, a port of the `examples/coding-harness` baseline: workspace tools with checkpoint rewind, an allow-listed shell, permission rules, a loop guard and output cap as hooks, a compaction setting, a `fix-failing-test` skill and a read-only `explorer` sub-agent (manifest: `exec`, `fs-write`; install with `lousho add coding-kit --dir --yes --allow exec,fs-write`). See [Registry](docs/registry.md).
+- The `pi` provider: `createAgent({ model: 'pi//' })` routes calls through `@earendil-works/pi-ai` (`1.0.3`, a new optional peer - `npm install @earendil-works/pi-ai@1.0.3`), the engine `pi-coding-agent` runs on, so pi's whole catalog (OpenRouter, Anthropic, OpenAI, Google,...) is usable natively; e.g. `pi/openrouter/openai/gpt-4o-mini` is OpenRouter's `openai/gpt-4o-mini` (nested slashes are kept: the segment after `pi/` is the pi provider, the rest the model id). Messages (including reasoning blocks, tool calls/results and image parts), tool `parameters` (raw JSON Schema, zod 3/4 or Standard Schema), streamed chunks and usage/cost map to the same surface the `ai`-SDK providers expose, and pi's own retries are pinned off so `retry`/`fallbackModels` stay the only retry layer. Credentials are per nested provider: pi reads each one's own env var (`pi/openrouter/...` reads `OPENROUTER_API_KEY`), and a missing one fails the call with `LOUSHO_PROVIDER_MISSING_API_KEY` naming it; a missing package fails with `MissingPeerDependencyError` naming `npm install @earendil-works/pi-ai@1.0.3`. New export `PiProvider` (+ `PiProviderConfig`, whose `models` injects custom/faux catalog entries). `pi` is Node-only: the `cloudflare-worker` target refuses `pi/...` specs. See docs/providers.md#the-pi-provider.
+- `piAgent()` ("pi-coder"): a Pi coding-agent session (`@earendil-works/pi-coding-agent`, a new optional peer) usable in-process as a `createAgent({ subagents })` entry - `subagents: { coder: piAgent({ cwd, model, description, permissions }) }`. Each `task` call is one Pi session under the task's `sessionId`, so `taskId` resumes and approval resumes reopen the same JSONL transcript even across a restart. `permissions` (the same `PermissionRule`s as `createAgent()`) gate every Pi tool call through a `tool_call` extension handler: `deny` refuses it, `ask` pauses the lead run durably for approval when the caller can pause and refuses it otherwise; approving re-issues that exact call once (Pi has no run-blocked-call API, so a decision prompt asks the model to repeat it and the gate lets that one call through), rejecting sends the note as the next turn. Pi usage rolls into the lead's `result.usage`, and aborts propagate to the Pi session. Node-only: the package is loaded lazily, so a consumer without it still typechecks and `run()` fails with `LOUSHO_PEER_MISSING`. Also new: `defineRemoteSubagent(impl)` wraps any `RemoteSubagent` implementation (same contract as `remoteAgent()`), and `SubagentApprovalPause` is exported for such adapters.
+- Agent directories: three more `agent.*` config keys wire the approval surface. `approvalTtlMs` is `createAgent`'s run-level default for how long a pause for approval stays decidable (milliseconds); a `permissions` `ask` rule may set `ttlMs`, bounding that rule's pauses and winning over `approvalTtlMs` (both positive finite numbers, rejected otherwise). `store` declares a durable store so paused approvals survive restarts ("draft today, approve tomorrow"): `{ "dir": "./.lousho" }` (resolved against the agent directory, staying inside it) becomes a `fileStore()` - sessions, checkpoints and approvals as JSON files, with `historyLimit` / `tokenKey` also declarable; a code config may set an `AgentStore` instance instead. `store` is Node-only: the Cloudflare Worker target rejects it. `loadAgentDir(dir, { approve: null })` / `{ hooks: null }` strip the directory's wiring (the file the config names is not even imported), where `undefined` keeps it. The generated node-server / docker server keeps a config-declared `store` unless `LOUSHO_STORE` is set. See [Agent directories](docs/agent-directories.md).
+- Agent directories: `"engine": "pi"` in a `subagents//` config makes that directory a `piAgent()` coding sub-agent instead of a nested `createAgent()` one - it lands in the parent's `subagents` map (the `task` tool) and its sessions run in the parent's directory, the workspace both share. Its `model` is the usual `pi//` id and its `permissions` gate the Pi session's own tool calls; the remaining config keys are ignored (Pi carries its own prompt and tools). `loadAgentDir()`'s new `piAgent` override injects `PiAgentOptions` into every such sub-agent (a faux `modelRuntime` in tests, a `sessionDir` for durable approvals); the directory always wins `cwd`, `name` and `description`. Cloudflare Worker targets reject `engine` - Pi needs the Node runtime. The default registry's second kit, `coding-pi`, is `coding-kit` on the full Pi stack: pi-provider lead, dir-declared pi coder, same safety rails (install `--allow exec,fs-write,network,env`). See [Agent directories](docs/agent-directories.md#sub-agents).
+- The `approve` callback may return `'defer'` to abstain: the call stays pending (listed by `agent.approvals.list()`, resolvable by `agent.approvals.resolve()` / `streamResolve()` / `answer()`) and the run surfaces the pause - `send()` resolves with `finishReason: 'awaiting-approval'`, as if the callback had never been asked - so an approver can decide the calls it trusts and hand the rest to a human. Note: `'defer'` is now reserved; an approval note that is exactly `'defer'` defers instead of approving. See [Deciding in code](docs/approvals.md#deciding-in-code).
+- Tool-call hook contexts carry `resumedAfterApproval`: `true` only when `preToolCall` / `postToolCall` fire for the post-approval re-run of a call the run paused on (`agent.approvals.resolve()`, `resumeAfterApproval()`). Stateful hooks (loop guards, audit counters) can skip the re-fire with `if (ctx.resumedAfterApproval) return;`. See [Hooks](docs/hooks.md).
+- New `ToolDescriptor.transient` marks a tool bound per run (a memory slot's `remember_*` / `recall_*` tools, bound to the run's scope keys): it is left out of the agent fingerprint, so an approval-resumed run - whose registry intentionally does not rebind them - no longer reports `tools removed` agent drift on every resolve.
+- Approvals can expire. `createAgent({ approvalTtlMs })` (validated: positive finite) sets a default deadline for every pause, `send()`/`stream()` take a per-call override, a `permissions` `ask` rule may set `ttlMs` (wins over the run default), and `ExecuteOptions.approvalTtlMs` covers `AgentExecutor.execute()`; sub-agents inherit the lead's TTL unless they set their own. A pending approval past `expiresAt` resolves as denied with `'approval expired'` - enforced at decision time, so a durable-store resume (a restart, another process) can never approve a stale pause - and `list()` keeps expired entries (marked by `expiresAt`) so `resolve()` still continues the run. `approval.requested` events and `UIPendingApproval` carry `expiresAt` for UI countdowns; expiry writes an audited `deny` decision to `onPermissionDecision`. See [Approvals](docs/approvals.md).
+- `llmCritique(config)`: like `llmJudge` but returns `{ score, feedback }` - one judge call, a `SCORE:`/`FEEDBACK:` prompt, graceful degradation on malformed output - so evaluator-optimizer loops get actionable revision guidance, not just a number. See [Evals](docs/evals.md).
+- Testing helpers: `testToolContext(overrides?)` (`@lousho/build-ai-agent/testing`) builds a valid `ToolExecutionContext` for direct `tool.execute()` calls - `getToken`/`requireAuth` fail loudly unless stubbed - and `toolResultText(message)` (root export) unwraps a `tool` message's JSON-encoded content to its result text.
+- Channels: `ChannelInbound.metadata` now reaches the run as `SendOptions.metadata` (memory scopes and `when` contexts can key on channel-supplied data; it is unauthenticated, unlike `principal`), and `webhookChannel({ principal: (body, req) =>... })` can derive the caller principal from a verified body/header - so a webhook intake (PagerDuty, Grafana) attributes the run to the alerting system. See [Channels](docs/channels.md).
+- New registry kits and examples replicating the most-built agent archetypes: `inbox-triage` (channel intake → classify → draft → approval-gated send, per-sender memory, `@hourly` sweep; install `lousho add inbox-triage --allow env,fs-write`) and `incident-response` (webhook alert → diagnose → risk-tiered approvals → remediate → verify → audit timeline; install `--allow env,fs-write`); examples `deep-research` (orchestrator-workers fan-out with cited synthesis), `support-desk` (triage → `handoff()` to specialist agents), `evaluator-loop` (writer ↔ `llmCritique` revision loop) and `data-analyst` (text-to-SQL with read-only enforcement). Each runs offline on mock models and accepts `OPENROUTER_API_KEY` for a live pass.
+- `claudeProject(dir)`: loads a repository's Claude-Code-conventional context into options that spread into `createAgent()` — `CLAUDE.md`/`AGENTS.md` + `.claude/rules/*.md` become `instructions`, `.claude/skills/*/SKILL.md` load through `loadSkills()` unchanged, `.claude/agents/*.md` frontmatter becomes `subagents` (frontmatter `model` selects the sub-agent's model; `provider`/`model` options are the fallback), and `.claude/scratchpad/` gets a `scratchpad` note tool (name-validated `*.md` writes). Nothing in the directory executes: markdown is read as data, and `.claude/commands`, `settings*.json` and `mcp.json` are listed on `manifest.ignored` rather than loaded. See [Claude Code projects](docs/claude-projects.md).
+- `decide()`: typed answers from the OpenAI Decisions API (`POST /v1/decisions`, public beta — `gpt-6-luna` only) — `predicate` questions return a probability, `choice` returns a value plus per-option `probabilities` and `confidence`, `score` returns a rubric's probability-weighted level index, and any question may come back a `refusal`. Input is a string or messages with `input_text`/`input_image` (inline base64) parts; `apiKey` (default `OPENAI_API_KEY`), `baseURL`, `timeoutMs`, `signal` and `fetch` are overridable. It is the fast path for typed decisions (route/gate/score) — no agent turn and no generated prose — and does not go through providers or OpenRouter. See [Decisions](docs/decisions.md).
+- Four new examples covering the agentic-patterns catalog: `waves` (Workers · Aggregate · Verify · Extend — bounded parallel workers, deterministic merge, a typed `{ verdict, gaps }` verifier, extension waves seeded by its gaps), `phase-pipeline` (discuss → spec → plan → implement → proof gate → report on `FlowBuilder`/`FlowExecutor`, with the implement↔check retry ladder as a `oneOf` chain), `coding-agent-workflows` (a coding agent routed by a typed decision — `output` schema `{ route, confidence, reason }`, a confidence floor escalating low-confidence calls to the careful path), and `connectors` (MCP connectors à la Claude — runs fully offline against a mock `serveMcp` stdio server exposing `docs_*`/`slack_*` tools; README maps Google Docs, Slack, GitHub and Notion). Plus `hostinger-deploy`: build an agent with `lousho build --target=docker`, ship it to a Hostinger VPS over SSH and smoke-test `/health` + bearer auth + approval pause/resume. See [Prompting and context techniques](docs/prompting-techniques.md), [Connectors](docs/connectors.md) and [Hostinger VPS](docs/hostinger-vps.md).
+- `lousho add` has a default registry: without `--registry` or `LOUSHO_REGISTRY` it reads `https://registry.lousho.com/index.json` - the `registry/dist/` folder of this repository served as static JSON - instead of failing with "no registry configured; there is no hosted registry yet", so `lousho add --list` works out of the box. It carries five starter items: the tools `open-meteo-weather` (current weather by city through Open-Meteo, no API key) and `github-issues` (list and create issues through `api.github.com`; creating asks for approval), the skills `changelog` and `code-review`, and the `generic-webhook` channel (HMAC-SHA256 signature verification from `WEBHOOK_SECRET`). `--registry` and `LOUSHO_REGISTRY` still override it, and the new value `none` (in either place) disables the registry entirely with the old `LOUSHO_CONFIG_INVALID` "no registry configured" error, for offline or locked-down use. The items live in `registry/` as an `item.json` plus their files per folder; `npm run registry:build` validates each against the registry schemas, the path rules and the M7a code/manifest check, and writes the committed `registry/dist/` (`npm run registry:check` fails when it is stale and runs in CI). See docs/registry.md.
+
+### Fixed
+- A channel button click (Slack, Discord), inline-keyboard tap (Telegram), Adaptive Card press (Teams) or `/approve` command (GitHub) after a restart is now bound to the conversation's session before the approval is resolved, so its continuation is appended to the session transcript again. The decision names the conversation it came from (`inbound`); with checkpointed sessions (`mountChannels` `store` with `checkpoints`, e.g. a `SqliteStore`) it is accepted only when that session's pending turn waits on exactly that approval id and kind - a decision for another conversation, a replay of a decided one and a decision of the wrong kind are refused (`LOUSHO_APPROVAL_NOT_FOUND`) before the approval is touched, and `onDecision` audits only decisions that pass, so a replayed click cannot run the tool twice and the thread's next message continues the same session. Without a checkpoint store nothing can be checked, so the approval store alone decides, as before.
+- A channel's function `approvers` works after a restart: `ctx.approval(id)` asked only this process's `agent.approvals.list()`, so a pause the restarted process did not make looked the same as a denial and the function failed closed. `ApprovalStore` gains an optional `load(id)` - `resolve()` without removing the record; every shipped store implements it, and a custom store without it keeps compiling and behaves as before - and the new `agent.approvals.get(id)` reads through it. `ctx.approval` falls back to that, so the function sees the same request (`toolName`, `input`, `sessionId`, `principal`) it saw before the restart and fails closed only when no store knows the id (a process-local approval store still forgets its pauses at a restart).
+- A hand-written Cloudflare Worker can now bundle `createAgent`. The package root also exports file stores, the agent-directory loader, MCP over stdio and sandboxes, so bundling it for `platform: 'browser'` failed with `Could not resolve "async_hooks"` (and `crypto`, `fs/promises`, `http`, `net`, `stream`) - only the generated Worker ever bundled, because `lousho build --target=cloudflare-worker` redirects those imports to shims itself. `@lousho/build-ai-agent/worker` is a new subpath exporting `createAgent` (and its config types), `memoryStore`, the KV stores (`KVStore`, `KVCheckpointStore`, `CHECKPOINT_KV_BINDING`), the schedule helpers (`defineSchedule`, `handleScheduled`), `serveFetch` (the `/chat` API as a fetch handler), the providers a Worker can run and the whole Worker-safe subset agent-directory code may import. The SDK's own build produces it through the same Node-builtin/sandbox/QuickJS shims the adapter applies (plus new `ollama-ai-provider` and `@modelcontextprotocol/sdk` ones, which an external specifier would leave unresolvable and `mcpServers` therefore fails on first connect - use `lousho build` for MCP on Workers), so the published entry carries no `node:` import for a user's own bundler to fail on; `ai`, `zod`, `@opentelemetry/api` and the `@ai-sdk/*` provider packages stay external specifiers resolved from the user's install. Guarded by `src/deploy/worker.test.ts`; see docs/cloudflare-workers.md#writing-your-own-worker.
+- The published `.d.ts` files no longer import the types of optional peers: `SubprocessSandboxOptions.dockerOptions` is now the structural `DockerConnectionOptions` (same fields as dockerode's `DockerOptions`, passed through unchanged) and `McpClientLike` is the structural subset of the MCP SDK's `Client` it already was (`listTools` / `callTool`). Before, `dist/index.d.ts` imported `dockerode` and the shared schema chunk imported `@modelcontextprotocol/sdk`, so a consumer compiling with `skipLibCheck: false` without those optional peers installed got TS2307 inside the SDK's own declarations. A dockerode `DockerOptions` or MCP `Client` value still satisfies both types.
+
+## [1.0.0-alpha.9] - 2026-10-04
+
+### Security
+- `http_request` is no longer open to DNS rebinding. It resolved a host name once to check it against the private-address list and then let `fetch` resolve it again to connect, so a name that answered a public address to the check and `127.0.0.1` (or a cloud metadata or intranet address) to the connection reached the private address; the docs and a comment in the Worker runtime described the check as rebinding-safe. Now the one resolution is the check: the in-process path connects through an undici `Agent` whose `connect.lookup` checks every address and hands the socket the address it checked (each redirect hop included), and the sandboxed path (`sandboxExecute`, which agents use) resolves and checks the host in the agent's process and has the sandboxed process connect to that address (node:http/https with a fixed lookup; the host name still goes in `Host` and TLS SNI). The private-address list is now the shared one (below), which adds `0.0.0.0/8`, `100.64.0.0/10`, `192.0.0.0/24`, `198.18.0.0/15`, multicast and reserved `224.0.0.0/3`, `::`, `ff00::/8`, NAT64 `64:ff9b::/96` and 6to4 `2002::/16` to what `http_request` refused. Behavior changes: a host name that cannot be resolved now fails with the resolver's error instead of being passed to `fetch`; `undici` is loaded on the first `http_request` request (not only with `validateSSL: false`). New option `createHttpTool({ allowPrivate })` for hosts that may resolve to private addresses. Cloudflare Workers have no DNS hook, so neither tool exists in the Worker build; docs/deployment.md now says what a Worker can and cannot check.
+- A run's principal is fixed for the whole run: an approval resolved by another caller, a restart, `agent.resume()` or a session's pending turn continue as the caller that started the run, and the decider is recorded as the approver (`ctx.approval.by`), never swapped in as the run's principal, so a later caller cannot use an earlier caller's grant by approving their pending call. The principal comes only from route auth or a channel's verified sender; the approvals route and channels never read it from a body field. It is frozen before a tool, a policy or a hook sees it. Remote sub-agents are not told who the caller is.
+
+### Changed
+- Behaviour and types: a tool whose `execute` returns an async iterator that is also async iterable (what an `async function*` returns) is now iterated: its last yielded value is the result, instead of the iterator object itself (which used to reach the model as `{}`). `AgentEvent` gains `tool.partial` (`ToolPartialEvent`), `isToolEvent()` narrows to it too, `UIToolCall` gains `partial?`, the `tool-output-available` UI chunk type gains `preliminary?: true`, and `DefinedTool` gains a third, defaulted type parameter (what `execute` resolves to). Migration: a `switch` over `event.type` meant to be exhaustive needs a `'tool.partial'` case; code using the result of `isToolEvent()` as start/done/error only should check `event.type`; a tool that means to return an async generator as a value should wrap it (`{ items: generator }`) - a value that is only async iterable, such as a `ReadableStream`, is still returned as it is.
+- Types only: `AgentEvent` gains the `handoff` event (`HandoffEvent`) and `ExecutionResult` the optional `agentName` (set on every result the executor returns: the agent of the final reply). Migration: a `switch` over `event.type` meant to be exhaustive needs a `handoff` case; a test comparing a whole `ExecutionResult` with `toEqual()` now sees `agentName`. Nothing changes at runtime for an agent without `handoffs`.
+- BREAKING (types only): `McpServerStatus` gains `'needs-auth'`, and `AgentOAuth` (the type of `agent.oauth`) gains `mcpSignInUrl(server)`. Migration: an exhaustive `switch` over `connectMcp(...).status()` values needs a `'needs-auth'` case (only servers with `oauth` ever report it); an object written by hand to satisfy `AgentOAuth` (a test double, say) needs an `mcpSignInUrl` method. Runtime behaviour without `oauth` is unchanged.
+- BREAKING (types only): `ToolExecutionContext` gains the methods `getToken(provider)` and `requireAuth(provider)`, and `ApprovalKind` gains `'sign-in'`. Migration: code that builds a tool's execute context by hand (for example to call `tool.execute(args, { toolCallId, messages })` in a unit test) must add the two methods or cast (`as ToolExecutionContext`); a `switch` over `PendingApproval.kind` / `approval.requested`'s `kind` that is meant to be exhaustive needs a `'sign-in'` case. Contexts the SDK builds have them already; nothing changes at runtime.
+- BREAKING: `send()` and `AgentExecutor.execute()` with a listener (`createAgent({ onEvent })`, `onAgentEvent`, or the deprecated `onEvent` option) now stream model calls: a step's text arrives as several `text.delta` events, as in `stream()`, instead of one `text.delta` per step after the step finished. What a listener receives otherwise does not change: the same event types in the same order, exactly one `text.done` per step with the step's whole text, the same `step.done` usage and `run.done`, the same `ExecutionResult` (text, tool calls, usage, steps). A provider without `stream()`, or whose `supportsStreaming(model)` is `false`, still gives one `text.delta` per step. Output guardrails on `send()` still check only the final reply (a `stream()` you iterate checks every step's text, as before). `send()` without a listener still calls `provider.generate()`. Hooks' `ctx.emit` is set whenever the run has listeners (it was already; the docs said "only when streamed"). Migration: concatenate `text.delta`, or listen to `text.done` for whole steps; `execute({ streamModelCalls: false })` (new option, default `true`) restores whole steps.
+- `MockLLMProvider` / `createMockProvider()`: `stream()` now streams the same step `generate()` returns: the same tool calls (it streamed none, so a streamed mock run never called a tool), finish reason and usage, and text chunks that add up to exactly the generated text (the old chunks ended with an extra space: `'This is a mock response. '`). Runs on the mock provider with a listener (Agent Forge's, for one) therefore give the same results as before, and `stream()` runs on it now call tools as `send()` does.
+- Agent Forge: runs now stream their model calls, so the run WebSocket (`WS /agents/:id/stream`) carries several `text.delta` events per step; the chat, logs and trace views are unchanged (they use `text.done` and the reconciled messages). Stop is checked between streamed chunks and after the last one, as it was after each `generate()` call.
+- CI peer matrix: the `typecheck-ai7` and `typecheck-zod4` jobs are replaced by one `peers` job with five entries, each installed for real on top of the default install and run through `tsc`, `test:types`, both builds and `npx vitest run`: `ai4-zod4`, `ai6-zod3`, `ai6-zod4`, `ai7-zod3`, `ai7-zod4`. The two zod 4 entries on `ai` 6/7 also install `ollama-ai-provider-v2` (3.x / 4.x), and the new `src/providers/ollamaV2.contract.test.ts` runs `OllamaProvider` against the real package and a local fake Ollama server (`generate()`, `stream()`, a tool-call turn through `createAgent().send()` and `.stream()`). The `ai-v6` dev alias replaces the hand-made `ai` 6 stand-in in `aiMajorPeers.test.ts`.
+- `lousho init --provider ollama` now scaffolds `ai@^7.0.0` with `ollama-ai-provider-v2@^4.0.0` and `zod@^4.0.0` (it was `ai@^4.3.19` with `ollama-ai-provider@^1.2.0` and zod 3). Existing projects are not touched; to stay on the old pairing keep `ai@^4.3.19` and `ollama-ai-provider@^1.2.0`.
+- A run paused inside a sub-agent compares the sub-agent with its current definition on resume, under the lead's `onAgentDrift`. Behaviour change: with `onAgentDrift: 'error'`, `agent.approvals.resolve()` (and `resumeAfterApproval()`) now also rejects with `LOUSHO_AGENT_DRIFT` when a paused sub-agent's instructions, model or tools changed; before, only tool and provider default-model changes of a sub-agent were seen, and always as a warning. This holds at any depth (a sub-agent of a sub-agent uses the lead's mode). With `'warn'` (still the default) the `agent.drift` event carries `subagent`. A rejected resume puts the lead's approval record and its `'awaiting-approval'` checkpoint back, so fixing the sub-agent and resolving again finishes the run; before, a sub-agent's missing-tool error became an error result of the `task` call and the run continued. Migration: none for `'warn'` / `'ignore'`; with `'error'`, resolve approvals paused before a sub-agent changed with the old definition, or use `'warn'`. See docs/durable-execution.md#resuming-with-a-changed-agent.
+- `lousho add` enforces the permission manifest at install. Behaviour change: an item whose code does not match its manifest is now refused with the new code `LOUSHO_REGISTRY_MANIFEST_MISMATCH`, and nothing is written. Before writing, every JavaScript / TypeScript file of the item is scanned (comments stripped) for `child_process` / `execa` / `shelljs` / `createShellTool` / `SubprocessSandbox` (needs `exec: true`), `fs` imports and `createFsTools` (needs `filesystem: "read"`), file-writing calls (needs `"write"`), `fetch(` and network modules (needs a non-empty `network`), `http(s)://` hosts in strings (each must match a `network` entry) and `process.env.NAME` reads (each must be in `env`); `eval(`, `new Function(`, a computed `process.env[...]` and a non-literal `import()` / `require()` are always refused. Each finding is listed as `:: ()`. `network` entries must now be host patterns and `env` entries variable names (`LOUSHO_REGISTRY_INVALID` otherwise). With `--yes`, an item asking for `exec: true`, `filesystem: "write"`, a `network` or an `env` is refused unless each is named in the new `--allow exec,fs-write,network,env` flag; the interactive prompt lists them, and the printed manifest marks them `[elevated:...]`. After writing, `lousho add` records the item (type, registry, time, permissions, each file's sha256) in `/lousho-registry.json`. Migration: registry authors declare what their code uses; scripts that run `lousho add --yes` add `--allow` with the permissions they accept. It is a static check, not a sandbox; see docs/registry.md#permission-manifest. The example item in docs/registry.md now uses `input` (not `inputSchema`).
+- Behaviour change: continuing an unfinished checkpointed run (`send(message, { sessionId, principal })`, `AgentExecutor.execute({ sessionId, checkpointStore, principal })`) with a principal other than the one it was saved with now throws `LOUSHO_CONFIG_INVALID` instead of running the new input; without a principal (`agent.resume()`, a session's pending turn) the run continues as the saved caller. Migration: resume such a run without `principal`, or start a new session. `onPermissionDecision` is called with a second argument, `{ principal }` (callbacks that take one argument are unaffected). Checkpoints, approval snapshots and pending approvals now store the run's principal (claims included): treat those stores as personal data. Checkpoints and snapshots written before this release resume with no principal; no migration.
+
+### Added
+- `lousho dev` and `lousho chat` take `--traces` (or `--traces=`) to write local traces with `fileTraceExporter()`: `.lousho/traces` by default, the directory `lousho traces` reads, for spec files, agent directories and modules (`specToAgent()` now also accepts an `exporter` option). It is opt-in because trace files hold prompt and tool content even with `captureContent: false` (only `redactContent: true` removes it); the command prints where it writes. `lousho init` projects already ignore `.lousho/`.
+- Code mode: `createAgent({ codeMode: true | CodeModeOptions })` adds a `run_code` tool. The model writes the body of an async JavaScript function that calls the agent's tools as `await tools.(args)`, loops and combines their results, and returns one JSON value; `run_code` returns `{ result, logs, toolCalls }` and only that enters the transcript. Scripts run in a QuickJS WebAssembly isolate from the new optional peer `quickjs-emscripten` (`^0.32.0`, listed by `lousho doctor`; without it the first run of a `codeMode` agent fails with `MissingPeerDependencyError`): a fresh runtime per script, no `require`, `import`, `process`, `fetch`, timers or host objects, values passed as JSON copies, and the limits `timeoutMs` (30,000, whole script including awaited tool calls), `memoryLimitBytes` (64 MiB), `maxToolCalls` (50) and `maxOutputChars` (20,000, return value plus logs). `tools` picks the callable tools (default: all but `run_code`, `ask_question`, sub-agent tools, `tool_search`, `load_skill` and deferred tools); `exclusive: true` takes them off the model's tool list. Every inner call (id `:`) goes through the same gate as a direct call: validation, hooks, permission rules and modes (plan mode refuses non-read-only inner calls; `run_code` itself is read-only), tool guardrails (a block stops the run), `needsApproval`, sandbox routing and the principal. A call that would pause the run (approval, sign-in, a sub-agent's approval) throws inside the script instead and the run does not pause; `run_code` itself can be paused for approval by a rule and runs after the decision. A failed script's error lists its first five tool calls with their (clipped) results. New export: type `CodeModeOptions`. Types: `parentToolCallId?` on the `tool.start`, `tool.partial`, `tool.done` and `tool.error` events (set on inner calls; their `tool.done` / `tool.error` comes before `run_code`'s); inner calls get `execute_tool` spans under the `run_code` span with the attribute `lousho.tool.parent_call_id`. No migration needed: nothing changes without `codeMode`. See docs/code-mode.md.
+- Streaming partial tool results: a tool's `execute` may be an `async function*`. Every `yield` is a complete snapshot of its output, streamed as the new `tool.partial` event (`{ toolCallId, toolName, output, index }`, `index` from 0) between the call's `tool.start` and `tool.done`; the last yield is the result the model gets (and `tool.done`'s `result`), a `return` value is ignored and a generator that yields nothing has the result `undefined`. Snapshots are not sent to the model, not added to the transcript, not checkpointed and not recorded in trace spans; hooks see only the result; tokens from `ctx.getToken()` are redacted in snapshots like in the result. The main loop, a streamed resume after an approval (`agent.approvals.streamResolve()`) and a flow's tool-call node iterate the generator; aborting the run stops reading it and calls its `return()`; `toolConcurrency` counts the whole generator run. A call that pauses for a sign-in mid-stream runs again from the start (index from 0); partial output is lost on a crash. `defineTool` types it: `ToolOutput` is the yielded type (new helper type `ToolResultOf`). The UI reducer (`useLoushoAgent()` and the Vue / Svelte bindings) keeps the latest snapshot as `toolCalls[i].partial`, and `toUIMessageStream()` sends snapshots as the AI SDK's preliminary tool output (`tool-output-available` with `preliminary: true`). See docs/tools.md#streaming-partial-results.
+- Tool search: tools marked `deferLoading` (`defineTool({ deferLoading: true })`, or every tool of an MCP server with `mcpServers..deferLoading: true`, also in spec files and agent directories) are withheld from the model, which finds them with a built-in `tool_search` tool (`{ query }` -> `{ loaded: [{ name, description }], more }`); the tools it finds are sent from the next step on, and `tool_search` stops being offered once everything is loaded. New option `toolSearch` on `createAgent()` and `AgentExecutor.execute()`: `{ thresholdPercent? (default 0.1 of the context window; 0 always defers), maxResults? (5), contextWindow?, search? }`, or `false` to send every tool upfront. Below the threshold nothing is deferred and no `tool_search` exists. Which tools are loaded is read from the transcript at each model call, so loading survives crash resume, approvals, forks and session turns; compaction that prunes a `tool_search` result unloads its tools; a handoff target or a sub-agent never inherits the lead's loaded tools. Skills, memory, `task`, `ask_question` and handoff tools are never deferred. New exports: `ToolSearchOptions`, `ToolSearchResult`, `rankToolsByKeywords()`. Types: `ToolDescriptor.deferLoading`, `DefineToolOptions.deferLoading`, `McpStdioServerSpec` / `McpHttpServerSpec.deferLoading`, `LoadMcpToolsOptions.deferLoading`, `SubagentSpec.toolSearch`, `ToolMetadata.mcp.server`. Behaviour change: tools loaded by `loadMcpTools()` / `connectMcp()` now carry their server name in `metadata.mcp.server`; a test comparing an MCP tool's whole `metadata` with `toEqual()` needs that key. A user tool named `tool_search` is refused (`LOUSHO_CONFIG_INVALID`) only when deferral is active. See docs/tool-search.md.
+- Handoffs: `createAgent({ handoffs: [billing, handoff(refunds, options)] })` lets an agent hand the whole conversation to another `createAgent()` agent (with a `name` and a `description`), offered to the model as one `transfer_to_` tool each. The handoff call is not run as a tool: once the other calls of its step are done, the run goes on in the same `send()` / `stream()` as the target, with its instructions (replacing the system prompt), model, tools, skills, sub-agents, reasoning and own handoffs, the lead's guardrails and permission rules before its own, on the transcript the handoff's `inputFilter` returns (`handoffFilters.removeToolCalls`, `handoffFilters.lastUserMessage`, or your own). Hooks, `limits` and spend, `maxSteps` (counted across agents), the signal, the approval, checkpoint and token stores, listeners, `output`, the permission mode and the principal stay the run's. `handoff()` options: `toolName`, `description`, `input` (validated; bad arguments get a tool error and no handoff), `inputFilter`, `onHandoff`, `isEnabled` (a `PerRun`). Only the first handoff call of a turn is honored; `maxHandoffs` (default 5) caps one run. The transcript marks each handoff with `metadata.handoff = { from, to }`; a session's next turn, `agent.resume()`, `session.resume()` and `agent.approvals.resolve()` continue as the agent it last handed off to (`agent.send()` without a session starts at the lead), and the resume drift check compares with that agent. Approvals remembered with `once()` are dropped at a handoff. A `transfer_to_*` call passes the run's gate like a tool call: `permissions` rules apply to it (`deny` gives the model a `kind: 'denied'` tool error and no handoff happens; `ask` pauses the run for approval and the decided call then hands off or reports the rejection), pre/post tool hooks may deny it or rewrite its arguments, and its decision is audited to `onPermissionDecision` / `permission.decision`. Each handoff appends a routing system note (`metadata.handoff`) carrying the validated call arguments to the transcript the target reads; `handoffFilters.removeToolCalls` keeps it and a custom `inputFilter` may drop or replace it. `createAgent()` warns once when a reachable handoff target was created with run-level options (`approve`, `approvalStore`, `permissionMode`, `approvalTtlMs`, `store`) - a run keeps the entry agent's. New `handoff` event (`from`, `to`, `toolCallId`) and `ExecutionResult.agentName`; `ExecuteOptions.handoffs` / `maxHandoffs` for the executor API. Remote agents cannot be handoff targets. See docs/handoffs.md.
+- OAuth for HTTP MCP servers, per the MCP authorization spec: an `mcpServers` HTTP entry takes `oauth: { redirectUri, clientId?, clientSecret?, scopes?, clientName? }` (new type `McpOAuthOptions`; refused on stdio entries by the spec schema). The MCP SDK's client does discovery (RFC 9728 protected resource metadata, RFC 8414 authorization server metadata), dynamic client registration (RFC 7591) when no `clientId` is given, PKCE and resource indicators (RFC 8707), and refreshes an expired token; tokens are kept in the agent's encrypted token store as provider `mcp-`, owned by the app. An operator signs the agent in once with the new `agent.oauth.mcpSignInUrl(server)`; N9b's `agent.oauth.complete()` and `GET /oauth/callback` finish it. Without a usable token the server's status is the new `'needs-auth'` and connecting fails with the new error code `LOUSHO_MCP_AUTH_REQUIRED` (a tool call refused mid-run is a tool error with that code; nothing pauses). `connectMcp(servers, { tokens })` takes the token store. Security: `oauth` needs an https `url` (http only for localhost, 127.0.0.1 and [::1]); an `Authorization` header together with `oauth` is refused; with `oauth`, `headers` go only to the MCP server's origin, never to the authorization server; a stored grant is only used for the `url` it was obtained for (a changed `url` needs a new sign-in); no token, code, verifier or client secret appears in errors, logs, `status()`, events or results. Out of scope for now: per-user MCP grants, a `lousho mcp login` command, OAuth over stdio. See docs/oauth.md, "MCP servers with OAuth".
+- Parallel input guardrails: `IoGuardrail.runInParallel` (default `false`) makes an `input` guardrail run while the first model call is already in flight, so a slow, model-backed check no longer delays the answer by its whole latency; `promptInjectionGuardrail({ runInParallel })` and `moderationGuardrail({ runInParallel })` take it as an option. The blocking input guardrails run first as before (rewrites included), then the parallel ones start together on that input. Until all have passed, the call's `text.delta`, `reasoning.*` and provider-run tool events are held in order (on the iterator and `onAgentEvent` alike), its tool calls do not run, and `onLLMResponse`, post-generate hooks, output guardrails, `text.done` and the checkpoint after the response wait. A trip aborts the call (its signal only, not the run's), drops the held events and ends the run with `finishReason: 'guardrail'`, `result.guardrail` and `guardrail.tripped` (or `GuardrailError` under `onTripped: 'throw'`), with nothing of the call in the transcript or a checkpoint; the call's usage counts in `result.usage` only when the provider reported it. A parallel check cannot rewrite (`action: 'rewrite'` blocks, the reason prefixed `runInParallel guardrails cannot rewrite: `), and a check that throws fails the run. Ignored on `output` and `tools` guardrails. No change for guardrails without the flag. See docs/guardrails.md#input-and-output-guardrails.
+- OAuth sign-in for tools: `defineOAuthProvider({ name, displayName?, credentialOwner?, authorizationUrl, tokenUrl, clientId, clientSecret?, clientAuth?, scopes?, redirectUri, authorizationParams?, fetch? })`, and in a tool `ctx.getToken(provider)` and `ctx.requireAuth(provider)`. Without a usable token the run pauses durably like an approval (`finishReason: 'awaiting-approval'`, `kind: 'sign-in'`, `signIn: { provider, displayName?, url }` on the pending approval and the `approval.requested` event); the authorization URL uses PKCE S256 and a single-use 256-bit `state` that expires after 10 minutes, and the verifier is kept only in the encrypted pending record. The new route `GET /oauth/callback` (`lousho dev`, the node server, the Worker) and `GET /oauth/callback` (`createRouteHandler`) is not behind route auth (a browser redirect carries no API token): it calls the new `agent.oauth.complete({ state, code?, error? })`, which stores the token for the paused run's principal and answers a small HTML page (400 for an unknown, used or expired state). Approving the pause (`agent.approvals.resolve({ id, approved: true })`, or the approvals route) runs the tool call again; before the callback it throws `LOUSHO_SIGNIN_PENDING` (HTTP 409) and the run stays paused. Tokens within 60 s of expiry are refreshed; `requireAuth()` deletes a refused token and pauses. A user credential needs a principal (`LOUSHO_OAUTH_PRINCIPAL_REQUIRED`); an app credential (`credentialOwner: 'app'`) is never asked of a chat user (`LOUSHO_OAUTH_APP_SIGNIN_REQUIRED`) and is connected by the operator with `agent.oauth.signInUrl(provider)`. The `approve` callback is never asked about a sign-in. Slack sends the link with `chat.postEphemeral` and Discord as an ephemeral follow-up, both without buttons; GitHub never posts it in a public thread; behind `createDeployedServer()` and `lousho dev` the callback continues the paused channel turn. The UI state (`pendingApproval.signIn`), the `data-lousho-approval` part, `lousho dev`'s page, `lousho chat` and `lousho acp` show the link. A tool result that contains a token handed out during that call is redacted to `[REDACTED]` with a warning. New error codes `LOUSHO_OAUTH_PRINCIPAL_REQUIRED`, `LOUSHO_OAUTH_APP_SIGNIN_REQUIRED`, `LOUSHO_OAUTH_STORE_MISSING`, `LOUSHO_OAUTH_STATE_INVALID`, `LOUSHO_SIGNIN_PENDING`, `LOUSHO_OAUTH_TOKEN_EXCHANGE_FAILED`. See [OAuth](docs/oauth.md#tools-that-need-sign-in).
+- Hosted tools on Anthropic and OpenRouter: `webSearch()` and `codeInterpreter()` now also run on the Anthropic provider (`ai` 6 with `@ai-sdk/anthropic` 3, `ai` 7 with `@ai-sdk/anthropic` 4): `webSearch()` is the newest `tools.webSearch_*()` the installed package exports (options `maxUses`, `allowedDomains`, `blockedDomains`, `userLocation`), `codeInterpreter()` the newest `tools.codeExecution_*()`; events and `usage.hostedToolCalls` keep the neutral name `code_interpreter`. `fileSearch()` on Anthropic, and `codeInterpreter()` / `fileSearch()` on OpenRouter, are `LOUSHO_HOSTED_TOOL_UNSUPPORTED`. `webSearch()` works with every `openrouter/...` model on any `ai` major: the provider adds OpenRouter's `openrouter:web_search` server tool to the request body (after the function tools, composing with the `reasoning` field; `maxUses`, `allowedDomains`, `blockedDomains` and `searchContextSize` map to its parameters) and reports one hosted call per step that searched, read from the response: the cited urls from `url_citation` annotations as `sources`, and `result: { requests }` when OpenRouter reports `usage.server_tool_use.web_search_requests`. OpenRouter bills each search on top of tokens (not in `costUsd`). `supportsHostedTool()` on both providers reflects this. The per-provider table in docs/hosted-tools.md is complete, and a replayable cassette from a live OpenRouter search is in the test suite (`npm run test:live` re-records it with `OPENROUTER_API_KEY`).
+- Hosted provider tools: `webSearch()`, `codeInterpreter()`, `fileSearch()` and `hostedTool(name, aiSdkTool)` go in `createAgent({ tools })` next to local tools (array or record form; in the record form the key must be the tool's name), and the provider runs them inside the model request. OpenAI maps them to `openai.tools.webSearch()` / `.codeInterpreter()` / `.fileSearch()` on `ai` 6 with `@ai-sdk/openai` 3 and `ai` 7 with `@ai-sdk/openai` 4 (options it does not take, such as `maxUses`, are dropped with one warning); `hostedTool()` passes any AI SDK provider tool through on `ai` 6 or 7, including on `fromAiSdk()` models. Provider-executed calls are never handed to the executor as local tool calls (on `ai` 6/7 they used to come back as calls of an unknown tool): they are reported as `tool.start` / `tool.done` / `tool.error` with the new optional field `executedBy: 'provider'` (as they stream in, or after a non-streamed call in call order; results capped at 20,000 characters of JSON), kept on the step's assistant message as `metadata.hostedToolCalls` (with url sources; not sent back to the model), counted in `usage.hostedToolCalls` and `run.done` usage per tool name (fees not in `costUsd`), and named on the `chat` span as `lousho.hosted_tool_calls`. A step whose only calls the provider ran ends the run even when the provider reports `tool-calls`. No permission rule, guardrail, approval or hook runs for them; instead, in permission mode `'plan'` only the read-only `webSearch()` and `fileSearch()` are sent to the provider (`codeInterpreter()` and `hostedTool()` are left out of the request, read before every model call), and every other mode sends them all. New: `ExecuteOptions.hostedTools`, `GenerateOptions.hostedTools`, `GenerateResult.hostedToolCalls`, `HostedToolCall`, stream chunks `hosted-tool-call` / `hosted-tool-result`, optional `LLMProvider.supportsHostedTool(type)`, `mockModel` turns with `hostedToolCalls`, cassettes that keep hosted calls, error code `LOUSHO_HOSTED_TOOL_UNSUPPORTED` (ai 4, Ollama, a provider without `supportsHostedTool()`, or an `@ai-sdk/openai` without the tool). Sub-agents do not inherit the lead's hosted tools; the agent fingerprint includes them. Anthropic and OpenRouter mappings follow in N1b (below). See docs/hosted-tools.md.
+- `session.history()` and `session.fork({ fromStep, id?, patch? })` on `agent.session()` sessions. `history()` lists the steps of the committed transcript (one per model response: `step` numbered from 1 across the session, `turn`, `messageIndex`, `text`, and `toolCalls` with `args` and `result`). `fork()` saves the transcript up to and including a step (`0` keeps nothing), optionally with one tool result replaced (`patch.toolResult`), under a new id in the same store (default `-fork-`) and returns a session of the same agent and options (with the permission mode the session has at that moment); the original is not changed. The spend recorded for `limits` up to that point carries over. A turn waiting on an approval or a question, or an interrupted checkpointed turn, stays with the source session, so it is resolved once; workspace files are not rewound. Works on every shipped store (memory, `fileStore()`, `SqliteStore`, `KVStore`), with one contract suite run against each. New types `SessionHistoryStep`, `SessionForkOptions` and `SessionSpawner`; new error codes `LOUSHO_SESSION_STEP_NOT_FOUND`, `LOUSHO_SESSION_EXISTS` and `LOUSHO_SESSION_FORK_UNSUPPORTED`; `AgentSession`'s constructor takes an optional fourth argument, the function that creates its forks. See [Forking a session](docs/sessions.md#forking-a-session).
+- Permission modes: `createAgent({ permissionMode })`, `send()` / `stream()` `{ permissionMode }`, `agent.session({ permissionMode })` and `session.setPermissionMode()` put an agent in `'plan'` (only read-only tools run; everything else is refused with `kind: 'denied'`, also when an `allow` rule matched, and a run that starts in plan mode gets one system-prompt paragraph saying so), `'acceptEdits'` (file edits that would ask run without asking) or `'dontAsk'` (a call that would ask is refused; nothing pauses). The modes are presets applied after hooks, permission rules, tool guardrails and `needsApproval`, so no mode turns a deny into a run. A function mode, and a session's mode, are read at every tool call, so a switch applies from the next call, also mid-turn and in a paused turn continued by `agent.approvals.resolve()`. Sub-agents run under the lead's mode unless it is `'default'`; a remote sub-agent is refused in plan mode. Audit: under a mode other than `'default'` every call is audited, `PermissionDecisionEntry.mode` says when the mode changed the outcome, and the new `onPermissionModeChange` records each switch. New: `PermissionMode` and `PermissionModeChange` types, `defineTool({ editsFiles })` (`metadata.editsFiles`, set on `write_file` and `edit_file`), `readOnlyHint: true` on `load_skill`, `recall_`, `agent_status` and `agent_await`. The mode is not saved in checkpoints. Example: `examples/plan-mode`. See [Permission modes](docs/permission-modes.md).
+- Semantic recall: a memory slot can recall by meaning. New `EmbeddingProvider` (`{ id, embed(texts) }`) and `aiSdkEmbedder(model, { id?, maxBatch? })`, which wraps any AI SDK embedding model through `embedMany` from the installed `ai` (4, 6 or 7; `ai` is loaded on first use, no new dependency), and two providers that rank by cosine similarity: `inMemoryVectorMemory({ embedder, maxItems?, minScore? })` from the root and `sqliteVectorMemory(store, options)` from `@lousho/build-ai-agent/sqlite` (a new `memory_vectors` table, added to an existing database file on open; `memory_items` and `sqliteMemory()` are unchanged). With a query, `list()` returns the scope key's items by score (floor `minScore`, default 0.2; score in `metadata.score`); without one it lists the newest first. Items embedded by another embedder id are skipped when ranking until `provider.reindex(scopeKey?)` re-embeds them. A `MemoryProvider` may set `ranking: 'relevance'`, which makes the `recall_` tool say "by meaning, most relevant first". `hashEmbedder({ dimensions? })` from `@lousho/build-ai-agent/testing` is a deterministic offline embedder for tests. `describeMemoryProviderContract` takes `{ query: 'filter' | 'rank' }`. docs/memory.md has a new `## Semantic recall` section at the end.
+- `teamsChannel({ appId, appPassword, tenantId?, name?, fetch?, approvers?, onError? })`: an agent behind an Azure Bot in Microsoft Teams, mounted like the other channels (`POST /teams`). The inbound Bot Framework token is verified with the auth module (`oidc()`: RS256, the key set of the fixed Bot Framework OpenID metadata, issuer `https://api.botframework.com`, audience `appId`, 5-minute tolerance) before the body is parsed, and its `serviceurl` claim must equal the activity's `serviceUrl`; replies go only to that service URL (https). Personal chats always reach the agent, group chats and channels only on an `@mention` (stripped from the text); one session per conversation; Markdown replies split at 25,000 characters; tool approvals are Adaptive Cards with Approve and Deny buttons (`approvers` as on the other channels; the card updates to "Approved by..." and names its conversation, so a copied or replayed reference decides nothing); `ask_question` is answered by the next message, also after a restart with durable stores. No `node:*` import. Telegram and GitHub now set the sender as the run's `principal` too (`authenticator: 'telegram'` with the user id, `'github'` with the login), as Slack and Discord do. See docs/channels.md#microsoft-teams.
+- Cloudflare Worker target builds agent directories (config, instructions, TypeScript tools, skills): `npx lousho build./my-agent --target=cloudflare-worker`. The build reads the directory on Node and generates `agent.module.ts` (static imports of `tools/*` and of an `agent.ts` config; `instructions.md`, a JSON/YAML config and the skills embedded as JSON), and the Worker builds the agent with the rules of `resolveAgentDir()` (the config check and the tool-export rules now live in Node-free modules both use). The config's `model` must name a Worker provider, or `agent.ts` sets a `provider` instance. `subagents/`, `schedules/`, `channels/`, `memory/`, `projectInstructions` and an unsupported provider are rejected with `LOUSHO_DEPLOY_FAILED`, naming what to remove. In a Worker build, an agent file's `@lousho/build-ai-agent` import resolves to a Worker-safe subset (`defineTool`, `defineSkill`, the Worker providers, `fromAiSdk`, errors,...); another name fails the build with the list. The leak check now names the file that imported a Node builtin (`"node:fs" imported by my-agent/tools/files.ts`) instead of failing with esbuild's "Could not resolve". Tools run in the Worker's isolate with its rights: see docs/cloudflare-workers.md#build-and-deploy.
+- Workspace rewind: `createFsTools(fs, { checkpoints })` with a new `WorkspaceCheckpoints` backs up every file `write_file` and `edit_file` change (content and, where the provider has the new optional `FsProvider.getMode` / `chmod`, permission bits), grouped by session and turn. `checkpoints.list({ sessionId })` shows the changed paths per turn; `checkpoints.rewind(toTurn, { sessionId, dryRun, force })` restores every file changed in that turn or later, deletes files the agent created, and skips files changed since by something else (`'changed-since'`), files over `maxFileBytes` (`'too-large'`) and paths that are no longer files. Every path is checked before anything is written, and restores go through the provider, so `NodeWorkspace` keeps them inside its root. Retention: the newest `maxTurns` turns per session (default 20), the old content only for a turn's first write of a path, rewound turns dropped, `clear({ sessionId })`. Stores: `MemoryWorkspaceCheckpointStore` (default) and `FileWorkspaceCheckpointStore(dir)` (one JSON file per session, atomic writes). `NodeWorkspace` gains `getMode()` and `chmod()`. Without `checkpoints` the tools behave as before. See [Workspace tools](docs/workspace-tools.md#the-tools).
+- Route auth and principals: a new subpath, `@lousho/build-ai-agent/auth`, with `jwt()`, `oidc()`, `basic()`, `apiToken()`, `anonymous()` and `routeAuth()`. A route takes an ordered list of entries: the first one that returns a `Principal` accepts the request, `null` skips to the next, `AuthError(401 | 403)` stops; when every entry skips the answer is a generic `401` with one `WWW-Authenticate` header per challenge (`Bearer`, `Basic realm="..."`), and the body never says which check failed. JWTs are verified with Web Crypto only (no new dependency, no `node:` import, so the helpers run on Workers and edge routes): `alg: none`, algorithms outside the configured list, a `crit` header and algorithm confusion (an HS token checked against a public key) are refused; `exp` is required, `nbf` / `iat` honored, `iss` / `aud` checked, with a clock tolerance of 60 s (at most 300); JWKS key sets are cached for 10 minutes and refetched at most once per 30 s, and a key-set URL is never taken from a token. `oidc()` uses the configured issuer's discovery document and requires it to name the same issuer. `basic()` and `apiToken()` compare in constant time. `createRouteHandler({ auth })` and `createDeployedServer(agent, { auth })` take the list (a token string and a boolean function work as before); the node server appends `apiToken(LOUSHO_API_TOKEN)` when that variable is set; an agent directory's `auth.ts` is bundled by `lousho build --target=node-server` / `docker` and guards the built server (`resolveAgentDir()` returns it as `auth`, the manifest has `auth: true`). The accepted principal reaches the run: `send()` / `stream()` / session turns take `principal`, and `RunConfigContext` (the `model` / `instructions` / `tools` functions) and `MemoryScopeContext` (memory scopes) carry it; Slack and Discord channels set the sender as the principal. `Principal` is also exported from the root. Bad helper options throw the new error code `LOUSHO_AUTH_CONFIG_INVALID` when the helper is created. `createRouteHandler()` without `auth` logs one warning when `NODE_ENV` is `production`. Route auth does not check session ownership, the principal does not reach tools or approval policies yet, and the Cloudflare Worker target keeps the `LOUSHO_API_TOKEN` token only. See [Route auth and principals](docs/auth.md).
+- OAuth token storage: `AgentStore` gains a fourth optional part, `tokens` (`OAuthTokenStore`: `get` / `set` / `delete` / `list` per provider and credential owner, single-use pending sign-ins with `putPending` / `takePending`, and `getClient` / `setClient` for dynamically registered clients). Owners are `{ owner: 'app' }` or `{ owner: 'user', principalId, issuer? }`; `tokenStoreKey(provider, owner)` is the stable, percent-encoded record key (`|app`, `|user||`). `memoryStore()` keeps tokens in process memory; `fileStore(dir)`, `SqliteStore` and `KVStore` encrypt every record with AES-256-GCM (Web Crypto, a fresh 12-byte IV per write, the record key as additional authenticated data) under a 32-byte key the application supplies as `tokenKey` or `LOUSHO_TOKEN_KEY`; there is no default key. Several keys (newest first, or comma-separated in the variable) rotate: writes use the first, reads try each. New error codes `LOUSHO_TOKEN_KEY_MISSING` (first write, or a read of an existing record, without a key) and `LOUSHO_TOKEN_DECRYPT_FAILED` (wrong key or a changed record; names the provider, never the token). `list()` returns metadata only. `generateTokenKey()` makes a key. SQLite: schema migration 4 adds the `oauth_tokens` and `oauth_pending` tables, and `prune()` also deletes expired pending sign-ins (its result gains `oauthPending`). KV: keys under `oauth/`, pending sign-ins written with an `expirationTtl`; `KVBinding` gains an optional `list()` (used only by `tokens.list()`), and `KVListOptions` / `KVListResult` are exported from `/kv`. The generated Cloudflare Worker passes its `LOUSHO_TOKEN_KEY` secret to `KVStore`. Nothing signs in yet; the sign-in flow follows. New page [OAuth](docs/oauth.md); new `## OAuth` section at the end of docs/errors.md.
+- `githubChannel({ webhookSecret, botName, token?, app?, botLogin?, name?, apiUrl?, fetch?, triggers?, approvers?, onError? })`: an agent that answers GitHub issue, pull-request and review comments. `@` in a comment starts a turn and the reply is a new comment in the same thread (a review thread gets a reply under it; text over 60,000 characters is split); one session per issue or pull request and one per review thread, and later comments in a thread with a session are follow-ups. `X-Hub-Signature-256` is verified over the raw body in constant time before the body is parsed (401 otherwise); the webhook is acknowledged at once. Comments by bots, by the channel's own account and every comment the channel posts (a hidden marker) are ignored, and so are edits, deletions and other events. Replies are posted with `token` (a personal access token, or a function) or as a GitHub App (`app: { appId, privateKey }`: a PKCS#1 or PKCS#8 key signs an RS256 JWT with Web Crypto, exchanged for an installation token that is cached per installation until 5 minutes before it expires; `src/channels/githubAppAuth.ts`, not exported). Approvals are comments: the prompt asks for `/approve ` or `/deny ` (first line only, notes below it), and by default only a commenter whose `author_association` is `OWNER`, `MEMBER` or `COLLABORATOR` may decide, read from the command comment itself; `approvers` (logins, or a function that sees the association in `user.roles`) overrides it, and `triggers` (logins, or a function) restricts who may start a turn or answer a question (default: everyone who can comment, so restrict it on a public repository). A comment is untrusted input to the agent; see the security note in docs/channels.md. An `ask_question` is answered by the next comment in the thread and survives a restart given durable stores. Errors name the call and the HTTP status, never the token. The `LOUSHO_CHANNEL_INVALID` hint, docs/agent-directories.md and docs/errors.md now list `githubChannel()`. New section `## GitHub` at the end of docs/channels.md.
+- Agent Forge keeps the traces of its runs: every run is written with `fileTraceExporter()` to `.lousho/agents//traces` (the files `lousho traces` reads), and the Trace tab gets a list of the agent's past runs (time, duration, model calls, tokens, cost, status) with a "Live" entry while a run is active; choosing one opens its spans in the waterfall, with span kind and error status. New server routes `GET /agents/:id/traces?limit=N` and `GET /agents/:id/traces/:traceId`; the server reads only inside the agent's trace folder. The live `span` WebSocket messages now carry `kind` and `status` (optional fields of `SpanEvent`). See [Agent Forge](docs/agent-forge.md).
+- A background sub-agent that needs approval pauses the lead when the lead awaits it (`agent_await`), and resumes with `approvals.resolve()` / `resume()`. The lead's result is `awaiting-approval` with the child's call (`subagentPath: ['']`) in `agent.approvals.list()`; the decision runs or rejects the child's call, the child finishes, and the continued lead gets its answer as the `agent_await` result. A child that pauses again, or several awaited tasks that are all paused, pause the lead one approval at a time. The child's usage is added to the lead's, and its conversation is saved under its `taskId`. The paused tasks are kept on the lead's approval record (`SubagentSuspension.background`, plain JSON), never in the tool arguments the model sees. Without `agent_await` nothing changes: the task reports `awaiting-approval` and is not resumable. The `task` and `agent_status` tool descriptions tell the model that a task awaiting approval continues only when it calls `agent_await` on it. Docs: docs/sub-agents.md ("When the lead run ends" and "Continuing a task").
+- Guardrail starter set: `piiGuardrail()` (email, phone, credit card with a Luhn check, IBAN with its mod-97 check, US SSN, IPv4; blocks, or rewrites each match to `[]`), `secretsGuardrail()` (private keys; Anthropic, OpenRouter, OpenAI, AWS, GitHub, Slack, Google and Stripe live keys; JWTs; `Authorization: Bearer` tokens; `extraPatterns`; rewrites to `[secret]` by default), `promptInjectionGuardrail()` (phrase, role-marker and Unicode-tag heuristics, plus an optional model check that must answer `SAFE` or `INJECTION: `) and `moderationGuardrail({ model })` (one model call that answers `NONE` or the categories that apply: `hate`, `harassment`, `self-harm`, `sexual`, `sexual-minors`, `violence`, `illicit`). The PII, secret and injection checks are heuristic pattern checks: they catch common cases and miss others, and are not a security boundary. Model-backed checks fail closed on a reply they cannot read. A guardrail result can now carry `info?: GuardrailTripInfo` (`pii`, `secret`, `prompt-injection`, `moderation` or `custom`), copied to `result.guardrail`, `guardrail.tripped` and `guardrail.rewrote`; it holds types, labels and offsets, never the matched text. Agent spec files can name them in `policy.guardrails` as `pii`, `secrets`, `prompt-injection` and `moderation`. New exported types `GuardrailTripInfo`, `PiiType`, `ModerationCategory`. Docs: [Guardrails](docs/guardrails.md#input-and-output-guardrails), [Configuration](docs/configuration.md#policy-policy), [Stream events](docs/stream-events.md).
+- `telegramChannel({ botToken, secretToken, botUsername?, name?, fetch?, approvers?, onError? })`: an agent behind a Telegram bot, mounted like the Slack and Discord channels (`POST &channels&telegram`). The webhook secret token header (`X-Telegram-Bot-Api-Secret-Token`) is compared in constant time (401 otherwise); updates are acknowledged with `200` and the turn runs after. A private chat is one session per chat; in a group only `&ask`, `&ask@`, an `@` mention or a reply to the bot wakes it, and a forum topic is its own session. Replies are plain text, split at 4096 characters. Tool approvals are an inline keyboard (Approve & Deny) that only `approvers` (default: the user who started the turn) can tap; the tap names its chat, so it works after a restart. An `ask_question` is sent with `force_reply`; a pending one survives a restart given durable stores, like Slack and Discord. The bot token is never put in an error message or log line. `src&channels&channelSupport.ts` gains the internal `secretsEqual()` (constant-time compare with Web Crypto). The `LOUSHO_CHANNEL_INVALID` hint, docs&agent-directories.md and docs&errors.md now list `discordChannel()` and `telegramChannel()`. New section `## Telegram` at the end of docs&channels.md (attachments are not read yet).&r
+- `todo.updated` stream event and `useTodos()` (React, Vue) / `loushoTodos()` (Svelte): every successful `todo_write` emits `todo.updated { todos, counts, toolCallId }` right after its `tool.done` (sub-agents too, with `subagent`). The shared UI reducer keeps the list in a new `AgentUIState.todos` (carried across turns, cleared by `reset()`), `useLoushoAgent()` / `loushoAgent()` expose it, `useTodos(agent)` / `loushoTodos(agent)` add counts, the current item and progress (`todoView()` computes the same view framework-free), and `toUIMessageStream()` sends a `data-lousho-todos` part for the AI SDK UI. See [Todos](docs/react.md#todos).
+- Cloudflare Worker target: the `openrouter` provider and the `http` tool (with a required `LOUSHO_HTTP_ALLOW` host allowlist). `openrouter` reads its key from the `OPENROUTER_API_KEY` binding and is `@ai-sdk/openai` pointed at OpenRouter, so its bundle passes the `node:` leak check like `openai`. The Worker's `http` is `http_request` with the Node tool's input, over the platform `fetch()`: since a Worker cannot see or pin where a host name resolves, it reaches only the host names (`api.github.com`) and `*.` wildcards (subdomains only) listed in the comma-separated `LOUSHO_HTTP_ALLOW` binding, refuses IP-address hosts even when listed and any scheme but `http:`/`https:`, checks every redirect hop the same way, and refuses every request when the binding is unset or empty. An invalid entry fails every request with an error naming it. `wrangler.toml` gets a commented `[vars]` `LOUSHO_HTTP_ALLOW` line when the spec lists `http`. `ollama` and `web-fetch` stay unsupported on Workers. Internally, the request, redirect and response logic of `http_request` moved to the Node-free `src/tools/built-in/httpCore.ts`, shared by both tools; the Node tool's behaviour is unchanged. See docs/cloudflare-workers.md#providers-and-tools.
+- `fromAiSdk(model)`: any AI SDK `LanguageModel` (Google, Bedrock, Azure, Mistral, Gateway,...) as `createAgent({ provider })`. Options `name` (default: the model's `provider` field), `fileMediaTypes`, `replaysReasoning` and `maxRetries` (default 0); exported with `FromAiSdkOptions` from the package root. Throws `LOUSHO_CONFIG_INVALID` for a model id string, for a model built for another `ai` major than the installed one, and for a call with another model id than the wrapped one. See docs/providers.md#any-ai-sdk-model-fromaisdk.
+- Two ready-made durable stores. `@lousho/build-ai-agent/kv` is a new subpath exporting `KVStore`, `KVCheckpointStore`, `CHECKPOINT_KV_BINDING` and the types `KVStoreOptions`, `KVBinding`, `KVPutOptions`, so a hand-written Cloudflare Worker can use `createAgent({ provider, store: new KVStore(env.AGENT_KV) })`; nothing in its import graph touches `node:*`, so it bundles without shims (`assertSessionId` moved to the Node-free `src/session/sessionId.ts` and is still exported where it was). `fileStore(dir, { historyLimit? })` (root export) is an `AgentStore` of plain JSON files: `sessions/.json`, `checkpoints/.json`, `checkpoint-history/.json` and `approvals/.json`, each written to a temp file and renamed into place, with no lock files and no `StorageService`; resolving an approval claims it with an exclusive create, so of two processes resolving one approval only one gets it. It replaces combining `FileSessionStore`, `LocalStorageCheckpointStore` and `StorageServiceApprovalStore` by hand. See docs/sessions.md#choosing-a-store and docs/deployment.md.
+- `web_fetch` built-in tool: `webFetchTool` / `createWebFetchTool(options)`, and `web-fetch` in spec files. It `GET`s one public web page and returns `{ url, finalUrl, status, contentType, content, truncated }`: HTML converted to text by a small built-in converter (scripts and styles dropped, link URLs kept, entities decoded), JSON and `text/*` as sent, a note for other types; a 4xx/5xx is returned with its body. Loopback, private and link-local destinations are refused on every hop with the connection pinned to the checked address; redirects (10), bytes read (2 MiB), characters returned (50 000) and time (30 s) are capped; `allowedHosts` / `blockedHosts` are checked before DNS and `allowPrivate` exempts named hosts. Node only. See docs/tools.md#built-in-tools.
- `src/security/privateAddress.ts` (internal): `isPrivateAddress()` and `pinnedLookup()`, shared by the credential broker, `http_request` and `web_fetch`. The credential broker uses it instead of its own list, so it also refuses `192.0.0.0/24`, `198.18.0.0/15`, NAT64 and 6to4 addresses.
-- Slack and Discord: a pending `ask_question` survives a restart (M10a). Given durable stores for sessions, checkpoints and approvals, the next message in the Slack thread (or the next `/ask` in the Discord channel) after a restart is still the answer: the turn continues, the reply is posted, and the question, the answer and the reply are appended to the session transcript (before, the answer became a new turn and the question was orphaned). A message in a conversation that waits on a tool approval still does not decide it. New `ChannelContext.pendingQuestion(sessionKey)` for custom channels: the id of the `ask_question` that key's session waits on, also one asked before a restart (it binds the approval to the session, so the continuation is recorded); handed out once until that answer has run. A channel's answer and click continuations now run one at a time with the session's turns. Supporting additions: `Checkpoint.approvalKind`, `PendingTurn.approvalKind` (`session.pending()`) and `SessionAwaitingApprovalError.approvalKind` are `'question'` for a turn paused on an `ask_question`. Slack thread replies that answer a pending question no longer need a saved transcript first (a first turn paused on a question with checkpoints has none yet). See docs/channels.md.
-- Local traces and `lousho traces` (M5a): `createAgent({ exporter, captureContent })` traces every run of the agent (`send()`, `stream()`, session turns, `agent.resume()` and runs continued by `agent.approvals.resolve()`; sub-agents join the lead's trace). The new subpath `@lousho/build-ai-agent/traces` exports `fileTraceExporter({ dir })`, which writes each run as JSON Lines to `//.jsonl` (default `.lousho/traces`; one finished span per line; a write error warns once and never fails the run), and `listTraces()` / `readTrace()` to read them. `npx lousho traces [--dir D] [--limit N] [--json]` lists recent runs with duration, model and tool calls, tokens and cost; `npx lousho traces [--json] [--content]` prints one run as a span tree with duration bars. Projects made by `lousho init` now ignore `.lousho/`. See docs/observability.md#local-traces.
-- `openApiTools(document, options)` (N8): an OpenAPI 3.0 / 3.1 document (object, JSON or YAML string, or https URL) becomes one typed tool per operation, exported from the root and `./tools`. Names come from `operationId` (or `_`); the input has path, query, header and cookie parameters plus an `application/json` `body`, with `$ref`s resolved; every HTTP response is returned as `{ status, statusText, body }`. GET, HEAD and OPTIONS run and other methods ask for approval by default (`approval`), with read-only / destructive annotations. Options: `baseUrl`, `include`, `exclude`, `prefix`, `headers`, `bearerToken`, `providedArguments` (removed from the model's schema), `timeoutMs`, `maxResponseChars`, `fetch`. The base URL must be https (http only for loopback), credentials never reach events or the transcript, and redirects are followed only on the base origin. Swagger 2.0, multipart bodies and remote `$ref`s are refused. `jsonSchemaToZod(schema, root?)` takes an optional root for `$ref` resolution. New page docs/openapi-tools.md.
+- Slack and Discord: a pending `ask_question` survives a restart. Given durable stores for sessions, checkpoints and approvals, the next message in the Slack thread (or the next `/ask` in the Discord channel) after a restart is still the answer: the turn continues, the reply is posted, and the question, the answer and the reply are appended to the session transcript (before, the answer became a new turn and the question was orphaned). A message in a conversation that waits on a tool approval still does not decide it. New `ChannelContext.pendingQuestion(sessionKey)` for custom channels: the id of the `ask_question` that key's session waits on, also one asked before a restart (it binds the approval to the session, so the continuation is recorded); handed out once until that answer has run. A channel's answer and click continuations now run one at a time with the session's turns. Supporting additions: `Checkpoint.approvalKind`, `PendingTurn.approvalKind` (`session.pending()`) and `SessionAwaitingApprovalError.approvalKind` are `'question'` for a turn paused on an `ask_question`. Slack thread replies that answer a pending question no longer need a saved transcript first (a first turn paused on a question with checkpoints has none yet). See docs/channels.md.
+- Local traces and `lousho traces`: `createAgent({ exporter, captureContent })` traces every run of the agent (`send()`, `stream()`, session turns, `agent.resume()` and runs continued by `agent.approvals.resolve()`; sub-agents join the lead's trace). The new subpath `@lousho/build-ai-agent/traces` exports `fileTraceExporter({ dir })`, which writes each run as JSON Lines to `//.jsonl` (default `.lousho/traces`; one finished span per line; a write error warns once and never fails the run), and `listTraces()` / `readTrace()` to read them. `npx lousho traces [--dir D] [--limit N] [--json]` lists recent runs with duration, model and tool calls, tokens and cost; `npx lousho traces [--json] [--content]` prints one run as a span tree with duration bars. Projects made by `lousho init` now ignore `.lousho/`. See docs/observability.md#local-traces.
+- `openApiTools(document, options)`: an OpenAPI 3.0 / 3.1 document (object, JSON or YAML string, or https URL) becomes one typed tool per operation, exported from the root and `./tools`. Names come from `operationId` (or `_`); the input has path, query, header and cookie parameters plus an `application/json` `body`, with `$ref`s resolved; every HTTP response is returned as `{ status, statusText, body }`. GET, HEAD and OPTIONS run and other methods ask for approval by default (`approval`), with read-only / destructive annotations. Options: `baseUrl`, `include`, `exclude`, `prefix`, `headers`, `bearerToken`, `providedArguments` (removed from the model's schema), `timeoutMs`, `maxResponseChars`, `fetch`. The base URL must be https (http only for loopback), credentials never reach events or the transcript, and redirects are followed only on the base origin. Swagger 2.0, multipart bodies and remote `$ref`s are refused. `jsonSchemaToZod(schema, root?)` takes an optional root for `$ref` resolution. New page docs/openapi-tools.md.
- Built-in OpenAI, Anthropic and OpenRouter providers send PDF file parts on `ai` 6 and 7 (Anthropic also `text/plain`); `ai` 4 and Ollama keep the text note. A provider subclass opts more media types in with `fileMediaTypes()` (`acceptsFileParts = true` still sends every type). The text-note warning is now once per provider and media type and names the type and the `ai` major. Added `npm run test:live` (`vitest.live.config.ts`) for `*.live.test.ts` files, which the default suite excludes. See docs/providers.md#multimodal-input.
-- Principals in tools, approval policies and permissions (N10b, #250): the run's principal (N10a) reaches a tool's `execute(args, ctx)` as `ctx.principal`, `needsApproval` functions (`ApprovalCheckContext.principal`), permission rules' `when(args, ctx)` (`PermissionContext.principal`), `preToolCall` / `postToolCall` hooks (`ToolCallHookContext.principal`), `onPermissionDecision`'s new second argument (`PermissionAuditContext`), pending approvals (`PendingApproval.principal`, seen by `approve` and by a channel's `approvers` function as `request.principal`) and in-process sub-agents (`task`, background tasks, `createDelegateTool()`; `remoteAgent()` does not forward it). New `ExecuteOptions.principal`, set by `createAgent()` for `send()`, `stream()`, session turns and channel turns. It is saved in checkpoints (`Checkpoint.principal`) and approval snapshots (`ExecutionSnapshot.principal`), so a static or dynamic run resumed after an approval or a crash acts for the same caller. `agent.approvals.resolve()` / `answer()` / `streamResolve()` / `streamAnswer()` take `{ principal }` (new `ResolveApprovalOptions`) for who decides, and the approved tool sees it as `ctx.approval.by`; `resumeAfterApproval()` takes it as `executeOptions.approver`. The approvals route (`POST /chat/:sessionId/approvals/:id`) passes the caller route auth accepted, and `mountChannels()` the clicking user (`{ id, type: 'user', authenticator: }`) or, for a question answered by a message, its sender. `agent.fork()` keeps the source run's principal; `session.fork()` copies only the conversation. The principal is not added to events, spans or trace files. See [Principals in tools and approvals](docs/auth.md#principals-in-tools-and-approvals).
-- `lousho add` has a default registry (M7b, #230): without `--registry` or `LOUSHO_REGISTRY` it reads `https://registry.lousho.com/index.json` - the `registry/dist/` folder of this repository served as static JSON - instead of failing with "no registry configured; there is no hosted registry yet", so `lousho add --list` works out of the box. It carries five starter items: the tools `open-meteo-weather` (current weather by city through Open-Meteo, no API key) and `github-issues` (list and create issues through `api.github.com`; creating asks for approval), the skills `changelog` and `code-review`, and the `generic-webhook` channel (HMAC-SHA256 signature verification from `WEBHOOK_SECRET`). `--registry` and `LOUSHO_REGISTRY` still override it, and the new value `none` (in either place) disables the registry entirely with the old `LOUSHO_CONFIG_INVALID` "no registry configured" error, for offline or locked-down use. The items live in `registry/` as an `item.json` plus their files per folder; `npm run registry:build` validates each against the registry schemas, the path rules and the M7a code/manifest check, and writes the committed `registry/dist/` (`npm run registry:check` fails when it is stale and runs in CI). See docs/registry.md.
+- Principals in tools, approval policies and permissions: the run's principal reaches a tool's `execute(args, ctx)` as `ctx.principal`, `needsApproval` functions (`ApprovalCheckContext.principal`), permission rules' `when(args, ctx)` (`PermissionContext.principal`), `preToolCall` / `postToolCall` hooks (`ToolCallHookContext.principal`), `onPermissionDecision`'s new second argument (`PermissionAuditContext`), pending approvals (`PendingApproval.principal`, seen by `approve` and by a channel's `approvers` function as `request.principal`) and in-process sub-agents (`task`, background tasks, `createDelegateTool()`; `remoteAgent()` does not forward it). New `ExecuteOptions.principal`, set by `createAgent()` for `send()`, `stream()`, session turns and channel turns. It is saved in checkpoints (`Checkpoint.principal`) and approval snapshots (`ExecutionSnapshot.principal`), so a static or dynamic run resumed after an approval or a crash acts for the same caller. `agent.approvals.resolve()` / `answer()` / `streamResolve()` / `streamAnswer()` take `{ principal }` (new `ResolveApprovalOptions`) for who decides, and the approved tool sees it as `ctx.approval.by`; `resumeAfterApproval()` takes it as `executeOptions.approver`. The approvals route (`POST /chat/:sessionId/approvals/:id`) passes the caller route auth accepted, and `mountChannels()` the clicking user (`{ id, type: 'user', authenticator: }`) or, for a question answered by a message, its sender. `agent.fork()` keeps the source run's principal; `session.fork()` copies only the conversation. The principal is not added to events, spans or trace files. See [Principals in tools and approvals](docs/auth.md#principals-in-tools-and-approvals).
### Deprecated
- `SlackTriggerAdapter`, `CronTriggerAdapter` and `WebhookTriggerAdapter` (`@lousho/build-ai-agent/triggers`) are deprecated and will be removed in a future major version. Use `slackChannel()` for Slack (sessions per thread, approval buttons), `defineSchedule()` with `startSchedules()` for cron (or `schedules/` in an agent directory, or cron triggers in a spec file), and `webhookChannel()` for webhooks, each mounted with `mountChannels()`. `verifySlackSignature()`, the `WebhookAuth` helpers, `parseCronExpression()`, `TriggerAdapter` and `TriggerRegistry` are not deprecated. Their options types (`SlackTriggerAdapterOptions`, `CronTriggerAdapterOptions` with `CronIntervalOptions` and `CronExpressionOptions`, `WebhookTriggerAdapterOptions`) and `WebhookTriggerHandle` carry `@deprecated` too. Nothing changes at run time. See docs/triggers.md.
### Fixed
-
-- A channel button click (Slack, Discord), inline-keyboard tap (Telegram), Adaptive Card press (Teams) or `/approve` command (GitHub) after a restart is now bound to the conversation's session before the approval is resolved, so its continuation is appended to the session transcript again (#279). The decision names the conversation it came from (`inbound`); with checkpointed sessions (`mountChannels` `store` with `checkpoints`, e.g. a `SqliteStore`) it is accepted only when that session's pending turn waits on exactly that approval id and kind - a decision for another conversation, a replay of a decided one and a decision of the wrong kind are refused (`LOUSHO_APPROVAL_NOT_FOUND`) before the approval is touched, and `onDecision` audits only decisions that pass, so a replayed click cannot run the tool twice and the thread's next message continues the same session. Without a checkpoint store nothing can be checked, so the approval store alone decides, as before.
-- A channel's function `approvers` works after a restart (#280): `ctx.approval(id)` asked only this process's `agent.approvals.list()`, so a pause the restarted process did not make looked the same as a denial and the function failed closed. `ApprovalStore` gains an optional `load(id)` - `resolve()` without removing the record; every shipped store implements it, and a custom store without it keeps compiling and behaves as before - and the new `agent.approvals.get(id)` reads through it. `ctx.approval` falls back to that, so the function sees the same request (`toolName`, `input`, `sessionId`, `principal`) it saw before the restart and fails closed only when no store knows the id (a process-local approval store still forgets its pauses at a restart).
-- A hand-written Cloudflare Worker can now bundle `createAgent` (#289). The package root also exports file stores, the agent-directory loader, MCP over stdio and sandboxes, so bundling it for `platform: 'browser'` failed with `Could not resolve "async_hooks"` (and `crypto`, `fs/promises`, `http`, `net`, `stream`) - only the generated Worker ever bundled, because `lousho build --target=cloudflare-worker` redirects those imports to shims itself. `@lousho/build-ai-agent/worker` is a new subpath exporting `createAgent` (and its config types), `memoryStore`, the KV stores (`KVStore`, `KVCheckpointStore`, `CHECKPOINT_KV_BINDING`), the schedule helpers (`defineSchedule`, `handleScheduled`), `serveFetch` (the `/chat` API as a fetch handler), the providers a Worker can run and the whole Worker-safe subset agent-directory code may import. The SDK's own build produces it through the same Node-builtin/sandbox/QuickJS shims the adapter applies (plus new `ollama-ai-provider` and `@modelcontextprotocol/sdk` ones, which an external specifier would leave unresolvable and `mcpServers` therefore fails on first connect - use `lousho build` for MCP on Workers), so the published entry carries no `node:` import for a user's own bundler to fail on; `ai`, `zod`, `@opentelemetry/api` and the `@ai-sdk/*` provider packages stay external specifiers resolved from the user's install. Guarded by `src/deploy/worker.test.ts`; see docs/cloudflare-workers.md#writing-your-own-worker.
-- The published `.d.ts` files no longer import the types of optional peers (#346): `SubprocessSandboxOptions.dockerOptions` is now the structural `DockerConnectionOptions` (same fields as dockerode's `DockerOptions`, passed through unchanged) and `McpClientLike` is the structural subset of the MCP SDK's `Client` it already was (`listTools` / `callTool`). Before, `dist/index.d.ts` imported `dockerode` and the shared schema chunk imported `@modelcontextprotocol/sdk`, so a consumer compiling with `skipLibCheck: false` without those optional peers installed got TS2307 inside the SDK's own declarations. A dockerode `DockerOptions` or MCP `Client` value still satisfies both types.
-- `lousho` CLI surface (LOU-R19): `lousho dev`/`lousho chat --traces` now writes traces for a `.ts`/`.js` module that exports an already-built `createAgent()` agent - the module's export could not take the flag's exporter after the fact, so nothing was written. `createAgent()` remembers the config it was built with (readable via the internal `createAgentConfigOf()`), and the loader rebuilds the agent with the flag's exporter (the module's own `exporter` is replaced while the flag is set; a module exporting an agent not made by `createAgent()` prints a warning and keeps its own). A spec path that does not exist (e.g. `lousho chat missing.yaml`) now fails with the coded `LOUSHO_SPEC_NOT_FOUND` instead of a raw ENOENT. `lousho add --dry-run` no longer fails when a target file already exists; it prints the file list with an "(exists - a real install needs --overwrite)" marker and exits 0 (the symlink checks still run). Top-level `lousho --help` lists the implemented flags again: `--no-schedules`/`--traces` on `dev` and `chat`, `eval`'s record/replay/drift/`--config` flags, `init`'s `--sdk-path`, and `build`'s positional form.
-- A cassette failure keeps its type through `agent.send()` (LOU-R13): a replay mismatch (or a missing cassette raised by the provider-interception seam that `lousho eval` installs) was compacted into a generic `CompactedLLMProviderError`, losing the cassette path, the call number and the re-record hint. `CassetteMismatchError` is now an `SDKError` with code `LOUSHO_CASSETTE_INVALID` (docs/errors.md already claimed cassette errors carry it), and the run loop rethrows cassette errors untouched instead of compacting them, so `send()` rejects with the `CassetteMismatchError` itself (`.cassette`, `.callNumber`). `setProviderInterceptor()` and the `ProviderInterceptor` type are now exported from `@lousho/build-ai-agent/testing`, the public entry for the model-boundary seam docs/evals.md describes.
-- A tool call approved with `agent.approvals.resolve()` (or `resumeAfterApproval()`) now has its `execute_tool` span (#281). It ran before the continued run started, outside any span, so OpenTelemetry and `fileTraceExporter()` showed the continued run's `invoke_agent` and `chat` spans but not the approved tool. The continued run's `invoke_agent` span now opens first and the approved call runs inside it: its `execute_tool` span is a child of it (a `run_code` call's inner calls and a sub-agent the tool starts are children of the tool span), with the same `captureContent` / `redactContent` rules as any tool span, and no span is made for a rejected call. A pause that happens again (sign-in) leaves the `invoke_agent` span with the tool span and no `chat`.
-- A tool that returns `undefined` (or an async generator that yields nothing) no longer writes a `tool` message with `content: undefined` into the transcript (#341). Its result is now `null` everywhere: the transcript and the next model request carry `"null"`, and `tool.done`, approval resumes, `run_code` and flows see `null`. Providers, session stores and checkpoints get a valid string.
-- `recordReplay` matches a tool's parameter schema the same way under zod 3 and zod 4 (#333). A zod 4 schema was fingerprinted from its JSON Schema (with `$schema`, `required`, `additionalProperties`) while a zod 3 one, and so every recorded cassette, used a fingerprint of the schema itself, so cassettes recorded with zod 3 failed to replay with zod 4 installed (the CI `peers` jobs `ai4-zod4`, `ai6-zod4` and `ai7-zod4` failed on every replay test). Both now give the same fingerprint for the same schema; existing cassettes stay valid and need no re-recording, and a real change to a tool's parameters still fails the match.
+- `lousho` CLI surface: `lousho dev`/`lousho chat --traces` now writes traces for a `.ts`/`.js` module that exports an already-built `createAgent()` agent - the module's export could not take the flag's exporter after the fact, so nothing was written. `createAgent()` remembers the config it was built with (readable via the internal `createAgentConfigOf()`), and the loader rebuilds the agent with the flag's exporter (the module's own `exporter` is replaced while the flag is set; a module exporting an agent not made by `createAgent()` prints a warning and keeps its own). A spec path that does not exist (e.g. `lousho chat missing.yaml`) now fails with the coded `LOUSHO_SPEC_NOT_FOUND` instead of a raw ENOENT. `lousho add --dry-run` no longer fails when a target file already exists; it prints the file list with an "(exists - a real install needs --overwrite)" marker and exits 0 (the symlink checks still run). Top-level `lousho --help` lists the implemented flags again: `--no-schedules`/`--traces` on `dev` and `chat`, `eval`'s record/replay/drift/`--config` flags, `init`'s `--sdk-path`, and `build`'s positional form.
+- A cassette failure keeps its type through `agent.send()`: a replay mismatch (or a missing cassette raised by the provider-interception seam that `lousho eval` installs) was compacted into a generic `CompactedLLMProviderError`, losing the cassette path, the call number and the re-record hint. `CassetteMismatchError` is now an `SDKError` with code `LOUSHO_CASSETTE_INVALID` (docs/errors.md already claimed cassette errors carry it), and the run loop rethrows cassette errors untouched instead of compacting them, so `send()` rejects with the `CassetteMismatchError` itself (`.cassette`, `.callNumber`). `setProviderInterceptor()` and the `ProviderInterceptor` type are now exported from `@lousho/build-ai-agent/testing`, the public entry for the model-boundary seam docs/evals.md describes.
+- A tool call approved with `agent.approvals.resolve()` (or `resumeAfterApproval()`) now has its `execute_tool` span. It ran before the continued run started, outside any span, so OpenTelemetry and `fileTraceExporter()` showed the continued run's `invoke_agent` and `chat` spans but not the approved tool. The continued run's `invoke_agent` span now opens first and the approved call runs inside it: its `execute_tool` span is a child of it (a `run_code` call's inner calls and a sub-agent the tool starts are children of the tool span), with the same `captureContent` / `redactContent` rules as any tool span, and no span is made for a rejected call. A pause that happens again (sign-in) leaves the `invoke_agent` span with the tool span and no `chat`.
+- A tool that returns `undefined` (or an async generator that yields nothing) no longer writes a `tool` message with `content: undefined` into the transcript. Its result is now `null` everywhere: the transcript and the next model request carry `"null"`, and `tool.done`, approval resumes, `run_code` and flows see `null`. Providers, session stores and checkpoints get a valid string.
+- `recordReplay` matches a tool's parameter schema the same way under zod 3 and zod 4. A zod 4 schema was fingerprinted from its JSON Schema (with `$schema`, `required`, `additionalProperties`) while a zod 3 one, and so every recorded cassette, used a fingerprint of the schema itself, so cassettes recorded with zod 3 failed to replay with zod 4 installed (the CI `peers` jobs `ai4-zod4`, `ai6-zod4` and `ai7-zod4` failed on every replay test). Both now give the same fingerprint for the same schema; existing cassettes stay valid and need no re-recording, and a real change to a tool's parameters still fails the match.
- Parallel `task` calls now get their `taskId`s, and background tasks start, in call order: allocating an id awaited a SHA-256 digest whose completion order is not fixed, so with `maxConcurrent: 1` the second task could take the slot first (#296, the flaky "queues tasks beyond maxConcurrent" test).
-- `SubprocessSandbox.run()` no longer fails with HTTP 404 "no such container" for a command that exits at once (#326): the container is `AutoRemove`d, and `wait()` was only called after `start()`, so a fast command could be gone by then (10 of 40 parallel `true` runs failed on Docker Desktop). It now begins `wait({ condition: 'next-exit' })` before `start()`, and removes the container if `start()` itself fails.
+- `SubprocessSandbox.run()` no longer fails with HTTP 404 "no such container" for a command that exits at once: the container is `AutoRemove`d, and `wait()` was only called after `start()`, so a fast command could be gone by then (10 of 40 parallel `true` runs failed on Docker Desktop). It now begins `wait({ condition: 'next-exit' })` before `start()`, and removes the container if `start()` itself fails.
- The `cloudflare-worker` build's Node-builtin leak check now accepts the runtime-guarded `loadNodeModule("node:module" | "node:dns")` probes that `@ai-sdk/provider-utils` 4 (installed with `ai` 6) uses, as it already did for `ai` 7's `loadBuiltinModule`; without it a Worker bundle built on `ai` 6 failed with "Node builtins leaked" (found by the new `ai6` matrix entries).
- `isAgentEvent()` is now exhaustive (the runtime type list is a `Record`, so the compiler rejects a missing type). It had no `agent.drift`, so remote UI clients (`parseEventStream()`, the React, Vue and Svelte bindings) silently dropped that event.
-- A remote sub-agent's token usage is added to the lead's `result.usage` (M10b). It was dropped, so budgets and cost reports undercounted delegation to a `remoteAgent()`. The lead adds what the remote agent reports on its `run.done` event to its totals, to `usage.delegated` and to `byModel['remote:']`, which keeps the remote's own `costUsd` (the lead's `costUsd` is `undefined` when the remote sends none). A remote run that pauses for an approval adds what it spent up to the pause, and the continuation adds only what it spent after it; background remote tasks roll up the same way. A deployment that sends no usage adds nothing. `RemoteRunOptions` has a new optional `onUsage(usage)` callback; `RemoteSubagent.run()` still returns `Promise`, so hand-written ones compile unchanged. See docs/sub-agents.md#remote-sub-agents.
+- A remote sub-agent's token usage is added to the lead's `result.usage`. It was dropped, so budgets and cost reports undercounted delegation to a `remoteAgent()`. The lead adds what the remote agent reports on its `run.done` event to its totals, to `usage.delegated` and to `byModel['remote:']`, which keeps the remote's own `costUsd` (the lead's `costUsd` is `undefined` when the remote sends none). A remote run that pauses for an approval adds what it spent up to the pause, and the continuation adds only what it spent after it; background remote tasks roll up the same way. A deployment that sends no usage adds nothing. `RemoteRunOptions` has a new optional `onUsage(usage)` callback; `RemoteSubagent.run()` still returns `Promise`, so hand-written ones compile unchanged. See docs/sub-agents.md#remote-sub-agents.
- `lousho studio` finds the Agent Forge build that ships inside the installed package when it is run from a project that does not have `apps/agent-forge` (it used to fail with "could not find apps/agent-forge" everywhere except the SDK repo). A project's own `apps/agent-forge` still wins, and `.lousho/` is still created in the directory you ran it from. `--dev` from an installed package still needs the TypeScript source and says so.
- Documentation corrections: install commands, CLI command lists, optional peers, durable execution and compaction descriptions, the Status section, reasoning and file-part notes in the providers guide, and the `KVStore` note in the deployment guide now match the code. Ticket ids are gone from user-facing prose.
### Tests
-- Two more load-sensitive tests fixed (#330). `SandboxShell.test.ts` "passes only set values and allowed host names, never planted secrets" starts two real `node -e` processes and timed out at the 5 s default in 1 of 20 parallel runs; it has a 30 s timeout now (pure slowness, no race: it passes in under a second alone). `backgroundSubagents.test.ts` "reports a sub-agent paused for approval as awaiting-approval" waited a fixed 10 ms for the child's pause and failed in 9 of 20 parallel runs with the task still `running`; the lead now asks `agent_status` again until the task has left `running`.
+- Two more load-sensitive tests fixed. `SandboxShell.test.ts` "passes only set values and allowed host names, never planted secrets" starts two real `node -e` processes and timed out at the 5 s default in 1 of 20 parallel runs; it has a 30 s timeout now (pure slowness, no race: it passes in under a second alone). `backgroundSubagents.test.ts` "reports a sub-agent paused for approval as awaiting-approval" waited a fixed 10 ms for the child's pause and failed in 9 of 20 parallel runs with the task still `running`; the lead now asks `agent_status` again until the task has left `running`.
- `http.test.ts` "rejects near the configured timeout" no longer compares real elapsed time to a real timeout (flaky under load, #324): it drives the request timer with a fake clock and checks the request is still pending at 499 ms, rejects with "timed out after 500ms" at 500 ms, and that the connection is closed.
-- `src/tools/workspace/checkpoints.node.test.ts` "rewinds three turns" has a 30 s timeout (#326): it takes well under a second alone, but timed out at the 5 s default when eight copies of the suite ran at once. The rewind itself is correct; every failure was the timeout.
-- Live-model cassettes recorded (#260): real gpt-4o-mini runs through OpenRouter are now replayed in CI with no key, for the coding agent in the docs, the Telegram, GitHub and Teams channels, plan mode, `web_fetch`, streaming `send()`, the guardrail starter set, `fromAiSdk()` and PDF/PNG file parts, plus a recorded `lousho traces` fixture. The live files that never ran (`sendStreams`, `guardrailStarterSet`, `fileTraceExporter`, `remoteUsage`) were missing the `import '../providers'` that registers OpenRouter; fixed. New live tests (skipped without a key): ai 6 through OpenRouter (M8) and `aiSdkEmbedder()` over OpenRouter embeddings (N15).
-- `pack-smoke` allows a 16 MiB unpacked tarball (was 14 MiB): `main` was 45 KB under the old cap and permission modes (N4) went over it; see #316 for shrinking the package instead.
-- Sandbox egress and the credential broker are tested against a real Docker Engine (M6): a new CI workflow, "Docker Engine" (`.github/workflows/docker.yml`, on pull requests touching the sandbox, broker or docker deploy adapter, on demand and weekly), runs `npm run test:docker` on GitHub's `ubuntu-latest` runner (rootful Engine 28.0.4). `src/security/sandboxEgress.docker.test.ts` checks nine cases of `SubprocessSandbox({ network: { allow }, broker })` with `curlimages/curl`: an allowed HTTPS host answers through the broker, another host gets a 403, bypassing the proxy and raw IPs have no route, outside names do not resolve, the broker injects a credential the container never sees, a container outside the internal network cannot use the gateway listener, an aborted run and `close()` leave no container or network, and a reused non-internal network is refused. The docker deploy image test (`/health` and `/chat`) moved to `src/deploy/adapters/docker.docker.test.ts` and runs in the same job; it was skipped in CI before and passes there. No product defect was found. `*.docker.test.ts` files are excluded from `npm test` and `npm run test:coverage`; they skip without a daemon unless `LOUSHO_DOCKER_TESTS=1` is set (the workflow sets it), which makes a missing daemon fail. docs/workspace-tools.md says what the job checks; CONTRIBUTING.md mentions `npm run test:docker`.
-- `src/execution/principal.test.ts` and `principal.test-d.ts` (N10b): the principal in tools (send, stream, session and channel turns), policies, rules, hooks and sub-agents (local, background, remote), pause and resume with another approver, a restart, a crash resume on an on-disk `SqliteStore`, old checkpoints and snapshots, forks, the approvals route and channel clicks; frozen in strict mode; absent from events and spans.
-- Test files can be type-checked (A6a, #243): `npm run typecheck:tests` runs `tsc` over `src/` including every `*.test.ts` through the new `tsconfig.tests.json`, and the test files under `src/execution`, `src/context`, `src/session`, `src/subagents`, `src/acp` and `src/testing` now pass it. The check is not in CI yet (A6b, #244). One type change: `textOf()` also accepts a message or content whose parts are `readonly`, such as a `mockModel` request snapshot.
+- `src/tools/workspace/checkpoints.node.test.ts` "rewinds three turns" has a 30 s timeout: it takes well under a second alone, but timed out at the 5 s default when eight copies of the suite ran at once. The rewind itself is correct; every failure was the timeout.
+- Live-model cassettes recorded: real gpt-4o-mini runs through OpenRouter are now replayed in CI with no key, for the coding agent in the docs, the Telegram, GitHub and Teams channels, plan mode, `web_fetch`, streaming `send()`, the guardrail starter set, `fromAiSdk()` and PDF/PNG file parts, plus a recorded `lousho traces` fixture. The live files that never ran (`sendStreams`, `guardrailStarterSet`, `fileTraceExporter`, `remoteUsage`) were missing the `import '../providers'` that registers OpenRouter; fixed. New live tests (skipped without a key): ai 6 through OpenRouter and `aiSdkEmbedder()` over OpenRouter embeddings.
+- `pack-smoke` allows a 16 MiB unpacked tarball (was 14 MiB): `main` was 45 KB under the old cap and permission modes went over it; see #316 for shrinking the package instead.
+- Sandbox egress and the credential broker are tested against a real Docker Engine: a new CI workflow, "Docker Engine" (`.github/workflows/docker.yml`, on pull requests touching the sandbox, broker or docker deploy adapter, on demand and weekly), runs `npm run test:docker` on GitHub's `ubuntu-latest` runner (rootful Engine 28.0.4). `src/security/sandboxEgress.docker.test.ts` checks nine cases of `SubprocessSandbox({ network: { allow }, broker })` with `curlimages/curl`: an allowed HTTPS host answers through the broker, another host gets a 403, bypassing the proxy and raw IPs have no route, outside names do not resolve, the broker injects a credential the container never sees, a container outside the internal network cannot use the gateway listener, an aborted run and `close()` leave no container or network, and a reused non-internal network is refused. The docker deploy image test (`/health` and `/chat`) moved to `src/deploy/adapters/docker.docker.test.ts` and runs in the same job; it was skipped in CI before and passes there. No product defect was found. `*.docker.test.ts` files are excluded from `npm test` and `npm run test:coverage`; they skip without a daemon unless `LOUSHO_DOCKER_TESTS=1` is set (the workflow sets it), which makes a missing daemon fail. docs/workspace-tools.md says what the job checks; CONTRIBUTING.md mentions `npm run test:docker`.
+- `src/execution/principal.test.ts` and `principal.test-d.ts`: the principal in tools (send, stream, session and channel turns), policies, rules, hooks and sub-agents (local, background, remote), pause and resume with another approver, a restart, a crash resume on an on-disk `SqliteStore`, old checkpoints and snapshots, forks, the approvals route and channel clicks; frozen in strict mode; absent from events and spans.
+- Test files can be type-checked: `npm run typecheck:tests` runs `tsc` over `src/` including every `*.test.ts` through the new `tsconfig.tests.json`, and the test files under `src/execution`, `src/context`, `src/session`, `src/subagents`, `src/acp` and `src/testing` now pass it. The check is not in CI yet. One type change: `textOf()` also accepts a message or content whose parts are `readonly`, such as a `mockModel` request snapshot.
### Docs
-- The `vendor/` model prefix note (LOU-R22): every docs page whose snippets hardcode a `provider/model` string now says the prefix chooses the provider and that with OpenRouter the id is `openrouter//` (e.g. `openrouter/openai/gpt-4o-mini`), so `createAgent()` does not throw `LOUSHO_PROVIDER_MISSING_API_KEY` when only `OPENROUTER_API_KEY` is set. Stale model ids refreshed: `anthropic/claude-3-5-haiku-latest` examples are now `anthropic/claude-haiku-4-5`, `'anthropic/claude-3-5-sonnet-latest'` in errors.md is `'anthropic/claude-sonnet-4-5'`, `google('gemini-2.0-flash')` is `google('gemini-2.5-flash')` and the OpenRouter fallback example uses `openrouter/meta-llama/llama-3.3-70b-instruct`. (`anthropic/claude-3-5-sonnet-latest` stays where it documents the `ANTHROPIC_API_KEY` env default, which is still that id in `src/providers/providerSpec.ts`.)
+- The `vendor/` model prefix note: every docs page whose snippets hardcode a `provider/model` string now says the prefix chooses the provider and that with OpenRouter the id is `openrouter//` (e.g. `openrouter/openai/gpt-4o-mini`), so `createAgent()` does not throw `LOUSHO_PROVIDER_MISSING_API_KEY` when only `OPENROUTER_API_KEY` is set. Stale model ids refreshed: `anthropic/claude-3-5-haiku-latest` examples are now `anthropic/claude-haiku-4-5`, `'anthropic/claude-3-5-sonnet-latest'` in errors.md is `'anthropic/claude-sonnet-4-5'`, `google('gemini-2.0-flash')` is `google('gemini-2.5-flash')` and the OpenRouter fallback example uses `openrouter/meta-llama/llama-3.3-70b-instruct`. (`anthropic/claude-3-5-sonnet-latest` stays where it documents the `ANTHROPIC_API_KEY` env default, which is still that id in `src/providers/providerSpec.ts`.)
- Corrections in [Errors](docs/errors.md), [Troubleshooting](docs/troubleshooting.md) and [Testing](docs/testing.md): not every intentional error is an `SDKError` (the named `Error` subclasses — `PropagatingToolError`, `CassetteMismatchError`, `CronExpressionError`, `DecryptionError` — carry no `code`); the `Find a code by area` table now lists the `Auth` section and every code that has one; `LOUSHO_CASSETTE_INVALID` no longer claims to cover request mismatches (that is `CassetteMismatchError`), and its example uses `webhookChannel()` rather than a `webhookTrigger()` that does not exist; `createAgent()`'s default retry is documented as applying only to model strings and env-resolved models, not a `provider` instance; the file-parts entry now matches providers.md (PDFs and `text/plain` do reach providers on `ai` 6/7); `connectMcp().status()` lists `'needs-auth'`; the `mockModel` `usage` default is "none reported" (the run estimates it) rather than zero; and the request-assertion snippet notes where `temperature` comes from, since `createAgent()` has no `temperature` option.
- [Stream events](docs/stream-events.md) gains the missing `agent.drift` row (emitted on a resume under `onAgentDrift: 'warn'`, before the continuation's first step) and the fields the table understated: `executedBy: 'provider'` on `tool.start` / `tool.done` / `tool.error` (hosted tools), `replacedByHook` on `tool.done`, `hook` on `permission.decision`, `trigger: 'manual'` on `compaction.start` / `compaction.done`, and the full `usage` shape (`inputTokens` / `outputTokens`, `estimated`, `costUsd`, and `modelCalls` / `hostedToolCalls` on `run.done`). The ordering guarantees now say that a streamed approval continuation reports `agent.drift` and the decided call's `tool.start` / `tool.done` before its first `step.start`, so those events fall outside a step. [Runs](docs/runs.md) uses the canonical event names `tool.start` / `tool.done` / `run.done` instead of the deprecated `tool-call` / `tool-result` / `finish`, and [Testing](docs/testing.md) no longer claims a `mockModel` turn without `usage` reports zeros (it reports none).
-- Long-tail corrections across the guides (LOU-R26): `lousho eval --config`, `lousho init`'s `InitUsageError` and `-y`, `.cts` among the dev-server module extensions and streamed approval continuations in docs/cli.md; `lousho build --target=...` (not `lousho deploy`) and the public `createRouteHandler().handler` (not the internal `serveFetch()`) in docs/sub-agents.md; `description` as a `createAgent()` option and `createDeployedServer()` as build-internal in docs/agent-directories.md, docs/channels.md and docs/schedules.md; approval continuations on Workers emit `tool.start` / `tool.done`, `deploy-runtime-worker` is bundler-internal and `handleScheduled` needs a store in docs/cloudflare-workers.md; `schedule-` sessions, `checkpoint-history/` and the late-appearing `approvals/` directory in docs/schedules.md and docs/sessions.md; `saveAttachment`/`readAttachment` signatures in docs/utilities.md; 256-bit OAuth state in docs/oauth.md; the `recall_` description's `` in docs/memory.md; `ctx.sessionId` in docs/tools.md; the `throw` flow node in docs/flows.md; the `triggers` spec field in docs/configuration.md; `principal` in a permission rule's `when` in docs/approvals.md; the `'not-a-file'` rewind skip and `NodeWorkspace`'s required `root` in docs/workspace-tools.md; a built-in channel's `name` setting its route in docs/channels.md; the `completed()` 'max-steps' message in docs/evals.md; `formatUsage`'s `$0.0000` in docs/models-and-cost.md; the completed registry static-check list and the leading letter/digit name rule in docs/registry.md; `exporter`, `captureContent` and `redactContent` as `createAgent()` options in docs/migrating-to-create-agent.md; `connectMcp().tools` as a `Record` that combines with array tools in docs/mcp.md; `HookRegistry.size()` as a method in docs/hooks.md; `*.example.com` matching subdomains only in docs/tools.md, docs/workspace-tools.md and docs/registry.md; `resumeAfterApproval()` named as an export, not an executor method, and the fork `divergedAt` comment corrected in docs/durable-execution.md.
-- New page, [Permission modes](docs/permission-modes.md) (N4): the modes and their table, which tools are read-only, the `editsFiles` marker, the order of evaluation, switching, the audit log, sub-agents and resume. [Approvals](docs/approvals.md) "Permission policies" links to it; [Tools](docs/tools.md), [Stream events](docs/stream-events.md) and [Sub-agents](docs/sub-agents.md) ("What a sub-agent inherits") gained a row each.
+- Long-tail corrections across the guides: `lousho eval --config`, `lousho init`'s `InitUsageError` and `-y`, `.cts` among the dev-server module extensions and streamed approval continuations in docs/cli.md; `lousho build --target=...` (not `lousho deploy`) and the public `createRouteHandler().handler` (not the internal `serveFetch()`) in docs/sub-agents.md; `description` as a `createAgent()` option and `createDeployedServer()` as build-internal in docs/agent-directories.md, docs/channels.md and docs/schedules.md; approval continuations on Workers emit `tool.start` / `tool.done`, `deploy-runtime-worker` is bundler-internal and `handleScheduled` needs a store in docs/cloudflare-workers.md; `schedule-` sessions, `checkpoint-history/` and the late-appearing `approvals/` directory in docs/schedules.md and docs/sessions.md; `saveAttachment`/`readAttachment` signatures in docs/utilities.md; 256-bit OAuth state in docs/oauth.md; the `recall_` description's `` in docs/memory.md; `ctx.sessionId` in docs/tools.md; the `throw` flow node in docs/flows.md; the `triggers` spec field in docs/configuration.md; `principal` in a permission rule's `when` in docs/approvals.md; the `'not-a-file'` rewind skip and `NodeWorkspace`'s required `root` in docs/workspace-tools.md; a built-in channel's `name` setting its route in docs/channels.md; the `completed()` 'max-steps' message in docs/evals.md; `formatUsage`'s `$0.0000` in docs/models-and-cost.md; the completed registry static-check list and the leading letter/digit name rule in docs/registry.md; `exporter`, `captureContent` and `redactContent` as `createAgent()` options in docs/migrating-to-create-agent.md; `connectMcp().tools` as a `Record` that combines with array tools in docs/mcp.md; `HookRegistry.size()` as a method in docs/hooks.md; `*.example.com` matching subdomains only in docs/tools.md, docs/workspace-tools.md and docs/registry.md; `resumeAfterApproval()` named as an export, not an executor method, and the fork `divergedAt` comment corrected in docs/durable-execution.md.
+- New page, [Permission modes](docs/permission-modes.md): the modes and their table, which tools are read-only, the `editsFiles` marker, the order of evaluation, switching, the audit log, sub-agents and resume. [Approvals](docs/approvals.md) "Permission policies" links to it; [Tools](docs/tools.md), [Stream events](docs/stream-events.md) and [Sub-agents](docs/sub-agents.md) ("What a sub-agent inherits") gained a row each.
- New page, [Route auth and principals](docs/auth.md): the ordered list and its three outcomes, each helper, 401 / 403 / `WWW-Authenticate`, which helpers run where (Node, Workers, edge), `createRouteHandler`, the node server and `auth.ts`, reading the principal in the run, and security notes (no session ownership check; `basic()` only over HTTPS). docs/errors.md gains a `## Auth` section at the end (`LOUSHO_AUTH_CONFIG_INVALID`). One-paragraph links from deployment.md (`### Auth`), nextjs.md (`## Routes`), memory.md (`## Scopes`), agent-directories.md (layout and deploy), api-overview.md and a "Route auth" row in the cloudflare-workers.md limits table; no heading of an existing page changed.
- New page, [Cloudflare Workers](docs/cloudflare-workers.md): a table of what the Worker target supports and what it does not comes first, then build and deploy, bindings, sessions, checkpoints and approvals in KV, scheduled runs, consistency, and bundle size and Node builtins. The Worker sections moved out of docs/deployment.md unchanged (`Bindings, sessions and the API on Workers`, `Cron triggers and handleScheduled` and `Durable execution (pause/resume) on Workers` are gone from it); deployment.md keeps a short `cloudflare-worker` summary that links to the page. Links to `deployment.md#cron-triggers-and-handlescheduled` moved to `cloudflare-workers.md#scheduled-runs`.
- Two dead heading links fixed: docs/executor-api.md now links to `tools.md#advanced-toolregistry`, and docs/troubleshooting.md links to `mcp.md#use-mcp-servers-in-an-agent` (the configuration anchor moved with the MCP section). No other link in `docs/` or the README points to a missing heading. docs/approvals.md lists `fileStore(dir)` among the approval stores.
@@ -495,7 +273,7 @@ the checked-in `api/` reports).
- The Quick Start is rewritten around `createAgent()`: hello world, a tool, streaming, a session, an approval, an offline test with `mockModel`, a custom provider and spec files, each runnable with no API key (the first excepted). The `AgentBuilder` + `AgentExecutor` section moved to a new page, docs/executor-api.md (with the `ToolRegistry` note); the Quick Start no longer shows either API.
- `create-lousho-agent` has a README (its npm page was empty) and `license`, `homepage` and `repository` fields; they appear on npm with its next release. `lousho init --help` no longer says `--sdk-path` is needed until the package is on npm.
- docs/auth.md gains a section at its end, `## Principals in tools and approvals` (with `### In a tool`, `### Approval policies and permission rules`, `### Who approved`, `### Pauses, crashes and forks`, `### Sub-agents`, `### What is stored and logged`); its "Reading the principal in the run" and "Security notes" text is updated. One-sentence links, no new headings: docs/approvals.md (`needsApproval` context, `approve`), docs/tools.md (`ctx.principal` and `ctx.approval` rows), docs/permission-modes.md (the audit log) and docs/channels.md (`approvers`).
-- UI and server examples corrected (LOU-R25): docs/ai-sdk-ui.md's client snippet used `useChat({ api: '/api/chat' })`, the `ai` 4 signature, which is invalid for the documented `ai` 6/7 combos — it now uses `useChat({ transport: new DefaultChatTransport({ api }) })` and a note says which `ai` majors are supported (`ai` 4 keeps `useChat({ api })` and returns `input` / `handleInputChange` / `handleSubmit`; `ai` 5 is not in the peer range). docs/nextjs.md's `useChat` snippet has the same version note. The route handlers of ai-sdk-ui.md and nextjs.md opened `agent.session({ id })` per request with no `store`, so a second turn forgot the first: both now pass `store: memoryStore()` and say to use a durable store on serverless; docs/react.md's server section says the same for a per-request session. The `data-lousho-approval` part no longer reads as if `kind` / `question` only mark an `ask_question`: a tool waiting on an OAuth sign-in carries `kind: 'sign-in'` and `signIn: { provider, displayName?, url }`; docs/react.md's `pendingApproval` row says the same.
+- UI and server examples corrected: docs/ai-sdk-ui.md's client snippet used `useChat({ api: '/api/chat' })`, the `ai` 4 signature, which is invalid for the documented `ai` 6/7 combos — it now uses `useChat({ transport: new DefaultChatTransport({ api }) })` and a note says which `ai` majors are supported (`ai` 4 keeps `useChat({ api })` and returns `input` / `handleInputChange` / `handleSubmit`; `ai` 5 is not in the peer range). docs/nextjs.md's `useChat` snippet has the same version note. The route handlers of ai-sdk-ui.md and nextjs.md opened `agent.session({ id })` per request with no `store`, so a second turn forgot the first: both now pass `store: memoryStore()` and say to use a durable store on serverless; docs/react.md's server section says the same for a per-request session. The `data-lousho-approval` part no longer reads as if `kind` / `question` only mark an `ask_question`: a tool waiting on an OAuth sign-in carries `kind: 'sign-in'` and `signIn: { provider, displayName?, url }`; docs/react.md's `pendingApproval` row says the same.
## [1.0.0-alpha.8] - 2026-10-02
diff --git a/api/index.api.md b/api/index.api.md
index b40854f86..04f1c7e76 100644
--- a/api/index.api.md
+++ b/api/index.api.md
@@ -1921,7 +1921,7 @@ export const ERROR_CODES: {
readonly LOUSHO_REQUEST_FORBIDDEN: "Call from the server's own origin (or add the page's origin to allowedOrigins) and use a Host the server allows (allowedHosts).";
readonly LOUSHO_UNSUPPORTED_MEDIA_TYPE: "Send the request body as JSON with the header 'Content-Type: application/json'.";
readonly LOUSHO_SESSION_AWAITING_APPROVAL: "Resolve the pending approval first (agent.approvals.resolve() or resumeAfterApproval()), then send again.";
- readonly LOUSHO_SESSION_ID_INVALID: "Session ids: 1-128 characters from A-Z, a-z, 0-9, '_' and '-'. Run ids (send({ sessionId })): up to 200 and '.' too, not ending in '.turn-' or '.paused'.";
+ readonly LOUSHO_SESSION_ID_INVALID: "Session ids (agent.session({ id }), POST /chat): 1-128 characters from A-Z, a-z, 0-9, '_' and '-'. Run ids (send({ sessionId })): up to 200 and '.' too, not ending in '.turn-', '.paused' or '.fire'.";
readonly LOUSHO_SESSION_BUSY: "Wait for the running turn to finish (await its send(), or abort it), then call again.";
readonly LOUSHO_SESSION_TURN_PENDING: "Finish the interrupted turn with session.resume(), or drop it with session.discardPending(), then call again.";
readonly LOUSHO_SESSION_TURN_FAILED: "Drop the failed turn with session.discardPending() and send again, or fix the cause and retry it with session.resume().";
diff --git a/api/mcp.api.md b/api/mcp.api.md
index 509b00bbc..8e7076148 100644
--- a/api/mcp.api.md
+++ b/api/mcp.api.md
@@ -581,7 +581,7 @@ const ERROR_CODES: {
readonly LOUSHO_REQUEST_FORBIDDEN: "Call from the server's own origin (or add the page's origin to allowedOrigins) and use a Host the server allows (allowedHosts).";
readonly LOUSHO_UNSUPPORTED_MEDIA_TYPE: "Send the request body as JSON with the header 'Content-Type: application/json'.";
readonly LOUSHO_SESSION_AWAITING_APPROVAL: "Resolve the pending approval first (agent.approvals.resolve() or resumeAfterApproval()), then send again.";
- readonly LOUSHO_SESSION_ID_INVALID: "Session ids: 1-128 characters from A-Z, a-z, 0-9, '_' and '-'. Run ids (send({ sessionId })): up to 200 and '.' too, not ending in '.turn-' or '.paused'.";
+ readonly LOUSHO_SESSION_ID_INVALID: "Session ids (agent.session({ id }), POST /chat): 1-128 characters from A-Z, a-z, 0-9, '_' and '-'. Run ids (send({ sessionId })): up to 200 and '.' too, not ending in '.turn-', '.paused' or '.fire'.";
readonly LOUSHO_SESSION_BUSY: "Wait for the running turn to finish (await its send(), or abort it), then call again.";
readonly LOUSHO_SESSION_TURN_PENDING: "Finish the interrupted turn with session.resume(), or drop it with session.discardPending(), then call again.";
readonly LOUSHO_SESSION_TURN_FAILED: "Drop the failed turn with session.discardPending() and send again, or fix the cause and retry it with session.resume().";
diff --git a/api/testing.api.md b/api/testing.api.md
index 4ffcff356..799324a5a 100644
--- a/api/testing.api.md
+++ b/api/testing.api.md
@@ -106,7 +106,7 @@ const ERROR_CODES: {
readonly LOUSHO_REQUEST_FORBIDDEN: "Call from the server's own origin (or add the page's origin to allowedOrigins) and use a Host the server allows (allowedHosts).";
readonly LOUSHO_UNSUPPORTED_MEDIA_TYPE: "Send the request body as JSON with the header 'Content-Type: application/json'.";
readonly LOUSHO_SESSION_AWAITING_APPROVAL: "Resolve the pending approval first (agent.approvals.resolve() or resumeAfterApproval()), then send again.";
- readonly LOUSHO_SESSION_ID_INVALID: "Session ids: 1-128 characters from A-Z, a-z, 0-9, '_' and '-'. Run ids (send({ sessionId })): up to 200 and '.' too, not ending in '.turn-' or '.paused'.";
+ readonly LOUSHO_SESSION_ID_INVALID: "Session ids (agent.session({ id }), POST /chat): 1-128 characters from A-Z, a-z, 0-9, '_' and '-'. Run ids (send({ sessionId })): up to 200 and '.' too, not ending in '.turn-', '.paused' or '.fire'.";
readonly LOUSHO_SESSION_BUSY: "Wait for the running turn to finish (await its send(), or abort it), then call again.";
readonly LOUSHO_SESSION_TURN_PENDING: "Finish the interrupted turn with session.resume(), or drop it with session.discardPending(), then call again.";
readonly LOUSHO_SESSION_TURN_FAILED: "Drop the failed turn with session.discardPending() and send again, or fix the cause and retry it with session.resume().";
diff --git a/api/tools.api.md b/api/tools.api.md
index 07b3c5b24..905421e24 100644
--- a/api/tools.api.md
+++ b/api/tools.api.md
@@ -676,7 +676,7 @@ const ERROR_CODES: {
readonly LOUSHO_REQUEST_FORBIDDEN: "Call from the server's own origin (or add the page's origin to allowedOrigins) and use a Host the server allows (allowedHosts).";
readonly LOUSHO_UNSUPPORTED_MEDIA_TYPE: "Send the request body as JSON with the header 'Content-Type: application/json'.";
readonly LOUSHO_SESSION_AWAITING_APPROVAL: "Resolve the pending approval first (agent.approvals.resolve() or resumeAfterApproval()), then send again.";
- readonly LOUSHO_SESSION_ID_INVALID: "Session ids: 1-128 characters from A-Z, a-z, 0-9, '_' and '-'. Run ids (send({ sessionId })): up to 200 and '.' too, not ending in '.turn-' or '.paused'.";
+ readonly LOUSHO_SESSION_ID_INVALID: "Session ids (agent.session({ id }), POST /chat): 1-128 characters from A-Z, a-z, 0-9, '_' and '-'. Run ids (send({ sessionId })): up to 200 and '.' too, not ending in '.turn-', '.paused' or '.fire'.";
readonly LOUSHO_SESSION_BUSY: "Wait for the running turn to finish (await its send(), or abort it), then call again.";
readonly LOUSHO_SESSION_TURN_PENDING: "Finish the interrupted turn with session.resume(), or drop it with session.discardPending(), then call again.";
readonly LOUSHO_SESSION_TURN_FAILED: "Drop the failed turn with session.discardPending() and send again, or fix the cause and retry it with session.resume().";
diff --git a/api/worker.api.md b/api/worker.api.md
index 558cea436..d8a30554e 100644
--- a/api/worker.api.md
+++ b/api/worker.api.md
@@ -1305,7 +1305,7 @@ const ERROR_CODES: {
readonly LOUSHO_REQUEST_FORBIDDEN: "Call from the server's own origin (or add the page's origin to allowedOrigins) and use a Host the server allows (allowedHosts).";
readonly LOUSHO_UNSUPPORTED_MEDIA_TYPE: "Send the request body as JSON with the header 'Content-Type: application/json'.";
readonly LOUSHO_SESSION_AWAITING_APPROVAL: "Resolve the pending approval first (agent.approvals.resolve() or resumeAfterApproval()), then send again.";
- readonly LOUSHO_SESSION_ID_INVALID: "Session ids: 1-128 characters from A-Z, a-z, 0-9, '_' and '-'. Run ids (send({ sessionId })): up to 200 and '.' too, not ending in '.turn-' or '.paused'.";
+ readonly LOUSHO_SESSION_ID_INVALID: "Session ids (agent.session({ id }), POST /chat): 1-128 characters from A-Z, a-z, 0-9, '_' and '-'. Run ids (send({ sessionId })): up to 200 and '.' too, not ending in '.turn-', '.paused' or '.fire'.";
readonly LOUSHO_SESSION_BUSY: "Wait for the running turn to finish (await its send(), or abort it), then call again.";
readonly LOUSHO_SESSION_TURN_PENDING: "Finish the interrupted turn with session.resume(), or drop it with session.discardPending(), then call again.";
readonly LOUSHO_SESSION_TURN_FAILED: "Drop the failed turn with session.discardPending() and send again, or fix the cause and retry it with session.resume().";
diff --git a/docs/agent-directories.md b/docs/agent-directories.md
index a777970d1..867f73932 100644
--- a/docs/agent-directories.md
+++ b/docs/agent-directories.md
@@ -298,7 +298,7 @@ files. Every option is passed on to the agent, including the ones no file
declares (`guardrails`, `onEvent`, `retry`, `reasoning`, `redactContent`,
`output`, ...). Use it to swap the model in tests:
-```ts
+```ts no-run
import { loadAgentDir } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
diff --git a/docs/auth.md b/docs/auth.md
index c40adfa9f..2c1bae552 100644
--- a/docs/auth.md
+++ b/docs/auth.md
@@ -253,7 +253,7 @@ const notes = defineMemory({
});
const agent = createAgent({
- provider: mockModel(['Hi!']),
+ provider: mockModel(['Hi!', 'Hi again!']), // one scripted reply per turn below
model: ({ principal }) => (principal?.claims?.plan === 'pro' ? 'openai/gpt-4o' : 'openai/gpt-4o-mini'),
instructions: ({ principal }) => (principal?.type === 'service' ? 'Answer in JSON.' : 'Be friendly.'),
memory: [notes],
diff --git a/docs/channels.md b/docs/channels.md
index 7b0390423..c773e96ed 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -41,7 +41,7 @@ const channels = mountFetchChannels(agent, [slack], { waitUntil: (turn) => void
`httpChannel()` is the reference channel: `{ sessionKey, input }` in, JSON out.
-```ts
+```ts no-run
import * as http from 'node:http';
import { createAgent, createMockProvider, httpChannel, mountChannels } from '@lousho/build-ai-agent';
diff --git a/docs/durable-execution.md b/docs/durable-execution.md
index 391d7226d..9ae8fb1f6 100644
--- a/docs/durable-execution.md
+++ b/docs/durable-execution.md
@@ -631,8 +631,11 @@ if (original && replayed) {
leases, the last save wins.
- A run id (`sessionId` of `send()` / `stream()`) is 1-200 characters of
letters, digits, `_`, `-` and `.`, not starting or ending with `.`, on every
- store, and may not end with `.turn-` or `.paused`: those are a session's
- own checkpoint keys. Anything else fails with `LOUSHO_SESSION_ID_INVALID`.
+ store, and may not end with `.turn-` or `.paused` (a session's own
+ checkpoint keys) or `.fire` (a schedule's fire claim). Anything else fails
+ with `LOUSHO_SESSION_ID_INVALID`. A session id (`agent.session({ id })`, the
+ routes' `sessionId`) has a stricter rule: 1-128 characters of
+ `A-Za-z0-9_-`, with no `.`.
- If you call `resumeAfterApproval()` (the function behind
`agent.approvals.resolve()`) without the `checkpointStore`, the session
keeps its `'awaiting-approval'` mark and later `send()` (or `execute()`)
diff --git a/docs/errors.md b/docs/errors.md
index d4cb25ccf..386dd83ca 100644
--- a/docs/errors.md
+++ b/docs/errors.md
@@ -517,7 +517,9 @@ paused on an approval (`error.approvalId`), so it cannot take new input yet.
not 1-200 characters of letters, digits, `_`, `-` and `.` (not starting or
ending with `.`), the same rule on every store; or it ends with `.turn-` or
`.paused`, which are a session's own checkpoint keys (a run under
-`'victim.turn-2'` would write into session `victim`'s turn).
+`'victim.turn-2'` would write into session `victim`'s turn), or `.fire` (a
+schedule's fire claim). The message says which of the two rules applies: an id
+with a `.` or over 128 characters is a valid run id but not a valid session id.
**Fix:** use an id such as `'chat-9f2c4e7a1b'`, or omit it to get a generated one. On a
route that serves several users, do not make ids guessable from a user's name or number
diff --git a/docs/evals.md b/docs/evals.md
index c54025f40..cd7fc445e 100644
--- a/docs/evals.md
+++ b/docs/evals.md
@@ -19,7 +19,7 @@ agent's provider makes the eval deterministic: no network, no API key, no
flakiness. **This is the default way to write an eval**; use a real model only
for the [judge evals](#judge-evals) below.
-```ts
+```ts no-run
// refund.eval.ts
import { z } from 'zod';
import { createAgent, defineEval, defineTool, includes } from '@lousho/build-ai-agent';
@@ -159,7 +159,7 @@ its own cassette. `timeoutMs` covers all runs of a case, so raise it with `repea
Pass `cases` and `test` runs once per case, each reported separately. A case is
any object; its `label` (or `name`, or its `input`) names it.
-```ts
+```ts no-run
import { z } from 'zod';
import { createAgent, defineEval, defineTool } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
@@ -203,7 +203,7 @@ model runs out of turns on the second case.
configured one: without `judge`, `t.judge()` throws an error saying how to fix
it.
-```ts
+```ts no-run
// tone.judge.eval.ts: run with `lousho eval --judge`
import { createAgent, defineEval, atLeast } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
diff --git a/docs/providers.md b/docs/providers.md
index ed5c1a921..acb484ae8 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -41,7 +41,7 @@ const custom = LLMProviderRegistry.create('openai', {
`ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `OLLAMA_BASE_URL` that is
present. With none set it throws an error listing the fixes.
-```ts
+```ts no-run
import { createAgent, createMockProvider, resolveProvider } from '@lousho/build-ai-agent';
const fromString = createAgent({ model: 'anthropic/claude-sonnet-5-5' });
diff --git a/docs/schedules.md b/docs/schedules.md
index 51490c131..3d357f317 100644
--- a/docs/schedules.md
+++ b/docs/schedules.md
@@ -57,7 +57,7 @@ The timers are unref'd: they never keep a process alive on their own, so a
server or a test exits normally. A script that does nothing but run schedules
would therefore exit straight away; pass `keepAlive: true` for that:
-```ts
+```ts no-run
import { createAgent, createMockProvider, defineSchedule, startSchedules } from '@lousho/build-ai-agent';
const agent = createAgent({ instructions: 'You write reports.', provider: createMockProvider() });
diff --git a/docs/triggers.md b/docs/triggers.md
index 47f2895d1..ec74b7369 100644
--- a/docs/triggers.md
+++ b/docs/triggers.md
@@ -74,7 +74,7 @@ handle.stop();
**Deprecated.** `WebhookTriggerAdapter` is replaced by `webhookChannel()` mounted with `mountChannels()` (see [Channels](./channels.md)), which takes the same `auth` option and adds sessions and approvals. The `WebhookAuth` shapes below stay and are the reference for `webhookChannel({ auth })`.
-```ts
+```ts no-run
import { createAgent, createMockProvider, mountChannels, webhookChannel } from '@lousho/build-ai-agent';
const agent = createAgent({ instructions: 'You are helpful.', provider: createMockProvider() });
@@ -95,7 +95,7 @@ through `options.logger`. Without `auth` on loopback, a request whose `Host`
is not a loopback name (DNS rebinding) or that carries an `Origin` header (a
web page) gets `403`.
-```ts
+```ts no-run
import { createAgent, createMockProvider } from '@lousho/build-ai-agent';
import { WebhookTriggerAdapter } from '@lousho/build-ai-agent/triggers';
@@ -144,7 +144,7 @@ tokens and payloads are not sent in clear text.
**Deprecated.** `SlackTriggerAdapter` is replaced by `slackChannel()` mounted with `mountChannels()` (see [Slack in Channels](./channels.md#slack)): one session per Slack thread, approval buttons, and the signature check built in. `verifySlackSignature()` stays and is not deprecated.
-```ts
+```ts no-run
import { createAgent, createMockProvider, mountChannels, slackChannel } from '@lousho/build-ai-agent';
const agent = createAgent({ instructions: 'You are helpful.', provider: createMockProvider() });
@@ -171,7 +171,7 @@ rejected (the timestamp is in seconds, as Slack sends it). Failures get a generi
logged at `warn` level. The signed `url_verification` handshake is answered
after verification.
-```ts
+```ts no-run
import { createAgent, createMockProvider } from '@lousho/build-ai-agent';
import { SlackTriggerAdapter, verifySlackSignature } from '@lousho/build-ai-agent/triggers';
import * as http from 'node:http';
diff --git a/docs/upgrading.md b/docs/upgrading.md
index fbd340cb5..342cafb82 100644
--- a/docs/upgrading.md
+++ b/docs/upgrading.md
@@ -4,8 +4,8 @@
Anyone with code written against `1.0.0-alpha.8` or an earlier `1.0.0-alpha.*`
release. Between those alphas and the 1.0 line (the changes listed under
-`1.0.0-rc.0` in the changelog, published in the later `1.0.0-alpha.*` releases) the package's public surface
-was reorganized: imports moved to subpaths, dead or superseded APIs were
+`1.0.0-alpha.19` in the changelog, first published in `1.0.0-alpha.15`) the
+package's public surface was reorganized: imports moved to subpaths, dead or superseded APIs were
removed, and the patch guardrails were renamed patch checks. `createAgent()`
and its options did not change - an agent that only does
`createAgent({ model, instructions, tools })` and `agent.send()` upgrades by
@@ -224,7 +224,7 @@ there is one. A change marked "no opt-out" closed a security or data-loss bug.
| Resolving an approval whose run was deleted or replaced fails with `LOUSHO_APPROVAL_ORPHANED` and withdraws it (it used to overwrite the newer conversation). | no opt-out |
| A session turn paused for approval locks the session id: a new turn on it throws `SessionAwaitingApprovalError` (`LOUSHO_SESSION_AWAITING_APPROVAL`). | decide the approval, or `session.discardPending()` |
| Session turns keep the checkpoint history of the latest 10 committed turns. See [Durable sessions](./sessions.md#durable-sessions). | `agent.session({ id, turnHistoryLimit })` with a larger whole number |
-| `sessionId` must be 1-200 characters of `A-Za-z0-9_.-` on every store, and may not end in `.turn-`, `.paused` or `.fire` (a schedule's fire claim); `''` is refused. | no opt-out |
+| A run id (`agent.send()` / `agent.stream()`'s `sessionId`, a fork's `newSessionId`) must be 1-200 characters of `A-Za-z0-9_.-`, not starting or ending with `.`, on every store, and may not end in `.turn-`, `.paused` or `.fire` (a schedule's fire claim); `''` is refused. This is not the session id rule: a session id (`agent.session({ id })`, the routes' and `lousho dev`'s `sessionId`, `lousho chat --session`) is still 1-128 characters of `A-Za-z0-9_-`, with no `.`. Both fail with `LOUSHO_SESSION_ID_INVALID`. | no opt-out |
| A lease left by a process that died on another machine (or in another container with the same hostname) is taken over 30 to 60 seconds after its last heartbeat (a grace for clock skew), not at 30 seconds. See [Run leases](./durable-execution.md#run-leases). | no opt-out |
| During a rolling upgrade, replicas still on an earlier release neither take nor respect run leases, so a turn can run twice until every replica is upgraded. A SQLite database opened by this release is migrated and then refused by older releases. | upgrade all replicas together; keep a copy of the database to roll back |
| `approvalTtlMs` (and an `ask` rule's `ttlMs`) above 100 years throws `LOUSHO_CONFIG_INVALID` instead of meaning "never expires". | leave it out for pauses that never expire |
diff --git a/docs/workspace-tools.md b/docs/workspace-tools.md
index c4ac24570..7be3feb1d 100644
--- a/docs/workspace-tools.md
+++ b/docs/workspace-tools.md
@@ -301,6 +301,9 @@ refused command is never offered for approval:
returns `true` for the rest of the line (`''` when there is none):
```ts
+ import { createShellTool, NodeWorkspace } from '@lousho/build-ai-agent';
+
+ const workspace = new NodeWorkspace({ root: '.' });
createShellTool(workspace, {
needsApproval: false,
allow: [
diff --git a/examples/README.md b/examples/README.md
index 0557a6506..13583454b 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -5,6 +5,12 @@ is runnable directly (see its own README for exact instructions); most use
`tsx` and a free mock provider by default, so nothing here needs an API key
to try.
+The examples import the SDK by its package name and public subpaths
+(`@lousho/build-ai-agent`, `@lousho/build-ai-agent/testing`, ...), exactly as
+your app would, so you can copy a file out as it is. Inside this repo the
+`paths` in `tsconfig.json` (and a resolver in `vitest.config.ts`) point those
+imports at `src/`, so nothing needs building first.
+
## [agent-dir](./agent-dir)
An agent defined as a directory (`instructions.md`, `tools/`, `skills/`, `agent.json`) and loaded with `loadAgentDir()`; runs offline with a mock model (LOU-Y5).
diff --git a/examples/agent-dir/index.test.ts b/examples/agent-dir/index.test.ts
index fbb6e4339..edca81558 100644
--- a/examples/agent-dir/index.test.ts
+++ b/examples/agent-dir/index.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
-import { loadAgentDir } from '../../src';
-import { mockModel } from '../../src/testing';
+import { loadAgentDir } from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
describe('examples/agent-dir', () => {
it('loads from disk and its tool and skill are callable offline', async () => {
diff --git a/examples/agent-dir/index.ts b/examples/agent-dir/index.ts
index b17b8dcaf..289e7fb83 100644
--- a/examples/agent-dir/index.ts
+++ b/examples/agent-dir/index.ts
@@ -9,8 +9,8 @@
* you trust. Run with: npx tsx examples/agent-dir/index.ts
*/
import path from 'node:path';
-import { loadAgentDir } from '../../src';
-import { mockModel } from '../../src/testing';
+import { loadAgentDir } from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
const provider = mockModel([
{
diff --git a/examples/agent-dir/tools/word_count.ts b/examples/agent-dir/tools/word_count.ts
index 2bd22228a..86b91d0a5 100644
--- a/examples/agent-dir/tools/word_count.ts
+++ b/examples/agent-dir/tools/word_count.ts
@@ -1,5 +1,5 @@
import { z } from 'zod';
-import { defineTool } from '../../../src/index.js';
+import { defineTool } from '@lousho/build-ai-agent';
export default defineTool({
name: 'word_count',
diff --git a/examples/coding-agent-workflows/index.test.ts b/examples/coding-agent-workflows/index.test.ts
index fd6e3b326..65a730b58 100644
--- a/examples/coding-agent-workflows/index.test.ts
+++ b/examples/coding-agent-workflows/index.test.ts
@@ -1,8 +1,8 @@
import { z } from 'zod';
import { describe, expect, it } from 'vitest';
-import { mockModel } from '../../src/testing';
-import { defineTool } from '../../src';
-import { ToolRegistry } from '../../src/tools';
+import { mockModel } from '@lousho/build-ai-agent/testing';
+import { defineTool } from '@lousho/build-ai-agent';
+import { ToolRegistry } from '@lousho/build-ai-agent/executor';
import { codingTools, memoryWorkspace, runCodingAgent, triage } from './index';
const decision = (route: string, confidence: number) =>
diff --git a/examples/coding-agent-workflows/index.ts b/examples/coding-agent-workflows/index.ts
index 60d6fe61c..2ce35a7d7 100644
--- a/examples/coding-agent-workflows/index.ts
+++ b/examples/coding-agent-workflows/index.ts
@@ -29,10 +29,10 @@
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { z } from 'zod';
-import { createAgent, defineTool, resolveProvider, type LLMProvider, type SimpleAgent } from '../../src';
-import { ToolRegistry } from '../../src/tools';
-import { FlowBuilder, FlowExecutor, type EditorStep, type FlowExecutionResult } from '../../src/flows';
-import { mockModel } from '../../src/testing';
+import { createAgent, defineTool, resolveProvider, type LLMProvider, type SimpleAgent } from '@lousho/build-ai-agent';
+import { ToolRegistry } from '@lousho/build-ai-agent/executor';
+import { FlowBuilder, FlowExecutor, type EditorStep, type FlowExecutionResult } from '@lousho/build-ai-agent/flows';
+import { mockModel } from '@lousho/build-ai-agent/testing';
export const LIVE_MODEL = 'openrouter/openai/gpt-4o-mini';
diff --git a/examples/coding-harness/index.test.ts b/examples/coding-harness/index.test.ts
index 0e94663b1..2ab3db8e2 100644
--- a/examples/coding-harness/index.test.ts
+++ b/examples/coding-harness/index.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
-import { MemoryWorkspace } from '../../src';
-import { mockModel } from '../../src/testing';
+import { MemoryWorkspace } from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
import { createCodingHarness, fakeExec, FIXTURE, instructionsFor, scriptedExplorer, scriptedLead } from './index';
function memoryWorkspace(): MemoryWorkspace {
diff --git a/examples/coding-harness/index.ts b/examples/coding-harness/index.ts
index e144a7540..e6571f855 100644
--- a/examples/coding-harness/index.ts
+++ b/examples/coding-harness/index.ts
@@ -32,8 +32,8 @@ import {
type AgentHook,
type LLMProvider,
type Message,
-} from '../../src';
-import { mockModel } from '../../src/testing';
+} from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
/** The project the harness works on: `add()` subtracts, so its test fails. */
export const FIXTURE: Record = {
diff --git a/examples/connectors/index.test.ts b/examples/connectors/index.test.ts
index 04d0e5b28..59e8a725d 100644
--- a/examples/connectors/index.test.ts
+++ b/examples/connectors/index.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { createAgent } from '../../src';
-import { mockModel } from '../../src/testing';
+import { createAgent } from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
import { connectorSpecs } from './index';
// The test spawns the real mock-connectors stdio server - the honest check
diff --git a/examples/connectors/index.ts b/examples/connectors/index.ts
index 85ac5ed27..b47d02638 100644
--- a/examples/connectors/index.ts
+++ b/examples/connectors/index.ts
@@ -23,9 +23,9 @@
*/
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
-import { createAgent, resolveProvider, type LLMProvider } from '../../src';
-import { mockModel } from '../../src/testing';
-import { type McpServerSpec } from '../../src/spec/schema';
+import { createAgent, resolveProvider, type LLMProvider } from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
+import { type McpServerSpec } from '@lousho/build-ai-agent';
export const LIVE_MODEL = 'openrouter/openai/gpt-4o-mini';
diff --git a/examples/connectors/mock-connectors-server.ts b/examples/connectors/mock-connectors-server.ts
index 20191c9c0..ffe6f2e65 100644
--- a/examples/connectors/mock-connectors-server.ts
+++ b/examples/connectors/mock-connectors-server.ts
@@ -12,9 +12,9 @@
* required `agent` option; the connector tools ride in `tools`.
*/
import { z } from 'zod';
-import { createAgent, defineTool } from '../../src';
-import { serveMcp } from '../../src/tools/mcp';
-import { mockModel } from '../../src/testing';
+import { createAgent, defineTool } from '@lousho/build-ai-agent';
+import { serveMcp } from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
const DOCS = new Map([
['doc-1', '# Launch plan\n\nShip the connector example on Friday.'],
diff --git a/examples/data-analyst/index.test.ts b/examples/data-analyst/index.test.ts
index 81f4f1931..d5cb55987 100644
--- a/examples/data-analyst/index.test.ts
+++ b/examples/data-analyst/index.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { mockModel, testToolContext } from '../../src/testing';
+import { mockModel, testToolContext } from '@lousho/build-ai-agent/testing';
import {
createAnalystTools,
createDataAnalyst,
diff --git a/examples/data-analyst/index.ts b/examples/data-analyst/index.ts
index 8338d5e3e..f8595611d 100644
--- a/examples/data-analyst/index.ts
+++ b/examples/data-analyst/index.ts
@@ -25,12 +25,12 @@
* database credentials - the demo seeds an in-memory database, which
* must be writable while it is seeded.
*
- * The database is Node's built-in `node:sqlite` (`DatabaseSync`), loaded
- * through the SDK's lazy driver (`src/storage/sqlite/driver.ts`): engines
- * requires Node >= 22.19, well past the 22.13 release that shipped
- * `node:sqlite` without a flag, and the lazy load keeps `@types/node` 20
- * (which has no `node:sqlite` typings) and Vite's builtin resolution happy.
- * On a Node without it, `main()` prints the driver's error and exits.
+ * The database is Node's built-in `node:sqlite` (`DatabaseSync`, Node >=
+ * 22.13), loaded lazily with `process.getBuiltinModule` and typed with the
+ * few methods the demo uses (`SqlDatabase` below): that keeps `@types/node`
+ * 20 (which has no `node:sqlite` typings) and Vite's builtin resolution
+ * happy. In your own app, `import { DatabaseSync } from 'node:sqlite'` works
+ * just as well. On a Node without it, `main()` prints the error and exits.
*
* Offline (the default) a scripted mock model walks the loop; with
* OPENROUTER_API_KEY set it runs for real on openrouter/openai/gpt-4o-mini.
@@ -40,9 +40,8 @@
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { z } from 'zod';
-import { allow, createAgent, defineTool, type AgentHook, type LLMProvider } from '../../src';
-import { loadDatabaseSync, type SqlDatabase } from '../../src/storage/sqlite/driver';
-import { mockModel } from '../../src/testing';
+import { allow, createAgent, defineTool, type AgentHook, type LLMProvider } from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
export const LIVE_MODEL = 'openrouter/openai/gpt-4o-mini';
@@ -77,6 +76,26 @@ export const MAX_ROWS = 100;
// Two tables a question like QUESTION needs a join across. October's revenue
// is led by silver; November's ("last month", per the demo clock) by gold.
+/** The parts of `node:sqlite`'s `DatabaseSync` the demo uses. */
+export interface SqlDatabase {
+ exec(sql: string): void;
+ prepare(sql: string): {
+ get(): Record | undefined;
+ all(): Record[];
+ };
+ close(): void;
+}
+
+function loadDatabaseSync(): new (path: string) => SqlDatabase {
+ const sqlite = process.getBuiltinModule?.('node:sqlite') as
+ | { DatabaseSync?: new (path: string) => SqlDatabase }
+ | undefined;
+ if (!sqlite?.DatabaseSync) {
+ throw new Error(`The built-in 'node:sqlite' module needs Node >= 22.13 (this is Node ${process.versions.node}).`);
+ }
+ return sqlite.DatabaseSync;
+}
+
export function seedDatabase(): SqlDatabase {
const DatabaseSync = loadDatabaseSync();
const db = new DatabaseSync(':memory:');
diff --git a/examples/deep-research/index.test.ts b/examples/deep-research/index.test.ts
index f37729fd9..ca6d95eee 100644
--- a/examples/deep-research/index.test.ts
+++ b/examples/deep-research/index.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
-import { toolResultText } from '../../src';
-import { testToolContext } from '../../src/testing';
+import { toolResultText } from '@lousho/build-ai-agent';
+import { testToolContext } from '@lousho/build-ai-agent/testing';
import {
CORPUS,
createDeepResearch,
diff --git a/examples/deep-research/index.ts b/examples/deep-research/index.ts
index b65b06366..1a1086ca5 100644
--- a/examples/deep-research/index.ts
+++ b/examples/deep-research/index.ts
@@ -24,8 +24,8 @@
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { z } from 'zod';
-import { createAgent, defineTool, textOf, toolResultText, type LLMProvider } from '../../src';
-import { mockModel } from '../../src/testing';
+import { createAgent, defineTool, textOf, toolResultText, type LLMProvider } from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
export const LIVE_MODEL = 'openrouter/openai/gpt-4o-mini';
export const DEFAULT_QUESTION =
diff --git a/examples/doc-qa/index.ts b/examples/doc-qa/index.ts
index b816e34ed..01087f5e2 100644
--- a/examples/doc-qa/index.ts
+++ b/examples/doc-qa/index.ts
@@ -8,9 +8,9 @@
*
* Run with: tsx examples/doc-qa/index.ts
*/
-import { createAgent } from '../../src/createAgent';
-import { createMockProvider } from '../../src/providers/mock';
-import { resolveProvider } from '../../src/providers/resolveProvider';
+import { createAgent } from '@lousho/build-ai-agent';
+import { createMockProvider } from '@lousho/build-ai-agent';
+import { resolveProvider } from '@lousho/build-ai-agent';
const DOCUMENT = `
Lousho Refund Policy: Refunds are available within 30 days of purchase.
diff --git a/examples/evaluator-loop/index.test.ts b/examples/evaluator-loop/index.test.ts
index 084c6fec0..338e5c0df 100644
--- a/examples/evaluator-loop/index.test.ts
+++ b/examples/evaluator-loop/index.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { createAgent, textOf } from '../../src';
-import { mockModel } from '../../src/testing';
+import { createAgent, textOf } from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
import { RUBRIC, runEvaluatorLoop } from './index';
const PROMPT = 'Write a 3-sentence product description for HydraTrack.';
diff --git a/examples/evaluator-loop/index.ts b/examples/evaluator-loop/index.ts
index 7cde6b24c..3e0b46dfc 100644
--- a/examples/evaluator-loop/index.ts
+++ b/examples/evaluator-loop/index.ts
@@ -28,8 +28,8 @@ import {
resolveProvider,
type LLMJudgeConfig,
type SimpleAgent,
-} from '../../src';
-import { mockModel } from '../../src/testing';
+} from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
export const LIVE_MODEL = 'openrouter/openai/gpt-4o-mini';
/** Bare model id, as `judge.provider.generate({ model })` expects it. */
diff --git a/examples/harnesses.live.test.ts b/examples/harnesses.live.test.ts
index b4d3d322e..37ad1eca2 100644
--- a/examples/harnesses.live.test.ts
+++ b/examples/harnesses.live.test.ts
@@ -19,7 +19,7 @@
*/
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
-import { createAgent, defineTool, resolveProvider } from '../src';
+import { createAgent, defineTool, resolveProvider } from '@lousho/build-ai-agent';
import { createDeepResearch } from './deep-research';
import { createSupportDesk } from './support-desk';
import { runEvaluatorLoop } from './evaluator-loop';
diff --git a/examples/imports.test.ts b/examples/imports.test.ts
new file mode 100644
index 000000000..8ec61c91d
--- /dev/null
+++ b/examples/imports.test.ts
@@ -0,0 +1,47 @@
+/**
+ * Examples are meant to be copied into an app, so they import the SDK by its
+ * package name and public subpaths, never `src/`. The repo maps those names
+ * to source through tsconfig.json `paths`, which must list every export.
+ */
+import { describe, it, expect } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import ts from 'typescript';
+
+const REPO_ROOT = path.resolve(__dirname, '..');
+const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8'));
+
+function walk(dir: string): string[] {
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) return entry.name === 'node_modules' ? [] : walk(full);
+ return /\.(ts|mts|mjs)$/.test(entry.name) ? [full] : [];
+ });
+}
+
+describe('example imports', () => {
+ it('example sources import the SDK through public entry points, not src/', () => {
+ const offenders = walk(__dirname)
+ .filter((file) => !/\.test\.ts$/.test(file))
+ .flatMap((file) =>
+ fs
+ .readFileSync(file, 'utf8')
+ .split('\n')
+ .filter((line) => /from '(\.\.\/)+src(\/|')/.test(line))
+ .map((line) => `${path.relative(REPO_ROOT, file)}: ${line.trim()}`)
+ );
+ expect(offenders).toEqual([]);
+ });
+
+ it('tsconfig.json maps exactly the package exports to their sources', () => {
+ const { config } = ts.readConfigFile(path.join(REPO_ROOT, 'tsconfig.json'), ts.sys.readFile);
+ const expected: Record = Object.fromEntries(
+ Object.entries<{ require: { types: string } }>(pkg.exports).map(([subpath, entry]) => [
+ subpath === '.' ? pkg.name : `${pkg.name}/${subpath.slice(2)}`,
+ [entry.require.types.replace(/^\.\/dist\//, './src/').replace(/\.d\.ts$/, '.ts')],
+ ])
+ );
+ expect(config.compilerOptions.paths).toEqual(expected);
+ for (const [target] of Object.values(expected)) expect(fs.existsSync(path.join(REPO_ROOT, target))).toBe(true);
+ });
+});
diff --git a/examples/openrouter/index.ts b/examples/openrouter/index.ts
index ead9666aa..b9e36a0c9 100644
--- a/examples/openrouter/index.ts
+++ b/examples/openrouter/index.ts
@@ -8,8 +8,8 @@
* snippets: basic, streaming, compare, tools, agent-builder, models,
* cost, errors, conversation, capabilities
*/
-import { LLMProviderRegistry, Message } from '../../src/index';
-import { AgentBuilder } from '../../src/executor';
+import { LLMProviderRegistry, Message } from '@lousho/build-ai-agent';
+import { AgentBuilder } from '@lousho/build-ai-agent/executor';
const apiKey = process.env.OPENROUTER_API_KEY || '';
diff --git a/examples/ops-pipeline/demoProvider.ts b/examples/ops-pipeline/demoProvider.ts
index 2e72004ab..1ab502bfb 100644
--- a/examples/ops-pipeline/demoProvider.ts
+++ b/examples/ops-pipeline/demoProvider.ts
@@ -13,8 +13,8 @@
* registry): returns a fenced ```diff block diagnosing the demo's
* synthetic NullPointerException.
*/
-import { GenerateOptions, GenerateResult, LLMProvider, StreamResult } from '../../src/providers';
-import { textOf } from '../../src/providers/content';
+import { GenerateOptions, GenerateResult, LLMProvider, StreamResult } from '@lousho/build-ai-agent';
+import { textOf } from '@lousho/build-ai-agent';
const DEMO_FIXER_DIFF_RESPONSE = [
'Root cause: `order` is not null-checked before `charge()` is called.',
diff --git a/examples/ops-pipeline/fixer.test.ts b/examples/ops-pipeline/fixer.test.ts
index 246ac2fcd..688a5394d 100644
--- a/examples/ops-pipeline/fixer.test.ts
+++ b/examples/ops-pipeline/fixer.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { runFixer, extractDiffBlock, EmptyPatchError, FixRequest, createFixerTool } from './fixer';
-import { LLMProvider, GenerateResult } from '../../src/providers';
+import { LLMProvider, GenerateResult } from '@lousho/build-ai-agent';
function makeScriptedProvider(text: string): LLMProvider {
const result: GenerateResult = {
diff --git a/examples/ops-pipeline/fixer.ts b/examples/ops-pipeline/fixer.ts
index 31cf4ff37..ea1916fc2 100644
--- a/examples/ops-pipeline/fixer.ts
+++ b/examples/ops-pipeline/fixer.ts
@@ -14,14 +14,11 @@
* rather than being fixer-core itself.
*/
import { z } from 'zod';
-import {
- AgentExecutor,
- ExecuteOptions,
- ExecutionResult,
-} from '../../src/execution/AgentExecutor';
-import { AgentConfig, ToolDescriptor } from '../../src/types';
-import { defineTool } from '../../src/tools/defineTool';
-import { LLMProvider } from '../../src/providers';
+import { AgentExecutor, ExecuteOptions } from '@lousho/build-ai-agent/executor';
+import { ExecutionResult } from '@lousho/build-ai-agent';
+import { AgentConfig, ToolDescriptor } from '@lousho/build-ai-agent';
+import { defineTool } from '@lousho/build-ai-agent';
+import { LLMProvider } from '@lousho/build-ai-agent';
/**
* A request to diagnose-and-fix an error, built from an ErrorSignal
diff --git a/examples/ops-pipeline/guardedPr.test.ts b/examples/ops-pipeline/guardedPr.test.ts
index ee4ae8e83..0eff3c68a 100644
--- a/examples/ops-pipeline/guardedPr.test.ts
+++ b/examples/ops-pipeline/guardedPr.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { handleFixerPatch } from './guardedPr';
-import { ToolDescriptor } from '../../src/types';
+import { ToolDescriptor } from '@lousho/build-ai-agent';
describe('handleFixerPatch (LOU-J7)', () => {
const CLEAN_PATCH = '--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n';
diff --git a/examples/ops-pipeline/guardedPr.ts b/examples/ops-pipeline/guardedPr.ts
index 715b31e6e..3ffc434c4 100644
--- a/examples/ops-pipeline/guardedPr.ts
+++ b/examples/ops-pipeline/guardedPr.ts
@@ -25,8 +25,8 @@ import {
createDiffSizeCheck,
runPatchChecks,
secretScanCheck,
-} from '../../src/execution/patchChecks';
-import { ToolDescriptor } from '../../src/types';
+} from '@lousho/build-ai-agent';
+import { ToolDescriptor } from '@lousho/build-ai-agent';
export interface FixerPatchContext {
/** The REAL LOU-E13/E14-hardened GitHub tool's PR-creation ToolDescriptor. */
diff --git a/examples/ops-pipeline/index.ts b/examples/ops-pipeline/index.ts
index e87bd8b72..049a151bc 100644
--- a/examples/ops-pipeline/index.ts
+++ b/examples/ops-pipeline/index.ts
@@ -28,10 +28,10 @@
import * as http from 'node:http';
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
-import { ToolDescriptor } from '../../src/types';
-import { ToolRegistry } from '../../src/tools';
-import { verifySlackSignature } from '../../src/triggers';
-import { LLMProvider } from '../../src/providers/llm';
+import { ToolDescriptor } from '@lousho/build-ai-agent';
+import { ToolRegistry } from '@lousho/build-ai-agent/executor';
+import { verifySlackSignature } from '@lousho/build-ai-agent/triggers';
+import { LLMProvider } from '@lousho/build-ai-agent';
import { startMonitorServer, MonitorServerHandle, StartMonitorServerOptions } from './monitor';
import { createFixerTool, buildFixerAgent } from './fixer';
import { handleFixerPatch } from './guardedPr';
@@ -43,13 +43,13 @@ import {
readBody,
} from './slackInteractions';
import { closeServer, listenOn, sendJson, sendNotFound } from './httpHelpers';
-import { ApprovalStore } from '../../src/execution/ApprovalGate';
-import { ExecutionResult } from '../../src/execution/AgentExecutor';
-import { PatchCheck } from '../../src/execution/patchChecks';
+import { ApprovalStore } from '@lousho/build-ai-agent';
+import { ExecutionResult } from '@lousho/build-ai-agent';
+import { PatchCheck } from '@lousho/build-ai-agent';
import { createDemoProvider } from './demoProvider';
import { createMockGithubTool } from './mocks/mockGithubTool';
import { createMockSlackTool } from './mocks/mockSlackTool';
-import { textOf } from '../../src/providers/content';
+import { textOf } from '@lousho/build-ai-agent';
const DELEGATE_TOOL_NAME = 'delegate_to_fixer';
const DEFAULT_CHANNEL = '#incidents';
diff --git a/examples/ops-pipeline/mocks/mockGithubTool.ts b/examples/ops-pipeline/mocks/mockGithubTool.ts
index 9dfef6d8b..481729cdf 100644
--- a/examples/ops-pipeline/mocks/mockGithubTool.ts
+++ b/examples/ops-pipeline/mocks/mockGithubTool.ts
@@ -5,7 +5,7 @@
* Never makes a real network call, so the ops-pipeline demo runs with zero
* external network access.
*/
-import { ToolDescriptor } from '../../../src/types';
+import { ToolDescriptor } from '@lousho/build-ai-agent';
export interface MockCreatedPullRequest {
title: string;
diff --git a/examples/ops-pipeline/mocks/mockSlackTool.ts b/examples/ops-pipeline/mocks/mockSlackTool.ts
index c7075c74b..54a3ac90e 100644
--- a/examples/ops-pipeline/mocks/mockSlackTool.ts
+++ b/examples/ops-pipeline/mocks/mockSlackTool.ts
@@ -6,8 +6,8 @@
* POSTing to a real webhook - so the ops-pipeline demo runs with zero
* external network access.
*/
-import { ToolDescriptor } from '../../../src/types';
-import { buildSlackAlertPayload, SlackAlertPayload } from '../../../src/tools/built-in/slack';
+import { ToolDescriptor } from '@lousho/build-ai-agent';
+import { buildSlackAlertPayload, SlackAlertPayload } from '@lousho/build-ai-agent/integrations';
export interface MockSlackPost {
channel: string;
diff --git a/examples/ops-pipeline/monitor.test.ts b/examples/ops-pipeline/monitor.test.ts
index 0bf99bda6..d5e29ea63 100644
--- a/examples/ops-pipeline/monitor.test.ts
+++ b/examples/ops-pipeline/monitor.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { handleErrorSignal, buildMonitorPrompt, ErrorSignal, startMonitorServer } from './monitor';
-import { AgentExecutor } from '../../src/execution/AgentExecutor';
-import { createMockProvider } from '../../src/providers/mock';
+import { AgentExecutor } from '@lousho/build-ai-agent/executor';
+import { createMockProvider } from '@lousho/build-ai-agent';
function makeSignal(overrides: Partial = {}): ErrorSignal {
return {
diff --git a/examples/ops-pipeline/monitor.ts b/examples/ops-pipeline/monitor.ts
index 87c31b3f4..aa1d4cd8d 100644
--- a/examples/ops-pipeline/monitor.ts
+++ b/examples/ops-pipeline/monitor.ts
@@ -15,7 +15,8 @@
*/
import * as http from 'node:http';
import { closeServer, listenOn, sendJson, sendNotFound } from './httpHelpers';
-import { AgentExecutor, ExecuteOptions, ExecutionResult } from '../../src/execution/AgentExecutor';
+import { AgentExecutor, ExecuteOptions } from '@lousho/build-ai-agent/executor';
+import { ExecutionResult } from '@lousho/build-ai-agent';
/**
* A single error/incident signal from a monitoring source.
diff --git a/examples/ops-pipeline/pipeline.eval.ts b/examples/ops-pipeline/pipeline.eval.ts
index 325b7d79a..b0f801f06 100644
--- a/examples/ops-pipeline/pipeline.eval.ts
+++ b/examples/ops-pipeline/pipeline.eval.ts
@@ -26,10 +26,10 @@
* ran, in what order, without inventing a fake single-result shape.
*/
import { describe, it, expect } from 'vitest';
-import { defineEval } from '../../src/evals/defineEval';
-import { toolCallOrder } from '../../src/evals/scorers';
-import { ToolRegistry } from '../../src/tools';
-import { secretScanCheck, createDiffSizeCheck } from '../../src/execution/patchChecks';
+import { defineEval } from '@lousho/build-ai-agent';
+import { toolCallOrder } from '@lousho/build-ai-agent';
+import { ToolRegistry } from '@lousho/build-ai-agent/executor';
+import { secretScanCheck, createDiffSizeCheck } from '@lousho/build-ai-agent';
import { startOpsPipeline } from './index';
import { createDemoProvider } from './demoProvider';
import { createFixerTool, buildFixerAgent } from './fixer';
diff --git a/examples/ops-pipeline/slackInteractions.test.ts b/examples/ops-pipeline/slackInteractions.test.ts
index b64210bd0..8819b838a 100644
--- a/examples/ops-pipeline/slackInteractions.test.ts
+++ b/examples/ops-pipeline/slackInteractions.test.ts
@@ -6,9 +6,9 @@ import {
createInMemoryApprovalStore,
SlackInteractionPayload,
} from './slackInteractions';
-import { ApprovalStore, ExecutionSnapshot, PendingApproval } from '../../src/execution/ApprovalGate';
-import { ToolRegistry } from '../../src/tools';
-import { createMockProvider } from '../../src/providers/mock';
+import { ApprovalStore, ExecutionSnapshot, PendingApproval } from '@lousho/build-ai-agent';
+import { ToolRegistry } from '@lousho/build-ai-agent/executor';
+import { createMockProvider } from '@lousho/build-ai-agent';
describe('extractApprovalIdFromInteraction', () => {
it('extracts the approvalId from a fix_it block_actions click', () => {
diff --git a/examples/ops-pipeline/slackInteractions.ts b/examples/ops-pipeline/slackInteractions.ts
index b6c5e3721..f40c937ac 100644
--- a/examples/ops-pipeline/slackInteractions.ts
+++ b/examples/ops-pipeline/slackInteractions.ts
@@ -10,14 +10,11 @@
* ApprovalGate, not a bypass or a reimplementation of it.
*/
import * as http from 'node:http';
-import {
- resumeAfterApproval,
- ResumeExecuteOptions,
-} from '../../src/execution/resume';
-import { ApprovalStore, ExecutionSnapshot, PendingApproval } from '../../src/execution/ApprovalGate';
-import { ExecutionResult } from '../../src/execution/AgentExecutor';
-import { LLMProvider } from '../../src/providers';
-import { ToolRegistry } from '../../src/tools';
+import { resumeAfterApproval, ResumeExecuteOptions } from '@lousho/build-ai-agent/executor';
+import { ApprovalStore, ExecutionSnapshot, PendingApproval } from '@lousho/build-ai-agent';
+import { ExecutionResult } from '@lousho/build-ai-agent';
+import { LLMProvider } from '@lousho/build-ai-agent';
+import { ToolRegistry } from '@lousho/build-ai-agent/executor';
/** The subset of Slack's real block_actions interaction callback payload this route reads. */
export interface SlackInteractionPayload {
diff --git a/examples/phase-pipeline/index.test.ts b/examples/phase-pipeline/index.test.ts
index 6cf384878..fdd3a8cca 100644
--- a/examples/phase-pipeline/index.test.ts
+++ b/examples/phase-pipeline/index.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
-import { defineTool } from '../../src';
-import { ToolRegistry } from '../../src/tools';
-import { mockModel } from '../../src/testing';
+import { defineTool } from '@lousho/build-ai-agent';
+import { ToolRegistry } from '@lousho/build-ai-agent/executor';
+import { mockModel } from '@lousho/build-ai-agent/testing';
import { z } from 'zod';
import { buildPhaseFlow, runPhasePipeline, scriptedPhases } from './index';
diff --git a/examples/phase-pipeline/index.ts b/examples/phase-pipeline/index.ts
index 84b916046..98a1a6e23 100644
--- a/examples/phase-pipeline/index.ts
+++ b/examples/phase-pipeline/index.ts
@@ -24,16 +24,16 @@ import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { z } from 'zod';
-import { defineTool, resolveProvider, type LLMProvider } from '../../src';
+import { defineTool, resolveProvider, type LLMProvider } from '@lousho/build-ai-agent';
import {
FlowBuilder,
FlowExecutor,
type AgentFlow,
type EditorStep,
type FlowExecutionResult,
-} from '../../src/flows';
-import { ToolRegistry } from '../../src/tools';
-import { mockModel } from '../../src/testing';
+} from '@lousho/build-ai-agent/flows';
+import { ToolRegistry } from '@lousho/build-ai-agent/executor';
+import { mockModel } from '@lousho/build-ai-agent/testing';
export const LIVE_MODEL = 'openrouter/openai/gpt-4o-mini';
diff --git a/examples/plan-mode/index.ts b/examples/plan-mode/index.ts
index c4daa6d21..3f67e80d3 100644
--- a/examples/plan-mode/index.ts
+++ b/examples/plan-mode/index.ts
@@ -11,10 +11,10 @@
*
* Run with: npx tsx examples/plan-mode/index.ts
*/
-import { createAgent } from '../../src';
-import { createFsTools } from '../../src/tools/workspace/fsTools';
-import { MemoryWorkspace } from '../../src/tools/workspace/MemoryWorkspace';
-import { mockModel } from '../../src/testing';
+import { createAgent } from '@lousho/build-ai-agent';
+import { createFsTools } from '@lousho/build-ai-agent';
+import { MemoryWorkspace } from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
const workspace = new MemoryWorkspace({
files: { 'greet.ts': "export function greet(name: string) {\n return 'Hello ' + name;\n}\n" },
diff --git a/examples/research-assistant/index.ts b/examples/research-assistant/index.ts
index d8339c6ab..7deecca07 100644
--- a/examples/research-assistant/index.ts
+++ b/examples/research-assistant/index.ts
@@ -9,10 +9,10 @@
*
* Run with: tsx examples/research-assistant/index.ts
*/
-import { createAgent } from '../../src/createAgent';
-import { createMockProvider } from '../../src/providers/mock';
-import { resolveProvider } from '../../src/providers/resolveProvider';
-import { httpTool } from '../../src/tools/built-in/http';
+import { createAgent } from '@lousho/build-ai-agent';
+import { createMockProvider } from '@lousho/build-ai-agent';
+import { resolveProvider } from '@lousho/build-ai-agent';
+import { httpTool } from '@lousho/build-ai-agent';
const provider = process.env.ANTHROPIC_API_KEY
? resolveProvider('anthropic/claude-3-5-sonnet-latest')
diff --git a/examples/slack-notifier/index.ts b/examples/slack-notifier/index.ts
index e0003615f..8604ddf35 100644
--- a/examples/slack-notifier/index.ts
+++ b/examples/slack-notifier/index.ts
@@ -7,9 +7,9 @@
*
* Run with: tsx examples/slack-notifier/index.ts
*/
-import { createAgent } from '../../src/createAgent';
-import { createMockProvider } from '../../src/providers/mock';
-import { resolveProvider } from '../../src/providers/resolveProvider';
+import { createAgent } from '@lousho/build-ai-agent';
+import { createMockProvider } from '@lousho/build-ai-agent';
+import { resolveProvider } from '@lousho/build-ai-agent';
const provider = process.env.OPENAI_API_KEY
? resolveProvider('openai/gpt-4o-mini')
diff --git a/examples/support-bot/index.ts b/examples/support-bot/index.ts
index 65071c832..d4f45e7d4 100644
--- a/examples/support-bot/index.ts
+++ b/examples/support-bot/index.ts
@@ -7,9 +7,9 @@
*
* Run with: tsx examples/support-bot/index.ts
*/
-import { createAgent } from '../../src/createAgent';
-import { createMockProvider } from '../../src/providers/mock';
-import { resolveProvider } from '../../src/providers/resolveProvider';
+import { createAgent } from '@lousho/build-ai-agent';
+import { createMockProvider } from '@lousho/build-ai-agent';
+import { resolveProvider } from '@lousho/build-ai-agent';
const provider = process.env.OPENAI_API_KEY
? resolveProvider('openai/gpt-4o-mini')
diff --git a/examples/support-desk/index.test.ts b/examples/support-desk/index.test.ts
index 248f6ffc5..7c931cbb9 100644
--- a/examples/support-desk/index.test.ts
+++ b/examples/support-desk/index.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
-import { mockModel, type MockModel } from '../../src/testing';
+import { mockModel, type MockModel } from '@lousho/build-ai-agent/testing';
import { createSupportDesk, demoState, scriptedBilling, scriptedTriage } from './index';
const toolNames = (model: MockModel, call: number) => (model.calls[call]?.tools ?? []).map((tool) => tool.function.name);
diff --git a/examples/support-desk/index.ts b/examples/support-desk/index.ts
index a0329361e..21dd5e95f 100644
--- a/examples/support-desk/index.ts
+++ b/examples/support-desk/index.ts
@@ -65,8 +65,8 @@ import {
type HandoffInputData,
type LLMProvider,
type SimpleAgent,
-} from '../../src';
-import { mockModel } from '../../src/testing';
+} from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
export const LIVE_MODEL = 'openrouter/openai/gpt-4o-mini';
diff --git a/examples/tracing/console-exporter.ts b/examples/tracing/console-exporter.ts
index acd03f75d..1ebbc2a60 100644
--- a/examples/tracing/console-exporter.ts
+++ b/examples/tracing/console-exporter.ts
@@ -9,7 +9,7 @@
* createOtelTraceExporter() for a bundled OpenTelemetry bridge).
*/
-import { Span, TraceExporter } from '../../src/execution/tracing';
+import { Span, TraceExporter } from '@lousho/build-ai-agent';
export function createConsoleExporter(): TraceExporter {
// Tracks depth so nested spans (chat/execute_tool under invoke_agent)
diff --git a/examples/tracing/run-otel.ts b/examples/tracing/run-otel.ts
index 9bf327f84..6bb5e4d98 100644
--- a/examples/tracing/run-otel.ts
+++ b/examples/tracing/run-otel.ts
@@ -29,7 +29,7 @@ import {
ConsoleSpanExporter,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-node';
-import { createOtelTraceExporter } from '../../src/execution/otel';
+import { createOtelTraceExporter } from '@lousho/build-ai-agent/otel';
import { runWeatherScenario } from './weather-scenario';
async function main() {
diff --git a/examples/tracing/weather-scenario.ts b/examples/tracing/weather-scenario.ts
index dd6888bd4..356961db3 100644
--- a/examples/tracing/weather-scenario.ts
+++ b/examples/tracing/weather-scenario.ts
@@ -7,11 +7,11 @@
import { tool } from 'ai';
import { z } from 'zod';
-import { AgentExecutor } from '../../src/execution/AgentExecutor';
-import { AgentBuilder } from '../../src/core';
-import { ToolRegistry } from '../../src/tools';
-import { LLMProvider, GenerateOptions, GenerateResult } from '../../src/providers';
-import type { TraceExporter } from '../../src/execution/tracing';
+import { AgentExecutor } from '@lousho/build-ai-agent/executor';
+import { AgentBuilder } from '@lousho/build-ai-agent/executor';
+import { ToolRegistry } from '@lousho/build-ai-agent/executor';
+import { LLMProvider, GenerateOptions, GenerateResult } from '@lousho/build-ai-agent';
+import type { TraceExporter } from '@lousho/build-ai-agent';
/** A tiny mock provider: one tool call, then a final answer. */
class MockProvider implements LLMProvider {
diff --git a/examples/waves/index.test.ts b/examples/waves/index.test.ts
index ebfbd83a0..1844b8611 100644
--- a/examples/waves/index.test.ts
+++ b/examples/waves/index.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { createAgent } from '../../src';
-import { mockModel } from '../../src/testing';
+import { createAgent } from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
import { runWaves } from './index';
/** A worker factory handing each new agent the next scripted line. */
diff --git a/examples/waves/index.ts b/examples/waves/index.ts
index e9a2b0169..97fa2cc39 100644
--- a/examples/waves/index.ts
+++ b/examples/waves/index.ts
@@ -23,8 +23,8 @@
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { z } from 'zod';
-import { createAgent, resolveProvider, type LLMProvider, type SimpleAgent } from '../../src';
-import { mockModel } from '../../src/testing';
+import { createAgent, resolveProvider, type LLMProvider, type SimpleAgent } from '@lousho/build-ai-agent';
+import { mockModel } from '@lousho/build-ai-agent/testing';
export const LIVE_MODEL = 'openrouter/openai/gpt-4o-mini';
diff --git a/examples/workflow-router/index.ts b/examples/workflow-router/index.ts
index 94d226020..dd856a33c 100644
--- a/examples/workflow-router/index.ts
+++ b/examples/workflow-router/index.ts
@@ -7,9 +7,9 @@
*
* Run with: tsx examples/workflow-router/index.ts
*/
-import { createAgent } from '../../src/createAgent';
-import { createMockProvider } from '../../src/providers/mock';
-import { resolveProvider } from '../../src/providers/resolveProvider';
+import { createAgent } from '@lousho/build-ai-agent';
+import { createMockProvider } from '@lousho/build-ai-agent';
+import { resolveProvider } from '@lousho/build-ai-agent';
const provider = process.env.OPENAI_API_KEY
? resolveProvider('openai/gpt-4o-mini')
diff --git a/llms-full.txt b/llms-full.txt
index 579b1eaa8..362492c27 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -1765,7 +1765,7 @@ files. Every option is passed on to the agent, including the ones no file
declares (`guardrails`, `onEvent`, `retry`, `reasoning`, `redactContent`,
`output`, ...). Use it to swap the model in tests:
-```ts
+```ts no-run
import { loadAgentDir } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
@@ -3194,7 +3194,7 @@ const notes = defineMemory({
});
const agent = createAgent({
- provider: mockModel(['Hi!']),
+ provider: mockModel(['Hi!', 'Hi again!']), // one scripted reply per turn below
model: ({ principal }) => (principal?.claims?.plan === 'pro' ? 'openai/gpt-4o' : 'openai/gpt-4o-mini'),
instructions: ({ principal }) => (principal?.type === 'service' ? 'Answer in JSON.' : 'Be friendly.'),
memory: [notes],
@@ -3858,7 +3858,7 @@ const channels = mountFetchChannels(agent, [slack], { waitUntil: (turn) => void
`httpChannel()` is the reference channel: `{ sessionKey, input }` in, JSON out.
-```ts
+```ts no-run
import * as http from 'node:http';
import { createAgent, createMockProvider, httpChannel, mountChannels } from '@lousho/build-ai-agent';
@@ -7614,8 +7614,11 @@ if (original && replayed) {
leases, the last save wins.
- A run id (`sessionId` of `send()` / `stream()`) is 1-200 characters of
letters, digits, `_`, `-` and `.`, not starting or ending with `.`, on every
- store, and may not end with `.turn-` or `.paused`: those are a session's
- own checkpoint keys. Anything else fails with `LOUSHO_SESSION_ID_INVALID`.
+ store, and may not end with `.turn-` or `.paused` (a session's own
+ checkpoint keys) or `.fire` (a schedule's fire claim). Anything else fails
+ with `LOUSHO_SESSION_ID_INVALID`. A session id (`agent.session({ id })`, the
+ routes' `sessionId`) has a stricter rule: 1-128 characters of
+ `A-Za-z0-9_-`, with no `.`.
- If you call `resumeAfterApproval()` (the function behind
`agent.approvals.resolve()`) without the `checkpointStore`, the session
keeps its `'awaiting-approval'` mark and later `send()` (or `execute()`)
@@ -8141,7 +8144,9 @@ paused on an approval (`error.approvalId`), so it cannot take new input yet.
not 1-200 characters of letters, digits, `_`, `-` and `.` (not starting or
ending with `.`), the same rule on every store; or it ends with `.turn-