diff --git a/.dependency-cruiser-known-violations.json b/.dependency-cruiser-known-violations.json
index 387c91ef..bfd28946 100644
--- a/.dependency-cruiser-known-violations.json
+++ b/.dependency-cruiser-known-violations.json
@@ -12,35 +12,5 @@
"severity": "error",
"name": "core-knows-no-verbs"
}
- },
- {
- "type": "cycle",
- "from": "packages/core-agent/session/runtime/env-events.ts",
- "to": "packages/core-agent/session/runtime/instance.ts",
- "unresolvedTo": "./instance.ts",
- "dependencyTypes": [
- "local",
- "import"
- ],
- "rule": {
- "severity": "error",
- "name": "no-circular"
- },
- "cycle": [
- {
- "name": "packages/core-agent/session/runtime/instance.ts",
- "dependencyTypes": [
- "local",
- "import"
- ]
- },
- {
- "name": "packages/core-agent/session/runtime/env-events.ts",
- "dependencyTypes": [
- "local",
- "import"
- ]
- }
- ]
}
]
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 4bc09a8e..f9e5e434 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -23,7 +23,6 @@
"area:agent":
- changed-files:
- any-glob-to-any-file:
- - packages/core-agent/**
- packages/app-vscode/**
- packages/acp-fake/**
- packages/acp-scenarios/**
diff --git a/CLAUDE.md b/CLAUDE.md
index 6ee25b17..00e3a42e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -60,13 +60,11 @@ packages/core-db/ nodes and edges. All graph access goes through TenantGra
packages/core-domain/ research actions. Verb-first: no createClaim(), only recordAnalysis().
packages/app-mcp/ the agent surface.
packages/app-cli/ the terminal surface. A composition root, nothing else.
-packages/core-agent/ durable, inspectable agent sessions: journal, projections, providers, effects.
```
The dev stack runs labkit-effect's ACP agent behind the HTTP bridge `labkit-agent-http.ts` in
`packages/app-web/scripts/`; the VS Code extension runs whichever agent its settings name.
-`packages/core-agent` has its own guidance in `docs/agent/`; start at `docs/agent/guidance.md`.
-`bun run test:agent` runs its tests.
+`docs/acp-language.md` says what ACP's messages mean and oblige.
`packages/core-db/domain.ts` is the domain as graph structure — labels, edges, property
shapes. `packages/core-domain/` is the domain as it matters to a researcher.
diff --git a/bun.lock b/bun.lock
index 1dadd02c..c5a2c76f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -103,7 +103,6 @@
"version": "0.1.0",
"dependencies": {
"@agentclientprotocol/sdk": "catalog:",
- "@labkit/core-agent": "workspace:*",
"@labkit/ui": "workspace:*",
"@labkit/view-model": "workspace:*",
"react": "catalog:",
@@ -161,18 +160,6 @@
"vite": "^7.1.5",
},
},
- "packages/core-agent": {
- "name": "@labkit/core-agent",
- "version": "0.1.0",
- "dependencies": {
- "@logtape/logtape": "catalog:",
- "zod": "catalog:",
- },
- "devDependencies": {
- "@logtape/testing-bun": "catalog:",
- "prettier": "^3.8.3",
- },
- },
"packages/core-db": {
"name": "@labkit/core-db",
"version": "0.0.0",
@@ -454,8 +441,6 @@
"@labkit/app-web": ["@labkit/app-web@workspace:packages/app-web"],
- "@labkit/core-agent": ["@labkit/core-agent@workspace:packages/core-agent"],
-
"@labkit/core-db": ["@labkit/core-db@workspace:packages/core-db"],
"@labkit/core-domain": ["@labkit/core-domain@workspace:packages/core-domain"],
@@ -470,10 +455,6 @@
"@logtape/logtape": ["@logtape/logtape@2.4.0", "", {}, "sha512-xhvG/NwfBXs+a+oJxl6xOmqA4zX8giP1brvd5lVbL3qUHUkZfVt+4QE/+9p/Vf76JNwe/upH3XYTNItocKQ8Qg=="],
- "@logtape/testing": ["@logtape/testing@2.3.8", "", { "peerDependencies": { "@logtape/logtape": "^2.3.8" } }, "sha512-KWoBfhJ4w/9Bc68q6PsUA9NHqWVGkG919OoTivgfalXSuf5xl346eY7Yr5rN3hu7lm+7kHgYu6lbrolEsPY62A=="],
-
- "@logtape/testing-bun": ["@logtape/testing-bun@2.3.8", "", { "dependencies": { "@logtape/testing": "^2.3.8" }, "peerDependencies": { "@logtape/logtape": "^2.3.8" } }, "sha512-7QW/qfX2gkeb22TmBYxJqNBqgsADHoFI0jOi/fLeiSv/bkAo9FA6kL4cTO9GPJh4W3WFbE4XyA2pY9jSiEJrhQ=="],
-
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.1", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA=="],
"@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="],
@@ -1752,6 +1733,8 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
+ "@labkit/app-vscode/@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
+
"@vscode/vsce/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
@@ -1824,6 +1807,8 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
+ "@labkit/app-vscode/@types/bun/bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
+
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
diff --git a/docs/agent/acp-language.md b/docs/acp-language.md
similarity index 100%
rename from docs/agent/acp-language.md
rename to docs/acp-language.md
diff --git a/docs/agent/agent-flow-diagrams.md b/docs/agent/agent-flow-diagrams.md
deleted file mode 100644
index e8e73712..00000000
--- a/docs/agent/agent-flow-diagrams.md
+++ /dev/null
@@ -1,119 +0,0 @@
-# Operation identity and cancellation
-
-These diagrams explain actor behavior shared by both runtimes. Actor commit installs immutable
-state; it does **not** mean journal persistence. The session adds the
-[receipt gates](session-runtime.md) before commands execute. Dashed returns below are outcomes,
-not permission to bypass those gates.
-
-## A turn has a permission phase
-
-```mermaid
-stateDiagram-v2
- [*] --> idle
- idle --> awaiting_model: user, steps remain / consume step
- idle --> done: user, no steps / exhausted
- idle --> done: abort
- awaiting_model --> done: answer
- awaiting_model --> awaiting_permission: tools, permissions ask
- awaiting_model --> executing_tools: tools, permissions off
- awaiting_permission --> executing_tools: every call approved or refused
- awaiting_permission --> done: failure or cancellation
- awaiting_model --> awaiting_model: handoff, steps remain / successor's step
- awaiting_model --> done: handoff, no steps / exhausted
- executing_tools --> awaiting_model: batch success, steps remain / consume step
- executing_tools --> done: batch success, no steps / exhausted
- executing_tools --> done: batch failure or cancellation
- executing_tools --> cancelling_tools: abort
- cancelling_tools --> done: batch outcome, preserve accepted results
- awaiting_model --> done: projection or completion failure, or cancellation
- done --> idle: record terminal turn and reset allowance
-```
-
-`awaiting_model` is one host operation: it projects the step's prompt from the journaled facts, then
-calls the model. A handoff sets the turn's view to a marker naming the predecessor and the message
-index where the handoff happened; the successor's step renders the handoff packet from it. Neither
-the prompt nor the packet is journaled.
-Replacement input is omitted from this diagram for readability; the sequence below covers it.
-A refused call does not stop the turn: the batch still runs, answering that call with a
-permission-refused tool result the model reads on its next step. Permission-dialog cancellation
-aborts the turn; an invalid response or port failure fails it. The distinction must survive into a
-client-facing result.
-
-## Replace model work without accepting its late answer
-
-```mermaid
-sequenceDiagram
- participant A as Application
- participant C as Conversation
- participant H as Shared host
- participant O as Old operation
- participant N as New operation
- A->>C: replacement user input
- C->>C: install new child identity in the same turn
- C->>H: cancel old child; start replacement
- H->>O: abort signal
- H->>N: start
- N-->>H: result for new child
- H-->>C: correlated outcome
- C->>C: accept matching turn and child
- O-->>H: late result, if adapter ignored signal
- H->>H: ignore already-cancelled operation result
- Note over C,H: Conversation also rejects mismatched turn or child identities.
-```
-
-Both inputs settle with the same terminal turn. Under the default policy, replacement applies to a
-step in flight (including a handoff successor's first step), not tool or permission work. A timeout uses the same cancellation
-machinery with a distinct typed reason; it does not spawn a replacement automatically.
-
-## Cancelling a batch preserves what was accepted
-
-```mermaid
-sequenceDiagram
- participant A as Application
- participant C as Conversation
- participant H as Host
- participant B as Tool batch
- participant T as Tools A and B
- H->>T: start both calls
- T-->>H: A succeeds
- Note over H,B: Session saves A's result before release. In-memory runtime releases immediately.
- H->>B: accept A result
- A->>C: abort
- C->>C: enter cancelling_tools
- C->>H: cancel batch
- H->>B: cancel
- B->>H: cancel unfinished B
- H->>T: abort B signal
- B-->>C: cancelled batch with A result, through host
- C->>C: record aborted turn with accepted result
-```
-
-Tool B may already have changed an external service. Its cancellation is not proof that nothing
-happened. On failure, unfinished siblings receive the initiating call's cause so the public outcome
-and logs can explain why the rest of the batch stopped.
-
-## An operation settles once
-
-```mermaid
-stateDiagram-v2
- [*] --> ready
- ready --> validating_input: start
- validating_input --> running: valid input
- running --> validating_output: returned
- validating_output --> succeeded: valid output
- validating_input --> failed: invalid input
- running --> failed: execution failure
- validating_output --> failed: invalid output
- ready --> cancelled: cancel
- validating_input --> cancelled: cancel
- running --> cancelled: cancel
- validating_output --> cancelled: cancel
- succeeded --> [*]
- failed --> [*]
- cancelled --> [*]
-```
-
-Cancellation carries a reason. The surrounding host/turn translates a timeout reason into a failed
-terminal outcome with classification `timeout`; user cancellation remains distinguishable. A
-terminal actor does not accept a late success. Keep arbitrary Error objects and AbortControllers
-outside snapshots; retain serializable causes and operation identity inside outcomes.
diff --git a/docs/agent/core-runtime.md b/docs/agent/core-runtime.md
deleted file mode 100644
index 61449547..00000000
--- a/docs/agent/core-runtime.md
+++ /dev/null
@@ -1,75 +0,0 @@
-# Why the runtime uses actors
-
-An external operation can finish after the caller has cancelled it, replaced it, or closed the
-session. The runtime must decide whether that outcome still belongs to current work. Serialized
-actor mailboxes and explicit operation identities provide that decision boundary.
-
-Use the [session API](../../packages/core-agent/session/README.md) for durable integrations. The `/agent`
-runtime uses the same host but keeps history in memory. Its event-processing acknowledgement does
-not promise that a turn has finished, and it has no crash-recovery guarantee.
-
-## Commit a decision before starting its effects
-
-A decision returns the complete next state and commands synchronously. The mailbox freezes that
-state before dispatching commands. If a fast adapter reports back immediately, it therefore sees
-the operation that the decision actually installed. Mutating state later or starting I/O inside a
-decision would break this ordering and make replay depend on timing.
-
-Runtime authors should keep controllers, callbacks, credentials, and resource handles in the host.
-Snapshots contain immutable data; holding an old snapshot remains safe while the runtime advances.
-Session commit additionally means a validated storage receipt. In-memory actor commit only means
-that the next state is installed; the two guarantees are different.
-
-## Correlate work by identity, not by timing
-
-Every child outcome carries its turn and operation identity. The active state names the child it
-will accept. Barge-in during model work installs a replacement child before cancelling the old one.
-A late response from the old child cannot become the replacement's answer, even if its adapter
-ignored cancellation. Do not bypass this check by routing completion callbacks directly into a UI's
-conversation history.
-
-User input cannot replace tool execution under the default policy: a tool may already have changed
-the outside world. Explicit abort stops the batch locally and preserves accepted partial results.
-The session policy layer can queue successor input or request tool cancellation before starting it.
-[Actor diagrams](agent-flow-diagrams.md) illustrate the distinction.
-
-## Validate at the point where trust changes
-
-Model output is untrusted even if its JSON parses. Completion admission checks tool names, handoff
-targets, and call correlation before creating tool work. Each tool then validates arguments and
-output. An output-validation failure can occur after an external effect, so it must carry the same
-identity and diagnostic cause as execution failures.
-
-A batch owns call membership and partial results. It ignores duplicate/unknown outcomes; successful
-settlement requires every original call exactly once. The turn owns only the batch outcome. This
-keeps parallel-call bookkeeping out of turn decisions and gives cancellation one place to preserve
-results. Sessions additionally commit individual results before releasing them to the batch.
-
-## Preserve the difference between history and a model request
-
-Stored history records what occurred. Projection chooses what a model needs now. In an interrupted
-historical tool exchange, the default projection can omit calls without results so the next request
-is valid; it does not rewrite the stored evidence. Missing results in a completed exchange are an
-error rather than context to silently discard.
-
-A step is one model attempt: its prompt is projected and its model called as a single operation,
-including replacement/handoff/tool-loop continuation. A step counts when it starts, so a barge-in
-that replaces an in-flight step uses another, and a handoff's successor starts its own step. The
-prompt is recomputed from the journaled facts each time and never journaled. Media the target
-cannot, or might not, read becomes a pointer; missing or corrupt attachment bytes, or an invalid
-projected exchange, fail the step before any HTTP request.
-Zero allowance exhausts the turn without dispatch.
-This bounds a loop, not tokens, elapsed time, or external effects. Model settings, projection, and
-input admission are described in the [policy guide](../../packages/core-agent/policy/README.md).
-
-## Fork at a completed boundary
-
-A fork waits for the active turn to end, captures that exact boundary, and receives independent
-execution resources. Reading the parent's snapshot later would let a subsequent input leak into
-the child. Compaction is the same boundary with caller-supplied replacement context, not an
-implicit model summary. For persisted publication and interrupted creation, use the
-[session diagrams](session-runtime.md#branch-publication-has-two-persistence-boundaries).
-
-When changing this machinery, test immediate replies, late replies after cancellation, duplicate
-call IDs, partial tool results, and a queued event at the fork boundary. Happy-path transcripts
-alone cannot establish these ordering guarantees.
diff --git a/docs/agent/glossary.md b/docs/agent/glossary.md
deleted file mode 100644
index 417c4add..00000000
--- a/docs/agent/glossary.md
+++ /dev/null
@@ -1,152 +0,0 @@
-# Glossary
-
-These are the canonical meanings of Labkit's domain terms. Use them in code, docs, plans and agent
-prompts. When existing code uses a word differently, the "Code today" note says so. Those uses are
-naming debt, not alternative definitions.
-
-## Conversation structure
-
-**Turn.** Everything from a user prompt until the model stops. After that the conversation waits for
-the next prompt. One turn can contain hundreds of steps and last an hour.
-Code today: `TurnState` (`packages/core-agent/agent/agent-fsm.ts`). ACP calls a turn a "prompt turn":
-one `session/prompt` request and everything up to its response. VS Code calls it a "prompt".
-Not to be confused with the provider stop reason `end_turn`.
-
-**Step.** One LLM call. The model emits narrative and tool-call decisions. The harness dispatches those
-tool calls and gathers their results, and the results go back to the model in the next step.
-Code today: "step" in `docs/core-runtime.md` and the policy's `steps` allowance. `TurnData.generation`
-is not a step counter; it counts child operations: one per step, plus one per permission request and
-tool batch.
-
-**Step boundary.** The point where the next completion request is assembled from history, the current
-turn and any new context. This is where interjections are inserted.
-Code today: prompt projection inside the `complete` host operation, which projects the prompt with
-the policy `project` resolver (such as `history@1`) and then makes the LLM call. The prompt is not
-journaled. The provider profile's `encode` only formats the request; it adds no content.
-
-**Settled step.** A step whose model output is committed to the journal (`model_settled`). Queued
-notices wait for this point. Other things also "settle" (operations, tool batches, turns, append
-receipts); always name which one.
-
-**Tool call / tool batch.** A tool call is one invocation proposed by a step. The tool batch is all
-calls proposed by one step. Code today: the next step waits until the whole batch has settled. That
-is current behaviour, not a requirement (see Backgrounded tool call).
-
-## Getting context to the model mid-turn
-
-**Interjection.** Context that arrives while a turn is running and is delivered to the model at the
-next step boundary. The turn continues. There are four kinds:
-
-- **Steering:** input from the user.
-- **Advisory:** guidance from an advisor reviewing the work.
-- **System notice:** code-based rules, TODO-list reminders, messages from other sessions or agents.
-- **Memory recall:** memories surfaced by what the agent is doing, such as reading or editing a file.
-
-An interjection that has gone stale should be dropped or re-checked before delivery. A note is stale
-when its subject changed after it was written.
-Status: not implemented. There is no interjection event type. User input during tools is rejected
-under `default@1`, and at best (`queue-user`) it waits until the turn ends. `system` events are
-refused while a turn is active.
-
-**Backgrounded tool call.** A tool call that is still running when the next step starts. The model
-receives a placeholder result ("running in the background") so that the interjection can be
-delivered, and the real result arrives later as ordinary content.
-Status: not implemented. The turn machine holds one child operation at a time and moves on only
-after the whole batch settles.
-
-**Barge-in.** Cancelling a live completion, streaming or not, and re-issuing that step with the new
-context. This is a separate feature from interjection at the step boundary.
-Code today: the `bargeIn` policy flag; some docs call it "replacement". It is implemented only for
-user input while a step is in flight (prompt projection and LLM call are one operation). The partial
-stream is discarded, and the re-issued call uses up a step.
-
-**Queued input.** User input held for delivery after the current turn (`pendingInputs`, the
-`queue-user` policy). It is not an interjection: it waits for the end of the turn, not for the next
-step.
-
-## Configuration and history
-
-**Configuration.** The user-selectable settings of a session: provider, model, thinking, output limit,
-streaming, permissions, tool scope and tool-failure handling. Changing it is two transitions:
-selecting a value commits it to the session's configuration store immediately, at any time; applying
-it records a "configuration applied" fact at the next boundary between turns. A turn already in
-progress keeps the settings it started with. Users choose providers and models by name. Adapter
-profile versions (such as `anthropic-messages@4`) are internal and are never offered as choices.
-Code today: `updatePolicy(patch)` selects (the store is the persistence port's `getConfig`/`putConfig`)
-and the "configuration applied" fact is a `policy` record holding the resulting settings.
-
-**Model catalog.** The list of providers and models the environment can use, together with each
-model's thinking choices and output limit. It is built from the committed models.dev snapshot, plus a
-localhost server if one is reachable (`packages/core-agent/providers/catalog.ts`).
-
-**Registry.** The live tool and agent definitions a session runs with. When a session is reopened, a
-registry that differs from the saved one is adopted: the first new work records the live registry in
-the journal, and the saved history stays unchanged.
-
-**History.** The committed record of what happened: prompts, model output, tool calls and results,
-and configuration changes. History is a record of facts. Reopening a session must never require
-today's models, tools or settings to match those of the past.
-
-**Load.** Rebuilding session state from the journal. This is a fold over committed records, checked
-for journal integrity only: schema, batch and revision continuity, append and entry identifiers,
-session identity, the creation record first, and terminal records where a turn ended. A record must
-also name a turn, operation or queued input that exists in the folded state. Commit-time rules are
-not re-run, and no projection is recomputed.
-Code today: `replay()` in `session-log.ts`; a failure is a `JournalIntegrityError` naming the rule
-and the offending record. No prompt is stored. A terminal record's agent and outcome are the
-turn-ended fact and are taken as written; effect records have no fold precondition. A policy record
-holds the settings a configuration applied, and load applies it as written.
-
-**Recovery.** Closing a turn that was interrupted by process exit when the session is reloaded. External
-effects are not repeated. Do not confuse it with **reconciliation**, which resolves a storage append
-whose outcome was uncertain.
-
-## Facts, projections and effects
-
-See [session model](session-model.md) for the full reference.
-
-**Fact.** Something that happened in the conversation, recorded once in the session journal: user
-input, model output, a tool result, a permission decision, a configuration being applied, a turn
-ending. The journal holds facts and nothing else.
-
-**Projection.** A view computed from facts, never stored as history: the next LLM prompt, a transcript,
-ACP replay, `/export`. The prompt projection also takes the target model's capabilities, and renders
-any part the target cannot read, or might not read, as a pointer.
-
-**Effect.** An interaction with the outside world: an HTTP request and response, token usage and
-cost, raw tool output, a permission request. Effects are emitted as events where they happen and
-consumed by logging, capture, accounting and hooks. The journal may record chosen effects (such as
-per-step usage) as records typed separately from facts; the conversation fold ignores them.
-Code today: host, tool, permission and provider effects are typed `EffectEvent`s emitted through a
-bound emitter (`packages/core-agent/effects`); session lifecycle, journal appends and loads, and the ACP
-adapter still log with direct `diagnostic()` calls. Token usage is an `effect` record staged after
-the step's model output in the same append.
-
-**Decision point.** A named seam where a policy chooses what happens next, for example tool
-permission, provider selection or a budget veto. A policy may read facts, configuration and data
-gathered from effects. An outcome that changes the conversation is recorded as a fact.
-
-**Blob URI.** `blob://.`, the reference form of stored binary content. The harness resolves
-it against whatever store holds the bytes; a model that reads it with a tool receives the content.
-
-**Session journal / session log.** The journal holds facts. "Session log" means the effect log.
-Code today: `session-log.ts` is the journal module.
-
-## Words with more than one meaning in code today
-
-Qualify these words, or rename them in new code:
-
-- **admission:** a durable input receipt, the policy field for handling input during a turn, and
- staging failures. Prefer "input receipt" and "mid-turn input policy".
-- **batch:** a tool batch, and a journal append batch (`CommittedBatch`).
-- **child:** a child operation of a turn, and a child session created by fork or compaction.
-- **settle(d):** operation, tool batch, turn, and append receipt.
-- **pending / queue:** durable queued inputs, the in-memory submissions awaiting a storage receipt, and
- staged appends.
-- **projection:** now one concept (any view computed from facts). Name which projection: the prompt
- projection, the web console view, ACP replay.
-- **continuation:** a provider continuation payload (thinking signatures), the next step of a turn,
- and continuing after a tool error.
-- **system:** standing session instructions (`systemInputs`), an agent's `systemPrompt`, and the
- `system` chat role. None of these is a system notice.
-- **replacement:** barge-in, compaction context, and registry replacement.
diff --git a/docs/agent/guidance.md b/docs/agent/guidance.md
deleted file mode 100644
index ab7dccab..00000000
--- a/docs/agent/guidance.md
+++ /dev/null
@@ -1,206 +0,0 @@
-# Agent runtime guidance
-
-Guidance for `packages/core-agent`: an experimental TypeScript runtime for durable, inspectable
-agent sessions. The repository's own rules in
-`CLAUDE.md` apply as well; where the two differ on formatting or checks, `CLAUDE.md` and the
-repository's checks win.
-
-## Find the right documentation
-
-When a module has a README, read it before editing that module. Module READMEs own local
-APIs, boundaries, invariants, and testing notes. Higher-level and cross-module
-architecture belongs in `docs/agent/`.
-
-- `packages/core-agent/agent/`: pure turn and conversation decisions, prompt projection,
- operation actors, and the nonjournaled runtime.
-- `packages/core-agent/fsm/`: typed decisions, immutable snapshots, and serialized actor
- mailboxes.
-- `packages/core-agent/session/`: journaled execution, persistence receipts, settlement,
- restore/recovery, and durable branches. See its `README.md`.
-- `packages/core-agent/host/`: shared resource ownership, child routing, cancellation, and
- completion/tool ports. See its `README.md`.
-- `packages/core-agent/policy/`: versioned input, projection, handoff, tool-error, and provider
- policies. See its `README.md`.
-- `packages/core-agent/providers/`: pure versioned provider profiles and the shared fetch
- transport. See its `README.md`.
-- `packages/core-agent/environment/`: asynchronous event sources and renderer bindings. See
- its `README.md`.
-- `packages/core-agent/logging/`: environment-owned diagnostic logging. See its `README.md`.
-
-Read `docs/agent/glossary.md` before writing plans, docs or code that name runtime concepts
-(turn, step, interjection, barge-in, configuration, load). Its definitions are canonical; code
-that uses those words differently is naming debt.
-
-Read `docs/agent/session-model.md` before changing what the journal records, how prompts or views
-are built, or where effects are logged. It defines facts, projections, effects and decision
-points; where code or other docs disagree with it, the code is the divergence.
-
-Use `docs/agent/core-runtime.md` and `docs/agent/agent-flow-diagrams.md` for the underlying agent
-runtime. Use `docs/agent/session-runtime.md` for persistence and session flows.
-`docs/agent/acp-language.md` says what ACP's messages mean and oblige, as the specification
-states them.
-
-When documentation and implementation differ, source and tests describe current
-behavior. Update the relevant module README for a local contract change and the
-appropriate file in `docs/agent/` for a cross-module architecture change.
-
-## Commands
-
-Run commands from the repository root:
-
-```sh
-bun run test:agent # core-agent's tests
-bun run test:agent:logs # the same with debug diagnostics printed
-bun test path/to/file.test.ts # scope tests to a file
-bun run typecheck
-bun run check # everything, before committing
-```
-
-Use Bun rather than Node.js, npm, pnpm, or Yarn. Use `bun:test` for tests and prefer
-Bun's built-in APIs where applicable. Bun loads environment files automatically; do
-not add dotenv.
-
-For behavior changes, run focused tests, the relevant module suite, and type checking.
-The codebase contains compile-time assertions that the test runner alone cannot
-validate. Documentation-only changes need formatting and diff review rather than application tests.
-
-## Definition of done: observability and traceability
-
-A feature or behavior change is not done until it can be diagnosed from its runtime
-logs. Passing functional tests, producing journal fixtures, or writing files under
-`session_artefacts` / `.session-artifacts` does not satisfy this requirement. Users
-must not need to copy protocol traffic from a UI or reproduce a failure under a
-debugger to discover why an operation stopped.
-
-- Include observability in the feature's acceptance criteria and implementation,
- not as follow-up work. Instrument meaningful lifecycle events: admission, dispatch,
- waiting and its reason, state transitions, completion, failure, cancellation, and
- recovery where applicable. Successful execution must also leave useful evidence.
-- Make events traceable across boundaries. Use stable event names and structured
- fields with the applicable session, turn, operation/child, tool call, request, and
- append IDs. Include relevant provider/profile versions, configuration changes,
- statuses, limits, and timing so an operator can reconstruct what happened.
-- Preserve diagnostic causes through error translation. Record the actual provider
- stop reason, HTTP status, provider request ID, error code, and useful error details
- when available. Do not replace this evidence with only "Internal error", "Agent
- turn failed", a list of allowed schema values, or an unexplained status code.
- Distinguish exhausted limits, rejected permissions, cancellation, invalid input,
- missing bindings, and transport/provider failures.
-- Protect secrets through targeted field exclusion or redaction. Do not use
- "security", "privacy", or "safe logging" as a reason to swallow exceptions,
- discard causes, or remove the evidence needed to debug the system. Routine
- diagnostics do not require prompts, file contents, credentials, or authorization
- headers. Preserve useful error metadata and explanations without those payloads.
-- Environment launchers must provide durable, bounded diagnostic output with a
- documented location, levels, rotation/retention, and retrieval procedure. Stderr
- or a transient UI Output channel alone is insufficient for a launched agent.
- Keep ACP stdout exclusively for protocol traffic. Core must not configure global
- logging or own file sinks; log at runtime/adapter boundaries, keeping decisions pure.
-- Add tests that assert the feature's expected diagnostic events and correlation
- fields on successful and relevant failed/cancelled paths. Exercise the real
- instrumentation, not test-only print statements. Verify sensitive fields are
- excluded without erasing diagnostic causes.
-- Run the affected tests with debug logging enabled and inspect the emitted output:
-
- ```sh
- LOGTAPE_TEST_MODE=always LOGTAPE_TEST_LOWEST_LEVEL=debug bun test path/to/affected.test.ts
- ```
-
- Check that the new behavior produces the expected new events or meaningful fields
- in existing events. Unchanged log output across newly implemented lifecycle paths
- is a gap to investigate, not evidence of success. Test capture must show the logs
- that runtime instrumentation emits when the feature works and when it fails.
-
-Completion reports must state what diagnostic evidence was verified and where an
-operator can retrieve it. If instrumentation or durable launcher logging is missing,
-report the work as incomplete; do not claim that passing tests makes it done.
-
-## Meaningful Failure Reports
-
-- Make failure reports self-contained. State which operation failed, what it was
- trying to do, what prevented it, and the consequence. Include relevant tool names,
- paths, arguments, configuration values, or limits. `outcome="failed"` alone is
- not an explanation.
-- Preserve the original error and its cause. Distinguish observed facts from
- inference; if the cause is unknown, say so. Do not replace specific errors with
- generic messages such as "operation failed".
-- Use stable event names and consistent fields. Include correlation IDs to connect
- the report to surrounding events; IDs supplement an explanation, not replace it.
-- Choose severity for the event's meaning. Routine lifecycle events belong at
- DEBUG or INFO. Warnings must identify a condition or intervention worth
- investigating—for example, a user refusing a model-requested tool action.
- Explain its consequence, such as blocking the entire tool batch.
-- Log the cause where it is known and the resulting outcome where it is committed.
- Make their relationship explicit. Do not force readers to infer the cause from
- an unrelated operation's generic failure.
-- Test diagnostic usefulness, not just event presence. Inspect persisted output:
- a WARNING/ERROR-only scan must expose the important exception or intervention;
- surrounding INFO/DEBUG records must explain the sequence. Assert meaningful
- context and severity, including the absence of warnings during routine success.
-- Redact API keys without discarding the evidence needed to diagnose the problem.
-
-The central rule is: **a reader should need surrounding logs to understand the sequence,
-not to discover what the warning means.**
-
-## Runtime conventions
-
-- Keep decisions pure and synchronous. Return the complete next state and commands;
- commit and freeze state before dispatching commands.
-- Keep controllers, callbacks, registries, credentials, persistence handles, and other
- mutable resources outside domain snapshots.
-- The shared host owns operation resources, child routing, cancellation, completion
- ports, and tool ports. Do not duplicate those responsibilities in agent or session
- code.
-- The session layer owns journal staging, persistence receipts, reconciliation,
- settlement, restore, and durable branch publication. Never release dependent work
- before its matching append receipt commits.
-- Commit individual tool results before releasing them to the tool batch. Preserve the
- existing identity and correlation checks for turns, children, batches, and calls.
-- Treat an indeterminate append as unknown, not failed. Reconcile the stable append ID
- and bytes through a load before retrying or stopping.
-- Policies and provider profiles are versioned, pure data/code bindings. Policy patches
- take effect at committed boundaries; they do not replace live registries or mutate an
- operation already in flight.
-- Provider profiles only encode and decode. Fetch work runs through the shared host
- operation machinery, and credentials and origins remain environment bindings rather
- than journal data.
-- The nonjournaled agent runtime publicly accepts user and abort events. The session
- environment also admits system, policy, fork, compact, and close events; child
- outcomes stay private.
-- Persistence and logging lifetimes belong to the environment or caller. Diagnostic
- logs never certify a journal commit.
-- Use discriminated unions, branded domain values, and Zod validation at trust
- boundaries. Reuse existing asynchronous test helpers and injected ports.
-
-## Session fixtures
-
-Run the fixture inspector when changing session ordering, persistence, policy, or
-recovery behavior:
-
-```sh
-bun run packages/core-agent/session/fixture-runner.ts
-bun run packages/core-agent/session/fixture-runner.ts --v2
-```
-
-Approved fixture baselines are review artifacts. Use `--update` only when intentionally
-accepting a behavior change, then review the generated diff. Do not casually replace
-baselines or commit ignored inspection output (`.session-artifacts/`).
-
-## Style and generated files
-
-Biome is the formatter (`bun run format`) and the linter; import order is not managed. Use two
-spaces, double quotes, semicolons, trailing commas, and a 100-column width. The journal fixture
-baselines under `packages/core-agent/session/fixtures` are written by the fixture runner through
-Prettier (`packages/core-agent/prettier.config.mjs`) and are never formatted by hand.
-
-Preserve strict TypeScript behavior, including `noUncheckedIndexedAccess` and
-`verbatimModuleSyntax`. Avoid unrelated formatting changes. Do not edit generated
-output in `dist/`, dependencies in `node_modules/`, or ignored session inspection
-artifacts.
-
-- Separate top-level declarations with exactly one blank line. This includes
- functions, classes, types, interfaces, and module-level variables.
-- Separate function declarations—including nested functions and functions assigned
- to variables—from adjacent statements or declarations with one blank line.
- No blank line is needed at the beginning or end of a scope.
-- Check this spacing during diff review; formatter output does not waive the rule.
diff --git a/docs/agent/session-model.md b/docs/agent/session-model.md
deleted file mode 100644
index ae6d4ede..00000000
--- a/docs/agent/session-model.md
+++ /dev/null
@@ -1,187 +0,0 @@
-# Session model: facts, projections, effects and decisions
-
-This is the reference for how a session records what happened and how every view of it is produced.
-When code or another document disagrees with this page, the code is wrong and the page wins. The
-"Code today" notes list the known divergences; the correction work removes them.
-
-## Four concerns
-
-| Concern | What it is | Where it lives | Example |
-| -------------- | ------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- |
-| **Fact** | Something that happened in the conversation | The session journal, written once | the user's text and attachments, the model's output, a tool's result |
-| **Projection** | A view computed from facts | Nowhere; it is recomputed when needed | the next LLM prompt, the VS Code transcript, `/export` |
-| **Effect** | An interaction with the outside world | The effect stream: logs, traces, subscribers | an HTTP request and response, token usage, raw tool output |
-| **Decision** | A choice made at a named decision point | Pluggable policy code; its outcome is a fact when it changes the conversation | permission, provider selection, a budget veto |
-
-The journal is the input to every projection. It never stores projections. It holds facts and,
-separately typed, the effects the environment chooses to record. Decisions read facts,
-configuration and data gathered from effects, and they never re-derive a stored value in order to
-refuse a write.
-
-## Facts: the session journal
-
-A fact is recorded once, in the order it happened, and never rewritten. Content is recorded as typed
-parts: text, thinking, tool calls, and blob references for anything binary.
-
-| Fact | Records |
-| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Session created | origin (root, fork of a parent, compaction of a parent), inherited or seeded context, starting configuration |
-| Configuration applied | the settings in force from the next turn (provider, model, thinking, output limit, streaming, permissions, tool scope, tool-failure handling, projection) |
-| User input | text and blob references |
-| Model output | typed parts (text, thinking with any provider signature, tool calls, handoff), and which provider and model produced it |
-| Tool result | the call it answers, and succeeded (typed parts), failed (failure), cancelled, refused by permission, or running in the background (placeholder) |
-| Permission decision | what the user decided for each call |
-| Decision outcome | a decision point's outcome that changed the conversation, for example a vetoed step with its reason |
-| Turn ended | the outcome: completed, aborted, exhausted or failed, with the failure |
-| Interruption and recovery | a turn closed on reload after process exit |
-| Standing instructions | a new list of session instructions |
-| Registry adopted | the live agents and tools a reopened session continues with |
-
-These are **not** facts:
-
-- the prompt sent for a step (a projection; its HTTP body is an effect);
-- a copy of a turn's messages (derived from the facts above);
-- values derived from other facts (a tool batch's collected results, whether a step needs permission,
- the step allowance);
-- token usage, cache reads and writes, cost, stop details, request IDs, timings (effects).
-
-Provider continuation data (for example a thinking signature or an encrypted reasoning item) is part
-of the model output fact, because the next prompt to that provider needs it.
-
-### Recorded effects
-
-There is one journal, and each record is typed as a fact or an effect. The conversation fold reads
-facts only. Effect records are checked for integrity and nothing else; they never gate load or new
-work. The environment chooses which effects to record. The default records, for each step, what the
-provider reports: token usage including cache reads and writes, the stop reason, the provider request
-ID, and cost only when the provider reports it (the runtime never estimates it). Request and
-response bodies are side-car logging: an optional event subscriber writes them to a separate, dense
-log file for debugging. They are not a core concern, and the journal does not reference them.
-
-Any view of recorded effects is a projection, and what to show the user is a UI decision. For
-example, usage after a restore is shown from the recorded usage effects. A decision can read them
-too: a drop in cache reads to zero after a long pause shows that the provider's prompt cache has
-expired, which is the point where a compact-and-fork policy can discard stale context without losing
-a warm cache.
-
-### Blobs
-
-Binary content is stored once, addressed by its SHA-256, and referenced from facts by a structured
-blob reference (hash, media type, size, optional name). Its URI form is `blob://.`. The
-harness resolves a blob URI against whatever store holds the bytes. A model that reads a `blob://` URI
-with a tool receives the content.
-
-## Projections
-
-A projection is a pure function of facts and its own inputs. It is never journaled, never compared
-with a stored copy, and can change between versions without affecting load.
-
-**The next LLM prompt** is `project(facts, target, projection)`:
-
-1. `target` is the provider and model the step will call, with that model's capabilities. Each
- capability is three-valued: supported, unsupported, or unknown.
-2. The projection selects the history the policy asks for (full history, context only, or a handoff
- packet computed from the handoff fact).
-3. Each part is rendered for the target. A part the target supports is kept. A part the target does
- not support, or might not support, becomes a pointer, for example
- `[image/png, 68 KiB: blob://9f86d0….png]`. The model can read the pointer with a tool.
-4. Provider continuation data is kept only for the provider that produced it.
-5. The system prompt, standing instructions and the live tool list are added.
-6. The provider adapter encodes the result. How a kept blob is sent (inline base64, a files endpoint,
- a URL) is the adapter's choice.
-
-Other projections read the same facts: ACP `session/load` replay and live updates, the VS Code and
-web transcripts, `/export` Markdown and JSONL, session titles, and `.session-artifacts/` dumps. They
-share one message projection instead of each deriving conversation history separately.
-
-**Compaction** creates a new session whose seed is a lossy, possibly non-deterministic projection of
-the parent (a summary, old images as pointers). The seed is a fact in the child and a projection of
-the parent, which is why compaction creates a new session.
-
-## Effects
-
-Events are effects. Every event the runtime emits describes something it did to the outside world,
-and it is emitted at the point where that happens: the host's operation boundary, the provider
-transport around fetch, the tool runner, the permission and client ports, blob I/O and journal
-appends. The emitter is supplied by the environment through session bindings. Pure decisions and the
-journal fold never emit events; the fold also runs on load, where nothing happens. Committing a fact
-emits a "fact committed" event after the append, which is how live views learn about it; the fact
-itself is in the journal, not in the event stream.
-
-Effect events carry correlation IDs (session, turn, step, operation, tool call, HTTP request, append).
-Typical events: the rendered request and its digest, the provider response or stream, token usage and
-cost, tool start and end with a reference to raw output, permission requests, client file and
-terminal calls.
-
-Subscribers are cross-cutting and optional: diagnostic logging, provider request/response capture,
-usage and cost accounting, auditing, and hooks. Adding a subscriber changes no core code.
-
-## Decisions
-
-A decision point is a named seam where a policy chooses what happens next. Core ships default
-policies; an environment can replace or add them without changing core. A policy may read facts,
-configuration, and aggregates built from effects (for example usage totals kept by an accounting
-subscriber).
-
-| Decision point | Default | Example replacement |
-| --------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------- |
-| input during a turn | barge-in while waiting for the model; otherwise queue | deliver as an interjection at the next step boundary |
-| before a step is dispatched | proceed with the configured target | veto when a provider budget is exhausted; choose another provider from a list |
-| tool permission | ask the user, or allow | allow by rule |
-| tool failure | return the error to the model and continue | fail the turn |
-| unknown capability | render a pointer | send and let the provider's error surface |
-
-A decision that changes the conversation is recorded as a fact: a vetoed step, a refused permission,
-and the provider and model that produced an output. A decision that only chooses how to render or
-encode something is not recorded.
-
-Usage and cost are effects. They become input to a decision only through a policy that reads them,
-for example "use no more than 80% of my Anthropic subscription", followed by a provider selector
-that picks the next provider to try. Core never refuses work on its own account because of an
-effect.
-
-## Configuration
-
-Changing configuration is two transitions:
-
-1. **Select.** The user's choice is committed to the session's configuration store immediately, at
- any time, including while a turn is running.
-2. **Apply.** At the next boundary between turns, the runtime compares the selection with the
- configuration in force and, when they differ, records a "configuration applied" fact. The next
- turn runs with it; a running turn keeps the settings it started with.
-
-Views show both: the selected configuration and the configuration the transcript ran under.
-
-## Load
-
-Loading is a fold over the journal, checked for integrity only: record format, revision and batch
-continuity, identities, the creation record first, and correlation (every result names a call, turn
-or input that exists). It does not re-run commit-time rules or recompute projections. Reopening a
-session never requires today's models, tools or settings to match those of the past.
-
-## Invariants that stay
-
-- A tool result answers exactly one call of the step that proposed it; a call is answered once.
-- The next step waits for its inputs: every call of the previous step has a result or a placeholder.
-- A fact is committed before any work that depends on it is released.
-- Credentials never enter facts, projections or effect events; effect subscribers redact.
-
-## Code today
-
-The remaining divergences:
-
-- Token usage (with its HTTP provenance) is the only recorded effect: core stages it as an `effect`
- record in the step's append whenever the completion reports it; the environment cannot yet choose
- which effects to record, and cost and stop details are not recorded.
-- Media capability has no source for `unknown`: `mediaCapability()` marks every media kind a bound
- profile declares as supported and every other kind as unsupported, because the committed model
- catalog carries no per-model modality data. The projection already renders `unknown` as a pointer,
- and there is no replaceable policy for it.
-- There is no decision point before a step is dispatched (no budget veto or provider selector).
-- The core defaults differ from the table above: the default policy pack rejects input while tools
- run (`reject-during-tools`) instead of queueing it, and fails the turn on a tool failure
- (`fail-turn`); `queued@1` and `tolerant@1` select the other behaviours.
-- Journal appends, loads and session lifecycle (`session/runtime/`, `session-operation.ts`) still
- log with direct `diagnostic()` calls instead of emitting typed effect events through the bound
- emitter, and there is no "fact committed" event: live views observe session snapshots. The ACP
- client ports (files, terminal, elicitation, MCP) also log directly.
diff --git a/docs/agent/session-persistence.md b/docs/agent/session-persistence.md
deleted file mode 100644
index 3b43e071..00000000
--- a/docs/agent/session-persistence.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Journal and blob persistence
-
-The journal records execution decisions; blobs retain content too large or unsuitable for those
-records. Keep both for a usable restored session. A valid journal can restore without reading blobs,
-but the next request may fail if its attachment bytes were deleted.
-
-`SessionPersistence` has independent journal (`load`, `append`) and object-store (`putBlob`,
-`getBlob`) operations. Every operation takes an AbortSignal. Journal receipts and stable append-ID
-reconciliation retain their existing semantics. Blob operations do not advance journal revisions.
-
-Blobs are session-scoped and immutable. `BlobId` is the lowercase hex SHA-256 of raw bytes.
-`putBlob` accepts a Uint8Array and `{ media, name? }`, rejects more than 8 MiB before writing,
-and copies caller bytes before returning control. Identical bytes return the same ID and stored
-metadata on retry. The first optional name is retained; attempting to change the media type for
-already-stored bytes is rejected. Zero-byte blobs are valid. Metadata is immutable and get returns
-an independent byte buffer. A cancelled call rejects; an absent get returns `{ kind: "not_found" }`.
-
-Supported ref media types are text/plain, text/markdown, image/png, image/jpeg and application/pdf.
-The ref schema admitting a media type does not imply a profile can encode it. The shipped Anthropic @2–@4 profiles declare PDF document support. Each step, after projecting its prompt and before its HTTP request, verifies stored size,
-media and hash against the ref for each blob the projected prompt still sends; a part the target
-cannot, or might not, read is already a pointer and is not loaded. Ref names are display labels, not paths.
-
-The environment puts bytes before admitting a user ref. The current journal format stores refs and content parts,
-never the raw bytes. Loading decodes refs as part of each record and does not read the object store.
-Idle restoration therefore succeeds without blob access; missing bytes fail the next step that sends them, before its HTTP request.
-
-Large continuation payloads are serialized as UTF-8 JSON blobs before model_settled is journaled.
-Their envelopes carry `payloadBlob` instead of `payload`, using the same format. The inline cap stays at
-65,536 JSON characters and the blob cap at 8 MiB raw. A step attaches these refs to its request
-without reading them first; missing/corrupt payload bytes fail the step before HTTP. Restore
-and replay never resolve them. A failed append may leave an unreferenced payload blob.
-
-Branch publication copies only blobs cited by inherited history/context and retained continuation
-owners, using get/put through the
-same port. Compaction copies the replacement-context subset. Copies finish before child creation
-is published; no parent objects are removed. Content-addressing makes copying retryable, but there
-is no atomic transaction spanning child blob writes and journal creation, nor garbage collection.
-
-`createMemoryBacking` shares journal streams and session blob maps across independent memory
-adapter instances. It has process-local lifetime only and provides no crash durability. The reusable
-`testing/persistence-contract.ts` suite checks hashing, scope, retries, absent IDs, cancellation,
-size limits and alias safety in addition to the existing append/load contract. Durable adapters
-must add storage-specific process-crash tests and preserve both methods' lifetime guarantees.
diff --git a/docs/agent/session-runtime.md b/docs/agent/session-runtime.md
deleted file mode 100644
index 4e40890d..00000000
--- a/docs/agent/session-runtime.md
+++ /dev/null
@@ -1,289 +0,0 @@
-# Where a session's guarantees come from
-
-A session joins two systems that cannot share a transaction: its journal and the outside world.
-The journal can prove that a tool was authorized and that a result was saved. It cannot prove that
-an interrupted external write did not happen. The design therefore commits before releasing work
-and stops interrupted work on restore instead of replaying it.
-
-These diagrams show the boundaries an integrator must preserve. They omit mailbox plumbing;
-[core actor diagrams](agent-flow-diagrams.md) cover replacement and cancellation inside a turn.
-
-## Keep execution, decisions, and storage separate
-
-```mermaid
-flowchart LR
- caller[Application]
- session[Session: admission and receipt gates]
- decisions[Pure decisions: next state and commands]
- store[(Caller-owned persistence)]
- host[Shared host: operation lifetime]
- tools[Tool bindings]
- transport[Provider transport: HTTP and capture]
- profile[Pure profile: encode and decode]
- external[External services]
- caller -->|input or configuration| session
- session <--> decisions
- session <-->|append and load| store
- session -->|commands after matching receipt| host
- host --> tools
- host --> transport
- transport <-->|data conversion| profile
- tools <--> external
- transport <-->|one HTTP attempt| external
- host -->|correlated outcomes| session
- session -->|admission and settlement| caller
-```
-
-Profiles cannot fetch, and decisions cannot perform I/O. This separation lets replay reconstruct
-state without touching an external service. Bindings and credentials remain environment resources;
-they must be supplied again on restore.
-
-## Admission and settlement answer different questions
-
-```mermaid
-sequenceDiagram
- participant A as Application
- participant S as Session
- participant P as Persistence
- participant H as Host
- A->>S: input(text)
- S->>P: append accepted input
- P-->>S: matching committed receipt
- S-->>A: accepted
- S->>H: start the step (project prompt, call model)
- Note over S,H: The prompt is projected inside the step and never journaled. Each step output commits under its own receipt gate before tools or the next step start.
- H-->>S: outcome that ends the turn
- S->>P: append outcome and terminal record
- P-->>S: matching committed receipt
- S-->>A: settled with the turn record the fold derived
-```
-
-The two arrows following a receipt express eligibility, not a promise about callback scheduling.
-Await admission to know input was saved; await settlement to learn how it ended. Neither a resolved
-promise nor a display notification implies a successful answer. Inspect the returned discriminants.
-
-## Permission must precede the whole batch
-
-```mermaid
-flowchart TD
- model[Validated model tool calls] --> intent[Commit model outcome]
- intent --> mode{Permission policy?}
- mode -->|off| run[Start tool batch]
- mode -->|ask| ask[Ask once for each call in order]
- ask --> answer{Decision}
- answer -->|each call allowed or refused| approval[Commit permission outcome]
- approval -->|refused calls get a permission-refused result| run
- answer -->|cancelled| abort[Commit aborted turn]
- answer -->|invalid response or port failure| fail[Commit failed turn with cause]
-```
-
-No tool runs while the batch is waiting. Approval of call A does not allow A to start while call B
-is still undecided. A notification showing a pending tool is display evidence only. Permission
-waiting has no core timeout; the caller may cancel it.
-
-## Save each tool result before continuing
-
-```mermaid
-sequenceDiagram
- participant T as Tool
- participant H as Host
- participant S as Session
- participant P as Persistence
- participant B as Batch
- T-->>H: validated result or failure
- H-->>S: correlated tool outcome
- Note over H,S: A terminal display update can already be visible.
- S->>P: append individual tool outcome
- P-->>S: matching committed receipt
- S->>H: releaseTool(outcome)
- H->>B: accept result
- B-->>S: batch outcome, through host, when settled
- S->>P: append batch outcome
- P-->>S: matching committed receipt
- S->>H: next commands, if the turn continues
-```
-
-Saving only the final batch would lose completed siblings if another tool hangs or the process
-stops. A durable partial result remains available for recovery even when the batch never completes.
-The batch may settle early on failure/cancellation; it does not always wait for every tool to succeed.
-
-## A lost receipt is uncertainty, not failure
-
-```mermaid
-flowchart TD
- append[Append attempt] --> result{Result}
- result -->|matching receipt| release[Promote durable state and release work]
- result -->|conflict or rejection| stop[Stop session]
- result -->|indeterminate| load[Consistent load and integrity check]
- load --> found{Identical append at stream tip?}
- found -->|yes| release
- found -->|no| absent{Absent at expected revision and retry unused?}
- absent -->|yes| retry[Retry same append ID and bytes once]
- absent -->|no| stop
- retry -->|matching receipt| release
- retry -->|other outcome| stop
- load -->|load or integrity failure| stop
-```
-
-Only the storage append is retried. No completion or tool is repeated. The persistence adapter must
-ensure a request cannot commit later after a consistent load reports it absent. Otherwise the
-runtime cannot safely decide whether dependent work may start. Close or AbortSignal cancellation
-alone is also not a storage result; restore after close to discover a dispatched append's outcome.
-
-## Input waiting and configuration are different cases
-
-A storage append can temporarily queue submissions in memory; that queue is lost on process exit.
-A `queue-user` policy instead saves user input for a later turn. Do not label both as “accepted”:
-only the receipt establishes durable admission.
-
-System instruction changes return `busy` while a turn or durable queued input is outstanding,
-including staged active work. This check happens before ordinary transient queueing. Wait for the
-existing work to drain, commit the change, then submit the next input.
-
-Configuration changes are never `busy`. Selecting stores the new settings in the persistence
-port's configuration store at once; the session actor keeps the pending selection in its
-non-durable state and, at the next boundary between turns, stages it as a "configuration applied"
-`policy` record before dequeuing queued input or admitting new input. A turn already running keeps
-the settings it started with. A selection equal to the policy in force records nothing. Restore
-applies a selection stored before the process stopped, after recovery and registry adoption.
-Configuration changes do not change the journal format.
-
-```mermaid
-sequenceDiagram
- participant C as Caller
- participant S as Session
- participant K as Configuration store
- participant J as Journal
- C->>S: updatePolicy(patch) during a turn
- S->>K: putConfig(selection)
- S-->>C: selected
- Note over S: running turn keeps its settings
- S->>J: terminal (turn ends)
- S->>J: policy (configuration applied)
- S->>J: dequeued or next user input
-```
-
-## Restore inspects history and closes interrupted work
-
-```mermaid
-flowchart TD
- load[Load journal and check integrity] --> valid{Intact?}
- valid -->|no| reject[Reject restore naming the violated rule and record]
- valid -->|yes| work{Unfinished work?}
- work -->|none| registry
- work -->|active turn or saved queued inputs| stage[Stage recovery batch]
- stage --> active{Active turn?}
- active -->|yes| terminal[Include interrupted terminal failure and committed partial results]
- active -->|no| queued[Include cancellation of any saved queued inputs]
- terminal --> queued
- queued --> append[Commit one atomic recovery batch]
- append -->|receipt or reconciled commit| registry{Live registry differs?}
- append -->|cannot establish commit| reject
- registry -->|no| ready
- registry -->|yes| pending[Return runtime with pending registry adoption]
-```
-
-An idle session with only queued inputs gets cancellations, not an invented failed turn. None of
-these paths invokes completion, tool, or permission bindings. Blob bytes are read only when needed
-for later work, so restoration is not a test of attachment availability. Repeating an uncertain
-action requires a new explicit invocation after restoration.
-
-## Restore adopts the live registry and bindings
-
-Stored history is a record of what happened, not a constraint on what may happen next. Loading checks
-journal integrity only: record decoding, batch continuity, the revision sequence, append and entry
-IDs, session identity, the creation record first, and each turn's terminal record in the same batch
-as the record that ended the turn. A record must also name a turn, operation, tool batch, call or
-queued input that exists in the folded state, because the fold cannot apply it otherwise. Load never
-re-runs commit-time rules (configuration validation, permissions, admission) and never asks whether
-a provider, model, profile setting or permission port named in a record is bound today. No prompt
-is stored, so none is compared. A configuration-applied record's policy, and a terminal record's
-agent and outcome, are facts and are taken as written; an effect record loads whatever state the
-turn is in. New work is still staged under every commit-time rule, and new configuration selections
-are validated against live bindings before they are admitted.
-
-If the live agents or tool schemas differ from the journal's registry, or the current policy does
-not validate against the live bindings, restore still succeeds and reports
-`registry: { kind: "pending_adoption", differences }`; `session.policy` is the policy the next turn
-will use. Tool differences name the schema delta, e.g. `changed tools.read_file.parameters: added
-properties line, limit`. Opening writes nothing beyond a recovery batch, unless a configuration
-selection stored before the process stopped differs from the journaled policy: restore then applies
-it, which is new work. The first input, system or configuration change, fork or compaction (or that
-restored selection) first appends a `configuration` record with stable ID
-`configuration//`, then the work queues behind it:
-
-```mermaid
-sequenceDiagram
- participant A as Application
- participant S as Session
- participant J as Journal
- A->>S: input
- S->>J: configuration (live registry, reconciled policy/agent)
- J-->>S: committed
- S->>J: user input
- J-->>S: committed
- S-->>A: turn runs with live tools; registry is current
-```
-
-Steps staged after that record are admitted against the new registry (a proposed tool call must be
-one the active agent may use). The record replaces the registry and, only when needed, reconciles:
-
-- `policy`: a new policy version. Per-agent tool permissions drop unregistered tools and removed
- agents; new agents are derived as creation does. A policy pack `id`, projection (`project`) or
- `handoff` the live resolvers do not supply takes the value a new session would get. Provider,
- model, thinking, thinking budget, stream, output limit and permission mode that the live bindings
- reject are replaced by the policy a new session would get, changing the fewest fields (the saved
- provider/model is kept when some combination allows it). No other policy field may change; the
- result is validated.
-- `agent`: the live default agent, only when the idle conversation's agent is no longer registered.
-
-Each change is logged as `session.registry.reconciled` with the previous and next values and the
-consequence (for example `next turn uses anthropic/claude-opus-4-5` or `next turn projects history
-with history@1`). A changed model, agent, projection or handoff is a warning, because it changes
-what the model sees; a changed pack ID alone is info.
-
-History is never filtered or rewritten. The next prompt projects every earlier message, including
-calls to and results from tools that are no longer registered, and advertises only live tools. If
-the adoption append fails, the session fails and queued work settles with that storage cause.
-
-## Branch publication has two persistence boundaries
-
-```mermaid
-sequenceDiagram
- participant A as Application
- participant S as Parent session
- participant P as Parent journal
- participant C as Child storage
- A->>S: fork or compact
- S->>P: save branch request with child ID
- P-->>S: committed
- Note over S,P: If a turn is active, wait for its committed terminal boundary. Capture excludes queued successor inputs.
- S->>C: copy referenced blobs and commit child seed
- C-->>S: child creation committed
- S-->>A: publish independent child runtime
-```
-
-A fork retains history; compaction uses your replacement context. Neither copies live operations or
-mutates the parent. There is no transaction across parent and child streams. If publication is
-interrupted, inspect the saved child ID and restore it rather than issuing another fork blindly.
-
-## Model selection and observable traffic
-
-```mermaid
-flowchart LR
- selection[Captured provider, model, settings] --> binding[Environment resolves wire ID and profile]
- binding --> validation[Validate declared capabilities]
- validation --> encode[Pure encoder]
- encode --> request[Retain actual request]
- request --> http[HTTP attempt with operation signal]
- http --> response[Retain response or partial stream]
- response --> decode[Assemble, decode, and validate]
- decode --> outcome[Typed completion outcome]
- outcome --> journal[Session receipt gate]
- response -->|invalid or incomplete| failure[Failure with operation and cause]
-```
-
-The capture sink is environment-owned and opt-in. A successful response is not required to retain
-the offending body: JSON parse failures and stream EOF leave evidence. Capture is separate from
-normal diagnostic logs and from the journal; each answers a different question. See
-[logging](../../packages/core-agent/logging/README.md) and [capture setup](../../packages/core-agent/environment/README.md).
diff --git a/package.json b/package.json
index dce73abf..0c043cf4 100644
--- a/package.json
+++ b/package.json
@@ -50,8 +50,6 @@
"probe:dogfood": "zsh scripts/db/probe-dogfood.sh",
"record:sync": "bun scripts/db/sync-record.ts",
"snapshot:record": "bun scripts/db/snapshot-record.ts",
- "test:agent:logs": "LOGTAPE_TEST_MODE=always LOGTAPE_TEST_LOWEST_LEVEL=debug bun run test:agent",
- "test:agent": "bun test --timeout 20000 packages/core-agent",
"test:migration-safety": "bun scripts/db/test-migration-safety.ts",
"test:pg": "bash scripts/checks/test-postgres.sh",
"test": "bun test --timeout 20000",
diff --git a/packages/app-vscode/package.json b/packages/app-vscode/package.json
index d2eb6cd0..bf50b0a4 100644
--- a/packages/app-vscode/package.json
+++ b/packages/app-vscode/package.json
@@ -316,7 +316,6 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "catalog:",
- "@labkit/core-agent": "workspace:*",
"@labkit/ui": "workspace:*",
"@labkit/view-model": "workspace:*",
"react": "catalog:",
diff --git a/packages/app-vscode/src/handlers/FileSystemHandler.ts b/packages/app-vscode/src/handlers/FileSystemHandler.ts
index 83bfb23c..d20a69d5 100644
--- a/packages/app-vscode/src/handlers/FileSystemHandler.ts
+++ b/packages/app-vscode/src/handlers/FileSystemHandler.ts
@@ -9,7 +9,7 @@ import type {
} from "@agentclientprotocol/sdk";
import * as vscode from "vscode";
-import { diagnosticError } from "@labkit/core-agent/logging";
+import { diagnosticError } from "../utils/redact";
import { logDiagnostic } from "../utils/Logger";
/** The editor owns text and saving. Direct disk writes would bypass dirty buffers. */
diff --git a/packages/app-vscode/src/handlers/PermissionHandler.ts b/packages/app-vscode/src/handlers/PermissionHandler.ts
index 570a43cf..9acd453f 100644
--- a/packages/app-vscode/src/handlers/PermissionHandler.ts
+++ b/packages/app-vscode/src/handlers/PermissionHandler.ts
@@ -6,7 +6,7 @@ import type {
} from "@agentclientprotocol/sdk";
import * as vscode from "vscode";
-import { diagnosticError } from "@labkit/core-agent/logging";
+import { diagnosticError } from "../utils/redact";
import { logDiagnostic } from "../utils/Logger";
type Option = RequestPermissionRequest["options"][number];
diff --git a/packages/app-vscode/src/handlers/TerminalHandler.ts b/packages/app-vscode/src/handlers/TerminalHandler.ts
index bb8a8af1..46d4974f 100644
--- a/packages/app-vscode/src/handlers/TerminalHandler.ts
+++ b/packages/app-vscode/src/handlers/TerminalHandler.ts
@@ -17,7 +17,7 @@ import type {
} from "@agentclientprotocol/sdk";
import * as vscode from "vscode";
-import { diagnosticError } from "@labkit/core-agent/logging";
+import { diagnosticError } from "../utils/redact";
import { logDiagnostic, registerEnvironmentSecrets } from "../utils/Logger";
export type TerminalDisplay = TerminalOutputResponse & {
diff --git a/packages/app-vscode/src/utils/Logger.ts b/packages/app-vscode/src/utils/Logger.ts
index d196f28b..adff2bfb 100644
--- a/packages/app-vscode/src/utils/Logger.ts
+++ b/packages/app-vscode/src/utils/Logger.ts
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
-import { diagnosticError, redactDiagnostics } from "@labkit/core-agent/logging";
+import { diagnosticError, redactDiagnostics } from "./redact";
import { fileDiagnostics } from "./file-diagnostics";
let output: vscode.OutputChannel | undefined;
diff --git a/packages/app-vscode/src/utils/file-diagnostics.ts b/packages/app-vscode/src/utils/file-diagnostics.ts
index 08f54fe1..ba43aba5 100644
--- a/packages/app-vscode/src/utils/file-diagnostics.ts
+++ b/packages/app-vscode/src/utils/file-diagnostics.ts
@@ -2,7 +2,7 @@ import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { rotatingFileSink } from "./rotating-file-sink.ts";
-import { redactDiagnostics } from "@labkit/core-agent/logging";
+import { redactDiagnostics } from "./redact";
/** One bounded log set per extension storage directory, across restarts. */
export function fileDiagnostics(
diff --git a/packages/app-vscode/src/utils/redact.test.ts b/packages/app-vscode/src/utils/redact.test.ts
new file mode 100644
index 00000000..a67ec98e
--- /dev/null
+++ b/packages/app-vscode/src/utils/redact.test.ts
@@ -0,0 +1,34 @@
+import { expect, test } from "bun:test";
+import { redactDiagnostics } from "./redact";
+
+test("redaction targets credentials and preserves diagnostic details including exact-key echoes", () => {
+ const output = redactDiagnostics(
+ {
+ path: "/workspace/auth/token.ts",
+ maxOutputTokens: 4096,
+ tokenUsage: { output: 3000 },
+ url: "https://example.invalid/messages?api_key=secret-value&model=sonnet",
+ body: "Provider rejected key arbitrary-provider-credential: invalid account scope",
+ headers: new Headers({ "x-api-key": "secret-value", "request-id": "req-5" }),
+ cause: new Error("failure details"),
+ },
+ ["arbitrary-provider-credential"],
+ );
+ const serialized = JSON.stringify(output);
+ for (const detail of [
+ "/workspace/auth/token.ts",
+ "4096",
+ "3000",
+ "model=sonnet",
+ "invalid account scope",
+ "req-5",
+ "failure details",
+ ]) {
+ expect(serialized).toContain(detail);
+ }
+ expect(serialized).not.toContain("secret-value");
+ expect(serialized).not.toContain("arbitrary-provider-credential");
+ const cyclic: Record = {};
+ cyclic.cause = cyclic;
+ expect(redactDiagnostics(cyclic)).toEqual({ cause: "[Circular]" });
+});
diff --git a/packages/core-agent/logging/index.ts b/packages/app-vscode/src/utils/redact.ts
similarity index 59%
rename from packages/core-agent/logging/index.ts
rename to packages/app-vscode/src/utils/redact.ts
index bef07613..343b8701 100644
--- a/packages/core-agent/logging/index.ts
+++ b/packages/app-vscode/src/utils/redact.ts
@@ -1,22 +1,7 @@
-import { getLogger, type LogLevel } from "@logtape/logtape";
-
-// Configuration and sink lifetime belong to the environment, never to a session.
-export {
- configure,
- reset,
- getConsoleSink,
- getStreamSink,
- getJsonLinesFormatter,
- fromAsyncSink,
- type LogLevel,
- type LogRecord,
- type Sink,
-} from "@logtape/logtape";
-
-export type DiagnosticFields = Readonly>;
-
-/** A caught error, redacted and flattened by {@link diagnosticError}: identity, stack, cause, code. */
-export type DiagnosticError = Readonly>;
+/**
+ * Redaction for the extension's diagnostics: credential fields and recognizable credential text are
+ * replaced, and every other detail is kept, so a log line still says what failed.
+ */
const secretField =
/^(?:api[-_]?key|x[-_]api[-_]key|authorization|proxy[-_]authorization|password|client[-_]?secret|access[-_]?token|refresh[-_]?token|id[-_]?token|cookie|set-cookie)$/i;
@@ -79,36 +64,3 @@ export function diagnosticError(
? (detail as Record)
: { message: detail };
}
-
-/** Best-effort diagnostics cannot change actor execution or durable decisions. */
-export function diagnostic(
- category: string,
- level: LogLevel,
- event: string,
- fields: DiagnosticFields = {},
-): void {
- try {
- const properties = redactDiagnostics({ ...fields, event }) as Record;
- const detail = Object.entries(properties)
- .filter(([key, value]) => key !== "event" && value !== undefined)
- .map(([key, value]) => `${key}=${JSON.stringify(value)}`)
- .join(" ");
- getLogger(["labkit", category]).emit({
- level,
- timestamp: Date.now(),
- rawMessage: event,
- message: [detail ? `${event} ${detail}` : event],
- properties,
- });
- } catch {
- // A failing synchronous sink must not become an operation failure.
- // Async sinks must use LogTape's fromAsyncSink (which handles rejections).
- }
-}
-
-/** Immutable explicit context; safe for concurrent sessions and browser runtimes. */
-export function diagnosticContext(category: string, context: DiagnosticFields) {
- const bound = { ...context };
- return (level: LogLevel, event: string, fields: DiagnosticFields = {}): void =>
- diagnostic(category, level, event, { ...bound, ...fields });
-}
diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md
index bc7cdf2a..7c620a75 100644
--- a/packages/app-web/docs/architecture.md
+++ b/packages/app-web/docs/architecture.md
@@ -36,7 +36,7 @@ HAL root (`Accept: application/hal+json` on `/`, or `/api` through Vite):
{ id: "labkit", _links: { self: { href: "/" }, start: { href: "/questions/1", name: "Q_1" }, questions: { href: "/questions" }, ... } }
```
-The Trace Console starts at `/collections/workspace`.
+The web app starts at a list of the workspaces, read from `/workspace`. Everything after that is inside one workspace, so the app reads nothing from `/collections`.
### Files and history
@@ -131,7 +131,7 @@ Text that is not UTF-8, or a binary file of a text type, gets the note.
| Column | Shows |
|--------|-------|
-| Left | The sidebar, in three sections that fold: File Explorer, Source Explorer, and Record Explorer, which lists two collections: the one picked (`?list=`) and the collection it is listed in. Collections takes at most 40% of Record Explorer and scrolls beyond that; Items has the rest and a filter box that narrows the listed items. |
+| Left | The sidebar, in three sections that fold: File Explorer, Source Explorer, and Record Explorer. In a workspace, Record Explorer lists the workspace's collections and, below, the one picked (`?list=`), or the open collection and the collection it is listed in. Before a workspace is picked it is disabled and says so. Collections takes at most 40% of Record Explorer and scrolls beyond that; Items has the rest. |
| Centre | The graph, a card that folds: the resources opened in the workspace since its last reset and what they relate to, on a 3D canvas (`GraphView`). Clicking a node opens its resource. Below it, the open resource. Tabs: Overview (properties, then a card for each related resource, outbound then inbound), Debug, drawn as a wrench (the response; the events of the record, or of the acts that ran against the commit, each a line with chips for the records it names; and the commits those acts ran against). `?tab=` names the tab. The tabs and Reload sit at the right of the line with the resource's name. |
| Right | In development, in a workspace: the agent's chat. At full width it slides open and shut beside a rail that stays; the rail's button expands or minimises it. In a window 860 px wide or narrower it is a drawer over the page from the right, opened by a button in the top bar. Its sessions open in the workspace's folder, which the dev server reads from the workspaces mapping file (`/agent-workspace/`). A workspace's session connects the first time the chat is shown in that workspace, and stays connected while the chat is hidden; a workspace the chat was never shown in has no session. Each workspace keeps its session for the life of the page: leaving it and coming back reopens that session. `?session=` names the session of the workspace in scope, so a reload reopens it. A workspace the file does not map has no chat, and the button is disabled with the reason. `/new` starts a session in place, and `/sessions` lists the sessions the agent keeps for the workspace's folder (ACP `session/list` by `cwd`), newest first, in a palette; picking one reopens it. `@` offers the workspace's files at HEAD (`/paths`); each file a message mentions is sent with it as a `resource_link` to the file in the workspace's folder (`file:///`), which the agent reads itself. The border at the chat's left edge moves by drag, by Left and Right (16 px), and by Home and End (to 320 px, and to 60% of the window); a double-click returns it to 400 px, and the browser keeps the width. |
@@ -154,10 +154,12 @@ The browser also keeps whether each section is folded. The sections list:
| Section | Lists | Opening an item shows |
| --- | --- | --- |
-| File Explorer | The workspace's files at HEAD, as a tree. A directory's entries are fetched when it is first opened. Each file's icon and colour come from its listed `media_type` (`file-icons.tsx`); a file listed without one has the plain file icon. The box that marks the open file slides to the next file opened. | The file, shown as its media type. |
-| Source Explorer | Every branch's commits, newest first, with each branch's line drawn beside them as `git log --graph` draws it, and branch names on the commits they point to. A branch picker opens on HEAD's branch and scrolls to the picked branch's tip. The box that marks the open commit slides to the next commit opened. | The commit's properties, message, parents, changed files and diff. A changed file opens as it was at that commit. |
+| File Explorer | The workspace's files at HEAD, as a tree. A directory's entries are fetched when it is first opened. Each file's icon and colour come from its listed `media_type` (`file-icons.tsx`); a file listed without one has the plain file icon. The box that marks the open file slides to the next file opened. A file opened from anywhere else, such as the graph or a link, opens the folders above it. Its filter lists the files whose paths hold every word typed, from `/paths`, so files in folders not yet opened are found. | The file, shown as its media type. |
+| Source Explorer | Every branch's commits, newest first, with each branch's line drawn beside them as `git log --graph` draws it, and branch names on the commits they point to. A branch picker opens on HEAD's branch and scrolls to the picked branch's tip. The box that marks the open commit slides to the next commit opened. Its filter narrows the commits loaded so far, by id, message, author or branch, and says how many it searched. | The commit's properties, message, parents, changed files and diff. A changed file opens as it was at that commit. |
| Record Explorer | The workspace's collections of records and acts, and the items of the one picked. The `file` and `commit` collections are left out. | The record. |
+Items, File Explorer and Source Explorer each have a filter, hidden until the search button at the right of the list's header shows it. Hiding a filter, by its button or by Escape in its box, empties it, so a hidden filter never narrows a list.
+
Open sections divide the sidebar's height in proportion to their shares. Record Explorer, which
holds two panes, starts with twice the share of the others, and its Collections pane scrolls when
it is short. Dragging the border between two open sections moves height from one section to the
diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts
index c4acd795..7eaf1b32 100644
--- a/packages/app-web/e2e/app.e2e.ts
+++ b/packages/app-web/e2e/app.e2e.ts
@@ -51,23 +51,34 @@ const item = (page: import("@playwright/test").Page, handle: string) =>
has: page.locator(".rid", { hasText: new RegExp(`^${handle}$`) }),
});
const list = (path: string) => `list=${encodeURIComponent(path)}`;
+// A workspace on the landing view, by its name.
+const workspaceCard = (page: import("@playwright/test").Page, name: string) =>
+ page.locator(".workspace-card", {
+ has: page.locator(".workspace-name", { hasText: new RegExp(`^${name}$`) }),
+ });
// The canvas's nodes are also a visually hidden list of buttons, for the keyboard, which is how
// these tests pick a node: a canvas has no element to click.
const node = (page: import("@playwright/test").Page, name: string | RegExp) =>
page.getByRole("list", { name: "Nodes in the graph" }).getByRole("button", { name });
test.describe("getting around", () => {
- test("a browser at the root lands on the app, with the workspaces", async ({ page }) => {
+ test("a browser at the root lands on the app, which lists the workspaces and nothing above them", async ({
+ page,
+ }) => {
+ const asked: string[] = [];
+ page.on("request", (req) => asked.push(new URL(req.url()).pathname));
await page.goto("/");
await expect(page).toHaveURL(/\/app\/$/);
- await expect(row(page, "alpha")).toBeVisible();
- await expect(row(page, "beta")).toBeVisible();
+ await expect(workspaceCard(page, "alpha")).toBeVisible();
+ await expect(workspaceCard(page, "beta")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Record Explorer", exact: true })).toBeDisabled();
+ expect(asked.filter((path) => path.startsWith("/collections"))).toEqual([]);
});
test("workspace, then collection, then resource, by clicking", async ({ page }) => {
await page.goto("/app/");
- await row(page, "alpha").click();
- await expect(page).toHaveURL(new RegExp(list("/workspace/alpha")));
+ await workspaceCard(page, "alpha").click();
+ await expect(page).toHaveURL(/\/app\/workspace\/alpha$/);
await row(page, "Question").click();
await expect(page).toHaveURL(new RegExp(list("/workspace/alpha/question")));
@@ -84,7 +95,20 @@ test.describe("getting around", () => {
await page.goto("/app/workspace/alpha/Q_1");
await page.getByRole("button", { name: "LabKit" }).click();
await expect(page).toHaveURL(/\/app\/$/);
- await expect(page.getByText("Pick a type on the left")).toBeVisible();
+ await expect(workspaceCard(page, "alpha")).toBeVisible();
+ });
+
+ test("an address that names its workspace only in ?list= names it in its path instead", async ({
+ page,
+ }) => {
+ await page.goto(`/app/?${list("/workspace/alpha/question")}`);
+ await expect(page).toHaveURL(
+ new RegExp(`/app/workspace/alpha\\?${list("/workspace/alpha/question")}$`),
+ );
+ await expect(item(page, "Q_1")).toBeVisible();
+ await page.goto(`/app/?${list("/workspace/alpha")}`);
+ await expect(page).toHaveURL(/\/app\/workspace\/alpha$/);
+ await expect(row(page, "Question")).toBeVisible();
});
test("the top bar has the LabKit name and no sidebar, history or filter controls", async ({
@@ -137,12 +161,21 @@ test.describe("getting around", () => {
expect(await bar.evaluate((e) => getComputedStyle(e).height)).toBe("52px");
});
- test("the Items filter narrows the listed items", async ({ page }) => {
+ test("the Items filter is hidden behind a search button; it narrows the items, and Escape hides and empties it", async ({
+ page,
+ }) => {
await page.goto(`/app/workspace/alpha/Q_1?${list("/workspace/alpha/question")}`);
await expect(item(page, "Q_2")).toBeVisible();
- await page.getByRole("textbox", { name: "Filter the items" }).fill("Q_2");
+ const box = page.getByRole("textbox", { name: "Filter the items" });
+ await expect(box).toHaveCount(0);
+ await page.getByRole("button", { name: "Filter the items" }).click();
+ await expect(box).toBeFocused();
+ await box.fill("Q_2");
await expect(item(page, "Q_2")).toBeVisible();
await expect(item(page, "Q_1")).toHaveCount(0);
+ await box.press("Escape");
+ await expect(box).toHaveCount(0);
+ await expect(item(page, "Q_1")).toBeVisible();
});
test("in a short window, Collections takes at most 40% of Record Explorer and Items keeps the rest", async ({
@@ -174,7 +207,7 @@ test.describe("getting around", () => {
}) => {
await page.goto("/app/workspace/alpha/question");
await expect(item(page, "Q_1")).toBeVisible();
- await expect(page.getByText("Pick a type on the left")).toBeVisible();
+ await expect(page.getByText("Pick a collection in Record Explorer")).toBeVisible();
await item(page, "Q_1").click();
await expect(header(page)).toHaveText("Q_1");
await expect(page).toHaveURL(new RegExp(`/Q_1\\?${list("/workspace/alpha/question")}$`));
@@ -184,7 +217,7 @@ test.describe("getting around", () => {
test("a collection picked from the upper pane loads below it, and the upper pane stays", async ({
page,
}) => {
- await page.goto(`/app/?${list("/workspace/alpha")}`);
+ await page.goto("/app/workspace/alpha");
await expect(row(page, "Question")).toBeVisible();
let release: () => void = () => {};
const held = new Promise((resolve) => {
@@ -206,14 +239,14 @@ test.describe("getting around", () => {
test("opening a resource of another type than the listed one closes the Items pane", async ({
page,
}) => {
- await page.goto(`/app/?${list("/workspace/alpha/question")}`);
+ await page.goto(`/app/workspace/alpha?${list("/workspace/alpha/question")}`);
await item(page, "Q_1").click();
await expect(header(page)).toHaveText("Q_1");
await expect(item(page, "Q_2")).toBeVisible();
await page.locator(".card-head", { hasText: "LOE_1" }).click();
await expect(header(page)).toHaveText("LOE_1");
- await expect(page).toHaveURL(new RegExp(`${list("/workspace/alpha")}$`));
+ await expect(page).toHaveURL(/\/app\/workspace\/alpha\/LOE_1$/);
await expect(page.getByText("Pick a collection above to list its items.")).toBeVisible();
await expect(row(page, "Question")).toBeVisible();
});
@@ -229,7 +262,7 @@ test.describe("getting around", () => {
});
test("a collection's next page is added on request", async ({ page }) => {
- await page.goto(`/app/?${list("/workspace/alpha/question?limit=1")}`);
+ await page.goto(`/app/workspace/alpha?${list("/workspace/alpha/question?limit=1")}`);
await expect(item(page, "Q_1")).toBeVisible();
await expect(item(page, "Q_2")).toHaveCount(0);
await page.getByRole("button", { name: "Load more" }).click();
@@ -306,7 +339,7 @@ test.describe("the sidebar", () => {
page,
}) => {
await page.goto("/app/");
- await expect(row(page, "alpha")).toBeVisible();
+ await expect(workspaceCard(page, "alpha")).toBeVisible();
await expect(explorer(page, "File Explorer")).toBeDisabled();
await expect(explorer(page, "File Explorer")).toHaveAccessibleDescription(
"Open a workspace to browse its files.",
@@ -397,6 +430,56 @@ test.describe("the sidebar", () => {
await expect.poll(async () => (await top(box)) - (await top(file("big.txt")))).toBe(0);
});
+ test("a file opened from a link opens the folders above it in File Explorer, and its row is marked", async ({
+ page,
+ }) => {
+ await page.goto("/app/workspace/alpha/Q_1");
+ await explorer(page, "File Explorer").click();
+ const tree = page.locator(".file-tree");
+ await expect(tree.getByRole("button", { name: "README.md", exact: true })).toBeVisible();
+ await expect(tree.getByRole("button", { name: "plan.md", exact: true })).toHaveCount(0);
+ await page.goto("/app/workspace/alpha/file/notes/plan.md");
+ await expect(header(page)).toHaveText("plan.md");
+ const row = tree.getByRole("button", { name: "plan.md", exact: true });
+ await expect(row).toBeVisible();
+ await expect(row).toHaveClass(/active/);
+ const top = async (locator: import("@playwright/test").Locator) =>
+ Math.round((await locator.boundingBox())?.y ?? -1);
+ await expect
+ .poll(async () => (await top(tree.locator(".active-box.outline"))) - (await top(row)))
+ .toBe(0);
+ });
+
+ test("File Explorer's filter finds a file in a folder not yet opened, and opens it", async ({
+ page,
+ }) => {
+ await page.goto("/app/workspace/alpha/Q_1");
+ await explorer(page, "File Explorer").click();
+ const tree = page.locator(".file-tree");
+ await page.getByRole("button", { name: "Filter the files" }).click();
+ await page.getByRole("textbox", { name: "Filter the files" }).fill("plan");
+ await expect(tree.locator(".tree-note")).toContainText(/^1 of \d+ files$/);
+ await tree.getByRole("button", { name: "notes/plan.md" }).click();
+ await expect(header(page)).toHaveText("plan.md");
+ await expect(tree.getByRole("button", { name: "notes/plan.md" })).toHaveClass(/active/);
+ });
+
+ test("Source Explorer's filter narrows the loaded commits and says how many it searched", async ({
+ page,
+ }) => {
+ await page.goto("/app/workspace/alpha/Q_1");
+ await explorer(page, "Source Explorer").click();
+ const history = page.locator(".history");
+ await expect(history.locator(".commit-row")).not.toHaveCount(0);
+ const loaded = await history.locator(".commit-row").count();
+ await page.getByRole("button", { name: "Filter the commits" }).click();
+ await page.getByRole("textbox", { name: "Filter the commits" }).fill("side work");
+ await expect(history.locator(".commit-row .subject")).toHaveText(["Side work"]);
+ await expect(history.locator(".tree-note")).toContainText(
+ `1 of the ${loaded} commits loaded match`,
+ );
+ });
+
test("in Source Explorer, a box marks the open commit and slides to the next one opened", async ({
page,
}) => {
@@ -457,7 +540,7 @@ test.describe("the sidebar", () => {
test("File and Source Explorers are enabled once Record Explorer lists a workspace, before any record is open", async ({
page,
}) => {
- await page.goto(`/app/?${list("/workspace/alpha")}`);
+ await page.goto("/app/workspace/alpha");
await expect(row(page, "Question")).toBeVisible();
await expect(explorer(page, "File Explorer")).toBeEnabled();
await expect(explorer(page, "Source Explorer")).toBeEnabled();
@@ -704,7 +787,7 @@ test.describe("the sidebar", () => {
test("Record Explorer lists the workspace's records and acts, and leaves files and history to their own sections", async ({
page,
}) => {
- await page.goto(`/app/?${list("/workspace/alpha")}`);
+ await page.goto("/app/workspace/alpha");
await expect(row(page, "Question")).toBeVisible();
await expect(row(page, "Act")).toBeVisible();
await expect(row(page, "File")).toHaveCount(0);
@@ -913,7 +996,7 @@ test.describe("the Items pane's open row", () => {
test("in the Collections pane, a box marks the listed collection and slides to the next", async ({
page,
}) => {
- await page.goto(`/app/?${list("/workspace/alpha/question")}`);
+ await page.goto(`/app/workspace/alpha?${list("/workspace/alpha/question")}`);
const outline = page.locator(".type-list .active-box.outline");
const top = async (locator: import("@playwright/test").Locator) =>
Math.round((await locator.boundingBox())?.y ?? -1);
@@ -1273,7 +1356,7 @@ test.describe("workspaces are separate", () => {
});
test("links inside a workspace stay inside it", async ({ page }) => {
- await page.goto(`/app/?${list("/workspace/beta/question")}`);
+ await page.goto(`/app/workspace/beta?${list("/workspace/beta/question")}`);
await item(page, "Q_1").click();
await expect(page).toHaveURL(/\/app\/workspace\/beta\/Q_1\?/);
await expect(page.locator(".detail").getByText("beta question")).toBeVisible();
@@ -1376,7 +1459,7 @@ test.describe("the agent's chat in the Trace Console", () => {
const box = await rail(page).boundingBox();
expect((box?.x ?? 0) + (box?.width ?? 0)).toBe(page.viewportSize()?.width);
await page.goto("/app/");
- await expect(page.getByText("Pick a type on the left")).toBeVisible();
+ await expect(workspaceCard(page, "alpha")).toBeVisible();
await expect(rail(page)).toHaveCount(0);
});
@@ -1428,7 +1511,7 @@ test.describe("the agent's chat in the Trace Console", () => {
await expect(page).toHaveURL(/[?&]session=fake-\d+/);
await page.getByRole("button", { name: "LabKit" }).click();
await expect(page).toHaveURL(/\/app\/$/);
- await row(page, "beta").click();
+ await workspaceCard(page, "beta").click();
await expect(chatToggle(page)).toBeDisabled();
await expect(chatToggle(page)).toHaveAttribute("title", /maps no folder to the workspace beta/);
expect(new URL(page.url()).searchParams.get("session")).toBeNull();
@@ -1514,7 +1597,7 @@ test.describe("the agent's chat in the Trace Console", () => {
await page.getByRole("button", { name: "LabKit" }).click();
await expect(page).toHaveURL(/\/app\/$/);
expect(new URL(page.url()).searchParams.get("session")).toBeNull();
- await row(page, "alpha").click();
+ await workspaceCard(page, "alpha").click();
await expect(chatToggle(page)).toBeEnabled();
await expect(page).toHaveURL(new RegExp(`[?&]session=${named}`));
await expect.poll(() => sent).toEqual(["session/new", "session/load"]);
diff --git a/packages/app-web/public/docs/api.md b/packages/app-web/public/docs/api.md
index 87c1fdf3..36bdf00e 100644
--- a/packages/app-web/public/docs/api.md
+++ b/packages/app-web/public/docs/api.md
@@ -6,15 +6,16 @@ Machine-readable description: [/docs/openapi.json](/docs/openapi.json). Discover
## Workspaces
-Everything below is scoped to a workspace, one research project's graph. There is no default workspace: `/collections/workspace` lists them and every other path names one, as `/workspace/{slug}`:
+Everything below is scoped to a workspace, one research project's graph. There is no default workspace: `/workspace` lists them and every other path names one, as `/workspace/{slug}`:
```
+GET /workspace every workspace
GET /workspace/{slug} a workspace's collections, one per node type
GET /workspace/{slug}/{type} one collection
GET /workspace/{slug}/{id} one entity
```
-- A workspace is a collection. `/collections/workspace` lists them, and each item's address is the workspace itself. It is offered at the API root only, since workspaces are not nested.
+- A workspace is a collection. `/workspace` lists them, and each item's address is the workspace itself. `/collections/workspace` lists the same workspaces from the API root.
- A slug that does not exist is a 404. A workspace never falls back to another one's data.
- Links in a response keep you in the workspace you arrived by. `/workspace/{slug}/{id}` links to `/workspace/{slug}/…`.
- Inside a workspace a collection and an entity share a path level. A collection name is lower case and a handle is not, so a name is never both.
diff --git a/packages/app-web/src/server/collections-handler.ts b/packages/app-web/src/server/collections-handler.ts
index a6bbdab0..a5a6ac05 100644
--- a/packages/app-web/src/server/collections-handler.ts
+++ b/packages/app-web/src/server/collections-handler.ts
@@ -186,7 +186,9 @@ async function listing(
async function workspaceListing(
req: Request,
workspaces: Workspace[],
- root: string,
+ self: string,
+ /** The index above the listing; the listing at `/workspace` has none. */
+ up: string | undefined,
): Promise {
const url = new URL(req.url);
const origin = publicOrigin(req).origin;
@@ -195,11 +197,11 @@ async function workspaceListing(
const rows = workspaces.slice(offset, offset + limit + 1);
const page = rows.slice(0, limit);
- const self = `${root}/${WORKSPACE_SLUG}`;
+ const paging = pagingLinks(up ?? self, self, limit, offset, rows.length > limit);
return halJson(req, {
_links: {
self: { href: `${self}?limit=${limit}&offset=${offset}` },
- ...linksOf(pagingLinks(root, self, limit, offset, rows.length > limit)),
+ ...linksOf(up === undefined ? paging.filter((l) => l.rel !== "index") : paging),
},
offset,
limit,
@@ -252,7 +254,8 @@ export async function rootCollectionsHandler(
const name = path.replace(/^\/collections\/?/, "").replace(/\/$/, "");
const root = `${publicOrigin(req).origin}/collections`;
if (name === "") return index(req, root, [{ slug: WORKSPACE_SLUG, type: "Workspace" }]);
- if (name === WORKSPACE_SLUG) return workspaceListing(req, await listWorkspaces(runtime), root);
+ if (name === WORKSPACE_SLUG)
+ return workspaceListing(req, await listWorkspaces(runtime), `${root}/${WORKSPACE_SLUG}`, root);
return problem(
404,
"Not Found",
@@ -260,6 +263,15 @@ export async function rootCollectionsHandler(
);
}
+/**
+ * `/workspace`: every workspace, each addressed by its own index, `/workspace/{slug}`. The same
+ * workspaces as `/collections/workspace`, at the address its items sit under, with no index above.
+ */
+export async function workspacesHandler(req: Request, runtime: Runtime): Promise {
+ const self = `${publicOrigin(req).origin}/workspace`;
+ return workspaceListing(req, await listWorkspaces(runtime), self, undefined);
+}
+
// `/workspace/{slug}` and `/workspace/{slug}/{type}`. `rest` is what follows the slug.
export async function workspaceCollectionsHandler(
req: Request,
diff --git a/packages/app-web/src/server/git-handler.ts b/packages/app-web/src/server/git-handler.ts
index 82f682ca..5838e24f 100644
--- a/packages/app-web/src/server/git-handler.ts
+++ b/packages/app-web/src/server/git-handler.ts
@@ -862,7 +862,8 @@ function rawFile(
/**
* Every file's path in the tree at HEAD, in BY_NAME order of the whole path: what can be mentioned
- * in a message to the agent. Directories and submodules are not listed. At most MAX_PATHS are
+ * in a message to the agent, and what File Explorer's filter searches. The templated `file` link
+ * names each path's file. Directories and submodules are not listed. At most MAX_PATHS are
* listed; `total` counts them all, and `truncated` says when some are left out.
*/
function filePaths(req: Request, repo: Repository, base: string, slug: string): Response {
@@ -888,6 +889,9 @@ function filePaths(req: Request, repo: Repository, base: string, slug: string):
_links: {
self: { href: `${base}/paths` },
index: { href: `${base}/${FILE_SLUG}` },
+ // Each listed path's file, by RFC 6570 reserved expansion: the path's slashes are kept and
+ // each segment is percent-encoded.
+ file: { href: `${base}/${FILE_SLUG}/{+path}`, templated: true },
},
total: paths.length,
count: listed.length,
diff --git a/packages/app-web/src/server/handler.ts b/packages/app-web/src/server/handler.ts
index ffce0b8f..59d6f485 100644
--- a/packages/app-web/src/server/handler.ts
+++ b/packages/app-web/src/server/handler.ts
@@ -3,6 +3,7 @@ import {
isCollectionSlug,
rootCollectionsHandler,
workspaceCollectionsHandler,
+ workspacesHandler,
} from "./collections-handler";
import { docsHandler } from "./docs-handler";
import { eventsHandler, isEventsPath } from "./events-handler";
@@ -63,9 +64,10 @@ export async function handle(req: Request, runtime: Runtime): Promise
return withCors(req, await route(req, runtime));
}
-// `/workspace/{slug}` is a workspace's collections, `/workspace/{slug}/{type}` one collection and
-// `/workspace/{slug}/{handle}` one node. There is no default workspace: `/collections/workspace`
-// lists them and every other path names one.
+// `/workspace` lists the workspaces, `/workspace/{slug}` is a workspace's collections,
+// `/workspace/{slug}/{type}` one collection and `/workspace/{slug}/{handle}` one node. There is no
+// default workspace: `/workspace` (and `/collections/workspace`) list them and every other path
+// names one.
const WORKSPACE_PATH = /^\/workspace\/([^/]+)(\/.*)?$/;
async function inWorkspace(
@@ -84,6 +86,11 @@ async function route(req: Request, runtime: Runtime): Promise {
const original = new URL(req.url).pathname;
const path = original;
+ if (original === "/workspace" || original === "/workspace/") {
+ console.debug("request: workspaces", original);
+ return workspacesHandler(req, runtime);
+ }
+
const workspace = WORKSPACE_PATH.exec(original);
if (workspace) {
const slug = decodeURIComponent(workspace[1]!);
diff --git a/packages/app-web/src/ui/chrome/chrome.css b/packages/app-web/src/ui/chrome/chrome.css
index 62e7c868..1a187196 100644
--- a/packages/app-web/src/ui/chrome/chrome.css
+++ b/packages/app-web/src/ui/chrome/chrome.css
@@ -136,6 +136,25 @@
.lk-frame .items-filter::placeholder {
color: var(--text-faint);
}
+/* The button that shows or hides a list's filter: a search icon with no border, like the rail's. */
+.lk-frame .filter-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 0;
+ border-radius: 5px;
+ background: none;
+ color: var(--text-faint);
+ font-size: 14px;
+}
+.lk-frame .filter-btn:hover,
+.lk-frame .filter-btn.on {
+ background: var(--panel-2);
+ color: var(--text);
+}
/* The rail: a narrow column of icons at the left edge that stays when the sidebar is hidden. Its
first button shows or hides the sidebar; the others fold or open one section each. */
diff --git a/packages/app-web/src/ui/trace/TraceConsole.tsx b/packages/app-web/src/ui/trace/TraceConsole.tsx
index e6a3bef5..ba62985f 100644
--- a/packages/app-web/src/ui/trace/TraceConsole.tsx
+++ b/packages/app-web/src/ui/trace/TraceConsole.tsx
@@ -83,6 +83,8 @@ import {
import { FileTree, History } from "./explorers";
import { ActiveBox } from "./active-box";
import { ChatButton, ChatSidebar, useChat } from "./chat-sidebar";
+import { FilterBox, FilterButton, useFilter } from "./filter";
+import { WorkspacePicker } from "./workspace-picker";
import { CommitView, FileView, RELATIONS_APART, SHOWN_APART } from "./git-views";
import { type Playback, usePlayback } from "./playback";
import { proseSegments } from "./prose";
@@ -90,7 +92,8 @@ import { Badge, EventRow, PropRows, type Prose } from "./values";
import "./trace.css";
/** Where the left column starts: the list of workspaces. */
-const ENTRY = "/collections/workspace";
+/** The API's list of workspaces: the page's landing view lists it, and nothing else. */
+const WORKSPACES = "/workspace";
export type Tab = "overview" | "debug";
@@ -175,7 +178,8 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) {
);
const [graphOpen, setGraphOpen] = useState(true);
const explored = useExplored(path);
- const workspace = workspaceOf(path);
+ // A workspace's own index, `/workspace/`, is inside that workspace too.
+ const workspace = workspaceOf(`${path}/`);
useStoreVersion();
const held = store.index();
@@ -186,7 +190,13 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) {
// Opening what is already open adds no history entry. Opening a resource of another type than
// the listed collection's lists the collection that one is listed in instead, which closes the
// Items pane: the reader is following relations, not reading down the list.
- const shownList = openIsCollection ? path : list;
+ // The workspace in scope: the open resource's, or else that of the collection picked.
+ const scope = workspace ?? (list === undefined ? undefined : workspaceOf(`${list}/`));
+ const workspaceIndex = scope?.replace(/\/$/, "");
+ // Record Explorer lists the open collection, or else the one picked, or else the workspace's
+ // collections. The workspace's own index, open in the centre, lists what is picked under it.
+ const listKey = openIsCollection && path !== workspaceIndex ? path : (list ?? workspaceIndex);
+ const shownList = listKey;
const open = (key: string) => {
setDrawer(undefined);
if (key === path) return;
@@ -201,7 +211,8 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) {
void navigate({
to: "/$",
params: { _splat: key.replace(/^\//, "") },
- search: (s) => ({ ...s, list: nextList }),
+ // The workspace's own collections are what Record Explorer lists when nothing is picked.
+ search: (s) => ({ ...s, list: nextList === workspaceIndex ? undefined : nextList }),
});
};
const playback = usePlayback(path, workspace, open);
@@ -209,7 +220,11 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) {
// as records are written, and its rows stay on screen until the new page arrives.
const pickList = (key: string) => {
store.load(requestPath(key), true);
- void navigate({ to: ".", search: (s) => ({ ...s, list: key }), replace: true });
+ void navigate({
+ to: ".",
+ search: (s) => ({ ...s, list: key === workspaceIndex ? undefined : key }),
+ replace: true,
+ });
};
const pickTab = (next: Tab) =>
void navigate({
@@ -219,13 +234,20 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) {
});
const home = () => {
setDrawer(undefined);
- store.load(requestPath(ENTRY), true);
+ store.load(requestPath(WORKSPACES), true);
void navigate({ to: "/$", params: { _splat: "" }, search: {} });
};
- const listKey = openIsCollection ? path : (list ?? ENTRY);
- // The workspace of the open resource, or else of the collection Record Explorer lists.
- const scope = workspace ?? workspaceOf(`${listKey}/`);
+ // An address that names its workspace only in `?list=` names it in its path instead.
+ useEffect(() => {
+ if (path !== "" || workspaceIndex === undefined) return;
+ void navigate({
+ to: "/$",
+ params: { _splat: workspaceIndex.replace(/^\//, "") },
+ search: (s) => ({ ...s, list: s.list === workspaceIndex ? undefined : s.list }),
+ replace: true,
+ });
+ }, [path, workspaceIndex, navigate]);
const chat = useChat({
slug: scope?.split("/")[2],
expanded: narrow ? drawer === "chat" : chatShown,
@@ -262,7 +284,7 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) {
].join(" ")}
workspace={scope}
listKey={listKey}
- root={listKey === ENTRY}
+ root={listKey !== undefined && listKey === workspaceIndex}
openKey={path}
held={held}
expanded={narrow ? drawer === "items" : sidebarShown}
@@ -294,6 +316,7 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) {
onPause={playback.pause}
/>
)}
+ {path === "" ? : null}
/`, or undefined when none is open. */
workspace: string | undefined;
- listKey: string;
+ /** The collection Record Explorer lists; undefined when no workspace is in scope. */
+ listKey: string | undefined;
root: boolean;
openKey: string;
held: HeldIndex;
@@ -423,29 +447,36 @@ function Lists({
onOpen: (key: string) => void;
onList: (key: string) => void;
}) {
- // Narrows the Items pane to the items whose text holds it.
- const [term, setTerm] = useState("");
- const listEntry = useDocument(requestPath(listKey));
+ // Each list's filter, hidden until its search button shows it.
+ const itemsFilter = useFilter();
+ const term = itemsFilter.term;
+ const filesFilter = useFilter();
+ const commitsFilter = useFilter();
+ const listEntry = useDocument(listKey === undefined ? undefined : requestPath(listKey));
// Until the picked collection's first page arrives, the collection it was picked from stays the
// upper one, so the upper pane does not show the picked collection and then swap back.
const shown = useRef(undefined);
- const upperKey = root
- ? listKey
- : docOf(listEntry) !== undefined
- ? (indexOfList(listKey) ?? listKey)
- : shown.current !== undefined && rowIn(shown.current, listKey) !== undefined
- ? shown.current
- : listKey;
+ const upperKey =
+ listKey === undefined || root
+ ? listKey
+ : docOf(listEntry) !== undefined
+ ? (indexOfList(listKey) ?? listKey)
+ : shown.current !== undefined && rowIn(shown.current, listKey) !== undefined
+ ? shown.current
+ : listKey;
useEffect(() => {
shown.current = upperKey;
});
const lowerKey = upperKey === listKey ? undefined : listKey;
// The row for the lower collection, which `list` may name with a query (`?limit=`), is lit.
const lowerRow = lowerKey === undefined ? undefined : keyOf(lowerKey);
- const upper = usePages(requestPath(upperKey), readCollection(upperKey));
+ const upper = usePages(
+ upperKey === undefined ? undefined : requestPath(upperKey),
+ readCollection(upperKey ?? ""),
+ );
const lower = usePages(
lowerKey === undefined ? undefined : requestPath(lowerKey),
- readCollection(listKey),
+ readCollection(listKey ?? ""),
);
// The Items pane follows the open resource: while it is of the type the pane lists and not among
// the rows loaded, the next page is loaded, as long as the last one names a next page.
@@ -495,6 +526,8 @@ function Lists({
? `${slug} serves no ${what}.`
: undefined;
const filesUnavailable = unavailable(filesKey, "files");
+ const recordsUnavailable =
+ listKey === undefined ? "Pick a workspace to browse its records." : undefined;
const historyUnavailable = unavailable(historyKey, "history");
// The open sections, in order. A border that can be dragged sits after each but the last.
const shares: Share[] = [
@@ -516,7 +549,7 @@ function Lists({
element: historySection,
}
: undefined,
- recordsOpen
+ recordsOpen && recordsUnavailable === undefined
? {
name: "Record Explorer",
initial: RECORDS_SHARE,
@@ -593,7 +626,7 @@ function Lists({
,
recordsOpen,
setRecordsOpen,
- undefined,
+ recordsUnavailable,
)}