Skip to content
92 changes: 86 additions & 6 deletions docs/control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,15 @@ the user is told.

## The actions

Seven, and the set is closed. Adding one is a contract change.
Eight, and the set is closed. Adding one is a contract change.

`progress` is the eighth. It did **not** move the protocol version, and the
reason is what the version is for: a surface built against 4 can tell an engine
that disagrees from one too old to have been asked, and the failure it guards
against is a surface sending something the engine does not have. Adding an action
cannot cause that — a surface built against 4 never sends `progress`, and one
that does is talking to an engine that has it. *Removing* an action can, which is
what 4 was for.

`launch` and `close` were the eighth and ninth until protocol 4. They are parked
with the code behind them ([#72](https://github.com/okqixiaobao727-design/GPT-VoiceCoding/issues/72)):
Expand All @@ -113,27 +121,98 @@ Payload: none. Data:
}
```

A session:
A session — every field of one roster row, because a surface that had to ask a
second question to render one line would be a second reader of the same Session:

```json
{"target": {"agent": "claude", "session_id": "abc", "pid": 1234},
"label": "gpt-voicecoding · build the control plane",
"workspace": "/Users/…", "registered_at": 1787222000.0,
"state": "live", "reply_window": "closed"}
"name": "workspace-claude-ed",
"workspace": "/Users/…", "first_seen": 1787222000.0,
"lifecycle": "live", "state": "idle",
"waiting_for": {"kind": "question", "caught_up": true, "prompt": "Which base?",
"options": [{"text": "main", "recommended": true}],
"recommendation": "main", "tool_name": null, "detail": null,
"approval_id": null},
"progress": {"recent": [{"role": "user", "text": "do the thing"},
{"role": "assistant", "text": "done"}],
"truncated": false, "read_at": "2026-08-26T02:44:39+00:00"},
"last_activity": "2026-08-26T02:44:39+00:00",
"child": {"kind": "main", "parent": null},
"reply_window": "open"}
```

`target` is the **address**; `label` is for speech and for matching. A label
never crosses the wire as an address — resolving one to a target is Bridge
Core's router, on the way in from the Companion Channel.

`registered_at` is wall-clock seconds: it is written to disk and read back by the
next engine.
`first_seen` is wall-clock seconds, and it is when *this engine* first saw the
Session — no agent knows it.

`waiting_for` is what a stopped Session stopped on, as structure rather than as a
rendered sentence. `kind` is one of `none`, `question`, `permission`, `unknown`;
`unknown` always comes with `caught_up: false`, and means *ask again*, never
*nothing is happening*.

`progress` is `null` when nobody read it, and an object with an **empty**
`recent` when it was read and the Session had said nothing — a surface that
collapses the two reports a working Session as an idle one. Each entry says which
side spoke it, so `"make it blue"` and `"I made it blue"` cannot read the same.
It is bounded to the newest 3 entries and 3 KB per row: `truncated` says
something older was dropped, and entries are dropped whole, never cut.

`last_activity` is separate from `progress` on purpose. A Session can have moved
without saying anything a reader would show, and this is the field that says so.
It is `null` when nothing read one.

It is deliberately **wider than `progress`**: it counts any work in the Session,
its own subagents included, where `progress` carries only what the user would be
shown. On the Claude lane that means a sidechain record advances it even though
such a record never becomes an entry; on the Codex lane it is the thread's own
`updatedAt`, which moves for any work at all. One meaning on both lanes — and a
Session four minutes into a subagent is not a Session nobody has heard from. A
subagent is not a Child Process: that is a separate agent process, and it has a
row of its own.

`reply_window` is derived on the row and rendered here, so no surface re-derives
it and no two surfaces can disagree about one Session.

### `sessions`

Payload: none. Data: `{"sessions": [...]}` — the same rows `status` carries, for a
surface that renders only the roster.

### `progress`

Payload: `{"target": {"agent": "codex", "session_id": "abc", "pid": null}}`. Data:
`{"session": { … }}` — one roster row, in exactly the shape above.

It is a **read**: it resolves one exact identity, asks that lane and no other,
and never starts a turn. What it adds over the same row in `sessions` is *when* —
the Session is read at the moment it was asked about, rather than at the last
discovery. A Session mid-turn is answered here and is deliberately not carried on
the roster: reading it is the expensive half, and "how far along is it" is the
question a user asks precisely while it works.

**It refuses rather than answering emptily**, and the four refusals are four
different facts:

| Code | When |
| --- | --- |
| `unknown_session` | No Session by that identity was ever registered here. |
| `stale_session` | The identity names a different process now, **or** the Session has ended. The row is not ended by this action: `sessions` is the whole-lane reading and is the only thing that ends one. |
| `refused` | The lane could not be read at all. The message is the lane's own words, and the row stands exactly as the roster last saw it: not being able to look is not a sighting. |
| `refused` | Nothing could read how far it has got — a Codex Session the shared daemon does not hold, or one whose first turn has written no record yet. |

The last one is the line this whole action is drawn around. A Session that *was*
read and had said nothing answers normally, with an empty `recent`; a Session
nobody could read is a refusal. A surface handed the second as the first would
render a working Session as an idle one.

A Session the Codex daemon does not hold therefore never gets an invented
reading. Its rollout is on disk and reading it would be a second source answering
the same question with worse evidence.

### `switch`

Payload: `{"name": "duty", "on": true}`. `on` must be a JSON boolean; a string is
Expand Down Expand Up @@ -219,6 +298,7 @@ by one parser, so neither can grow a command the other lacks.
status
switch <name> on|off
sessions
progress <agent>:<session id>[:<pid>]
live
relay <agent>:<session id>[:<pid>] [--supplement] <words>
approve <approval id> allow|deny|ask
Expand Down
8 changes: 6 additions & 2 deletions shell/Sources/ShellCore/Wire.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@ public let maxRequestBytes = 65536
/// declaration by the cross-language agreement test in `tests/test_app_bundle.py`.
public let controlPlaneProtocolVersion = 4

/// Every action this engine has. Seven, and the set is closed — adding one is a
/// contract change, so the shell names them rather than composing strings.
/// Every action this engine has. Eight, and the set is closed — adding one is a
/// contract change, so the shell names them rather than composing strings. The
/// two spellings are held to each other by `tests/test_app_bundle.py`, which
/// reads this enum and compares it with the engine's own.
///
/// `launch` and `close` were here until protocol 4, and are parked with the code
/// behind them: the engine answers `unknown_action` for either now, so naming
/// them here would let this shell offer an action nothing can carry out.
public enum Action: String, Sendable, CaseIterable {
case status
case sessions
/// How far along one exact Session is, read now. A question, never a turn.
case progress
case `switch`
/// The Live Toggle. One action: it ends the call the system owns, or starts
/// one if none is up. Bridge Core owns that policy; no surface holds call state.
Expand Down
77 changes: 77 additions & 0 deletions src/gpt_voicecoding/adapters/agent/_progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""How much of what a Session said travels. One bound, both lanes, no exceptions.

Each lane finds its entries its own way — Claude walks a transcript it owns,
Codex asks the shared daemon for a thread's turns — but *how many* of them a
`Progress` carries is a property of the type and of the wire it crosses, not of
either lane. Two lanes bounding themselves would be two answers to one question,
and a Control Panel row whose length depended on which agent wrote it.

**Why these two numbers.** `Progress` rides on **every** roster row of a reply
with a 64 KB ceiling (`seams/control_plane.py:44`, enforced on the reply too at
`control_plane/client.py:88-94`). The reference implementation's 12 entries and
32 KB were a per-Session verb's, asked about one Session at a time
(`legacy@1d32845:config.plist:449-452`), and one such answer would spend half
this reply on one row. Three entries is what a spoken line or a menu-bar row can
use — the last thing said, and enough before it to place it — and 3 KB is the
smallest budget that still holds an ordinary paragraph, so a row does not go
silent over a sentence somebody wrote at length.

The **rule** is ported whole from `legacy@1d32845:bridge/transcript.py:2828-2860`:
keep the newest, widen down until it fits, and say so when anything was dropped.
Legacy's `drop_oversize_tail` is the roster case here rather than an option — a
single entry over the budget yields no entries and `truncated`, because a reply
nobody can read is a worse failure than a row that admits it is empty.
"""

from __future__ import annotations

import json
from collections.abc import Sequence
from typing import Final

from gpt_voicecoding.seams.agent import ProgressEntry

#: How many entries one reading carries.
RECENT_LIMIT: Final = 3

#: And how many bytes they may encode to, on the document that travels.
RECENT_MAX_BYTES: Final = 3 * 1024


def bounded(
entries: Sequence[ProgressEntry],
limit: int = RECENT_LIMIT,
*,
max_bytes: int = RECENT_MAX_BYTES,
) -> tuple[tuple[ProgressEntry, ...], bool]:
"""The newest of these that fit, and whether anything older was dropped.

An entry goes whole or stays whole. Half a sentence read aloud says less
than nothing, and a cut lands mid-secret as readily as mid-word — the same
rule `stop_analysis.SUMMARY_MAX_CHARS` follows for a tool summary.
"""
if limit <= 0 or max_bytes <= 0:
raise ValueError("a bound that bounds nothing is a bound nobody set")
kept = list(entries[-limit:])
truncated = len(kept) < len(entries)
while kept and encoded_size(kept) > max_bytes:
kept.pop(0)
truncated = True
return tuple(kept), truncated


def encoded_size(entries: Sequence[ProgressEntry]) -> int:
"""How many bytes these entries take on the wire.

Measured on the document `control_plane.payloads.progress_document` renders,
rather than on the text alone, because the budget exists to keep a roster
reply under the protocol's ceiling and the role travels inside it.
`tests/test_control_plane_actions.py` holds the two spellings together, so
this cannot quietly start measuring a shape nobody sends.
"""
return len(
json.dumps(
[{"role": str(entry.role), "text": entry.text} for entry in entries],
ensure_ascii=False,
).encode("utf-8")
)
91 changes: 81 additions & 10 deletions src/gpt_voicecoding/adapters/agent/claude/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@
import logging
from contextlib import suppress
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from pathlib import Path

from gpt_voicecoding.adapters.agent.claude import discovery as claude_discovery
from gpt_voicecoding.adapters.agent.claude import stop_analysis
from gpt_voicecoding.adapters.agent.claude import stop_analysis, transcript_tail
from gpt_voicecoding.adapters.agent.claude.approval import (
CWD_FIELD,
MESSAGING_SOCKET_FIELD,
Expand All @@ -73,7 +74,7 @@
channel_kind_for,
)
from gpt_voicecoding.adapters.agent.claude.settings import ClaudeSettings
from gpt_voicecoding.adapters.agent.claude.transcript import TranscriptReader
from gpt_voicecoding.adapters.agent.claude.transcript import Record, TranscriptReader
from gpt_voicecoding.adapters.agent.claude.window import ReplyWindowWatcher
from gpt_voicecoding.adapters.agent.claude.wire import (
ChannelConnection,
Expand All @@ -85,6 +86,7 @@
ApprovalVerdict,
LaneDiscovery,
LaneUnavailable,
Progress,
RelayReceipt,
RelayRoute,
ReplyWindow,
Expand Down Expand Up @@ -393,21 +395,82 @@ async def discover(self) -> LaneDiscovery:
return replace(lane, rows=tuple(self._row_with_stop(row) for row in lane.rows))

def _row_with_stop(self, row: SessionInspection) -> SessionInspection:
"""One roster row, with what it stopped on read in.
"""One roster row, with everything its own transcript says about it.

**A Session mid-turn is not stopped on anything**, so a `RUNNING` row is
returned untouched and its transcript is never opened. That is what keeps
this off the hot path: on a machine of busy Sessions, the five-second
cadence costs one roster command and no file reads at all.
cadence costs one roster command and no file reads at all. #76 rides on
the same gate rather than a wider one of its own — a roster row is the
cheap projection, and the `progress` verb is where a running Session can
still be asked (`inspect`).
"""
if row.state is SessionState.RUNNING:
return row
waiting = self.stopped_on(row.target, row.waiting_for)
return row if waiting == row.waiting_for else replace(row, waiting_for=waiting)
return self._read_into(row)

def _read_into(self, row: SessionInspection) -> SessionInspection:
"""One row, filled from one read of the transcript its Session named.

**One read answers both questions.** What the Session stopped on (#75)
and how far along it is (#76) come from the same records at the same
moment; two reads would be two whole-file parses per tick describing two
moments of a file the Session is still appending to.

**Nothing here bypasses the reader's cache, and nothing needs to.** That
cache is keyed on the file's own identity as the filesystem reports it
(`transcript.py:36-40`), so a hit means the Session has not written a
byte since — and a transcript that has not changed cannot have changed
what its tail says. Forgetting the parse first would buy no freshness and
cost a second pass over a file measured at 186 MB on this machine.

**A `RUNNING` row is read for progress and never for a stop.** The two
questions do not have the same answer mid-turn: an outstanding `tool_use`
with no result yet is a tool *running*, and reading it as a stop would
report a working Session as one waiting on the user's permission. Only
`inspect` gets here with a `RUNNING` row at all — the cadence returns one
untouched — and that is the row the gate exists for.
"""
records = self._transcripts.records(self._transcript_path(row.target))
waiting = (
row.waiting_for
if row.state is SessionState.RUNNING
else self._overlay(row.target, row.waiting_for, records)
)
if records is None:
# No path, no file yet, or a read that failed. The roster's own word
# stands, and `progress` stays `None` — "not read", never "read and
# found nothing".
return row if waiting == row.waiting_for else replace(row, waiting_for=waiting)
entries, truncated, moved = transcript_tail.recent(records)
return replace(
row,
waiting_for=waiting,
progress=Progress(recent=entries, truncated=truncated, read_at=datetime.now(UTC)),
last_activity=moved,
)

def _transcript_path(self, target: SessionTarget) -> Path | None:
"""Where this Session's own record is, as its registration named it."""
report = self._reported.get(target)
return report.transcript_path if report else None

def stopped_on(self, target: SessionTarget, roster: WaitingFor | None = None) -> WaitingFor:
"""What one Session stopped on, from its transcript and any parked dialog.

The Reply Window watcher's route to the same answer (`window.py:308-311`),
and the reason the overlay below is a method rather than inline: a stop
raised by the watcher and a stop read off the roster must be the one
reading, not two that agree most of the time.
"""
records = self._transcripts.records(self._transcript_path(target))
return self._overlay(target, roster if roster is not None else WaitingFor(), records)

def _overlay(
self, target: SessionTarget, base: WaitingFor, records: tuple[Record, ...] | None
) -> WaitingFor:
"""What these records and any parked dialog say this Session stopped on.

Two sources, and they are ranked rather than merged, because they can
disagree and the ranking is the behaviour (#75):

Expand All @@ -433,9 +496,6 @@ def stopped_on(self, target: SessionTarget, roster: WaitingFor | None = None) ->
Session the roster calls `waiting`, which is the seam's way of saying
*ask again, never guess*.
"""
base = roster if roster is not None else WaitingFor()
report = self._reported.get(target)
records = self._transcripts.records(report.transcript_path if report else None)
found = base if records is None else stop_analysis.analyse(records)
if found.kind is WaitingKind.NONE:
# The transcript is not held up on anything, which the roster may
Expand Down Expand Up @@ -473,6 +533,17 @@ async def inspect(self, target: SessionTarget) -> SessionInspection:
A Session the roster no longer lists reads as `ENDED`, which is the
honest answer to "what is it doing" for a Session that is not there.

**This one is asked about a running Session too, and `discover` is not.**
The cadence skips a `RUNNING` row because a Session mid-turn has not
stopped on anything; but "how far along is it" is the question a user
asks *precisely* while it works, so the per-target read has no such gate
(#76, the `progress` verb), and every row that reaches it is read here
rather than taken from the cadence's pass. What that costs is one pure
walk of records already in memory: the file itself is opened again only
if the Session has written to it since, because the reader's cache is
keyed on the file's own identity. Bypassing that cache is deliberately
*not* done — a hit is the proof of freshness, not a stale answer.

**A roster that could not be read is not a roster that lists nothing.**
The two are one value apart here and a whole verdict apart for the
caller, so a failed enumeration raises rather than answering `ENDED`.
Expand All @@ -483,7 +554,7 @@ async def inspect(self, target: SessionTarget) -> SessionInspection:
raise LaneUnavailable(AgentKind.CLAUDE, lane.error)
for row in lane.rows:
if row.target == target:
return row
return self._read_into(row)
return SessionInspection(
target=target,
workspace=Path(),
Expand Down
Loading
Loading