Talk to a session: a page you hold a conversation with #67

Merged
mph merged 18 commits from cc-chat into main 2026-08-28 12:00:28 +00:00
Owner

Slice — Talk to a session (cc-chat/04)

Goal. I can hold a conversation with an agent from a browser page: I type, it answers, I watch
it work, and I stop it when it goes the wrong way. The page is where the conversation happens, not
a window onto one happening somewhere else.

Fit test — passed on Michal's own read (2026-08-28). He asked a real question about real code
through the page, interrupted and redirected it, and acted on the answer. Nothing typed was lost,
the page never looked frozen, and stop stopped the agent rather than the page's reading of it.

What ships

  • The contract at its settled shape. Every operation now hangs under the session it is about —
    /v1/sessions/{session}, with markup, follow, artifact, send and interrupt beneath it
    — and a version marker opens every address. TranscriptEndpoint/TranscriptApi became
    SessionEndpoint/SessionApi: the transcript is one part of a session, not the other way round.
    The rendering path's refusal is typed as what the wire actually carries (a status and the page
    the host rendered), where it had been typed as a failure whose reading direction was a constant.
  • Sessions (zio). The live sessions one host holds, each under the id the CLI named it with,
    each bounded by its own scope: let go of when its process ends, and all of them when the host
    stops. It holds a process, never a conversation — the transcript stays the CLI's own file.
  • Three writing operations. Start a session, say something to it, stop what it is doing. They
    refuse in their own vocabulary (SessionFailure), because a transcript that cannot be read is
    not a session that will not take what was typed.
  • The composer, in Laminar. The one part of a conversation no transcript can carry: a message
    nobody has sent yet. One control beside the box — Send while there is nothing to stop, Stop while
    the agent is working — and what a person had typed survives stopping, because stopping is how a
    turn that went the wrong way is redirected.
  • Where the session stands, said by the host rather than guessed by the page: its own kind of
    message on the follow (not started here / ready / working). Three things turn on it — whether
    there is a box at all, whether it is shut, and what the page says for itself.
  • Nothing leaves a mark, by construction. Sessions run DontAsk with a read-only allow-list
    and a deny-list for the tools that leave marks.

Two findings worth reading

  • An allow-list is not a gate. Under dontAsk the CLI reads each Bash command and permits the
    ones it judges harmless: the agent ran fd -e scala . ui/src | sort though Bash was in no
    allow-list, while a writing command was refused and no file appeared. The host now names the
    mark-making tools in disallowedTools, where a refusal outranks every permission.
  • A shared decoder is not free in a browser. Reading two string fields with the contract's
    circe decoders took the bundle to 196 245 bytes gzipped — worse than before CC-D3. It reads them
    with the platform's parser now (98 758), and BundleWeightTest weighs the shipped bundle on
    every ui.test run.

Follow-ups (parked, not deferred work from this card)

  • --restricted as a session option in the SDK — the construction the card's invariant describes.
  • A session that has ended reads as one that was never ours.
  • The live area and its contents; the SDK event path over the wire; unfolding the open run.
  • Runtime affordances for following (start/stop the tail), at Michal's direction.
## Slice — Talk to a session (cc-chat/04) **Goal.** I can hold a conversation with an agent from a browser page: I type, it answers, I watch it work, and I stop it when it goes the wrong way. The page is where the conversation happens, not a window onto one happening somewhere else. **Fit test — passed on Michal's own read (2026-08-28).** He asked a real question about real code through the page, interrupted and redirected it, and acted on the answer. Nothing typed was lost, the page never looked frozen, and stop stopped the agent rather than the page's reading of it. ### What ships - **The contract at its settled shape.** Every operation now hangs under the session it is about — `/v1/sessions/{session}`, with `markup`, `follow`, `artifact`, `send` and `interrupt` beneath it — and a version marker opens every address. `TranscriptEndpoint`/`TranscriptApi` became `SessionEndpoint`/`SessionApi`: the transcript is one part of a session, not the other way round. The rendering path's refusal is typed as what the wire actually carries (a status and the page the host rendered), where it had been typed as a failure whose reading direction was a constant. - **`Sessions` (zio).** The live sessions one host holds, each under the id the CLI named it with, each bounded by its own scope: let go of when its process ends, and all of them when the host stops. It holds a process, never a conversation — the transcript stays the CLI's own file. - **Three writing operations.** Start a session, say something to it, stop what it is doing. They refuse in their own vocabulary (`SessionFailure`), because a transcript that cannot be read is not a session that will not take what was typed. - **The composer, in Laminar.** The one part of a conversation no transcript can carry: a message nobody has sent yet. One control beside the box — Send while there is nothing to stop, Stop while the agent is working — and what a person had typed survives stopping, because stopping is how a turn that went the wrong way is redirected. - **Where the session stands**, said by the host rather than guessed by the page: its own kind of message on the follow (not started here / ready / working). Three things turn on it — whether there is a box at all, whether it is shut, and what the page says for itself. - **Nothing leaves a mark, by construction.** Sessions run `DontAsk` with a read-only allow-list **and a deny-list** for the tools that leave marks. ### Two findings worth reading - **An allow-list is not a gate.** Under `dontAsk` the CLI reads each Bash command and permits the ones it judges harmless: the agent ran `fd -e scala . ui/src | sort` though Bash was in no allow-list, while a writing command was refused and no file appeared. The host now names the mark-making tools in `disallowedTools`, where a refusal outranks every permission. - **A shared decoder is not free in a browser.** Reading two string fields with the contract's circe decoders took the bundle to 196 245 bytes gzipped — worse than before CC-D3. It reads them with the platform's parser now (98 758), and `BundleWeightTest` weighs the shipped bundle on every `ui.test` run. ### Follow-ups (parked, not deferred work from this card) - `--restricted` as a session option in the SDK — the construction the card's invariant describes. - A session that has ended reads as one that was never ours. - The live area and its contents; the SDK event path over the wire; unfolding the open run. - Runtime affordances for following (start/stop the tail), at Michal's direction.
mph added 15 commits 2026-08-28 11:01:06 +00:00
Activity and Hook rows are rendered but their reading was never decided, and
the session facts on ResultMessage/SystemMessage have no row at all. Measured
while defining the live-stream slice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZX7Zfw7nhRQZQkJHfRoM4
AskUserQuestion is called 446 times across 156 local sessions. The reading path
already shows the exchange; answering from the page is unbuilt, and whether an
SDK-driven session is asked at all is unverified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZX7Zfw7nhRQZQkJHfRoM4
The parked line read "blocked: the SDK does not answer can_use_tool". Checked:
JsonParser handles only control_response inbound, so an inbound control_request
becomes an UnknownMessage. The gap is ours, not the vendor's. Whether the CLI
sends those requests in stream-json mode is unverified and becomes the slice's
first step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZX7Zfw7nhRQZQkJHfRoM4
An entry's timestamp is the message time, not the write time. AskUserQuestion
writes both lines only once answered, with retrospective timestamps, so the
file cannot show a tool still waiting on a person.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZX7Zfw7nhRQZQkJHfRoM4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZX7Zfw7nhRQZQkJHfRoM4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZX7Zfw7nhRQZQkJHfRoM4
Every operation now hangs under the session it is about —
/v1/sessions/{session}, and markup, follow and artifact beneath it —
instead of asking for the session as a parameter beside a flat path. The
version opens every address, so a later shape can be served beside this
one.

A refusal on the rendering path is typed as what the wire carries: a
status and the page the host rendered. It was typed as a
TranscriptFailure whose reading direction was a constant, which would
have told a client the wrong case for every failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS9XLuXRvpbEuV9ZE3Whn1
The zio module gains Sessions: the live sessions one host holds, each
under the id the CLI named it with, let go of when their process ends and
when the host stops. It holds a process, never a conversation.

The contract gains the two writing operations — start a session, and say
something to one — refusing in their own vocabulary, because a transcript
that cannot be read is not a session that will not take what was typed.
TranscriptEndpoint and TranscriptApi are now SessionEndpoint and
SessionApi: they address a session, of which the transcript is one part.

The element gains a composer, in Laminar, for the one thing no transcript
can carry: a message nobody has sent yet. Told to talk with no session, it
asks the host for one and says which name it got; the demo host puts that
name in the address bar, so a reload comes back to the same conversation.

The session runs DontAsk over a read-only allow-list, so nothing it does
can leave a mark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS9XLuXRvpbEuV9ZE3Whn1
A session this host has just started has no transcript until its first
turn writes one, and the follow refused it with a 404 — which EventSource
treats as final, so the page said it had stopped following and never asked
again. The first turn of every fresh session was therefore invisible.

A session the host holds is now waited for instead: the beats go on, so
the page says it is following, which it is. A session the host does not
hold is still refused, which is what a page needs to hear.

Checkpoints the tracker at the first usable increment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS9XLuXRvpbEuV9ZE3Whn1
A transcript this host did not start reads perfectly well and cannot be
talked to at all: send writes to a process we started. The page could not
tell the two apart — it said "Following.", which a reader hears as "the
agent is live", and offered a box that could only ever be refused.

The host now says where the session stands, as its own kind of message on
the follow: not started here, ready, or working. Three things turn on it —
the composer exists only where there is an agent this host can reach, the
box shuts while the agent works, and the line says the session's standing
while the connection is fine and the connection's when it is not. Working
is the host's own bookkeeping, because the SDK models no turns.

The refusal's words were false for a live session in someone's terminal;
it now says it was not started here.

Also: the element read two string fields with the contract's circe
decoders, which took the bundle to 196 245 bytes gzipped — worse than
before CC-D3. It reads them with the platform's parser now (98 758), and
BundleWeightTest weighs the shipped bundle on every ui.test run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS9XLuXRvpbEuV9ZE3Whn1
interrupt on the contract, over Session.interrupt, reached from one
control beside the box: Send while there is nothing to stop, Stop while
the agent is working — which is exactly when sending is refused, so the
position is never dead. What a person had typed survives stopping, because
stopping is how a turn that went the wrong way is redirected.

Verifying it turned up an invariant that did not hold. The card says the
agent cannot run a command; it ran `fd -e scala . ui/src | sort` through
Bash, which was in no allow-list. Under dontAsk the CLI reads each command
and permits the ones it judges harmless — a writing one is refused and no
file appears, so nothing left a mark, but that rested on the CLI's reading
rather than on anything the host decided. The host now denies Bash,
BashOutput, KillShell, Write, Edit and NotebookEdit by name, where a
refusal outranks every permission: the agent now answers that the session
has no Bash tool at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS9XLuXRvpbEuV9ZE3Whn1
The critical one: Sessions.start covered only the opening with cleanup, so a
fiber interrupted after the process was up and named — a browser dropping
POST /v1/sessions while the CLI came up — left a live process nothing held.
The whole open → name → hold → watch sequence is now uninterruptible but for
the opening, and the failure path closes the scope with the real cause.

Also: scopes are closed outside the map's lock, so one slow-to-die process no
longer blocks every other start; what a session was asked is dropped when the
session is; the working/ready rule moved to SessionStanding.of where it is
tested; the jsdom stub can now break, so the "host could not be reached"
message is covered; a refusal bounds the id it repeats back; 201 says where
the session it made now lives; the start answer calls the session sessionId
like everything else does; TranscriptWatch.State no longer keeps a second
answer to "can this be told anything"; Task is denied by name.

The contract now states that authentication is the host's business, and that
a browser-facing host must refuse a foreign Origin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS9XLuXRvpbEuV9ZE3Whn1
Promoted:
- a blocking tool reaches the transcript only after it is answered → a
  cautionary comment at ConversationLogEntry.timestamp, written as what we
  observed of Claude Code today rather than as a rule about every tool
- a shared decoder in the browser cost eight times its own transcript →
  fixed-at-source (platform parser, bundle budget test, notes beside the
  JSON givens)
- a read-only allow-list let the agent run read-only commands →
  fixed-at-source at both ends: the host's deny-list, and the docstring that
  caused it. PermissionMode.DontAsk claimed to "enforce allowedTools as a
  hard allow-list"; measured against CLI 2.1.250 it does not, and that
  sentence ships to every consumer of this SDK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS9XLuXRvpbEuV9ZE3Whn1
The pre-push gate refuses warnings, and appending a node to the page is a
value nobody reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS9XLuXRvpbEuV9ZE3Whn1
fix(ui): drop an import the element no longer uses
Some checks failed
CI / Check Formatting (pull_request) Successful in 56s
CI / Compile (pull_request) Successful in 1m13s
CI / Check Linting (pull_request) Successful in 57s
CI / Run Tests (pull_request) Successful in 1m38s
CI / Run Integration Tests (pull_request) Successful in 2m10s
CI / Check Bundle Size (pull_request) Failing after 1m18s
cdad0467b7
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS9XLuXRvpbEuV9ZE3Whn1
build: raise the bundle gate for the composer, and pair it with the local one
All checks were successful
CI / Check Formatting (pull_request) Successful in 46s
CI / Compile (pull_request) Successful in 1m15s
CI / Check Linting (pull_request) Successful in 59s
CI / Run Tests (pull_request) Successful in 1m25s
CI / Check Bundle Size (pull_request) Successful in 45s
CI / Run Integration Tests (pull_request) Successful in 1m42s
ee0b9d3c43
CI refused the push: the bundle gate stood at 20 000 and Laminar takes the
bundle to about 100 000. The growth is intentional — a page a person types
into holds what no transcript can carry, which is what Laminar is here for
— so the gate is raised to 110 000 with the measurement written down.

The gate this slice added in ui.test duplicated one that already existed
and had already caught this class of regression once. Both are kept, on
purpose: the test weighs the hashed asset that ships in the jar, at a
tighter 105 000, so a person meets the limit on their own machine seconds
after the change; CI weighs the linker's output as the backstop, looser
because compressed size moves about three per cent with the environment
(98 749 locally, 101 426 on CI). Each comment names the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS9XLuXRvpbEuV9ZE3Whn1
workflow(sl): close cc-chat/04-talk-to-a-session (flush parked items)
All checks were successful
CI / Check Formatting (pull_request) Successful in 52s
CI / Compile (pull_request) Successful in 1m7s
CI / Check Linting (pull_request) Successful in 58s
CI / Run Tests (pull_request) Successful in 1m27s
CI / Check Bundle Size (pull_request) Successful in 48s
CI / Run Integration Tests (pull_request) Successful in 1m42s
e37ab797d7
Tracker closed, review-state at awaiting_review with PR #67. The map item is
marked done; its parked items become four candidate slices —
restricted-sessions, following-on-demand, a-session-that-ended, and
the-element-holds-two-things — with three review findings recorded as parked
asides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS9XLuXRvpbEuV9ZE3Whn1
workflow(sl): review-state after the closing CI run
All checks were successful
CI / Check Formatting (pull_request) Successful in 40s
CI / Compile (pull_request) Successful in 1m7s
CI / Check Linting (pull_request) Successful in 59s
CI / Run Tests (pull_request) Successful in 1m25s
CI / Check Bundle Size (pull_request) Successful in 45s
CI / Run Integration Tests (pull_request) Successful in 1m40s
e599be8d9e
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS9XLuXRvpbEuV9ZE3Whn1
mph merged commit 640610167d into main 2026-08-28 12:00:28 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
iterative-works/claude-code-query!67
No description provided.