diff options
| author | main <main@swarm.moe> | 2026-03-23 15:38:00 -0400 |
|---|---|---|
| committer | main <main@swarm.moe> | 2026-03-23 15:38:00 -0400 |
| commit | ff76c72f3c78694eebe4824318e85c4751343cf4 (patch) | |
| tree | cfb49d55874a9f90b11fed9756f00e1fc233faeb | |
| parent | 36f3be895696e674fced66ef2b6d285149ee5562 (diff) | |
| download | phone_opus-ff76c72f3c78694eebe4824318e85c4751343cf4.zip | |
Support resuming Claude consult sessions
| -rw-r--r-- | Cargo.lock | 84 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | assets/codex-skills/phone-opus/SKILL.md | 13 | ||||
| -rw-r--r-- | crates/phone-opus/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/phone-opus/src/mcp/catalog.rs | 6 | ||||
| -rw-r--r-- | crates/phone-opus/src/mcp/service.rs | 61 | ||||
| -rw-r--r-- | crates/phone-opus/tests/mcp_hardening.rs | 55 |
8 files changed, 207 insertions, 16 deletions
@@ -53,6 +53,12 @@ dependencies = [ ] [[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -298,6 +304,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -366,6 +382,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -394,6 +416,7 @@ dependencies = [ "serde", "serde_json", "thiserror", + "uuid", ] [[package]] @@ -467,6 +490,12 @@ dependencies = [ ] [[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] name = "schemars" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -699,12 +728,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -21,6 +21,7 @@ libmcp-testkit = { git = "https://git.swarm.moe/libmcp.git", rev = "e325cd23f193 serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" thiserror = "2.0.17" +uuid = "1.18.1" [workspace.lints.rust] elided_lifetimes_in_paths = "deny" @@ -7,6 +7,7 @@ It exposes one blocking domain tool: - `consult`: run the system `claude` install in print mode, wait for the answer, and return the response plus execution metadata + - pass `session_id` from a previous response to resume that Claude Code conversation The server keeps the public MCP session in a durable host, isolates the actual Claude invocation in a disposable worker, and ships standard health and @@ -40,4 +41,3 @@ Run the server locally with: ```bash cargo run -- mcp serve ``` - diff --git a/assets/codex-skills/phone-opus/SKILL.md b/assets/codex-skills/phone-opus/SKILL.md index 481cd4a..5fe91d7 100644 --- a/assets/codex-skills/phone-opus/SKILL.md +++ b/assets/codex-skills/phone-opus/SKILL.md @@ -9,27 +9,28 @@ Use `phone_opus.consult` when you want a blocking Claude Code consult without ed ## When to use it +Whenever you're making a *major, nontrivial, high level* design decision, you should get a second opinion from Opus. +Opus is intelligent and has a different perspective, so takes its feedback seriously; but nothing it says +should be taken as authoritative or final. It is a pure consultant. + - Ask for a second opinion on code, architecture, debugging, or design. - Point Claude at a specific repository with `cwd`. - Cap the consultation with `max_turns` when needed. +- Reuse `session_id` from an earlier call when you want Claude to continue the same conversation. ## Tool surface - `consult` - required: `prompt` - - optional: `cwd`, `max_turns`, `render`, `detail` + - optional: `cwd`, `max_turns`, `session_id`, `render`, `detail` - `health_snapshot` - `telemetry_snapshot` ## Runtime posture -- Uses the system `claude` install. -- Runs with default Claude settings. -- Exposes no MCP servers to Claude. -- Restricts Claude to the read-only built-in toolset: - - `Bash,Read,Grep,Glob,LS,WebFetch,WebSearch` - Uses `--permission-mode dontAsk`, so only globally preapproved read-only Bash commands can execute. - This surface is consultative only. Edit tools are unavailable. +- The returned `session_id` is reusable: pass it back into a later `consult` call to continue that Claude conversation. ## Example diff --git a/crates/phone-opus/Cargo.toml b/crates/phone-opus/Cargo.toml index 402d459..b96bab3 100644 --- a/crates/phone-opus/Cargo.toml +++ b/crates/phone-opus/Cargo.toml @@ -17,6 +17,7 @@ libmcp.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +uuid.workspace = true [dev-dependencies] libmcp-testkit.workspace = true diff --git a/crates/phone-opus/src/mcp/catalog.rs b/crates/phone-opus/src/mcp/catalog.rs index a7e7cf6..4c71e83 100644 --- a/crates/phone-opus/src/mcp/catalog.rs +++ b/crates/phone-opus/src/mcp/catalog.rs @@ -41,7 +41,7 @@ impl ToolSpec { const TOOL_SPECS: &[ToolSpec] = &[ ToolSpec { name: "consult", - description: "Run a blocking consult against the system Claude Code install using a read-only built-in toolset and return the response.", + description: "Run a blocking consult against the system Claude Code install using a read-only built-in toolset, optionally resume a prior Claude session by session_id, and return the response.", dispatch: DispatchTarget::Worker, replay: ReplayContract::NeverReplay, }, @@ -94,6 +94,10 @@ fn tool_schema(name: &str) -> Value { "type": "integer", "minimum": 1, "description": "Optional maximum number of Claude agent turns before stopping." + }, + "session_id": { + "type": "string", + "description": "Optional Claude session handle returned by a previous consult call. When set, phone_opus resumes that conversation instead of starting a fresh one." } }, "required": ["prompt"] diff --git a/crates/phone-opus/src/mcp/service.rs b/crates/phone-opus/src/mcp/service.rs index 2472887..c57d0db 100644 --- a/crates/phone-opus/src/mcp/service.rs +++ b/crates/phone-opus/src/mcp/service.rs @@ -7,6 +7,7 @@ use libmcp::{Generation, SurfaceKind}; use serde::Deserialize; use serde_json::{Value, json}; use thiserror::Error; +use uuid::Uuid; use crate::mcp::fault::{FaultRecord, FaultStage}; use crate::mcp::output::{ @@ -100,6 +101,7 @@ struct ConsultArgs { prompt: String, cwd: Option<String>, max_turns: Option<u64>, + session_id: Option<String>, } #[derive(Debug, Clone)] @@ -107,6 +109,7 @@ struct ConsultRequest { prompt: PromptText, cwd: WorkingDirectory, max_turns: Option<TurnLimit>, + session: Option<SessionHandle>, } impl ConsultRequest { @@ -115,8 +118,21 @@ impl ConsultRequest { prompt: PromptText::parse(args.prompt)?, cwd: WorkingDirectory::resolve(args.cwd)?, max_turns: args.max_turns.map(TurnLimit::parse).transpose()?, + session: args.session_id.map(SessionHandle::parse).transpose()?, }) } + + fn session_mode(&self) -> &'static str { + if self.session.is_some() { + "resumed" + } else { + "new" + } + } + + fn requested_session_id(&self) -> Option<String> { + self.session.as_ref().map(SessionHandle::display) + } } #[derive(Debug, Clone)] @@ -188,6 +204,21 @@ impl TurnLimit { } } +#[derive(Debug, Clone)] +struct SessionHandle(Uuid); + +impl SessionHandle { + fn parse(raw: String) -> Result<Self, ConsultRequestError> { + Uuid::parse_str(&raw) + .map(Self) + .map_err(|_| ConsultRequestError::InvalidSessionHandle(raw)) + } + + fn display(&self) -> String { + self.0.to_string() + } +} + #[derive(Debug, Error)] enum ConsultRequestError { #[error("prompt must not be empty")] @@ -200,6 +231,8 @@ enum ConsultRequestError { NotDirectory(String), #[error("max_turns must be greater than zero")] InvalidTurnLimit, + #[error("session_id must be a valid UUID, got `{0}`")] + InvalidSessionHandle(String), } #[derive(Debug, Error)] @@ -317,6 +350,9 @@ fn invoke_claude(request: &ConsultRequest) -> Result<ConsultResponse, ConsultInv .arg(CLAUDE_TOOLSET) .arg("--permission-mode") .arg("dontAsk"); + if let Some(session) = request.session.as_ref() { + let _ = command.arg("--resume").arg(session.display()); + } if let Some(max_turns) = request.max_turns { let _ = command.arg("--max-turns").arg(max_turns.get().to_string()); } @@ -401,6 +437,8 @@ fn consult_output( let concise = json!({ "response": response.result, "cwd": response.cwd.display(), + "session_mode": request.session_mode(), + "requested_session_id": request.requested_session_id(), "model": response.model_name(), "duration_ms": response.duration_ms, "num_turns": response.num_turns, @@ -414,6 +452,8 @@ fn consult_output( "cwd": response.cwd.display(), "prompt": request.prompt.as_str(), "max_turns": request.max_turns.map(TurnLimit::get), + "session_mode": request.session_mode(), + "requested_session_id": request.requested_session_id(), "duration_ms": response.duration_ms, "duration_api_ms": response.duration_api_ms, "num_turns": response.num_turns, @@ -429,8 +469,8 @@ fn consult_output( fallback_detailed_tool_output( &concise, &full, - concise_text(response), - Some(full_text(response)), + concise_text(request, response), + Some(full_text(request, response)), SurfaceKind::Read, generation, FaultStage::Worker, @@ -438,9 +478,10 @@ fn consult_output( ) } -fn concise_text(response: &ConsultResponse) -> String { +fn concise_text(request: &ConsultRequest, response: &ConsultResponse) -> String { let mut status = vec![ "consult ok".to_owned(), + format!("session={}", request.session_mode()), format!("turns={}", response.num_turns), format!("duration={}", render_duration_ms(response.duration_ms)), ]; @@ -456,6 +497,9 @@ fn concise_text(response: &ConsultResponse) -> String { let mut lines = vec![status.join(" ")]; lines.push(format!("cwd: {}", response.cwd.display())); + if let Some(session_id) = request.requested_session_id() { + lines.push(format!("requested_session: {session_id}")); + } if let Some(session_id) = response.session_id.as_deref() { lines.push(format!("session: {session_id}")); } @@ -470,12 +514,19 @@ fn concise_text(response: &ConsultResponse) -> String { lines.join("\n") } -fn full_text(response: &ConsultResponse) -> String { +fn full_text(request: &ConsultRequest, response: &ConsultResponse) -> String { let mut lines = vec![ - format!("consult ok turns={}", response.num_turns), + format!( + "consult ok session={} turns={}", + request.session_mode(), + response.num_turns + ), format!("cwd: {}", response.cwd.display()), format!("duration: {}", render_duration_ms(response.duration_ms)), ]; + if let Some(session_id) = request.requested_session_id() { + lines.push(format!("requested_session: {session_id}")); + } if let Some(duration_api_ms) = response.duration_api_ms { lines.push(format!( "api_duration: {}", diff --git a/crates/phone-opus/tests/mcp_hardening.rs b/crates/phone-opus/tests/mcp_hardening.rs index b47b365..bc338ca 100644 --- a/crates/phone-opus/tests/mcp_hardening.rs +++ b/crates/phone-opus/tests/mcp_hardening.rs @@ -11,6 +11,7 @@ use libmcp_testkit::read_json_lines; use serde as _; use serde_json::{Value, json}; use thiserror as _; +use uuid as _; type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>; @@ -223,7 +224,8 @@ fn cold_start_exposes_consult_and_ops_tools() -> TestResult { } #[test] -fn consult_uses_read_only_toolset_and_requested_working_directory() -> TestResult { +fn consult_can_resume_a_prior_session_with_read_only_toolset_and_requested_working_directory() +-> TestResult { let root = temp_root("consult_success")?; let state_home = root.join("state-home"); let sandbox = root.join("sandbox"); @@ -234,6 +236,7 @@ fn consult_uses_read_only_toolset_and_requested_working_directory() -> TestResul let stdout_file = root.join("stdout.json"); let args_file = root.join("args.txt"); let pwd_file = root.join("pwd.txt"); + let resumed_session = "81f218eb-568b-409b-871b-f6e86d8f666f"; write_fake_claude_script(&fake_claude)?; must( fs::write( @@ -247,7 +250,7 @@ fn consult_uses_read_only_toolset_and_requested_working_directory() -> TestResul "num_turns": 2, "result": "oracle", "stop_reason": "end_turn", - "session_id": "session-123", + "session_id": resumed_session, "total_cost_usd": 0.125, "usage": { "input_tokens": 10, @@ -287,16 +290,29 @@ fn consult_uses_read_only_toolset_and_requested_working_directory() -> TestResul json!({ "prompt": "say oracle", "cwd": sandbox.display().to_string(), - "max_turns": 7 + "max_turns": 7, + "session_id": resumed_session }), )?; assert_tool_ok(&consult); assert_eq!(tool_content(&consult)["response"].as_str(), Some("oracle")); assert_eq!( + tool_content(&consult)["session_mode"].as_str(), + Some("resumed") + ); + assert_eq!( + tool_content(&consult)["requested_session_id"].as_str(), + Some(resumed_session) + ); + assert_eq!( tool_content(&consult)["cwd"].as_str(), Some(sandbox.display().to_string().as_str()) ); assert_eq!(tool_content(&consult)["num_turns"].as_u64(), Some(2)); + assert_eq!( + tool_content(&consult)["session_id"].as_str(), + Some(resumed_session) + ); let pwd = must(fs::read_to_string(&pwd_file), "read fake pwd file")?; assert_eq!(pwd.trim(), sandbox.display().to_string()); @@ -315,6 +331,8 @@ fn consult_uses_read_only_toolset_and_requested_working_directory() -> TestResul assert!(lines.contains(&"Bash,Read,Grep,Glob,LS,WebFetch,WebSearch")); assert!(lines.contains(&"--permission-mode")); assert!(lines.contains(&"dontAsk")); + assert!(lines.contains(&"--resume")); + assert!(lines.contains(&resumed_session)); assert!(lines.contains(&"--max-turns")); assert!(lines.contains(&"7")); assert_eq!(lines.last().copied(), Some("say oracle")); @@ -334,6 +352,37 @@ fn consult_uses_read_only_toolset_and_requested_working_directory() -> TestResul } #[test] +fn consult_rejects_invalid_session_handles() -> TestResult { + let root = temp_root("consult_invalid_session")?; + let state_home = root.join("state-home"); + must(fs::create_dir_all(&state_home), "create state home")?; + + let mut harness = McpHarness::spawn(&state_home, &[])?; + let _ = harness.initialize()?; + harness.notify_initialized()?; + + let consult = harness.call_tool( + 3, + "consult", + json!({ + "prompt": "fail", + "session_id": "not-a-uuid" + }), + )?; + assert_tool_error(&consult); + assert_eq!( + tool_content(&consult)["fault"]["class"].as_str(), + Some("protocol") + ); + assert!( + tool_content(&consult)["fault"]["detail"] + .as_str() + .is_some_and(|value| value.contains("session_id must be a valid UUID")) + ); + Ok(()) +} + +#[test] fn consult_surfaces_downstream_cli_failures() -> TestResult { let root = temp_root("consult_failure")?; let state_home = root.join("state-home"); |