Skip to content

Commit bb2fea0

Browse files
h4x0rclaude
andcommitted
feat: add approve/shutdown REST routes, fix WS status updates, revise README
- Add POST /api/tasks/:id/approve, /api/approve-all, /api/shutdown routes that the CLI already calls but were missing from the router - Fix WebSocket TaskApprove/TaskApproveAll handlers to update task status from "input" to "running" in the database (was only writing to PTY) - Use two-phase lock pattern: update DB while holding lock, then drop before async PTY writes - Revise README: mark Docker isolation as planned, update iTerm2 section to reflect WebSocket API (not bridge scripts), update test counts, fix roadmap to v0.1.0-alpha with accurate feature list - 8 new handler tests covering approve, approve-all, and shutdown Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5680f59 commit bb2fea0

5 files changed

Lines changed: 313 additions & 45 deletions

File tree

README.md

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,7 @@ Shepherd finds them automatically. No config, no restart, no flags.
7070

7171
Every iTerm2 pane running a known agent (Claude Code, Codex, AdaL, Aider, Gemini CLI, OpenCode, Goose, Plandex, gptme) appears on the Kanban board within seconds. Click any card to see the terminal, approve permissions, or review diffs.
7272

73-
**Make adoption persistent across new windows** (30 seconds, one-time):
74-
75-
1. Open iTerm2 → **Preferences → Profiles → General**
76-
2. Under **"Send text at start"**, add: `shepherd-bridge &`
77-
78-
From then on, every new iTerm2 session automatically registers with Shepherd.
73+
Shepherd connects to iTerm2's native WebSocket API to discover sessions — no bridge scripts or manual setup required. It reads your iTerm2 auth cookie automatically.
7974

8075
---
8176

@@ -218,7 +213,7 @@ iTerm2 window Shepherd Kanban
218213

219214
**For all other agents**, tasks appear immediately in the board with a purple "iTerm2" badge and the detected agent name.
220215

221-
Setup takes 30 seconds: add `shepherd-bridge.py` to your iTerm2 AutoLaunch profile (`Preferences → Profiles → General → Send text at start`). The bridge forwards your iTerm2 cookie and key to Shepherd's auth file so session scanning works without manual configuration.
216+
Shepherd connects to iTerm2 via its native WebSocket API (`~/.config/iterm2/socket.sock`), authenticating with the cookie and key stored in `~/Library/Application Support/iTerm2/`. No bridge scripts or manual config required.
222217

223218
### Quality gates that block bad PRs
224219

@@ -248,13 +243,13 @@ Done reviewing a task? Click "Create PR":
248243

249244
Zero git commands. Zero context switches.
250245

251-
### Three isolation modes
246+
### Isolation modes
252247

253-
| Mode | Speed | Safety | Use case |
254-
|------|-------|--------|----------|
255-
| Git Worktree | Fast | Good | Default. Most tasks. |
256-
| Docker Container | Medium | Strong | Untrusted code, experiments |
257-
| Local Workspace | Instant | None | Quick fixes on current branch |
248+
| Mode | Speed | Safety | Use case | Status |
249+
|------|-------|--------|----------|--------|
250+
| Git Worktree | Fast | Good | Default. Most tasks. | Available |
251+
| Local Workspace | Instant | None | Quick fixes on current branch | Available |
252+
| Docker Container | Medium | Strong | Untrusted code, experiments | Planned (v0.2) |
258253

259254
### Keyboard-first
260255

@@ -425,27 +420,25 @@ Restart Shepherd. Your agent shows up in the New Task dropdown.
425420

426421
| | Shepherd | Vibe Kanban | Clorch | Claude Squad | Emdash | JetBrains Air |
427422
|---|:---:|:---:|:---:|:---:|:---:|:---:|
428-
| Multi-agent (6+ providers) ||| Claude only | 5 agents | 20+ agents | 4 agents |
423+
| Multi-agent (6+ providers) |(9 adapters) || Claude only | 5 agents | 20+ agents | 4 agents |
429424
| Kanban board |||||||
430425
| YOLO rules engine || YOLO only |||| 4-level |
431426
| Quality gates || Plugin |||||
432427
| One-click PR |||||||
433428
| CLI + GUI ||| CLI only | CLI only |||
434429
| Shell completions |||||||
435430
| Kernel sandbox (nono.sh) |||||||
436-
| Diff review + comments |||||||
437431
| Cross-platform ||| macOS ||| macOS |
438432
| Binary size | ~600KB | ~150MB | pip | Go binary | ~150MB | ~500MB |
439433
| Name gen + logo gen + [North Star](https://northstaradvisor.app/) PMF |||||||
440-
| New project wizard |||||||
441434
| Ecosystem auto-install |||||||
442435
| Open source | Apache 2.0 | Apache 2.0 | MIT | MIT | MIT ||
443436

444437
## Roadmap
445438

446-
**v0.1.0** (current): Core engine with embedded Axum server, task dispatch loop, PTY agent execution, session monitoring, YOLO rules engine, Kanban board (React + Zustand + xterm.js), WebSocket real-time events, CLI with auto-server-spawn and shell completions, 9 agent adapters, iTerm2 session adoption, quality gates, name/logo generators, North Star PMF wizard, nono.sh sandbox, ecosystem auto-install. 1,400+ tests. 99.7% Rust code coverage. CI with fmt, clippy, cargo-deny, Vitest, and Playwright.
439+
**v0.1.0-alpha** (current): Core engine with embedded Axum server, task dispatch loop (polls every 2s), PTY agent execution via portable-pty, session monitoring with regex pattern detection, YOLO rules engine (YAML deny/allow), Kanban board (React + Zustand + xterm.js), WebSocket real-time events (14 event types), REST + WebSocket approve/deny, CLI with auto-server-spawn and shell completions, 9 agent adapters (TOML-defined), iTerm2 session adoption via WebSocket API, quality gates (auto-detect + custom scripts), PR pipeline (diff/commit/push/gh-pr-create), name/logo generators, North Star PMF wizard, nono.sh sandbox integration. 1,500+ tests. CI with fmt, clippy, cargo-deny, typos, Codecov, Vitest, and Playwright.
447440

448-
**v0.2**: Full multi-agent coordination (concurrent dispatch, agent-to-agent handoff). One-click PR pipeline. Docker isolation mode. Homebrew tap + winget package.
441+
**v0.2**: Docker isolation mode. Homebrew tap + winget package. shepherd-bridge auto-registration script. Inline diff viewer with comments.
449442

450443
**v1.0**: Best-of-N (run same task on multiple agents, compare outputs). Issue tracker integration (Linear, GitHub Issues, Jira). Event-driven automations. Cloud sync (Shepherd Pro).
451444

@@ -463,7 +456,7 @@ cd Shepherd
463456
cargo fmt --all -- --check # formatting
464457
cargo clippy --workspace # lints
465458
cargo deny check # license + advisory audit
466-
cargo test --workspace # 1,350+ Rust tests
459+
cargo test --workspace # 1,500+ Rust tests
467460

468461
# Frontend
469462
npm install

crates/shepherd-server/src/handler_tests.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,4 +547,146 @@ mod tests {
547547
assert_eq!(status, StatusCode::NOT_FOUND);
548548
assert!(body["error"].as_str().unwrap().contains("Task not found"));
549549
}
550+
551+
// -- Approve handler tests ------------------------------------------------
552+
553+
#[tokio::test]
554+
async fn handler_approve_task_not_found() {
555+
let state = test_state();
556+
let app = crate::build_router(state);
557+
let resp = app
558+
.oneshot(json_post("/api/tasks/99999/approve", serde_json::json!({})))
559+
.await
560+
.unwrap();
561+
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
562+
}
563+
564+
#[tokio::test]
565+
async fn handler_approve_task_writes_to_pty_and_updates_status() {
566+
let state = test_state();
567+
// Create a task with status "input"
568+
{
569+
let db = state.db.lock().await;
570+
db.execute(
571+
"INSERT INTO tasks (id, title, prompt, agent_id, repo_path, branch, isolation_mode, status) VALUES (1, 'Test', '', 'claude-code', '/tmp', 'main', 'none', 'input')",
572+
[],
573+
).unwrap();
574+
}
575+
let app = crate::build_router(state.clone());
576+
let resp = app
577+
.oneshot(json_post("/api/tasks/1/approve", serde_json::json!({})))
578+
.await
579+
.unwrap();
580+
assert_eq!(resp.status(), StatusCode::OK);
581+
let body = body_json(resp).await;
582+
assert_eq!(body["status"], "running");
583+
584+
// Verify DB status updated
585+
let db = state.db.lock().await;
586+
let status: String = db
587+
.query_row("SELECT status FROM tasks WHERE id = 1", [], |row| {
588+
row.get(0)
589+
})
590+
.unwrap();
591+
assert_eq!(status, "running");
592+
}
593+
594+
#[tokio::test]
595+
async fn handler_approve_all_returns_count() {
596+
let state = test_state();
597+
// Create two tasks with status "input"
598+
{
599+
let db = state.db.lock().await;
600+
db.execute(
601+
"INSERT INTO tasks (id, title, prompt, agent_id, repo_path, branch, isolation_mode, status) VALUES (1, 'T1', '', 'claude-code', '/tmp', 'main', 'none', 'input')",
602+
[],
603+
).unwrap();
604+
db.execute(
605+
"INSERT INTO tasks (id, title, prompt, agent_id, repo_path, branch, isolation_mode, status) VALUES (2, 'T2', '', 'claude-code', '/tmp', 'main', 'none', 'input')",
606+
[],
607+
).unwrap();
608+
// One running task that should NOT be approved
609+
db.execute(
610+
"INSERT INTO tasks (id, title, prompt, agent_id, repo_path, branch, isolation_mode, status) VALUES (3, 'T3', '', 'claude-code', '/tmp', 'main', 'none', 'running')",
611+
[],
612+
).unwrap();
613+
}
614+
let app = crate::build_router(state.clone());
615+
let resp = app
616+
.oneshot(json_post("/api/approve-all", serde_json::json!({})))
617+
.await
618+
.unwrap();
619+
assert_eq!(resp.status(), StatusCode::OK);
620+
let body = body_json(resp).await;
621+
assert_eq!(body["approved"], 2);
622+
623+
// Verify both input tasks are now running
624+
let db = state.db.lock().await;
625+
let s1: String = db
626+
.query_row("SELECT status FROM tasks WHERE id = 1", [], |r| r.get(0))
627+
.unwrap();
628+
let s2: String = db
629+
.query_row("SELECT status FROM tasks WHERE id = 2", [], |r| r.get(0))
630+
.unwrap();
631+
let s3: String = db
632+
.query_row("SELECT status FROM tasks WHERE id = 3", [], |r| r.get(0))
633+
.unwrap();
634+
assert_eq!(s1, "running");
635+
assert_eq!(s2, "running");
636+
assert_eq!(s3, "running"); // was already running
637+
}
638+
639+
#[tokio::test]
640+
async fn handler_approve_all_empty() {
641+
let state = test_state();
642+
let app = crate::build_router(state);
643+
let resp = app
644+
.oneshot(json_post("/api/approve-all", serde_json::json!({})))
645+
.await
646+
.unwrap();
647+
assert_eq!(resp.status(), StatusCode::OK);
648+
let body = body_json(resp).await;
649+
assert_eq!(body["approved"], 0);
650+
}
651+
652+
// -- Shutdown handler tests -----------------------------------------------
653+
654+
#[tokio::test]
655+
async fn handler_shutdown_returns_ok() {
656+
let state = test_state();
657+
let app = crate::build_router(state);
658+
let resp = app
659+
.oneshot(json_post("/api/shutdown", serde_json::json!({})))
660+
.await
661+
.unwrap();
662+
assert_eq!(resp.status(), StatusCode::OK);
663+
let body = body_json(resp).await;
664+
assert_eq!(body["status"], "shutting_down");
665+
}
666+
667+
// -- Direct: Approve/Shutdown ---------------------------------------------
668+
669+
#[tokio::test]
670+
async fn direct_approve_task_not_found() {
671+
let state = test_state();
672+
let result = crate::routes::tasks::approve_task(State(state), Path(99999i64)).await;
673+
assert!(result.is_err());
674+
let (status, _) = result.unwrap_err();
675+
assert_eq!(status, StatusCode::NOT_FOUND);
676+
}
677+
678+
#[tokio::test]
679+
async fn direct_approve_all() {
680+
let state = test_state();
681+
let result = crate::routes::tasks::approve_all(State(state)).await;
682+
let Json(body) = result.unwrap();
683+
assert_eq!(body["approved"], 0);
684+
}
685+
686+
#[tokio::test]
687+
async fn direct_shutdown() {
688+
let state = test_state();
689+
let Json(body) = crate::routes::tasks::shutdown_server(State(state)).await;
690+
assert_eq!(body["status"], "shutting_down");
691+
}
550692
}

crates/shepherd-server/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
2525
"/api/northstar/phase",
2626
post(routes::northstar::execute_phase),
2727
)
28+
.route("/api/tasks/:id/approve", post(routes::tasks::approve_task))
29+
.route("/api/approve-all", post(routes::tasks::approve_all))
30+
.route("/api/shutdown", post(routes::tasks::shutdown_server))
2831
.route("/api/tasks/:id/gates", post(routes::gates::run_task_gates))
2932
.route("/api/tasks/:id/pr", post(routes::pr::create_pr))
3033
.route(

crates/shepherd-server/src/routes/tasks.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use axum::{extract::Path, extract::State, http::StatusCode, Json};
22
use serde_json::Value;
3+
use shepherd_core::db::models::TaskStatus;
34
use shepherd_core::db::{models::CreateTask, queries};
45
use shepherd_core::events::{ServerEvent, TaskEvent};
56
use std::sync::Arc;
@@ -63,6 +64,110 @@ pub async fn delete_task(
6364
Ok(Json(serde_json::json!({ "deleted": id })))
6465
}
6566

67+
/// Approve a pending permission for a task — sends the approve keystroke to PTY
68+
/// and transitions task status from "input" back to "running".
69+
#[tracing::instrument(skip(state))]
70+
pub async fn approve_task(
71+
State(state): State<Arc<AppState>>,
72+
Path(id): Path<i64>,
73+
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
74+
let db = state.db.lock().await;
75+
let task = queries::get_task(&db, id).map_err(|e| {
76+
(
77+
StatusCode::NOT_FOUND,
78+
Json(serde_json::json!({ "error": format!("Task not found: {}", e) })),
79+
)
80+
})?;
81+
82+
let approve_str = state
83+
.adapters
84+
.get(&task.agent_id)
85+
.map(|a| a.permissions.approve.clone())
86+
.unwrap_or_else(|| "y\n".into());
87+
88+
queries::update_task_status(&db, id, &TaskStatus::Running).map_err(|e| {
89+
(
90+
StatusCode::INTERNAL_SERVER_ERROR,
91+
Json(serde_json::json!({ "error": e.to_string() })),
92+
)
93+
})?;
94+
drop(db);
95+
96+
let _ = state.pty.write_to(id, &approve_str).await;
97+
let _ = state.event_tx.send(ServerEvent::TaskUpdated(TaskEvent {
98+
id: task.id,
99+
title: task.title,
100+
agent_id: task.agent_id,
101+
status: "running".into(),
102+
branch: task.branch,
103+
repo_path: task.repo_path,
104+
iterm2_session_id: task.iterm2_session_id,
105+
}));
106+
107+
Ok(Json(serde_json::json!({ "id": id, "status": "running" })))
108+
}
109+
110+
/// Approve all tasks currently in "input" status.
111+
#[tracing::instrument(skip(state))]
112+
pub async fn approve_all(
113+
State(state): State<Arc<AppState>>,
114+
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
115+
// Phase 1: Hold lock, update DB statuses, collect data for PTY writes
116+
let pending = {
117+
let db = state.db.lock().await;
118+
let tasks = queries::list_tasks(&db).map_err(|e| {
119+
(
120+
StatusCode::INTERNAL_SERVER_ERROR,
121+
Json(serde_json::json!({ "error": e.to_string() })),
122+
)
123+
})?;
124+
125+
let pending: Vec<_> = tasks
126+
.into_iter()
127+
.filter(|t| t.status == TaskStatus::Input)
128+
.collect();
129+
130+
for task in &pending {
131+
let _ = queries::update_task_status(&db, task.id, &TaskStatus::Running);
132+
}
133+
pending
134+
// db lock dropped here
135+
};
136+
137+
let count = pending.len();
138+
139+
// Phase 2: No lock held — send PTY writes and events
140+
for task in &pending {
141+
let approve_str = state
142+
.adapters
143+
.get(&task.agent_id)
144+
.map(|a| a.permissions.approve.clone())
145+
.unwrap_or_else(|| "y\n".into());
146+
let _ = state.pty.write_to(task.id, &approve_str).await;
147+
let _ = state.event_tx.send(ServerEvent::TaskUpdated(TaskEvent {
148+
id: task.id,
149+
title: task.title.clone(),
150+
agent_id: task.agent_id.clone(),
151+
status: "running".into(),
152+
branch: task.branch.clone(),
153+
repo_path: task.repo_path.clone(),
154+
iterm2_session_id: task.iterm2_session_id.clone(),
155+
}));
156+
}
157+
158+
Ok(Json(serde_json::json!({ "approved": count })))
159+
}
160+
161+
/// Graceful server shutdown — kills all agents and signals exit.
162+
#[tracing::instrument(skip(state))]
163+
pub async fn shutdown_server(State(state): State<Arc<AppState>>) -> Json<Value> {
164+
state
165+
.pty
166+
.shutdown_all(std::time::Duration::from_secs(5))
167+
.await;
168+
Json(serde_json::json!({ "status": "shutting_down" }))
169+
}
170+
66171
#[cfg(test)]
67172
mod tests {
68173
use shepherd_core::db::models::CreateTask;

0 commit comments

Comments
 (0)