Skip to content

feat(desktop): the Companion runs a claude session locally, hub-free (vision-parity L3a) - #544

Open
physercoe wants to merge 1 commit into
mainfrom
feat-vision-l3a-local-agent-service
Open

feat(desktop): the Companion runs a claude session locally, hub-free (vision-parity L3a)#544
physercoe wants to merge 1 commit into
mainfrom
feat-vision-l3a-local-agent-service

Conversation

@physercoe

Copy link
Copy Markdown
Owner

First half of L3 in desktop-companion-vision-parity.md — the wedge the whole lane was pointed at. Electron main hosts a local agent service: it owns the engine child, keeps the session's transcript in an append-only log with cursor semantics, and serves it to the renderer as the hub's own agent_events shape, so the feed, the folds and the composer never learn which producer they are reading (D-7 — the hub is an option, never a prerequisite).

L3 as written is a service, a driver, a permission story and a durability story in one line. It ships in two: L3a here; L3b takes the durable half (on-disk log, rebind across an app restart via N1's resume recipes, the multi-root session catalog, the loopback WebSocket).

Four things measured against claude-code 2.1.220, three of which contradict the obvious assumption

Both engines are installed on this box, so for the first time in either desktop plan the engine's behaviour was observed rather than inferred.

Assumption What the binary does
--print is one-shot, so a session needs respawn-per-turn It is not. With --input-format stream-json and stdin held open, one child answered two prompts and reported the same session_id for both. That is what makes a session server possible at all — and why stop() ends stdin before signalling.
--permission-mode gates tool use It does not, under --print. manual ran Bash. plan ran Bash. No flag ran Bash. No control frame, permission_denials: []. There is no interactive channel for a mode to hold a decision against.
permission mode is the lever (per the glossary) Only the tool list is. --tools Read,Glob,Grep removed Bash from the session outright; the model reported it unavailable and called nothing.
the vendored frame profile is current It is. A live session emitted system/thinking_tokens — a subtype no rule names, new since the corpus was recorded — and the catch-all {type: system} rule absorbed it. Zero raw rows across a full turn.

The safety consequence

A local session launches with a tool posture — an allowlist, defaulting to read-only, recorded on the transcript's lifecycle row so it states what the agent was permitted to do:

Posture Tools
converse none (--tools "")
read_local Read, Glob, Grepdefault
unrestricted no restriction; a caller has to name it

Not a second spelling of permission mode, which gates approval of a call and needs a hub to route to. A denylist was rejected: it fails open for every tool a future claude adds, and "the engine grew a capability" must not silently widen what a local session can do to the director's machine.

The loader question L2 deferred

resources/agent_families.generated.json is every family marshalled from agent_families.yaml by a Go test that fails when the two disagree, shipped as an electron-builder extraResource. So the M2 launch argv and the frame profile are the same data the hub uses — no YAML parser and no hand-kept mirror on the TS side.

Two narrowings, recorded in the plan rather than papered over

  • The cursor is {seq}, not discussion §9's {seq, epoch}. This log lives in main's heap, so anything that ends it also ends the renderer holding the cursor; an epoch would be a comparison whose two sides can never differ — a branch no test could reach except by fabricating an input production cannot produce. It arrives with L3b's on-disk log.
  • stampContextWindow ports the engine-learned half but not Go's static model table. Copying a heuristic that goes stale as models ship would give the local service its own drifting opinion about a number the engine itself reports. Cost: a local session's first turn has no context ring, every turn after it does.

Verification

engine.e2e.test.ts spawns the real CLI and asserts the whole transcript — session.init, the engine session id captured, the model's reply as typed text, turn boundaries, dense seq, and zero raw rows. Opt-in (TERMIPOD_LOCAL_ENGINE_E2E=1) because it runs a real model turn and spends tokens, so CI skips it and so does a plain npm test.

15 mutations of the new guards, all caught — after two rounds. Both first-round survivors were worth the trip:

  • a clause shadowed by an earlier family !== 'claude-code' return; it guards a real future state (the YAML losing its M2 block), so it now has a test that can reach it
  • dead code — a watching guard for a close-before-acquire path that cannot happen, since acquire() runs synchronously before subscribe() returns. Deleted.

A third defect the tests found on the way: Number(null) is 0, so a null cursor silently meant "replay the whole transcript" — the exact failure that reader claims to prevent.

go test ./... 31 pkgs green · desktop 823/823 · electron 530/530 +1 skipped · both typechecks clean · 12/13 lints (lint-openapi needs a venv this box lacks; no spec touched) · token ratchet 65 / phantom 0 · vite build clean.

Not done here, and not silently

The companionMode hub/local toggle still exists. Its own comment says L3 is what retires it — but retiring a mode is an offer-surface sweep (both i18n dicts, the launcher panel, the persisted key), which is a wedge, not a footnote. L3a merges local sessions into the existing picker and leaves the toggle alone.

Not verified: nothing here has been seen on a display — no display on this box. The picker rows, the + Local session button and the empty-state copy are unrendered.

🤖 Generated with Claude Code

…(L3a)

Vision-parity W3, first half of L3. Electron main hosts a local agent
service: it owns the engine child, keeps the session's transcript in an
append-only log with cursor semantics, and serves it to the renderer as
the hub's own `agent_events` shape — so the feed, the folds and the
composer never learn which producer they are reading (plan D-7).

L3 as written is a service, a driver, a permission story and a
durability story in one line, so it ships in two. L3a is the service,
the log, the claude M2 child, the IPC surface and the renderer source.
L3b takes the durable half: on-disk log, rebind across an app restart
via N1's resume recipes, the multi-root session catalog, the loopback
WebSocket.

Four things were measured against claude-code 2.1.220 rather than
assumed, and three contradict what this would otherwise have been built
on:

  - `--print` is NOT one-shot. With `--input-format stream-json` and
    stdin held open, one child answered two prompts and reported the
    same session_id for both. That is what makes a session server
    possible at all, and it is why stop() ends stdin before signalling.
  - `--permission-mode` does NOT gate tool use under `--print`.
    `manual` ran Bash. `plan` ran Bash. No flag ran Bash. There is no
    interactive channel for a mode to hold a decision against.
  - `--tools <list>` DOES gate: the excluded tools are absent from the
    session outright and the model reports them unavailable.
  - the vendored frame profile is current — a live session emitted
    `system/thinking_tokens`, new since the corpus was recorded, and
    the catch-all `{type: system}` rule absorbed it. Zero raw rows.

Hence tool posture — `converse` / `read_local` / `unrestricted`, an
allowlist defaulting to read-only and recorded on the transcript's
lifecycle row. Not a second spelling of permission mode, which gates
approval of a call and needs a hub to route to. A denylist was rejected:
it fails open for every tool a future claude adds.

The loader question L2 deferred is answered by generating
`resources/agent_families.generated.json` from agent_families.yaml with
a Go test that fails when the two disagree, shipped as an
extraResource. The M2 launch argv and the frame profile are the same
data the hub uses; no YAML parser and no hand-kept mirror on the TS
side.

Two narrowings, recorded in the plan rather than papered over: the
cursor is {seq} and not discussion §9's {seq, epoch} (this log lives in
main's heap, so an epoch would be a comparison whose sides can never
differ — it arrives with L3b's on-disk log); and stampContextWindow
ports the engine-learned half but not Go's static model table, so a
local session's first turn has no context ring and every turn after it
does.

Verified: `engine.e2e.test.ts` spawns the real CLI and asserts the whole
transcript — session.init, the engine session id, the reply as typed
text, turn boundaries, dense seq, zero raw. Opt-in
(TERMIPOD_LOCAL_ENGINE_E2E=1) because it spends tokens, so CI skips it.
This is the first wedge in either desktop plan whose engine behaviour
was observed rather than inferred.

Also: 15 mutations of the new guards, all caught after two rounds — one
survivor was a clause shadowed by an earlier return (now reached by a
test), the other was dead code (now deleted). A third defect the tests
found: `Number(null)` is 0, so a null cursor silently meant "replay the
whole transcript".

go test ./... 31 pkgs green · desktop 823/823 · electron 530/530 +1
skipped · typecheck clean · 12/13 lints (openapi needs a venv this box
lacks) · token ratchet 65/phantom 0 · vite build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@physercoe
physercoe force-pushed the feat-vision-l3a-local-agent-service branch from 6d1db35 to 7a6129c Compare August 11, 2026 10:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant