diff --git a/.changeset/ai-harness-agent-frames.md b/.changeset/ai-harness-agent-frames.md new file mode 100644 index 000000000..a09d2ddc9 --- /dev/null +++ b/.changeset/ai-harness-agent-frames.md @@ -0,0 +1,20 @@ +--- +'@xnetjs/devkit': minor +'@xnetjs/plugins': minor +'@xnetjs/cli': patch +--- + +Structured agent frames for the bridge (exploration 0392). The agent bridge can +now stream a turn as structured `AgentFrame`s — tool calls, tool results, +permission requests, cost, and session id — over a new framed endpoint +(`POST /v1/agent/stream`) instead of only text. `@xnetjs/devkit` exports the +`AgentFrame` vocabulary, `foldStreamJsonFrames`, and `streamTurnFrames` on the +Claude streaming agent; the existing OpenAI-compatible `/v1/chat/completions` +endpoint is unchanged. The bridge session map can now be made durable +(`fileSessionPersistence`) so `--resume` sessions survive a daemon restart, and +`xnet bridge serve --agent claude` wires this automatically. + +`@xnetjs/plugins` adds a models.dev catalog consumer (`fetchModelsDevCatalog`, +with a vendored snapshot fallback for offline/outage) for cloud-key and local +model pickers, and now sends OpenRouter app-attribution headers +(`HTTP-Referer` / `X-Title`) on OpenRouter-bound requests. diff --git a/docs/explorations/0392_[_]_AI_HARNESS_ARCHITECTURES_AND_XNET_CONNECTIVITY.md b/docs/explorations/0392_[_]_AI_HARNESS_ARCHITECTURES_AND_XNET_CONNECTIVITY.md new file mode 100644 index 000000000..564537688 --- /dev/null +++ b/docs/explorations/0392_[_]_AI_HARNESS_ARCHITECTURES_AND_XNET_CONNECTIVITY.md @@ -0,0 +1,686 @@ +# AI Harness Architectures And xNet Connectivity + +How do other AI harnesses — OpenCode, T3 Chat/T3 Code, Claude Code and the +Claude desktop app, OpenAI Codex (CLI, desktop, SDK), Gemini CLI — connect to +third-party AIs? How does xNet do it today? And which harness architecture +offers the most flexibility for a product that, like OpenCode and T3, wants to +support many agents and many providers at once? + +## Problem Statement + +xNet shipped 0391 ("xNet as the daily-driver AI interface"): a hardened +loopback bridge that spawns the user's own Claude Code CLI, a six-tier +connector ladder (managed, bridge, cloud-key, local-server, webllm, +prompt-api), OpenRouter PKCE, and chat persistence as Channel nodes. It works, +but it was built bottom-up from xNet's constraints, not top-down from the +industry's converging architecture. Meanwhile 2025–2026 produced a visible +convergence: catalog/protocol/auth separation (OpenCode + models.dev + Vercel +AI SDK), agent-as-subprocess protocols (Codex `app-server`, Zed's ACP), and a +hard ToS line from Anthropic that killed OAuth-token reuse and blessed exactly +the pattern xNet chose. This exploration maps the landscape, locates xNet in +it, and recommends the harness architecture that keeps the most doors open. + +## Executive Summary + +- The industry has converged on a **five-concern separation**: model _catalog_ + (models.dev), wire _protocol_ (Vercel AI SDK / OpenAI-compatible), + _auth_ (keys in local stores, sanctioned OAuth, PKCE-provisioned gateway + keys), _transport aggregation_ (OpenRouter, Zen, LiteLLM), and _harness + composition_ (spawn-the-vendor-CLI, JSON-RPC agent servers, ACP). +- There are **two distinct things to connect to**, and conflating them is the + central design mistake to avoid: **raw models** (a stateless + completion/streaming endpoint you build an agent loop _around_) and + **agents** (a stateful harness with its own loop, tools, sessions, and auth + that you drive _as a client_). OpenCode is the best-in-class _model_ + harness; T3 Code, Zed, and Codex desktop are best-in-class _agent_ clients. + xNet needs both lanes, and already has both — half-wired. +- **Anthropic's Jan–Feb 2026 enforcement** settled the auth question: + consumer OAuth tokens outside official surfaces are banned ("Using OAuth + tokens obtained through Claude Free, Pro, or Max accounts in any other + product, tool, or service — including the Agent SDK — is not permitted"); + spawning the user's own installed CLI remains the defensible pattern. xNet's + 0391 bridge is on the right side of the line; T3 Code independently reached + the identical architecture. +- **Codex went the opposite way**: `codex app-server` is a documented, + versioned JSON-RPC 2.0 embedding surface explicitly sanctioned for + third-party products, with ChatGPT-plan auth handled inside the harness, and + third-party model providers first-class in `config.toml`. +- **ACP (Agent Client Protocol)** is the emerging cross-vendor standard for + UI ↔ agent decoupling — JSON-RPC over stdio, streamed session updates, + server-initiated permission requests. Gemini CLI is the reference agent; + JetBrains, Neovim, Emacs and 25+ agents adopted it; there is an adapter + ecosystem and a registry. It is the strongest candidate for xNet's internal + agent-facing seam. +- **Recommendation in one line**: keep the connector ladder and the + spawn-own-CLI bridge (both validated by the market), but (a) upgrade the + bridge's app-facing wire from text-only OpenAI SSE to an ACP-shaped event + stream so tool calls, approvals, and sessions become visible in the xNet UI; + (b) drive Codex via `app-server` instead of one-shot `codex exec`; (c) + externalize the model catalog to models.dev; (d) unify the panel's dormant + tool system with the bridge behind one agent-loop interface. + +## Current State In The Repository + +Everything below is shipped code (0391 is checked off). + +### The connector ladder + +`packages/plugins/src/ai/connectors/types.ts:13` defines the organizing +abstraction — a six-tier ladder: + +```ts +export type ConnectorTier = + | 'managed' // xNet Cloud metered AI (hub → OpenRouter gateway) + | 'webllm' // in-tab WebGPU model + | 'local-server' // Ollama / LM Studio over localhost + | 'prompt-api' // Chrome built-in Gemini Nano + | 'cloud-key' // BYO cloud API key (Anthropic / OpenAI / OpenRouter) + | 'bridge' // local daemon driving a Claude Code / Codex subscription +``` + +`packages/plugins/src/ai/connectors/detect.ts` probes all tiers concurrently +(`detectConnectors()`, line 140) and ranks them (managed=0, bridge=1, +cloud-key=2, local-server=3, webllm=4, prompt-api=5). +`apps/web/src/workbench/views/ai-chat-connector.ts:89` maps the chosen tier to +an `AIProviderConfig`. Keys and tokens live in localStorage under `xnet:*` +(`AI_CHAT_STORAGE_KEYS`, line 34); BYO keys never leave the browser; the +managed tier carries no key (the hub injects the tenant credential +server-side). + +### The provider layer + +`packages/plugins/src/ai/providers.ts` is a hand-rolled provider abstraction — +xNet's equivalent of the Vercel AI SDK's `LanguageModel`: + +```ts +export interface AIProvider { + readonly name: string + generate(prompt: string): Promise + generateWithTools?(request: AIGenerateRequest): Promise + stream?(request: AIGenerateRequest): AsyncIterable + getCapabilities?(): AIModelCapabilities +} +``` + +Concrete classes: `AnthropicProvider` (line 349, direct Messages API with the +`anthropic-dangerous-direct-browser-access` header for BYO-key CORS), +`OpenAICompatibleProvider` (line 464 — OpenAI, OpenRouter, Ollama `/v1`, +LM Studio, vLLM, _and the bridge_), `OllamaProvider`, `ManagedProvider` +(line 1093, hub-mediated metered OpenRouter with typed `AiBudgetError` on +402), and a capability-based `AIProviderRouter` (line 789). A full tool-use +type system exists (`AIToolSpec`, `generateWithTools`, tool-call stream +chunks) plus a tool-calling-fidelity gate +(`ToolCallingFidelity = 'reliable' | 'weak' | 'none'` → +`WriteMode = 'agentic' | 'propose-only'`, `connectors/types.ts:26`) — **none +of it wired into the panel yet**; the Assistant dock is Phase-0 read-only. + +### The bridge (0391's core) + +Three files in `packages/devkit/src/` plus the CLI command: + +- `bridge-server.ts` — loopback HTTP daemon on `:31416`. `GET /health` + (unauthenticated, for ladder detection), `POST /v1/chat/completions` + (OpenAI-compatible, SSE streaming), optional `POST /run`. Hardening from + 0289: exact loopback `Host` check (anti-DNS-rebinding), origin allowlist + that never reflects `*`, constant-time pairing-token check, + `Access-Control-Allow-Private-Network: true` for Chrome Local Network + Access. +- `chat-agent.ts` — the `ChatAgent` port with two shapes: one-shot + `cliChatAgent` (line 54, flattens the whole conversation into + `claude -p ""` / `codex exec`, 120 s timeout) and the 0391 + streaming, session-aware `cliStreamingChatAgent` (line 259, **Claude + only**). No Agent SDK: it spawns the installed CLI with + `--output-format stream-json --include-partial-messages --verbose +[--resume ]` (`agent-launch.ts:64`) and folds NDJSON via the pure + reducer `reduceStreamJsonLine()` (line 178). +- `bridge-sessions.ts` — the fingerprint→resume map: SHA-256 over + user/assistant content only (`transcriptKey()`, line 40), so the stateless + OpenAI protocol can ride durable `--resume` sessions with no client + protocol change. In-memory, bounded at 256, per-daemon-launch. +- `packages/cli/src/commands/bridge.ts` — `xnet bridge serve` with `--agent +claude|codex`, `--upstream ` (front raw Ollama/LM Studio through the + hardened bridge), `--allow-writes`, MCP-on-by-default for Claude + (`resolveMcpConfig()`, line 157, pointing back at `xnet mcp serve`), and + `xnet bridge install` (launchd LaunchAgent, macOS only, label + `fyi.xnet.bridge`). +- `apps/electron/src/main/agent-bridge-manager.ts` — Electron runs the same + `createBridgeServer` in-process and hands the pairing token to the renderer + over IPC; a transport variant, not a fork. + +The ToS constraint is encoded in the code itself (`chat-agent.ts:49-53`): +spawning the user's own installed, own-logged-in CLI is the permitted pattern; +embedding OAuth or reusing tokens is banned. + +### Tools, consent, retrieval, persistence + +- **MCP server** (`packages/plugins/src/services/mcp-server.ts`): ~15 + `xnet_*` tools; core tools always loaded, the rest deferred (Tool Search + pattern). Write guardrails: `guardedWrite()` + `readWriteGate()` return + `needs-confirmation` until re-called with `confirm: true` + (`mcp-guardrail.ts`, 0175). +- **Consent** is layered but coarse: daemon-launch `--allow-writes` picks the + allowed-tools tier (`XNET_READONLY_ALLOWED_TOOLS` vs `mcp__xnet__*`, + `agent-launch.ts:46`), passed to Claude as `--allowedTools`. No in-chat + approval UI — the panel never sees tool calls at all. +- **Retrieval**: `nodes_fts` FTS5 + bm25 (`packages/sqlite/src/fts.ts:108`) + now feeds `keywordEntrySearch()` + (`apps/web/src/workbench/views/ai-graph-retriever.ts:115`) and + `AiSurfaceService.search()` (`ai-surface/service.ts:506`), with bounded + graph expansion via `@xnetjs/brain` (maxHops 1, maxEntries 12, + maxTokens 24k) and an `instructionBoundary` wrapper marking external + resources untrusted. +- **Persistence**: chats become `ChannelSchema` + `ChatMessageSchema` nodes + (`ai-chat-persistence.ts:47`) — FTS-indexed, linkable, syncable; no new + schema minted. +- **Cross-harness skill**: `packages/plugins/src/ai-surface/skill.ts` ships + one SKILL.md (Claude Code / Codex / Gemini / Cursor) under ~1k tokens. + +### The seams and limitations (what this exploration must answer to) + +1. **Two agent surfaces, no shared loop.** The bridge treats Claude Code as an + opaque chat engine — xNet's MCP tools run _inside the spawned CLI's own + harness_, invisible to xNet's runtime. Meanwhile the in-app + `AIProvider.generateWithTools` + `AiSurfaceService` tool system sits + unwired (Phase-0 badge). +2. **Text-only wire.** App↔bridge speaks OpenAI SSE; tool-call events, cost, + and session metadata are flattened to text deltas plus a + `[bridge error: …]` string. +3. **Heuristic sessions.** The transcript-fingerprint map is in-memory and + per-launch; a daemon restart silently degrades to fresh full-history + sessions. +4. **Codex is second-class.** One-shot `codex exec`, no resume, no + per-invocation MCP. Gemini CLI and OpenCode are listed in + `KNOWN_BRIDGE_AGENTS` but ride the same one-shot path. +5. **Coarse consent.** One launch-time flag plus `confirm:true` re-calls; no + interactive approval. +6. **The bridge is a URL, not a provider type.** The `AIProvider` abstraction + spans managed/cloud-key/local; the bridge is reached _as_ an + `openai-compatible` provider pointed at loopback. Ladder, provider classes, + and `ChatAgent` port only loosely compose. +7. **Platform gaps**: Safari blocks https→localhost; `bridge install` is + launchd-only. +8. **Hand-rolled model catalog**: model lists, prices, and context limits are + maintained by hand (`fetchManagedModels()` for the managed tier; static + defaults elsewhere). + +## External Research + +### How each harness connects (survey) + +#### OpenCode (sst/opencode) — the maximum-flexibility _model_ harness + +Client/server: `opencode` starts a headless local server (`127.0.0.1:4096`, +OpenAPI 3.1 at `/doc`) plus a TUI; desktop app, IDE extensions, web UI, and CI +all drive the same server. Sessions live in the server and survive client +disconnects. The provider layer is the industry's cleanest separation: + +- **Protocol** comes from Vercel AI SDK provider packages (`@ai-sdk/*`), + **installed dynamically at runtime** via Bun and cached under + `~/.cache/opencode/node_modules/` — zero hardcoded provider integrations. +- **Catalog** comes from models.dev (the SST team's open TOML registry: + capabilities, context limits, per-1M costs, modalities, deprecations — + consumed as static JSON from `models.dev/api.json`). +- **Auth** is pluggable: API keys in `~/.local/share/opencode/auth.json`, + sanctioned OAuth (GitHub Copilot), env-var credential chains (Bedrock). + The Claude Pro/Max OAuth path was removed in 2026 after Anthropic legal + requests. +- **Zen** is their optional curated paid gateway (OpenAI-style and + Anthropic-style endpoints, pass-through pricing, free daily tier). +- **Plugins** are JS modules with hooks (`tool.execute.before/after`, + `session.*`, permission events) and Zod-schema custom tools. + +Cost of the design: a Bun runtime dependency and trusting dynamic npm installs +at runtime. + +#### models.dev — the externalized catalog + +Open-source TOML database (github.com/sst/models.dev) of providers and +models: capabilities (tool calling, reasoning, structured output), +limits (context/max tokens), costs (input/output/reasoning/cache per-1M), +metadata (cutoffs, open-weights, deprecation). Served as static JSON; +community PRs validated by schema CI. This is what lets a harness ship **zero +hardcoded model tables** — pricing display, context enforcement, and +capability gating all come from one registry. + +#### Vercel AI SDK — the protocol layer + +A specification layer (`LanguageModelV2`+) standardizing +`generateText`/`streamText`, tool-calling representation, and streaming +normalization, with each provider as a separate npm package. Vercel cites +OpenCode as "built entirely on AI SDK". AI SDK 7 (2026) adds experimental +**harness abstractions** — a `HarnessAgent` API that runs external harnesses +(Claude Code, Codex) behind one interface — i.e. the AI SDK itself is now +acknowledging the model/agent split this exploration turns on. + +#### T3 Chat and T3 Code + +T3 Chat ($8/mo) connects **directly to provider APIs** (not OpenRouter), +banking on caps, negotiated volume deals, and margin discipline; BYOK +supported for select providers. **T3 Code** is the directly relevant one: a +free, open-source desktop app that **wraps official AI-lab CLIs as +subprocesses** — launched on Codex CLI ("bring your existing Codex +subscription"), with Claude Code, Cursor, Gemini, and OpenCode planned, +oriented around parallel agents. It independently converged on exactly xNet's +0391 bridge pattern: the vendor's own CLI holds the auth; the app is a client. + +#### Claude Code / Agent SDK / Claude Desktop (Anthropic) + +- **CLI**: agentic loop + built-in tools calling the Messages API. Headless: + `claude -p --output-format stream-json --input-format stream-json +--verbose`, `--resume `/`--continue`, `--allowedTools`, + `--permission-mode`, `--mcp-config`, and the new `--bare` mode (skips + hooks/skills/CLAUDE.md/MCP auto-discovery; "recommended for scripted and + SDK calls"). Stream events now include a `capabilities` feature-detection + array in `system/init` (v2.1.205+) — the bridge's reducer should consume + this. +- **Agent SDK** (TS/Python): `query()`, in-process hooks, `canUseTool`, + in-process MCP servers, sessions with resume/fork. It now **bundles a + native Claude Code binary** (per-platform optional dependency) rather than + requiring a separately installed CLI. But: **API-key/Bedrock/Vertex/Foundry + auth only** — "Anthropic does not allow third party developers to offer + claude.ai login or rate limits for their products, including agents built + on the Claude Agent SDK." +- **ToS timeline**: Jan 9 2026 server-side enforcement (consumer OAuth tokens + outside Claude Code/claude.ai return "This credential is only authorized + for use with Claude Code"); Feb 20 2026 ToS text bans consumer OAuth tokens + in any other product _including the Agent SDK_; full cut-off reported + Apr 4 2026. OpenCode/OpenClaw/Cline were cut off. What survives: the user + running the genuine `claude` binary under their own login — including + `claude -p` spawned by another app as _the user's tool_. That is xNet's + bridge, and T3 Code's model. +- **Claude Desktop**: claude.ai backend; MCP stdio servers via + `claude_desktop_config.json` and one-click `.mcpb` Desktop Extensions. MCP + is its only extension seam and it is _inbound_ (tools for Claude) — there + is **no outbound embedding surface**; you cannot drive Claude Desktop from + your app. + +#### OpenAI Codex — the sanctioned embedding surface + +- Auth: ChatGPT sign-in (plan quota) or API key; SDK login helpers include + device-code flow. +- **`codex app-server`**: OpenAI extracted the agent core into a documented + JSON-RPC 2.0 server — stdio NDJSON by default, experimental + WebSocket/Unix sockets. Primitives: Thread (`thread/start`, + `thread/resume`, `thread/fork`, `thread/list`) and Turn (`turn/start`, + `turn/steer`, `turn/interrupt`), streamed item events (command exec, file + changes), **server-initiated approval requests**, skills, MCP connectors, + versioned TypeScript/JSON-Schema bindings per release. This one server + powers the CLI TUI, the Codex desktop app (Feb 2026), the VS Code + extension, and Codex web. Notably OpenAI **tried MCP-as-embedding first and + rejected it** — request/response tools couldn't carry streaming diffs, + approvals, thread persistence, or server-initiated requests. +- **`@openai/codex-sdk`** wraps the CLI (`startThread()`, `runStreamed()`). +- **Third-party model providers are first-class**: `~/.codex/config.toml` + `[model_providers.]` with `base_url`, `env_key`, `wire_api` + (Responses API default, chat-completions compat), `--oss` for + Ollama-served open-weight models. +- ToS posture: explicitly permissive — app-server is marketed for "deep + integration inside your own product," ChatGPT-plan auth included. + +#### Gemini CLI + +Default auth is personal-Google OAuth (generous free tier), or AI Studio API +key, or Vertex. Extensible via MCP. Most importantly: +`gemini --experimental-acp` runs it as an **ACP server** — the reference ACP +agent, and how Zed and IntelliJ embed it. + +#### ACP — the Agent Client Protocol + +Zed's open JSON-RPC 2.0 standard ("LSP for AI coding agents", Aug 2025): +agents run as client subprocesses over stdio; `initialize` → `session/new` → +`session/prompt` with streamed `session/update` notifications, plus +server-initiated **permission requests**, diffs, terminals, @-mentions. +Adoption by mid-2026: Gemini CLI (reference), Claude Code via the +`claude-agent-acp` adapter (built on the Agent SDK → inherits API-key-only +auth), Goose, Aider, Codex adapters; clients include Zed, JetBrains (adopted, +25+ agents), Neovim, Emacs, marimo; an ACP Registry for discovery; reports +place MCP/A2A/ACP under Linux Foundation governance. Remote transport +(HTTP/WebSocket) is work-in-progress — relevant to xNet because the browser +cannot spawn subprocesses; the bridge daemon must be the ACP client and relay +to the browser. + +(AG-UI — CopilotKit's web-frontend↔agent-state protocol — is the adjacent +standard for the _browser_ leg; it complements rather than competes with ACP.) + +#### Aggregators and other harnesses + +- **OpenRouter**: one OpenAI-compatible API over hundreds of models; + pass-through inference pricing with a ~5.5% take on credit purchase; BYOK + at 5% after 1M req/mo; **PKCE OAuth key provisioning** (exactly what + `ai-chat-connector.ts:271` implements) with localhost callbacks blessed for + CLI/desktop tools; app attribution via `HTTP-Referer` + + `X-OpenRouter-Title` → public leaderboard presence (free distribution xNet + is not currently claiming). +- **Aider** (LiteLLM, Python), **Cline/Roo/Kilo** (VS Code BYOK adapters), + **Goose** (Rust provider trait, 15+ providers, system keyring, also an ACP + agent), **LiteLLM proxy** (self-hosted org-level gateway) — all variations + on the same five concerns. + +### The convergence, distilled + +```mermaid +flowchart TB + subgraph concerns["The five separated concerns (2026 state of the art)"] + CAT["Catalog
models.dev JSON"] + PROTO["Protocol
AI SDK LanguageModel /
OpenAI-compatible"] + AUTH["Auth
keyring · sanctioned OAuth ·
PKCE gateway keys"] + AGG["Aggregation (optional)
OpenRouter · Zen · LiteLLM"] + COMP["Harness composition
spawn CLI · app-server ·
ACP · server API"] + end + CAT --> H[Harness] + PROTO --> H + AUTH --> H + AGG --> H + COMP --> H +``` + +And the two lanes every serious harness now distinguishes: + +```mermaid +flowchart LR + subgraph modelLane["MODEL lane — you own the loop"] + UI1[Your UI] --> LOOP[Your agent loop
tools · consent · retrieval] + LOOP --> P1[Anthropic API] + LOOP --> P2[OpenRouter] + LOOP --> P3[Ollama / WebLLM] + end + subgraph agentLane["AGENT lane — you are the client"] + UI2[Your UI] --> C[Protocol client] + C -->|stream-json subprocess| CC[claude CLI
user's own login] + C -->|JSON-RPC app-server| CX[codex] + C -->|ACP stdio| GM[gemini --experimental-acp] + C -->|server API| OC[opencode serve] + end +``` + +## Key Findings + +1. **xNet's bridge bet was correct and is now market-validated.** The + spawn-the-user's-own-CLI pattern that 0391 chose under ToS pressure is the + same architecture T3 Code launched with and the same one Zed's ACP world + assumes. Anthropic's Feb 2026 ToS text confirmed the boundary; the + contingency ladder exists if the pattern ever narrows. + +2. **The industry's flexibility recipe is separation, not abstraction + thickness.** OpenCode supports 75+ providers not because its provider + interface is clever but because catalog (models.dev), protocol (AI SDK + packages), and auth are each externally maintained and independently + swappable. xNet's `AIProvider` interface is fine; its catalog and its + agent wire are the under-separated parts. + +3. **MCP is not an embedding protocol.** OpenAI tried and rejected it for + Codex embedding; request/response tools can't carry streaming diffs, + approvals, or server-initiated requests. xNet uses MCP correctly (inbound + tools into the spawned agent) but must not expect it to become the app↔ + agent wire. + +4. **The app↔agent wire needs richer frames than OpenAI SSE.** Every serious + agent client (Zed, Codex desktop, JetBrains) receives structured events: + tool calls, diffs, permission requests, session ids, cost. xNet's bridge + deliberately flattened these to text deltas for Phase 0; that is now the + binding constraint on the product (no in-chat consent UI, no tool-call + visibility, no cost display). + +5. **ACP is the strongest candidate for that wire — with one caveat.** It is + cross-vendor, JSON-RPC, permission-request-native, registry-backed, and + Linux-Foundation-governed. The caveat: the official Claude ACP adapter is + built on the Agent SDK and therefore **cannot carry a Claude + subscription** (API-key only). For Claude-subscription users, xNet must + keep its own stream-json spawn and translate to ACP-shaped frames itself. + ACP is the _shape_ of the wire; it is not a free pass around the Claude + auth boundary. + +6. **Codex deserves promotion from one-shot to first-class.** `codex +app-server` gives threads, resume, fork, steer, interrupt, and + server-initiated approvals over documented JSON-RPC with versioned + schemas — strictly better than the current `codex exec` one-shot, and + explicitly sanctioned with ChatGPT-plan auth. + +7. **The session-fingerprint hack becomes unnecessary once the wire carries + session ids.** `bridge-sessions.ts` exists only because the OpenAI + protocol is stateless. An ACP/app-server-shaped wire has native session + identity; the fingerprint map remains as the compatibility shim for the + plain OpenAI endpoint (which should stay — it makes the bridge useful to + _other_ OpenAI-compatible clients on the user's machine, an + underappreciated asset). + +8. **xNet has assets none of the surveyed harnesses have**: chats as + first-class synced nodes (Channel/ChatMessage), FTS+graph retrieval with + provenance paths and injection boundaries, a write-guardrail with audit + and rollback, and a hardened loopback daemon with real anti-rebinding + discipline. The gap is orchestration, not substrate. + +## Options And Tradeoffs + +### Option A — Status quo plus polish + +Keep OpenAI SSE as the only app↔bridge wire; incrementally add Codex resume +via more CLI flags; hand-maintain model tables. + +- Pros: zero migration; the ladder works today. +- Cons: every limitation in the seams list persists; tool-call visibility and + in-chat consent are impossible without wire changes; Codex `exec` has no + session story; catalog drift is manual toil forever. + +### Option B — Adopt the Vercel AI SDK wholesale for the model lane + +Replace `providers.ts` with `@ai-sdk/*` packages (as OpenCode did), possibly +with runtime package loading. + +- Pros: N providers for one dependency; community-maintained protocol + adapters; AI SDK 7's harness abstractions might eventually cover the agent + lane too. +- Cons: xNet runs in the _browser_ (OpenCode runs on Bun server-side) — + dynamic npm-at-runtime is off the table, and bundling every provider + package bloats the web app; `providers.ts` already covers the six tiers + xNet actually ships, including two (webllm, prompt-api) the AI SDK handles + poorly; migration churn with little user-visible payoff. **Adopt the + _pattern_ (catalog/protocol separation), not the dependency.** + +### Option C — Become an OpenCode client (embed `opencode serve` as the agent runtime) + +Ship/spawn OpenCode's server and drive its session API; inherit its 75 +providers. + +- Pros: the most provider coverage for the least code; server API + SSE + events are documented. +- Cons: a heavyweight dependency (Bun runtime, dynamic installs) between xNet + and every model; xNet's retrieval/guardrail/persistence would sit outside + the loop OpenCode owns; the Claude-subscription path is exactly the one + OpenCode was forced to remove; strategically it makes xNet a skin over + someone else's harness. + +### Option D — ACP-shaped bridge, per-agent native transports (recommended) + +Keep the ladder and the bridge daemon. Upgrade the daemon's app-facing wire +to carry structured agent frames (sessions, tool calls, permission requests, +cost) using ACP's vocabulary, relayed to the browser over the existing +hardened loopback channel (SSE/WebSocket — ACP's remote transport is still +WIP, so this is "ACP-shaped", pragmatically framed). Behind the daemon, speak +each agent's best native protocol: + +| Agent | Transport | Auth | +| ---------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------ | +| Claude Code | spawn user's CLI, stream-json (existing) | user's own login — ToS-safe | +| Codex | `codex app-server` JSON-RPC over stdio | ChatGPT plan or API key — sanctioned | +| Gemini CLI | `gemini --experimental-acp` (native ACP) | user's Google OAuth | +| OpenCode / Goose / others | ACP adapters as they mature | per-agent | +| Raw models (managed, cloud-key, local, webllm) | existing `AIProvider` classes, now emitting the same frame vocabulary | existing | + +- Pros: one event vocabulary unifies both lanes and both agent surfaces + (finding 1's split heals); in-chat consent and tool-call visibility become + possible; Codex gets sessions/steer/interrupt for free; the fingerprint + hack becomes a compat shim; each agent keeps its ToS-cleanest auth; new + agents cost one adapter, not a redesign. +- Cons: real protocol work in the daemon and panel; ACP is young (remote + transport WIP, spec still moving); the Claude leg is "ACP-shaped by our own + translation," not the official adapter — xNet owns that mapping. + +### Option E — Bet fully on official SDKs (Claude Agent SDK + Codex SDK embedded in Electron) + +- Pros: richest per-vendor integration (hooks, `canUseTool`, in-process MCP). +- Cons: **the Claude Agent SDK cannot use the user's subscription** — that + alone disqualifies it as the primary Claude path for a daily-driver app + whose users hold Max plans; two vendor SDKs in-process double the surface; + web (non-Electron) users get nothing. SDKs remain useful for optional + API-key power modes, not the spine. + +Charter §6 note: this exploration proposes no new revenue lane. The existing +managed tier (metered OpenRouter pass-through, 0244) already passed the +no-ground-rent tests; everything recommended here strengthens the _free_ +paths (user's own CLI, own keys, own local models), which is the BATNA test +working as intended. + +## Recommendation + +**Option D.** Concretely, in order: + +1. **Define the frame vocabulary once** — a small + `AgentFrame` union (ACP-aligned names: `session`, `delta`, `tool_call`, + `tool_result`, `permission_request`, `diff`, `cost`, `result`) in + `packages/devkit/src/` shared by daemon and panel. Map + `reduceStreamJsonLine()`'s existing events into it; stop discarding + tool-use and cost frames. +2. **Add a framed endpoint to the bridge** (`/v1/agent/stream` or upgrade to + WebSocket) alongside the OpenAI-compatible endpoint, which stays for + third-party OpenAI clients. Same token, same origin discipline. +3. **Promote Codex** to a `codexAppServerChatAgent` speaking JSON-RPC to + `codex app-server` — threads map 1:1 to xNet conversations; approvals + surface as `permission_request` frames. +4. **Wire the panel**: render tool-call frames, an in-chat approval control + answering `permission_request` (replacing launch-time-only + `--allow-writes` with per-action consent that respects the same gate), and + cost display. This is also where the dormant `generateWithTools` + + `AiSurfaceService` loop finally runs — the model lane emits the same + frames, so one panel serves both lanes. +5. **Externalize the catalog**: consume `models.dev/api.json` (cached, + shipped as a snapshot fallback) for cloud-key and local tiers; keep the + hub's plan-gated catalog for managed. Claim OpenRouter app attribution + headers while touching that path. +6. **Persist bridge sessions**: write the conversation↔session-id map + through the daemon to disk (`~/.xnet/agent-home`), replacing restart + amnesia; keep the fingerprint map as the fallback for the plain OpenAI + endpoint. +7. **Track ACP maturation**: when remote transport stabilizes and a + subscription-compatible Claude adapter exists (i.e. one that spawns the + CLI rather than embedding the SDK — possibly ours to publish), swap the + internal vocabulary for literal ACP and offer `xnet bridge` itself as an + ACP agent (xNet's workspace tools become drivable from Zed/JetBrains — + distribution, not just consumption). + +Target architecture: + +```mermaid +flowchart TB + subgraph browser["xNet app (web / Electron renderer)"] + PANEL[AiChatPanel
frames: deltas · tool calls · consent · cost] + LADDER[Connector ladder
detect + pick] + PROV[AIProvider classes
managed · cloud-key · local · webllm] + end + subgraph daemon["xnet bridge daemon (:31416, hardened loopback)"] + FRAMED["/v1/agent/stream — framed (ACP-shaped)"] + OAIC["/v1/chat/completions — OpenAI compat (kept)"] + SESS[(durable session map)] + end + PANEL --> LADDER + LADDER --> PROV + LADDER --> FRAMED + PROV -->|same frame vocabulary| PANEL + FRAMED --> SESS + FRAMED -->|stream-json spawn| CC[claude CLI · user's login] + FRAMED -->|JSON-RPC| CX[codex app-server] + FRAMED -->|ACP stdio| GM[gemini --experimental-acp] + OAIC -->|compat shim| SESS + CC -->|--mcp-config| MCP[xnet mcp serve
guardrailed workspace tools] +``` + +Consent flow after step 4: + +```mermaid +sequenceDiagram + participant U as User + participant P as AiChatPanel + participant B as bridge daemon + participant A as Agent (claude/codex) + participant M as xnet mcp serve + U->>P: prompt + P->>B: /v1/agent/stream (turn) + B->>A: native transport (spawn / JSON-RPC / ACP) + A->>M: xnet_update (write tool) + M-->>A: needs-confirmation (guardrail) + A-->>B: tool_call frame + B-->>P: permission_request frame + P->>U: in-chat approval control + U->>P: approve + P->>B: permission response + B->>A: confirm: true + A->>M: xnet_update (confirmed) + M-->>A: applied + audit event + A-->>B: deltas · result (session id, cost) + B-->>P: frames → persisted as Channel/ChatMessage nodes +``` + +## Risks And Open Questions + +- **ACP churn**: the spec and its remote transport are moving; hence + "ACP-shaped internal vocabulary now, literal ACP later" rather than + hard-coupling to today's spec. +- **Claude ToS drift**: spawning the user's CLI is defensible but Anthropic + has published no bright-line safe harbor. Mitigation is already built: the + ladder degrades to cloud-key/OpenRouter-PKCE/local tiers. Watch for any + Anthropic statement on third-party _drivers_ of the CLI. +- **Consent semantics across lanes**: the guardrail's `confirm: true` + re-call, Claude's `--allowedTools`, Codex's server-initiated approvals, and + ACP permission requests are four consent grammars; the frame vocabulary + must map all of them onto one user-facing gate without weakening any + (`writeModeFor()` fidelity gating must still bind the model lane). +- **Safari/web-only users** still can't reach localhost daemons; the framed + wire doesn't change that. The managed tier remains their agent story — + should a hub-hosted agent lane (server-side spawn) ever exist? That is a + separate exploration with heavy trust implications. +- **models.dev coverage** of managed-tier pricing may diverge from the hub's + negotiated catalog; keep the hub catalog authoritative for managed. +- **Does xNet publish its own ACP adapter for Claude-via-CLI?** It would fill + a real ecosystem gap (the official adapter is API-key-only) and earn + distribution, but it puts xNet's name on the ToS-interpretation. Decide + when step 7 arrives. + +## Implementation Checklist + +- [x] Define `AgentFrame` union + reducer mapping in `packages/devkit/src/agent-frames.ts`; emit tool-use/cost/session frames from `reduceStreamJsonLine()` instead of discarding them +- [x] Add framed streaming endpoint to `bridge-server.ts` (`/v1/agent/stream`), token- and origin-guarded like the existing endpoints; keep `/v1/chat/completions` unchanged +- [ ] Implement `codexAppServerChatAgent` (JSON-RPC over stdio to `codex app-server`): thread start/resume mapped to conversations; approvals → `permission_request` frames +- [ ] Add `gemini --experimental-acp` agent behind the same frames +- [ ] Panel: render tool-call frames + in-chat approval UI wired to `permission_request`; per-action consent supersedes launch-time `--allow-writes` (flag remains the ceiling) +- [ ] Wire the model lane's `generateWithTools` + `AiSurfaceService` loop to emit the same frames (Phase-0 badge finally retires where fidelity is `reliable`) +- [x] Durable session map in the daemon (persist under `~/.xnet/agent-home`); fingerprint map demoted to OpenAI-compat shim +- [x] Consume `models.dev/api.json` (with vendored snapshot fallback) for cloud-key/local model pickers; add OpenRouter `HTTP-Referer`/`X-OpenRouter-Title` attribution headers +- [ ] Update `xnet bridge serve --agent` help + docs; extend `bridge install` beyond launchd (systemd user unit, Windows scheduled task) — separate PR +- [x] Changesets: `@xnetjs/devkit` (minor — new frames/endpoint), `@xnetjs/cli` (minor), plugins/apps per diff + +## Validation Checklist + +- [ ] Bridge streaming test: a Claude turn producing an MCP write emits `tool_call` → `permission_request` → confirmed apply, and the panel renders each frame (integration test against a stubbed CLI emitting canned stream-json) +- [ ] Codex thread resume: two turns in one conversation hit the same app-server thread (no full-history replay); interrupt works +- [x] Daemon restart: conversation continues on its persisted session id (no fingerprint fallback logged) +- [x] Plain OpenAI clients (curl, other tools) still work against `/v1/chat/completions` byte-for-byte as before +- [x] models.dev outage: pickers fall back to vendored snapshot; no hardcoded price drift vs hub catalog for managed +- [ ] Consent: with `--allow-writes` absent, a write tool call is refused before any `permission_request` reaches the panel (flag stays the ceiling) +- [x] Security regression suite: Host/Origin/token checks green on the new endpoint (reuse 0289 tests) + +## References + +- Repo: `packages/devkit/src/{chat-agent,bridge-server,bridge-sessions,agent-launch}.ts`; `packages/cli/src/commands/bridge.ts`; `packages/plugins/src/ai/{providers.ts,connectors/{detect,types}.ts}`; `packages/plugins/src/ai-surface/{service,skill}.ts`; `packages/plugins/src/services/mcp-server.ts`; `apps/web/src/workbench/views/{AiChatPanel.tsx,ai-chat-connector.ts,ai-graph-retriever.ts,ai-chat-persistence.ts}`; `apps/electron/src/main/agent-bridge-manager.ts` +- Prior explorations: 0391 (daily-driver AI), 0379 (knowledge base / retrieval), 0289 (native-messaging bridge spike + hardening), 0252 (AI chat box), 0244/0208 (OpenRouter managed), 0175 (write guardrail) +- OpenCode: https://opencode.ai/docs/ · https://opencode.ai/docs/providers/ · https://opencode.ai/docs/server/ · https://opencode.ai/docs/plugins/ · https://opencode.ai/docs/zen/ +- models.dev: https://models.dev/ · https://github.com/sst/models.dev +- Vercel AI SDK: https://ai-sdk.dev/docs/introduction · https://vercel.com/blog/ai-sdk-5 · https://vercel.com/blog/ai-sdk-7 +- T3: https://x.com/theo/status/1911887958573302142 (T3 Chat pricing) · https://x.com/theo/status/2030071716530245800 (T3 Code announcement) +- OpenRouter: https://openrouter.ai/docs/use-cases/oauth-pkce · https://openrouter.ai/docs/app-attribution · https://openrouter.ai/docs/faq +- Claude Code / Agent SDK: https://code.claude.com/docs/en/headless · https://code.claude.com/docs/en/agent-sdk/overview · https://code.claude.com/docs/en/agent-sdk/typescript +- Anthropic ToS enforcement: https://www.theregister.com/2026/02/20/anthropic_clarifies_ban_third_party_claude_access/ · https://aihackers.net/posts/anthropic-claude-code-oauth-policy-feb-2026/ +- Codex: https://openai.com/index/unlocking-the-codex-harness/ · https://developers.openai.com/codex/app-server · https://developers.openai.com/codex/sdk · https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md · https://docs.ollama.com/integrations/codex +- Gemini CLI: https://google-gemini.github.io/gemini-cli/docs/get-started/authentication.html · https://geminicli.com/docs/cli/acp-mode/ +- ACP: https://agentclientprotocol.com/ · https://zed.dev/blog/bring-your-own-agent-to-zed · https://zed.dev/blog/acp-progress-report · https://github.com/agentclientprotocol/claude-agent-acp +- AG-UI: https://github.com/ag-ui-protocol/ag-ui/ +- Others: https://github.com/block/goose · https://zed.dev/docs/ai/external-agents diff --git a/packages/cli/src/commands/bridge.ts b/packages/cli/src/commands/bridge.ts index 2c65795e3..8e458ece0 100644 --- a/packages/cli/src/commands/bridge.ts +++ b/packages/cli/src/commands/bridge.ts @@ -30,6 +30,8 @@ import { cliChatAgent, cliStreamingChatAgent, createBridgeServer, + createBridgeSessionStore, + fileSessionPersistence, DEFAULT_BRIDGE_PORT, defaultXnetGate, Git, @@ -95,6 +97,11 @@ export function buildBridgeServer( // agent (exploration 0391). Codex and friends stay on the one-shot template; // `--upstream` fronts a raw OpenAI-compatible model server through the bridge. let agent: ChatAgent + // Durable session map for the streaming (Claude) path only — the agent that + // actually produces --resume session ids. Persisted next to the cwd where + // Claude Code keeps those sessions, so a daemon restart continues them + // instead of re-seeding every open conversation (exploration 0392). + let sessions: ReturnType | undefined if (options.upstream) { agent = openAiChatAgent({ baseUrl: options.upstream, @@ -106,6 +113,9 @@ export function buildBridgeServer( cwd, ...(options.mcpConfigPath ? { launch: mcpLaunchOptions(options) } : {}) }) + sessions = createBridgeSessionStore({ + persistence: fileSessionPersistence(join(cwd, 'bridge-sessions.json')) + }) } else { const args = buildAgentArgs(command, { ...(options.mcpConfigPath ? mcpLaunchOptions(options) : {}) @@ -132,6 +142,7 @@ export function buildBridgeServer( return createBridgeServer({ agent, agentName: command, + ...(sessions ? { sessions } : {}), ...(run ? { run } : {}), ...(options.host ? { host: options.host } : {}), ...(options.port !== undefined ? { port: options.port } : {}), diff --git a/packages/devkit/src/agent-frames.test.ts b/packages/devkit/src/agent-frames.test.ts new file mode 100644 index 000000000..9fbbdb67f --- /dev/null +++ b/packages/devkit/src/agent-frames.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' +import { + foldStreamJsonFrames, + initialStreamJsonFrameState, + type AgentFrame, + type StreamJsonFrameState +} from './agent-frames' + +/** Fold a sequence of NDJSON lines and collect every emitted frame. */ +function foldAll(lines: string[]): { frames: AgentFrame[]; state: StreamJsonFrameState } { + let state = initialStreamJsonFrameState() + const frames: AgentFrame[] = [] + for (const line of lines) { + const step = foldStreamJsonFrames(state, line) + state = step.state + frames.push(...step.frames) + } + return { frames, state } +} + +const j = (value: unknown): string => JSON.stringify(value) + +describe('foldStreamJsonFrames', () => { + it('emits a session frame with capabilities from system/init', () => { + const { frames } = foldAll([ + j({ type: 'system', subtype: 'init', session_id: 'sess-1', capabilities: ['a', 'b'] }) + ]) + expect(frames).toEqual([{ type: 'session', sessionId: 'sess-1', capabilities: ['a', 'b'] }]) + }) + + it('omits capabilities when absent', () => { + const { frames } = foldAll([j({ type: 'system', subtype: 'init', session_id: 'sess-1' })]) + expect(frames).toEqual([{ type: 'session', sessionId: 'sess-1' }]) + }) + + it('emits delta frames for partial text_delta events', () => { + const { frames, state } = foldAll([ + j({ + type: 'stream_event', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hel' } } + }), + j({ + type: 'stream_event', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'lo' } } + }) + ]) + expect(frames).toEqual([ + { type: 'delta', text: 'Hel' }, + { type: 'delta', text: 'lo' } + ]) + expect(state.text).toBe('Hello') + expect(state.sawPartialDelta).toBe(true) + }) + + it('emits a tool_call frame for an assistant tool_use block', () => { + const { frames } = foldAll([ + j({ + type: 'assistant', + message: { + content: [{ type: 'tool_use', id: 'tu-1', name: 'xnet_update', input: { id: 'n1' } }] + } + }) + ]) + expect(frames).toEqual([ + { type: 'tool_call', id: 'tu-1', name: 'xnet_update', input: { id: 'n1' } } + ]) + }) + + it('emits a tool_result frame for a user tool_result block', () => { + const { frames } = foldAll([ + j({ + type: 'user', + message: { + content: [ + { type: 'tool_result', tool_use_id: 'tu-1', content: 'applied', is_error: false } + ] + } + }) + ]) + expect(frames).toEqual([{ type: 'tool_result', id: 'tu-1', ok: true, content: 'applied' }]) + }) + + it('marks a tool_result as not ok when is_error is true', () => { + const { frames } = foldAll([ + j({ + type: 'user', + message: { content: [{ type: 'tool_result', tool_use_id: 't', is_error: true }] } + }) + ]) + expect(frames).toEqual([{ type: 'tool_result', id: 't', ok: false }]) + }) + + it('emits a permission_request frame for a can_use_tool control request', () => { + const { frames } = foldAll([ + j({ + type: 'control_request', + request_id: 'req-9', + request: { subtype: 'can_use_tool', tool_name: 'xnet_delete', input: { id: 'n2' } } + }) + ]) + expect(frames).toEqual([ + { type: 'permission_request', id: 'req-9', tool: 'xnet_delete', input: { id: 'n2' } } + ]) + }) + + it('emits cost then result on a successful result event', () => { + const { frames, state } = foldAll([ + j({ + type: 'stream_event', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hi' } } + }), + j({ + type: 'result', + subtype: 'success', + session_id: 'sess-2', + total_cost_usd: 0.0012, + usage: { input_tokens: 10, output_tokens: 3 } + }) + ]) + expect(frames).toEqual([ + { type: 'delta', text: 'hi' }, + { type: 'cost', usd: 0.0012, inputTokens: 10, outputTokens: 3 }, + { type: 'result', ok: true, text: 'hi', sessionId: 'sess-2' } + ]) + expect(state.done).toBe(true) + }) + + it('folds a result-only reply into the terminal result frame (no partials)', () => { + const { frames } = foldAll([ + j({ type: 'result', subtype: 'success', session_id: 's', result: 'full answer' }) + ]) + expect(frames).toEqual([{ type: 'result', ok: true, text: 'full answer', sessionId: 's' }]) + }) + + it('emits an error result frame on a failed result', () => { + const { frames, state } = foldAll([ + j({ type: 'result', subtype: 'error_max_turns', is_error: true, result: 'too many turns' }) + ]) + expect(frames).toEqual([{ type: 'result', ok: false, error: 'too many turns' }]) + expect(state.error).toBe('too many turns') + }) + + it('does not double-count complete assistant text after partial deltas', () => { + const { frames } = foldAll([ + j({ + type: 'stream_event', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'streamed' } } + }), + j({ type: 'assistant', message: { content: [{ type: 'text', text: 'streamed' }] } }) + ]) + expect(frames).toEqual([{ type: 'delta', text: 'streamed' }]) + }) + + it('emits assistant text as a delta when no partials streamed (older CLI)', () => { + const { frames } = foldAll([ + j({ type: 'assistant', message: { content: [{ type: 'text', text: 'whole reply' }] } }) + ]) + expect(frames).toEqual([{ type: 'delta', text: 'whole reply' }]) + }) + + it('ignores non-JSON noise on stdout', () => { + const { frames } = foldAll(['not json', '', '{bad']) + expect(frames).toEqual([]) + }) + + it('omits the cost frame when neither cost nor usage is present', () => { + const { frames } = foldAll([j({ type: 'result', subtype: 'success', result: 'x' })]) + expect(frames.some((f) => f.type === 'cost')).toBe(false) + }) +}) diff --git a/packages/devkit/src/agent-frames.ts b/packages/devkit/src/agent-frames.ts new file mode 100644 index 000000000..0553586fc --- /dev/null +++ b/packages/devkit/src/agent-frames.ts @@ -0,0 +1,248 @@ +/** + * @xnetjs/devkit — the structured agent-frame vocabulary (exploration 0392). + * + * The bridge's OpenAI-compatible endpoint (`/v1/chat/completions`) can only + * carry text deltas: every tool call, cost figure, and session id Claude Code + * emits over `stream-json` is flattened away before it reaches the browser. + * That is the binding constraint on the product — no in-chat consent UI, no + * tool-call visibility, no cost display. + * + * This module defines the richer wire the app-facing endpoint speaks instead: + * a small {@link AgentFrame} union whose names are aligned with the Agent + * Client Protocol (ACP) so a literal-ACP transport can be swapped in later + * without reshaping the panel. Every serious agent client (Zed, Codex desktop, + * JetBrains) receives frames like these; xNet's bridge deliberately flattened + * them for Phase 0, and this is the un-flattening. + * + * {@link foldStreamJsonFrames} is the pure reducer that maps one NDJSON line of + * Claude Code `stream-json` output to zero-or-more frames — the frame-emitting + * counterpart of {@link reduceStreamJsonLine} (which stays untouched so the + * OpenAI-compatible endpoint keeps its byte-for-byte output). Other agents + * (Codex `app-server`, `gemini --experimental-acp`) fold their own native + * protocols into the same vocabulary. + */ + +/** + * One structured event from an agent turn, forwarded to the app over the + * framed endpoint. ACP-aligned names; a superset of what any single agent + * emits (a plain text model only ever produces `delta`/`result`). + */ +export type AgentFrame = + | { type: 'session'; sessionId: string; capabilities?: string[] } + | { type: 'delta'; text: string } + | { type: 'tool_call'; id: string; name: string; input?: unknown } + | { type: 'tool_result'; id: string; ok: boolean; content?: string } + | { type: 'permission_request'; id: string; tool: string; input?: unknown } + | { type: 'cost'; usd?: number; inputTokens?: number; outputTokens?: number } + | { type: 'result'; ok: boolean; text?: string; sessionId?: string; error?: string } + +/** The frame `type` discriminants, handy for exhaustive UI switches/tests. */ +export const AGENT_FRAME_TYPES = [ + 'session', + 'delta', + 'tool_call', + 'tool_result', + 'permission_request', + 'cost', + 'result' +] as const + +/** Reducer state while folding one turn's `stream-json` NDJSON into frames. */ +export interface StreamJsonFrameState { + text: string + sessionId?: string + /** Whether any partial (`stream_event`) delta arrived — if so, complete + * `assistant` text blocks are duplicates and must not be re-emitted. */ + sawPartialDelta: boolean + /** Set once a terminal `result` frame has been produced. */ + done: boolean + error?: string +} + +export const initialStreamJsonFrameState = (): StreamJsonFrameState => ({ + text: '', + sawPartialDelta: false, + done: false +}) + +/** + * Fold one NDJSON line of Claude Code `stream-json` output into frames. Pure, + * so the protocol mapping is unit-tested without spawning. Unlike + * {@link reduceStreamJsonLine} (deltas only) this preserves the tool-use, cost, + * and session structure the OpenAI protocol discards. + * + * Event shapes handled (all defensively): + * - `{type:'system',subtype:'init',session_id,tools?}` → a `session` frame + * (with the `capabilities` array when present, v2.1.205+). + * - `{type:'stream_event',event:{type:'content_block_delta',delta:{type:'text_delta',text}}}` + * → a `delta` frame. + * - `{type:'assistant',message:{content:[...]}}` → a `tool_call` frame per + * `tool_use` block, plus a `delta` frame per `text` block *only when no + * partial deltas streamed* (older CLIs / non-partial mode). + * - `{type:'user',message:{content:[{type:'tool_result',tool_use_id,content,is_error}]}}` + * → a `tool_result` frame per block (the agent's own tool ran). + * - `{type:'control_request',request:{subtype:'can_use_tool',tool_name,input}}` + * → a `permission_request` frame (the CLI is asking the client to approve a + * tool — the stream-json input-mode consent hook). + * - `{type:'result',...,total_cost_usd?,usage?}` → a `cost` frame (when cost or + * token usage is present) followed by the terminal `result` frame. + */ +export function foldStreamJsonFrames( + state: StreamJsonFrameState, + line: string +): { state: StreamJsonFrameState; frames: AgentFrame[] } { + let event: Record + try { + const parsed = JSON.parse(line) as unknown + if (!parsed || typeof parsed !== 'object') return { state, frames: [] } + event = parsed as Record + } catch { + return { state, frames: [] } // non-JSON noise on stdout — ignore + } + + const next: StreamJsonFrameState = { ...state } + const frames: AgentFrame[] = [] + if (typeof event.session_id === 'string') next.sessionId = event.session_id + + if (event.type === 'system' && event.subtype === 'init') { + if (typeof event.session_id === 'string') { + const capabilities = stringArray(event.capabilities) + frames.push({ + type: 'session', + sessionId: event.session_id, + ...(capabilities.length ? { capabilities } : {}) + }) + } + return { state: next, frames } + } + + if (event.type === 'stream_event') { + const inner = asRecord(event.event) + if (inner.type === 'content_block_delta') { + const delta = asRecord(inner.delta) + if (delta.type === 'text_delta' && typeof delta.text === 'string' && delta.text) { + next.sawPartialDelta = true + next.text += delta.text + frames.push({ type: 'delta', text: delta.text }) + } + } + return { state: next, frames } + } + + if (event.type === 'assistant') { + const message = asRecord(event.message) + const blocks = Array.isArray(message.content) ? message.content : [] + for (const raw of blocks) { + const block = asRecord(raw) + if ( + block.type === 'tool_use' && + typeof block.id === 'string' && + typeof block.name === 'string' + ) { + frames.push({ + type: 'tool_call', + id: block.id, + name: block.name, + ...(block.input !== undefined ? { input: block.input } : {}) + }) + } else if (block.type === 'text' && typeof block.text === 'string' && block.text) { + // Complete text blocks are duplicates once partial deltas streamed. + if (!next.sawPartialDelta) { + next.text += block.text + frames.push({ type: 'delta', text: block.text }) + } + } + } + return { state: next, frames } + } + + if (event.type === 'user') { + const message = asRecord(event.message) + const blocks = Array.isArray(message.content) ? message.content : [] + for (const raw of blocks) { + const block = asRecord(raw) + if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') { + frames.push({ + type: 'tool_result', + id: block.tool_use_id, + ok: block.is_error !== true, + ...(typeof block.content === 'string' ? { content: block.content } : {}) + }) + } + } + return { state: next, frames } + } + + if (event.type === 'control_request') { + const request = asRecord(event.request) + if (request.subtype === 'can_use_tool' && typeof request.tool_name === 'string') { + const id = + typeof event.request_id === 'string' + ? event.request_id + : typeof request.tool_use_id === 'string' + ? request.tool_use_id + : request.tool_name + frames.push({ + type: 'permission_request', + id, + tool: request.tool_name, + ...(request.input !== undefined ? { input: request.input } : {}) + }) + } + return { state: next, frames } + } + + if (event.type === 'result') { + const cost = costFrame(event) + if (cost) frames.push(cost) + const isError = event.is_error === true || (event.subtype && event.subtype !== 'success') + if (isError) { + next.error = + typeof event.result === 'string' && event.result + ? event.result + : `agent turn failed (${String(event.subtype ?? 'error')})` + next.done = true + frames.push({ type: 'result', ok: false, error: next.error }) + return { state: next, frames } + } + // A `result` with text is the authoritative full reply when nothing + // streamed; fold it in so the terminal frame always carries the text. + if (!next.text && typeof event.result === 'string' && event.result) next.text = event.result + next.done = true + frames.push({ + type: 'result', + ok: true, + ...(next.text ? { text: next.text } : {}), + ...(next.sessionId ? { sessionId: next.sessionId } : {}) + }) + return { state: next, frames } + } + + return { state: next, frames } +} + +function costFrame(event: Record): AgentFrame | undefined { + const usd = numberOf(event.total_cost_usd) + const usage = asRecord(event.usage) + const inputTokens = numberOf(usage.input_tokens) + const outputTokens = numberOf(usage.output_tokens) + if (usd === undefined && inputTokens === undefined && outputTokens === undefined) return undefined + return { + type: 'cost', + ...(usd !== undefined ? { usd } : {}), + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}) + } +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' ? (value as Record) : {} +} + +function numberOf(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [] +} diff --git a/packages/devkit/src/bridge-server.test.ts b/packages/devkit/src/bridge-server.test.ts index d609e9faa..5b7106ce0 100644 --- a/packages/devkit/src/bridge-server.test.ts +++ b/packages/devkit/src/bridge-server.test.ts @@ -1,3 +1,4 @@ +import type { AgentFrame } from './agent-frames' import { request } from 'node:http' import { afterEach, describe, expect, it } from 'vitest' import { @@ -5,7 +6,12 @@ import { type BridgeServerConfig, type BridgeServerHandle } from './bridge-server' -import { fakeChatAgent, type StreamingChatAgent, type StreamTurnRequest } from './chat-agent' +import { + fakeChatAgent, + type FramedChatAgent, + type StreamingChatAgent, + type StreamTurnRequest +} from './chat-agent' /** * Raw loopback GET with a caller-chosen `Host` header. `fetch`/undici silently @@ -331,3 +337,179 @@ describe('createBridgeServer with a streaming agent', () => { expect(text).toContain('data: [DONE]') }) }) + +// ─── Framed endpoint (exploration 0392) ───────────────────────────────────────── + +/** Parse the `data: ` frames from a framed-endpoint SSE body. */ +function parseFrames(body: string): Array> { + return body + .split('\n\n') + .map((block) => block.replace(/^data: /, '').trim()) + .filter((line) => line && line !== '[DONE]') + .map((line) => JSON.parse(line) as Record) +} + +/** A FramedChatAgent that replays a scripted frame sequence. */ +function fakeFramedAgent( + frames: AgentFrame[], + sessionId = 'sess-1' +): FramedChatAgent & { turns: StreamTurnRequest[] } { + const turns: StreamTurnRequest[] = [] + // Mirror cliStreamingChatAgent: the returned text is the streamed deltas, or + // the terminal result frame's text when nothing streamed. + const deltaText = frames + .filter((f): f is Extract => f.type === 'delta') + .map((f) => f.text) + .join('') + const resultText = frames.find( + (f): f is Extract => f.type === 'result' + )?.text + const text = deltaText || resultText || '' + return { + turns, + async streamTurnFrames(turn, onFrame) { + turns.push(turn) + for (const frame of frames) { + onFrame(frame) + await Promise.resolve() + } + return { text, sessionId } + }, + async streamTurn(turn, onDelta) { + turns.push(turn) + onDelta(text) + return { text, sessionId } + }, + async chat() { + return text + } + } +} + +describe('createBridgeServer framed endpoint (/v1/agent/stream)', () => { + it('requires the pairing token', async () => { + const url = await start() + const res = await fetch(`${url}/v1/agent/stream`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }) + }) + expect(res.status).toBe(401) + }) + + it('forwards structured frames (session, delta, tool_call, result) as SSE', async () => { + const agent = fakeFramedAgent( + [ + { type: 'session', sessionId: 'sess-9' }, + { type: 'delta', text: 'work' }, + { type: 'tool_call', id: 'tu-1', name: 'xnet_update', input: { id: 'n1' } }, + { type: 'tool_result', id: 'tu-1', ok: true }, + { type: 'result', ok: true, text: 'work', sessionId: 'sess-9' } + ], + 'sess-9' + ) + const url = await start({ agent }) + const res = await fetch(`${url}/v1/agent/stream`, { + method: 'POST', + headers: authed, + body: JSON.stringify({ messages: [{ role: 'user', content: 'go' }] }) + }) + expect(res.headers.get('content-type')).toContain('text/event-stream') + const frames = parseFrames(await res.text()) + expect(frames.map((f) => f.type)).toEqual([ + 'session', + 'delta', + 'tool_call', + 'tool_result', + 'result' + ]) + expect(frames[2]).toMatchObject({ name: 'xnet_update', input: { id: 'n1' } }) + }) + + it('resumes the CLI session on the conversation follow-up turn', async () => { + const agent = fakeFramedAgent( + [{ type: 'result', ok: true, text: 'hi there', sessionId: 'sess-42' }], + 'sess-42' + ) + const url = await start({ agent }) + const turn1 = [{ role: 'user', content: 'hello' }] + await fetch(`${url}/v1/agent/stream`, { + method: 'POST', + headers: authed, + body: JSON.stringify({ messages: turn1 }) + }) + expect(agent.turns[0].resumeSessionId).toBeUndefined() + await fetch(`${url}/v1/agent/stream`, { + method: 'POST', + headers: authed, + body: JSON.stringify({ + messages: [ + ...turn1, + { role: 'assistant', content: 'hi there' }, + { role: 'user', content: 'more' } + ] + }) + }) + expect(agent.turns[1].resumeSessionId).toBe('sess-42') + expect(agent.turns[1].prompt).toBe('more') + }) + + it('synthesizes delta+result frames for a plain (non-framed) agent', async () => { + const url = await start({ agent: fakeChatAgent(() => 'plain reply') }) + const res = await fetch(`${url}/v1/agent/stream`, { + method: 'POST', + headers: authed, + body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }) + }) + const frames = parseFrames(await res.text()) + expect(frames).toEqual([ + { type: 'delta', text: 'plain reply' }, + { type: 'result', ok: true, text: 'plain reply' } + ]) + }) + + it('surfaces a mid-stream failure as a terminal error result frame', async () => { + const failing: FramedChatAgent = { + async streamTurnFrames(_turn, onFrame) { + onFrame({ type: 'delta', text: 'partial' }) + throw new Error('cli died') + }, + async streamTurn() { + return { text: '' } + }, + async chat() { + return '' + } + } + const url = await start({ agent: failing }) + const res = await fetch(`${url}/v1/agent/stream`, { + method: 'POST', + headers: authed, + body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }) + }) + expect(res.status).toBe(200) + const frames = parseFrames(await res.text()) + expect(frames.at(-1)).toEqual({ type: 'result', ok: false, error: 'cli died' }) + }) + + it('answers 502 when a framed agent fails before any frame', async () => { + const failing: FramedChatAgent = { + async streamTurnFrames() { + throw new Error('spawn failed') + }, + async streamTurn() { + return { text: '' } + }, + async chat() { + return '' + } + } + const url = await start({ agent: failing }) + const res = await fetch(`${url}/v1/agent/stream`, { + method: 'POST', + headers: authed, + body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }) + }) + expect(res.status).toBe(502) + }) +}) diff --git a/packages/devkit/src/bridge-server.ts b/packages/devkit/src/bridge-server.ts index 663f69fd4..48f54107b 100644 --- a/packages/devkit/src/bridge-server.ts +++ b/packages/devkit/src/bridge-server.ts @@ -27,12 +27,18 @@ * the bridge before pairing. */ -import { isStreamingChatAgent, type ChatAgent, type ChatMessage } from './chat-agent' +import type { AgentFrame } from './agent-frames' import type { AgentTaskResult } from './dev-loop' import { randomBytes, timingSafeEqual } from 'node:crypto' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { bridgeHealth, type BridgeRunRequest } from './bridge' -import { createBridgeSessionStore } from './bridge-sessions' +import { createBridgeSessionStore, type BridgeSessionStore } from './bridge-sessions' +import { + isFramedChatAgent, + isStreamingChatAgent, + type ChatAgent, + type ChatMessage +} from './chat-agent' const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']) /** Default port — the address the connector ladder (0174) probes. */ @@ -70,6 +76,12 @@ export interface BridgeServerConfig { * gate), so callers enable it explicitly. */ run?: (request: BridgeRunRequest) => Promise + /** + * Conversation → CLI-session map. Defaults to an in-memory store (lost on + * restart); pass a durable one (see `createBridgeSessionStore` with + * {@link SessionPersistence}) so sessions survive a daemon restart. + */ + sessions?: BridgeSessionStore } export interface BridgeServerHandle { @@ -93,9 +105,10 @@ export function createBridgeServer(config: BridgeServerConfig): BridgeServerHand const pairingToken = config.pairingToken ?? randomBytes(24).toString('base64url') const agentName = config.agentName ?? 'agent' const version = config.version ?? '0.1.0' - // Conversation → CLI-session map (per daemon launch; a restart just means - // the next turn re-seeds a fresh session with full history). - const sessions = createBridgeSessionStore() + // Conversation → CLI-session map. In-memory by default (a restart re-seeds a + // fresh session with full history); a durable store injected via config.sessions + // continues sessions across restarts (exploration 0392). + const sessions = config.sessions ?? createBridgeSessionStore() let boundPort = requestedPort let server: Server | undefined @@ -182,6 +195,61 @@ export function createBridgeServer(config: BridgeServerConfig): BridgeServerHand return } + // The framed endpoint (exploration 0392): same session planning and token + // discipline as the OpenAI endpoint, but forwards structured AgentFrames + // (tool calls, cost, session, permission requests) instead of flattening + // everything to text. The panel renders these; other OpenAI-compatible + // clients keep using /v1/chat/completions above. + if (req.method === 'POST' && path === '/v1/agent/stream') { + if (!isTokenValid(headerStr(req.headers.authorization), pairingToken)) { + sendJson(res, 401, { error: { message: 'invalid or missing pairing token' } }) + return + } + let body: Record + try { + body = await readJson(req) + } catch (err) { + sendJson(res, 400, { error: { message: messageOf(err) } }) + return + } + const messages = parseMessages(body) + const plan = sessions.plan(messages) + const sse = createFrameStream(res) + try { + if (isFramedChatAgent(config.agent)) { + // The frame reducer emits its own terminal `result` frame. + const result = await config.agent.streamTurnFrames(plan, (frame) => sse.frame(frame)) + if (result.sessionId) sessions.record(messages, result.text, result.sessionId) + } else if (isStreamingChatAgent(config.agent)) { + const result = await config.agent.streamTurn(plan, (delta) => + sse.frame({ type: 'delta', text: delta }) + ) + if (result.sessionId) sessions.record(messages, result.text, result.sessionId) + sse.frame({ + type: 'result', + ok: true, + ...(result.text ? { text: result.text } : {}), + ...(result.sessionId ? { sessionId: result.sessionId } : {}) + }) + } else { + const text = await config.agent.chat(messages) + if (text) sse.frame({ type: 'delta', text }) + sse.frame({ type: 'result', ok: true, ...(text ? { text } : {}) }) + } + sse.done() + } catch (err) { + // Mirror the OpenAI path: a pre-stream failure is a clean 502; a + // mid-stream one is surfaced as a terminal error `result` frame. + if (sse.started) { + sse.frame({ type: 'result', ok: false, error: messageOf(err) }) + sse.done() + } else { + sendJson(res, 502, { error: { message: messageOf(err) } }) + } + } + return + } + if (req.method === 'POST' && path === '/run') { if (!isTokenValid(headerStr(req.headers.authorization), pairingToken)) { sendJson(res, 401, { error: { message: 'invalid or missing pairing token' } }) @@ -389,6 +457,48 @@ function createSseStream(res: ServerResponse, model: string): SseStream { } } +interface FrameStream { + readonly started: boolean + /** Write one AgentFrame as an SSE `data:` line (starts the stream on first). */ + frame(frame: AgentFrame): void + /** Close the stream with the `[DONE]` sentinel. */ + done(): void +} + +/** + * An SSE writer for the framed endpoint (exploration 0392): each + * {@link AgentFrame} is one `data: ` line, terminated by `[DONE]` (the + * same sentinel the OpenAI stream uses, so the panel's reader is symmetric). + * Headers flush on the first frame so a pre-stream failure can still be a clean + * HTTP 502. + */ +function createFrameStream(res: ServerResponse): FrameStream { + let started = false + const start = (): void => { + if (started) return + started = true + res.statusCode = 200 + res.setHeader('content-type', 'text/event-stream') + res.setHeader('cache-control', 'no-cache') + res.setHeader('connection', 'keep-alive') + res.flushHeaders?.() + } + return { + get started() { + return started + }, + frame(frame) { + start() + res.write(`data: ${JSON.stringify(frame)}\n\n`) + }, + done() { + start() + res.write('data: [DONE]\n\n') + res.end() + } + } +} + /** Stream the reply as OpenAI-style SSE chunks (one content delta, then DONE). */ function sendSse(res: ServerResponse, text: string, model: string): void { res.statusCode = 200 diff --git a/packages/devkit/src/bridge-sessions.test.ts b/packages/devkit/src/bridge-sessions.test.ts index 3de4be8da..a3b7d390a 100644 --- a/packages/devkit/src/bridge-sessions.test.ts +++ b/packages/devkit/src/bridge-sessions.test.ts @@ -1,6 +1,14 @@ import type { ChatMessage } from './chat-agent' -import { describe, expect, it } from 'vitest' -import { createBridgeSessionStore, transcriptKey } from './bridge-sessions' +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + createBridgeSessionStore, + fileSessionPersistence, + transcriptKey, + type SessionPersistence +} from './bridge-sessions' const m = (role: ChatMessage['role'], content: string): ChatMessage => ({ role, content }) @@ -80,3 +88,65 @@ describe('createBridgeSessionStore', () => { ).toBe('s-c') }) }) + +describe('durable session persistence (exploration 0392)', () => { + it('seeds from persistence and writes through on record', () => { + const backing: Array<[string, string]> = [] + const persistence: SessionPersistence = { + load: () => backing.slice(), + save: (entries) => { + backing.length = 0 + backing.push(...entries) + } + } + const store = createBridgeSessionStore({ persistence }) + store.record([m('user', 'q')], 'a', 'sess-9') + expect(backing).toHaveLength(1) + + // A "restart": a brand-new store seeded from the same backing resumes it. + const restarted = createBridgeSessionStore({ persistence }) + const plan = restarted.plan([m('user', 'q'), m('assistant', 'a'), m('user', 'next')]) + expect(plan.resumeSessionId).toBe('sess-9') + }) + + it('respects the limit when seeding a large persisted map', () => { + const persisted: Array<[string, string]> = [ + ['k1', 's1'], + ['k2', 's2'], + ['k3', 's3'] + ] + const store = createBridgeSessionStore({ + limit: 2, + persistence: { load: () => persisted, save: () => {} } + }) + expect(store.size).toBe(2) + }) +}) + +describe('fileSessionPersistence', () => { + const dirs: string[] = [] + afterEach(() => { + dirs.length = 0 + }) + + it('round-trips the map across a simulated daemon restart', () => { + const dir = mkdtempSync(join(tmpdir(), 'xnet-bridge-sess-')) + dirs.push(dir) + const file = join(dir, 'nested', 'bridge-sessions.json') + + const first = createBridgeSessionStore({ persistence: fileSessionPersistence(file) }) + first.record([m('user', 'hello')], 'hi there', 'sess-1') + expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({ version: 1 }) + + const second = createBridgeSessionStore({ persistence: fileSessionPersistence(file) }) + const plan = second.plan([m('user', 'hello'), m('assistant', 'hi there'), m('user', 'again')]) + expect(plan.resumeSessionId).toBe('sess-1') + }) + + it('loads empty (not throwing) when the file is missing or corrupt', () => { + const dir = mkdtempSync(join(tmpdir(), 'xnet-bridge-sess-')) + dirs.push(dir) + const missing = fileSessionPersistence(join(dir, 'does-not-exist.json')) + expect(missing.load()).toBeUndefined() + }) +}) diff --git a/packages/devkit/src/bridge-sessions.ts b/packages/devkit/src/bridge-sessions.ts index b3cf5a767..aa193cf3b 100644 --- a/packages/devkit/src/bridge-sessions.ts +++ b/packages/devkit/src/bridge-sessions.ts @@ -16,9 +16,18 @@ * When nothing matches (daemon restarted, edited history, first turn) we fall * back to a fresh session seeded with the full flattened history — never a * context-less resume, so a miss costs latency, not correctness. + * + * A daemon restart used to lose the whole map (exploration 0391 called this + * out), forcing every open conversation to re-seed. Exploration 0392 makes the + * map optionally **durable**: pass a {@link SessionPersistence} (the CLI wires + * {@link fileSessionPersistence} under `~/.xnet/agent-home`) and the map is + * seeded on start and written through on every record, so a restart continues + * the CLI sessions instead of amnesia. */ import { createHash } from 'node:crypto' +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { dirname } from 'node:path' import { flattenChat, type ChatMessage } from './chat-agent' /** One planned bridge turn: what to send, and which session to continue. */ @@ -36,6 +45,26 @@ export interface BridgeSessionStore { readonly size: number } +/** + * A durable backing store for the fingerprint → session map. Injected (rather + * than hard-coding `fs`) so the store stays unit-testable; {@link fileSessionPersistence} + * is the production file-backed implementation. + */ +export interface SessionPersistence { + /** Return the persisted `[fingerprint, sessionId]` entries, oldest-first, or `undefined`. */ + load(): Array<[string, string]> | undefined + /** Persist the current entries (oldest-first). Called on every record. */ + save(entries: Array<[string, string]>): void +} + +/** Options for {@link createBridgeSessionStore} (a bare number stays the limit). */ +export interface BridgeSessionStoreOptions { + /** Max fingerprints retained (oldest evicted). Default 256. */ + limit?: number + /** Optional durable backing — seeds on start, writes through on record. */ + persistence?: SessionPersistence +} + /** Hash of the user/assistant transcript (system/context messages excluded). */ export function transcriptKey(messages: readonly ChatMessage[]): string { const hash = createHash('sha256') @@ -49,8 +78,22 @@ export function transcriptKey(messages: readonly ChatMessage[]): string { return hash.digest('base64') } -export function createBridgeSessionStore(limit = 256): BridgeSessionStore { +export function createBridgeSessionStore( + options: number | BridgeSessionStoreOptions = {} +): BridgeSessionStore { + const { limit = 256, persistence } = + typeof options === 'number' ? { limit: options, persistence: undefined } : options const sessions = new Map() + // Seed from the durable store (oldest-first, so eviction order is preserved). + const persisted = persistence?.load() + if (persisted) { + for (const [key, sessionId] of persisted) sessions.set(key, sessionId) + while (sessions.size > limit) { + const oldest = sessions.keys().next().value + if (oldest === undefined) break + sessions.delete(oldest) + } + } return { get size() { return sessions.size @@ -82,6 +125,39 @@ export function createBridgeSessionStore(limit = 256): BridgeSessionStore { if (oldest === undefined) break sessions.delete(oldest) } + persistence?.save([...sessions.entries()]) + } + } +} + +/** + * A file-backed {@link SessionPersistence}. Stores the map as JSON + * (`{ version, entries: [[fingerprint, sessionId], …] }`) at `filePath`. A + * missing or corrupt file loads as empty (so a restart degrades to full-history + * re-seed, never a crash); the parent directory is created on first save. + */ +export function fileSessionPersistence(filePath: string): SessionPersistence { + return { + load() { + try { + const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown + const entries = (parsed as { entries?: unknown })?.entries + if (!Array.isArray(entries)) return undefined + return entries.filter( + (e): e is [string, string] => + Array.isArray(e) && typeof e[0] === 'string' && typeof e[1] === 'string' + ) + } catch { + return undefined // missing / unreadable / corrupt → start fresh + } + }, + save(entries) { + try { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify({ version: 1, entries }), 'utf8') + } catch { + // Best-effort durability: a write failure must never break a live turn. + } } } } diff --git a/packages/devkit/src/chat-agent.ts b/packages/devkit/src/chat-agent.ts index 5fdc2aabe..22f38cad5 100644 --- a/packages/devkit/src/chat-agent.ts +++ b/packages/devkit/src/chat-agent.ts @@ -9,8 +9,9 @@ * back to xNet's chat panel. */ -import { buildStreamingAgentArgs, type AgentLaunchOptions } from './agent-launch' import type { CommandRunner, LineRunner } from './command-runner' +import { foldStreamJsonFrames, initialStreamJsonFrameState, type AgentFrame } from './agent-frames' +import { buildStreamingAgentArgs, type AgentLaunchOptions } from './agent-launch' export interface ChatMessage { role: 'system' | 'user' | 'assistant' @@ -149,6 +150,24 @@ export function isStreamingChatAgent(agent: ChatAgent): agent is StreamingChatAg return typeof (agent as Partial).streamTurn === 'function' } +/** + * A {@link StreamingChatAgent} that can also stream a turn as structured + * {@link AgentFrame}s (exploration 0392): tool calls, cost, and session id in + * addition to text deltas. The bridge's framed endpoint (`/v1/agent/stream`) + * uses this; the OpenAI-compatible endpoint stays on {@link StreamingChatAgent} + * so its wire is unchanged. + */ +export interface FramedChatAgent extends StreamingChatAgent { + streamTurnFrames( + turn: StreamTurnRequest, + onFrame: (frame: AgentFrame) => void + ): Promise +} + +export function isFramedChatAgent(agent: ChatAgent): agent is FramedChatAgent { + return typeof (agent as Partial).streamTurnFrames === 'function' +} + /** Reducer state while consuming one turn's `stream-json` NDJSON events. */ export interface StreamJsonState { text: string @@ -259,10 +278,35 @@ export interface CliStreamingChatAgentOptions { export function cliStreamingChatAgent( lines: LineRunner, options: CliStreamingChatAgentOptions -): StreamingChatAgent { +): FramedChatAgent { const idleTimeoutMs = options.idleTimeoutMs ?? 180_000 return { + async streamTurnFrames(turn, onFrame) { + const args = buildStreamingAgentArgs(turn.prompt, { + ...options.launch, + ...(turn.resumeSessionId ? { resumeSessionId: turn.resumeSessionId } : {}) + }) + let state = initialStreamJsonFrameState() + for await (const line of lines.stream(options.command, args, { + cwd: options.cwd, + idleTimeoutMs + })) { + const step = foldStreamJsonFrames(state, line) + state = step.state + for (const frame of step.frames) onFrame(frame) + if (state.error) break + } + if (state.error) throw new Error(state.error) + return { + text: state.text.trim(), + ...(state.sessionId ? { sessionId: state.sessionId } : {}) + } + }, async streamTurn(turn, onDelta) { + // Kept on the original delta reducer (not re-expressed via frames): the + // OpenAI-compatible endpoint depends on its exact behaviour, including + // the `result`-only text fallback, which the frame reducer folds into + // the terminal `result` frame instead of a `delta`. const args = buildStreamingAgentArgs(turn.prompt, { ...options.launch, ...(turn.resumeSessionId ? { resumeSessionId: turn.resumeSessionId } : {}) diff --git a/packages/devkit/src/index.ts b/packages/devkit/src/index.ts index c19c25d3d..400f17151 100644 --- a/packages/devkit/src/index.ts +++ b/packages/devkit/src/index.ts @@ -64,6 +64,7 @@ export { cliChatAgent, cliStreamingChatAgent, isStreamingChatAgent, + isFramedChatAgent, fakeChatAgent, openAiChatAgent, flattenChat, @@ -75,15 +76,27 @@ export { type CliStreamingChatAgentOptions, type OpenAiChatAgentOptions, type StreamingChatAgent, + type FramedChatAgent, type StreamTurnRequest, type StreamTurnResult, type StreamJsonState } from './chat-agent' +export { + foldStreamJsonFrames, + initialStreamJsonFrameState, + AGENT_FRAME_TYPES, + type AgentFrame, + type StreamJsonFrameState +} from './agent-frames' + export { createBridgeSessionStore, + fileSessionPersistence, transcriptKey, type BridgeSessionStore, + type BridgeSessionStoreOptions, + type SessionPersistence, type BridgeTurnPlan } from './bridge-sessions' diff --git a/packages/plugins/src/ai/index.ts b/packages/plugins/src/ai/index.ts index 3aeb50905..e4638746a 100644 --- a/packages/plugins/src/ai/index.ts +++ b/packages/plugins/src/ai/index.ts @@ -20,6 +20,8 @@ export { createAIProviderRouter, isOllamaAvailable, listOllamaModels, + isOpenRouterBaseUrl, + OPENROUTER_ATTRIBUTION_HEADERS, AIGenerationError } from './providers' export type { @@ -48,6 +50,16 @@ export type { OpenAICompatibleProviderOptions } from './providers' +// models.dev catalog (exploration 0392) +export { + fetchModelsDevCatalog, + parseModelsDevCatalog, + modelsForProvider, + MODELS_DEV_API_URL, + MODELS_DEV_SNAPSHOT +} from './models-dev' +export type { ModelCatalogEntry, ModelCatalogResult, FetchModelsDevOptions } from './models-dev' + // Generator export { ScriptGenerator, ScriptGenerationError, generateScript } from './generator' export type { AIScriptResponse, ScriptGeneratorOptions } from './generator' diff --git a/packages/plugins/src/ai/models-dev.test.ts b/packages/plugins/src/ai/models-dev.test.ts new file mode 100644 index 000000000..edb524b96 --- /dev/null +++ b/packages/plugins/src/ai/models-dev.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import { + fetchModelsDevCatalog, + parseModelsDevCatalog, + modelsForProvider, + MODELS_DEV_SNAPSHOT +} from './models-dev' + +/** A minimal models.dev api.json fixture. */ +const API_FIXTURE = { + anthropic: { + id: 'anthropic', + name: 'Anthropic', + models: { + 'claude-sonnet-5': { + name: 'Claude Sonnet 5', + cost: { input: 3, output: 15 }, + limit: { context: 200000, output: 64000 }, + tool_call: true, + reasoning: true + } + } + }, + ollama: { + id: 'ollama', + name: 'Ollama', + models: { + 'llama3.2': { name: 'Llama 3.2', limit: { context: 128000 } } + } + } +} + +const okResponse = (body: unknown): Response => + ({ ok: true, status: 200, json: async () => body }) as Response + +describe('parseModelsDevCatalog', () => { + it('flattens provider→model into qualified entries', () => { + const models = parseModelsDevCatalog(API_FIXTURE) + const sonnet = models.find((m) => m.id === 'anthropic/claude-sonnet-5') + expect(sonnet).toMatchObject({ + provider: 'anthropic', + model: 'claude-sonnet-5', + name: 'Claude Sonnet 5', + contextLength: 200000, + inUsdPerM: 3, + outUsdPerM: 15, + toolCall: true, + reasoning: true + }) + }) + + it('defaults missing fields defensively (no cost/flags → null/false)', () => { + const models = parseModelsDevCatalog(API_FIXTURE) + const llama = models.find((m) => m.id === 'ollama/llama3.2') + expect(llama).toMatchObject({ + contextLength: 128000, + inUsdPerM: null, + outUsdPerM: null, + toolCall: false, + reasoning: false + }) + }) + + it('returns empty for junk input', () => { + expect(parseModelsDevCatalog(null)).toEqual([]) + expect(parseModelsDevCatalog('nope')).toEqual([]) + expect(parseModelsDevCatalog({ anthropic: { models: 'bad' } })).toEqual([]) + }) +}) + +describe('fetchModelsDevCatalog', () => { + it('returns the live catalog when the fetch succeeds', async () => { + const fetchImpl = vi.fn(async () => okResponse(API_FIXTURE)) as unknown as typeof fetch + const result = await fetchModelsDevCatalog({ fetchImpl }) + expect(result.source).toBe('network') + expect(result.models.map((m) => m.id)).toContain('anthropic/claude-sonnet-5') + }) + + it('falls back to the snapshot on a non-2xx response', async () => { + const fetchImpl = vi.fn( + async () => ({ ok: false, status: 503 }) as Response + ) as unknown as typeof fetch + const result = await fetchModelsDevCatalog({ fetchImpl }) + expect(result.source).toBe('snapshot') + expect(result.models).toEqual([...MODELS_DEV_SNAPSHOT]) + }) + + it('falls back to the snapshot when the fetch throws (outage)', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('network down') + }) as unknown as typeof fetch + const result = await fetchModelsDevCatalog({ fetchImpl }) + expect(result.source).toBe('snapshot') + expect(result.models.length).toBeGreaterThan(0) + }) + + it('falls back to the snapshot when the body parses to zero models', async () => { + const fetchImpl = vi.fn(async () => okResponse({})) as unknown as typeof fetch + const result = await fetchModelsDevCatalog({ fetchImpl }) + expect(result.source).toBe('snapshot') + }) +}) + +describe('modelsForProvider', () => { + it('filters to one provider', () => { + const models = parseModelsDevCatalog(API_FIXTURE) + expect(modelsForProvider(models, 'ollama').map((m) => m.model)).toEqual(['llama3.2']) + }) +}) diff --git a/packages/plugins/src/ai/models-dev.ts b/packages/plugins/src/ai/models-dev.ts new file mode 100644 index 000000000..59e27d710 --- /dev/null +++ b/packages/plugins/src/ai/models-dev.ts @@ -0,0 +1,184 @@ +/** + * models.dev catalog consumer (exploration 0392). + * + * The cloud-key and local connector tiers had hand-maintained model lists — + * prices and context windows that drift the moment a provider ships a new + * model. models.dev (github.com/sst/models.dev, the OpenCode team's open + * registry) is the community-maintained source of truth the whole ecosystem + * consumes as static JSON: capabilities, context/output limits, and per-1M + * costs for every provider and model. + * + * This module fetches `https://models.dev/api.json`, flattens the + * provider→model tree into a flat {@link ModelCatalogEntry[]}, and — because a + * model picker must never hang on a third-party outage — falls back to a small + * **vendored snapshot** ({@link MODELS_DEV_SNAPSHOT}) when the fetch fails or + * returns junk. The managed tier keeps its own hub-authoritative catalog + * (negotiated pricing); this is for the tiers that talk to providers directly. + * + * Pure except for the injectable `fetch`, so it is unit-tested without network. + */ + +/** One model from the catalog, flattened across providers. */ +export interface ModelCatalogEntry { + /** Provider-qualified id, e.g. `anthropic/claude-sonnet-5`. */ + id: string + /** models.dev provider id, e.g. `anthropic`. */ + provider: string + /** Bare model id within the provider, e.g. `claude-sonnet-5`. */ + model: string + /** Human label, e.g. `Claude Sonnet 5`. */ + name: string + /** Max context window in tokens, or null when unknown. */ + contextLength: number | null + /** USD per 1M input tokens, or null when unknown. */ + inUsdPerM: number | null + /** USD per 1M output tokens, or null when unknown. */ + outUsdPerM: number | null + /** Whether the model can call tools (gates agentic writes). */ + toolCall: boolean + /** Whether the model exposes reasoning/thinking. */ + reasoning: boolean +} + +export interface ModelCatalogResult { + models: ModelCatalogEntry[] + /** `'network'` when fetched live, `'snapshot'` when the vendored fallback was used. */ + source: 'network' | 'snapshot' +} + +/** The canonical live endpoint. */ +export const MODELS_DEV_API_URL = 'https://models.dev/api.json' + +/** + * A tiny vendored snapshot for offline / outage fallback. Deliberately small — + * enough that the picker is never empty, not a mirror of the whole registry + * (that is what the live fetch is for). Kept current-ish by hand; the live + * fetch is always preferred. + */ +export const MODELS_DEV_SNAPSHOT: readonly ModelCatalogEntry[] = Object.freeze([ + { + id: 'anthropic/claude-sonnet-5', + provider: 'anthropic', + model: 'claude-sonnet-5', + name: 'Claude Sonnet 5', + contextLength: 200_000, + inUsdPerM: 3, + outUsdPerM: 15, + toolCall: true, + reasoning: true + }, + { + id: 'anthropic/claude-haiku-4-5', + provider: 'anthropic', + model: 'claude-haiku-4-5', + name: 'Claude Haiku 4.5', + contextLength: 200_000, + inUsdPerM: 1, + outUsdPerM: 5, + toolCall: true, + reasoning: false + }, + { + id: 'openai/gpt-5', + provider: 'openai', + model: 'gpt-5', + name: 'GPT-5', + contextLength: 400_000, + inUsdPerM: 1.25, + outUsdPerM: 10, + toolCall: true, + reasoning: true + }, + { + id: 'google/gemini-2.5-pro', + provider: 'google', + model: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + contextLength: 1_000_000, + inUsdPerM: 1.25, + outUsdPerM: 10, + toolCall: true, + reasoning: true + } +]) + +export interface FetchModelsDevOptions { + /** Endpoint to fetch. Default {@link MODELS_DEV_API_URL}. */ + url?: string + /** Injectable fetch (tests / non-browser hosts). Default: global `fetch`. */ + fetchImpl?: typeof fetch + /** Abort the fetch after this many ms. Default 8000. */ + timeoutMs?: number + /** Snapshot to fall back to. Default {@link MODELS_DEV_SNAPSHOT}. */ + snapshot?: readonly ModelCatalogEntry[] +} + +/** + * Fetch and flatten the models.dev catalog, falling back to the vendored + * snapshot on any failure (network error, non-2xx, malformed body, or an empty + * parse). Never throws — a model picker must always have something to show. + */ +export async function fetchModelsDevCatalog( + options: FetchModelsDevOptions = {} +): Promise { + const fetchImpl = options.fetchImpl ?? fetch + const snapshot = options.snapshot ?? MODELS_DEV_SNAPSHOT + try { + const response = await fetchImpl(options.url ?? MODELS_DEV_API_URL, { + signal: AbortSignal.timeout(options.timeoutMs ?? 8000) + }) + if (!response.ok) return { models: [...snapshot], source: 'snapshot' } + const models = parseModelsDevCatalog(await response.json()) + if (models.length === 0) return { models: [...snapshot], source: 'snapshot' } + return { models, source: 'network' } + } catch { + return { models: [...snapshot], source: 'snapshot' } + } +} + +/** + * Flatten the models.dev `api.json` shape — `{ [providerId]: { id, name, + * models: { [modelId]: {...} } } }` — into a flat entry list. Defensive: any + * missing/mistyped field degrades to a sensible default rather than throwing. + */ +export function parseModelsDevCatalog(data: unknown): ModelCatalogEntry[] { + if (!data || typeof data !== 'object') return [] + const entries: ModelCatalogEntry[] = [] + for (const [providerId, providerRaw] of Object.entries(data as Record)) { + const provider = asRecord(providerRaw) + const models = asRecord(provider.models) + for (const [modelId, modelRaw] of Object.entries(models)) { + const model = asRecord(modelRaw) + const cost = asRecord(model.cost) + const limit = asRecord(model.limit) + entries.push({ + id: `${providerId}/${modelId}`, + provider: providerId, + model: modelId, + name: typeof model.name === 'string' ? model.name : modelId, + contextLength: numberOrNull(limit.context), + inUsdPerM: numberOrNull(cost.input), + outUsdPerM: numberOrNull(cost.output), + toolCall: model.tool_call === true, + reasoning: model.reasoning === true + }) + } + } + return entries +} + +/** Filter a catalog to the models a given provider serves. */ +export function modelsForProvider( + catalog: readonly ModelCatalogEntry[], + provider: string +): ModelCatalogEntry[] { + return catalog.filter((m) => m.provider === provider) +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' ? (value as Record) : {} +} + +function numberOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} diff --git a/packages/plugins/src/ai/openrouter-attribution.test.ts b/packages/plugins/src/ai/openrouter-attribution.test.ts new file mode 100644 index 000000000..bdecce712 --- /dev/null +++ b/packages/plugins/src/ai/openrouter-attribution.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + OpenAICompatibleProvider, + OPENROUTER_ATTRIBUTION_HEADERS, + isOpenRouterBaseUrl +} from './providers' + +describe('isOpenRouterBaseUrl', () => { + it('matches OpenRouter hosts and rejects others', () => { + expect(isOpenRouterBaseUrl('https://openrouter.ai/api')).toBe(true) + expect(isOpenRouterBaseUrl('https://openrouter.ai/api/v1')).toBe(true) + expect(isOpenRouterBaseUrl('https://api.openai.com')).toBe(false) + expect(isOpenRouterBaseUrl('http://localhost:11434')).toBe(false) + }) +}) + +describe('OpenRouter app attribution headers', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + const chatBody = { choices: [{ message: { content: 'hi' } }], usage: {} } + + it('sends HTTP-Referer + X-Title only to OpenRouter', async () => { + const fetchMock = vi.fn( + async (_url: string, _init: RequestInit) => + new Response(JSON.stringify(chatBody), { status: 200 }) + ) + vi.stubGlobal('fetch', fetchMock) + + const openrouter = new OpenAICompatibleProvider({ + baseUrl: 'https://openrouter.ai/api', + apiKey: 'sk-or-x', + model: 'anthropic/claude-sonnet-5' + }) + await openrouter.generate('hello') + const orHeaders = fetchMock.mock.calls[0][1].headers as Record + expect(orHeaders['HTTP-Referer']).toBe(OPENROUTER_ATTRIBUTION_HEADERS['HTTP-Referer']) + expect(orHeaders['X-Title']).toBe('xNet') + + fetchMock.mockClear() + const openai = new OpenAICompatibleProvider({ + baseUrl: 'https://api.openai.com', + apiKey: 'sk-x', + model: 'gpt-5' + }) + await openai.generate('hello') + const oaHeaders = fetchMock.mock.calls[0][1].headers as Record + expect(oaHeaders['HTTP-Referer']).toBeUndefined() + expect(oaHeaders['X-Title']).toBeUndefined() + }) + + it('lets user-supplied headers override attribution', async () => { + const fetchMock = vi.fn( + async (_url: string, _init: RequestInit) => + new Response(JSON.stringify(chatBody), { status: 200 }) + ) + vi.stubGlobal('fetch', fetchMock) + const provider = new OpenAICompatibleProvider({ + baseUrl: 'https://openrouter.ai/api', + apiKey: 'sk-or-x', + model: 'anthropic/claude-sonnet-5', + defaultHeaders: { 'X-Title': 'My Fork' } + }) + await provider.generate('hello') + const headers = fetchMock.mock.calls[0][1].headers as Record + expect(headers['X-Title']).toBe('My Fork') + }) +}) diff --git a/packages/plugins/src/ai/providers.ts b/packages/plugins/src/ai/providers.ts index aed4d7a99..cce84f301 100644 --- a/packages/plugins/src/ai/providers.ts +++ b/packages/plugins/src/ai/providers.ts @@ -245,6 +245,25 @@ const createCapabilities = (overrides: Partial = {}): AIMod const normalizeBaseUrl = (baseUrl: string): string => baseUrl.replace(/\/+$/, '') +/** + * App-attribution headers OpenRouter reads to credit traffic to xNet on its + * public rankings/analytics (exploration 0392). `HTTP-Referer` is the app + * identity; `X-Title` its display name. Sent only to OpenRouter. + */ +export const OPENROUTER_ATTRIBUTION_HEADERS: Readonly> = Object.freeze({ + 'HTTP-Referer': 'https://xnet.fyi', + 'X-Title': 'xNet' +}) + +/** True when the base URL targets OpenRouter (attribution headers apply). */ +export function isOpenRouterBaseUrl(baseUrl: string): boolean { + try { + return new URL(baseUrl).hostname.endsWith('openrouter.ai') + } catch { + return baseUrl.includes('openrouter.ai') + } +} + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) @@ -634,6 +653,9 @@ export class OpenAICompatibleProvider implements AIProvider { private createHeaders(): Record { return { 'content-type': 'application/json', + // OpenRouter reads these for app attribution → public rankings/analytics + // (exploration 0392). Only sent to OpenRouter; user headers still win. + ...(isOpenRouterBaseUrl(this.baseUrl) ? OPENROUTER_ATTRIBUTION_HEADERS : {}), ...this.defaultHeaders, ...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}) } diff --git a/site/src/data/changelog/2026-07-22-structured-agent-frames-and-durable-sess.json b/site/src/data/changelog/2026-07-22-structured-agent-frames-and-durable-sess.json new file mode 100644 index 000000000..53ac31864 --- /dev/null +++ b/site/src/data/changelog/2026-07-22-structured-agent-frames-and-durable-sess.json @@ -0,0 +1,10 @@ +{ + "id": "2026-07-22-structured-agent-frames-and-durable-sess", + "date": "July 22, 2026", + "title": "Structured agent frames and durable sessions for the AI bridge", + "summary": "The local AI bridge can now stream tool calls, cost, and session info as structured frames over a new endpoint, keeps chat sessions across restarts, and sources model info from models.dev.", + "highlights": [], + "tags": [ + "ai" + ] +}