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    let value = serde_json::json!({
55        "hooks": {
56            "PermissionRequest": [{"hooks": [{"type": "command", "command": set_notify}]}],
57            "Stop":              [{"hooks": [{"type": "command", "command": set_done}]}],
58            "UserPromptSubmit":  [{"hooks": [{"type": "command", "command": &set_working}]}],
59            "PreToolUse":        [{"hooks": [{"type": "command", "command": set_working}]}],
60            "SessionStart":      [{"hooks": [{"type": "command", "command": set_idle}]}],
61            "SessionEnd":        [{"hooks": [{"type": "command", "command": clear}]}],
62        }
63    });
64    serde_json::to_string_pretty(&value).expect("serde_json::Value always serializes")
65}
66
67fn build_pi_extension(bin_path: &str) -> String {
68    let template = include_str!("../../extensions/pi-coding-agent.ts");
69    let serialized = serde_json::to_string(bin_path).expect("path serializes");
70    let replacement = format!("const BIN = {serialized};");
71    template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
72}
73
74fn build_opencode_extension(bin_path: &str) -> String {
75    let template = include_str!("../../extensions/opencode.ts");
76    let serialized = serde_json::to_string(bin_path).expect("path serializes");
77    let replacement = format!("const BIN = {serialized};");
78    template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
79}
80
81/// The exact BIN-resolution line shared by `extensions/pi-coding-agent.ts`
82/// and `extensions/opencode.ts`. Matched verbatim by `str::replacen` so the
83/// embedded template can be specialized with an absolute path. If this line
84/// drifts in the .ts source, the substitution silently no-ops and the file
85/// keeps its env-fallback resolution at runtime — still functional, just
86/// not alias-optimized.
87const TS_BIN_RESOLUTION_LINE: &str =
88    "const BIN = process.env.AGENT_STATUS_BIN ?? \"agent-status\";";
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn build_extension_returns_extension_for_claude_code() {
96        let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
97        assert_eq!(ext.filename, "claude-code.json");
98        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
99        assert!(parsed.get("hooks").is_some(), "missing top-level hooks key");
100    }
101
102    #[test]
103    fn build_extension_claude_code_wires_all_hook_events() {
104        let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
105        for event in [
106            "PermissionRequest",
107            "Stop",
108            "UserPromptSubmit",
109            "PreToolUse",
110            "SessionStart",
111            "SessionEnd",
112        ] {
113            assert!(ext.content.contains(event), "missing hook event {event}");
114        }
115    }
116
117    #[test]
118    fn build_extension_claude_code_does_not_subscribe_to_notification() {
119        // Notification fires on a timer for `idle_prompt` ("Claude is
120        // waiting for your input"), which would spuriously flip a
121        // freshly-cleared (`/clear`) session from `idle` back to
122        // `notify`. PermissionRequest covers the legitimate permission
123        // case. See the comment in `build_claude_code_settings`.
124        let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
125        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
126        assert!(
127            parsed.pointer("/hooks/Notification").is_none(),
128            "should not subscribe to Notification; got: {}",
129            ext.content,
130        );
131    }
132
133    #[test]
134    fn build_extension_claude_code_uses_set_and_clear_correctly() {
135        let ext = build_extension("/path/to/agent-status", AgentName::ClaudeCode);
136        assert!(ext.content.contains("set --agent claude-code notify"));
137        assert!(ext.content.contains("set --agent claude-code done"));
138        assert!(ext.content.contains("clear --agent claude-code"));
139        assert!(ext.content.contains("/path/to/agent-status"));
140    }
141
142    #[test]
143    fn build_extension_escapes_unsafe_chars_in_bin_path() {
144        let ext = build_extension(
145            r#"/x/has"quote\and-backslash/agent-status"#,
146            AgentName::ClaudeCode,
147        );
148        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
149        let command = parsed
150            .pointer("/hooks/PermissionRequest/0/hooks/0/command")
151            .and_then(serde_json::Value::as_str)
152            .expect("PermissionRequest command string");
153        assert!(command.contains(r#"has"quote\and-backslash"#), "got: {command}");
154    }
155
156    #[test]
157    fn build_extension_returns_pi_coding_agent_extension() {
158        let ext = build_extension("/abs/path/agent-status", AgentName::PiCodingAgent);
159        assert_eq!(ext.filename, "pi-coding-agent.ts");
160        assert!(
161            ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
162            "missing substituted BIN; got:\n{}",
163            ext.content,
164        );
165        assert!(
166            !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
167            "env-fallback line should have been replaced",
168        );
169        assert!(ext.content.contains("export default function"));
170    }
171
172    #[test]
173    fn build_extension_pi_extension_json_escapes_bin_path() {
174        let ext = build_extension(
175            r#"/x/has"quote\and-backslash/agent-status"#,
176            AgentName::PiCodingAgent,
177        );
178        assert!(
179            ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
180            "BIN line not escaped correctly; got:\n{}",
181            ext.content,
182        );
183    }
184
185    #[test]
186    fn build_extension_returns_opencode_extension() {
187        let ext = build_extension("/abs/path/agent-status", AgentName::Opencode);
188        assert_eq!(ext.filename, "opencode.ts");
189        assert!(
190            ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
191            "missing substituted BIN; got:\n{}",
192            ext.content,
193        );
194        assert!(
195            !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
196            "env-fallback line should have been replaced",
197        );
198        assert!(ext.content.contains("AgentStatusPlugin"));
199    }
200
201    #[test]
202    fn build_extension_opencode_extension_json_escapes_bin_path() {
203        let ext = build_extension(
204            r#"/x/has"quote\and-backslash/agent-status"#,
205            AgentName::Opencode,
206        );
207        assert!(
208            ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
209            "BIN line not escaped correctly; got:\n{}",
210            ext.content,
211        );
212    }
213
214    #[test]
215    fn build_extension_claude_code_user_prompt_submit_sets_working() {
216        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
217        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
218        let cmd = parsed
219            .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
220            .and_then(serde_json::Value::as_str)
221            .expect("UserPromptSubmit command");
222        assert!(
223            cmd.contains("set --agent claude-code working"),
224            "got: {cmd}",
225        );
226    }
227
228    #[test]
229    fn build_extension_claude_code_pre_tool_use_sets_working() {
230        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
231        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
232        let cmd = parsed
233            .pointer("/hooks/PreToolUse/0/hooks/0/command")
234            .and_then(serde_json::Value::as_str)
235            .expect("PreToolUse command");
236        assert!(
237            cmd.contains("set --agent claude-code working"),
238            "got: {cmd}",
239        );
240    }
241
242    #[test]
243    fn build_extension_claude_code_permission_request_sets_notify() {
244        // PermissionRequest fires when Claude Code shows a tool-permission dialog
245        // (after PreToolUse, before the user clicks Yes/No). Without this hook the
246        // PreToolUse-emitted `working` state stays until the user resolves the
247        // dialog — so the tmux indicator and agent-switcher would silently miss
248        // the "needs you now" transition.
249        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
250        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
251        let cmd = parsed
252            .pointer("/hooks/PermissionRequest/0/hooks/0/command")
253            .and_then(serde_json::Value::as_str)
254            .expect("PermissionRequest command");
255        assert!(
256            cmd.contains("set --agent claude-code notify"),
257            "got: {cmd}",
258        );
259    }
260
261    #[test]
262    fn build_extension_claude_code_session_start_sets_idle() {
263        // SessionStart registers the session as `idle` so every Claude session
264        // appears in the switcher from the moment it starts — even before the
265        // user has typed their first prompt. Clearing on SessionStart (the
266        // previous behavior) made the row invisible until UserPromptSubmit or
267        // PreToolUse fired.
268        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
269        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
270        let cmd = parsed
271            .pointer("/hooks/SessionStart/0/hooks/0/command")
272            .and_then(serde_json::Value::as_str)
273            .expect("SessionStart command");
274        assert!(
275            cmd.contains("set --agent claude-code idle"),
276            "got: {cmd}",
277        );
278    }
279
280    #[test]
281    fn build_extension_claude_code_session_end_still_clears() {
282        // SessionEnd is the only lifecycle event that should remove the row.
283        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
284        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
285        let cmd = parsed
286            .pointer("/hooks/SessionEnd/0/hooks/0/command")
287            .and_then(serde_json::Value::as_str)
288            .expect("SessionEnd command");
289        assert!(
290            cmd.contains("clear --agent claude-code"),
291            "SessionEnd should still clear; got: {cmd}",
292        );
293    }
294}