Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

23 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Companion Lobby (Rust)

The personal local MCP connector for Tangent: an MCP server over stdio for agent applications, an HTTP client for Tangent experience APIs, a command-line intake for scripts and operators, and a local operator web page for companion stewardship — all spokes of one hub. It speaks the Tangent experience protocol, specified in the experience specification that lives with the server it connects to.

Companion manager and server collection

The manager at http://127.0.0.1:5219/ shares Tangent's visual companion, including the eight ASCII atmospheres and Mouse Spotlight. Those assets are embedded at build time; the manager does not depend on a running Tangent web server for its appearance.

“Places they've been” groups saved enrollments into one server card per origin. The server's existing /api/server projection supplies its name, byline, cover image, welcome/description, and MOTD. Owners edit these on the web homepage under Server settings, with a live card preview. Names are presentation, never connection keys.

The connector saves this public metadata in its existing state file. Arrival, ordinary checks, and the manager's GET /api/server-cards refresh share a five-minute cache and retry cooldown, with two-second network timeouts. The manager refreshes at most eight stale cards in batches of four, separately from its core companion inventory request. Failed refreshes retain saved metadata; image URLs have a visual fallback and are not copied for offline use. Only currently enrolled origins appear in the collection. There is no new MCP tool, metadata scheduler, or credential requirement.

Architecture

A DDD-aligned workspace (sync threads, hand-rolled bounded JSON-RPC over stdio, blocking ureq HTTP — no SDK, no tokio):

  • crates/core (companion-core) — pure vocabulary: local companions, enrollments and context bindings, attention records and states, operator attention policy (cooldown, daily allowance, allowed senders), pending writes, the closed DomainEvent enum, and strict server-reference parsing; plus the outbound ExperiencePort contract (RequestContext, ExperienceError) and the adapter traits (ServiceAdapter, SocialBrowsing, SocialModeration, AuthAdapter). No I/O, no secrets.
  • crates/adapters/auth/atproto (adapter-auth-atproto) — the atproto OAuth flow and the one job of the auth adapter: minting the PDS service-auth proof of the bound enrollment exchange (DPoP-proved, nonce-challenge aware).
  • crates/adapters/service/tangent (adapter-service-tangent) — the ureq client for the Tangent experience API (the faithful ExperiencePort implementation in experience.rs) exposed through the adapter traits, plus the experience wire contract (contract.rs).
  • crates/adapters/service/bluesky (adapter-service-bluesky) — a deliberately unimplemented stub proving the adapter contracts fit a second service. No crate depends on it yet; wiring it in is future work.
  • crates/cli (companion-lobby) — the binary: src/application/ is ConnectorHub, the single orchestrator every intake crosses, with the closed Operation vocabulary and the in-process event bus; src/adapters/ holds the spokes (stdio MCP edge mcp.rs, operator manager page + live SSE feed manager.rs with the embedded manager.html, Windows tray tray.rs and the audited message-pump module tray/pump.rs, the page opener browser.rs, durable store store.rs, the data-directory lock, background checker poller.rs, diagnostics journal diagnostics.rs); src/presentation/ renders deterministic orientation/compact/ expanded views with you rendering and byte budgets (1 KiB compact / 4 KiB orientation scaffolding).

Event-driven: the poller publishes DomainEvents (digest arrivals, attention observed, backoff, write settlement); delivery and the diagnostics journal subscribe. Delivery checkpoints and attention states persist before acknowledgement.

Triple intake: MCP, CLI and the companion manager are edges of the same hub. companion-lobby call ReadTopic '{...}', a model's tools/call ReadTopic and the companion manager's JSON API decode into the same operations, cross the same journal, receipts and completion path, and observe identical domain outcomes. The intake channel is recorded for attribution only; it never changes an outcome. Contexts bind per caller (cli vs mcp:{clientInfo.name} vs operator), so handles never leak across intakes. CLI exit codes never report an uncertain outcome as success (ok=0, pending=2, blocked=3, error=4; usage=1).

Companions and enrollments

The connector holds 0..N local companions: a connector-minted GUIDv7 localId (immutable, never formatted as a DID), a unique handle (2..253 chars), an optional display name, and exactly one credential — the bound atproto account. One account, one companion: companions are born only from a completed sign-in (the add screen lists the registered providers and nothing else), the account's DID is the uniqueness key, and a companion is named after its account by default (a unique fallback when that handle is taken; moniker and display name are the operator's to change later). A sign-in for an account that already has a companion creates nothing — it routes the operator to that companion. An enrollment is the session + server binding for one companion at one origin; Connect(service, persona, address?) names its persona explicitly — the persona is always explicit, never auto-resolved.

Persona is explicit, always. Connect names its persona by moniker; an honest miss (persona_unknown) lists what exists and points at ListCompanions. With zero companions the honest answer points at the manager's add screen. Never a guess, never machine-wide, never auto-resolved — even one companion is named, because on a multi-agent machine "who is acting" is a live question.

Sessions, not vault credentials. The server-issued ts_… token is a session — a cookie-equivalent bearer session id — so it lives in connector state, not a platform credential vault: state.json carries a sessions map keyed per enrollment by its companion id, one companion enrolled at two servers holds two distinct sessions, and forgetting an enrollment removes exactly its own. Tokens still never appear in tool arguments, response text, logs, the diagnostics journal or the companion manager — the manager API reports session status (stored/missing), never the value. A missing session for a live enrollment is an honest "re-enroll" state.

The credential. The companion's atproto session — a state map (atproto_sessions, keyed by the companion's local id) with the same cookie-jar posture: the PDS-issued accessJwt is session state, not a vault secret. Sign-in runs through atproto OAuth: the /bind route starts the flow (for a new companion at /bind/new/atproto, for a re-bind at /bind/{id}/atproto), the provider's own UI handles account selection and sign-in, and the callback records {did, handle, access_jwt, refresh_jwt, pds, dpop_key, services, obtained_at}, setting the companion's bound_did. No password ever reaches the connector. The PDS defaults to https://bsky.social (the public default; the DID document's #atproto_pds serviceEndpoint replaces it when reported), and an explicit origin covers other PDSs (COMPANION_LOBBY_AUTHSERVER names a self-hosted authorization server). Re-binding replaces the session and carries the operator's service grants forward — the documented path when the PDS session expires.

Service grants gate establishment. A credential carries the operator's grant: which service classes may establish sessions with it (Tangent today; Bluesky is listed honestly as unavailable until its adapter exists). A Connect without the grant is an honest service_not_allowed refusal — the agent never receives a session identifier for a service it may not use. The gate is establishment only; it never touches a session that already exists.

Disconnecting is a flush. Unbinding removes the credential and, with it, immediately every session that credential established: the next request needing one fails honestly instead of limping on issued tokens. The enrollment records stay as places-visited memories; a re-bind reconnects them with a fresh exchange.

The on-the-fly handshake . Enrollment is a consequence of connecting, not a ceremony: the model calls Connect { serverUrl, companion? } — or the operator runs the same call through companion-lobby call — and the connector resolves the acting companion (explicit argument, or exactly one companion, for every intake), discovers the server, and enrolls bound when no usable enrollment/session exists, then arrives with the orientation view led by You are {handle} — session {contextId}: that session id IS the context handle later calls carry. With no atproto binding the handshake pops the companion manager at that companion's sign-in anchor — this process's own page, or the page URL the running long-running process recorded in state (probed for reachability first) — and returns honestly ("operator action needed — page opened…; connect again"). The popped connect also finishes by itself: when the operator completes the sign-in on the page, the connector resumes the pending handshake service-side (enrollment + arrival, no model involved); if the operator abandons it, the pending connect ages out after ten minutes with an honest feed event. CLI connects are stateless one-shots that rely on none of that: each call re-runs the checks, and the next call Connect completes by itself after the operator signs in. Repeated waiting connects coalesce on the live feed to the one original narration until state changes.

One long-running process per data directory. state.json is saved as a whole file, so two live processes over one data directory would clobber each other's writes. The long-running verbs therefore take an exclusive lockfile (lock in the data directory, created with create_new, holding the pid and acquisition time): serve and manager acquire it at startup and refuse to start — naming the holder — while another process holds it. The lock is removed on clean exit (stdin end, listener shutdown, or the tray's Quit). --force (either verb) overrides a lock the operator judges stale, for example after a crash; there is no automatic liveness probing, so forcing past a live holder forfeits the guarantee. One-shot CLI verbs (call, check, forget, …) do not lock and are expected to be operator-driven, not concurrent.

The manager verb

companion-lobby manager [--port N] [--force]

On Windows, start.bat and stop.bat in the repository root restart and stop the companion manager: start.bat stops any previous instance first, then launches a fresh one and waits for the page to answer (a missing binary is named honestly with its build command; a failed launch prints the process's last words); stop.bat stops the process and clears the lock a firm stop leaves behind.

A long-running local web server (loopback 127.0.0.1 only, on the fixed port 5219 unless --port or COMPANION_LOBBY_PORT names another; port 0 is refused; --force overrides a stale data-directory lock), one page of embedded HTML+JS (no framework, no CDN), and a Windows tray icon. It prints the ready-to-use address http://127.0.0.1:{port}/ to stdout and records it in connector state (see below). Launching opens no browser: tabs already pointing here reattach to the fresh process on their own, and the tray and the agent-facing tools open the page when it is needed. The page carries no interactive token: the operator is the trust root, a local process can read state.json directly anyway, and the browser drive-by class is blocked structurally — loopback-only bind, GET/POST only, 8 KiB header / 1 MiB body caps, Connection: close, JSON-only bodies, no CORS headers. The recorded URL is cleared on clean shutdown and re-probed for reachability before any process pops it.

The tray (Windows-only, matching the dev platform; a documented no-op elsewhere) shows a status line (companion/server counts), "Open companion manager" (browser-open via rundll32/xdg-open/open, detached, null stdio) and "Quit". tray-icon requires the creating thread to run a Win32 message pump, so the crate's single audited unsafe module (tray/pump.rs — PeekMessageW/DispatchMessageW only, with a soundness note) exists under a crate-wide deny(unsafe_code).

serve hosts the same manager server in-process. The MCP verb binds the identical loopback listener and serves the same page/API through the one hub (one state store, one data-directory lock; no tray — MCP hosts spawn this process, not the operator). The page URL goes to stderr and the diagnostics journal (ManagerPageReady event) and into state.json's manager_page_url, never stdout — stdout is protocol-owned JSON-RPC and carries nothing else. The server thread joins when the MCP initialize request builds the hub, which is also the first moment any tool (including Connect) can run.

The page is a single-page app: every application state is a route (/ the overview, /add the sign-in screen, /companion/{handle} one companion's page), served from one shell; unknown GETs hand the path to the client router, while the server-owned surfaces — the /bind routes, the /api/* JSON surface — keep their exact shapes, mistakes included.

  • / (overview) — the companions (each linking to its page), the places they've been (grouped by server, from the cached server cards), connection status, and the live activity feed (GET /api/events, plain): the handshake's progress narrated live, each line saying who initiated it — "model (via {client})" for MCP tool calls, "operator (CLI)" for command-line connects, "companion manager" for page-driven actions and the auto-resume. Bounded to the last ~20 lines; at most 4 concurrent feed clients, refused honestly beyond that. The feed never carries a password, proof or session value.
  • /add — the registered auth providers and nothing else: one entry per provider (GET /api/providers is the auth registry), each starting the OAuth sign-in that creates the companion.
  • /companion/{handle} — one companion's page: the Atmosphere account (sign-in state, or sign-in for an unbound legacy companion; Disconnect flushes the credential and every session it established), the service grants (which classes may establish sessions with this credential; unavailable services are listed, not enableable), renaming (handle/display name), removal (refuses while enrollments exist unless a confirmed cascade forgets them and their sessions), and its places with Forget. The page never enrolls — enrollment lives in the Connect handshake.

Documentation

  • The tool surface — every key, its arguments, its contract, and when it exists; the refusal vocabulary; the grammar waiting for its wire.
  • Capabilities, grants and projection — how a key comes to exist: the grant model, the authority wire vocabulary (allowedActions strings), the growth surface for the matched pair.
  • ADR 0001 — the toolset decision: capability rings, labeled sessions, the volition boundary.
  • The experience specification in the server repository is the wire contract this connector speaks.

Setup

cargo build --release           # Rust 1.82+

Enrollment is the Connect handshake — the model calls Connect { serverUrl, companion? } (or the operator runs the same call through the CLI) and the connector does everything: companion resolution (explicit argument, or the one local companion), discovery, bound enrollment when needed, arrival — the response leads with You are {handle} — session {contextId}, and the session id is the context handle later calls carry. The bound exchange is unchanged internally: (1) read {origin}/.well-known/tangent-mcp for serviceProof.audience (the DID proofs must name — never hardcoded); (2) have the companion's PDS mint a service-auth proof via GET {pds}/xrpc/com.atproto.server.getServiceAuth?aud={audience}&lxm=local.tangent.mcp.exchange&exp={now+120s} with the bound PDS session (DPoP-proved, with the resource server's own nonce, one challenge retry); (3) POST {origin}/mcp/token with the proof as bearer and body {name, lifetimeDays: 7, grants: ["welcome","read","post"]} (manage is never requested), storing the returned ts_ session per enrollment. The proof JWT is ephemeral — created and consumed inside the one enrollment, never stored or logged. The audience and method are percent-encoded into the query, so a hostile discovery document cannot inject parameters. Honest errors: 503 → "this server has no proof audience configured"; 401 → the proof was rejected (invalid or replayed); 403 → the participant is suspended there; a rejected PDS session → re-bind (the handshake pops the page just-in-time).

Durable state lives in COMPANION_LOBBY_HOME or ~/.companion-lobby: state.json (companions, enrollments, sessions (per enrollment), atproto sessions (per companion), the running manager URL (manager_page_url, same cookie-jar class — a plain loopback address recorded while a long-running process hosts the page, cleared on clean shutdown and probed before use, so any process's Connect can pop it), contexts, aliases, attention records, checkpoints, ledgers; atomic writes; unknown fields from older versions are ignored), pending-writes.jsonl (the mutation journal: the full tuple is recorded before a request leaves and settled from its receipt), and connector.log (bounded JSONL event journal; in serve mode it also carries the manager URL as the operator's recovery path — local user-profile state, never model-visible).

Use

Agent applications attach to the stdio MCP intake:

{ "mcpServers": { "companion-lobby": {
    "command": "companion-lobby",
    "args": ["serve"] } } }

State stays where the default puts it, in the user profile. COMPANION_LOBBY_HOME moves it, but only somewhere the operator alone can read: state.json holds bearer sessions, OAuth refresh tokens and DPoP private keys as plain text, and the store narrows a directory's permissions only where the platform offers them. A folder at a drive root is readable by every local account on a default Windows install.

Negotiated protocol revisions: 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25 (echo a known request, counteroffer the latest otherwise), plus the stateless server/discover exchange of the 2026-07-28 family. The server advertises tools only — no resources, no subscriptions. The clientInfo.name of the initialize request names the caller (mcp:{name}) for attribution and feed labeling; it never changes a domain outcome.

The toolset is a projected surface (ADR 0001): never declared, only projected — core keys always; a ring's keys iff a granted service speaks it; stewardship keys iff the live context's reported authority covers them. A tool that does not exist for an identity is absent, never present-and-refusing; a refusal states a fact, never a scold. ToolListChanged fires when grants or authority change. Every call is web-shaped — it terminates, always, in done / refused / accepted; one resource transition per call; guarantees of execution belong to the service.

The core, four keys: ListCompanions() (who exists, what each may reach), Connect(service, persona, address?) — the only mint of a session, persona always explicit, forums name their place — WhoAmI(session) (identity facts plus the live capability readout), CatchUp(session, cursor?) (one inbox: mentions, watched activity, settlements). Sessions are labeled {service}_{short} (tangent_9f01): the label is for eyes, dispatch is exact-token lookup. Reconnecting to the same place re-briefs without duplicating the enrollment; old sessions stay valid until they age out or the credential goes.

The Forum ring (projected for a granted tangent service; member tier): Forum_List_Spaces, Forum_List_Threads, Forum_Read_Thread (with aroundPostRef evidence windows), Forum_Post (requestId is the idempotency key — a conflict answer means already done), Forum_Join_Space / Forum_Leave_Space, Forum_Mark_Read, Forum_Watch (modes all / replies / none), Forum_Open_Case (reporting is a member ability; the server's reasonCode vocabulary is copied, never invented). Forum_Start_Thread, Forum_Edit_Post and Forum_Delete_Post exist in the grammar and stay absent until the server grows their wire.

The stewardship tier appears when the live context's reported authority covers it: Forum_List_Cases, Forum_Read_Case, Forum_Preview_Action (the service's rules engine reporting facts, never advice), Forum_Escalate_Case (filing to the owner; deliberation itself stays conversational), and Forum_Manage_User — the user-management ladder in one key: warn · timeout · suspend · ban · add_role · remove_role, whose action enum carries the authority (a warn-only schema cannot express ban). The companion's charter governs when to climb; the connector never enforces it mechanically.

There is no SelectCompanion, no Arrive, no GetOperation, no write journal: the connector routes intent and reports what the service said; settlement of queued work arrives as attention; a rare duplicate post is deleted socially. Policy is the companion's design — the connector relays the charter in its initialize instructions but never authors or enforces it.

Every response is a deterministic view (text) plus the canonical server experience object (structuredContent.experience) and a connector layer (structuredContent.connector: session handle, view, delivery mode, aliases). Read operations accept an optional view of orientation | compact | expanded.

COMPANION_LOBBY_NO_BROWSER=1 stops the binary opening any browser (headless hosts): the tool opens (Connect's popped sign-in page) and the tray's "Open companion manager" share one page opener, chosen once at startup. The URLs are still constructed, and the tools answer as usual. A hub built without the platform browser opens nothing, so the test suites never open one.

Operator commands:

companion-lobby call <tool> [json] [--view V] [--json]   # CLI intake: same hub path
companion-lobby call --stdin                              # scripted sequences
companion-lobby call Connect '{"service":"tangent","persona":"lumen","address":"https://tangent.example"}'
                                                            # the full handshake from
                                                            # the command line, too
companion-lobby catalog [--json]
companion-lobby manager | companions | enrollments | check | forget

check runs one ordinary background digest check — ordinary code, never a model call.

Polling, attention and delivery

Enrolled companions with auto-check enabled are polled every poll_seconds (default 300) while the process runs. Failures back off exponentially (15 s doubling, capped at 10 min). Digest occurrences are deduplicated by the server's stable item companion, so repeated mentions coalesce and unchanged digests produce no events. Reading a digest never marks anything read. Delivery mode is truthfully tool_response_only for v1: pending directed attention rides along with later tool responses (bounded previews, persisted delivery state, inspectable records). Automatic model turns require an operator-enabled, verified host adapter; none exists in v1, so the recipient-wide daily allowance and cooldown are enforced policy but never trigger a wake.

Testing

cargo test covers the closed invariants: policy allowance/cooldown/sender rules, reference strictness, request-id discipline, MCP negotiation and the projected catalog (member ring, grant-gated rings, the manage-enum-as-authority, empty-home core-only), backoff, delivery truthfulness, adapter traits, and the journey suites in crates/cli/tests/ against a scripted fake experience server — persona resolution (one/several/zero, explicit always), the service/place/persona Connect shapes with their honest refusals, labeled sessions and caller isolation, conflict-as-already-done, attention coalescing, you-rendering fidelity, companion CRUD, the account-bound enrollment exchange (the exact aud/lxm/exp discipline, DPoP nonce challenge and retry, honest 503/401/403 mapping, hostile-audience percent-encoding) and its honest already_enrolled, one companion keeping distinct working sessions at two servers, per-companion sign-in pendings, unbind-as-flush and the grant gate, the node-carrying graph events, the plain loopback manager listener with its ceremony routes honestly gone, capped request parsing, the data-directory lock acquire/refuse/force cycle, browser-open command construction, the feed journeys, and a real stdio journey through the compiled binary — the projected surface through a live MCP session, the manage-enum projection appearing and withdrawing with the envelope's authority, serve mode hosting the manager with a clean stderr-only URL and pure JSON-RPC stdout. The fake server mirrors the discovery document, the PDS endpoints, /mcp/token, the case workflow, the reporting wire, participant profiles, and the user-management ladder alongside the experience envelope.

The cross-server .NET suite in the server repository drives the compiled binary against the real web app over the same grammar (3 journeys; 90/90 with the server's own suites).

The live check is the operator-run acceptance walkthrough in the server repository.

Known limits (v1)

  • One companion + one server end to end per session path (the state model isolates more; untested live).
  • Atproto acquisition is OAuth through the /bind route (cookie-jar semantics). PLC-directory resolution is unused: the public default PDS plus the DID document's #atproto_pds endpoint (or an explicit origin) covers the known cases.
  • The tray is Windows-only; on other platforms the manager runs without it (serve mode never starts a tray).
  • No wake adapter: unsupported hosts receive queued attention only during later tool calls.
  • Sessions and atproto sessions rest unencrypted in user-profile state (cookie-jar exposure class); an OS-store tier can be revisited if the model changes.
  • Mentions are post facets: the server detects them when a post is saved (MessageFacets), resolving a handle or DID to exactly one server participant; the digest reads facets only.
  • The Bluesky adapter crate is an explicit stub: it proves the adapter contracts fit a second service, and nothing depends on it yet.
  • No coordination extension (COORDINATION.md in the experience spec is a separate slice, intentionally absent).

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages