Skip to content

Commit 16e4176

Browse files
Terraphim CIclaude
andcommitted
feat(symphony): add Claude Code runner and config extensions
Add ClaudeCodeSession runner that spawns `claude -p` with NDJSON event stream parsing, as an alternative to the Codex JSON-RPC app-server. Extend ServiceConfig with runner_kind() and claude_flags() getters. Orchestrator dispatch now branches on runner kind. Includes example WORKFLOW-claude-code.md and QUICKSTART.md documentation updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3433b84 commit 16e4176

6 files changed

Lines changed: 760 additions & 69 deletions

File tree

crates/terraphim_symphony/QUICKSTART.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,12 @@ Shell scripts executed at workspace lifecycle points. All run with `sh -lc` in t
198198

199199
| Setting | Default | Description |
200200
|---------|---------|-------------|
201+
| `agent.runner` | `"codex"` | Runner kind: `"codex"` (JSON-RPC app-server) or `"claude-code"` (Claude Code CLI) |
201202
| `agent.max_concurrent_agents` | `10` | Maximum parallel agent sessions |
202203
| `agent.max_turns` | `20` | Maximum turns per agent session |
203204
| `agent.max_retry_backoff_ms` | `300000` (5 min) | Cap for exponential retry backoff |
204205
| `agent.max_concurrent_agents_by_state` | -- | Per-state concurrency limits (map of state -> limit) |
206+
| `agent.claude_flags` | -- | Additional CLI flags for Claude Code runner (e.g. `"--dangerously-skip-permissions"`) |
205207

206208
### Codex (Agent Process)
207209

@@ -212,6 +214,23 @@ Shell scripts executed at workspace lifecycle points. All run with `sh -lc` in t
212214
| `codex.read_timeout_ms` | `5000` (5s) | Timeout for handshake responses |
213215
| `codex.stall_timeout_ms` | `300000` (5 min) | Kill session after this long with no activity. Set to `-1` to disable |
214216

217+
### Claude Code Runner
218+
219+
When `agent.runner` is set to `"claude-code"`, Symphony uses `claude -p` (Claude Code CLI) instead of the Codex JSON-RPC app-server. This is a single-shot invocation per issue -- no handshake, no approval flow, no multi-turn continuation.
220+
221+
**Requirements**: `claude` CLI must be on PATH. Install via `npm install -g @anthropic-ai/claude-code`.
222+
223+
**Example** (see `examples/WORKFLOW-claude-code.md`):
224+
```yaml
225+
agent:
226+
runner: claude-code
227+
max_concurrent_agents: 2
228+
max_turns: 10
229+
claude_flags: "--dangerously-skip-permissions --allowedTools Bash,Read,Write,Edit,Glob,Grep"
230+
```
231+
232+
The session spawns `claude -p "<prompt>" --output-format stream-json --max-turns N <flags>` and parses the NDJSON event stream. Token usage, turn counts, and errors are extracted from the stream and reported to the orchestrator.
233+
215234
### Server (Optional)
216235

217236
| Setting | Default | Description |
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
tracker:
3+
kind: gitea
4+
endpoint: https://git.terraphim.cloud
5+
api_key: $GITEA_TOKEN
6+
owner: terraphim
7+
repo: pagerank-viewer
8+
9+
polling:
10+
interval_ms: 30000
11+
12+
agent:
13+
runner: claude-code
14+
max_concurrent_agents: 2
15+
max_turns: 10
16+
claude_flags: "--dangerously-skip-permissions --allowedTools Bash,Read,Write,Edit,Glob,Grep"
17+
18+
workspace:
19+
root: ~/symphony_workspaces
20+
21+
hooks:
22+
after_create: "git clone https://git.terraphim.cloud/terraphim/pagerank-viewer.git ."
23+
before_run: "git fetch origin && git checkout main && git pull"
24+
after_run: "git add -A && git commit -m 'symphony: auto-commit for {{ issue.identifier }}' || true"
25+
timeout_ms: 60000
26+
27+
codex:
28+
turn_timeout_ms: 3600000
29+
stall_timeout_ms: 600000
30+
---
31+
You are working on issue {{ issue.identifier }}: {{ issue.title }}.
32+
33+
{% if issue.description %}
34+
## Issue Description
35+
36+
{{ issue.description }}
37+
{% endif %}
38+
39+
## Instructions
40+
41+
1. Read the issue carefully.
42+
2. Examine the relevant code in this workspace.
43+
3. Implement the required changes following project standards.
44+
4. Write tests to verify your changes.
45+
5. Commit with a message referencing {{ issue.identifier }}.
46+
47+
{% if attempt %}
48+
This is retry attempt {{ attempt }}. Review previous work and continue.
49+
{% endif %}

crates/terraphim_symphony/src/config/mod.rs

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,22 @@ impl ServiceConfig {
187187
map
188188
}
189189

190+
// --- Runner ---
191+
192+
/// Runner kind: `"codex"` (default) or `"claude-code"`.
193+
///
194+
/// Determines which agent session type to use for dispatching issues.
195+
pub fn runner_kind(&self) -> String {
196+
self.get_str(&["agent", "runner"])
197+
.unwrap_or_else(|| "codex".into())
198+
}
199+
200+
/// Additional CLI flags for the Claude Code runner (e.g.
201+
/// `"--dangerously-skip-permissions --max-turns 10"`).
202+
pub fn claude_flags(&self) -> Option<String> {
203+
self.get_str(&["agent", "claude_flags"])
204+
}
205+
190206
// --- Codex ---
191207

192208
/// Coding-agent command to execute.
@@ -289,8 +305,18 @@ impl ServiceConfig {
289305
None => {} // Already caught above
290306
}
291307

292-
if self.codex_command().is_empty() {
293-
checks.push("codex.command must not be empty".into());
308+
match self.runner_kind().as_str() {
309+
"codex" => {
310+
if self.codex_command().is_empty() {
311+
checks.push("codex.command must not be empty".into());
312+
}
313+
}
314+
"claude-code" => {
315+
// No codex.command needed; claude CLI is invoked directly.
316+
}
317+
other => {
318+
checks.push(format!("unsupported agent.runner: {other}"));
319+
}
294320
}
295321

296322
if checks.is_empty() {
@@ -549,6 +575,57 @@ mod tests {
549575
assert_eq!(cfg.codex_stall_timeout_ms(), 300_000);
550576
}
551577

578+
#[test]
579+
fn default_runner_kind() {
580+
let cfg = config_from_yaml("tracker:\n kind: gitea");
581+
assert_eq!(cfg.runner_kind(), "codex");
582+
}
583+
584+
#[test]
585+
fn custom_runner_kind() {
586+
let cfg = config_from_yaml("agent:\n runner: claude-code");
587+
assert_eq!(cfg.runner_kind(), "claude-code");
588+
}
589+
590+
#[test]
591+
fn claude_flags_none_by_default() {
592+
let cfg = config_from_yaml("tracker:\n kind: gitea");
593+
assert!(cfg.claude_flags().is_none());
594+
}
595+
596+
#[test]
597+
fn claude_flags_present() {
598+
let cfg = config_from_yaml(
599+
"agent:\n claude_flags: \"--dangerously-skip-permissions --max-turns 10\"",
600+
);
601+
assert_eq!(
602+
cfg.claude_flags().unwrap(),
603+
"--dangerously-skip-permissions --max-turns 10"
604+
);
605+
}
606+
607+
#[test]
608+
fn validation_claude_code_runner_no_codex_command_needed() {
609+
let cfg = config_from_yaml(
610+
"tracker:\n kind: gitea\n api_key: test\n owner: o\n repo: r\nagent:\n runner: claude-code",
611+
);
612+
assert!(cfg.validate_for_dispatch().is_ok());
613+
}
614+
615+
#[test]
616+
fn validation_unsupported_runner() {
617+
let cfg = config_from_yaml(
618+
"tracker:\n kind: gitea\n api_key: test\n owner: o\n repo: r\nagent:\n runner: unknown-runner",
619+
);
620+
let err = cfg.validate_for_dispatch().unwrap_err();
621+
match err {
622+
SymphonyError::ValidationFailed { checks } => {
623+
assert!(checks.iter().any(|c| c.contains("unsupported agent.runner")));
624+
}
625+
_ => panic!("expected ValidationFailed"),
626+
}
627+
}
628+
552629
#[test]
553630
fn env_var_resolution() {
554631
// SAFETY: test is single-threaded and uses a unique env var name

crates/terraphim_symphony/src/orchestrator/mod.rs

Lines changed: 83 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub use state::{OrchestratorRuntimeState, StateSnapshot};
1212
use crate::config::template::render_prompt;
1313
use crate::config::ServiceConfig;
1414
use crate::error::Result;
15+
use crate::runner::claude_code::ClaudeCodeSession;
1516
use crate::runner::protocol::AgentEvent;
1617
use crate::runner::session::{CodexSession, WorkerOutcome};
1718
use crate::runner::TokenCounts;
@@ -245,6 +246,8 @@ impl SymphonyOrchestrator {
245246
// tracker checks in the worker and let the orchestrator handle it
246247
// via retry continuation.
247248

249+
let runner_kind = self.config.runner_kind();
250+
248251
let worker_handle = tokio::spawn(async move {
249252
let per_issue_event_tx = event_tx.clone();
250253
let issue_id_for_events = issue_id_clone.clone();
@@ -265,75 +268,88 @@ impl SymphonyOrchestrator {
265268
}
266269
});
267270

268-
// Start session
269-
let session = match CodexSession::start(
270-
&workspace_path,
271-
&config,
272-
session_event_tx.clone(),
273-
)
274-
.await
275-
{
276-
Ok(s) => s,
277-
Err(e) => {
278-
forward_handle.abort();
279-
let outcome = WorkerOutcome::Failed {
280-
reason: e.to_string(),
281-
turn_count: 0,
282-
tokens: TokenCounts::default(),
283-
};
284-
let _ = exit_tx
285-
.send(WorkerExit {
286-
issue_id: issue_id_for_events,
287-
identifier: identifier_clone,
288-
outcome: WorkerOutcome::Failed {
289-
reason: e.to_string(),
290-
turn_count: 0,
291-
tokens: TokenCounts::default(),
292-
},
293-
started_at,
294-
})
295-
.await;
296-
return outcome;
297-
}
298-
};
299-
300-
// For this initial implementation, run a single turn.
301-
// Multi-turn with tracker state checks will be added when
302-
// we have a way to share the tracker reference.
303-
let turn_timeout = Duration::from_millis(config.codex_turn_timeout_ms());
304-
let mut session = session;
305-
306-
// Use the full prompt for the single turn approach
307-
let outcome = match tokio::time::timeout(
308-
turn_timeout,
309-
session.run_turn_simple(&prompt_clone, &issue_clone, &session_event_tx),
310-
)
311-
.await
312-
{
313-
Ok(Ok(turn_id)) => {
314-
debug!(turn_id, "single turn completed");
315-
session.stop().await;
316-
WorkerOutcome::Normal {
317-
turn_count: 1,
318-
tokens: session.accumulated_tokens(),
319-
}
320-
}
321-
Ok(Err(e)) => {
322-
let tokens = session.accumulated_tokens();
323-
session.stop().await;
324-
WorkerOutcome::Failed {
325-
reason: e.to_string(),
326-
turn_count: 1,
327-
tokens,
271+
let outcome = match runner_kind.as_str() {
272+
"claude-code" => {
273+
// Claude Code runner: single-shot `claude -p` invocation
274+
match ClaudeCodeSession::start(
275+
&workspace_path,
276+
&config,
277+
&prompt_clone,
278+
session_event_tx.clone(),
279+
)
280+
.await
281+
{
282+
Ok(session) => {
283+
let turn_timeout =
284+
Duration::from_millis(config.codex_turn_timeout_ms());
285+
session.run_to_completion(&session_event_tx, turn_timeout).await
286+
}
287+
Err(e) => WorkerOutcome::Failed {
288+
reason: e.to_string(),
289+
turn_count: 0,
290+
tokens: TokenCounts::default(),
291+
},
328292
}
329293
}
330-
Err(_) => {
331-
let tokens = session.accumulated_tokens();
332-
session.stop().await;
333-
WorkerOutcome::Failed {
334-
reason: format!("turn timeout after {}ms", config.codex_turn_timeout_ms()),
335-
turn_count: 1,
336-
tokens,
294+
_ => {
295+
// Codex runner: JSON-RPC handshake + single turn
296+
match CodexSession::start(
297+
&workspace_path,
298+
&config,
299+
session_event_tx.clone(),
300+
)
301+
.await
302+
{
303+
Ok(mut session) => {
304+
let turn_timeout =
305+
Duration::from_millis(config.codex_turn_timeout_ms());
306+
307+
match tokio::time::timeout(
308+
turn_timeout,
309+
session.run_turn_simple(
310+
&prompt_clone,
311+
&issue_clone,
312+
&session_event_tx,
313+
),
314+
)
315+
.await
316+
{
317+
Ok(Ok(turn_id)) => {
318+
debug!(turn_id, "single turn completed");
319+
session.stop().await;
320+
WorkerOutcome::Normal {
321+
turn_count: 1,
322+
tokens: session.accumulated_tokens(),
323+
}
324+
}
325+
Ok(Err(e)) => {
326+
let tokens = session.accumulated_tokens();
327+
session.stop().await;
328+
WorkerOutcome::Failed {
329+
reason: e.to_string(),
330+
turn_count: 1,
331+
tokens,
332+
}
333+
}
334+
Err(_) => {
335+
let tokens = session.accumulated_tokens();
336+
session.stop().await;
337+
WorkerOutcome::Failed {
338+
reason: format!(
339+
"turn timeout after {}ms",
340+
config.codex_turn_timeout_ms()
341+
),
342+
turn_count: 1,
343+
tokens,
344+
}
345+
}
346+
}
347+
}
348+
Err(e) => WorkerOutcome::Failed {
349+
reason: e.to_string(),
350+
turn_count: 0,
351+
tokens: TokenCounts::default(),
352+
},
337353
}
338354
}
339355
};

0 commit comments

Comments
 (0)