Run fabro exec through pebble's command-line session

Pebble's command line is a library now. `fabro exec` builds its agent as
before, with fabro's client, sandbox, MCP servers, skills, search, and
redaction, and hands it to pebble's session: the events rendered as they
happen, the answer on standard output, the summary after it, the agent shut
down for the reason the prompt ended with, and the terminal approval prompt
for tools the permission level does not allow. Fabro's own progress printer,
approval prompt, summary, and MCP report are gone. The event stream of
`--output-format json` stays on standard output. Standard output now carries
the final answer alone rather than every assistant message; `--verbose` no
longer prints tool results, since the session's renderer shows tool failures
only. The lockfile moves tempfile to the version pebble pins, and the SQLite
backup migration uses the replacement for the constructor that version
deprecates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-11 20:51:01 -06:00
parent a33c9069cf
commit 66360c2896
No known key found for this signature in database
5 changed files with 81 additions and 415 deletions

64
Cargo.lock generated
View file

@ -235,9 +235,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arc-swap"
@ -2063,7 +2063,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -2190,7 +2190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -2424,6 +2424,7 @@ dependencies = [
"openssl",
"paste",
"pebble-agent",
"pebble-cli-core",
"pebble-coding-agent",
"predicates",
"progenitor-client",
@ -4346,7 +4347,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
"windows-core 0.62.2",
]
[[package]]
@ -5292,7 +5293,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -5857,7 +5858,7 @@ dependencies = [
[[package]]
name = "pebble-agent"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=3a76aeee74d6c4aef349b927b09455c5616b296e#3a76aeee74d6c4aef349b927b09455c5616b296e"
source = "git+https://github.com/lithoscomputer/pebble?rev=cb6b24e26eaab24fa94a53babeafaccf20b29ee8#cb6b24e26eaab24fa94a53babeafaccf20b29ee8"
dependencies = [
"async-trait",
"futures-util",
@ -5871,10 +5872,39 @@ dependencies = [
"uuid",
]
[[package]]
name = "pebble-cli-core"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=cb6b24e26eaab24fa94a53babeafaccf20b29ee8#cb6b24e26eaab24fa94a53babeafaccf20b29ee8"
dependencies = [
"anyhow",
"async-trait",
"base64",
"clap",
"crossterm",
"fs2",
"futures-util",
"humantime",
"lithos-llm",
"pebble-agent",
"pebble-coding-agent",
"rustix",
"serde",
"serde_json",
"similar",
"tempfile",
"termimad",
"tokio",
"tokio-util",
"unicode-segmentation",
"unicode-width 0.2.2",
"uuid",
]
[[package]]
name = "pebble-coding-agent"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=3a76aeee74d6c4aef349b927b09455c5616b296e#3a76aeee74d6c4aef349b927b09455c5616b296e"
source = "git+https://github.com/lithoscomputer/pebble?rev=cb6b24e26eaab24fa94a53babeafaccf20b29ee8#cb6b24e26eaab24fa94a53babeafaccf20b29ee8"
dependencies = [
"async-trait",
"futures-util",
@ -6244,7 +6274,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@ -6721,7 +6751,7 @@ dependencies = [
"errno 0.3.14",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -6780,7 +6810,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -7424,7 +7454,7 @@ version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno 0.2.8",
"errno 0.3.14",
"libc",
]
@ -7981,15 +8011,15 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.26.0"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -8024,7 +8054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -9091,7 +9121,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]

View file

@ -112,8 +112,9 @@ futures-util = "0.3"
# the merge commit once it lands. Pebble pins the same lithos-llm rev as
# fabro, and its lockfile policy is that every shared crate resolves to the
# version lithos-llm locks.
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "3a76aeee74d6c4aef349b927b09455c5616b296e" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "3a76aeee74d6c4aef349b927b09455c5616b296e", features = ["mcp", "search-providers"] }
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "cb6b24e26eaab24fa94a53babeafaccf20b29ee8" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "cb6b24e26eaab24fa94a53babeafaccf20b29ee8", features = ["mcp", "search-providers"] }
pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "cb6b24e26eaab24fa94a53babeafaccf20b29ee8" }
sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" }
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" }
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" }

View file

@ -26,6 +26,7 @@ fabro-oauth = { path = "../../foundation/fabro-oauth" }
fabro-github = { path = "../../components/fabro-github" }
pebble-agent.workspace = true
pebble-coding-agent.workspace = true
pebble-cli-core.workspace = true
fabro-dump = { path = "../../components/fabro-dump" }
fabro-hooks = { path = "../../components/fabro-hooks" }
fabro-install = { path = "../../components/fabro-install" }

View file

@ -1,13 +1,16 @@
//! `fabro exec`: one agentic coding session in the current directory.
//!
//! The session is pebble's coding agent over a local sandbox. Model calls go
//! either straight to the provider with the CLI's credentials or through a
//! Fabro server's completions endpoint when a server target is set.
//! The session is pebble's coding agent over a local sandbox, run through
//! pebble's own command-line session: its event renderer, closing summary,
//! and terminal approval prompt. What is fabro's here is the client (model
//! calls go either straight to the provider with the CLI's credentials or
//! through a Fabro server's completions endpoint when a server target is
//! set), the sandbox, the MCP servers, skills, search, and redaction.
use std::collections::HashMap;
use std::io::IsTerminal as _;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use anyhow::{Context as _, Result as AnyResult};
use async_trait::async_trait;
@ -20,7 +23,6 @@ use fabro_mcp::config::McpServerSettings;
use fabro_mcp::pebble::pebble_servers;
use fabro_sandbox::{RunSandbox, SecretRedactor, local_sandbox};
use fabro_static::EnvVars;
use fabro_types::PermissionLevel;
use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat;
use fabro_types::settings::run::ResolvedMcpEntry;
use fabro_util::exit::{self, ErrorExt, ExitClass};
@ -28,20 +30,14 @@ use fabro_util::home::Home;
use fabro_util::terminal::Styles;
use fabro_workflow::web_search::{self, SearchSecrets};
use lithos_llm::catalog::ProviderId;
use pebble_agent::{ToolCallRequest, ToolSystemError};
use pebble_cli_core::approval::TerminalApproval;
use pebble_cli_core::render::{self, JsonStream, Style};
use pebble_cli_core::session::{SessionOptions, run_prompt};
use pebble_coding_agent::environment::Environment;
use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent};
use pebble_coding_agent::state::Message;
use pebble_coding_agent::subagents::SubagentOptions;
use pebble_coding_agent::tools::{
ApprovalDecision, PermissionLevelPolicy, PermissionMiddleware, ToolApprovalService,
};
use pebble_coding_agent::{
CodingAgent, CodingAgentOptions, MemoryDiscovery, ShutdownReason, SkillDiscovery,
};
use tokio::io::{AsyncWriteExt, stdout};
use pebble_coding_agent::tools::{PermissionLevelPolicy, PermissionMiddleware};
use pebble_coding_agent::{CodingAgent, CodingAgentOptions, MemoryDiscovery, SkillDiscovery};
use tokio::signal;
use tokio::task::spawn_blocking;
use tokio_util::sync::CancellationToken;
use crate::args::{AgentArgs, ExecArgs, ExecOutputFormat};
@ -325,162 +321,6 @@ fn summarizer_model(catalog: &Catalog, provider_id: &ProviderId, selected_model:
format!("{provider_id}/{model}")
}
/// Interactive approval for tools the permission level does not allow
/// outright. Without a terminal, or with `--auto-approve`, such tools are
/// refused.
struct CliApproval {
level: Mutex<PermissionLevel>,
is_interactive: bool,
styles: &'static Styles,
}
#[async_trait]
impl ToolApprovalService for CliApproval {
async fn approve(
&self,
request: &ToolCallRequest,
) -> Result<ApprovalDecision, ToolSystemError> {
let tool_name = request.call().name.clone();
let current_level = *self
.level
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if current_level.auto_approves_tool(&tool_name) {
return Ok(ApprovalDecision::Allow);
}
if !self.is_interactive {
return Ok(ApprovalDecision::Deny {
reason: format!("{tool_name} tool denied at current permission level"),
});
}
let styles = self.styles;
let answer = spawn_blocking(move || prompt_for_approval(&tool_name, styles))
.await
.map_err(|error| ToolSystemError::new(format!("approval prompt failed: {error}")))?;
match answer {
Ok(ApprovalAnswer::Allow) => Ok(ApprovalDecision::Allow),
Ok(ApprovalAnswer::AllowAlways) => {
*self
.level
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = PermissionLevel::Full;
Ok(ApprovalDecision::Allow)
}
Ok(ApprovalAnswer::Deny { tool_name }) => Ok(ApprovalDecision::Deny {
reason: format!("{tool_name} tool denied by user"),
}),
Err(reason) => Ok(ApprovalDecision::Deny { reason }),
}
}
}
enum ApprovalAnswer {
Allow,
AllowAlways,
Deny { tool_name: String },
}
#[allow(
clippy::print_stderr,
reason = "Interactive approval prompts belong on stderr, not assistant output."
)]
#[expect(
clippy::disallowed_methods,
clippy::disallowed_types,
reason = "Interactive tool approval blocks on stdin and stderr by design, on a blocking task."
)]
fn prompt_for_approval(tool_name: &str, styles: &Styles) -> Result<ApprovalAnswer, String> {
use std::io::Write as _;
eprint!(
"Allow {}? [y]es / [n]o / [a]lways: ",
styles.bold.apply_to(tool_name),
);
std::io::stderr().flush().ok();
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| format!("Failed to read input: {e}"))?;
Ok(match input.trim().to_lowercase().as_str() {
"y" | "yes" => ApprovalAnswer::Allow,
"a" | "always" => ApprovalAnswer::AllowAlways,
_ => ApprovalAnswer::Deny {
tool_name: tool_name.to_string(),
},
})
}
fn format_tool_args(args: &serde_json::Value, cwd: &str) -> String {
let cwd_prefix = if cwd.ends_with('/') {
cwd.to_string()
} else {
format!("{cwd}/")
};
let Some(obj) = args.as_object() else {
return args.to_string();
};
obj.iter()
.map(|(k, v)| match v {
serde_json::Value::String(s) => {
let s = s.strip_prefix(&cwd_prefix).unwrap_or(s);
let display = if s.len() > 80 {
format!("{}...", &s[..s.floor_char_boundary(77)])
} else {
s.to_string()
};
format!("{k}={display:?}")
}
other => format!("{k}={other}"),
})
.collect::<Vec<_>>()
.join(", ")
}
#[allow(
clippy::print_stdout,
reason = "Assistant responses are the CLI's primary stdout output."
)]
fn print_output(agent: &CodingAgent, styles: &Styles) {
for turn in agent.history().turns() {
if let Message::Assistant { content, .. } = turn {
if !content.is_empty() {
println!("{}", styles.render_markdown(content));
}
}
}
}
#[allow(
clippy::print_stderr,
reason = "Session summaries are diagnostic metadata, not assistant output."
)]
fn print_summary(agent: &CodingAgent, styles: &Styles) {
let (mut turn_count, mut tool_call_count, mut total_tokens) = (0usize, 0usize, 0u64);
for turn in agent.history().turns() {
if let Message::Assistant {
tool_calls, usage, ..
} = turn
{
turn_count += 1;
tool_call_count += tool_calls.len();
total_tokens = total_tokens.saturating_add(usage.input.saturating_add(usage.output));
}
}
let token_str = if total_tokens >= 1_000_000 {
format!("{:.1}m", total_tokens as f64 / 1_000_000.0)
} else if total_tokens >= 1000 {
format!("{}k", total_tokens / 1000)
} else {
total_tokens.to_string()
};
eprintln!(
"{}",
styles.dim.apply_to(format!(
"Done ({turn_count} turns, {tool_call_count} tools, {token_str} toks)"
)),
);
}
/// Middleware that logs LLM request/response summaries to stderr.
struct DebugMiddleware {
styles: &'static Styles,
@ -554,9 +394,8 @@ impl Middleware for VerboseMiddleware {
}
#[allow(
clippy::print_stdout,
clippy::print_stderr,
reason = "Assistant output stays on stdout while prompts and diagnostics use stderr."
reason = "The model line is a diagnostic for the person running the CLI."
)]
async fn run_session(
args: AgentArgs,
@ -587,7 +426,6 @@ async fn run_session(
eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}")));
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let cwd_str = cwd.to_string_lossy().to_string();
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(cwd)
.await
@ -600,14 +438,9 @@ async fn run_session(
reason = "is_terminal() on stdin is a non-blocking fstat; no actual I/O performed"
)]
let is_interactive = std::io::stdin().is_terminal() && !args.auto_approve;
let approval = Arc::new(CliApproval {
level: Mutex::new(permissions),
is_interactive,
styles,
});
let permission_middleware =
PermissionMiddleware::new(Arc::new(PermissionLevelPolicy::new(permissions)))
.with_approval(approval);
.with_approval(Arc::new(TerminalApproval::new(permissions, is_interactive)));
// The profile's own instruction files from the repository root down, and
// fabro's skill directories: pebble knows the files and does the walk.
@ -639,16 +472,22 @@ async fn run_session(
if let Some(search) = web_search::search_provider(&cli_search_secrets()) {
builder = builder.search_provider(search);
}
let mut agent = builder
let agent = builder
.build()
.await
.context("failed to start the agent session")?;
if matches!(
args.output_format.unwrap_or(ExecOutputFormat::Text),
ExecOutputFormat::Text
) {
print_mcp_servers(&agent, styles);
}
// Text puts progress on stderr and the answer on stdout; JSON puts the
// event stream itself on stdout, as scripts that read it expect.
let session = match args.output_format.unwrap_or(ExecOutputFormat::Text) {
ExecOutputFormat::Text => SessionOptions::default(),
ExecOutputFormat::Json => SessionOptions {
style: Style::Json,
json_to: JsonStream::Stdout,
write_answer: false,
},
};
render::report_mcp_servers(&agent, session.style);
// SIGINT ends the prompt; the session shuts down as cancelled.
let cancel_token = CancellationToken::new();
@ -658,204 +497,13 @@ async fn run_session(
sigint_token.cancel();
});
let verbose = args.verbose;
let output_format = args.output_format.unwrap_or(ExecOutputFormat::Text);
let mut rx = agent.subscribe();
let printer = tokio::spawn(async move {
match output_format {
ExecOutputFormat::Json => {
let mut stdout = stdout();
while let Ok(event) = rx.recv().await {
if let Ok(json) = serde_json::to_string(&event) {
let _ = stdout.write_all(json.as_bytes()).await;
let _ = stdout.write_all(b"\n").await;
let _ = stdout.flush().await;
}
}
}
ExecOutputFormat::Text => {
while let Ok(event) = rx.recv().await {
print_progress(&event, verbose, &cwd_str, styles);
}
}
}
});
let report = agent
.prompt_with_cancellation(args.prompt.as_str(), &cancel_token)
.await;
let shutdown_reason = match &report.result {
Ok(_) => ShutdownReason::Completed,
Err(_) if cancel_token.is_cancelled() => ShutdownReason::Cancelled,
Err(_) => ShutdownReason::Error,
};
if let Err(error) = agent.shutdown(shutdown_reason).await {
tracing::debug!(error = %error, "agent session did not shut down cleanly");
}
// The stream ends with the shutdown, so the printer drains everything.
let _ = printer.await;
if matches!(output_format, ExecOutputFormat::Text) {
print_output(&agent, styles);
print_summary(&agent, styles);
}
let report = run_prompt(agent, args.prompt.as_str(), &cancel_token, session).await?;
report
.result
.map(|_| ())
.map_err(|error| anyhow::Error::new(SessionError::from(error)))
}
/// Report what became of each configured MCP server on stderr: pebble
/// started them while the agent was built, so the outcomes are read from the
/// agent rather than from a stream that had no subscriber yet.
#[allow(
clippy::print_stderr,
reason = "MCP connection outcomes are diagnostics for the person running the CLI."
)]
fn print_mcp_servers(agent: &CodingAgent, styles: &Styles) {
for status in agent.snapshot().mcp_servers() {
match &status.error {
None => eprintln!(
"{}",
styles.dim.apply_to(format!(
"[mcp] {}: {} tools",
status.server,
status.tools.len()
))
),
Some(error) => eprintln!(
"{}",
styles
.red
.apply_to(format!("[mcp] {} failed: {error}", status.server))
),
}
}
}
#[allow(
clippy::print_stderr,
reason = "Progress lines are diagnostics on stderr; assistant output stays on stdout."
)]
fn print_progress(event: &CodingAgentEvent, verbose: bool, cwd: &str, s: &Styles) {
let child_prefix = if event.parent_session_id.is_some() {
format!("[child {}] ", event.session_id)
} else {
String::new()
};
match &event.event {
CodingEvent::ToolCallStarted {
tool_name,
arguments,
..
} => {
eprintln!(
" {} {}{}",
s.dim.apply_to("\u{25cf}"),
s.bold_cyan.apply_to(format!("{child_prefix}{tool_name}")),
s.dim
.apply_to(format!("({})", format_tool_args(arguments, cwd))),
);
}
CodingEvent::ToolCallCompleted {
tool_name,
output,
is_error,
..
} if verbose => {
let label = if *is_error {
"tool error"
} else {
"tool result"
};
eprintln!(
" {}\n{}",
s.dim
.apply_to(format!("[{label}] {child_prefix}{tool_name}:")),
serde_json::to_string_pretty(output).unwrap_or_else(|_| output.to_string()),
);
}
CodingEvent::Error { error } => {
eprintln!(
" {}",
s.red
.apply_to(format!("\u{2717} {child_prefix}{}", error.message)),
);
}
CodingEvent::SubAgentSpawned {
agent_id,
depth,
task,
generation,
}
| CodingEvent::SubAgentTurnStarted {
agent_id,
depth,
task,
generation,
} => {
let started = if matches!(event.event, CodingEvent::SubAgentSpawned { .. }) {
"spawned"
} else {
"turn started"
};
let task_preview = if task.len() > 60 {
&task[..task.floor_char_boundary(60)]
} else {
task
};
eprintln!(
" {}",
s.dim.apply_to(format!(
"{child_prefix}\u{25b6} subagent {agent_id} {started} (depth={depth}, generation={generation}) task={task_preview:?}"
)),
);
}
CodingEvent::SubAgentCompleted {
agent_id,
depth,
generation,
success,
turns_used,
} => {
eprintln!(
" {}",
s.dim.apply_to(format!(
"{child_prefix}\u{25a0} subagent {agent_id} completed (depth={depth}, generation={generation}, success={success}, turns={turns_used})"
)),
);
}
CodingEvent::SubAgentFailed {
agent_id,
depth,
generation,
error,
} => {
eprintln!(
" {}",
s.red.apply_to(format!(
"{child_prefix}\u{2717} subagent {agent_id} failed (depth={depth}, generation={generation}): {}",
error.message
)),
);
}
CodingEvent::SubAgentClosed {
agent_id,
depth,
generation,
} => {
eprintln!(
" {}",
s.dim.apply_to(format!(
"{child_prefix}\u{25a0} subagent {agent_id} closed (depth={depth}, generation={generation})"
)),
);
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
@ -864,10 +512,7 @@ mod tests {
use fabro_types::settings::run::{McpServerRef, McpServerSettings, ResolvedMcpEntry};
use lithos_llm::catalog::builtin;
use super::{
AgentArgs, format_tool_args, resolve_provider_id, run_mcp_servers_for_exec,
summarizer_model,
};
use super::{AgentArgs, resolve_provider_id, run_mcp_servers_for_exec, summarizer_model};
use crate::args::{ExecOutputFormat, PermissionsArg};
fn args(provider: Option<&str>, model: Option<&str>) -> AgentArgs {
@ -946,15 +591,4 @@ mod tests {
assert!(selector.starts_with("anthropic/"), "{selector}");
assert_ne!(selector, "anthropic/claude-opus-4-6");
}
#[test]
fn tool_args_strip_the_working_directory_prefix() {
let rendered = format_tool_args(
&serde_json::json!({"file_path": "/work/src/main.rs", "limit": 20}),
"/work",
);
assert!(rendered.contains("file_path=\"src/main.rs\""), "{rendered}");
assert!(rendered.contains("limit=20"), "{rendered}");
}
}

View file

@ -78,7 +78,7 @@ pub(crate) async fn create_backup(
let publish_staging = staging_path.clone();
let publish_backup = backup_path.to_path_buf();
let already_exists = spawn_blocking(move || {
let staging = tempfile::TempPath::from_path(publish_staging);
let staging = tempfile::TempPath::try_from_path(publish_staging)?;
match staging.persist_noclobber(&publish_backup) {
Ok(()) => {
// Make the rename's directory entry durable: the retained