Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -459,9 +459,11 @@ export const AGENTS = {
- [x] Expose devkit `runAgentTask` as a command: `xnet code "<intent>"`
(worktree → gate → checkpoint/rollback → optional `--pr`), so an agent can
author/edit xNet or a scaffolded plugin from the CLI.
- [ ] Wire "create/edit plugin" into the in-app UI (e.g. the bridge daemon's
`/run` endpoint over devkit `handleBridgeRun`) + combine with the plugin
scaffolder.
- [x] Bridge daemon `POST /run` endpoint over devkit `handleBridgeRun`
(opt-in via `xnet bridge serve --code`; 501 when disabled) — the HTTP seam
the in-app UI will call to trigger a gated code task.
- [ ] Wire a "create/edit plugin" button in the UI to `POST /run` + combine with
the plugin scaffolder.
- [ ] Surface honest unavailability when no local daemon/agent is present (the
Electron manager already records a `detail` reason; surface it in the panel).

Expand Down
30 changes: 25 additions & 5 deletions packages/cli/src/commands/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
buildAgentArgs,
cliAgentRunner,
cliChatAgent,
createBridgeServer,
DEFAULT_BRIDGE_PORT,
defaultXnetGate,
Git,
handleBridgeRun,
mcpConfigFor,
NodeCommandRunner,
type BridgeServerHandle,
Expand All @@ -34,6 +38,8 @@ export interface BridgeServeOptions {
cwd?: string
/** Path to an MCP config JSON giving the agent XNet's workspace tools. */
mcpConfigPath?: string
/** Enable `POST /run` — agentic code tasks (worktree → gate → checkpoint/PR). */
code?: boolean
}

/** Build (but don't start) the bridge server for the chosen agent. Injectable runner for tests. */
Expand All @@ -42,17 +48,30 @@ export function buildBridgeServer(
runner: CommandRunner = new NodeCommandRunner()
): BridgeServerHandle {
const command = options.agent ?? 'claude'
const cwd = options.cwd ?? process.cwd()
const args = buildAgentArgs(command, {
...(options.mcpConfigPath ? { mcpConfigPath: options.mcpConfigPath } : {})
})
const agent = cliChatAgent(runner, {
command,
cwd: options.cwd ?? process.cwd(),
args
})
const agent = cliChatAgent(runner, { command, cwd, args })
// `--code` enables the agentic dev-loop over HTTP (powerful → opt-in): the
// coding agent edits in a worktree off `cwd`, then the gate runs.
const run = options.code
? (request: Parameters<typeof handleBridgeRun>[1]) =>
handleBridgeRun(
{
git: new Git(runner, cwd),
runner,
agent: cliAgentRunner(runner, { command }),
gate: defaultXnetGate(),
worktreeRoot: join(cwd, '.xnet', 'agent-worktrees')
},
request
)
: undefined
return createBridgeServer({
agent,
agentName: command,
...(run ? { run } : {}),
...(options.host ? { host: options.host } : {}),
...(options.port !== undefined ? { port: options.port } : {}),
...(options.allowOrigin ? { allowedOrigins: options.allowOrigin } : {})
Expand All @@ -75,6 +94,7 @@ export function registerBridgeCommand(program: Command): void {
'Browser origins permitted (e.g. https://user.github.io for the web deployment)'
)
.option('--cwd <dir>', 'Working directory the agent runs in (default current dir)')
.option('--code', 'Enable POST /run agentic code tasks (worktree → gate → checkpoint/PR)')
.option('--mcp', "Give the agent XNet's workspace tools via `xnet mcp serve`")
.option(
'--mcp-api-url <url>',
Expand Down
60 changes: 60 additions & 0 deletions packages/devkit/src/bridge-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,64 @@ describe('createBridgeServer', () => {
})
expect(res.status).toBe(502)
})

it('answers /run with 501 when code tasks are not enabled', async () => {
const url = await start()
const res = await fetch(`${url}/run`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ taskId: 't1', prompt: 'do it' })
})
expect(res.status).toBe(501)
})

it('delegates /run to the configured handler', async () => {
let seen: { taskId: string; prompt: string } | undefined
const url = await start({
run: async (request) => {
seen = { taskId: request.taskId, prompt: request.prompt }
return {
ok: true,
branch: `agent/${request.taskId}`,
worktreePath: '/wt',
gate: { ok: true, steps: [] },
rolledBack: false,
agentOutput: 'done'
}
}
})
const res = await fetch(`${url}/run`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ taskId: 't1', prompt: 'add a toggle' })
})
const body = (await res.json()) as { ok: boolean; branch: string }
expect(res.status).toBe(200)
expect(body).toMatchObject({ ok: true, branch: 'agent/t1' })
expect(seen).toEqual({ taskId: 't1', prompt: 'add a toggle' })
})

it('rejects /run without taskId + prompt (400)', async () => {
const url = await start({ run: async () => ({}) as never })
const res = await fetch(`${url}/run`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt: 'no id' })
})
expect(res.status).toBe(400)
})

it('returns 502 when the /run handler throws', async () => {
const url = await start({
run: async () => {
throw new Error('worktree boom')
}
})
const res = await fetch(`${url}/run`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ taskId: 't1', prompt: 'x' })
})
expect(res.status).toBe(502)
})
})
41 changes: 40 additions & 1 deletion packages/devkit/src/bridge-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
*/

import type { ChatAgent, ChatMessage } from './chat-agent'
import type { AgentTaskResult } from './dev-loop'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import { bridgeHealth } from './bridge'
import { bridgeHealth, type BridgeRunRequest } from './bridge'

const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
/** Default port — the address the connector ladder (0174) probes. */
Expand All @@ -42,6 +43,13 @@ export interface BridgeServerConfig {
* web origin must be listed here to reach the local agent.
*/
allowedOrigins?: string[]
/**
* Optional code-task handler for `POST /run` (e.g. devkit `handleBridgeRun`):
* isolate in a worktree → agent edits → gate → checkpoint/rollback. Opt-in —
* when absent, `/run` answers 501. This is powerful (runs a coding agent + the
* gate), so callers enable it explicitly.
*/
run?: (request: BridgeRunRequest) => Promise<AgentTaskResult>
}

export interface BridgeServerHandle {
Expand Down Expand Up @@ -114,6 +122,37 @@ export function createBridgeServer(config: BridgeServerConfig): BridgeServerHand
return
}

if (req.method === 'POST' && path === '/run') {
if (!config.run) {
sendJson(res, 501, { error: 'code tasks are not enabled on this bridge' })
return
}
let body: Record<string, unknown>
try {
body = await readJson(req)
} catch (err) {
sendJson(res, 400, { error: { message: messageOf(err) } })
return
}
const taskId = typeof body.taskId === 'string' ? body.taskId : ''
const prompt = typeof body.prompt === 'string' ? body.prompt : ''
if (!taskId || !prompt) {
sendJson(res, 400, { error: 'taskId and prompt are required' })
return
}
const request: BridgeRunRequest = {
taskId,
prompt,
...(typeof body.worktreeName === 'string' ? { worktreeName: body.worktreeName } : {})
}
try {
sendJson(res, 200, await config.run(request))
} catch (err) {
sendJson(res, 502, { error: { message: messageOf(err) } })
}
return
}

sendJson(res, 404, { error: 'not found' })
}

Expand Down
Loading