Skip to content

Commit 805caf7

Browse files
authored
Merge pull request #1741 from gitbluf/develop
feat(hook): add pi support
2 parents 26c8890 + f6a5451 commit 805caf7

8 files changed

Lines changed: 685 additions & 41 deletions

File tree

docs/guide/getting-started/supported-agents.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Agent runs "cargo test"
3535
| Gemini CLI | Rust binary (`BeforeTool`) | Yes |
3636
| OpenCode | TypeScript plugin (`tool.execute.before`) | Yes |
3737
| OpenClaw | TypeScript plugin (`before_tool_call`) | Yes |
38+
| Pi | TypeScript extension (`tool_call` event) | Yes |
3839
| Hermes | Python plugin (`terminal` command mutation) | Yes |
3940
| Cline / Roo Code | Rules file (prompt-level) | N/A |
4041
| Windsurf | Rules file (prompt-level) | N/A |
@@ -85,6 +86,27 @@ rtk init --global --opencode
8586

8687
Creates `~/.config/opencode/plugins/rtk.ts`. Uses the `tool.execute.before` hook.
8788

89+
### Pi
90+
91+
```bash
92+
# Project-local (default)
93+
rtk init --agent pi
94+
95+
# Global — all projects
96+
rtk init --agent pi --global
97+
```
98+
99+
Creates `.pi/extensions/rtk.ts` (local) or `~/.pi/agent/extensions/rtk.ts` (global). Pi auto-discovers extensions from both paths on startup.
100+
101+
Uninstall:
102+
103+
```bash
104+
rtk init --uninstall --agent pi
105+
rtk init --uninstall --agent pi --global
106+
```
107+
108+
Removes only the installed Pi extension file.
109+
88110
### OpenClaw
89111

90112
```bash
@@ -151,7 +173,7 @@ Support is blocked on upstream `BeforeToolCallback` ([mistral-vibe#531](https://
151173
| **Plugin** | TypeScript, JavaScript, or Python in agent's plugin system | Transparent, in-place mutation when the agent allows it |
152174
| **Rules file** | Prompt-level instructions | Guidance only — agent is told to prefer `rtk <cmd>` |
153175

154-
Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it.
176+
Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it. Plugin integrations (OpenCode, Pi) use in-place mutation via the agent's TypeScript extension API.
155177

156178
## Windows support
157179

hooks/README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
**Deployed hook artifacts** — the actual files installed on user machines by `rtk init`. These are shell scripts, TypeScript plugins, and rules files that run outside the Rust binary. They are **thin delegates**: parse agent-specific JSON, call `rtk rewrite` as a subprocess, format agent-specific response. Zero filtering logic lives here.
66

7-
Owns: per-agent hook scripts and configuration files for 8 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes).
7+
Owns: per-agent hook scripts and configuration files for 9 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes, Pi).
88

99
Does **not** own: hook installation/uninstallation (that's `src/hooks/init.rs`), the rewrite pattern registry (that's `discover/registry`), or integrity verification (that's `src/hooks/integrity.rs`).
1010

@@ -40,6 +40,7 @@ Each agent subdirectory has its own README with hook-specific details:
4040
- **[`windsurf/`](windsurf/README.md)** — Rules file (prompt-level), `.windsurfrules` workspace-scoped
4141
- **[`codex/`](codex/README.md)** — Awareness document, `AGENTS.md` integration, `$CODEX_HOME` or `~/.codex/` location
4242
- **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation
43+
- **[`pi/`](pi/README.md)** — TypeScript extension, `tool_call` event, `isToolCallEventType` guard, in-place mutation, `~/.pi/agent/extensions/`
4344
- **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation
4445

4546
## Supported Agents
@@ -55,13 +56,15 @@ Each agent subdirectory has its own README with hook-specific details:
5556
| Windsurf | Custom instructions (rules file) | Prompt-level guidance | N/A |
5657
| Codex CLI | AGENTS.md / instructions | Prompt-level guidance | N/A |
5758
| OpenCode | TypeScript plugin (`tool.execute.before`) | In-place mutation | Yes |
59+
| Pi | TypeScript extension (`tool_call` event) | In-place mutation | Yes |
5860
| Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes |
5961

6062
## JSON Formats by Agent
6163

6264
### Claude Code (Shell Hook)
6365

6466
**Input** (stdin):
67+
6568
```json
6669
{
6770
"tool_name": "Bash",
@@ -70,6 +73,7 @@ Each agent subdirectory has its own README with hook-specific details:
7073
```
7174

7275
**Output** (stdout, when rewritten):
76+
7377
```json
7478
{
7579
"hookSpecificOutput": {
@@ -86,6 +90,7 @@ Each agent subdirectory has its own README with hook-specific details:
8690
**Input**: Same as Claude Code.
8791

8892
**Output** (stdout, when rewritten):
93+
8994
```json
9095
{
9196
"permission": "allow",
@@ -98,6 +103,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).
98103
### Copilot CLI (Rust Binary)
99104

100105
**Input** (stdin, camelCase, `toolArgs` is JSON-stringified):
106+
101107
```json
102108
{
103109
"toolName": "bash",
@@ -106,6 +112,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).
106112
```
107113

108114
**Output** (no `updatedInput` support -- uses deny-with-suggestion):
115+
109116
```json
110117
{
111118
"permissionDecision": "deny",
@@ -116,6 +123,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).
116123
### VS Code Copilot Chat (Rust Binary)
117124

118125
**Input** (stdin, snake_case):
126+
119127
```json
120128
{
121129
"tool_name": "Bash",
@@ -128,6 +136,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).
128136
### Gemini CLI (Rust Binary)
129137

130138
**Input** (stdin):
139+
131140
```json
132141
{
133142
"tool_name": "run_shell_command",
@@ -136,6 +145,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).
136145
```
137146

138147
**Output** (when rewritten):
148+
139149
```json
140150
{
141151
"decision": "allow",
@@ -150,6 +160,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).
150160
### OpenCode (TypeScript Plugin)
151161

152162
Mutates `args.command` in-place via the zx library:
163+
153164
```typescript
154165
const result = await $`rtk rewrite ${command}`.quiet().nothrow()
155166
const rewritten = String(result.stdout).trim()
@@ -230,7 +241,7 @@ New integrations must follow the [Exit Code Contract](#exit-code-contract) and [
230241
| Tier | Mechanism | Maintenance | Examples |
231242
|------|-----------|-------------|----------|
232243
| **Full hook** | Shell script or Rust binary, intercepts commands via agent's hook API | High — must track agent API changes | Claude Code, Cursor, Copilot, Gemini |
233-
| **Plugin** | TypeScript/JS/Python plugin in agent's plugin system | Medium — agent manages loading | OpenCode, Hermes |
244+
| **Plugin** | TypeScript/JS/Python plugin in agent's plugin system | Medium — agent manages loading | OpenCode, Hermes, Pi |
234245
| **Rules file** | Prompt-level instructions the agent reads | Low — no code to break | Cline, Windsurf, Codex |
235246

236247
### Eligibility

hooks/pi/README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Pi Hooks
2+
3+
> Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code
4+
5+
## Design Intent
6+
7+
RTK's Pi extension is a **rewrite-only token optimizer**. It mutates bash commands to their
8+
`rtk`-prefixed equivalents, saving 60–90% context tokens.
9+
10+
**Permission gating is intentionally out of scope.** RTK does not block, confirm, or audit
11+
commands — that concern belongs to a dedicated permission extension (e.g. one that gates
12+
`rm -rf`, `sudo`, etc.). This separation keeps RTK's hook fast, predictable, and composable
13+
with other Pi extensions.
14+
15+
## Specifics
16+
17+
- TypeScript extension using Pi's `ExtensionAPI` (not a shell hook, no `zx` dependency)
18+
- Subscribes to `tool_call` event, narrows to `bash` tool via `isToolCallEventType`
19+
- Calls `rtk rewrite` via `pi.exec`; mutates `event.input.command` in-place if rewrite differs
20+
- All error paths return `undefined` (pass through); RTK never blocks execution
21+
- Version guard at load time: checks `rtk >= 0.23.0`; warns and registers no-op if too old or missing
22+
- Installed to `.pi/extensions/rtk.ts` by `rtk init --agent pi` (project-local) or `~/.pi/agent/extensions/rtk.ts` by `rtk init --agent pi --global`
23+
24+
## Uninstall
25+
26+
```bash
27+
# Remove project-local install (run from the project root)
28+
rtk init --uninstall --agent pi
29+
# → removes .pi/extensions/rtk.ts
30+
31+
# Remove global install
32+
rtk init --uninstall --agent pi --global
33+
# → removes ~/.pi/agent/extensions/rtk.ts
34+
```
35+
36+
Uninstall is idempotent — re-running when nothing is installed is a no-op.
37+
Only the extension file is managed by install/uninstall.
38+
39+
## Testing
40+
41+
```bash
42+
# Load the extension directly without installing
43+
pi -e ./hooks/pi/rtk.ts
44+
45+
# Verify rewrites are active — ask the agent to run a command, then check history
46+
rtk gain --history # should show rtk-prefixed commands with savings %
47+
48+
# Test RTK_DISABLED passthrough
49+
RTK_DISABLED=1 pi -e ./hooks/pi/rtk.ts
50+
# → commands pass through unchanged; no rewrites in rtk gain --history
51+
52+
# Test version guard — temporarily shadow rtk with a stub that prints "rtk 0.22.0"
53+
# → extension logs a warning at startup and registers a no-op; pi starts normally
54+
```
55+
56+
## Design Notes
57+
58+
- All filtering logic lives in `rtk rewrite` (the Rust registry), not in this file
59+
- Exit codes 0 and 3 both mean "rewrite and allow"; they are handled identically
60+
- Uses `pi.exec` for subprocess management — consistent with Pi's extension API

hooks/pi/rtk.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// RTK Pi extension — rewrites bash commands to use rtk for token savings.
2+
// Requires: rtk >= 0.23.0 in PATH.
3+
//
4+
// This is a thin delegating extension: all rewrite logic lives in `rtk rewrite`,
5+
// which is the single source of truth (src/discover/registry.rs).
6+
// To add or change rewrite rules, edit the Rust registry — not this file.
7+
//
8+
// Exit code contract for `rtk rewrite`:
9+
// 0 + stdout Rewrite found → mutate command
10+
// 1 No RTK equivalent → pass through unchanged
11+
// 3 + stdout Rewrite (advisory) → mutate command
12+
13+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
14+
import { isToolCallEventType } from "@earendil-works/pi-coding-agent"
15+
16+
const REWRITE_TIMEOUT_MS = 2_000
17+
const MIN_SUPPORTED_RTK_MINOR = 23
18+
19+
// Parse "X.Y.Z" semver, return [major, minor, patch] or null.
20+
function parseSemver(raw: string): [number, number, number] | null {
21+
const m = raw.trim().match(/(\d+)\.(\d+)\.(\d+)/)
22+
if (!m) return null
23+
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
24+
}
25+
26+
// Calls `rtk rewrite`; returns the rewritten command or null (pass through).
27+
async function rewriteCommand(
28+
pi: ExtensionAPI,
29+
cmd: string,
30+
signal?: AbortSignal
31+
): Promise<string | null> {
32+
const result = await pi.exec("rtk", ["rewrite", cmd], {
33+
timeout: REWRITE_TIMEOUT_MS,
34+
signal,
35+
})
36+
if (result.killed) return null
37+
if (result.code !== 0 && result.code !== 3) return null
38+
return result.stdout.trim() || null
39+
}
40+
41+
export default async function (pi: ExtensionAPI) {
42+
// Probe rtk version at load time; disables extension if missing or too old.
43+
const ver = await pi.exec("rtk", ["--version"], { timeout: REWRITE_TIMEOUT_MS })
44+
if (ver.code !== 0) {
45+
console.warn("[rtk] rtk binary not found in PATH — extension disabled")
46+
return
47+
}
48+
49+
// Warn and bail if rtk predates 0.23.0 (when `rtk rewrite` was introduced).
50+
const parsed = parseSemver(ver.stdout.replace(/^rtk\s+/, ""))
51+
if (parsed) {
52+
const [major, minor] = parsed
53+
if (major === 0 && minor < MIN_SUPPORTED_RTK_MINOR) {
54+
console.warn(`[rtk] rtk ${ver.stdout.trim()} is too old (need >= 0.23.0) — extension disabled`)
55+
return
56+
}
57+
}
58+
59+
pi.on("tool_call", async (event, ctx) => {
60+
try {
61+
if (!isToolCallEventType("bash", event)) return
62+
63+
const cmd = event.input.command
64+
if (typeof cmd !== "string" || cmd.trim() === "") return
65+
66+
if (cmd.startsWith("rtk ")) return
67+
if (process.env.RTK_DISABLED === "1") return
68+
69+
// Delegate to RTK.
70+
const rewritten = await rewriteCommand(pi, cmd, ctx.signal)
71+
if (rewritten && rewritten !== cmd) {
72+
event.input.command = rewritten
73+
}
74+
} catch (err) {
75+
// Fail open: never block execution on an unexpected error.
76+
console.warn("[rtk] unexpected error in tool_call handler; passing through command", err)
77+
return
78+
}
79+
})
80+
}

src/hooks/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
The **lifecycle management** layer for LLM agent hooks: install, uninstall, verify integrity, audit usage, and manage trust. This component creates and maintains the hook artifacts that live in `hooks/` (root), but does **not** execute rewrite logic itself — that lives in `discover/registry`.
88

9-
Owns: `rtk init` installation flows (4 agents via `AgentTarget` enum + 3 special modes: Gemini, Codex, OpenCode), SHA-256 integrity verification, hook version checking, audit log analysis, `rtk rewrite` CLI entry point, and TOML filter trust management.
9+
Owns: `rtk init` installation flows (5 agents via `AgentTarget` enum + 3 special modes: Gemini, Codex, OpenCode), SHA-256 integrity verification, hook version checking, audit log analysis, `rtk rewrite` CLI entry point, and TOML filter trust management.
1010

1111
Does **not** own: the deployed hook scripts themselves (that's `hooks/`), the rewrite pattern registry (that's `discover/`), or command filtering (that's `cmds/`).
1212

@@ -22,14 +22,15 @@ LLM agent integration layer that installs, validates, and executes command-rewri
2222
`rtk init` supports these installation flows:
2323

2424
| Mode | Command | Creates | Patches |
25-
|------|---------|---------|---------|
25+
|------|---------|---------|----------|
2626
| Default (global) | `rtk init -g` | Hook, SHA-256 hash, RTK.md | settings.json, CLAUDE.md |
2727
| Hook only | `rtk init -g --hook-only` | Hook, SHA-256 hash | settings.json |
2828
| Claude-MD (legacy) | `rtk init --claude-md` | 134-line RTK block | CLAUDE.md |
2929
| Windsurf | `rtk init -g --agent windsurf` | `.windsurfrules` | -- |
3030
| Cline | `rtk init --agent cline` | `.clinerules` | -- |
3131
| Codex | `rtk init --codex` | RTK.md in `$CODEX_HOME` or `~/.codex` | AGENTS.md |
3232
| Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json |
33+
| Pi | `rtk init --agent pi` | `.pi/extensions/rtk.ts` | -- |
3334
| Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` |
3435

3536

src/hooks/constants.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ pub const OPENCODE_PLUGIN_FILE: &str = "rtk.ts";
2121
pub const CURSOR_DIR: &str = ".cursor";
2222
pub const CODEX_DIR: &str = ".codex";
2323
pub const GEMINI_DIR: &str = ".gemini";
24+
25+
pub const PI_DIR: &str = ".pi/agent";
26+
pub const PI_LOCAL_DIR: &str = ".pi";
27+
pub const PI_EXTENSIONS_SUBDIR: &str = "extensions";
28+
pub const PI_PLUGIN_FILE: &str = "rtk.ts";
29+
pub const PI_CODING_AGENT_DIR_ENV: &str = "PI_CODING_AGENT_DIR";
30+
2431
pub const HERMES_DIR: &str = ".hermes";
2532
pub const HERMES_PLUGINS_SUBDIR: &str = "plugins";
2633
pub const HERMES_PLUGIN_NAME: &str = "rtk-rewrite";

0 commit comments

Comments
 (0)