diff --git a/README.md b/README.md index d77461e..7794051 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ under `.pi/npm` and `.pi/git`, while `--global` uses the configured rpi agent directory's `npm` and `git` stores. Local directories are enabled in place and are never copied or deleted. Existing legacy `.rpi/packages`, `.pi/packages`, and native `~/.pi/agent` installs remain discoverable. Package-manager argv is -selected from trusted `.rpi/settings.json`, trusted `.pi/settings.json`, then +selected from project `.rpi/settings.json`, project `.pi/settings.json`, then global `settings.json`; the default is npm. rpi treats the setting as structured argv rather than a shell command string, applies hardened encoding to Windows `.cmd` shims, and uses the native Pi flags for npm, pnpm, or bun. diff --git a/crates/pi-cli/embedded-docs/README.md b/crates/pi-cli/embedded-docs/README.md index d77461e..7794051 100644 --- a/crates/pi-cli/embedded-docs/README.md +++ b/crates/pi-cli/embedded-docs/README.md @@ -181,7 +181,7 @@ under `.pi/npm` and `.pi/git`, while `--global` uses the configured rpi agent directory's `npm` and `git` stores. Local directories are enabled in place and are never copied or deleted. Existing legacy `.rpi/packages`, `.pi/packages`, and native `~/.pi/agent` installs remain discoverable. Package-manager argv is -selected from trusted `.rpi/settings.json`, trusted `.pi/settings.json`, then +selected from project `.rpi/settings.json`, project `.pi/settings.json`, then global `settings.json`; the default is npm. rpi treats the setting as structured argv rather than a shell command string, applies hardened encoding to Windows `.cmd` shims, and uses the native Pi flags for npm, pnpm, or bun. diff --git a/crates/pi-cli/embedded-docs/user-guide.md b/crates/pi-cli/embedded-docs/user-guide.md index 8c9331a..3a034a1 100644 --- a/crates/pi-cli/embedded-docs/user-guide.md +++ b/crates/pi-cli/embedded-docs/user-guide.md @@ -145,8 +145,9 @@ rpi install rpi install-pi rpi uninstall rpi uninstall-pi -rpi update # 更新 Rust/npm package -rpi pi-update # 更新 rpi CLI 自身 +rpi update # 只更新 Rust 原生扩展 +rpi pi-update # 只更新 Pi npm/Git package +rpi self-update # 更新 rpi CLI 自身 ``` ## 4. 内置工具 @@ -249,12 +250,12 @@ rpi uninstall pi npm:@scope/package rpi package add ../my-pi-package rpi package list rpi package remove ../my-pi-package -rpi package update +rpi package update # 兼容入口:同时更新 Rust 原生扩展和 Pi package ``` npm 和 Git 安装使用与原生 Pi 一致的托管布局:项目范围分别写入 `.pi/npm`、`.pi/git`,`--global` 则写入当前 rpi agent 配置目录下的 `npm`、`git`。本地目录只记录到 settings,不会复制,也不会在卸载时删除。旧版 `.rpi/packages`、`.pi/packages` 以及 `~/.pi/agent` 下的原生 Pi 安装仍可发现和迁移。 -`npmCommand` 是 argv 数组,不是 shell 字符串;依次选择已信任项目的 `.rpi/settings.json`、`.pi/settings.json`、全局 `settings.json`,都未配置时使用 npm。rpi 会按识别到的 npm、pnpm 或 bun 生成与原生 Pi 一致的 install/uninstall 参数。未信任项目的 settings 和 package 路径不会参与解析;无法安全验证的路径、来源或 manifest 会直接拒绝。Node.js 是运行 JS/TS extension 的必要条件。普通 rpi 命令不会加载这些 package,需显式传 `--enable-pi-packages`。 +`npmCommand` 是 argv 数组,不是 shell 字符串;依次选择项目 `.rpi/settings.json`、`.pi/settings.json`、全局 `settings.json`,都未配置时使用 npm。项目资源默认直接加载,不会弹出确认;需要临时禁用时使用 `--no-approve`,或在 TUI 中执行 `/trust no`。rpi 会按识别到的 npm、pnpm 或 bun 生成与原生 Pi 一致的 install/uninstall 参数。无法安全验证的路径、来源或 manifest 会直接拒绝。Node.js 是运行 JS/TS extension 的必要条件。普通 rpi 命令不会加载这些 package,需显式传 `--enable-pi-packages`。 ### 静态资源 diff --git a/crates/pi-cli/src/app.rs b/crates/pi-cli/src/app.rs index bb508f2..efb1a4f 100644 --- a/crates/pi-cli/src/app.rs +++ b/crates/pi-cli/src/app.rs @@ -16,7 +16,7 @@ //! //! # v1 scope cuts vs TS `main.ts` (in `docs/m6-cli-open-questions.md`) //! -//! The TS `main` is enormous: HTTP proxy config, project-trust prompts, +//! The TS `main` is enormous: HTTP proxy config, project-trust handling, //! first-time setup, migrations, and full npm package management remain //! outside this port. rpi does support local static package management via //! `rpi package` and Rust cdylib extension installation. The regular agent path @@ -25,7 +25,7 @@ //! but not attached to the prompt — the harness `prompt_text` accepts images, //! but v1 does not yet wire an image processor; binary/non-UTF-8 files error). -use std::io::{IsTerminal, Read, Write}; +use std::io::{IsTerminal, Read}; use std::path::Path; use rpi_ai::types::{ImageContent, ImageContentType}; @@ -97,14 +97,12 @@ pub async fn run() -> i32 { return crate::packages::run_cli(&argv[1..]); } if argv.first().map(|s| s.as_str()) == Some("update") { - // The top-level update command owns Rust/npm package updates. - // `pi-update` is the explicit self-update command for rpi itself. - let mut package_args = Vec::with_capacity(argv.len()); - package_args.push("update".to_string()); - package_args.extend_from_slice(&argv[1..]); - return crate::packages::run_cli(&package_args); + return crate::packages::run_native_update(&argv[1..]); } if argv.first().map(|s| s.as_str()) == Some("pi-update") { + return crate::packages::run_pi_update(&argv[1..]); + } + if argv.first().map(|s| s.as_str()) == Some("self-update") { return crate::updates::run_self_update(&argv[1..]); } if argv.first().map(|s| s.as_str()) == Some("install") { @@ -215,25 +213,6 @@ pub async fn run() -> i32 { None }; - // Native Pi asks before loading project-local settings/resources. Only - // prompt when an interactive terminal is available and there is something - // project-owned to authorize; headless/print/json invocations remain - // fail-closed without blocking for input. - if parsed.trust_override.is_none() - && std::io::stdin().is_terminal() - && std::io::stdout().is_terminal() - && crate::session::project_has_local_resources(&cwd) - { - match prompt_project_trust(&cwd) { - Some(decision) => parsed.trust_override = Some(decision), - None => { - eprintln!( - "warning: project trust prompt unavailable; local resources remain disabled" - ); - } - } - } - // `-r/--resume` is an interactive picker, unlike `-c/--continue` which // immediately opens the latest session. Resolve the picker result before // building the harness so cancelling does not create or modify a session. @@ -446,22 +425,6 @@ pub async fn run() -> i32 { exit_code } -fn prompt_project_trust(cwd: &Path) -> Option { - let display = cwd.display(); - print!("Trust project {display} and load local resources? [y/N] "); - let _ = std::io::stdout().flush(); - let mut answer = String::new(); - if std::io::stdin().read_line(&mut answer).is_err() { - return None; - } - let normalized = answer.trim().to_ascii_lowercase(); - let trusted = matches!(normalized.as_str(), "y" | "yes"); - if let Err(error) = crate::config::set_project_trust(cwd, Some(trusted)) { - eprintln!("warning: could not persist project trust decision: {error}"); - } - Some(trusted) -} - /// Print the merged model catalog, optionally filtered by a case-insensitive /// fuzzy-ish substring over provider, id, and display name. async fn list_models(search: &str) -> i32 { diff --git a/crates/pi-cli/src/args.rs b/crates/pi-cli/src/args.rs index 46b14d2..1e59f31 100644 --- a/crates/pi-cli/src/args.rs +++ b/crates/pi-cli/src/args.rs @@ -582,8 +582,8 @@ pub fn print_help() { --list-models [search] List available models (with optional fuzzy search) --offline Disable startup network operations (same as PI_OFFLINE=1) --export Export a JSONL session to HTML and exit - --approve, -a Trust the current project for local resources - --no-approve, -na Do not trust the current project + --approve, -a Force-enable current-project resources + --no-approve, -na Disable current-project resources --print, -p Non-interactive: process prompt(s) and exit --continue, -c Continue the most recent session --resume, -r Browse and select a session to resume @@ -608,8 +608,9 @@ pub fn print_help() { --version, -v Show version {u}Subcommands:{r} - update Update installed Rust and npm packages - pi-update Update the rpi CLI from crates.io + update Update installed Rust-native extensions + pi-update Update configured Pi npm/Git packages + self-update Update the rpi CLI from crates.io auth login|check|logout Manage persisted credentials in ~/.rpi/auth.json (see `rpi auth --help`) package list|add|remove|update Manage TS packages and Rust extensions diff --git a/crates/pi-cli/src/install_pi.rs b/crates/pi-cli/src/install_pi.rs index 650e9a9..f45103d 100644 --- a/crates/pi-cli/src/install_pi.rs +++ b/crates/pi-cli/src/install_pi.rs @@ -114,13 +114,12 @@ pub fn run(args: &[String]) -> i32 { } fn project_trust_for_package_operation(cwd: &Path, global: bool) -> Result { - match crate::config::project_trust_decision(cwd) { - Ok(Some(true)) => Ok(true), - Ok(_) if global => Ok(false), - Ok(_) => Err("project is not trusted; refusing to access project package storage".into()), - Err(_) if global => Ok(false), - Err(error) => Err(format!("could not read project trust decision: {error}")), + if global { + return Ok(false); } + crate::config::project_trust_decision(cwd) + .map(|decision| decision.unwrap_or(true)) + .map_err(|error| format!("could not read project trust decision: {error}")) } fn classify_install_spec(cwd: &Path, spec: &str) -> InstallSpecKind { @@ -3966,7 +3965,7 @@ mod tests { } #[test] - fn project_package_operations_require_saved_trust() { + fn project_package_operations_are_enabled_by_default() { struct RestoreEnv(Option); impl Drop for RestoreEnv { fn drop(&mut self) { @@ -3986,8 +3985,10 @@ mod tests { let _restore = RestoreEnv(std::env::var_os(crate::config::CONFIG_DIR_ENV)); std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent); - assert!(project_trust_for_package_operation(&project, false).is_err()); + assert!(project_trust_for_package_operation(&project, false).unwrap()); assert!(!project_trust_for_package_operation(&project, true).unwrap()); + crate::config::set_project_trust(&project, Some(false)).unwrap(); + assert!(!project_trust_for_package_operation(&project, false).unwrap()); crate::config::set_project_trust(&project, Some(true)).unwrap(); assert!(project_trust_for_package_operation(&project, false).unwrap()); } diff --git a/crates/pi-cli/src/interactive_tui.rs b/crates/pi-cli/src/interactive_tui.rs index 04d399d..79f7cba 100644 --- a/crates/pi-cli/src/interactive_tui.rs +++ b/crates/pi-cli/src/interactive_tui.rs @@ -4333,6 +4333,18 @@ pub async fn interactive_tui( // return defensive clones, so rendering this summary does not retain a // harness lock or trigger a second resource scan. let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default(); + // AgentHarness uses an empty active-name list as the default "all tools" + // state. Do not expose that implementation sentinel as `Tools (0) none` + // in the welcome banner (it is especially visible on the first prompt). + if active_tool_names.is_empty() { + active_tool_names = harness + .get_tools() + .await + .unwrap_or_default() + .into_iter() + .map(|tool| tool.tool.schema().name.clone()) + .collect(); + } let resources_snapshot = harness.get_resources().await.unwrap_or_default(); let skill_names: Vec = resources_snapshot .skills @@ -6229,7 +6241,6 @@ async fn handle_agent_event( for c in &a.content { if let Content::ToolCall(tc) = c { if tc.name == "bash" { - saw_bash_tool_call = true; // Bash has a dedicated component. Create it here as // well as on ToolExecutionStart because the tool // call can become visible in a MessageUpdate first. @@ -6240,6 +6251,16 @@ async fn handle_agent_event( .get("command") .and_then(|v| v.as_str()) .unwrap_or(""); + // Streaming tool-call arguments may still be `{}` + // here. Do not create a running bash panel until + // the lifecycle start event provides the command; + // otherwise the spinner renders first and the + // actual `$ command` header appears one frame + // later. + if command.trim().is_empty() { + continue; + } + saw_bash_tool_call = true; let mut bash = state.bash_components.lock().unwrap(); if !bash.contains_key(&tc.id) { let comp = Arc::new(BashExecutionComponent::new(command)); @@ -6387,25 +6408,24 @@ async fn handle_agent_event( args, partial_result, } => { + let partial_text = tool_result_text(&partial_result); + let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result); if tool_name == "bash" { // Append the streamed chunk to the bash component's preview. // RAW text (no single-line collapsing) — the old // `summarize_tool_result` folded every newline into a `⏎` // glyph, cramming e.g. `ls -la`'s listing onto one line. - let chunk = tool_result_text(&partial_result); + let chunk = partial_text; if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) { - bash.append_output(&chunk); - } else { - // No component yet — create a running bash one so the - // partial shows (command unknown at Update time; leave blank). - let comp = Arc::new(BashExecutionComponent::new("")); - comp.append_output(&chunk); - chat.add_child(comp.clone()); - state - .bash_components - .lock() - .unwrap() - .insert(tool_call_id.clone(), comp); + if has_partial_payload { + bash.append_output(&chunk); + } + } else if has_partial_payload { + // ToolExecutionStart is emitted before a tool can run. + // Ignore an out-of-order partial until that event gives us + // the real command, rather than showing a spinner above an + // empty `$ ` header. Normal updates are handled by the + // component created in ToolExecutionStart. } } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) { if let Some(skill) = skill_tool_name(&tool_name, &args) { @@ -6413,17 +6433,22 @@ async fn handle_agent_event( } // Raw multi-line text — read/ls-style tools must show their // full content, not the single-line ⏎-folded summary. - comp.set_result(&tool_result_text(&partial_result), false); - apply_edit_diff(comp, &tool_name, &partial_result.details, &tui); - } else { + if has_partial_payload { + comp.set_result(&partial_text, false); + apply_edit_diff(comp, &tool_name, &partial_result.details, &tui); + } + } else if has_partial_payload { // No component yet — create a running one so the partial shows. - let comp = Arc::new(ToolExecutionComponent::new(&tool_name, "")); + // Empty callbacks are common before ToolExecutionStart; wait + // for Start so the first panel has the real arguments instead + // of an empty `TOOLS` box. + let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string())); comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap()); if let Some(skill) = skill_tool_name(&tool_name, &args) { comp.set_skill_name(skill); } comp.set_running(); - comp.set_result(&tool_result_text(&partial_result), false); + comp.set_result(&partial_text, false); apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui); chat.add_child(comp.clone()); state @@ -6611,6 +6636,13 @@ fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String { parts.join("\n") } +/// Empty progress callbacks are valid (notably before a tool's start event), +/// but they do not contain anything useful to render. Defer those callbacks so +/// the first tool panel is created from `ToolExecutionStart` with real args. +fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool { + !text.trim().is_empty() || !result.details.is_null() +} + // =========================================================================== // Selectors — editor-container swap (TS showSelector pattern) // =========================================================================== @@ -7178,12 +7210,22 @@ fn open_tools_selector( // runtime (it's called from the main loop's channel dispatch or the submit // closure that lives on the blocking thread — but `handle.block_on` is safe // because `get_active_tools` is std-Mutex-backed and finishes quickly). - let active = match tokio::runtime::Handle::try_current() { + let mut active = match tokio::runtime::Handle::try_current() { Ok(h) => h .block_on(async { lane.get_active_tools().await }) .unwrap_or_default(), Err(_) => Vec::new(), }; + // An empty active set is the harness sentinel for "all registered tools" + // (the selector only exposes built-ins). Expand it before rendering and + // toggling so the first `/tools` visit does not show every tool as off or + // accidentally reduce the active set to the one item selected. + if active.is_empty() { + active = crate::session::BUILTIN_TOOL_NAMES + .iter() + .map(|name| (*name).to_string()) + .collect(); + } let mut items: Vec = Vec::new(); for name in crate::session::BUILTIN_TOOL_NAMES { let on = active.iter().any(|a| a == name); @@ -8185,18 +8227,18 @@ mod tests { name: "rpi".into(), current: "0.1.10".into(), latest: "0.1.11".into(), - command: "rpi pi-update".into(), + command: "rpi self-update".into(), }, crate::updates::UpdateNotice { name: "rpi-search".into(), current: "0.1.0".into(), latest: "0.1.1".into(), - command: "rpi update".into(), + command: "rpi pi-update".into(), }, ], warnings: vec![crate::updates::UpdateWarning { message: "The previously scheduled rpi self-update failed: access denied".into(), - command: "rpi pi-update".into(), + command: "rpi self-update".into(), }], }; @@ -8211,7 +8253,7 @@ mod tests { ); assert!(plain.contains("Update Available"), "{plain}"); assert!(plain.contains("New version 0.1.11 is available"), "{plain}"); - assert!(plain.contains("rpi update"), "{plain}"); + assert!(plain.contains("rpi self-update"), "{plain}"); assert!(plain.contains("Package Updates Available"), "{plain}"); assert!(plain.contains("rpi pi-update"), "{plain}"); assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}"); @@ -8247,6 +8289,21 @@ mod tests { assert_eq!(plain, "Skills (0) none"); } + #[test] + fn empty_tool_progress_is_deferred_until_start() { + let empty = rpi_agent::AgentToolResult::default(); + assert!(!tool_update_has_payload("", &empty)); + + let text = rpi_agent::AgentToolResult::text("partial output"); + assert!(tool_update_has_payload("partial output", &text)); + + let details = rpi_agent::AgentToolResult { + details: serde_json::json!({"path": "src/lib.rs"}), + ..Default::default() + }; + assert!(tool_update_has_payload("", &details)); + } + /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion` /// replaces the editor text, the NEXT rendered frame must show the /// completed text (" /model " with the caret after it), not the old diff --git a/crates/pi-cli/src/packages.rs b/crates/pi-cli/src/packages.rs index f31a2e2..5f8ec77 100644 --- a/crates/pi-cli/src/packages.rs +++ b/crates/pi-cli/src/packages.rs @@ -1315,18 +1315,75 @@ pub fn run_cli(args: &[String]) -> i32 { } } +/// Top-level `rpi update`: update only installed Rust-native extensions. +pub fn run_native_update(args: &[String]) -> i32 { + run_top_level_update(args, UpdateScope::Native) +} + +/// Top-level `rpi pi-update`: update only configured Pi npm/Git packages. +pub fn run_pi_update(args: &[String]) -> i32 { + run_top_level_update(args, UpdateScope::Pi) +} + +fn run_top_level_update(args: &[String], scope: UpdateScope) -> i32 { + crate::args::normalize_offline_mode(args); + let args = crate::args::without_offline_flag(args); + let cwd = match std::env::current_dir() { + Ok(path) => path, + Err(error) => { + eprintln!("error: could not determine current directory: {error}"); + return 1; + } + }; + if args + .iter() + .any(|arg| matches!(arg.as_str(), "--help" | "-h")) + { + print_scoped_update_help(scope); + return 0; + } + let project_trusted = if scope.includes_pi() { + match package_command_project_trusted(&cwd, &args) { + Ok(trusted) => trusted, + Err(error) => { + eprintln!("error: {error}"); + return 2; + } + } + } else { + if let Some(arg) = args.first() { + eprintln!("error: unknown native update option `{arg}`"); + return 2; + } + false + }; + update_packages_with_scope(&cwd, project_trusted, scope) +} + fn print_help() { println!( - "Usage: rpi package \n\nCommands:\n list [--json] [--approve|--no-approve]\n List enabled TS packages and installed Rust extensions\n add Enable a local/package.json package\n remove \n Disable a Pi package\n update [--approve|--no-approve] [--offline]\n Update TS npm/git packages and Rust crates.io extensions\n\nProject packages are read only when the project has a saved trust decision or --approve is supplied. TS package resources are loaded from skills/, prompts/, themes/, SYSTEM.md, APPEND_SYSTEM.md, and extensions. Rust-native extensions are installed with `rpi install`." + "Usage: rpi package \n\nCommands:\n list [--json] [--approve|--no-approve]\n List enabled TS packages and installed Rust extensions\n add Enable a local/package.json package\n remove \n Disable a Pi package\n update [--approve|--no-approve] [--offline]\n Update TS npm/git packages and Rust crates.io extensions\n\nProject packages load by default without confirmation; use --no-approve to disable project package access. TS package resources are loaded from skills/, prompts/, themes/, SYSTEM.md, APPEND_SYSTEM.md, and extensions. Rust-native extensions are installed with `rpi install`." ); } fn print_update_help() { println!( - "Usage: rpi update [--approve|--no-approve] [--offline]\n\nUpdate installed Rust-native and npm/Git Pi packages.\n\nThe legacy `rpi package update` spelling remains supported." + "Usage: rpi update [--offline]\n\nUpdate installed Rust-native extensions only.\n\nUse `rpi pi-update` for configured Pi npm/Git packages. The legacy `rpi package update` spelling still updates both package families." ); } +fn print_scoped_update_help(scope: UpdateScope) { + match scope { + UpdateScope::Native => println!( + "Usage: rpi update [--offline]\n\nUpdate installed Rust-native extensions only." + ), + UpdateScope::Pi => println!( + "Usage: rpi pi-update [--approve|--no-approve] [--offline]\n\nUpdate configured Pi npm/Git packages only.\n\nThe rpi CLI itself is updated with `rpi self-update`." + ), + UpdateScope::All => print_update_help(), + } +} + fn package_command_project_trusted(cwd: &Path, args: &[String]) -> Result { let mut override_value = None; for arg in args { @@ -1347,39 +1404,65 @@ fn package_command_project_trusted(cwd: &Path, args: &[String]) -> Result bool { + matches!(self, Self::Native | Self::All) + } + + fn includes_pi(self) -> bool { + matches!(self, Self::Pi | Self::All) + } } fn update_packages(cwd: &Path, project_trusted: bool) -> i32 { + update_packages_with_scope(cwd, project_trusted, UpdateScope::All) +} + +fn update_packages_with_scope(cwd: &Path, project_trusted: bool, scope: UpdateScope) -> i32 { if crate::args::offline_env_enabled() { println!("package update skipped: offline mode is enabled"); return 0; } - // This command updates Rust and TS packages as one operation. Validate the - // native registry before discovery because discovery may recover an - // interrupted npm/Git directory swap. A damaged registry must make the - // whole command a no-op rather than allowing a partial TS-only update. - let native = match crate::install::installed_native_packages_strict() { - Ok(packages) => packages, - Err(error) => { - eprintln!( - "error: refusing package update while native package metadata is invalid: {error}" - ); - return 1; + // Native updates validate their registry before mutating anything. Pi + // package updates independently validate settings before recovering an + // interrupted npm/Git directory swap. + let native = if scope.includes_native() { + match crate::install::installed_native_packages_strict() { + Ok(packages) => packages, + Err(error) => { + eprintln!( + "error: refusing native package update while metadata is invalid: {error}" + ); + return 1; + } } + } else { + Vec::new() }; - // Load every settings document before performing recovery, invoking a - // package manager, or updating a Rust extension. A malformed active file - // must make the entire command a no-op rather than silently narrowing the - // requested package set and partially updating it. - let (resources, preflight_npm_command) = + // Load every settings document before performing Pi package recovery or + // invoking a package manager. A malformed active file must make the Pi + // update a no-op rather than silently narrowing the requested package set. + let (resources, preflight_npm_command) = if scope.includes_pi() { match discover_from_settings_for_update(cwd, project_trusted) { Ok(result) => result, Err(error) => { - eprintln!("error: refusing package update with unreadable settings: {error}"); + eprintln!("error: refusing Pi package update with unreadable settings: {error}"); return 1; } - }; + } + } else { + (PackageResources::default(), None) + }; let blocked = resources .diagnostics .iter() @@ -1411,7 +1494,7 @@ fn update_packages(cwd: &Path, project_trusted: bool) -> i32 { }; let mut failed = 0; if resources.packages.is_empty() && native.is_empty() { - println!("no Pi packages enabled"); + println!("no packages available for update"); return 0; } let mut updated = 0; @@ -1574,7 +1657,12 @@ fn update_packages(cwd: &Path, project_trusted: bool) -> i32 { None => unreachable!("package command was preflighted for update candidates"), } } - println!("package update complete: {updated} updated, {skipped} skipped"); + let label = match scope { + UpdateScope::Native => "native package update", + UpdateScope::Pi => "Pi package update", + UpdateScope::All => "package update", + }; + println!("{label} complete: {updated} updated, {skipped} skipped"); i32::from(failed > 0) } @@ -3821,6 +3909,42 @@ mod tests { assert_eq!(run_cli(&["update".into(), "--help".into()]), 0); } + #[test] + fn native_and_pi_update_scopes_validate_only_their_own_metadata() { + let _guard = crate::config::test_support::env_lock().lock().unwrap(); + let previous = std::env::var_os(config::CONFIG_DIR_ENV); + let tmp = tempfile::tempdir().unwrap(); + let agent = tmp.path().join("agent"); + std::fs::create_dir_all(&agent).unwrap(); + std::fs::write(agent.join("native-packages.json"), "[{broken").unwrap(); + std::env::set_var(config::CONFIG_DIR_ENV, &agent); + + assert_eq!( + update_packages_with_scope(tmp.path(), false, UpdateScope::Native), + 1 + ); + assert_eq!( + update_packages_with_scope(tmp.path(), false, UpdateScope::Pi), + 0 + ); + + std::fs::remove_file(agent.join("native-packages.json")).unwrap(); + std::fs::write(agent.join("settings.json"), "{ malformed").unwrap(); + assert_eq!( + update_packages_with_scope(tmp.path(), false, UpdateScope::Native), + 0 + ); + assert_eq!( + update_packages_with_scope(tmp.path(), false, UpdateScope::Pi), + 1 + ); + + match previous { + Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value), + None => std::env::remove_var(config::CONFIG_DIR_ENV), + } + } + #[test] fn discovers_conventional_and_manifest_resources() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/pi-cli/src/session.rs b/crates/pi-cli/src/session.rs index 9a9ebc1..d47ea99 100644 --- a/crates/pi-cli/src/session.rs +++ b/crates/pi-cli/src/session.rs @@ -15,8 +15,8 @@ //! re-run through `load_skills`/`load_prompt_templates` (individual `.md` files //! load too — `load_skills` accepts both dirs and files). Package themes are //! parsed by the TUI when selected via settings or `--theme`.** -//! A project trust gate now fails closed by default; use `--approve` or a -//! stored `trust.json` decision to enable project-local resources. +//! Project resources load by default without a prompt. `--no-approve` or a +//! stored negative `trust.json` decision disables them explicitly. //! - **No `ModelRuntime`/multi-provider registry.** The resolver supports the //! built-in Anthropic/OpenAI-compatible providers and `models.json`, but //! runtime catalog mutation remains outside this layer. @@ -239,12 +239,12 @@ pub async fn build( let cwd_str = cwd.to_string_lossy().to_string(); if !project_trusted && args.verbose { eprintln!( - "warning: project is not trusted; local settings, resources, and discovered extensions are disabled (use --approve or /trust)" + "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)" ); } // Pi packages are explicitly opt-in because discovery can start Node and - // execute package code. Trust additionally limits discovery to global - // settings when the current project has not been approved. + // execute package code. An explicit project opt-out limits discovery to + // global settings. let package_resources = if args.dev_local_only { crate::packages::PackageResources::default() } else { @@ -1535,28 +1535,8 @@ fn build_models_with_extensions( models } -/// Whether the cwd contains project-owned resources that warrant a trust -/// decision prompt. Session storage alone is intentionally excluded so a -/// normal launch does not repeatedly ask after creating `.rpi/sessions`. -pub fn project_has_local_resources(cwd: &Path) -> bool { - const FILES: &[&str] = &[ - "settings.json", - "SYSTEM.md", - "APPEND_SYSTEM.md", - "packages.json", - ]; - const DIRS: &[&str] = &["skills", "prompts", "themes", "extensions", "packages"]; - [".rpi", ".pi"].iter().any(|layout| { - let root = cwd.join(layout); - FILES.iter().any(|name| root.join(name).is_file()) - || DIRS.iter().any(|name| root.join(name).is_dir()) - }) -} - -/// Resolve the project trust gate without prompting. Explicit CLI overrides -/// win; otherwise a stored `trust.json` decision is honored. An absent or -/// malformed decision fails closed so untrusted project files cannot execute -/// during startup. +/// Resolve project resource loading without prompting. Explicit CLI overrides +/// win, then a stored decision; projects with no decision load by default. pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool { if let Some(override_value) = args.trust_override { return override_value; @@ -1564,7 +1544,7 @@ pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool { crate::config::project_trust_decision(cwd) .ok() .flatten() - .unwrap_or(false) + .unwrap_or(true) } /// Resolve the extension dirs to scan and load the cdylib plugins, returning @@ -2635,19 +2615,19 @@ mod tests { } #[test] - fn project_trust_override_fails_closed_by_default() { - let denied = Args::default(); - assert!(!resolve_project_trust( - &denied, + fn project_resources_load_by_default_and_allow_explicit_opt_out() { + let defaults = Args::default(); + assert!(resolve_project_trust( + &defaults, Path::new("C:/definitely-not-a-project") )); - let approved = Args { - trust_override: Some(true), + let denied = Args { + trust_override: Some(false), ..Args::default() }; - assert!(resolve_project_trust( - &approved, + assert!(!resolve_project_trust( + &denied, Path::new("C:/definitely-not-a-project") )); } @@ -2700,16 +2680,6 @@ mod tests { assert!(context.project_trusted); } - #[test] - fn project_resource_probe_ignores_session_directory_but_detects_config() { - let root = tempfile::tempdir().unwrap(); - let cwd = root.path(); - std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap(); - assert!(!project_has_local_resources(cwd)); - std::fs::write(cwd.join(".rpi/settings.json"), "{}").unwrap(); - assert!(project_has_local_resources(cwd)); - } - #[test] fn select_latest_for_continue_and_resume() { let args = Args { diff --git a/crates/pi-cli/src/updates.rs b/crates/pi-cli/src/updates.rs index bf916d4..7bdef83 100644 --- a/crates/pi-cli/src/updates.rs +++ b/crates/pi-cli/src/updates.rs @@ -701,7 +701,7 @@ fn report_from_cache_scope( name: "rpi".into(), current: crate::VERSION.into(), latest: latest.into(), - command: "rpi pi-update".into(), + command: "rpi self-update".into(), }); } } @@ -723,7 +723,7 @@ fn report_from_cache_scope( name: package.name.clone(), current: current.into(), latest: latest.into(), - command: "rpi update".into(), + command: "rpi pi-update".into(), }); } } @@ -750,7 +750,7 @@ fn report_from_cache_scope( name: format!("{}/{}", git.host, git.path), current: short_git_oid(¤t), latest: short_git_oid(&latest), - command: "rpi update".into(), + command: "rpi pi-update".into(), }); } } @@ -1217,15 +1217,15 @@ pub fn run_self_update(args: &[String]) -> i32 { .iter() .any(|arg| matches!(arg.as_str(), "--help" | "-h")) { - println!("Usage: rpi pi-update [--offline]\n\nUpdate the rpi CLI from crates.io."); + println!("Usage: rpi self-update [--offline]\n\nUpdate the rpi CLI from crates.io."); return 0; } if !args.is_empty() { - eprintln!("error: `rpi pi-update` does not accept arguments"); + eprintln!("error: `rpi self-update` does not accept arguments"); return 2; } if offline { - println!("rpi pi-update skipped: offline mode is enabled"); + println!("rpi self-update skipped: offline mode is enabled"); return 0; } @@ -2098,7 +2098,7 @@ fn run_windows_self_update() -> i32 { Ok(_) => { let persisted_staging = staging.directory.keep(); println!( - "rpi pi-update staged; it will be applied after this process exits\nStatus: {}", + "rpi self-update staged; it will be applied after this process exits\nStatus: {}", plan.status_file.display() ); debug_assert!(git_paths_equal(&persisted_staging, &plan.staging_dir)); @@ -2449,7 +2449,7 @@ fn consume_self_update_statuses(agent_dir: &Path) -> Vec { "The previously scheduled rpi self-update failed: {}", sanitized_self_update_status_message(&status.message) ), - command: "rpi pi-update".to_string(), + command: "rpi self-update".to_string(), }); } } @@ -2814,7 +2814,7 @@ mod tests { .message .contains("self-update failed: permission [31m denied")); assert!(!warnings[0].message.chars().any(char::is_control)); - assert_eq!(warnings[0].command, "rpi pi-update"); + assert_eq!(warnings[0].command, "rpi self-update"); assert!(!failed.exists()); assert!(!succeeded.exists()); assert!(waiting.exists()); diff --git a/crates/pi-harness/src/agent_harness.rs b/crates/pi-harness/src/agent_harness.rs index 6a7cee2..9793a96 100644 --- a/crates/pi-harness/src/agent_harness.rs +++ b/crates/pi-harness/src/agent_harness.rs @@ -1468,11 +1468,11 @@ impl AgentHarness { thinking_level: snap.thinking_level, api_key: None, timeout: snap.stream_options.timeout, - max_retries: if snap.retry.enabled { - Some(snap.retry.max_retries) - } else { - None - }, + // Pi's `retry.maxRetries` is an assistant-level retry budget. The + // provider/SDK retry budget is intentionally independent and + // defaults to zero, so transient failures are retried by the + // harness exactly once per agent attempt. + max_retries: None, max_retry_delay: Some(std::time::Duration::from_millis( snap.retry.max_agent_delay_ms, )), @@ -1503,7 +1503,43 @@ impl AgentHarness { // therefore persist ALL of them (skip 0). The old `skip(prompts_len)` // was a leftover from a design that passed `prompts` in; it wrongly // dropped the first real assistant message. - let result = run_agent_loop(Vec::new(), agent_context, config, emitter, stream_fn).await; + let retry_policy = snap.retry.clone(); + let mut retry_attempt = 0u32; + let result = loop { + let attempt_result = run_agent_loop( + Vec::new(), + agent_context.clone(), + config.clone(), + Arc::clone(&emitter), + Arc::clone(&stream_fn), + ) + .await; + + let should_retry = retry_policy.enabled + && retry_attempt < retry_policy.max_retries + && attempt_result.as_ref().is_ok_and(|messages| { + messages.iter().rev().find_map(|message| match message { + AgentMessage::Assistant(assistant) => Some( + assistant.stop_reason == StopReason::Error + && crate::compaction::is_retryable_assistant_error(assistant), + ), + _ => None, + }) == Some(true) + }); + if !should_retry { + break attempt_result; + } + + retry_attempt += 1; + let delay_ms = retry_policy + .base_delay_ms + .saturating_mul(1u64 << retry_attempt.saturating_sub(1)) + .min(retry_policy.max_agent_delay_ms); + tokio::select! { + _ = tokio::time::sleep(std::time::Duration::from_millis(delay_ms)) => {} + _ = signal.cancelled() => break attempt_result, + } + }; // Persist new messages + derive outcome. let (leaf_id, _final_entry_id, outcome, op_outcome, op_error) = match result { diff --git a/crates/pi-harness/src/compaction/compaction.rs b/crates/pi-harness/src/compaction/compaction.rs index a8854bd..58ac3b1 100644 --- a/crates/pi-harness/src/compaction/compaction.rs +++ b/crates/pi-harness/src/compaction/compaction.rs @@ -497,7 +497,7 @@ where /// Conservative substring port of `isRetryableAssistantError`. Returns false /// for quota/billing errors (non-retryable) and true only for clearly-transient /// transport/server text. See module doc for the divergence note. -fn is_retryable_assistant_error(message: &AssistantMessage) -> bool { +pub fn is_retryable_assistant_error(message: &AssistantMessage) -> bool { if message.stop_reason != StopReason::Error { return false; } diff --git a/crates/pi-harness/src/compaction/mod.rs b/crates/pi-harness/src/compaction/mod.rs index 9fe7687..c81d7a6 100644 --- a/crates/pi-harness/src/compaction/mod.rs +++ b/crates/pi-harness/src/compaction/mod.rs @@ -27,8 +27,8 @@ pub mod tokens; pub use compaction::{ combine_usage, compact, complete_simple_with_retries, extract_file_operations, generate_summary_with_usage, generate_turn_prefix_summary, get_message_from_entry, - get_message_from_entry_for_compaction, prepare_compaction, CompactResult, CompactionDetails, - CompactionError, CompactionLlmOptions, CompactionPreparation, + get_message_from_entry_for_compaction, is_retryable_assistant_error, prepare_compaction, + CompactResult, CompactionDetails, CompactionError, CompactionLlmOptions, CompactionPreparation, }; pub use cut_point::{find_cut_point, find_turn_start_index, find_valid_cut_points, CutPointResult}; pub use settings::{should_compact, CompactionSettings, DEFAULT_COMPACTION_SETTINGS}; diff --git a/crates/pi-harness/src/types.rs b/crates/pi-harness/src/types.rs index 85c35c1..114618d 100644 --- a/crates/pi-harness/src/types.rs +++ b/crates/pi-harness/src/types.rs @@ -254,9 +254,12 @@ pub struct RetryPolicy { impl Default for RetryPolicy { fn default() -> Self { Self { - enabled: false, - max_retries: 0, - base_delay_ms: 1000, + // Pi retries transient assistant failures by default. Provider + // SDK retries remain disabled separately in the agent stream + // options so one failure is not retried twice. + enabled: true, + max_retries: 3, + base_delay_ms: 2000, max_agent_delay_ms: 60_000, } } diff --git a/docs/user-guide.md b/docs/user-guide.md index 8c9331a..3a034a1 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -145,8 +145,9 @@ rpi install rpi install-pi rpi uninstall rpi uninstall-pi -rpi update # 更新 Rust/npm package -rpi pi-update # 更新 rpi CLI 自身 +rpi update # 只更新 Rust 原生扩展 +rpi pi-update # 只更新 Pi npm/Git package +rpi self-update # 更新 rpi CLI 自身 ``` ## 4. 内置工具 @@ -249,12 +250,12 @@ rpi uninstall pi npm:@scope/package rpi package add ../my-pi-package rpi package list rpi package remove ../my-pi-package -rpi package update +rpi package update # 兼容入口:同时更新 Rust 原生扩展和 Pi package ``` npm 和 Git 安装使用与原生 Pi 一致的托管布局:项目范围分别写入 `.pi/npm`、`.pi/git`,`--global` 则写入当前 rpi agent 配置目录下的 `npm`、`git`。本地目录只记录到 settings,不会复制,也不会在卸载时删除。旧版 `.rpi/packages`、`.pi/packages` 以及 `~/.pi/agent` 下的原生 Pi 安装仍可发现和迁移。 -`npmCommand` 是 argv 数组,不是 shell 字符串;依次选择已信任项目的 `.rpi/settings.json`、`.pi/settings.json`、全局 `settings.json`,都未配置时使用 npm。rpi 会按识别到的 npm、pnpm 或 bun 生成与原生 Pi 一致的 install/uninstall 参数。未信任项目的 settings 和 package 路径不会参与解析;无法安全验证的路径、来源或 manifest 会直接拒绝。Node.js 是运行 JS/TS extension 的必要条件。普通 rpi 命令不会加载这些 package,需显式传 `--enable-pi-packages`。 +`npmCommand` 是 argv 数组,不是 shell 字符串;依次选择项目 `.rpi/settings.json`、`.pi/settings.json`、全局 `settings.json`,都未配置时使用 npm。项目资源默认直接加载,不会弹出确认;需要临时禁用时使用 `--no-approve`,或在 TUI 中执行 `/trust no`。rpi 会按识别到的 npm、pnpm 或 bun 生成与原生 Pi 一致的 install/uninstall 参数。无法安全验证的路径、来源或 manifest 会直接拒绝。Node.js 是运行 JS/TS extension 的必要条件。普通 rpi 命令不会加载这些 package,需显式传 `--enable-pi-packages`。 ### 静态资源