Skip to content

Commit 8474e5e

Browse files
committed
feat: route opencode through headless spawn via app-server
OpenCode exposes an HTTP app-server (opencode serve); relay connects to it instead of spawning an interactive PTY. spawnAgentOnce routes cli=opencode through spawnCli({ transport: 'headless' }) (relay 8.3.0 API). Adds the agentRuntimes map and attachTerminal headless guard so PTY-only operations (resize, snapshot) are skipped for OpenCode agents. worker_stream chunks still flow through broker:pty-chunk → xterm for V1 terminal rendering. https://claude.ai/code/session_01KXU1uAUwx3L82TMLnAmU4z
1 parent 2e1292c commit 8474e5e

2 files changed

Lines changed: 39 additions & 7 deletions

File tree

src/main/broker.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type MockClient = {
1616
resizePty: ReturnType<typeof vi.fn>
1717
getPending: ReturnType<typeof vi.fn>
1818
spawnPty: ReturnType<typeof vi.fn>
19+
spawnCli: ReturnType<typeof vi.fn>
1920
onEvent: ReturnType<typeof vi.fn>
2021
addListener: ReturnType<typeof vi.fn>
2122
connectEvents: ReturnType<typeof vi.fn>
@@ -44,6 +45,10 @@ const mock = vi.hoisted(() => {
4445
client.agentNames.push(input.name)
4546
return { name: input.name, runtime: 'pty' }
4647
}),
48+
spawnCli: vi.fn(async (input: { name: string }) => {
49+
client.agentNames.push(input.name)
50+
return { name: input.name, runtime: 'headless' }
51+
}),
4752
setInboundDeliveryMode: vi.fn(async (_name: string, mode: string) => ({ mode, flushed: 0 })),
4853
snapshot: vi.fn(async () => ({ rows: 24, cols: 80, cursor: { x: 0, y: 0 }, screen: 'aGVsbG8=' })),
4954
resizePty: vi.fn(async () => undefined),

src/main/broker.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
HarnessDriverClient as AgentRelayClient,
1010
type RuntimeSpawnOptions as AgentRelaySpawnOptions,
1111
type SpawnPtyInput,
12+
type SpawnCliInput,
1213
type SendMessageInput,
1314
type BrokerEvent,
1415
type BrokerStatus,
@@ -1182,6 +1183,9 @@ export class BrokerManager {
11821183
// so agent names are project-unique in practice — the set tracks which
11831184
// session actually owns the worker so agent-scoped calls route correctly.
11841185
private agentSessions = new Map<string, Set<string>>()
1186+
// Tracks the runtime for each agent name so attachTerminal can skip PTY
1187+
// operations (snapshot, resizePty, input streams) for headless agents.
1188+
private agentRuntimes = new Map<string, 'pty' | 'headless'>()
11851189
private inputStreams = new Map<string, PtyInputStream>()
11861190
private inputStreamFallbacks = new Set<string>()
11871191
private inputStreamFallbackRetryAt = new Map<string, number>()
@@ -2037,12 +2041,16 @@ export class BrokerManager {
20372041

20382042
if (event.kind === 'agent_spawned' && event.name) {
20392043
this.rememberAgentSession(event.name, sessionKey)
2044+
if ('runtime' in event && (event.runtime === 'pty' || event.runtime === 'headless')) {
2045+
this.agentRuntimes.set(event.name, event.runtime)
2046+
}
20402047
if (event.parent) {
20412048
void this.handleSpawnedChildLineage(sessionKey, event)
20422049
}
20432050
} else if (event.kind === 'agent_exit' && event.name) {
20442051
this.closeInputStream(this.getInputStreamKey(sessionKey, event.name), 1000, 'agent closed')
20452052
this.forgetAgentSession(event.name, sessionKey)
2053+
this.agentRuntimes.delete(event.name)
20462054
void client.release(event.name, 'agent exit').catch((err) => {
20472055
if (!isMissingAgentError(err)) {
20482056
console.warn(`[broker] Failed to release exited agent ${event.name}:`, err)
@@ -2051,6 +2059,7 @@ export class BrokerManager {
20512059
} else if ((event.kind === 'agent_exited' || event.kind === 'agent_released') && event.name) {
20522060
this.closeInputStream(this.getInputStreamKey(sessionKey, event.name), 1000, 'agent closed')
20532061
this.forgetAgentSession(event.name, sessionKey)
2062+
this.agentRuntimes.delete(event.name)
20542063
} else if ('name' in event && typeof event.name === 'string') {
20552064
this.rememberAgentSession(event.name, sessionKey)
20562065
} else if ('from' in event && typeof event.from === 'string') {
@@ -2376,8 +2385,22 @@ export class BrokerManager {
23762385
nextInput.cli
23772386
)
23782387
}
2379-
const spawned = await session.client.spawnPty(nextInput)
2388+
// OpenCode exposes an HTTP app-server; run it headless so relay connects
2389+
// to the server instead of spawning an interactive PTY. worker_stream
2390+
// chunks still flow to broker:pty-chunk → xterm for V1 rendering.
2391+
const useHeadless = !shellSession && spawnCliLabel(nextInput.cli) === 'opencode'
2392+
const headlessClient = session.client as AgentRelayClient & {
2393+
spawnCli(input: SpawnCliInput): Promise<{ name: string; runtime: string }>
2394+
}
2395+
const spawned = useHeadless
2396+
? await headlessClient.spawnCli({ ...nextInput, transport: 'headless' } as SpawnCliInput)
2397+
: await session.client.spawnPty(nextInput)
23802398
const spawnedName = spawned.name || nextInput.name
2399+
const resolvedRuntime: 'pty' | 'headless' =
2400+
spawned.runtime === 'headless' || (useHeadless && !spawned.runtime) ? 'headless' : 'pty'
2401+
// Set immediately so attachTerminal sees the correct runtime before the
2402+
// async agent_spawned event fires.
2403+
this.agentRuntimes.set(spawnedName, resolvedRuntime)
23812404
this.rememberAgentSession(spawnedName, sessionKeyFor(session))
23822405
const burnInput = { ...nextInput, name: spawnedName }
23832406
const lineage = session.pearLineage.get(spawnedName)
@@ -2393,12 +2416,7 @@ export class BrokerManager {
23932416
).catch((err) => {
23942417
console.warn('[burn-spawn-hook] post-spawn burn stamp failed:', err)
23952418
})
2396-
return {
2397-
name: spawnedName,
2398-
runtime: typeof spawned.runtime === 'string' && spawned.runtime.trim()
2399-
? spawned.runtime
2400-
: 'pty'
2401-
}
2419+
return { name: spawnedName, runtime: resolvedRuntime }
24022420
} catch (err) {
24032421
if (!isAgentNameConflict(err)) {
24042422
throw buildSpawnFailureError(err, nextInput, session.cloudSandboxId ? 'cloud' : 'local')
@@ -2731,6 +2749,7 @@ export class BrokerManager {
27312749
console.warn(`[broker] attachTerminal: ${name} did not appear in listAgents within wait window; falling through to per-call retry`)
27322750
}
27332751
const mode = toInboundDeliveryMode(input.mode)
2752+
const isHeadless = this.agentRuntimes.get(name) === 'headless'
27342753
let previousMode: InboundDeliveryMode | undefined
27352754

27362755
try {
@@ -2743,6 +2762,14 @@ export class BrokerManager {
27432762
// queue mode while human terminal input continues to go through sendInput.
27442763
await this.withAgentMissingRetry('setInboundDeliveryMode', name, () => client.setInboundDeliveryMode(name, mode))
27452764

2765+
// Headless agents (app-server based) have no PTY — skip resize and snapshot.
2766+
if (isHeadless) {
2767+
const pending = mode === 'manual_flush'
2768+
? await this.withAgentMissingRetry('getPending', name, () => client.getPending(name)).then((messages) => messages.length).catch(() => 0)
2769+
: 0
2770+
return { name, mode, previousMode, pending, runtime: 'headless' }
2771+
}
2772+
27462773
let resizedBeforeSnapshot = false
27472774
if (isPositiveInteger(input.rows) && isPositiveInteger(input.cols)) {
27482775
try {

0 commit comments

Comments
 (0)