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
1 change: 1 addition & 0 deletions apps/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
94 changes: 94 additions & 0 deletions apps/electron/src/main/agent-bridge-manager.ts
Original file line number Diff line number Diff line change
@@ -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(

Check warning

Code scanning / fallow

Function has a high CRAP score (complexity combined with low coverage) Warning

'startAgentBridge' has CRAP score 56.0 (threshold: 30.0, cyclomatic 7)
options: { agent?: string; cwd?: string } = {}
): Promise<AgentBridgeStatus> {
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<void> {
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()
})
}
11 changes: 11 additions & 0 deletions apps/electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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()

Expand Down Expand Up @@ -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()

Expand Down
6 changes: 6 additions & 0 deletions apps/electron/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading
Loading