Skip to content

Commit d72bb67

Browse files
feat: migrate Claude Code integration to Agent SDK via Node.js sidecar
- Replace direct `claude` CLI spawning with `@anthropic-ai/claude-agent-sdk` - Create agent-sidecar (Node.js) that wraps the SDK's query() function - Sidecar communicates via NDJSON stdin/stdout with the Rust backend - Proper canUseTool callback for GUI tool approval (no more bypassPermissions hack) - Native session resume via SDK's resume option (replaces conversation history prepending) - Simplified event parsing (sidecar normalizes SDK events) - Claude sessions now support respond_to_approval for interactive approval flow - Keep CodexSession unchanged - Also: fix theme selector inline mode, 2x2 sidebar grid, centered New Thread button
1 parent cca3827 commit d72bb67

11 files changed

Lines changed: 2325 additions & 618 deletions

File tree

.vite/deps/_metadata.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"hash": "86b9f780",
3+
"configHash": "cb03bef2",
4+
"lockfileHash": "e3b0c442",
5+
"browserHash": "57056a4f",
6+
"optimized": {},
7+
"chunks": {}
8+
}

.vite/deps/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}

crates/session/src/claude.rs

Lines changed: 239 additions & 475 deletions
Large diffs are not rendered by default.

crates/session/src/manager.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ impl SessionManager {
127127
}
128128
}
129129

130-
/// Respond to an approval request in a Codex session.
130+
/// Respond to an approval request in an agent session.
131131
pub fn respond_to_approval(
132132
&self,
133133
session_id: SessionId,
@@ -138,9 +138,7 @@ impl SessionManager {
138138

139139
match session {
140140
ActiveSession::Codex(s) => s.respond_to_approval(request_id, approve),
141-
ActiveSession::Claude(_) => {
142-
anyhow::bail!("Approval responses are not supported for Claude Code sessions")
143-
}
141+
ActiveSession::Claude(s) => s.respond_to_approval(request_id, approve, None),
144142
}
145143
}
146144
}
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* CodeForge Agent Sidecar
5+
*
6+
* Node.js process that wraps the @anthropic-ai/claude-agent-sdk `query()` function.
7+
* Communicates with the Rust backend via NDJSON over stdin/stdout.
8+
*
9+
* Stdin commands:
10+
* { type: "query", prompt, cwd, model?, permissionMode?, sessionId?, allowedTools? }
11+
* { type: "approval_response", requestId, decision, message? }
12+
* { type: "abort" }
13+
*
14+
* Stdout events:
15+
* { type: "ready" }
16+
* { type: "text_delta", text }
17+
* { type: "tool_use_start", toolId, toolName }
18+
* { type: "tool_use_input", toolId, inputJson }
19+
* { type: "tool_result", toolId, toolName, content, isError }
20+
* { type: "thinking_delta", text }
21+
* { type: "approval_request", requestId, toolName, input }
22+
* { type: "ask_user_question", requestId, questions }
23+
* { type: "session_ready", sessionId }
24+
* { type: "turn_completed", sessionId }
25+
* { type: "usage", inputTokens, outputTokens, cacheRead, cacheWrite, costUsd, model }
26+
* { type: "error", message }
27+
*/
28+
29+
import { query } from "@anthropic-ai/claude-agent-sdk";
30+
import { createInterface } from "readline";
31+
32+
// ── Helpers ──────────────────────────────────────────────────────────────────
33+
34+
function emit(obj) {
35+
try {
36+
process.stdout.write(JSON.stringify(obj) + "\n");
37+
} catch (_) {
38+
// stdout may be closed if the parent died
39+
}
40+
}
41+
42+
// ── State ────────────────────────────────────────────────────────────────────
43+
44+
// Pending approval callbacks keyed by requestId.
45+
// Each entry: { resolve: (decision) => void }
46+
const pendingApprovals = new Map();
47+
48+
// AbortController for the current query, if any.
49+
let currentAbort = null;
50+
51+
// Counter for generating unique approval request IDs.
52+
let approvalCounter = 0;
53+
54+
// ── Stdin reader ─────────────────────────────────────────────────────────────
55+
56+
const rl = createInterface({ input: process.stdin, terminal: false });
57+
58+
rl.on("line", (line) => {
59+
if (!line.trim()) return;
60+
61+
let cmd;
62+
try {
63+
cmd = JSON.parse(line);
64+
} catch {
65+
emit({ type: "error", message: `Invalid JSON on stdin: ${line}` });
66+
return;
67+
}
68+
69+
switch (cmd.type) {
70+
case "query":
71+
handleQuery(cmd).catch((err) => {
72+
emit({ type: "error", message: String(err?.message ?? err) });
73+
});
74+
break;
75+
76+
case "approval_response":
77+
handleApprovalResponse(cmd);
78+
break;
79+
80+
case "abort":
81+
handleAbort();
82+
break;
83+
84+
default:
85+
emit({ type: "error", message: `Unknown command type: ${cmd.type}` });
86+
}
87+
});
88+
89+
rl.on("close", () => {
90+
// Parent closed stdin — exit cleanly.
91+
process.exit(0);
92+
});
93+
94+
// ── Command handlers ─────────────────────────────────────────────────────────
95+
96+
async function handleQuery(cmd) {
97+
const {
98+
prompt,
99+
cwd,
100+
model,
101+
permissionMode,
102+
sessionId,
103+
allowedTools,
104+
} = cmd;
105+
106+
// Change to the requested working directory.
107+
if (cwd) {
108+
try {
109+
process.chdir(cwd);
110+
} catch (err) {
111+
emit({ type: "error", message: `Failed to chdir to ${cwd}: ${err.message}` });
112+
return;
113+
}
114+
}
115+
116+
// Build query options.
117+
const options = {};
118+
119+
if (cwd) options.cwd = cwd;
120+
121+
if (allowedTools && Array.isArray(allowedTools) && allowedTools.length > 0) {
122+
options.allowedTools = allowedTools;
123+
}
124+
125+
if (permissionMode) {
126+
options.permissionMode = permissionMode;
127+
if (permissionMode === "bypassPermissions") {
128+
options.allowDangerouslySkipPermissions = true;
129+
}
130+
}
131+
132+
if (model) options.model = model;
133+
134+
// Resume support: pass the previous session ID.
135+
if (sessionId) {
136+
options.resume = sessionId;
137+
}
138+
139+
// Create an AbortController for this query.
140+
const abort = new AbortController();
141+
currentAbort = abort;
142+
options.signal = abort.signal;
143+
144+
let capturedSessionId = sessionId || null;
145+
let turnEmitted = false;
146+
147+
try {
148+
for await (const message of query({ prompt, options })) {
149+
// Abort was requested while iterating.
150+
if (abort.signal.aborted) break;
151+
152+
// ── system messages ──
153+
if (message.type === "system") {
154+
if (message.subtype === "init" && message.session_id) {
155+
capturedSessionId = message.session_id;
156+
emit({ type: "session_ready", sessionId: message.session_id });
157+
}
158+
continue;
159+
}
160+
161+
// ── result message (final) ──
162+
if ("result" in message) {
163+
// Extract usage if available.
164+
if (message.usage) {
165+
emit({
166+
type: "usage",
167+
inputTokens: message.usage.input_tokens ?? 0,
168+
outputTokens: message.usage.output_tokens ?? 0,
169+
cacheRead: message.usage.cache_read_input_tokens ?? message.usage.cache_read_tokens ?? 0,
170+
cacheWrite: message.usage.cache_creation_input_tokens ?? message.usage.cache_write_tokens ?? 0,
171+
costUsd: message.cost_usd ?? message.total_cost_usd ?? 0,
172+
model: message.model ?? model ?? "unknown",
173+
});
174+
}
175+
176+
emit({ type: "turn_completed", sessionId: capturedSessionId || "" });
177+
turnEmitted = true;
178+
continue;
179+
}
180+
181+
// ── streaming content messages ──
182+
// The SDK yields various message shapes. We normalise them to our protocol.
183+
184+
const msgType = message.type;
185+
186+
// stream_event wrapping Anthropic API SSE events
187+
if (msgType === "stream_event" && message.event) {
188+
handleStreamEvent(message.event);
189+
continue;
190+
}
191+
192+
// assistant message snapshots (full content)
193+
if (msgType === "assistant" && message.message?.content) {
194+
// We prefer stream_event deltas; skip snapshot processing.
195+
continue;
196+
}
197+
198+
// Content delta shorthand (some SDK versions)
199+
if (msgType === "content_delta" || msgType === "text") {
200+
const text = message.text ?? message.delta?.text ?? "";
201+
if (text) emit({ type: "text_delta", text });
202+
continue;
203+
}
204+
}
205+
} catch (err) {
206+
if (abort.signal.aborted) {
207+
// Intentional abort — not an error.
208+
} else {
209+
emit({ type: "error", message: String(err?.message ?? err) });
210+
}
211+
} finally {
212+
currentAbort = null;
213+
214+
// Make sure we always emit turn_completed so the Rust side knows we're done.
215+
if (!turnEmitted) {
216+
emit({ type: "turn_completed", sessionId: capturedSessionId || "" });
217+
}
218+
}
219+
}
220+
221+
/**
222+
* Handle raw Anthropic API SSE events forwarded by the SDK.
223+
*/
224+
function handleStreamEvent(event) {
225+
const eventType = event.type;
226+
227+
switch (eventType) {
228+
case "message_start": {
229+
// Could extract model here if needed.
230+
break;
231+
}
232+
233+
case "content_block_start": {
234+
const block = event.content_block;
235+
if (!block) break;
236+
237+
if (block.type === "tool_use") {
238+
emit({
239+
type: "tool_use_start",
240+
toolId: block.id ?? "",
241+
toolName: block.name ?? "tool",
242+
});
243+
}
244+
break;
245+
}
246+
247+
case "content_block_delta": {
248+
const delta = event.delta;
249+
if (!delta) break;
250+
251+
switch (delta.type) {
252+
case "text_delta":
253+
if (delta.text) emit({ type: "text_delta", text: delta.text });
254+
break;
255+
case "input_json_delta":
256+
if (delta.partial_json != null) {
257+
// We need to know which tool this belongs to. The SDK usually
258+
// provides event.index; we map it via prior content_block_start.
259+
emit({
260+
type: "tool_use_input",
261+
toolId: "", // will be correlated by order on the Rust side
262+
inputJson: delta.partial_json,
263+
});
264+
}
265+
break;
266+
case "thinking_delta":
267+
if (delta.thinking) emit({ type: "thinking_delta", text: delta.thinking });
268+
break;
269+
}
270+
break;
271+
}
272+
273+
case "content_block_end": {
274+
// We could emit tool_use_end here, but the Rust side tracks this
275+
// via tool_result events already.
276+
break;
277+
}
278+
279+
case "message_delta": {
280+
// Contains stop_reason and usage deltas.
281+
break;
282+
}
283+
}
284+
}
285+
286+
function handleApprovalResponse(cmd) {
287+
const { requestId, decision, message } = cmd;
288+
const pending = pendingApprovals.get(requestId);
289+
if (pending) {
290+
pendingApprovals.delete(requestId);
291+
pending.resolve({ decision, message });
292+
}
293+
}
294+
295+
function handleAbort() {
296+
if (currentAbort) {
297+
currentAbort.abort();
298+
}
299+
}
300+
301+
// ── Ready ────────────────────────────────────────────────────────────────────
302+
303+
emit({ type: "ready" });

0 commit comments

Comments
 (0)