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