Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ DerivedData/
.claude/
.code-review-graph/

# Secrets, kept out of the tree and read into the shell that needs them. The
# acceptance's Telegram bot token has no durable home yet (#55); this is it
# until it does.
.env

# Local-only research notes, scratch work, and throwaway prototypes.
docs/research
notes/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 6. The Claude Session Channel server is Python, and speaks MCP with the standard library alone

Date: 2026-08-21 · Status: Accepted · Source: [#8](https://github.com/okqixiaobao727-design/GPT-VoiceCoding/issues/8)
Date: 2026-08-21 · Status: Superseded by [ADR 0013](0013-the-answer-relay-rides-the-sessions-own-inbox-socket.md) · Source: [#8](https://github.com/okqixiaobao727-design/GPT-VoiceCoding/issues/8)

The Answer Relay's route into a Claude Session is an MCP channel server that Claude Code spawns from a plugin manifest; the bridge only dials its socket. Legacy wrote it in Node (`claude-channel/channel.mjs`), which would put a second runtime in the bundle (ADR 0005) and outside the test suite.

Expand All @@ -12,4 +12,4 @@ The Answer Relay's route into a Claude Session is an MCP channel server that Cla

One runtime in the bundle; the handshake is covered by `pytest`. The only deliberate byte-level difference from `channel.mjs` is the bridge–channel wire carrying an explicit Relay kind. Handshake drift surfaces as a classified non-delivery, never as silence.

This route depends on launch-time injection and serves the pre-#67 Session definition; the inbox-socket route replaces it once [#71](https://github.com/okqixiaobao727-design/GPT-VoiceCoding/issues/71) proves it.
This route depends on launch-time injection and serves the pre-#67 Session definition. [#71](https://github.com/okqixiaobao727-design/GPT-VoiceCoding/issues/71) proved the inbox-socket route live on 2.1.245 and it replaces this one: see [ADR 0013](0013-the-answer-relay-rides-the-sessions-own-inbox-socket.md). The server, its wire and its protocol transcription were removed in [#77](https://github.com/okqixiaobao727-design/GPT-VoiceCoding/issues/77); what the decision leaves behind is its one durable lesson, which ADR 0013 keeps — the channel's acknowledgement was a real receipt, and giving it up for reach is what made "an accepted write is not a receipt" a rule rather than a preference.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 13. The Claude Answer Relay rides the Session's own inbox socket, and an accepted write is not a receipt

Date: 2026-08-26 · Status: Accepted · Source: [#71](https://github.com/okqixiaobao727-design/GPT-VoiceCoding/issues/71), built in [#77](https://github.com/okqixiaobao727-design/GPT-VoiceCoding/issues/77)

v1.0 is a bridge over the Sessions the user already started ([#67](https://github.com/okqixiaobao727-design/GPT-VoiceCoding/issues/67)), and the route ADR 0006 chose cannot reach one: a channel server is spawned from a plugin manifest at launch, so it exists only in Sessions this product started. Claude Code has since grown an inbox socket of its own, bound by default for every Session. The documented external ingress routes are channels — a research preview requiring a startup `--channels` selector, which means taking over the user's `claude` command — and `-p --input-format stream-json`, which is not an interactive Session at all. Neither reaches a Session started by hand.

## Decision

**The Answer Relay writes into the Session's own inbox socket, on private surface, knowingly** (Simon, 2026-08-25). Four things follow, and they are the decision rather than its background.

**1. The socket path is read, never built.** 2.1.245 derives the socket directory from `CLAUDE_CODE_TMPDIR` or `$XDG_RUNTIME_DIR` and accepts `--messaging-socket-path`, so a constructed path is a guess. It arrives on the `SessionStart` registration (ADR 0011) and is held per exact target.

**2. `DELIVERED` has exactly two sources, and an accepted write is neither.** The line was taken by a socket, not read by a Session. The two are the `held → delivered` `peer_message_status` receipt, correlated by `orig_msg_id`; and the target's own transcript entry, whose `origin.from` is our reply address and whose `origin.msg_id` is the id we minted. `denied` / `refused` / `expired` / `dropped` are proven non-delivery and grade `FAILED`; `held` is its own grade; everything weaker is `UNKNOWN`, which P9 never re-sends on this system's own authority.

**3. Words travel on this wire; authority never does.** A peer message is announced to the receiving Session as not typed by its user, and upstream enforces it — a Session asked to approve a pending dialog on a peer's say-so refuses of its own accord and names it permission laundering. **So a Session's question can never be answered over this route**, and no later work on the socket can recover that half. It is why the `PermissionRequest` hook route (ADR 0011) exists rather than being an optimisation.

**4. To be receipted at all, this engine publishes a peer key.** A receiver answers only a sender it can resolve, which means a reply socket bound inside the receiver's own socket namespace and a `<pid>.<sha256(socket path)>.key` file published beside the Session records. It is *only* a key — no `<pid>.json` — so nothing this engine does puts a phantom row in a Session roster. One reply socket per socket directory, because the directory is the namespace a receipt may be addressed inside. Both the socket and the key live in directories that are not ours, and both are removed when the engine stops.

**No version pin** (Simon, 2026-08-25): Claude Code auto-updates, and when it breaks we fix it then. The safeguard is not a pin but the rule the evidence already forces — never infer delivery from a successful write — so an upstream change surfaces as a missing receipt rather than as words the user believes were delivered. The frame shapes are not restated here: they live in `adapters/agent/claude/inbox.py`, beside the code that sends them, because they are what a re-probe compares against.

## Consequences

Every Claude Session on the machine is reachable, including ones started before this engine was running, and the reference implementation's launch wrapper is not needed for the Relay half.

**Most `UNKNOWN`s on this lane are Relays that did arrive.** An immediately-accepted message yields no receipt to an external process — the receiver logs `[peer-cred] peer pid unavailable`, and its notion of verification is *own-child*, a process posting to its parent's socket. On a machine with `crossSessionInbound: "accept"` nothing is ever held and therefore nothing is ever receipted, so the transcript is the whole of the evidence there. That is the price of the honesty rule, paid deliberately.

**A grade that has not finished happening keeps a listener, and both directions are raised.** A `held` message settles when the person answers, expires after about five minutes, or is dropped once the hold queue passes a hundred. So `HELD` and `UNKNOWN` keep a watcher for that lifetime: a later `delivered` is raised so the hub stops holding words that arrived, and a later `denied` / `expired` / `dropped` is raised as `FAILED`, which is the one grade P9 permits another attempt for. Without the second half a Relay would stay recorded as parked long after it was thrown away, which is the implied delivery this ADR exists to refuse. A grade that was already terminal never gets a listener, so nothing here re-grades a settled attempt.

**The auth frame carries the receiver's own messaging token** when its registration reported one, and is omitted otherwise. That token's one documented meaning is own-child verification, and it is the documented way past a `bypassPermissions` receiver's hold; delivery does not need it, and neither it nor our own token earns a receipt. Attesting `from_mode` was hypothesised and disproven — an external process cannot assert a permission class — so this product does not send one.

**This is undocumented surface and the acceptance is where it is checked.** The `relay` step exercises the whole route against a real Session; a build that changed the frame, the key derivation or the `procStart` shape shows up there as a lost receipt.

ADR 0006 is superseded. ADR 0007 was already superseded by ADR 0011, which is the other half of this map: the inbox for words, the hooks for authority.
3 changes: 2 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
| [0003](0003-the-engine-reports-what-it-loaded.md) | The engine reports what it loaded, and liveness checks read that answer |
| [0004](0004-the-engine-owns-its-log.md) | The engine owns its log, so rotation can rename rather than truncate |
| [0005](0005-the-engine-lives-inside-the-app-bundle.md) | The engine lives inside the app bundle, because that is what earns the microphone grant |
| [0006](0006-the-claude-channel-server-is-python-and-stdlib-only.md) | The Claude Session Channel server is Python, and speaks MCP with the standard library alone |
| [0006](0006-the-claude-channel-server-is-python-and-stdlib-only.md) | The Claude Session Channel server is Python, and speaks MCP with the standard library alone — **superseded by 0013** |
| [0007](0007-the-approval-hook-is-a-session-scoped-plugin.md) | The Claude Approval Relay's hook is registered as a session-scoped plugin — **superseded by 0011** |
| [0010](0010-legacy-is-the-behaviour-spec.md) | The seam architecture stays, and the first generation is the behaviour spec it must satisfy |
| [0011](0011-the-claude-hooks-are-a-fingerprinted-block-in-the-user-settings-file.md) | The Claude hooks are a fingerprinted block in the user's settings file |
| [0012](0012-installation-runs-at-first-launch.md) | Installation runs at first launch, from a package that needs no engine |
| [0013](0013-the-answer-relay-rides-the-sessions-own-inbox-socket.md) | The Claude Answer Relay rides the Session's own inbox socket, and an accepted write is not a receipt |

0008 (headless direct-child launcher) and 0009 (a launch carries its Opening Instruction) were removed with the launcher when v1.0 became a bridge over Sessions the user starts ([#67](https://github.com/okqixiaobao727-design/GPT-VoiceCoding/issues/67), [#68](https://github.com/okqixiaobao727-design/GPT-VoiceCoding/issues/68)); they return with the launch map, from git history.

Expand Down
11 changes: 6 additions & 5 deletions docs/app-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,12 @@ own words.
**7. Approval Relay.** Get the Session to ask for a permission it needs, and
answer it by voice. One verdict, one request.

A live Session is reachable *only* through the Answer Relay: `launch --task`
carries a label, not an instruction, and a launched Session has no terminal to
type into. Steps 6 and 7 therefore share the fate of the Session Channel — if
the carrier is not there, neither step is performable, and that is a blocked
step rather than a failed one.
Both steps are performed against a Session you started yourself, at your own
terminal: v1.0 launches nothing (#67). Step 6 rides that Session's own inbox
socket and step 7 the `PermissionRequest` hook, so each is performable only where
its own carrier reached that Session — the hook installed in its config directory
(ADR 0011) and its `SessionStart` registration received. A carrier that is not
there is a blocked step rather than a failed one, and the receipt says which.

**8. The Companion Channel.** A notice must reach Telegram, and a reply typed
there must come back as inbound text. **Run this with Voice off**, together with
Expand Down
14 changes: 6 additions & 8 deletions src/gpt_voicecoding/adapters/agent/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
calls it with the event sink and this seam's settings table. Nothing else
imports an adapter (ADR 0001).

This package is the shared Claude adapter for the Answer Relay over the MCP
Session Channel and the Approval Relay over the `PermissionRequest` hook — two
routes, two proofs, one settings table, one bootstrap contract with the launcher,
and one set of socket privacy rules.
This package is the shared Claude adapter for the Answer Relay over the Session's
own **inbox socket** and the Approval Relay over the `PermissionRequest` hook —
two routes, two proofs, one settings table, and one set of socket privacy rules.
The inbox carries the user's words; only the hook can carry their authority, and
#71 proved that boundary is upstream's own and enforced (`inbox.py`).

The approval socket is bound at `connect` in a directory of this engine's own,
because its address has to exist before any Session launches — the launch is what
carries it to the hook.
because its address has to exist before any Session's hook dials it.

**The Approval Relay needs two things this package does not launch**: the hook
installed in the Session's config directory, which is `installation`'s job
Expand All @@ -35,7 +35,6 @@
from gpt_voicecoding.adapters.agent.claude.bootstrap import (
CHANNEL_CONFIG_VARIABLE,
BootstrapError,
bootstrap_value,
publish_address,
withdraw_address,
)
Expand All @@ -57,7 +56,6 @@
"RegistryError",
"SettingsError",
"approval_socket_path",
"bootstrap_value",
"claude_agent",
"hook_decision",
"publish_address",
Expand Down
Loading
Loading