From 54282ca6b79a41850201b097420c9c769a1bea9f Mon Sep 17 00:00:00 2001 From: AlexMikhalev Date: Sat, 8 Aug 2026 18:29:16 +0100 Subject: [PATCH 1/5] fix(learnings): accept Claude tool_response/exitCode envelopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Claude Code PostToolUse sends tool_response + exitCode, not the fixture tool_result + exit_code shape — learn hook fail-opened with zero captures (#2704 / multi-client plan C2). - serde alias tool_response → tool_result, exitCode → exit_code - Auto format: normalize bash→Bash; legacy {tool,result} from #2704 - Unit tests for live Claude, lowercase bash, legacy sample Host Phase 0 (separate): PATH agent 1.21.1, Claude post→learn hook, OpenCode terraphim-learn plugin. --- crates/terraphim_agent/src/learnings/hook.rs | 111 ++++++++++++++++++- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/crates/terraphim_agent/src/learnings/hook.rs b/crates/terraphim_agent/src/learnings/hook.rs index 5f70454..fe7516e 100644 --- a/crates/terraphim_agent/src/learnings/hook.rs +++ b/crates/terraphim_agent/src/learnings/hook.rs @@ -344,7 +344,11 @@ pub struct HookInput { pub tool_name: String, /// Tool input parameters pub tool_input: ToolInput, - /// Tool execution result + /// Tool execution result. + /// + /// Claude Code live PostToolUse often sends `tool_response` instead of + /// `tool_result` — accept both. + #[serde(alias = "tool_response")] pub tool_result: ToolResult, } @@ -368,7 +372,9 @@ pub struct ToolInput { #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct ToolResult { - /// Exit code (0 = success, non-zero = failure) + /// Exit code (0 = success, non-zero = failure). + /// Claude live payloads use camelCase `exitCode`. + #[serde(alias = "exitCode")] pub exit_code: i32, /// Standard output captured from the tool #[serde(default)] @@ -506,8 +512,61 @@ impl HookInput { let value: serde_json::Value = serde_json::from_str(json)?; // Claude / Codex / opencode-normalised: canonical tool event. - if value.get("tool_name").is_some() && value.get("tool_result").is_some() { - return serde_json::from_str(json); + // Live Claude may use `tool_response` instead of `tool_result`. + if value.get("tool_name").is_some() + && (value.get("tool_result").is_some() || value.get("tool_response").is_some()) + { + return serde_json::from_str(json).map(|mut input: HookInput| { + // Normalize tool name so should_capture matches. + if input.tool_name.eq_ignore_ascii_case("bash") { + input.tool_name = "Bash".to_string(); + } + input + }); + } + // Legacy / minimal: { "tool": "Bash", "result": { "exit_code": 1 } } (#2704 sample) + if value.get("tool").is_some() && value.get("result").is_some() { + let tool = value + .get("tool") + .and_then(|v| v.as_str()) + .unwrap_or("Bash"); + let result = value.get("result").cloned().unwrap_or_default(); + let exit = result + .get("exit_code") + .or_else(|| result.get("exitCode")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + let cmd = value + .get("tool_input") + .and_then(|t| t.get("command")) + .and_then(|c| c.as_str()) + .or_else(|| value.get("command").and_then(|c| c.as_str())) + .map(|s| s.to_string()); + let tool_name = if tool.eq_ignore_ascii_case("bash") { + "Bash".to_string() + } else { + tool.to_string() + }; + return Ok(HookInput { + tool_name, + tool_input: ToolInput { + command: cmd, + extra: HashMap::new(), + }, + tool_result: ToolResult { + exit_code: exit, + stdout: result + .get("stdout") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + stderr: result + .get("stderr") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }, + }); } // opencode native: `tool` + (`args` | `output`), no `tool_name`. if value.get("tool").is_some() @@ -613,6 +672,50 @@ mod tests { assert_eq!(input.tool_result.stderr, "rejected"); } + #[test] + fn test_hook_input_claude_tool_response_exit_code_alias() { + // Live Claude Code PostToolUse shape (2026-08 investigation) + let json = r#"{ + "tool_name": "Bash", + "tool_input": {"command": "ls /nope-live"}, + "tool_response": { + "exitCode": 2, + "stdout": "", + "stderr": "No such file", + "interrupted": false, + "isImage": false + } + }"#; + let input = HookInput::from_json_with_format(json, AgentFormat::Claude).unwrap(); + assert_eq!(input.tool_name, "Bash"); + assert_eq!(input.command(), Some("ls /nope-live")); + assert_eq!(input.tool_result.exit_code, 2); + assert_eq!(input.tool_result.stderr, "No such file"); + assert!(input.should_capture()); + } + + #[test] + fn test_hook_input_auto_normalizes_lowercase_bash() { + let json = r#"{ + "tool_name": "bash", + "tool_input": {"command": "false"}, + "tool_result": {"exit_code": 1, "stdout": "", "stderr": "x"} + }"#; + let input = HookInput::from_json_with_format(json, AgentFormat::Auto).unwrap(); + assert_eq!(input.tool_name, "Bash"); + assert!(input.should_capture()); + } + + #[test] + fn test_hook_input_legacy_2704_tool_result_object() { + let json = r#"{"tool":"Bash","command":"false","result":{"exit_code":1,"stderr":"fail"}}"#; + let input = HookInput::from_json_with_format(json, AgentFormat::Auto).unwrap(); + assert_eq!(input.tool_name, "Bash"); + assert_eq!(input.command(), Some("false")); + assert_eq!(input.tool_result.exit_code, 1); + assert!(input.should_capture()); + } + #[test] fn test_should_capture_failed_bash() { let input = HookInput { From 28d53981e4735398e40006e04a6903f9e0b9d7fd Mon Sep 17 00:00:00 2001 From: AlexMikhalev Date: Sat, 8 Aug 2026 18:45:20 +0100 Subject: [PATCH 2/5] style(learnings): cargo fmt hook.rs for CI --- crates/terraphim_agent/src/learnings/hook.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/terraphim_agent/src/learnings/hook.rs b/crates/terraphim_agent/src/learnings/hook.rs index fe7516e..0a39442 100644 --- a/crates/terraphim_agent/src/learnings/hook.rs +++ b/crates/terraphim_agent/src/learnings/hook.rs @@ -526,10 +526,7 @@ impl HookInput { } // Legacy / minimal: { "tool": "Bash", "result": { "exit_code": 1 } } (#2704 sample) if value.get("tool").is_some() && value.get("result").is_some() { - let tool = value - .get("tool") - .and_then(|v| v.as_str()) - .unwrap_or("Bash"); + let tool = value.get("tool").and_then(|v| v.as_str()).unwrap_or("Bash"); let result = value.get("result").cloned().unwrap_or_default(); let exit = result .get("exit_code") From 897f0579aa3c03c4ab7a83b14642285fc952a9f9 Mon Sep 17 00:00:00 2001 From: AlexMikhalev Date: Sat, 8 Aug 2026 18:49:41 +0100 Subject: [PATCH 3/5] feat(learnings): pi-rust learn hooks (AgentType::Pi + package) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 multi-client plan (disciplined design + TDD): - AgentType::Pi, ~/.pi/agent config, install-hook docs - packages/pi-terraphim-learn: onToolResult → learn hook (fail-open) - design gate docs/plans/design-pi-terraphim-learn-2026-08-08.md - install unit tests RED→GREEN (7 ok) C2 Claude envelopes already on main (PR #90). --- .../terraphim_agent/src/learnings/install.rs | 71 +++++++++++- .../design-pi-terraphim-learn-2026-08-08.md | 57 +++++++++ packages/pi-terraphim-learn/README.md | 36 ++++++ packages/pi-terraphim-learn/index.js | 109 ++++++++++++++++++ packages/pi-terraphim-learn/package.json | 12 ++ 5 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 docs/plans/design-pi-terraphim-learn-2026-08-08.md create mode 100644 packages/pi-terraphim-learn/README.md create mode 100644 packages/pi-terraphim-learn/index.js create mode 100644 packages/pi-terraphim-learn/package.json diff --git a/crates/terraphim_agent/src/learnings/install.rs b/crates/terraphim_agent/src/learnings/install.rs index 7f97d20..cf18230 100644 --- a/crates/terraphim_agent/src/learnings/install.rs +++ b/crates/terraphim_agent/src/learnings/install.rs @@ -1,7 +1,7 @@ //! Hook installation for AI agents. //! //! This module provides functionality to install hooks for various AI agents -//! (Claude Code, Codex, opencode) to capture failed commands as learnings. +//! (Claude Code, Codex, opencode, pi) to capture failed commands as learnings. //! //! # Usage //! @@ -25,6 +25,8 @@ pub enum AgentType { Codex, /// Opencode CLI Opencode, + /// pi coding-agent (pi_agent_rust) + Pi, } impl AgentType { @@ -34,6 +36,7 @@ impl AgentType { AgentType::Claude => "claude", AgentType::Codex => "codex", AgentType::Opencode => "opencode", + AgentType::Pi => "pi", } } @@ -43,6 +46,8 @@ impl AgentType { AgentType::Claude => dirs::config_dir().map(|d| d.join("claude")), AgentType::Codex => dirs::config_dir().map(|d| d.join("codex")), AgentType::Opencode => dirs::config_dir().map(|d| d.join("opencode")), + // pi_agent_rust uses ~/.pi/agent for settings and packages + AgentType::Pi => dirs::home_dir().map(|d| d.join(".pi").join("agent")), } } @@ -113,6 +118,26 @@ else # terraphim-agent not installed, pass through unchanged cat fi +"# + .to_string(), + // Pi uses JS extensions (`pi install`), not a shell CLAUDE_HOOK path. + // This "script" is install documentation + a smoke helper that pipes + // a normalized learn envelope (same as onToolResult handler). + AgentType::Pi => r#"#!/bin/bash +# Terraphim learn hooks for pi (pi_agent_rust) +# Preferred install: +# pi install /packages/pi-terraphim-learn +# Extension listens for onToolResult and calls: +# terraphim-agent learn hook --format claude --learn-hook-type post-tool-use +# +# Smoke (stdin JSON → learn capture), fail-open: +if command -v terraphim-agent >/dev/null 2>&1; then + INPUT=$(cat) + echo "$INPUT" | terraphim-agent learn hook --format claude --learn-hook-type post-tool-use 2>/dev/null || true + echo "$INPUT" +else + cat +fi "# .to_string(), } @@ -120,7 +145,12 @@ fi /// Get the hook file path for this agent. pub fn hook_path(&self) -> Option { - self.config_dir().map(|d| d.join("terraphim-hook.sh")) + match self { + AgentType::Pi => self + .config_dir() + .map(|d| d.join("extensions").join("terraphim-learn-smoke.sh")), + _ => self.config_dir().map(|d| d.join("terraphim-hook.sh")), + } } } @@ -219,6 +249,12 @@ pub async fn install_hook(agent: AgentType) -> Result<(), InstallError> { println!(" Opencode: Set the OPCODE_HOOK environment variable:"); println!(" export OPCODE_HOOK={}", hook_path.display()); } + AgentType::Pi => { + println!(" pi (pi_agent_rust): install the JS extension package:"); + println!(" pi install /packages/pi-terraphim-learn"); + println!(" Smoke helper also written to: {}", hook_path.display()); + println!(" Extension uses pi.on(\"onToolResult\") → terraphim-agent learn hook"); + } } println!(); println!("Or add the above line to your shell profile (~/.bashrc, ~/.zshrc, etc.)"); @@ -288,6 +324,7 @@ pub fn get_installation_status() -> Vec<(AgentType, bool)> { (AgentType::Claude, is_hook_installed(AgentType::Claude)), (AgentType::Codex, is_hook_installed(AgentType::Codex)), (AgentType::Opencode, is_hook_installed(AgentType::Opencode)), + (AgentType::Pi, is_hook_installed(AgentType::Pi)), ] } @@ -300,6 +337,7 @@ mod tests { assert_eq!(AgentType::Claude.as_str(), "claude"); assert_eq!(AgentType::Codex.as_str(), "codex"); assert_eq!(AgentType::Opencode.as_str(), "opencode"); + assert_eq!(AgentType::Pi.as_str(), "pi"); } #[test] @@ -307,6 +345,8 @@ mod tests { assert_ne!(AgentType::Claude, AgentType::Codex); assert_ne!(AgentType::Claude, AgentType::Opencode); assert_ne!(AgentType::Codex, AgentType::Opencode); + assert_ne!(AgentType::Pi, AgentType::Claude); + assert_ne!(AgentType::Pi, AgentType::Opencode); } #[test] @@ -322,6 +362,24 @@ mod tests { let opencode_script = AgentType::Opencode.hook_script(); assert!(opencode_script.contains("terraphim-agent")); assert!(opencode_script.contains("learn hook")); + + let pi_script = AgentType::Pi.hook_script(); + assert!(pi_script.contains("terraphim-agent")); + assert!(pi_script.contains("learn hook") || pi_script.contains("pi install")); + assert!( + pi_script.contains("onToolResult") || pi_script.contains("pi-terraphim-learn"), + "Pi install docs must mention extension event or package name" + ); + } + + #[test] + fn test_pi_config_dir_is_under_pi_agent() { + let dir = AgentType::Pi.config_dir().expect("pi config dir"); + let s = dir.to_string_lossy(); + assert!( + s.contains(".pi") || s.ends_with("pi/agent") || s.contains("pi"), + "unexpected pi config dir: {s}" + ); } #[test] @@ -332,6 +390,15 @@ mod tests { assert!(script.contains("cat")); } + #[test] + fn test_get_installation_status_includes_pi() { + let status = get_installation_status(); + assert!( + status.iter().any(|(a, _)| *a == AgentType::Pi), + "get_installation_status must include Pi" + ); + } + #[test] fn test_install_error_display() { let err = InstallError::ConfigNotFound; diff --git a/docs/plans/design-pi-terraphim-learn-2026-08-08.md b/docs/plans/design-pi-terraphim-learn-2026-08-08.md new file mode 100644 index 0000000..81c21a9 --- /dev/null +++ b/docs/plans/design-pi-terraphim-learn-2026-08-08.md @@ -0,0 +1,57 @@ +# Design Gate — pi-rust learn hooks (Phase 2 multi-client) + +**Date:** 2026-08-08 +**Issue/plan:** `2026-08-08-learn-hooks-multi-client.md` Phase 2 +**Repo:** terraphim-clients (+ installable package for `pi install`) + +## Problem +pi (pi_agent_rust) has no Terraphim learn/replace/guard wiring. Claude and OpenCode are Phase 0 done; pi is the gap. + +## Decision +**Approach A:** JS extension package (not Rust fork interceptor). + +### Touchpoints +1. **NEW** `packages/pi-terraphim-learn/` + - `index.js` — `export default function (pi) { pi.on("onToolResult", ...); }` + - Optional: message/input event for user-prompt-submit if available + - `package.json` / README for `pi install ` +2. **EDIT** `crates/terraphim_agent/src/learnings/install.rs` + - `AgentType::Pi` + - `hook_script()` documents install path (pi uses packages, not shell in ~/.claude) + - `config_dir()` → `~/.pi/agent` +3. **EDIT** `AgentFormat` only if needed — prefer normalize to Claude/opencode envelope in the extension and call `learn hook --format auto` + +### Event contract (from pi docs/ext-compat.md) +- `pi.on("onToolResult", async (event) => { ... })` after tool runs +- Host tools: `pi.tool("bash", …)` / built-in bash +- Extension must **fail-open** if `terraphim-agent` missing +- Prefer `pi.exec` only for spawning agent CLI with stdin JSON + +### Envelope mapping (in extension) +```js +// after onToolResult — shape may vary; normalize defensively +{ + tool_name: "Bash", + tool_input: { command }, + tool_result: { exit_code, stdout, stderr } +} +→ terraphim-agent learn hook --format claude --learn-hook-type post-tool-use +``` + +Pre-tool: if pi exposes before-tool event, mirror OpenCode before (guard/replace/learn-pre). If only onToolResult, ship **post-only** first (capture), document pre as follow-up. + +### Acceptance +1. `AgentType::Pi` in install enum + tests +2. Package loads: `pi doctor packages/pi-terraphim-learn` (or install) without hard fail +3. Documented smoke: failed bash → learning file when agent on PATH +4. No secrets in logs; fail-open + +### Out of scope +- Correction→KG compile (#810 P3) +- Hard-block guard on pi (advisory only v1) +- Merging into pi_agent_rust upstream + +### Test plan +- Unit: install.rs Pi variant +- Manual/script: pipe synthetic onToolResult-equivalent JSON through agent +- `pi doctor` on package path if available diff --git a/packages/pi-terraphim-learn/README.md b/packages/pi-terraphim-learn/README.md new file mode 100644 index 0000000..c1d65a4 --- /dev/null +++ b/packages/pi-terraphim-learn/README.md @@ -0,0 +1,36 @@ +# pi-terraphim-learn + +Terraphim **learn capture** extension for [pi](https://github.com/terraphim/pi_agent_rust) (`pi_agent_rust`). + +## Install + +```bash +# requires terraphim-agent >= 1.21.0 on PATH +pi install /path/to/terraphim-clients/packages/pi-terraphim-learn +# or from a checkout: +pi install ~/projects/terraphim-clients/packages/pi-terraphim-learn +``` + +Acknowledge extension trust if `pi doctor` prompts. + +## Behaviour + +| Event | Action | +|-------|--------| +| `onToolResult` | If bash-like command failed → `terraphim-agent learn hook --format claude --learn-hook-type post-tool-use` | + +Fail-open: missing agent, parse errors, or timeouts never block pi. + +## Smoke without pi + +```bash +echo '{"tool_name":"Bash","tool_input":{"command":"false"},"tool_result":{"exit_code":1,"stdout":"","stderr":"x"}}' \ + | terraphim-agent learn hook --format claude +ls -lt ~/.local/share/terraphim/learnings/ | head +``` + +## Related + +- Multi-client plan: `cto-executive-system/2026-08-08-learn-hooks-multi-client.md` +- Design: `docs/plans/design-pi-terraphim-learn-2026-08-08.md` +- CLI: `terraphim-agent learn install-hook pi` diff --git a/packages/pi-terraphim-learn/index.js b/packages/pi-terraphim-learn/index.js new file mode 100644 index 0000000..0f8a09c --- /dev/null +++ b/packages/pi-terraphim-learn/index.js @@ -0,0 +1,109 @@ +/** + * pi-terraphim-learn — Terraphim learn capture for pi (pi_agent_rust) + * + * Install: + * pi install /path/to/terraphim-clients/packages/pi-terraphim-learn + * + * Listens for onToolResult, normalizes bash failures to Claude learn envelope, + * pipes to: terraphim-agent learn hook --format claude --learn-hook-type post-tool-use + * + * Fail-open: missing agent or parse errors never block the session. + */ +import { spawn } from "node:child_process"; + +function runLearnHook(payload) { + return new Promise((resolve) => { + try { + const child = spawn( + "terraphim-agent", + ["learn", "hook", "--format", "claude", "--learn-hook-type", "post-tool-use"], + { stdio: ["pipe", "ignore", "ignore"] } + ); + child.on("error", () => resolve()); + child.on("close", () => resolve()); + child.stdin.write(JSON.stringify(payload)); + child.stdin.end(); + // Don't hang the agent forever + setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* ignore */ + } + resolve(); + }, 5000); + } catch { + resolve(); + } + }); +} + +function extractBashFailure(event) { + // Defensive: pi event shapes vary by version + const e = event || {}; + const tool = + e.toolName || e.tool_name || e.name || e.tool || e.type || ""; + const isBash = + String(tool).toLowerCase() === "bash" || + String(tool).toLowerCase() === "shell" || + String(tool).toLowerCase() === "tool_call" && + String(e.tool || e.name || "").toLowerCase() === "bash"; + + const cmd = + e.command || + e.args?.command || + e.input?.command || + e.params?.command || + e.toolInput?.command || + null; + + const exit = + e.exitCode ?? + e.exit_code ?? + e.result?.exitCode ?? + e.result?.exit_code ?? + e.metadata?.exitCode ?? + e.metadata?.exit_code ?? + (e.isError || e.error ? 1 : 0); + + const stdout = e.stdout || e.result?.stdout || e.output || ""; + const stderr = + e.stderr || e.result?.stderr || e.errorMessage || e.error || ""; + + if (!cmd) return null; + // Capture only failures; if we can't tell, skip unless stderr looks failed + const code = Number(exit) || 0; + if (code === 0 && !stderr) return null; + + // If tool name missing but command present and non-zero, still capture + if (!isBash && tool && String(tool).toLowerCase() !== "bash") { + // allow empty tool name with command + if (tool) return null; + } + + return { + tool_name: "Bash", + tool_input: { command: String(cmd) }, + tool_result: { + exit_code: code === 0 && stderr ? 1 : code, + stdout: String(stdout).slice(0, 8000), + stderr: String(stderr).slice(0, 8000), + }, + }; +} + +export default function activate(pi) { + if (!pi || typeof pi.on !== "function") { + return; + } + + pi.on("onToolResult", async (event) => { + try { + const payload = extractBashFailure(event); + if (!payload) return; + await runLearnHook(payload); + } catch { + /* fail-open */ + } + }); +} diff --git a/packages/pi-terraphim-learn/package.json b/packages/pi-terraphim-learn/package.json new file mode 100644 index 0000000..b4ddd30 --- /dev/null +++ b/packages/pi-terraphim-learn/package.json @@ -0,0 +1,12 @@ +{ + "name": "pi-terraphim-learn", + "version": "0.1.0", + "description": "Terraphim learn capture for pi_agent_rust (onToolResult → terraphim-agent learn hook)", + "type": "module", + "main": "index.js", + "license": "Apache-2.0", + "keywords": ["pi", "terraphim", "hooks", "learn"], + "engines": { + "node": ">=18" + } +} From 9bd36457ebeec4babaf25da4ceb04c4b1606f608 Mon Sep 17 00:00:00 2001 From: AlexMikhalev Date: Sat, 8 Aug 2026 18:50:14 +0100 Subject: [PATCH 4/5] fix(pi-terraphim-learn): correct isBash operator precedence --- packages/pi-terraphim-learn/index.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/pi-terraphim-learn/index.js b/packages/pi-terraphim-learn/index.js index 0f8a09c..41f55c2 100644 --- a/packages/pi-terraphim-learn/index.js +++ b/packages/pi-terraphim-learn/index.js @@ -43,11 +43,12 @@ function extractBashFailure(event) { const e = event || {}; const tool = e.toolName || e.tool_name || e.name || e.tool || e.type || ""; + const toolLower = String(tool).toLowerCase(); const isBash = - String(tool).toLowerCase() === "bash" || - String(tool).toLowerCase() === "shell" || - String(tool).toLowerCase() === "tool_call" && - String(e.tool || e.name || "").toLowerCase() === "bash"; + toolLower === "bash" || + toolLower === "shell" || + (toolLower === "tool_call" && + String(e.tool || e.name || "").toLowerCase() === "bash"); const cmd = e.command || From 499d0ec9a8dba22363461bb21e5bd443a627d486 Mon Sep 17 00:00:00 2001 From: AlexMikhalev Date: Sat, 8 Aug 2026 19:02:34 +0100 Subject: [PATCH 5/5] style: cargo fmt terraphim_update (CI fmt --all) --- crates/terraphim_update/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/terraphim_update/src/lib.rs b/crates/terraphim_update/src/lib.rs index a026cd9..5474efb 100644 --- a/crates/terraphim_update/src/lib.rs +++ b/crates/terraphim_update/src/lib.rs @@ -348,8 +348,7 @@ impl TerraphimUpdater { show_progress, ..Default::default() }; - if let Err(e) = - downloader::download_with_retry(&asset_url, &archive_path, Some(dl_cfg)) + if let Err(e) = downloader::download_with_retry(&asset_url, &archive_path, Some(dl_cfg)) { // Transport failure -> Err so the caller can fall back. return Err(anyhow!("download failed: {e}"));