@@ -147,6 +147,16 @@ type Session = {
147147 * DEFAULT_CONTEXT_WINDOW, refreshed from each result's modelUsage, and
148148 * invalidated when the user switches the session's model. */
149149 contextWindowSize : number ;
150+ /** Ephemeral side-question Query spawned by /btw. Tracked so cancel() can
151+ * interrupt it alongside the main query. Undefined when no /btw is in flight. */
152+ btwQuery ?: Query ;
153+ /** Resolvers for /btw handlers waiting for the main query to become idle.
154+ * Drained by prompt()'s finally block when promptRunning flips to false,
155+ * and by cancel() so queued /btw calls bail out. */
156+ idleResolvers : Array < ( ) => void > ;
157+ /** True once prompt() has started a main turn at least once, meaning the
158+ * session JSONL exists on disk and is safe for /btw to resume from. */
159+ hasRunMainPrompt : boolean ;
150160} ;
151161
152162/** Compute a stable fingerprint of the session-defining params so we can
@@ -581,6 +591,14 @@ export class ClaudeAcpAgent implements Agent {
581591 const isLocalOnlyCommand =
582592 firstText . startsWith ( "/" ) && LOCAL_ONLY_COMMANDS . has ( firstText . split ( " " , 1 ) [ 0 ] ) ;
583593
594+ // /btw <question>: ephemeral side-question. Routes to a separate query()
595+ // that resumes the main session's context but does not persist, so the
596+ // main session's transcript stays clean for subsequent turns.
597+ if ( firstText === "/btw" || firstText . startsWith ( "/btw " ) ) {
598+ const question = firstText . slice ( 4 ) . trim ( ) ;
599+ return await this . handleBtwQuestion ( session , params , question ) ;
600+ }
601+
584602 if ( session . promptRunning ) {
585603 session . input . push ( userMessage ) ;
586604 const order = session . nextPendingOrder ++ ;
@@ -602,6 +620,13 @@ export class ClaudeAcpAgent implements Agent {
602620 while ( true ) {
603621 const { value : message , done } = await session . query . next ( ) ;
604622
623+ // The SDK subprocess has emitted a message — the session JSONL exists
624+ // on disk by now, so subsequent /btw calls can safely `resume` from it.
625+ // Flipping here rather than before the loop ensures we don't mark the
626+ // session as run if the subprocess fails to start (errors are caught
627+ // below and /btw resume would have failed against a nonexistent file).
628+ session . hasRunMainPrompt = true ;
629+
605630 if ( done || ! message ) {
606631 if ( session . cancelled ) {
607632 return { stopReason : "cancelled" } ;
@@ -1008,6 +1033,9 @@ export class ClaudeAcpAgent implements Agent {
10081033 } finally {
10091034 if ( ! handedOff ) {
10101035 session . promptRunning = false ;
1036+ // Release any /btw handlers waiting on main-session idle.
1037+ const idleWaiters = session . idleResolvers . splice ( 0 ) ;
1038+ for ( const resolve of idleWaiters ) resolve ( ) ;
10111039 // This usually should not happen, but in case the loop finishes
10121040 // without claude sending all message replays, we resolve the
10131041 // next pending prompt call to ensure no prompts get stuck.
@@ -1034,9 +1062,180 @@ export class ClaudeAcpAgent implements Agent {
10341062 pending . resolve ( true ) ;
10351063 }
10361064 session . pendingMessages . clear ( ) ;
1065+ // Release any /btw handlers waiting on main-session idle; they observe
1066+ // session.cancelled and return stopReason: "cancelled".
1067+ const idleWaiters = session . idleResolvers . splice ( 0 ) ;
1068+ for ( const resolve of idleWaiters ) resolve ( ) ;
1069+ // Interrupt an in-flight /btw side-question Query, if any.
1070+ if ( session . btwQuery ) {
1071+ try {
1072+ await session . btwQuery . interrupt ( ) ;
1073+ } catch ( error ) {
1074+ this . logger . error (
1075+ `[claude-agent-acp] Error interrupting /btw query: ${ ( error as Error ) . message } ` ,
1076+ ) ;
1077+ }
1078+ }
10371079 await session . query . interrupt ( ) ;
10381080 }
10391081
1082+ /**
1083+ * Handle a `/btw <question>` side question. Spawns an ephemeral Query that
1084+ * resumes the main session's transcript for context but uses
1085+ * `persistSession: false` so nothing is written to disk. The assistant's
1086+ * response is forwarded to Zed on the main ACP sessionId. Tools are
1087+ * disabled via `tools: []` + `disallowedTools: ["*"]` and turns are capped
1088+ * at 1, so the side query can only produce a single text answer.
1089+ *
1090+ * Serialized behind any in-flight main turn via session.idleResolvers so
1091+ * the two queries don't interleave output on the same ACP sessionId.
1092+ */
1093+ private async handleBtwQuestion (
1094+ session : Session ,
1095+ params : PromptRequest ,
1096+ question : string ,
1097+ ) : Promise < PromptResponse > {
1098+ if ( question === "" ) {
1099+ await this . client . sessionUpdate ( {
1100+ sessionId : params . sessionId ,
1101+ update : {
1102+ sessionUpdate : "agent_message_chunk" ,
1103+ content : { type : "text" , text : "Usage: `/btw <question>`" } ,
1104+ } ,
1105+ } ) ;
1106+ return { stopReason : "end_turn" } ;
1107+ }
1108+
1109+ // Only one /btw in flight per session. ACP clients serialize prompt()
1110+ // per session so this shouldn't happen from Zed, but guard the invariant
1111+ // so a stray concurrent call doesn't orphan an in-flight Query.
1112+ if ( session . btwQuery ) {
1113+ await this . client . sessionUpdate ( {
1114+ sessionId : params . sessionId ,
1115+ update : {
1116+ sessionUpdate : "agent_message_chunk" ,
1117+ content : {
1118+ type : "text" ,
1119+ text : "A `/btw` question is already in progress — wait for it to finish." ,
1120+ } ,
1121+ } ,
1122+ } ) ;
1123+ return { stopReason : "end_turn" } ;
1124+ }
1125+
1126+ // Wait for main query to idle so output streams don't interleave and
1127+ // so the side query's `resume` reads a stable on-disk transcript.
1128+ if ( session . promptRunning ) {
1129+ await new Promise < void > ( ( resolve ) => {
1130+ session . idleResolvers . push ( resolve ) ;
1131+ } ) ;
1132+ if ( session . cancelled ) {
1133+ return { stopReason : "cancelled" } ;
1134+ }
1135+ }
1136+
1137+ const input = new Pushable < SDKUserMessage > ( ) ;
1138+ input . push ( {
1139+ type : "user" ,
1140+ message : { role : "user" , content : [ { type : "text" , text : question } ] } ,
1141+ session_id : params . sessionId ,
1142+ parent_tool_use_id : null ,
1143+ } ) ;
1144+ // maxTurns: 1 means the SDK won't need more input; close the stream so
1145+ // the subprocess sees EOF cleanly rather than relying solely on close().
1146+ input . end ( ) ;
1147+
1148+ const canResume = session . hasRunMainPrompt ;
1149+ const sideOptions : Options = {
1150+ cwd : session . cwd ,
1151+ persistSession : false ,
1152+ maxTurns : 1 ,
1153+ tools : [ ] ,
1154+ disallowedTools : [ "*" ] ,
1155+ systemPrompt : { type : "preset" , preset : "claude_code" } ,
1156+ settingSources : [ "user" , "project" , "local" ] ,
1157+ executable : isStaticBinary ( ) ? undefined : ( process . execPath as any ) ,
1158+ ...( process . env . CLAUDE_CODE_EXECUTABLE
1159+ ? { pathToClaudeCodeExecutable : process . env . CLAUDE_CODE_EXECUTABLE }
1160+ : isStaticBinary ( )
1161+ ? { pathToClaudeCodeExecutable : await claudeCliPath ( ) }
1162+ : { } ) ,
1163+ ...( canResume ? { resume : params . sessionId } : { } ) ,
1164+ } ;
1165+
1166+ let sideQuery : Query ;
1167+ try {
1168+ sideQuery = query ( { prompt : input , options : sideOptions } ) ;
1169+ session . btwQuery = sideQuery ;
1170+ await sideQuery . initializationResult ( ) ;
1171+ if ( session . models . currentModelId ) {
1172+ try {
1173+ await sideQuery . setModel ( session . models . currentModelId ) ;
1174+ } catch ( error ) {
1175+ this . logger . error ( `[claude-agent-acp] /btw setModel failed: ${ ( error as Error ) . message } ` ) ;
1176+ }
1177+ }
1178+ } catch ( error ) {
1179+ session . btwQuery = undefined ;
1180+ await this . client . sessionUpdate ( {
1181+ sessionId : params . sessionId ,
1182+ update : {
1183+ sessionUpdate : "agent_message_chunk" ,
1184+ content : {
1185+ type : "text" ,
1186+ text : `\`/btw\` failed: ${ ( error as Error ) . message } ` ,
1187+ } ,
1188+ } ,
1189+ } ) ;
1190+ return { stopReason : "end_turn" } ;
1191+ }
1192+
1193+ const ephemeralCache : ToolUseCache = { } ;
1194+ try {
1195+ for await ( const message of sideQuery ) {
1196+ if ( session . cancelled ) {
1197+ return { stopReason : "cancelled" } ;
1198+ }
1199+ if ( message . type === "assistant" && message . parent_tool_use_id === null ) {
1200+ const notifications = toAcpNotifications (
1201+ message . message . content ,
1202+ "assistant" ,
1203+ params . sessionId ,
1204+ ephemeralCache ,
1205+ this . client ,
1206+ this . logger ,
1207+ {
1208+ clientCapabilities : this . clientCapabilities ,
1209+ cwd : session . cwd ,
1210+ registerHooks : false ,
1211+ } ,
1212+ ) ;
1213+ for ( const notification of notifications ) {
1214+ notification . update . _meta = {
1215+ ...notification . update . _meta ,
1216+ claudeCode : {
1217+ ...( notification . update . _meta ?. claudeCode || { } ) ,
1218+ btw : true ,
1219+ } ,
1220+ } ;
1221+ await this . client . sessionUpdate ( notification ) ;
1222+ }
1223+ } else if ( message . type === "result" ) {
1224+ break ;
1225+ }
1226+ }
1227+ } finally {
1228+ session . btwQuery = undefined ;
1229+ try {
1230+ sideQuery . close ( ) ;
1231+ } catch {
1232+ // ignore close errors; the subprocess may already be gone
1233+ }
1234+ }
1235+
1236+ return { stopReason : session . cancelled ? "cancelled" : "end_turn" } ;
1237+ }
1238+
10401239 /** Cleanly tear down a session: cancel in-flight work, dispose resources,
10411240 * and remove it from the session map. */
10421241 private async teardownSession ( sessionId : string ) : Promise < void > {
@@ -1716,6 +1915,8 @@ export class ClaudeAcpAgent implements Agent {
17161915 emitRawSDKMessages : sessionMeta ?. claudeCode ?. emitRawSDKMessages ?? false ,
17171916 contextWindowSize :
17181917 inferContextWindowFromModel ( models . currentModelId ) ?? DEFAULT_CONTEXT_WINDOW ,
1918+ idleResolvers : [ ] ,
1919+ hasRunMainPrompt : false ,
17191920 } ;
17201921
17211922 return {
@@ -1944,7 +2145,7 @@ async function getAvailableModels(
19442145 } ;
19452146}
19462147
1947- function getAvailableSlashCommands ( commands : SlashCommand [ ] ) : AvailableCommand [ ] {
2148+ export function getAvailableSlashCommands ( commands : SlashCommand [ ] ) : AvailableCommand [ ] {
19482149 const UNSUPPORTED_COMMANDS = [
19492150 "cost" ,
19502151 "keybindings-help" ,
@@ -1955,7 +2156,7 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[]
19552156 "todos" ,
19562157 ] ;
19572158
1958- return commands
2159+ const result = commands
19592160 . map ( ( command ) => {
19602161 const input = command . argumentHint
19612162 ? {
@@ -1975,6 +2176,17 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[]
19752176 } ;
19762177 } )
19772178 . filter ( ( command : AvailableCommand ) => ! UNSUPPORTED_COMMANDS . includes ( command . name ) ) ;
2179+
2180+ // Append /btw — implemented in this agent layer (not backed by the SDK's
2181+ // supportedCommands list), for ephemeral side questions that don't persist
2182+ // to the session transcript. See handleBtwQuestion.
2183+ result . push ( {
2184+ name : "btw" ,
2185+ description : "Ask a side question — not saved to this thread's context" ,
2186+ input : { hint : "question" } ,
2187+ } ) ;
2188+
2189+ return result ;
19782190}
19792191
19802192function formatUriAsLink ( uri : string ) : string {
0 commit comments