eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

πŸ€– Shared remote Claude Code sessions β€” an Eleven group fronting a workstation

Date: 2026-08-05 Β· Status: accepted β€” open questions decided by Paul 2026-08-28 (see Β§ Decisions) Β· Scope: design only β€” no implementation yet

What this is

A group in Eleven becomes a shared front-end to Claude Code sessions running on a remote workstation (the motivating box: docs-t1). Multiple people share the sessions: the chat on the left is where you steer β€” @claude is an ordinary member of the chat β€” and the right-hand pane is a live view of the remote sessions. Agent activity is translated into Eleven primitives (typed events, cards, views, action lines, notices) rather than mirrored as a terminal.

The design uses almost entirely existing machinery β€” no new group type, no new E2EE exceptions, one new sidecar-shaped process and one new mini-app.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Chat                      β”‚  agents app (Interface pane) β”‚
β”‚                            β”‚                              β”‚
β”‚  Paul: @claude fix the     β”‚  β–Έ fix-login   πŸ“ messenger  β”‚
β”‚        flaky reminder test β”‚    running Β· turn 3          β”‚
β”‚  @claude [fix-login]:      β”‚    βš™ go test ./internal/bot  β”‚
β”‚    Found it β€” the ticker…  β”‚  β–Έ ops-poke    πŸ–₯ workstation β”‚
β”‚  Ana: @claude also check…  β”‚    waiting for approval      β”‚
β”‚                            β”‚    [Allow] [Deny]            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   the shipped Chat | Split | widget-bar layout, nothing new

Trust model (read this first)

  • The E2EE rule holds untouched. The server relays opaque ciphertext; every session.* event, prompt, approval, and view is encrypted under the thread key like any message. The server never learns what the sessions do, which repos exist, or that a box called docs-t1 is involved.
  • The bridge is a bot-model process (docs/bots.md): an E2EE member whose "device" is the workstation. It runs ON the box, beside the sessions, holds its member private key + the Anthropic access there, and is a pure outbound client β€” dials the instance and the Anthropic API, no inbound ports. Bot code never runs inside the lchat server process.
  • Group membership is the consent moment. Everyone in the group can steer sessions, which means everyone in the group has a lever on the box β€” filesystem, git credentials, whatever the bridge's unix user reaches. This is the operator-only dev-bot tier (docs/bots.md tier 4): chat input is untrusted input to a machine with authority. The bridge's config (below) enumerates exactly what that authority is; adding a member is granting it.
  • Flat authority for v1. Any member can prompt, approve, and interrupt. Roles (driver/observer) are layerable later as ordinary fold state (agents.role events) without schema changes.

The pieces

1. The bridge β€” lchat agents run

A sibling subcommand of lchat bot run, reusing the Go sidecar runtime (WebSocket client, key unwrap, reconnect, trigger plumbing β€” all existing). It runs on the workstation and:

  • joins the instance as the @claude member (one keypair, one roster row β€” the standard bot-member shape, not a charter persona);
  • owns the Claude Code sessions: spawns them headless (claude -p --output-format stream-json, resumable via --resume <session-id>; the Agent SDK is the same thing as a library), consumes the structured event stream β€” assistant text, tool_use blocks with full inputs, tool results, turn boundaries. No terminal emulator: nothing scrapes a TTY. The only terminal-ish work is stripping/basic-rendering ANSI colours out of Bash tool output (~50 lines, no cursor addressing exists in a -p session);
  • translates the stream into encrypted session.* events (vocabulary below) posted into the group thread, and reads members' prompt/approval/interrupt events back off the same thread;
  • serializes turns per session (a session is one-turn-at-a-time), queues prompts that arrive mid-turn, and attributes prompts by speaker β€” "Paul: fix the flaky test" β€” so the model knows it's talking to a room.

Config, per group the bridge serves:

groups:
  <thread-id>:
    roots:  ["~/github.com/elevenmessenger/messenger"]  # scoped sessions live here
    plain:  false                                       # whole-workstation sessions allowed?

The config is the blast radius, written down in one place on the box.

2. Sessions β€” two flavors, chosen at spawn

  • Scoped (default): bound to a directory β€” an allowed root, normally a fresh git worktree under it so parallel sessions don't trample each other. A scoped session gets that project's own .claude/ settings, CLAUDE.md, hooks, and permission config exactly as a human dev would β€” the repo's existing guardrails govern the remote session with zero new machinery.
  • Plain (opt-in via plain: true): no pinning β€” cwd = home, the whole workstation at the bridge's unix user's reach. The "shared computer" mode for ops-ish, cross-repo work. Off by default because it hands every member the entire box.

Flavor is visible everywhere: carried on session.spawn, badged on the session's view ("πŸ“ messenger @ fix-login" vs "πŸ–₯ full workstation") and on its chat card. Nobody wonders which kind of hands are on the box.

Birth is conversational: "@claude fix X" with zero live sessions spawns a scoped session in the group's default root; /claude new <name> names one; /claude new --plain <name> when the config allows. Session state persists on disk on the box (Claude Code's own session files), so a human on the workstation can claude --resume <id> from a real terminal after the bridge lets go β€” continuity is preserved; concurrent dual-driving is not pretended.

3. Chat β€” @claude plus focus-based routing

One bot member; the mental model is "a colleague in the chat who happens to be several sessions deep."

  • Routing: the target is the active view. Each member's client knows which session view they have focused in the right pane; a message to @claude carries target: <session-id> inside the encrypted payload. Focus is per-member β€” two people can steer two sessions from the same chat simultaneously.
  • Routing is visible at send time. When more than one session is live, the composer shows a chip β€” "β†’ fix-login" β€” tappable to retarget without touching the pane.
  • Replies pin the target. Every @claude bubble carries a subtle session tag; replying to a bubble routes to that session regardless of pane focus β€” this is also the mobile/chat-only answer. The tag works the other way too: tapping it focuses that session's view.
  • Degenerate cases collapse. One live session: everything routes there, no chip. Zero sessions: @claude answers as the workstation ("no sessions running β€” want one?") and a prompt spawns one.

Output posts as @claude with the session tag on each bubble β€” the feed reads as one assistant working several desks, which is the truth.

4. The right pane β€” one app, one view per session

  • One agents mini-app (web/static/apps/agents.js), chat-placed (places: ["chat"]), added to the group like any app β€” one glyph in the widget bar, rendering in the standard Interface pane. The shipped Chat | Split | widget-bar toggle IS the "chat left, session right" layout; nothing new is built for it.
  • Its Interface is the fleet board: one card per session (name, flavor badge, status, current activity line, waiting-on-approval chips), folded from durable session.* events.
  • Per-session status rides the views engine (views.js, view.create/view.update from the bridge): one engine view per session. Views are declarative SCHEMA + DATA interpreted by both web and the Swift twin engine, so session boards render on iOS/Mac with no per-feature Swift. Chips on @claude's replies open the session's view; /views lists them.
  • Fidelity is "board + drill-in" (option c). Tap a session to focus it: the durable board plus a live tail β€” tool-call chips ("Ran go test ./..."), collapsed monospace output, file-edit lines, assistant prose β€” streamed as ephemeral events (relay-only frames, the existing primitive): live viewers see the chatter; nobody replays it; the encrypted log stores only what deserves permanence. The views engine cannot host the tail (views are durable documents; streaming through view.update would bloat the log) β€” the tail is the app's own render over the ephemeral stream, web-first.

5. Event vocabulary

Durable (thread log; the fold state every member converges on):

| event | payload sketch | why durable | |---|---|---| | session.spawn | id, name, flavor, root/worktree, by | fleet board exists offline | | session.status | id, state (running/idle/waiting/dead), activity line | board truth | | session.prompt | id=target, text, by | the steering history IS the log | | session.turn | id, assistant text (final, per turn) | the conversation with the agent | | session.result | id, summary card (files changed, tests, links) | milestones as cards | | session.approval | id, request (tool, input digest), state, decided-by | audit trail of authority used | | session.end | id, reason, by | lifecycle honesty |

Ephemeral (relay-only; live tail + presence):

  • session.tool β€” tool_use started/finished, truncated output chunks
  • session.stream β€” assistant text deltas while a turn is in flight
  • typing-style "session is thinking" indicators

Streaming into chat has no partial-message primitive, so @claude's chat bubbles are post-per-turn (the durable session.turn), with the live delta visible in the drill-in tail instead β€” no post-then-edit churn in the feed.

6. Approvals

Claude Code's permission gate surfaces as a chip row β€” on the session's board and as a @claude chat card: "fix-login wants to run git push β€” [Allow] [Deny]". A tap emits an encrypted session.approval decision event; the bridge reads it and answers the gate. Flat authority: first decision wins, attributed in the event and shown in the feed ("Ana allowed git push"). Notices/push ride the existing E2EE notify path, so "waiting for approval" reaches pockets.

7. Liveness

Derived, never declared (the docs/bots.md pattern): the bridge heartbeats (ephemeral presence + a periodic durable status touch); a session whose bridge is gone must read as dead/stale in the board β€” "last seen 4m ago" β€” not linger as "running". A dead bridge silently eating prompts is the worst failure mode; staleness is always rendered from event age, client-side.

What the server gains

Nothing. Every piece rides existing surfaces: bot membership, thread events, ephemeral frames, the views engine, the mini-app framework, notify/push. The bridge is a new subcommand of the existing binary; the app is one new file under web/static/apps/.

Hard parts, named

  • The approval round-trip β€” latency between the gate firing and a human tapping Allow; the session blocks meanwhile. Mitigations: push notices, per-session auto-allow lists (later; config-side, never chat-grantable).
  • Log discipline β€” a busy session emits a lot; the durable/ephemeral split above is the design's load-bearing wall. Anything high-churn must be ephemeral or it bloats every member's encrypted log forever.
  • Prompt-injection containment β€” session output is model output over untrusted repo/web content; it lands in chat as content, never as commands. The bridge only ever acts on typed events from members.

Deferred (deliberately)

  • Driver/observer roles β€” layer later as agents.role fold state.
  • Fleet-across-groups overview β€” a "you"-placed app aggregating every workstation group; needs nothing new from this design.
  • Attaching to a human's interactive TUI session β€” ruled out for v1 (tmux-scraping + keystroke injection fights the one-writer TUI; the honest handoff is --resume after the bridge lets go).
  • Auto-allow permission profiles per session flavor.
  • Native drill-in tail β€” boards are native-free via the views engine; the live tail ships web-first.

Decisions (Paul, 2026-08-28)

  1. Naming: agents stands. lchat agents run, apps/agents.js, session.* events, and the member reads as @claude.
  2. The prompt IS the chat message. One session.prompt event, two projections β€” the member's visible bubble in the feed and the bridge's steering input, tagged with the target. No separate chat.msg.
  3. Closing a session closes its worktree. Sessions are closable from within Eleven, and session.end is the cleanup moment: the bridge removes a scoped session's worktree then. The branch (any committed work) survives worktree removal as an ordinary git branch; only the checkout goes.