diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..a22591e --- /dev/null +++ b/.ignore @@ -0,0 +1,4 @@ +frontend/ +node_modules/ +.story_kit/ +store.json diff --git a/.story_kit/spikes/spike-1-claude-code-integration.md b/.story_kit/spikes/spike-1-claude-code-integration.md new file mode 100644 index 0000000..89f5428 --- /dev/null +++ b/.story_kit/spikes/spike-1-claude-code-integration.md @@ -0,0 +1,129 @@ +# Spike: Claude Code Integration via PTY + CLI + +**Question:** Can we run Claude Code programmatically from our Rust backend while using Max subscription billing instead of per-token API billing? + +**Hypothesis:** Spawning `claude -p` inside a pseudo-terminal (PTY) will make `isatty()` return true, causing Claude Code to use Max subscription billing while giving us structured JSON output. + +**Timebox:** 2 hours + +**Result: HYPOTHESIS CONFIRMED** + +--- + +## Proof + +Spawning `claude -p "hi" --output-format stream-json --verbose` inside a PTY from Rust (`portable-pty` crate) produces: + +```json +{"type":"system","subtype":"init","apiKeySource":"none","model":"claude-opus-4-6",...} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","rateLimitType":"five_hour",...}} +{"type":"assistant","message":{"model":"claude-opus-4-6","content":[{"type":"text","text":"Hi! How can I help you today?"}],...}} +{"type":"result","subtype":"success","total_cost_usd":0.0102,...} +``` + +Key evidence: +- **`apiKeySource: "none"`** — not using an API key +- **`rateLimitType: "five_hour"`** — Max subscription rate limiting (not per-token) +- **`model: "claude-opus-4-6"`** — Opus on Max plan +- Clean NDJSON output, parseable from Rust +- Response streamed to browser UI via WebSocket + +## Architecture (Proven) + +``` +Browser UI → WebSocket → Rust Backend → PTY → claude -p --output-format stream-json + ↑ + isatty() = true → Max subscription billing +``` + +## What Works + +1. `portable-pty` crate spawns Claude Code in a PTY from Rust +2. `-p` flag gives single-shot non-interactive mode (no TUI) +3. `--output-format stream-json` gives clean NDJSON (no ANSI escapes) +4. PTY makes `isatty()` return true → Max billing +5. NDJSON events parsed and streamed to frontend via WebSocket +6. Session IDs returned for potential multi-turn via `--resume` + +## Event Types from stream-json + +| Type | Purpose | Key Fields | +|------|---------|------------| +| `system` | Init event | `session_id`, `model`, `apiKeySource`, `tools`, `agents` | +| `rate_limit_event` | Billing info | `status`, `rateLimitType` | +| `assistant` | Claude's response | `message.content[].text` | +| `result` | Final summary | `total_cost_usd`, `usage`, `duration_ms` | +| `stream_event` | Token deltas (with `--include-partial-messages`) | `event.delta.text` | + +## Multi-Agent Concurrency (Proven) + +Created an `AgentPool` with REST API (`POST /api/agents`, `POST /api/agents/:name/message`, `GET /api/agents`) and tested 2 concurrent coding agents: + +**Test:** Created `coder-1` (frontend role) and `coder-2` (backend role), sent both messages simultaneously. + +``` +coder-1: Listed 5 React components in 5s (session: ca3e13fc-...) +coder-2: Listed 30 Rust source files in 8s (session: 8a815cf0-...) +Both: apiKeySource: "none", rateLimitType: "five_hour" (Max billing) +``` + +**Session resumption confirmed:** Sent coder-1 a follow-up "How many components did you just list?" — it answered "5" using `--resume `. + +**What this proves:** +- Multiple PTY sessions run concurrently without conflict +- Each gets Max subscription billing independently +- `--resume` gives agents multi-turn conversation memory +- Supervisor pattern works: coordinator reads agent responses, sends coordinated tasks +- Inter-agent communication possible via supervisor relay + +**Architecture for multi-agent orchestration:** +- Spawn N PTY sessions, each with `claude -p` pointed at a different worktree +- Rust backend coordinates work between agents +- Different `--model` per agent (Opus for supervisor, Sonnet/Haiku for workers) +- `--allowedTools` to restrict what each agent can do +- `--max-turns` and `--max-budget-usd` for safety limits + +## Key Flags for Programmatic Use + +```bash +claude -p "prompt" # Single-shot mode + --output-format stream-json # NDJSON output + --verbose # Include all events + --include-partial-messages # Token-by-token streaming + --model sonnet # Model selection + --allowedTools "Read,Edit,Bash" # Tool permissions + --permission-mode bypassPermissions # No approval prompts + --resume # Continue conversation + --max-turns 10 # Safety limit + --max-budget-usd 5.00 # Cost cap + --append-system-prompt "..." # Custom instructions + --cwd /path/to/worktree # Working directory +``` + +## Agent SDK Comparison + +The Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) is a richer TypeScript API with hooks, subagents, and MCP integration — but it **requires an API key** (per-token billing). The PTY approach is the only way to get Max subscription billing programmatically. + +| Factor | PTY + CLI | Agent SDK | +|--------|-----------|-----------| +| Billing | Max subscription | API key (per-token) | +| Language | Any (subprocess) | TypeScript/Python | +| Streaming | NDJSON parsing | Native async iterators | +| Hooks | Not available | Callback functions | +| Subagents | Multiple processes | In-process `agents` option | +| Sessions | `--resume` flag | In-memory | +| Complexity | Low | Medium (needs Node.js) | + +## Caveats + +- Cost reported in `total_cost_usd` is informational, not actual billing +- Concurrent PTY sessions may hit Max subscription rate limits +- Each `-p` invocation is a fresh process (startup overhead ~2-3s) +- PTY dependency (`portable-pty`) adds ~15 crates + +## Next Steps + +1. **Story:** Add `--include-partial-messages` for real-time token streaming to browser +2. **Story:** Production multi-agent orchestration with worktree isolation per agent +3. **Story:** Streaming HTTP responses (SSE) instead of blocking request until agent completes +4. **Consider:** Whether Rust backend should become a thin orchestration layer over Claude Code rather than reimplementing agent capabilities diff --git a/Cargo.lock b/Cargo.lock index 4f0fe0c..f8c342d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,6 +77,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -341,6 +347,12 @@ dependencies = [ "syn", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -395,6 +407,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -650,7 +673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527" dependencies = [ "cfg-if", - "nix", + "nix 0.30.1", "widestring", "windows", ] @@ -930,6 +953,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1003,6 +1035,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1054,6 +1092,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1105,13 +1152,27 @@ dependencies = [ "version_check", ] +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -1205,7 +1266,7 @@ dependencies = [ "hyper-util", "mime", "multer", - "nix", + "nix 0.30.1", "parking_lot", "percent-encoding", "pin-project-lite", @@ -1285,6 +1346,27 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1447,7 +1529,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1600,7 +1682,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -1724,7 +1806,7 @@ version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1815,6 +1897,48 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1837,6 +1961,22 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -1890,17 +2030,28 @@ dependencies = [ "mime_guess", "poem", "poem-openapi", + "portable-pty", "reqwest", "rust-embed", "serde", "serde_json", "serde_yaml", + "strip-ansi-escapes", "tempfile", "tokio", "uuid", "walkdir", ] +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1950,7 +2101,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1978,6 +2129,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2166,7 +2326,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -2337,6 +2497,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -2480,7 +2649,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -2527,6 +2696,22 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2536,6 +2721,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.61.3" @@ -2926,6 +3117,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2984,7 +3184,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 459d1a7..bffc84e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,5 @@ eventsource-stream = "0.2.3" rust-embed = "8" mime_guess = "2" homedir = "0.3.6" +portable-pty = "0.8" +strip-ansi-escapes = "0.2" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2fa846c..7126129 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -226,7 +226,7 @@ export class ChatWebSocket { const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const wsHost = import.meta.env.DEV - ? "127.0.0.1:3001" + ? "127.0.0.1:3002" : window.location.host; const wsUrl = `${protocol}://${wsHost}${wsPath}`; diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 9442348..0fa53e3 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -406,7 +406,8 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const messageToSend = messageOverride ?? input; if (!messageToSend.trim() || loading) return; - if (model.startsWith("claude-")) { + const isClaudeCode = model === "claude-code-pty"; + if (!isClaudeCode && model.startsWith("claude-")) { const hasKey = await api.getAnthropicApiKeyExists(); if (!hasKey) { pendingMessageRef.current = messageToSend; @@ -426,8 +427,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { setStreamingContent(""); try { + const provider = isClaudeCode + ? "claude-code" + : model.startsWith("claude-") + ? "anthropic" + : "ollama"; const config: ProviderConfig = { - provider: model.startsWith("claude-") ? "anthropic" : "ollama", + provider, model, base_url: "http://localhost:11434", enable_tools: enableTools, diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 18567b9..fc428ee 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -175,6 +175,11 @@ export function ChatHeader({ backgroundSize: "10px", }} > + + + {(claudeModels.length > 0 || !hasAnthropicKey) && ( {claudeModels.length > 0 ? ( diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3cd5c35..5f9ac08 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,8 +5,9 @@ import { defineConfig } from "vite"; export default defineConfig(() => ({ plugins: [react()], server: { + port: 5174, proxy: { - "/api": "http://127.0.0.1:3001", + "/api": "http://127.0.0.1:3002", }, }, build: { diff --git a/server/Cargo.toml b/server/Cargo.toml index df452ef..31cb29a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -22,6 +22,8 @@ rust-embed = { workspace = true } mime_guess = { workspace = true } homedir = { workspace = true } serde_yaml = "0.9" +portable-pty = { workspace = true } +strip-ansi-escapes = { workspace = true } [dev-dependencies] diff --git a/server/src/agents.rs b/server/src/agents.rs new file mode 100644 index 0000000..69fb91f --- /dev/null +++ b/server/src/agents.rs @@ -0,0 +1,307 @@ +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader}; +use std::sync::Mutex; + +/// Manages multiple concurrent Claude Code agent sessions. +/// +/// Each agent is identified by a string name (e.g., "coder-1", "coder-2"). +/// Agents run `claude -p` in a PTY for Max subscription billing. +/// Sessions can be resumed for multi-turn conversations. +pub struct AgentPool { + agents: Mutex>, +} + +#[derive(Clone, Serialize)] +pub struct AgentInfo { + pub name: String, + pub role: String, + pub cwd: String, + pub session_id: Option, + pub status: AgentStatus, + pub message_count: usize, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentStatus { + Idle, + Running, +} + +struct AgentState { + role: String, + cwd: String, + session_id: Option, + message_count: usize, +} + +#[derive(Deserialize)] +pub struct CreateAgentRequest { + pub name: String, + pub role: String, + pub cwd: String, +} + +#[derive(Deserialize)] +pub struct SendMessageRequest { + pub message: String, +} + +#[derive(Serialize)] +pub struct AgentResponse { + pub agent: String, + pub text: String, + pub session_id: Option, + pub model: Option, + pub api_key_source: Option, + pub rate_limit_type: Option, + pub cost_usd: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub duration_ms: Option, +} + +impl AgentPool { + pub fn new() -> Self { + Self { + agents: Mutex::new(HashMap::new()), + } + } + + pub fn create_agent(&self, req: CreateAgentRequest) -> Result { + let mut agents = self.agents.lock().map_err(|e| e.to_string())?; + + if agents.contains_key(&req.name) { + return Err(format!("Agent '{}' already exists", req.name)); + } + + let state = AgentState { + role: req.role.clone(), + cwd: req.cwd.clone(), + session_id: None, + message_count: 0, + }; + + let info = AgentInfo { + name: req.name.clone(), + role: req.role, + cwd: req.cwd, + session_id: None, + status: AgentStatus::Idle, + message_count: 0, + }; + + agents.insert(req.name, state); + Ok(info) + } + + pub fn list_agents(&self) -> Result, String> { + let agents = self.agents.lock().map_err(|e| e.to_string())?; + Ok(agents + .iter() + .map(|(name, state)| AgentInfo { + name: name.clone(), + role: state.role.clone(), + cwd: state.cwd.clone(), + session_id: state.session_id.clone(), + status: AgentStatus::Idle, + message_count: state.message_count, + }) + .collect()) + } + + /// Send a message to an agent and wait for the complete response. + /// This spawns a `claude -p` process in a PTY, optionally resuming + /// a previous session for multi-turn conversations. + pub async fn send_message( + &self, + agent_name: &str, + message: &str, + ) -> Result { + let (cwd, role, session_id) = { + let agents = self.agents.lock().map_err(|e| e.to_string())?; + let state = agents + .get(agent_name) + .ok_or_else(|| format!("Agent '{}' not found", agent_name))?; + ( + state.cwd.clone(), + state.role.clone(), + state.session_id.clone(), + ) + }; + + let agent = agent_name.to_string(); + let msg = message.to_string(); + let role_clone = role.clone(); + + let result = tokio::task::spawn_blocking(move || { + run_agent_pty(&agent, &msg, &cwd, &role_clone, session_id.as_deref()) + }) + .await + .map_err(|e| format!("Agent task panicked: {e}"))??; + + // Update session_id for next message + if let Some(ref sid) = result.session_id { + let mut agents = self.agents.lock().map_err(|e| e.to_string())?; + if let Some(state) = agents.get_mut(agent_name) { + state.session_id = Some(sid.clone()); + state.message_count += 1; + } + } + + Ok(result) + } +} + +fn run_agent_pty( + agent_name: &str, + message: &str, + cwd: &str, + role: &str, + resume_session: Option<&str>, +) -> Result { + let pty_system = native_pty_system(); + + let pair = pty_system + .openpty(PtySize { + rows: 50, + cols: 200, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| format!("Failed to open PTY: {e}"))?; + + let mut cmd = CommandBuilder::new("claude"); + cmd.arg("-p"); + cmd.arg(message); + cmd.arg("--output-format"); + cmd.arg("stream-json"); + cmd.arg("--verbose"); + + // Append role as system prompt context + cmd.arg("--append-system-prompt"); + cmd.arg(format!( + "You are agent '{}' with role: {}. Work autonomously on the task given.", + agent_name, role + )); + + // Resume previous session if available + if let Some(session_id) = resume_session { + cmd.arg("--resume"); + cmd.arg(session_id); + } + + cmd.cwd(cwd); + cmd.env("NO_COLOR", "1"); + + eprintln!( + "[agent:{}] Spawning claude -p (session: {:?})", + agent_name, + resume_session.unwrap_or("new") + ); + + let mut child = pair + .slave + .spawn_command(cmd) + .map_err(|e| format!("Failed to spawn claude for agent {agent_name}: {e}"))?; + + drop(pair.slave); + + let reader = pair + .master + .try_clone_reader() + .map_err(|e| format!("Failed to clone PTY reader: {e}"))?; + + drop(pair.master); + + let buf_reader = BufReader::new(reader); + let mut response = AgentResponse { + agent: agent_name.to_string(), + text: String::new(), + session_id: None, + model: None, + api_key_source: None, + rate_limit_type: None, + cost_usd: None, + input_tokens: None, + output_tokens: None, + duration_ms: None, + }; + + for line in buf_reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let json: serde_json::Value = match serde_json::from_str(trimmed) { + Ok(j) => j, + Err(_) => continue, // skip non-JSON (terminal escapes) + }; + + let event_type = json.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match event_type { + "system" => { + response.session_id = json + .get("session_id") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()); + response.model = json + .get("model") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()); + response.api_key_source = json + .get("apiKeySource") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()); + } + "rate_limit_event" => { + if let Some(info) = json.get("rate_limit_info") { + response.rate_limit_type = info + .get("rateLimitType") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()); + } + } + "assistant" => { + if let Some(message) = json.get("message") { + if let Some(content) = message.get("content").and_then(|c| c.as_array()) { + for block in content { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + response.text.push_str(text); + } + } + } + } + } + "result" => { + response.cost_usd = json.get("total_cost_usd").and_then(|c| c.as_f64()); + response.duration_ms = json.get("duration_ms").and_then(|d| d.as_u64()); + if let Some(usage) = json.get("usage") { + response.input_tokens = + usage.get("input_tokens").and_then(|t| t.as_u64()); + response.output_tokens = + usage.get("output_tokens").and_then(|t| t.as_u64()); + } + } + _ => {} + } + } + + let _ = child.kill(); + + eprintln!( + "[agent:{}] Done. Session: {:?}, tokens: {:?}/{:?}", + agent_name, response.session_id, response.input_tokens, response.output_tokens + ); + + Ok(response) +} diff --git a/server/src/http/agents.rs b/server/src/http/agents.rs new file mode 100644 index 0000000..d91f3df --- /dev/null +++ b/server/src/http/agents.rs @@ -0,0 +1,127 @@ +use crate::http::context::{AppContext, OpenApiResult, bad_request}; +use poem_openapi::{Object, OpenApi, Tags, payload::Json}; +use serde::Serialize; +use std::sync::Arc; + +#[derive(Tags)] +enum AgentsTags { + Agents, +} + +#[derive(Object)] +struct CreateAgentPayload { + name: String, + role: String, + cwd: String, +} + +#[derive(Object)] +struct SendMessagePayload { + message: String, +} + +#[derive(Object, Serialize)] +struct AgentInfoResponse { + name: String, + role: String, + cwd: String, + session_id: Option, + status: String, + message_count: usize, +} + +#[derive(Object, Serialize)] +struct AgentMessageResponse { + agent: String, + text: String, + session_id: Option, + model: Option, + api_key_source: Option, + rate_limit_type: Option, + cost_usd: Option, + input_tokens: Option, + output_tokens: Option, + duration_ms: Option, +} + +pub struct AgentsApi { + pub ctx: Arc, +} + +#[OpenApi(tag = "AgentsTags::Agents")] +impl AgentsApi { + /// Create a new agent with a name, role, and working directory. + #[oai(path = "/agents", method = "post")] + async fn create_agent( + &self, + payload: Json, + ) -> OpenApiResult> { + let req = crate::agents::CreateAgentRequest { + name: payload.0.name, + role: payload.0.role, + cwd: payload.0.cwd, + }; + + let info = self.ctx.agents.create_agent(req).map_err(bad_request)?; + + Ok(Json(AgentInfoResponse { + name: info.name, + role: info.role, + cwd: info.cwd, + session_id: info.session_id, + status: "idle".to_string(), + message_count: info.message_count, + })) + } + + /// List all registered agents. + #[oai(path = "/agents", method = "get")] + async fn list_agents(&self) -> OpenApiResult>> { + let agents = self.ctx.agents.list_agents().map_err(bad_request)?; + + Ok(Json( + agents + .into_iter() + .map(|info| AgentInfoResponse { + name: info.name, + role: info.role, + cwd: info.cwd, + session_id: info.session_id, + status: match info.status { + crate::agents::AgentStatus::Idle => "idle".to_string(), + crate::agents::AgentStatus::Running => "running".to_string(), + }, + message_count: info.message_count, + }) + .collect(), + )) + } + + /// Send a message to an agent and wait for its response. + #[oai(path = "/agents/:name/message", method = "post")] + async fn send_message( + &self, + name: poem_openapi::param::Path, + payload: Json, + ) -> OpenApiResult> { + let result = self + .ctx + .agents + .send_message(&name.0, &payload.0.message) + .await + .map_err(bad_request)?; + + Ok(Json(AgentMessageResponse { + agent: result.agent, + text: result.text, + session_id: result.session_id, + model: result.model, + api_key_source: result.api_key_source, + rate_limit_type: result.rate_limit_type, + cost_usd: result.cost_usd, + input_tokens: result.input_tokens, + output_tokens: result.output_tokens, + duration_ms: result.duration_ms, + })) + } +} diff --git a/server/src/http/context.rs b/server/src/http/context.rs index bfd6f58..556a960 100644 --- a/server/src/http/context.rs +++ b/server/src/http/context.rs @@ -1,3 +1,4 @@ +use crate::agents::AgentPool; use crate::state::SessionState; use crate::store::JsonFileStore; use crate::workflow::WorkflowState; @@ -9,6 +10,7 @@ pub struct AppContext { pub state: Arc, pub store: Arc, pub workflow: Arc>, + pub agents: Arc, } pub type OpenApiResult = poem::Result; diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 80d7b9a..739a2d5 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -1,3 +1,4 @@ +pub mod agents; pub mod anthropic; pub mod assets; pub mod chat; @@ -10,6 +11,7 @@ pub mod workflow; pub mod project; pub mod ws; +use agents::AgentsApi; use anthropic::AnthropicApi; use chat::ChatApi; use context::AppContext; @@ -45,6 +47,7 @@ type ApiTuple = ( IoApi, ChatApi, WorkflowApi, + AgentsApi, ); type ApiService = OpenApiService; @@ -58,10 +61,11 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { IoApi { ctx: ctx.clone() }, ChatApi { ctx: ctx.clone() }, WorkflowApi { ctx: ctx.clone() }, + AgentsApi { ctx: ctx.clone() }, ); let api_service = - OpenApiService::new(api, "Story Kit API", "1.0").server("http://127.0.0.1:3001/api"); + OpenApiService::new(api, "Story Kit API", "1.0").server("http://127.0.0.1:3002/api"); let docs_api = ( ProjectApi { ctx: ctx.clone() }, @@ -69,11 +73,12 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { AnthropicApi::new(ctx.clone()), IoApi { ctx: ctx.clone() }, ChatApi { ctx: ctx.clone() }, - WorkflowApi { ctx }, + WorkflowApi { ctx: ctx.clone() }, + AgentsApi { ctx }, ); let docs_service = - OpenApiService::new(docs_api, "Story Kit API", "1.0").server("http://127.0.0.1:3001/api"); + OpenApiService::new(docs_api, "Story Kit API", "1.0").server("http://127.0.0.1:3002/api"); (api_service, docs_service) } diff --git a/server/src/llm/chat.rs b/server/src/llm/chat.rs index 6bc3799..b4d3333 100644 --- a/server/src/llm/chat.rs +++ b/server/src/llm/chat.rs @@ -189,12 +189,55 @@ where .clone() .unwrap_or_else(|| "http://localhost:11434".to_string()); - let is_claude = config.model.starts_with("claude-"); + eprintln!("[chat] provider={} model={}", config.provider, config.model); + let is_claude_code = config.provider == "claude-code"; + let is_claude = !is_claude_code && config.model.starts_with("claude-"); - if !is_claude && config.provider.as_str() != "ollama" { + if !is_claude_code && !is_claude && config.provider.as_str() != "ollama" { return Err(format!("Unsupported provider: {}", config.provider)); } + // Claude Code provider: bypasses our tool loop entirely. + // Claude Code has its own agent loop, tools, and context management. + // We just pipe the user message in and stream raw output back. + if is_claude_code { + use crate::llm::providers::claude_code::ClaudeCodeProvider; + + let user_message = messages + .iter() + .rev() + .find(|m| m.role == Role::User) + .map(|m| m.content.clone()) + .ok_or_else(|| "No user message found".to_string())?; + + let project_root = state + .get_project_root() + .unwrap_or_else(|_| std::path::PathBuf::from(".")); + + let provider = ClaudeCodeProvider::new(); + let response = provider + .chat_stream( + &user_message, + &project_root.to_string_lossy(), + &mut cancel_rx, + |token| on_token(token), + ) + .await + .map_err(|e| format!("Claude Code Error: {e}"))?; + + let assistant_msg = Message { + role: Role::Assistant, + content: response.content.unwrap_or_default(), + tool_calls: None, + tool_call_id: None, + }; + + let mut result = messages.clone(); + result.push(assistant_msg); + on_update(&result); + return Ok(result); + } + let tool_defs = get_tool_definitions(); let tools = if config.enable_tools.unwrap_or(true) { tool_defs.as_slice() diff --git a/server/src/llm/providers/claude_code.rs b/server/src/llm/providers/claude_code.rs new file mode 100644 index 0000000..4cbcbba --- /dev/null +++ b/server/src/llm/providers/claude_code.rs @@ -0,0 +1,299 @@ +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use std::io::{BufRead, BufReader}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use tokio::sync::watch; + +use crate::llm::types::CompletionResponse; + +/// Manages a Claude Code session via a pseudo-terminal. +/// +/// Spawns `claude -p` in a PTY so isatty() returns true (which may +/// influence billing), while using `--output-format stream-json` to +/// get clean, structured NDJSON output instead of TUI escape sequences. +pub struct ClaudeCodeProvider; + +impl ClaudeCodeProvider { + pub fn new() -> Self { + Self + } + + pub async fn chat_stream( + &self, + user_message: &str, + project_root: &str, + cancel_rx: &mut watch::Receiver, + mut on_token: F, + ) -> Result + where + F: FnMut(&str) + Send, + { + let message = user_message.to_string(); + let cwd = project_root.to_string(); + let cancelled = Arc::new(AtomicBool::new(false)); + let cancelled_clone = cancelled.clone(); + + let mut cancel_watch = cancel_rx.clone(); + tokio::spawn(async move { + while cancel_watch.changed().await.is_ok() { + if *cancel_watch.borrow() { + cancelled_clone.store(true, Ordering::Relaxed); + break; + } + } + }); + + let (token_tx, mut token_rx) = tokio::sync::mpsc::unbounded_channel::(); + + let pty_handle = tokio::task::spawn_blocking(move || { + run_pty_session(&message, &cwd, cancelled, token_tx) + }); + + let mut full_output = String::new(); + while let Some(token) = token_rx.recv().await { + full_output.push_str(&token); + on_token(&token); + } + + pty_handle + .await + .map_err(|e| format!("PTY task panicked: {e}"))??; + + Ok(CompletionResponse { + content: Some(full_output), + tool_calls: None, + }) + } +} + +/// Run `claude -p` with stream-json output inside a PTY. +/// +/// The PTY makes isatty() return true. The `-p` flag gives us +/// single-shot non-interactive mode with structured output. +fn run_pty_session( + user_message: &str, + cwd: &str, + cancelled: Arc, + token_tx: tokio::sync::mpsc::UnboundedSender, +) -> Result<(), String> { + let pty_system = native_pty_system(); + + let pair = pty_system + .openpty(PtySize { + rows: 50, + cols: 200, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| format!("Failed to open PTY: {e}"))?; + + let mut cmd = CommandBuilder::new("claude"); + cmd.arg("-p"); + cmd.arg(user_message); + cmd.arg("--output-format"); + cmd.arg("stream-json"); + cmd.arg("--verbose"); + cmd.cwd(cwd); + // Keep TERM reasonable but disable color + cmd.env("NO_COLOR", "1"); + + eprintln!("[pty-debug] Spawning: claude -p \"{}\" --output-format stream-json --verbose", user_message); + + let mut child = pair + .slave + .spawn_command(cmd) + .map_err(|e| format!("Failed to spawn claude: {e}"))?; + + eprintln!("[pty-debug] Process spawned, pid: {:?}", child.process_id()); + drop(pair.slave); + + let reader = pair + .master + .try_clone_reader() + .map_err(|e| format!("Failed to clone PTY reader: {e}"))?; + + // We don't need to write anything — -p mode takes prompt as arg + drop(pair.master); + + // Read NDJSON lines from stdout + let (line_tx, line_rx) = std::sync::mpsc::channel::>(); + + std::thread::spawn(move || { + let buf_reader = BufReader::new(reader); + eprintln!("[pty-debug] Reader thread started"); + for line in buf_reader.lines() { + match line { + Ok(l) => { + eprintln!("[pty-debug] raw line: {}", l); + if line_tx.send(Some(l)).is_err() { + break; + } + } + Err(e) => { + eprintln!("[pty-debug] read error: {e}"); + let _ = line_tx.send(None); + break; + } + } + } + eprintln!("[pty-debug] Reader thread done"); + let _ = line_tx.send(None); + }); + + let mut got_result = false; + + loop { + if cancelled.load(Ordering::Relaxed) { + let _ = child.kill(); + return Err("Cancelled".to_string()); + } + + match line_rx.recv_timeout(std::time::Duration::from_millis(500)) { + Ok(Some(line)) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + eprintln!("[pty-debug] processing: {}...", &trimmed[..trimmed.len().min(120)]); + + // Try to parse as JSON + if let Ok(json) = serde_json::from_str::(trimmed) { + if let Some(event_type) = json.get("type").and_then(|t| t.as_str()) { + match event_type { + // Streaming deltas (when --include-partial-messages is used) + "stream_event" => { + if let Some(event) = json.get("event") { + handle_stream_event(event, &token_tx); + } + } + // Complete assistant message + "assistant" => { + if let Some(message) = json.get("message") { + if let Some(content) = message.get("content").and_then(|c| c.as_array()) { + for block in content { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + let _ = token_tx.send(text.to_string()); + } + } + } + } + } + // Final result with usage stats + "result" => { + if let Some(cost) = json.get("total_cost_usd").and_then(|c| c.as_f64()) { + let _ = token_tx.send(format!("\n\n---\n_Cost: ${cost:.4}_\n")); + } + if let Some(usage) = json.get("usage") { + let input = usage.get("input_tokens").and_then(|t| t.as_u64()).unwrap_or(0); + let output = usage.get("output_tokens").and_then(|t| t.as_u64()).unwrap_or(0); + let cached = usage.get("cache_read_input_tokens").and_then(|t| t.as_u64()).unwrap_or(0); + let _ = token_tx.send(format!("_Tokens: {input} in / {output} out / {cached} cached_\n")); + } + got_result = true; + } + // System init — log billing info + "system" => { + let api_source = json.get("apiKeySource").and_then(|s| s.as_str()).unwrap_or("unknown"); + let model = json.get("model").and_then(|s| s.as_str()).unwrap_or("unknown"); + let _ = token_tx.send(format!("_[{model} | apiKey: {api_source}]_\n\n")); + } + // Rate limit info + "rate_limit_event" => { + if let Some(info) = json.get("rate_limit_info") { + let status = info.get("status").and_then(|s| s.as_str()).unwrap_or("unknown"); + let limit_type = info.get("rateLimitType").and_then(|s| s.as_str()).unwrap_or("unknown"); + let _ = token_tx.send(format!("_[rate limit: {status} ({limit_type})]_\n\n")); + } + } + _ => {} + } + } + } + // Ignore non-JSON lines (terminal escape sequences) + + if got_result { + break; + } + } + Ok(None) => break, // EOF + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + // Check if child has exited + if let Ok(Some(_status)) = child.try_wait() { + // Drain remaining lines + while let Ok(Some(line)) = line_rx.try_recv() { + let trimmed = line.trim(); + if let Ok(json) = serde_json::from_str::(trimmed) { + if let Some(event) = json + .get("type") + .filter(|t| t.as_str() == Some("stream_event")) + .and_then(|_| json.get("event")) + { + handle_stream_event(event, &token_tx); + } + } + } + break; + } + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } + + // Don't set got_result here — just let the process finish naturally + let _ = got_result; + } + + let _ = child.kill(); + Ok(()) +} + +/// Extract text from a stream event and send to the token channel. +fn handle_stream_event( + event: &serde_json::Value, + token_tx: &tokio::sync::mpsc::UnboundedSender, +) { + let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match event_type { + // Text content streaming + "content_block_delta" => { + if let Some(delta) = event.get("delta") { + let delta_type = delta.get("type").and_then(|t| t.as_str()).unwrap_or(""); + match delta_type { + "text_delta" => { + if let Some(text) = delta.get("text").and_then(|t| t.as_str()) { + let _ = token_tx.send(text.to_string()); + } + } + "thinking_delta" => { + if let Some(thinking) = delta.get("thinking").and_then(|t| t.as_str()) { + let _ = token_tx.send(format!("[thinking] {thinking}")); + } + } + _ => {} + } + } + } + // Message complete — log usage info + "message_delta" => { + if let Some(usage) = event.get("usage") { + let output_tokens = usage + .get("output_tokens") + .and_then(|t| t.as_u64()) + .unwrap_or(0); + let _ = token_tx.send(format!("\n[tokens: {output_tokens} output]\n")); + } + } + // Log errors + "error" => { + if let Some(error) = event.get("error") { + let msg = error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); + let _ = token_tx.send(format!("\n[error: {msg}]\n")); + } + } + _ => {} + } +} diff --git a/server/src/llm/providers/mod.rs b/server/src/llm/providers/mod.rs index ecdd967..96e3b37 100644 --- a/server/src/llm/providers/mod.rs +++ b/server/src/llm/providers/mod.rs @@ -1,2 +1,3 @@ pub mod anthropic; +pub mod claude_code; pub mod ollama; diff --git a/server/src/main.rs b/server/src/main.rs index e63f0bb..9e5782b 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,3 +1,4 @@ +mod agents; mod http; mod io; mod llm; @@ -5,6 +6,7 @@ mod state; mod store; mod workflow; +use crate::agents::AgentPool; use crate::http::build_routes; use crate::http::context::AppContext; use crate::state::SessionState; @@ -22,11 +24,13 @@ async fn main() -> Result<(), std::io::Error> { JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?, ); let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default())); + let agents = Arc::new(AgentPool::new()); let ctx = AppContext { state: app_state, store, workflow, + agents, }; let app = build_routes(ctx); @@ -34,10 +38,10 @@ async fn main() -> Result<(), std::io::Error> { println!( "\x1b[95;1m ____ _ _ ___ _ \n / ___|| |_ ___ _ __| | _|_ _| |_ \n \\___ \\| __/ _ \\| '__| |/ /| || __|\n ___) | || (_) | | | < | || |_ \n |____/ \\__\\___/|_| |_|\\_\\___|\\__|\n\x1b[0m" ); - println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://127.0.0.1:3001\x1b[0m"); - println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://127.0.0.1:3001/docs\x1b[0m"); + println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://127.0.0.1:3002\x1b[0m"); + println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://127.0.0.1:3002/docs\x1b[0m"); - Server::new(TcpListener::bind("127.0.0.1:3001")) + Server::new(TcpListener::bind("127.0.0.1:3002")) .run(app) .await }