Accept story 30: Worktree-based agent orchestration

Add git worktree isolation for concurrent story agents. Each agent now
runs in its own worktree with setup/teardown commands driven by
.story_kit/project.toml config. Agents stream output via SSE and support
start/stop lifecycle with Pending/Running/Completed/Failed statuses.

Backend: config.rs (TOML parsing), worktree.rs (git worktree lifecycle),
refactored agents.rs (broadcast streaming), agents_sse.rs (SSE endpoint).
Frontend: AgentPanel.tsx with Run/Stop buttons and streaming output log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-19 17:58:53 +00:00
parent 7e56648954
commit 5e5cdd9b2f
15 changed files with 1440 additions and 281 deletions

View File

@@ -158,8 +158,8 @@ fn run_pty_session(
eprintln!("[pty-debug] processing: {}...", &trimmed[..trimmed.len().min(120)]);
// Try to parse as JSON
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
if let Some(event_type) = json.get("type").and_then(|t| t.as_str()) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
&& 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" => {
@@ -169,15 +169,14 @@ fn run_pty_session(
}
// Complete assistant message
"assistant" => {
if let Some(message) = json.get("message") {
if let Some(content) = message.get("content").and_then(|c| c.as_array()) {
if let Some(message) = json.get("message")
&& 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" => {
@@ -209,7 +208,6 @@ fn run_pty_session(
_ => {}
}
}
}
// Ignore non-JSON lines (terminal escape sequences)
if got_result {
@@ -223,15 +221,14 @@ fn run_pty_session(
// Drain remaining lines
while let Ok(Some(line)) = line_rx.try_recv() {
let trimmed = line.trim();
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
if let Some(event) = json
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
&& 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;
}