Skip to content

Commit 6fbd13b

Browse files
committed
feat: add /btw ephemeral side-question command
Adds a `/btw <question>` slash command that answers a tangential question with full access to the current conversation's context, but does NOT persist either the question or answer to the session transcript. Subsequent turns see a pristine context. This mirrors the Claude Code CLI's `/btw` semantics using only public SDK primitives: - persistSession: false — nothing written to ~/.claude/projects/ - resume: <sessionId> — side query has access to main context - maxTurns: 1 + tools: [] + disallowedTools: ["*"] — single turn, tools disabled at model-context level (not just execution) The side query's assistant text is forwarded to the ACP client on the main sessionId via toAcpNotifications so the answer appears inline. Serialized behind any in-flight main turn via a new `idleResolvers` list so the two queries don't interleave output. Interruptible via ACP cancel(), which interrupts btwQuery alongside the main query. Session type additions: - btwQuery?: Query — cancel handle for in-flight side question - idleResolvers: Array<() => void> — wait-for-idle barrier - hasRunMainPrompt: boolean — gates whether resume is safe to use; flipped inside the main loop after the first successful query.next() so a failed subprocess-start doesn't leave /btw trying to resume a nonexistent JSONL Guards: - Empty argument shows a usage notice without spawning a subprocess - Concurrent /btw returns an in-progress notice (ACP clients serialize prompt() per session, but the invariant is enforced) Tests cover: empty-argument (with and without trailing space), concurrent-guard, cancel-while-waiting-for-idle, and the available-commands append path.
1 parent b2fa8fb commit 6fbd13b

2 files changed

Lines changed: 396 additions & 3 deletions

File tree

src/acp-agent.ts

Lines changed: 214 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

19802192
function formatUriAsLink(uri: string): string {

0 commit comments

Comments
 (0)