swarm repositories / source
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-23 15:38:00 -0400
committermain <main@swarm.moe>2026-03-23 15:38:00 -0400
commitff76c72f3c78694eebe4824318e85c4751343cf4 (patch)
treecfb49d55874a9f90b11fed9756f00e1fc233faeb
parent36f3be895696e674fced66ef2b6d285149ee5562 (diff)
downloadphone_opus-ff76c72f3c78694eebe4824318e85c4751343cf4.zip
Support resuming Claude consult sessions
-rw-r--r--Cargo.lock84
-rw-r--r--Cargo.toml1
-rw-r--r--README.md2
-rw-r--r--assets/codex-skills/phone-opus/SKILL.md13
-rw-r--r--crates/phone-opus/Cargo.toml1
-rw-r--r--crates/phone-opus/src/mcp/catalog.rs6
-rw-r--r--crates/phone-opus/src/mcp/service.rs61
-rw-r--r--crates/phone-opus/tests/mcp_hardening.rs55
8 files changed, 207 insertions, 16 deletions
diff --git a/Cargo.lock b/Cargo.lock
index f87b4e0..f339af4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Cargo.toml b/Cargo.toml
index 221e0c7..0e362a7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/README.md b/README.md
index 86c66f6..301f64a 100644
--- a/README.md
+++ b/README.md
@@ -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");