swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates/phone-opus/src/mcp
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-23 18:11:44 -0400
committermain <main@swarm.moe>2026-03-23 18:11:44 -0400
commit4e80028e44ede42b9e0f7bd4beaee8a5cf9aae7c (patch)
treeedcc99524a961398060fd1a1002d271107c15d72 /crates/phone-opus/src/mcp
parenta4a4798b4deed085021149e45e20c5e014ee4de5 (diff)
downloadphone_opus-4e80028e44ede42b9e0f7bd4beaee8a5cf9aae7c.zip
Persist consult outputs in /tmp
Diffstat (limited to 'crates/phone-opus/src/mcp')
-rw-r--r--crates/phone-opus/src/mcp/service.rs175
1 files changed, 166 insertions, 9 deletions
diff --git a/crates/phone-opus/src/mcp/service.rs b/crates/phone-opus/src/mcp/service.rs
index 9fc1134..2baa744 100644
--- a/crates/phone-opus/src/mcp/service.rs
+++ b/crates/phone-opus/src/mcp/service.rs
@@ -10,6 +10,7 @@ use libmcp::{Generation, SurfaceKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use thiserror::Error;
+use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use users::get_current_uid;
use uuid::Uuid;
@@ -351,6 +352,7 @@ struct BackgroundConsultFailure {
struct BackgroundConsultResponseRecord {
cwd: String,
response: String,
+ persisted_output_path: String,
duration_ms: u64,
duration_api_ms: Option<u64>,
num_turns: u64,
@@ -369,6 +371,7 @@ impl BackgroundConsultResponseRecord {
Self {
cwd: response.cwd.display(),
response: response.result,
+ persisted_output_path: response.persisted_output_path.display(),
duration_ms: response.duration_ms,
duration_api_ms: response.duration_api_ms,
num_turns: response.num_turns,
@@ -384,10 +387,7 @@ impl BackgroundConsultResponseRecord {
}
fn model_name(&self) -> Option<String> {
- let Value::Object(models) = self.model_usage.as_ref()? else {
- return None;
- };
- models.keys().next().cloned()
+ model_name(self.model_usage.as_ref())
}
}
@@ -495,6 +495,7 @@ struct ClaudeJsonEnvelope {
struct ConsultResponse {
cwd: WorkingDirectory,
result: String,
+ persisted_output_path: PersistedConsultPath,
duration_ms: u64,
duration_api_ms: Option<u64>,
num_turns: u64,
@@ -510,10 +511,7 @@ struct ConsultResponse {
impl ConsultResponse {
fn model_name(&self) -> Option<String> {
- let Value::Object(models) = self.model_usage.as_ref()? else {
- return None;
- };
- models.keys().next().cloned()
+ model_name(self.model_usage.as_ref())
}
}
@@ -525,6 +523,11 @@ const XDG_CONFIG_DIR_NAME: &str = "xdg-config";
const XDG_CACHE_DIR_NAME: &str = "xdg-cache";
const XDG_STATE_DIR_NAME: &str = "xdg-state";
const SHARED_TMP_ROOTS: [&str; 2] = ["/tmp", "/var/tmp"];
+const CONSULT_OUTPUT_ROOT: &str = "/tmp/phone_opus-consults";
+const CONSULT_OUTPUT_KEEP_COUNT: usize = 256;
+const CONSULT_OUTPUT_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
+const CONSULT_TIMESTAMP_FORMAT: &[time::format_description::FormatItem<'static>] =
+ time::macros::format_description!("[year][month][day]T[hour][minute][second]Z");
const CLAUDE_SEED_FILES: [&str; 5] = [
".credentials.json",
"settings.json",
@@ -684,6 +687,31 @@ impl ClaudeSandbox {
}
}
+#[derive(Debug, Clone)]
+struct PersistedConsultPath(PathBuf);
+
+impl PersistedConsultPath {
+ fn new(request: &ConsultRequest) -> io::Result<Self> {
+ fs::create_dir_all(CONSULT_OUTPUT_ROOT)?;
+ let timestamp = OffsetDateTime::now_utc()
+ .format(CONSULT_TIMESTAMP_FORMAT)
+ .map_err(|error| io::Error::other(error.to_string()))?;
+ let slug = consult_slug(request.prompt.as_str());
+ Ok(Self(Path::new(CONSULT_OUTPUT_ROOT).join(format!(
+ "{timestamp}-{slug}-{}.json",
+ Uuid::new_v4()
+ ))))
+ }
+
+ fn as_path(&self) -> &Path {
+ self.0.as_path()
+ }
+
+ fn display(&self) -> String {
+ self.0.display().to_string()
+ }
+}
+
fn deserialize<T: for<'de> Deserialize<'de>>(
value: Value,
operation: &str,
@@ -925,6 +953,7 @@ fn background_job_tool_output(
"prompt_prefix_injected": record.prompt_prefix_injected,
"result": record.result.as_ref().map(|result| json!({
"response": result.response,
+ "persisted_output_path": result.persisted_output_path,
"duration_ms": result.duration_ms,
"num_turns": result.num_turns,
"session_id": result.session_id,
@@ -947,6 +976,7 @@ fn background_job_tool_output(
result.num_turns,
render_duration_ms(result.duration_ms)
));
+ lines.push(format!("saved={}", result.persisted_output_path));
lines.push("response:".to_owned());
lines.push(result.response.clone());
}
@@ -1130,9 +1160,13 @@ fn invoke_claude(request: &ConsultRequest) -> Result<ConsultResponse, ConsultInv
.unwrap_or_else(|| downstream_message(output.status.code(), &stdout, &stderr)),
));
}
+ let result = envelope.result.clone().unwrap_or_default();
+ let persisted_output_path = persist_consult_output(request, &result, &envelope)
+ .map_err(ConsultInvocationError::Spawn)?;
Ok(ConsultResponse {
cwd: request.cwd.clone(),
- result: envelope.result.unwrap_or_default(),
+ result,
+ persisted_output_path,
duration_ms: envelope.duration_ms.unwrap_or(0),
duration_api_ms: envelope.duration_api_ms,
num_turns: envelope.num_turns.unwrap_or(0),
@@ -1231,6 +1265,112 @@ fn write_bytes_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
Ok(())
}
+fn persist_consult_output(
+ request: &ConsultRequest,
+ result: &str,
+ envelope: &ClaudeJsonEnvelope,
+) -> io::Result<PersistedConsultPath> {
+ let path = PersistedConsultPath::new(request)?;
+ let saved_at = OffsetDateTime::now_utc()
+ .format(&Rfc3339)
+ .map_err(|error| io::Error::other(error.to_string()))?;
+ let artifact = json!({
+ "kind": "phone_opus_consult",
+ "saved_at": saved_at,
+ "saved_unix_ms": unix_ms_now(),
+ "cwd": request.cwd.display(),
+ "prompt": request.prompt.as_str(),
+ "prompt_prefix": CLAUDE_CONSULT_PREFIX,
+ "effective_prompt": request.prompt.rendered(),
+ "session_mode": request.session_mode(),
+ "requested_session_id": request.requested_session_id(),
+ "response": result,
+ "model": model_name(envelope.model_usage.as_ref()),
+ "duration_ms": envelope.duration_ms.unwrap_or(0),
+ "duration_api_ms": envelope.duration_api_ms,
+ "num_turns": envelope.num_turns.unwrap_or(0),
+ "stop_reason": envelope.stop_reason,
+ "session_id": envelope.session_id,
+ "total_cost_usd": envelope.total_cost_usd,
+ "usage": envelope.usage,
+ "model_usage": envelope.model_usage,
+ "permission_denials": envelope.permission_denials,
+ "fast_mode_state": envelope.fast_mode_state,
+ "uuid": envelope.uuid,
+ });
+ write_json_file(path.as_path(), &artifact)?;
+ let _ = prune_persisted_consult_outputs();
+ Ok(path)
+}
+
+fn prune_persisted_consult_outputs() -> io::Result<()> {
+ let root = Path::new(CONSULT_OUTPUT_ROOT);
+ if !root.exists() {
+ return Ok(());
+ }
+ let now = SystemTime::now();
+ let mut entries = fs::read_dir(root)?
+ .filter_map(Result::ok)
+ .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
+ .filter_map(|entry| {
+ let path = entry.path();
+ (path.extension().and_then(|ext| ext.to_str()) == Some("json")).then_some(path)
+ })
+ .collect::<Vec<_>>();
+ entries.sort_unstable_by(|left, right| right.file_name().cmp(&left.file_name()));
+ for stale in entries.iter().skip(CONSULT_OUTPUT_KEEP_COUNT) {
+ let _ = fs::remove_file(stale);
+ }
+ for path in entries.iter().take(CONSULT_OUTPUT_KEEP_COUNT) {
+ let age = path
+ .metadata()
+ .and_then(|metadata| metadata.modified())
+ .ok()
+ .and_then(|modified| now.duration_since(modified).ok());
+ if age.is_some_and(|age| age > CONSULT_OUTPUT_MAX_AGE) {
+ let _ = fs::remove_file(path);
+ }
+ }
+ Ok(())
+}
+
+fn consult_slug(prompt: &str) -> String {
+ let mut slug = String::new();
+ let mut last_was_dash = false;
+ for ch in prompt.chars() {
+ let next = if ch.is_ascii_alphanumeric() {
+ Some(ch.to_ascii_lowercase())
+ } else if ch.is_ascii_whitespace() || "-_./:".contains(ch) {
+ Some('-')
+ } else {
+ None
+ };
+ let Some(next) = next else {
+ continue;
+ };
+ if next == '-' {
+ if slug.is_empty() || last_was_dash {
+ continue;
+ }
+ last_was_dash = true;
+ } else {
+ last_was_dash = false;
+ }
+ slug.push(next);
+ if slug.len() >= 48 {
+ break;
+ }
+ }
+ while slug.ends_with('-') {
+ let _ = slug.pop();
+ }
+ if slug.is_empty() {
+ "consult".to_owned()
+ } else {
+ slug
+ }
+}
+
fn consult_output(
request: &ConsultRequest,
response: &ConsultResponse,
@@ -1241,6 +1381,7 @@ fn consult_output(
"mode": request.mode().as_str(),
"response": response.result,
"cwd": response.cwd.display(),
+ "persisted_output_path": response.persisted_output_path.display(),
"session_mode": request.session_mode(),
"requested_session_id": request.requested_session_id(),
"prompt_prefix_injected": true,
@@ -1256,6 +1397,7 @@ fn consult_output(
"mode": request.mode().as_str(),
"response": response.result,
"cwd": response.cwd.display(),
+ "persisted_output_path": response.persisted_output_path.display(),
"prompt": request.prompt.as_str(),
"prompt_prefix": CLAUDE_CONSULT_PREFIX,
"effective_prompt": request.prompt.rendered(),
@@ -1310,6 +1452,10 @@ fn concise_text(request: &ConsultRequest, response: &ConsultResponse) -> String
if let Some(session_id) = response.session_id.as_deref() {
lines.push(format!("session: {session_id}"));
}
+ lines.push(format!(
+ "saved: {}",
+ response.persisted_output_path.display()
+ ));
if !response.permission_denials.is_empty() {
lines.push(format!(
"permission_denials: {}",
@@ -1349,6 +1495,10 @@ fn full_text(request: &ConsultRequest, response: &ConsultResponse) -> String {
if let Some(session_id) = response.session_id.as_deref() {
lines.push(format!("session: {session_id}"));
}
+ lines.push(format!(
+ "saved: {}",
+ response.persisted_output_path.display()
+ ));
if let Some(cost) = response.total_cost_usd {
lines.push(format!("cost_usd: {cost:.6}"));
}
@@ -1391,6 +1541,13 @@ fn usage_summary(usage: Option<&Value>) -> Option<String> {
})
}
+fn model_name(model_usage: Option<&Value>) -> Option<String> {
+ let Value::Object(models) = model_usage? else {
+ return None;
+ };
+ models.keys().next().cloned()
+}
+
fn render_duration_ms(duration_ms: u64) -> String {
if duration_ms < 1_000 {
return format!("{duration_ms}ms");