Skip to content

Commit e874e16

Browse files
author
StackMemory Bot (CLI)
committed
feat(symphony): add Claude Code app-server adapter for Symphony orchestrator
Translates Codex JSON-RPC 2.0 stdio protocol to Claude Code CLI calls. Primary mode uses `claude -p --output-format stream-json` for full agentic tool use; falls back to `claude --print` if streaming fails.
1 parent 4d77ecf commit e874e16

File tree

1 file changed

+340
-0
lines changed

1 file changed

+340
-0
lines changed
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Claude Code App-Server Adapter for Symphony
4+
*
5+
* Speaks the Codex app-server JSON-RPC 2.0 stdio protocol
6+
* but runs Claude Code underneath.
7+
*
8+
* Primary mode: `claude -p --output-format stream-json` (full tool use)
9+
* Fallback mode: `claude --print` (single-turn, no tools)
10+
*
11+
* Symphony spawns this process and sends:
12+
* initialize → thread/start → turn/start → (stream events) → turn/completed
13+
*
14+
* Usage in WORKFLOW.md:
15+
* codex:
16+
* command: node /path/to/claude-app-server.cjs
17+
*/
18+
19+
const { spawn } = require('child_process');
20+
const { randomUUID } = require('crypto');
21+
const readline = require('readline');
22+
23+
// State
24+
let threadId = null;
25+
let workspace = null;
26+
let sessionId = null;
27+
28+
// Read JSON-RPC messages from stdin (one per line)
29+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
30+
31+
rl.on('line', (line) => {
32+
const trimmed = line.trim();
33+
if (!trimmed) return;
34+
35+
let msg;
36+
try {
37+
msg = JSON.parse(trimmed);
38+
} catch {
39+
return;
40+
}
41+
42+
handleMessage(msg);
43+
});
44+
45+
rl.on('close', () => {
46+
process.exit(0);
47+
});
48+
49+
function send(obj) {
50+
process.stdout.write(JSON.stringify(obj) + '\n');
51+
}
52+
53+
function log(msg) {
54+
process.stderr.write(`[claude-adapter] ${msg}\n`);
55+
}
56+
57+
function handleMessage(msg) {
58+
const { method, id, params } = msg;
59+
60+
switch (method) {
61+
case 'initialize':
62+
send({
63+
id,
64+
result: {
65+
serverInfo: {
66+
name: 'claude-app-server-adapter',
67+
version: '0.2.0',
68+
},
69+
capabilities: {},
70+
},
71+
});
72+
break;
73+
74+
case 'initialized':
75+
break;
76+
77+
case 'thread/start':
78+
threadId = randomUUID();
79+
workspace = params?.cwd || process.cwd();
80+
send({
81+
id,
82+
result: { thread: { id: threadId } },
83+
});
84+
break;
85+
86+
case 'turn/start':
87+
handleTurn(id, params);
88+
break;
89+
90+
default:
91+
if (id !== undefined) {
92+
send({ id, result: {} });
93+
}
94+
break;
95+
}
96+
}
97+
98+
async function handleTurn(turnRequestId, params) {
99+
const turnId = randomUUID();
100+
sessionId = `${threadId}-${turnId}`;
101+
const prompt = extractPrompt(params);
102+
const cwd = params?.cwd || workspace;
103+
104+
send({
105+
id: turnRequestId,
106+
result: { turn: { id: turnId } },
107+
});
108+
109+
try {
110+
// Primary: stream-json mode with full tool use
111+
log(`Starting agentic turn (stream-json) in ${cwd}`);
112+
const result = await runClaudeStreaming(prompt, cwd, turnId);
113+
114+
send({
115+
method: 'turn/completed',
116+
params: {
117+
turnId,
118+
threadId,
119+
result: {
120+
type: 'completed',
121+
output: [{ type: 'text', text: result }],
122+
},
123+
},
124+
});
125+
} catch (err) {
126+
log(`Agentic mode failed: ${err.message}`);
127+
log('Falling back to single-turn --print mode');
128+
129+
try {
130+
const fallbackResult = await runClaudePrint(prompt, cwd);
131+
132+
send({
133+
method: 'turn/completed',
134+
params: {
135+
turnId,
136+
threadId,
137+
result: {
138+
type: 'completed',
139+
output: [{ type: 'text', text: fallbackResult }],
140+
},
141+
},
142+
});
143+
} catch (fallbackErr) {
144+
log(`Fallback also failed: ${fallbackErr.message}`);
145+
146+
send({
147+
method: 'turn/failed',
148+
params: {
149+
turnId,
150+
threadId,
151+
error: {
152+
message: `Primary: ${err.message} | Fallback: ${fallbackErr.message}`,
153+
},
154+
},
155+
});
156+
}
157+
}
158+
}
159+
160+
function extractPrompt(params) {
161+
if (!params?.input) return '';
162+
return params.input
163+
.filter((item) => item.type === 'text')
164+
.map((item) => item.text)
165+
.join('\n');
166+
}
167+
168+
/**
169+
* Primary mode: `claude -p --output-format stream-json`
170+
*
171+
* Runs Claude Code with full tool use (Bash, Edit, Read, etc.).
172+
* Streams JSON events, we parse them and forward relevant ones
173+
* to Symphony as notifications, then collect the final result.
174+
*/
175+
function runClaudeStreaming(prompt, cwd, turnId) {
176+
return new Promise((resolve, reject) => {
177+
const args = [
178+
'-p',
179+
'--output-format', 'stream-json',
180+
'--dangerously-skip-permissions',
181+
prompt,
182+
];
183+
184+
const claude = spawn('claude', args, {
185+
cwd,
186+
env: { ...process.env },
187+
stdio: ['pipe', 'pipe', 'pipe'],
188+
});
189+
190+
let lastAssistantText = '';
191+
let toolUseCount = 0;
192+
let lineBuffer = '';
193+
194+
claude.stdout.on('data', (chunk) => {
195+
lineBuffer += chunk.toString();
196+
197+
// Process complete lines
198+
const lines = lineBuffer.split('\n');
199+
lineBuffer = lines.pop(); // keep incomplete last line
200+
201+
for (const line of lines) {
202+
if (!line.trim()) continue;
203+
try {
204+
const event = JSON.parse(line);
205+
processStreamEvent(event, turnId);
206+
207+
// Track outputs
208+
if (event.type === 'assistant' && event.message) {
209+
const textBlocks = (event.message.content || [])
210+
.filter((b) => b.type === 'text')
211+
.map((b) => b.text);
212+
if (textBlocks.length > 0) {
213+
lastAssistantText = textBlocks.join('\n');
214+
}
215+
216+
const toolBlocks = (event.message.content || [])
217+
.filter((b) => b.type === 'tool_use');
218+
toolUseCount += toolBlocks.length;
219+
}
220+
221+
// Also capture result message
222+
if (event.type === 'result' && event.result) {
223+
lastAssistantText = event.result;
224+
}
225+
} catch {
226+
// non-JSON line from claude, ignore
227+
}
228+
}
229+
});
230+
231+
let stderr = '';
232+
claude.stderr.on('data', (data) => {
233+
stderr += data.toString();
234+
});
235+
236+
claude.on('close', (code) => {
237+
// Process any remaining buffer
238+
if (lineBuffer.trim()) {
239+
try {
240+
const event = JSON.parse(lineBuffer);
241+
if (event.type === 'result' && event.result) {
242+
lastAssistantText = event.result;
243+
}
244+
} catch {
245+
// ignore
246+
}
247+
}
248+
249+
log(`Claude stream exited code=${code} tools_used=${toolUseCount}`);
250+
251+
if (code === 0 && lastAssistantText) {
252+
resolve(lastAssistantText);
253+
} else if (code === 0) {
254+
resolve('(Claude completed but produced no text output)');
255+
} else {
256+
reject(new Error(`Claude stream exited code ${code}: ${stderr.slice(0, 500)}`));
257+
}
258+
});
259+
260+
claude.on('error', (err) => {
261+
reject(new Error(`Failed to spawn claude: ${err.message}`));
262+
});
263+
});
264+
}
265+
266+
/**
267+
* Forward stream events to Symphony as notifications.
268+
* Symphony expects these as method notifications (no id).
269+
*/
270+
function processStreamEvent(event, turnId) {
271+
if (!event.type) return;
272+
273+
switch (event.type) {
274+
case 'assistant': {
275+
// Forward tool_use blocks as command executions
276+
const content = event.message?.content || [];
277+
for (const block of content) {
278+
if (block.type === 'tool_use') {
279+
send({
280+
method: 'item/commandExecution/started',
281+
params: {
282+
turnId,
283+
threadId,
284+
tool: block.name,
285+
arguments: block.input,
286+
},
287+
});
288+
log(`Tool: ${block.name}`);
289+
}
290+
}
291+
break;
292+
}
293+
294+
case 'result': {
295+
// Final result — handled by caller
296+
break;
297+
}
298+
299+
default:
300+
// system, user, etc. — skip
301+
break;
302+
}
303+
}
304+
305+
/**
306+
* Fallback: `claude --print <prompt>`
307+
* Simple single-turn, no tool use.
308+
*/
309+
function runClaudePrint(prompt, cwd) {
310+
return new Promise((resolve, reject) => {
311+
const claude = spawn('claude', ['--print', prompt], {
312+
cwd,
313+
env: { ...process.env },
314+
stdio: ['pipe', 'pipe', 'pipe'],
315+
});
316+
317+
let stdout = '';
318+
let stderr = '';
319+
320+
claude.stdout.on('data', (data) => {
321+
stdout += data.toString();
322+
});
323+
324+
claude.stderr.on('data', (data) => {
325+
stderr += data.toString();
326+
});
327+
328+
claude.on('close', (code) => {
329+
if (code === 0) {
330+
resolve(stdout.trim() || '(no output)');
331+
} else {
332+
reject(new Error(`Claude print exited code ${code}: ${stderr.slice(0, 500)}`));
333+
}
334+
});
335+
336+
claude.on('error', (err) => {
337+
reject(new Error(`Failed to spawn claude: ${err.message}`));
338+
});
339+
});
340+
}

0 commit comments

Comments
 (0)