Skip to main content

agent_status/commands/
agent_extension.rs

1use crate::agents::AgentName;
2
3/// One generated extension/settings file: the filename to write it as and the
4/// content to fill it with. Returned by [`build_extension`] for agents that
5/// support a per-launch file-loaded integration (Claude Code's `--settings`,
6/// pi's `-e <path>`).
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ExtensionFile {
9    pub filename: String,
10    pub content: String,
11}
12
13/// Build the extension/settings file an alias-installed agent loads at launch.
14///
15/// Every [`AgentName`] variant has a branch (the match is exhaustive), so the
16/// caller always gets an [`ExtensionFile`]. `claude-code` uses `--settings
17/// <file>`, `pi-coding-agent` uses `-e <file>`, and `opencode`'s in-process
18/// plugin file can be copied once. The `filename` member is the basename to
19/// write as (`claude-code.json`, `pi-coding-agent.ts`, `opencode.ts`); the
20/// `content` member is the file body.
21#[must_use]
22pub fn build_extension(bin_path: &str, agent: AgentName) -> ExtensionFile {
23    match agent {
24        AgentName::ClaudeCode => ExtensionFile {
25            filename: "claude-code.json".to_string(),
26            content: build_claude_code_settings(bin_path),
27        },
28        AgentName::PiCodingAgent => ExtensionFile {
29            filename: "pi-coding-agent.ts".to_string(),
30            content: build_pi_extension(bin_path),
31        },
32        AgentName::Opencode => ExtensionFile {
33            filename: "opencode.ts".to_string(),
34            content: build_opencode_extension(bin_path),
35        },
36    }
37}
38
39fn build_claude_code_settings(bin_path: &str) -> String {
40    let set_notify = format!("{bin_path} set --agent claude-code notify");
41    let set_done = format!("{bin_path} set --agent claude-code done");
42    let set_working = format!("{bin_path} set --agent claude-code working");
43    let set_idle = format!("{bin_path} set --agent claude-code idle");
44    let clear = format!("{bin_path} clear --agent claude-code");
45
46    // Intentionally NOT subscribing to Claude Code's `Notification` hook:
47    // it fires for several matchers, including `idle_prompt` (a periodic
48    // "Claude is waiting for your input" reminder fired on a timer) and
49    // `permission_prompt` (which duplicates `PermissionRequest`). Wiring
50    // `Notification → notify` would spuriously flip a freshly-cleared
51    // (`/clear`) session back to `notify` after the idle reminder fires.
52    // `PermissionRequest` fires first for tool-permission gates and is
53    // the canonical, deterministic signal.
54    // `PostToolUse` closes the post-approval gap: after a `PermissionRequest`
55    // is approved, Claude Code emits no hook for the approval itself. Without
56    // `PostToolUse → working` the row would stay stuck at `notify` until the
57    // next `PreToolUse` or `Stop` — which can be many seconds away if the
58    // agent is just thinking. `PostToolUse` fires for every tool call (not
59    // just permission-gated ones), so it's a no-op transition in the normal
60    // case and a fix in the approved-permission case. A denied permission
61    // does not fire `PostToolUse`, so the row legitimately stays at `notify`
62    // until the agent retries (next `PreToolUse`) or gives up (`Stop`).
63    let value = serde_json::json!({
64        "hooks": {
65            "PermissionRequest": [{"hooks": [{"type": "command", "command": set_notify}]}],
66            "Stop":              [{"hooks": [{"type": "command", "command": set_done}]}],
67            "UserPromptSubmit":  [{"hooks": [{"type": "command", "command": &set_working}]}],
68            "PreToolUse":        [{"hooks": [{"type": "command", "command": &set_working}]}],
69            "PostToolUse":       [{"hooks": [{"type": "command", "command": set_working}]}],
70            "SessionStart":      [{"hooks": [{"type": "command", "command": set_idle}]}],
71            "SessionEnd":        [{"hooks": [{"type": "command", "command": clear}]}],
72        }
73    });
74    serde_json::to_string_pretty(&value).expect("serde_json::Value always serializes")
75}
76
77fn build_pi_extension(bin_path: &str) -> String {
78    let template = include_str!("../../extensions/pi-coding-agent.ts");
79    let serialized = serde_json::to_string(bin_path).expect("path serializes");
80    let replacement = format!("const BIN = {serialized};");
81    template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
82}
83
84fn build_opencode_extension(bin_path: &str) -> String {
85    let template = include_str!("../../extensions/opencode.ts");
86    let serialized = serde_json::to_string(bin_path).expect("path serializes");
87    let replacement = format!("const BIN = {serialized};");
88    template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
89}
90
91/// The exact BIN-resolution line shared by `extensions/pi-coding-agent.ts`
92/// and `extensions/opencode.ts`. Matched verbatim by `str::replacen` so the
93/// embedded template can be specialized with an absolute path. If this line
94/// drifts in the .ts source, the substitution silently no-ops and the file
95/// keeps its env-fallback resolution at runtime — still functional, just
96/// not alias-optimized.
97const TS_BIN_RESOLUTION_LINE: &str =
98    "const BIN = process.env.AGENT_STATUS_BIN ?? \"agent-status\";";
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn build_extension_returns_extension_for_claude_code() {
106        let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
107        assert_eq!(ext.filename, "claude-code.json");
108        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
109        assert!(parsed.get("hooks").is_some(), "missing top-level hooks key");
110    }
111
112    #[test]
113    fn build_extension_claude_code_wires_all_hook_events() {
114        let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
115        for event in [
116            "PermissionRequest",
117            "Stop",
118            "UserPromptSubmit",
119            "PreToolUse",
120            "PostToolUse",
121            "SessionStart",
122            "SessionEnd",
123        ] {
124            assert!(ext.content.contains(event), "missing hook event {event}");
125        }
126    }
127
128    #[test]
129    fn build_extension_claude_code_does_not_subscribe_to_notification() {
130        // Notification fires on a timer for `idle_prompt` ("Claude is
131        // waiting for your input"), which would spuriously flip a
132        // freshly-cleared (`/clear`) session from `idle` back to
133        // `notify`. PermissionRequest covers the legitimate permission
134        // case. See the comment in `build_claude_code_settings`.
135        let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
136        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
137        assert!(
138            parsed.pointer("/hooks/Notification").is_none(),
139            "should not subscribe to Notification; got: {}",
140            ext.content,
141        );
142    }
143
144    #[test]
145    fn build_extension_claude_code_uses_set_and_clear_correctly() {
146        let ext = build_extension("/path/to/agent-status", AgentName::ClaudeCode);
147        assert!(ext.content.contains("set --agent claude-code notify"));
148        assert!(ext.content.contains("set --agent claude-code done"));
149        assert!(ext.content.contains("clear --agent claude-code"));
150        assert!(ext.content.contains("/path/to/agent-status"));
151    }
152
153    #[test]
154    fn build_extension_escapes_unsafe_chars_in_bin_path() {
155        let ext = build_extension(
156            r#"/x/has"quote\and-backslash/agent-status"#,
157            AgentName::ClaudeCode,
158        );
159        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
160        let command = parsed
161            .pointer("/hooks/PermissionRequest/0/hooks/0/command")
162            .and_then(serde_json::Value::as_str)
163            .expect("PermissionRequest command string");
164        assert!(command.contains(r#"has"quote\and-backslash"#), "got: {command}");
165    }
166
167    #[test]
168    fn build_extension_returns_pi_coding_agent_extension() {
169        let ext = build_extension("/abs/path/agent-status", AgentName::PiCodingAgent);
170        assert_eq!(ext.filename, "pi-coding-agent.ts");
171        assert!(
172            ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
173            "missing substituted BIN; got:\n{}",
174            ext.content,
175        );
176        assert!(
177            !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
178            "env-fallback line should have been replaced",
179        );
180        assert!(ext.content.contains("export default function"));
181    }
182
183    #[test]
184    fn build_extension_pi_extension_json_escapes_bin_path() {
185        let ext = build_extension(
186            r#"/x/has"quote\and-backslash/agent-status"#,
187            AgentName::PiCodingAgent,
188        );
189        assert!(
190            ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
191            "BIN line not escaped correctly; got:\n{}",
192            ext.content,
193        );
194    }
195
196    #[test]
197    fn build_extension_returns_opencode_extension() {
198        let ext = build_extension("/abs/path/agent-status", AgentName::Opencode);
199        assert_eq!(ext.filename, "opencode.ts");
200        assert!(
201            ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
202            "missing substituted BIN; got:\n{}",
203            ext.content,
204        );
205        assert!(
206            !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
207            "env-fallback line should have been replaced",
208        );
209        assert!(ext.content.contains("AgentStatusPlugin"));
210    }
211
212    #[test]
213    fn build_extension_opencode_extension_json_escapes_bin_path() {
214        let ext = build_extension(
215            r#"/x/has"quote\and-backslash/agent-status"#,
216            AgentName::Opencode,
217        );
218        assert!(
219            ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
220            "BIN line not escaped correctly; got:\n{}",
221            ext.content,
222        );
223    }
224
225    #[test]
226    fn build_extension_claude_code_user_prompt_submit_sets_working() {
227        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
228        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
229        let cmd = parsed
230            .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
231            .and_then(serde_json::Value::as_str)
232            .expect("UserPromptSubmit command");
233        assert!(
234            cmd.contains("set --agent claude-code working"),
235            "got: {cmd}",
236        );
237    }
238
239    #[test]
240    fn build_extension_claude_code_pre_tool_use_sets_working() {
241        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
242        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
243        let cmd = parsed
244            .pointer("/hooks/PreToolUse/0/hooks/0/command")
245            .and_then(serde_json::Value::as_str)
246            .expect("PreToolUse command");
247        assert!(
248            cmd.contains("set --agent claude-code working"),
249            "got: {cmd}",
250        );
251    }
252
253    #[test]
254    fn build_extension_claude_code_post_tool_use_sets_working() {
255        // PostToolUse fires after every tool call (including those gated by
256        // PermissionRequest). It transitions the row out of `notify` once
257        // an approved tool finishes, since Claude Code emits no hook for
258        // permission approval itself.
259        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
260        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
261        let cmd = parsed
262            .pointer("/hooks/PostToolUse/0/hooks/0/command")
263            .and_then(serde_json::Value::as_str)
264            .expect("PostToolUse command");
265        assert!(
266            cmd.contains("set --agent claude-code working"),
267            "got: {cmd}",
268        );
269    }
270
271    #[test]
272    fn build_extension_claude_code_permission_request_sets_notify() {
273        // PermissionRequest fires when Claude Code shows a tool-permission dialog
274        // (after PreToolUse, before the user clicks Yes/No). Without this hook the
275        // PreToolUse-emitted `working` state stays until the user resolves the
276        // dialog — so the tmux indicator and agent-switcher would silently miss
277        // the "needs you now" transition.
278        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
279        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
280        let cmd = parsed
281            .pointer("/hooks/PermissionRequest/0/hooks/0/command")
282            .and_then(serde_json::Value::as_str)
283            .expect("PermissionRequest command");
284        assert!(
285            cmd.contains("set --agent claude-code notify"),
286            "got: {cmd}",
287        );
288    }
289
290    #[test]
291    fn build_extension_claude_code_session_start_sets_idle() {
292        // SessionStart registers the session as `idle` so every Claude session
293        // appears in the switcher from the moment it starts — even before the
294        // user has typed their first prompt. Clearing on SessionStart (the
295        // previous behavior) made the row invisible until UserPromptSubmit or
296        // PreToolUse fired.
297        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
298        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
299        let cmd = parsed
300            .pointer("/hooks/SessionStart/0/hooks/0/command")
301            .and_then(serde_json::Value::as_str)
302            .expect("SessionStart command");
303        assert!(
304            cmd.contains("set --agent claude-code idle"),
305            "got: {cmd}",
306        );
307    }
308
309    #[test]
310    fn build_extension_claude_code_session_end_still_clears() {
311        // SessionEnd is the only lifecycle event that should remove the row.
312        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
313        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
314        let cmd = parsed
315            .pointer("/hooks/SessionEnd/0/hooks/0/command")
316            .and_then(serde_json::Value::as_str)
317            .expect("SessionEnd command");
318        assert!(
319            cmd.contains("clear --agent claude-code"),
320            "SessionEnd should still clear; got: {cmd}",
321        );
322    }
323}