Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 104 additions & 4 deletions crates/terraphim_agent/src/learnings/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand All @@ -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)]
Expand Down Expand Up @@ -506,8 +512,58 @@ 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()
Expand Down Expand Up @@ -613,6 +669,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 {
Expand Down
71 changes: 69 additions & 2 deletions crates/terraphim_agent/src/learnings/install.rs
Original file line number Diff line number Diff line change
@@ -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
//!
Expand All @@ -25,6 +25,8 @@ pub enum AgentType {
Codex,
/// Opencode CLI
Opencode,
/// pi coding-agent (pi_agent_rust)
Pi,
}

impl AgentType {
Expand All @@ -34,6 +36,7 @@ impl AgentType {
AgentType::Claude => "claude",
AgentType::Codex => "codex",
AgentType::Opencode => "opencode",
AgentType::Pi => "pi",
}
}

Expand All @@ -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")),
}
}

Expand Down Expand Up @@ -113,14 +118,39 @@ 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 <repo>/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(),
}
}

/// Get the hook file path for this agent.
pub fn hook_path(&self) -> Option<PathBuf> {
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")),
}
}
}

Expand Down Expand Up @@ -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 <path-to-terraphim-clients>/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.)");
Expand Down Expand Up @@ -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)),
]
}

Expand All @@ -300,13 +337,16 @@ 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]
fn test_agent_type_variants_distinct() {
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]
Expand All @@ -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]
Expand All @@ -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;
Expand Down
3 changes: 1 addition & 2 deletions crates/terraphim_update/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"));
Expand Down
57 changes: 57 additions & 0 deletions docs/plans/design-pi-terraphim-learn-2026-08-08.md
Original file line number Diff line number Diff line change
@@ -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 <path>`
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
36 changes: 36 additions & 0 deletions packages/pi-terraphim-learn/README.md
Original file line number Diff line number Diff line change
@@ -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`
Loading
Loading