feat(desktop): the Companion runs a claude session locally, hub-free (vision-parity L3a) - #544
Open
physercoe wants to merge 1 commit into
Open
feat(desktop): the Companion runs a claude session locally, hub-free (vision-parity L3a)#544physercoe wants to merge 1 commit into
physercoe wants to merge 1 commit into
Conversation
8 tasks
…(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
force-pushed
the
feat-vision-l3a-local-agent-service
branch
from
August 11, 2026 10:34
6d1db35 to
7a6129c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ownagent_eventsshape, 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.
--printis one-shot, so a session needs respawn-per-turn--input-format stream-jsonand stdin held open, one child answered two prompts and reported the samesession_idfor both. That is what makes a session server possible at all — and whystop()ends stdin before signalling.--permission-modegates tool use--print.manualran Bash.planran Bash. No flag ran Bash. No control frame,permission_denials: []. There is no interactive channel for a mode to hold a decision against.--tools Read,Glob,Grepremoved Bash from the session outright; the model reported it unavailable and called nothing.system/thinking_tokens— a subtype no rule names, new since the corpus was recorded — and the catch-all{type: system}rule absorbed it. Zerorawrows 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
lifecyclerow so it states what the agent was permitted to do:converse--tools "")read_localRead,Glob,Grep— defaultunrestrictedNot 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.jsonis every family marshalled fromagent_families.yamlby 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
{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.stampContextWindowports 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.tsspawns the real CLI and asserts the whole transcript —session.init, the engine session id captured, the model's reply as typedtext, turn boundaries, denseseq, and zerorawrows. 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 plainnpm test.15 mutations of the new guards, all caught — after two rounds. Both first-round survivors were worth the trip:
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 itwatchingguard for a close-before-acquire path that cannot happen, sinceacquire()runs synchronously beforesubscribe()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-openapineeds a venv this box lacks; no spec touched) · token ratchet 65 / phantom 0 · vite build clean.Not done here, and not silently
The
companionModehub/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 sessionbutton and the empty-state copy are unrendered.🤖 Generated with Claude Code