Skip to main content

agentmux_bashwrap/
sessionstart.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `sessionstart` subcommand — registered as Claude Code's `SessionStart`
5//! hook, once per part (`--part 1` … `--part 8`). Delivers the agent's memory
6//! (docs/specs/SPEC_GLOBAL_MEMORY_DELIVERY_2026_09_27.md §7 P2).
7//!
8//! A `SessionStart` hook's `additionalContext` reaches the model without a
9//! visible turn, but only up to about 10,000 characters per hook command (the
10//! CLI swaps anything longer for a file preview). So the sidecar splits the
11//! memory into labelled parts, and each hook command asks for its own:
12//!
13//! 1. read the hook's stdin (`session_id`, `source`);
14//! 2. `POST /api/v1/agent/memory/session-start/part` for part N — the sidecar
15//!    decides whether this source gets a delivery at all (`resume` and `fork`
16//!    don't) and how many parts there are;
17//! 3. print `{"hookSpecificOutput":{"hookEventName":"SessionStart",
18//!    "additionalContext":…}}` and flush it;
19//! 4. `POST …/session-start/ack`, so the sidecar knows this part reached the
20//!    CLI. Once every part is acknowledged the pane gets its notice.
21//!
22//! Every failure (no env, sidecar down, no part for this number) degrades to
23//! exit 0 with nothing printed: a hook must never block or break a session
24//! start, and an empty stdout is a hook with nothing to add.
25
26use anyhow::Result;
27use serde::{Deserialize, Serialize};
28use std::io::{Read, Write};
29
30use crate::mps_client::WpsClient;
31
32const PART_PATH: &str = "/api/v1/agent/memory/session-start/part";
33const ACK_PATH: &str = "/api/v1/agent/memory/session-start/ack";
34
35/// The CLI waits for every `SessionStart` hook before the session starts, so
36/// a slow or wedged sidecar must not hold it up for long. Composing reads a
37/// handful of small files on the same host.
38const PART_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
39const ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(1500);
40
41/// CLI args for `sessionstart`.
42#[derive(clap::Parser, Debug)]
43pub struct Args {
44    /// Which part of the delivery this hook command carries (1-based).
45    #[arg(long)]
46    pub part: usize,
47}
48
49/// The fields of the `SessionStart` stdin payload this hook uses.
50#[derive(Deserialize, Default, Debug, PartialEq)]
51struct SessionStartInput {
52    #[serde(default)]
53    session_id: String,
54    #[serde(default)]
55    source: String,
56    /// The session's working directory: where the CLI looked for its
57    /// startup files (`CLAUDE.md` up the tree, skills, `.mcp.json`).
58    #[serde(default)]
59    cwd: String,
60}
61
62#[derive(Serialize)]
63struct PartRequest<'a> {
64    block_id: &'a str,
65    session_id: &'a str,
66    source: &'a str,
67    part: usize,
68    /// So the pane's card can list the files the CLI loaded by itself.
69    cwd: &'a str,
70    /// The CLI's config dir (`CLAUDE_CONFIG_DIR`): the user's `CLAUDE.md`.
71    config_dir: &'a str,
72}
73
74/// Entry point. Always `Ok(())`: see the module doc.
75pub async fn run(args: Args) -> Result<()> {
76    let mut buf = String::new();
77    let _ = std::io::stdin().read_to_string(&mut buf);
78    let input: SessionStartInput = serde_json::from_str(&buf).unwrap_or_default();
79
80    let Some(client) = WpsClient::from_env() else {
81        tracing::debug!(target: "bashwrap", "sessionstart: sidecar env absent, nothing to deliver");
82        return Ok(());
83    };
84    let Some(block_id) = std::env::var("AGENTMUX_BLOCKID").ok().filter(|v| !v.is_empty()) else {
85        tracing::debug!(target: "bashwrap", "sessionstart: no AGENTMUX_BLOCKID, nothing to deliver");
86        return Ok(());
87    };
88    let token = std::env::var("AGENTMUX_AGENT_TOKEN").ok();
89    let config_dir = std::env::var("CLAUDE_CONFIG_DIR").unwrap_or_default();
90    let req = PartRequest {
91        block_id: &block_id,
92        session_id: &input.session_id,
93        source: &input.source,
94        part: args.part,
95        cwd: &input.cwd,
96        config_dir: &config_dir,
97    };
98
99    let reply = match client.post_json(PART_PATH, token.as_deref(), &req, PART_TIMEOUT).await {
100        Ok(v) => v,
101        Err(e) => {
102            tracing::warn!(target: "bashwrap", part = args.part, error = %e, "sessionstart: part request failed");
103            return Ok(());
104        }
105    };
106    let Some(text) = reply.get("text").and_then(|t| t.as_str()) else {
107        return Ok(());
108    };
109
110    let mut stdout = std::io::stdout().lock();
111    if stdout.write_all(hook_output(text).as_bytes()).and_then(|_| stdout.flush()).is_err() {
112        return Ok(());
113    }
114    drop(stdout);
115
116    if let Err(e) = client.post_json(ACK_PATH, token.as_deref(), &req, ACK_TIMEOUT).await {
117        tracing::warn!(target: "bashwrap", part = args.part, error = %e, "sessionstart: ack failed");
118    }
119    tracing::info!(target: "bashwrap", part = args.part, source = %input.source, chars = text.chars().count(), "sessionstart: part delivered");
120    Ok(())
121}
122
123/// The hook's stdout for a part: the shape Claude Code reads a
124/// `SessionStart` hook's added context from.
125fn hook_output(text: &str) -> String {
126    serde_json::json!({
127        "hookSpecificOutput": {
128            "hookEventName": "SessionStart",
129            "additionalContext": text,
130        }
131    })
132    .to_string()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn reads_session_id_source_and_cwd_from_stdin() {
141        let input: SessionStartInput = serde_json::from_str(
142            r#"{"session_id":"s1","transcript_path":"/t","cwd":"/c","hook_event_name":"SessionStart","source":"compact"}"#,
143        )
144        .unwrap();
145        assert_eq!(input, SessionStartInput { session_id: "s1".into(), source: "compact".into(), cwd: "/c".into() });
146    }
147
148    #[test]
149    fn a_malformed_payload_degrades_to_empty() {
150        let input: SessionStartInput = serde_json::from_str("not json").unwrap_or_default();
151        assert_eq!(input, SessionStartInput::default());
152    }
153
154    #[test]
155    fn prints_the_shape_claude_code_reads() {
156        let out: serde_json::Value = serde_json::from_str(&hook_output("[AgentMux memory — part 1 of 1]\nhi")).unwrap();
157        assert_eq!(out["hookSpecificOutput"]["hookEventName"], "SessionStart");
158        assert_eq!(out["hookSpecificOutput"]["additionalContext"], "[AgentMux memory — part 1 of 1]\nhi");
159    }
160
161    #[test]
162    fn the_request_names_block_session_source_part_and_where_the_cli_runs() {
163        let req = PartRequest {
164            block_id: "b",
165            session_id: "s",
166            source: "startup",
167            part: 3,
168            cwd: "/ws",
169            config_dir: "/cfg",
170        };
171        assert_eq!(
172            serde_json::to_value(&req).unwrap(),
173            serde_json::json!({"block_id": "b", "session_id": "s", "source": "startup", "part": 3, "cwd": "/ws", "config_dir": "/cfg"})
174        );
175    }
176}