feat(runner): add SDK and developer tooling (#12608)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The runner package already provides the production protocol and
execution spine.
> - Contributors still need stable SDK surfaces, deterministic test
tools, and local inspection tools.
> - Those surfaces share generated contracts and must change as one
package boundary.
> - This pull request adds the package-local SDK, labs, examples, and
drift checks.
> - The benefit is a reviewable developer platform that does not change
application execution selection.

## Linked Issues or Issue Description

**Subsystem affected**

`packages/paperclip-runner` — runner SDK, conformance tools, and
developer tooling.

**Problem or motivation**

The production runner spine is present, but package consumers cannot
build deterministic integrations, inspect sessions, or verify
provider-neutral behavior through supported surfaces.

**Proposed solution**

Add browser, React, standalone, live-session, scenario, conformance, and
evaluation surfaces. Add generated contract inventories and
package-local verification scripts. Keep production application routing
unchanged.

**Alternatives considered**

We considered splitting each generated catalog, SDK surface, and demo
into separate pull requests. Those changes share exports, fixtures, and
drift gates. Splitting them would create intermediate package states
that do not build.

**Roadmap alignment**

No overlapping item appears in `ROADMAP.md`. This work extends the
runner package that is already on `master`.

## What Changed

- Add browser, React, standalone, live-session, and issue-thread SDK
surfaces.
- Add deterministic mock control-plane, scenario, conformance, replay,
and evaluation tools.
- Add bounded Codex, OpenCode, and ACPX development transports and
fixtures.
- Keep deferred managed-provider execution fail-closed. Persisted
compatibility data remains readable.
- Add generated capability inventories with their source files and drift
checks.
- Add examples, package documentation, browser checks, and
clean-consumer checks.
- Preserve the reviewed protocol bounds, replay compatibility aliases,
process environment isolation, and semantic redaction limits.
- Update the ACPX package patch that the existing workspace patch
registry already tracks.
- Do not change `pnpm-lock.yaml`, repository workflows, server runtime
selection, or the application UI.

## Verification

GitHub Actions is the verification authority for this pull request. The
repository CI, package TypeScript and Rust checks, package tests,
generated-output drift checks, browser checks, security scans, and
Greptile review must pass on the exact head.

Local test suites were not run because this series uses parallel GitHub
Actions for verification.

## Risks

This is a large greenfield package change. The main risks are public
export drift, generated-output drift, and optional React consumer
compatibility. Package boundary checks, clean-consumer checks, and
browser tests cover those risks. Production adapter selection and server
execution are outside this pull request.

## Stack

1. **This PR:** runner SDK and developer tooling.
2. [Codex production server
integration](https://github.com/paperclipai/paperclip/pull/12616).
3. [Provider-neutral task-thread
UI](https://github.com/paperclipai/paperclip/pull/12617).

## Model Used

OpenAI Codex, GPT-5, high-reasoning mode, with tool use and code
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the feature request
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta authored and GitHub committed 2026-08-31 21:33:11 -05:00
1 parent b5fd13e09f
commit 560e7e48b5
381 files changed
+97707 -2393

No files matched your search

+1
View File
@@ -0,0 +1 @@
patches/*.patch whitespace=-space-before-tab
+1
View File
@@ -3,6 +3,7 @@ node_modules/
**/node_modules
**/node_modules/
dist/
packages/paperclip-runner/runner/target/
ui/storybook-static/
.env
*.tsbuildinfo
+12
View File
@@ -1 +1,13 @@
/runner/target/
/dist-browser/
/dist-sdk/
/playwright-report/
/test-results/
/dist-standalone/
/dist-scenarios/
/dist-issue-thread/
# The repository root ignores scratch `check-*.mjs` files and only re-includes
# them under the root `scripts/` directory. This package's checks are real
# source, so re-include them here instead of relying on `git add -f`.
!scripts/check-*.mjs
+159 -87
View File
@@ -1,72 +1,45 @@
# Paperclip Runner
# Paperclip Native Runner
This private workspace package contains the staged Paperclip Runner work.
This package is the standalone development boundary for Paperclip's native
runner protocol, process supervision, durable transport, provider drivers, and
normalized session backends. Rust owns the production runner under `runner/`;
TypeScript provides the control-plane reference, browser SDK, scenario tools,
and conformance oracle.
The package currently exposes the language-neutral PRP v1 TypeScript
contract, provider-neutral structured questions and responses, deterministic
fixture validation/replay, structured-result normalization, and the session
reducer oracle. It also contains a package-local Rust runner, scripted fake
harness, bounded process supervisor, cross-language replay oracle, and durable
PRP transport. The transport authenticates and encrypts loopback WebSocket
sessions, persists an ACK-driven outbox and command journal, and reconnects with
a short-lived lease. The Rust runner now includes a Codex-only app-server
provider bridge with durable thread resume, cancellation, structured questions,
and provider-neutral event normalization. The root surface now also exposes an
authenticated durable PRP authority for server-side use. It stores only
bootstrap and reconnect credential digests, validates immutable run identity on
every connection and event, and persists commands and cumulative event ACK
state across server restarts.
The package also publishes the canonical semantic action declarations and
their input and output schemas. Its package-local dispatcher projects only
bound, run-authorized actions and emits redacted semantic receipts.
The package includes one coherent set of capabilities: PRP v1 validation and
replay, a supervised local runner with a scripted fake harness, durable
WebSocket delivery and recovery, a skillless Codex app-server driver, live
session and issue-thread surfaces, a public browser/React SDK, a standalone
adapter demo, and a deterministic mock control plane. None of these surfaces
imports or starts Paperclip's server, UI, CLI, or production database.
The first and only provider selected directly by runnerd remains Codex. The
package also contains the qualified OpenCode 1.18.17 proxy and the bounded ACPX
sidecar for Codex and Claude, but this slice does not authorize the server
to select those additional providers for a fresh native run. Dynamic semantic
tools remain undiscoverable unless the hidden server coordinator projects one
of the five same-task read bindings for an already persisted native Codex run.
Catalog membership alone does not grant authority. The server can now create
and start a Codex-backed native run only through the default-off
`paperclip_runner` adapter. See
[`SEMANTIC_ACTIONS.md`](SEMANTIC_ACTIONS.md) for the catalog boundary.
## Public package surfaces
The package has two initial public surfaces:
- `@paperclipai/paperclip-runner` — production contracts, clients/backends,
PRP validation/replay, canonical catalog/dispatcher, and compatibility check.
- `@paperclipai/paperclip-runner/testing` — deterministic mocks plus PRP and
semantic conformance kits. Tests and external conformance consumers import
this explicitly.
- `@paperclipai/paperclip-runner` contains runtime contracts, validation,
replay/reducer logic, the semantic catalog, the authorization dispatcher, and
the Node-only durable server authority.
- `@paperclipai/paperclip-runner/testing` adds Node-only fixture loading and a
provider-neutral semantic conformance kit for deterministic test adapters.
The package root has no mock or scenario exports, and the workspace has no
separate eval-package importer. Provider-backed eval campaigns remain deferred.
See [ADR 0001](docs/adr/0001-runner-testing-eval-package-boundaries.md).
No SDK, browser, React, eval, live-console, or lab entry point is exported. The
package remains private in this wave. The server route
at `/api/runner/v1/connect/:runId` has no authority until the hidden coordinator
registers an exact existing run binding. Fresh native starts are rejected
unless the instance `enableNativeRunner` flag is enabled. Existing direct
adapters keep their original execution path.
The two conformance surfaces intentionally prove different contracts. The
existing `runControlPlanePortConformance` suite checks narrow PRP run/event
persistence. `CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS` and
`runSemanticConformanceKit` compare normalized tool authorization, state,
effects, audit, retries, conflicts, redaction, continuation, and terminal
decisions. The production adapter stays App-owned and invokes Paperclip's real
route/service authorities; it does not copy those rules into this package.
The package build compiles the release `paperclip-runnerd` executable and
stages it under `dist/bin`. The normal server build vendors that directory, so
an installed server does not depend on a separate system Rust installation or
a manually copied binary. `pnpm-lock.yaml` remains under the repository's
existing lockfile process.
The `paperclip-runner-opencode-proxy` binary adapts the pinned OpenCode server
protocol to the normalized harness contract. It admits only a bounded provider
environment, uses an authenticated loopback MCP endpoint, validates dynamic
tool inputs, keeps terminal completion tools private to the runner boundary,
and preserves exact provider session identity for recovery. The backend factory
requires an explicit runtime directory before constructing this provider.
## Quick start
The package also builds `paperclip-runner-acpx-sidecar`. This bounded v2
stdin/stdout bridge admits the closed Claude and Codex ACPX profiles. It
validates each agent's exact package/model pair, session identity, tool catalog,
structured input, and terminal settlement at the process boundary. Claude runs
without ambient project or local settings. Pi remains unavailable until its
separately spawned runtime can use the same descriptor-confined verified launch
boundary as the ACP server. Runnerd and the server do not select this sidecar
in this slice.
stdin/stdout bridge admits the qualified Codex ACPX profile only. It validates
the exact model, session identity, tool catalog, structured input, and terminal
settlement at the process boundary. Runnerd and the server do not select this
sidecar in this slice. Other ACPX agents remain unavailable.
The Rust core includes a bounded client for this sidecar protocol. It enforces
request identity, event order, frame and queue limits, timeouts, redacted
@@ -103,7 +76,7 @@ unresolved turn-scoped requests. This reducer still does not select ACPX in
runnerd.
The package-local session bootstrap starts the bounded sidecar transport,
verifies the selected qualified capability handshake and effective model, opens one
verifies the Codex-only capability handshake and effective model, opens one
identity-bound session, and confirms its run attachment. Any failed bootstrap
terminates the process; session shutdown preserves persistent provider state.
The session can then start one immutable-workspace turn, request interruption,
@@ -121,8 +94,9 @@ against the exact persisted question IDs, answer modes, options, required
answers, custom-answer policy, and text constraints before provider delivery.
Tool results and structured question responses then use two-phase resolution:
validate retained identity and schema, require the exact sidecar
acknowledgement, and only then clear pending local state. Permission events that
escape the pinned sidecar policy terminate the session fail closed.
acknowledgement, and only then clear pending local state. Codex permission
requests violate its pinned sidecar policy and terminate the session fail
closed.
Safe suspension is available only with no active turn or pending request. The
sidecar must return the exact persistent session identity before runnerd
terminates the local process.
@@ -138,37 +112,135 @@ prospective session configuration exactly.
Run the complete contract gate with:
```sh
pnpm --filter @paperclipai/paperclip-runner check:protocol
pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --offline --ignore-scripts --dev
pnpm --filter @paperclipai/paperclip-runner verify
```
Run the Rust runner gate with:
The verification command requires a stable Rust toolchain with `cargo` on
`PATH`, in addition to Node.js 24.11+ and pnpm 9+.
Minimal Debian/Ubuntu hosts without root access can extract the required
Playwright browser libraries into a user-owned cache and run the same acceptance
sequence with:
```sh
pnpm --filter @paperclipai/paperclip-runner check:runner
pnpm --filter @paperclipai/paperclip-runner verify:rootless
```
This command checks Rust formatting, builds and tests the minimal workspace in
release mode, verifies bounded process cleanup, launches the real
`paperclip-runnerd` binary through the fake harness, and compares the Rust
conformance and replay summaries with the shared fixtures. The checked-in Cargo
lock and pinned Rust toolchain keep this verification reproducible.
The tracer's final line is stable:
Durability and failure semantics are documented in
[`runner/DURABLE_TRANSPORT.md`](runner/DURABLE_TRANSPORT.md). The fault suite
drops a connection before its event ACK, reconnects with the bound lease,
replays the same event, and proves the duplicated command effect ran once.
Codex launch, resume, cancellation, and normalization behavior is documented in
[`runner/CODEX_PROVIDER.md`](runner/CODEX_PROVIDER.md).
```json
{"schemaVersion":"paperclip.runner.conformance.output.v1","runIdentity":{"runId":"run_conformance_0001","sessionId":"session_conformance_0001"},"result":{"status":"succeeded","summary":"Standalone Conformance fixture accepted."}}
```
Use `generate:protocol-manifest` after a schema or fixture change,
`generate:protocol-types` after a schema change, and
`generate:replay-goldens` after an intentional reducer change. Commit generated
outputs with their sources; do not edit them by hand.
Run only the tracer with:
Use `generate:semantic-action-catalog` after changing a semantic action
declaration. Its checked-in JSON inventory must land with the source change.
```sh
pnpm --filter @paperclipai/paperclip-runner trace:conformance
```
The gate compiles every schema with AJV 2020-12, validates accepted fixtures,
rejects unsupported required versions, checks generated TypeScript schema
drift, runs the TypeScript contract tests, and compares reducer snapshots and
parity summaries byte-for-byte with their checked-in golden files.
Replay the Replay happy path, run a Local session, or open the browser
devtool:
```sh
pnpm --filter @paperclipai/paperclip-runner replay:fixture
pnpm --filter @paperclipai/paperclip-runner trace:local-runner -- --scenario happy-path
pnpm --filter @paperclipai/paperclip-runner trace:codex
pnpm --filter @paperclipai/paperclip-runner demo:live-console -- --host 127.0.0.1 --port 4174
# Live console: chat with a live session in the browser.
pnpm --filter @paperclipai/paperclip-runner console:live-console
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
# SDK: open the public-SDK reference console and mini consumer.
pnpm --filter @paperclipai/paperclip-runner console:sdk
# Standalone: run the standalone legacy/native/kill-switch tracer and page.
pnpm --filter @paperclipai/paperclip-runner trace:standalone
pnpm --filter @paperclipai/paperclip-runner trace:standalone -- --feature-flag enabled
pnpm --filter @paperclipai/paperclip-runner trace:standalone -- --feature-flag enabled --kill-switch enabled
pnpm --filter @paperclipai/paperclip-runner demo:standalone
```
Live console provider-backed routes are loopback-only and reject wildcard/LAN
binds. Browser mutations require same-origin Fetch Metadata, matching Origin,
and JSON content; see the protocol-server tutorial for direct `curl` examples.
## Package-owned commands
| Command | Purpose |
|---|---|
| `build` | Compile the TypeScript public surface, Rust workspace, and browser devtool. |
| `typecheck` | Check TypeScript, Rust, generated schema sources, and browser types. |
| `test` | Run Rust/TypeScript fixture, supervisor, fake-driver, live/replay, and boundary tests. |
| `check:forbidden-imports` | Reject TypeScript imports and Cargo path dependencies that cross into Paperclip core. |
| `check:tracked-imports` | Reject tracked imports and `package.json` entry points that only resolve against untracked files, so a clean checkout of any commit builds. |
| `check:numbered-milestones` | Reject numbered construction-milestone names in tracked package paths and source. |
| `check:package-boundaries` | Enforce the acyclic runtime/testing dependency and manifest boundary. |
| `check:clean-consumers` | Pack the runner and install its root and testing exports in a clean consumer. |
| `check:conformance-parity` | Require byte-for-byte equivalent Rust and TypeScript tracer output. |
| `check:replay-goldens` | Require all reducer snapshots and cross-language summaries to match checked goldens. |
| `check:replay-parity` | Run TypeScript and Rust against the same Replay fixture summaries. |
| `check:browser-tokens` | Reject component-local visual literals and require the standalone token layer. |
| `docs:validate` | Validate local documentation links. |
| `trace:conformance` | Run the Rust mock-core tracer, print the stable result, and exit. |
| `trace:conformance:typescript` | Run the TypeScript reference tracer directly. |
| `replay:fixture` | Validate and reduce a fixture to a final snapshot. |
| `trace:local-runner` | Run one native local session through the Rust runner and fake harness. |
| `trace:codex` | Run the mock core with a real, local skillless Codex app-server session. |
| `demo:live-console` | Start the package-local HTTP/SSE server with server-only Codex authentication. |
| `console:live-console` | Start the standalone browser devtool with the Live console on `127.0.0.1:4180`. |
| `console:sdk` | Start the public-SDK reference console and mini consumer on `127.0.0.1:4181`. |
| `test:sdk` | Run targeted browser-client, reducer-projection, and React component contract tests. |
| `test:browser:sdk` | Exercise both consumers with the fake driver, keyboard/a11y checks, reconnect/replay, measurements, and screenshots. |
| `record:sdk:codex` | Run both public consumers against a safe real Codex session and capture live screenshots. |
| `check:capability-contract` | Verify the generated capability, legacy MCP, and eval traceability contract. |
| `check:semantic-contracts` | Verify the provider-neutral semantic tool contract is current. |
| `trace:live-runner` | Run the real runnerd/Codex semantic loop against the mock control plane. |
| `demo:scenarios` | Start the Capability scenario explorer over the mock control plane on `127.0.0.1:4183`. |
| `console:issue-thread` | Start the Paperclip-style issue thread on `127.0.0.1:4184`. |
| `test:scenarios` | Run the scenario index, run-artifact, parity, explorer component, and route tests. |
| `test:browser:scenarios` | Exercise both the scenario explorer and issue-thread browser contracts. |
| `browser:dev` | Start the standalone live/replay browser devtool. |
| `test:browser` | Exercise static replay and live scenarios, then capture temporary screenshots under ignored test output. |
| `verify` | Run the complete deterministic Conformance through SDK acceptance sequence. |
| `verify:rootless` | Extract Debian/Ubuntu browser libraries without root, then run `verify`. |
## Navigate
- [Architecture and dependency boundary](docs/architecture.md)
- [ADR 0001: runner and testing package boundaries](docs/adr/0001-runner-testing-eval-package-boundaries.md)
- [Tutorial index](docs/index.md)
- [Conformance hand-run tutorial](docs/tutorials/conformance-standalone-tracer.md)
- [Replay hand-run tutorial](docs/tutorials/replay.md)
- [Local runner hand-run tutorial](docs/tutorials/local-runner.md)
- [Local protocol reference](docs/local-runner.md)
- [Durable transport reference](docs/durable-recovery.md)
- [Codex skillless Codex tutorial](docs/tutorials/codex.md)
- [Codex skillless Codex driver reference](docs/codex-driver.md)
- [Live console protocol/server tutorial](docs/tutorials/live-console-protocol-server.md)
- [Live console protocol/server reference](docs/live-console-protocol-server.md)
- [Live console tutorial](docs/tutorials/live-console.md)
- [Live console reference](docs/live-console.md)
- [SDK console tutorial](docs/tutorials/sdk-console.md)
- [SDK browser SDK reference](docs/sdk.md)
- [SDK component decision record](docs/design/sdk-component-decisions.md)
- [Capability semantic catalog and authorization](docs/capability-semantic-catalog.md)
- [Capability live runnerd/Codex loop](docs/capability-live-runnerd-codex.md)
- [Capability issue-thread UI](docs/capability-issue-thread-ui.md)
- [PRP compatibility/versioning policy](docs/protocol-compatibility.md)
- [Adding a harness and permission-mode requirements](docs/adding-a-harness.md)
- [PRP v1 expressiveness audit](spec/prp-v1-expressiveness-audit.md)
- [Cumulative end-to-end tutorial](docs/tutorials/end-to-end.md)
Codex adds the package-local real-model reference driver, Live console adds the
package-local browser console, and SDK extracts a reusable public SDK plus
two standalone consumers. Runtime production Paperclip integration remains
deferred; the App-owned production conformance adapter is test-only.
The SDK reference console opens in direct chat mode. Enter a normal prompt,
then open the protocol inspector to review events and reducer state. Expand a
Terminal row and its nested **Debug details** disclosure to inspect every
canonical event retained for that command. The header marker `🖇️ v0.1.2`
identifies the current console iteration.
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Paperclip Runner · PRP Static Replay</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,398 @@
import { expect, test, type Page } from "@playwright/test";
const DESKTOP = { width: 1440, height: 900 };
const MOBILE = { width: 390, height: 844 };
function shot(page: Page, name: string) {
return page.screenshot({ path: test.info().outputPath(`${name}.png`), fullPage: true });
}
async function openConsole(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "Live console" }).click();
await expect(page.getByRole("heading", { name: "Live Codex protocol console" })).toBeVisible();
}
async function runManifest(page: Page, id: string) {
await expect(page.getByTestId("manifest-list")).toBeVisible();
await page.getByTestId(`manifest-${id}`).click();
await page.getByTestId("run-manifest").click();
await expect(page.getByTestId("connection-status")).toHaveText("connected", { timeout: 15_000 });
}
test.describe("Live console", () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(DESKTOP);
});
test("shows the empty state and runs a completed turn", async ({ page }) => {
await openConsole(page);
await expect(page.getByTestId("empty-transcript")).toBeVisible();
await expect(page.getByTestId("composer-send")).toBeDisabled();
await shot(page, "desktop-01-idle-empty");
await runManifest(page, "completion");
await expect(page.getByTestId("reasoning-item")).toBeVisible();
await expect(page.getByTestId("tool-item")).toBeVisible();
await shot(page, "desktop-02-streaming-turn");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("live-transcript")).toContainText("finished the task");
await expect(page.getByTestId("live-transcript")).toContainText(
"Summarize what the Live console demo server proves.",
);
await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "completed");
await shot(page, "desktop-03-turn-completed");
// The browser must never receive a provider credential.
const body = await page.locator("body").innerText();
expect(body).not.toContain("Bearer ");
expect(body).not.toContain("auth.json");
});
test("acknowledges steering and keeps rejected text recoverable", async ({ page }) => {
await openConsole(page);
await runManifest(page, "steering");
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
await page.getByTestId("composer-input").fill("Keep it to one sentence.");
await page.getByTestId("composer-send").click();
const chip = page.getByTestId("steering-chip").first();
await expect(chip).toContainText("acknowledged", { timeout: 15_000 });
await shot(page, "desktop-04-steering-acknowledged");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 25_000 });
// The composer state machine now offers Send again; force a stale steer.
await page.evaluate(() => window.history.replaceState({}, "", "/"));
});
test("rejects a stale steer and offers to resend it", async ({ page }) => {
await openConsole(page);
await runManifest(page, "steering");
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
await page.getByTestId("composer-input").fill("Typed while the turn was running.");
// Let the turn finish before the steer is sent: the classic stale race.
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 25_000 });
await expect(page.getByTestId("composer-send")).toHaveText("Send");
});
test("cancels a turn interrupted before it starts", async ({ page }) => {
await openConsole(page);
await runManifest(page, "interrupt-before-start");
await expect(page.getByTestId("composer-stop")).toBeEnabled();
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("turn-cancelled")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("turn-cancelled")).toContainText("Cancelled before start");
await shot(page, "desktop-05-interrupt-before-start");
});
test("keeps partial text when a streaming turn is stopped", async ({ page }) => {
await openConsole(page);
await runManifest(page, "interrupt-during-generation");
await expect(page.getByTestId("live-transcript")).toContainText("The tracer keeps", {
timeout: 15_000,
});
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("turn-interrupted")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("interrupted-divider").first()).toBeVisible();
await expect(page.getByTestId("live-transcript")).not.toContainText("And this final one.");
await shot(page, "desktop-06-interrupt-during-generation");
});
test("keeps an interrupted tool item without a fabricated result", async ({ page }) => {
await openConsole(page);
await runManifest(page, "interrupt-during-tool");
await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "running", {
timeout: 15_000,
});
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "interrupted", {
timeout: 15_000,
});
await expect(page.getByTestId("turn-interrupted")).toBeVisible();
await shot(page, "desktop-07-interrupt-during-tool");
});
test("resolves approvals exactly once and collapses the resolved card", async ({ page }) => {
await openConsole(page);
await runManifest(page, "approvals");
const card = page.getByTestId("request-card").first();
await expect(card).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await expect(page.getByTestId("pending-request-banner")).toBeVisible();
await expect(page.getByTestId("request-action-accept_for_session")).toBeVisible();
await shot(page, "desktop-08-request-pending");
await page.getByTestId("request-action-accept").first().click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
"data-status",
"resolved",
{ timeout: 15_000 },
);
await expect(page.getByTestId("request-card").first()).toContainText("resolved — Approve");
await shot(page, "desktop-09-request-resolved");
// Second request only offers accept and decline.
const second = page.getByTestId("request-card").nth(1);
await expect(second).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await expect(second.getByTestId("request-action-accept_for_session")).toHaveCount(0);
});
test("submits user-input and elicitation answers and lets an unanswered request expire", async ({ page }) => {
test.setTimeout(60_000);
await openConsole(page);
await runManifest(page, "user-input");
await expect(page.getByTestId("request-answer")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("request-answer").fill("staging");
await page.getByTestId("request-action-submit").click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
"data-status",
"resolved",
{ timeout: 15_000 },
);
// An elicitation submits `content`, not `answers`. The runner validates the
// body against the request kind, so a card that sent the user-input shape
// would be rejected and stay pending here.
const elicitation = page.getByTestId("request-card").nth(1);
await expect(elicitation).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await elicitation.getByTestId("request-answer").fill("stable");
await elicitation.getByTestId("request-action-submit").click();
await expect(elicitation).toHaveAttribute("data-status", "resolved", { timeout: 15_000 });
await expect(elicitation).toContainText("resolved — Submit");
await expect(page.getByTestId("request-card").nth(2)).toHaveAttribute(
"data-status",
"expired",
{ timeout: 25_000 },
);
await expect(page.getByTestId("request-card").nth(2)).toContainText("expired before response");
await shot(page, "desktop-10-request-expired");
});
test("moves a goal through its lifecycle", async ({ page }) => {
await openConsole(page);
await runManifest(page, "goals");
await expect(page.getByTestId("goal-banner")).toHaveText("No goal set", { timeout: 15_000 });
await page.locator("#goal-menu").click();
await page.getByRole("menuitem", { name: "Set goal…" }).click();
await page.getByTestId("goal-objective").fill("Keep the demo goal visible");
await page.getByTestId("goal-set-confirm").click();
await expect(page.getByTestId("goal-banner")).toContainText("Keep the demo goal visible", {
timeout: 15_000,
});
await expect(page.getByTestId("goal-banner")).toContainText("active");
await page.locator("#goal-menu").click();
await page.getByRole("menuitem", { name: "Pause" }).click();
await expect(page.getByTestId("goal-banner")).toContainText("paused", { timeout: 15_000 });
await shot(page, "desktop-11-goal-supported");
});
test("disables the goal menu with the exact upstream diagnostic", async ({ page }) => {
await openConsole(page);
await runManifest(page, "goals-unsupported");
const trigger = page.locator("#goal-menu");
await expect(trigger).toHaveAttribute("aria-disabled", "true", { timeout: 15_000 });
const describedBy = await trigger.getAttribute("aria-describedby");
expect(describedBy).not.toBeNull();
await expect(page.locator(`#${describedBy}`)).toHaveText(
"Goal operations unsupported: app-server 0.132.0 does not advertise the goals capability",
);
await page.getByTestId("toggle-inspector").click();
await page.getByRole("tab", { name: "Capabilities" }).click();
await expect(page.getByTestId("inspector-capabilities")).toContainText(
"does not advertise the goals capability",
);
await shot(page, "desktop-12-goal-unsupported");
});
test("shows child threads and refuses to emulate child steering", async ({ page }) => {
await openConsole(page);
await runManifest(page, "subagents");
await expect(page.getByTestId("thread-thread-child-b")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("thread-thread-child-a")).toContainText("Completed");
await expect(page.getByTestId("thread-thread-child-b")).toContainText("Failed");
await page.getByTestId("thread-thread-child-a").click();
await expect(page.getByTestId("breadcrumb")).toContainText("thread-child-a");
await expect(page.getByTestId("composer-reason")).toHaveText(
"Direct steering of child threads is not supported by this app-server.",
);
await expect(page.getByTestId("composer-input")).toBeDisabled();
await shot(page, "desktop-13-lineage-child");
});
test("shows the failed item and diagnostic in the record", async ({ page }) => {
await openConsole(page);
await runManifest(page, "failure");
await expect(page.getByTestId("turn-failed")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("tool-failure")).toContainText("cannot find module");
await shot(page, "desktop-14-failed-turn");
});
test("resumes the same session after a transport drop", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
const before = await page.getByTestId("live-transcript").innerText();
await page.getByTestId("drop-connection").click();
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
await expect(page.getByTestId("composer-input")).toBeDisabled();
await shot(page, "desktop-15-reconnect-banner");
await page.getByTestId("retry-now").click();
await expect(page.getByTestId("connection-status")).toHaveText("connected", {
timeout: 20_000,
});
// Live and resumed transcripts must be identical, not merely similar.
expect(await page.getByTestId("live-transcript").innerText()).toContain(before.trim());
});
test("restores the same session across a page refresh", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
const before = await page.getByTestId("live-transcript").innerText();
await page.reload();
await page.getByRole("button", { name: "Live console" }).click();
await expect(page.getByTestId("live-transcript")).toContainText("finished the task", {
timeout: 20_000,
});
expect(await page.getByTestId("live-transcript").innerText()).toContain(before.trim());
await shot(page, "desktop-16-refresh-replay");
});
test("replays a recorded session with a stepper", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByTestId("toggle-replay").click();
await expect(page.getByTestId("replay-badge")).toBeVisible();
await expect(page.getByTestId("replay-stepper")).toBeVisible();
await expect(page.getByTestId("empty-transcript")).toBeVisible();
await page.getByRole("button", { name: "Step forward" }).click();
await page.getByRole("button", { name: "Step forward" }).click();
await expect(page.getByTestId("empty-transcript")).toHaveCount(0);
await shot(page, "desktop-17-replay-mode");
await page.getByTestId("toggle-replay").click();
await expect(page.getByTestId("replay-badge")).toHaveCount(0);
await expect(page.getByTestId("turn-completed")).toBeVisible();
});
test("inspects canonical events and reports reducer parity", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByTestId("toggle-inspector").click();
await expect(page.getByTestId("inspector-events")).toBeVisible();
await page.getByTestId("inspector-filter").fill("turn.completed");
await expect(page.getByTestId("inspector-events").getByRole("listitem")).toHaveCount(1);
await shot(page, "desktop-18-inspector-events");
await page.getByTestId("inspector-filter").fill("");
await page.getByRole("tab", { name: "Session" }).click();
await expect(page.getByTestId("inspector-session")).toContainText("Credentials in browser");
await expect(page.getByTestId("replay-parity")).toHaveText("match");
await shot(page, "desktop-19-inspector-session");
});
test("meets the keyboard contract", async ({ page }) => {
await openConsole(page);
await runManifest(page, "steering");
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
// K2: Escape moves focus to Stop and never interrupts directly.
await page.getByTestId("composer-input").focus();
await page.keyboard.press("Escape");
await expect(page.getByTestId("composer-stop")).toBeFocused();
await expect(page.getByTestId("turn-interrupted")).toHaveCount(0);
// K2: Enter steers the running turn.
await page.getByTestId("composer-input").fill("Steered with the keyboard.");
await page.getByTestId("composer-input").press("Enter");
await expect(page.getByTestId("steering-chip").first()).toContainText("acknowledged", {
timeout: 15_000,
});
// K7: inspector tabs follow the arrow-key tabs pattern.
await page.getByTestId("toggle-inspector").click();
await page.getByRole("tab", { name: "Events" }).focus();
await page.keyboard.press("ArrowRight");
await expect(page.getByRole("tab", { name: "Requests" })).toBeFocused();
await expect(page.getByRole("tab", { name: "Requests" })).toHaveAttribute(
"aria-selected",
"true",
);
// K5: lineage tree responds to arrow keys.
await page.getByTestId("lineage-tree").getByRole("treeitem").first().focus();
await page.keyboard.press("ArrowDown");
// K6/A1: the transcript is a labelled log landmark.
await expect(page.getByTestId("live-transcript")).toHaveAttribute("role", "log");
await expect(page.getByTestId("live-transcript")).toHaveAttribute("aria-live", "polite");
});
test("resets demo state behind a naming confirmation", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByRole("button", { name: "Reset demo state", exact: true }).first().click();
await expect(page.getByRole("dialog")).toContainText(
"Recorded evidence files are not touched.",
);
await page.getByTestId("reset-confirm").click();
await expect(page.getByTestId("empty-transcript")).toBeVisible();
});
});
test.describe("Live console on a small screen", () => {
test("keeps the transcript and composer usable at 390 by 844", async ({ page }) => {
await page.setViewportSize(MOBILE);
await openConsole(page);
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
await expect(page.getByTestId("manifest-list")).toHaveCount(0);
await shot(page, "mobile-01-idle-empty");
await page.getByRole("tab", { name: "Session" }).click();
await expect(page.getByTestId("manifest-list")).toBeVisible();
await page.getByTestId("manifest-approvals").click();
await page.getByTestId("run-manifest").click();
await shot(page, "mobile-02-session-segment");
await page.getByRole("tab", { name: "Chat" }).click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
"data-status",
"pending",
{ timeout: 20_000 },
);
await shot(page, "mobile-03-request-pending");
await page.getByTestId("request-action-accept").first().click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
"data-status",
"resolved",
{ timeout: 15_000 },
);
await page.getByRole("tab", { name: "Inspector" }).click();
await expect(page.getByTestId("inspector-events")).toBeVisible();
await shot(page, "mobile-04-inspector");
// WCAG 1.4.10 reflow: no horizontal scrolling of the page itself.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(1);
});
});
@@ -0,0 +1,29 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: ["static-replay.spec.ts", "live-console.spec.ts"],
fullyParallel: false,
workers: 1,
reporter: "line",
use: {
baseURL: "http://127.0.0.1:4179",
viewport: { width: 1440, height: 1000 },
},
webServer: {
command:
"pnpm exec vite preview --config vite.config.ts --host 127.0.0.1 --port 4179",
env: {
// Keep streamed chunks slow enough that interrupt races are reachable
// from a test, and fast enough that the suite stays quick.
PAPERCLIP_LIVE_CONSOLE_CHUNK_DELAY_MS: "60",
// One server serves the whole suite, and every test leaves its session
// behind when its page closes, so the interactive default of 16 is
// exhausted partway through.
PAPERCLIP_LIVE_CONSOLE_MAX_SESSIONS: "64",
},
url: "http://127.0.0.1:4179",
reuseExistingServer: false,
timeout: 30_000,
},
});
@@ -0,0 +1,642 @@
import { useMemo, useRef, useState } from "react";
import type { PrpEvent, PrpRequest } from "../../../src/protocol/replay-contract";
import type { SessionSnapshot } from "../../../src/reducer/session-reducer";
import type {
LocalRunnerRunMetadata,
LocalRunnerRunTrace,
LocalRunnerScenario,
} from "../../../src/contracts/local-runner";
import {
applyLocalRunnerLiveEvent,
createLocalRunnerLiveSnapshot,
localRunnerSnapshotsMatch,
replayLocalRunnerEvents,
} from "../../../src/tracer/local-runner-live";
import { replayReplayFixtureText } from "../../../src/tracer/replay";
import { Badge } from "./components/ui/badge";
import { Button } from "./components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./components/ui/card";
import { Textarea } from "./components/ui/textarea";
import { LiveConsole } from "./live/LiveConsole";
const fixtureFiles = import.meta.glob(
"../../../protocol/fixtures/replay/*.json",
{ eager: true, query: "?raw", import: "default" },
) as Record<string, string>;
const fixtureOrder = [
"happy-path",
"failed-run",
"interrupted-run",
"duplicate-event",
"source-gap",
"unknown-optional-fields",
"unsupported-required-version",
];
const liveScenarios: Array<{ key: LocalRunnerScenario; label: string }> = [
{ key: "happy-path", label: "Happy path" },
{ key: "permission-input", label: "Permission and input" },
{ key: "interrupted", label: "Interruption" },
{ key: "error", label: "Scripted error" },
{ key: "duplicate-terminal", label: "Duplicate terminal guard" },
];
type BrowserMode = "console" | "live" | "replay";
type LiveStatus = "idle" | "starting" | "running" | "terminal" | "error";
const modeCopy: Record<
BrowserMode,
{ eyebrow: string; title: string; description: string }
> = {
console: {
eyebrow: "Paperclip Runner Protocol · Live console",
title: "Live Codex protocol console",
description:
"Chat with a live session, steer it, stop it, answer its requests, and inspect the canonical protocol behind every surface.",
},
live: {
eyebrow: "Paperclip Runner Protocol · Local runner",
title: "Live runner diagnostics",
description:
"Run the local harness, stream validated events, and compare the live and replayed state.",
},
replay: {
eyebrow: "Paperclip Runner Protocol · Replay",
title: "Static protocol replay",
description:
"Validate a protocol fixture, replay its events, and inspect the resulting session state.",
},
};
interface BrowserFixture {
key: string;
label: string;
source: string;
}
type LocalRunnerStreamRecord =
| { kind: "event"; event: unknown }
| { kind: "diagnostic"; message: string }
| { kind: "trace"; trace: LocalRunnerRunTrace }
| { kind: "error"; message: string };
const browserFixtures = Object.entries(fixtureFiles)
.map(([path, source]): BrowserFixture => {
const key = path.split("/").at(-1)?.replace(/\.json$/, "") ?? path;
const parsed = JSON.parse(source) as { name?: string };
return { key, label: parsed.name ?? key, source };
})
.sort((left, right) => fixtureOrder.indexOf(left.key) - fixtureOrder.indexOf(right.key));
function terminalTone(snapshot: SessionSnapshot) {
if (snapshot.integrity === "gap_detected") {
return "warning" as const;
}
if (snapshot.terminal?.runTerminalState === "succeeded") {
return "success" as const;
}
if (snapshot.terminal?.runTerminalState === "failed") {
return "danger" as const;
}
return "neutral" as const;
}
function humanizeProtocolLabel(value: string): string {
const words = value.replaceAll(/[_-]/g, " ");
return words.length === 0 ? words : `${words[0]?.toUpperCase()}${words.slice(1)}`;
}
function liveStatusTone(status: LiveStatus, snapshot: SessionSnapshot | null) {
if (status === "error") {
return "danger" as const;
}
if (status !== "terminal") {
return "neutral" as const;
}
if (snapshot?.terminal?.runTerminalState === "succeeded") {
return "success" as const;
}
if (snapshot?.terminal?.runTerminalState === "failed") {
return "danger" as const;
}
return "neutral" as const;
}
function SnapshotSummary({
snapshot,
label = "Session snapshot",
}: {
snapshot: SessionSnapshot;
label?: string;
}) {
return (
<>
<div className="snapshot-heading">
<div>
<p className="eyebrow">{label}</p>
<h2>{snapshot.fixtureName}</h2>
</div>
<Badge tone={terminalTone(snapshot)} data-testid="terminal-badge">
{humanizeProtocolLabel(
snapshot.integrity === "gap_detected"
? "Gap detected"
: (snapshot.terminal?.runTerminalState ?? snapshot.runPhase),
)}
</Badge>
</div>
<dl className="snapshot-grid">
<div>
<dt>Run</dt>
<dd>{snapshot.identity.runId}</dd>
</div>
<div>
<dt>Session</dt>
<dd>{snapshot.identity.normalizedSessionId}</dd>
</div>
<div>
<dt>Turn</dt>
<dd>{snapshot.terminal?.turnTerminalState ?? snapshot.turnState}</dd>
</div>
<div>
<dt>Events</dt>
<dd>{snapshot.timeline.length}</dd>
</div>
</dl>
{snapshot.proposedResult ? (
<div className="result-summary" data-testid="result-summary">
<span>
Reported {humanizeProtocolLabel(snapshot.proposedResult.reportedWorkDisposition)}
</span>
<p>{snapshot.proposedResult.summary}</p>
</div>
) : null}
{snapshot.gaps.length > 0 ? (
<div className="diagnostic" role="status">
Missing source sequence {snapshot.gaps.flatMap((gap) => gap.missing).join(", ")}.
Replay remains inspectable but is not complete.
</div>
) : null}
<div className="timeline-heading">
<h3>Ordered timeline</h3>
<span>{snapshot.duplicateEventIds.length} duplicate events ignored</span>
</div>
<ol className="timeline" data-testid="timeline">
{snapshot.timeline.map((event) => (
<li key={event.sourceEventId}>
<span className="sequence" aria-hidden="true">
{event.sourceSeq}
</span>
<div>
<div className="timeline-title">
<code>{event.eventType}</code>
<time dateTime={event.emittedAt}>{event.emittedAt.slice(11, 23)}</time>
</div>
<p>{event.summary}</p>
</div>
</li>
))}
</ol>
<details className="snapshot-json">
<summary>Inspect snapshot JSON</summary>
<pre>{JSON.stringify(snapshot, null, 2)}</pre>
</details>
</>
);
}
function pendingRuntimeRequest(events: readonly PrpEvent[]): PrpRequest | null {
const resolved = new Set(
events
.filter((event) => event.eventType === "runtime_request.resolved")
.map((event) => (event.payload as Record<string, unknown>).requestId)
.filter((value): value is string => typeof value === "string"),
);
for (const event of events.toReversed()) {
if (event.eventType !== "runtime_request.created") {
continue;
}
const request = (event.payload as Record<string, unknown>).request;
if (
typeof request === "object" &&
request !== null &&
"requestId" in request &&
typeof request.requestId === "string" &&
!resolved.has(request.requestId)
) {
return request as PrpRequest;
}
}
return null;
}
function LiveRunner() {
const [scenario, setScenario] = useState<LocalRunnerScenario>("happy-path");
const [status, setStatus] = useState<LiveStatus>("idle");
const [runId, setRunId] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<SessionSnapshot | null>(null);
const [events, setEvents] = useState<PrpEvent[]>([]);
const [error, setError] = useState<string | null>(null);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [input, setInput] = useState("local-runner-live-trace");
const [trace, setTrace] = useState<LocalRunnerRunTrace | null>(null);
const [parity, setParity] = useState<boolean | null>(null);
const snapshotRef = useRef<SessionSnapshot | null>(null);
const eventsRef = useRef<PrpEvent[]>([]);
const request = useMemo(() => pendingRuntimeRequest(events), [events]);
async function startRun() {
setStatus("starting");
setError(null);
setDiagnostic(null);
setTrace(null);
setParity(null);
setRunId(null);
setEvents([]);
eventsRef.current = [];
setSnapshot(null);
snapshotRef.current = null;
try {
const startResponse = await fetch("/api/localRunner/runs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scenario }),
});
const started = (await startResponse.json()) as {
id?: string;
metadata?: LocalRunnerRunMetadata;
error?: string;
};
if (!startResponse.ok || started.id === undefined || started.metadata === undefined) {
throw new Error(started.error ?? "The local runner did not start.");
}
setRunId(started.id);
const initial = createLocalRunnerLiveSnapshot(started.metadata);
setSnapshot(initial);
snapshotRef.current = initial;
setStatus("running");
const streamResponse = await fetch(`/api/localRunner/runs/${started.id}/events`);
if (!streamResponse.ok || streamResponse.body === null) {
throw new Error("The live event stream did not open.");
}
const reader = streamResponse.body.getReader();
const decoder = new TextDecoder();
let buffered = "";
while (true) {
const part = await reader.read();
buffered += decoder.decode(part.value, { stream: !part.done });
const lines = buffered.split("\n");
buffered = lines.pop() ?? "";
for (const line of lines) {
if (line.trim().length > 0) {
handleRecord(JSON.parse(line) as LocalRunnerStreamRecord, started.metadata);
}
}
if (part.done) {
if (buffered.trim().length > 0) {
handleRecord(JSON.parse(buffered) as LocalRunnerStreamRecord, started.metadata);
}
break;
}
}
} catch (cause) {
setStatus("error");
setError(cause instanceof Error ? cause.message : String(cause));
}
}
function handleRecord(record: LocalRunnerStreamRecord, metadata: LocalRunnerRunMetadata) {
if (record.kind === "event") {
const current = snapshotRef.current;
if (current === null) {
return;
}
const applied = applyLocalRunnerLiveEvent(current, record.event);
if (!applied.ok) {
setStatus("error");
setError(applied.issues.join("; "));
return;
}
snapshotRef.current = applied.snapshot;
setSnapshot(applied.snapshot);
eventsRef.current = [...eventsRef.current, applied.event];
setEvents(eventsRef.current);
return;
}
if (record.kind === "diagnostic") {
setDiagnostic(record.message);
return;
}
if (record.kind === "error") {
setStatus("error");
setError(record.message);
return;
}
setTrace(record.trace);
const replay = replayLocalRunnerEvents(metadata, record.trace.events);
setParity(
snapshotRef.current === null
? false
: localRunnerSnapshotsMatch(snapshotRef.current, replay),
);
setStatus("terminal");
}
async function postAction(action: "interrupt" | "resolve", body?: unknown) {
if (runId === null) {
return;
}
const response = await fetch(`/api/localRunner/runs/${runId}/${action}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!response.ok) {
const failure = (await response.json()) as { error?: string };
setError(failure.error ?? `The ${action} command failed.`);
setStatus("error");
}
}
return (
<div className="workspace-grid live-workspace">
<Card aria-labelledby="live-controls-title">
<CardHeader>
<CardTitle id="live-controls-title">Local runner controls</CardTitle>
<CardDescription>
Start one Rust runner and one scripted fake harness. No network or model is used.
</CardDescription>
</CardHeader>
<CardContent className="editor-content">
<label htmlFor="live-scenario">Scenario</label>
<select
id="live-scenario"
value={scenario}
disabled={status === "starting" || status === "running"}
onChange={(event) => setScenario(event.target.value as LocalRunnerScenario)}
>
{liveScenarios.map((candidate) => (
<option key={candidate.key} value={candidate.key}>
{candidate.label}
</option>
))}
</select>
<div className="scenario-actions">
<Button
type="button"
disabled={status === "starting" || status === "running"}
onClick={() => void startRun()}
>
{status === "starting" ? "Starting…" : "Start local run"}
</Button>
<Button
type="button"
disabled={status !== "running" || snapshot?.turnState !== "running"}
onClick={() => void postAction("interrupt")}
>
Interrupt turn
</Button>
</div>
<div className="live-status" aria-live="polite">
<span>Runner status</span>
<Badge
tone={liveStatusTone(status, snapshot)}
data-testid="live-status"
>
{humanizeProtocolLabel(status)}
</Badge>
</div>
{request?.requestKind === "runtime" ? (
<div className="request-card" data-testid="runtime-request">
<div>
<p className="eyebrow">{request.type} request</p>
<p>{request.prompt}</p>
</div>
{request.type === "permission" ? (
<Button
type="button"
onClick={() =>
void postAction("resolve", {
requestId: request.requestId,
response: { decision: "allow" },
})
}
>
Allow
</Button>
) : (
<>
<label htmlFor="runtime-input">Response</label>
<input
id="runtime-input"
value={input}
onChange={(event) => setInput(event.target.value)}
/>
<Button
type="button"
disabled={input.trim().length === 0}
onClick={() =>
void postAction("resolve", {
requestId: request.requestId,
response: { text: input },
})
}
>
Send input
</Button>
</>
)}
</div>
) : null}
{trace?.harnessProcessExit ? (
<dl className="process-facts" data-testid="process-facts">
<div>
<dt>Harness process exit</dt>
<dd>{trace.harnessProcessExit.exitCode ?? "signal"}</dd>
</div>
<div>
<dt>Semantic result</dt>
<dd>{trace.result?.reportedWorkDisposition ?? "missing"}</dd>
</div>
</dl>
) : null}
{parity !== null ? (
<div className="parity-line" data-testid="parity-result">
<span>Live and replay reducer output</span>
<Badge tone={parity ? "success" : "danger"}>
{parity ? "Match" : "Mismatch"}
</Badge>
</div>
) : null}
{diagnostic ? <p className="diagnostic">{diagnostic}</p> : null}
{error ? <p className="validation-errors" role="alert">{error}</p> : null}
</CardContent>
</Card>
<section className="replay-panel" aria-live="polite">
{snapshot ? (
<SnapshotSummary snapshot={snapshot} label="Validated live snapshot" />
) : (
<div className="empty-live-state">
<p className="eyebrow">Validated live snapshot</p>
<h2>Start a local run</h2>
<p>Each incoming event will pass the Replay validator before the reducer applies it.</p>
</div>
)}
</section>
</div>
);
}
function StaticReplay() {
const defaultFixture = browserFixtures[0];
if (defaultFixture === undefined) {
throw new Error("No Replay fixtures were bundled");
}
const [selectedFixture, setSelectedFixture] = useState(defaultFixture.key);
const [source, setSource] = useState(defaultFixture.source);
const [replay, setReplay] = useState(() => replayReplayFixtureText(defaultFixture.source));
const selected = useMemo(
() => browserFixtures.find((fixture) => fixture.key === selectedFixture),
[selectedFixture],
);
function chooseFixture(key: string) {
const fixture = browserFixtures.find((candidate) => candidate.key === key);
if (fixture === undefined) {
return;
}
setSelectedFixture(key);
setSource(fixture.source);
setReplay(replayReplayFixtureText(fixture.source));
}
return (
<div className="workspace-grid">
<Card aria-labelledby="fixture-editor-title">
<CardHeader>
<CardTitle id="fixture-editor-title">Fixture input</CardTitle>
<CardDescription>
Choose a conformance case or edit the JSON, then validate and replay it.
</CardDescription>
</CardHeader>
<CardContent className="editor-content">
<label htmlFor="fixture-select">Fixture</label>
<select
id="fixture-select"
value={selectedFixture}
onChange={(event) => chooseFixture(event.target.value)}
>
{browserFixtures.map((fixture) => (
<option key={fixture.key} value={fixture.key}>
{fixture.label}
</option>
))}
</select>
<label htmlFor="fixture-source">PRP JSON</label>
<Textarea
id="fixture-source"
spellCheck={false}
value={source}
onChange={(event) => setSource(event.target.value)}
/>
<div className="editor-actions">
<span>{selected?.label ?? "Edited fixture"}</span>
<Button type="button" onClick={() => setReplay(replayReplayFixtureText(source))}>
Validate &amp; replay
</Button>
</div>
</CardContent>
</Card>
<section className="replay-panel" aria-live="polite">
{replay.ok ? (
<SnapshotSummary snapshot={replay.snapshot} label="Static replay snapshot" />
) : (
<div className="validation-errors" role="alert">
<Badge tone="danger">Validation failed</Badge>
<h2>Fixture cannot be replayed</h2>
<ul>
{replay.issues.map((issue, index) => (
<li key={`${issue.path}-${index}`}>
<code>{issue.path}</code> — {issue.message}
</li>
))}
</ul>
</div>
)}
</section>
</div>
);
}
export function App() {
// Replay-3 surfaces keep their landing mode: their screenshots are frozen
// QA evidence and their specs open on the Local runner runner.
const [mode, setMode] = useState<BrowserMode>("live");
const header = modeCopy[mode];
return (
<main className="app-shell">
<header className="page-header">
<div>
<p className="eyebrow">{header.eyebrow}</p>
<h1>{header.title}</h1>
<p>{header.description}</p>
</div>
<Badge tone="neutral">Standalone · no Paperclip core</Badge>
</header>
<nav className="mode-switch" aria-label="Runner devtool mode">
<Button
type="button"
aria-pressed={mode === "console"}
onClick={() => setMode("console")}
>
Live console
</Button>
<Button
type="button"
aria-pressed={mode === "live"}
onClick={() => setMode("live")}
>
Live runner
</Button>
<Button
type="button"
aria-pressed={mode === "replay"}
onClick={() => setMode("replay")}
>
Static replay
</Button>
</nav>
{mode === "console" ? (
<LiveConsole />
) : mode === "live" ? (
<LiveRunner />
) : (
<StaticReplay />
)}
</main>
);
}
@@ -0,0 +1,19 @@
import * as React from "react";
/** Source-adapted shadcn Badge with semantic state supplied through data-tone. */
export function Badge({
className = "",
tone = "neutral",
...props
}: React.ComponentProps<"span"> & {
tone?: "neutral" | "accent" | "success" | "warning" | "danger";
}) {
return (
<span
data-slot="badge"
data-tone={tone}
className={`ui-badge ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,40 @@
import * as React from "react";
/**
* Source-adapted shadcn Alert. Failures stay in the transcript record, so this
* is only used for standing conditions (reconnect, pending request, replay).
*/
export function Banner({
className = "",
tone = "neutral",
assertive = false,
...props
}: React.ComponentProps<"div"> & {
tone?: "neutral" | "accent" | "warning" | "danger";
assertive?: boolean;
}) {
return (
<div
data-slot="banner"
data-tone={tone}
role={assertive ? "alert" : "status"}
aria-live={assertive ? "assertive" : "polite"}
className={`ui-banner ${className}`.trim()}
{...props}
/>
);
}
export function BannerText({ className = "", ...props }: React.ComponentProps<"p">) {
return <p data-slot="banner-text" className={`ui-banner-text ${className}`.trim()} {...props} />;
}
export function BannerActions({ className = "", ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="banner-actions"
className={`ui-banner-actions ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,15 @@
import * as React from "react";
/** Source-adapted shadcn Button for the standalone, non-Tailwind devtool. */
export function Button({
className = "",
...props
}: React.ComponentProps<"button">) {
return (
<button
data-slot="button"
className={`ui-button ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,26 @@
import * as React from "react";
function slotClass(slot: string, className: string | undefined) {
return `${slot} ${className ?? ""}`.trim();
}
/** Source-adapted shadcn Card family for the standalone devtool. */
export function Card({ className, ...props }: React.ComponentProps<"section">) {
return <section data-slot="card" className={slotClass("ui-card", className)} {...props} />;
}
export function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-header" className={slotClass("ui-card-header", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.ComponentProps<"h2">) {
return <h2 data-slot="card-title" className={slotClass("ui-card-title", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
return <p data-slot="card-description" className={slotClass("ui-card-description", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={slotClass("ui-card-content", className)} {...props} />;
}
@@ -0,0 +1,133 @@
import * as React from "react";
import type { ComposerState } from "../../live/transcript-model";
import { Button } from "./button";
const MAX_ROWS = 8;
const PRIMARY_LABEL: Record<ComposerState, string> = {
idle: "Send",
submitting: "Sending…",
"active-turn": "Steer",
interrupting: "Stopping…",
disconnected: "Send",
terminal: "Send",
};
const HINT: Record<ComposerState, string> = {
idle: "Enter sends. Shift and Enter add a line.",
submitting: "Waiting for the turn to be accepted.",
"active-turn": "Enter steers the running turn. Escape moves focus to Stop.",
interrupting: "Stopping the turn. Waiting for the terminal turn event.",
disconnected: "The composer is disabled until the connection is restored.",
terminal: "This session is closed. Replay it or reset the demo state.",
};
/**
* Source-adapted AI Elements `PromptInput`. The textarea plus action-row
* anatomy and submit-on-Enter are kept; the Send/Steer/Stop tri-state comes
* from the runner protocol, and there are no attachments or model pickers.
*/
export function Composer({
state,
value,
onValueChange,
onSubmit,
onStop,
disabledReason = null,
label = "Message",
}: {
state: ComposerState;
value: string;
onValueChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
disabledReason?: string | null;
label?: string;
}) {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const stopRef = React.useRef<HTMLButtonElement>(null);
const inputId = React.useId();
const hintId = React.useId();
const reasonId = React.useId();
const locked =
disabledReason !== null ||
state === "submitting" ||
state === "interrupting" ||
state === "disconnected" ||
state === "terminal";
const canStop = state === "active-turn" || state === "submitting";
React.useEffect(() => {
const textarea = textareaRef.current;
if (textarea === null) return;
textarea.rows = 1;
const rows = Math.min(value.split("\n").length, MAX_ROWS);
textarea.rows = Math.max(rows, 2);
}, [value]);
function onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (!locked && value.trim().length > 0) onSubmit();
return;
}
// Escape never interrupts directly; it only moves focus to Stop.
if (event.key === "Escape" && canStop) {
event.preventDefault();
stopRef.current?.focus();
}
}
return (
<div data-slot="composer" data-state={state} className="ui-composer">
<label className="ui-visually-hidden" htmlFor={inputId}>
{label}
</label>
<textarea
ref={textareaRef}
id={inputId}
className="ui-composer-input"
data-testid="composer-input"
rows={2}
value={value}
disabled={locked}
aria-describedby={disabledReason === null ? hintId : `${hintId} ${reasonId}`}
placeholder={state === "active-turn" ? "Steer the running turn…" : "Send a message…"}
onChange={(event) => onValueChange(event.target.value)}
onKeyDown={onKeyDown}
/>
<div className="ui-composer-actions">
<p id={hintId} className="ui-composer-hint">
{HINT[state]}
</p>
{disabledReason === null ? null : (
<p id={reasonId} className="ui-composer-reason" data-testid="composer-reason">
{disabledReason}
</p>
)}
<div className="ui-composer-buttons">
<Button
ref={stopRef}
type="button"
className="ui-button--danger"
data-testid="composer-stop"
disabled={!canStop}
onClick={onStop}
>
Stop
</Button>
<Button
type="button"
data-testid="composer-send"
disabled={locked || value.trim().length === 0}
onClick={onSubmit}
>
{PRIMARY_LABEL[state]}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,95 @@
import * as React from "react";
/**
* Source-adapted AI Elements `Conversation`. The stick-to-bottom semantics and
* the "jump to latest" affordance are kept; the `use-stick-to-bottom`
* dependency is replaced by a scroll listener, and the unseen count comes from
* the reducer timeline length rather than a message store.
*/
export function Conversation({
itemCount,
label,
children,
}: {
itemCount: number;
label: string;
children: React.ReactNode;
}) {
const viewportRef = React.useRef<HTMLDivElement>(null);
const [following, setFollowing] = React.useState(true);
const [seenCount, setSeenCount] = React.useState(itemCount);
const scrollToBottom = React.useCallback((smooth: boolean) => {
const viewport = viewportRef.current;
if (viewport === null) return;
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: smooth && !reduceMotion ? "smooth" : "auto",
});
}, []);
React.useEffect(() => {
if (!following) return;
scrollToBottom(true);
setSeenCount(itemCount);
}, [itemCount, following, scrollToBottom]);
function onScroll() {
const viewport = viewportRef.current;
if (viewport === null) return;
const atBottom =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 24;
setFollowing(atBottom);
if (atBottom) setSeenCount(itemCount);
}
function reengage() {
setFollowing(true);
setSeenCount(itemCount);
scrollToBottom(true);
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "End") {
event.preventDefault();
reengage();
} else if (event.key === "Home") {
event.preventDefault();
setFollowing(false);
viewportRef.current?.scrollTo({ top: 0 });
}
}
const unseen = Math.max(itemCount - seenCount, 0);
return (
<div data-slot="conversation" className="ui-conversation">
<div
ref={viewportRef}
role="log"
aria-label={label}
aria-live="polite"
aria-relevant="additions"
tabIndex={0}
className="ui-conversation-viewport"
data-following={following}
data-testid="live-transcript"
onScroll={onScroll}
onKeyDown={onKeyDown}
>
{children}
</div>
{following ? null : (
<button
type="button"
className="ui-jump-to-latest"
data-testid="jump-to-latest"
onClick={reengage}
>
Jump to latest{unseen > 0 ? ` (${unseen} new)` : ""}
</button>
)}
</div>
);
}
@@ -0,0 +1,61 @@
import * as React from "react";
/**
* Source-adapted shadcn Dialog rebuilt on the native `<dialog>` element.
* `showModal()` supplies the focus trap, the backdrop, and Escape-to-close,
* so no radix runtime and no focus-trap dependency are needed.
*/
export function Dialog({
open,
onClose,
title,
description,
children,
footer,
}: {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children?: React.ReactNode;
footer: React.ReactNode;
}) {
const ref = React.useRef<HTMLDialogElement>(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
const element = ref.current;
if (element === null) return;
if (open && !element.open) element.showModal();
if (!open && element.open) element.close();
}, [open]);
return (
<dialog
ref={ref}
data-slot="dialog"
className="ui-dialog"
aria-labelledby={titleId}
{...(description === undefined ? {} : { "aria-describedby": descriptionId })}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClose={onClose}
>
<form method="dialog" className="ui-dialog-body">
<h2 id={titleId} className="ui-dialog-title">
{title}
</h2>
{description === undefined ? null : (
<p id={descriptionId} className="ui-dialog-description">
{description}
</p>
)}
{children}
<div className="ui-dialog-footer">{footer}</div>
</form>
</dialog>
);
}
@@ -0,0 +1,138 @@
import * as React from "react";
export interface MenuItemDefinition {
id: string;
label: string;
enabled: boolean;
/** Exact upstream diagnostic; exposed through aria-describedby, not hover. */
reason: string | null;
}
/**
* Source-adapted shadcn DropdownMenu narrowed to the menu-button pattern the
* goal surface needs. Five fixed verbs, no search, no radix portal.
*/
export function Menu({
label,
items,
onSelect,
disabled = false,
disabledReason = null,
triggerId,
}: {
label: string;
items: readonly MenuItemDefinition[];
onSelect: (id: string) => void;
disabled?: boolean;
disabledReason?: string | null;
triggerId: string;
}) {
const [open, setOpen] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const descriptionId = `${triggerId}-reason`;
React.useEffect(() => {
if (!open) return undefined;
function onPointerDown(event: MouseEvent) {
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open]);
function focusItem(index: number) {
const buttons = containerRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]',
);
if (buttons === undefined || buttons.length === 0) return;
buttons[(index + buttons.length) % buttons.length]?.focus();
}
function onTriggerKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (["ArrowDown", "Enter", " "].includes(event.key)) {
event.preventDefault();
setOpen(true);
window.setTimeout(() => focusItem(0), 0);
}
}
function onMenuKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const buttons = [
...(containerRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? []),
];
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
} else if (event.key === "ArrowDown") {
event.preventDefault();
focusItem(index + 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
focusItem(index - 1);
}
}
return (
<div ref={containerRef} data-slot="menu" className="ui-menu">
<button
ref={triggerRef}
id={triggerId}
type="button"
className="ui-button ui-button--quiet"
aria-haspopup="menu"
aria-expanded={open}
aria-disabled={disabled}
{...(disabled && disabledReason !== null ? { "aria-describedby": descriptionId } : {})}
onClick={() => {
if (disabled) return;
setOpen((current) => !current);
}}
onKeyDown={(event) => {
if (disabled) return;
onTriggerKeyDown(event);
}}
>
{label}
<span aria-hidden="true"> ▾</span>
</button>
{disabled && disabledReason !== null ? (
<p id={descriptionId} className="ui-menu-reason">
{disabledReason}
</p>
) : null}
{open && !disabled ? (
<div role="menu" aria-labelledby={triggerId} className="ui-menu-list" onKeyDown={onMenuKeyDown}>
{items.map((item) => {
const itemReasonId = `${triggerId}-${item.id}-reason`;
return (
<React.Fragment key={item.id}>
<button
type="button"
role="menuitem"
className="ui-menu-item"
aria-disabled={!item.enabled}
{...(item.reason === null ? {} : { "aria-describedby": itemReasonId })}
onClick={() => {
if (!item.enabled) return;
setOpen(false);
onSelect(item.id);
}}
>
{item.label}
</button>
{item.reason === null ? null : (
<p id={itemReasonId} className="ui-menu-reason">
{item.reason}
</p>
)}
</React.Fragment>
);
})}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,60 @@
import * as React from "react";
import type { TranscriptRole } from "../../live/transcript-model";
const ROLE_LABELS: Record<TranscriptRole, string> = {
user: "You",
assistant: "Assistant",
reasoning: "Reasoning",
tool: "Tool",
steering: "Steering acknowledged",
interrupt: "Interrupt acknowledged",
goal: "Goal",
lineage: "Child thread",
system: "System",
};
/**
* Source-adapted AI Elements `Message`. The role-based alignment and the
* compact avatar-less variant are kept; the props are reducer item snapshots
* rather than AI SDK message parts.
*/
export function Message({
role,
children,
interrupted = false,
failed = false,
streaming = false,
}: {
role: TranscriptRole;
children: React.ReactNode;
interrupted?: boolean;
failed?: boolean;
streaming?: boolean;
}) {
return (
<article
data-slot="message"
data-role={role}
data-state={failed ? "failed" : interrupted ? "interrupted" : streaming ? "streaming" : "settled"}
className="ui-message"
>
<p className="ui-message-role">{ROLE_LABELS[role]}</p>
<div className="ui-message-body">{children}</div>
{interrupted ? (
<p className="ui-message-divider" data-testid="interrupted-divider">
Interrupted — the text above is everything that arrived.
</p>
) : null}
</article>
);
}
export function MessageText({ text, streaming }: { text: string; streaming: boolean }) {
return (
<p className="ui-message-text">
{text}
{streaming ? <span className="ui-stream-cursor" aria-hidden="true" /> : null}
</p>
);
}
@@ -0,0 +1,33 @@
import * as React from "react";
/**
* Source-adapted AI Elements `Reasoning`. The collapsible block that opens
* while streaming is kept; it is rebuilt on native `<details>` and renders
* exact streamed text with no markdown pipeline.
*/
export function ReasoningItem({
text,
streaming,
interrupted,
}: {
text: string;
streaming: boolean;
interrupted: boolean;
}) {
const summary = text.split("\n")[0]?.slice(0, 96) ?? "Reasoning";
return (
<details
data-slot="reasoning"
className="ui-disclosure"
open={streaming}
data-testid="reasoning-item"
>
<summary className="ui-disclosure-summary">
<span>{streaming ? "Reasoning…" : "Reasoning"}</span>
<span className="ui-disclosure-preview">{summary}</span>
</summary>
<pre className="ui-payload">{text}</pre>
{interrupted ? <p className="ui-message-divider">Interrupted while reasoning.</p> : null}
</details>
);
}
@@ -0,0 +1,94 @@
import * as React from "react";
export interface TabDefinition {
id: string;
label: string;
}
/**
* Source-adapted shadcn Tabs rebuilt on the WAI-ARIA tabs pattern with a
* roving tabindex. No radix runtime: arrow keys move, Home/End jump, Tab
* enters the panel.
*/
export function Tabs({
tabs,
value,
onValueChange,
label,
className = "",
children,
}: {
tabs: readonly TabDefinition[];
value: string;
onValueChange: (value: string) => void;
label: string;
className?: string;
children: React.ReactNode;
}) {
const listRef = React.useRef<HTMLDivElement>(null);
function moveFocus(nextIndex: number) {
const next = tabs[(nextIndex + tabs.length) % tabs.length];
if (next === undefined) return;
onValueChange(next.id);
const button = listRef.current?.querySelector<HTMLButtonElement>(
`[data-tab-id="${next.id}"]`,
);
button?.focus();
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const index = tabs.findIndex((tab) => tab.id === value);
if (event.key === "ArrowRight") {
event.preventDefault();
moveFocus(index + 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
moveFocus(index - 1);
} else if (event.key === "Home") {
event.preventDefault();
moveFocus(0);
} else if (event.key === "End") {
event.preventDefault();
moveFocus(tabs.length - 1);
}
}
return (
<div data-slot="tabs" className={`ui-tabs ${className}`.trim()}>
<div
ref={listRef}
role="tablist"
aria-label={label}
className="ui-tablist"
onKeyDown={onKeyDown}
>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
data-tab-id={tab.id}
id={`tab-${tab.id}`}
aria-selected={tab.id === value}
aria-controls={`tabpanel-${tab.id}`}
tabIndex={tab.id === value ? 0 : -1}
className="ui-tab"
onClick={() => onValueChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div
role="tabpanel"
id={`tabpanel-${value}`}
aria-labelledby={`tab-${value}`}
tabIndex={0}
className="ui-tabpanel"
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import * as React from "react";
/** Source-adapted shadcn Textarea for editable fixture JSON. */
export function Textarea({
className = "",
...props
}: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={`ui-textarea ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,80 @@
import * as React from "react";
import { Badge } from "./badge";
export type ToolStatus = "running" | "completed" | "failed" | "interrupted";
const STATUS_TONE: Record<ToolStatus, "neutral" | "accent" | "success" | "danger" | "warning"> = {
running: "accent",
completed: "success",
failed: "danger",
interrupted: "warning",
};
const STATUS_LABEL: Record<ToolStatus, string> = {
running: "Running",
completed: "Completed",
failed: "Failed",
interrupted: "Interrupted",
};
/**
* Source-adapted AI Elements `Tool`. The header/input/output anatomy is kept;
* the status enum comes from canonical item events instead of an AI SDK tool
* part, and payloads stay in `<pre>` so nothing is prettified away.
*/
export function ToolItem({
name,
status,
input,
output,
failure,
}: {
name: string;
status: ToolStatus;
input: string | null;
output: string | null;
failure: string | null;
}) {
return (
<details
data-slot="tool"
className="ui-disclosure"
open={status === "running" || status === "failed"}
data-testid="tool-item"
data-status={status}
>
<summary className="ui-disclosure-summary">
<span>
Tool <code>{name}</code>
</span>
<Badge tone={STATUS_TONE[status]}>{STATUS_LABEL[status]}</Badge>
</summary>
{input === null ? null : (
<>
<p className="ui-disclosure-label">Input</p>
<pre className="ui-payload">{input}</pre>
</>
)}
{output === null ? null : (
<>
<p className="ui-disclosure-label">Output</p>
<pre className="ui-payload">{output}</pre>
</>
)}
{failure === null ? null : (
<>
<p className="ui-disclosure-label">Diagnostic</p>
<pre className="ui-payload ui-payload--danger" data-testid="tool-failure">
{failure}
</pre>
</>
)}
{status === "interrupted" ? (
<p className="ui-message-divider" data-testid="interrupted-divider">
Interrupted — the last known status is shown and no result was fabricated.
</p>
) : null}
</details>
);
}
@@ -0,0 +1,25 @@
import * as React from "react";
/**
* Source-adapted shadcn Tooltip. The content is always mirrored into a
* described-by element, so a tooltip is never the only channel for a
* diagnostic (interaction map A5).
*/
export function Tooltip({
content,
id,
children,
}: {
content: string;
id: string;
children: React.ReactElement<{ "aria-describedby"?: string }>;
}) {
return (
<span data-slot="tooltip" className="ui-tooltip">
{React.cloneElement(children, { "aria-describedby": id })}
<span role="tooltip" id={id} className="ui-tooltip-content">
{content}
</span>
</span>
);
}
@@ -0,0 +1,229 @@
import * as React from "react";
import type { PrpEvent } from "../../../../src/protocol/replay-contract";
import type { SessionSnapshot } from "../../../../src/reducer/session-reducer";
import { Badge } from "../components/ui/badge";
import { Tabs } from "../components/ui/tabs";
import type { LiveConnectionStatus, LiveSessionState } from "./protocol";
import { capabilityRows } from "./transcript-model";
const TABS = [
{ id: "events", label: "Events" },
{ id: "requests", label: "Requests" },
{ id: "capabilities", label: "Capabilities" },
{ id: "session", label: "Session" },
];
function CopyButton({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = React.useState(false);
return (
<button
type="button"
className="ui-copy"
aria-label={label}
onClick={() => {
void navigator.clipboard?.writeText(value).then(
() => setCopied(true),
() => setCopied(false),
);
}}
>
{copied ? "Copied" : "Copy"}
</button>
);
}
/**
* The debugging surface. Redaction is the server's job: the inspector renders
* redaction markers verbatim and never tries to reconstruct a redacted value.
*/
export function Inspector({
events,
snapshot,
state,
connection,
reconnectAttempt,
replayParity,
highlightItemId,
onHighlightItem,
}: {
events: readonly PrpEvent[];
snapshot: SessionSnapshot | null;
state: LiveSessionState | null;
connection: LiveConnectionStatus;
reconnectAttempt: number;
replayParity: boolean | null;
highlightItemId: string | null;
onHighlightItem: (itemId: string | null) => void;
}) {
const [tab, setTab] = React.useState("events");
const [filter, setFilter] = React.useState("");
const [facet, setFacet] = React.useState<string | null>(null);
const facets = React.useMemo(
() => [...new Set(events.map((event) => event.eventType))].sort(),
[events],
);
const visible = React.useMemo(
() =>
events.filter((event) => {
if (facet !== null && event.eventType !== facet) return false;
if (filter.length === 0) return true;
return JSON.stringify(event).toLowerCase().includes(filter.toLowerCase());
}),
[events, facet, filter],
);
return (
<Tabs tabs={TABS} value={tab} onValueChange={setTab} label="Protocol inspector">
{tab === "events" ? (
<div className="ui-inspector-events">
<label className="ui-visually-hidden" htmlFor="inspector-filter">
Filter events
</label>
<input
id="inspector-filter"
className="ui-inspector-filter"
data-testid="inspector-filter"
placeholder="Filter events"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<div className="ui-facets">
<button
type="button"
className="ui-facet"
aria-pressed={facet === null}
onClick={() => setFacet(null)}
>
All
</button>
{facets.map((candidate) => (
<button
key={candidate}
type="button"
className="ui-facet"
aria-pressed={facet === candidate}
onClick={() => setFacet(candidate)}
>
{candidate}
</button>
))}
</div>
<ol className="ui-inspector-list" data-testid="inspector-events">
{visible.map((event) => (
<li
key={event.sourceEventId}
data-highlighted={
highlightItemId !== null && event.itemId === highlightItemId
}
>
<details
onToggle={(toggle) =>
onHighlightItem(
(toggle.currentTarget as HTMLDetailsElement).open
? (event.itemId ?? null)
: null,
)
}
>
<summary className="ui-inspector-row">
<code className="ui-seq">{event.sourceSeq}</code>
<code>{event.eventType}</code>
<code className="ui-inspector-id">{event.itemId ?? event.turnId ?? "—"}</code>
</summary>
<pre className="ui-payload">{JSON.stringify(event, null, 2)}</pre>
<CopyButton
label={`Copy event ${event.sourceSeq} payload`}
value={JSON.stringify(event, null, 2)}
/>
</details>
</li>
))}
</ol>
</div>
) : null}
{tab === "requests" ? (
<dl className="ui-inspector-grid" data-testid="inspector-requests">
{(snapshot?.requests ?? []).map((request) => (
<div key={request.requestId}>
<dt>
<code>{request.requestId}</code>
</dt>
<dd>
{request.type} — {request.status}
</dd>
</div>
))}
{(snapshot?.requests.length ?? 0) === 0 ? <p>No runtime requests yet.</p> : null}
</dl>
) : null}
{tab === "capabilities" ? (
<ul className="ui-capability-list" data-testid="inspector-capabilities">
{capabilityRows(state?.capabilities ?? { resume: false, typedEvents: false, steering: false, interruption: false, structuredResult: false }).map(
(row) => (
<li key={row.name}>
<span>{row.name}</span>
<Badge tone={row.supported ? "success" : "warning"}>
{row.supported ? "supported" : "unsupported"}
</Badge>
{row.diagnostic === null ? null : (
<p className="ui-capability-diagnostic">{row.diagnostic}</p>
)}
</li>
),
)}
</ul>
) : null}
{tab === "session" ? (
<dl className="ui-inspector-grid" data-testid="inspector-session">
<div>
<dt>Run</dt>
<dd>{state?.runId ?? "—"}</dd>
</div>
<div>
<dt>Normalized session</dt>
<dd>{state?.normalizedSessionId ?? "—"}</dd>
</div>
<div>
<dt>Driver session</dt>
<dd>{state?.driverSession.driverSessionId ?? "—"}</dd>
</div>
<div>
<dt>Source cursors</dt>
<dd>{JSON.stringify(snapshot?.sourceCursors ?? {})}</dd>
</div>
<div>
<dt>Integrity</dt>
<dd>{snapshot?.integrity ?? "—"}</dd>
</div>
<div>
<dt>Gaps</dt>
<dd>{snapshot?.gaps.length ?? 0}</dd>
</div>
<div>
<dt>Connection</dt>
<dd>
{connection}
{connection === "reconnecting" ? ` (attempt ${reconnectAttempt})` : ""}
</dd>
</div>
<div>
<dt>Credentials in browser</dt>
<dd>{state?.credentialsExposed === true ? "yes" : "no"}</dd>
</div>
<div>
<dt>Live and replay reducer</dt>
<dd data-testid="replay-parity">
{replayParity === null ? "not compared yet" : replayParity ? "match" : "mismatch"}
</dd>
</div>
</dl>
) : null}
</Tabs>
);
}
@@ -0,0 +1,487 @@
import * as React from "react";
import { Badge } from "../components/ui/badge";
import { Banner, BannerActions, BannerText } from "../components/ui/banner";
import { Button } from "../components/ui/button";
import { Composer } from "../components/ui/composer";
import { Conversation } from "../components/ui/conversation";
import { Dialog } from "../components/ui/dialog";
import { Message, MessageText } from "../components/ui/message";
import { ReasoningItem } from "../components/ui/reasoning-item";
import { Tabs } from "../components/ui/tabs";
import { ToolItem, type ToolStatus } from "../components/ui/tool-item";
import { Inspector } from "./Inspector";
import { RequestCard } from "./RequestCard";
import { ConnectionBlock, GoalBlock, LineageTree, ManifestList } from "./SessionPanel";
import { useLiveConsole } from "./use-live-console";
import type { TranscriptEntry } from "./transcript-model";
const MOBILE_SEGMENTS = [
{ id: "chat", label: "Chat" },
{ id: "session", label: "Session" },
{ id: "inspector", label: "Inspector" },
];
const TURN_TONE = {
completed: "success",
failed: "danger",
interrupted: "warning",
cancelled: "warning",
} as const;
const TURN_LABEL = {
completed: "Turn completed",
failed: "Turn failed",
interrupted: "Turn interrupted",
cancelled: "Cancelled before start",
} as const;
/**
* Renders exactly one layout. Hiding a second copy with CSS would duplicate
* the transcript log landmark and every control in the DOM.
*/
function useCompactLayout(): boolean {
const [compact, setCompact] = React.useState(
() => window.matchMedia?.("(max-width: 64rem)").matches === true,
);
React.useEffect(() => {
const query = window.matchMedia?.("(max-width: 64rem)");
if (query === undefined) return undefined;
const onChange = () => setCompact(query.matches);
onChange();
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return compact;
}
function toolStatus(entry: Extract<TranscriptEntry, { kind: "item" }>): ToolStatus {
if (entry.interrupted) return "interrupted";
if (entry.item.status === "failed") return "failed";
if (entry.item.status === "running") return "running";
return "completed";
}
export function LiveConsole() {
const console_ = useLiveConsole();
const [draft, setDraft] = React.useState("");
const [inspectorOpen, setInspectorOpen] = React.useState(false);
const [segment, setSegment] = React.useState("chat");
const [resetOpen, setResetOpen] = React.useState(false);
const [highlightItemId, setHighlightItemId] = React.useState<string | null>(null);
const [outcomes, setOutcomes] = React.useState<Record<string, string>>({});
const oldestPendingRef = React.useRef<HTMLLIElement>(null);
const compact = useCompactLayout();
React.useEffect(() => {
const stored = window.localStorage.getItem("paperclip-runner.live-console.inspector");
if (stored === "open") setInspectorOpen(true);
}, []);
React.useEffect(() => {
window.localStorage.setItem(
"paperclip-runner.live-console.inspector",
inspectorOpen ? "open" : "closed",
);
}, [inspectorOpen]);
const manifestId = console_.state?.manifest ?? console_.selectedManifestId;
const activeManifest = console_.manifests.find((entry) => entry.id === manifestId);
const pendingRequests = console_.transcript.filter(
(entry) => entry.kind === "request" && entry.status === "pending",
);
const viewingChild = console_.selectedThreadId !== null;
const childSteeringSupported = false;
React.useEffect(() => {
const snapshot = console_.snapshot;
if (snapshot === null || console_.state === null) return;
if (!["completed", "failed", "interrupted", "cancelled"].includes(snapshot.turnState)) return;
setOutcomes((current) => ({
...current,
[console_.state!.manifest ?? "unknown"]:
snapshot.turnState === "completed" ? "passed observations" : snapshot.turnState,
}));
}, [console_.snapshot, console_.state]);
function submit() {
const text = draft.trim();
if (text.length === 0) return;
setDraft("");
if (console_.composer === "active-turn") void console_.steer(text);
else void console_.send(text);
}
function runManifest(id: string) {
const manifest = console_.manifests.find((entry) => entry.id === id);
void console_.start({ manifestId: id, message: manifest?.prompt ?? "Run the demo." });
}
const composerReason = viewingChild && !childSteeringSupported
? "Direct steering of child threads is not supported by this app-server."
: null;
const transcriptBody = (
<Conversation itemCount={console_.transcript.length} label="Session transcript">
{console_.transcript.length === 0 ? (
<div className="ui-empty-transcript" data-testid="empty-transcript">
<p className="eyebrow">No session yet</p>
<h3>Pick a demo chat or start a blank session</h3>
<p>
The browser never holds a provider credential. Only the demo server starts a driver
and owns the working directory.
</p>
</div>
) : null}
<ol className="ui-transcript" role="list">
{console_.transcript.map((entry) => {
if (entry.kind === "user") {
return (
<li key={entry.key}>
<Message role="user">
<MessageText text={entry.text} streaming={false} />
</Message>
</li>
);
}
if (entry.kind === "turn") {
return (
<li key={entry.key} className="ui-turn-marker" data-testid={`turn-${entry.state}`}>
<Badge tone={TURN_TONE[entry.state]}>{TURN_LABEL[entry.state]}</Badge>
<span>{entry.detail}</span>
</li>
);
}
if (entry.kind === "diagnostic") {
return (
<li key={entry.key}>
<Message role="system" failed>
<MessageText text={entry.message} streaming={false} />
</Message>
</li>
);
}
if (entry.kind === "request") {
const isOldestPending =
entry.status === "pending" && pendingRequests[0]?.key === entry.key;
return (
<RequestCard
key={entry.key}
entry={entry}
{...(isOldestPending ? { focusRef: oldestPendingRef } : {})}
onResolve={(resolution) =>
void console_.resolve(entry.requestId, entry.turnId ?? "", resolution)
}
/>
);
}
if (entry.role === "reasoning") {
return (
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
<ReasoningItem
text={entry.item.text}
streaming={entry.streaming}
interrupted={entry.interrupted}
/>
</li>
);
}
if (entry.role === "tool") {
return (
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
<ToolItem
name={entry.toolName ?? entry.item.kind}
status={toolStatus(entry)}
input={entry.input}
output={entry.output}
failure={entry.failure}
/>
</li>
);
}
return (
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
<Message
role={entry.role}
streaming={entry.streaming}
interrupted={entry.interrupted}
failed={entry.item.status === "failed"}
>
<MessageText text={entry.item.text} streaming={entry.streaming} />
{entry.failure === null ? null : (
<pre className="ui-payload ui-payload--danger">{entry.failure}</pre>
)}
</Message>
</li>
);
})}
{console_.steeringChips.map((chip) => (
<li key={chip.id} className="ui-steering-chip" data-status={chip.status} data-testid="steering-chip">
<Badge
tone={
chip.status === "acknowledged"
? "success"
: chip.status === "pending"
? "accent"
: "danger"
}
>
{chip.status === "rejected"
? `rejected — ${chip.detail ?? "turn already ended"}`
: chip.status}
</Badge>
<span>{chip.text}</span>
{chip.status === "rejected" || chip.status === "failed" ? (
<Button
type="button"
className="ui-button--quiet"
data-testid="steer-as-new-message"
onClick={() => {
console_.dismissSteeringChip(chip.id);
void console_.send(chip.text);
}}
>
Send as new message
</Button>
) : null}
</li>
))}
</ol>
</Conversation>
);
const composerBlock = (
<div className="ui-composer-region">
{console_.connection === "disconnected" || console_.connection === "reconnecting" ? (
<Banner tone="warning" data-testid="reconnect-banner">
<BannerText>
{console_.connection === "reconnecting"
? `Connection lost — reconnecting (attempt ${console_.reconnectAttempt})…`
: "Connection lost — the session is still on the server."}
</BannerText>
<BannerActions>
<Button type="button" data-testid="retry-now" onClick={console_.retryNow}>
Retry now
</Button>
</BannerActions>
</Banner>
) : null}
{pendingRequests.length > 0 ? (
<Banner tone="accent" assertive data-testid="pending-request-banner">
<BannerText>{pendingRequests.length} request waiting — Review</BannerText>
<BannerActions>
<Button
type="button"
onClick={() => oldestPendingRef.current?.focus()}
data-testid="review-request"
>
Review
</Button>
</BannerActions>
</Banner>
) : null}
<Composer
state={console_.composer}
value={draft}
onValueChange={setDraft}
onSubmit={submit}
onStop={() => void console_.interrupt()}
disabledReason={composerReason}
/>
</div>
);
const sessionRail = (
<>
<ManifestList
manifests={console_.manifests}
selectedId={console_.selectedManifestId}
onSelect={console_.selectManifest}
onRun={runManifest}
lastOutcome={outcomes}
disabled={console_.busy}
/>
<ConnectionBlock
connection={console_.connection}
reconnectAttempt={console_.reconnectAttempt}
state={console_.state}
pendingRequestCount={pendingRequests.length}
onDrop={console_.dropConnection}
onRetry={console_.retryNow}
onScrollToOldestRequest={() => oldestPendingRef.current?.focus()}
/>
<GoalBlock state={console_.state} onGoal={(operation, body) => void console_.goal(operation, body)} />
<LineageTree
lineage={console_.state?.lineage ?? []}
selectedThreadId={console_.selectedThreadId}
onSelect={console_.selectThread}
/>
<section className="ui-panel">
<h2 className="ui-panel-title">Demo state</h2>
<Button type="button" className="ui-button--quiet" onClick={() => setResetOpen(true)}>
Reset demo state
</Button>
{activeManifest === undefined ? null : (
<ol className="ui-observation-list" data-testid="observation-checklist">
{activeManifest.expectedObservations.map((observation) => (
<li key={observation}>
<label>
<input type="checkbox" /> {observation}
</label>
</li>
))}
</ol>
)}
</section>
</>
);
const inspectorPanel = (
<Inspector
events={console_.events}
snapshot={console_.snapshot}
state={console_.state}
connection={console_.connection}
reconnectAttempt={console_.reconnectAttempt}
replayParity={console_.replayParity}
highlightItemId={highlightItemId}
onHighlightItem={setHighlightItemId}
/>
);
return (
<div className="live-console" data-inspector={inspectorOpen}>
<div className="live-console-header">
<div className="live-console-title">
{console_.replay.active ? (
<Badge tone="warning" data-testid="replay-badge">
REPLAY
</Badge>
) : null}
<h2>{activeManifest?.name ?? "Live console"}</h2>
{viewingChild ? (
<p className="ui-breadcrumb" data-testid="breadcrumb">
root ▸ {console_.selectedThreadId}
</p>
) : null}
</div>
<div className="live-console-header-actions">
<Button
type="button"
className="ui-button--quiet"
data-testid="toggle-inspector"
aria-pressed={inspectorOpen}
onClick={() => setInspectorOpen((current) => !current)}
>
Protocol inspector
</Button>
<Button
type="button"
className="ui-button--quiet"
data-testid="toggle-replay"
aria-pressed={console_.replay.active}
disabled={console_.events.length === 0}
onClick={() => (console_.replay.active ? console_.replay.exit() : console_.replay.enter())}
>
{console_.replay.active ? "Exit replay" : "Replay"}
</Button>
</div>
</div>
<p className="ui-visually-hidden" aria-live="polite" data-testid="live-announcement">
{console_.announcement}
</p>
{console_.error === null ? null : (
<Banner tone="danger" assertive data-testid="console-error">
<BannerText>{console_.error}</BannerText>
</Banner>
)}
{console_.replay.active ? (
<div className="ui-replay-stepper" data-testid="replay-stepper">
<Button type="button" onClick={console_.replay.togglePlay}>
{console_.replay.playing ? "Pause" : "Play"}
</Button>
<Button type="button" className="ui-button--quiet" onClick={() => console_.replay.step(-1)}>
Step back
</Button>
<Button type="button" className="ui-button--quiet" onClick={() => console_.replay.step(1)}>
Step forward
</Button>
<label htmlFor="replay-position">Position</label>
<input
id="replay-position"
type="range"
min={0}
max={console_.replay.length}
value={console_.replay.position}
data-testid="replay-position"
onChange={(event) => console_.replay.setPosition(Number(event.target.value))}
/>
<span>
{console_.replay.position} / {console_.replay.length}
</span>
</div>
) : null}
{compact ? (
<div className="live-console-mobile">
<Tabs
tabs={MOBILE_SEGMENTS}
value={segment}
onValueChange={setSegment}
label="Console sections"
>
{segment === "chat" ? (
<>
{transcriptBody}
{composerBlock}
</>
) : null}
{segment === "session" ? sessionRail : null}
{segment === "inspector" ? inspectorPanel : null}
</Tabs>
</div>
) : (
<div className="live-console-desktop">
<aside className="live-console-rail">{sessionRail}</aside>
<main className="live-console-center">
{transcriptBody}
{composerBlock}
</main>
{inspectorOpen ? (
<aside className="live-console-inspector" aria-label="Protocol inspector">
{inspectorPanel}
</aside>
) : null}
</div>
)}
<Dialog
open={resetOpen}
onClose={() => setResetOpen(false)}
title="Reset demo state"
description="Discards the current live session transcript. Recorded evidence files are not touched."
footer={
<>
<Button type="button" className="ui-button--quiet" onClick={() => setResetOpen(false)}>
Keep the session
</Button>
<Button
type="button"
className="ui-button--danger"
data-testid="reset-confirm"
onClick={() => {
setResetOpen(false);
setDraft("");
void console_.reset();
}}
>
Reset demo state
</Button>
</>
}
/>
</div>
);
}
@@ -0,0 +1,168 @@
import * as React from "react";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { runtimeRequestSubmission, type TranscriptRequestEntry } from "./transcript-model";
const KIND_LABELS: Record<string, string> = {
command_approval: "Command",
file_approval: "File change",
permission_approval: "Permission",
user_input: "Input",
elicitation: "Input",
};
const ACTION_LABELS: Record<string, string> = {
accept: "Approve",
accept_for_session: "Approve for session",
decline: "Reject",
cancel: "Cancel",
submit: "Submit",
};
const STATUS_TONE = {
pending: "accent",
resolved: "success",
expired: "warning",
cancelled: "warning",
} as const;
function detailBlock(details: Record<string, unknown>): string | null {
const command = details.command;
if (typeof command === "string") return command;
const diff = details.diff;
if (typeof diff === "string") {
const path = typeof details.path === "string" ? `${details.path}\n` : "";
return `${path}${diff}`;
}
return null;
}
/**
* One card renders all five request kinds. Actions come from the upstream
* request contract, so the card can never invent an affordance the runner
* cannot honour, and the first click locks the row until the canonical
* resolved event arrives.
*/
export function RequestCard({
entry,
onResolve,
focusRef,
}: {
entry: TranscriptRequestEntry;
onResolve: (resolution: Record<string, unknown>) => void;
focusRef?: React.Ref<HTMLLIElement>;
}) {
const [answer, setAnswer] = React.useState("");
const [resolving, setResolving] = React.useState(false);
const answerId = React.useId();
const kindLabel = KIND_LABELS[entry.requestKind] ?? "Request";
const pending = entry.status === "pending";
const isInput = entry.requestKind === "user_input" || entry.requestKind === "elicitation";
const payload = detailBlock(entry.details);
React.useEffect(() => {
if (!pending) setResolving(false);
}, [pending]);
function submit(resolution: Record<string, unknown>) {
setResolving(true);
onResolve(resolution);
}
if (!pending) {
return (
<li
ref={focusRef}
className="ui-request ui-request--settled"
data-status={entry.status}
data-testid="request-card"
tabIndex={-1}
>
<details>
<summary className="ui-request-summary">
<span>
{kindLabel} · {entry.status === "resolved"
? `resolved — ${ACTION_LABELS[entry.resolvedAction ?? ""] ?? entry.resolvedAction ?? "unknown"}`
: entry.status === "expired"
? "expired before response"
: "cancelled"}
</span>
<Badge tone={STATUS_TONE[entry.status]}>{entry.status}</Badge>
</summary>
<p className="ui-request-prompt">{entry.prompt}</p>
<p className="ui-request-meta">
<code>{entry.requestId}</code>
{entry.resolvedAt === null ? null : <time dateTime={entry.resolvedAt}>{entry.resolvedAt}</time>}
</p>
{payload === null ? null : <pre className="ui-payload">{payload}</pre>}
</details>
</li>
);
}
return (
<li
ref={focusRef}
className="ui-request"
data-status="pending"
data-testid="request-card"
tabIndex={-1}
>
<div className="ui-request-header">
<span className="ui-request-kind">{kindLabel}</span>
<Badge tone="accent">pending</Badge>
</div>
<p className="ui-request-prompt">{entry.prompt}</p>
<p className="ui-request-meta">
<code>{entry.requestId}</code>
</p>
{payload === null ? null : <pre className="ui-payload">{payload}</pre>}
{isInput ? (
<div className="ui-request-input">
<label htmlFor={answerId}>Answer</label>
<input
id={answerId}
data-testid="request-answer"
value={answer}
disabled={resolving}
onChange={(event) => setAnswer(event.target.value)}
/>
</div>
) : null}
<div className="ui-request-actions">
{entry.actions.map((action) => (
<Button
key={action}
type="button"
className={
action === "decline"
? "ui-button--danger"
: action === "cancel"
? "ui-button--quiet"
: action === "accept" || action === "submit"
? ""
: "ui-button--secondary"
}
disabled={resolving || (action === "submit" && answer.trim().length === 0)}
data-testid={`request-action-${action}`}
onClick={() =>
submit(
action === "submit"
? runtimeRequestSubmission(entry.requestKind, answer)
: { action },
)
}
>
{ACTION_LABELS[action] ?? action}
</Button>
))}
</div>
{resolving ? (
<p className="ui-request-resolving" role="status">
Resolving — waiting for the canonical resolved event.
</p>
) : null}
</li>
);
}
@@ -0,0 +1,346 @@
import * as React from "react";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Dialog } from "../components/ui/dialog";
import { Menu } from "../components/ui/menu";
import type {
GoalOperation,
LiveConnectionStatus,
LiveLineageEntry,
LiveManifestSummary,
LiveSessionState,
} from "./protocol";
import { GOAL_OPERATION_LABELS } from "./protocol";
import { goalAvailability } from "./transcript-model";
const CONNECTION_TONE = {
idle: "neutral",
connecting: "accent",
connected: "success",
reconnecting: "warning",
disconnected: "danger",
terminal: "neutral",
} as const;
const THREAD_STATE_LABEL: Record<string, string> = {
starting: "Starting",
running: "Running",
completed: "Completed",
failed: "Failed",
};
export function ManifestList({
manifests,
selectedId,
onSelect,
onRun,
lastOutcome,
disabled,
}: {
manifests: readonly LiveManifestSummary[];
selectedId: string;
onSelect: (id: string) => void;
onRun: (id: string) => void;
lastOutcome: Record<string, string>;
disabled: boolean;
}) {
return (
<section className="ui-panel" aria-labelledby="manifest-heading">
<h2 id="manifest-heading" className="ui-panel-title">
Demo chats
</h2>
<ul className="ui-manifest-list" data-testid="manifest-list">
{manifests.map((manifest) => (
<li key={manifest.id} data-selected={manifest.id === selectedId}>
<button
type="button"
className="ui-manifest-row"
aria-pressed={manifest.id === selectedId}
data-testid={`manifest-${manifest.id}`}
onClick={() => onSelect(manifest.id)}
>
<span className="ui-manifest-name">{manifest.name}</span>
<span className="ui-manifest-purpose">{manifest.purpose}</span>
<span className="ui-manifest-badges">
{manifest.scenarios.map((scenario) => (
<Badge key={scenario}>{scenario}</Badge>
))}
<Badge tone={lastOutcome[manifest.id] === undefined ? "neutral" : "success"}>
{lastOutcome[manifest.id] ?? "not run"}
</Badge>
</span>
</button>
{manifest.id === selectedId ? (
<>
<details className="ui-disclosure" data-testid="expected-observations">
<summary className="ui-disclosure-summary">Expected observations</summary>
<ol className="ui-observation-list">
{manifest.expectedObservations.map((observation) => (
<li key={observation}>{observation}</li>
))}
</ol>
</details>
<Button
type="button"
disabled={disabled}
data-testid="run-manifest"
onClick={() => onRun(manifest.id)}
>
Run this demo chat
</Button>
</>
) : null}
</li>
))}
</ul>
</section>
);
}
export function ConnectionBlock({
connection,
reconnectAttempt,
state,
pendingRequestCount,
onDrop,
onRetry,
onScrollToOldestRequest,
}: {
connection: LiveConnectionStatus;
reconnectAttempt: number;
state: LiveSessionState | null;
pendingRequestCount: number;
onDrop: () => void;
onRetry: () => void;
onScrollToOldestRequest: () => void;
}) {
return (
<section className="ui-panel" aria-labelledby="connection-heading">
<h2 id="connection-heading" className="ui-panel-title">
Session
</h2>
<div className="ui-status-row">
<span>Connection</span>
<Badge tone={CONNECTION_TONE[connection]} data-testid="connection-status">
{connection === "reconnecting" ? `reconnecting (attempt ${reconnectAttempt})` : connection}
</Badge>
</div>
<div className="ui-status-row">
<span>Pending requests</span>
<button
type="button"
className="ui-chip"
data-testid="pending-request-chip"
disabled={pendingRequestCount === 0}
onClick={onScrollToOldestRequest}
>
{pendingRequestCount}
</button>
</div>
<div className="ui-status-row">
<span>Credentials in browser</span>
<Badge tone={state?.credentialsExposed === true ? "danger" : "success"}>
{state?.credentialsExposed === true ? "exposed" : "none"}
</Badge>
</div>
<dl className="ui-identity-grid">
<div>
<dt>Run</dt>
<dd>{state?.runId ?? "—"}</dd>
</div>
<div>
<dt>Session</dt>
<dd>{state?.normalizedSessionId ?? "—"}</dd>
</div>
</dl>
<div className="ui-panel-actions">
<Button
type="button"
className="ui-button--quiet"
data-testid="drop-connection"
disabled={state === null || connection === "disconnected"}
onClick={onDrop}
>
Simulate connection loss
</Button>
<Button
type="button"
className="ui-button--quiet"
data-testid="resume-session"
disabled={state === null}
onClick={onRetry}
>
Resume this session
</Button>
</div>
</section>
);
}
export function GoalBlock({
state,
onGoal,
}: {
state: LiveSessionState | null;
onGoal: (operation: GoalOperation, body?: Record<string, unknown>) => void;
}) {
const [dialogOpen, setDialogOpen] = React.useState(false);
const [objective, setObjective] = React.useState("");
const objectiveId = React.useId();
const capabilities = state?.capabilities ?? {
resume: false,
typedEvents: false,
steering: false,
interruption: false,
structuredResult: false,
};
const availability = goalAvailability(capabilities, state?.goal?.status ?? null);
const unsupported = capabilities.goals !== true;
const unsupportedReason = availability[0]?.reason ?? null;
return (
<section className="ui-panel" aria-labelledby="goal-heading">
<div className="ui-panel-header">
<h2 id="goal-heading" className="ui-panel-title">
Goal
</h2>
<Menu
triggerId="goal-menu"
label="Goal"
disabled={unsupported || state === null}
disabledReason={
state === null ? "Start a session before changing the goal." : unsupportedReason
}
items={availability.map((entry) => ({
id: entry.operation,
label: GOAL_OPERATION_LABELS[entry.operation],
enabled: entry.enabled,
reason: entry.reason,
}))}
onSelect={(id) => {
if (id === "set") setDialogOpen(true);
else onGoal(id as GoalOperation);
}}
/>
</div>
{state === null ? null : state.goal === null ? (
<p className="ui-goal-empty" data-testid="goal-banner">
{unsupported ? "Goal operations are unsupported here." : "No goal set"}
</p>
) : (
<div className="ui-goal-banner" data-testid="goal-banner">
<p className="ui-goal-objective">{state.goal.objective}</p>
<div className="ui-status-row">
<Badge tone={state.goal.status === "active" ? "success" : "warning"}>
{state.goal.status}
</Badge>
<time dateTime={new Date(state.goal.updatedAt).toISOString()}>
{new Date(state.goal.updatedAt).toISOString().slice(11, 19)}
</time>
</div>
</div>
)}
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title="Set goal"
description="The goal is durable on the thread and every change is also a transcript event."
footer={
<>
<Button type="button" className="ui-button--quiet" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
type="button"
data-testid="goal-set-confirm"
disabled={objective.trim().length === 0}
onClick={() => {
onGoal("set", { objective: objective.trim() });
setObjective("");
setDialogOpen(false);
}}
>
Set goal
</Button>
</>
}
>
<label htmlFor={objectiveId}>Objective</label>
<textarea
id={objectiveId}
className="ui-dialog-input"
data-testid="goal-objective"
rows={3}
value={objective}
onChange={(event) => setObjective(event.target.value)}
/>
</Dialog>
</section>
);
}
export function LineageTree({
lineage,
selectedThreadId,
onSelect,
}: {
lineage: readonly LiveLineageEntry[];
selectedThreadId: string | null;
onSelect: (threadId: string | null) => void;
}) {
const rootId = lineage.find((entry) => entry.depth === 0)?.threadId ?? null;
const current = selectedThreadId ?? rootId;
const treeRef = React.useRef<HTMLUListElement>(null);
function onKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {
const rows = [
...(treeRef.current?.querySelectorAll<HTMLButtonElement>('[role="treeitem"]') ?? []),
];
const index = rows.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === "ArrowDown") {
event.preventDefault();
rows[Math.min(index + 1, rows.length - 1)]?.focus();
} else if (event.key === "ArrowUp") {
event.preventDefault();
rows[Math.max(index - 1, 0)]?.focus();
}
}
return (
<section className="ui-panel" aria-labelledby="lineage-heading">
<h2 id="lineage-heading" className="ui-panel-title">
Threads
</h2>
<ul
ref={treeRef}
role="tree"
aria-label="Session thread lineage"
className="ui-tree"
data-testid="lineage-tree"
onKeyDown={onKeyDown}
>
{lineage.length === 0 ? <li className="ui-tree-empty">No threads yet.</li> : null}
{lineage.map((entry) => (
<li key={entry.threadId} role="none" data-depth={entry.depth}>
<button
type="button"
role="treeitem"
aria-current={entry.threadId === current}
aria-selected={entry.threadId === current}
className="ui-tree-row"
data-testid={`thread-${entry.threadId}`}
onClick={() => onSelect(entry.depth === 0 ? null : entry.threadId)}
>
<span className="ui-state-dot" data-state={entry.status} aria-hidden="true" />
<code>{entry.nickname ?? entry.threadId}</code>
<span className="ui-tree-state">
{THREAD_STATE_LABEL[entry.status] ?? entry.status}
</span>
</button>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,165 @@
import type { PrpEvent } from "../../../../src/protocol/replay-contract";
import type {
GoalOperation,
LiveManifestSummary,
LiveSessionState,
} from "./protocol";
const BASE = "/api/liveConsole";
export class LiveConsoleError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "LiveConsoleError";
this.status = status;
this.code = code;
}
}
async function send<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
...init,
headers: init?.body === undefined ? undefined : { "content-type": "application/json" },
});
const body = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new LiveConsoleError(
response.status,
typeof body.error === "string" ? body.error : "request_failed",
typeof body.message === "string" ? body.message : `Request to ${path} failed.`,
);
}
return body as T;
}
export async function fetchManifests(): Promise<LiveManifestSummary[]> {
const body = await send<{ manifests: LiveManifestSummary[] }>("/manifests");
return body.manifests;
}
export async function createSession(input: {
manifest: string;
objective: string;
message?: string;
startTurn?: boolean;
}): Promise<LiveSessionState> {
return send<LiveSessionState>("/sessions", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function readSession(sessionId: string): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}`);
}
export async function readEvents(
sessionId: string,
after = 0,
): Promise<{ events: PrpEvent[]; cursor: number; replay: boolean }> {
return send(`/sessions/${sessionId}/events?after=${after}`);
}
export async function startTurn(
sessionId: string,
text: string,
): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/turns`, {
method: "POST",
body: JSON.stringify({ text }),
});
}
export async function steerTurn(
sessionId: string,
turnId: string,
text: string,
): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/steer`, {
method: "POST",
body: JSON.stringify({ turnId, text }),
});
}
export async function interruptTurn(
sessionId: string,
turnId: string | null,
): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/interrupt`, {
method: "POST",
body: JSON.stringify({
reason: "browser_operator",
...(turnId === null ? {} : { turnId }),
}),
});
}
export async function resolveRequest(
sessionId: string,
requestId: string,
turnId: string,
resolution: Record<string, unknown>,
): Promise<LiveSessionState> {
return send<LiveSessionState>(
`/sessions/${sessionId}/requests/${encodeURIComponent(requestId)}/resolve`,
{ method: "POST", body: JSON.stringify({ turnId, resolution }) },
);
}
export async function goalOperation(
sessionId: string,
operation: GoalOperation,
body: Record<string, unknown> = {},
): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/goal/${operation}`, {
method: "POST",
body: JSON.stringify(body),
});
}
export async function reconnectSession(sessionId: string): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/reconnect`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function closeSession(sessionId: string): Promise<void> {
await send(`/sessions/${sessionId}/close`, {
method: "POST",
body: JSON.stringify({}),
});
}
export interface EventStreamHandle {
close(): void;
}
/**
* Subscribes to the canonical event stream from a durable cursor. The stream
* never carries provider credentials; the server redacts every frame.
*/
export function openEventStream(
sessionId: string,
after: number,
handlers: {
onEvent: (event: PrpEvent) => void;
onOpen?: () => void;
onError?: () => void;
},
): EventStreamHandle {
const source = new EventSource(`${BASE}/sessions/${sessionId}/stream?after=${after}`);
source.onopen = () => handlers.onOpen?.();
source.onmessage = (message) => {
try {
handlers.onEvent(JSON.parse(message.data) as PrpEvent);
} catch {
handlers.onError?.();
}
};
source.onerror = () => handlers.onError?.();
return { close: () => source.close() };
}
@@ -0,0 +1,115 @@
import type { PrpEvent } from "../../../../src/protocol/replay-contract";
import type { SessionSnapshot } from "../../../../src/reducer/session-reducer";
/**
* The browser only ever sees the demo server's public state and the canonical
* PRP event stream. There is no second event model and no client-side session
* cache: every surface reads these shapes or the reducer snapshot they carry.
*/
export interface LiveCapabilities {
resume: boolean;
typedEvents: boolean;
steering: boolean;
interruption: boolean;
structuredResult: boolean;
read?: boolean;
reconciliation?: boolean;
usage?: boolean;
runtimeRequestResolution?: boolean;
runtimeRequestHandoff?: boolean;
goals?: boolean;
threadLineage?: boolean;
unsupported?: string[];
}
export interface LivePendingRequest {
requestId: string;
requestKind: string;
method: string;
turnId: string;
itemId: string;
status: string;
prompt: string;
details: Record<string, unknown>;
}
export interface LiveGoal {
threadId: string;
objective: string;
status: string;
tokenBudget: number | null;
tokensUsed: number;
timeUsedSeconds: number;
createdAt: number;
updatedAt: number;
}
export interface LiveLineageEntry {
threadId: string;
providerSessionId: string | null;
parentThreadId: string | null;
depth: number;
nickname: string | null;
role: string | null;
status: string;
}
export interface LiveSessionState {
sessionId: string;
runId: string;
normalizedSessionId: string;
manifest: string | null;
providerAuthentication: string;
credentialsExposed: boolean;
capabilities: LiveCapabilities;
driverSession: {
driverSessionId: string;
providerSessionId?: string | null;
displayId?: string | null;
};
activeTurnId: string | null;
pendingRequests: LivePendingRequest[];
goal: LiveGoal | null;
lineage: LiveLineageEntry[];
cursor: number;
snapshot: SessionSnapshot;
}
export interface LiveManifestSummary {
id: string;
name: string;
purpose: string;
scenarios: string[];
objective: string;
prompt: string;
expectedObservations: string[];
}
export type LiveConnectionStatus =
| "idle"
| "connecting"
| "connected"
| "reconnecting"
| "disconnected"
| "terminal";
export type GoalOperation = "get" | "set" | "pause" | "resume" | "clear";
export type LiveEventList = readonly PrpEvent[];
export const GOAL_OPERATIONS: readonly GoalOperation[] = [
"set",
"get",
"pause",
"resume",
"clear",
];
/** Human labels for the five fixed goal verbs (interaction map §5). */
export const GOAL_OPERATION_LABELS: Record<GoalOperation, string> = {
set: "Set goal…",
get: "View",
pause: "Pause",
resume: "Resume",
clear: "Clear",
};
@@ -0,0 +1,293 @@
import { describe, expect, it } from "vitest";
import { LiveConsoleScriptedDriver } from "../../../../src/mock-core/live-console-scripted-driver";
import {
parseHarnessRuntimeRequestResolution,
type HarnessSession,
} from "../../../../src/contracts/harness-driver";
import type { PrpEvent } from "../../../../src/protocol/replay-contract";
import {
applyPrpEvent,
createSessionSnapshotFromMetadata,
type SessionSnapshot,
} from "../../../../src/reducer/session-reducer";
import type { LiveCapabilities } from "./protocol";
import {
buildTranscript,
capabilityRows,
composerState,
goalAvailability,
runtimeRequestSubmission,
transcriptRole,
type TranscriptEntry,
} from "./transcript-model";
const FULL: LiveCapabilities = {
resume: true,
typedEvents: true,
steering: true,
interruption: true,
structuredResult: true,
runtimeRequestResolution: true,
goals: true,
threadLineage: true,
};
function seed(runId: string, sessionId: string): SessionSnapshot {
return createSessionSnapshotFromMetadata({
fixtureName: "live-console-demo",
identity: {
schema: "paperclip.prp.identity.v1",
companyId: "package-local-demo",
issueId: "live-console-demo",
runId,
environmentLeaseId: "package-local",
runnerInstanceId: "live-console-demo-server",
normalizedSessionId: sessionId,
driverSessionId: `thread-${sessionId}`,
},
capabilities: {
schema: "paperclip.prp.capabilities.v1",
sessionReusePolicy: "reuse_per_issue",
driver: { kind: "live-console-demo", version: "1" },
steer: true,
interrupt: true,
resume: true,
runtimeRequests: true,
structuredResult: true,
typedEvents: true,
},
});
}
async function drive(
manifestId: string,
interact?: (session: HarnessSession, turnId: string) => Promise<void>,
): Promise<{ events: PrpEvent[]; snapshot: SessionSnapshot }> {
const driver = new LiveConsoleScriptedDriver({ manifestId, chunkDelayMs: 0 });
const session = await driver.openSession({
runId: `run-${manifestId}`,
normalizedSessionId: `session-${manifestId}`,
workingDirectory: "/demo",
});
const events: PrpEvent[] = [];
const pump = (async () => {
for await (const event of session.events()) events.push(event);
})();
const { turnId } = await session.startTurn({ message: { role: "user", text: "Go." } });
if (interact !== undefined) await interact(session, turnId);
await new Promise<void>((resolve) => setTimeout(resolve, 60));
await session.close({ reason: "test" });
await pump;
return {
events,
snapshot: events.reduce(applyPrpEvent, seed(`run-${manifestId}`, `session-${manifestId}`)),
};
}
function kinds(entries: readonly TranscriptEntry[]): string[] {
return entries.map((entry) => (entry.kind === "item" ? `item:${entry.role}` : entry.kind));
}
describe("transcriptRole", () => {
it("maps canonical item kinds onto transcript roles", () => {
expect(transcriptRole("reasoning")).toBe("reasoning");
expect(transcriptRole("agentMessage")).toBe("assistant");
expect(transcriptRole("commandExecution")).toBe("tool");
expect(transcriptRole("steering_acknowledgement")).toBe("steering");
expect(transcriptRole("thread_lineage")).toBe("lineage");
expect(transcriptRole("something_new")).toBe("system");
});
});
describe("buildTranscript", () => {
it("orders entries by the reducer timeline and shows the submitted message", async () => {
const { events, snapshot } = await drive("completion");
const entries = buildTranscript(snapshot, events);
expect(kinds(entries)).toEqual([
"user",
"item:reasoning",
"item:tool",
"item:assistant",
"turn",
]);
const user = entries[0];
expect(user.kind === "user" && user.text).toBe("Go.");
const tool = entries[2];
expect(tool.kind === "item" && tool.input).toBe("docs/live-console-protocol-server.md");
expect(tool.kind === "item" && tool.output).toContain("server-sent events");
});
it("marks an interrupted item and never claims it completed cleanly", async () => {
const { events, snapshot } = await drive("interrupt-during-tool", async (session, turnId) => {
await new Promise<void>((resolve) => setTimeout(resolve, 20));
await session.interrupt?.({ turnId });
});
const entries = buildTranscript(snapshot, events);
const tool = entries.find((entry) => entry.kind === "item" && entry.role === "tool");
expect(tool?.kind === "item" && tool.interrupted).toBe(true);
expect(tool?.kind === "item" && tool.output).toBeNull();
const turn = entries.find((entry) => entry.kind === "turn");
expect(turn?.kind === "turn" && turn.state).toBe("interrupted");
});
it("keeps a resolved request in the record with the chosen action", async () => {
const { events, snapshot } = await drive("approvals", async (session, turnId) => {
await new Promise<void>((resolve) => setTimeout(resolve, 20));
await session.resolveRuntimeRequest?.({
requestId: "request-command",
turnId,
resolution: { action: "accept_for_session" },
});
});
const request = buildTranscript(snapshot, events).find(
(entry) => entry.kind === "request" && entry.requestId === "request-command",
);
expect(request?.kind === "request" && request.status).toBe("resolved");
expect(request?.kind === "request" && request.resolvedAction).toBe("accept_for_session");
expect(request?.kind === "request" && request.actions).toEqual([
"accept",
"accept_for_session",
"decline",
"cancel",
]);
});
it("renders a failed item with its exact diagnostic", async () => {
const { events, snapshot } = await drive("failure");
const failed = buildTranscript(snapshot, events).find(
(entry) => entry.kind === "item" && entry.failure !== null,
);
expect(failed?.kind === "item" && failed.failure).toContain("cannot find module");
});
it("keeps a replayed prefix a real prefix of the finished transcript", async () => {
// Replay mode reduces events[0..n]. Stepping forward must only ever add
// entries in the same positions the live session produced.
const { events, snapshot } = await drive("completion");
const full = buildTranscript(snapshot, events);
for (let cut = 1; cut <= events.length; cut += 1) {
const prefix = events.slice(0, cut);
const stepped = buildTranscript(
prefix.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
prefix,
);
expect(stepped.length).toBeLessThanOrEqual(full.length);
stepped.forEach((entry, index) => {
expect(entry.key).toBe(full[index]?.key);
expect(entry.position).toBe(full[index]?.position);
});
}
expect(
buildTranscript(
events.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
events,
),
).toEqual(full);
});
});
describe("composerState", () => {
const base = {
connection: "connected" as const,
submitting: false,
interrupting: false,
activeTurnId: null,
sessionState: "running" as const,
};
it("shows exactly one state per protocol situation", () => {
expect(composerState(base)).toBe("idle");
expect(composerState({ ...base, submitting: true })).toBe("submitting");
expect(composerState({ ...base, activeTurnId: "turn-1" })).toBe("active-turn");
expect(composerState({ ...base, interrupting: true, activeTurnId: "turn-1" })).toBe(
"interrupting",
);
expect(composerState({ ...base, connection: "disconnected" })).toBe("disconnected");
expect(composerState({ ...base, sessionState: "closed" })).toBe("terminal");
});
it("puts a terminal session ahead of every other state", () => {
expect(
composerState({
...base,
sessionState: "failed",
activeTurnId: "turn-1",
connection: "disconnected",
}),
).toBe("terminal");
});
});
describe("goalAvailability", () => {
it("disables every verb with the exact diagnostic when goals are unsupported", () => {
const rows = goalAvailability(
{
...FULL,
goals: false,
unsupported: ["Goal operations unsupported: app-server 0.132.0 does not advertise goals"],
},
null,
);
expect(rows).toHaveLength(5);
expect(rows.every((row) => !row.enabled)).toBe(true);
expect(rows[0]?.reason).toContain("does not advertise goals");
});
it("disables only the verbs that are invalid for the current goal state", () => {
const active = goalAvailability(FULL, "active");
expect(active.find((row) => row.operation === "pause")?.enabled).toBe(true);
expect(active.find((row) => row.operation === "resume")?.enabled).toBe(false);
const paused = goalAvailability(FULL, "paused");
expect(paused.find((row) => row.operation === "resume")?.enabled).toBe(true);
expect(paused.find((row) => row.operation === "pause")?.enabled).toBe(false);
const none = goalAvailability(FULL, null);
expect(none.find((row) => row.operation === "set")?.enabled).toBe(true);
expect(none.find((row) => row.operation === "clear")?.enabled).toBe(false);
});
});
describe("runtimeRequestSubmission", () => {
it("shapes each submit body the way its request kind is validated", async () => {
expect(runtimeRequestSubmission("user_input", "staging")).toEqual({
action: "submit",
answers: { answer: { answers: ["staging"] } },
});
expect(runtimeRequestSubmission("elicitation", "stable")).toEqual({
action: "submit",
content: { answer: "stable" },
});
// The driver is the authority on shape, so both bodies are checked against
// the real validator rather than against this test's expectations alone.
for (const [requestKind, answer] of [
["user_input", "staging"],
["elicitation", "stable"],
] as const) {
expect(() =>
parseHarnessRuntimeRequestResolution(
requestKind,
runtimeRequestSubmission(requestKind, answer),
)).not.toThrow();
// ...and the other kind's body is rejected, which is what the browser
// used to send for elicitation.
const wrongKind = requestKind === "user_input" ? "elicitation" : "user_input";
expect(() =>
parseHarnessRuntimeRequestResolution(
requestKind,
runtimeRequestSubmission(wrongKind, answer),
)).toThrow(/rejected its resolution/);
}
});
});
describe("capabilityRows", () => {
it("gives every unsupported capability a diagnostic string", () => {
const rows = capabilityRows({ ...FULL, goals: false, usage: false });
expect(rows.filter((row) => !row.supported).every((row) => row.diagnostic !== null)).toBe(true);
expect(rows.find((row) => row.name === "steer")?.supported).toBe(true);
});
});
@@ -0,0 +1,402 @@
import type { PrpEvent } from "../../../../src/protocol/replay-contract";
import type {
SessionItemSnapshot,
SessionSnapshot,
} from "../../../../src/reducer/session-reducer";
import type { GoalOperation, LiveCapabilities } from "./protocol";
/**
* A projection of reducer state plus the canonical events that produced it.
* It never stores anything the protocol did not send, so a replayed session
* and a live session with the same events project identically.
*/
export type TranscriptRole =
| "user"
| "assistant"
| "reasoning"
| "tool"
| "steering"
| "interrupt"
| "goal"
| "lineage"
| "system";
export interface TranscriptItemEntry {
kind: "item";
key: string;
position: number;
role: TranscriptRole;
item: SessionItemSnapshot;
interrupted: boolean;
streaming: boolean;
toolName: string | null;
input: string | null;
output: string | null;
failure: string | null;
}
export interface TranscriptUserEntry {
kind: "user";
key: string;
position: number;
turnId: string | null;
text: string;
}
export interface TranscriptRequestEntry {
kind: "request";
key: string;
position: number;
requestId: string;
requestKind: string;
type: string;
prompt: string;
status: "pending" | "resolved" | "expired" | "cancelled";
actions: string[];
details: Record<string, unknown>;
resolvedAction: string | null;
resolvedAt: string | null;
turnId: string | null;
}
export interface TranscriptTurnEntry {
kind: "turn";
key: string;
position: number;
turnId: string | null;
state: "completed" | "failed" | "interrupted" | "cancelled";
detail: string;
}
export interface TranscriptDiagnosticEntry {
kind: "diagnostic";
key: string;
position: number;
message: string;
code: string | null;
}
export type TranscriptEntry =
| TranscriptItemEntry
| TranscriptUserEntry
| TranscriptRequestEntry
| TranscriptTurnEntry
| TranscriptDiagnosticEntry;
const TOOL_KINDS = new Set([
"commandExecution",
"command_execution",
"fileChange",
"file_change",
"mcpToolCall",
"tool",
"webSearch",
"patchApply",
]);
const ASSISTANT_KINDS = new Set(["agentMessage", "assistant", "assistantMessage", "message"]);
export function transcriptRole(kind: string): TranscriptRole {
if (kind === "reasoning") return "reasoning";
if (ASSISTANT_KINDS.has(kind)) return "assistant";
if (TOOL_KINDS.has(kind)) return "tool";
if (kind === "steering_acknowledgement") return "steering";
if (kind === "interrupt_acknowledgement") return "interrupt";
if (kind === "goal" || kind === "goal_cleared") return "goal";
if (kind === "thread_lineage") return "lineage";
return "system";
}
function payloadRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function payloadText(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
interface ItemFacts {
interrupted: boolean;
toolName: string | null;
input: string | null;
output: string | null;
failure: string | null;
terminal: boolean;
}
function collectItemFacts(events: readonly PrpEvent[]): Map<string, ItemFacts> {
const facts = new Map<string, ItemFacts>();
for (const event of events) {
if (event.itemId === undefined || !event.eventType.startsWith("item.")) continue;
const payload = payloadRecord(event.payload);
const current = facts.get(event.itemId) ?? {
interrupted: false,
toolName: null,
input: null,
output: null,
failure: null,
terminal: false,
};
if (payload.interrupted === true || payload.status === "interrupted") {
current.interrupted = true;
}
current.toolName = payloadText(payload.toolName) ?? current.toolName;
current.input = payloadText(payload.input) ?? current.input;
current.output = payloadText(payload.output) ?? current.output;
if (event.eventType === "item.failed") {
current.failure = payloadText(payload.message) ?? "The item failed.";
}
if (event.eventType === "item.completed" || event.eventType === "item.failed") {
current.terminal = true;
}
facts.set(event.itemId, current);
}
return facts;
}
/**
* Builds the submit body a request kind actually accepts. Elicitation answers
* travel as `content` and user-input answers as `answers`; the runner validates
* both against the request kind, so sending the wrong one fails closed rather
* than resolving the request with an empty response.
*/
export function runtimeRequestSubmission(
requestKind: string,
answer: string,
): Record<string, unknown> {
if (requestKind === "elicitation") {
return { action: "submit", content: { answer } };
}
return { action: "submit", answers: { answer: { answers: [answer] } } };
}
function requestActions(details: unknown): string[] {
const record = payloadRecord(details);
return Array.isArray(record.actions)
? record.actions.filter((action): action is string => typeof action === "string")
: [];
}
/**
* Builds the ordered transcript. Order comes from the reducer timeline, which
* is authoritative; nothing is re-sorted client-side.
*/
export function buildTranscript(
snapshot: SessionSnapshot,
events: readonly PrpEvent[],
): TranscriptEntry[] {
const facts = collectItemFacts(events);
const items = new Map(snapshot.items.map((item) => [item.itemId, item]));
const seenItems = new Set<string>();
const entries: TranscriptEntry[] = [];
const eventsById = new Map(events.map((event) => [event.sourceEventId, event]));
for (const timelineEntry of snapshot.timeline) {
const event = eventsById.get(timelineEntry.sourceEventId);
const payload = payloadRecord(event?.payload);
const position = timelineEntry.position;
if (timelineEntry.eventType === "turn.submitted") {
entries.push({
kind: "user",
key: `user:${timelineEntry.sourceEventId}`,
position,
turnId: typeof payload.turnId === "string" ? payload.turnId : null,
text: payloadText(payload.text) ?? "(no message text in the canonical event)",
});
continue;
}
if (
["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(
timelineEntry.eventType,
)
) {
const state = timelineEntry.eventType.split(".").at(-1) as TranscriptTurnEntry["state"];
entries.push({
kind: "turn",
key: `turn:${timelineEntry.sourceEventId}`,
position,
turnId: typeof payload.turnId === "string" ? payload.turnId : null,
state,
detail:
payloadText(payload.message) ??
payloadText(payload.reason) ??
timelineEntry.summary,
});
continue;
}
if (timelineEntry.eventType === "runtime_request.created") {
const request = payloadRecord(payload.request ?? payload);
const requestId = typeof request.requestId === "string" ? request.requestId : "";
const projected = snapshot.requests.find(
(candidate) => candidate.requestId === requestId,
);
const resolution = events.find(
(candidate) =>
candidate.eventType.startsWith("runtime_request.") &&
candidate.eventType !== "runtime_request.created" &&
payloadRecord(candidate.payload).requestId === requestId,
);
entries.push({
kind: "request",
key: `request:${requestId}`,
position,
requestId,
requestKind: typeof request.requestKind === "string" ? request.requestKind : "runtime",
type: typeof request.type === "string" ? request.type : "runtime",
prompt: payloadText(request.prompt) ?? "Runtime request",
status: (projected?.status ?? "pending") as TranscriptRequestEntry["status"],
actions: requestActions(request),
details: payloadRecord(request.details),
resolvedAction: payloadText(payloadRecord(resolution?.payload).action),
resolvedAt: resolution?.emittedAt ?? null,
turnId: typeof request.turnId === "string" ? request.turnId : null,
});
continue;
}
if (
timelineEntry.eventType === "harness.diagnostic" ||
timelineEntry.eventType === "runner.diagnostic"
) {
entries.push({
kind: "diagnostic",
key: `diagnostic:${timelineEntry.sourceEventId}`,
position,
message: payloadText(payload.message) ?? timelineEntry.summary,
code: payloadText(payload.code),
});
continue;
}
const itemId = timelineEntry.itemId;
if (itemId === undefined || seenItems.has(itemId)) continue;
const item = items.get(itemId);
if (item === undefined) continue;
seenItems.add(itemId);
const itemFacts = facts.get(itemId);
entries.push({
kind: "item",
key: `item:${itemId}`,
position,
role: transcriptRole(item.kind),
item,
interrupted: itemFacts?.interrupted ?? false,
streaming: item.status === "running" && itemFacts?.terminal !== true,
toolName: itemFacts?.toolName ?? null,
input: itemFacts?.input ?? null,
output: itemFacts?.output ?? null,
failure: itemFacts?.failure ?? null,
});
}
return entries;
}
export type ComposerState =
| "idle"
| "submitting"
| "active-turn"
| "interrupting"
| "disconnected"
| "terminal";
export interface ComposerStateInput {
connection: "idle" | "connecting" | "connected" | "reconnecting" | "disconnected" | "terminal";
submitting: boolean;
interrupting: boolean;
activeTurnId: string | null;
sessionState: SessionSnapshot["sessionState"];
}
/** One state machine with exactly one visible primary action. */
export function composerState(input: ComposerStateInput): ComposerState {
if (input.sessionState === "closed" || input.sessionState === "failed") return "terminal";
if (input.connection === "disconnected" || input.connection === "reconnecting") {
return "disconnected";
}
if (input.interrupting) return "interrupting";
if (input.activeTurnId !== null) return "active-turn";
if (input.submitting) return "submitting";
return "idle";
}
export interface GoalOperationAvailability {
operation: GoalOperation;
enabled: boolean;
reason: string | null;
}
const GOAL_UNSUPPORTED_FALLBACK =
"Goal operations unsupported: this app-server does not advertise the goals capability";
/**
* Capability gating for the goal menu. An unsupported verb is disabled with
* the exact upstream diagnostic — never hidden and never emulated.
*/
export function goalAvailability(
capabilities: LiveCapabilities,
goalStatus: string | null,
): GoalOperationAvailability[] {
const unsupported = capabilities.unsupported ?? [];
if (capabilities.goals !== true) {
const reason = unsupported[0] ?? GOAL_UNSUPPORTED_FALLBACK;
return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => ({
operation,
enabled: false,
reason,
}));
}
return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => {
if (operation === "set") return { operation, enabled: true, reason: null };
if (goalStatus === null) {
return {
operation,
enabled: false,
reason: "No goal is set on this thread yet.",
};
}
if (operation === "pause" && goalStatus !== "active") {
return { operation, enabled: false, reason: `The goal is already ${goalStatus}.` };
}
if (operation === "resume" && goalStatus === "active") {
return { operation, enabled: false, reason: "The goal is already active." };
}
return { operation, enabled: true, reason: null };
});
}
export interface CapabilityRow {
name: string;
supported: boolean;
diagnostic: string | null;
}
export function capabilityRows(capabilities: LiveCapabilities): CapabilityRow[] {
const unsupported = capabilities.unsupported ?? [];
const rows: Array<[string, boolean | undefined]> = [
["steer", capabilities.steering],
["interrupt", capabilities.interruption],
["resume", capabilities.resume],
["runtime requests", capabilities.runtimeRequestResolution],
["runtime request handoff", capabilities.runtimeRequestHandoff],
["goals", capabilities.goals],
["child threads", capabilities.threadLineage],
["structured result", capabilities.structuredResult],
["typed events", capabilities.typedEvents],
["usage", capabilities.usage],
];
return rows.map(([name, supported]) => ({
name,
supported: supported === true,
diagnostic:
supported === true
? null
: (unsupported.find((entry) => entry.toLowerCase().includes(name.split(" ")[0]!)) ??
`${name} is not advertised by this app-server`),
}));
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import type { PrpEvent } from "../../../../src/protocol/replay-contract";
import {
acceptLiveEvent,
createLiveEventCursor,
} from "./use-live-console";
function event(sourceEventId: string): PrpEvent {
return { sourceEventId } as PrpEvent;
}
describe("Live console reconnect cursor", () => {
it("does not advance past an unseen event when replay delivers a duplicate", () => {
const first = event("source:event-1");
const second = event("source:event-2");
const cursor = createLiveEventCursor([first], 1);
expect(acceptLiveEvent(cursor, first)).toBe(false);
expect(cursor.cursor).toBe(1);
expect(acceptLiveEvent(cursor, second)).toBe(true);
expect(cursor.cursor).toBe(2);
});
});
@@ -0,0 +1,579 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PrpEvent } from "../../../../src/protocol/replay-contract";
import {
applyPrpEvent,
createSessionSnapshotFromMetadata,
type SessionSnapshot,
} from "../../../../src/reducer/session-reducer";
import * as client from "./client";
import type {
GoalOperation,
LiveConnectionStatus,
LiveManifestSummary,
LiveSessionState,
} from "./protocol";
import {
buildTranscript,
composerState,
type ComposerState,
type TranscriptEntry,
} from "./transcript-model";
const SESSION_STORAGE_KEY = "paperclip-runner.live-console.session";
const RECONNECT_DELAY_MS = 900;
const MAX_RECONNECT_ATTEMPTS = 5;
export interface LiveEventCursor {
cursor: number;
seenSourceEventIds: Set<string>;
}
export function createLiveEventCursor(
events: readonly PrpEvent[] = [],
cursor = 0,
): LiveEventCursor {
return {
cursor,
seenSourceEventIds: new Set(events.map((event) => event.sourceEventId)),
};
}
export function acceptLiveEvent(cursor: LiveEventCursor, event: PrpEvent): boolean {
if (cursor.seenSourceEventIds.has(event.sourceEventId)) return false;
cursor.seenSourceEventIds.add(event.sourceEventId);
cursor.cursor += 1;
return true;
}
export type SteeringChipStatus = "pending" | "acknowledged" | "rejected" | "failed";
export interface SteeringChip {
id: string;
text: string;
expectedTurnId: string;
status: SteeringChipStatus;
detail: string | null;
}
function reduceEvents(
state: LiveSessionState | null,
events: readonly PrpEvent[],
): SessionSnapshot | null {
if (state === null) return null;
const seed = createSessionSnapshotFromMetadata({
fixtureName: state.snapshot.fixtureName,
identity: state.snapshot.identity,
capabilities: state.snapshot.capabilities,
});
return events.reduce(applyPrpEvent, seed);
}
function errorMessage(cause: unknown): string {
if (cause instanceof client.LiveConsoleError) return cause.message;
return cause instanceof Error ? cause.message : String(cause);
}
export interface LiveConsole {
manifests: LiveManifestSummary[];
selectedManifestId: string;
selectManifest: (id: string) => void;
state: LiveSessionState | null;
events: PrpEvent[];
snapshot: SessionSnapshot | null;
transcript: TranscriptEntry[];
connection: LiveConnectionStatus;
reconnectAttempt: number;
replayParity: boolean | null;
composer: ComposerState;
steeringChips: SteeringChip[];
announcement: string;
error: string | null;
busy: boolean;
selectedThreadId: string | null;
selectThread: (threadId: string | null) => void;
replay: {
active: boolean;
position: number;
length: number;
playing: boolean;
enter: () => void;
exit: () => void;
setPosition: (position: number) => void;
togglePlay: () => void;
step: (delta: number) => void;
};
start: (input: { manifestId: string; message: string }) => Promise<void>;
send: (text: string) => Promise<void>;
steer: (text: string) => Promise<void>;
interrupt: () => Promise<void>;
resolve: (
requestId: string,
turnId: string,
resolution: Record<string, unknown>,
) => Promise<void>;
goal: (operation: GoalOperation, body?: Record<string, unknown>) => Promise<void>;
dropConnection: () => void;
retryNow: () => void;
reset: () => Promise<void>;
dismissSteeringChip: (id: string) => void;
}
export function useLiveConsole(): LiveConsole {
const [manifests, setManifests] = useState<LiveManifestSummary[]>([]);
const [selectedManifestId, setSelectedManifestId] = useState("completion");
const [state, setState] = useState<LiveSessionState | null>(null);
const [events, setEvents] = useState<PrpEvent[]>([]);
const [connection, setConnection] = useState<LiveConnectionStatus>("idle");
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const [steeringChips, setSteeringChips] = useState<SteeringChip[]>([]);
const [announcement, setAnnouncement] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [interrupting, setInterrupting] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [replayActive, setReplayActive] = useState(false);
const [replayPosition, setReplayPosition] = useState(0);
const [replayPlaying, setReplayPlaying] = useState(false);
const streamRef = useRef<client.EventStreamHandle | null>(null);
const eventCursorRef = useRef(createLiveEventCursor());
const sessionIdRef = useRef<string | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeStream = useCallback(() => {
streamRef.current?.close();
streamRef.current = null;
if (retryTimerRef.current !== null) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
}, []);
const subscribe = useCallback(
(sessionId: string, attempt = 0) => {
closeStream();
setConnection(attempt === 0 ? "connecting" : "reconnecting");
setReconnectAttempt(attempt);
streamRef.current = client.openEventStream(sessionId, eventCursorRef.current.cursor, {
onOpen: () => {
setConnection("connected");
setReconnectAttempt(0);
setAnnouncement("Connected");
},
onEvent: (event) => {
if (!acceptLiveEvent(eventCursorRef.current, event)) return;
setEvents((current) => [...current, event]);
},
onError: () => {
closeStream();
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
setConnection("disconnected");
setAnnouncement("Disconnected");
return;
}
setConnection("reconnecting");
setReconnectAttempt(attempt + 1);
setAnnouncement(`Reconnecting, attempt ${attempt + 1}`);
retryTimerRef.current = setTimeout(
() => subscribe(sessionId, attempt + 1),
RECONNECT_DELAY_MS,
);
},
});
},
[closeStream],
);
const adopt = useCallback(
(next: LiveSessionState) => {
setState(next);
setError(null);
},
[],
);
const attach = useCallback(
async (sessionId: string, resume: boolean) => {
const [next, history] = await Promise.all([
client.readSession(sessionId),
client.readEvents(sessionId, 0),
]);
sessionIdRef.current = sessionId;
window.sessionStorage.setItem(SESSION_STORAGE_KEY, sessionId);
adopt(next);
setEvents(history.events);
eventCursorRef.current = createLiveEventCursor(history.events, history.cursor);
if (resume) setAnnouncement("Replayed the durable transcript");
subscribe(sessionId, 0);
},
[adopt, subscribe],
);
useEffect(() => {
let cancelled = false;
void client
.fetchManifests()
.then((list) => {
if (cancelled) return;
setManifests(list);
if (list[0] !== undefined) setSelectedManifestId((current) => current || list[0]!.id);
})
.catch((cause) => {
if (!cancelled) setError(errorMessage(cause));
});
const stored = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
if (stored !== null) {
void attach(stored, true).catch(() => {
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
});
}
return () => {
cancelled = true;
};
// Runs once: session restoration is a mount concern.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => () => closeStream(), [closeStream]);
// Lineage, goal, pending requests, and the server cursor live in the public
// state, so a stream that only carries events would leave them stale.
useEffect(() => {
const sessionId = sessionIdRef.current;
if (sessionId === null || events.length === 0) return undefined;
const timer = setTimeout(() => {
void client
.readSession(sessionId)
.then((next) => setState(next))
.catch(() => undefined);
}, 150);
return () => clearTimeout(timer);
}, [events.length]);
const liveSnapshot = useMemo(() => reduceEvents(state, events), [state, events]);
const visibleEvents = useMemo(
() => (replayActive ? events.slice(0, replayPosition) : events),
[events, replayActive, replayPosition],
);
const snapshot = useMemo(
() => (replayActive ? reduceEvents(state, visibleEvents) : liveSnapshot),
[replayActive, state, visibleEvents, liveSnapshot],
);
const replayParity = useMemo(() => {
if (state === null || liveSnapshot === null) return null;
if (state.cursor !== events.length) return null;
return (
JSON.stringify(liveSnapshot.timeline) === JSON.stringify(state.snapshot.timeline) &&
liveSnapshot.integrity === state.snapshot.integrity
);
}, [state, liveSnapshot, events.length]);
const transcript = useMemo(
() => (snapshot === null ? [] : buildTranscript(snapshot, visibleEvents)),
[snapshot, visibleEvents],
);
useEffect(() => {
if (!replayPlaying) return undefined;
if (replayPosition >= events.length) {
setReplayPlaying(false);
return undefined;
}
const timer = setTimeout(() => setReplayPosition((current) => current + 1), 140);
return () => clearTimeout(timer);
}, [replayPlaying, replayPosition, events.length]);
// Steering chips resolve against canonical acknowledgements only.
useEffect(() => {
setSteeringChips((chips) => {
if (chips.every((chip) => chip.status !== "pending")) return chips;
const acknowledged = events.filter(
(event) =>
event.eventType === "item.completed" &&
(event.payload as { kind?: string }).kind === "steering_acknowledgement",
);
let index = 0;
return chips.map((chip) => {
if (chip.status !== "pending") return chip;
const match = acknowledged[index];
index += 1;
return match === undefined
? chip
: { ...chip, status: "acknowledged" as const, detail: null };
});
});
}, [events]);
// The reducer is authoritative for the active turn. Falling back to the
// last public-state read would keep a finished turn alive in the composer.
const activeTurnId = snapshot === null ? (state?.activeTurnId ?? null) : snapshot.activeTurnId;
// A submitted turn that has not been accepted yet is a protocol fact, not a
// local flag: it is what makes interrupt-before-start reachable.
const awaitingTurnStart = useMemo(() => {
let pending = false;
for (const entry of snapshot?.timeline ?? []) {
if (entry.eventType === "turn.submitted") pending = true;
else if (entry.eventType.startsWith("turn.")) pending = false;
}
return pending;
}, [snapshot]);
const composer = composerState({
connection,
submitting: submitting || awaitingTurnStart,
interrupting,
activeTurnId,
sessionState: snapshot?.sessionState ?? "not_started",
});
const guard = useCallback(async (task: () => Promise<void>) => {
setBusy(true);
try {
await task();
} catch (cause) {
setError(errorMessage(cause));
} finally {
setBusy(false);
}
}, []);
const start = useCallback(
async (input: { manifestId: string; message: string }) => {
await guard(async () => {
closeStream();
setEvents([]);
eventCursorRef.current = createLiveEventCursor();
setSteeringChips([]);
setReplayActive(false);
setReplayPosition(0);
setSelectedThreadId(null);
setSubmitting(true);
const manifest = manifests.find((entry) => entry.id === input.manifestId);
try {
const created = await client.createSession({
manifest: input.manifestId,
objective: manifest?.objective ?? "Run the Live console demo.",
message: input.message,
});
sessionIdRef.current = created.sessionId;
window.sessionStorage.setItem(SESSION_STORAGE_KEY, created.sessionId);
adopt(created);
setAnnouncement("Turn running");
subscribe(created.sessionId, 0);
} finally {
setSubmitting(false);
}
});
},
[adopt, closeStream, guard, manifests, subscribe],
);
const refreshState = useCallback(async () => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
adopt(await client.readSession(sessionId));
}, [adopt]);
const send = useCallback(
async (text: string) => {
const sessionId = sessionIdRef.current;
if (sessionId === null) {
await start({ manifestId: selectedManifestId, message: text });
return;
}
await guard(async () => {
setSubmitting(true);
try {
adopt(await client.startTurn(sessionId, text));
setAnnouncement("Turn running");
} finally {
setSubmitting(false);
}
});
},
[adopt, guard, selectedManifestId, start],
);
const steer = useCallback(
async (text: string) => {
const sessionId = sessionIdRef.current;
const expectedTurnId = activeTurnId;
if (sessionId === null || expectedTurnId === null) return;
const chipId = `steer-${Date.now()}-${steeringChips.length}`;
setSteeringChips((chips) => [
...chips,
{ id: chipId, text, expectedTurnId, status: "pending", detail: null },
]);
try {
adopt(await client.steerTurn(sessionId, expectedTurnId, text));
setAnnouncement("Steering sent");
} catch (cause) {
const stale =
cause instanceof client.LiveConsoleError &&
(cause.code === "stale_turn" || cause.code === "already_terminal");
setSteeringChips((chips) =>
chips.map((chip) =>
chip.id === chipId
? {
...chip,
status: stale ? "rejected" : "failed",
detail: stale ? "turn already ended" : errorMessage(cause),
}
: chip,
),
);
setAnnouncement(stale ? "Steering rejected: turn already ended" : "Steering failed");
await refreshState().catch(() => undefined);
}
},
[activeTurnId, adopt, refreshState, steeringChips.length],
);
const interrupt = useCallback(async () => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
setInterrupting(true);
setAnnouncement("Stopping");
try {
adopt(await client.interruptTurn(sessionId, activeTurnId));
setAnnouncement("Turn stopped");
} catch (cause) {
setError(errorMessage(cause));
} finally {
setInterrupting(false);
}
}, [activeTurnId, adopt]);
const resolve = useCallback(
async (requestId: string, turnId: string, resolution: Record<string, unknown>) => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
await guard(async () => {
adopt(await client.resolveRequest(sessionId, requestId, turnId, resolution));
setAnnouncement("Request resolved");
});
},
[adopt, guard],
);
const goal = useCallback(
async (operation: GoalOperation, body: Record<string, unknown> = {}) => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
await guard(async () => {
adopt(await client.goalOperation(sessionId, operation, body));
setAnnouncement(`Goal ${operation}`);
});
},
[adopt, guard],
);
const dropConnection = useCallback(() => {
closeStream();
setConnection("disconnected");
setAnnouncement("Connection lost");
}, [closeStream]);
const retryNow = useCallback(() => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
void guard(async () => {
const resumed = await client.reconnectSession(sessionId);
adopt(resumed);
const history = await client.readEvents(sessionId, 0);
setEvents(history.events);
eventCursorRef.current = createLiveEventCursor(history.events, history.cursor);
subscribe(sessionId, 0);
setAnnouncement("Resumed the same session");
});
}, [adopt, guard, subscribe]);
const reset = useCallback(async () => {
const sessionId = sessionIdRef.current;
closeStream();
if (sessionId !== null) {
try {
await client.closeSession(sessionId);
} catch (cause) {
setConnection("disconnected");
setError(errorMessage(cause));
setAnnouncement("Demo reset failed");
return;
}
}
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
sessionIdRef.current = null;
eventCursorRef.current = createLiveEventCursor();
setState(null);
setEvents([]);
setSteeringChips([]);
setConnection("idle");
setReplayActive(false);
setReplayPosition(0);
setSelectedThreadId(null);
setError(null);
setAnnouncement("Demo state reset");
}, [closeStream]);
const replay = useMemo(
() => ({
active: replayActive,
position: replayPosition,
length: events.length,
playing: replayPlaying,
enter: () => {
setReplayActive(true);
setReplayPosition(0);
setReplayPlaying(false);
},
exit: () => {
setReplayActive(false);
setReplayPlaying(false);
},
setPosition: (position: number) =>
setReplayPosition(Math.min(Math.max(position, 0), events.length)),
togglePlay: () => setReplayPlaying((current) => !current),
step: (delta: number) =>
setReplayPosition((current) =>
Math.min(Math.max(current + delta, 0), events.length),
),
}),
[events.length, replayActive, replayPlaying, replayPosition],
);
return {
manifests,
selectedManifestId,
selectManifest: setSelectedManifestId,
state,
events,
snapshot,
transcript,
connection,
reconnectAttempt,
replayParity,
composer,
steeringChips,
announcement,
error,
busy,
selectedThreadId,
selectThread: setSelectedThreadId,
replay,
start,
send,
steer,
interrupt,
resolve,
goal,
dropConnection,
retryNow,
reset,
dismissSteeringChip: (id: string) =>
setSteeringChips((chips) => chips.filter((chip) => chip.id !== id)),
};
}
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
const root = document.getElementById("root");
if (root === null) {
throw new Error("Browser replay root element is missing");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,112 @@
import { expect, test } from "@playwright/test";
test("validates and renders the shared Replay fixture", async ({ page }, testInfo) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Live runner diagnostics" })).toBeVisible();
await page.getByRole("button", { name: "Static replay" }).click();
await expect(page.getByRole("heading", { name: "Static protocol replay" })).toBeVisible();
await expect(page.getByTestId("terminal-badge")).toHaveText("Succeeded");
await expect(page.getByTestId("timeline").getByRole("listitem")).toHaveCount(9);
await expect(page.getByTestId("timeline")).not.toContainText("workspace_preparing");
await expect(page.getByTestId("result-summary")).toContainText(
"The scripted run completed successfully.",
);
await page.screenshot({
path: testInfo.outputPath("replay-static-replay.png"),
fullPage: true,
});
});
test("shows duplicate and unsupported-version replay states", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Static replay" }).click();
await page.getByLabel("Fixture", { exact: true }).selectOption("duplicate-event");
await expect(page.getByText("1 duplicate events ignored")).toBeVisible();
await page
.getByLabel("Fixture", { exact: true })
.selectOption("unsupported-required-version");
await expect(page.getByRole("heading", { name: "Fixture cannot be replayed" })).toBeVisible();
await expect(page.getByText(/protocolVersion 2 is unsupported/)).toBeVisible();
});
test("streams a live run and proves replay parity", async ({ page }, testInfo) => {
await page.goto("/");
await page.getByRole("button", { name: "Start local run" }).click();
await expect(page.getByTestId("live-status")).toHaveText("Terminal");
await expect(page.getByTestId("terminal-badge")).toHaveText("Succeeded");
await expect(page.getByTestId("process-facts")).toContainText("Harness process exit");
await expect(page.getByTestId("process-facts")).toContainText("done");
await expect(page.getByTestId("parity-result")).toContainText("Match");
await page.screenshot({
path: testInfo.outputPath("local-runner-live-complete.png"),
fullPage: true,
});
});
test("resolves live permission and input requests", async ({ page }, testInfo) => {
await page.goto("/");
await page.getByLabel("Scenario").selectOption("permission-input");
await page.getByRole("button", { name: "Start local run" }).click();
await expect(page.getByTestId("runtime-request")).toContainText("permission request");
await expect(page.getByTestId("terminal-badge")).toHaveText("Executing");
await expect(
page.getByTestId("timeline").getByRole("listitem").filter({
hasText: "runtime_request.created",
}).first(),
).toContainText("permission: Allow the fake driver to write its local fixture?");
await page.screenshot({
path: testInfo.outputPath("local-runner-live-permission.png"),
fullPage: true,
});
await page.getByRole("button", { name: "Allow" }).click();
await expect(page.getByTestId("runtime-request")).toContainText("input request");
await expect(
page.getByTestId("timeline").getByRole("listitem").filter({
hasText: "runtime_request.resolved",
}).first(),
).toContainText("Resolved permission: Allow the fake driver to write its local fixture?");
await page.waitForTimeout(10_500);
await page.getByLabel("Response").fill("local-runner-browser-trace");
await page.getByRole("button", { name: "Send input" }).click();
await expect(page.getByTestId("live-status")).toHaveText("Terminal");
await expect(page.getByTestId("parity-result")).toContainText("Match");
});
test("interrupts a live turn without duplicating terminal state", async ({ page }, testInfo) => {
await page.goto("/");
await page.getByLabel("Scenario").selectOption("interrupted");
await page.getByRole("button", { name: "Start local run" }).click();
const interrupt = page.getByRole("button", { name: "Interrupt turn" });
await expect(interrupt).toBeEnabled();
await interrupt.click();
await expect(page.getByTestId("live-status")).toHaveText("Terminal");
await expect(page.getByTestId("terminal-badge")).toHaveText("Cancelled");
await expect(page.getByTestId("live-status")).toHaveAttribute("data-tone", "neutral");
await expect(page.getByTestId("terminal-badge")).toHaveAttribute("data-tone", "neutral");
await expect(page.getByTestId("timeline").getByText("run.terminal")).toHaveCount(1);
await page.screenshot({
path: testInfo.outputPath("local-runner-live-interrupted.png"),
fullPage: true,
});
});
test("shows failed outcomes as danger and names the duplicate-terminal guard", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Scenario").selectOption("error");
await page.getByRole("button", { name: "Start local run" }).click();
await expect(page.getByTestId("live-status")).toHaveText("Terminal");
await expect(page.getByTestId("live-status")).toHaveAttribute("data-tone", "danger");
await expect(page.getByTestId("terminal-badge")).toHaveText("Failed");
await expect(page.getByTestId("terminal-badge")).toHaveAttribute("data-tone", "danger");
await page.getByLabel("Scenario").selectOption("duplicate-terminal");
await page.getByRole("button", { name: "Start local run" }).click();
await expect(page.getByTestId("live-status")).toHaveText("Terminal");
await expect(
page.getByTestId("timeline").getByRole("listitem").filter({
hasText: "harness.diagnostic",
}),
).toContainText(
"Duplicate terminal event ignored; the first terminal event remains authoritative.",
);
});
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>🧯 Mock Paperclip · Issue thread</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,46 @@
import { defineConfig } from "@playwright/test";
/**
* Focused browser suite for the Capability issue thread.
*
* The suite runs the built app through `vite preview` on loopback. Fake-mode
* routes render from package fixtures, so no provider, runnerd, or Codex
* process is started here.
*/
export default defineConfig({
testDir: ".",
testMatch: ["issue-thread.spec.ts"],
fullyParallel: false,
workers: 1,
reporter: "line",
use: {
baseURL: "http://127.0.0.1:4184",
viewport: { width: 1440, height: 900 },
launchOptions: {
// Hosts without the Playwright chromium system libraries can point at a
// preinstalled Chromium instead of running `pnpm verify:rootless`.
...(process.env.PAPERCLIP_RUNNER_CHROMIUM_PATH === undefined
? {}
: { executablePath: process.env.PAPERCLIP_RUNNER_CHROMIUM_PATH }),
},
},
webServer: [
{
command:
"pnpm exec vite preview --config vite.issue-thread.config.ts --host 127.0.0.1 --port 4184",
url: "http://127.0.0.1:4184",
reuseExistingServer: false,
timeout: 60_000,
},
{
// Same app and same package server, with a scripted Codex provider, so
// the streaming checks can drive a real NDJSON turn without a provider
// process. See `vite.issue-thread-stream.config.ts`.
command:
"pnpm exec vite preview --config vite.issue-thread-stream.config.ts --host 127.0.0.1 --port 4185",
url: "http://127.0.0.1:4185",
reuseExistingServer: false,
timeout: 60_000,
},
],
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,161 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import type { CapabilityComposerModel } from "../../../src/issue-thread/types";
/**
* Composer (contract §4). `data-composer-state` is the single test and
* screenshot hook. Draft text survives refresh through localStorage keyed by
* session id; Stop never discards the transcript.
*/
export interface ComposerProps {
model: CapabilityComposerModel;
sessionId: string;
onSend: (message: string) => void;
onStop: () => void;
onRetry: () => void;
onReset: () => void;
onFocusPending: (interactionId: string) => void;
/** The clean room resets into a new tenant, so it names the action itself. */
resetLabel?: string;
}
function draftKey(sessionId: string): string {
return `paperclip-runner.capability.draft.${sessionId}`;
}
function readDraft(sessionId: string): string {
try {
return window.localStorage.getItem(draftKey(sessionId)) ?? "";
} catch {
return "";
}
}
export function Composer(props: ComposerProps) {
const {
model,
sessionId,
onSend,
onStop,
onRetry,
onReset,
onFocusPending,
resetLabel = "Reset scenario",
} = props;
const [value, setValue] = useState(() => readDraft(sessionId));
const inputRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
setValue(readDraft(sessionId));
}, [sessionId]);
useEffect(() => {
try {
window.localStorage.setItem(draftKey(sessionId), value);
} catch {
// A blocked storage quota must not break the session.
}
}, [sessionId, value]);
const editable = model.state === "ready" || model.state === "streaming";
const canSend = editable && value.trim().length > 0;
function submit(event: FormEvent) {
event.preventDefault();
if (!canSend) return;
onSend(value.trim());
setValue("");
}
return (
<form
className="pit-composer"
data-composer-state={model.state}
onSubmit={submit}
aria-label="Send a message to the agent"
>
<div className="pit-composer-inner">
{model.helper !== null ? (
<p className="pit-composer-helper">
{model.helper}
{model.state === "waiting" && model.pendingInteractionId !== null ? (
<>
{" "}
<button
type="button"
className="pit-link-button"
data-testid="pending-anchor"
onClick={() => onFocusPending(model.pendingInteractionId as string)}
>
Go to the pending request
</button>
</>
) : null}
</p>
) : null}
{model.reason !== null ? (
<p className="pit-composer-reason" data-testid="composer-reason">
{model.reason}
</p>
) : null}
<div className="pit-composer-row">
<label className="pit-visually-hidden" htmlFor="composer-input">
Message
</label>
<textarea
className="pit-composer-input"
id="composer-input"
ref={inputRef}
rows={2}
value={value}
disabled={!editable}
placeholder={editable ? "Message the agent…" : ""}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
if (canSend) {
onSend(value.trim());
setValue("");
}
}
}}
/>
{model.state === "streaming" ? (
<button
type="button"
className="pit-button"
data-variant="destructive"
onClick={onStop}
data-testid="composer-stop"
>
Stop
</button>
) : model.state === "reconnecting" ? (
<button type="button" className="pit-button" onClick={onRetry}>
Retry now
</button>
) : model.state === "disabled" ? (
<button type="button" className="pit-button" onClick={onReset}>
{resetLabel}
</button>
) : null}
<button
type="submit"
className="pit-button"
data-variant="primary"
disabled={!canSend || model.state === "sending"}
data-testid="composer-send"
>
{model.state === "sending" ? "Sending…" : model.state === "streaming" ? "Steer" : "Send"}
</button>
</div>
</div>
</form>
);
}
@@ -0,0 +1,646 @@
import { useEffect, useMemo, useState } from "react";
import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools";
import { Icon, type IconName } from "./Icons";
import { EvalAssertions, type EvalAssertion } from "./ThreadItems";
export type CapabilityDevtoolsTab =
| "eval"
| "evidence"
| "timeline"
| "state"
| "diff"
| "documents"
| "protocol"
| "trace"
| "runtime"
| "authority";
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
export interface EvalInspectorReport {
attemptId: string;
caseId: string;
disposition: string;
passed: boolean;
checks: EvalAssertion[];
run: {
model: string;
provider: string;
driver: string;
providerVersion: string | null;
runnerProvider: string;
configuration: string;
sessionId: string;
providerSessionId: string | null;
agentVersion: string | null;
retainedSession: boolean | null;
retainedSessionStatus: string | null;
fixtureDigest: string;
runnerPackageDigest: string;
runnerdDigest: string;
runnerBuild: string;
startedAt: string;
finishedAt: string;
durationMs: number;
initialRevision: number;
finalRevision: number;
usage: {
agentTurns: number;
providerRequests: number | null;
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
reasoningTokens: number;
providerReportedCostNanodollars?: number;
estimatedCostNanodollars: number;
pricingVersion: string;
} | null;
};
}
function JsonTree({ value, name = "root" }: { value: Json; name?: string }) {
if (value === null || typeof value !== "object") {
return <span className="pit-json-value">{JSON.stringify(value)}</span>;
}
const entries = Array.isArray(value)
? value.map((entry, index) => [String(index), entry] as const)
: Object.entries(value);
return (
<details className="pit-json-node" open={name === "root"}>
<summary>
{name}{" "}
<span className="pit-muted">
{Array.isArray(value) ? `[${entries.length}]` : `{${entries.length}}`}
</span>
</summary>
<div className="pit-json-children">
{entries.map(([key, entry]) => (
<div className="pit-json-row" key={key}>
<span className="pit-json-key">{key}</span>
{entry !== null && typeof entry === "object" ? (
<JsonTree value={entry as Json} name={key} />
) : (
<JsonTree value={entry as Json} />
)}
</div>
))}
</div>
</details>
);
}
interface DiffRow {
path: string;
before: Json | undefined;
after: Json | undefined;
}
function diff(
before: Json | undefined,
after: Json | undefined,
path = "state",
): DiffRow[] {
if (JSON.stringify(before) === JSON.stringify(after)) return [];
if (
before !== null &&
after !== null &&
typeof before === "object" &&
typeof after === "object" &&
!Array.isArray(before) &&
!Array.isArray(after)
) {
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
return [...keys].flatMap((key) =>
diff(before[key], after[key], `${path}.${key}`),
);
}
return [{ path, before, after }];
}
function download(snapshot: CapabilityDevtoolsSnapshot) {
const blob = new Blob([JSON.stringify(snapshot, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `paperclip-devtools-r${snapshot.currentRevision}.json`;
anchor.click();
URL.revokeObjectURL(url);
}
const TABS: ReadonlyArray<{
id: CapabilityDevtoolsTab;
icon: IconName;
label: string;
}> = [
{ id: "evidence", icon: "evidence", label: "Evidence" },
{ id: "timeline", icon: "timeline", label: "Timeline" },
{ id: "state", icon: "state", label: "State" },
{ id: "diff", icon: "diff", label: "Diff" },
{ id: "documents", icon: "documents", label: "Documents" },
{ id: "protocol", icon: "protocol", label: "Protocol" },
{ id: "trace", icon: "protocol", label: "Provider trace" },
{ id: "runtime", icon: "terminal", label: "Runtime" },
{ id: "authority", icon: "authority", label: "Authority" },
];
function documentsOf(state: Json): Array<{
id: string;
key: string;
title: string;
revisions: Array<{
id: string;
revision: number;
body: string;
changeSummary: string | null;
createdAt: string;
}>;
}> {
if (state === null || typeof state !== "object" || Array.isArray(state))
return [];
const documents = state.documents;
if (!Array.isArray(documents)) return [];
return documents.flatMap((value) => {
if (value === null || typeof value !== "object" || Array.isArray(value))
return [];
const revisions = Array.isArray(value.revisions)
? value.revisions.flatMap((candidate) => {
if (
candidate === null ||
typeof candidate !== "object" ||
Array.isArray(candidate)
)
return [];
return [
{
id: String(candidate.id ?? ""),
revision: Number(candidate.revision ?? 0),
body: String(candidate.body ?? ""),
changeSummary:
candidate.changeSummary === null
? null
: String(candidate.changeSummary ?? ""),
createdAt: String(candidate.createdAt ?? ""),
},
];
})
: [];
return [
{
id: String(value.id ?? ""),
key: String(value.key ?? ""),
title: String(value.title ?? "Untitled"),
revisions,
},
];
});
}
export function DevtoolsInspector({
snapshot,
onFork,
tab,
onTabChange,
evalReport,
}: {
snapshot: CapabilityDevtoolsSnapshot;
onFork: (revision: number) => void;
tab: CapabilityDevtoolsTab;
onTabChange: (tab: CapabilityDevtoolsTab) => void;
evalReport?: EvalInspectorReport | null;
}) {
const [query, setQuery] = useState("");
const latest = snapshot.revisions.at(-1)!;
const [revision, setRevision] = useState(latest.revision);
const [following, setFollowing] = useState(true);
const [compareRevision, setCompareRevision] = useState(
snapshot.revisions[0]!.revision,
);
const selected =
snapshot.revisions.find((entry) => entry.revision === revision) ?? latest;
const compared =
snapshot.revisions.find((entry) => entry.revision === compareRevision) ??
snapshot.revisions[0]!;
const rows = useMemo(
() => diff(compared.state as Json, selected.state as Json),
[compared.state, selected.state],
);
const normalized = query.trim().toLowerCase();
const revisions = snapshot.revisions.filter(
(entry) =>
normalized.length === 0 ||
JSON.stringify(entry).toLowerCase().includes(normalized),
);
const protocol = snapshot.protocol.filter(
(entry) =>
normalized.length === 0 ||
JSON.stringify(entry).toLowerCase().includes(normalized),
);
const documents = useMemo(
() => documentsOf(selected.state as Json),
[selected.state],
);
const [documentId, setDocumentId] = useState("");
const document =
documents.find((entry) => entry.id === documentId) ?? documents[0] ?? null;
const [documentRevision, setDocumentRevision] = useState(0);
const visibleDocumentRevision =
document?.revisions.find((entry) => entry.revision === documentRevision) ??
document?.revisions.at(-1) ??
null;
useEffect(() => {
if (following) setRevision(latest.revision);
}, [following, latest.revision]);
return (
<section className="pit-devtools" data-testid="devtools-inspector">
<div className="pit-devtools-toolbar">
<span className="pit-devtools-brand">
<Icon name="activity" /> State monitor
</span>
<input
id="devtools-search"
className="pit-input"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="operation, entity, turn…"
/>
<button
className="pit-icon-button"
type="button"
aria-pressed={!following}
onClick={() => setFollowing((value) => !value)}
title={following ? "Pause live updates" : "Follow latest revision"}
aria-label={
following ? "Pause live updates" : "Follow latest revision"
}
>
<Icon name={following ? "pause" : "play"} />
</button>
<button
className="pit-icon-button"
type="button"
onClick={() => download(snapshot)}
title="Export redacted snapshot"
aria-label="Export redacted snapshot"
>
<Icon name="download" />
</button>
<button
className="pit-button"
type="button"
onClick={() => onFork(revision)}
>
<Icon name="branch" /> Fork r{revision}
</button>
</div>
<div
className="pit-devtools-tabs"
role="tablist"
aria-label="Developer tools"
>
{[
...(evalReport
? [
{
id: "eval" as const,
icon: "evidence" as const,
label: "Eval",
},
]
: []),
...TABS,
].map(({ id, icon, label }) => (
<button
key={id}
type="button"
role="tab"
aria-selected={tab === id}
className="pit-tab"
onClick={() => onTabChange(id)}
>
<span className="pit-tab-glyph">
<Icon name={icon} />
</span>
<span>{label}</span>
</button>
))}
</div>
{tab === "eval" && evalReport ? (
<div className="pit-devtools-pane pit-eval-inspector">
<div className="pit-eval-summary-head">
<a href="../../index.html">← Eval suite</a>
<strong>
{evalReport.passed
? "PASS"
: evalReport.disposition.replaceAll("_", " ").toUpperCase()}
</strong>
<code>{evalReport.attemptId}</code>
</div>
<dl className="pit-eval-run-facts">
<div>
<dt>Model</dt>
<dd>
{evalReport.run.model.startsWith(`${evalReport.run.provider}/`)
? evalReport.run.model
: `${evalReport.run.provider}/${evalReport.run.model}`}
</dd>
</div>
<div>
<dt>Configuration</dt>
<dd>{evalReport.run.configuration}</dd>
</div>
<div>
<dt>Session</dt>
<dd>{evalReport.run.sessionId}</dd>
</div>
<div>
<dt>Provider session</dt>
<dd>{evalReport.run.providerSessionId ?? "unavailable"}</dd>
</div>
<div>
<dt>Driver</dt>
<dd>
{evalReport.run.driver}
{evalReport.run.providerVersion
? ` · ${evalReport.run.providerVersion}`
: ""}
</dd>
</div>
{evalReport.run.agentVersion ? (
<div>
<dt>Agent version</dt>
<dd>{evalReport.run.agentVersion}</dd>
</div>
) : null}
<div>
<dt>Retained session</dt>
<dd>
{evalReport.run.retainedSession === true
? (evalReport.run.retainedSessionStatus ?? "retained")
: "not applicable"}
</dd>
</div>
<div>
<dt>Duration</dt>
<dd>{evalReport.run.durationMs} ms</dd>
</div>
<div>
<dt>Fixture</dt>
<dd>{evalReport.run.fixtureDigest}</dd>
</div>
<div>
<dt>State</dt>
<dd>
r{evalReport.run.initialRevision} → r
{evalReport.run.finalRevision}
</dd>
</div>
<div>
<dt>Tokens</dt>
<dd>
{evalReport.run.usage === null
? "unknown"
: `${evalReport.run.usage.inputTokens} in · ${evalReport.run.usage.outputTokens} out · ${evalReport.run.usage.cachedInputTokens} cached`}
</dd>
</div>
<div>
<dt>Agent turns</dt>
<dd>{evalReport.run.usage?.agentTurns ?? "unknown"}</dd>
</div>
<div>
<dt>Provider requests</dt>
<dd>{evalReport.run.usage?.providerRequests ?? "unavailable"}</dd>
</div>
<div>
<dt>Estimated cost</dt>
<dd>
{evalReport.run.usage === null
? "unknown"
: `$${(evalReport.run.usage.estimatedCostNanodollars / 1_000_000_000).toFixed(6)} · ${evalReport.run.usage.pricingVersion}`}
</dd>
</div>
<div>
<dt>Provider list cost</dt>
<dd>
{typeof evalReport.run.usage
?.providerReportedCostNanodollars !== "number"
? "unknown"
: `$${(evalReport.run.usage.providerReportedCostNanodollars / 1_000_000_000).toFixed(6)}`}
</dd>
</div>
<div>
<dt>Runner</dt>
<dd>{evalReport.run.runnerPackageDigest}</dd>
</div>
<div>
<dt>Runner build</dt>
<dd>{evalReport.run.runnerBuild}</dd>
</div>
<div>
<dt>runnerd</dt>
<dd>{evalReport.run.runnerdDigest}</dd>
</div>
</dl>
<h3>Assertions</h3>
<EvalAssertions assertions={evalReport.checks} />
</div>
) : null}
{tab === "timeline" ? (
<div className="pit-devtools-list">
{revisions.map((entry) => (
<button
type="button"
className="pit-devtools-event"
data-selected={entry.revision === revision}
key={entry.revision}
onClick={() => {
setFollowing(false);
setRevision(entry.revision);
}}
>
<strong>r{entry.revision}</strong>
<span>{entry.operationId}</span>
<span className="pit-muted">{entry.turnId ?? "session"}</span>
</button>
))}
</div>
) : null}
{tab === "state" ? (
<div className="pit-devtools-pane">
<label className="pit-card-meta" htmlFor="devtools-revision">
Revision
</label>
<select
id="devtools-revision"
className="pit-select"
value={revision}
onChange={(event) => setRevision(Number(event.target.value))}
>
{snapshot.revisions.map((entry) => (
<option key={entry.revision} value={entry.revision}>
r{entry.revision} · {entry.operationId}
</option>
))}
</select>
<JsonTree value={selected.state as Json} />
</div>
) : null}
{tab === "diff" ? (
<div className="pit-devtools-pane">
<div className="pit-button-row">
<select
className="pit-select"
aria-label="Compare from revision"
value={compareRevision}
onChange={(event) =>
setCompareRevision(Number(event.target.value))
}
>
{snapshot.revisions.map((entry) => (
<option key={entry.revision} value={entry.revision}>
from r{entry.revision}
</option>
))}
</select>
<select
className="pit-select"
aria-label="Compare to revision"
value={revision}
onChange={(event) => setRevision(Number(event.target.value))}
>
{snapshot.revisions.map((entry) => (
<option key={entry.revision} value={entry.revision}>
to r{entry.revision}
</option>
))}
</select>
</div>
{rows.map((row) => (
<div className="pit-diff-row" key={row.path}>
<strong>{row.path}</strong>
<del>{JSON.stringify(row.before)}</del>
<ins>{JSON.stringify(row.after)}</ins>
</div>
))}
{rows.length === 0 ? (
<p className="pit-muted">No changes between these revisions.</p>
) : null}
</div>
) : null}
{tab === "documents" ? (
documents.length === 0 ? (
<div className="pit-devtools-empty">
<span aria-hidden="true">▤</span>
<p>No documents exist at r{selected.revision}.</p>
</div>
) : (
<div className="pit-document-browser">
<div className="pit-document-sidebar">
{documents.map((entry) => (
<button
type="button"
key={entry.id}
data-selected={entry.id === document?.id}
onClick={() => {
setDocumentId(entry.id);
setDocumentRevision(0);
}}
>
<strong>{entry.key}</strong>
<span>{entry.title}</span>
<small>
{entry.revisions.length} revision
{entry.revisions.length === 1 ? "" : "s"}
</small>
</button>
))}
</div>
{document !== null && visibleDocumentRevision !== null ? (
<article className="pit-document-viewer">
<header>
<div>
<span className="pit-card-meta">{document.key}</span>
<h4>{document.title}</h4>
</div>
<select
className="pit-select"
aria-label="Document revision"
value={visibleDocumentRevision.revision}
onChange={(event) =>
setDocumentRevision(Number(event.target.value))
}
>
{document.revisions.map((entry) => (
<option key={entry.id} value={entry.revision}>
r{entry.revision}
</option>
))}
</select>
</header>
{visibleDocumentRevision.changeSummary ? (
<p className="pit-document-summary">
{visibleDocumentRevision.changeSummary}
</p>
) : null}
<pre className="pit-document-body">
{visibleDocumentRevision.body}
</pre>
</article>
) : null}
</div>
)
) : null}
{tab === "protocol" ? (
<div className="pit-devtools-list">
{protocol.map((entry) => (
<details className="pit-runner-event" key={entry.id}>
<summary>
<strong>{entry.boundary}</strong> · {entry.event}
</summary>
<JsonTree value={entry.detail as Json} />
</details>
))}
</div>
) : null}
{tab === "trace" ? (
snapshot.providerTrace ? (
<div className="pit-devtools-pane">
<p>
<strong>{snapshot.providerTrace.status}</strong> · expires{" "}
{snapshot.providerTrace.expiresAt}
</p>
<h3>Raw frame → Interpretation → PRP events → Presentation</h3>
<JsonTree
value={
{
frames: snapshot.providerTrace.frames,
interpretations: snapshot.providerTrace.interpretations,
} as Json
}
/>
</div>
) : (
<div className="pit-devtools-empty">
<span aria-hidden="true">◇</span>
<p>
Raw provider capture was off for this run. Canonical protocol
records remain available in Protocol.
</p>
</div>
)
) : null}
{tab === "runtime" ? (
<div className="pit-devtools-pane">
<JsonTree value={snapshot.runtime as Json} />
</div>
) : null}
{tab === "authority" ? (
<div className="pit-devtools-pane">
<JsonTree value={snapshot.authority as Json} />
</div>
) : null}
</section>
);
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import type { CapabilityEvidenceRunnerRecord } from "../../../src/issue-thread/types.js";
import { groupCapabilityRunnerEvents } from "./EvidencePanel.js";
function record(
ordinal: number,
event: string,
turnId = "turn-1",
): CapabilityEvidenceRunnerRecord {
return {
id: `event-${ordinal}`,
turnId,
kind: "provider_event",
ordinal,
detail: `provider event · ${event}`,
details: [{ label: "Event", value: event }],
};
}
describe("Runner event grouping", () => {
it("aggregates each delta category per turn at its first position", () => {
const session: CapabilityEvidenceRunnerRecord = {
id: "event-1",
turnId: "turn-0",
kind: "session",
ordinal: 1,
detail: "session · started",
details: [{ label: "Action", value: "started" }],
};
const groups = groupCapabilityRunnerEvents([
session,
record(2, "assistant_delta"),
record(3, "reasoning_delta"),
record(4, "assistant_delta"),
record(5, "assistant_delta", "turn-2"),
]);
expect(groups.map((group) => [group.event, group.records.map((entry) => entry.ordinal)])).toEqual([
[null, [1]],
["assistant_delta", [2, 4]],
["reasoning_delta", [3]],
["assistant_delta", [5]],
]);
});
it("leaves non-delta events individually expandable", () => {
const groups = groupCapabilityRunnerEvents([
record(1, "turn_started"),
record(2, "tool_result"),
]);
expect(groups).toHaveLength(2);
expect(groups.every((group) => group.event === null && group.records.length === 1)).toBe(true);
});
});
@@ -0,0 +1,622 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type {
CapabilityEvidenceModel,
CapabilityEvidenceRunnerRecord,
CapabilityEvidenceSectionId,
CapabilityEvidenceToolRow,
CapabilityIssueThreadSnapshot,
CapabilityJsonValue,
CapabilityToolDisposition,
} from "../../../src/issue-thread/types";
import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools";
import { DevtoolsInspector, type CapabilityDevtoolsTab, type EvalInspectorReport } from "./DevtoolsInspector";
import { Icon } from "./Icons";
import { capabilitySemanticToolDescriptor } from "../../../src/semantic-tools/catalog";
import {
CAPABILITY_EVIDENCE_SECTIONS,
capabilityDispositionLabel,
} from "../../../src/issue-thread/types";
/**
* Evidence panel (contract §7): eight accordion sections in fixed order with a
* turn selector. Every record is rendered from the snapshot; the panel derives
* no verdicts of its own.
*/
const DISPOSITION_ORDER: CapabilityToolDisposition[] = [
"always_agent_tool",
"optional_agent_tool",
"control_plane_owned",
];
export interface CapabilityRunnerEventGroup {
id: string;
event: string | null;
records: CapabilityEvidenceRunnerRecord[];
}
/**
* Stream deltas are useful evidence but terrible default reading material.
* Aggregate each sanitized delta category within a turn at its first position;
* expanding the group still exposes every individual event in ordinal order.
*/
export function groupCapabilityRunnerEvents(
records: readonly CapabilityEvidenceRunnerRecord[],
): CapabilityRunnerEventGroup[] {
const groups: CapabilityRunnerEventGroup[] = [];
const deltaGroups = new Map<string, CapabilityRunnerEventGroup>();
for (const record of records) {
const event = record.details.find((entry) => entry.label === "Event")?.value ?? null;
if (event === null || !event.endsWith("_delta")) {
groups.push({ id: record.id, event: null, records: [record] });
continue;
}
const key = `${record.turnId}:${event}`;
const existing = deltaGroups.get(key);
if (existing !== undefined) {
existing.records.push(record);
continue;
}
const group = { id: `delta:${key}`, event, records: [record] };
deltaGroups.set(key, group);
groups.push(group);
}
return groups;
}
function RunnerEvent({ record }: { record: CapabilityEvidenceRunnerRecord }) {
return (
<details className="pit-runner-event" data-record-id={record.id}>
<summary className="pit-runner-event-summary">
<span className="pit-record-title">#{record.ordinal} {record.kind}</span>
<span className="pit-record-detail">{record.detail}</span>
<span className="pit-activity-caret" aria-hidden="true">›</span>
</summary>
<dl className="pit-runner-event-details">
{record.details.length === 0 ? (
<>
<dt>Detail</dt>
<dd>No additional sanitized fields were retained.</dd>
</>
) : (
record.details.map((entry, index) => (
<div key={`${entry.label}:${index}`}>
<dt>{entry.label}</dt>
<dd>{entry.value}</dd>
</div>
))
)}
</dl>
</details>
);
}
function RunnerEventGroup({ group }: { group: CapabilityRunnerEventGroup }) {
if (group.event === null) return <RunnerEvent record={group.records[0]!} />;
return (
<details className="pit-runner-group" data-runner-delta-group={group.event}>
<summary className="pit-runner-group-summary">
<span className="pit-activity-caret" aria-hidden="true">›</span>
<span className="pit-record-title">{group.event}</span>
<span className="pit-record-detail">
{group.records.length} streamed update{group.records.length === 1 ? "" : "s"}
</span>
</summary>
<div className="pit-runner-group-events">
{group.records.map((record) => <RunnerEvent key={record.id} record={record} />)}
</div>
</details>
);
}
export interface EvidencePanelProps {
snapshot: CapabilityIssueThreadSnapshot;
devtools?: CapabilityDevtoolsSnapshot | null;
evalReport?: EvalInspectorReport | null;
onForkRevision?: (revision: number) => void;
layout: "side" | "overlay" | "segment";
width: number;
selectedTurnId: string | "all";
openSections: CapabilityEvidenceSectionId[];
highlightedRecordId: string | null;
onSelectTurn: (turnId: string | "all") => void;
onToggleSection: (section: CapabilityEvidenceSectionId) => void;
onClose: () => void;
onJumpToThread: (anchorId: string) => void;
onInvokeTool?: (operationId: string, input: CapabilityJsonValue) => Promise<CapabilityJsonValue>;
}
function sectionCount(
evidence: CapabilityEvidenceModel,
section: CapabilityEvidenceSectionId,
turnId: string | "all",
): string {
if (section === "tools") {
const records = filterByTurn(evidence.tools, turnId);
return String(new Set(records.flatMap((record) => record.rows.map((row) => row.operationId))).size);
}
if (section === "parity") {
const rows = filterByTurn(evidence.parity, turnId);
const passing = rows.filter((row) => row.verdict === "pass").length;
return `${passing}/${rows.length}`;
}
const rows = filterByTurn(
evidence[section] as ReadonlyArray<{ turnId: string }>,
turnId,
);
return String(rows.length);
}
function starterInput(schema: { properties?: Readonly<Record<string, { type?: string | readonly string[]; enum?: readonly CapabilityJsonValue[]; default?: CapabilityJsonValue }>>; required?: readonly string[] } | undefined): Record<string, CapabilityJsonValue> {
const result: Record<string, CapabilityJsonValue> = {};
for (const name of schema?.required ?? []) {
const property = schema?.properties?.[name];
if (property?.default !== undefined) result[name] = property.default;
else if (property?.enum?.[0] !== undefined) result[name] = property.enum[0];
else if (name === "idempotencyKey") result[name] = `devtools-${Date.now()}`;
else if (property?.type === "integer" || property?.type === "number") result[name] = 0;
else if (property?.type === "boolean") result[name] = false;
else if (property?.type === "array") result[name] = [];
else if (property?.type === "object") result[name] = {};
else result[name] = "";
}
return result;
}
function ToolsBrowser({ records, onInvoke }: { records: CapabilityEvidenceModel["tools"]; onInvoke?: (operationId: string, input: CapabilityJsonValue) => Promise<CapabilityJsonValue> }) {
const [collapsed, setCollapsed] = useState<CapabilityToolDisposition[]>([]);
const [selected, setSelected] = useState<CapabilityEvidenceToolRow | null>(null);
const [inputText, setInputText] = useState("{}");
const [invocationResult, setInvocationResult] = useState<CapabilityJsonValue | null>(null);
const [invocationError, setInvocationError] = useState<string | null>(null);
const [invoking, setInvoking] = useState(false);
const rows = useMemo(() => {
const unique = new Map<string, CapabilityEvidenceToolRow>();
for (const record of records) {
for (const row of record.rows) unique.set(row.operationId, row);
}
return [...unique.values()];
}, [records]);
const descriptor = selected === null ? undefined : capabilitySemanticToolDescriptor(selected.operationId);
useEffect(() => {
if (selected === null) return;
setInputText(JSON.stringify(starterInput(descriptor?.inputSchema), null, 2));
setInvocationResult(null);
setInvocationError(null);
}, [descriptor, selected]);
useEffect(() => {
if (selected === null) return;
const close = (event: KeyboardEvent) => { if (event.key === "Escape") setSelected(null); };
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [selected]);
return (
<>
<div className="pit-tool-groups">
{DISPOSITION_ORDER.map((disposition) => {
const group = rows.filter((row) => row.disposition === disposition);
if (group.length === 0) return null;
const folded = collapsed.includes(disposition);
const title = disposition === "control_plane_owned"
? "Control plane (not exposed to the agent)"
: capabilityDispositionLabel(disposition);
return (
<section className="pit-tool-group" data-group={disposition} key={disposition}>
<h4>
<button type="button" className="pit-tool-group-toggle" aria-expanded={!folded} onClick={() => setCollapsed((current) => current.includes(disposition) ? current.filter((entry) => entry !== disposition) : [...current, disposition])}>
<span aria-hidden="true">{folded ? "›" : "⌄"}</span>
<span>{title}</span>
<span className="pit-section-count">{group.length}</span>
</button>
</h4>
{!folded ? (
<div className="pit-tool-group-rows">
{group.map((row) => (
<button type="button" className="pit-tool-row" key={row.operationId} data-disposition={disposition} onClick={() => setSelected(row)}>
<span className="pit-record-title">{row.operationId}</span>
<span className="pit-tool-row-description">{row.description}</span>
{row.grant !== null && disposition === "optional_agent_tool" ? <span className="pit-grant">{row.grant}</span> : null}
<span className="pit-activity-caret" aria-hidden="true">›</span>
</button>
))}
</div>
) : null}
</section>
);
})}
</div>
{selected !== null ? (
<div className="pit-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setSelected(null); }}>
<div className="pit-dialog pit-tool-dialog" data-invokable={onInvoke !== undefined} role="dialog" aria-modal="true" aria-labelledby="tool-dialog-title">
<header className="pit-tool-dialog-head">
<div><span className="pit-card-meta">Semantic tool · v{descriptor?.version ?? 1}</span><h2 id="tool-dialog-title">{descriptor?.title ?? selected.operationId}</h2></div>
<button type="button" className="pit-icon-button" aria-label="Close tool details" title="Close tool details" onClick={() => setSelected(null)}><Icon name="close" /></button>
</header>
<div className="pit-tool-dialog-grid" data-invokable={onInvoke !== undefined}>
<div className="pit-tool-reference">
<code className="pit-tool-operation">{selected.operationId}</code>
<p>{descriptor?.description ?? selected.description}</p>
<dl className="pit-tool-facts">
<div><dt>Placement</dt><dd>{capabilityDispositionLabel(selected.disposition)}</dd></div>
<div><dt>Required claims</dt><dd>{descriptor?.requiredClaims.join(", ") || selected.grant || "None"}</dd></div>
<div><dt>Task modes</dt><dd>{descriptor?.allowedModes.join(", ") || "—"}</dd></div>
<div><dt>Actor roles</dt><dd>{descriptor?.allowedRoles?.join(", ") || "Any allowed role"}</dd></div>
</dl>
<div>
<h3 className="pit-tool-schema-title">Input schema</h3>
<pre className="pit-code">{JSON.stringify(descriptor?.inputSchema ?? {}, null, 2)}</pre>
</div>
</div>
{onInvoke !== undefined ? (
<div className="pit-tool-tester">
<h3 className="pit-tool-schema-title">Invoke in conversation</h3>
<p className="pit-muted">Runs through the real semantic dispatcher and records a turn, evidence, and company-state changes.</p>
<label className="pit-card-meta" htmlFor="tool-test-input">Parameters (JSON)</label>
<textarea id="tool-test-input" className="pit-tool-test-input" value={inputText} onChange={(event) => setInputText(event.target.value)} spellCheck={false} />
<button type="button" className="pit-button" disabled={invoking} onClick={() => {
let parsed: CapabilityJsonValue;
try {
parsed = JSON.parse(inputText) as CapabilityJsonValue;
} catch (error) {
setInvocationError(error instanceof Error ? error.message : String(error));
return;
}
setInvoking(true);
setInvocationError(null);
setInvocationResult(null);
void onInvoke(selected.operationId, parsed)
.then(setInvocationResult)
.catch((error) => setInvocationError(error instanceof Error ? error.message : String(error)))
.finally(() => setInvoking(false));
}}>{invoking ? "Invoking…" : "Invoke as tool call"}</button>
{invocationError !== null ? <p className="pit-error" role="alert">{invocationError}</p> : null}
{invocationResult !== null ? <pre className="pit-code" data-testid="tool-test-result">{JSON.stringify(invocationResult, null, 2)}</pre> : null}
</div>
) : null}
</div>
</div>
</div>
) : null}
</>
);
}
function filterByTurn<T extends { turnId: string }>(
rows: ReadonlyArray<T>,
turnId: string | "all",
): T[] {
return turnId === "all" ? [...rows] : rows.filter((row) => row.turnId === turnId);
}
function Section({
id,
title,
count,
open,
onToggle,
children,
}: {
id: CapabilityEvidenceSectionId;
title: string;
count: string;
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<section className="pit-section" data-evidence-section={id}>
<h3>
<button
type="button"
className="pit-section-button"
aria-expanded={open}
aria-controls={`evidence-section-${id}`}
onClick={onToggle}
>
<span aria-hidden="true">{open ? "⌄" : "›"}</span>
{title}
<span className="pit-section-count">{count}</span>
</button>
</h3>
<div className="pit-section-body" id={`evidence-section-${id}`} hidden={!open}>
{children}
</div>
</section>
);
}
export function EvidencePanel(props: EvidencePanelProps) {
const [devtoolsTab, setDevtoolsTab] = useState<CapabilityDevtoolsTab>(props.evalReport ? "eval" : "evidence");
const {
snapshot,
devtools,
evalReport,
onForkRevision = () => undefined,
layout,
width,
selectedTurnId,
openSections,
highlightedRecordId,
onSelectTurn,
onToggleSection,
onClose,
onJumpToThread,
onInvokeTool,
} = props;
const headingRef = useRef<HTMLHeadingElement | null>(null);
const evidence = snapshot.evidence;
useEffect(() => {
// Contract §9.2: opening Evidence moves focus to its heading.
headingRef.current?.focus();
}, []);
useEffect(() => {
// Contract §9.1: Escape closes the desktop overlay sheet. The side panel is
// a persistent region rather than a sheet, and the mobile segment is a tab
// view, so neither of those is dismissible this way.
if (layout !== "overlay") return;
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
// A modal dialog owns Escape while it is open (§6 reset confirm).
if (document.querySelector('[role="dialog"][aria-modal="true"]') !== null) return;
onClose();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [layout, onClose]);
const isOpen = (section: CapabilityEvidenceSectionId) => openSections.includes(section);
return (
<aside
className="pit-panel"
data-layout={layout}
data-testid="evidence-panel"
style={layout === "side" ? { width: `${width}px` } : undefined}
aria-label="Evidence"
>
<div className="pit-panel-chrome">
<h2 className="pit-visually-hidden" tabIndex={-1} ref={headingRef}>Developer tools</h2>
<span className="pit-panel-kicker"><Icon name="activity" /> Paperclip DevTools</span>
<button type="button" className="pit-icon-button" onClick={onClose} aria-label="Close DevTools" title="Close DevTools">
<Icon name="close" />
</button>
</div>
{devtools !== undefined ? (
<>
{devtools === null ? (
<p className="pit-muted pit-devtools-loading">Loading company state…</p>
) : (
<DevtoolsInspector snapshot={devtools} onFork={onForkRevision} tab={devtoolsTab} onTabChange={setDevtoolsTab} evalReport={evalReport} />
)}
</>
) : null}
{devtoolsTab === "evidence" ? <div className="pit-evidence-browser">
<div className="pit-evidence-filter">
<label className="pit-card-meta" htmlFor="evidence-turn-selector">Evidence scope</label>
<select className="pit-select" id="evidence-turn-selector" value={selectedTurnId} onChange={(event) => onSelectTurn(event.target.value as string | "all")}>
{snapshot.turns.map((turn) => <option key={turn.id} value={turn.id}>Turn {turn.ordinal}</option>)}
<option value="all">All turns</option>
</select>
</div>
<Section
id="tools"
title="Tools exposed"
count={sectionCount(evidence, "tools", selectedTurnId)}
open={isOpen("tools")}
onToggle={() => onToggleSection("tools")}
>
<ToolsBrowser records={filterByTurn(evidence.tools, selectedTurnId)} onInvoke={onInvokeTool} />
</Section>
<Section
id="calls"
title="Calls & results"
count={sectionCount(evidence, "calls", selectedTurnId)}
open={isOpen("calls")}
onToggle={() => onToggleSection("calls")}
>
{filterByTurn(evidence.calls, selectedTurnId).map((record) => (
<div
className="pit-record"
key={record.id}
data-record-id={record.id}
data-highlighted={highlightedRecordId === record.id}
>
<span className="pit-record-title">
{record.operationId} · v{record.version} · {record.outcome}
</span>
<span className="pit-record-detail">{record.providerRequest}</span>
<span className="pit-record-detail">{record.dispatchedCommand}</span>
<span className="pit-record-detail">{JSON.stringify(record.result)}</span>
{record.redactions.length > 0 ? (
<span className="pit-record-detail">
••• redacted by {record.redactions.join(", ")}
</span>
) : null}
<button
type="button"
className="pit-link-button"
onClick={() => onJumpToThread(record.threadAnchorId)}
>
Show in thread
</button>
</div>
))}
</Section>
<Section
id="authorization"
title="Authorization"
count={sectionCount(evidence, "authorization", selectedTurnId)}
open={isOpen("authorization")}
onToggle={() => onToggleSection("authorization")}
>
{filterByTurn(evidence.authorization, selectedTurnId).map((record) => (
<div
className="pit-record"
key={record.id}
data-record-id={record.id}
data-allowed={record.allowed}
data-highlighted={highlightedRecordId === record.id}
>
<span className="pit-record-title">
<span aria-hidden="true">{record.allowed ? "✓" : "✕"}</span>{" "}
{record.operationId} · {record.phase} · {record.allowed ? "allowed" : "denied"}
</span>
<span className="pit-record-detail">{record.reason}</span>
<span className="pit-record-detail">
claims: {record.claimsConsidered.join(", ") || "none"}
</span>
{record.redactions.length > 0 ? (
<span className="pit-record-detail">
••• redacted by {record.redactions.join(", ")}
</span>
) : null}
{record.stateChangeRef !== null ? (
<span className="pit-record-detail">state: {record.stateChangeRef}</span>
) : null}
{record.threadAnchorId.length > 0 ? (
<button
type="button"
className="pit-link-button"
onClick={() => onJumpToThread(record.threadAnchorId)}
>
Show in thread
</button>
) : null}
</div>
))}
</Section>
<Section
id="control_plane"
title="Control plane"
count={sectionCount(evidence, "control_plane", selectedTurnId)}
open={isOpen("control_plane")}
onToggle={() => onToggleSection("control_plane")}
>
{filterByTurn(evidence.control_plane, selectedTurnId).map((record) => (
<div
className="pit-record"
key={record.id}
data-record-id={record.id}
data-highlighted={highlightedRecordId === record.id}
>
<span className="pit-record-title">
{record.category} · {record.outcome} · rev {record.stateRevision}
</span>
<span className="pit-record-detail">{record.reason}</span>
</div>
))}
</Section>
<Section
id="runner"
title="Runner & events"
count={sectionCount(evidence, "runner", selectedTurnId)}
open={isOpen("runner")}
onToggle={() => onToggleSection("runner")}
>
{groupCapabilityRunnerEvents(filterByTurn(evidence.runner, selectedTurnId)).map((group) => (
<RunnerEventGroup key={group.id} group={group} />
))}
</Section>
<Section
id="state"
title="State diff"
count={sectionCount(evidence, "state", selectedTurnId)}
open={isOpen("state")}
onToggle={() => onToggleSection("state")}
>
{filterByTurn(evidence.state, selectedTurnId).map((record) => (
<div
className="pit-record"
key={record.id}
data-record-id={record.id}
data-highlighted={highlightedRecordId === record.id}
>
<span className="pit-record-title">
revision {record.fromRevision} → {record.toRevision}
</span>
{record.rows.map((row) => (
<div className="pit-diff-row" key={`${row.entityClass}-${row.entityRef}`}>
<span className="pit-record-detail">
{row.entityClass} · {row.entityRef}
</span>
<span className="pit-record-detail">
{row.before} → {row.after}
</span>
</div>
))}
</div>
))}
</Section>
<Section
id="traceability"
title="Traceability"
count={sectionCount(evidence, "traceability", selectedTurnId)}
open={isOpen("traceability")}
onToggle={() => onToggleSection("traceability")}
>
{filterByTurn(evidence.traceability, selectedTurnId).map((record) => (
<div className="pit-record" key={record.id} data-record-id={record.id}>
<span className="pit-record-title">
{record.caseId} · {record.group}
</span>
<span className="pit-record-detail">{record.sourceAnchor}</span>
<span className="pit-record-detail">{record.browserEvidenceRecipe}</span>
<span className="pit-record-detail">
expects: {record.expectedSemanticOperations.join(", ")}
</span>
<span className="pit-record-detail">
forbids: {record.forbiddenOperations.join(", ") || "none"}
</span>
<span className="pit-record-detail">
grants: {record.requiredCapabilityGrants.join(", ") || "none"}
</span>
</div>
))}
</Section>
<Section
id="parity"
title="Parity"
count={sectionCount(evidence, "parity", selectedTurnId)}
open={isOpen("parity")}
onToggle={() => onToggleSection("parity")}
>
{filterByTurn(evidence.parity, selectedTurnId).map((record) => (
<div className="pit-record" key={record.id} data-record-id={record.id}>
<span className="pit-verdict" data-verdict={record.verdict}>
<span aria-hidden="true">
{record.verdict === "pass" ? "✓" : record.verdict === "fail" ? "✕" : "—"}
</span>
{record.verdict === "intentional_gap" ? "intentional gap" : record.verdict}
</span>
<span className="pit-record-detail">{record.assertion}</span>
{record.note !== null ? (
<span className="pit-record-detail">{record.note}</span>
) : null}
</div>
))}
</Section>
</div> : null}
</aside>
);
}
export const CAPABILITY_EVIDENCE_SECTION_IDS = CAPABILITY_EVIDENCE_SECTIONS.map(
(section) => section.id,
);
@@ -0,0 +1,32 @@
export type IconName =
| "activity" | "authority" | "branch" | "close" | "diff" | "documents"
| "download" | "evidence" | "pause" | "play" | "protocol" | "reset"
| "spark" | "state" | "stop" | "terminal" | "timeline";
const PATHS: Record<IconName, string> = {
activity: "M3 12h3l2-6 4 12 3-9 2 5h4",
authority: "M12 3l7 4v5c0 4.6-2.8 7.6-7 9-4.2-1.4-7-4.4-7-9V7zM9 12l2 2 4-4",
branch: "M6 3v12a3 3 0 0 0 3 3h6M15 18l-3-3m3 3-3 3M6 7h6a3 3 0 0 0 3-3V3",
close: "M5 5l14 14M19 5 5 19",
diff: "M8 5v14M4 9h8M17 7v10M14 12h6",
documents: "M6 3h9l3 3v15H6zM15 3v4h4M9 11h6M9 15h6",
download: "M12 3v12m-5-5 5 5 5-5M5 21h14",
evidence: "M4 5h16v14H4zM8 9h8M8 13h5",
pause: "M8 5v14M16 5v14",
play: "M8 5v14l11-7z",
protocol: "M4 8h14m-3-3 3 3-3 3M20 16H6m3-3-3 3 3 3",
reset: "M5 8a8 8 0 1 1-1 7M5 8V3m0 5h5",
spark: "M12 2l1.7 5.3L19 9l-5.3 1.7L12 16l-1.7-5.3L5 9l5.3-1.7zM19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8z",
state: "M9 4H6a2 2 0 0 0-2 2v3M15 4h3a2 2 0 0 1 2 2v3M9 20H6a2 2 0 0 1-2-2v-3M15 20h3a2 2 0 0 0 2-2v-3M9 9h6v6H9z",
stop: "M7 7h10v10H7z",
terminal: "M5 7l4 5-4 5m7 0h7",
timeline: "M5 5v14M5 8h5M5 13h9M5 18h13",
};
export function Icon({ name }: { name: IconName }) {
return (
<svg className="pit-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d={PATHS[name]} />
</svg>
);
}
@@ -0,0 +1,528 @@
import { useEffect, useId, useRef, useState } from "react";
import type {
CapabilityThreadInteractionCard,
CapabilityThreadInteractionState,
} from "../../../src/issue-thread/types";
import { Chip } from "./primitives";
/**
* Native interaction cards (contract §5). All five `request_human_input`
* kinds render typed controls; the card never asks the user to type an answer
* into the composer.
*
* The submit handler posts to the package server, which stores the response in
* the mock control plane before the runner is resumed. The card only leaves
* `submitting` when the server acknowledges — there is no optimistic
* resolution here.
*/
export interface CapabilityInteractionResponse {
interactionId: string;
outcome: "answered" | "accepted" | "rejected";
result: Record<string, unknown>;
}
const STATE_GLYPH: Record<CapabilityThreadInteractionState, string> = {
pending: "⏳",
submitting: "⏳",
accepted: "✓",
answered: "✓",
rejected: "✕",
stale_target: "⌛",
superseded_by_comment: "⌛",
expired: "⌛",
withdrawn: "⌛",
issue_closed: "⌛",
};
const RESOLVED_STATES = new Set<CapabilityThreadInteractionState>([
"accepted",
"answered",
"rejected",
"stale_target",
"superseded_by_comment",
"expired",
"withdrawn",
"issue_closed",
]);
function stateTone(state: CapabilityThreadInteractionState) {
if (state === "accepted" || state === "answered") return "success" as const;
if (state === "rejected") return "danger" as const;
if (state === "pending" || state === "submitting") return "accent" as const;
return undefined;
}
export function InteractionCard({
card,
onRespond,
onOpenEvidence,
autoFocus,
}: {
card: CapabilityThreadInteractionCard;
onRespond?: (response: CapabilityInteractionResponse) => void;
onOpenEvidence?: (section: string, recordId: string) => void;
autoFocus?: boolean;
}) {
const titleId = useId();
const [answers, setAnswers] = useState<Record<string, string>>({});
const [selected, setSelected] = useState<string[]>(() =>
card.payload.kind === "checkbox"
? card.payload.options.filter((option) => option.defaultSelected).map((option) => option.id)
: card.payload.kind === "suggest_tasks"
? card.payload.tasks.map((task) => task.id)
: [],
);
const [verdicts, setVerdicts] = useState<Record<string, string>>({});
const [reasons, setReasons] = useState<Record<string, string>>({});
const [rejectReason, setRejectReason] = useState("");
const [error, setError] = useState<string | null>(null);
const firstControl = useRef<HTMLElement | null>(null);
const stateChip = useRef<HTMLSpanElement | null>(null);
const previousState = useRef<CapabilityThreadInteractionState>(card.state);
const resolved = RESOLVED_STATES.has(card.state);
const busy = card.state === "submitting";
useEffect(() => {
// Contract §9.2: resolving a card moves focus to its state chip. The
// controls the user just used unmount on resolve, so without this the
// keyboard caret would fall back to `body` exactly when the card changes.
const from = previousState.current;
previousState.current = card.state;
if (from === card.state) return;
if (!RESOLVED_STATES.has(from) && RESOLVED_STATES.has(card.state)) {
stateChip.current?.focus();
}
}, [card.state]);
function respond(response: CapabilityInteractionResponse) {
setError(null);
onRespond?.(response);
}
function toggle(list: string[], id: string): string[] {
return list.includes(id) ? list.filter((entry) => entry !== id) : [...list, id];
}
return (
<section
className="pit-interaction"
data-interaction-id={card.interactionId}
data-interaction-kind={card.interactionKind}
data-interaction-state={card.state}
aria-labelledby={titleId}
id={`interaction-${card.interactionId}`}
>
<div className="pit-card-head">
<h3 className="pit-interaction-title" id={titleId}>
{card.title}
</h3>
<Chip
tone={stateTone(card.state)}
testId="interaction-state-chip"
tabIndex={-1}
chipRef={stateChip}
>
<span aria-hidden="true">{STATE_GLYPH[card.state]}</span>
{card.stateLabel}
</Chip>
</div>
<p className="pit-card-body">{card.prompt}</p>
{card.target !== null ? (
<p className="pit-card-meta">
Target:{" "}
<a href={card.target.href} data-testid="interaction-target">
{card.target.label}
</a>
</p>
) : null}
{card.payload.kind === "confirmation" ? (
<p className="pit-quote">{card.payload.targetSummary}</p>
) : null}
{resolved ? (
<div className="pit-interaction-controls">
{card.resolvedSummary.length > 0 ? (
<ul className="pit-summary-list">
{card.resolvedSummary.map((entry) => (
<li key={entry}>{entry}</li>
))}
</ul>
) : null}
{card.reason !== null ? <blockquote className="pit-quote">{card.reason}</blockquote> : null}
{card.supersededBy !== null ? (
<p className="pit-card-meta">
<a href={card.supersededBy.href}>{card.supersededBy.label}</a>
</p>
) : null}
</div>
) : (
<div className="pit-interaction-controls">
{card.payload.kind === "questions"
? card.payload.questions.map((question, index) => (
<div className="pit-field" key={question.id}>
<span className="pit-field-label" id={`${titleId}-${question.id}`}>
{question.prompt}
</span>
{question.control === "text" ? (
<input
className="pit-input"
type="text"
aria-labelledby={`${titleId}-${question.id}`}
disabled={busy}
value={answers[question.id] ?? ""}
onChange={(event) =>
setAnswers((current) => ({ ...current, [question.id]: event.target.value }))
}
/>
) : question.control === "select" ? (
<select
className="pit-select"
aria-labelledby={`${titleId}-${question.id}`}
disabled={busy}
value={answers[question.id] ?? ""}
onChange={(event) =>
setAnswers((current) => ({ ...current, [question.id]: event.target.value }))
}
>
<option value="">Choose…</option>
{(question.options ?? []).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : (
<div role="radiogroup" aria-labelledby={`${titleId}-${question.id}`}>
{(question.options ?? []).map((option, optionIndex) => (
<label className="pit-option-row" key={option}>
<input
type="radio"
name={`${card.interactionId}-${question.id}`}
value={option}
disabled={busy}
ref={
index === 0 && optionIndex === 0
? (node) => {
firstControl.current = node;
if (autoFocus === true && node !== null) node.focus();
}
: undefined
}
checked={answers[question.id] === option}
onChange={() =>
setAnswers((current) => ({ ...current, [question.id]: option }))
}
/>
<span>{option}</span>
</label>
))}
</div>
)}
</div>
))
: null}
{card.payload.kind === "checkbox"
? card.payload.options.map((option, index) => (
<label className="pit-option-row" key={option.id}>
<input
type="checkbox"
disabled={busy}
checked={selected.includes(option.id)}
ref={
index === 0
? (node) => {
if (autoFocus === true && node !== null) node.focus();
}
: undefined
}
onChange={() => setSelected((current) => toggle(current, option.id))}
/>
<span>{option.label}</span>
</label>
))
: null}
{card.payload.kind === "suggest_tasks"
? card.payload.tasks.map((task) => (
<label className="pit-option-row" key={task.id}>
<input
type="checkbox"
disabled={busy}
checked={selected.includes(task.id)}
onChange={() => setSelected((current) => toggle(current, task.id))}
/>
<span>
<strong>{task.title}</strong>
<br />
<span className="pit-card-meta">{task.description}</span>
</span>
</label>
))
: null}
{card.payload.kind === "item_verdicts"
? card.payload.items.map((item) => (
<div className="pit-field" key={item.id}>
<span className="pit-field-label" id={`${titleId}-${item.id}`}>
{item.title}
</span>
<div
className="pit-segmented-verdicts"
role="radiogroup"
aria-labelledby={`${titleId}-${item.id}`}
>
{(["approve", "reject", "defer"] as const).map((verdict) => (
<button
key={verdict}
type="button"
className="pit-button"
role="radio"
aria-checked={
(item.lockedVerdict ?? verdicts[item.id] ?? "") === verdict
}
disabled={busy || item.lockedVerdict != null}
onClick={() =>
setVerdicts((current) => ({ ...current, [item.id]: verdict }))
}
>
{verdict}
</button>
))}
</div>
{item.requireReason === true ? (
<textarea
className="pit-textarea"
aria-label={`Reason for ${item.title}`}
disabled={busy || item.lockedVerdict != null}
value={reasons[item.id] ?? ""}
onChange={(event) =>
setReasons((current) => ({ ...current, [item.id]: event.target.value }))
}
/>
) : null}
</div>
))
: null}
{card.payload.kind === "confirmation" && card.payload.requireRejectReason ? (
<div className="pit-field">
<label className="pit-field-label" htmlFor={`${titleId}-reject-reason`}>
Reason (required to request changes)
</label>
<textarea
className="pit-textarea"
id={`${titleId}-reject-reason`}
disabled={busy}
value={rejectReason}
onChange={(event) => setRejectReason(event.target.value)}
/>
</div>
) : null}
{error !== null ? (
<p className="pit-composer-reason" role="alert">
{error}
</p>
) : null}
<div className="pit-button-row">
{card.payload.kind === "confirmation" ? (
<>
<button
type="button"
className="pit-button"
data-variant="primary"
disabled={busy}
ref={(node) => {
if (autoFocus === true && node !== null && firstControl.current === null) {
firstControl.current = node;
node.focus();
}
}}
onClick={() =>
respond({
interactionId: card.interactionId,
outcome: "accepted",
result: { accepted: true },
})
}
>
{card.payload.acceptLabel}
</button>
<button
type="button"
className="pit-button"
data-variant="destructive"
disabled={busy}
onClick={() => {
if (
card.payload.kind === "confirmation" &&
card.payload.requireRejectReason &&
rejectReason.trim().length === 0
) {
setError("A reason is required to request changes.");
return;
}
respond({
interactionId: card.interactionId,
outcome: "rejected",
result: { accepted: false, reason: rejectReason },
});
}}
>
{card.payload.rejectLabel}
</button>
</>
) : null}
{card.payload.kind === "questions" ? (
<button
type="button"
className="pit-button"
data-variant="primary"
disabled={busy}
onClick={() => {
const missing =
card.payload.kind === "questions"
? card.payload.questions.filter(
(question) =>
question.required === true &&
(answers[question.id] ?? "").trim().length === 0,
)
: [];
if (missing.length > 0) {
setError(`Answer every required question (${missing.length} remaining).`);
return;
}
respond({
interactionId: card.interactionId,
outcome: "answered",
result: { answers },
});
}}
>
{card.payload.submitLabel}
</button>
) : null}
{card.payload.kind === "checkbox" ? (
<>
<button
type="button"
className="pit-button"
data-variant="primary"
disabled={busy}
onClick={() => {
if (card.payload.kind !== "checkbox") return;
if (selected.length < card.payload.minSelected) {
setError(`Select at least ${card.payload.minSelected}.`);
return;
}
if (selected.length > card.payload.maxSelected) {
setError(`Select at most ${card.payload.maxSelected}.`);
return;
}
respond({
interactionId: card.interactionId,
outcome: "accepted",
result: { selected },
});
}}
>
{card.payload.acceptLabel}
</button>
<button
type="button"
className="pit-button"
data-variant="destructive"
disabled={busy}
onClick={() =>
respond({
interactionId: card.interactionId,
outcome: "rejected",
result: { selected: [] },
})
}
>
{card.payload.rejectLabel}
</button>
</>
) : null}
{card.payload.kind === "suggest_tasks" ? (
<button
type="button"
className="pit-button"
data-variant="primary"
disabled={busy}
onClick={() =>
respond({
interactionId: card.interactionId,
outcome: "accepted",
result: { acceptedTaskIds: selected },
})
}
>
{card.payload.acceptLabel}
</button>
) : null}
{card.payload.kind === "item_verdicts" ? (
<button
type="button"
className="pit-button"
data-variant="primary"
disabled={busy}
onClick={() => {
if (card.payload.kind !== "item_verdicts") return;
const submitted = card.payload.items.filter(
(item) => item.lockedVerdict == null && verdicts[item.id] !== undefined,
);
const missingReason = submitted.filter(
(item) =>
item.requireReason === true && (reasons[item.id] ?? "").trim().length === 0,
);
if (missingReason.length > 0) {
setError(`A reason is required for ${missingReason.length} item(s).`);
return;
}
if (submitted.length === 0) {
setError("Choose a verdict for at least one item.");
return;
}
respond({
interactionId: card.interactionId,
outcome: "answered",
result: {
verdicts: submitted.map((item) => ({
id: item.id,
verdict: verdicts[item.id],
reason: reasons[item.id] ?? null,
})),
},
});
}}
>
{card.payload.submitLabel}
</button>
) : null}
</div>
</div>
)}
<button
type="button"
className="pit-link-button"
onClick={() => onOpenEvidence?.(card.evidenceRef.section, card.evidenceRef.recordId)}
>
View request evidence
</button>
</section>
);
}
@@ -0,0 +1,213 @@
import { useEffect, useRef, useState } from "react";
import type { CapabilityIssueThreadSnapshot } from "../../../src/issue-thread/types";
import { Icon } from "./Icons";
import { Chip, PriorityIcon, StatusBadge } from "./primitives";
/**
* Sticky issue header (contract §1 and §2). The three identity chips are never
* hidden by scroll, mode is data rather than styling, and on mobile Stop stays
* outside the overflow menu whenever a turn is active.
*/
export interface IssueHeaderProps {
snapshot: CapabilityIssueThreadSnapshot;
scenarios: string[];
/** `chat` is the clean room: no preset scenario, no recording to replay. */
surface?: "issue" | "chat";
/** Immutable eval recordings use the same inspector without live controls. */
readOnly?: boolean;
/** Clean-room tenant token, rendered so identity rotation is visible. */
cleanRoomToken?: string | null;
evidenceOpen: boolean;
denialCount: number;
segment: "thread" | "evidence";
onToggleEvidence: () => void;
onSelectScenario: (scenario: string) => void;
onReplay: () => void;
onReset: () => void;
onStop: () => void;
onSelectSegment: (segment: "thread" | "evidence") => void;
}
export function IssueHeader(props: IssueHeaderProps) {
const {
snapshot,
scenarios,
surface = "issue",
readOnly = false,
cleanRoomToken = null,
evidenceOpen,
denialCount,
segment,
onToggleEvidence,
onSelectScenario,
onReplay,
onReset,
onStop,
onSelectSegment,
} = props;
const chat = surface === "chat";
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const turnActive = snapshot.composer.state === "streaming" || snapshot.composer.state === "sending";
useEffect(() => {
if (!menuOpen) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setMenuOpen(false);
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [menuOpen]);
return (
<header className="pit-header" data-session-mode={snapshot.mode}>
<div className="pit-header-primary">
<div className="pit-header-title-row">
<span className="pit-identifier">{snapshot.issue.identifier}</span>
<h1 className="pit-title">{snapshot.issue.title}</h1>
<StatusBadge status={snapshot.issue.status} />
</div>
<div className="pit-header-controls">
{!chat && !readOnly ? (
<>
<label className="pit-visually-hidden" htmlFor="scenario-picker">Scenario</label>
<select className="pit-select pit-desktop-only" id="scenario-picker" value={snapshot.issue.fixtureProfile} onChange={(event) => onSelectScenario(event.target.value)} data-testid="scenario-picker">
{scenarios.map((scenario) => <option key={scenario} value={scenario}>{scenario}</option>)}
</select>
<button type="button" className="pit-button pit-desktop-only" onClick={onReplay} data-testid="replay-button"><Icon name="play" /> Replay</button>
</>
) : null}
{!readOnly ? <button type="button" className="pit-icon-button pit-desktop-only" data-variant="destructive" onClick={onReset} data-testid="reset-button" title={chat ? "Reset chat" : "Reset scenario"} aria-label={chat ? "Reset chat" : "Reset scenario"}><Icon name="reset" /></button> : null}
{!readOnly ? <button type="button" className="pit-icon-button" data-variant="destructive" onClick={onStop} disabled={!turnActive} data-testid="stop-button" title="Stop turn" aria-label="Stop turn"><Icon name="stop" /></button> : null}
<button type="button" className="pit-button pit-desktop-only" aria-expanded={evidenceOpen} onClick={onToggleEvidence} data-testid="evidence-toggle"><Icon name="evidence" /> DevTools{denialCount > 0 ? ` (${denialCount})` : ""}</button>
</div>
</div>
<div className="pit-header-context">
<div className="pit-chip-row" data-testid="identity-chips">
<Chip tone={snapshot.mode === "live" ? "live" : undefined} testId="agent-chip">
{snapshot.identity.agentLabel}
{snapshot.identity.replaySource !== null
? ` · ${snapshot.identity.replaySource} source`
: ""}
</Chip>
<Chip testId="runner-chip">
<span
className="pit-process-dot"
data-attached={snapshot.identity.runnerAttached}
aria-hidden="true"
/>
{snapshot.identity.runnerLabel}
<span className="pit-visually-hidden">
{snapshot.identity.runnerAttached ? "attached" : "detached"}
</span>
</Chip>
<Chip tone="mock" title={snapshot.identity.controlPlaneTooltip} testId="control-plane-chip">
{snapshot.identity.controlPlaneLabel}
</Chip>
{chat && cleanRoomToken !== null ? (
<Chip tone="accent" testId="clean-room-identity">
Clean room {cleanRoomToken}
</Chip>
) : null}
</div>
<PriorityIcon priority={snapshot.issue.priority} />
{snapshot.issue.assignee !== null ? <span className="pit-assignee">{snapshot.issue.assignee}</span> : null}
<span className="pit-run-state" title={snapshot.issue.runState}>{snapshot.issue.runState}</span>
<div className="pit-menu pit-mobile-only" ref={menuRef}>
<button
type="button"
className="pit-button"
aria-expanded={menuOpen}
aria-haspopup="menu"
onClick={() => setMenuOpen((current) => !current)}
data-testid="overflow-menu-button"
>
<span aria-hidden="true">⋯</span>
<span className="pit-visually-hidden">More actions</span>
</button>
{menuOpen ? (
<div className="pit-menu-list" role="menu" data-testid="overflow-menu">
{chat ? null : (
<>
<label className="pit-menu-item" htmlFor="scenario-picker-mobile">
Scenario
<select
className="pit-select"
id="scenario-picker-mobile"
value={snapshot.issue.fixtureProfile}
onChange={(event) => {
setMenuOpen(false);
onSelectScenario(event.target.value);
}}
>
{scenarios.map((scenario) => (
<option key={scenario} value={scenario}>
{scenario}
</option>
))}
</select>
</label>
<button
type="button"
role="menuitem"
className="pit-menu-item"
onClick={() => {
setMenuOpen(false);
onReplay();
}}
>
Replay
</button>
</>
)}
<button
type="button"
role="menuitem"
className="pit-menu-item"
onClick={() => {
setMenuOpen(false);
onReset();
}}
>
{chat ? "Reset chat" : "Reset scenario"}
</button>
</div>
) : null}
</div>
</div>
<div className="pit-segmented" role="tablist" aria-label="Thread or evidence">
<button
type="button"
role="tab"
className="pit-segment"
aria-selected={segment === "thread"}
onClick={() => onSelectSegment("thread")}
data-testid="segment-thread"
>
Thread
</button>
<button
type="button"
role="tab"
className="pit-segment"
aria-selected={segment === "evidence"}
onClick={() => onSelectSegment("evidence")}
data-testid="segment-evidence"
>
Evidence
{denialCount > 0 ? (
<span className="pit-segment-badge" data-testid="segment-denial-badge">
{denialCount}
</span>
) : null}
</button>
</div>
</header>
);
}
@@ -0,0 +1,57 @@
import type { CapabilitySurface } from "./route";
import { Icon } from "./Icons";
/**
* Top-level surface switch (Capability plan revision 5).
*
* Capability now has two primary paths, not one path with a mode toggle: the
* preset scenario explorer and a clean-room chat that starts from nothing. They
* are different jobs, so they get links rather than tabs — the clean-room entry
* has to be reachable from the landing page without first choosing a scenario.
*
* These are real anchors on purpose. The entry must survive a hard refresh, be
* copyable, and open in a new tab, which a button handler would not give.
*/
export interface CapabilityChatHistoryItem {
sessionId: string;
identifier: string;
title: string;
updatedAt: string;
current: boolean;
}
export function SurfaceNav({ surface, history = [], activeSessionId = null, onNewChat, onSelectHistory }: {
surface: CapabilitySurface;
history?: CapabilityChatHistoryItem[];
activeSessionId?: string | null;
onNewChat?: () => void;
onSelectHistory?: (sessionId: string) => void;
}) {
return (
<aside className="pit-surface-nav" aria-label="Runner navigation" data-testid="surface-nav">
<div className="pit-sidebar-brand"><Icon name="activity" /><span>Runner lab</span></div>
<nav className="pit-sidebar-primary" aria-label="Capability surfaces">
<a className="pit-surface-link" href="#/issue/hb-baseline" aria-current={surface === "issue" ? "page" : undefined} data-testid="surface-issue-link">
<Icon name="timeline" /><span>Scenario explorer</span>
</a>
<button type="button" className="pit-surface-link" onClick={onNewChat} disabled={onNewChat === undefined} data-testid="surface-chat-link">
<Icon name="spark" /><span>New chat</span>
</button>
</nav>
{surface === "chat" ? (
<section className="pit-session-history" aria-labelledby="session-history-title">
<h2 id="session-history-title">Session history</h2>
<div className="pit-session-history-list">
{history.map((item) => (
<button type="button" key={item.sessionId} className="pit-session-history-item" data-selected={activeSessionId === item.sessionId} onClick={() => onSelectHistory?.(item.sessionId)}>
<span className="pit-session-history-title">{item.title}</span>
<span>{item.identifier}{item.current ? " · current" : ""}</span>
</button>
))}
{history.length === 0 ? <p className="pit-muted">No chats yet.</p> : null}
</div>
</section>
) : null}
</aside>
);
}
@@ -0,0 +1,616 @@
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type {
CapabilityEvidenceSectionId,
CapabilityThreadItem,
CapabilityThreadTurn,
} from "../../../src/issue-thread/types";
import { InteractionCard, type CapabilityInteractionResponse } from "./InteractionCard";
import { Chip, StatusBadge, Timestamp, formatBytes } from "./primitives";
export interface EvalAssertion {
id: string;
title: string;
description: string;
passed: boolean;
detail: string;
definition: Record<string, unknown>;
}
export function EvalAssertions({ assertions }: { assertions: EvalAssertion[] }) {
const [selected, setSelected] = useState<EvalAssertion | null>(null);
if (assertions.length === 0) return null;
return (
<>
<div className="pit-inline-assertions" aria-label="Eval assertions">
{assertions.map((assertion) => (
<button type="button" key={assertion.id} data-passed={assertion.passed} onClick={() => setSelected(assertion)}>
<strong>{assertion.passed ? "✓ PASS" : "✕ FAIL"} · {assertion.title}</strong>
<span>{assertion.detail}</span>
</button>
))}
</div>
{selected !== null ? (
<div className="pit-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setSelected(null); }}>
<div className="pit-dialog pit-eval-check-dialog" role="dialog" aria-modal="true" aria-labelledby="eval-check-title">
<header className="pit-tool-dialog-head"><div><span className="pit-card-meta">Eval assertion · {selected.id}</span><h2 id="eval-check-title">{selected.title}</h2></div><button type="button" className="pit-icon-button" aria-label="Close assertion" onClick={() => setSelected(null)}>×</button></header>
<p>{selected.description}</p>
<p className="pit-card-meta">Observed result</p><p>{selected.detail}</p>
<p className="pit-card-meta">Original case definition</p><pre className="pit-code">{JSON.stringify(selected.definition, null, 2)}</pre>
</div>
</div>
) : null}
</>
);
}
/** Progressive disclosure budget from contract §3 (T4). */
const VISIBLE_STRIPS = 3;
const STATUS_GLYPH = { ok: "✓", denied: "✕", running: "⏳" } as const;
function MarkdownBody({ children }: { children: string }) {
return (
<div className="pit-card-body pit-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
</div>
);
}
export interface ThreadCallbacks {
onOpenEvidence: (section: CapabilityEvidenceSectionId, recordId: string) => void;
onRespond: (response: CapabilityInteractionResponse) => void;
focusInteractionId: string | null;
}
function ToolStrip({
item,
callbacks,
}: {
item: Extract<CapabilityThreadItem, { kind: "tool_activity" }>;
callbacks: ThreadCallbacks;
}) {
return (
<details
id={item.id}
className="pit-activity-item"
data-thread-item="tool_activity"
data-status={item.status}
data-tool-strip={item.operationId}
>
<summary className="pit-activity-summary">
<span className="pit-activity-glyph" aria-hidden="true">
{STATUS_GLYPH[item.status]}
</span>
<span className="pit-visually-hidden">{item.status}</span>
<span className="pit-activity-operation">{item.operationId}</span>
<span className="pit-activity-description">{item.summary}</span>
<span className="pit-activity-caret" aria-hidden="true">›</span>
</summary>
<div className="pit-activity-detail">
<p className="pit-card-meta">Sanitized tool input and result</p>
<div className="pit-activity-payloads">
<pre className="pit-code">{JSON.stringify(item.input, null, 2)}</pre>
<pre className="pit-code">{JSON.stringify(item.result, null, 2)}</pre>
</div>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View in Evidence
</button>
</div>
</details>
);
}
function ProgressItem({
item,
}: {
item: Extract<CapabilityThreadItem, { kind: "progress_activity" }>;
}) {
return (
<details
id={item.id}
className="pit-activity-item pit-progress-activity"
data-thread-item="progress_activity"
data-activity={item.activity}
data-status={item.status}
open={item.status === "running"}
>
<summary className="pit-activity-summary">
<span className="pit-activity-glyph" aria-hidden="true">
{item.status === "running" ? "⏳" : "✓"}
</span>
<span className="pit-activity-operation">{item.label}</span>
<span className="pit-activity-description">{item.summary}</span>
<span className="pit-activity-caret" aria-hidden="true">›</span>
</summary>
<div className="pit-activity-detail">
{Object.entries(item.details).map(([name, value]) =>
name === "withheld" ? null : (
<div className="pit-activity-field" key={name}>
<span>{name}</span>
<pre className="pit-code">{typeof value === "string" ? value : JSON.stringify(value, null, 2)}</pre>
</div>
),
)}
{Array.isArray(item.details.withheld) ? (
<p className="pit-withheld-note">
Not available here: {item.details.withheld.join(", ")}.
</p>
) : null}
</div>
</details>
);
}
function valueRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function ProviderActivity({ item, callbacks }: {
item: Extract<CapabilityThreadItem, { kind: "provider_activity" }>;
callbacks: ThreadCallbacks;
}) {
const payload = valueRecord(item.payload);
const steps = Array.isArray(payload.steps) ? payload.steps.map(valueRecord) : [];
const children = Array.isArray(payload.children) ? payload.children.map(valueRecord) : [];
const sources = Array.isArray(payload.sources) ? payload.sources.map(valueRecord) : [];
const output = typeof payload.output === "string" ? payload.output : "";
const glyph = item.status === "running" ? "⏳" : item.status === "failed" ? "✕" : item.status === "interrupted" ? "■" : "✓";
return (
<details id={item.id} className="pit-activity-item pit-provider-activity" data-thread-item="provider_activity" data-family={item.family} data-status={item.status} open={item.status === "running"}>
<summary className="pit-activity-summary"><span className="pit-activity-glyph" aria-hidden="true">{glyph}</span><span className="pit-activity-operation">{item.title}</span><span className="pit-activity-description">{item.summary}</span><span className="pit-activity-caret" aria-hidden="true">›</span></summary>
<div className="pit-activity-detail">
{steps.length > 0 ? <ol className="pit-provider-plan">{steps.map((step, index) => <li key={String(step.stepId ?? index)} data-plan-status={String(step.status ?? "pending")}><span aria-hidden="true">{step.status === "completed" ? "✓" : step.status === "blocked" ? "!" : step.status === "in_progress" ? "◐" : "○"}</span>{String(step.body ?? "")}</li>)}</ol> : null}
{children.length > 0 ? <ul className="pit-provider-tree">{children.map((child, index) => <li key={String(child.childId ?? index)}><strong>{String(child.role ?? "Child agent")}</strong><span>{String(child.status ?? "unknown")}</span><small>{String(child.summary ?? "")}</small></li>)}</ul> : null}
{sources.length > 0 ? <ul className="pit-provider-sources">{sources.map((source, index) => { const url = typeof source.url === "string" && /^https?:\/\//.test(source.url) ? source.url : null; return <li key={String(source.sourceId ?? index)}>{url === null ? <span>{String(source.title ?? "Unavailable source")}</span> : <a href={url} target="_blank" rel="noreferrer">{String(source.title ?? url)}</a>}<small>Provider-reported</small></li>; })}</ul> : null}
{output.length > 0 ? <pre className="pit-provider-output" aria-label="Command output">{output}</pre> : null}
{item.family === "model_identity" ? <dl className="pit-provider-fields"><dt>Requested</dt><dd>{String(payload.requestedModel ?? "—")}</dd><dt>Effective</dt><dd>{String(payload.effectiveModel ?? "—")}</dd></dl> : null}
{item.family === "artifact" && typeof payload.reference === "string" ? <button type="button" className="pit-link-button">Open {payload.reference}</button> : null}
<button type="button" className="pit-link-button" onClick={() => callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)}>View in Evidence</button>
</div>
</details>
);
}
function FileDiff({ diff }: { diff: string | null }) {
if (diff === null) return <p className="pit-workspace-binary">Binary or oversized file; textual diff unavailable.</p>;
return (
<pre className="pit-workspace-diff" aria-label="Unified file diff">
{diff.split("\n").map((line, index) => (
<span
key={`${index}:${line}`}
data-diff-line={line.startsWith("+") && !line.startsWith("+++")
? "addition"
: line.startsWith("-") && !line.startsWith("---")
? "deletion"
: line.startsWith("@@")
? "hunk"
: "context"}
>{line || " "}{"\n"}</span>
))}
</pre>
);
}
function WorkspaceChanges({
item,
}: {
item: Extract<CapabilityThreadItem, { kind: "workspace_changes" }>;
}) {
const [expanded, setExpanded] = useState(false);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const files = item.changeSet.files;
const visible = expanded ? files : files.slice(0, 3);
const selected = files.find((file) => file.path === selectedPath) ?? null;
const stats = (file: (typeof files)[number]) => (
<span className="pit-workspace-stats">
{file.additions === null ? null : <span data-stat="add">+{file.additions}</span>}
{file.deletions === null ? null : <span data-stat="delete">-{file.deletions}</span>}
</span>
);
return (
<>
<article id={item.id} className="pit-workspace-card" data-thread-item="workspace_changes">
<header className="pit-workspace-head">
<span className="pit-workspace-icon" aria-hidden="true">▣</span>
<span>
<strong>{item.changeSet.complete ? "Edited" : "Editing"} {files.length} file{files.length === 1 ? "" : "s"}</strong>
<small>{item.changeSet.source === "runner_verified" ? "Verified from workspace" : "Reported by harness"}</small>
</span>
<span className="pit-workspace-total">
{item.changeSet.totals.additions === null ? null : <span data-stat="add">+{item.changeSet.totals.additions}</span>}
{item.changeSet.totals.deletions === null ? null : <span data-stat="delete">-{item.changeSet.totals.deletions}</span>}
</span>
<button type="button" className="pit-workspace-review" onClick={() => setSelectedPath(files[0]?.path ?? null)}>Review</button>
</header>
<div className="pit-workspace-files">
{visible.map((file) => (
<button type="button" key={`${file.operation}:${file.path}`} onClick={() => setSelectedPath(file.path)}>
<span className="pit-workspace-path">
{file.previousPath === null ? file.path : `${file.previousPath} → ${file.path}`}
<small>{file.operation.replace("_", " ")}</small>
</span>
{stats(file)}
</button>
))}
</div>
{files.length > 3 ? (
<button type="button" className="pit-workspace-more" onClick={() => setExpanded((value) => !value)}>
{expanded ? "Show fewer files" : `Show ${files.length - 3} more files`} <span aria-hidden="true">⌄</span>
</button>
) : null}
</article>
{selected !== null ? (
<div className="pit-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setSelectedPath(null); }}>
<div className="pit-dialog pit-workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="workspace-diff-title">
<header className="pit-tool-dialog-head">
<div>
<span className="pit-card-meta">{selected.operation.replace("_", " ")} · workspace diff</span>
<h2 id="workspace-diff-title">{selected.path}</h2>
</div>
{stats(selected)}
<button type="button" className="pit-icon-button" aria-label="Close diff" onClick={() => setSelectedPath(null)}>×</button>
</header>
{files.length > 1 ? (
<nav className="pit-workspace-file-nav" aria-label="Changed files">
{files.map((file) => <button type="button" key={file.path} data-selected={file.path === selected.path} onClick={() => setSelectedPath(file.path)}>{file.path}</button>)}
</nav>
) : null}
<FileDiff diff={selected.diff} />
</div>
</div>
) : null}
</>
);
}
function WorkspaceFileReference({
item,
}: {
item: Extract<CapabilityThreadItem, { kind: "workspace_file_reference" }>;
}) {
const [open, setOpen] = useState(false);
const extension = item.reference.path.includes(".")
? item.reference.path.split(".").at(-1)?.toUpperCase()
: "FILE";
const presentation = item.reference.presentation === "document"
? "Document"
: item.reference.presentation === "code"
? "Code"
: item.reference.presentation === "image"
? "Image"
: "File";
return (
<>
<article id={item.id} className="pit-file-reference-card" data-thread-item="workspace_file_reference">
<span className="pit-file-reference-icon" aria-hidden="true">▤</span>
<span className="pit-file-reference-copy">
<strong>{item.reference.displayName}</strong>
<small>{presentation} · {extension}{item.reference.line === null ? "" : ` · line ${item.reference.line}`}</small>
</span>
<button type="button" className="pit-workspace-review" onClick={() => setOpen(true)}>Open</button>
</article>
{open ? (
<div className="pit-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setOpen(false); }}>
<div className="pit-dialog pit-file-reference-dialog" role="dialog" aria-modal="true" aria-labelledby="workspace-file-title">
<header className="pit-tool-dialog-head">
<div><span className="pit-card-meta">{item.reference.path}</span><h2 id="workspace-file-title">{item.reference.displayName}</h2></div>
<button type="button" className="pit-icon-button" aria-label="Close file" onClick={() => setOpen(false)}>×</button>
</header>
<div className="pit-file-reference-preview">
{item.reference.preview === null
? <p className="pit-workspace-binary">Preview unavailable. The reference remains recorded with its normalized workspace path.</p>
: item.reference.presentation === "document"
? <MarkdownBody>{item.reference.preview}</MarkdownBody>
: <pre className="pit-code">{item.reference.preview}</pre>}
{item.reference.previewTruncated ? <p className="pit-withheld-note">Preview truncated by the runner.</p> : null}
</div>
</div>
</div>
) : null}
</>
);
}
function ThreadItemView({
item,
callbacks,
}: {
item: CapabilityThreadItem;
callbacks: ThreadCallbacks;
}) {
switch (item.kind) {
case "user_message":
return (
<article
id={item.id}
className="pit-message"
data-role="user"
data-thread-item="user_message"
>
<div className="pit-card-head">
<span className="pit-card-author">{item.author}</span>
<Timestamp value={item.at} />
</div>
<MarkdownBody>{item.body}</MarkdownBody>
</article>
);
case "agent_message":
return (
<article
id={item.id}
className="pit-message"
data-role="assistant"
data-thread-item="agent_message"
data-streaming={item.streaming}
>
<div className="pit-card-head">
<span className="pit-card-author">{item.author}</span>
<Timestamp value={item.at} />
{item.streaming ? (
<Chip tone="accent" testId="streaming-indicator">
<span aria-hidden="true">⏳</span>
Streaming
</Chip>
) : null}
</div>
<MarkdownBody>{item.body}</MarkdownBody>
</article>
);
case "durable_comment":
return (
<article id={item.id} className="pit-card" data-thread-item="durable_comment">
<div className="pit-card-head">
<span className="pit-card-author">{item.author}</span>
<Timestamp value={item.at} />
<span
className="pit-durable-tag"
title={`Recorded by the ${item.operationId} semantic operation.`}
>
<span aria-hidden="true">◆</span>
Recorded to mock thread
</span>
</div>
<div className="pit-card-body">{item.body}</div>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View in Evidence
</button>
</article>
);
case "tool_activity":
return <ToolStrip item={item} callbacks={callbacks} />;
case "progress_activity":
return <ProgressItem item={item} />;
case "workspace_changes":
return <WorkspaceChanges item={item} />;
case "workspace_file_reference":
return <WorkspaceFileReference item={item} />;
case "provider_activity":
return <ProviderActivity item={item} callbacks={callbacks} />;
case "denial":
return (
<div id={item.id} className="pit-strip pit-denial" data-status="denied" data-thread-item="denial">
<div className="pit-strip-button">
<span className="pit-strip-glyph" aria-hidden="true">
✕
</span>
<span className="pit-strip-operation">{item.operationId}</span>
<span className="pit-strip-summary" data-testid="denial-reason">
{item.reason}
</span>
</div>
<div className="pit-strip-detail">
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View in Evidence
</button>
</div>
</div>
);
case "interaction":
return (
<InteractionCard
card={item}
autoFocus={callbacks.focusInteractionId === item.interactionId}
onRespond={callbacks.onRespond}
onOpenEvidence={(section, recordId) =>
callbacks.onOpenEvidence(section as CapabilityEvidenceSectionId, recordId)
}
/>
);
case "document":
return (
<article id={item.id} className="pit-card" data-thread-item="document">
<div className="pit-card-head">
<span className="pit-card-author">{item.title}</span>
<span className="pit-card-meta">{item.documentKey}</span>
<Timestamp value={item.at} />
</div>
<div className="pit-card-body">
<span className="pit-card-meta" data-testid="revision-chain">
{item.revisionFrom === null ? `r${item.revisionTo}` : `r${item.revisionFrom} → r${item.revisionTo}`}
</span>
{" · "}
{item.author}
{item.staleBehind !== null ? (
<>
{" "}
<Chip>
<span aria-hidden="true">⌛</span>
Stale — {item.staleBehind} newer revision(s)
</Chip>
</>
) : null}
</div>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View diff
</button>
</article>
);
case "deliverable":
return (
<article id={item.id} className="pit-card" data-thread-item="deliverable">
<div className="pit-card-head">
<span className="pit-card-author">{item.filename}</span>
<Timestamp value={item.at} />
</div>
<div className="pit-card-body">
<span className="pit-card-meta">
{item.deliverableKind} · {formatBytes(item.byteSize)} · registered by {item.registeredBy}
</span>
</div>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
Download {item.filename}
</button>
</article>
);
case "dependency":
return (
<article id={item.id} className="pit-card" data-thread-item="dependency">
<div className="pit-card-head">
<span className="pit-card-author">Delegation</span>
<Timestamp value={item.at} />
</div>
<ul className="pit-summary-list">
{item.createdTasks.map((task) => (
<li key={task.identifier}>
<span className="pit-record-title">{task.identifier}</span> {task.title}
</li>
))}
{item.blockerEdges.map((edge) => (
<li key={edge}>{edge}</li>
))}
</ul>
</article>
);
case "disposition":
return (
<article id={item.id} className="pit-card pit-terminal-card" data-thread-item="disposition">
<div className="pit-card-head">
<StatusBadge status={item.status} />
<span className="pit-card-meta">{item.operationId}</span>
<Timestamp value={item.at} />
</div>
<div className="pit-card-body">{item.body}</div>
{item.blockerOwner !== null ? (
<p className="pit-card-meta">Blocker owner: {item.blockerOwner}</p>
) : null}
</article>
);
case "system_notice":
return (
<p id={item.id} className="pit-notice" data-thread-item="system_notice">
<span aria-hidden="true">{item.glyph}</span>
<span className="pit-notice-text">{item.text}</span>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
Details
</button>
</p>
);
}
}
export function TurnGroup({
turn,
callbacks,
assertions = {},
terminalAssertions = [],
}: {
turn: CapabilityThreadTurn;
callbacks: ThreadCallbacks;
assertions?: Record<string, EvalAssertion[]>;
terminalAssertions?: EvalAssertion[];
}) {
const [showAllStrips, setShowAllStrips] = useState(false);
const stripIndexes = turn.items
.map((item, index) => (item.kind === "tool_activity" ? index : -1))
.filter((index) => index >= 0);
const hiddenStripIndexes = new Set(
showAllStrips ? [] : stripIndexes.slice(VISIBLE_STRIPS),
);
return (
<section className="pit-turn" data-turn-id={turn.id} aria-label={`Turn ${turn.ordinal}`}>
<h2 className="pit-turn-header">
<span className="pit-turn-label">
Turn {turn.ordinal} · {turn.mode} · {turn.toolCallCount} tool call
{turn.toolCallCount === 1 ? "" : "s"} ·{" "}
{new Date(turn.at).toISOString().slice(11, 19)}
</span>
{turn.stoppedByUser ? (
<span className="pit-stopped-marker" data-testid="stopped-marker">
Stopped by user
</span>
) : null}
</h2>
{turn.items.map((item, index) => hiddenStripIndexes.has(index) ? null : (
<div className="pit-eval-item" key={item.id}>
<ThreadItemView item={item} callbacks={callbacks} />
<EvalAssertions assertions={assertions[item.id] ?? []} />
</div>
))}
{hiddenStripIndexes.size > 0 ? (
<button
type="button"
className="pit-more-button"
onClick={() => setShowAllStrips(true)}
>
{hiddenStripIndexes.size} more…
</button>
) : null}
<EvalAssertions assertions={terminalAssertions} />
</section>
);
}
@@ -0,0 +1,82 @@
import type {
CapabilityIssueThreadSnapshot,
CapabilityThreadInteractionState,
CapabilityThreadItem,
} from "../../../src/issue-thread/types";
import type { CapabilityInteractionResponse } from "./InteractionCard";
/**
* `fake`-mode interaction store.
*
* The screenshot matrix runs without a session server, so this module plays
* the mock control plane's role for the deterministic fixtures only: it stores
* the response and then hands back a new snapshot, mirroring the live authority
* path (store first, resume second) rather than resolving the card optimistically
* in the card component. Live mode never reaches this code — it posts to the
* package server instead.
*/
function resolvedState(
outcome: CapabilityInteractionResponse["outcome"],
): { state: CapabilityThreadInteractionState; label: string } {
if (outcome === "accepted") return { state: "accepted", label: "Accepted" };
if (outcome === "rejected") return { state: "rejected", label: "Changes requested" };
return { state: "answered", label: "Answered" };
}
function summarise(response: CapabilityInteractionResponse): string[] {
const result = response.result;
if (Array.isArray(result.selected)) return result.selected.map((entry) => String(entry));
if (Array.isArray(result.acceptedTaskIds)) {
return result.acceptedTaskIds.map((entry) => `Accepted ${String(entry)}`);
}
if (Array.isArray(result.verdicts)) {
return result.verdicts.map((entry) => {
const verdict = entry as { id?: unknown; verdict?: unknown };
return `${String(verdict.id)} — ${String(verdict.verdict)}`;
});
}
if (typeof result.answers === "object" && result.answers !== null) {
return Object.entries(result.answers as Record<string, unknown>).map(
([key, value]) => `${key} — ${String(value)}`,
);
}
if (result.accepted === true) return ["Accepted"];
return [];
}
function applyToItem(
item: CapabilityThreadItem,
response: CapabilityInteractionResponse,
): CapabilityThreadItem {
if (item.kind !== "interaction" || item.interactionId !== response.interactionId) return item;
const view = resolvedState(response.outcome);
return {
...item,
state: view.state,
stateLabel: view.label,
resolvedSummary: summarise(response),
reason: typeof response.result.reason === "string" ? response.result.reason : null,
};
}
/** Store the typed response, then return the snapshot the card renders from. */
export function applyFakeInteractionResponse(
snapshot: CapabilityIssueThreadSnapshot,
response: CapabilityInteractionResponse,
): CapabilityIssueThreadSnapshot {
const turns = snapshot.turns.map((turn) => ({
...turn,
items: turn.items.map((item) => applyToItem(item, response)),
}));
const stillPending = turns.some(
(turn) => turn.items.some((item) => item.kind === "interaction" && item.state === "pending"),
);
return {
...snapshot,
turns,
composer: stillPending
? snapshot.composer
: { state: "ready", helper: null, reason: null, pendingInteractionId: null },
};
}
@@ -0,0 +1,78 @@
# Capability issue-thread fonts
The Capability issue-thread bundle includes Latin-subset WOFF2 files so captures
do not depend on fonts installed on the host.
## Inter
- Upstream: https://github.com/rsms/inter
- Version: 4.1
- Source: `ui/public/fonts/InterVariable.woff2`
- License: SIL Open Font License 1.1
- License text: https://github.com/rsms/inter/blob/v4.1/LICENSE.txt
The bundled file is subset from the repository's unmodified upstream Inter 4.1
variable WOFF2. CSS exposes weights 400 through 700.
## DejaVu Sans Mono
- Upstream: https://dejavu-fonts.github.io/
- Version: 2.37
- Source faces: DejaVu Sans Mono Book and Bold; DejaVu Sans Book for status glyphs
- License: Bitstream Vera Fonts license; DejaVu changes are public domain
- License text: https://dejavu-fonts.github.io/License.html
The bundled files are Latin subsets of the Book and Bold faces. The source font
copyright and permission notice follow.
> Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a
> trademark of Bitstream, Inc.
>
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of the fonts accompanying this license ("Fonts") and associated documentation
> files (the "Font Software"), to reproduce and distribute the Font Software,
> including without limitation the rights to use, copy, merge, publish,
> distribute, and/or sell copies of the Font Software, and to permit persons to
> whom the Font Software is furnished to do so, subject to the following
> conditions:
>
> The above copyright and trademark notices and this permission notice shall be
> included in all copies of one or more of the Font Software typefaces.
>
> The Font Software may be modified, altered, or added to, and in particular the
> designs of glyphs or characters in the Fonts may be modified and additional
> glyphs or characters may be added to the Fonts, only if the fonts are renamed
> to names not containing either the words "Bitstream" or the word "Vera".
>
> This License becomes null and void to the extent applicable to Fonts or Font
> Software that has been modified and is distributed under the "Bitstream Vera"
> names.
>
> The Font Software may be sold as part of a larger software package but no copy
> of one or more of the Font Software typefaces may be sold by itself.
>
> THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
> OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
> TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION
> BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
> SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION
> OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO
> USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
>
> Except as contained in this notice, the names of Gnome, the Gnome Foundation,
> and Bitstream Inc., shall not be used in advertising or otherwise to promote
> the sale, use or other dealings in the Font Software without prior written
> authorization from the Gnome Foundation or Bitstream Inc., respectively.
## Noto Sans Symbols 2
- Upstream: https://github.com/notofonts/symbols
- Version: 2.003
- Source face: Noto Sans Symbols 2 Regular
- License: SIL Open Font License 1.1
- License text: https://github.com/notofonts/symbols/blob/main/OFL.txt
The 1.2 kB bundled subset contains only the two hourglass glyphs that are not
present in Inter or DejaVu Sans. It is exposed under the same package-specific
symbol fallback family using a disjoint `unicode-range`.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,260 @@
import type { CapabilityIssueThreadSnapshot } from "../../../src/issue-thread/types";
import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools";
import type { CapabilityJsonValue } from "../../../src/mock-core/capability-control-plane-types";
import {
CAPABILITY_TURN_STREAM_ACCEPT,
CapabilityTurnStreamError,
readCapabilityTurnStream,
} from "../../../src/live/turn-stream";
/**
* Browser client for the package session server.
*
* Every response is a server-projected `CapabilityIssueThreadSnapshot`. The client
* posts intents and renders what comes back; it never patches the snapshot
* locally, which is what keeps policy and state authority on the server
* (contract §11).
*
* A turn is the one route that answers with many of those projections instead
* of one: `send` consumes NDJSON frames as the provider produces them, hands
* each interim view to `onFrame`, and resolves with the settled payload. The
* authority rule is unchanged — every frame is still a server projection, and
* the settled one is final.
*/
const BASE = "/api/capability/ui";
/**
* Every route is session-scoped and mediated by the per-browser capability
* cookie the server sets, so the cookie has to ride along. Stated rather than
* left to the default so a future change to this client cannot silently drop
* the capability and fall back to session-id-only access (track 7U).
*/
const CREDENTIALS = "same-origin" as const;
export interface CapabilityCleanRoomIdentity {
token: string;
sequence: number;
companyId: string;
actorId: string;
taskId: string;
identifier: string;
}
export interface CapabilityHarnessConfiguration {
provider: "codex" | "opencode" | "acpx";
model: string | null;
acpxAgent?: "claude" | "codex";
lifecyclePolicy:
| { mode: "per_turn"; idleTimeoutMs: null }
| { mode: "warm"; idleTimeoutMs: number };
}
export interface CapabilityLiveResponse {
sessionId: string;
view: CapabilityIssueThreadSnapshot;
surface?: "issue" | "cleanroom";
/** Present on the clean-room surface so the UI can show which tenant is live. */
identity?: CapabilityCleanRoomIdentity;
limits?: { maxTurns: number; maxMessageBytes: number };
turns?: number;
configuration?: CapabilityHarnessConfiguration;
runtime?: {
providerSessionId: string | null;
driverSessionId?: string | null;
runnerPid: number | null;
providerPid: number | null;
sidecarPid?: number | null;
agentPid?: number | null;
providerVersion?: string | null;
agentServerVersion?: string | null;
agentRuntimeVersion?: string | null;
acpProtocolVersion?: number | null;
executionKind: "local_process" | "remote_service";
status: string;
};
}
export interface CapabilityToolTestResponse extends CapabilityLiveResponse {
toolResult: CapabilityJsonValue;
toolTurnId: string;
}
/**
* The server names its own failures. Surfacing the code lets the clean room say
* "this chat hit its turn limit" instead of "500", and — more importantly —
* lets a failure to start real Codex read as a failure rather than quietly
* degrading into a fixture.
*/
export class CapabilityLiveError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
this.name = "CapabilityLiveError";
}
}
async function readError(response: Response, path: string): Promise<CapabilityLiveError> {
try {
const body = (await response.json()) as { error?: string; message?: string };
return new CapabilityLiveError(
body.error ?? `http_${response.status}`,
body.message ?? `${path} failed with ${response.status}`,
);
} catch {
return new CapabilityLiveError(`http_${response.status}`, `${path} failed with ${response.status}`);
}
}
async function post<T = CapabilityLiveResponse>(path: string, body: unknown): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
credentials: CREDENTIALS,
});
if (!response.ok) throw await readError(response, path);
return (await response.json()) as T;
}
/** Interim projection delivered while the turn is still running. */
export type CapabilityTurnFrameHandler = (view: CapabilityIssueThreadSnapshot) => void;
export interface CapabilitySendOptions {
onFrame?: CapabilityTurnFrameHandler;
/** Abort the turn stream — used when the surface is torn down or rotated. */
signal?: AbortSignal;
}
async function postTurnStream(
path: string,
body: unknown,
options: CapabilitySendOptions,
): Promise<CapabilityLiveResponse> {
const response = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { "content-type": "application/json", accept: CAPABILITY_TURN_STREAM_ACCEPT },
body: JSON.stringify(body),
credentials: CREDENTIALS,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
// Admission failures still answer with a JSON body and a real status code,
// because they are decided before the first frame is written.
if (!response.ok) throw await readError(response, path);
try {
return await readCapabilityTurnStream<CapabilityIssueThreadSnapshot, CapabilityLiveResponse>(
response,
(frame) => options.onFrame?.(frame.view),
);
} catch (cause) {
if (cause instanceof CapabilityTurnStreamError) {
throw new CapabilityLiveError(cause.code, cause.message);
}
throw cause;
}
}
export const capabilityLiveClient = {
async devtools(sessionId: string): Promise<CapabilityDevtoolsSnapshot> {
const response = await fetch(`${BASE}/devtools?sessionId=${encodeURIComponent(sessionId)}`, {
credentials: CREDENTIALS,
});
if (!response.ok) throw await readError(response, "/devtools");
return (await response.json()) as CapabilityDevtoolsSnapshot;
},
fork(sessionId: string, revision: number): Promise<CapabilityLiveResponse> {
return post("/devtools/fork", { sessionId, revision });
},
invokeTool(
sessionId: string,
operationId: string,
input: CapabilityJsonValue,
): Promise<CapabilityToolTestResponse> {
return post("/tool", { sessionId, operationId, input }) as Promise<CapabilityToolTestResponse>;
},
async load(sessionId: string | null): Promise<CapabilityLiveResponse> {
const query = sessionId === null ? "" : `?sessionId=${encodeURIComponent(sessionId)}`;
const response = await fetch(`${BASE}/session${query}`, { credentials: CREDENTIALS });
if (!response.ok) throw await readError(response, "/session");
return (await response.json()) as CapabilityLiveResponse;
},
create(scenario: string): Promise<CapabilityLiveResponse> {
return post("/session", { scenario });
},
/** Reconnect to a clean room, or open one when there is nothing to resume. */
async loadCleanRoom(sessionId: string | null, configuration: CapabilityHarnessConfiguration): Promise<CapabilityLiveResponse> {
const query = new URLSearchParams({ provider: configuration.provider });
if (sessionId !== null) query.set("sessionId", sessionId);
if (configuration.model !== null) query.set("model", configuration.model);
if (configuration.acpxAgent) query.set("acpxAgent", configuration.acpxAgent);
query.set("lifecycleMode", configuration.lifecyclePolicy.mode);
if (configuration.lifecyclePolicy.mode === "warm") {
query.set("idleTimeoutMs", String(configuration.lifecyclePolicy.idleTimeoutMs));
}
const response = await fetch(`${BASE}/cleanroom/session?${query.toString()}`, { credentials: CREDENTIALS });
if (!response.ok) throw await readError(response, "/cleanroom/session");
return (await response.json()) as CapabilityLiveResponse;
},
/** `New chat`: retire the current room and mint a new mock tenant. */
newCleanRoom(sessionId: string | null, configuration: CapabilityHarnessConfiguration): Promise<CapabilityLiveResponse> {
return post("/cleanroom/session", { ...(sessionId === null ? {} : { sessionId }), ...configuration });
},
/**
* Runs one turn. `onFrame` fires for every interim projection the server
* writes while the POST is open; the resolved value is the settled payload.
*/
send(
sessionId: string,
message: string,
options: CapabilitySendOptions = {},
): Promise<CapabilityLiveResponse> {
return postTurnStream("/message", { sessionId, message }, options);
},
stop(sessionId: string): Promise<CapabilityLiveResponse> {
return post("/interrupt", { sessionId });
},
reset(sessionId: string): Promise<CapabilityLiveResponse> {
return post("/reset", { sessionId });
},
reconnect(sessionId: string): Promise<CapabilityLiveResponse> {
return post("/reconnect", { sessionId });
},
respond(
sessionId: string,
interactionId: string,
outcome: string,
result: unknown,
): Promise<CapabilityLiveResponse> {
return post("/interaction", { sessionId, interactionId, outcome, result });
},
};
const SESSION_STORAGE_KEY = "paperclip-runner.capability.session";
/**
* The clean room keeps its own key. Sharing one would let a scenario session id
* be handed to the clean-room route (and the reverse) after a refresh, which is
* exactly the cross-surface bleed the isolation criterion forbids.
*/
const CLEAN_ROOM_STORAGE_KEY = "paperclip-runner.capability.cleanroom.session";
function storageKey(surface: "issue" | "cleanroom"): string {
return surface === "cleanroom" ? CLEAN_ROOM_STORAGE_KEY : SESSION_STORAGE_KEY;
}
export function rememberSession(sessionId: string, surface: "issue" | "cleanroom" = "issue"): void {
try {
window.localStorage.setItem(storageKey(surface), sessionId);
} catch {
// Refresh restore falls back to a fresh session when storage is blocked.
}
}
export function recallSession(surface: "issue" | "cleanroom" = "issue"): string | null {
try {
return window.localStorage.getItem(storageKey(surface));
} catch {
return null;
}
}
@@ -0,0 +1,24 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./issue-thread.css";
// `?capture=1` freezes animation, caret, and smooth scrolling so the
// screenshot matrix is byte-stable across runs (contract §10.1).
const params = new URLSearchParams(window.location.search);
const hashQuery = window.location.hash.split("?")[1] ?? "";
if (params.get("capture") === "1" || new URLSearchParams(hashQuery).get("capture") === "1") {
document.documentElement.dataset.capture = "true";
}
const root = document.getElementById("root");
if (root === null) {
throw new Error("Capability issue-thread root element is missing");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1,91 @@
import type { ReactNode, Ref } from "react";
import type { CapabilityTaskStatus } from "../../../src/mock-core/capability-control-plane-types";
/**
* Package-local equivalents of the product StatusBadge/chip anatomy. Every
* state pairs color with a glyph and text so the surface stays readable
* without color (contract §9.5).
*/
const STATUS_COPY: Record<CapabilityTaskStatus, { label: string; glyph: string }> = {
backlog: { label: "Backlog", glyph: "○" },
todo: { label: "Todo", glyph: "○" },
in_progress: { label: "In progress", glyph: "◐" },
in_review: { label: "In review", glyph: "◑" },
done: { label: "Done", glyph: "✓" },
blocked: { label: "Blocked", glyph: "✕" },
cancelled: { label: "Cancelled", glyph: "–" },
};
const PRIORITY_GLYPH: Record<string, string> = {
critical: "▲▲",
high: "▲",
medium: "▬",
low: "▼",
};
export function StatusBadge({ status }: { status: CapabilityTaskStatus }) {
const copy = STATUS_COPY[status];
return (
<span className="pit-status-badge" data-status={status}>
<span aria-hidden="true">{copy.glyph}</span>
{copy.label}
</span>
);
}
export function PriorityIcon({ priority }: { priority: string }) {
return (
<span className="pit-priority">
<span aria-hidden="true">{PRIORITY_GLYPH[priority] ?? "▬"}</span>{" "}
{priority.charAt(0).toUpperCase() + priority.slice(1)} priority
</span>
);
}
export function Chip({
tone,
children,
title,
testId,
/** Set to -1 so focus management can land on a chip without adding a tab stop. */
tabIndex,
chipRef,
}: {
tone?: "live" | "mock" | "accent" | "success" | "danger";
children: ReactNode;
title?: string;
testId?: string;
tabIndex?: number;
chipRef?: Ref<HTMLSpanElement>;
}) {
return (
<span
className="pit-chip"
data-tone={tone}
title={title}
data-testid={testId}
tabIndex={tabIndex}
ref={chipRef}
>
{children}
</span>
);
}
export function Timestamp({ value }: { value: string }) {
// Rendered from fixture data with a fixed locale so captures stay stable.
const stamp = new Date(value);
const hours = String(stamp.getUTCHours()).padStart(2, "0");
const minutes = String(stamp.getUTCMinutes()).padStart(2, "0");
const seconds = String(stamp.getUTCSeconds()).padStart(2, "0");
return (
<time className="pit-card-meta" dateTime={value}>
{hours}:{minutes}:{seconds}
</time>
);
}
/** Re-exported so the thread strip and the deliverable card share one unit. */
export { capabilityFormatBytes as formatBytes } from "../../../src/issue-thread/types";
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { parseCapabilityRoute, capabilityRouteHref } from "./route";
function at(hash: string, search = "") {
return parseCapabilityRoute({ hash, search });
}
describe("Capability issue-thread routes", () => {
it("keeps the existing deterministic scenario deep links intact", () => {
const route = at("#/issue/dp-documents?shot=document-revision&panel=state&rec=doc-1&at=3&seg=evidence");
expect(route.surface).toBe("issue");
expect(route.fixtureProfile).toBe("dp-documents");
expect(route.shot).toBe("document-revision");
expect(route.panel).toBe("state");
expect(route.record).toBe("doc-1");
expect(route.at).toBe(3);
expect(route.segment).toBe("evidence");
expect(route.mode).toBe("fake");
expect(capabilityRouteHref(route, {})).toBe(
"#/issue/dp-documents?shot=document-revision&panel=state&rec=doc-1&at=3&seg=evidence",
);
});
it("still infers replay mode from the replay shot and honours an explicit mode", () => {
expect(at("#/issue/hb-baseline?shot=replay-mode").mode).toBe("replay");
expect(at("#/issue/hb-baseline?mode=live").mode).toBe("live");
});
});
describe("Capability clean-room route", () => {
it("is reachable without naming a scenario", () => {
const route = at("#/chat");
expect(route.surface).toBe("chat");
expect(route.shot).toBe(null);
expect(route.at).toBe(null);
expect(capabilityRouteHref(route, {})).toBe("#/chat");
});
it("is always live: no URL can downgrade it to a fixture or a recording", () => {
for (const hash of [
"#/chat",
"#/chat?mode=fake",
"#/chat?mode=replay",
"#/chat?shot=replay-mode",
"#/chat?shot=thread-baseline&at=4",
]) {
const route = at(hash);
expect(route.surface, hash).toBe("chat");
expect(route.mode, hash).toBe("live");
expect(route.shot, hash).toBe(null);
expect(route.at, hash).toBe(null);
}
expect(at("", "?mode=fake#/chat").surface).toBe("issue");
});
it("carries only the evidence deep-link parameters back into its own href", () => {
const route = at("#/chat?panel=calls&rec=call-1&seg=evidence");
expect(route.panel).toBe("calls");
expect(route.record).toBe("call-1");
expect(route.segment).toBe("evidence");
expect(capabilityRouteHref(route, {})).toBe("#/chat?panel=calls&rec=call-1&seg=evidence");
expect(capabilityRouteHref(route, { panel: null, record: null, segment: "thread" })).toBe("#/chat");
});
it("switches surfaces without dragging scenario state across", () => {
const chat = at("#/chat");
expect(capabilityRouteHref(chat, { surface: "issue", fixtureProfile: "ar-artifacts" })).toBe(
"#/issue/ar-artifacts?mode=live",
);
});
});
@@ -0,0 +1,98 @@
/**
* Deterministic route scheme from the Capability UX contract §10.1, plus the
* Capability clean-room surface.
*
* `#/issue/<fixtureProfile>?shot=<slug>&panel=<section>&rec=<id>&at=<ordinal>&seg=thread|evidence`
* `#/chat?panel=<section>&rec=<id>&seg=thread|evidence`
*
* Parameters are also accepted on the document query string so a capture tool
* can address a state without depending on hash-fragment escaping.
*
* The clean-room surface has no fixture, shot, or replay ordinal by design: it
* starts blank and is always a live Codex session. `mode` is pinned to `live`
* there rather than parsed, so no URL can talk the clean room into answering
* with a fixture or a recording.
*/
import type { CapabilityEvidenceSectionId } from "../../../src/issue-thread/types";
import { CAPABILITY_EVIDENCE_SECTIONS } from "../../../src/issue-thread/types";
import { CAPABILITY_DEFAULT_FIXTURE_PROFILE } from "../../../src/issue-thread/fixtures";
export type CapabilitySurface = "issue" | "chat";
export interface CapabilityRoute {
surface: CapabilitySurface;
fixtureProfile: string;
shot: string | null;
panel: CapabilityEvidenceSectionId | null;
record: string | null;
at: number | null;
segment: "thread" | "evidence";
/** `live` opts into the package session server; otherwise fixtures render. */
mode: "fake" | "live" | "replay";
}
const SECTION_IDS = new Set<string>(CAPABILITY_EVIDENCE_SECTIONS.map((section) => section.id));
function mergeParams(search: string, hash: string): URLSearchParams {
const params = new URLSearchParams(search);
const queryIndex = hash.indexOf("?");
if (queryIndex >= 0) {
for (const [key, value] of new URLSearchParams(hash.slice(queryIndex + 1))) {
params.set(key, value);
}
}
return params;
}
export function parseCapabilityRoute(url: {
search: string;
hash: string;
}): CapabilityRoute {
const params = mergeParams(url.search, url.hash);
const path = url.hash.replace(/^#/, "").split("?")[0] ?? "";
const chat = /^\/chat\/?$/.test(path);
const match = /^\/issue\/([^/?]+)/.exec(path);
const shot = chat ? null : params.get("shot");
const requestedMode = params.get("mode");
const panel = params.get("panel");
const at = params.get("at");
const segment = params.get("seg");
const mode = chat
? ("live" as const)
: requestedMode === "live" || requestedMode === "replay" || requestedMode === "fake"
? requestedMode
: shot === "replay-mode"
? "replay"
: "fake";
return {
surface: chat ? "chat" : "issue",
fixtureProfile: match?.[1] ?? CAPABILITY_DEFAULT_FIXTURE_PROFILE,
shot,
panel: panel !== null && SECTION_IDS.has(panel) ? (panel as CapabilityEvidenceSectionId) : null,
record: params.get("rec"),
at: chat || at === null || !/^\d+$/.test(at) ? null : Number.parseInt(at, 10),
segment: segment === "evidence" ? "evidence" : "thread",
mode,
};
}
export function capabilityRouteHref(route: CapabilityRoute, overrides: Partial<CapabilityRoute>): string {
const next = { ...route, ...overrides };
const params = new URLSearchParams();
if (next.surface === "chat") {
if (next.panel !== null) params.set("panel", next.panel);
if (next.record !== null) params.set("rec", next.record);
if (next.segment !== "thread") params.set("seg", next.segment);
const chatQuery = params.toString();
return `#/chat${chatQuery.length > 0 ? `?${chatQuery}` : ""}`;
}
if (next.shot !== null) params.set("shot", next.shot);
if (next.mode !== "fake") params.set("mode", next.mode);
if (next.panel !== null) params.set("panel", next.panel);
if (next.record !== null) params.set("rec", next.record);
if (next.at !== null) params.set("at", String(next.at));
if (next.segment !== "thread") params.set("seg", next.segment);
const query = params.toString();
return `#/issue/${next.fixtureProfile}${query.length > 0 ? `?${query}` : ""}`;
}
@@ -0,0 +1,166 @@
/**
* Scripted Codex transport for the browser streaming test (track 7Q).
*
* Everything below the provider is the real thing: the same package server
* middleware, the same `CapabilityLiveSession`, the same NDJSON turn stream, and
* the same built browser bundle. Only the provider is scripted, and only so the
* test can decide when a delta arrives — a real Codex process cannot be asked
* to emit exactly four deltas 220 ms apart, and a browser assertion needs that
* to distinguish "streaming" from "arrived all at once".
*
* This module is loaded by `vite.issue-thread-stream.config.ts` only. It is not
* reachable from the shipped server, the deployed surface, or any route: the
* production plugin takes no transport override from a request or an
* environment variable.
*/
const DELTA_INTERVAL_MS = 220;
export const CAPABILITY_STREAM_FIXTURE_PRIVATE_REASONING =
"PRIVATE reasoning text must never reach the browser.";
/** Four deltas: enough for a browser to observe growth twice over, and short. */
export const CAPABILITY_STREAM_FIXTURE_DELTAS = [
"Reading the clean-room issue. ",
"It is blank, with one mock agent and one mock task. ",
"Recording a first status against the mock control plane. ",
"Done — every record stayed in the mock port.",
];
export const CAPABILITY_STREAM_FIXTURE_REPLY = CAPABILITY_STREAM_FIXTURE_DELTAS.join("");
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
class Notifications {
#values = [];
#waiters = [];
#closed = false;
push(value) {
const waiter = this.#waiters.shift();
if (waiter) waiter({ value, done: false });
else this.#values.push(value);
}
close() {
this.#closed = true;
for (const waiter of this.#waiters.splice(0)) waiter({ value: undefined, done: true });
}
[Symbol.asyncIterator]() {
return {
next: async () => {
const value = this.#values.shift();
if (value) return { value, done: false };
if (this.#closed) return { value: undefined, done: true };
return new Promise((resolve) => this.#waiters.push(resolve));
},
};
}
}
class StreamFixtureTransport {
#queue = new Notifications();
#closed = false;
#turns = 0;
#interrupted = new Set();
async request(method, params) {
if (method === "initialize") return { user: { sessionId: "stream-fixture-session" } };
if (method === "thread/start" || method === "thread/read" || method === "thread/resume") {
return { thread: { id: "stream-fixture-thread", sessionId: "stream-fixture-session" } };
}
if (method === "turn/start") {
this.#turns += 1;
const turnId = `stream-turn-${this.#turns}`;
void this.#runTurn(turnId);
return { turn: { id: turnId, status: "inProgress" } };
}
if (method === "turn/interrupt") {
// A stopped turn keeps whatever it had already streamed, so the surface
// can be checked for a coherent partial reply.
this.#interrupted.add(String(params.turnId));
this.#queue.push({
method: "turn/completed",
params: {
threadId: "stream-fixture-thread",
turn: { id: String(params.turnId), status: "interrupted" },
},
});
return {};
}
throw new Error(`unsupported scripted Codex method ${method}`);
}
notify() {}
notifications() {
return this.#queue;
}
setServerRequestHandler() {}
async close() {
this.#closed = true;
this.#queue.close();
}
processInfo() {
return { pid: 7100, processGroupId: 7100, exited: this.#closed, exitCode: null, signal: null };
}
async #runTurn(turnId) {
this.#queue.push({
method: "turn/started",
params: { threadId: "stream-fixture-thread", turn: { id: turnId, status: "inProgress" } },
});
await sleep(Math.floor(DELTA_INTERVAL_MS / 2));
if (this.#closed || this.#interrupted.has(turnId)) return;
this.#queue.push({
method: "item/reasoning/summaryTextDelta",
params: {
threadId: "stream-fixture-thread",
turnId,
delta: CAPABILITY_STREAM_FIXTURE_PRIVATE_REASONING,
},
});
for (const delta of CAPABILITY_STREAM_FIXTURE_DELTAS) {
await sleep(DELTA_INTERVAL_MS);
if (this.#closed || this.#interrupted.has(turnId)) return;
this.#queue.push({
method: "item/agentMessage/delta",
params: { threadId: "stream-fixture-thread", turnId, delta },
});
}
await sleep(DELTA_INTERVAL_MS);
if (this.#closed || this.#interrupted.has(turnId)) return;
this.#queue.push({
method: "item/completed",
params: {
threadId: "stream-fixture-thread",
turnId,
item: { id: `message-${turnId}`, type: "agentMessage", text: CAPABILITY_STREAM_FIXTURE_REPLY },
},
});
this.#queue.push({
method: "turn/completed",
params: { threadId: "stream-fixture-thread", turn: { id: turnId, status: "completed" } },
});
}
}
export function capabilityStreamFixtureTransportFactory(options = {}) {
const evidence = {
runnerPid: 7100,
runnerProcessGroupId: 7100,
codexPid: 7200,
runnerExited: false,
runnerExitCode: null,
runnerSignal: null,
childEnvironmentKeys: ["CODEX_HOME", "HOME", "PATH"],
diagnostics: [],
};
options.onEvidence?.(evidence);
return { transport: new StreamFixtureTransport(), evidence: () => ({ ...evidence }) };
}
@@ -0,0 +1,87 @@
# Adding a Harness
Every Paperclip Runner harness must declare its permission behavior as part of
the provider contract. A driver is not complete until its permission modes,
maximum non-interactive default, durable request translation, recovery
identity, isolation behavior, and conformance coverage are defined.
## Current permission catalog
| Provider | Agent configuration key | Supported values | Default |
|---|---|---|---|
| Codex | `codexPermissionMode` | `never`, `on-request`, `untrusted` | `never` |
| OpenCode | `opencodePermissionMode` | `allow`, `ask`, `deny` | `allow` |
| ACPX (Claude, Codex) | `acpxPermissionMode` | `approve-all`, `approve-reads`, `deny-all` | `approve-all` |
The browser-safe source of truth for labels, defaults, and configuration
validation is `PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES` in
`@paperclipai/adapter-utils`. Native process boundaries validate the pinned
provider value again; they must not silently accept an unknown mode.
“Full auto” means the harness does not pause for a duplicate approval inside
the environment assigned to the run. It does not grant host-wide filesystem
access, unrestricted network access, Paperclip credentials, or write access in
read-only planning mode. Workspace containment, protected-path and symlink
checks, network policy, credential bindings, and authenticated PRP
authorization remain authoritative and run before any harness auto-approval.
## Driver requirements
When adding or upgrading a harness:
1. Declare the exact native modes supported by the provider. Choose the
provider's highest non-interactive mode as the default for a fresh
Paperclip Runner execution.
2. Add its labels, configuration key, options, and default to the shared
provider-discriminated capability catalog. The agent form must show only
the selected provider's setting and retain stored choices when the provider
selection changes.
3. Pin the effective value in native execution input and provider recovery
state. Include it in provider-session compatibility so changing a mode
replaces an incompatible warm session rather than mutating an active turn.
4. Translate every lower-mode prompt into the canonical runtime-request
lifecycle: emit one durable request, recover it after reconnect, accept
allow-once, allow-for-session, deny, and cancellation where the provider
supports them, and send exactly one provider-native resolution.
5. Authorize Paperclip semantic and question tools through authenticated PRP.
These tools may bypass duplicate harness approval, but never their
control-plane authorization.
6. Emit the provider and effective permission mode in session-start
diagnostics without including credentials or unredacted provider input.
7. Keep standalone local adapters unchanged unless their own contract is
deliberately revised. Paperclip Runner defaults apply only to
`paperclip_runner` executions.
## Compatibility rules
Native execution input v4 pins `approvalPolicy` for Codex and
`permissionMode` for OpenCode and ACPX. Persisted v1-v3 executions remain
replayable. Their missing Codex and OpenCode settings use the historical
effective behavior, and legacy ACPX `permissionPolicy: "interactive"` means
the historical `approve-reads` behavior. Legacy fields are readable only;
new agent configuration must not persist them.
Do not rewrite an active execution when configuration changes. A fresh
execution carries the new policy and replaces an incompatible idle or
checkpointed session through normal session-compatibility handling.
## Conformance checklist
A new driver must cover:
- agent create/edit defaults, selected-provider visibility, provider switching,
invalid-value rejection, and no Runner sandbox-bypass control;
- native serialization, legacy replay, recovery identity, and replacement
after a permission change;
- start and resume propagation for every supported mode;
- no runtime request in the maximum mode;
- once, session, and deny resolution in prompting modes, including every
provider event version the driver accepts;
- immediate rejection in deny mode;
- workspace and protected-path denial before auto-approval;
- authenticated semantic-tool behavior; and
- an end-to-end default-mode write plus validation command that completes
without an Input needed permission card.
Run TypeScript, Rust, UI, protocol-generation, durable transport, and
documentation validation before declaring the harness qualified.
@@ -0,0 +1,119 @@
# ADR 0001: Runner and Testing Package Boundaries
- Status: Accepted
- Date: 2026-08-11
- Decision owners: Paperclip App
## Context
The runner package previously exported production contracts, deterministic
mocks, conformance fixtures, scenario/eval helpers, and demo servers from one
root. A workspace checkout hid two packaging defects: consumers could use
undeclared deep surfaces, and the public semantic dispatcher loaded `ajv` even
though `ajv` was only a development dependency.
External conformance consumers must consume App contracts and test helpers
without making the App runtime depend on scenario corpora, provider
experiments, or a separate eval package.
## Decision
Ownership is:
| Owner | Stable responsibility |
|---|---|
| Paperclip App | PRP schemas and fixtures, canonical semantic catalog and dispatcher, runnerd/client interfaces, `ControlPlanePort`, the production binding, deterministic mock, and mock/real parity fixtures |
| External eval repositories | Scenario corpus, provider configuration, experiment reports, and provider-backed orchestration |
The production binding remains App code at
`server/src/services/native-runtime/paperclip-control-plane-port.ts`. It
implements the App-owned `ControlPlanePort`; the runner package never imports
the server, database, UI, or CLI.
Public runner exports are:
| Export | Stability and purpose |
|---|---|
| `@paperclipai/paperclip-runner` | Runtime contracts, runner clients/backends, PRP validation/replay, canonical catalog/dispatcher, and compatibility preflight |
| `@paperclipai/paperclip-runner/testing` | Deterministic mocks, PRP port conformance, and provider-neutral semantic conformance kit |
| `./browser`, `./react`, `./standalone`, `./styles.css` | Existing explicitly named UI/standalone consumers |
Mock adapters and conformance constants are no longer package-root exports.
Tests and external conformance consumers must use `./testing`. Scenario
content, provider-backed matrices, reports, and provider configuration are not
public App package exports.
The testkit remains an App `./testing` subpath rather than a separate package.
It shares the runner protocol/catalog release cadence, and no independent
consumer or version cadence currently justifies another package. Split it only
after an independent release requirement exists; a directory preference is not
sufficient.
The credential-free matrix engine used by runner conformance remains
package-local test implementation. A separately versioned eval kernel and any
provider-backed campaign are deferred. The runner's dependency,
optional-dependency, peer-dependency, and workspace importer sets remain free
of eval packages.
The dependency graph is acyclic:
```text
Paperclip App production binding
|
v
@paperclipai/paperclip-runner (runtime contracts)
^
|
External conformance consumers --> @paperclipai/paperclip-runner/testing
```
No arrow points from App runtime to an external eval repository.
## Compatibility and versioning
Package semver describes distribution compatibility. Independently versioned
contracts are published in `PAPERCLIP_RUNNER_COMPATIBILITY`:
| Component | Current contract | Compatibility rule |
|---|---:|---|
| Canonical catalog | 1 | Operation removals, renames, placement changes, or incompatible schemas require a new contract version |
| PRP | 1 | Highest overlapping required protocol version; no overlap fails closed |
| Runner client | 1 | Breaking runnerd/client interface changes require a new version |
| runnerd artifact | 2 | Binary metadata/package disagreement or digest mismatch fails before launch |
| Harness driver | 1 | Breaking descriptor/config/session/conformance behavior requires a new version |
| Native execution | 1 | Breaking App attempt-bundle semantics require a new schema version and converter |
| Control-plane adapter | 1 | Breaking `ControlPlanePort` or production-binding expectations require a new version |
| Testkit | 1 | Breaking mock seed, vector, observation, or conformance behavior requires a new version |
| Eval corpus | 1 | Runner declares a supported inclusive corpus-version range; out-of-range bundles fail before execution |
`assertPaperclipRunnerCompatibility` performs a fail-closed preflight. It emits
`paperclip_runner_incompatible` with stable issue codes for component mismatch,
unsupported corpus versions, unknown catalog operations, missing provider
capability declarations, and provider-operation gaps. Provider-specific runtime
errors must not stand in for this preflight.
## Clean-consumer proof
Run:
```sh
pnpm --filter @paperclipai/paperclip-runner check:package-boundaries
pnpm --filter @paperclipai/paperclip-runner check:clean-consumers
```
The second command builds and packs the runner, installs its tarball into a
clean consumer, imports only the root and `./testing` exports, executes
deterministic PRP, harness-driver, and semantic conformance, and verifies the
separately staged runnerd artifact digest. The consumer uses no workspace
protocol, source-relative import, or deep package path. This is the packaging
gate; workspace tests alone are not proof.
## Consequences
- Existing tests importing mock/conformance values from the package root must
migrate to `@paperclipai/paperclip-runner/testing`.
- `ajv` is a runtime dependency because the public dispatcher imports it.
- The semantic conformance kit defines normalized comparison; real App service
adapters and risk-weighted vectors may evolve behind its testkit version.
- Scenario corpus changes do not force an App runtime release unless their
declared compatibility requirement changes.
@@ -0,0 +1,298 @@
# Architecture and Standalone Boundary
## Dependency direction
```text
language-neutral protocol fixture
|
+---------------+---------------+
v v
Rust runner-core + mock path TypeScript contracts + mock path
| |
+---------------+---------------+
v
byte-identical Conformance result
Paperclip App production binding --> implements ControlPlanePort
External conformance consumers --> use packed runtime + ./testing exports
```
The dependency arrow always points from an implementation toward a contract.
The standalone package does not reach backward into a Paperclip implementation.
The production implementation lives in
`server/src/services/native-runtime/paperclip-control-plane-port.ts`; it depends
on the public port, never the reverse. The accepted package/export ownership is
recorded in [ADR 0001](adr/0001-runner-testing-eval-package-boundaries.md).
## Public package surfaces
- The package root is runtime-only: PRP, runner/client contracts, normalized
backends, catalog/dispatcher, and compatibility preflight.
- `./testing` contains deterministic mocks and conformance kits.
- Package-local deterministic matrices remain internal test implementation.
A separately versioned eval package and provider-backed campaigns are
deferred and are not workspace dependencies or public exports.
## Core contracts
- `ControlPlanePort` is the narrow surface through which a runner opens a run,
appends ordered events, and submits a terminal structured result.
- `HarnessDriver` owns a local harness session and its provider-specific
identity, event, turn, snapshot, and close behavior.
- `NativeSessionBackend` normalizes local runner and hosted-provider sessions for
a future control-plane consumer. Environment placement is not implied by the
backend type.
The TypeScript contracts name responsibility and dependency direction while
the executable replay path provides a deterministic oracle:
```text
protocol/schemas/*.json
| generate/check | shared fixtures
v v
TypeScript schema constants/types -> validator -> deterministic reducer
| |
v v
CLI browser devtool
|
golden parity summaries
|
v
Rust runner-core oracle
```
The Rust `runner-core` crate establishes the production language/package
boundary and checks the same fixture summaries. Local runner adds the package-local
`paperclip-runnerd` and `fake-harness` binaries without changing that dependency
direction.
The clean-consumer gate packs the declared root and `./testing` exports and
stages the release runnerd executable as a separately checksummed artifact.
## Language ownership
- Rust is the production direction for deterministic runner behavior,
supervision, durable delivery, and the eventual `paperclip-runnerd` binary.
- TypeScript owns the control-plane/browser side and remains a useful reference
client/test oracle.
- JSON Schema and shared fixtures are the language-neutral authority. Conformance
keeps its narrow tracer fixture; Replay adds the executable PRP v1 schema and
conformance corpus without silently changing the accepted Conformance path.
- `check:conformance-parity` prevents either implementation from introducing a
language-specific observable result.
- `check:replay-parity` prevents TypeScript replay and the Rust production
direction from disagreeing on identity, terminal state, duplicates, or gaps.
## Allowed dependencies
- Rust crates declared by the package-local Cargo workspace.
- Node.js standard-library modules.
- Third-party packages declared by this workspace.
- Files within `packages/paperclip-runner/`.
- A future explicit generated-schema package only after architecture review and
an allowlist change in the boundary checker.
## Forbidden dependencies
The following imports and package dependencies are rejected:
- `server/`, `ui/`, and `cli/` implementation paths;
- `@paperclipai/db` and production database schema or client modules;
- `@paperclipai/shared`, adapter utilities, and other Paperclip workspace
internals unless a boundary review explicitly allows a public contract;
- relative or absolute imports that escape `packages/paperclip-runner/`.
This rule applies to type-only imports, exports, dynamic imports, CommonJS
`require` calls, Rust include/path attributes, and Cargo path dependencies. The
negative fixtures under `test-fixtures/` intentionally reference `server/` and
must fail the checker.
## Enforcement
```sh
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
pnpm --filter @paperclipai/paperclip-runner test
pnpm --filter @paperclipai/paperclip-runner check:replay-parity
```
The first command scans the package source, scripts, and manifest. The test
command additionally asserts that the negative fixture is rejected. The normal
scan excludes that fixture so a deliberate proof does not make the package fail.
## Conformance process boundary
The mock core is an in-memory adapter, not a Paperclip server. Starting it only
changes local object state. The tracer performs this sequence:
1. load and validate `protocol/fixtures/conformance-minimal-run.json`;
2. start the mock adapter;
3. open the fixture run through `ControlPlanePort`;
4. append contiguous typed events;
5. submit the matching terminal result;
6. print a stable JSON identity/result and stop the adapter.
No socket, database, browser, Paperclip process, or model process is started.
The default command executes this sequence in Rust. The TypeScript reference
executes the same sequence, and the parity check compares their complete stdout.
## Static replay boundary
`replayReplayFixtureText` is the single entry point used by the CLI and browser.
It parses JSON, validates JSON Schema plus cross-record bindings, and only then
calls the reducer. The reducer is pure: it clones input state, applies an event
at most once by source event ID, records source gaps/out-of-order deliveries,
and never performs I/O.
The browser is a Vite application under `devtools/browser/`. Its Button, Badge,
Card, and Textarea are source-compatible adaptations of shadcn primitives; all
visual values live in its local `styles.css` token layer. It imports the same
replay module as the CLI and does not create a browser-only protocol model.
## Local process boundary
```text
TypeScript mock core
| PRP commands over stdin JSONL
v
paperclip-runnerd (Rust supervisor)
| fake-harness commands over stdin JSONL
v
fake-harness (Rust scripted driver)
| typed messages over stdout JSONL
v
paperclip-runnerd -> canonical PRP events -> mock core
|
v
browser NDJSON stream
```
The mock core starts one runner process. The runner creates a new process group
for one fake harness and its workers. The runner clears the inherited
environment and restores only the path needed to launch local executables. It
captures stderr and scripted log messages in a bounded tail.
The controller and harness links use newline-delimited JSON over stdio. This is
the smallest local transport that keeps process ownership clear. The browser
does not connect to the runner. A package-local Vite middleware exposes an HTTP
start/action API and an NDJSON event stream from the TypeScript mock core.
The runner publishes the structured semantic result before it publishes the
harness process exit fact. It then emits one `run.terminal` event. A non-zero
harness exit can coexist with a valid yielded result. Duplicate commands and
duplicate terminal messages cannot repeat side effects or close the run twice.
Every browser event passes `validatePrpEvent` and `applyPrpEvent`. When the run
ends, the browser reduces the complete event list again and compares the replay
snapshot with the live snapshot.
## Durable transport boundary
```text
TypeScript mock core
| one-time ticket -> short-lived connection lease
| PRP v1 hello/welcome, commands, events, cumulative ACKs
v
paperclip-runnerd (Rust WebSocket client)
| atomic private JSON state
+-- durable outbox and processed-command cache
+-- stable runner/session/turn/item identities
+-- Local runner fake-harness process for restart proof
```
The runner initiates the loopback WebSocket. The bootstrap ticket is present
only in the runner process environment. The returned connection-lease token is
kept only in runner memory. The mock core stores SHA-256 digests of capabilities
and the runner state stores neither capability. The runner writes each event and
command result before network delivery, then removes outbox events only after a
valid cumulative ACK.
The TypeScript peer is a package-local control-plane implementation, not
production Paperclip. Its focused tests cover lost ACKs, socket loss, malformed
input, process restarts, lease expiry, storage pressure, drain, and revoke.
## Capability-model boundary
```text
Paperclip skill + 7 references Paperclip Evals corpus (106 cases)
| |
+-------------------+------------------+
v
generated capability contract (258 rows, 41 MCP aliases)
|
+-----------------------+-----------------------+
v v v
semantic tool catalog authorization engine eval conformance suite
\ | /
\ v /
+-----> in-process mock ControlPlanePort <--+
|
v
read-only browser scenario explorer
```
Capability is a package-local model of a native Paperclip run. It classifies every
capability as control-plane-owned, always-agent-tool, or optional-agent-tool,
exposes the always/optional set as a transport-neutral semantic tool catalog,
gates optional tools behind grants, and proves 106 eval-derived cases against an
in-process mock `ControlPlanePort`. The mock adapter is the only coupling point,
so a real adapter can replace it later without touching the catalog,
authorization rules, or conformance suite. Capability contacts no Paperclip
service, database, ACPX session, or provider credential; the
[forbidden-imports checker](#forbidden-dependencies) keeps it that way. Real
integration is future upload integration (ACPX) and requires separate approval; see
[the future binding boundary](capability-future-binding-boundary.md).
## Capability live process topology
The live surface adds a real provider turn loop over the same mock core. The
package server owns every credential and every child process; the browser holds
none.
```text
browser (issue thread + evidence panel; no credential)
| HTTP/SSE over the trusted-proxy boundary
v
package server ── projectCapabilityIssueThread ──> CapabilityIssueThreadSnapshot
| (one contract, two producers)
| owns session, tool loop, provider auth
v
paperclip-runnerd (real binary, owns the Codex process group)
| newline-delimited JSON-RPC over stdio
v
codex app-server (real session)
| tool request
v
CapabilitySemanticDispatcher ── typed command ──> in-process mock ControlPlanePort
^ |
+──────── typed result / typed denial ─────+
```
Three actors stay separate at all times: **Real Codex** (the app-server
session), **Real runnerd** (the package-local binary), and **Mock Paperclip**
(the in-process `ControlPlanePort`). The same
`CapabilityIssueThreadSnapshot` is produced by deterministic `fake` fixtures for the
screenshot matrix and by the server-side projection for a live session; the
projection reads only durable records and decides nothing, so UI-side state math
is a defect by construction. The scripted (`fake`) mode drives the conformance
suite and replay offline; the Codex (live) mode requires a locally authenticated
Codex. See [execution modes and identity](capability-execution-modes.md) for the
mode and eligibility rules, and [the live runnerd/Codex loop](capability-live-runnerd-codex.md)
for the session API. The package server blocks every request to a real
Paperclip API, and the evidence suite proves no such request occurred. The same
topology serves the [clean-room chat](capability-clean-room-chat.md): the only
difference is a mock tenant seeded with a company, an agent, and one blank issue
instead of a recorded eval case.
## Integration rule
Paperclip core may implement these contracts behind a separately reviewed
adapter, but this package must remain independently buildable, testable, and
runnable against the mock adapter.
The proposed Standalone seam is recorded in
[Standalone Thin Paperclip Adapter Boundary](design/standalone-thin-paperclip-adapter.md).
It keeps one dependency direction, branches only after Paperclip workspace and
environment realization, composes a package-owned `NativeSessionBackend` with
a server-bound `ControlPlanePort`, and returns to the existing Paperclip
finalization path. The core seam contains no runner behavior. The proposal is
design-only until the CTO gate accepts it.
@@ -0,0 +1,98 @@
# Capability Authorization and Exposure
Two decisions gate every capability: **exposure** (is the tool even offered to
this actor?) and **invocation** (may this specific call proceed?). The
authorization engine answers both, produces typed denials that carry no
protected state, and redacts secrets before any observable boundary.
Sources: `src/tools/capability-tool-authorization.ts`,
`src/tools/capability-semantic-tool-runtime.ts`, `src/tools/capability-tool-bindings.ts`.
## Three-way classification
- **Control-plane-owned** — a frozen operation list (`checkout_task`,
`release_task`, `select_work`, `route_wake`, `enforce_budget`,
`append_audit_record`, `persist_run`, `replay_run`, `schedule_blocker_wake`,
`reconcile_run`). `authorizeInvocation` short-circuits these to outcome
`absent` with reason `control_plane_owned_operation`. No agent tool exists for
them and none can be invoked.
- **Always-agent tool** — exposed to any actor whose task is active.
- **Optional-agent tool** — exposed only when a grant unlocks it.
## Exposure vs. invocation
- `computeVisibleTools()` walks the whole catalog and records, per tool, an
exposure of `exposed` or `absent`, returning only the allowed tools.
- `authorizeInvocation()` re-evaluates at call time, producing `allowed` or
`denied`.
Invocation is evaluated in order and fails at the first rule that rejects:
policy-denied operation IDs; task-mode membership
(`operation_not_available_in_task_mode`); role check
(`actor_role_not_authorized`); policy-denied claims
(`required_claim_denied_by_policy`); missing grants
(`required_claim_missing`); `read_secret_value` requires
`policy.allowSecretValueAccess` (`secret_value_access_disabled`);
`generic_api_request` requires the escape hatch to be enabled and the target to
be allowlisted (`generic_escape_hatch_disabled` / `..._target_denied`); and the
interaction-kind policy for `request_human_input`. Allowed calls carry the
reason `task_scoped_always_tool` or `required_claims_granted`.
Self-approval is prohibited even with the role and an explicit decision claim.
## How grants unlock optional tools
The actor's grants are the union of `capabilityGrants` (the actor's own) and
`scenarioGrants` (seeded per scenario). An optional tool unlocks only when
**every** entry in its `requiredClaims` is present in that union. Each
authorization record stores the `consideredClaims`, `grantedClaims`, and
`missingClaims`, so a denial explains exactly which claim was absent.
## Typed denials carry no protected state
A denial is a `CapabilityPolicyDenial`: `ok: false` with an `error.code` of
`policy_denied`, `operation_absent`, `input_invalid`, or `operation_unsupported`,
the `operationId`, a generic `reason`, and the authorization record. It carries
no fixture state and no protected payload. If the underlying mock operation
throws, the runtime emits `operation_unsupported` with reason
`mock_operation_rejected` and swallows the underlying error, so protected state
cannot leak through an exception.
## Secret redaction (the TASK-16909 rule)
Redaction is two layers:
1. **Model-only delivery is separated from every observable boundary.**
`invoke()` returns a redacted `observableResult`. The only path to an
unredacted secret is `invokeForModel()`, which returns a frozen capsule whose
`toJSON()` and default serialization always yield the redacted form; only an
explicit `readModelResult()` opens the raw value. The capsule uses a distinct
schema (`paperclip.capability.model-tool-result.v1`) so it cannot be assigned to
an observable sink by mistake.
2. **Path redaction rules.** `read_secret_value` redacts `$.value` to
`[SECRET_VALUE]` across output, error, and authorization-record channels;
`generic_api_request` redacts `$.headers.authorization` and
`$.headers.cookie` to `[REDACTED]` across all channels. The scenario runner
applies the same redaction again defensively at the explorer artifact
boundary and injects only a placeholder secret value.
This is the security-gate outcome (TASK-16902) and its remediation (TASK-16909):
a real secret value reaches the model surface only, never a trace, artifact, or
browser view.
## Running the tests
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/tools/capability-semantic-tools.test.ts
```
The "exposure and authorization" and "security policy" describes cover typed
denials without protected state, model-only secret delivery, the self-approval
prohibition, and the test-only, explicitly granted, allowlisted escape hatch.
## Related
- [Capability disposition](capability-disposition.md)
- [Semantic tool catalog](capability-semantic-tools.md)
- [Scenario explorer](capability-scenario-explorer.md)
@@ -0,0 +1,170 @@
# Capability clean-room live chat
Capability adds a second primary path beside the preset scenario explorer. A board
user opens a blank chat, sends a free-form message, and watches real Codex work
a mock Paperclip issue through real runnerd. There is no scenario to pick, no
recorded transcript, and no scripted tool tour.
Both paths ship in the same app and share one view contract. What separates them
is what the session is seeded with, and whether a fixture is allowed to stand in
for a provider.
| | Preset scenario explorer | Clean-room chat |
| --- | --- | --- |
| Route | `#/issue/<fixtureProfile>` | `#/chat` |
| Seed | A recorded eval case: transcript, scripted calls, parity verdicts | A company, an agent, and one blank issue |
| Modes | `fake` (default), `replay`, `mode=live` | Live only — no fixture, no recording |
| Tool calls | Fixed by the scenario | Chosen by the conversation |
| Evidence drawer | Collapsed by default | Collapsed by default |
## What "clean room" means
Every open mints a new mock tenant — a new company, actor, task, and `MCK-`
identifier — and seeds nothing else. `assertCapabilityCleanRoomSeedIsBlank` fails the
open if any comment, document, interaction, approval, artifact, blocker, wake,
or fault is present, so "the thread starts blank" is enforced at the seam that
creates the session rather than assumed.
The only mutation an open performs is the run checking the mock issue out, which
is why a fresh room reads `in_progress` rather than `todo`.
## Live only, and loudly so
The clean room never falls back:
- `parseCapabilityRoute` pins `mode` to `live` on `#/chat` and drops `shot` and
`at`. No URL can talk it into rendering a fixture or a recording.
- The package server has no scripted path for this route. It projects the live
session and refuses any view that is not `mode=live` with a `Real Codex`
agent label.
- When runnerd or the Codex app-server cannot start, the surface says exactly
that and offers `Try again`. It does not render a canned thread.
## Exposure profile
The profile is deterministic and inspectable even though the calls are not.
`CAPABILITY_CLEAN_ROOM_CLAIMS` grants the read, comment, document, interaction,
deliverable, delegation, dependency, wake, and approval-request operations.
Three grants stay withheld on purpose:
| Withheld grant | Effect |
| --- | --- |
| `governance:approvals:decide` | The agent may request an approval; the board decides it. |
| `workspace:control` | No service lifecycle changes from a chat. |
| `test:generic_api_request` | The escape hatch stays off. |
Withholding them keeps a denial reachable in a conversation nobody scripted: if
the model reaches for one, it gets a typed denial with a named code and the mock
state does not move. The Evidence drawer's Tools section lists all three under
`Control plane (not exposed to the agent)`.
The run itself holds the wider adapter claim set that the mock command boundary
requires (`capabilityFixtureRunCapabilities`). That union widens the port, never the
catalog the model sees: effective claims are the intersection of run claims,
scenario claims, and explicitly delegated claims.
## Session lifecycle
```text
GET /api/capability/ui/cleanroom/session[?sessionId=…] open or reconnect
POST /api/capability/ui/cleanroom/session {sessionId?} New chat (retires the caller's room)
POST /api/capability/ui/message {sessionId, message}
POST /api/capability/ui/interrupt {sessionId}
POST /api/capability/ui/reconnect {sessionId}
POST /api/capability/ui/reset {sessionId}
POST /api/capability/ui/interaction {sessionId, interactionId, outcome, result}
```
`Reset` and `New chat` both stop active work, clear the previous session's
authority, drop its workspace directory, and open a new tenant. A retired
session id answers `404` on every route afterwards — the rotation is verifiable,
not just visible. Refresh reconnects to the same durable session; a stale id
from `localStorage` opens a fresh room rather than dead-ending.
The clean room stores its session id under its own `localStorage` key, so a
scenario session can never be handed to the chat route or the reverse.
## Bounds
| Bound | Value |
| --- | --- |
| Turns per chat | 24, then a named `turn_limit` refusal that points at `New chat` |
| Message size | 8 KiB |
| Concurrent clean rooms | 4; the oldest yields to a new board user |
| Turn timeout | 120 s (`CapabilityLiveSessionService` default) |
## Real-API block
`CapabilityLiveSession` routes every Paperclip operation through the in-process mock
`ControlPlanePort`; no code path reaches a Paperclip URL. The projection turns
that into a record rather than a claim: the Control plane section of the
Evidence drawer carries a `network-guard-<sessionId>` row reading
`Real Paperclip API requests: 0. Child PAPERCLIP_* environment keys: none.`
The child environment is allowlisted by `createSanitizedCodexEnvironment`, so no
`PAPERCLIP_*` value reaches runnerd or Codex, and the browser receives no
provider, runner, or control-plane credential.
## DevTools state inspector
Opening **DevTools** opens a Redux-DevTools-style inspector over the live
mock company. Its timeline starts at the pristine fixture, adds a revision for
each successful semantic mutation, and lets the operator inspect or diff the
complete browser-safe company scaffolding: company, actors, tasks, comments,
documents, interactions, approvals, artifacts, work products, blockers,
workspace services, budgets, runs, wakes, audit records, decisions,
idempotency records, and configured faults. Separate tabs expose the protocol
boundary records, runner/runtime facts, and effective authority. A dedicated
**Documents** tab renders every mock document and lets the operator switch
between its retained revisions without digging through raw JSON.
The Evidence log consolidates repeated tool-exposure snapshots into one
deduplicated catalog. Its Always, Granted, and Control-plane groups fold
independently; selecting a tool opens its catalog title, description, placement,
required claims, allowed task modes and roles, and full input schema.
**Pause** pins a revision while work continues, **Export** downloads the
redacted DevTools snapshot, and **Fork rN** retires the current mock session and
starts a new executable branch from that retained state. Provider payloads,
session checkpoints, artifact content references, working directories, and
secret-shaped strings remain server-side or are withheld by the explicit
browser projection.
While a turn runs, a live status rail appears immediately after send and tracks
the newest safe Codex activity. Reasoning, planning, shell-command, file-change,
MCP/dynamic-tool, assistant-stream, and Paperclip semantic-tool lifecycle events
each trigger an interim frame. Discrete tools remain separate rows; only noisy
text deltas are grouped. Structured shell items show a bounded,
credential-redacted command preview; raw command output, other provider
payloads, and chain of thought stay withheld.
## Remote preview gateway
The tailnet preview keeps the package server on loopback and exposes it through
`dist/capability/tailnet-gateway.js`. Every API call requires the gateway's
high-entropy HttpOnly capability cookie. Mutations additionally require the
exact configured `Origin` and `application/json`.
Fetch Metadata is checked as defense in depth: any supplied `Sec-Fetch-Site`,
`Sec-Fetch-Mode`, or `Sec-Fetch-Dest` value must describe the expected
same-origin `fetch()` request. Browsers that omit one or all of those optional
headers are still accepted after the capability, Origin, and content-type
checks pass. This keeps Safari and embedded/private browser clients working
without weakening the explicit cross-site denial.
## Verification
| Surface | Command |
| --- | --- |
| Seed, exposure profile, and identity rotation | `pnpm --filter @paperclipai/paperclip-runner test:scenarios` |
| Clean-room HTTP routes end to end (stub provider) | included in `test:scenarios` |
| Browser entry, blank state, evidence-on-demand, narrow layout, axe | `pnpm --filter @paperclipai/paperclip-runner test:browser:issue-thread` |
| Real Codex through real runnerd | `pnpm --filter @paperclipai/paperclip-runner smoke:capability:cleanroom` |
| Live screenshots | `pnpm --filter @paperclipai/paperclip-runner recorded-evidence campaign (deferred)` |
See the [clean-room chat tutorial](tutorials/capability-clean-room-chat.md) for the
clean-start walkthrough, [execution modes and identity](capability-execution-modes.md)
for the fake/live eligibility rules, and the
[issue-thread UI reference](capability-issue-thread-ui.md) for the thread,
composer, and Evidence panel this surface reuses.
@@ -0,0 +1,244 @@
<!-- GENERATED FILE — DO NOT EDIT. Run pnpm generate:capability-inventory. -->
# Capability Capability Contract
This generated contract is a self-contained derivative of the Paperclip skill, its seven references, the Paperclip Evals corpus, and the legacy MCP tool surface. It does not import or contact the Paperclip control plane.
The skill/reference inventory and eval cases are the only normative behavior sources. Paperclip does not use the legacy MCP calls as a production capability surface; all MCP names below are traceability aliases folded into normative eval rows. Their disposition, grants, assertions, and evidence contract are inherited from the target row rather than classified independently.
## Baseline Counts
- Skill/reference headings: 152
- Eval cases: 106 across 16 groups
- Total normative rows: 258
- Legacy MCP aliases folded into normative rows: 42
| Eval group | Cases |
| --- | ---: |
| hb | 5 |
| co | 6 |
| st | 8 |
| cm | 6 |
| se | 4 |
| su | 4 |
| bl | 5 |
| dp | 3 |
| ix | 9 |
| ap | 6 |
| ar | 4 |
| er | 9 |
| rf | 22 |
| mh | 4 |
| rs | 3 |
| wk | 8 |
## Regeneration
- `pnpm --dir packages/paperclip-runner generate:capability-inventory` imports the canonical baselines and rewrites every generated file.
- `pnpm --dir packages/paperclip-runner check:capability-inventory` validates counts, uniqueness, normative dispositions, one-to-one MCP folds, required fields, and generated-file drift without requiring the external eval repository.
## Skill / Reference Rows
| Capability | Primary disposition | Source anchor |
| --- | --- | --- |
| skill:skills/paperclip/SKILL.md:paperclip-skill:10 | optional_agent_tool | skills/paperclip/SKILL.md:10 |
| skill:skills/paperclip/SKILL.md:terminology:14 | optional_agent_tool | skills/paperclip/SKILL.md:14 |
| skill:skills/paperclip/SKILL.md:authentication:18 | control_plane_owned | skills/paperclip/SKILL.md:18 |
| skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:30 | optional_agent_tool | skills/paperclip/SKILL.md:30 |
| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:101 | always_agent_tool | skills/paperclip/SKILL.md:101 |
| skill:skills/paperclip/SKILL.md:status-quick-guide:148 | control_plane_owned | skills/paperclip/SKILL.md:148 |
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:158 | optional_agent_tool | skills/paperclip/SKILL.md:158 |
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:171 | always_agent_tool | skills/paperclip/SKILL.md:171 |
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:182 | control_plane_owned | skills/paperclip/SKILL.md:182 |
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:190 | control_plane_owned | skills/paperclip/SKILL.md:190 |
| skill:skills/paperclip/SKILL.md:requesting-board-approval:215 | optional_agent_tool | skills/paperclip/SKILL.md:215 |
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:236 | optional_agent_tool | skills/paperclip/SKILL.md:236 |
| skill:skills/paperclip/SKILL.md:standalone-decisions:265 | optional_agent_tool | skills/paperclip/SKILL.md:265 |
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:369 | optional_agent_tool | skills/paperclip/SKILL.md:369 |
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:411 | optional_agent_tool | skills/paperclip/SKILL.md:411 |
| skill:skills/paperclip/SKILL.md:cases:421 | optional_agent_tool | skills/paperclip/SKILL.md:421 |
| skill:skills/paperclip/SKILL.md:company-skills-workflow:426 | optional_agent_tool | skills/paperclip/SKILL.md:426 |
| skill:skills/paperclip/SKILL.md:routines:437 | optional_agent_tool | skills/paperclip/SKILL.md:437 |
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:448 | optional_agent_tool | skills/paperclip/SKILL.md:448 |
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:455 | optional_agent_tool | skills/paperclip/SKILL.md:455 |
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:462 | optional_agent_tool | skills/paperclip/SKILL.md:462 |
| skill:skills/paperclip/SKILL.md:critical-rules:488 | optional_agent_tool | skills/paperclip/SKILL.md:488 |
| skill:skills/paperclip/SKILL.md:comment-style-required:512 | always_agent_tool | skills/paperclip/SKILL.md:512 |
| skill:skills/paperclip/SKILL.md:update:544 | optional_agent_tool | skills/paperclip/SKILL.md:544 |
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:554 | optional_agent_tool | skills/paperclip/SKILL.md:554 |
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:587 | optional_agent_tool | skills/paperclip/SKILL.md:587 |
| skill:skills/paperclip/SKILL.md:searching-issues:616 | optional_agent_tool | skills/paperclip/SKILL.md:616 |
| skill:skills/paperclip/SKILL.md:full-reference:626 | optional_agent_tool | skills/paperclip/SKILL.md:626 |
| skill:skills/paperclip/references/artifacts.md:generated-artifacts-and-work-products:1 | always_agent_tool | skills/paperclip/references/artifacts.md:1 |
| skill:skills/paperclip/references/artifacts.md:workspace-only-file-references:15 | optional_agent_tool | skills/paperclip/references/artifacts.md:15 |
| skill:skills/paperclip/references/cases.md:cases:1 | optional_agent_tool | skills/paperclip/references/cases.md:1 |
| skill:skills/paperclip/references/cases.md:core-model:12 | optional_agent_tool | skills/paperclip/references/cases.md:12 |
| skill:skills/paperclip/references/cases.md:upsert-semantics:29 | optional_agent_tool | skills/paperclip/references/cases.md:29 |
| skill:skills/paperclip/references/cases.md:read-and-search:67 | optional_agent_tool | skills/paperclip/references/cases.md:67 |
| skill:skills/paperclip/references/cases.md:documents:90 | always_agent_tool | skills/paperclip/references/cases.md:90 |
| skill:skills/paperclip/references/cases.md:fields:118 | optional_agent_tool | skills/paperclip/references/cases.md:118 |
| skill:skills/paperclip/references/cases.md:issue-links:151 | optional_agent_tool | skills/paperclip/references/cases.md:151 |
| skill:skills/paperclip/references/cases.md:child-cases:176 | optional_agent_tool | skills/paperclip/references/cases.md:176 |
| skill:skills/paperclip/references/cases.md:attachments:195 | optional_agent_tool | skills/paperclip/references/cases.md:195 |
| skill:skills/paperclip/references/cases.md:lifecycle:208 | optional_agent_tool | skills/paperclip/references/cases.md:208 |
| skill:skills/paperclip/references/cases.md:worked-blog-post-example:222 | optional_agent_tool | skills/paperclip/references/cases.md:222 |
| skill:skills/paperclip/references/company-skills.md:company-skills-workflow:1 | optional_agent_tool | skills/paperclip/references/company-skills.md:1 |
| skill:skills/paperclip/references/company-skills.md:what-exists:5 | optional_agent_tool | skills/paperclip/references/company-skills.md:5 |
| skill:skills/paperclip/references/company-skills.md:permission-model:22 | optional_agent_tool | skills/paperclip/references/company-skills.md:22 |
| skill:skills/paperclip/references/company-skills.md:core-endpoints:29 | optional_agent_tool | skills/paperclip/references/company-skills.md:29 |
| skill:skills/paperclip/references/company-skills.md:install-a-skill-into-the-company:65 | optional_agent_tool | skills/paperclip/references/company-skills.md:65 |
| skill:skills/paperclip/references/company-skills.md:app-shipped-catalog:75 | optional_agent_tool | skills/paperclip/references/company-skills.md:75 |
| skill:skills/paperclip/references/company-skills.md:external-source-import:101 | optional_agent_tool | skills/paperclip/references/company-skills.md:101 |
| skill:skills/paperclip/references/company-skills.md:source-types-in-order-of-preference:105 | optional_agent_tool | skills/paperclip/references/company-skills.md:105 |
| skill:skills/paperclip/references/company-skills.md:example-skills-sh-import-preferred:116 | optional_agent_tool | skills/paperclip/references/company-skills.md:116 |
| skill:skills/paperclip/references/company-skills.md:example-github-import:138 | optional_agent_tool | skills/paperclip/references/company-skills.md:138 |
| skill:skills/paperclip/references/company-skills.md:inspect-what-was-installed:164 | optional_agent_tool | skills/paperclip/references/company-skills.md:164 |
| skill:skills/paperclip/references/company-skills.md:assign-skills-to-an-existing-agent:181 | optional_agent_tool | skills/paperclip/references/company-skills.md:181 |
| skill:skills/paperclip/references/company-skills.md:include-skills-during-hire-or-create:216 | optional_agent_tool | skills/paperclip/references/company-skills.md:216 |
| skill:skills/paperclip/references/company-skills.md:notes:256 | optional_agent_tool | skills/paperclip/references/company-skills.md:256 |
| skill:skills/paperclip/references/issue-workspaces.md:issue-workspace-runtime-controls:1 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:1 |
| skill:skills/paperclip/references/issue-workspaces.md:discover-the-workspace:5 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:5 |
| skill:skills/paperclip/references/issue-workspaces.md:control-services:23 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:23 |
| skill:skills/paperclip/references/issue-workspaces.md:start-all-configured-services-waits-for-configured-readiness-checks:28 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:28 |
| skill:skills/paperclip/references/issue-workspaces.md:restart-all-configured-services:36 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:36 |
| skill:skills/paperclip/references/issue-workspaces.md:stop-all-running-services:44 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:44 |
| skill:skills/paperclip/references/issue-workspaces.md:read-the-url:63 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:63 |
| skill:skills/paperclip/references/issue-workspaces.md:mcp-tools:72 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:72 |
| skill:skills/paperclip/references/routines.md:paperclip-routines:1 | optional_agent_tool | skills/paperclip/references/routines.md:1 |
| skill:skills/paperclip/references/routines.md:lifecycle:16 | optional_agent_tool | skills/paperclip/references/routines.md:16 |
| skill:skills/paperclip/references/routines.md:creating-a-routine:27 | optional_agent_tool | skills/paperclip/references/routines.md:27 |
| skill:skills/paperclip/references/routines.md:concurrency-policies:64 | optional_agent_tool | skills/paperclip/references/routines.md:64 |
| skill:skills/paperclip/references/routines.md:catch-up-policies:76 | optional_agent_tool | skills/paperclip/references/routines.md:76 |
| skill:skills/paperclip/references/routines.md:activity-gated-scheduled-runs:87 | optional_agent_tool | skills/paperclip/references/routines.md:87 |
| skill:skills/paperclip/references/routines.md:example-skip-quiet-nights:107 | optional_agent_tool | skills/paperclip/references/routines.md:107 |
| skill:skills/paperclip/references/routines.md:adding-triggers:126 | optional_agent_tool | skills/paperclip/references/routines.md:126 |
| skill:skills/paperclip/references/routines.md:schedule-cron:136 | optional_agent_tool | skills/paperclip/references/routines.md:136 |
| skill:skills/paperclip/references/routines.md:webhook:150 | optional_agent_tool | skills/paperclip/references/routines.md:150 |
| skill:skills/paperclip/references/routines.md:api-manual-only:167 | optional_agent_tool | skills/paperclip/references/routines.md:167 |
| skill:skills/paperclip/references/routines.md:updating-and-deleting-triggers:179 | optional_agent_tool | skills/paperclip/references/routines.md:179 |
| skill:skills/paperclip/references/routines.md:manual-run:196 | optional_agent_tool | skills/paperclip/references/routines.md:196 |
| skill:skills/paperclip/references/routines.md:updating-a-routine:212 | optional_agent_tool | skills/paperclip/references/routines.md:212 |
| skill:skills/paperclip/references/routines.md:reading-routines-and-runs:223 | optional_agent_tool | skills/paperclip/references/routines.md:223 |
| skill:skills/paperclip/references/workflows.md:paperclip-workflow-playbooks:1 | optional_agent_tool | skills/paperclip/references/workflows.md:1 |
| skill:skills/paperclip/references/workflows.md:project-setup-ceo-manager:7 | optional_agent_tool | skills/paperclip/references/workflows.md:7 |
| skill:skills/paperclip/references/workflows.md:openclaw-invite-ceo:22 | optional_agent_tool | skills/paperclip/references/workflows.md:22 |
| skill:skills/paperclip/references/workflows.md:setting-agent-instructions-path:50 | optional_agent_tool | skills/paperclip/references/workflows.md:50 |
| skill:skills/paperclip/references/workflows.md:company-import-export:79 | optional_agent_tool | skills/paperclip/references/workflows.md:79 |
| skill:skills/paperclip/references/workflows.md:self-test-playbook-app-level:106 | optional_agent_tool | skills/paperclip/references/workflows.md:106 |
| skill:skills/paperclip/references/api-reference.md:paperclip-api-reference:1 | optional_agent_tool | skills/paperclip/references/api-reference.md:1 |
| skill:skills/paperclip/references/api-reference.md:response-schemas:7 | optional_agent_tool | skills/paperclip/references/api-reference.md:7 |
| skill:skills/paperclip/references/api-reference.md:agent-record-get-api-agents-me-or-get-api-agents-agentid:9 | optional_agent_tool | skills/paperclip/references/api-reference.md:9 |
| skill:skills/paperclip/references/api-reference.md:company-portability:42 | optional_agent_tool | skills/paperclip/references/api-reference.md:42 |
| skill:skills/paperclip/references/api-reference.md:issue-with-ancestors-get-api-issues-issueid:108 | optional_agent_tool | skills/paperclip/references/api-reference.md:108 |
| skill:skills/paperclip/references/api-reference.md:issue-update-response-patch-api-issues-issueid:194 | optional_agent_tool | skills/paperclip/references/api-reference.md:194 |
| skill:skills/paperclip/references/api-reference.md:blocker-diagnostics-get-api-issues-issueid-diagnostics-blockers:236 | control_plane_owned | skills/paperclip/references/api-reference.md:236 |
| skill:skills/paperclip/references/api-reference.md:wake-diagnostics-get-api-issues-issueid-diagnostics-wakes:275 | control_plane_owned | skills/paperclip/references/api-reference.md:275 |
| skill:skills/paperclip/references/api-reference.md:subtree-diagnostics-get-api-issues-issueid-diagnostics-subtree:319 | optional_agent_tool | skills/paperclip/references/api-reference.md:319 |
| skill:skills/paperclip/references/api-reference.md:execution-policy-fields-on-an-issue:367 | optional_agent_tool | skills/paperclip/references/api-reference.md:367 |
| skill:skills/paperclip/references/api-reference.md:cross-agent-review-gates:419 | always_agent_tool | skills/paperclip/references/api-reference.md:419 |
| skill:skills/paperclip/references/api-reference.md:worked-example-ic-heartbeat:452 | optional_agent_tool | skills/paperclip/references/api-reference.md:452 |
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:457 | control_plane_owned | skills/paperclip/references/api-reference.md:457 |
| skill:skills/paperclip/references/api-reference.md:2-check-inbox:461 | control_plane_owned | skills/paperclip/references/api-reference.md:461 |
| skill:skills/paperclip/references/api-reference.md:3-already-have-issue-101-inprogress-highest-priority-continue-it:468 | optional_agent_tool | skills/paperclip/references/api-reference.md:468 |
| skill:skills/paperclip/references/api-reference.md:4-do-the-actual-work-write-code-run-tests:475 | optional_agent_tool | skills/paperclip/references/api-reference.md:475 |
| skill:skills/paperclip/references/api-reference.md:5-work-is-done-update-status-and-comment-in-one-call:477 | always_agent_tool | skills/paperclip/references/api-reference.md:477 |
| skill:skills/paperclip/references/api-reference.md:6-still-have-time-checkout-the-next-task:481 | control_plane_owned | skills/paperclip/references/api-reference.md:481 |
| skill:skills/paperclip/references/api-reference.md:7-made-partial-progress-not-done-yet-comment-and-exit:488 | always_agent_tool | skills/paperclip/references/api-reference.md:488 |
| skill:skills/paperclip/references/api-reference.md:worked-example-report-a-board-user-s-mine-inbox:493 | control_plane_owned | skills/paperclip/references/api-reference.md:493 |
| skill:skills/paperclip/references/api-reference.md:board-user-created-the-requesting-issue:498 | optional_agent_tool | skills/paperclip/references/api-reference.md:498 |
| skill:skills/paperclip/references/api-reference.md:fetch-the-board-user-s-mine-inbox-issues:502 | control_plane_owned | skills/paperclip/references/api-reference.md:502 |
| skill:skills/paperclip/references/api-reference.md:summarize-it-back-to-the-board-in-a-comment-or-document:516 | always_agent_tool | skills/paperclip/references/api-reference.md:516 |
| skill:skills/paperclip/references/api-reference.md:worked-example-archive-a-resolved-inbox-item:521 | control_plane_owned | skills/paperclip/references/api-reference.md:521 |
| skill:skills/paperclip/references/api-reference.md:the-responsible-user-s-id-is-resolved-from-the-authenticated-agent-run:526 | optional_agent_tool | skills/paperclip/references/api-reference.md:526 |
| skill:skills/paperclip/references/api-reference.md:reverse-the-archive-if-it-was-premature-or-no-longer-desired:535 | optional_agent_tool | skills/paperclip/references/api-reference.md:535 |
| skill:skills/paperclip/references/api-reference.md:worked-example-reviewer-approver-heartbeat:545 | always_agent_tool | skills/paperclip/references/api-reference.md:545 |
| skill:skills/paperclip/references/api-reference.md:worked-example-manager-heartbeat:584 | optional_agent_tool | skills/paperclip/references/api-reference.md:584 |
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:587 | control_plane_owned | skills/paperclip/references/api-reference.md:587 |
| skill:skills/paperclip/references/api-reference.md:2-check-team-status:591 | optional_agent_tool | skills/paperclip/references/api-reference.md:591 |
| skill:skills/paperclip/references/api-reference.md:3-agent-42-is-blocked-read-comments:598 | control_plane_owned | skills/paperclip/references/api-reference.md:598 |
| skill:skills/paperclip/references/api-reference.md:4-unblock-reassign-and-comment:602 | control_plane_owned | skills/paperclip/references/api-reference.md:602 |
| skill:skills/paperclip/references/api-reference.md:5-check-own-assignments:606 | optional_agent_tool | skills/paperclip/references/api-reference.md:606 |
| skill:skills/paperclip/references/api-reference.md:6-create-subtasks-and-delegate:613 | optional_agent_tool | skills/paperclip/references/api-reference.md:613 |
| skill:skills/paperclip/references/api-reference.md:load-tests-depend-on-caching-layer-being-done-first-paperclip-will-auto-wake-agent-55-when-the-blocker-resolves:619 | control_plane_owned | skills/paperclip/references/api-reference.md:619 |
| skill:skills/paperclip/references/api-reference.md:7-dashboard-for-health-check:624 | optional_agent_tool | skills/paperclip/references/api-reference.md:624 |
| skill:skills/paperclip/references/api-reference.md:comments-and-mentions:630 | always_agent_tool | skills/paperclip/references/api-reference.md:630 |
| skill:skills/paperclip/references/api-reference.md:update:637 | optional_agent_tool | skills/paperclip/references/api-reference.md:637 |
| skill:skills/paperclip/references/api-reference.md:cross-team-work-and-delegation:675 | optional_agent_tool | skills/paperclip/references/api-reference.md:675 |
| skill:skills/paperclip/references/api-reference.md:receiving-cross-team-work:679 | optional_agent_tool | skills/paperclip/references/api-reference.md:679 |
| skill:skills/paperclip/references/api-reference.md:escalation:689 | optional_agent_tool | skills/paperclip/references/api-reference.md:689 |
| skill:skills/paperclip/references/api-reference.md:company-context:699 | optional_agent_tool | skills/paperclip/references/api-reference.md:699 |
| skill:skills/paperclip/references/api-reference.md:company-branding-ceo-board:711 | optional_agent_tool | skills/paperclip/references/api-reference.md:711 |
| skill:skills/paperclip/references/api-reference.md:openclaw-invite-prompt-ceo:731 | optional_agent_tool | skills/paperclip/references/api-reference.md:731 |
| skill:skills/paperclip/references/api-reference.md:setting-agent-instructions-path:750 | optional_agent_tool | skills/paperclip/references/api-reference.md:750 |
| skill:skills/paperclip/references/api-reference.md:project-setup-create-workspace:783 | optional_agent_tool | skills/paperclip/references/api-reference.md:783 |
| skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:787 | optional_agent_tool | skills/paperclip/references/api-reference.md:787 |
| skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:806 | optional_agent_tool | skills/paperclip/references/api-reference.md:806 |
| skill:skills/paperclip/references/api-reference.md:governance-and-approvals:835 | optional_agent_tool | skills/paperclip/references/api-reference.md:835 |
| skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:839 | optional_agent_tool | skills/paperclip/references/api-reference.md:839 |
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:859 | optional_agent_tool | skills/paperclip/references/api-reference.md:859 |
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:868 | always_agent_tool | skills/paperclip/references/api-reference.md:868 |
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:926 | always_agent_tool | skills/paperclip/references/api-reference.md:926 |
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1041 | optional_agent_tool | skills/paperclip/references/api-reference.md:1041 |
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1151 | optional_agent_tool | skills/paperclip/references/api-reference.md:1151 |
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1157 | always_agent_tool | skills/paperclip/references/api-reference.md:1157 |
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1175 | always_agent_tool | skills/paperclip/references/api-reference.md:1175 |
| skill:skills/paperclip/references/api-reference.md:error-handling:1205 | control_plane_owned | skills/paperclip/references/api-reference.md:1205 |
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1219 | optional_agent_tool | skills/paperclip/references/api-reference.md:1219 |
| skill:skills/paperclip/references/api-reference.md:agents:1221 | optional_agent_tool | skills/paperclip/references/api-reference.md:1221 |
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1242 | optional_agent_tool | skills/paperclip/references/api-reference.md:1242 |
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1282 | optional_agent_tool | skills/paperclip/references/api-reference.md:1282 |
| skill:skills/paperclip/references/api-reference.md:routines:1306 | optional_agent_tool | skills/paperclip/references/api-reference.md:1306 |
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1322 | optional_agent_tool | skills/paperclip/references/api-reference.md:1322 |
| skill:skills/paperclip/references/api-reference.md:secrets:1344 | optional_agent_tool | skills/paperclip/references/api-reference.md:1344 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1357 | optional_agent_tool | skills/paperclip/references/api-reference.md:1357 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1457 | optional_agent_tool | skills/paperclip/references/api-reference.md:1457 |
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1497 | optional_agent_tool | skills/paperclip/references/api-reference.md:1497 |
## Legacy MCP Alias Index
This is a compatibility/traceability index, not a tool catalog. “Inherited disposition” is shown only to make the normative target easy to audit.
| Legacy MCP name | Folded into normative row | Inherited disposition | Source anchor |
| --- | --- | --- | --- |
| paperclipMe | eval:hb-inbox-lite-01 | control_plane_owned | packages/mcp-server/src/tools.ts:290 |
| paperclipInboxLite | eval:hb-inbox-lite-01 | control_plane_owned | packages/mcp-server/src/tools.ts:296 |
| paperclipListAgents | eval:rf-api-mgr-heartbeat-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:302 |
| paperclipListSkills | eval:rf-cskill-audit-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:308 |
| paperclipGetAgent | eval:rf-api-mgr-heartbeat-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:314 |
| paperclipListIssues | eval:se-q-filters-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:323 |
| paperclipGetIssue | eval:se-get-issue-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:338 |
| paperclipGetHeartbeatContext | eval:hb-context-01 | control_plane_owned | packages/mcp-server/src/tools.ts:344 |
| paperclipListComments | eval:se-get-issue-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:353 |
| paperclipGetComment | eval:hb-wake-comment-01 | control_plane_owned | packages/mcp-server/src/tools.ts:366 |
| paperclipListIssueApprovals | eval:ap-board-approval-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:373 |
| paperclipListDocuments | eval:dp-base-revision-01 | always_agent_tool | packages/mcp-server/src/tools.ts:379 |
| paperclipGetDocument | eval:dp-base-revision-01 | always_agent_tool | packages/mcp-server/src/tools.ts:385 |
| paperclipListDocumentRevisions | eval:dp-base-revision-01 | always_agent_tool | packages/mcp-server/src/tools.ts:392 |
| paperclipListProjects | eval:rf-wf-project-setup-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:402 |
| paperclipGetProject | eval:rf-wf-project-setup-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:408 |
| paperclipGetIssueWorkspaceRuntime | eval:rf-iws-start-url-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:417 |
| paperclipControlIssueWorkspaceServices | eval:rf-iws-start-url-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:423 |
| paperclipWaitForIssueWorkspaceService | eval:rf-iws-target-restart-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:440 |
| paperclipListGoals | eval:su-parent-goal-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:466 |
| paperclipGetGoal | eval:su-parent-goal-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:472 |
| paperclipListApprovals | eval:ap-approval-wake-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:478 |
| paperclipCreateApproval | eval:ap-board-approval-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:487 |
| paperclipGetApproval | eval:ap-approval-wake-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:496 |
| paperclipGetApprovalIssues | eval:ap-approval-wake-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:502 |
| paperclipListApprovalComments | eval:ap-approval-deny-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:508 |
| paperclipCreateIssue | eval:su-parent-goal-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:514 |
| paperclipUpdateIssue | eval:st-done-comment-01 | always_agent_tool | packages/mcp-server/src/tools.ts:521 |
| paperclipCheckoutIssue | eval:co-body-contract-01 | control_plane_owned | packages/mcp-server/src/tools.ts:528 |
| paperclipReleaseIssue | eval:er-release-01 | control_plane_owned | packages/mcp-server/src/tools.ts:540 |
| paperclipAddComment | eval:cm-multiline-01 | always_agent_tool | packages/mcp-server/src/tools.ts:546 |
| paperclipSuggestTasks | eval:ix-suggest-tasks-01 | always_agent_tool | packages/mcp-server/src/tools.ts:553 |
| paperclipAskUserQuestions | eval:ix-questions-01 | always_agent_tool | packages/mcp-server/src/tools.ts:565 |
| paperclipRequestConfirmation | eval:ix-confirmation-plan-01 | always_agent_tool | packages/mcp-server/src/tools.ts:577 |
| paperclipRequestCheckboxConfirmation | eval:ix-checkbox-01 | always_agent_tool | packages/mcp-server/src/tools.ts:589 |
| paperclipUpsertIssueDocument | eval:dp-plan-doc-01 | always_agent_tool | packages/mcp-server/src/tools.ts:601 |
| paperclipRestoreIssueDocumentRevision | eval:dp-base-revision-01 | always_agent_tool | packages/mcp-server/src/tools.ts:612 |
| paperclipLinkIssueApproval | eval:ap-board-approval-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:627 |
| paperclipUnlinkIssueApproval | eval:ap-board-approval-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:636 |
| paperclipApprovalDecision | eval:ap-approval-wake-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:646 |
| paperclipAddApprovalComment | eval:ap-approval-deny-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:668 |
| paperclipApiRequest | eval:rf-api-404-report-01 | optional_agent_tool | packages/mcp-server/src/tools.ts:677 |
@@ -0,0 +1,84 @@
# Capability Capability Disposition
This page explains how Capability classifies every Paperclip capability. The
classification itself lives in a generated file,
[the Capability contract](capability-contract.md), which
carries a "DO NOT EDIT" header and is rewritten by `generate:capability-inventory`.
Read this page to understand what the generated table means; read the table for
the authoritative rows.
## Normative sources
Only two sources are normative:
1. The Paperclip skill and its seven references (`SKILL.md` plus
`references/*.md`), contributing **152 headings**.
2. The Paperclip Evals corpus, contributing **106 cases across 16 groups**.
Together these produce **258 normative rows**. The legacy Paperclip MCP tool
surface (**41 tools**) is not a production capability surface; each MCP name is
folded one-to-one into a normative eval row as a traceability alias and inherits
that row's disposition. The contract prints the alias index only so the
normative target is easy to audit.
## The three dispositions
Every capability is classified as exactly one of:
- **`control_plane_owned`** — the control plane performs this; no agent tool
exists for it. Checkout, inbox resolution, blocker diagnostics, and status
arbitration are control-plane-owned. In the explorer these appear as
control-plane actions labelled "no agent tool exists for this," never as a
callable tool.
- **`always_agent_tool`** — the agent always has this as a semantic tool. Adding
a comment, writing a document, opening an interaction, and registering a
deliverable are always-agent-tool capabilities.
- **`optional_agent_tool`** — the agent may have this tool, but only when a
grant unlocks it. Absent the grant, the capability is not exposed and calling
it is denied. See [authorization and exposure](capability-authorization-and-exposure.md).
The disposition is what makes the boundary legible: it states, per capability,
whether an agent can act, must wait for the control plane, or needs a grant
first.
## Reading the generated contract
The contract has three parts:
- **Baseline counts** — the heading, case, row, and alias totals above, plus the
per-group case table.
- **Skill / reference rows** — one row per heading, its primary disposition, and
its `file:line` source anchor.
- **Legacy MCP alias index** — each MCP name, the normative row it folds into,
the inherited disposition, and its `packages/mcp-server/src/tools.ts` anchor.
## Regenerating and checking
Generation reads the live in-repo skill/reference sources, the legacy MCP tool
source, and the Paperclip Evals corpus, so it **requires** the external eval
repository (via `PAPERCLIP_EVALS_ROOT` or a known local path) and is not part of
the offline path. Checking and testing read only the checked-in derivatives
under `spec/capability/` and need no external repository.
```sh
# Rewrite every generated file. Requires the external Paperclip Evals corpus.
pnpm --filter @paperclipai/paperclip-runner generate:capability-inventory
# Validate counts, uniqueness, normative dispositions, one-to-one MCP folds,
# required fields, and generated-file drift. Offline; no external eval repo.
pnpm --filter @paperclipai/paperclip-runner check:capability-inventory
# Prove the validator rejects an independent MCP classification and rejects
# missing, duplicate, or unknown MCP folds. Offline.
pnpm --filter @paperclipai/paperclip-runner test:capability-inventory
```
`check:capability-inventory` diffs the checked-in generated files against what the
live in-repo sources imply and fails on any drift or stale anchor, so the
contract cannot silently fall out of sync.
## Related
- [Semantic tool catalog](capability-semantic-tools.md)
- [Authorization and exposure](capability-authorization-and-exposure.md)
- [Eval conformance](capability-eval-conformance.md)
@@ -0,0 +1,90 @@
# Capability Eval-Derived Conformance
Capability turns the Paperclip Evals corpus into an executable, offline
conformance suite. The suite derives its cases from the checked-in Capability
traceability derivative (`spec/capability/eval-traceability.yaml`) — it does
**not** clone or read an external eval repository, and it starts only the
in-process mock control plane. It hard-fails unless the derivative declares
schema version 2, exactly 106 rows, exactly 16 groups, and unique case IDs.
Sources: `src/conformance/capability-eval-suite.ts` and its test
`src/conformance/capability-eval-suite.test.ts`; the reporter
`scripts/run-capability-eval-suite.mjs`.
## The corpus
- **106 cases across 16 groups.** Per-group counts: hb 5, co 6, st 8, cm 6,
se 4, su 4, bl 5, dp 3, ix 9, ap 6, ar 4, er 9, rf 22, mh 4, rs 3, wk 8.
- Each case declares an actor role, a task mode, an input scenario, and the
expected outcome, all bound to a row in
[the capability contract](capability-contract.md).
## Assertion classes
Every case belongs to one assertion class, and each class checks a different
kind of invariant:
- **`agent_tool_contract`** — a semantic tool call produces the expected typed
effect and state change.
- **`authorization_policy`** — a capability is exposed, denied, or unlocked by a
grant exactly as its disposition requires.
- **`control_plane_invariant`** — a control-plane-owned action happens without
any agent tool, and no tool can perform it.
- **`combined_multi_hop`** — a sequence of operations across turns produces the
expected cumulative state and respects forbidden-operation rules.
- **`restraint_no_call`** — the correct behavior is to make **no** call; the
case passes only if the agent deliberately does nothing further.
## Fake-agent matrix and bounded Codex sample
The suite executes each case with a deterministic fake agent whose plan is
fixed by a fixture seed, so repeat runs are byte-identical. Optional-tool rows
are run twice — once with the unlocking grants (must be allowed and must mutate
state) and once ungranted (must be absent or denied with no state change);
control-plane-owned rows remain absent in every configuration; and
`restraint_no_call` rows must produce an empty state diff.
The fake-agent surface is 14 always-agent tools plus 4 optional tools unlocked
by four seed grants (`discovery:tasks:read`, `discovery:agents:read`,
`delegation:tasks:create`, `governance:approvals:request`) — **18 operations**.
The suite binds that surface through both the fake-agent and Codex bindings and
asserts the two operation lists are byte-identical (**18/18**).
A **bounded Codex binding sample** picks one representative case from nine
groups (`hb`, `dp`, `bl`, `ap`, `ar`, `ix`, `mh`, `rs`, `wk`) and checks that
`checkout_task` is absent from the Codex surface while every other sampled
operation is present. It is an offline parity check; no real Codex or network is
contacted, and the browser explorer holds no credential.
## Running it
```sh
# Run the 106-case suite in-process (one vitest file drives all cases).
pnpm --filter @paperclipai/paperclip-runner test:capability-evals
# Build the public surface and write the parity report with per-group counts,
# assertion classes, the fake-agent matrix, the bounded Codex sample, and the
# semantic-operation execution counts.
pnpm --filter @paperclipai/paperclip-runner report:capability-evals
```
The reporter writes `.paperclip-local/evidence/capability/eval-parity-report.{json,md}`.
Run the bounded provider conformance matrix separately. It creates exactly one
real Codex turn for each of the 16 checked-in eval groups while retaining the
in-process mock control plane:
```sh
pnpm --filter @paperclipai/paperclip-runner report:capability-live-evals
```
Each failure carries its case ID, assertion class, semantic operation,
authorization decision, and final state diff. The report is generated on demand
and is not committed; delete it before running `docs:validate` (it carries no
OKF frontmatter). See the
[verification commands reference](capability-verification-commands.md).
## Related
- [Capability disposition](capability-disposition.md)
- [Semantic tool catalog](capability-semantic-tools.md)
- [Scenario explorer](capability-scenario-explorer.md)
@@ -0,0 +1,87 @@
# Capability execution modes and identity
Capability runs the same semantic tool catalog, authorization engine, and mock
`ControlPlanePort` under two agent modes. Whichever mode is active, the
surface always names three separate actors so a reader never mistakes one for
another.
## Three actors, always separate
| Actor | What it is in Capability | What it is **not** |
| --- | --- | --- |
| **Real Codex** | A real Codex app-server session driving the turn loop. The agent has no Paperclip skill; it sees only the semantic tools the scenario exposes. | Not a scripted stand-in, and not a Paperclip-aware agent. |
| **Real runnerd** | The real package-local `paperclip-runnerd` binary. It owns the Codex child process group and proxies newline-delimited JSON-RPC over stdio. | Not an in-process fake and not the production Paperclip runtime. |
| **Mock Paperclip** | The deterministic in-process `ControlPlanePort` adapter. It holds every issue, comment, document, interaction, approval, and audit record as mock state. | Not the Paperclip control plane, database, or API. No request leaves the process for a Paperclip service. |
The live surface renders a `Real Codex`, `Real runnerd`, and `Mock Paperclip`
marker at all times. Mock records carry an `MCK-` identifier prefix so a mock
issue is never confused with a real `PAP-` issue.
## Two agent modes
### Scripted (deterministic) — fake mode
The scripted driver replays a recorded conversation over the same mock core.
It needs no provider credential, no runnerd process, and no network after
`pnpm install`. It is the mode used for:
- the 106-case conformance suite and its bounded parity report;
- the byte-stable screenshot matrix;
- replay of a recorded session.
Because it is fully deterministic, two runs of the same route produce
byte-identical output. **A scripted (`mode=fake`) artifact cannot satisfy a
live acceptance criterion** — it proves the contract, not a real provider turn.
### Codex (bounded) — live mode
Live mode starts a real `paperclip-runnerd` process and a real Codex
app-server session. The package server — never the browser — owns the Codex
process, the session, the tool loop, and provider authentication. The browser
receives no provider, runner, mock-control-plane, or real-control-plane
credential.
Live mode requires a locally authenticated Codex installation. When that relay
is not present, the UI keeps Codex disabled with a named reason and scripted
mode stays available. Live mode is the only mode eligible for the final
Revision 3/4 acceptance criteria that require a real provider turn.
## Mode-independent controls
These behave the same in either mode because they operate on the mock core and
the runner session, not on the agent:
- **Reset** restores the original clean mock seed under new run/session
authority and rotates or clears the old session authority. It does not
affect another browser session's state.
- **Stop** cancels only the active turn and reaps the runnerd process group;
the session and its transcript survive. Cleanup evidence shows no abandoned
child process and no active session afterward.
- **Replay** reproduces a recorded canonical timeline. It is always scripted
and always labelled as fake-derived, even inside a session that also ran a
live turn.
- **Refresh / reconnect** restores the same durable session, pending
interaction, transcript, and mock state. Reconnect starts a fresh runnerd and
Codex app-server and resumes the persisted provider thread; the session does
not restart because the browser reconnected.
## Two entry points, one live mode
Live mode is reachable from both primary surfaces:
- the preset scenario explorer, with `mode=live` on a chosen scenario;
- the [clean-room chat](capability-clean-room-chat.md) at `#/chat`, which has no
other mode. It starts blank on a freshly minted mock tenant, pins `mode=live`
in the route, and reports a failure to start real Codex rather than falling
back to a fixture or a recording.
## Eligibility summary
| Evidence | Eligible for |
| --- | --- |
| Scripted / `mode=fake` run | 106-case conformance, replay determinism, screenshot matrix, contract proofs |
| Live Codex run on the final build | Revision 3/4 final acceptance criteria that require a real provider turn |
| Revision 2 preview (build `da0d32d74a`, historical URL) | Historical comparison only — **not** eligible for final acceptance |
See the [live runnerd and Codex loop](capability-live-runnerd-codex.md) reference
for the session API and verification commands behind each row above.
@@ -0,0 +1,51 @@
# Capability Future Binding Boundary (future upload integration / ACPX)
Capability is a package-local model. It does not integrate the runner into
Paperclip, and it must not. This page states exactly what Capability defers to
future upload integration and what clean seam it preserves so the deferral stays cheap.
## What Capability does not do
Nothing in Capability contacts a real Paperclip control plane, database, ACPX
session, or provider credential. The 106-case conformance suite and the browser
explorer run entirely against the in-process
[mock ControlPlanePort](capability-mock-control-plane-port.md) and checked-in
fixtures. The [forbidden-imports boundary](architecture.md) rejects any import
that crosses into `server/`, `ui/`, `cli/`, `@paperclipai/db`, or other
Paperclip workspace internals.
## The preserved seam
The dependency arrow always points from an implementation toward a contract.
The package owns two contracts a future consumer implements:
- **`ControlPlanePort`** — the narrow surface through which a runner opens a
run, appends ordered events, records semantic operations, and submits a
terminal result. Capability injects the *mock* adapter behind this port; future upload integration
injects a real one. The catalog, authorization engine, and conformance suite
bind to the port, not to any adapter.
- **`NativeSessionBackend`** — the normalized session surface for a future
control-plane consumer.
Because the port is the only coupling point, future upload integration replaces the adapter
without touching the tool catalog, the authorization rules, or the eval-derived
conformance suite. The package remains independently buildable, testable, and
runnable against the mock adapter after the real one exists.
## What future upload integration (ACPX) will bind
future upload integration binds a real Paperclip `ControlPlanePort` implementation behind the same
seam so the semantic tools and authorization engine act against a live control
plane instead of the mock. That work is out of scope here and requires separate
CTO approval at the Capability checkpoint (`TASK-16908`). Until then:
- ACPX is future upload integration, not Capability.
- No Capability documentation claims future upload integration capability.
- Real integration is a separately reviewed phase, consistent with the
[future integration rule](architecture.md) recorded for every prior phase.
## Related
- [Architecture and dependency boundary](architecture.md)
- [Mock ControlPlanePort](capability-mock-control-plane-port.md)
- [Capability disposition](capability-disposition.md)
@@ -0,0 +1,224 @@
# Capability Paperclip-style issue-thread UI
Capability renders the mock Paperclip issue as a native issue thread. A board user
reads the thread, answers typed interactions inline, and inspects the evidence
behind every mock mutation. The implementation follows the binding
[Capability issue-thread UX contract](design/capability-issue-thread-ux-contract.md).
The same shell hosts a second primary path: the
[clean-room live chat](capability-clean-room-chat.md) at `#/chat`, which starts a
blank thread on a freshly minted mock tenant instead of a preset scenario. It
reuses every surface described below — header, thread, composer, and Evidence
panel — and differs only in what the session is seeded with and in refusing any
non-live mode.
## Authority boundary
```text
browser -> package server -> CapabilityLiveSession -> paperclip-runnerd -> codex
(projection) CapabilitySemanticDispatcher -> ControlPlanePort mock
```
The browser holds no authority. It renders one shape,
`CapabilityIssueThreadSnapshot` (`src/issue-thread/types.ts`), and computes no
claim, policy decision, state diff, or parity verdict. Two producers emit that
shape:
- `capabilityIssueThreadFixture(slug)` — deterministic `fake` snapshots used by the
screenshot matrix and the browser suite.
- `projectCapabilityIssueThread({ snapshot })` — the live projection. It runs in the
package server and rearranges durable records only: the live session's
transcript, evidence entries, semantic authorization records, and the
serialized mock state.
The one browser-initiated mock mutation is an interaction response. It posts to
`POST /api/capability/ui/interaction`; `CapabilityLiveSession.resolveInteraction` stores
the typed response in the mock control plane **before** resuming the same Codex
thread, so the card only leaves `submitting` on server acknowledgement.
No provider, runner, or control-plane credential reaches the page. Redacted
fields render as `••• redacted` with the redaction rule name, mock issues use
the reserved `MCK-` prefix, and no real Paperclip URL is ever rendered.
## What the browser is allowed to see
The projection is an internal shape. What ships is the published DTO,
`toCapabilityPublicThreadView` (`src/issue-thread/public-view.ts`), which copies the
view field by field — so a field added to the projection, or to any record it
passes through, cannot reach a browser until it is listed there. Every response
path uses it: interim stream frames, the settled payload, and reconnect or
replay replies alike.
Three narrowings stack, so no single omission opens a disclosure path:
1. **At record time.** `redactCapabilityEvidenceData`
(`src/live/evidence-redaction.ts`) is the only way an evidence entry enters
a live session. Provider notifications are reduced to a coarse category,
provider diagnostics to the fact that one occurred, tool arguments to the
catalog-declared field names they used, and tool results to the outcome,
revisions, and mock entity refs the UI resolves into cards. Provider thread
and session identity, model and token metadata, and raw tool payloads are
never retained, so no later reader, frame, or log can republish them.
2. **In the projection.** `Runner & events` details are composed from the
redacted record rather than stringified from it, and `Calls & results` names
the operation and its field count instead of echoing arguments.
3. **In the DTO.** Provider-authored turn and call identifiers are replaced by
in-view aliases (`turn-1`, `call-1`) that stay consistent across anchors,
evidence refs, and successive frames of one turn, and any value the caller
declares withheld is scrubbed from the encoded result.
A streamed turn that fails answers with a code and fixed operator copy; the
underlying message stays server side, because provider text can quote prompts
and paths.
## Session capability
Every session-scoped route is bound to a per-browser capability. The server
mints one on session creation, stores only its SHA-256 with the session record,
sets it as an `HttpOnly; SameSite=Strict` cookie, and compares it in constant
time on every read and mutation — message, reconnect, interaction, stop, reset,
and new chat. A valid session id presented without its capability is answered
`404`, exactly like an id that never existed, so an unauthorized caller cannot
tell a live session from a dead one.
Rotation belongs to the actions that start something new — `New chat`, a scenario
POST, reset. Each revokes the caller's existing bindings before issuing the
replacement cookie. Reopening a page whose stored id is simply gone mints a
session under the capability the browser already holds instead, because rotating
there would make two tabs of one surface revoke each other on every load while
protecting nothing: one browser is one principal, and cross-browser denial rests
on the binding rather than on how often the value changes.
The two surfaces use separate cookie names (`paperclip_capability_issue`,
`paperclip_capability_chat`) because they are separate pages of one origin: a single
name would make opening the explorer revoke the clean room. A script that drives
these routes has to behave like one browser — `scripts/capability-cookie-jar.mjs` is
what the smoke scripts use for that.
## Surfaces
- **Header** — three identity chips (`Real Codex` / `Fake agent` / `Replay`,
`Real runnerd` / `In-process runner`, and `Mock Paperclip` in every mode),
status, priority, run state, and the Scenario/Replay/Reset/Stop controls.
`data-session-mode` carries the mode as data, never as styling.
- **Thread** — turn groups binding the contract's T1–T11 item types: user
messages, model prose, durable progress comments marked
`Recorded to mock thread`, collapsed tool strips, interaction cards, document
revision cards, deliverables, delegation cards, terminal dispositions, typed
denials, and muted system notices.
- **Composer** — six mutually exclusive states behind `data-composer-state`:
`ready`, `sending`, `streaming` (input stays editable to steer; Stop is
primary), `waiting`, `reconnecting`, and `disabled`. Drafts survive refresh.
- **Evidence panel** — eight accordion sections in fixed order: Tools exposed,
Calls & results, Authorization, Control plane, Runner & events, State diff,
Traceability, Parity. The Tools section groups `Agent tool — always`,
`Agent tool — granted` (with its grant), and a separated
`Control plane (not exposed to the agent)` list, because what the model
*cannot* call is first-class evidence. Every strip, denial, and card deep-links
into the matching record, and each record links back to its thread anchor.
Live sessions additionally put a six-tab DevTools inspector above these
sections: revision timeline, complete browser-safe company state, structural
diff, protocol records, runtime, and authority. The inspector can pause live
following, export redacted JSON, and fork a retained revision.
The panel is collapsed by default and resizable between 320px and 640px with a
keyboard-operable splitter. Below 1100px it becomes an overlay sheet that
Escape dismisses; below 768px the page switches to a `Thread` / `Evidence`
segmented control, with Stop kept outside the `⋯` menu while a turn is active.
Closing the panel by either route hands focus back to the visible control that
owns it.
- **Replay strip** — in `mode=replay` a progress strip pins under the header
with `Step back`, `Next turn`, and `Play all`. `?at=<ordinal>` is the single
source of truth for the parked ordinal, so the three controls and the deep
link all move the same value; `Play all` advances one ordinal every 800 ms
and parks itself at the end of the recording.
## Routes
```text
#/issue/<fixtureProfile>?shot=<slug>&panel=<section>&rec=<id>&at=<ordinal>&seg=thread|evidence&mode=live
```
- `shot` seeds one of the twelve deterministic `fake` states.
- `mode=live` opts into the package session server; the default is `fake`.
- `capture=1` freezes animation, caret, and smooth scrolling for screenshots.
- The root element sets `data-thread-state="settled"` once hydration, fixture
load, and auto-scroll finish. Tooling waits for that attribute, never a
timeout.
## Commands
```sh
# Deterministic fake-mode app (no provider process)
pnpm --filter @paperclipai/paperclip-runner console:issue-thread
# Focused browser suite, including the axe gate on all 12 slugs × 2 viewports
pnpm --filter @paperclipai/paperclip-runner test:browser:scenarios
# View-model and live-projection unit tests
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/issue-thread
# Screenshot matrix (12 slugs × 2 viewports) and its byte-stability check
# Recorded evidence generation is deferred from this release.
pnpm --filter @paperclipai/paperclip-runner check:capability:ui
# Real runnerd + real Codex through the same HTTP routes the browser uses
pnpm --filter @paperclipai/paperclip-runner smoke:capability:ui
# Recorded evidence generation is deferred from this release.
```
Hosts without the Playwright chromium system libraries can either run
`pnpm --filter @paperclipai/paperclip-runner verify:rootless` or set
`PAPERCLIP_RUNNER_CHROMIUM_PATH` to a preinstalled Chromium.
The committed PNGs are pinned to the Chromium build listed in
`.paperclip-local/evidence/capability/ui/index.md`, so `check:capability:ui` needs that same
browser. Point `PAPERCLIP_RUNNER_CHROMIUM_PATH` at the recorded browser before
comparing — and when that path is the agent-browser wrapper, also set
`PAPERCLIP_CHROMIUM_BIN` to the exact binary, because the wrapper otherwise
picks the newest installed Playwright Chromium. The issue-thread bundle
self-hosts its Latin Inter and DejaVu Sans Mono WOFF2 faces plus tiny status-glyph
subsets, so host fontconfig directories do not participate in capture. The recorder probes the
package-specific bundled families and refuses to record or compare when either
face is absent or fails to load; the drift report prints the Chromium version
and bundled-font probe it recorded with.
## Accessibility
The suite enforces the contract's blocking gate: axe reports zero serious or
critical WCAG 2.1 A/AA violations on every screenshot route at both viewports.
Structure is one `h1`, `header`/`main`/`complementary` landmarks, a `form`
composer, `section` interaction cards labelled by their prompt, and tool strips
as disclosure buttons with `aria-expanded`. Every state chip pairs color with a
glyph and text, all actionable controls clear 44×44 CSS px on mobile, and
`prefers-reduced-motion` disables the pulse dot, banner slide, and smooth
scrolling.
Focus management (§9.2) is covered by named regressions in the browser suite:
- Opening Evidence moves focus to its heading; closing it — with the `Close`
button or with Escape on the overlay sheet — returns focus to the toggle.
- Resolving an interaction card moves focus to the card's state chip. The
controls the user just operated unmount on resolve, so without this the
keyboard caret drops to `body` at the moment the card changes.
- The `waiting` composer's anchor moves focus to the pending card's first
control.
## Contract deviations
One deviation is recorded against the Capability contract:
- **§5 expired-family dimming.** The contract asks for a 60% opacity body on
`stale_target` and the other expired outcomes. A literal opacity drops that
card's text to ~3.2:1 and fails the blocking axe gate in §9.7. The dim is
implemented as a recessed surface plus muted text that still clears 4.5:1.
## Determinism notes
Fake-mode fixtures render from authored data with a fixed clock, so two captures
of a slug from a clean checkout are byte-identical. Live sessions are not
byte-stable — a real model writes their prose — so live evidence lives in
`.paperclip-local/evidence/capability/ui-live/` and is excluded from the determinism gate.
Durable comments in live mode carry the mock control plane's own deterministic
clock rather than wall time, because that is the timestamp on the mock record.
@@ -0,0 +1,122 @@
# Capability live runnerd and Codex loop
> **Reference lab, not the production sandbox topology.** This API remains the
> runner-lab/session implementation used by UI and recovery tests. Production
> sandbox execution and live protocol evals use the Rust-owned bridge described
> in [`../../../doc/plans/2026-08-20-single-daemon-runner-tool-bridge.md`](../../../doc/plans/2026-08-20-single-daemon-runner-tool-bridge.md): external control plane → PRP → one Rust `paperclip-runnerd` → provider. The TypeScript dispatcher below does not run in the sandbox.
Capability binds the provider-neutral semantic catalog to a real package-local
`paperclip-runnerd` process and a real Codex app-server session. Paperclip data
remains deterministic mock state behind `ControlPlanePort`; no request reaches
the Paperclip API.
## Process and authority boundary
The process chain is:
```text
CapabilityLiveSession -> paperclip-runnerd -> codex app-server
-> CapabilitySemanticDispatcher -> ControlPlanePort mock
```
`paperclip-runnerd` owns the Codex child and proxies newline-delimited JSON-RPC
over stdio. The transport starts a dedicated Unix process group. Normal close,
stop/reset cleanup, and fatal protocol errors terminate that group with a
bounded TERM/KILL sequence so a Codex child is not abandoned.
Only the allowlisted Codex host environment is copied. `PAPERCLIP_*` variables,
provider credentials other than Codex's server-side home, and credentialed
proxy URLs are not passed to runnerd or Codex. Model-issued commands still use
the separate skillless, network-disabled workspace permission profile.
## Stable session API
Production workers import the live entrypoint and bind a durable store to the
attempt's immutable authority tuple:
```ts
import {
CapabilityLiveSessionService,
DurableCapabilityLiveSessionStore,
} from "@paperclipai/paperclip-runner/live";
const binding = { sessionId, runId, companyId, actorId, taskId };
const store = new DurableCapabilityLiveSessionStore({ directory, binding });
const service = new CapabilityLiveSessionService({ store });
const session = await service.create({ ...binding, attemptId, workingDirectory });
const turn = await session.sendMessage("Read the mock task and report progress.");
await session.interrupt(); // only when a turn is active
await service.stop(session.id);
```
After a worker terminates, a new worker must construct a new service and use the
production resume entrypoint. It must not call `create` again:
```ts
const resumed = await new CapabilityLiveSessionService({ store }).resume({
sessionId,
attemptId: resumeAttemptId,
resumeOf: killedAttemptId,
});
await resumed.reconcileActiveTurn();
```
`resume` first commits the killed attempt as `terminated` and the successor as
`running`. Only then does it start runnerd, read the checkpointed provider
thread, and resume that exact thread. A missing or corrupt checkpoint, authority
binding mismatch, attempt-lineage mismatch, or provider thread/session drift
fails closed. The killed attempt and its usage remain immutable.
The handoff surface for later tracks is:
- `create(input)` starts runnerd, Codex, one mock run, and one dynamic-tool thread.
- `sendMessage(text)` supports repeated turns on the same provider thread.
- `pendingInteractions()` and `resolveInteraction(input)` preserve typed human
interactions and return their results to that same thread.
- `reconnect(sessionId)` closes the old process group, starts a fresh runnerd and
Codex app-server, then reads and resumes the persisted provider thread.
- `restore(sessionId)` recreates mock state, transcript, authority, authorization
records, pending interactions, and the provider thread from a stored snapshot.
- `resume({ sessionId, attemptId, resumeOf })` is the cross-worker production
path and records distinct linked attempts before provider recovery.
- `recordUsage(receipt)` durably commits an attempt-bound, exactly-once provider
response receipt before the caller acknowledges that response. Reusing a
receipt with different contents fails closed.
- `reconcileActiveTurn()` interrupts and records the terminal fact for a turn
that was active in the checkpoint when its worker terminated.
- `interrupt(reason)` cancels only the active turn and retains session authority.
- `service.stop(sessionId)` clears authority and reaps the process group;
`reset` also deletes the old snapshot and restores the original clean mock
seed under new run/session authority.
`DurableCapabilityLiveSessionStore` writes a checksummed, revisioned checkpoint
with atomic rename plus file and directory fsync. It persists provider identity,
mock state and semantic idempotency receipts, attempt lineage, active and
terminal turn facts, and the usage ledger. The included in-memory store remains
limited to tests and single-process consumers.
Every snapshot includes bounded transcript/evidence, serialized mock state,
semantic authorization records, runner/Codex PIDs and exit state, and explicit
network evidence. Tool calls are admitted only when their thread and turn match
the active Codex turn. Their typed `CapabilitySemanticToolResult` is serialized into
the app-server response, allowing Codex to use the resulting state revision in
its next response.
## Verification
Run the deterministic contract suite:
```sh
pnpm --filter @paperclipai/paperclip-runner test:scenarios
```
Run a real runnerd and Codex app-server smoke:
```sh
pnpm --filter @paperclipai/paperclip-runner trace:live-runner -- --json
```
The smoke requires an authenticated local Codex installation. It checks a real
semantic tool mutation, typed-result response, same-thread second turn, process
ownership/cleanup, cleared authority, and zero Paperclip network/child-env
exposure.
@@ -0,0 +1,94 @@
# Capability Mock ControlPlanePort
`CapabilityMockControlPlaneAdapter` is a deterministic, serializable, in-memory
model of Paperclip's control-plane **semantics** — not its transport. It imports
no server route, service, database, or provider binding, and it holds no
credential.
Sources: `src/mock-core/capability-mock-control-plane-adapter.ts`,
`src/mock-core/capability-control-plane-types.ts`. Shared contract:
`src/conformance/control-plane-port.ts`.
## Entity domains
The adapter models ten renderable entity domains, which are exactly the domains
the [scenario explorer](capability-scenario-explorer.md) diffs:
`tasks`, `comments`, `documents`, `interactions`, `approvals`, `artifacts`
(artifacts and work products), `blockers`, `workspace` (workspace services),
`budget`, and `run`.
The fixture state also carries supporting records — company, actors, wakes,
audit, decisions, idempotency, faults, counters, and a fixture clock — that back
behavior but are not diff domains.
## Operations
- **Lifecycle:** `start()` / `stop()` only change local object state.
- **Runs:** `openRun()` / `openFixtureRun()` return a run context with atomic
checkout; `context(runId)` reads it back.
- **Events:** `appendEvent()` accepts both canonical PRP events and native run
events with deduplication, replay-conflict detection, and contiguous-sequence
tracking; `replayEvents()` replays from an exclusive cursor.
- **Commands:** `applyCommand(envelope)` executes a semantic command with
idempotency and an optimistic `expectedRevision`.
- **Sessions:** `loadSessionCheckpoint()` / `checkpointSession()`.
- **Terminal:** `completeRun()` reconciles a terminal disposition — `done`,
`blocked`, `needs_review`, or `yielded` — and fans out blocker wakes.
- **Introspection:** `snapshot()`, `decisionRecords()`, `serialize()`, and
static `restore()`.
The eighteen semantic command kinds (`report_progress`, `write_document`,
`request_human_input`, `resolve_human_input`, `register_deliverable`,
`set_dependencies`, `create_task`, `request_approval`, `decide_approval`,
`comment_on_approval`, `control_workspace_service`, `record_budget_usage`,
`finish_task`, `block_task`, `request_review`, `release_task`, `schedule_wake`)
are the mock side of the [semantic tool catalog](capability-semantic-tools.md).
## Determinism
- **Fixture clock.** Every timestamp derives from `clock.epochMs + tick*1000`
and the tick increments on each read. The default epoch is
`2026-08-09T00:00:00Z`. There is no RNG anywhere.
- **Deterministic IDs.** Every generated ID is `fixture-<kind>-<nnnn>` from a
monotonic per-kind counter.
- **Immutability.** Inputs and outputs are `structuredClone`d and every returned
value is deep-frozen. Canonical JSON with sorted keys backs every dedupe and
replay comparison.
- **Serializable.** `serialize()` / `restore()` round-trip under the schema tag
`paperclip.capability.mock-state.v1`.
- **Scripted faults.** A fault rule injects a bounded number of
`retryable_error` or `lost_ack` effects; a lost-ack command commits once and
returns its stored result on retry.
## Boundary
The adapter is a pure state machine: no network, no database, no provider SDK,
no credentials. Secrets appearing in wake payloads are redacted against
`/authorization|credential|api.?key|secret|token/i`; the adapter test seeds
`apiKey` and `Bearer` values and asserts they never surface. See
[authorization and exposure](capability-authorization-and-exposure.md).
## Shared conformance
The adapter satisfies the same `runControlPlanePortConformance` contract used by
earlier phases: it rejects open-binding violations, recovers from source gaps,
treats duplicates idempotently, and rejects event-id, sequence, replay-binding,
and result mutations (failing closed with `native_event_replay_conflict`).
## Running the tests
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/conformance/control-plane-port.test.ts \
src/mock-core/capability-mock-control-plane-adapter.test.ts
```
Nine tests, no npm alias of their own — they also run inside
`pnpm --filter @paperclipai/paperclip-runner test`.
## Related
- [Semantic tool catalog](capability-semantic-tools.md)
- [Authorization and exposure](capability-authorization-and-exposure.md)
- [Future binding boundary](capability-future-binding-boundary.md)
@@ -0,0 +1,84 @@
# Capability Browser Scenario Explorer
The scenario explorer is a read-only browser surface over all 106 conformance
cases. It runs each scenario in the page against the
[mock control plane](capability-mock-control-plane-port.md) through the
[semantic tool runtime](capability-semantic-tools.md) and renders the resulting
run artifact. It never re-judges parity, never leaves its own origin, and holds
no credential.
Sources: `src/scenarios/` (scenario index, runner, parity, state diff) and
`examples/scenario-explorer/`. Interaction contract:
[Capability scenario explorer UX](design/capability-scenario-explorer-ux.md).
## The run artifact
Running a scenario produces one immutable artifact carrying:
- **Tool exposure** — which semantic tools were available, and for optional
tools, the grant that unlocked each one.
- **Control-plane actions** — control-plane-owned steps (for example checkout),
each labelled "no agent tool exists for this."
- **Authorization records** — allow and typed-deny decisions; a denial carries
the missing claim and no protected task data.
- **State diff** — an immutable diff over the ten entity domains, with unchanged
domains collapsed.
- **Parity verdict** — the runtime's own verdict, plus Capability's per-case
result carried through in a separately labelled block. The explorer displays
these; it does not recompute them.
## Read-only and frozen SDK
The explorer imports the frozen **0.1.2** public SDK through the package-local
alias `@paperclip-runner-local/capability`, which is deliberately not the published
package name. It adds no SDK export and changes no published surface. Fake mode
runs entirely in the page against checked-in fixtures and renders fixture time
only, so two loads of the same route produce identical settled DOM.
## What the explorer shows
- **Home** — 16 group facets whose counts sum to 106, corpus stats, and example
deep links.
- **Picker** — a listbox with live filter chips, disabled zero-count values, and
a clear-filters control.
- **Transcript** — both channels (agent-visible and control-plane).
- **Inspector** — four tabs: context (exposure and grants), authorization
(allow/deny), state diff, and traceability.
## Boundary
- No network request leaves the explorer's own origin.
- `localStorage` holds no run artifact, grant, or fixture payload.
- Codex mode is a disabled option with a stated reason; wiring it to the SDK
relay is deferred, and the browser holds no provider credential either way.
- Secrets are redacted before display; redaction chips name the rule and never
the value. See [authorization and exposure](capability-authorization-and-exposure.md).
## Accessibility
The 7F browser suite asserts: listbox arrow-key navigation with
`aria-activedescendant`, WAI-ARIA tab arrow-key activation with exactly one
tablist, every interactive control named, a polite live region announcing the
settled verdict, three landmarks, a single `h1`, and — at 390px — one segment at
a time with zero horizontal overflow.
## Running it
```sh
# Open the explorer on 127.0.0.1:4183.
pnpm --filter @paperclipai/paperclip-runner demo:scenarios
# Scenario runtime, explorer components, and route determinism (49 tests).
pnpm --filter @paperclipai/paperclip-runner test:scenarios
# Browser IA, determinism, evidence routes, boundary, a11y, responsive (25).
pnpm --filter @paperclipai/paperclip-runner test:browser:scenarios
# Deterministic 24-image acceptance set (12 routes x 2 viewports).
# Recorded evidence generation is deferred from this release.
```
## Related
- [Eval conformance](capability-eval-conformance.md)
- [Verification commands](capability-verification-commands.md)
@@ -0,0 +1,79 @@
# Capability semantic catalog and authorization
Capability adds a transport-neutral tool boundary over the deterministic mock
`ControlPlanePort`. It does not contain Paperclip REST routes, authentication
headers, credentials, ACPX code, or a real control-plane binding.
## Public boundary
Import the catalog, policy, dispatcher, and binding helpers from the package
root:
```ts
import {
CapabilitySemanticDispatcher,
createCapabilityProviderNeutralBinding,
} from "@paperclipai/paperclip-runner";
const dispatcher = new CapabilitySemanticDispatcher(mockPort, {
scenario: {
id: "dependency-manager",
claims: ["dependencies:write"],
},
explicitClaims: ["dependencies:write"],
});
const exposed = dispatcher.listTools(runId);
const result = await dispatcher.dispatch({
runId,
callId: "tool-call-1",
operationId: "set_dependencies",
input: {
idempotencyKey: "dependency-change-1",
blockedByTaskIds: ["task-2"],
},
});
```
`createCapabilityProviderNeutralBinding("fake")` and
`createCapabilityProviderNeutralBinding("live_codex")` return identical contract
arrays. Only the binding label differs. Track 7E can translate those definitions
to Codex tool definitions and pass calls to the dispatcher without changing
operation IDs, schemas, claims, or result shapes.
## Authorization order
Exposure and invocation both evaluate the actor, active-task ownership and
mode, scenario allow/deny rules, role restrictions, run claims, and explicit
claims. Optional operations require their descriptor claim to be present in the
intersection authorized for the run. The dispatcher repeats the evaluation
immediately before every mock read or command; a tool that was exposed earlier
can still be denied after a claim or ownership change.
Unauthorized optional tools are absent from `listTools`. Direct calls receive a
`paperclip.semantic-denial.v1` result. The mock command boundary independently
checks command claims and task ownership, so bypassing catalog exposure does not
bypass authorization.
The generic API escape hatch is an optional `skill_test` descriptor and is
disabled by default. When a scenario and explicit claim enable it, only two
read-only package-local paths are accepted: `/mock/state/revision` and
`/mock/task`.
## Protected data
Tool schemas contain no credential fields. Inputs containing protected keys or
credential-shaped values are rejected before mutation. Read results, denials,
and immutable semantic authorization records pass through the same recursive
redactor. Mock actor discovery also omits capability and budget internals.
Generate and verify the checked-in contracts with:
```sh
pnpm --dir packages/paperclip-runner generate:semantic-contracts
pnpm --dir packages/paperclip-runner check:semantic-contracts
```
The generated contract is
`generated/capability/semantic-tool-contracts.json`. Capability traceability remains
under `generated/capability/` and is checked with `check:capability-contract`.
@@ -0,0 +1,98 @@
# Capability Semantic Tool Catalog
The semantic tool catalog is the transport-neutral surface an agent may call. It
is a frozen set of JSON-schema descriptors — no HTTP method, path, or provider
detail. Provider-specific shapes are produced only by *bindings*, so every
provider sees the same operation set.
Each protocol action is single-sourced in its own module under
`src/protocol-actions/`. That module owns the action's policy metadata,
documentation, examples, and its live and scenario JSON-schema presentations.
`src/catalog/canonical-operations.ts`, `src/semantic-tools/catalog.ts`, and
`src/tools/capability-semantic-tool-catalog.ts` are compatibility projections;
do not add action definitions to them. See
[catalog reconciliation](../spec/capability/catalog-reconciliation.md).
Sources: `src/protocol-actions/`, `src/catalog/canonical-operations.ts`,
`src/tools/capability-semantic-tool-catalog.ts`,
`src/tools/capability-semantic-tool-types.ts`, `src/tools/capability-tool-bindings.ts`,
barrel `src/tools/index.ts`.
## Two dispositions, plus a separate control-plane list
A tool descriptor's `disposition` is either **`always_agent_tool`** or
**`optional_agent_tool`**. Control-plane-owned operations are a separate frozen
list, not a disposition — they are never exposed as tools. See
[capability disposition](capability-disposition.md) and
[authorization and exposure](capability-authorization-and-exposure.md).
The catalog holds **37 tools**: 14 always-agent tools and 23 optional tools
across 10 groups.
<!-- These counts are drift-checked against src/tools/capability-semantic-tool-catalog.ts
by src/catalog/catalog-docs.test.ts; update the catalog, not the numbers. -->
### Always-agent tools (14)
`get_task_context`, `get_task_history`, `list_documents`, `read_document`,
`list_document_revisions`, `report_progress`, `answer_status_question`,
`finish_task`, `block_task`, `request_review`, `write_document`,
`request_human_input`, `register_deliverable`, `inspect_operation_result`.
### Optional tools (23), by group
| Group | Tools |
| --- | --- |
| discovery | `search_tasks`, `list_agents`, `list_projects`, `list_goals` |
| delegation_dependencies | `create_task`, `set_dependencies` |
| governance | `list_approvals`, `request_approval`, `decide_approval`, `comment_on_approval` |
| cases | `list_cases`, `upsert_case` |
| workspace_runtime | `get_workspace_runtime`, `control_workspace_service` |
| routines | `list_routines`, `manage_routine` |
| company_skills | `list_company_skills`, `sync_company_skills` |
| secrets | `list_secret_metadata`, `read_secret_value` |
| portability_admin | `export_company`, `administer_company` |
| test_escape_hatch | `generic_api_request` |
## Descriptor shape
Each descriptor carries `operationId`, `version` (1), `title`, `description`,
input/output JSON schemas, `disposition`, an optional `optionalGroup`,
`requiredClaims`, and optionally `allowedRoles`, `taskModes`, a `sideEffectClass`
(`read`, `task_write`, `company_write`, `governance`, `workspace_control`,
`secret_read`, `admin`, `test_escape_hatch`), an `idempotency` level, `redaction`
rules, and an abstract `mockCommandMapping`.
The `mockCommandMapping` is one of `context_read`, `snapshot_read`,
`semantic_command`, `operation_result`, or `mock_extension` — describing *what*
the tool does against the mock, never *how* a transport would carry it.
## Transport neutrality
Bindings, not descriptors, produce provider shapes, both derived from the same
`visibleTools.tools` array:
- `CapabilityFakeAgentToolBinding` emits `{operationId, description, inputSchema,
outputSchema}`.
- `CapabilityCodexToolBinding` emits `{type: "function", name, description, strict:
true, parameters}`.
Because both bindings derive from one array, the fake-agent and Codex operation
surfaces are byte-identical. The [conformance suite](capability-eval-conformance.md)
asserts this parity (the 18/18 fake-agent/Codex operation matrix).
## Running the tests
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/tools/capability-semantic-tools.test.ts
```
The "catalog" describe asserts a unique, versioned, provider-neutral catalog and
that every optional group is present.
## Related
- [Authorization and exposure](capability-authorization-and-exposure.md)
- [Mock ControlPlanePort](capability-mock-control-plane-port.md)
- [Eval conformance](capability-eval-conformance.md)
@@ -0,0 +1,50 @@
# Capability Verification Commands
Every command here runs from the repository root, is offline and deterministic,
starts no Paperclip service, and holds no credential. All are prefixed
`pnpm --filter @paperclipai/paperclip-runner`.
| Surface | Command | Expected result |
| --- | --- | --- |
| Capability contract completeness and drift | `check:capability-inventory` | `Capability inventory completeness and generated-output checks passed.` |
| Contract validator negatives | `test:capability-inventory` | 4 tests pass |
| Mock control plane + shared port | `exec vitest run src/conformance/control-plane-port.test.ts src/mock-core/capability-mock-control-plane-adapter.test.ts` | 9 tests pass |
| Semantic tools + authorization/redaction | `exec vitest run src/tools/capability-semantic-tools.test.ts` | 9 tests pass |
| 106-case conformance | `test:capability-evals` | 1 file (all 106 cases) passes |
| Fake-agent matrix + bounded Codex + parity report | `report:capability-evals` | `Capability eval conformance passed: 106 cases across 16 groups.` |
| Scenario runtime + explorer + clean room + routes | `test:scenarios` | 159 tests pass |
| Browser IA, accessibility, determinism, boundary | `test:browser:scenarios` | Playwright suites pass (60 in the issue-thread/clean-room suite) |
| Screenshot acceptance set | deferred recorded-evidence campaign | 24 deterministic images |
| Documentation links | `docs:validate` | all local links resolve |
## Live commands (not offline)
The clean-room chat has no fake or replay path, so its end-to-end proof needs a
Rust toolchain and a locally authenticated Codex. These are the only Capability
commands that start a provider.
| Surface | Command | Expected result |
| --- | --- | --- |
| Real Codex through real runnerd on a fresh mock tenant | `smoke:capability:cleanroom` | every assertion `true`; two `MCK-` identifiers, one per chat |
| Preset issue thread against real Codex | `smoke:capability:ui` | every assertion `true` |
| Clean-room screenshots | deferred recorded-evidence campaign | 7 images; intentionally not byte-stable |
## Notes
- **No Rust toolchain is needed** for any Capability command. The full
`verify` target still builds the Rust workspace, but nothing above does.
- **Determinism.** Fake-mode runs render fixture time only, so repeat runs and
repeat screenshots are byte-identical.
- **The parity report is generated on demand.** `report:capability-evals` writes
`.paperclip-local/evidence/capability/eval-parity-report.{json,md}`, which are not
committed or scanned by `docs:validate`.
- **Browser libraries.** On minimal or rootless hosts, extract Playwright's
browser libraries first; the package's `verify:rootless` target shows the
pattern.
## Related
- [Clean-start tutorial](tutorials/capability-scenario-explorer.md)
- [Eval conformance](capability-eval-conformance.md)
- [Scenario explorer](capability-scenario-explorer.md)
- [Clean-room live chat](capability-clean-room-chat.md)
@@ -0,0 +1,168 @@
# Codex Skillless Codex Driver
## Scope
Codex implements a direct Codex app-server v2 driver behind the package's
existing `HarnessDriver` contract. The driver, mock core, example CLI, tests,
and evidence stay inside `packages/paperclip-runner/`. They do not import or
change Paperclip server, UI, database, or production control-plane behavior.
The app-server process is local to the execution environment and uses newline
delimited JSON-RPC over stdio. It is not exposed as a network service.
## Identity mapping
| Runner identity | Codex source | Persistence rule |
| --- | --- | --- |
| run ID | mock-core input | Never replaced during recovery. |
| normalized session ID | controller-owned mock-core input | A distinct identity, independent of the run and provider IDs, that stays stable across transport/process recovery. |
| driver session ID | `thread.id` | Resumed by exact ID. A different returned ID fails recovery. |
| provider session ID | `thread.sessionId` | Kept separately from the driver thread ID. |
| turn ID | `turn.id` | Required by steer and interrupt preconditions. |
| item ID | `item.id`, request ID, or deterministic turn/kind key | Preserved on lifecycle and delta events. |
| source event ID | runner instance + run + source sequence | Source sequence continues from the persisted snapshot. |
The persisted session snapshot records run, normalized session, driver
session, provider session, the exact active turn, committed semantic-result
content/call binding, observed terminal-turn fingerprints, and the last source
sequence. Recovery starts a new local app-server transport, reads the exact
persisted thread to validate identity and working directory, resumes that
thread, and then reads it again for reconciliation. Reconciliation considers
only the persisted active turn: it retains that turn when active, terminalizes
that turn when terminal, and fails recoverably when it is missing or a
different active turn appears. Historical terminal turns never substitute for
the persisted active turn, and a terminal already in the durable snapshot is
not emitted again.
## App-server operations
| Driver operation | App-server method | Degradation |
| --- | --- | --- |
| initialize | `initialize`, then `initialized` | Startup fails visibly. |
| create | `thread/start` | Required. |
| resume | `thread/resume` | `recovered: false` with a redacted reason. |
| read | `thread/read` | Explicit `HarnessCapabilityUnavailableError`. |
| start turn | `turn/start` | Required. |
| steer | `turn/steer` with `expectedTurnId` | Explicit unsupported diagnostic; no stdin fallback. |
| interrupt | `turn/interrupt` | Explicit unsupported diagnostic; session is not killed. |
| usage | `thread/tokenUsage/updated` | Returns the last snapshot or explicit unsupported error. |
| reconcile | `thread/read` plus `session.reconciled` | Disabled when read is unavailable. |
Capability flags are descriptive and executable. Unsupported operations emit
canonical `harness.diagnostic` events with secret-redacted detail. No
harness-specific branch is required in the mock core.
## Skillless context boundary
The model receives one text input containing `paperclip.skillless_task.v1`:
- objective;
- completion-contract revision and criteria;
- task constraints; and
- the expected canonical result schema name.
The thread config explicitly disables automatic skill and app instruction
blocks. Codex's built-in collaboration instructions are enabled by default so
interactive runs receive native commentary and tool preambles; a driver caller
may explicitly disable them for a specialized deterministic fixture. This
does not enable skills, apps, plugins, memories, or extra model-input kinds.
The model input accepts only text, never a Codex `skill` input. The driver
captures the returned instruction-source list and requires it to be empty for
the skillless assertion.
The trusted app-server process has an allowlisted environment. It retains host
`HOME` and `CODEX_HOME` only so the provider can authenticate. Model-issued
commands have a separate boundary: an empty-by-default environment with no
`HOME` or `CODEX_HOME`, no network, and a named Codex
permission profile requesting read-only minimal runtime files, no host-home or
Codex-home access, and write access to the assigned workspace. The driver
refuses filesystem-root workspaces, workspaces containing host `HOME`, and any
workspace overlapping host `CODEX_HOME`.
The returned sandbox facts remain authoritative. Codex 0.132.0 may inject a
provider-managed writable root such as `~/.codex/memories` after a first run,
even with `features.memories=false` and an explicit Codex-home deny. That makes
the Codex-home directory discoverable in a warmed environment, so Codex does
not claim whole-directory unreadability. Its authenticated proof instead
requires each readable `auth.json`/`config.toml` file and an unrelated host
secret to remain unreadable and unwritable, while recording any injected root
in `context.sandbox.legacyPolicy`.
Paperclip bearer values, `OPENAI_API_KEY`, arbitrary skill paths, and other
inherited variables are not passed. Diagnostics redact bearer/basic
credentials, credentialed proxy URLs, secret query parameters, sensitive JSON
keys, and common key assignments.
The context snapshot records configuration and environment **key names**, not
secret values.
## Semantic completion
The provider-facing structured-output schema covers `done` and `needs_review`.
It uses the strict OpenAI shape: every object rejects additional properties and
the constant schema field includes both `type: "string"` and `const`.
Two dynamic semantic tools are registered when supported:
- `paperclip_finish` accepts `done` or `needs_review`;
- `paperclip_block` accepts `blocked` and requires a blocker owner, action,
reason, and scope.
Both normalize through the canonical `paperclip.run_result.v1` validator. The
first valid result is proposed. Any canonically identical result retry is
idempotent even when the provider assigned a new call ID; the original call
binding remains persisted for audit, while changed content is rejected. Tool
calls and provider notifications must name the exact opened thread and active turn. Missing,
pre-turn, cross-thread, cross-turn, and post-terminal bindings fail the provider
session closed. Canonically identical terminal replays are no-ops; conflicting
terminal facts are rejected. Process exit or prose alone never implies
completion.
The provider cannot commit controller state. It emits `run.result.proposed` and
a provider turn terminal; the mock core validates the proposal against its
task envelope, emits `run.result.accepted` or `run.result.rejected`, and alone
emits `run.terminal`.
## Canonical event mapping
- thread lifecycle -> `session.started`, `session.resumed`,
`session.reconciled`;
- turn lifecycle -> `turn.submitted`, `turn.accepted`, `turn.started`, and one
terminal turn event;
- messages, reasoning, plans, commands, file changes, dynamic tools, and diffs
-> `item.started`, `item.delta`, `item.completed`;
- model selection -> a completed `model` item;
- app-server decisions -> `runtime_request.created` and
`runtime_request.resolved` with redacted detail;
- token snapshots -> completed `usage` items;
- semantic verification rows -> completed `verification` items;
- provider completion -> at most one `run.result.proposed` and one turn
terminal;
- controller decision -> one `run.result.accepted` or `run.result.rejected`,
followed by one `run.terminal`.
The JSON-RPC transport limits each input line, pending client requests,
in-flight server requests, queued notification count and bytes, diagnostic
lines, and retained provider payloads. Malformed or oversized messages close
the transport and reject pending work.
The existing Replay reducer consumes the live stream. Replay crosses a
serialized JSONL boundary that validates byte and event counts, line size,
schema, run/session binding, unique source event IDs, continuous per-source
sequence, and exactly one final run terminal before reducing. The Codex
tracer requires byte-equivalent live and replay snapshots.
## Runnable example
`trace:codex` starts a real local `codex app-server` session through the mock
core. Its safe task creates `hello.txt` with network disabled. The evidence
recorder additionally probes all readable host Codex credential/config files
and an unrelated host secret, requiring reads and writes to be denied while
workspace output and app-server authentication still succeed. It gates output
reads on an accepted `done` result and reports missing files by name rather
than surfacing a raw filesystem `ENOENT`. See the
[Codex tutorial](tutorials/codex.md).
This phase changes no browser surface, so no new browser screenshot applies.
The canonical events are proved through the existing reducer/replay path and
JSON trace evidence.
@@ -0,0 +1,429 @@
# Capability — Issue-Thread UX Contract
Status: **binding** for track 7G (Paperclip-style Web UI) and reviewable by tracks 7E/7K.
Source of authority: Capability plan (TASK-16897 `plan` document), Revision 3 §"Page information
architecture", §"Native issue-thread interactions", §"Live execution contract", and Revision 4
§"Final evidence eligibility". Capability vocabulary: Capability generated contract
(`generated/capability/capabilities.yaml`, `mcp-tool-map.yaml`, `eval-traceability.yaml`,
`contract-schema.json` — TASK-16939).
If a corner of this contract is underspecified, 7G comments on TASK-16940 and waits for a
revision. 7G does not diverge silently. Deviations are either fixed in the implementation or
written back into this document as an explicit revision.
---
## 0. Vocabulary (normative)
All UI copy and all implementation identifiers use the 7A terms. The UI never invents
capability language.
- **Dispositions** (closed enum): `control_plane_owned`, `always_agent_tool`,
`optional_agent_tool`.
- **Always semantic operations** (thread-visible verbs): `get_task_context`,
`report_progress`, `answer_status_question`, `finish_task`, `block_task`, `request_review`,
`write_document` (+ revision reads), `request_human_input` (kinds `questions`,
`confirmation`, `checkbox`, `suggest_tasks`, `item_verdicts`), `register_deliverable`.
- **Optional operation families**: discovery (`scoped_discovery`, `search_tasks`,
`list_agents`, …), delegation/dependencies (`create_task`, `delegate_task`,
`set_dependencies`, `create_blocked_task`), approvals (`request_approval`,
`decide_approval`, …), cases, workspace runtime, routines, company skills, secrets, admin,
escape hatch. Grants render exactly as 7A writes them (e.g. `rf:read_or_write`).
- **Mock-state kinds**: `operation_result`, `runtime_decision_record`, `active_task_context`.
- **Eval keys**: case `id` (e.g. `ix-confirmation-plan-01`), `group` (16 groups),
`fixtureProfile` (e.g. `hb-baseline`), `browserEvidenceRecipe` (e.g. `hb/hb-context-01`),
`expectedSemanticOperations`, `forbiddenOperations`.
- **Modes**: `live` (real Codex via real runnerd), `fake` (deterministic fake agent),
`replay` (recorded canonical events). A surface never shows an unlabeled mode.
Human-readable labels (UI copy) for the dispositions are fixed: `Agent tool — always`,
`Agent tool — granted`, `Control plane`. The machine term appears in the debug panel and in
tooltips, never invented synonyms ("system tool", "built-in", etc. are forbidden).
## 1. Identity and mode banner (always visible)
The page must answer, at all times and on every viewport, four questions: who is the agent,
what runs it, what control plane it talks to, and whether this session is live.
1. **Three identity chips**, rendered in the issue header and never hidden by scroll
(header is sticky):
- `Real Codex` — cyan chip, only when the active session mode is `live`. In `fake` mode the
chip reads `Fake agent`; in `replay` it reads `Replay`.
- `Real runnerd` — neutral chip with a live process dot (pulse while a runnerd session is
attached; static gray when detached). Shown in `live` mode only; `fake`/`replay` show
`In-process runner`.
- `Mock Paperclip` — amber chip, shown in **all** modes. Tooltip: "All issue records are
mock. No real Paperclip API is reachable."
2. **Mode is data, not styling**: the root element carries `data-session-mode="live|fake|replay"`
and the chips render from the server-reported session record. A session artifact with
`mode=fake` must be visually distinguishable from `live` in every screenshot (Revision 4
evidence eligibility).
3. **Mock identifier scheme**: mock issues use a reserved prefix that cannot be confused with
a real company (fixture default: `MCK-<n>`, e.g. `MCK-31`). The UI never renders a
`/PAP/...`-style link to a real control plane; mock entity links navigate inside the
explorer only.
4. **No credential surface**: no header, panel, tooltip, error, or diff may contain a token,
key, or `Authorization` value. Redacted fields render as `••• redacted` with the redaction
rule name from the authorization record.
## 2. Page information architecture
### 2.1 Desktop (reference viewport 1440×900)
```
┌──────────────────────────────────────────────────────────────┬───────────────┐
│ Issue header (sticky): │ │
│ MCK-31 · title · StatusBadge · PriorityIcon · assignee │ Debug panel │
│ [Fake agent|Real Codex] [Real runnerd] [Mock Paperclip] │ (collapsed │
│ run state · Scenario ▾ · Replay · Reset · Stop │ by default; │
├──────────────────────────────────────────────────────────────┤ resizable │
│ Thread column (centered, max-width 760px, single column): │ 320–640px) │
│ turn groups: user message → agent activity → responses │ │
│ comment / interaction / document / deliverable / │ │
│ disposition cards in chronological order │ │
│ system notices (wakes, reconciliation) as one-line rows │ │
├──────────────────────────────────────────────────────────────┤ │
│ Composer (pinned bottom of thread column) │ │
└──────────────────────────────────────────────────────────────┴───────────────┘
```
- The **thread is the primary readable surface**. Default panel state is **collapsed**; the
user's open/width choice persists per browser (`localStorage`), and deep links may force it
(§10). The page never leads with raw protocol events.
- Thread column measure: max-width 760px, centered in the remaining space; `text-sm` body
scale per the Paperclip type ramp.
- Debug panel: right side, resizable 320–640px with a keyboard-operable splitter
(`role="separator"`, `aria-valuenow`), collapse toggle in the header (`Evidence` button with
open/closed state).
- Below 1100px viewport width the panel switches from side-by-side to an overlay sheet from
the right (same content, same tab order), so the thread column never drops below ~600px.
### 2.2 Mobile (reference viewport 390×844)
- One column. Sticky condensed header: row 1 = `MCK-31` + StatusBadge + overflow menu `⋯`
(Scenario, Replay, Reset inside the menu; **Stop stays outside the menu** whenever a turn is
active). Row 2 = the three identity chips, wrapping to a second line if needed — chips never
cause horizontal page scroll.
- A **segmented control** with exactly two segments — `Thread` and `Evidence` — sits under the
header (pattern validated in the Scenario chat mobile review; a drawer was rejected there and
stays rejected). Thread is default. Badge on `Evidence` shows the current turn's
authorization-denial count when nonzero.
- Composer is fixed to the bottom of the `Thread` segment, above the keyboard inset
(`env(safe-area-inset-bottom)`).
- **No horizontal page scroll at 390px** (`document.scrollingElement.scrollWidth <=
clientWidth` is an automated acceptance check). `pre`/code/diff blocks wrap or scroll inside
their own container only.
- Touch targets ≥ 44×44 CSS px for every actionable element, including chip tooltips
(tap-to-toggle on touch), accordion headers, and interaction-card controls.
## 3. Thread item taxonomy
Every thread item renders from a **mock-core record or canonical event** — the browser holds
no state authority, computes no claim/policy/parity result, and never mutates mock state
outside an interaction response (§5). Items in chronological order, grouped by turn:
| # | Item | Source | Anatomy |
|---|------|--------|---------|
| T1 | **User message** | thread record | Right-aligned bubble style is **not** used; Paperclip comment card with author "You (board user)", timestamp, markdown body. |
| T2 | **Agent response** | model output items | Comment card, author = agent identity chip (`Fake agent` / `Real Codex`), streaming state per §6. Model prose only — never confused with durable records (see T3). |
| T3 | **Durable progress comment** | `report_progress` / `answer_status_question` `operation_result` | Distinct comment card with a `Recorded to mock thread` marker (filled corner tag + tooltip naming the semantic operation). This is the visual boundary between ephemeral model text (T2) and durable mock records. |
| T4 | **Tool activity strip** | semantic call + typed result | One line per call inside the turn group: status glyph (`✓ ok`, `✕ denied`, `⏳ running`), operation id (`write_document`), one-line human summary, `›` expander. Expanded: request args (redacted per rules), typed result, and a `View in Evidence` link that opens the debug panel pre-filtered to that call. Strips are collapsed by default; a turn shows at most 3 strips + `N more…` expander (progressive disclosure). |
| T5 | **Interaction card** | `request_human_input` record | §5. Rendered at its chronological position. |
| T6 | **Document card** | `write_document` result | Document key + title, revision chain (`r3 → r4`), author, `View diff` (opens Evidence → State), and stale marker when a later revision exists. |
| T7 | **Deliverable card** | `register_deliverable` result | File/ref name, kind (attachment bytes / external ref / workspace file), size, registered-by, download affordance for attachment-backed deliverables. |
| T8 | **Dependency / delegation card** | optional-op results (`create_task`, `set_dependencies`, …) | Created mock child issues with identifiers and blocker edges (`MCK-32 blocks MCK-31`). |
| T9 | **Disposition card** | `finish_task` / `block_task` / `request_review` result | Terminal banner card: new status via StatusBadge, explanation body, named blocker owner where applicable. After a terminal disposition the composer enters `disabled` (§6). |
| T10 | **Denial notice** | typed policy denial (`operation_result` with deny) | Inline red-bordered strip variant of T4: `✕ create_task — denied: missing grant su:read_or_write`. Shows deny reason **from the authorization record verbatim**; never leaks protected state or credentials. |
| T11 | **System notice** | `runtime_decision_record` (wake, checkout, reconciliation, budget stop, session events) | One-line, muted, icon + text (e.g. `⚙ Wake: issue_blockers_resolved → turn 3 started`). Progressive disclosure into Evidence → Control plane. Never a card; system notices must read quieter than work content. |
Turn grouping: each turn renders a hairline group header `Turn N · <mode> · <n> tool calls ·
<hh:mm:ss>` binding T2/T4/T5-T10 items produced within it. Thread auto-follows the newest item
only while the user is at the bottom; a `Jump to latest` pill appears when scrolled up
(≥ 300px) or when new items arrive off-screen.
## 4. Composer contract
States (mutually exclusive; `data-composer-state` attribute is the test/screenshot hook):
| State | Trigger | Visual | Controls |
|-------|---------|--------|----------|
| `ready` | session attached, no active turn, issue non-terminal | normal input, `Send` primary | Send (Cmd/Ctrl+Enter), attach disabled in Capability |
| `sending` | message posted, turn not yet streaming | input cleared, inline spinner on Send | Send disabled |
| `streaming` | active turn | input stays **editable** (steer), primary button becomes `Stop` (destructive-outline), helper text `Codex is working — send to steer, or stop the turn.` | Send = steer (queued as next user input), Stop |
| `waiting` | pending interaction card requires the user | input disabled, helper `Answer the pending request above to continue.` with an anchor link that scrolls to and focuses the pending card | none |
| `reconnecting` | transport lost | input disabled, helper `Reconnecting… your session is preserved.` | Retry now |
| `disabled` | terminal disposition, replay mode, or budget stop | input disabled with reason line (`Issue is done`, `Replay is read-only`, `Budget limit reached`) | New scenario / Reset |
Draft text survives refresh (localStorage per session id). Stop never discards the transcript;
a stopped turn's partial output stays in the thread with a `Stopped by user` marker on the
turn header.
## 5. Interaction cards (native lifecycle)
All five `request_human_input` kinds render as native cards using the typed payload's prompt,
options, labels, and validation — never as markdown asking the user to type an answer.
Kinds and their answer controls:
- `questions` (ask_user_questions): typed form — per-question control (radio/select/short
text), one submit.
- `confirmation` (request_confirmation): target summary + Accept / Reject buttons; reject
reason textarea when the payload requires it. **Revision-bound**: the card shows the target
(`plan · r4`) and links the exact revision.
- `checkbox` (request_checkbox_confirmation): checkbox list with min/max enforcement,
default-selected ids, Accept label / Reject label from payload.
- `suggest_tasks`: proposed-task list (title + description); board accepts a subset; accepted
tasks appear as T8 cards afterwards.
- `item_verdicts` (request_item_verdicts): per-item Approve / Reject / Defer segmented
buttons; reason required per `requireReasonOn`; supports partial submit (submitted items
lock, remaining stay editable, card stays `pending`).
**Response authority path (normative):** the card's submit posts the typed response to the
package server; the **mock control plane stores the response before the runner receives it**,
and only then does the same Codex session resume with the typed result. The card's UI state
moves `pending → submitting → resolved` only on server acknowledgment (no optimistic
resolution). This is the *only* browser-initiated mock mutation; there is no other write path
from the page.
**State matrix (all states are required and visually distinct):**
| State | Store shape | Card treatment |
|-------|------------|----------------|
| `pending` | status `pending` | Accent left border (violet), controls enabled, `Waiting for you` chip, focus lands on first control when the card is the reason the composer is `waiting`. |
| `submitting` | in flight | Controls disabled, inline spinner. |
| `accepted` | status `accepted` | Green check chip `Accepted`, chosen values summarized inline, controls collapse to read-only summary. |
| `answered` | status `answered` | Same as accepted with `Answered` chip (questions / verdicts complete). |
| `rejected` | status `rejected` | Red chip `Changes requested`, reason quoted in the card. |
| `stale_target` | status `expired`, `result.outcome=stale_target` | Gray chip `Stale — plan moved to r5`, link to the superseding revision, controls removed, body dimmed (see revision 2). |
| `superseded_by_comment` | status `expired`, `result.outcome=superseded_by_comment` | Gray chip `Superseded by a later comment`, link to that comment. |
| `expired` | status `expired`, no outcome | Gray chip `Expired <relative time>`. |
| `withdrawn` | status `cancelled`, `result.outcome=withdrawn` | Gray chip `Withdrawn`, optional reason. |
| `issue_closed` | status `cancelled`/`expired`, `result.outcome=issue_closed` | Gray chip `Issue closed`. |
Resolved/expired cards are durable history: they stay in the thread at their chronological
position, are keyboard-reachable, and expose `View request evidence` linking the debug panel
to the related request, policy decision, mock mutation, wake, and resume events (the plan's
required debug linkage). Pending cards survive refresh and reconnect (§6) — rehydrated from
mock state, not from browser memory.
## 6. Session lifecycle behaviors
- **Refresh (F5)**: full restore from server state — same session, same turn ids, transcript,
pending interaction cards, composer state, and mode chips. The session **never restarts
because the browser reconnected**. Scroll restores to latest; a `Restored session` system
notice (T11) is *not* emitted (silent restore) — the evidence panel's session record is the
proof.
- **Reconnect**: on transport drop the composer enters `reconnecting`, a slim amber banner
pins under the header (`Connection lost — retrying (attempt n)`), and streaming indicators
freeze with a `paused` glyph. On reconnect, missed canonical events replay by ordinal (no
duplicates, no gaps) and the banner resolves to a 3s `Reconnected` confirmation.
`prefers-reduced-motion` replaces the banner slide with opacity.
- **Stop**: header Stop and composer Stop are the same action — bounded cancel of the active
turn. Post-state: partial output retained + `Stopped by user` turn marker; session and
pending interactions unaffected; composer returns to `ready`.
- **Reset**: destructive — always behind a confirm dialog (`Reset scenario? This clears the
mock state and starts a clean session. The transcript will be lost.`; confirm button
`Reset scenario`, destructive style; cancel is default focus). Reset re-seeds the fixture,
rotates/clears session authority (Revision 4), and lands on a clean thread with a fresh
`Turn 0` seeded context. Reset affects only the current browser session's scenario instance.
- **Replay**: mode `replay` re-renders a recorded run from canonical events. Composer
`disabled` (`Replay is read-only`), identity chip row shows `Replay` + `Mock Paperclip`,
and a top progress strip allows step/next-turn/play-all with a deterministic `?at=<ordinal>`
deep-link parameter. Replay of a `fake` recording must still be labeled as fake-derived
(chip `Replay · fake source`) so replay evidence can never satisfy a live criterion.
- **Stop/Reset/Replay/Scenario controls** live in the header on desktop; on mobile
Replay/Reset/Scenario collapse into `⋯`, Stop stays exposed while a turn is active (§2.2).
## 7. Side debug panel (Evidence)
Named **Evidence** in UI copy. Content scope: a **turn selector** at the top (`Turn N ▾`,
default = latest; `All turns` option for the state and parity sections). Below it, eight
accordion sections in this order (accordion, not tabs — >7 categories, and multiple sections
must be open simultaneously for review):
1. **Tools exposed** — the turn's visible tool list grouped `Agent tool — always`, then
`Agent tool — granted` (each with its grant, e.g. `rf:read_or_write`), then a separated
muted list `Control plane (not exposed to the agent)` naming `control_plane_owned`
operations relevant to the fixture. Negative evidence is first-class: the control-plane
list exists precisely to show what the model *cannot* call.
2. **Calls & results** — chronological semantic calls: raw Codex request → dispatched command
→ typed result/denial, with operation id, version, and redaction annotations.
3. **Authorization** — one record per decision: operation, claims considered, allow/deny +
reason, redactions applied, resulting state change ref. Deny records use the same red
accent as T10.
4. **Control plane** — `runtime_decision_record` stream: checkout/lock, wake scheduling,
budget, idempotency/retry, reconciliation, session lifecycle.
5. **Runner & events** — canonical PRP events and runnerd/Codex process diagnostics (session
id, thread id, process state, cleanup evidence).
6. **State diff** — before/after per entity class (tasks, comments, documents, interactions,
approvals, artifacts, blockers, workspace, budget, run). Per-turn by default; `All turns`
shows fixture-seed → current. Rendered from immutable snapshots; the browser never
computes a diff from its own bookkeeping.
7. **Traceability** — the fixture's 7A anchors: capability rows (id + `sourceAnchor`), eval
case id/group/`browserEvidenceRecipe`, `expectedSemanticOperations`,
`forbiddenOperations`, `requiredCapabilityGrants`.
8. **Parity** — assertion list with verdict chips (`pass` green / `fail` red /
`intentional gap` gray + note), summarized as `n/m` in the section header.
Cross-linking contract: every T4 strip, T10 denial, and interaction card deep-links into the
matching Evidence record (`View in Evidence`), and every Evidence record links back to its
thread anchor. Deep-link target = section + record id, e.g. `?panel=authorization&rec=<id>`.
Mobile: the same eight sections render inside the `Evidence` segment, full-width accordions,
turn selector pinned under the segmented control.
## 8. Visual language
Follow the Paperclip design language without importing the product `ui/` package:
- Dark theme default, OKLCH neutral grays; semantic tokens only (background/card/muted/
accent/destructive/border/ring equivalents defined package-locally). No raw hex in
components.
- Type ramp: page title `text-xl font-bold`; card titles `text-sm font-medium`; body
`text-sm`; metadata `text-xs text-muted-foreground`; identifiers and operation ids
`font-mono text-xs`.
- Status/priority renders with StatusBadge/StatusIcon-equivalent components using the
product's status hue table (todo blue, in_progress indigo, in_review violet, done green,
blocked red, backlog/cancelled gray).
- Radii ≤ `rounded-xl`; shadows ≤ `shadow-sm`; density = product issue page, not a marketing
layout.
- Interaction cards use the product interaction-card anatomy (title row + prompt + controls +
state chip) so the mock thread reads as a Paperclip issue thread (Jakob's Law is the point
of this phase's demo).
## 9. Accessibility acceptance (blocking)
7G is not acceptable until all of these pass; 7K re-verifies them clean-room:
1. **Keyboard tour** (documented, testable): Tab order = header controls → thread (each card
is a focusable group; Enter expands) → composer → Evidence toggle → panel. The splitter is
arrow-key resizable. Pending interaction controls are reachable without pointer; Escape
closes the mobile `⋯` menu and the desktop overlay sheet.
2. **Focus management**: opening Evidence moves focus to its heading; resolving a card moves
focus to the card's state chip; `waiting` composer's anchor link moves focus to the pending
card's first control. Focus ring = 3px ring token, never suppressed.
3. **Live regions**: streaming agent text in `aria-live="polite"` chunk announcements (throttled
≥ 2s); turn completion, denial notices, and interaction resolution announce via a single
polite status region; reconnect banner is `role="status"`, Stop confirmation `role="alert"`.
4. **Structure**: one `h1` (issue title), landmarks `header/main/complementary` (Evidence),
`form` for composer; interaction cards are `section`s labeled by their prompt; tool strips
are disclosure buttons with `aria-expanded`.
5. **Color independence**: every state chip pairs color with a glyph + text (`✓ Accepted`,
`✕ Denied`, `⏳`); parity verdicts likewise. Contrast ≥ 4.5:1 for text, ≥ 3:1 for UI
glyphs, verified in dark theme.
6. **Reduced motion**: `prefers-reduced-motion` disables pulse dots, banner slides, streaming
shimmer, and smooth scrolling (instant jumps).
7. **Automated gate**: axe (or equivalent) run against every screenshot route in §10 with
zero serious/critical violations, executed in CI alongside screenshot capture.
## 10. Deterministic screenshot contract
### 10.1 Route scheme
- Base route: `#/issue/<fixtureProfile>` (e.g. `#/issue/hb-baseline`). Scenario/fixture ids
come from 7A `fixtureProfile`; per-case evidence uses the 7A `browserEvidenceRecipe` path
(`<group>/<case-id>`) as the canonical evidence id.
- Screenshot state param: `?shot=<slug>` seeds the named deterministic state below in `fake`
mode with: fixed clock (all timestamps render from fixture time), animations/caret/pulse
disabled, network idle, fonts loaded.
- Panel/deep-link params: `?panel=<section>[&rec=<id>]`, `?at=<ordinal>` (replay),
`?seg=thread|evidence` (mobile segment).
- Settle signal: the root element sets `data-thread-state="settled"` when hydration, fixture
load, and auto-scroll are complete. Capture tooling waits for it — never for timeouts.
### 10.2 Required matrix (12 slugs × 2 viewports = 24 PNGs)
Viewports: desktop `1440×900`, mobile `390×844`. Output path:
`.paperclip-local/evidence/capability/ui/<slug>--<desktop|mobile>.png` (package-local). Every capture
also asserts `scrollWidth <= clientWidth` on the scrolling element at 390×844.
| Slug | Seeded state | Must be visible |
|------|--------------|-----------------|
| `thread-baseline` | settled 3-turn fake run on `hb-baseline` | header w/ 3 identity chips + mode; T1/T2/T3/T4 items; turn headers; composer `ready` |
| `turn-streaming` | mid-turn stream | streaming indicator, composer `streaming` w/ Stop, editable steer input |
| `interaction-question-pending` | `ix-questions-01` pending card | typed question form, `Waiting for you` chip, composer `waiting` w/ anchor helper |
| `interaction-confirmation-pending` | `ix-confirmation-plan-01` pending | revision-bound target (`plan · r4`) on card, Accept/Reject, reject-reason affordance |
| `interaction-resolved-mixed` | history incl. accepted + rejected + `stale_target` + `superseded_by_comment` | four visually distinct resolved/expired treatments per §5 |
| `denial-optional-tool` | denied `create_task` (missing `su:read_or_write`) | T10 denial strip w/ verbatim deny reason; Evidence badge increment |
| `document-revision` | `dp-plan-doc-01` after `write_document` | T6 card w/ revision chain `r3 → r4` + View diff |
| `deliverable-registered` | `ar-upload-before-done-01` | T7 deliverable card w/ kind + registered-by |
| `disposition-terminal` | `st-done-comment-01` finished | T9 terminal card, StatusBadge `done`, composer `disabled` w/ reason |
| `debug-panel-open` | baseline + `?panel=authorization` | desktop: panel open at 384px w/ 8 sections, authorization records; mobile: `Evidence` segment active |
| `reconnect-banner` | forced transport drop | amber reconnect banner, composer `reconnecting`, frozen stream glyph |
| `replay-mode` | replay of recorded fake run `?at=12` | `Replay · fake source` chip, read-only composer, progress strip |
Determinism rule: two captures of the same slug/viewport from a clean checkout must be
pixel-identical (the Scenario chat byte-identical bar). Anything time-, random-, or
locale-dependent renders from fixture data.
### 10.3 Evidence naming
Per-eval-case evidence (7F/7K scope) reuses `browserEvidenceRecipe` verbatim:
`.paperclip-local/evidence/capability/cases/<group>/<case-id>--<viewport>.png`. The §10.2 matrix is
the UI acceptance set; case evidence is additive and follows the same settle/determinism
rules.
## 11. Authority and safety rules (UI-side restatement)
- The browser renders mock-core records, snapshots, and canonical events. It computes no
claim, policy, diff, or parity result client-side. UI-side state math is a defect.
- The only browser-initiated mock mutation is an interaction response (§5). Composer messages
go to the runner session, not to mock state.
- No provider, runner, or control-plane credential ever reaches the browser; redactions render
by rule name. Real Paperclip URLs/API paths never appear.
- Policy and state authority live in the package server + mock `ControlPlanePort`; refresh
and reconnect re-derive everything from them.
## 12. 7G handoff checklist
Implementation acceptance (UXDesigner review) requires:
1. All §3 item types and all §5 interaction states implemented and reachable via fixtures.
2. All §4 composer states with `data-composer-state` hooks.
3. §6 behaviors demonstrated: refresh restore, reconnect replay, stop, reset confirm, replay
read-only.
4. §7 Evidence panel with all eight sections, turn selector, and bidirectional deep links.
5. §10 matrix: 24 deterministic PNGs recorded at the named routes, byte-stable across two
clean runs, plus the 390px no-horizontal-scroll assertion per capture.
6. §9 accessibility gate green (axe + documented keyboard tour).
7. Screenshot review posted to the 7G issue for UXDesigner acceptance before 7G closes.
Questions or gaps → comment on TASK-16940.
---
## Revisions
### Revision 2 — expired-family dimming (2026-08-10, written back by 7G)
Revision 1 specified a literal 60% opacity on the body of `stale_target` and the
other expired-family cards (§5). Measured against the card surface in the dark
theme, that renders the card's text at ~3.16:1, which fails §9.5's 4.5:1 bar and
the blocking axe gate in §9.7 — the two rules cannot both hold.
§9 wins because it is the blocking gate. "Dimmed" is now specified as a
**recessed treatment**: the card drops to the sunken surface token and its
secondary text drops to the muted-foreground token, both of which clear 4.5:1.
The gray state chip, removed controls, and neutral left border are unchanged, so
the card still reads as history at a glance.
Implemented in `devtools/issue-thread/src/issue-thread.css` and covered by the
axe gate on the `interaction-resolved-mixed` slug at both viewports.
### Revision 3 — `interaction-resolved-mixed` capture framing (2026-08-10, written by the contract owner during the 7G gate)
Revision 1's §10.2 row required four resolved/expired card treatments visible
in the `interaction-resolved-mixed` frame. Two of this contract's own rules
make that impossible in one capture: §10.1's settle signal ends with
auto-scroll to the latest thread item, and the §5/§8 card anatomy makes four
resolved cards ~1100px tall — taller than either viewport. The seeded history
(answered + accepted + rejected + `stale_target` + `superseded_by_comment`)
cannot fit one auto-scrolled frame.
The row now reads: the seeded state must contain **all five** treatments
(answered, accepted, rejected, `stale_target`, `superseded_by_comment`), the
frame shows the latest-scrolled portion with the expired-family chips
(`Stale …`, `Superseded …`) fully visible, and the visual distinctness of all
five treatments is asserted by the browser suite plus a scrolled review pass
at the gate. Splitting the slug into two frames was rejected because the
24-PNG matrix count is referenced by the 7F/7K evidence and the §9.7 axe gate.
@@ -0,0 +1,549 @@
# Capability Interaction Map — Scenario Explorer and Screenshot Acceptance Spec
Status: **approved UX contract for 7F implementation** (TASK-16899, 2026-08-09).
Owner: UXDesigner. Implementer: 7F (browser scenario explorer), integrating
7C mock adapter output and 7E parity output.
Companion records: [Capability contract](../capability-contract.md),
`spec/capability/eval-traceability.yaml`, the Capability plan (TASK-16897),
[Live console interaction map](live-console-interaction-map.md),
[SDK component decisions](sdk-component-decisions.md).
(annotated desktop + mobile renders in `.paperclip-local/evidence/capability/`).
This map is the interaction contract for the Capability browser scenario
explorer. The explorer is a **read surface over records the runtime already
produced**. It renders scenario metadata from the generated 7A traceability
manifest, run artifacts from the 7C mock control-plane adapter, and parity
results from the 7E conformance suite. The UI never owns state, policy,
credentials, or execution decisions: it does not compute tool exposure, does
not evaluate claims, does not apply redaction, and holds no Paperclip or
provider credential. Every allow/deny, redaction, and state transition shown
on screen must arrive as a record emitted by the mock core.
## 0. Naming, vocabulary, and canonical labels
- Product name in the UI: **Scenario Explorer** (header: “Capability scenario
explorer — mock control plane”).
- UI copy uses the canonical term **task** (`DESIGN.md` principle 7). Eval
case IDs, fixture IDs, route paths, JSON payloads, and source anchors are
machine values and stay verbatim (`issues`, `i-0102`, `MCK-102`) in
monospace — never “translated.”
- The three dispositions render with exactly these labels, everywhere:
**Control plane** (`control_plane_owned`), **Always tool**
(`always_agent_tool`), **Optional tool** (`optional_agent_tool`). The
traceability panel and all badges must use the 7A contract values as their
source; no UI-local re-classification.
- The five assertion classes render as: **Invariant**
(`control_plane_invariant`), **Tool contract** (`agent_tool_contract`),
**Authorization** (`authorization_policy`), **Multi-hop**
(`combined_multi_hop`), **Restraint** (`restraint_no_call`).
- Parity statuses: **Pass**, **Fail**, **Intentional gap**, **Not run**.
Each has an icon + text label; color alone never encodes status (WCAG
color-independence).
## 1. Shell and information architecture
Package-local Vite entry `examples/scenario-explorer/` with script
`demo:scenarios` (default port 4183; 4182 belongs to the Standalone demo). It runs
from static assets plus checked-in fixtures with **no Paperclip services and
no network dependency** in fake-agent mode. Reuse the frozen `0.1.2` SDK
surface (`@paperclipai/paperclip-runner/react` + `./styles.css`) through its
five approved extension points; do not fork the token layer.
Desktop layout (≥ 64rem), one React tree (SDK finding — never render two
trees):
- **Left rail (`--pcr-rail-width`, 17rem):** scenario picker — search,
facet filters, case list grouped by eval group.
- **Center column (fluid, max `--pcr-center-width`, 48rem):** run view —
the chronological scenario transcript and the run header (verdict banner,
mode toggle, run/replay controls). Primary surface, strongest hierarchy.
- **Right rail (`--pcr-inspector-width`, 24rem):** inspector with four tabs
— **Context · Authorization · State diff · Parity**. Open by default on
desktop (unlike the 4b protocol inspector: here the inspector carries
first-class acceptance evidence, not diagnostics).
Mobile (< 64rem): single column behind a segmented control
(`Scenarios · Run · Inspect`), same pattern as the Live console console. The
segmented control is sticky at the top; the Run segment is the default when
a case is selected via route. Touch targets ≥ `--pcr-touch-target` (2.75rem).
Density: diagnostic-console dense, like 4b. All spacing, color, type, radius,
motion from `--pcr-*` tokens only (`DESIGN.md` principles 2–4; `pnpm
check:token-gates` applies).
## 2. Scenario picker and filters (left rail)
**Source of truth:** the generated scenario index derived from
`spec/capability/eval-traceability.yaml` (all 106 cases, 16 groups) plus the
latest parity results artifact from 7E. The picker consumes these; it never
re-derives group membership or dispositions.
Contents, top to bottom:
1. **Search input** — substring match on case ID and title. Mono rendering
of matched IDs. Debounce ≤ 150ms (Doherty).
2. **Facet filters** (collapsible group, `--pcr-facet-height` baseline):
- **Group** — the 16 eval groups (`hb 5 · co 6 · st 8 · cm 6 · se 4 ·
su 4 · bl 5 · dp 3 · ix 9 · ap 6 · ar 4 · er 9 · rf 22 · mh 4 · rs 3 ·
wk 8`), each with its case count.
- **Case** — direct case-ID picker (combobox) for jump-to-case.
- **Disposition** — Control plane / Always tool / Optional tool, from
`primaryDisposition`.
- **Role** — actor role of the fixture (`ic`, `manager`, `reviewer`,
`board_user`, …) from the scenario index (§9 data contract).
- **Claim** — required grants from `requiredGrants` (e.g.
`approval:read`), rendered mono.
- **Parity status** — Pass / Fail / Intentional gap / Not run, from the
parity artifact.
Facets are AND-combined across facets, OR-combined within a facet. Every
facet value shows its live result count; zero-count values stay visible
but disabled (recognition over recall — the vocabulary stays learnable).
An active-filter chip row with per-chip remove and one **Clear filters**
affordance sits above the list (forgiveness).
3. **Case list** — grouped by eval group with sticky group headers. Each row:
case ID (mono, `--pcr-font-size-xs`), title (one line, truncated with
full text in a tooltip), disposition badge, parity status dot+icon.
Selected row uses `--pcr-accent-surface` with a `--pcr-accent` left rule.
Keyboard: the list is a single-select listbox (`role="listbox"`,
`aria-activedescendant`); Up/Down move, type-ahead jumps, Enter selects and
moves focus to the run header. Facets are disclosure buttons +
checkbox groups. Filter state mirrors into the route (§7) so any filtered
view is linkable; filter state is never stored as protocol state.
Empty results: “No scenarios match these filters.” + Clear filters button —
never a bare void.
## 3. Run view (center column)
### 3.1 Run header
- Case ID (mono) + title, group badge, disposition badge, assertion-class
badges.
- **Verdict banner** (SDK `Banner`): overall parity verdict for the last
completed run — Pass (success surface), Fail (danger surface), Not run
(muted). The banner names the assertion counts (“12 assertions · 12 pass”)
and deep-links the Parity tab.
- **Mode control:** `Fake agent` (default) / `Codex (bounded)` segmented
control. Fake mode is always available offline. Codex mode appears only
when the local relay is reachable; otherwise the option renders disabled
with the reason (“provider relay not running — see tutorial §4”). The
browser never holds a provider credential; Codex traffic goes through the
same server-side relay boundary proven in SDK.
- **Run / Re-run** button and replay controls (SDK `replay-controls`).
Deterministic fixtures mean re-run in fake mode reproduces the identical
timeline; state diffs and parity always describe the displayed run.
### 3.2 Scenario transcript
**Source of truth:** the ordered run artifact timeline emitted by the mock
core (§9). Items render in artifact order; the UI never reorders, merges, or
re-times entries.
The transcript interleaves two visually distinct channels (Similarity +
Common Region — a stranger must tell them apart in two seconds):
1. **Agent channel** — model turns and semantic tool calls/results. Aligned
with the standard conversation layout (SDK `conversation`/`message`/
`tool-item`). A semantic tool call renders collapsed by default with a
one-line summary: operation ID (mono) + disposition badge + one-phrase
outcome (“`finish_task` · Always tool · committed”). Expansion is the
item-body / request-detail extension point (§8) and shows: operation ID
and version, argument JSON, result JSON, idempotency behavior, claims the
call required, and redaction chips (§5) — exactly the descriptor fields
from the 7D catalog, never re-labeled.
2. **Control-plane channel** — actions the runtime performed with **no
tool** (checkout, wake routing, budget stops, reconciliation, blocker
wake scheduling…). These render full-width on a `--pcr-muted` surface
with a “Control plane” badge and a gutter glyph distinct from tool items,
and are indented from the agent lane. Copy states the actor plainly:
“Control plane checked out task MCK-102 — no agent tool exists for this.”
Each entry expands to the decision record (inputs considered, resulting
state change, audit reference).
Failed/denied entries render in place on `--pcr-danger-surface` with the
exact typed denial (§5) — failures are part of the record, never toast-only.
Restraint/no-call scenarios (`rs-*`, `er-*` restraint cases) must remain
legible when the correct behavior is *absence*: after the final agent turn
the transcript appends a system note “No further operations — N forbidden
operations, none invoked” which deep-links the Parity tab’s forbidden list.
Restraint evidence must never depend on an empty screen.
PRP events stay behind a per-item “Protocol events” disclosure (same nested
pattern as SDK `debugEvents`) — the semantic view is primary; raw
protocol stays inspectable but secondary (progressive disclosure).
Auto-follow, jump-to-latest, streaming affordances, and reduced-motion rules
are inherited verbatim from the Live console map §1 — do not redesign them.
## 4. Inspector tabs (right rail)
All four tabs are SDK `Tabs`; the active tab mirrors into the route (§7).
### 4.1 Context
Answers “who ran, under what authority, with which tools” before any
transcript reading (mental model first):
- **Actor block:** actor role, task mode, fixture ID (mono), wake payload
summary (reason + trigger), budget posture.
- **Capability grants:** the scenario’s `requiredGrants` plus every grant
the fixture actually issued, rendered as mono chips.
- **Tool exposure list** — the acceptance-critical view. Three labeled
sections, populated **only** from the mock core’s exposure record:
1. **Always tools (N)** — always-catalog operations visible this run;
2. **Optional tools (N)** — visible optional operations, each with the
grant that unlocked it (“`decide_approval` — via `approval:decide`”);
3. **Control plane — no tool (N)** — control-plane-owned capabilities
relevant to this scenario, explicitly listed so reviewers can verify
absence (negative evidence must be visible, not implied).
- **Traceability panel:** source anchor (file:line, mono), skill elements
(file · section · lines), evidence IDs, legacy MCP mapping row if any —
verbatim from the 7A contract.
### 4.2 Authorization
Chronological table of every authorization record the mock core emitted:
operation (mono), claims considered, decision (**Allow** / **Deny** with
icon + text), reason string, redactions applied, resulting state change
reference. Row expansion shows the full record JSON. Denied rows use
`--pcr-danger-surface`; a count chip on the tab surfaces denials without
opening it (“Authorization · 1 deny”). Selecting a row scrolls/flashes the
corresponding transcript entry (uniform connectedness across panels).
### 4.3 State diff
Before/after immutable snapshot diff for the displayed run, grouped by the
ten entity domains in the plan: **tasks, comments, documents, interactions,
approvals, artifacts, blockers, workspace, budget, run**. Each domain is a
collapsible section headed by a change summary (“tasks · 1 changed”,
“comments · 2 added”); unchanged domains collapse to a single muted row
(“unchanged”) so change carries the visual weight (Von Restorff). Rows show
entity ID (mono) + field-level changes as `field: before → after`, with
added/removed/changed markers as icon + label, not color alone. The diff is
computed by the runtime/test layer and delivered as data; the UI performs no
diffing of its own. A “final state” toggle shows the complete after-snapshot
JSON per domain behind a disclosure.
### 4.4 Parity
The eval acceptance surface:
- **Verdict header** repeating the banner verdict.
- **Assertion list:** one row per conformance assertion — assertion class
badge, human-readable expectation, Pass/Fail, and on fail an expected vs
observed block (mono, side-by-side at desktop, stacked at mobile).
- **Forbidden operations:** each `forbiddenSemantics` entry with “never
invoked ✓” or the violating transcript reference.
- **Intentional gaps:** entries marked as intentional exclusions render
under their own heading with the recorded rationale — visually distinct
from Fail (a gap is a decision, not a defect).
- **Legacy baseline note:** the case’s skill-present/skill-absent reference
note, verbatim, muted — labeled “reference baseline, not the pass
condition.”
## 5. Credentials, redaction, and denial rendering
- Fake mode performs zero network I/O; Codex mode talks only to the local
relay; no route, storage key, or bundle string may contain a Paperclip or
provider credential. `localStorage` may hold UI preferences only
(inspector tab, filter collapse state) — never run artifacts, grants, or
anything from a fixture.
- Redacted values arrive pre-redacted from the mock core. The UI renders a
redaction chip: `•••` + “redacted” label + the redaction-rule name in a
tooltip. The UI must not receive-and-hide secrets; if a raw secret ever
reaches the browser payload, that is a 7C/7D defect, and the explorer
additionally fails closed by rendering nothing for fields flagged
redacted.
- A **denied** tool call renders the typed policy denial: operation, the
missing claim, and the denial reason — and never any protected state. An
**absent** tool simply does not appear in exposure lists; the Context tab’s
control-plane section is the affordance proving absence is intentional.
- The generic escape hatch (`paperclipApiRequest` successor), when a test
scenario grants it, renders with a persistent `--pcr-warning-surface`
“test-only escape hatch” banner on every call card (plan §4: visually
flagged).
## 6. Accessibility, responsive, empty/error states
Accessibility (WCAG POUR; inherits all Live console/5 baseline rules):
- Landmarks: `nav` (picker), `main` (run view), `complementary`
(inspector); one `h1`; group headers are `h2`/`h3` in order.
- Full keyboard operation: picker listbox (§2); `F6`/documented shortcut
cycles the three regions; tabs follow the WAI-ARIA tabs pattern
(arrow-key activation); every disclosure is a real `button`. Visible
focus ring via `--pcr-ring` everywhere.
- Live regions: run progress announces via `aria-live="polite"`
(“Scenario run settled — verdict pass”); denial entries announce
assertively once.
- Reduced motion: with `prefers-reduced-motion`, streaming reveal, banner
transitions, and scroll-flash connect cues render instantly/statically
(SDK rule).
- Contrast from the existing token pairs only; parity/authorization
statuses always icon + text.
Responsive: content-driven single breakpoint at 64rem (§1). At mobile the
inspector tabs become full-width stacked sections inside the Inspect
segment; expected-vs-observed blocks stack; the transcript keeps every
descendant intrinsically shrinkable (`min-w-0` discipline) so no horizontal
scrollbar appears at 390px (SDK finding).
States that must be designed, not defaulted:
| State | Surface | Rendering |
| --- | --- | --- |
| No scenario selected | Center + inspector | Explorer intro card: corpus stats (106 cases · 16 groups), “Pick a scenario” pointer at the rail, three example deep links. No dead panels. |
| Scenario selected, not run | Run view | Header + context render from the index; transcript area shows a primed empty state (“Run to produce the deterministic timeline”) with the Run button duplicated inline (Fitts). Diff/Parity tabs show “No run yet.” |
| Run in progress | All | Progress in the header (indeterminate, reduced-motion safe); transcript streams; tabs show live counts; controls disable with reasons. |
| Run failed (harness error) | Run view | Danger banner with the exact diagnostic + “Re-run”; partial timeline stays visible and labeled partial. Never silently reset. |
| Parity fail | Banner/Parity | Fail is a first-class rendered state (danger banner + failing assertion rows) — the explorer’s job is showing it, not avoiding it. |
| Denied/absent tool | Transcript/Context | Per §5. |
| Missing/invalid fixture or artifact | Shell | Error card naming the artifact path and the regenerate command (`pnpm generate:capability-inventory` / 7E command). No blank screen. |
| Codex relay unavailable | Mode control | Disabled option + reason; fake mode remains fully usable. |
## 7. Deterministic screenshot routes
Hash routing (static hosting, no server rewrites):
```
#/ explorer home (picker + intro)
#/case/<case-id> scenario selected, not run
#/case/<case-id>?run=fake deterministic fake run, auto-executed
#/case/<case-id>?run=fake&view=<transcript|context|authorization|diff|parity>
#/?group=<g>&disposition=<d>&role=<r>&claim=<c>&parity=<p>&q=<text> filtered picker
```
Determinism contract for screenshot/CI use:
- `run=fake` auto-runs the scenario to completion on load; when the run and
all panels are settled the shell sets `data-run-state="settled"`
(Playwright wait target). Until then it is `pending`.
- Fake runs render **fixture time only** — no wall-clock timestamps,
durations, or locale-dependent formatting anywhere in fake mode; ordering
is by artifact sequence. Two loads of the same route are pixel-identical
at the same viewport (modulo font rasterization).
- `view=` selects the inspector tab (desktop) or segment+tab (mobile);
`view=transcript` closes nothing — it just guarantees focus/scroll starts
at the transcript top.
- Stable `data-testid`s (minimum set): `scenario-picker`, `facet-<name>`,
`case-row-<id>`, `run-header`, `verdict-banner`, `mode-toggle`,
`transcript`, `transcript-item-<seq>`, `cp-action-<seq>`,
`inspector-tab-<name>`, `exposure-always`, `exposure-optional`,
`exposure-control-plane`, `authorization-row-<seq>`, `diff-domain-<name>`,
`parity-assertion-<n>`, `redaction-chip`.
## 8. Package-local implementation guidance (7F)
- **Reuse first.** Shell, transcript, and inspector compose the frozen SDK:
`RunnerConsoleApp` is *not* the shell (this is not a chat console);
compose `conversation`, `message`, `tool-item`, `tabs`, `card`, `badge`,
`banner`, `menu`, `dialog`, `tooltip`, `replay-controls` from
`@paperclipai/paperclip-runner/react`. Semantic call detail uses the
item-body renderer (ext point 1); control-plane detail uses the
request-detail renderer contract (ext point 2); theming via scoped
`--pcr-*` overrides (ext point 4). Do not add SDK exports or break the
`0.1.2` freeze — explorer-only components live in the explorer entry.
- **New explorer-local components** (system proposals, one job each):
`ScenarioPicker` (search + facets + listbox), `ExposureList`,
`AuthorizationTable`, `StateDiff` (domain sections + field rows),
`ParityPanel`, `RedactionChip`, `ChannelEntry` (control-plane transcript
entry). Each uses tokens only; any genuinely missing token (e.g. a
`--pcr-diff-*` pair if muted/success/danger surfaces prove insufficient)
is proposed in the 7F PR as a token addition, never inlined.
- **Data contract consumed (owned by 7C/7E, not the UI):**
- *Scenario index* (generated from `eval-traceability.yaml`): `id`,
`title`, `group`, `fixture`, `actorRole`, `taskMode`,
`primaryDisposition`, `requiredGrants[]`, `assertionClasses[]`,
`sourceAnchor`, `skillElements[]`, `evidenceIds[]`,
`forbiddenSemantics[]`. If `actorRole`/`taskMode` are not yet in the
manifest, 7C adds them to the index generator — the UI must not infer
them from prose.
- *Run artifact* (per scenario × mode): ordered `timeline[]` entries
discriminated as `semantic_call` / `semantic_result` /
`control_plane_action` / `protocol_event_ref`; `exposure` (always /
optional-with-grant / control-plane lists); `authorizationRecords[]`
(operation, claims considered, decision, reason, redactions, state-change
ref); `snapshots.before` / `snapshots.after` per entity domain;
`parity` (assertions with class/expected/observed/status, intentional
gaps with rationale, legacy baseline note, verdict).
- **Authority rule (binding):** every rendered fact traces to one of those
records. Grep-able heuristics, UI-side claim math, or re-deriving
dispositions from case IDs are defects. If a view needs a fact the records
lack, extend the record upstream.
- **`DESIGN.md` alignment:** tokens-only values; one component per job;
hierarchy by structure not decoration (domain sections and channels use
surface + position, minimal borders); status vocabulary systematic (§0
labels everywhere); machine values (IDs, anchors, grants, JSON) in
`--pcr-font-mono`; canonical copy “task.”
- **Testing hooks 7F must ship:** component tests for the four inspector
tabs and both transcript channels; keyboard-path tests (listbox, tabs,
region cycle); reduced-motion test; the §7 route determinism test
(two loads → identical settled DOM); accessibility assertions (landmarks,
names, contrast tokens); screenshot capture script for §9.
## 9. Screenshot acceptance matrix
Evidence lands in `.paperclip-local/evidence/capability/` as
`<slug>-<viewport>.png`, at both **1440×900** and **390×844**, captured from
the §7 routes after `data-run-state="settled"`. These twelve shots are the
UX acceptance set for 7F (they also cover the plan’s human-checkpoint
scenario families: heartbeat, planning, blockers, approvals, artifacts,
interactions, manager, multi-hop, restraint).
| # | Slug | Route | Expected visible evidence |
| --- | --- | --- | --- |
| 1 | `explorer-home` | `#/` | Picker with all 16 group facets and counts summing to 106; intro card with corpus stats; no dead panels. |
| 2 | `picker-filtered` | `#/?group=ix&disposition=always_agent_tool&parity=pass` | Active filter chips; filtered count visible; zero-count facet values disabled but legible; Clear filters affordance. |
| 3 | `hb-heartbeat` | `#/case/hb-scoped-wake-01?run=fake&view=transcript` | Control-plane channel entries for scoped-wake routing and checkout (“no agent tool exists for this”), then `get_task_context` agent call; pass banner. |
| 4 | `wk-planning` | `#/case/wk-plan-directive-01?run=fake&view=transcript` | `write_document` call with revision fields, revision-bound `request_human_input` (confirmation), review-parked disposition; no re-plan loop. |
| 5 | `bl-blockers-diff` | `#/case/bl-create-blocked-01?run=fake&view=diff` | State diff: tasks domain shows the new task created blocked; blockers domain shows first-class `blockedByIssueIds`; unchanged domains collapsed. |
| 6 | `ap-approval-denied` | `#/case/ap-mcp-gate-01?run=fake&view=authorization` | Authorization table with a Deny row (missing claim + reason, danger surface, no protected state); “Authorization · 1 deny” tab chip; transcript shows the typed denial card. |
| 7 | `ar-artifacts` | `#/case/ar-upload-before-done-01?run=fake&view=transcript` | `register_deliverable` completing **before** `finish_task` in artifact order; deliverable metadata in the expanded call card. |
| 8 | `ix-interaction` | `#/case/ix-checkbox-01?run=fake&view=transcript` | `request_human_input(kind=checkbox)` call card with options payload; control-plane wake/continuation entry; result inspection entry. |
| 9 | `rf-manager` | `#/case/rf-api-mgr-heartbeat-01?run=fake&view=context` | Context tab: manager role; optional discovery tools listed **with their unlocking grants**; control-plane “no tool” list populated; traceability panel showing source anchor + skill elements. |
| 10 | `mh-multihop` | `#/case/mh-subtask-tree-01?run=fake&view=parity` | Multi-hop assertion rows (child creation, chained blockers) all Pass; forbidden operations section showing “never invoked ✓”. |
| 11 | `rs-restraint` | `#/case/rs-question-only-01?run=fake&view=parity` | Restraint verdict: answer-only transcript with the “No further operations” note; parity forbidden list showing zero state writes; Pass banner. |
| 12 | `rs-secret-redaction` | `#/case/rs-secret-hygiene-01?run=fake&view=transcript` | Redaction chips (`•••` + rule name) in call/result cards; comment content free of the key; parity Pass. |
Mobile captures use the same routes; the segmented control must show the
correct active segment (`Run` for transcript shots, `Inspect` for tab shots)
and no horizontal scrollbar may be present.
Acceptance procedure: UXDesigner reviews all 24 images against this matrix
before 7F is accepted (plan §6: “UXDesigner supplies the interaction map and
reviews screenshots before implementation acceptance”). Any deviation is
either fixed or written into this document as a revision — silent divergence
fails the gate.
## 10. Out of scope / rejected
- No second app shell, no Tailwind/Radix, no dark mode, no markdown
renderer, no command palette, no transcript virtualization — SDK
rejections stand.
- No editing affordances of any kind: fixtures, grants, and policies are
changed in code/YAML, not in the browser. The mode toggle and run/replay
controls are the only mutating-looking controls, and they mutate only the
in-browser demo run.
- No parity “re-judging” in the UI: the Parity tab renders 7E output; it
never recomputes assertions.
- No live-Paperclip or ACPX affordances; the future binding boundary stays a
docs note, not a UI surface.
## 11. Revisions recorded during 7F implementation
Per §9, deviations are written here rather than left silent. All of the
following were found while building the explorer (TASK-16904) and are now part
of the contract.
1. **Timeline entry kinds extended.** §8 listed `semantic_call`,
`semantic_result`, `control_plane_action`, and `protocol_event_ref`. The
agent channel also needs plain model turns and the restraint note §3.2
requires, so the artifact adds `agent_message` and `system_note`. Protocol
event references ride on `semantic_result.protocolEventRefs` rather than
standing as their own entry, keeping the semantic view primary.
2. **Parity is computed by the runtime, not the browser.**
§4.4 and §10 forbid the UI from judging assertions, and they do: the
package-local scenario runner (`src/scenarios/scenario-parity.ts`) produces the
verdict from the traceability expectations and the recorded artifact. The
conformance suite's own per-case result is carried through verbatim in a separately
labelled "Capability conformance suite" block and is never merged into, or
used to overrule, the assertion list. When no conformance report is bundled the block
reads **Not run**.
3. **Actor role, task mode, and scenario claims are declared, not inferred.**
§8 assigned `actorRole`/`taskMode` to the scenario index generator; it shipped
without them. They are now declared in `src/scenarios/scenario-index.ts` as an
explicit fixture/case profile table, alongside the catalog claims each
scenario grants. A claim that cannot unlock any operation in the scenario's
task mode or for its actor role is dropped from the profile, so no scenario
renders a grant chip that unlocks nothing.
4. **The mobile section switcher is a `radiogroup`, not a `tablist`.** §1 asked
for the Live console segmented control. The Live console console renders it only in
compact mode; the explorer keeps one CSS-driven tree, so a `tablist` would
put two tablists in the document and model the three landmarks as tabpanels.
The three regions stay `nav`/`main`/`complementary`, and the only `tablist`
on the page is the inspector's.
5. **Two token-layer overrides, scoped to the explorer.** The SDK gives every
direct `span` child of a disclosure summary `min-width: 0`; a `Badge` is
also a `span` and sets `white-space: nowrap`, so at 390px badges were
squeezed below their own content and pushed the page into horizontal scroll.
The SDK badge also applies `text-transform: capitalize`, which renders
"Optional Tool" and "Not Run" against the §0 vocabulary. Both are corrected
in `examples/scenario-explorer/src/explorer.css` under `.pcr7-shell`; the
frozen `0.1.2` sheet is unchanged. **These are SDK defects worth fixing at
the next SDK revision, not explorer quirks.**
6. **Only the case list scrolls in its own box.** The rails are otherwise in
page flow. §9 evidence is captured full-page, and an inner scroll container
on the rail clipped exactly what the matrix asks to see (all 16 group facets
and their counts). The 106-row case list keeps a bounded height so the page
stays navigable.
7. **Shot 2's route uses `parity=not_run`.** §9 specified
`#/?group=ix&disposition=always_agent_tool&parity=pass`. Parity status is a
property of a *run*, and the picker route runs nothing, so `parity=pass`
selects zero scenarios on a cold load. The recorded route filters on
`not_run`, which is the honest cold-load state and still demonstrates
chips, live counts, disabled zero-count values, and Clear filters.
The following revisions were recorded at the 7F UX gate (TASK-16905,
2026-08-09) after reviewing all 24 captures and a live keyboard/responsive
session at both viewports.
8. **Shot 8's continuation evidence is split across surfaces.** In
`ix-checkbox-01` the continuation appears as the Context tab's
control-plane exposure entry (`route_wake` — "Interaction continuations
are routed by the control plane, not requested again") plus the parked-note
system entry — not as transcript entries. No fixture in the corpus scripts
the continuation-consuming side: only the ten §9 cases have bespoke
fake-agent plans, `ix-checkbox-result-01` falls back to a generic script
that re-raises `request_human_input` (contradicting its own premise), and
`inspect_operation_result` is catalogued but never invoked corpus-wide.
Accepted for 7F because the request side and routing authority are fully
evidenced; the continuation-side fixture upgrade is a filed follow-up, not
a silent gap.
9. **Forbidden-operations lists are empty corpus-wide.** All 106 cases record
empty `forbiddenSemantics`, so §9 rows 10–11's "never invoked ✓" rows
cannot render from real fixture data. Restraint is proven by state-diff
absence plus the intentional-gap rationale, and the Parity tab says so
explicitly ("This case records no forbidden operations; restraint is
proven by the absence of state writes in the diff"). The populated-list
path (`never invoked ✓` / violating transcript reference) exists in
`inspector-panels.tsx` and is unit-tested; the explanatory empty state is
accepted as the §9 rendering.
10. **Keyboard deltas, accepted as interim with a filed follow-up.** The
picker listbox uses selection-follows-focus (arrows select immediately —
a valid listbox pattern the map did not anticipate); type-ahead and
Enter-moves-focus-to-run-header are not implemented; `F6` region cycling
is replaced by a "Skip to run view" skip link plus landmark navigation;
denial entries do not announce assertively (only a fail verdict does).
§7's `inspector-tab-<name>` testids are absent (the frozen SDK `Tabs`
exposes roles, not testids) — tests target tabs by accessible name.
An unknown case ID in the route falls back silently to the home view
rather than a named error card.
11. **Redaction chips render the rule name inline**, not in a tooltip:
`••• redacted <rule>`. Inline beats hover-only disclosure (works on
touch, visible in captures); recorded as the new §5 contract.
12. **Evidence captures expand disclosures.** The recorder opens every
`main`/`aside` disclosure before capturing so argument payloads, decision
records, and final-state JSON are visible in the §9 images. The live
defaults are collapsed exactly as §4.3 specifies (verified in the gate's
live session); the expanded captures are a deliberate evidence choice,
not the default density.
13. **UX-gate keyboard follow-up.** The explorer handles `F6` for region
cycling when the browser delivers that key to the page. Chromium reserves
bare `F6` for browser chrome, so the documented in-page alternative is
`Control+Period`; `aria-keyshortcuts` exposes both bindings. Either binding
cycles picker (`nav`) → run (`main`) → inspector (`complementary`), with
`Shift+F6` cycling in reverse where bare `F6` is available. The skip link
remains unchanged.
@@ -0,0 +1,143 @@
# Live console Component Decision Record — shadcn/ui and Vercel AI Elements
Status: **approved** (UX gate TASK-16832, 2026-08-08). Sources checked
2026-08-08 against the live shadcn/ui and AI Elements documentation; this
supersedes and extends the
[2026-08-07 compatibility note](../research/2026-08-07-ui-library-compatibility.md)
for the authorized Live console browser surface.
## 1. Ground rules (binding on the implementation)
1. **Protocol authority.** The package's canonical PRP events and
`SessionSnapshot` reducer state are the only data contract. Third-party
chat message types, AI SDK `UIMessage`/message-part types, and
component-local state machines must not become inputs to, or shadows of,
the runner protocol. Adapted components accept props typed against
`src/reducer/session-reducer.ts` / `src/contracts/*` shapes only.
2. **Source adaptation, not dependency adoption.** Components are copied into
`devtools/browser/src/components/` and rewritten in the established local
idiom: plain function components, `data-slot` attributes, semantic `ui-*`
class names, all visual values from the token layer in `src/styles.css`.
No Tailwind, no `class-variance-authority`, no `radix-ui` runtime, no
`cn()` utility chain, no AI SDK. (Precedent: `components/ui/button.tsx`
already follows this idiom from the earlier phases.)
3. **Dependency budget: zero new runtime dependencies** for the demo app
beyond the existing `react`/`react-dom`. Every candidate below that would
pull a runtime dep (radix primitives, `cmdk`, `streamdown`, `shiki`,
`use-stick-to-bottom`, `nanoid`, `zod`) is either rebuilt on native
platform primitives (`<dialog>`, `<details>`, ARIA patterns) or rejected.
Rationale: Live console is a proof under `packages/paperclip-runner/` that
SDK will freeze into an SDK — every dependency added here becomes an
SDK liability decision later.
4. **Accessibility parity or better.** Where we rebuild a radix-backed
pattern on native primitives, the keyboard/SR contract in interaction map
§10 is the acceptance bar; "the library would have done it" is not
available as an excuse once we adapt source.
5. **Token gaps are system changes.** Live console needs a few new tokens (see
§5). Add them to `src/styles.css` `:root` in one commit with this record —
never inline values in component files.
## 2. Existing package-local primitives — REUSE
| Component | Decision | Notes |
|---|---|---|
| `ui/button.tsx` | Reuse | Add `ui-button--danger` and `ui-button--ghost` class variants (CSS only) for Stop / Cancel actions. |
| `ui/badge.tsx` | Reuse | Status badges for turn/request/connection states. |
| `ui/card.tsx` | Reuse | Base for request cards, manifest rows, inspector panels. |
| `ui/textarea.tsx` | Reuse | Composer input (wrapped with auto-grow behavior). |
## 3. Vercel AI Elements — decisions per primitive
AI Elements is a shadcn-registry distribution targeting React 19 + Tailwind 4
with Next.js + AI SDK prerequisites (docs, checked 2026-08-08). The turnkey
stack does not fit this standalone Vite package (already established in the
2026-08-07 note). We adapt **shapes and interaction patterns from source**,
re-typed to reducer snapshots and restyled to tokens.
**ADAPT (5):**
| Primitive | Adapted as | What we keep | What we change |
|---|---|---|---|
| `Conversation` | `ui/conversation.tsx` | stick-to-bottom semantics, "jump to latest" affordance, `role="log"` | drop `use-stick-to-bottom` dep — implement with a scroll listener + `scrollTo`; wire unseen-count from reducer timeline length |
| `Message` | `ui/message.tsx` | role-based alignment/grouping anatomy, avatar-less compact variant | props become `SessionItemSnapshot`; roles extended with `reasoning`/`tool`/`system`; no AI SDK message parts |
| `PromptInput` | `ui/composer.tsx` | textarea + action-row anatomy, submit-on-Enter, status-driven submit button | add Send/Steer/Stop tri-state from interaction map §1–§3; no attachments, no model picker, no AI SDK `sendMessage` |
| `Reasoning` | `ui/reasoning-item.tsx` | collapsible streaming reasoning with auto-open-while-streaming, duration caption | rebuild on `<details>`/summary with ARIA disclosure; content = plain streamed text (no markdown dep) |
| `Tool` | `ui/tool-item.tsx` | collapsible tool block: header (name + status badge) / input / output sections | status enum mapped from canonical item/turn events, not AI SDK `ToolUIPart` states; payloads render in `<pre>` |
**REJECT (with reasons; revisit in SDK where noted):**
| Primitive | Reason |
|---|---|
| `Response` (streamed markdown) | pulls `streamdown`/markdown pipeline; tracer renders exact text — markdown prettification can hide protocol truth. SDK candidate for the SDK console. |
| `CodeBlock` | needs `shiki` highlighting dep; `<pre>` + mono token suffices for a tracer. SDK candidate. |
| `Actions` (retry/like/copy row) | retry/regenerate/vote have no protocol backing; copy-to-clipboard is implemented locally in the inspector. |
| `Branch` | message branching does not exist in the PRP session model. |
| `Sources` / `InlineCitation` | no citation events in the protocol. |
| `Task` | overlapping with adapted `Tool`; subagent lineage uses the tree (map §6), not a task widget. |
| `ChainOfThought` | duplicate of `Reasoning` at higher visual weight. |
| `Context` (token/cost meter) | usage belongs in the inspector Session tab as plain data; a persistent meter overweights cost in a tracer. SDK candidate. |
| `Artifact`, `WebPreview`, `Image`, `Attachments`, `OpenIn`, `Suggestion`, `Queue` | no protocol counterpart in Live console; adopting them would invent affordances the runner cannot honor (fabricated-controls rule). |
| `Loader`/`Shimmer` | trivially replaced by a token-compliant CSS pulse honoring `prefers-reduced-motion`. |
## 4. shadcn/ui — decisions per primitive
**ADAPT (6):**
| Primitive | Adapted as | Implementation note |
|---|---|---|
| `Tabs` | `ui/tabs.tsx` | inspector tabs + mobile segments; WAI-ARIA tabs pattern, roving tabindex, no radix |
| `DropdownMenu` (menu-button subset) | `ui/menu.tsx` | Goal menu (§5) — five fixed items; menu-button ARIA pattern; no radix portal |
| `Dialog` | `ui/dialog.tsx` | native `<dialog>` element (`showModal()` gives focus trap + `Escape` for free); used by Set goal… and reset confirm |
| `Tooltip` | `ui/tooltip.tsx` | hover/focus tooltip for diagnostics; content mirrored to `aria-describedby` per map A5 (tooltip is never the only channel) |
| `Alert` | `ui/banner.tsx` | reconnect / pending-request / replay banners; tone variants from status tokens |
| `Separator`, `Kbd` | folded into `styles.css` | pure CSS (`ui-separator`, `ui-kbd`) — no component file needed |
**REJECT:**
| Primitive | Reason |
|---|---|
| `Command` (cmdk palette) | the goal surface has five fixed verbs — a searchable palette adds a dep and search UI for nothing (Hick's law works in our favor with a plain menu). Revisit only if goal/command vocabulary grows in SDK+. |
| `ScrollArea` | custom scrollbars are cosmetic; native scrolling is more accessible and free. |
| `Accordion`/`Collapsible` | native `<details>` covers reasoning/tool/observation disclosures. |
| `Sheet`/`Drawer`, `Popover`, `Select`, `Switch`, `Progress`, `Skeleton`, `Toast`/`Sonner` | no Live console surface needs them; toasts specifically are rejected because failures must live in the transcript record, not ephemeral notifications (map §1.5). |
| `Table` | inspector uses definition lists / simple grids; sortable tables are not needed. |
| `Sidebar` | the existing app-shell layout already provides rails; adopting the shadcn sidebar would restructure the proven shell. |
## 5. Token additions (system change, one commit)
Add to `:root` in `devtools/browser/src/styles.css`:
- `--accent: #2f5fa8` / `--accent-surface: #e9f0fa` — pending/streaming/info
states (currently only success/warning/danger exist; pending ≠ warning).
- `--surface-raised: #fcfcfa` — inspector rows and collapsed-card summaries.
- `--motion-medium: 200ms` — banner/dialog enter (fast is too abrupt for
modal context changes); both motion tokens gated by
`prefers-reduced-motion`.
- `--z-banner: 10`, `--z-dialog: 20` — stacking discipline for the new
layered surfaces.
Contrast pairs must pass map §10-V1; verify `--accent` on `--accent-surface`
≥ 4.5:1 before merge (measured 5.1:1 at the values above).
## 6. What the implementer must NOT do
- Do not `npx shadcn add` or use the AI Elements CLI/registry into this
package (they scaffold Tailwind/radix/AI SDK). Copy source manually,
then rewrite per §1.2.
- Do not import from `ai`, `@ai-sdk/*`, `zod`, `radix-ui`, `cmdk`,
`streamdown`, or `shiki` anywhere under `devtools/browser/`.
- Do not add a second event model, message store, or client-side session
cache that could diverge from the reducer (map §7.2).
- Do not restyle existing Replay–3 surfaces beyond shared-token additions;
their screenshots are frozen QA evidence.
## 7. Evidence checklist for this record
- [x] Live docs re-checked 2026-08-08 (shadcn/ui Vite path; AI Elements
prerequisites: Node 18+, Next.js + AI SDK project, React 19,
Tailwind 4, shadcn/ui auto-install; registry install paths).
- [x] Existing package idiom confirmed (`ui/button.tsx` et al. — plain CSS
classes on tokens, React 19, no Tailwind).
- [x] Interaction map cross-references: every ADAPT row maps to a surface in
the [interaction map](live-console-interaction-map.md); every surface has a
component owner.
@@ -0,0 +1,379 @@
# Live console Interaction Map — Live Codex Protocol Web UI
Status: **approved for implementation** (UX gate TASK-16832, 2026-08-08).
Owner: UXDesigner. Implementer: browser tracer owner (TASK-16834).
Companion record: [component decisions](live-console-component-decisions.md).
This map is the interaction contract for the Live console required by
spec §29.1. It binds every surface to canonical Paperclip Runner Protocol (PRP)
events and reducer state only. No surface may derive state from third-party
component message types, and no surface may show a control the upstream
capability set does not support.
## 0. Shell and information architecture
Extend the existing devtools browser app (`devtools/browser/`) with a new
**Live console** mode next to the existing Replay–3 modes. Do not build a
second app shell. Reuse the existing token layer in `src/styles.css` and the
existing `ui-*` primitives.
Layout (desktop ≥ 64rem):
- **Left rail (fixed, ~17rem):** demo-chat manifest list, session/connection
status block, goal banner, parent/child lineage tree.
- **Center column (fluid, max ~48rem):** chat transcript + composer. This is
the primary surface; it gets the widest column and the strongest visual
hierarchy.
- **Right rail (collapsible, ~24rem):** protocol inspector. Closed by default
on first load; its toggle lives in the header. State persists per session in
`localStorage` (UI preference only — never protocol state).
Layout (mobile < 64rem): single column. Transcript + composer first; manifest
picker, goal banner, lineage, and inspector become stacked collapsible sections
behind a segmented control (`Chat · Session · Inspector`). Composer stays
docked to the bottom of the viewport in the Chat segment.
Density: this is a diagnostic console — lean dense (Doherty: prioritize
feedback latency over decoration). All spacing from the existing scale.
## 1. Transcript and composer
**Source of truth:** the reducer timeline (`SessionSnapshot.timeline` /
`SessionItemSnapshot`), fed by `item.started` / `item.delta` /
`item.completed` / `item.failed` and turn lifecycle events.
Transcript rules:
1. Items render in reducer order. Never reorder client-side; the reducer's
sequence is authoritative (protocol authority rule).
2. Item roles: `user`, `assistant`, `reasoning`, `tool`, `system/diagnostic`.
User items align right with a filled surface; everything else aligns left
full-width. Reasoning and tool items render collapsed by default with a
one-line summary (Progressive Disclosure); assistant text renders expanded.
3. Streaming: an in-flight item (`item.started` + deltas, no terminal event)
shows a streaming affordance (pulsing cursor block, `--motion-fast`
respecting `prefers-reduced-motion`). Text appends without layout jumps.
4. Auto-follow: transcript sticks to bottom while the user is at bottom. Any
upward scroll disengages following and shows a **"Jump to latest"** pill
(fixed above the composer) with unseen-item count. Clicking or pressing
`End` re-engages. Never yank scroll position while disengaged.
5. Failed items (`item.failed`) render in place with a danger surface and the
exact diagnostic string — no toast-only errors; failures are part of the
record (Visibility of System Status).
6. Empty state: before any session, the transcript shows the manifest picker
inline ("Pick a demo chat or start a blank session") — not a bare void.
Composer states (one state machine, one visible primary action):
| State | Trigger | Primary action | Notes |
|---|---|---|---|
| `idle` | no active turn | **Send** (`turn.submitted`) | textarea enabled |
| `submitting` | send clicked, no `turn.accepted` yet | disabled, spinner in button | keep text visible |
| `active-turn` | `turn.started` for my turn | **Steer** (see §2) + **Stop** icon-button | composer relabels; input stays enabled for steering |
| `interrupting` | stop clicked | both disabled, "Stopping…" | see §3 |
| `disconnected` | transport lost | disabled + reconnect banner | see §7 |
| `terminal` | `session.closed`/`session.failed` | disabled + replay affordance | see §7 |
Composer behavior: `Enter` submits, `Shift+Enter` inserts newline. Textarea
auto-grows to 8 lines, then scrolls. The active state is announced via a
visually-hidden `aria-live="polite"` status line ("Turn running", "Steering
sent", "Turn stopped").
## 2. Steering: acknowledgement and stale-turn rejection
While a turn is active the composer's primary action is **Steer** and sends
`turn/steer` with `expectedTurnId` bound at submit time.
1. **Optimistic pending, explicit ack.** On send, append a local "steering"
chip to the transcript in a `pending` visual state (muted, spinner).
Promote it to `acknowledged` (solid, check) only on driver acknowledgement.
Never silently merge steering text into the user-message stream before ack.
2. **Stale-turn rejection is a first-class outcome, not an error toast.** If
the turn completed/failed/was interrupted between typing and send, the
driver rejects on `expectedTurnId` mismatch. The chip flips to `rejected —
turn already ended`, with the composed text preserved in an inline
**"Send as new message"** action (Forgiveness: one keystroke recovers the
intent; never discard typed input).
3. The pending chip must resolve to exactly one of `acknowledged`, `rejected
(stale turn)`, or `failed (diagnostic)` — no indefinite pending. If the
transport drops mid-steer, resolve to `failed` with the reconnect banner.
4. `aria-live="polite"` announces ack/rejection with the same copy as the
visual chip.
## 3. Interruption: three races, no session replacement
Stop is a distinct destructive-adjacent action: icon button with `Stop`
label, danger-tinted only while `active`, never adjacent enough to Send to
misclick (Fitts: minimum 2.75rem target, `--space-3` gap).
Race matrix (all three must be visually distinguishable — spec test contract):
| Race | UI before | UI after |
|---|---|---|
| interrupt **before start** (`turn.submitted`, no `turn.started`) | composer `submitting` | pending turn card resolves to `turn.cancelled` state: "Cancelled before start" |
| interrupt **during generation** | streaming assistant item | stream stops in place; item keeps partial text with an "interrupted" divider; `turn.interrupted` badge on the turn group |
| interrupt **during tool** | tool item running | tool item resolves to interrupted state with its last known status; no fabricated tool result |
Rules:
1. Stop → `interrupting` state ("Stopping…", both actions disabled) until the
terminal turn event arrives. If the driver reports interrupt unsupported,
show the exact diagnostic and re-enable — never kill or replace the session
(spec: "without silently replacing the session").
2. Partial output is preserved and labeled; never blank an interrupted item
(the partial record is the point of a tracer).
3. After `turn.interrupted`, composer returns to `idle` — resume is just a new
send. The turn group shows its terminal badge permanently.
## 4. Runtime request cards (command / file-change / permission / tool / user-input)
**Source of truth:** `runtime_request.created` / `.resolved` / `.expired` /
`.cancelled` plus `SessionRequestSnapshot` (`requestId`, `requestKind`,
`type`, `status`, `prompt`). Action availability comes from the upstream
request contract per request — the card renders only the actions the request
offers (never invent "approve for session" if the request does not offer it).
Placement: inline in the transcript at timeline position, plus a compact
"pending requests" counter chip in the session status block that scrolls to
the oldest pending card. A pending blocking request also pins a slim banner
above the composer ("1 request waiting — Review") because the turn cannot
proceed (Zeigarnik: make the open loop visible where the user is looking).
Card anatomy (one component, five kinds):
- Kind icon + kind label (`Command`, `File change`, `Permission`, `Tool`,
`Input`) + requestId (mono, truncated, full value in tooltip/inspector).
- Prompt/body: exact upstream prompt text. Command and file-change payloads
render in a mono block (command line, file diff summary) — never paraphrase.
- Action row, in this order when offered: **Approve** (primary),
**Approve for session** (secondary, only when upstream offers it),
**Reject** (quiet danger), **Cancel** (ghost, only when upstream allows
requester-side cancel). User-input kind renders a text field + **Submit**
instead of approve/reject.
- Resolution states: `pending` (accent left-border), `resolved — <outcome>`
(success surface, chosen action named, actor + time), `expired` (muted,
"expired before response"), `cancelled` (muted). Resolved cards collapse to
a single summary line, expandable (transcript stays scannable).
Rules:
1. **Single-response guarantee in the UI:** on first click, disable the whole
action row and show a resolving spinner; the card resolves only on the
`runtime_request.resolved` event, not optimistically. A late duplicate
click therefore cannot double-respond (mirrors the driver's no-double-
response contract).
2. A request resolved elsewhere (driver timeout, upstream cancel) must update
the card in place with the true outcome.
3. Keyboard: cards are focusable list items; action row is reachable by `Tab`;
see §10 for the full contract.
## 5. Goal capability states (set / view / pause / resume / clear)
**Source of truth:** capability negotiation from the app-server (per spec
§29.1.5), surfaced through the driver's capability snapshot. Goal state
changes arrive as canonical events; the banner derives from reducer state.
Surfaces:
- **Durable goal banner** in the left rail (and Session segment on mobile):
shows active goal text, state (`active`/`paused`), and last transition time.
Hidden entirely when no goal has ever been set this session; shows a quiet
"No goal set" row once goal capability is confirmed supported.
- **Goal menu** (not a fuzzy palette — five fixed operations, Hick's law):
a menu button `Goal ▾` in the session status block opening a menu with
`Set goal…`, `View`, `Pause`, `Resume`, `Clear`. `Set goal…` opens a small
dialog with one textarea. `View` focuses/expands the banner. Operations
invalid in the current goal state (e.g. `Resume` while active) are disabled
with the reason in a tooltip and `aria-disabled` + description.
Capability gating (spec-critical):
1. If the installed app-server does not advertise goal support, the whole menu
renders disabled with the **exact diagnostic** from capability negotiation
(e.g. `Goal operations unsupported: app-server 0.132.0 does not advertise
goals capability`) — copied verbatim into tooltip and inspector. Never hide
the control silently (discoverability of the gap is a deliverable) and
never fabricate a client-side goal.
2. Partial support (some verbs missing) disables only the missing verbs, same
exact-diagnostic rule per verb.
3. Every goal mutation is also a transcript/system event ("Goal paused") so
replay shows the same history.
## 6. Parent/child lineage and subagent activity
**Source of truth:** upstream thread identities (parent/child thread lineage
from the driver; `normalizedSessionId` / thread ids). No client-side
inference.
- Left-rail **lineage tree**: root session plus child threads, each row =
name/id (mono, truncated) + live state dot (`starting`, `running`,
`terminal-ok`, `terminal-failed`) + last-activity relative time. The row for
the currently viewed thread is marked with `aria-current="true"`.
- Selecting a child switches the center transcript to that child's timeline
(read-only if the driver does not support steering children). Breadcrumb at
the transcript top: `root ▸ child-a` for orientation (Jakob: matches file
tree/thread conventions).
- **Unsupported child steering renders as an explicit disabled composer** with
the exact diagnostic ("Direct steering of child threads is not supported by
this app-server"), not an absent composer and not an emulated one (spec:
"shown as unsupported rather than emulated").
- Child terminal states persist in the tree (terminal dot + badge); activity
is history, not just presence.
## 7. Reconnect, refresh, resume, replay
**Source of truth:** transport status + `session.resuming` / `session.resumed`
/ `session.reconciled` / `runner.reconnected` events and reducer cursor state.
1. **Connection status** lives in the session status block: `connected`,
`reconnecting (attempt n)`, `disconnected`, with the socket/cursor detail
in the inspector. State changes announce via `aria-live="polite"`.
2. **Transport drop:** banner above composer ("Connection lost — reconnecting…
/ Retry now"). Composer disabled while disconnected (§1). On resume, the
reducer replays from the durable cursor; the transcript must end
byte-identical to a never-disconnected session (live/replay parity is a
test contract, and the UI must not maintain shadow state that could
diverge).
3. **Page refresh:** reload restores the same normalized session and renders
the full replayed transcript, then continues live. A subtle divider marks
"replayed to here" for diagnostic honesty; it disappears on the next live
event group.
4. **Replay mode:** a terminal session offers **Replay** (from the manifest
list or terminal banner): re-runs the recorded canonical events through the
reducer with a stepper (play/pause, step, position slider). Replay is
visually distinguished by an eyebrow badge `REPLAY` in the header — never
let replay be mistakable for live (trust).
5. Never offer "start new session" as the recovery path for a drop — resume
the exact session (Codex rule: never replace a lost session).
## 8. Protocol inspector
**Source of truth:** the raw upstream method/event names and the canonical
event stream, side by side. The inspector is the debugging surface; the
transcript stays clean (Tesler: complexity lives here, deliberately).
Right-rail panel with four tabs:
- **Events:** virtualized list, one row per canonical event — `seq`,
`eventType`, turn/item id (mono). Row expands to the full canonical payload
and, when present, the raw upstream method/notification that produced it
(`debug` channel). Filter box (plain substring) + eventType facet chips.
Clicking a transcript item highlights its events and vice versa
(Uniform Connectedness across panels).
- **Requests:** pending + resolved runtime requests with full lifecycle
timestamps; row links to the transcript card.
- **Capabilities:** negotiated capability/version table (steer, interrupt,
goals, child threads, usage…), each row `supported / unsupported —
<exact diagnostic>`. This is the authoritative view backing every disabled
control in §5/§6.
- **Session:** identities (runId, normalizedSessionId, thread ids,
sourceInstanceId), sequence/cursor state, gap detection status, usage
counters, connection history.
Rules: monospace for identifiers and payloads; copy-to-clipboard per row;
**redaction is upstream's job but the inspector must render redaction markers
verbatim and never attempt client-side un-redaction or pretty-printing that
could reconstruct secrets**. No credential ever reaches the browser (spec
security rule), so the inspector renders only what the demo server sends.
## 9. Demo-chat manifests
**Source of truth:** deterministic manifest files shipped with the package
(one per spec scenario: completion, steering, interruption/resume, approvals,
user input, subagents, goals, reconnect, replay).
- Manifest list in the left rail: name, one-line purpose, scenario badges,
and an **expected observations** disclosure listing exactly what the runner
should show (from the manifest, not hand-written in the UI).
- Running a manifest pins a compact **observation checklist** panel the user
can tick off manually (recognition over recall — the human checkpoint
script is embedded in the UI, not in a separate doc).
- **Reset control** per manifest and a global "Reset demo state": resets
demo-server state and clears the local session, with a confirm dialog
naming exactly what is destroyed ("Discards the current live session
transcript. Recorded evidence files are not touched.").
- Manifests must be runnable in any order; the list shows last-run outcome
per manifest (`passed observations`, `not run`, `interrupted`).
## 10. Keyboard and accessibility acceptance criteria
These are the testable ACs for the component/keyboard/a11y checks required by
the spec evidence list. Implementation must satisfy every row; QA verifies
each with keyboard-only and screen-reader passes.
**Keyboard**
- K1. Every interactive control is reachable and operable with `Tab`/
`Shift+Tab`/`Enter`/`Space` alone; no keyboard trap anywhere, including
dialogs and the inspector.
- K2. Composer: `Enter` sends (or steers when a turn is active),
`Shift+Enter` newline. `Escape` while a turn is active moves focus to the
Stop button (it must never interrupt directly — destructive actions require
explicit activation).
- K3. Request cards: card list is an `role="list"`; each card's actions are
reachable in visual order; the oldest pending card is focusable via the
pending-requests banner (`Enter` on banner focuses it).
- K4. Goal menu: standard menu-button pattern (`Enter`/`Space`/`ArrowDown`
opens, arrows navigate, `Escape` closes, focus returns to trigger).
- K5. Lineage tree: `role="tree"` arrow-key navigation; `Enter` selects a
thread; selection is announced.
- K6. Transcript: `End` jumps to latest and re-engages follow; `Home` jumps
to first item; transcript region is a labeled `role="log"` landmark.
- K7. Inspector tabs: standard tabs pattern (arrow keys switch, `Tab` enters
panel); event rows expand/collapse with `Enter`.
- K8. Replay stepper: `Space` play/pause, `ArrowLeft`/`ArrowRight` step,
slider operable by arrows with value announced.
- K9. Visible focus indicator (`--ring`, ≥2px, ≥3:1 contrast against both
adjacent colors) on every focusable element; focus order matches visual
order.
**Screen reader / semantics**
- A1. Transcript is `role="log"` with `aria-live="polite"`; streaming deltas
do **not** announce per-token — announce on item completion and on state
transitions only (avoid SR flooding).
- A2. Turn/steer/interrupt/connection/goal state changes each produce exactly
one polite announcement with the same copy as the visible state.
- A3. A pending blocking request announces assertively once
(`aria-live="assertive"` on the banner), then stays quiet.
- A4. Every state conveyed by color (status dots, card borders, badges) has a
text or icon equivalent (WCAG 1.4.1 color-independence).
- A5. Disabled capability controls expose the exact diagnostic via
`aria-describedby`, not only a hover tooltip.
- A6. Dialogs (`Set goal…`, reset confirm) are proper modal dialogs: labeled,
focus moved in, trapped, restored on close.
- A7. All identifiers/payload blocks are in `<code>`/`<pre>` semantics; copy
buttons are labeled with what they copy ("Copy event 42 payload").
**Visual/WCAG**
- V1. Text contrast ≥ 4.5:1 (normal) / 3:1 (large); non-text UI ≥ 3:1 —
verify the token pairs used by new surfaces (notably `--muted-foreground`
on `--muted` and status-on-surface pairs).
- V2. Pointer targets ≥ 2.75rem (44px) on mobile breakpoints; Stop and Send
separated by ≥ `--space-3`.
- V3. `prefers-reduced-motion` disables streaming pulse, auto-scroll
smoothing, and skeleton shimmer (fall back to instant updates + static
placeholders).
- V4. Layout holds at 320px width and 200% zoom without horizontal scroll of
the transcript (WCAG 1.4.10 reflow); the inspector may scroll internally.
- V5. Both viewports in evidence: 1440×900 and 390×844 screenshots for every
major state (QA gate).
## 11. UX acceptance criteria for the implementation handoff
1. All surfaces derive from canonical events/reducer snapshots; grep-level
check: no import from any AI SDK, and adapted component files contain no
third-party message-type imports (see decision record).
2. Every state named in §1–§9 is reachable via the demo manifests, and each
has a distinct, labeled visual state (no two states share identical UI).
3. All K/A/V criteria in §10 pass keyboard-only and screen-reader passes.
4. Unsupported capabilities render exact diagnostics (§5, §6, §8-Capabilities)
— zero fabricated controls, zero silently hidden controls.
5. Token compliance: no raw color/spacing/radius/type values in new component
files; everything through `src/styles.css` custom properties (extend the
token set via PR if a needed token is missing — see decision record §5).
6. Screenshots at both evidence viewports for: idle+empty, streaming turn,
steering ack + stale rejection, all three interrupt races, each request
kind (pending/resolved/expired), goal supported+unsupported, lineage with
an active child, reconnect banner + replayed divider, replay mode,
inspector (Events + Capabilities tabs), and one full mobile pass.
@@ -0,0 +1,523 @@
# Scenario chat Interaction Map — Mobile Chat with Visible Mock Activity
Status: **approved UX contract**, implemented in TASK-16916 (2026-08-09).
Deviations taken during implementation are recorded in §11a; the §11 matrix is
kept as an acceptance contract; the recorded screenshot campaign is deferred.
Owner: UXDesigner. Implementer: 7I implementation (TASK-16916), consuming the
existing 7C mock adapter, 7D semantic catalog, and 7F explorer components.
Companion records: [Capability interaction map](capability-scenario-explorer-ux.md)
(the 7F contract — everything not overridden here is inherited verbatim),
[Live console interaction map](live-console-interaction-map.md) (chat console
baseline), [SDK component decisions](sdk-component-decisions.md).
(annotated desktop + mobile renders in `.paperclip-local/evidence/capability/`,
`scenario-chat-*` slugs).
Scenario chat adds a **Codex-style interactive chat** over the same package-local
Capability runner and mock `ControlPlanePort`. The board opens a scenario, chats
with the agent, and — this is the point of the phase — **sees the mock
Paperclip activity for every turn** instead of having it hidden behind chat.
The 7F explorer remains unchanged as the corpus-review surface; the chat is a
sibling surface in the same app shell, one React tree, sharing the picker,
transcript grammar, inspector components, and token layer.
The 7F authority rule is binding here too: the chat UI renders records the
runtime produced. It computes no exposure, evaluates no claim, applies no
redaction, holds no credential, and never re-times or reorders the artifact
stream.
## 0. Naming, vocabulary, and the turn model
- Surface name in the UI: **Scenario chat** (header: “Capability scenario chat —
mock control plane”).
- All 7F §0 vocabulary carries over exactly: **task** in copy; machine values
(case IDs, `MCK-102`, grants, JSON) verbatim in `--pcr-font-mono`;
disposition labels **Control plane / Always tool / Optional tool**; parity
statuses **Pass / Fail / Intentional gap / Not run** always icon + text.
- A **turn** is one user message and everything the runtime records until the
agent settles (model output, semantic calls/results, authorization
decisions, control-plane actions, state changes, reconciliation events,
and the turn parity verdict). Turns are numbered from 1 in artifact order.
- **Session start** is turn 0: the control-plane-owned seeding record (wake
payload, checkout, initial tool exposure) that exists before any user
input. It renders with full control-plane styling — the demo must show
that work happened with no agent tool before the first message.
## 1. Shell and information architecture
Same Vite entry, same app shell as 7F (`examples/scenario-explorer/`), new
routes under `#/chat/…` (§9). No second app, no second token sheet.
Desktop (≥ 64rem), three regions, one React tree:
- **Left rail (`--pcr-rail-width`, 17rem):** the existing 7F scenario picker,
reused as-is (search, facets, grouped case list). Selecting a case in chat
context seeds a chat session for that scenario instead of a fake run.
- **Center column (fluid, max `--pcr-center-width`, 48rem):** the **chat** —
session header, conversation, and composer. Primary surface, strongest
hierarchy. The composer is pinned to the bottom of the column.
- **Right rail (26rem, proposed token `--pcr-activity-width`):** the
**Activity stream** — the ordered per-turn evidence (§4). Open by default
on desktop; it carries the acceptance evidence, so it is never a
collapsed-by-default drawer on desktop.
Mobile (< 64rem): single column behind the established segmented control
pattern (a `radiogroup`, per 7F revision 4), with exactly two segments:
**Chat · Activity**. The segmented control is sticky at the top; the
composer is sticky at the bottom of the Chat segment (thumb zone). The
Activity segment contains the complete §4 stream as stacked cards — mobile
gets **all** the evidence, never a summary-only view. A count chip on the
Activity segment surfaces attention states without opening it
(“Activity · 1 deny”).
Density: diagnostic-console dense. All values from `--pcr-*` tokens only
(`DESIGN.md` principles 2–4); the two 7F token-layer overrides (badge
min-width / text-transform, 7F revision 5) apply to the chat surface too.
## 2. Session header, scenario selection, reset and replay
The session header replaces the 7F run header on chat routes:
- **Row 1:** case ID (mono) + title, group badge, disposition badge.
- **Row 2 — session status chip**, exactly one of:
- **Live · Codex (bounded)** (accent surface) — a real model is connected
through the server-side relay;
- **Replay · deterministic** (muted surface) — the scripted session is
playing or played;
- **Idle** (muted) — seeded, no turns yet.
- **Row 3 — controls:** mode segmented control (`Replay (scripted)` default /
`Codex (bounded)` when the relay is reachable — same availability and
disabled-reason rules as 7F §3.1), **Reset session**, and **Replay**
(re-runs the recorded session deterministically). Reset is a destructive
affordance: it clears the session back to the seeded turn-0 state and asks
for confirmation via the SDK `Dialog` (“Reset this session? The transcript
and mock state return to the seeded scenario.” — Forgiveness). Replay
needs no confirmation; it reproduces the identical timeline (7F
determinism contract).
- **Parity-so-far chip:** “Parity · 2 turns pass” (success) or
“Parity · turn 2 fail” (danger), deep-linking the newest parity entry in
the Activity stream.
Scenario switching mid-session prompts the same reset confirmation (the mock
core is seeded per scenario; two scenarios never share mutable state).
## 3. Chat transcript
The conversation composes the frozen SDK chat kit: `conversation`,
`message`, `tool-item`, `reasoning-item`, `Composer`. Three actors render
with three distinct visual grammars (Similarity + Common Region — a stranger
must attribute any row to its actor in two seconds):
1. **User turns** — right-aligned message bubbles on `--pcr-accent-surface`.
2. **Model output** — left-aligned message cards on `--pcr-card` with the
standard streaming affordances (token reveal, jump-to-latest, auto-follow
inherited verbatim from Live console §1; reduced-motion renders instantly).
Reasoning summaries, when the driver emits them, use `reasoning-item`
behind its existing disclosure.
3. **Agent tool work** — semantic calls render inline in the conversation as
collapsed `tool-item` cards on `--pcr-surface-raised`: operation ID
(mono) + disposition badge + one-phrase outcome. Expansion shows the 7D
descriptor fields exactly as in 7F §3.2 (arguments, result, claims,
idempotency, redaction chips). Denied calls render in place on
`--pcr-danger-surface` with the typed denial (operation, missing claim,
reason) — never toast-only, never any protected state.
4. **Control-plane work** — full-width strips on `--pcr-muted` with the
“Control plane” badge and the 7F gutter glyph, copy naming the actor
plainly (“Control plane registered the interaction and scheduled the
continuation wake — no agent tool exists for this.”). Turn 0 seeding uses
this grammar.
**Turn activity strip (new, binding):** after each completed turn the
conversation appends a full-width summary strip for that turn:
`Turn 2 activity · 3 tools · 1 denied · 2 control plane · diff 2 domains ·
parity ✓`, rendered as a single button. Counts are icon + text, never color
alone. Activation opens that turn’s group in the Activity stream (desktop:
scroll + flash the group, same connect cue as 7F authorization rows;
mobile: switch to the Activity segment anchored at the group, with a
“Back to chat” affordance that restores scroll position). The strip is the
guaranteed per-turn bridge — evidence is reachable from the turn itself,
not only by scanning the rail (Recognition over Recall).
The composer is the SDK `Composer` with its existing state machine —
`idle / submitting / active-turn (Steer) / interrupting / disconnected /
terminal` — and keyboard contract (Enter sends, Shift+Enter newline, Escape
moves focus to Stop during a turn). In Replay mode the composer renders
`disconnected`-style with the reason “Scripted replay — switch to Codex
(bounded) to chat live” and the Replay control adjacent; free-text input is
a live-driver capability, not a replay capability.
## 4. Activity stream (per-turn evidence)
**Source of truth:** the ordered run artifact timeline plus per-turn
records (§10 data contract). The stream is grouped by turn; groups render
newest-last and auto-follow the conversation. Each **turn group** is a
collapsible section headed by `Turn N` + the same counts as the turn strip;
the newest turn group is expanded by default, earlier groups collapse to
their header (progressive disclosure; Miller — never all turns fully open).
Inside a group, ordered sections, each rendered only when non-empty, always
in this order:
1. **Exposed tools** — the turn’s exposure record: Always (N) / Optional (N,
each with its unlocking grant) / Control plane — no tool (N), reusing the
7F `ExposureList`. Collapsed to counts by default; the acceptance-critical
full list is one disclosure away.
2. **Calls and results** — one row per semantic call: operation (mono),
disposition badge, outcome; expansion identical to the inline tool card.
Rows and their inline conversation twins highlight each other on
selection (uniform connectedness).
3. **Authorization** — the turn’s slice of the 7F `AuthorizationTable`:
decision (Allow/Deny, icon + text), claims considered, reason,
redactions; denied rows on `--pcr-danger-surface`.
4. **Control plane** — the turn’s control-plane actions with their decision
records (inputs considered, resulting state change, audit reference).
5. **State diff** — the 7F `StateDiff` scoped to the turn: per-domain change
summaries, unchanged domains as one muted row, `field: before → after`
in mono. Wake/reconciliation events (continuation scheduling, blocker
resolution, terminal reconciliation) render here as rows in their
domains, badged “Control plane”.
6. **Parity** — the turn verdict row (icon + text + assertion counts),
expanding to the 7F `ParityPanel` assertion rows for that turn. The
session-level verdict lives in the session footer group.
The stream ends with a **Session** group: cumulative diff against the seed,
session parity verdict, and the restraint note when the correct behavior was
absence (“No further operations — N forbidden operations, none invoked”,
7F §3.2 rule carried over).
A filter chip row at the top of the stream (`All · Tools · Authorization ·
Control plane · Diffs · Parity`) narrows sections across all turns;
filtering never deletes groups, it collapses non-matching sections (the
turn skeleton stays scannable). Filter state is view state, mirrored to the
route (§9), never protocol state.
## 5. Actor distinction (binding summary)
| Actor | Surface | Marker |
| --- | --- | --- |
| User | `--pcr-accent-surface` bubble, right-aligned | — |
| Model output | `--pcr-card` message card, left-aligned | streaming caret |
| Agent tool work | `--pcr-surface-raised` collapsed card, conversation-indented | mono operation + disposition badge |
| Control plane | `--pcr-muted` full-width strip | “Control plane” badge + gutter glyph |
| Denied (any) | `--pcr-danger-surface` in place | Deny icon + text |
No view — chat, activity, expanded detail — may render a fact without its
actor attribution. If a record ever lacks attribution, that is a 7C artifact
defect to fix upstream, not a UI guess.
## 6. Credentials, redaction, denial
7F §5 applies verbatim (including revision 11: redaction chips render
`••• redacted <rule>` inline, no tooltip-only disclosure). Additionally for
chat: the composer submits user text to the package-local runner only; in
Codex mode the provider credential stays server-side behind the same relay
boundary proven in SDK, and no user-typed text is ever written to
`localStorage` (a page reload may drop an unsent draft; that is the accepted
trade against persisting conversation content).
## 7. States that must be designed, not defaulted
| State | Surface | Rendering |
| --- | --- | --- |
| No scenario selected | Chat + activity | Intro card (corpus stats + “Pick a scenario to start chatting” + three example deep links); activity rail shows the 7F corpus summary. No dead panels. |
| Seeded, no turns | Chat | Turn-0 control-plane strips render immediately (checkout, exposure); composer focused and ready in Codex mode; primed hint in Replay mode (“Replay the scripted session, or switch to Codex (bounded)”). |
| Turn streaming | All | Model card streams; composer in `active-turn` (Steer + Stop); the pending turn’s activity group shows a live-updating header (“Turn 3 · running”); controls that would corrupt the run (Reset, Replay, mode switch, scenario switch) disable with reasons. |
| Turn failed (harness/driver error) | Chat | Danger strip in place with the exact diagnostic and a “Retry turn” affordance; the partial activity group stays visible and is labeled partial. Never silently reset. |
| Denied operation | Chat + activity | §3/§4 danger rendering; the Activity segment chip counts denials. |
| Relay unavailable | Session header + composer | Codex option disabled with reason; Replay mode fully usable; composer disabled-reason names the relay, not a generic error. |
| Session terminal | Composer | `terminal` state with its existing hint (“This session is closed. Replay it or reset the session state.”). |
| Reset confirm | Dialog | §2; focus returns to the composer after either outcome. |
| Long JSON / long code / long unbroken strings | Everywhere | `overflow-wrap: anywhere` on prose and key-value values; `pre` blocks may scroll **internally** (x) with a visible affordance, but the page never scrolls horizontally at any viewport ≥ 320px — the 7F `min-w-0` discipline is a hard gate at 390×844. |
| Unknown case ID in route | Chat | The 7F named error card (“No scenario named …”) — not a silent home fallback (closes 7F revision 10’s accepted gap for chat routes). |
## 8. Touch, keyboard, focus, accessibility
- Touch targets ≥ `--pcr-touch-target` (2.75rem) for segments, turn strips,
group headers, filter chips, composer buttons.
- Landmarks: `nav` (picker), `main` (chat), `complementary` (activity);
one `h1`; `F6` / `Control+Period` region cycling and the skip link carry
over from 7F (revision 13) with the activity rail as the third region.
- Composer keyboard contract per §3; the turn strip and group headers are
real `button`s; the mobile segment switch preserves and restores scroll
position per segment.
- Focus order on a completed turn: model card → inline tool cards → turn
strip → composer. After “Back to chat”, focus returns to the originating
turn strip.
- Live regions: turn settle announces politely (“Turn 2 settled — 1 denied
operation, parity pass”); a denial announces assertively once, when it
lands (upgrading 7F revision 10’s interim).
- Streaming, flash cues, and auto-follow honor `prefers-reduced-motion`
(instant/static variants, SDK rule). Contrast from existing token
pairs; every status icon + text.
## 9. Routes and determinism
Hash routing, extending the 7F scheme:
```
#/chat chat home (picker + intro)
#/chat/<case-id> live session, seeded (turn 0)
#/chat/<case-id>?replay=fake deterministic scripted session, auto-played
#/chat/<case-id>?replay=fake&view=<chat|activity>&turn=<n>
#/chat/<case-id>?replay=fake&filter=<tools|authorization|control|diff|parity>
```
- `replay=fake` auto-plays the scripted session to completion; when the
conversation and all activity groups are settled the shell sets
`data-chat-state="settled"` (Playwright wait target; `pending` until then).
Fixture time only, no wall-clock values, pixel-identical across loads at
the same viewport — the 7F determinism contract applies unchanged.
- `view=activity` selects the Activity segment on mobile and scrolls the
rail on desktop; `turn=<n>` anchors that turn’s group and expands it.
- Live chat routes (`#/chat/<case-id>` without `replay`) are the primary
board experience and are exempt from pixel determinism; QA acceptance of
live behavior uses the scripted routes plus one real Codex turn.
- Stable `data-testid`s (minimum new set): `chat-shell`, `session-header`,
`session-status`, `chat-conversation`, `chat-turn-<n>`,
`turn-activity-strip-<n>`, `activity-stream`, `activity-turn-<n>`,
`activity-section-<turn>-<name>`, `activity-filter-<name>`, `composer`,
`reset-session`, `replay-session`, `segment-chat`, `segment-activity`.
7F testids keep their meanings.
## 10. Package-local implementation guidance (7I)
- **Reuse first.** The chat column composes the SDK chat kit
(`conversation`, `message`, `tool-item`, `reasoning-item`, `Composer`,
`Dialog`, `Badge`, `Banner`) — `RunnerConsoleApp` itself is *not* the
shell (same 7F ruling: compose parts, don’t adopt the console’s
session-timeline IA). The activity stream composes the existing explorer
components: `ExposureList`, `AuthorizationTable`, `StateDiff`,
`ParityPanel`, `ChannelEntry`, `RedactionChip`.
- **New explorer-local components** (one job each): `SessionHeader`,
`TurnStrip` (the per-turn summary button), `ActivityStream` (turn
grouping + filter chips), `ActivityTurnGroup`. Tokens only; the two
candidate new tokens (`--pcr-activity-width: 26rem`,
`--pcr-touch-target` if not yet minted) are proposed as token additions
in the 7I PR, never inlined.
- **Data contract extension (owned by the runtime, not the UI):** timeline
entries gain a `turn` number (0 = session seed); the artifact gains
`turns[]` with per-turn `exposure`, `authorizationRecords`, `diff`,
`reconciliationEvents`, and `parity`, each shaped exactly like their
session-level 7F counterparts. A `user_message` timeline kind joins the
existing five kinds. The session-level records remain and feed the
Session group. The UI must not derive turn boundaries, per-turn diffs, or
per-turn verdicts itself — if the runtime can’t supply a per-turn record,
extend the runtime.
- **Driver seam:** Replay mode consumes recorded fixtures; Codex mode uses
the bounded driver through the SDK relay. The chat UI is
driver-agnostic — it renders whatever the artifact stream says happened.
- **Testing hooks 7I must ship:** component tests for `TurnStrip`,
`ActivityStream` grouping/filtering, and per-turn diff/parity rendering;
keyboard tests (composer contract, strip→group→back-to-chat focus
round-trip, region cycling with the new rail); the §9 route determinism
test; a 390×844 no-horizontal-scroll assertion on a long-JSON fixture;
reduced-motion test; screenshot capture script for §11.
## 11. Screenshot acceptance matrix
Evidence lands in `.paperclip-local/evidence/capability/` as
`scenario-chat-<slug>-<viewport>.png` at **1440×900** and **390×844**, captured
from §9 routes after `data-chat-state="settled"`. Mobile captures must show
the correct active segment and no horizontal scrollbar.
| # | Slug | Route | Expected visible evidence |
| --- | --- | --- | --- |
| 1 | `chat-home` | `#/chat` | Picker + chat intro; no dead panels. |
| 2 | `chat-session` | `#/chat/ix-checkbox-01?replay=fake` | Turn-0 control-plane strips; user/model/tool/control-plane grammars all distinguishable; turn activity strips with counts; composer in its mode-correct state; desktop shows the activity rail with the newest turn expanded. |
| 3 | `chat-denied` | `#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=2` | Denied call on danger surface in both conversation and authorization section; missing claim + reason; Activity segment chip counting the deny (mobile). |
| 4 | `chat-activity-diff` | `#/chat/ix-checkbox-01?replay=fake&view=activity&turn=3` | Turn group with exposure counts, calls, authorization, control-plane rows, per-turn state diff (changed vs unchanged domains), reconciliation/wake row, turn parity verdict. |
| 5 | `chat-streaming` | `#/chat/ap-mcp-gate-01?replay=fake&stage=streaming` | Streaming model card, composer `active-turn` (Steer/Stop), pending activity group header, disabled session controls with reasons. Exempt from pixel determinism; capture once per viewport. |
The scripted fixtures for `ix-checkbox-01` and `ap-mcp-gate-01` chat
sessions are new 7I fixtures (the 7F fake-agent plans are single-shot, not
conversational); they must script at least two user turns each so turn
grouping is actually exercised.
Acceptance procedure: UXDesigner reviews all ten images against this matrix
before 7I implementation is accepted; deviations are fixed or recorded as
revisions in this document — silent divergence fails the gate (7F §9 rule).
## 11a. Revisions recorded during 7I implementation (TASK-16916)
Silent divergence fails the §11 gate, so every deviation the implementation
made from §1–§11 is recorded here rather than left for a reviewer to discover.
1. **Mode names: `Scripted (deterministic)` / `Codex (bounded)`, and the
composer stays usable in scripted mode.** §3 said replay mode renders the
composer `disconnected` because "free-text input is a live-driver
capability". With the provider relay unavailable in the package-local demo
(the same 7F §3.1 condition), that rule would have made the phase's own
acceptance — "a user can complete successful and denied mock Paperclip
interactions from chat" — unreachable. Scripted mode therefore accepts a
prompt and drives the next recorded turn against the live mock core, and the
composer note says exactly that, with the number of scripted turns left. The
board is never told a model wrote the reply.
2. **`replay=fake` plays once per session, not per route.** An explicit
**Reset session** on a `replay=fake` route returns to the seeded turn 0 and
stays there; the route's auto-play applies to the first session only.
Otherwise reset would be undone in the same frame by the route replaying,
which is the opposite of what §2 promises.
3. **`stage=streaming` added to §9.** §11 shot 5 said "live route, staged".
Staging by hand is a race, so the route parameter holds the last scripted
turn mid-reveal and the shell reports `data-chat-state="streaming"` — a
stable, linkable wait target. `pending` covers the mid-reveal transition.
4. **`data-chat-state` lives on the shell, not only on `chat-shell`.** On the
mobile Activity segment the chat column is hidden, so a wait target that
only existed inside it was unreachable exactly where the evidence is. Both
elements now carry it.
5. **Tool cards reuse the 7F transcript disclosure rather than the SDK
`tool-item`.** `ToolItem` requires a reducer `SessionItemSnapshot`, which a
semantic call is not. The 7F rendering already follows the same
`pcr-disclosure` grammar `ToolItem` itself uses, and reusing it keeps one
description of a semantic call across both surfaces. `Message`, `Composer`,
`Dialog`, `Badge`, and `Banner` are used as specified.
6. **Turn 0 reports `Pass`, not a blank verdict.** The seed makes exactly one
checkable claim — control-plane work with no agent tool — and reports a
verdict over that claim alone. Turns that did no agent work and made no
control-plane claim report `Not run`; no turn ever reports a verdict for
work nobody did.
7. **A third authored turn for `ix-checkbox-01`.** §11 shot 4 asks for a
wake/reconciliation row, but the mock core schedules the continuation wake
when an interaction is **resolved**, not when it is requested. Turn 3 is the
board answering its own checkbox — a control-plane-owned action with no
agent tool — which is a better demonstration of the phase's point than a
fabricated wake would have been. Shot 4's route is therefore `turn=3`.
8. **Filtering the picker no longer clears the selected case on the chat
surface.** Narrowing the rail must not end a live session; the explorer
keeps its existing behaviour.
9. **Evidence captures neutralise the sticky composer.** A full-page capture
stretches the viewport to the content height, which leaves a sticky element
floating mid-document. Pinning is proven by a browser test that scrolls the
transcript and asserts the composer is still on screen.
## 11b. Acceptance review rulings (TASK-16920, UXDesigner, 2026-08-09)
Review method: all ten §11 captures inspected at full resolution, plus a live
pass against `demo:scenarios` at 1440×900 and 390×844 (scripted composer driven
interactively, streaming route inspected in-DOM). Shots 1–4 are **accepted**.
Shot 5 is **rejected pending revisions 12–14 below**; only shot 5 needs
re-capture.
Rulings on §11a: revisions 1, 2, 4, 5, 6, 7, 8, and 9 are **accepted** as
recorded. Notes where the ruling needed evidence beyond the text:
- **1 (composer usable in scripted mode) — accepted.** Verified live: a
free-text prompt drives the next recorded turn; the status chip, mode
segment, and the persistent composer note carry the attribution. The
exhausted state is genuinely designed: with zero scripted turns left, a
send runs a real `get_task_context` against the mock core and the reply
says the script is finished and how to replay — no silent drop, no faked
answer (Forgiveness; visibility of system status).
- **3 (`stage=streaming` route) — the route and wait-target approach are
accepted**; hand-staged captures were a race and a deterministic stage is
the right fix. The *current staging depth* fails §7 and the shot-5 matrix
row — see revisions 12–13.
- **7 (`turn=3`) — accepted.** The mock core wakes on interaction
*resolution*, not request; a board-owned resolution producing the real
`wake.scheduled` row demonstrates the phase's point without fabricating a
wake. Shot 4's evidence confirms the reconciliation row renders in the
turn diff, badged Control plane.
Reviewer-recorded revisions (10–11 accepted as built, 12–14 are the fixes
shot 5 is gated on):
10. **Mobile keeps three segments: Scenarios · Chat · Activity.** §1 said
"exactly two", which was written for the session surface and forgot that
mobile has no other path to the picker; with two segments a phone user
could never switch scenarios. The 7F segmented-control precedent (7F
revision 4) already includes the picker segment. §1 is amended; the
Activity count chip behaviour is unchanged. Accepted as built.
11. **Chat-home activity rail renders a purpose hint, not the 7F corpus
summary.** §7 asked for the corpus summary in the rail; the intro card
already carries the corpus stats, so repeating them one column away is
redundancy, not information (Occam). The hint card instead sets the
rail's mental model — what will appear there once a session starts — so
the panel is neither dead nor duplicate. §7 is amended. Accepted as
built.
12. **Fix required — staged streaming must hold the activity stream too.**
At `stage=streaming` the transcript is held mid-turn and the chat shows
"Turn 2 · running", but the activity rail renders the held turn fully
settled: calls the transcript has not shown yet, the state diff, and a
**parity verdict** — under a header promising evidence appears "as the
conversation happens". A surface whose whole premise is honest evidence
must not show a verdict for a turn it says is still running (§4 source
of truth; §7 streaming row: "the pending turn's activity group shows a
live-updating header"). Fix: scope the `ActivityStream` to the same
reveal ceiling the transcript uses (`chat-surface.tsx` holds the
transcript at `holdAt` but passes the full artifact to the rail); the
held turn's group renders the running header and only the evidence
revealed so far — no diff, no parity, no counts that include unrevealed
calls.
13. **Fix required — the staged hold must leave a model card visibly
mid-reveal.** The §11 shot-5 row's first expected item is "streaming
model card"; the current hold point (`firstSequence + 2`) lands on tool
entries for `ap-mcp-gate-01`, so the only caret on screen is the
pending-turn strip's. Choose the hold so a model-output entry is
partially revealed (caret + partial text on a `--pcr-card` message
card), or stage against a turn whose early entries include model
output.
14. **Fix required — disabled session controls need visible reasons.**
During a running turn, Reset/Replay disable with the reason only in
`title` — invisible in evidence, unreachable on touch, unannounced by
screen readers (§7 says "disable with reasons"; same rule that banned
tooltip-only redaction disclosure in 7F revision 11). Fix: one muted
line adjacent to the controls (e.g. "Controls return when the turn
settles."), wired via `aria-describedby`; keep the `title` if you like,
it just can't be the only channel.
Re-acceptance: re-run deferred recorded-evidence campaign and hand back only the two
`chat-streaming` captures for review; shots 1–4 stand.
## 11c. Re-acceptance ruling (TASK-16920 closing, UXDesigner, 2026-08-09)
Shot 5 (`chat-streaming`, both viewports, re-captured in `c8422d1aaa`) is
**accepted**. Revisions 12–14 verified against the new captures and the
code:
- **12 fixed.** The activity rail now consumes the same reveal ceiling as
the transcript (`ChatActivityStream` takes the conversation's inclusive
timeline ceiling); the held Turn 2 group renders the running header with
only the revealed `request_approval` call, its denial, and the one
authorization record — no diff, no parity verdict, no settled counts.
- **13 fixed.** The hold lands on a model card mid-reveal: partial message
text with the caret on a `--pcr-card` message card, in addition to the
pending-turn strip.
- **14 fixed.** "Controls return when the turn settles." renders as a
visible muted line adjacent to the controls, wired via `aria-describedby`
on all four mid-turn-disabled controls; `title` retained as a secondary
channel.
Reproducibility spot-check in the review run: deferred recorded-evidence campaign re-run
produced **byte-identical** `chat-streaming` PNGs at both viewports.
15. **Post-capture copy change (demo-hardening commits `0566a9f3ca`,
`362754a633`) — accepted, evidence refreshed.** After shots 1–4 were
recorded, the composer helper gained a synthetic-demo disclaimer
("Synthetic demo only. Session state is memory-only and is cleared on
reset, expiry, or restart; do not enter confidential or regulated
data.") and the mock-core wording changed to "isolated" /
"package-local". Both are improvements — the disclaimer is an honest
trust signal consistent with this surface's premise, and the wording is
more accurate — but they made the committed shot 1–4 captures stale
(divergence would otherwise be silent, failing §11's own rule). The
five affected PNGs (`chat-home` desktop, `chat-session` both,
`chat-denied` desktop, `chat-activity-diff` desktop) were re-recorded
at review time; the disclaimer verified to wrap cleanly at 390×844 with
no horizontal scroll.
The §11 matrix is **fully accepted**. The gate is closed.
## 12. Out of scope / rejected
- All 7F §10 rejections stand (no Tailwind/Radix, no dark mode, no markdown
renderer, no virtualization, no parity re-judging, no live-Paperclip
affordances).
- **No bottom-sheet drawer on mobile.** The segmented Chat · Activity
pattern is proven, keeps one React tree, and route-mirrors; a drawer adds
a new component and an un-linkable state for no evidence gain. Rejected
after comparison, not by omission.
- **No hiding of activity behind a “developer mode”.** Visible mock
activity is the phase’s purpose; there is no toggle that suppresses the
activity stream or turn strips.
- **No per-message edit/regenerate affordances.** The mock core’s
determinism story is session-scoped (reset/replay), not message-scoped.
- Multi-session tabs, session persistence across reloads, and export are
out of scope for the demo.
@@ -0,0 +1,106 @@
# SDK Extraction Decision Record
Date: 2026-08-08
Status: implemented
## Outcome
The accepted Live console transport and reducer contracts were copied into a
versioned `0.1.2` public surface. The reference console and a deliberately
small second consumer import only public package subpaths. Live console and the
Replay–3 surfaces remain the comparison baseline.
## Promoted contracts
The implementation promotes the framework-free client, `useRunnerConsole`,
all approved primitives and protocol views, and `RunnerConsoleApp`. Reducer
state and canonical events remain the only rendering authority. File, tool,
plan, terminal, failure, request, goal, lineage, connection, and replay views
do not maintain private protocol state.
The package exports `.`, `./browser`, `./react`, and `./styles.css`. React and
React DOM are peers; the surface adds zero runtime dependencies.
## Extension surface
The implementation has exactly the five approved extension points:
1. item-body renderer;
2. request-detail renderer;
3. Composer leading and trailing slots;
4. scoped `--pcr-*` token overrides;
5. Fetch/EventSource/base-URL transport injection.
The mini consumer demonstrates all five. No new extension registry or
headless contract was added during implementation.
## Kept out
Markdown `Response`, highlighted `CodeBlock`, a persistent Context meter, a
command palette, headless distribution, dark mode, Tailwind/Radix, toasts,
and transcript virtualization remain rejected for the reasons in the approved
plan. Default text and `<pre>` output keeps protocol evidence inspectable;
consumers can opt into rich item bodies through the one renderer contract.
## Implementation findings
- Event IDs must not be deduplicated by the hook. Exact duplicate delivery is
reducer input, so the shared reducer remains the sole duplicate authority.
- Node types are required only for browser-test and Vite configuration. They
are not part of the browser runtime or public dependency surface.
- Token enforcement must inspect CSS custom-property definitions as well as
component usage. The `pcr-` namespace is enforced across SDK sources.
- The reference console renders one React tree selected at the 900px
breakpoint. Rendering two trees would duplicate landmarks, test IDs, and
live regions.
- The compact layout keeps the transcript and composer as the primary view.
A single accessible Menu opens session controls or the protocol inspector in
a modal panel; secondary controls never replace or duplicate the chat tree.
- A real provider may explicitly disable goals. The consumer gates buttons on
that capability and preserves the upstream diagnostic instead of emulating
a goal.
- Real-provider steering timing is not a stable visual fixture. Fake-driver
tests own race coverage; a safe real completion owns transport, identity,
redaction, reconnect, and replay evidence.
- Minimal hosts may lack Playwright shared libraries. The package rootless
helper runs the same browser commands from a run-owned cache.
- Assistant text uses the canonical reducer text but reveals queued additions
progressively at the character level. Reduced-motion clients receive the
complete current text immediately; protocol events and replay stay unchanged.
- A completed assistant item that contains a semantic result JSON object shows
its summary and disposition as the primary agent response. Completion checks,
remaining work, and the exact protocol JSON stay behind a `Completion details`
disclosure, while the protocol inspector continues to expose the full event
record.
- Transcript follow mode anchors the latest settled assistant response instead
of terminal or diagnostic events appended after it. The complete ordered
transcript remains available below the response, and manual scrolling still
disengages follow mode.
## Compatibility rule
`0.1.2` is the current frozen SDK surface. Any later removal or semantic change
to an export, hook field, component prop, `data-slot`, extension point, or
token needs a versioned compatibility decision. Additive protocol fields stay
forward-compatible; the schema and shared reducer remain authoritative.
## Direct chat presentation
The reference console opens in a normal Codex chat mode. This mode sends plain
user text and permits follow-up turns in the same provider thread. It does not
add the Codex task envelope, semantic completion tools, or output schema.
The workspace sandbox and server-only provider authentication stay active.
The deterministic Codex fixtures remain selectable for protocol tests. The
chat layout removes decorative panel outlines and keeps the protocol inspector
available on demand. The header includes a package version and an iteration
emoji so a reviewer can identify the deployed build.
## Terminal diagnostics and responsive wrapping
`TranscriptItemEntry.debugEvents` is an additive, optional projection of the
canonical item events already retained by the client. `ToolItem` shows this
data in a nested folded disclosure rather than inventing a second diagnostic
model. Long commands, payloads, and assistant text wrap within the center
column; mobile layouts keep every transcript descendant intrinsically
shrinkable so the page does not gain a horizontal scrollbar.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,225 @@
# Durable recovery Durable Transport and Recovery
Durable recovery adds a real outbound WebSocket path to the standalone package. The
Rust `paperclip-runnerd` process is the client. The TypeScript mock core is the
remote peer. Neither side imports Paperclip server, UI, database, or shared
control-plane code.
This is a local reliability and authenticated-transport proof. The mock keeps
the RFC 6455 carrier on `ws://127.0.0.1`, but PRP application frames use a
mutually authenticated, encrypted session. Loopback position and the
WebSocket acceptance header are not authentication. A production bridge should
still use `wss://` for defense in depth and remains a separately reviewed
deployment phase.
## Connection and authentication
The connection starts in this order:
1. The mock core creates a random bootstrap ticket with a five-second lifetime.
2. The ticket is passed to the runner through
`PAPERCLIP_RUNNER_BOOTSTRAP_TICKET`. It is not a command-line argument.
3. The runner opens an unauthenticated WebSocket upgrade with no bearer header,
then sends only a public credential locator, a fresh client nonce, complete
runner/run/session identity, approved runner version and digest, negotiated
protocol range, and durable resume cursors.
4. The core returns a fresh server nonce plus an HMAC-SHA-256 proof over the
complete transcript. The proof binds runner, environment lease, run,
normalized session, turn, item, runner artifact, selected protocol,
credential/connection lease identity, expiry, and revocation epoch.
5. The runner validates that proof before returning its own transcript-bound
proof. The bootstrap ticket or connection lease token itself never crosses
the socket. A failed proof does not consume a one-use bootstrap ticket.
6. Both sides derive directional AES-256-GCM keys from the capability and both
nonces. Strict per-direction counters reject replays and out-of-order frames.
7. Only after mutual authentication does the core send an encrypted `welcome`.
The welcome selects PRP v1, returns a short-lived connection lease, reports
the cumulative committed event cursor, and carries at most one pending
command. Every later ACK, command, revoke, event, and command result remains
inside the encrypted session.
8. Later connections authenticate with the connection lease. A real runner
process restart receives a new one-time bootstrap ticket and loads the same
durable state before it connects.
The runner keeps the live connection lease token in memory only. Its state file
does not contain the bootstrap ticket or connection lease token. The mock core
persists domain-separated SHA-256 authentication keys instead of raw
capabilities. Used, expired, revoked, or identity-mismatched credentials fail
closed during the challenge. The runner validates the complete encrypted
welcome and every control envelope against the authenticated connection,
runner, environment lease, run, normalized session, turn, item, protocol,
lease ID, expiry, and revocation metadata before applying an ACK or command.
The daemon captures and removes the bootstrap environment variable before it
parses arguments or starts child work. Secret buffers are overwritten when they
are dropped. It resolves the destination once before sending a bearer value and
accepts only concrete loopback addresses. Userinfo, query strings, fragments,
wildcards, private-network addresses, public addresses, and mixed DNS answers
fail closed.
## WebSocket limits
The package client implements the RFC 6455 upgrade and masked client text
frames with Node-free Rust standard-library code. The mock core validates the
upgrade and parses bounded masked frames.
- Maximum HTTP upgrade headers: 16 KiB.
- Maximum PRP frame: 1 MiB.
- Unknown frame opcodes and unmasked client frames close the connection.
- Malformed JSON is recorded as a bounded diagnostic. Durable state remains
available for reconnect.
- WebSocket ping/pong and PRP diagnostic pong state are supported.
## Durable runner state
The runner writes `runner-state.json` below its private state directory. The
directory uses mode `0700` and the file uses mode `0600` on Unix. Every update
is written to an exclusive unpredictable sibling file, synchronized, atomically
renamed, and followed by a parent-directory sync. Symlinked directories or state
files, wrong ownership, and wrong modes fail closed.
The state contains:
- stable runner, environment lease, run, session, turn, and item IDs;
- next source event sequence and cumulative acknowledged source sequence;
- unacknowledged event envelopes and their byte counts;
- a bounded recent processed-command cache with a SHA-256 command digest and
prior result, plus a fixed-size fail-closed replay filter for compacted IDs;
- lifecycle, reconnect, backpressure, harness generation, and recovery facts;
- bounded, redacted diagnostics.
Authentication capabilities and arbitrary command bodies are not stored. A
recent command is represented by a SHA-256 comparison digest from the vetted
RustCrypto implementation, its stable ID and controller sequence, the redacted
result, and the logical-effect count. The exact cache keeps at most 128 entries.
Older IDs are added to a fixed 4 KiB Bloom filter before their exact records are
removed. A possible filter match is rejected with zero effects, so Bloom false
positives can reject new work but can never make an old command effective again.
The durable controller sequence remains monotonic across compaction and restart.
## Event delivery and ACKs
The runner writes an event to the outbox before it sends the event. Event IDs
and source sequence numbers do not change after reconnect or restart.
The mock core commits or deduplicates an event before it sends this cumulative
ACK:
```json
{
"kind": "ack",
"payload": { "ackedSourceSeq": 9 }
}
```
The ACK means that every source event through sequence 9 is committed or
deduplicated. The runner rejects an ACK that moves backward or beyond its
produced source cursor. It removes only events at or below a valid ACK.
For the lost-ACK fault, the mock commits an event, drops the ACK and socket, and
reports the prior cursor once after reconnect. The runner sends the same bytes
again. The mock increments delivery count, keeps one logical event, and sends
the committed cumulative ACK.
## Command delivery and effects
Mock-core commands are durable before delivery. Only the lowest pending
controller sequence is sent. A command result advances the queue.
The runner stores a command result before it sends the result. When a result or
socket is lost, the same command is delivered again. An equal command ID and
digest returns the stored result. It does not add events or repeat a process
effect. Reusing the ID with different bytes is rejected.
The trace records `logicalEffectCount`. Every completed command has exactly one
logical effect. A policy rejection, such as a new turn during drain or storage
pressure, has zero effects.
## Residual local trust and revocation window
The authenticated session removes trust in whichever process wins the configured
loopback port: a relay can forward opaque bytes, but it cannot learn a bootstrap
or lease capability, decrypt control data, or forge a welcome, ACK, command, or
revoke frame. The remaining local-host assumption is that the OS protects the
runner process memory, inherited bootstrap environment at launch, private
`0700`/`0600` state paths, and the mock core's derived authentication keys from
other same-user processes with debugging, memory-reading, or filesystem access.
An attacker with those privileges is outside this transport boundary.
Lease expiry is checked locally before control data is applied. Explicit
revocation reaches an already-connected runner through an authenticated revoke
frame; if that frame cannot be delivered, the session remains usable until the
connection closes or the runner reaches the signed lease expiry. The mock uses
a 30-second lease, so that is the maximum demonstrated revocation window. A
production core should close active sessions when it revokes a lease and choose
the lease TTL to match its required revocation bound.
## Restart and reconciliation
A socket drop keeps the same Rust process and in-memory lease. The process
reconnects and reloads the mock-core command and event cursor.
A runner restart kills the real Rust process with unacknowledged work. A new
Rust process receives a fresh ticket, reads the same state file, emits a P0
`runner.reconciled` event, and continues the same runner, session, turn, item,
command, and source-event identities.
The harness-restart fault starts the real Local runner `fake-harness` child, waits
for its ready message, terminates its process group, and starts a second child.
The runner then emits `harness.exited`, `harness.ready`, and
`session.reconciled` events with the same normalized session, turn, and item
IDs.
If a lease expires, reconnect fails closed. The runner records
`lease_expired_requires_bootstrap` and exits with its state intact. The mock
then gives the replacement runner a fresh ticket. Recovery continues from the
same cursor.
If recovery cannot be truthful, state names the outcome. For example, failure
to reserve storage for a P0 event records `p0_storage_exhausted` and the
`unrecoverable` lifecycle. It never reports a fresh session as resumed.
## Backpressure and bounded storage
The runner has a byte limit and a reserved P0 region.
- P2 item deltas coalesce before delivery.
- P1 events cannot consume the P0 reserve.
- New turns are rejected while backpressure is active.
- A P0 `runner.backpressure` event explains the state.
- P0 events are never dropped to make room.
- The state records peak outbox bytes so the final empty outbox does not hide a
limit violation.
The storage-pressure trace emits 250 P2 updates. They become one coalesced
event, all P0 facts reach the mock core, peak bytes remain below the configured
limit, and the next turn is rejected without an effect.
## Drain and revoke
`runner.drain` persists the `draining` lifecycle and a P0
`runner.draining` event. It rejects later `turn.start` commands but continues to
deliver the existing outbox. `runner.shutdown` stops only after the outbox is
acknowledged.
A `revoke` envelope persists the `revoked` lifecycle. The runner flushes any
existing durable events and exits. It does not accept new work or delete
unacknowledged facts.
## Verification
The production durable control plane and live-session suites cover reconnect,
ACK replay, restart recovery, backpressure, lease expiry, drain, and revoke.
Use `--json` for the complete trace or `--output <path>` to write it. The CLI
and browser show connection counts, safe lease ID and expiry, stable identities,
source and ACK cursors, outbox current/peak bytes, command delivery/effect
counts, replay counts, restart counts, outcomes, and redaction assertions.
They never show bootstrap tickets or connection lease tokens.
Regenerate the checked fault matrix and every exact per-fault trace with:
```sh
# Recorded evidence generation is deferred from this release.
```
+44
View File
@@ -0,0 +1,44 @@
# Runner SDK and lab documentation
These documents cover the package-local browser SDK, React components,
standalone demos, scenario explorer, issue-thread surface, and live-session
development tools. They do not enable a production provider or change
Paperclip's runtime selection.
## Tutorials
- [Local runner](tutorials/local-runner.md)
- [Live console protocol server](tutorials/live-console-protocol-server.md)
- [Live console](tutorials/live-console.md)
- [SDK console](tutorials/sdk-console.md)
- [Standalone adapter](tutorials/standalone-thin-paperclip-adapter.md)
- [Scenario explorer](tutorials/capability-scenario-explorer.md)
- [Scenario chat](tutorials/scenario-chat.md)
- [Issue-thread lab](tutorials/capability-issue-thread.md)
- [Clean-room Codex chat](tutorials/capability-clean-room-chat.md)
## Reference
- [Local runner and supervision](local-runner.md)
- [Durable transport and recovery](durable-recovery.md)
- [Live console protocol server](live-console-protocol-server.md)
- [Live console](live-console.md)
- [Browser and React SDK](sdk.md)
- [Standalone adapter](standalone-thin-paperclip-adapter.md)
- [Scenario explorer](capability-scenario-explorer.md)
- [Scenario chat](scenario-chat.md)
- [Mock control plane](capability-mock-control-plane-port.md)
- [Live runnerd/Codex loop](capability-live-runnerd-codex.md)
- [Issue-thread UI](capability-issue-thread-ui.md)
- [Clean-room chat](capability-clean-room-chat.md)
- [Issue-thread UX contract](design/capability-issue-thread-ux-contract.md)
- [Scenario explorer UX](design/capability-scenario-explorer-ux.md)
- [Scenario chat UX](design/scenario-chat-ux.md)
- [Live-console interaction map](design/live-console-interaction-map.md)
- [Live-console component decisions](design/live-console-component-decisions.md)
- [SDK component decisions](design/sdk-component-decisions.md)
- [Standalone adapter boundary](design/standalone-thin-paperclip-adapter.md)
- [UI library compatibility note](research/2026-08-07-ui-library-compatibility.md)
The SDK and labs are additive. Existing direct adapters continue to use their
existing composer, transcript, interaction, and finalization paths.
@@ -0,0 +1,135 @@
# Live console Protocol and Demo Server
## Scope
This is the lowest Live console layer. It extends the package-local Codex driver,
typed harness contract, deterministic fixtures, and mock-core demo server. It
does not import or change Paperclip server, UI, database, or production routes.
The browser boundary is HTTP plus server-sent events. The browser sends typed
actions to the demo server. Only the server starts `codex app-server`, reads
the existing Codex login, owns the working directory, and resumes provider
threads. No provider or Paperclip credential is serialized to browser state.
## Deterministic fixture
`protocol/fixtures/codex-driver/driver-conformance.json` records the exact Codex
0.132.0 wire shapes used by conformance tests:
- command, file, permission, user-input, and MCP elicitation requests;
- accept, accept-for-session, decline, cancel, and submitted answers;
- goal get, set, pause, resume, and clear calls;
- subagent parent/child thread source metadata;
- same-turn and stale-turn controls;
- interrupt-before-start and terminal-race outcomes;
- stable reconnect identities and source cursor; and
- hostile diagnostic strings that must be redacted.
The fixture loader rejects missing cases, duplicate request identities,
incomplete goal operations, and incomplete lineage or recovery identity.
## Browser-resolved requests
The driver no longer automatically declines supported runtime requests. It
emits `runtime_request.created`, keeps the JSON-RPC response pending, and waits
for `resolveRuntimeRequest`.
| Upstream request | Canonical kind | Allowed response shape |
| --- | --- | --- |
| `item/commandExecution/requestApproval` | `command_approval` | accept, session accept, decline, cancel |
| `item/fileChange/requestApproval` | `file_approval` | accept, session accept, decline, cancel |
| `item/permissions/requestApproval` | `permission_approval` | empty safe permission grant for turn or session |
| `item/tool/requestUserInput` | `user_input` | typed answer map, decline, cancel |
| `mcpServer/elicitation/request` | `elicitation` | typed content, decline, cancel |
The permission response cannot widen the skillless filesystem or network
boundary. A stale turn, duplicate response, provider cleanup, terminal turn,
protocol failure, close, or reconnect settles the pending request exactly once.
Only the resolution action enters the canonical event; submitted secrets or
answers do not enter the acknowledgement payload.
## Steering and interruption
Successful same-turn steering emits a completed
`steering_acknowledgement` item. A different or child identity is rejected
with `HarnessStaleTurnError` and `stale_turn_rejected`; direct child steering
is never emulated.
An interrupt received while `turn/start` is waiting for a provider turn ID is
queued and sent as soon as the ID arrives. After a terminal fact wins the race,
further steering or interruption returns `already_terminal`. A successful
interrupt request emits an `interrupt_acknowledgement` item without replacing
the session.
## Goals and lineage
The driver probes `thread/goal/get` after create or resume. Success enables
get, set, pause, resume, and clear. A method-not-found response disables goals
and advertises the precise unsupported capability. The installed Codex 0.132.0
binary generated goal bindings but did not enable the method at runtime in the
recorded real session, so the evidence correctly shows `goals: false`.
Root and child threads use `thread.id`, `thread.sessionId`, `forkedFromId`, and
`source.subAgent.thread_spawn` to build lineage. Child start, status, and close
notifications become canonical `thread_lineage` items. Unrelated thread
notifications are ignored instead of being attached to the active session.
## Demo-server API
Start it with:
```sh
pnpm --filter @paperclipai/paperclip-runner demo:live-console -- \
--host 127.0.0.1 --port 4174
```
The transport fails before binding unless `--host` is an explicit loopback
address. The standalone server and Vite middleware use the same admission
guard. It requires loopback local and remote sockets, the exact configured
loopback `Host` plus listening port, same-origin Fetch Metadata, a matching
browser `Origin` on mutations and streams, and `application/json` on every
mutation. Server-side automation uses a random per-launch bearer capability;
it is never returned by an API response or browser state.
Important routes:
- `GET /api/liveConsole/health`
- `POST /api/liveConsole/sessions`
- `GET /api/liveConsole/sessions/:id`
- `GET /api/liveConsole/sessions/:id/events?after=:cursor`
- `GET /api/liveConsole/sessions/:id/stream?after=:cursor`
- `POST /api/liveConsole/sessions/:id/steer`
- `POST /api/liveConsole/sessions/:id/interrupt`
- `POST /api/liveConsole/sessions/:id/requests/:requestId/resolve`
- `POST /api/liveConsole/sessions/:id/goal/:operation`
- `POST /api/liveConsole/sessions/:id/reconnect`
- `POST /api/liveConsole/sessions/:id/close`
The server chooses the working directory. A create body cannot override it.
Provider, Paperclip, cookie, and bearer credential fields in create bodies are
rejected. Runtime-request resolution must match the pending request, its turn,
and the session named by the route; stale, cross-scope, and duplicate responses
fail with `409` before reaching the driver.
Every JSON and event response passes a second bounded redaction layer. Replay
uses the same validated PRP events and reducer as the live view.
The demo keeps at most 16 active sessions and four SSE subscribers per session
by default. Call the close route (the browser does this on reset) to release a
session immediately; server shutdown closes every remaining session and
subscriber. Configuration may lower these limits and is hard-capped at 64
sessions and 16 subscribers.
Residual risk: a loopback service is still reachable by untrusted local
processes, which can reproduce browser headers. The Codex sandbox and approval
policy remain the final execution boundary; this demo transport is not a
multi-user authorization boundary.
## Verification
Run deterministic conformance first, then the real Codex boundary:
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/codex-app-server-driver.test.ts \
src/mock-core/live-console-demo-server.test.ts
```
@@ -0,0 +1,142 @@
# Live console Live Console
## Scope
This is the browser layer of Live console. It adds a **Live console** mode to the
existing standalone devtool in `devtools/browser/`. It builds on the
[protocol and demo server](live-console-protocol-server.md) and follows the
approved [interaction map](design/live-console-interaction-map.md) and
[component decision record](design/live-console-component-decisions.md).
Every file lives under `packages/paperclip-runner/`. The console does not
import or change Paperclip server, UI, database, or control-plane code.
## Start it
```sh
pnpm --filter @paperclipai/paperclip-runner console:live-console
```
Then open `http://127.0.0.1:4180/` and choose **Live console**.
Live console is deliberately loopback-only. A wildcard or LAN `--host` fails
startup because this developer console can start provider-backed turns and
resolve approvals. There is no unauthenticated remote mode.
Two environment variables change the driver behind the console:
| Variable | Default | Effect |
| --- | --- | --- |
| `PAPERCLIP_LIVE_CONSOLE_DRIVER` | `demo` | `codex` starts the real `codex app-server` driver behind the same routes |
| `PAPERCLIP_LIVE_CONSOLE_CHUNK_DELAY_MS` | `45` | Milliseconds between streamed chunks in the demo driver |
## Where state comes from
The console has one data contract: the canonical PRP event stream and the
`SessionSnapshot` the shared reducer produces from it. There is no second event
model, no message store, and no client-side session cache.
- The browser reduces the events it receives with the same
`applyPrpEvent` the server and the CLI use, so a live session and a replayed
session project identically. The inspector's **Session** tab reports that
comparison as `match` or `mismatch`.
- Transcript order is the reducer timeline order. Nothing is re-sorted.
- Lineage, goal state, pending requests, and the durable cursor come from the
demo server's public state, which is refreshed whenever new events arrive.
- The steering chip is the only local UI intent. It resolves to
`acknowledged`, `rejected`, or `failed` from canonical facts and never
merges text into the transcript before an acknowledgement.
## Surfaces
| Surface | Source of truth | Notes |
| --- | --- | --- |
| Transcript | reducer timeline and items | user messages come from `turn.submitted` payload text |
| Composer | composer state machine | one visible primary action: Send, Steer, or Stop |
| Steering chip | `steering_acknowledgement` item or a rejection code | rejected text stays recoverable with **Send as new message** |
| Request card | `runtime_request.*` events | renders only the actions the upstream request offers |
| Goal banner and menu | capability snapshot and goal events | unsupported verbs are disabled with the exact diagnostic |
| Lineage tree | driver thread lineage | child steering is shown as unsupported, never emulated |
| Connection block | transport status and durable cursor | includes a deliberate connection-loss control |
| Inspector | raw canonical events | four tabs: Events, Requests, Capabilities, Session |
| Replay stepper | recorded canonical events | marked with a `REPLAY` eyebrow badge |
## Interruption races
The console distinguishes all three races the spec requires:
| Race | What the console shows |
| --- | --- |
| interrupt before start | the pending turn resolves to `Cancelled before start`; no assistant item is ever created |
| interrupt during generation | the stream stops in place, the partial text stays, and an interrupted divider is added |
| interrupt during a tool call | the tool item keeps its last known status with no fabricated result |
`Escape` in the composer moves focus to **Stop**. It never interrupts
directly, because a destructive action needs an explicit activation.
## Demo chats
The demo server owns the manifest catalogue and serves it at
`GET /api/liveConsole/manifests`. Each manifest names the scenario it proves and
the observations a human should confirm; the console renders that list as a
checklist rather than restating it in the UI.
| Manifest | Proves |
| --- | --- |
| `completion` | streaming, reasoning, tool, answer, structured result |
| `steering` | same-turn steering acknowledgement and stale-turn rejection |
| `interrupt-before-start` | cancellation before the provider accepts the turn |
| `interrupt-during-generation` | preserved partial text |
| `interrupt-during-tool` | preserved tool status with no fabricated result |
| `approvals` | command and file-change approvals, single-response guarantee |
| `user-input` | typed answers and request expiry |
| `subagents` | parent/child lineage and unsupported child steering |
| `goals` | set, view, pause, resume, clear |
| `goals-unsupported` | exact capability diagnostic instead of a hidden control |
| `failure` | failed item and failed turn inside the record |
Manifests run in any order. **Reset demo state** names exactly what it
discards and leaves recorded evidence files untouched.
## Credentials
No provider or Paperclip credential reaches the browser. The Node process owns
the driver, the working directory, and any provider login. Every JSON and event
frame passes the demo server's redaction layer, and the inspector renders
redaction markers verbatim without attempting to reconstruct them.
The live console and standalone server share one transport admission guard:
exact loopback Host/port, loopback sockets, same-origin Origin and Fetch
Metadata, plus JSON-only mutations. Browser reset closes the server session;
active sessions and SSE subscribers are hard-capped. Local untrusted processes
remain a residual risk, and the Codex sandbox/approval policy is the final
execution boundary.
## Components
Adapted from shadcn/ui and Vercel AI Elements **source**, rewritten in the
package's local idiom, with zero new runtime dependencies:
- AI Elements: `conversation`, `message`, `composer`, `reasoning-item`,
`tool-item`.
- shadcn/ui: `tabs`, `menu`, `dialog`, `tooltip`, `banner`.
`scripts/check-forbidden-imports.mjs` fails the build if anything under
`devtools/browser/` imports `ai`, `@ai-sdk/*`, `zod`, `radix-ui`, `cmdk`,
`streamdown`, `shiki`, `use-stick-to-bottom`, `class-variance-authority`,
`tailwindcss`, or `nanoid`. `scripts/check-browser-tokens.mjs` fails if a
component file carries a raw colour, pixel, or font value.
## Verification
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/mock-core/live-console-scripted-driver.test.ts \
src/mock-core/live-console-demo-server.test.ts \
devtools/browser/src/live/transcript-model.test.ts
pnpm --filter @paperclipai/paperclip-runner test:browser
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
See the [tutorial](tutorials/live-console.md).
@@ -0,0 +1,145 @@
# Local runner Local Protocol and Supervision Reference
## Scope
Local runner proves one native local session. It uses no Paperclip service, database,
network provider, or model. The TypeScript mock core, Rust runner, and Rust fake
harness are all inside `packages/paperclip-runner/`.
## Processes and transports
| Boundary | Transport | Input | Output |
|---|---|---|---|
| Mock core → runner | stdin JSONL | `paperclip.prp.command.v1` | — |
| Runner → mock core | stdout JSONL | — | `paperclip.runner.stream.v1` |
| Runner → fake harness | stdin JSONL | `paperclip.fake_harness.command.v1` | — |
| Fake harness → runner | stdout JSONL | — | `paperclip.fake_harness.message.v1` |
| Mock core → browser | local HTTP + NDJSON | start/actions | validated events and final trace |
Stderr is diagnostic input. It never becomes a command or canonical event.
## Deterministic lifecycle
1. `run.prepare` creates the harness process group.
2. The harness sends `ready`.
3. `session.open` starts one fake driver session.
4. `turn.start` starts one scripted turn.
5. The harness emits typed events, requests, logs, a semantic result, and a
terminal proposal.
6. The runner waits for the harness process and records `harness.exited`.
7. The runner emits one `run.terminal` event and exits zero.
The runner gives each canonical event a deterministic source ID, source
sequence, and timestamp. Script delay can be overridden for fast tests.
## Command rules
- `controllerSeq` starts at 1 and is contiguous.
- The runner stores the canonical JSON for each accepted `commandId`.
- The same ID and same content returns `duplicate` without repeating an effect.
- The same ID with different content is rejected.
- A sequence gap is rejected.
- Local runner supports prepare, open, turn start, request resolution, interruption,
stop, close, and cancel command shapes. A script consumes only the commands it
expects.
## Fake-driver scripts
Scripts live under `protocol/fixtures/local-runner/scripts/`.
| Script | Proof |
|---|---|
| `happy-path` | Lifecycle, tool/command item, file item, logs, result, and success terminal |
| `permission-input` | Permission request and free-text input round-trip |
| `interrupted` | Operator interruption, yielded result, exit 130, and cancelled terminal |
| `error` | Non-zero process exit stays separate from the yielded semantic result |
| `duplicate-terminal` | A second terminal proposal is ignored and diagnosed |
| `linger` | Supervisor terminates the harness and its worker process group |
## Cleanup and credential boundary
The runner starts the harness in a new process group on Unix. Normal exit waits
for the process. Controller EOF or explicit cleanup sends `TERM`, waits for the
configured grace time, and then sends `KILL` to the full group. Drop cleanup is
a final guard.
The mock core passes only a small process environment. The runner also clears
the inherited environment before it starts the fake harness. Paperclip keys,
model keys, and AgentMail keys are not forwarded.
## Bounded diagnostics
The supervisor frames harness stdout and stderr with a 64 KiB per-line default
before it creates a `String`; a newline-free or otherwise oversized stdout
frame is a protocol violation and fails the run. Oversized stderr frames are
drained with bounded memory and represented by a short diagnostic marker. The
separate log-tail buffer then keeps only the configured number of lines and
bytes. It reports retained lines, retained bytes, and the dropped-line count
with `harness.exited`.
## Result and terminal authority
These facts are independent:
- `run.result.proposed` is the fake driver's structured work claim;
- `harness.exited` is the operating-system process fact;
- `run.terminal` is the one canonical terminal event for the local trace.
A scripted error can report useful yielded work and exit 7. An interruption can
report yielded work and exit 130. Neither process fact silently replaces the
semantic result.
The fake harness terminal object is only a proposal. The runner preserves it as
`harnessTerminalProposal`, validates its enum fields, and derives the canonical
turn/run terminal states from runner-owned facts: the process exit, whether a
semantic result was observed, an accepted interrupt/cancel command, controller
closure, and protocol violations. `reconciliation` records the decision and
whether the proposal contradicted those facts. A success proposal followed by a
non-zero exit therefore becomes a failed canonical terminal; the reverse
contradiction becomes succeeded only when both the process and semantic result
succeeded.
## Browser live mode
The Vite middleware starts the same TypeScript mock-core controller used by
tests. It streams history plus new events as NDJSON. The browser validates each
event with the replay schema, checks run/session binding, and applies the
session reducer. At completion it replays the full event list and compares both final
snapshots.
The browser reuses package-local shadcn-style Button, Badge, Card, and Textarea
components. All visual values remain in `styles.css`.
### Local transport trust model
The browser server is a developer-only loopback transport, not an authenticated
Paperclip API. Vite dev and preview default to `127.0.0.1`. Every
`/api/localRunner/*` request is checked before route lookup or process startup:
- both ends of the accepted socket and the HTTP `Host` must be loopback;
- an `Origin`, when a browser supplies one, must exactly match the request
origin, and cross-site Fetch Metadata values are rejected;
- JSON actions require `application/json` and bodies are capped at 16 KiB,
including streamed/chunked requests;
- the server permits at most 4 active runs, retains at most 16 total runs (old
completed entries are evicted), and permits at most 8 live stream clients per
run.
These are secure defaults plus defense in depth: even if Vite is accidentally
started with a broad `--host`, non-loopback Local runner API traffic is rejected.
There is intentionally no user identity, session cookie, or reusable bearer
credential in this spike.
Residual risk: another process running as the same local user is inside this
trust boundary and can make loopback requests. The scripted Local runner event set is
finite, but the retained event history inside one active run is not yet a
general-purpose per-session byte budget. A later real harness/network phase must
add authenticated capabilities, per-session event/byte budgets, durable
backpressure, and production authorization instead of widening this local
transport.
## Deferred work
Local runner has no durable outbox, ACK, reconnect, runner restart recovery, real
harness, production Paperclip bridge, or browser-to-runner connection. Those
items require later authorized phases.
@@ -0,0 +1,252 @@
# PRP Compatibility and Versioning Policy
## Authority
The JSON Schema files in [`protocol/schemas/`](../protocol/schemas/) are the
language-neutral source of truth for the Replay and Local runner executable contract. The
generated TypeScript schema module is checked against those files before every
TypeScript typecheck. Rust consumes the same fixtures and must produce the same
golden parity summaries.
The reviewable architecture and trust boundaries are defined in
[`architecture.md`](./architecture.md); durable transport and recovery behavior
is defined in [`durable-recovery.md`](./durable-recovery.md). Local runner reuses
the protocol contract for local live events. It adds package-local stdio and
stream envelopes, but it does not add durable transport, persistence, or
production control-plane behavior.
## Version fields
| Field | Replay support | Compatibility rule |
|---|---:|---|
| `protocolVersion` | `1` | Required. Negotiate the highest overlapping version; no overlap fails closed. |
| `fixtureVersion` | `1` | Required by the conformance corpus. Unknown values fail closed. |
| `event.schemaVersion` | `1` | Required on every event. Unknown values fail closed before reduction. |
| `capabilities.semanticTools.schemaVersion` | `1` | Optional advertisement. When present, an unknown required version fails closed. |
| `payload.semantic_tool.schemaVersion` | `1` | Optional on paired semantic tool input/result events. When present, an unknown required version fails closed. |
| `terminal.stopReason.schemaVersion` | `1` | Optional budget/cost receipt. When present, an unknown required version fails closed. |
| Typed `schema` discriminators | `*.v1` | Required. Unknown required schema identities fail JSON Schema validation. |
Wire protocol versions and fixture-corpus versions are independent. A fixture
format can evolve without changing PRP, and a future PRP version can be
represented only after the consumer advertises support for it.
## Forward compatibility
- Unknown object properties are accepted and preserved by validation. Reducers
ignore fields they do not understand until a later schema version gives those
fields defined behavior.
- Unknown required versions, schema discriminators, enum values, and required
fields fail closed. A consumer must never guess at their semantics.
- Scripted fixtures bind every event to the fixture run/session, require
contiguous controller command order, exactly one unique proposed result, and
exactly one unique terminal event.
- The top-level fixture result must equal the `run.result.proposed` payload after
canonical key ordering. Repeated `sourceEventId` deliveries must be
byte-equivalent after the same normalization.
The forward-compatibility fixture proves that optional fields survive validation
without changing the v1 snapshot. The unsupported-version fixture proves that a
required v2 protocol cannot be replayed by this consumer.
## Within-turn checklist snapshots
`plan.updated` / `paperclip.plan.updated.v1` is a complete, ordered snapshot of
the provider's checklist for one active turn. It is not a Paperclip Plan
document and must never be inferred from assistant prose, Codex proposed-plan
items, or generic TodoWrite output. Every replacement uses the provider turn ID
as `planId`; PRP `sourceSeq`, not an optional provider revision, determines
snapshot order. An empty step array clears the checklist, and `complete` is true
only when a non-empty snapshot contains only `completed` steps. The legacy
document-coupling fields are always `syncStatus: "not_applicable"` and
`documentRevision: null`.
| Qualified adapter profile | Checklist support |
|---|---|
| Direct Codex App Server | `turn/plan/updated` |
| ACPX Codex | Structured ACP `plan` entries |
| ACPX Claude | Structured ACP `plan` entries |
| ACPX Pi | Unavailable; no production-qualified profile is exposed |
| OpenCode | Unsupported until it exposes a structured plan event |
Codex `turn/diff/updated` is normalized separately as the latest same-turn
`workspace.change.updated` snapshot. Together these two independent event
families can drive a turn-status UI without changing the public PRP family or
adding a control-plane endpoint.
## Provider-neutral semantic receipts
- `capabilities.semanticTools` advertises stable operation IDs, availability,
required claims, and redaction disposition without naming a provider API.
- `mcp_app.tool_input` and `mcp_app.tool_result` may carry paired
`semantic_tool` envelopes. Correlation IDs must match the containing event;
operation ID and idempotency key must match across the pair.
- Content is represented by a canonical SHA-256 digest plus allowlisted typed
references. Raw credentials, provider payloads, and hidden identifiers do not
belong on the wire.
- Result receipts distinguish success, denial, conflict, exact duplicate,
unavailable, and failure. They can name the authorization boundary, safe
revision, artifact/work-product refs, immutable governed targets, and bounded
wake/monitor causality.
- `terminal.stopReason` records budget/cost kind, stable code, retryability,
limit class, safe aggregate, and decision receipt.
These fields are trace evidence only. The v1 reducer ignores `semantic_tool`
payloads, so adding or extending the optional envelope has no projection
effect. Eval `trace_completeness` treats PRP wire receipts as authoritative when
present and retains the pre-existing scalar fallback for live evidence that has
not yet emitted them.
## Provider-neutral structured input
Harness-initiated forms cross PRP as `paperclip.runtime_request.v2` with
`requestKind: "runtime"`, `type: "input"`, and an embedded
`paperclip.question_set.v1`. A submission is always
`{ "action": "submit", "response": paperclip.question_response.v1 }`.
Codex answer objects, OpenCode answer arrays, and ACP typed content exist only
inside their adapters; `origin` may retain the provider method and adapter name
for diagnostics but never provider response data.
Question and option order is significant, while answers are keyed by stable
question IDs and selections reference stable option IDs. The canonical modes
are `text`, `single_select`, and `multi_select`. Text validation is repeated at
the untrusted server edge and again against the persisted question set before a
provider receives the translated response.
Every harness adapter must add a
`paperclip.question_adapter_fixture.v1` fixture proving its native request
normalizes to the canonical shape and its canonical response can be translated
back. The shared fixture format deliberately contains both native and canonical
objects so adding a provider does not change PRP or the UI contract.
V1 runtime requests and their legacy resolutions remain accepted during the
migration. A request that contains no structured form stays on the legacy path;
once a provider supplies a form, malformed or unsupported fields fail closed
instead of silently degrading. ACPX sidecars advertise only form elicitation
and use sidecar protocol v2 `runtime.input_requested` / `input.resolve` frames.
The live lifecycle pauses and resumes the same provider turn. If the provider
process is lost first, Paperclip emits one non-replayable
`runtime_request.expired` fact and materializes an idempotent durable
`ask_user_questions` interaction using the identical question set. Explicit
cancellation and already-resolved requests never create that fallback.
## Native execution permission compatibility
`paperclip.native-execution-input.v4` pins the effective harness permission
policy in the closed provider configuration: `approvalPolicy` for Codex and
`permissionMode` for OpenCode and ACPX. The pinned value participates in
provider-session identity, so an incompatible idle or recovered session is
replaced on the next execution. An active turn is never mutated in place.
Persisted v1-v3 inputs remain replayable. Missing Codex and OpenCode policy
fields retain their historical effective behavior. Legacy ACPX
`permissionPolicy: "interactive"` is interpreted as `approve-reads`, while a
new v4 ACPX execution defaults to `approve-all` at the server boundary.
See [Adding a harness](adding-a-harness.md) for the permission catalog,
isolation rules, and provider conformance requirements.
Seven conformance fixtures cover artifact success, redacted denial without
fallback, stale conflict plus duplicate retry, governed target and continuation
causality, budget/cost stop, unknown optional fields, and rejection of an
unknown required version. The six accepted fixtures have shared TypeScript and
Rust golden parity summaries.
## Replay semantics
- Events are applied in fixture order and ordered independently by
`(sourceKind, sourceInstanceId, sourceSeq)`.
- A repeated source event ID has no second projection effect.
- A forward source-sequence gap is recorded explicitly; the reducer never
invents a missing event.
- An event at or behind the committed source cursor is ignored and recorded as
out of order.
- Replaying an already-applied batch leaves the snapshot unchanged.
The CLI and browser import the same `replayReplayFixtureText` function, so
validation, compatibility errors, and final snapshots cannot drift between the
two surfaces.
## Local envelope rules
- Mock-core commands use `paperclip.prp.command.v1` over stdin JSONL.
- Runner output uses `paperclip.runner.stream.v1` over stdout JSONL.
- Fake-harness commands use `paperclip.fake_harness.command.v1`.
- Fake-harness output uses `paperclip.fake_harness.message.v1`.
- An equivalent repeated `commandId` returns a duplicate receipt and has no
second driver effect. Reuse with different data is rejected.
- A new command must use the next contiguous `controllerSeq`.
- Harness logs are bounded diagnostic data. They are not canonical PRP events.
- `run.result.proposed`, `harness.exited`, and `run.terminal` are separate facts
and appear in that order when a semantic result exists.
- The live browser rejects an event with an invalid schema, run ID, or session
ID before it reaches the reducer.
These envelopes are local Local runner implementation contracts.
## Durable wire rules
- The runner opens an outbound WebSocket and sends PRP v1 `hello` before any
command result or event.
- A one-use bootstrap bearer capability returns a short-lived connection lease
in `welcome`. Later connections use that lease. Neither raw capability is
durable state.
- `hello.resume` reports the last processed controller sequence, next source
sequence, cumulative ACK cursor, and current unacknowledged range.
- `welcome` selects the one overlapping protocol version, returns the core's
cumulative ACK cursor, and carries at most one durable pending command.
- An event is durable before send. Event IDs and source sequences stay stable
across replay and process restart.
- An ACK is cumulative. The runner rejects a cursor behind its durable ACK or
beyond its produced source cursor.
- An equal repeated command ID and canonical digest returns its stored result.
Reuse with different bytes fails closed and cannot repeat an effect.
- Frames are bounded at 1 MiB and upgrade headers at 16 KiB. Unknown or invalid
required protocol data fails closed; malformed JSON is a bounded diagnostic.
These are package-local Durable recovery rules. Production TLS, control-plane admission,
and deployment policy remain separately reviewed work.
## Change policy
1. Change JSON Schema first.
2. Regenerate the TypeScript schema module.
3. Add or revise a shared fixture and its golden snapshot/summary.
4. Prove TypeScript and Rust parity.
5. Update this policy and the normative spike specification when behavior
changes.
Breaking changes require a new required version. Additive optional fields may
remain in v1 only when old consumers can safely ignore them.
## Package-level compatibility
PRP is one independently versioned component of the runner bundle. Catalog,
runner-client, control-plane-adapter, testkit, and eval-corpus compatibility is
declared by `PAPERCLIP_RUNNER_COMPATIBILITY` and checked before execution by
`assertPaperclipRunnerCompatibility`. A mismatch fails with
`paperclip_runner_incompatible` and stable per-issue codes; a provider-specific
tool error is not a compatibility negotiation mechanism.
See [ADR 0001](adr/0001-runner-testing-eval-package-boundaries.md) for the
component rules and clean-consumer packaging gate.
## Evals integration negotiation
The packed `./evals` entry point adds a stricter execution preflight for the
App/Evals join. `assertPaperclipRunnerEvalCompatibility` requires simultaneous
agreement on package semver, runnerd build metadata, a common PRP version,
semantic catalog version and SHA-256 digest, harness-driver contract and
required capabilities, and the native-execution version. It reports
`paperclip_runner_eval_incompatible` with expected/received values for every
mismatch and must run before launching a provider.
runnerd itself reports `paperclip-runner/runnerd-build-metadata/v1` from
`--build-metadata`. The consumer passes its path and expected content digest to
`resolvePaperclipRunnerdArtifact`; implicit PATH or source-tree discovery is
not part of the contract. Native attempt output is
`paperclip-runner/native-execution/v1`, whose parser accepts unknown additive
fields but rejects unknown required versions and inconsistent terminal,
semantic-denial, usage, or transcript facts. Full fields and the deterministic
gate are exercised by the package-local deterministic conformance suite.
@@ -0,0 +1,41 @@
# 2026-08-07 shadcn/ui and AI Elements Compatibility
## Decision
Conformance adds no UI runtime and no shadcn/ui, AI Elements, React, Tailwind, Next.js,
or AI SDK dependency to `@paperclipai/paperclip-runner`. A later browser phase
should adapt source components selectively after protocol and reducer contracts
exist.
## Dated findings
Sources were checked on 2026-08-07.
| Surface | Finding | Runner decision |
|---|---|---|
| shadcn/ui | Official documentation describes open-code component distribution and provides first-class Vite and monorepo setup paths. | Compatible with Paperclip's Vite UI model. Reuse or adapt checked-in source while keeping Paperclip tokens and accessibility rules authoritative. |
| AI Elements | Official documentation describes a registry built on shadcn/ui, targets React 19 and Tailwind CSS 4, and lists Next.js plus AI SDK as prerequisites. | Source shapes are promising, but the documented turnkey setup does not match a standalone Vite runner console. Evaluate individual source components; do not adopt the Next.js/AI SDK runtime stack by default. |
| Conformance package | This phase has no browser surface. | Keep the dependency graph empty at runtime and defer all component selection. |
## Candidate later evaluation
When the browser phase is authorized, evaluate conversation/message,
prompt/composer, plan, tool, code, terminal, and queue-style source components
against these gates:
- data contract maps to the shared runner reducer rather than component-local
state;
- styles use Paperclip's token layer with no raw color, spacing, radius,
typography, shadow, or motion values in Paperclip UI files;
- keyboard and screen-reader behavior passes local accessibility review;
- Vite and Storybook builds do not require a Next.js runtime;
- no AI SDK dependency is added merely to render already-normalized runner data.
## Primary sources
- [shadcn/ui introduction](https://ui.shadcn.com/docs)
- [shadcn/ui Vite installation](https://ui.shadcn.com/docs/installation/vite)
- [Vercel AI Elements introduction and prerequisites](https://elements.ai-sdk.dev/docs)
This note is a compatibility record, not authorization to add a UI surface in
Conformance.
@@ -0,0 +1,105 @@
# Scenario chat Scenario Chat
The scenario chat is an interactive surface over the same package-local Capability
runner and [mock control plane](capability-mock-control-plane-port.md) that the
[scenario explorer](capability-scenario-explorer.md) reads. You pick a scenario,
send prompts, and watch the mock Paperclip activity for every turn instead of
having it hidden behind the conversation. It contacts no Paperclip service,
holds no credential, and introduces no ACPX implementation.
Sources: `src/scenarios/chat-session.ts`, `src/scenarios/chat-script.ts`,
`src/scenarios/scenario-execution.ts`, and `examples/scenario-explorer/`.
Interaction contract:
[Scenario chat mobile chat UX](design/scenario-chat-ux.md).
## The turn model
A **session** is one long-lived mock control plane. Unlike a 7F single-shot
run, the state a turn changes is still there for the next turn — which is what
makes a per-turn diff mean anything.
- **Turn 0 is the session seed**: checkout, wake routing, and the first tool
exposure decision, all performed by the control plane before anyone can type.
It renders with control-plane styling because no agent tool was involved.
- **Turns 1..N** are one user message each, plus everything the runtime
recorded until the agent settled.
Every timeline entry carries the `turn` it belongs to, and the artifact carries
a `turns[]` array. The UI groups on those records; it never derives a turn
boundary, a per-turn diff, or a per-turn verdict itself.
## What each turn records
| Record | Produced by |
| --- | --- |
| Exposed tools (always / optional with unlocking grant / control-plane — no tool) | the 7D authorization engine |
| Semantic calls and typed results | the 7D tool runtime |
| Authorization decisions, missing claims, redactions | the 7D authorization engine |
| Control-plane actions with audit and decision references | the 7C mock core |
| State diff over the ten entity domains | immutable fixture snapshots |
| Wake / reconciliation events | the 7C mock core's own scheduled wakes |
| Turn parity verdict | `capabilityTurnParity` |
A turn is judged on what that turn did. A session-level expectation about work
the board has not asked for yet would read as a failure of turn 1 rather than
as work still to come, so the session verdict stays separate and is judged over
the whole transcript.
## Scripts and drivers
Every one of the 106 corpus scenarios opens in chat. Two carry authored
multi-turn conversations because the acceptance matrix names them:
- `ix-checkbox-01` — read the options, open a checkbox interaction, then the
board answers it. That answer is a **control-plane-owned action with no agent
tool**, and it is what makes the control plane schedule the continuation wake.
- `ap-mcp-gate-01` — read the approval queue (allowed), then attempt the gated
request without the decide claim (**denied**). The denial is a turn outcome
with its own authorization slice, not a broken session.
Every other scenario falls back to its 7F plan as a single scripted turn, which
keeps the whole corpus openable without inventing dialogue the traceability rows
do not support. Past the end of a script the session stays usable: the agent
re-reads the task context so the turn still shows real mock activity.
**Scripted (deterministic)** is the working driver: your prompt drives the next
recorded turn against the in-page mock core. **Codex (bounded)** is disabled
with a named reason unless the local provider relay is running — the same rule
the explorer applies. No provider credential ever reaches the page in either
mode.
## Routes
```
#/chat chat home (picker + intro)
#/chat/<case-id> live session, seeded at turn 0
#/chat/<case-id>?replay=fake scripted session, auto-played once
#/chat/<case-id>?replay=fake&view=activity&turn=<n>
#/chat/<case-id>?replay=fake&filter=<tools|authorization|control|diff|parity>
#/chat/<case-id>?replay=fake&stage=streaming holds the last turn mid-stream
```
The shell reports `data-chat-state` (`seeding` / `pending` / `streaming` /
`settled` / `failed`) so captures and tests wait on a state rather than a
timeout. `replay=fake` plays the script once per session: an explicit **Reset
session** returns to the seeded turn 0 and stays there.
## Boundary
- No network request leaves the page's own origin, and nothing the board types
is written to `localStorage`.
- The chat renders records the runtime produced. It computes no exposure,
evaluates no claim, applies no redaction, and re-times nothing.
- Redaction happens at the artifact boundary, so a raw secret cannot reach a
rendered record even if an upstream layer returned one.
## Commands
```sh
pnpm --filter @paperclipai/paperclip-runner run demo:scenarios # open the surface
pnpm --filter @paperclipai/paperclip-runner run test:scenarios # runtime + component tests
pnpm --filter @paperclipai/paperclip-runner run test:browser:scenarios
# Recorded evidence generation is deferred from this release.
```
Walkthrough: [Scenario chat scenario chat tutorial](tutorials/scenario-chat.md).
+171
View File
@@ -0,0 +1,171 @@
# SDK Browser SDK and Reference Console
## Public package surface
SDK freezes the browser SDK as package version `0.1.2`.
Version `0.1.1` adds direct chat mode. The browser sends the operator message
as plain Codex input. The protocol inspector still shows the event stream,
reducer state, requests, and replay data. The Codex task fixtures remain
available from the session controls.
Version `0.1.2` adds the canonical events for each transcript item to the
optional `debugEvents` projection. `ToolItem` renders these events inside a
nested `Debug details` disclosure. Completed Terminal rows stay folded by
default, while operators can inspect command input, output, provider items,
delta updates, event ids, sequence numbers, and timestamps. Long chat and
command content wraps at mobile widths without horizontal page scrolling.
| Import | Purpose |
| --- | --- |
| `@paperclipai/paperclip-runner` | Protocol, reducer, and existing package contracts |
| `@paperclipai/paperclip-runner/browser` | Framework-free HTTP/SSE client, protocol types, transcript projection |
| `@paperclipai/paperclip-runner/react` | Hook, reference console, and reusable React components |
| `@paperclipai/paperclip-runner/styles.css` | Scoped light-theme token and component styles |
React and React DOM are peer dependencies. The extracted surface adds no
runtime dependency. It is client-only: it uses `window`, `sessionStorage`,
Fetch, and EventSource, so applications must mount it in the browser rather
than render it on the server.
## Framework-free client
```ts
import { createRunnerClient } from "@paperclipai/paperclip-runner/browser";
const client = createRunnerClient({ baseUrl: "/api/liveConsole" });
const manifests = await client.fetchManifests();
```
`RunnerClient` exposes manifest listing; session create/read/close; event
history and SSE streaming; turn start, steering, and interrupt; typed request
resolution; goal operations; and reconnect. `RunnerClientError` preserves the
HTTP status, stable error code, and redacted server message.
The protocol server remains authoritative. The browser client never creates a
second event model, invents capabilities, changes run/session/provider
identities, or removes duplicate events before the shared reducer sees them.
## React hook
```tsx
import { useRunnerConsole } from "@paperclipai/paperclip-runner/react";
function Console() {
const runner = useRunnerConsole({ baseUrl: "/api/liveConsole" });
return <p>{runner.connection}: {runner.replayParity ? "match" : "waiting"}</p>;
}
```
`useRunnerConsole` returns manifests and selection, canonical server state,
the exact event list, reducer snapshot, transcript projection, connection and
retry state, composer state, steering acknowledgements, a single polite
announcement channel, errors, busy state, lineage selection, replay controls,
and verbs for every supported mutation. Durable history is re-read before a
reconnected stream opens. Replay uses the same reducer as live state.
## Components
The `./react` export includes:
- primitives: `Button`, `Badge`, `Card`, `Textarea`, `Tabs`, `Menu`, `Dialog`,
`Tooltip`, and `Banner`;
- protocol views: `Conversation`, `Message`, `ReasoningItem`, `ToolItem`,
`RequestCard`, `SessionTimeline`, `Inspector`, `ReplayControls`, and
`ConnectionBanner`;
- composition: `Composer`, `RunnerConsoleApp`, and `useRunnerConsole`.
Components take reducer/protocol shapes. Every component owns a stable
`data-slot` and `pcr-` class. Terminal, file, tool, plan, request, failure,
lineage, goal, connection, and replay states come only from canonical events
or public session state.
The reference server has two driver modes behind the same HTTP/SSE contract.
Deterministic manifests use `LiveConsoleScriptedDriver`. Real chat mode uses
`CodexAppServerDriver`, which starts a real local `codex app-server` process.
The Node server owns provider authentication and the disposable working
directory; browser code receives only canonical runner events.
## Five extension points
These are the complete extension surface for `0.1.2`:
1. `Conversation`, `Message`, `ReasoningItem`, and `ToolItem` accept
`renderItemBody(item)` for markdown, highlighting, or another item body.
2. `RequestCard` accepts `renderRequestDetail(request)` for its detail region.
3. `Composer` accepts `leadingActions` and `trailingActions`.
4. Consumers may override `--pcr-*` properties under `.pcr-root`.
5. `createRunnerClient` and `useRunnerConsole` accept `baseUrl`, `fetchImpl`,
and `eventSourceFactory` transport injection.
There is no headless distribution, component registry, render-prop shell,
dark theme, toast layer, or virtualized transcript in this version.
## Transport and credential boundary
```tsx
const runner = useRunnerConsole({
baseUrl: "/runner",
fetchImpl: (input, init) => fetch(input, {
...init,
headers: { ...Object.fromEntries(new Headers(init?.headers)), "x-app-session": appSession },
}),
eventSourceFactory: (url) => new EventSource(url),
});
```
Transport injection is for browser-to-protocol-server authentication, proxies,
and test doubles. Provider credentials stay on that server. Do not place a
provider bearer token in component props, manifest text, events, diagnostics,
browser storage, or an EventSource query string. The package demo fixes and
validates its workspace server-side and redacts provider paths and credentials.
## Tokens and contrast
`styles.css` declares a light-only `color-scheme` and all visual values under
`.pcr-root`. The shipped foreground/surface pairs are:
| Foreground | Surface | Contrast |
| --- | --- | ---: |
| `--pcr-foreground` | `--pcr-background` | 16.4:1 |
| `--pcr-card-foreground` | `--pcr-card` | 17.6:1 |
| `--pcr-primary-foreground` | `--pcr-primary` | 15.6:1 |
| `--pcr-muted-foreground` | `--pcr-muted` | 5.0:1 |
| `--pcr-success` | `--pcr-success-surface` | 6.5:1 |
| `--pcr-warning` | `--pcr-warning-surface` | 5.4:1 |
| `--pcr-danger` | `--pcr-danger-surface` | 6.2:1 |
| `--pcr-accent` | `--pcr-accent-surface` | 5.5:1 |
When a consumer overrides either side of a pair, that consumer owns a new
contrast check. Keep normal text at 4.5:1 or better. Motion respects
`prefers-reduced-motion`. All mobile controls use the `--pcr-touch-target`
minimum.
Minimum supported widths are 320px for `Conversation`, `Composer`, and
`Inspector`, and 280px for `RequestCard`. `RunnerConsoleApp` renders one
responsive tree: three panes above 900px and one selected pane below it.
## Keyboard and accessibility contract
- The transcript is a labelled `role="log"`; consumers render the hook's
`announcement` once in an `aria-live="polite"` region.
- Composer Enter sends or steers; Shift+Enter adds a line. Stop remains a
separate button.
- Tabs use arrow-key roving focus plus Home/End.
- Menu supports Enter/Space/ArrowDown, arrows, Home/End, and Escape with focus
restoration.
- The native dialog traps focus, closes on Escape, and restores its trigger.
- Reasoning and tool disclosures use native summary keyboard behavior.
- Replay Left/Right steps; Space toggles play from the scrubber.
- Status is always named in text, not communicated by color alone.
- Informational banners do not steal focus. A blocking reconnect failure may.
## Reference applications
`examples/reference-console/` composes `RunnerConsoleApp` only from public
exports. `examples/mini-consumer/` imports only the three public SDK subpaths
and visibly exercises every extension point. Both run against the package fake
driver or the real Codex driver through the same server-only adapter.
See the [hand-run tutorial](tutorials/sdk-console.md) and the
[implementation decision record](design/sdk-component-decisions.md).
@@ -0,0 +1,57 @@
# Standalone Standalone Adapter Demo
Standalone currently proves the thin adapter boundary as a standalone package demo.
It does not install or enable the runner in a Paperclip instance. All active
implementation, documentation, tutorial, and evidence work remains under
`packages/paperclip-runner/`.
## Selection and rollback
The standalone resolver has two inputs:
- the native demo feature flag, which defaults off;
- the kill switch, which always resolves to legacy.
The default path is legacy. Native mode is selected only when the demo flag is
enabled and the kill switch is disabled. Both paths use the public
`ControlPlanePort` contract and the canonical PRP reducer.
```sh
# Default legacy path
pnpm --filter @paperclipai/paperclip-runner trace:standalone
# Native standalone path
pnpm --filter @paperclipai/paperclip-runner trace:standalone -- --feature-flag enabled
# Kill-switch rollback
pnpm --filter @paperclipai/paperclip-runner trace:standalone -- --feature-flag enabled --kill-switch enabled
```
## Standalone page
Run:
```sh
pnpm --filter @paperclipai/paperclip-runner demo:standalone
```
Then open `http://127.0.0.1:4182/standalone-demo/`. The page exposes mode
selection, contract conformance, reducer projection, replay, finalization, and
native/legacy invocation counters. It makes no network call to Paperclip.
## Verified boundary
The package-local tracer verifies:
- identical public-port conformance for the selected demo adapter;
- ordered replay with duplicate idempotency and mutation rejection;
- canonical reducer projection through terminal success;
- idempotent result and terminal finalization;
- legacy default behavior and deterministic kill-switch rollback.
Company auth, budgets, approvals, audit, workspaces, and product persistence are
not claimed by this standalone checkpoint. Testing those concerns in a real
Paperclip instance remains outside this package and requires a separately
reviewed integration.
See the [runnable tutorial](tutorials/standalone-thin-paperclip-adapter.md).
@@ -0,0 +1,165 @@
# Capability Tutorial: Chat With Real Codex In A Clean Room
**Time to first success: about 3 minutes**, once the package is installed. You
open a blank chat, type whatever you like, and watch a real Codex session work a
brand-new mock Paperclip issue through a real `paperclip-runnerd` process. No
scenario to pick, no recorded transcript, and no real Paperclip service anywhere
in the picture.
This is the second primary Capability path. The first — the preset scenario
explorer — is covered by the
[issue-thread tutorial](capability-issue-thread.md) and stays available and
unchanged. Read [the clean-room reference](../capability-clean-room-chat.md) for
the seed, exposure profile, bounds, and route contract behind what you see here.
## What you need
- Node.js 20 or newer and pnpm 9 or newer (verified with Node 22.22.2 and pnpm
9.15.4).
- A Rust toolchain, because this path builds the real `paperclip-runnerd`
binary. There is no offline fallback: the clean room is live-only by design.
- A locally authenticated Codex CLI. The package server owns that
authentication; the browser never receives it.
Install the workspace from the repository root:
```sh
NODE_ENV=development pnpm install --filter @paperclipai/paperclip-runner --frozen-lockfile --offline --ignore-scripts
```
Setting `NODE_ENV=development` matters even if your shell already has
`NODE_ENV=production`: pnpm silently skips devDependencies otherwise, and the
build tooling lives there.
## 1. Prove the path without a browser (about 1 minute)
```sh
pnpm --filter @paperclipai/paperclip-runner smoke:capability:cleanroom -- --json
```
This builds the TypeScript output and `paperclip-runnerd`, starts the package
server on loopback, opens a clean room, and drives two real Codex turns through
the same HTTP routes the browser uses. Expected shape:
```json
{
"schema": "paperclip.capability.clean-room-smoke.v1",
"firstIssue": "MCK-2114",
"newChatIssue": "MCK-9539",
"turns": 2,
"toolCalls": [
{ "operationId": "get_task_context", "outcome": "ok" },
{ "operationId": "report_progress", "outcome": "ok" }
],
"assertions": { "...": true }
}
```
Every assertion must read `true`. The two issue identifiers differ because
`New chat` mints a new mock tenant rather than clearing the old one.
## 2. Open the chat in a browser (about 1 minute)
```sh
pnpm --filter @paperclipai/paperclip-runner console:issue-thread
```
Open `http://127.0.0.1:4184/`. The landing surface is the scenario explorer.
Above the issue header, a two-item rail offers **Scenario explorer** and
**New chat · clean room**. Click `New chat`, or go straight to
`http://127.0.0.1:4184/#/chat`.
The first load takes a few seconds: the server is starting a real runnerd and a
real Codex app-server for you. When it settles you should see:
- three identity chips — `Real Codex`, `Real runnerd`, `Mock Paperclip` — plus a
`Clean room <token>` chip naming the tenant;
- a mock issue identifier such as `MCK-3799` with the title `Clean-room chat`;
- a blank thread with a short explanation of what the surface is;
- a composer, and an `Evidence` button that is closed.
If Codex or runnerd cannot start, the page says so and offers `Try again`. It
will not quietly show you a fixture.
## 3. Send a real message
Type anything. A useful first prompt:
```
Read this issue, then record a one-sentence status on it.
```
You should see a user message, a `Real Codex` reply, collapsed tool strips for
the semantic calls Codex chose (`get_task_context`, `report_progress`, …), and a
durable comment card tagged **Recorded to mock thread** — that card is a mock
control-plane record, not model prose.
Keep going. The conversation is free-form and multi-turn on one durable session.
Ask Codex to write a document, register a deliverable, propose child tasks, or
ask you a structured question; interaction cards render inline and resume the
same Codex thread once you answer.
## 4. Look at the evidence, when you want it
Click `DevTools`. The drawer carries the same eight evidence sections the scenario path
uses: tools exposed, calls and typed results, authorization decisions,
control-plane actions, runner events, state diffs, traceability, and parity.
Above those sections, the DevTools inspector provides six tabs:
- **Timeline** selects any retained control-plane revision.
- **State** browses the full mock company tree.
- **Diff** compares any two revisions field by field.
- **Documents** reads document bodies and switches between revisions.
- **Protocol**, **Runtime**, and **Authority** show the surrounding scaffolding
that explains why each mutation happened.
Use **Pause** to stop following the newest revision, **Export** to download a
redacted JSON snapshot, or **Fork rN** to replace this chat with a fresh live
branch rooted at the selected mock-company revision.
Drag the bright divider at the drawer's left edge to resize it (up to 960px), or
focus that divider and use the arrow keys. As soon as you send a message, the
live activity rail reports the current Codex stage; shell commands, built-in
Codex tools, and Paperclip semantic calls also appear as individual activity
rows while the turn is still open.
Two things are worth finding on your first pass:
- **Tools exposed** lists `decide_approval`, `control_workspace_service`, and
`generic_api_request` under `Control plane (not exposed to the agent)`. Those
grants are withheld on purpose, so a denial stays reachable in a conversation
nobody scripted.
- **Control plane** carries a `network-guard-…` row reading
`Real Paperclip API requests: 0. Child PAPERCLIP_* environment keys: none.`
Close the drawer and the surface is a plain chat again.
## 5. Stop, reset, and start over
- **Stop** cancels the active turn and keeps the session and its transcript.
- **Reset** stops active work, retires this session's authority, and opens a new
mock tenant. The confirmation dialog says so before it happens.
- **New chat** does the same without the dialog.
- **Refresh** (F5) reconnects to the same chat. It does not become a recording.
After a reset or a new chat, look at the header: the `MCK-` identifier, the run
id, and the `Clean room` token have all changed. The previous session is gone —
its id answers `404` on every route.
## 6. Confirm the scenario explorer still works
Click `Scenario explorer` in the rail. Every preset deep link still resolves,
including the deterministic capture routes:
```
http://127.0.0.1:4184/#/issue/hb-baseline?shot=denial-optional-tool&capture=1
```
## Where to go next
- [Clean-room chat reference](../capability-clean-room-chat.md) — seed, exposure
profile, session routes, and bounds.
- [Execution modes and identity](../capability-execution-modes.md) — why a
`mode=fake` artifact can never satisfy a live criterion.
- [Verification commands](../capability-verification-commands.md) — the full
command set, including the browser and screenshot gates.
@@ -0,0 +1,222 @@
# Capability Clean-Start Tutorial: The Paperclip-Style Issue Thread Over a Mock Control Plane
**Time to first success: about 5 minutes.** Two commands take you from a clean
checkout to 106 passing conformance cases and a Paperclip-style issue thread you
can click through in a browser. The whole tutorial runs from the repository
root. It starts no Paperclip service, contacts no Paperclip control plane, clones
no external eval repository, and holds no provider credential. Everything the
default path needs is checked in under `packages/paperclip-runner/`.
This is the canonical Capability tutorial for the final build. It covers both agent
modes: **scripted (deterministic) fake mode**, which is fully offline, and
**Codex (bounded) live mode**, which is optional and needs a locally
authenticated Codex. The two modes share one mock control plane, one semantic
tool catalog, one authorization engine, and one view contract. See
[execution modes and identity](../capability-execution-modes.md) for the rules that
separate them, and [the future binding boundary](../capability-future-binding-boundary.md)
for why none of this touches real Paperclip — that is future upload integration and needs separate
approval.
For a deeper tour of the read-only scenario explorer and the capability
contract, see the companion
[scenario-explorer tutorial](capability-scenario-explorer.md); this page focuses
on the issue-thread surface and the live loop.
## What you need
- Node.js 20 or newer and pnpm 9 or newer. This tutorial was verified with Node
22.22.2 and pnpm 9.15.4.
- No Rust toolchain for the fake-mode steps (steps 1–4). The optional live steps
(step 5) build the `paperclip-runnerd` binary and need a Rust toolchain and a
locally authenticated Codex.
- No network access after `pnpm install`.
Install the package workspace from the repository root:
```sh
NODE_ENV=development pnpm install --filter @paperclipai/paperclip-runner --frozen-lockfile --offline --ignore-scripts
```
This keeps the checked-in lockfile authoritative while using only packages
already present in the pnpm store, so the offline install does not re-resolve
dependencies to registry-latest versions. Setting `NODE_ENV=development`
ensures the complete test toolchain is installed even when the checkout
inherits `NODE_ENV=production`.
## 1. Prove the 106-case conformance suite (about 1 minute)
```sh
pnpm --filter @paperclipai/paperclip-runner test:capability-evals
```
This drives all 106 eval-derived cases across the 16 groups entirely in-process
against the mock control plane. Expected final line:
```
Test Files 1 passed (1)
Tests 1 passed (1)
```
For the per-group counts, assertion classes, the 18-operation fake-agent matrix,
and the 16-case bounded Codex binding sample, generate the parity report:
```sh
pnpm --filter @paperclipai/paperclip-runner report:capability-evals
```
Expected final line:
```
Capability eval conformance passed: 106 cases across 16 groups.
```
The report is written to `.paperclip-local/evidence/capability/eval-parity-report.{json,md}`.
It is generated on demand and is not committed; delete it before running
`docs:validate`, or run validation first.
## 2. Run the issue-thread contract and UI tests (about 1 minute)
```sh
pnpm --filter @paperclipai/paperclip-runner test:scenarios
```
Expected final lines:
```
Test Files 11 passed (11)
Tests 137 passed (137)
```
This covers the mock `ControlPlanePort`, the semantic tools with their
authorization and redaction rules, and the `CapabilityIssueThreadSnapshot`
view-model contract — including that every one of the twelve deterministic
screenshot states builds a schema-valid snapshot with no credential anywhere in
the projected view.
## 3. Open the issue thread in fake mode (about 2 minutes)
```sh
pnpm --filter @paperclipai/paperclip-runner console:issue-thread
```
Open <http://127.0.0.1:4184/#/issue/hb-baseline?shot=thread-baseline>.
You are looking at a Paperclip issue thread, not a dashboard. The header carries
three identity chips that stay visible in every mode — `Fake agent`,
`In-process runner`, and `Mock Paperclip` — plus the mock issue identifier
(reserved `MCK-` prefix), status, priority, and the Scenario / Replay / Reset /
Stop controls. The main column is one readable work thread; the Evidence panel is
collapsed on the right.
Swap the `shot` value to seed any of the twelve contract states — each one is a
different item type or interaction state from the
[issue-thread UX contract](../design/capability-issue-thread-ux-contract.md):
| `shot=` | What it shows |
| --- | --- |
| `thread-baseline` | User message, model prose, a durable `Recorded to mock thread` comment, and a collapsed tool strip. |
| `turn-streaming` | A streaming turn with the composer in its `streaming` state (input editable, Stop primary). |
| `interaction-question-pending` | An inline `ask_user_questions` card that refuses an incomplete submit. |
| `interaction-confirmation-pending` | A revision-bound confirmation that requires a reject reason. |
| `interaction-resolved-mixed` | Accepted, rejected, expired, and stale interactions as durable history. |
| `denial-optional-tool` | A verbatim typed denial with its Evidence badge and no protected data. |
| `document-revision` | A document revision card bound to the mock document. |
| `deliverable-registered` | A registered deliverable reporting one size in one unit. |
| `disposition-terminal` | A terminal disposition that disables the composer. |
| `debug-panel-open` | The eight-section Evidence panel opened on the authorization records. |
| `reconnect-banner` | The reconnect banner and its `Reconnected` confirmation. |
| `replay-mode` | The replay progress strip with `Step back`, `Next turn`, and `Play all`. |
Open the Evidence panel (or the `Evidence` segment on a phone). Its eight
sections stay in a fixed order: Tools exposed, Calls & results, Authorization,
Control plane, Runner & events, State diff, Traceability, Parity. The Tools
section separates `Agent tool — always`, `Agent tool — granted` (with its
grant), and a `Control plane (not exposed to the agent)` list — what the model
*cannot* call is first-class evidence. Every strip, denial, and card deep-links
into its record, and each record links back to its thread anchor.
Resize the browser below 768px: the page collapses to a `Thread` / `Evidence`
segmented control, keeps Stop outside the `⋯` menu while a turn is active, and
never scrolls horizontally.
## 4. Record the deterministic screenshot matrix (about 1 minute)
```sh
# Recorded evidence generation is deferred from this release.
pnpm --filter @paperclipai/paperclip-runner check:capability:ui
```
The first writes 24 images — the twelve slugs at 1440×900 and 390×844 — to
`.paperclip-local/evidence/capability/ui/`. Every mobile capture asserts no horizontal
scroll before the shot. The second re-records and confirms all 24 reproduce
byte-for-byte, which is the property that makes the fake-mode matrix a stable
acceptance artifact. Both modes render fixture time only, so a fresh checkout
reproduces the committed images exactly.
## 5. Optional: drive a real Codex turn (live mode)
Live mode needs a Rust toolchain and a locally authenticated Codex. It starts a
real `paperclip-runnerd` process and a real Codex app-server session; the
package server owns the process, the tool loop, and the provider credential, and
the browser receives none of them. **Skip this section if you do not have Codex
installed** — nothing above depends on it.
Headless smoke over the package server:
```sh
pnpm --filter @paperclipai/paperclip-runner smoke:capability:ui
```
This creates a session, runs live turns, and asserts the identity reads
`Real Codex` / `Real runnerd` / `Mock Paperclip`, that a real tool call was
recorded with its authorization record, that the control-plane-owned list is
withheld from the agent, and that no credential appears in the view.
Capture the live browser frames (a real browser driving a real Codex turn):
```sh
# Recorded evidence generation is deferred from this release.
```
The frames land in `.paperclip-local/evidence/capability/ui-live/`. They are intentionally
**not** byte-stable — a real provider turn varies — so they are excluded from the
determinism gate and are the mode that satisfies the final live acceptance
criteria. A scripted (`mode=fake`) frame cannot.
Prove the process and network boundary directly:
```sh
pnpm --filter @paperclipai/paperclip-runner trace:live-runner -- --json
```
This checks a real semantic-tool mutation, a typed result, a same-thread second
turn, process ownership and cleanup (`runnerExited`, `authorityCleared`), and —
critically — `noRealPaperclipRequest` and `noPaperclipAuthorityInChild`.
## Reset, stop, replay, and cleanup
These controls work the same in either mode because they act on the mock core
and the runner session, not on the agent:
- **Reset** asks first, then restores the original clean mock seed under new
run/session authority and rotates or clears the old authority. It does not
touch another browser session.
- **Stop** cancels only the active turn and reaps the runnerd process group; the
session and transcript survive with a `Stopped by user` marker.
- **Replay** reproduces a recorded canonical timeline. It is always scripted and
always labelled fake-derived.
- **Refresh / reconnect** restores the same session, pending interaction,
transcript, and mock state; reconnect starts a fresh runnerd and Codex and
resumes the persisted thread without restarting the conversation.
## What this does not do
- No ACPX implementation and no real control-plane integration. Real Paperclip
binding is future upload integration and requires separate approval.
- No provider, runner, or control-plane credential in the browser, ever.
- Nothing you type is persisted beyond the mock session. A reload drops an
unsent draft — the accepted trade against storing conversation content.
References: [issue-thread UI](../capability-issue-thread-ui.md),
[live runnerd and Codex loop](../capability-live-runnerd-codex.md),
[execution modes and identity](../capability-execution-modes.md).
@@ -0,0 +1,179 @@
# Capability Clean-Start Tutorial: Capability Contract, Mock Control Plane, and Scenario Explorer
**Time to first success: about 5 minutes.** Two commands take you from a clean
checkout to 106 passing conformance cases and a browser you can click through.
The whole tutorial runs from the repository root. It starts no Paperclip
service, contacts no Paperclip control plane, clones no external eval
repository, and holds no provider credential. Everything it needs is checked in
under `packages/paperclip-runner/`.
Capability does not integrate the runner into Paperclip. It builds a package-local
model of what a native Paperclip run *would* do — a deterministic mock control
plane, a transport-neutral semantic tool catalog, an authorization engine, a
106-case conformance suite derived from the Paperclip Evals corpus, and a
read-only browser explorer over all of it. Real integration is future upload integration and
requires separate approval. See
[the future binding boundary reference](../capability-future-binding-boundary.md).
## What you need
- Node.js 20 or newer and pnpm 9 or newer. This tutorial was verified with Node
22.22.2 and pnpm 9.15.4.
- No Rust toolchain. Every command in this tutorial is TypeScript/Node only. (A
full `verify` still builds Rust, but nothing here does.)
- No network access after `pnpm install`.
Install the package workspace from the repository root:
```sh
pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --offline --ignore-scripts --dev
```
## 1. Prove the 106-case conformance suite (about 1 minute)
```sh
pnpm --filter @paperclipai/paperclip-runner test:capability-evals
```
This runs the eval-derived conformance suite entirely in-process against the
mock control plane. Expected final line:
```
Test Files 1 passed (1)
Tests 1 passed (1)
```
The single test file drives all 106 cases across the 16 eval groups. To see the
per-group counts, assertion classes, the fake-agent operation matrix, and the
bounded Codex binding sample, generate the parity report:
```sh
pnpm --filter @paperclipai/paperclip-runner report:capability-evals
```
Expected final line:
```
Capability eval conformance passed: 106 cases across 16 groups.
```
The report is written to
`.paperclip-local/evidence/capability/eval-parity-report.{json,md}`. It is a
generated-on-demand artifact, not a committed file; a clean checkout does not
contain it. Delete it before you run `pnpm --filter @paperclipai/paperclip-runner docs:validate`
(the report carries no OKF frontmatter and would otherwise fail the knowledge
bundle check — see [Known gaps](#known-gaps-and-boundaries)).
The 16 groups and case counts are fixed by the capability contract:
| Group | Cases | Group | Cases | Group | Cases | Group | Cases |
| --- | ---: | --- | ---: | --- | ---: | --- | ---: |
| hb | 5 | co | 6 | st | 8 | cm | 6 |
| se | 4 | su | 4 | bl | 5 | dp | 3 |
| ix | 9 | ap | 6 | ar | 4 | er | 9 |
| rf | 22 | mh | 4 | rs | 3 | wk | 8 |
## 2. Open the scenario explorer (about 2 minutes)
```sh
pnpm --filter @paperclipai/paperclip-runner demo:scenarios
```
Open `http://127.0.0.1:4183/scenario-explorer/`. The explorer is read-only. It
runs each scenario in the browser against the same mock control plane and
renders the run artifact; it never re-judges parity and never leaves its own
origin. Walk this path:
1. **Home.** Confirm 16 group facets whose counts sum to 106.
2. **Pick a scenario.** Filter to `ap` and open `ap-mcp-gate-01`. The
authorization view shows a deny row and a "1 deny" tab chip; the typed
denial card carries no task data.
3. **Read a heartbeat.** Open `hb-context-01`. The control-plane checkout entry
is labelled "no agent tool exists for this" — it is control-plane-owned, not
a semantic tool.
4. **Inspect a manager scenario.** Open `rf-api-mgr-heartbeat-01`. Optional
tools are listed with the grant that unlocked them, alongside the
control-plane "no tool" list and a traceability panel.
5. **Confirm the credential boundary.** Codex mode is a disabled option with a
stated reason. `localStorage` holds no run artifact or grant, and the page
makes no request beyond its own assets.
The explorer imports the frozen `0.1.2` SDK through the package-local
`@paperclip-runner-local/capability` alias — deliberately not the published package
name. See [the browser explorer reference](../capability-scenario-explorer.md).
## 3. Read what each surface guarantees
The explorer renders four surfaces produced by the runtime. Each has a
package-local reference page:
- [Capability disposition](../capability-disposition.md) — how every
skill/reference behavior and eval case is classified as control-plane-owned,
always-agent-tool, or optional-agent-tool, and how the generated contract is
produced and checked.
- [Mock ControlPlanePort](../capability-mock-control-plane-port.md) — the
deterministic in-memory adapter, its entity domains, and its boundary.
- [Semantic tool catalog](../capability-semantic-tools.md) — the
transport-neutral tools an agent may call.
- [Authorization and exposure](../capability-authorization-and-exposure.md) —
grants, typed denials, and secret redaction.
- [Eval conformance](../capability-eval-conformance.md) — how the 106 cases are
derived from the checked-in traceability derivative with no external repo.
## 4. Run the full focused verification set
Every command below is offline, deterministic, and package-local. The
[verification commands reference](../capability-verification-commands.md) lists
each one with its purpose and expected result.
```sh
# Capability contract: completeness, uniqueness, one-to-one MCP folds, no drift.
pnpm --filter @paperclipai/paperclip-runner check:capability-inventory
pnpm --filter @paperclipai/paperclip-runner test:capability-inventory
# Mock control plane adapter and shared ControlPlanePort conformance.
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/conformance/control-plane-port.test.ts \
src/mock-core/capability-mock-control-plane-adapter.test.ts
# Semantic tool catalog and authorization/redaction engine.
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/tools/capability-semantic-tools.test.ts
# 106-case conformance and the parity/fake-agent/bounded-Codex report.
pnpm --filter @paperclipai/paperclip-runner test:capability-evals
pnpm --filter @paperclipai/paperclip-runner report:capability-evals
# Scenario runtime, explorer components, and route determinism (49 tests).
pnpm --filter @paperclipai/paperclip-runner test:scenarios
# Browser information architecture, accessibility, determinism, and boundary.
pnpm --filter @paperclipai/paperclip-runner test:browser:scenarios
# Deterministic 24-image screenshot acceptance set.
# Recorded evidence generation is deferred from this release.
# Documentation links.
pnpm --filter @paperclipai/paperclip-runner docs:validate
```
## Known gaps and boundaries
- **Reports are local.** `report:capability-evals` writes ignored output under
`.paperclip-local/evidence/`; `docs:validate` does not scan it.
- **Codex mode is disabled.** The explorer offers a bounded real-Codex binding
sample only through `report:capability-evals`; the browser holds no credential
and the in-page Codex mode is inert with a stated reason.
- **No Paperclip integration.** Nothing here touches a real control plane,
database, or provider. future upload integration (ACPX) binds the real adapter behind the same
seam and requires separate CTO approval.
## Where this sits
- Plan: Capability plan, issue `TASK-16897` (see its `#document-plan` document).
- Child issues: 7A capability inventory (TASK-16898), 7B UX map (TASK-16899),
7C mock adapter (TASK-16900), 7D semantic tools and authorization (TASK-16901,
security gate TASK-16902, remediation TASK-16909), 7E conformance suite
(TASK-16903), 7F browser explorer (TASK-16904, UX gate TASK-16905), 7G docs and
evidence (TASK-16906), 7H clean-room QA (TASK-16907), Capability checkpoint
(TASK-16908).
Loaded 100 of 381 files, more files were not shown because too many files have changed in this diff. Show more