agent-status 2.1.0

Tmux-integrated indicator showing which AI coding agent sessions are waiting on user input.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
use crate::agents::AgentName;

/// One generated extension/settings file: the filename to write it as and the
/// content to fill it with. Returned by [`build_extension`] for agents that
/// support a per-launch file-loaded integration (Claude Code's `--settings`,
/// pi's and omp's `-e <path>`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtensionFile {
    pub filename: String,
    pub content: String,
}

/// Build the extension/settings file an alias-installed agent loads at launch.
///
/// Every [`AgentName`] variant has a branch (the match is exhaustive), so the
/// caller always gets an [`ExtensionFile`]. `claude-code` uses `--settings
/// <file>`, `pi` and `oh-my-pi` use `-e <file>`, and `opencode`'s
/// in-process plugin file can be copied once. The `filename` member is the
/// basename to write as (`claude-code.json`, `oh-my-pi.ts`, `pi.ts`,
/// `opencode.ts`); the `content` member is the file body.
#[must_use]
pub fn build_extension(bin_path: &str, agent: AgentName) -> ExtensionFile {
    match agent {
        AgentName::ClaudeCode => ExtensionFile {
            filename: "claude-code.json".to_string(),
            content: build_claude_code_settings(bin_path),
        },
        AgentName::OhMyPi => ExtensionFile {
            filename: "oh-my-pi.ts".to_string(),
            content: build_oh_my_pi_extension(bin_path),
        },
        AgentName::Pi => ExtensionFile {
            filename: "pi.ts".to_string(),
            content: build_pi_extension(bin_path),
        },
        AgentName::Opencode => ExtensionFile {
            filename: "opencode.ts".to_string(),
            content: build_opencode_extension(bin_path),
        },
    }
}

fn build_claude_code_settings(bin_path: &str) -> String {
    let set_notify = format!("{bin_path} set --agent claude-code notify");
    let set_done = format!("{bin_path} set --agent claude-code done");
    let set_working = format!("{bin_path} set --agent claude-code working");
    let set_idle = format!("{bin_path} set --agent claude-code idle");
    let clear = format!("{bin_path} clear --agent claude-code");

    // Intentionally NOT subscribing to Claude Code's `Notification` hook:
    // it fires for several matchers, including `idle_prompt` (a periodic
    // "Claude is waiting for your input" reminder fired on a timer) and
    // `permission_prompt` (which duplicates `PermissionRequest`). Wiring
    // `Notification → notify` would spuriously flip a freshly-cleared
    // (`/clear`) session back to `notify` after the idle reminder fires.
    // `PermissionRequest` fires first for tool-permission gates and is
    // the canonical, deterministic signal.
    // `PostToolUse` closes the post-approval gap: after a `PermissionRequest`
    // is approved, Claude Code emits no hook for the approval itself. Without
    // `PostToolUse → working` the row would stay stuck at `notify` until the
    // next `PreToolUse` or `Stop` — which can be many seconds away if the
    // agent is just thinking. `PostToolUse` fires for every tool call (not
    // just permission-gated ones), so it's a no-op transition in the normal
    // case and a fix in the approved-permission case. A denied permission
    // does not fire `PostToolUse`, so the row legitimately stays at `notify`
    // until the agent retries (next `PreToolUse`) or gives up (`Stop`).
    let value = serde_json::json!({
        "hooks": {
            "PermissionRequest": [{"hooks": [{"type": "command", "command": set_notify}]}],
            "Stop":              [{"hooks": [{"type": "command", "command": set_done}]}],
            "UserPromptSubmit":  [{"hooks": [{"type": "command", "command": &set_working}]}],
            "PreToolUse":        [{"hooks": [{"type": "command", "command": &set_working}]}],
            "PostToolUse":       [{"hooks": [{"type": "command", "command": set_working}]}],
            "SessionStart":      [{"hooks": [{"type": "command", "command": set_idle}]}],
            "SessionEnd":        [{"hooks": [{"type": "command", "command": clear}]}],
        }
    });
    serde_json::to_string_pretty(&value).expect("serde_json::Value always serializes")
}

fn build_oh_my_pi_extension(bin_path: &str) -> String {
    let template = include_str!("../../extensions/oh-my-pi.ts");
    let serialized = serde_json::to_string(bin_path).expect("path serializes");
    let replacement = format!("const BIN = {serialized};");
    template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
}

fn build_pi_extension(bin_path: &str) -> String {
    let template = include_str!("../../extensions/pi.ts");
    let serialized = serde_json::to_string(bin_path).expect("path serializes");
    let replacement = format!("const BIN = {serialized};");
    template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
}

fn build_opencode_extension(bin_path: &str) -> String {
    let template = include_str!("../../extensions/opencode.ts");
    let serialized = serde_json::to_string(bin_path).expect("path serializes");
    let replacement = format!("const BIN = {serialized};");
    template.replacen(TS_BIN_RESOLUTION_LINE, &replacement, 1)
}

/// The exact BIN-resolution line shared by `extensions/oh-my-pi.ts`,
/// `extensions/pi.ts`, and `extensions/opencode.ts`. Matched
/// verbatim by `str::replacen` so the embedded template can be specialized
/// with an absolute path. If this line drifts in the .ts source, the
/// substitution silently no-ops and the file keeps its env-fallback resolution
/// at runtime — still functional, just not alias-optimized.
const TS_BIN_RESOLUTION_LINE: &str =
    "const BIN = process.env.AGENT_STATUS_BIN ?? \"agent-status\";";

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_extension_returns_extension_for_claude_code() {
        let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
        assert_eq!(ext.filename, "claude-code.json");
        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
        assert!(parsed.get("hooks").is_some(), "missing top-level hooks key");
    }

    #[test]
    fn build_extension_claude_code_wires_all_hook_events() {
        let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
        for event in [
            "PermissionRequest",
            "Stop",
            "UserPromptSubmit",
            "PreToolUse",
            "PostToolUse",
            "SessionStart",
            "SessionEnd",
        ] {
            assert!(ext.content.contains(event), "missing hook event {event}");
        }
    }

    #[test]
    fn build_extension_claude_code_does_not_subscribe_to_notification() {
        // Notification fires on a timer for `idle_prompt` ("Claude is
        // waiting for your input"), which would spuriously flip a
        // freshly-cleared (`/clear`) session from `idle` back to
        // `notify`. PermissionRequest covers the legitimate permission
        // case. See the comment in `build_claude_code_settings`.
        let ext = build_extension("/x/agent-status", AgentName::ClaudeCode);
        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
        assert!(
            parsed.pointer("/hooks/Notification").is_none(),
            "should not subscribe to Notification; got: {}",
            ext.content,
        );
    }

    #[test]
    fn build_extension_claude_code_uses_set_and_clear_correctly() {
        let ext = build_extension("/path/to/agent-status", AgentName::ClaudeCode);
        assert!(ext.content.contains("set --agent claude-code notify"));
        assert!(ext.content.contains("set --agent claude-code done"));
        assert!(ext.content.contains("clear --agent claude-code"));
        assert!(ext.content.contains("/path/to/agent-status"));
    }

    #[test]
    fn build_extension_escapes_unsafe_chars_in_bin_path() {
        let ext = build_extension(
            r#"/x/has"quote\and-backslash/agent-status"#,
            AgentName::ClaudeCode,
        );
        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
        let command = parsed
            .pointer("/hooks/PermissionRequest/0/hooks/0/command")
            .and_then(serde_json::Value::as_str)
            .expect("PermissionRequest command string");
        assert!(command.contains(r#"has"quote\and-backslash"#), "got: {command}");
    }

    #[test]
    fn build_extension_returns_pi_extension() {
        let ext = build_extension("/abs/path/agent-status", AgentName::Pi);
        assert_eq!(ext.filename, "pi.ts");
        assert!(
            ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
            "missing substituted BIN; got:\n{}",
            ext.content,
        );
        assert!(
            !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
            "env-fallback line should have been replaced",
        );
        assert!(ext.content.contains("export default function"));
    }

    #[test]
    fn build_extension_pi_extension_json_escapes_bin_path() {
        let ext = build_extension(
            r#"/x/has"quote\and-backslash/agent-status"#,
            AgentName::Pi,
        );
        assert!(
            ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
            "BIN line not escaped correctly; got:\n{}",
            ext.content,
        );
    }

    #[test]
    fn build_extension_pi_extension_wires_all_parity_events() {
        // The pi bridge must keep parity with the Claude Code hook set:
        // notify (ask tool + approval prediction), the tool_call /
        // tool_execution_end gap-closers, and stale-row cleanup on
        // in-process session switches. See extensions/pi.ts.
        let ext = build_extension("/x/agent-status", AgentName::Pi);
        for event in [
            "session_start",
            "session_shutdown",
            "session_switch",
            "session_branch",
            "before_agent_start",
            "tool_execution_start",
            "tool_call",
            "tool_execution_end",
            "agent_end",
        ] {
            assert!(
                ext.content.contains(&format!("pi.on(\"{event}\"")),
                "missing pi event subscription {event}",
            );
        }
        assert!(
            ext.content.contains("\"notify\""),
            "bridge never emits notify",
        );
    }

    #[test]
    fn build_extension_returns_opencode_extension() {
        let ext = build_extension("/abs/path/agent-status", AgentName::Opencode);
        assert_eq!(ext.filename, "opencode.ts");
        assert!(
            ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
            "missing substituted BIN; got:\n{}",
            ext.content,
        );
        assert!(
            !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
            "env-fallback line should have been replaced",
        );
        assert!(ext.content.contains("AgentStatusPlugin"));
    }

    #[test]
    fn build_extension_opencode_extension_json_escapes_bin_path() {
        let ext = build_extension(
            r#"/x/has"quote\and-backslash/agent-status"#,
            AgentName::Opencode,
        );
        assert!(
            ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
            "BIN line not escaped correctly; got:\n{}",
            ext.content,
        );
    }

    #[test]
    fn build_extension_claude_code_user_prompt_submit_sets_working() {
        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
        let cmd = parsed
            .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
            .and_then(serde_json::Value::as_str)
            .expect("UserPromptSubmit command");
        assert!(
            cmd.contains("set --agent claude-code working"),
            "got: {cmd}",
        );
    }

    #[test]
    fn build_extension_claude_code_pre_tool_use_sets_working() {
        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
        let cmd = parsed
            .pointer("/hooks/PreToolUse/0/hooks/0/command")
            .and_then(serde_json::Value::as_str)
            .expect("PreToolUse command");
        assert!(
            cmd.contains("set --agent claude-code working"),
            "got: {cmd}",
        );
    }

    #[test]
    fn build_extension_claude_code_post_tool_use_sets_working() {
        // PostToolUse fires after every tool call (including those gated by
        // PermissionRequest). It transitions the row out of `notify` once
        // an approved tool finishes, since Claude Code emits no hook for
        // permission approval itself.
        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
        let cmd = parsed
            .pointer("/hooks/PostToolUse/0/hooks/0/command")
            .and_then(serde_json::Value::as_str)
            .expect("PostToolUse command");
        assert!(
            cmd.contains("set --agent claude-code working"),
            "got: {cmd}",
        );
    }

    #[test]
    fn build_extension_claude_code_permission_request_sets_notify() {
        // PermissionRequest fires when Claude Code shows a tool-permission dialog
        // (after PreToolUse, before the user clicks Yes/No). Without this hook the
        // PreToolUse-emitted `working` state stays until the user resolves the
        // dialog — so the tmux indicator and agent-switcher would silently miss
        // the "needs you now" transition.
        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
        let cmd = parsed
            .pointer("/hooks/PermissionRequest/0/hooks/0/command")
            .and_then(serde_json::Value::as_str)
            .expect("PermissionRequest command");
        assert!(
            cmd.contains("set --agent claude-code notify"),
            "got: {cmd}",
        );
    }

    #[test]
    fn build_extension_claude_code_session_start_sets_idle() {
        // SessionStart registers the session as `idle` so every Claude session
        // appears in the switcher from the moment it starts — even before the
        // user has typed their first prompt. Clearing on SessionStart (the
        // previous behavior) made the row invisible until UserPromptSubmit or
        // PreToolUse fired.
        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
        let cmd = parsed
            .pointer("/hooks/SessionStart/0/hooks/0/command")
            .and_then(serde_json::Value::as_str)
            .expect("SessionStart command");
        assert!(
            cmd.contains("set --agent claude-code idle"),
            "got: {cmd}",
        );
    }

    #[test]
    fn build_extension_claude_code_session_end_still_clears() {
        // SessionEnd is the only lifecycle event that should remove the row.
        let ext = build_extension("/path/agent-status", AgentName::ClaudeCode);
        let parsed: serde_json::Value = serde_json::from_str(&ext.content).unwrap();
        let cmd = parsed
            .pointer("/hooks/SessionEnd/0/hooks/0/command")
            .and_then(serde_json::Value::as_str)
            .expect("SessionEnd command");
        assert!(
            cmd.contains("clear --agent claude-code"),
            "SessionEnd should still clear; got: {cmd}",
        );
    }

    #[test]
    fn build_extension_returns_oh_my_pi_extension() {
        let ext = build_extension("/abs/path/agent-status", AgentName::OhMyPi);
        assert_eq!(ext.filename, "oh-my-pi.ts");
        assert!(
            ext.content.contains(r#"const BIN = "/abs/path/agent-status";"#),
            "missing substituted BIN; got:\n{}",
            ext.content,
        );
        assert!(
            !ext.content.contains("process.env.AGENT_STATUS_BIN ??"),
            "env-fallback line should have been replaced",
        );
        assert!(ext.content.contains("export default function"));
    }

    #[test]
    fn build_extension_oh_my_pi_extension_json_escapes_bin_path() {
        let ext = build_extension(
            r#"/x/has"quote\and-backslash/agent-status"#,
            AgentName::OhMyPi,
        );
        assert!(
            ext.content.contains(r#"const BIN = "/x/has\"quote\\and-backslash/agent-status";"#),
            "BIN line not escaped correctly; got:\n{}",
            ext.content,
        );
    }

    #[test]
    fn build_extension_oh_my_pi_wires_claude_code_parity_events() {
        // The bridge must subscribe to every event in the parity mapping —
        // including ask→notify and the switch/branch row cleanup.
        let ext = build_extension("/x/agent-status", AgentName::OhMyPi);
        for event in [
            "session_start",
            "session_switch",
            "session_branch",
            "session_shutdown",
            "before_agent_start",
            "tool_execution_start",
            "tool_execution_end",
            "agent_end",
        ] {
            assert!(
                ext.content.contains(&format!("omp.on(\"{event}\"")),
                "bridge missing subscription for {event}",
            );
        }
        assert!(
            ext.content.contains(r#""set", "notify""#),
            "bridge never sets notify (ask-tool mapping missing)",
        );
    }
}