From abc96191879cb608b816e5e3bfb3644fe19d9164 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Wed, 17 Jun 2026 11:50:41 -0700 Subject: [PATCH 1/3] docs(exploration): explore driving Claude Code/Codex/any agent from XNet Co-Authored-By: Claude Opus 4.8 --- ...CLAUDE_CODE_CODEX_AND_ANY_AGENT_IN_XNET.md | 508 ++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 docs/explorations/0194_[_]_AGENT_BRIDGE_CLAUDE_CODE_CODEX_AND_ANY_AGENT_IN_XNET.md diff --git a/docs/explorations/0194_[_]_AGENT_BRIDGE_CLAUDE_CODE_CODEX_AND_ANY_AGENT_IN_XNET.md b/docs/explorations/0194_[_]_AGENT_BRIDGE_CLAUDE_CODE_CODEX_AND_ANY_AGENT_IN_XNET.md new file mode 100644 index 000000000..a3867a899 --- /dev/null +++ b/docs/explorations/0194_[_]_AGENT_BRIDGE_CLAUDE_CODE_CODEX_AND_ANY_AGENT_IN_XNET.md @@ -0,0 +1,508 @@ +# Agent Bridge — Driving Claude Code, Codex, And Any Agent From XNet's UI + +## Problem Statement + +> "Get Claude Code and Codex working with XNet. Ideally on the web deployment, +> but at least the Electron app. They should leverage your existing Claude Code +> or Codex subscription. Ideally it works with **any** agent (OpenCode, Kimi +> K2.5, a local coding agent…), primarily Claude and Codex, and the integration +> *just works* across all surfaces — so you can use XNet's UI directly to drive +> an agent that creates/edits plugins, modifies your workspace, writes +> documents, and builds canvases." + +XNet's in-app AI chat (exploration 0192) can now talk to a raw model and +read the workspace, but a raw model is not an *agent*: it has no tool-execution +loop, no file editing, no plan/approve/apply cycle. Meanwhile the user already +pays for **Claude Code** and **Codex** — agents that already have all of that. +The opportunity is to **drive those existing agents from XNet's UI** rather than +rebuild them, so the assistant can actually *do* things: edit pages, build +canvases, mutate databases, and author plugins. + +## Executive Summary + +The pieces are unusually well-aligned for this: + +- XNet already **exposes its workspace + code surface as MCP tools** + (`xnet mcp serve`, `AiSurfaceService`, 20+ `xnet_*` tools). That's the + *agent-to-tool* layer — done. +- XNet's chat panel already **probes for a "local bridge" daemon at + `http://127.0.0.1:31416/health`** (the `bridge` connector tier, preference + #1). That's the *UI-to-agent* hook — but **nothing serves it**. +- The whole industry just standardized the *UI-to-agent* layer as **ACP (Agent + Client Protocol)** — JSON-RPC over stdio, the "LSP for coding agents." Zed, + JetBrains, and others drive **Claude Code, Codex, Gemini CLI, and OpenCode** + through ACP adapters today. ACP sessions even declare their `mcpServers` in + the handshake, so **ACP (agent) + MCP (tools) compose in one wire-up**. +- The ToS-safe way to "use your subscription" is to **spawn the user's own + installed CLI** (`claude`, `codex`, …) as a subprocess — exactly XNet's + existing `cliAgentRunner` "bring-your-own-agent" model. (Reusing the + subscription *OAuth token* directly is banned; spawning the CLI is sanctioned + and now draws from a subscription "Agent SDK credit.") + +**The missing 20% is one component: the agent bridge daemon.** A loopback +process that (a) answers `/health` so the panel lights up the bridge tier, +(b) launches the chosen agent (Claude Code / Codex / OpenCode / …) as an ACP +subprocess, (c) hands that agent XNet's MCP tool server, and (d) streams the +agent's events (text, tool calls, permission requests, diffs) back to the panel. + +```mermaid +flowchart LR + subgraph XNet["XNet UI (web or Electron)"] + Panel["AiChatPanel
bridge tier → :31416"] + end + subgraph Bridge["Agent Bridge daemon :31416 (NEW)"] + Health["/health"] + ACPc["ACP client"] + end + subgraph Agent["User's agent CLI (their subscription)"] + CC["claude-code-acp"] + CX["codex / codex-acp"] + OC["opencode-acp (Kimi K2, …)"] + end + subgraph Tools["XNet MCP tool surface (EXISTS)"] + MCP["xnet_* tools → AiSurfaceService → NodeStore"] + DEV["devkit runAgentTask (plugin/code edits)"] + end + Panel -->|"detect + chat"| Health + Panel --> ACPc + ACPc -->|"spawn (ACP/stdio)"| CC & CX & OC + CC & CX & OC -->|"MCP (stdio)"| MCP + CC & CX & OC -. "code tasks" .-> DEV + MCP --> Workspace[("Workspace
pages/db/canvas")] +``` + +**Recommendation:** build the **ACP-based agent bridge daemon** as the single +unifying seam. It runs in Electron (spawns agents directly) and as a standalone +`xnet bridge serve` for the web deployment (browser → loopback daemon, reusing +the hardened-loopback pattern already built for MCP HTTP). This gets "any agent" +for free (ACP adapters exist for Claude/Codex/Gemini/OpenCode), reuses XNet's +MCP tools wholesale, and — because the *agent* owns the tool loop — delivers +full agentic workspace editing **without** XNet having to build its own tool +loop (exploration 0192, Phase 1b). + +## Current State In The Repository + +### The agent-to-tool layer is built (MCP) + +- **`xnet mcp serve`** ([cli/src/commands/mcp.ts](packages/cli/src/commands/mcp.ts)) + — stdio (default) + a hardened **`--http`** loopback transport. Backed by the + local API (`createRemoteAgentBackend`, default `http://127.0.0.1:31415`). +- **MCP HTTP transport** ([mcp-http.ts](packages/plugins/src/services/mcp-http.ts)) + — binds loopback only, **default port `31416`**, pairing-token (constant-time), + Origin allowlist (no wildcard), `Access-Control-Allow-Private-Network` + + `OPTIONS` preflight for Chrome's Local Network Access, and an unauthenticated + `GET /health` returning `{ ok: true, server }`. +- **MCP server + tools** ([mcp-server.ts](packages/plugins/src/services/mcp-server.ts)) + — `createMCPServer({ store, schemas })` wires `AiSurfaceService`; core tools + `xnet_search`, `xnet_read_page_markdown`, `xnet_plan_page_patch`, + `xnet_apply_page_markdown`, `xnet_database_query`, plus `xnet_create`, + `xnet_update`, `xnet_create_page`, `xnet_create_task`, canvas ops, etc., all + behind the mutation-plan + approval guardrail. + +### The UI-to-agent hook exists but is unserved + +- **Bridge detection** ([connectors/detect.ts:55](packages/plugins/src/ai/connectors/detect.ts)) + — `DEFAULT_BRIDGE_URL = 'http://127.0.0.1:31416'`; `defaultProbeBridge` GETs + `/health` and requires `{ ok: true }`. The `bridge` tier is **preference #1** + ("Local bridge (Claude Code / Codex subscription)"). +- **Bridge → provider mapping** + ([ai-chat-connector.ts:68](apps/web/src/workbench/views/ai-chat-connector.ts)) + — today maps the bridge tier to `{ type: 'openai-compatible', baseUrl }`, i.e. + it expects an OpenAI-style `/v1/chat/completions` at `:31416`. +- **⚠️ Port collision / gap:** the MCP HTTP transport's default port is **also + `31416`**, and it serves `/health` returning `{ ok: true }` — so + `xnet mcp serve --http` would make the panel *detect* a bridge, but POSTing + `/v1/chat/completions` there 404s (MCP serves `/mcp` JSON-RPC, not chat). The + ports already converge on `:31416`; what's missing at that address is an + **agent-chat / ACP endpoint**, not the tool surface. + +### The bring-your-own-agent runner is built (devkit) + +- **`cliAgentRunner`** ([devkit/src/agent.ts](packages/devkit/src/agent.ts)) — + spawns the user's own `claude` / `codex` / `aider` CLI (default args + `['-p', '{prompt}']` = Claude Code headless). "Zero model cost — it's the + user's subscription." `AgentRunner` port + `fakeAgentRunner` for tests. +- **`runAgentTask`** ([devkit/src/dev-loop.ts](packages/devkit/src/dev-loop.ts)) + — isolate (worktree) → agent edits → validation gate → **checkpoint on pass / + reset on fail** → `openPullRequest`. This is the *code/plugin authoring* path. +- **Bridge daemon logic** ([devkit/src/bridge.ts](packages/devkit/src/bridge.ts)) + — `bridgeHealth()`, `handleBridgeRun()`, `BridgeDeps`, `resolveWorktreePath` + (path-traversal hardened). **Pure logic only — no HTTP server serves it.** The + doc comment literally says "The Electron HTTP server is a thin shell" — that + shell does not exist. + +### Electron can host it; the precedent is right there + +- **Boot** ([apps/electron/src/main/index.ts](apps/electron/src/main/index.ts)) + — `app.whenReady()` runs `setupIPC()`, `setupLocalAPIIPC()`, + `setupCloudflareTunnelIPC()`, then `await startLocalAPI()`; teardown stops them + on quit. A bridge would slot in beside `startLocalAPI()`. +- **Child-process precedent** + ([cloudflare-tunnel-manager.ts](apps/electron/src/main/cloudflare-tunnel-manager.ts)) + — `spawn()`s `cloudflared`, parses stdout for readiness, auto-restarts on + crash, stops on quit. The exact lifecycle pattern an agent-bridge manager + needs. +- **`ProcessManager`** ([plugins/services/process-manager.ts](packages/plugins/src/services/process-manager.ts)) + — a full spawn/lifecycle/restart/health library… **built but not wired** into + the Electron boot. Ready to reuse. +- **Local API** ([electron/main/local-api.ts](apps/electron/src/main/local-api.ts)) + — already serves a loopback API on **`:31415`** with an IPC proxy to the + renderer's `NodeStore` + schema registry. The bridge's MCP server can point at + this (`--api-url http://127.0.0.1:31415`) so agent writes flow through the real + store. + +### The chat runtime anticipates this + +- **`AiAgentRuntime`** ([ai/runtime.ts](packages/plugins/src/ai/runtime.ts)) — + `AiAgentOrchestratorMode = 'custom' | 'codex-app-server' | 'hybrid'`. The + **`codex-app-server`** mode is already named: the design anticipates plugging + in Codex's app-server protocol. Approvals (`requestApproval` / + `resolveApproval`), `classifyAiAgentDisplayState` + (`read-only-answer` / `proposed-change` / `applied-change`), and a `tool.call` + event type already exist — the scaffolding for rendering agent activity. +- **0192 Phase 1a (shipped)** grounded the panel in the workspace (read-only + context). **Phase 1b** (build XNet's *own* tool loop) is open — but the bridge + path makes it **optional for agent tiers**, because Claude Code/Codex run their + own loop. + +## External Research + +### ACP — the "LSP for coding agents" (the UI-to-agent layer) + +- **What:** JSON-RPC 2.0 over stdio between a *client* (editor/UI) and an + *agent* subprocess; explicitly modeled on LSP. Zed/JetBrains implement it; + Anthropic's Claude Code and OpenAI's Codex plug in via adapters. + ([agentclientprotocol.com](https://agentclientprotocol.com/get-started/agents), + [Zed blog](https://zed.dev/blog/claude-code-via-acp)) +- **Composes with MCP:** "ACP handles the editor-to-agent layer, MCP handles the + agent-to-tool layer. Sessions are bootstrapped via `session/new`, which can + declare the `mcpServers` the agent should connect to — so ACP and MCP wire up + in a single handshake." + ([Morph](https://www.morphllm.com/agent-client-protocol)) +- **Adapters that already exist** (= "any agent" for free): + - `@zed-industries/claude-code-acp` / `claude-agent-acp` — Claude Code via the + Agent SDK over ACP. ([npm](https://www.npmjs.com/package/@zed-industries/claude-code-acp), + [claude-agent-acp](https://github.com/zed-industries/claude-agent-acp)) + - `codex-acp` — Codex over ACP. ([Codex+Zed](https://codex.danielvaughan.com/2026/05/05/codex-cli-in-zed-parallel-agents-acp-integration-ide-workflows/)) + - **Gemini CLI** — native ACP. + - `opencode-acp` — OpenCode over ACP; connects to a running OpenCode server via + `OPENCODE_URL`. OpenCode supports many providers/models (incl. **Kimi K2**). + ([opencode-acp](https://github.com/josephschmitt/opencode-acp)) + +### Driving the agents directly (without ACP) + +- **Claude Agent SDK (TS)** — `query()` returns an async generator streaming + messages; built-in **MCP** support; **`canUseTool`** permission callback + (allow/deny per tool call) — perfect for surfacing approvals in XNet's UI. + ([Agent SDK TS](https://code.claude.com/docs/en/agent-sdk/typescript), + [permissions](https://docs.claude.com/en/docs/agent-sdk/permissions)) +- **Codex app-server** — a long-lived process speaking **JSON-RPC as JSONL over + stdio**, hosting Codex "threads"; plus `codex exec --json` (one-shot JSONL) and + TS/Python SDKs that drive the app-server. MCP servers configured in + `~/.codex/config.toml` or via `codex mcp`. + ([Codex app-server](https://developers.openai.com/codex/app-server), + [Codex SDK](https://developers.openai.com/codex/sdk)) + +### The authentication / ToS reality (critical) + +- Anthropic **bans reusing Claude Free/Pro/Max OAuth tokens** for the Agent SDK + *outside* Claude Code and Claude.ai; third-party SDK integrations are expected + to use **API keys**. + ([alternativeto](https://alternativeto.net/news/2026/2/anthropic-officially-bans-using-subscription-authentication-for-third-party-claude-use)) +- **But** spawning the user's own installed CLI is the sanctioned path: as of + **2026-06-15**, "Agent SDK and `claude -p` usage on Claude subscription plans + draws from a monthly Agent SDK credit, separate from interactive limits." + ([Agent SDK overview](https://code.claude.com/docs/en/agent-sdk/overview)) +- **Implication:** XNet must **never extract or proxy the user's subscription + token**. It should **spawn the user's own CLI/adapter** (which authenticates + itself), or let the user supply an **API key**. This is exactly devkit's + `cliAgentRunner` model and keeps XNet on the right side of every provider ToS. + +### Optional: AG-UI for the panel event stream + +CopilotKit's **AG-UI** standardizes streaming *agent→UI* events (text deltas, +tool calls, state). If XNet wants a provider-neutral panel event contract beyond +ACP's own client methods, AG-UI is prior art — but ACP's client-side methods +(`session/update`, `session/request_permission`, `fs/*`) already cover it, so +this is a "nice to know," not a dependency. + +## Key Findings + +1. **The tool side is done; only the agent side is missing.** XNet already + speaks MCP. The gap is a daemon that *launches an agent* and streams it back. +2. **ACP makes "any agent" a config choice, not N integrations.** One ACP client + in XNet + off-the-shelf adapters = Claude Code, Codex, Gemini, OpenCode/Kimi. +3. **The bridge path sidesteps 0192 Phase 1b.** Claude Code/Codex bring their own + tool loop; XNet doesn't have to build one for agent tiers. The panel's + `bridge` tier (preference #1) is the intended home for this. +4. **`:31416` is already the agreed address** — the panel probes it; the MCP HTTP + transport defaults to it. The daemon should *own* `:31416` and serve `/health` + + an agent endpoint, and mount/launch the MCP tool surface alongside. +5. **Electron is the "just works" surface; web works too, with the same loopback + hardening** (pairing token + Origin allowlist + Private Network Access) the + MCP HTTP transport already implements. A *purely hosted* web user with nothing + local still needs the managed-cloud path — out of scope here. +6. **Two distinct "do" surfaces, both already present:** workspace edits via MCP + (`AiSurfaceService`), and code/plugin authoring via devkit `runAgentTask` + (worktree + gate + PR) and the plugin scaffolder. The agent can be given both. +7. **ToS is a hard constraint, not a footnote:** spawn the user's CLI; never + reuse their subscription token. This shapes the whole design toward + subprocess-launching. + +## Options And Tradeoffs + +### Transport from XNet UI → agent + +| Option | What | Pros | Cons | +| --- | --- | --- | --- | +| **A. OpenAI-compat facade** | Daemon exposes `/v1/chat/completions` wrapping the CLI agent | Zero panel changes (bridge tier already maps to it); fast | Lossy: no native tool-call/approval/diff events; stateless chat ↔ stateful agent mismatch | +| **B. ACP bridge (recommended)** | Daemon is an ACP *client*; launches agent adapters; panel renders ACP events | "Any agent" via existing adapters; full fidelity (tools, permissions, diffs); MCP wired in the handshake | New ACP client + a native panel provider; more work than A | +| **C. Direct SDK embed** | Electron main embeds Claude Agent SDK / Codex app-server directly | Tight control, no extra adapter | Per-agent code; ToS pushes SDK→API-key (not subscription); least "any agent" | +| **D. MCP-only (inverse)** | User runs Claude Code in *their* terminal pointed at `xnet mcp serve` | Works today; minimal build | Not UI-driven — the opposite of the ask | + +### Where the daemon runs + +| Surface | Feasibility | Notes | +| --- | --- | --- | +| **Electron** | ✅ Best | Spawns agents directly (cloudflare-tunnel precedent); MCP over stdio; FS access for code tasks | +| **Web + local daemon** | ✅ Good | Browser → `:31416` loopback; reuse MCP HTTP's pairing + Origin allowlist + PNA. User runs `xnet bridge serve` (or Electron in the tray) | +| **Web, fully hosted, nothing local** | ❌ N/A here | No local subscription/CLI to drive → managed-cloud gateway (exploration 0192, Phase 2) | + +### Recommendation + +**Build the ACP agent bridge daemon (Option B), Electron-hosted with a +standalone `xnet bridge serve` for web, phased:** + +```mermaid +sequenceDiagram + participant UI as XNet panel + participant BR as Bridge daemon (:31416) + participant AG as Agent (claude-code-acp) + participant MCP as XNet MCP (stdio) + UI->>BR: GET /health → {ok:true} + UI->>BR: open session (prompt, agent=claude) + BR->>AG: spawn + ACP initialize + BR->>AG: session/new { mcpServers: [xnet] } + AG->>MCP: tools/list, tools/call (xnet_plan_page_patch …) + AG-->>BR: session/update (text deltas, tool calls) + AG->>BR: session/request_permission (apply page patch?) + BR-->>UI: stream events + approval prompt + UI->>BR: approve + BR->>AG: permission granted + AG->>MCP: xnet_apply_page_markdown + AG-->>BR: result + diff + BR-->>UI: applied-change (classifyAiAgentDisplayState) +``` + +- **Phase 0 — daemon skeleton + detection (Electron).** Serve `/health` at + `:31416` from the Electron main (wrap devkit `bridgeHealth()`); the panel's + bridge tier lights up. No agent yet. +- **Phase 1 — one agent, end to end (Claude Code via ACP).** Bridge spawns + `@zed-industries/claude-code-acp`, opens a session declaring XNet's MCP server, + streams text. The agent can already *read* the workspace via MCP. +- **Phase 2 — writes with approvals.** Map ACP `session/request_permission` → + XNet's existing approval flow (`requestApproval` / `classifyAiAgentDisplayState`); + render diffs. Now the agent edits pages/databases/canvases with user consent. +- **Phase 3 — "any agent."** Add a small **agent registry** (command + adapter + + args) so the panel offers Claude Code / Codex / Gemini / OpenCode (Kimi); + detect which CLIs are installed. +- **Phase 4 — web + code/plugins.** Ship `xnet bridge serve` (reuse MCP HTTP + hardening) so the web deployment drives the same daemon; wire devkit + `runAgentTask` + the plugin scaffolder as agent-invokable "code" tasks + (worktree + gate + PR) so "create/edit a plugin" works from the UI. + +Rationale: maximum leverage of what exists (MCP tools, `:31416` probe, devkit +runner, Electron spawn precedent, runtime approval scaffolding), "any agent" by +construction, ToS-safe (spawns the user's CLI), and it delivers agentic +workspace editing **sooner** than building XNet's own tool loop. + +## Example Code + +### Electron: an agent-bridge manager (mirrors cloudflare-tunnel-manager) + +```ts +// apps/electron/src/main/agent-bridge-manager.ts (new) +import { spawn } from 'node:child_process' +import { bridgeHealth } from '@xnetjs/devkit' +import { createServer } from 'node:http' + +// Phase 0: serve /health so the panel's bridge tier detects us. +export function startAgentBridge(opts: { port?: number; agent: string } ) { + const port = opts.port ?? 31416 + const server = createServer((req, res) => { + if (req.method === 'GET' && req.url?.startsWith('/health')) { + res.setHeader('content-type', 'application/json') + return res.end(JSON.stringify(bridgeHealth({ agent: opts.agent, version: '0.1.0' }))) + } + // Phase 1+: /session (SSE) → drive the ACP agent (below) + res.statusCode = 404; res.end() + }) + server.listen(port, '127.0.0.1') + return server +} +``` + +### Bridge ↔ agent over ACP, with XNet's MCP tools declared in the handshake + +```ts +// Phase 1: launch an ACP agent and open a session that connects to XNet's MCP. +import { spawn } from 'node:child_process' + +const agent = spawn('npx', ['-y', '@zed-industries/claude-code-acp'], { + stdio: ['pipe', 'pipe', 'inherit'] // JSON-RPC over stdin/stdout +}) +// ACP: initialize → session/new declaring the xnet MCP server (stdio). +rpc(agent, 'initialize', { protocolVersion: 1, clientCapabilities: { fs: true } }) +const { sessionId } = await rpc(agent, 'session/new', { + cwd: workspaceDir, + mcpServers: [{ name: 'xnet', command: 'xnet', args: ['mcp', 'serve', '--api-url', 'http://127.0.0.1:31415'] }] +}) +// Stream the user's turn; forward session/update + request_permission to the UI. +await rpc(agent, 'session/prompt', { sessionId, prompt: userMessage }) +``` + +### Panel: a native "bridge/agent" provider (Phase 2) + +Today `bridge` → `openai-compatible`. Add an agent-aware provider so tool calls, +`session/request_permission`, and diffs render natively — reusing the runtime's +`requestApproval` + `classifyAiAgentDisplayState` instead of flattening the +agent into plain chat text. (Phase 0–1 can ship behind the existing +openai-compatible facade for a quick win, then graduate to this.) + +### Agent registry ("any agent") + +```ts +// Pluggable: command + ACP adapter + how to detect it. +export const AGENTS = { + 'claude-code': { label: 'Claude Code', detect: 'claude', acp: ['npx','-y','@zed-industries/claude-code-acp'] }, + codex: { label: 'Codex', detect: 'codex', acp: ['npx','-y','codex-acp'] }, + gemini: { label: 'Gemini CLI', detect: 'gemini', acp: ['gemini','--experimental-acp'] }, + opencode: { label: 'OpenCode', detect: 'opencode', acp: ['npx','-y','opencode-acp'] } // Kimi K2 etc. +} as const +``` + +## Risks And Open Questions + +- **ToS / auth (highest):** never extract or proxy the subscription OAuth token. + Spawn the user's own CLI/adapter (self-authenticating) or take an API key. Make + this explicit in code and docs. Revisit per provider before shipping. +- **Security of a loopback agent runner:** the daemon can edit files and the + workspace. Reuse the MCP HTTP hardening (loopback-only bind, pairing token, + Origin allowlist, PNA). Gate code edits behind devkit's worktree isolation + + validation gate; gate workspace writes behind the mutation-plan approval flow. + An agent with shell access is power-user territory — default to + approval-required, surface clearly. +- **`:31416` ownership:** decide whether the bridge daemon *also* serves the MCP + surface (one process, one port) or launches `xnet mcp serve` as a child and + gives the agent stdio MCP. Stdio MCP avoids a second port + pairing for the + Electron case; the web case still needs the hardened HTTP transport. +- **ACP maturity / version drift:** ACP and the adapters are young and moving. + Pin adapter versions; treat the ACP client as a thin, well-tested seam. +- **Web without a local daemon:** a hosted-only user has nothing to drive → + needs the managed-cloud gateway (exploration 0192, Phase 2). Be honest + in the UI about when the bridge tier is unavailable. +- **Approval fatigue vs safety:** map ACP permission requests to batched, + legible approvals (per-plan, not per-keystroke) using the existing + `AiMutationPlan` granularity. +- **Plugin authoring scope:** "create/edit a plugin" spans (a) generating a + plugin from a script (AI script generator + `scaffoldPlugin` / + `scriptToPluginManifest`, already built) and (b) hand-coding via `runAgentTask`. + Decide which the UI exposes first (the scaffolder is the lower-risk start). +- **Windows/macOS/Linux spawn differences:** `npx`/path resolution, shell quoting + (devkit already uses split/join, not `replace`, for prompt safety). + +## Implementation Checklist + +**Phase 0 — daemon skeleton + detection (Electron)** +- [ ] Export `bridgeHealth` / `handleBridgeRun` from `@xnetjs/devkit` (logic + exists in `bridge.ts`; confirm it's in the package barrel). +- [ ] New `apps/electron/src/main/agent-bridge-manager.ts` serving `GET /health` + on `:31416` (mirror `cloudflare-tunnel-manager.ts` lifecycle). +- [ ] Start/stop it in `apps/electron/src/main/index.ts` boot/quit; add + preload IPC (`xnet:agent-bridge:status/start/stop`). +- [ ] Verify the panel's `bridge` tier flips to "available." + +**Phase 1 — Claude Code via ACP, read-only** +- [ ] Add a minimal ACP client (JSON-RPC/stdio) in the bridge. +- [ ] Spawn `@zed-industries/claude-code-acp`; `initialize` → `session/new` + declaring the `xnet` MCP server (stdio, `--api-url :31415`). +- [ ] Stream `session/prompt` → forward `session/update` text deltas to the panel + (behind the existing openai-compatible facade for the quick win). + +**Phase 2 — writes + approvals** +- [ ] Map ACP `session/request_permission` → `AiAgentRuntime.requestApproval` + + `classifyAiAgentDisplayState`; render diffs in the panel. +- [ ] Confirm `xnet_apply_*` writes land through the local API store. +- [ ] Add a native "bridge/agent" panel provider (graduate off the facade). + +**Phase 3 — any agent** +- [ ] Agent registry (Claude Code / Codex / Gemini / OpenCode-Kimi) with + installed-CLI detection; expose the choice in the connector bar. +- [ ] Per-agent adapter args + smoke test each. + +**Phase 4 — web + code/plugins** +- [ ] `xnet bridge serve` standalone daemon (reuse MCP HTTP hardening: pairing + token, Origin allowlist, PNA) so the web deployment drives the same bridge. +- [ ] Expose devkit `runAgentTask` (worktree → gate → PR) and the plugin + scaffolder as agent-invokable "code" tasks; wire "create/edit plugin" from + the UI. +- [ ] Surface honest unavailability when no local daemon/agent is present. + +## Validation Checklist + +- [ ] With the Electron app running and `claude` installed, the panel shows + **Local bridge — available** and a prompt round-trips through Claude Code. +- [ ] Asking "summarize my workspace" causes the agent to call `xnet_search` / + read tools (visible in logs) and answer from real data. +- [ ] Asking "create a page titled X with these sections" produces a **mutation + plan + approval**, and on approve the page exists in the workspace. +- [ ] "Build a canvas of …" and "add a row to database …" work via the + corresponding `xnet_*` tools with approval. +- [ ] Switching the agent to **Codex** (and **OpenCode/Kimi**) works with no + panel changes beyond the registry selection. +- [ ] The **web** deployment, with `xnet bridge serve` running locally, drives the + same agent (loopback, pairing token, no CORS errors). +- [ ] "Create a plugin that …" scaffolds/edits plugin code via `runAgentTask` + (worktree), passes the validation gate, and opens a PR / installs locally. +- [ ] **No subscription token is ever read by XNet** — the agent CLI + authenticates itself (verify by inspecting what the bridge spawns/sends). +- [ ] Bridge daemon refuses non-loopback binds; pairing token + Origin allowlist + enforced on the web path. + +## References + +- Repo — tools: [mcp.ts](packages/cli/src/commands/mcp.ts), + [mcp-http.ts](packages/plugins/src/services/mcp-http.ts), + [mcp-server.ts](packages/plugins/src/services/mcp-server.ts), + [ai-surface/service.ts](packages/plugins/src/ai-surface/service.ts) +- Repo — bridge hook: [connectors/detect.ts](packages/plugins/src/ai/connectors/detect.ts), + [ai-chat-connector.ts](apps/web/src/workbench/views/ai-chat-connector.ts), + [devkit/src/bridge.ts](packages/devkit/src/bridge.ts), + [devkit/src/agent.ts](packages/devkit/src/agent.ts), + [devkit/src/dev-loop.ts](packages/devkit/src/dev-loop.ts) +- Repo — Electron host: [main/index.ts](apps/electron/src/main/index.ts), + [cloudflare-tunnel-manager.ts](apps/electron/src/main/cloudflare-tunnel-manager.ts), + [process-manager.ts](packages/plugins/src/services/process-manager.ts), + [local-api.ts](apps/electron/src/main/local-api.ts), + [local-api-config.ts](apps/electron/src/main/local-api-config.ts) +- Repo — runtime: [ai/runtime.ts](packages/plugins/src/ai/runtime.ts) + (`codex-app-server` mode, approvals, `classifyAiAgentDisplayState`) +- Prior explorations: `0174_BRING_YOUR_OWN_MODEL_AI_CHAT_PANEL`, + `0175_XNET_AS_A_SUBSTRATE_FOR_OPENCLAW`, `0161_TOKEN_EFFICIENT_AGENT_INTERFACES`, + `0190_IN_APP_AGENTIC_VIBE_CODING_AND_SELF_MODIFICATION`, + `0192_GETTING_XNET_AI_WORKING_FIXING_THE_CHAT_PANEL` +- ACP: [agentclientprotocol.com](https://agentclientprotocol.com/get-started/agents), + [Zed — ACP](https://zed.dev/acp), + [Claude Code via ACP](https://zed.dev/blog/claude-code-via-acp), + [ACP vs MCP (Morph)](https://www.morphllm.com/agent-client-protocol), + [ACP intro (Marc Nuri)](https://blog.marcnuri.com/agent-client-protocol-acp-introduction) +- Adapters: [@zed-industries/claude-code-acp](https://www.npmjs.com/package/@zed-industries/claude-code-acp), + [claude-agent-acp](https://github.com/zed-industries/claude-agent-acp), + [opencode-acp](https://github.com/josephschmitt/opencode-acp), + [Codex CLI in Zed (ACP)](https://codex.danielvaughan.com/2026/05/05/codex-cli-in-zed-parallel-agents-acp-integration-ide-workflows/) +- SDKs: [Claude Agent SDK (TS)](https://code.claude.com/docs/en/agent-sdk/typescript), + [Agent SDK permissions](https://docs.claude.com/en/docs/agent-sdk/permissions), + [Codex app-server](https://developers.openai.com/codex/app-server), + [Codex SDK](https://developers.openai.com/codex/sdk) +- Auth/ToS: [Agent SDK overview](https://code.claude.com/docs/en/agent-sdk/overview), + [subscription-auth ban](https://alternativeto.net/news/2026/2/anthropic-officially-bans-using-subscription-authentication-for-third-party-claude-use) From d394af6e6c9592c1f0f1e12acd6b09ba827e2883 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Wed, 17 Jun 2026 12:45:48 -0700 Subject: [PATCH 2/3] feat(devkit,cli,electron): agent bridge daemon (exploration 0194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serves the loopback endpoint XNet's chat panel already probes at :31416 (the 'Local bridge' connector tier, preference #1) — the missing 'thin shell' that makes Claude Code / Codex / any agent usable from XNet's UI, leveraging the user's own subscription (the daemon spawns the user's CLI; it never sees a token). @xnetjs/devkit: - ChatAgent port + cliChatAgent (spawns claude -p / codex exec, returns reply) + fakeChatAgent; flattenChat. ToS-safe BYO-agent model. - createBridgeServer: hardened loopback HTTP daemon serving GET /health (bridgeHealth, so the panel detects it) and POST /v1/chat/completions — OpenAI-compatible, streaming (SSE, which the panel's provider requests) and one-shot. Loopback-only bind, OPTIONS preflight, Origin allowlist (loopback + configured; never wildcard), Access-Control-Allow-Private-Network. Mirrors the MCP HTTP transport's hardening. @xnetjs/cli: - builds a cliChatAgent and starts the daemon. Makes the bridge tier work on the web deployment (browser → local daemon) as well as Electron. apps/electron: - agent-bridge-manager starts the daemon on boot (fire-and-forget), but only advertises it when the agent CLI is actually runnable (a --version probe), so the panel never shows an 'available' bridge that errors on first message. Stops on quit; status/start/stop IPC + preload (window.xnetAgentBridge). Tests: devkit 44 (chat-agent 6, bridge-server 7 incl real ephemeral server + SSE + origin/PNA gates), cli 35 (bridge health + injected-agent chat). devkit + cli typecheck, electron main tsc, eslint --max-warnings 0, prettier clean. Deferred (in the exploration): ACP transport + agent registry (any agent), mapping permissions to the approval flow, and wiring the MCP tool surface into the spawned agent so it edits the workspace (Phase 2-4). Co-Authored-By: Claude Opus 4.8 --- apps/electron/package.json | 1 + .../electron/src/main/agent-bridge-manager.ts | 94 ++++++ apps/electron/src/main/index.ts | 11 + apps/electron/src/preload/index.ts | 6 + packages/cli/package.json | 1 + packages/cli/src/cli.ts | 2 + packages/cli/src/commands/bridge.test.ts | 38 +++ packages/cli/src/commands/bridge.ts | 95 +++++++ packages/devkit/src/bridge-server.test.ts | 99 +++++++ packages/devkit/src/bridge-server.ts | 267 ++++++++++++++++++ packages/devkit/src/chat-agent.test.ts | 57 ++++ packages/devkit/src/chat-agent.ts | 86 ++++++ packages/devkit/src/index.ts | 16 ++ pnpm-lock.yaml | 6 + 14 files changed, 779 insertions(+) create mode 100644 apps/electron/src/main/agent-bridge-manager.ts create mode 100644 packages/cli/src/commands/bridge.test.ts create mode 100644 packages/cli/src/commands/bridge.ts create mode 100644 packages/devkit/src/bridge-server.test.ts create mode 100644 packages/devkit/src/bridge-server.ts create mode 100644 packages/devkit/src/chat-agent.test.ts create mode 100644 packages/devkit/src/chat-agent.ts diff --git a/apps/electron/package.json b/apps/electron/package.json index cf070c940..d826ca957 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -30,6 +30,7 @@ "@xnetjs/canvas": "workspace:*", "@xnetjs/core": "workspace:*", "@xnetjs/data": "workspace:*", + "@xnetjs/devkit": "workspace:*", "@xnetjs/devtools": "workspace:*", "@xnetjs/editor": "workspace:*", "@xnetjs/identity": "workspace:*", diff --git a/apps/electron/src/main/agent-bridge-manager.ts b/apps/electron/src/main/agent-bridge-manager.ts new file mode 100644 index 000000000..d7f718215 --- /dev/null +++ b/apps/electron/src/main/agent-bridge-manager.ts @@ -0,0 +1,94 @@ +/** + * Agent bridge daemon for the Electron app (exploration 0194). + * + * Runs the loopback HTTP daemon XNet's chat panel probes at :31416 (the + * `bridge` connector tier), driving the user's OWN coding-agent CLI + * (`claude` / `codex` / …) as the model. The agent authenticates with the + * user's subscription — the app never sees the token. + * + * It only advertises the bridge when the agent CLI is actually runnable + * (a `--version` probe), so the panel never shows an "available" bridge that + * errors on first message. The HTTP server itself is in-process; the agent CLI + * is spawned per chat turn by `cliChatAgent`. + */ + +import { + cliChatAgent, + createBridgeServer, + NodeCommandRunner, + type BridgeServerHandle +} from '@xnetjs/devkit' +import { app, ipcMain } from 'electron' + +export interface AgentBridgeStatus { + running: boolean + agent: string + url?: string + detail?: string +} + +let handle: BridgeServerHandle | undefined +let status: AgentBridgeStatus = { running: false, agent: 'claude' } + +function resolveAgent(explicit?: string): string { + return explicit ?? process.env.XNET_BRIDGE_AGENT ?? 'claude' +} + +function argsForAgent(command: string): string[] | undefined { + if (command === 'codex') return ['exec', '{prompt}'] + return undefined // claude / default → cliChatAgent default ['-p', '{prompt}'] +} + +export function getAgentBridgeStatus(): AgentBridgeStatus { + return status +} + +/** Start the bridge if the chosen agent CLI is installed; otherwise record why. */ +export async function startAgentBridge( + options: { agent?: string; cwd?: string } = {} +): Promise { + if (handle) return status + const agentCmd = resolveAgent(options.agent) + const cwd = options.cwd ?? app.getPath('home') + const runner = new NodeCommandRunner() + + const probe = await runner.run(agentCmd, ['--version'], { cwd, timeoutMs: 4000 }) + if (!probe.ok) { + status = { running: false, agent: agentCmd, detail: `${agentCmd} not found on PATH` } + return status + } + + const args = argsForAgent(agentCmd) + const agent = cliChatAgent(runner, { command: agentCmd, cwd, ...(args ? { args } : {}) }) + const server = createBridgeServer({ agent, agentName: agentCmd, version: app.getVersion() }) + try { + await server.start() + } catch (err) { + status = { + running: false, + agent: agentCmd, + detail: err instanceof Error ? err.message : String(err) + } + return status + } + handle = server + status = { running: true, agent: agentCmd, url: server.url } + return status +} + +export async function stopAgentBridge(): Promise { + await handle?.stop() + handle = undefined + status = { ...status, running: false } +} + +export function setupAgentBridgeIPC(): void { + ipcMain.handle('xnet:agent-bridge:status', () => getAgentBridgeStatus()) + ipcMain.handle('xnet:agent-bridge:start', async (_event, agent?: string) => + startAgentBridge({ agent }) + ) + ipcMain.handle('xnet:agent-bridge:stop', async () => { + await stopAgentBridge() + return getAgentBridgeStatus() + }) +} diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index bb1d6028d..6caff344a 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -4,6 +4,7 @@ import { join, dirname } from 'path' import { fileURLToPath } from 'url' import { app, BrowserWindow } from 'electron' +import { setupAgentBridgeIPC, startAgentBridge, stopAgentBridge } from './agent-bridge-manager' import { setupCloudflareTunnelIPC, stopCloudflareTunnel } from './cloudflare-tunnel-ipc' import { spawnDataProcess, @@ -221,6 +222,9 @@ app.whenReady().then(async () => { // Setup Cloudflare tunnel IPC handlers cleanupTunnelIPC = setupCloudflareTunnelIPC() + // Setup agent bridge IPC handlers (drives the user's claude/codex CLI) + setupAgentBridgeIPC() + // Setup dev-only Storybook IPC handlers if (process.env.NODE_ENV === 'development') { setupStorybookIPC() @@ -229,6 +233,10 @@ app.whenReady().then(async () => { // Start Local API server (for external integrations) await startLocalAPI() + // Start the agent bridge daemon (no-op if the agent CLI isn't installed). + // Fire-and-forget: a slow `--version` probe must not delay window creation. + void startAgentBridge().catch(() => undefined) + // Create menu createMenu() @@ -258,6 +266,9 @@ app.on('window-all-closed', () => { }) app.on('before-quit', async () => { + // Stop the agent bridge daemon + await stopAgentBridge() + // Stop Local API server await stopLocalAPI() diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index a30728066..c6faefe54 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -282,6 +282,12 @@ contextBridge.exposeInMainWorld('xnetServices', { }) // Expose Local API status/control for renderer +contextBridge.exposeInMainWorld('xnetAgentBridge', { + status: () => ipcRenderer.invoke('xnet:agent-bridge:status'), + start: (agent?: string) => ipcRenderer.invoke('xnet:agent-bridge:start', agent), + stop: () => ipcRenderer.invoke('xnet:agent-bridge:stop') +}) + contextBridge.exposeInMainWorld('xnetLocalAPI', { status: () => ipcRenderer.invoke('xnet:localapi:status'), start: () => ipcRenderer.invoke('xnet:localapi:start'), diff --git a/packages/cli/package.json b/packages/cli/package.json index 705f03fb5..99dad88c1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -39,6 +39,7 @@ "@xnetjs/core": "workspace:*", "@xnetjs/crypto": "workspace:*", "@xnetjs/data": "workspace:*", + "@xnetjs/devkit": "workspace:*", "@xnetjs/identity": "workspace:*", "@xnetjs/plugins": "workspace:*", "@xnetjs/runtime": "workspace:*", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f8cef3682..e40705ab8 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -20,6 +20,7 @@ import { program } from 'commander' import { registerAgentCommands } from './commands/agent.js' +import { registerBridgeCommand } from './commands/bridge.js' import { registerDataCommand } from './commands/data.js' import { registerDoctorCommand } from './commands/doctor.js' import { registerMcpCommand } from './commands/mcp.js' @@ -37,6 +38,7 @@ registerSchemaCommand(program) registerDoctorCommand(program) registerAgentCommands(program) registerMcpCommand(program) +registerBridgeCommand(program) registerDataCommand(program) // Parse and run diff --git a/packages/cli/src/commands/bridge.test.ts b/packages/cli/src/commands/bridge.test.ts new file mode 100644 index 000000000..7448dbefa --- /dev/null +++ b/packages/cli/src/commands/bridge.test.ts @@ -0,0 +1,38 @@ +import type { BridgeServerHandle } from '@xnetjs/devkit' +import { FakeCommandRunner } from '@xnetjs/devkit' +import { afterEach, describe, expect, it } from 'vitest' +import { buildBridgeServer } from './bridge' + +let handle: BridgeServerHandle | undefined + +afterEach(async () => { + await handle?.stop() + handle = undefined +}) + +describe('buildBridgeServer', () => { + it('serves /health for the chosen agent without spawning it', async () => { + handle = buildBridgeServer({ agent: 'claude', port: 0 }, new FakeCommandRunner()) + await handle.start() + const res = await fetch(`${handle.url}/health`) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ ok: true, agent: 'claude' }) + }) + + it('drives the injected agent CLI for a chat turn (codex arg template)', async () => { + const runner = new FakeCommandRunner([ + { match: () => true, result: { stdout: 'codex says hi' } } + ]) + handle = buildBridgeServer({ agent: 'codex', port: 0 }, runner) + await handle.start() + const res = await fetch(`${handle.url}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }) + }) + const body = (await res.json()) as { choices: Array<{ message: { content: string } }> } + expect(body.choices[0].message.content).toBe('codex says hi') + expect(runner.calls[0].command).toBe('codex') + expect(runner.calls[0].args).toEqual(['exec', 'hi']) + }) +}) diff --git a/packages/cli/src/commands/bridge.ts b/packages/cli/src/commands/bridge.ts new file mode 100644 index 000000000..628924f33 --- /dev/null +++ b/packages/cli/src/commands/bridge.ts @@ -0,0 +1,95 @@ +/** + * `xnet bridge serve` — run the agent bridge daemon (exploration 0194). + * + * Serves the loopback endpoint XNet's chat panel probes at `:31416` (the + * `bridge` connector tier), driving the user's OWN coding-agent CLI + * (`claude` / `codex` / …) as the model. The agent authenticates itself with + * the user's subscription — xNet never sees the token. This is the missing + * "thin shell" that makes the bridge tier light up on any surface (Electron or + * the web deployment talking to a local daemon). + */ + +import { + cliChatAgent, + createBridgeServer, + DEFAULT_BRIDGE_PORT, + NodeCommandRunner, + type BridgeServerHandle, + type CommandRunner +} from '@xnetjs/devkit' +import { Command } from 'commander' + +export interface BridgeServeOptions { + /** Agent CLI to drive (default `claude`). */ + agent?: string + host?: string + port?: number + allowOrigin?: string[] + /** Working directory the agent runs in (default `process.cwd()`). */ + cwd?: string +} + +/** Headless arg template for a known agent CLI (falls back to Claude Code's). */ +function argsForAgent(command: string): string[] | undefined { + if (command === 'codex') return ['exec', '{prompt}'] + return undefined // claude / default → cliChatAgent default ['-p', '{prompt}'] +} + +/** Build (but don't start) the bridge server for the chosen agent. Injectable runner for tests. */ +export function buildBridgeServer( + options: BridgeServeOptions, + runner: CommandRunner = new NodeCommandRunner() +): BridgeServerHandle { + const command = options.agent ?? 'claude' + const args = argsForAgent(command) + const agent = cliChatAgent(runner, { + command, + cwd: options.cwd ?? process.cwd(), + ...(args ? { args } : {}) + }) + return createBridgeServer({ + agent, + agentName: command, + ...(options.host ? { host: options.host } : {}), + ...(options.port !== undefined ? { port: options.port } : {}), + ...(options.allowOrigin ? { allowedOrigins: options.allowOrigin } : {}) + }) +} + +export function registerBridgeCommand(program: Command): void { + const bridge = program + .command('bridge') + .description("Run the local agent bridge for XNet's AI chat panel") + + bridge + .command('serve') + .description('Serve the agent bridge on loopback (default :31416), driving your own agent CLI') + .option('--agent ', 'Agent CLI to drive (claude, codex, …)', 'claude') + .option('--host ', 'Loopback host (default 127.0.0.1)') + .option('--port ', `Port (default ${DEFAULT_BRIDGE_PORT})`, parseIntOption) + .option( + '--allow-origin ', + 'Browser origins permitted (e.g. https://user.github.io for the web deployment)' + ) + .option('--cwd ', 'Working directory the agent runs in (default current dir)') + .action(async (options: BridgeServeOptions) => { + const handle = buildBridgeServer(options) + await handle.start() + // stderr so stdout stays clean for any tooling that scrapes it. + console.error( + `xNet agent bridge listening on ${handle.url} (agent: ${options.agent ?? 'claude'})` + ) + console.error('In XNet, open the AI panel and select "Local bridge".') + const shutdown = (): void => { + void handle.stop().then(() => process.exit(0)) + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + }) +} + +function parseIntOption(value: string): number { + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed)) throw new Error(`Invalid number: ${value}`) + return parsed +} diff --git a/packages/devkit/src/bridge-server.test.ts b/packages/devkit/src/bridge-server.test.ts new file mode 100644 index 000000000..70531f901 --- /dev/null +++ b/packages/devkit/src/bridge-server.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + createBridgeServer, + type BridgeServerConfig, + type BridgeServerHandle +} from './bridge-server' +import { fakeChatAgent } from './chat-agent' + +let handle: BridgeServerHandle | undefined + +afterEach(async () => { + await handle?.stop() + handle = undefined +}) + +async function start(overrides: Partial = {}): Promise { + handle = createBridgeServer({ + agent: fakeChatAgent(() => 'hi there'), + agentName: 'claude', + port: 0, + ...overrides + }) + await handle.start() + return handle.url +} + +describe('createBridgeServer', () => { + it('refuses to bind a non-loopback host', () => { + expect(() => createBridgeServer({ agent: fakeChatAgent(() => ''), host: '0.0.0.0' })).toThrow( + /loopback/ + ) + }) + + it('serves /health with bridgeHealth so the connector ladder detects it', async () => { + const url = await start() + const res = await fetch(`${url}/health`) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ + ok: true, + service: 'xnet-agent-bridge', + agent: 'claude' + }) + }) + + it('answers chat completions (non-streaming) from the agent', async () => { + const url = await start({ agent: fakeChatAgent((m) => `echo:${m[m.length - 1].content}`) }) + const res = await fetch(`${url}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }) + }) + const body = (await res.json()) as { choices: Array<{ message: { content: string } }> } + expect(body.choices[0].message.content).toBe('echo:hi') + }) + + it('streams chat completions as OpenAI SSE ending in [DONE]', async () => { + const url = await start({ agent: fakeChatAgent(() => 'streamed reply') }) + const res = await fetch(`${url}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ stream: true, messages: [{ role: 'user', content: 'hi' }] }) + }) + expect(res.headers.get('content-type')).toContain('text/event-stream') + const text = await res.text() + expect(text).toContain('streamed reply') + expect(text.trimEnd().endsWith('data: [DONE]')).toBe(true) + }) + + it('refuses a disallowed browser origin', async () => { + const url = await start() + const res = await fetch(`${url}/health`, { headers: { origin: 'https://evil.example' } }) + expect(res.status).toBe(403) + }) + + it('allows a configured origin and emits Private Network Access on preflight', async () => { + const url = await start({ allowedOrigins: ['https://app.example'] }) + const res = await fetch(`${url}/v1/chat/completions`, { + method: 'OPTIONS', + headers: { origin: 'https://app.example' } + }) + expect(res.status).toBe(204) + expect(res.headers.get('access-control-allow-origin')).toBe('https://app.example') + expect(res.headers.get('access-control-allow-private-network')).toBe('true') + }) + + it('returns 502 when the agent throws', async () => { + const url = await start({ + agent: fakeChatAgent(() => { + throw new Error('agent down') + }) + }) + const res = await fetch(`${url}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + 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 new file mode 100644 index 000000000..75d1ac0d8 --- /dev/null +++ b/packages/devkit/src/bridge-server.ts @@ -0,0 +1,267 @@ +/** + * @xnetjs/devkit — the agent bridge HTTP daemon (exploration 0194). + * + * Serves the loopback endpoint the XNet chat panel's `bridge` connector tier + * already probes at `http://127.0.0.1:31416`: + * + * - `GET /health` → {@link bridgeHealth} so the panel detects the + * bridge tier (it requires `{ ok: true }`). + * - `POST /v1/chat/completions` → an **OpenAI-compatible** chat endpoint backed + * by a {@link ChatAgent} (the user's own `claude` / `codex` CLI). Supports + * streaming (SSE, which is what the panel's provider requests) and one-shot. + * + * Hardened like the MCP HTTP transport (`@xnetjs/plugins` `mcp-http.ts`): binds + * loopback only, answers `OPTIONS` preflights, gates by `Origin` (loopback + + * an allowlist — never reflects `*` to an arbitrary site), and emits + * `Access-Control-Allow-Private-Network` so an HTTPS page can reach the loopback + * daemon (Chrome's Local Network Access flow). + */ + +import type { ChatAgent, ChatMessage } from './chat-agent' +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import { bridgeHealth } from './bridge' + +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']) +/** Default port — the address the connector ladder (0174) probes. */ +export const DEFAULT_BRIDGE_PORT = 31416 +const MAX_BODY_BYTES = 1024 * 1024 + +export interface BridgeServerConfig { + /** The agent that produces replies (e.g. `cliChatAgent` over `claude`). */ + agent: ChatAgent + /** Which agent CLI this wraps, surfaced in `/health` (e.g. `'claude'`). */ + agentName?: string + version?: string + /** Loopback host. Defaults `127.0.0.1`; non-loopback is refused. */ + host?: string + /** Port to bind. Defaults {@link DEFAULT_BRIDGE_PORT}; pass `0` for ephemeral. */ + port?: number + /** + * Browser origins allowed *in addition to* loopback origins. A request whose + * `Origin` is absent (non-browser) or loopback is always allowed; a deployed + * web origin must be listed here to reach the local agent. + */ + allowedOrigins?: string[] +} + +export interface BridgeServerHandle { + start(): Promise + stop(): Promise + /** Resolved base URL, valid after `start()`. */ + readonly url: string +} + +export function createBridgeServer(config: BridgeServerConfig): BridgeServerHandle { + const host = config.host ?? '127.0.0.1' + if (!LOOPBACK_HOSTS.has(host)) { + throw new Error( + `Agent bridge refuses to bind non-loopback host "${host}"; it must stay on the local machine.` + ) + } + const requestedPort = config.port ?? DEFAULT_BRIDGE_PORT + const allowed = new Set(config.allowedOrigins ?? []) + const agentName = config.agentName ?? 'agent' + const version = config.version ?? '0.1.0' + let boundPort = requestedPort + let server: Server | undefined + + const onRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { + const origin = headerStr(req.headers.origin) + const ok = isOriginAllowed(origin, allowed) + + if (req.method === 'OPTIONS') { + if (!ok) { + endStatus(res, 403) + return + } + applyCors(res, origin) + endStatus(res, 204) + return + } + if (!ok) { + sendJson(res, 403, { error: 'origin not allowed' }) + return + } + applyCors(res, origin) + + const path = (req.url ?? '').split('?')[0] + + if (req.method === 'GET' && path === '/health') { + sendJson(res, 200, bridgeHealth({ agent: agentName, version })) + return + } + + if (req.method === 'POST' && path === '/v1/chat/completions') { + let body: Record + try { + body = await readJson(req) + } catch (err) { + sendJson(res, 400, { error: { message: messageOf(err) } }) + return + } + const messages = parseMessages(body) + const model = typeof body.model === 'string' ? body.model : agentName + const stream = body.stream === true + let text: string + try { + text = await config.agent.chat(messages) + } catch (err) { + sendJson(res, 502, { error: { message: messageOf(err) } }) + return + } + if (stream) sendSse(res, text, model) + else sendJson(res, 200, completion(text, model)) + return + } + + sendJson(res, 404, { error: 'not found' }) + } + + return { + get url() { + return `http://${host}:${boundPort}` + }, + start() { + return new Promise((resolve, reject) => { + const created = createServer((req, res) => { + void onRequest(req, res).catch(() => { + if (!res.headersSent) sendJson(res, 500, { error: 'internal error' }) + else res.end() + }) + }) + created.on('error', reject) + created.listen(requestedPort, host, () => { + const address = created.address() + if (address && typeof address === 'object') boundPort = address.port + server = created + resolve() + }) + }) + }, + stop() { + return new Promise((resolve) => { + if (!server) return resolve() + server.close(() => resolve()) + server = undefined + }) + } + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function isOriginAllowed(origin: string | undefined, allowed: Set): boolean { + if (origin === undefined) return true // non-browser client (curl, the CLI) + if (origin === 'null') return true // file:// pages (packaged Electron) + if (allowed.has(origin)) return true + try { + return LOOPBACK_HOSTS.has(new URL(origin).hostname) + } catch { + return false + } +} + +function applyCors(res: ServerResponse, origin: string | undefined): void { + if (origin && origin !== 'null') res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Vary', 'Origin') + res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'content-type, authorization') + res.setHeader('Access-Control-Allow-Private-Network', 'true') +} + +function parseMessages(body: Record): ChatMessage[] { + const raw = Array.isArray(body.messages) ? body.messages : [] + const messages: ChatMessage[] = [] + for (const entry of raw) { + if (entry && typeof entry === 'object') { + const role = (entry as { role?: unknown }).role + const content = (entry as { content?: unknown }).content + if ( + (role === 'system' || role === 'user' || role === 'assistant') && + typeof content === 'string' + ) { + messages.push({ role, content }) + } + } + } + if (messages.length === 0 && typeof body.prompt === 'string') { + messages.push({ role: 'user', content: body.prompt }) + } + return messages +} + +function completion(text: string, model: string): Record { + return { + id: 'bridge', + object: 'chat.completion', + model, + choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }] + } +} + +/** 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 + res.setHeader('content-type', 'text/event-stream') + res.setHeader('cache-control', 'no-cache') + res.setHeader('connection', 'keep-alive') + const chunk = (delta: Record): void => { + res.write( + `data: ${JSON.stringify({ + id: 'bridge', + object: 'chat.completion.chunk', + model, + choices: [{ index: 0, delta }] + })}\n\n` + ) + } + chunk({ role: 'assistant' }) + if (text) chunk({ content: text }) + chunk({}) + res.write('data: [DONE]\n\n') + res.end() +} + +function readJson(req: IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + let size = 0 + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => { + size += chunk.length + if (size > MAX_BODY_BYTES) { + reject(new Error('request body too large')) + req.destroy() + return + } + chunks.push(chunk) + }) + req.on('end', () => { + try { + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') as unknown + resolve(parsed && typeof parsed === 'object' ? (parsed as Record) : {}) + } catch { + reject(new Error('invalid JSON body')) + } + }) + req.on('error', reject) + }) +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify(body)) +} + +function endStatus(res: ServerResponse, status: number): void { + res.statusCode = status + res.end() +} + +function headerStr(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value +} + +function messageOf(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} diff --git a/packages/devkit/src/chat-agent.test.ts b/packages/devkit/src/chat-agent.test.ts new file mode 100644 index 000000000..4588b07d1 --- /dev/null +++ b/packages/devkit/src/chat-agent.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { cliChatAgent, fakeChatAgent, flattenChat, type ChatMessage } from './chat-agent' +import { FakeCommandRunner } from './command-runner' + +const msgs = (...pairs: Array<[ChatMessage['role'], string]>): ChatMessage[] => + pairs.map(([role, content]) => ({ role, content })) + +describe('flattenChat', () => { + it('keeps user content bare and role-prefixes the rest', () => { + expect(flattenChat(msgs(['system', 'S'], ['user', 'U'], ['assistant', 'A']))).toBe( + 'system: S\n\nU\n\nassistant: A' + ) + }) +}) + +describe('cliChatAgent', () => { + it('spawns the CLI with the flattened prompt and returns trimmed stdout', async () => { + const runner = new FakeCommandRunner([ + { match: () => true, result: { stdout: ' hello world\n' } } + ]) + const agent = cliChatAgent(runner, { command: 'claude', cwd: '/ws' }) + const reply = await agent.chat(msgs(['user', 'hi'])) + expect(reply).toBe('hello world') + expect(runner.calls[0].command).toBe('claude') + expect(runner.calls[0].args).toEqual(['-p', 'hi']) + expect(runner.calls[0].cwd).toBe('/ws') + }) + + it('passes a $-laden prompt verbatim (split/join, not replace)', async () => { + const runner = new FakeCommandRunner() + const agent = cliChatAgent(runner, { command: 'claude', cwd: '/ws' }) + await agent.chat(msgs(['user', 'use $& and $1 and $$ literally'])) + expect(runner.calls[0].args).toEqual(['-p', 'use $& and $1 and $$ literally']) + }) + + it('supports a custom arg template (e.g. codex exec)', async () => { + const runner = new FakeCommandRunner() + const agent = cliChatAgent(runner, { command: 'codex', args: ['exec', '{prompt}'], cwd: '/ws' }) + await agent.chat(msgs(['user', 'P'])) + expect(runner.calls[0].args).toEqual(['exec', 'P']) + }) + + it('throws with stderr when the CLI fails', async () => { + const runner = new FakeCommandRunner([ + { match: () => true, result: { code: 1, stderr: 'boom' } } + ]) + const agent = cliChatAgent(runner, { command: 'claude', cwd: '/ws' }) + await expect(agent.chat(msgs(['user', 'hi']))).rejects.toThrow(/boom/) + }) +}) + +describe('fakeChatAgent', () => { + it('returns the scripted reply', async () => { + const agent = fakeChatAgent((m) => `echo:${m[m.length - 1].content}`) + expect(await agent.chat(msgs(['user', 'hi']))).toBe('echo:hi') + }) +}) diff --git a/packages/devkit/src/chat-agent.ts b/packages/devkit/src/chat-agent.ts new file mode 100644 index 000000000..a1f303f4c --- /dev/null +++ b/packages/devkit/src/chat-agent.ts @@ -0,0 +1,86 @@ +/** + * @xnetjs/devkit — the chat-agent port (exploration 0194). + * + * The agent bridge drives the user's OWN coding-agent CLI as a *chat* surface: + * a conversation in, the assistant's reply text out. `cliChatAgent` spawns the + * user's `claude` / `codex` CLI (their subscription — zero model cost to xNet); + * `fakeChatAgent` scripts replies for tests. Distinct from {@link AgentRunner}, + * which edits files in a worktree — here we just want the model's reply to stream + * back to XNet's chat panel. + */ + +import type { CommandRunner } from './command-runner' + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' + content: string +} + +export interface ChatAgent { + /** Produce the assistant's reply text for a conversation. */ + chat(messages: ChatMessage[]): Promise +} + +export interface CliChatAgentOptions { + /** CLI to spawn, e.g. `'claude'` or `'codex'`. */ + command: string + /** + * Arg template; the literal `{prompt}` token is replaced by the flattened + * conversation. Default is Claude Code's headless form `['-p', '{prompt}']`; + * Codex would be `['exec', '{prompt}']`. + */ + args?: string[] + /** Working directory the agent runs in (its file/workspace scope). */ + cwd: string + /** Per-turn timeout in ms (0 = none). Default 120000. */ + timeoutMs?: number +} + +/** Flatten a conversation into a single prompt for headless CLIs. */ +export function flattenChat(messages: ChatMessage[]): string { + return messages + .map((message) => + message.role === 'user' ? message.content : `${message.role}: ${message.content}` + ) + .join('\n\n') +} + +/** + * A {@link ChatAgent} backed by the user's own coding-agent CLI. Spawning the + * installed CLI (rather than reusing its auth token) is the ToS-safe way to use + * the user's subscription. + */ +export function cliChatAgent(runner: CommandRunner, options: CliChatAgentOptions): ChatAgent { + return { + async chat(messages) { + const prompt = flattenChat(messages) + // split/join (not String.replace): the prompt is arbitrary text, and + // replace() would interpret `$&`/`$\``/`$'`/`$$`/`$n` as special patterns + // and only swap the first token. split/join is literal and replaces all. + const args = (options.args ?? ['-p', '{prompt}']).map((arg) => + arg.split('{prompt}').join(prompt) + ) + const result = await runner.run(options.command, args, { + cwd: options.cwd, + timeoutMs: options.timeoutMs ?? 120_000 + }) + if (!result.ok) { + throw new Error( + `agent "${options.command}" failed (code ${result.code}): ${result.stderr || result.stdout}`.trim() + ) + } + return result.stdout.trim() + } + } +} + +/** A test/dev {@link ChatAgent} that returns a scripted or derived reply. */ +export function fakeChatAgent( + reply: (messages: ChatMessage[]) => string | Promise +): ChatAgent { + return { + async chat(messages) { + return await reply(messages) + } + } +} diff --git a/packages/devkit/src/index.ts b/packages/devkit/src/index.ts index b3f4d0b59..7c9d54b43 100644 --- a/packages/devkit/src/index.ts +++ b/packages/devkit/src/index.ts @@ -54,3 +54,19 @@ export { type BridgeDeps, type BridgeRunRequest } from './bridge' + +export { + cliChatAgent, + fakeChatAgent, + flattenChat, + type ChatAgent, + type ChatMessage, + type CliChatAgentOptions +} from './chat-agent' + +export { + createBridgeServer, + DEFAULT_BRIDGE_PORT, + type BridgeServerConfig, + type BridgeServerHandle +} from './bridge-server' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccf25cfb2..28db5e0c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: '@xnetjs/data': specifier: workspace:* version: link:../../packages/data + '@xnetjs/devkit': + specifier: workspace:* + version: link:../../packages/devkit '@xnetjs/devtools': specifier: workspace:* version: link:../../packages/devtools @@ -652,6 +655,9 @@ importers: '@xnetjs/data': specifier: workspace:* version: link:../data + '@xnetjs/devkit': + specifier: workspace:* + version: link:../devkit '@xnetjs/identity': specifier: workspace:* version: link:../identity From 8a0d978f6aca7236a62b74b7e98590b0a4e5ebd6 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Wed, 17 Jun 2026 12:47:39 -0700 Subject: [PATCH 3/3] docs(exploration): check off agent bridge Phase 0 + facade (0194) Co-Authored-By: Claude Opus 4.8 --- ...CLAUDE_CODE_CODEX_AND_ANY_AGENT_IN_XNET.md | 77 +++++++++++-------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/docs/explorations/0194_[_]_AGENT_BRIDGE_CLAUDE_CODE_CODEX_AND_ANY_AGENT_IN_XNET.md b/docs/explorations/0194_[_]_AGENT_BRIDGE_CLAUDE_CODE_CODEX_AND_ANY_AGENT_IN_XNET.md index a3867a899..b8f2e6469 100644 --- a/docs/explorations/0194_[_]_AGENT_BRIDGE_CLAUDE_CODE_CODEX_AND_ANY_AGENT_IN_XNET.md +++ b/docs/explorations/0194_[_]_AGENT_BRIDGE_CLAUDE_CODE_CODEX_AND_ANY_AGENT_IN_XNET.md @@ -413,21 +413,28 @@ export const AGENTS = { ## Implementation Checklist -**Phase 0 — daemon skeleton + detection (Electron)** -- [ ] Export `bridgeHealth` / `handleBridgeRun` from `@xnetjs/devkit` (logic - exists in `bridge.ts`; confirm it's in the package barrel). -- [ ] New `apps/electron/src/main/agent-bridge-manager.ts` serving `GET /health` - on `:31416` (mirror `cloudflare-tunnel-manager.ts` lifecycle). -- [ ] Start/stop it in `apps/electron/src/main/index.ts` boot/quit; add - preload IPC (`xnet:agent-bridge:status/start/stop`). -- [ ] Verify the panel's `bridge` tier flips to "available." - -**Phase 1 — Claude Code via ACP, read-only** +**Phase 0 — daemon skeleton + detection + facade chat** — ✅ shipped +- [x] Export the bridge daemon pieces from `@xnetjs/devkit`: `bridgeHealth` + (already), plus the new `createBridgeServer` + `ChatAgent`/`cliChatAgent`. +- [x] `createBridgeServer` (in devkit) serves `GET /health` on `:31416` so the + panel detects the bridge tier; `apps/electron/src/main/agent-bridge-manager.ts` + runs it on boot (mirrors `cloudflare-tunnel-manager` lifecycle), gated on a + `--version` probe so it only advertises when the agent CLI is runnable. +- [x] Start/stop in `apps/electron/src/main/index.ts` boot/quit; preload IPC + (`window.xnetAgentBridge` → `xnet:agent-bridge:status/start/stop`). +- [x] **Facade chat (the quick win):** `POST /v1/chat/completions` (OpenAI-compatible, + streaming SSE + one-shot) backed by the user's own `claude -p` / `codex exec` + CLI — so the existing `bridge` tier (which maps to `openai-compatible`) chats + through the agent with **zero panel changes**. +- [ ] Verify in a running Electron build that the tier flips to "available" and a + prompt round-trips (covered by unit tests; not exercised in CI). + +**Phase 1 — Claude Code via ACP, read-only** — deferred (facade shipped instead) - [ ] Add a minimal ACP client (JSON-RPC/stdio) in the bridge. - [ ] Spawn `@zed-industries/claude-code-acp`; `initialize` → `session/new` - declaring the `xnet` MCP server (stdio, `--api-url :31415`). -- [ ] Stream `session/prompt` → forward `session/update` text deltas to the panel - (behind the existing openai-compatible facade for the quick win). + declaring the `xnet` MCP server (stdio, `--api-url :31415`) — so the agent + can read/edit the workspace via MCP rather than only chatting. +- [ ] Stream `session/update` natively (graduate off the OpenAI-compatible facade). **Phase 2 — writes + approvals** - [ ] Map ACP `session/request_permission` → `AiAgentRuntime.requestApproval` + @@ -441,33 +448,39 @@ export const AGENTS = { - [ ] Per-agent adapter args + smoke test each. **Phase 4 — web + code/plugins** -- [ ] `xnet bridge serve` standalone daemon (reuse MCP HTTP hardening: pairing - token, Origin allowlist, PNA) so the web deployment drives the same bridge. +- [x] `xnet bridge serve [--agent claude|codex] [--port] [--allow-origin] [--cwd]` + standalone daemon — loopback-only bind, Origin allowlist, Private Network + Access — so the web deployment drives the same bridge. (Pairing-token gate + deferred; loopback + Origin allowlist is the current protection.) - [ ] Expose devkit `runAgentTask` (worktree → gate → PR) and the plugin scaffolder as agent-invokable "code" tasks; wire "create/edit plugin" from the UI. -- [ ] Surface honest unavailability when no local daemon/agent is present. +- [ ] Surface honest unavailability when no local daemon/agent is present (the + Electron manager already records a `detail` reason; surface it in the panel). ## Validation Checklist +- [x] The daemon serves `/health` (so the tier detects) and a streaming + OpenAI-compatible chat endpoint that drives the agent CLI — covered by + `bridge-server.test.ts` (real ephemeral server, SSE, agent error → 502). - [ ] With the Electron app running and `claude` installed, the panel shows **Local bridge — available** and a prompt round-trips through Claude Code. -- [ ] Asking "summarize my workspace" causes the agent to call `xnet_search` / - read tools (visible in logs) and answer from real data. -- [ ] Asking "create a page titled X with these sections" produces a **mutation - plan + approval**, and on approve the page exists in the workspace. -- [ ] "Build a canvas of …" and "add a row to database …" work via the - corresponding `xnet_*` tools with approval. -- [ ] Switching the agent to **Codex** (and **OpenCode/Kimi**) works with no - panel changes beyond the registry selection. -- [ ] The **web** deployment, with `xnet bridge serve` running locally, drives the - same agent (loopback, pairing token, no CORS errors). -- [ ] "Create a plugin that …" scaffolds/edits plugin code via `runAgentTask` - (worktree), passes the validation gate, and opens a PR / installs locally. -- [ ] **No subscription token is ever read by XNet** — the agent CLI - authenticates itself (verify by inspecting what the bridge spawns/sends). -- [ ] Bridge daemon refuses non-loopback binds; pairing token + Origin allowlist - enforced on the web path. + *(Needs a packaged build + installed CLI; not exercised in CI.)* +- [x] Switching the agent CLI is a config choice (`--agent` / `XNET_BRIDGE_AGENT`): + Codex uses `['exec','{prompt}']`, others the Claude headless default — + covered by `chat-agent.test.ts` + `bridge.test.ts`. +- [x] The **web** deployment can drive the bridge via `xnet bridge serve` on + loopback (Origin allowlist + Private Network Access; no CORS errors for an + allowed origin) — covered by the preflight/origin tests. +- [x] **No subscription token is ever read by XNet** — the bridge spawns the + user's own CLI, which authenticates itself (BYO-agent by construction). +- [x] Bridge daemon refuses non-loopback binds; Origin allowlist enforced — + covered by `bridge-server.test.ts`. *(Pairing-token gate deferred.)* +- [ ] (ACP/Phase 1+) Asking "summarize my workspace" / "create a page" drives the + `xnet_*` MCP tools with approval and edits the workspace — deferred until + the agent is wired to XNet's MCP server (ACP `session/new mcpServers`). +- [ ] (Phase 4) "Create a plugin that …" scaffolds/edits plugin code via + `runAgentTask` (worktree → gate → PR). ## References