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 and omp'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` and `oh-my-pi` use `-e <file>`, and `opencode`'s
18/// in-process plugin file can be copied once. The `filename` member is the
19/// basename to write as (`claude-code.json`, `oh-my-pi.ts`, `pi.ts`,
20/// `opencode.ts`); the `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::OhMyPi => ExtensionFile {
29            filename: "oh-my-pi.ts".to_string(),
30            content: build_oh_my_pi_extension(bin_path),
31        },
32        AgentName::Pi => ExtensionFile {
33            filename: "pi.ts".to_string(),
34            content: build_pi_extension(bin_path),
35        },
36        AgentName::Opencode => ExtensionFile {
37            filename: "opencode.ts".to_string(),
38            content: build_opencode_extension(bin_path),
39        },
40    }
41}
42
43fn build_claude_code_settings(bin_path: &str) -> String {
44    let set_notify = format!("{bin_path} set --agent claude-code notify");
45    let set_done = format!("{bin_path} set --agent claude-code done");
46    let set_working = format!("{bin_path} set --agent claude-code working");
47    let set_idle = format!("{bin_path} set --agent claude-code idle");
48    let clear = format!("{bin_path} clear --agent claude-code");
49
50    // Intentionally NOT subscribing to Claude Code's `Notification` hook:
51    // it fires for several matchers, including `idle_prompt` (a periodic
52    // "Claude is waiting for your input" reminder fired on a timer) and
53    // `permission_prompt` (which duplicates `PermissionRequest`). Wiring
54    // `Notification → notify` would spuriously flip a freshly-cleared
55    // (`/clear`) session back to `notify` after the idle reminder fires.
56    // `PermissionRequest` fires first for tool-permission gates and is
57    // the canonical, deterministic signal.
58    // `PostToolUse` closes the post-approval gap: after a `PermissionRequest`
59    // is approved, Claude Code emits no hook for the approval itself. Without
60    // `PostToolUse → working` the row would stay stuck at `notify` until the
61    // next `PreToolUse` or `Stop` — which can be many seconds away if the
62    // agent is just thinking. `PostToolUse` fires for every tool call (not
63    // just permission-gated ones), so it's a no-op transition in the normal
64    // case and a fix in the approved-permission case. A denied permission
65    // does not fire `PostToolUse`, so the row legitimately stays at `notify`
66    // until the agent retries (next `PreToolUse`) or gives up (`Stop`).
67    let value = serde_json::json!({
68        "hooks": {
69            "PermissionRequest": [{"hooks": [{"type": "command", "command": set_notify}]}],
70            "Stop":              [{"hooks": [{"type": "command", "command": set_done}]}],
71            "UserPromptSubmit":  [{"hooks": [{"type": "command", "command": &set_working}]}],
72            "PreToolUse":        [{"hooks": [{"type": "command", "command": &set_working}]}],
73            "PostToolUse":       [{"hooks": [{"type": "command", "command": set_working}]}],
74            "SessionStart":      [{"hooks": [{"type": "command", "command": set_idle}]}],
75            "SessionEnd":        [{"hooks": [{"type": "command", "command": clear}]}],
76        }
77    });
78    serde_json::to_string_pretty(&value).expect("serde_json::Value always serializes")
79}
80
81fn build_oh_my_pi_extension(bin_path: &str) -> String {
82    let template = include_str!("../../extensions/oh-my-pi.ts");
83    let serialized = serde_json::to_string(bin_path).expect("path serializes");
84    let replacement = format!("const BIN = {serialized};");
85    template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
86}
87
88fn build_pi_extension(bin_path: &str) -> String {
89    let template = include_str!("../../extensions/pi.ts");
90    let serialized = serde_json::to_string(bin_path).expect("path serializes");
91    let replacement = format!("const BIN = {serialized};");
92    template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
93}
94
95fn build_opencode_extension(bin_path: &str) -> String {
96    let template = include_str!("../../extensions/opencode.ts");
97    let serialized = serde_json::to_string(bin_path).expect("path serializes");
98    let replacement = format!("const BIN = {serialized};");
99    template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
100}
101
102/// The exact BIN-resolution line shared by `extensions/oh-my-pi.ts`,
103/// `extensions/pi.ts`, and `extensions/opencode.ts`. Matched
104/// verbatim by `str::replacen` so the embedded template can be specialized
105/// with an absolute path. If this line drifts in the .ts source, the
106/// substitution silently no-ops and the file keeps its env-fallback resolution
107/// at runtime — still functional, just not alias-optimized.
108const TS_BIN_RESOLUTION_LINE: &str =
109    "const BIN = process.env.AGENT_STATUS_BIN ?? \"agent-status\";";
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn build_extension_returns_extension_for_claude_code() {
117        let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
118        assert_eq!(ext.filename, "claude-code.json");
119        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
120        assert!(parsed.get("hooks").is_some(), "missing top-level hooks key");
121    }
122
123    #[test]
124    fn build_extension_claude_code_wires_all_hook_events() {
125        let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
126        for event in [
127            "PermissionRequest",
128            "Stop",
129            "UserPromptSubmit",
130            "PreToolUse",
131            "PostToolUse",
132            "SessionStart",
133            "SessionEnd",
134        ] {
135            assert!(ext.content.contains(event), "missing hook event {event}");
136        }
137    }
138
139    #[test]
140    fn build_extension_claude_code_does_not_subscribe_to_notification() {
141        // Notification fires on a timer for `idle_prompt` ("Claude is
142        // waiting for your input"), which would spuriously flip a
143        // freshly-cleared (`/clear`) session from `idle` back to
144        // `notify`. PermissionRequest covers the legitimate permission
145        // case. See the comment in `build_claude_code_settings`.
146        let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
147        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
148        assert!(
149            parsed.pointer("/hooks/Notification").is_none(),
150            "should not subscribe to Notification; got: {}",
151            ext.content,
152        );
153    }
154
155    #[test]
156    fn build_extension_claude_code_uses_set_and_clear_correctly() {
157        let ext = build_extension("/path/to/agent-status", AgentName::ClaudeCode);
158        assert!(ext.content.contains("set --agent claude-code notify"));
159        assert!(ext.content.contains("set --agent claude-code done"));
160        assert!(ext.content.contains("clear --agent claude-code"));
161        assert!(ext.content.contains("/path/to/agent-status"));
162    }
163
164    #[test]
165    fn build_extension_escapes_unsafe_chars_in_bin_path() {
166        let ext = build_extension(
167            r#"/x/has"quote\and-backslash/agent-status"#,
168            AgentName::ClaudeCode,
169        );
170        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
171        let command = parsed
172            .pointer("/hooks/PermissionRequest/0/hooks/0/command")
173            .and_then(serde_json::Value::as_str)
174            .expect("PermissionRequest command string");
175        assert!(command.contains(r#"has"quote\and-backslash"#), "got: {command}");
176    }
177
178    #[test]
179    fn build_extension_returns_pi_extension() {
180        let ext = build_extension("/abs/path/agent-status", AgentName::Pi);
181        assert_eq!(ext.filename, "pi.ts");
182        assert!(
183            ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
184            "missing substituted BIN; got:\n{}",
185            ext.content,
186        );
187        assert!(
188            !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
189            "env-fallback line should have been replaced",
190        );
191        assert!(ext.content.contains("export default function"));
192    }
193
194    #[test]
195    fn build_extension_pi_extension_json_escapes_bin_path() {
196        let ext = build_extension(
197            r#"/x/has"quote\and-backslash/agent-status"#,
198            AgentName::Pi,
199        );
200        assert!(
201            ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
202            "BIN line not escaped correctly; got:\n{}",
203            ext.content,
204        );
205    }
206
207    #[test]
208    fn build_extension_pi_extension_wires_all_parity_events() {
209        // The pi bridge must keep parity with the Claude Code hook set:
210        // notify (ask tool + approval prediction), the tool_call /
211        // tool_execution_end gap-closers, and stale-row cleanup on
212        // in-process session switches. See extensions/pi.ts.
213        let ext = build_extension("/x/agent-status", AgentName::Pi);
214        for event in [
215            "session_start",
216            "session_shutdown",
217            "session_switch",
218            "session_branch",
219            "before_agent_start",
220            "tool_execution_start",
221            "tool_call",
222            "tool_execution_end",
223            "agent_end",
224        ] {
225            assert!(
226                ext.content.contains(&format!("pi.on(\"{event}\"")),
227                "missing pi event subscription {event}",
228            );
229        }
230        assert!(
231            ext.content.contains("\"notify\""),
232            "bridge never emits notify",
233        );
234    }
235
236    #[test]
237    fn build_extension_returns_opencode_extension() {
238        let ext = build_extension("/abs/path/agent-status", AgentName::Opencode);
239        assert_eq!(ext.filename, "opencode.ts");
240        assert!(
241            ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
242            "missing substituted BIN; got:\n{}",
243            ext.content,
244        );
245        assert!(
246            !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
247            "env-fallback line should have been replaced",
248        );
249        assert!(ext.content.contains("AgentStatusPlugin"));
250    }
251
252    #[test]
253    fn build_extension_opencode_extension_json_escapes_bin_path() {
254        let ext = build_extension(
255            r#"/x/has"quote\and-backslash/agent-status"#,
256            AgentName::Opencode,
257        );
258        assert!(
259            ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
260            "BIN line not escaped correctly; got:\n{}",
261            ext.content,
262        );
263    }
264
265    #[test]
266    fn build_extension_claude_code_user_prompt_submit_sets_working() {
267        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
268        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
269        let cmd = parsed
270            .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
271            .and_then(serde_json::Value::as_str)
272            .expect("UserPromptSubmit command");
273        assert!(
274            cmd.contains("set --agent claude-code working"),
275            "got: {cmd}",
276        );
277    }
278
279    #[test]
280    fn build_extension_claude_code_pre_tool_use_sets_working() {
281        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
282        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
283        let cmd = parsed
284            .pointer("/hooks/PreToolUse/0/hooks/0/command")
285            .and_then(serde_json::Value::as_str)
286            .expect("PreToolUse command");
287        assert!(
288            cmd.contains("set --agent claude-code working"),
289            "got: {cmd}",
290        );
291    }
292
293    #[test]
294    fn build_extension_claude_code_post_tool_use_sets_working() {
295        // PostToolUse fires after every tool call (including those gated by
296        // PermissionRequest). It transitions the row out of `notify` once
297        // an approved tool finishes, since Claude Code emits no hook for
298        // permission approval itself.
299        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
300        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
301        let cmd = parsed
302            .pointer("/hooks/PostToolUse/0/hooks/0/command")
303            .and_then(serde_json::Value::as_str)
304            .expect("PostToolUse command");
305        assert!(
306            cmd.contains("set --agent claude-code working"),
307            "got: {cmd}",
308        );
309    }
310
311    #[test]
312    fn build_extension_claude_code_permission_request_sets_notify() {
313        // PermissionRequest fires when Claude Code shows a tool-permission dialog
314        // (after PreToolUse, before the user clicks Yes/No). Without this hook the
315        // PreToolUse-emitted `working` state stays until the user resolves the
316        // dialog — so the tmux indicator and agent-switcher would silently miss
317        // the "needs you now" transition.
318        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
319        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
320        let cmd = parsed
321            .pointer("/hooks/PermissionRequest/0/hooks/0/command")
322            .and_then(serde_json::Value::as_str)
323            .expect("PermissionRequest command");
324        assert!(
325            cmd.contains("set --agent claude-code notify"),
326            "got: {cmd}",
327        );
328    }
329
330    #[test]
331    fn build_extension_claude_code_session_start_sets_idle() {
332        // SessionStart registers the session as `idle` so every Claude session
333        // appears in the switcher from the moment it starts — even before the
334        // user has typed their first prompt. Clearing on SessionStart (the
335        // previous behavior) made the row invisible until UserPromptSubmit or
336        // PreToolUse fired.
337        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
338        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
339        let cmd = parsed
340            .pointer("/hooks/SessionStart/0/hooks/0/command")
341            .and_then(serde_json::Value::as_str)
342            .expect("SessionStart command");
343        assert!(
344            cmd.contains("set --agent claude-code idle"),
345            "got: {cmd}",
346        );
347    }
348
349    #[test]
350    fn build_extension_claude_code_session_end_still_clears() {
351        // SessionEnd is the only lifecycle event that should remove the row.
352        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
353        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
354        let cmd = parsed
355            .pointer("/hooks/SessionEnd/0/hooks/0/command")
356            .and_then(serde_json::Value::as_str)
357            .expect("SessionEnd command");
358        assert!(
359            cmd.contains("clear --agent claude-code"),
360            "SessionEnd should still clear; got: {cmd}",
361        );
362    }
363
364    #[test]
365    fn build_extension_returns_oh_my_pi_extension() {
366        let ext = build_extension("/abs/path/agent-status", AgentName::OhMyPi);
367        assert_eq!(ext.filename, "oh-my-pi.ts");
368        assert!(
369            ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
370            "missing substituted BIN; got:\n{}",
371            ext.content,
372        );
373        assert!(
374            !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
375            "env-fallback line should have been replaced",
376        );
377        assert!(ext.content.contains("export default function"));
378    }
379
380    #[test]
381    fn build_extension_oh_my_pi_extension_json_escapes_bin_path() {
382        let ext = build_extension(
383            r#"/x/has"quote\and-backslash/agent-status"#,
384            AgentName::OhMyPi,
385        );
386        assert!(
387            ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
388            "BIN line not escaped correctly; got:\n{}",
389            ext.content,
390        );
391    }
392
393    #[test]
394    fn build_extension_oh_my_pi_wires_claude_code_parity_events() {
395        // The bridge must subscribe to every event in the parity mapping —
396        // including ask→notify and the switch/branch row cleanup.
397        let ext = build_extension("/x/agent-status", AgentName::OhMyPi);
398        for event in [
399            "session_start",
400            "session_switch",
401            "session_branch",
402            "session_shutdown",
403            "before_agent_start",
404            "tool_execution_start",
405            "tool_execution_end",
406            "agent_end",
407        ] {
408            assert!(
409                ext.content.contains(&format!("omp.on(\"{event}\"")),
410                "bridge missing subscription for {event}",
411            );
412        }
413        assert!(
414            ext.content.contains(r#""set", "notify""#),
415            "bridge never sets notify (ask-tool mapping missing)",
416        );
417    }
418}