Skip to content

Commit 2888060

Browse files
feat: add comprehensive status tracking to session manager TUI (shepherdjerred#172)
* feat: add comprehensive status tracking to session manager TUI Add real-time Claude working status, PR/CI tracking, and hotkey controls: - Claude Status Display: Shows real-time agent activity (Working ⠋, Waiting for Approval ⏸, Waiting for Input ⌨, Idle ○) - Hook System: Unix socket listener receives status updates from Claude Code hooks - CI Polling: Background GitHub checks poller updates PR status every 30s - Hotkeys: 'p' creates PR, 'f' fixes CI failures via natural language prompts - Database: Migration v3 adds claude_status and timestamp fields - API: SendPrompt endpoint for remote prompt injection Architecture: - Hook flow: Claude Code → Bash → Unix Socket → Daemon → SQLite - CI flow: Poller → gh CLI → GitHub API → SessionManager - Hotkey flow: TUI → API → Manager → Backend → Claude Files modified: 13 core files, 5 new modules (hooks, ci) Installation: Run hooks/install.sh to enable Claude status tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: address code review feedback for status tracking Critical security and reliability fixes: 1. Fix command injection vulnerability in send_prompt_to_session - Use stdin piping instead of shell interpolation - Prevents exploitation via malicious prompt strings - Now safe from backticks, $(), and other shell metacharacters 2. Fix race condition logging in hook listener - Log warnings when channel send fails - Prevents silent status update losses 3. Add ClaudeStatusChanged to event replay - Restores Claude status when replaying events - Fixes status being reset to Unknown on daemon restart 4. Improve CI poller error handling - Distinguish expected failures (404) from auth/network issues - Better error visibility for debugging 5. Add socket path validation in bash scripts - Validate HOME environment variable - Check socket existence before use - Prevents failures with unset/invalid paths 6. Make hotkey 'f' less restrictive - Allow sending Fix CI prompt even when not failing - Show warning instead of blocking - Better UX for edge cases --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 16318fb commit 2888060

19 files changed

Lines changed: 904 additions & 7 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env bash
2+
# Install Claude Code hooks for multiplexer status tracking
3+
4+
set -euo pipefail
5+
6+
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7+
CLAUDE_SETTINGS="${HOME}/.claude/settings.json"
8+
9+
# Ensure .claude directory exists
10+
mkdir -p "${HOME}/.claude"
11+
12+
# Create/update settings.json with hooks
13+
cat > "$CLAUDE_SETTINGS" <<'EOF'
14+
{
15+
"hooks": {
16+
"UserPromptSubmit": [
17+
{
18+
"hooks": [
19+
{
20+
"type": "command",
21+
"command": ["bash", "-c", "${HOME}/.multiplexer/hooks/send_status.sh UserPromptSubmit"]
22+
}
23+
]
24+
}
25+
],
26+
"PreToolUse": [
27+
{
28+
"matcher": "*",
29+
"hooks": [
30+
{
31+
"type": "command",
32+
"command": ["bash", "-c", "${HOME}/.multiplexer/hooks/send_status.sh PreToolUse"]
33+
}
34+
]
35+
}
36+
],
37+
"PermissionRequest": [
38+
{
39+
"matcher": "*",
40+
"hooks": [
41+
{
42+
"type": "command",
43+
"command": ["bash", "-c", "${HOME}/.multiplexer/hooks/send_status.sh PermissionRequest"]
44+
}
45+
]
46+
}
47+
],
48+
"Stop": [
49+
{
50+
"hooks": [
51+
{
52+
"type": "command",
53+
"command": ["bash", "-c", "${HOME}/.multiplexer/hooks/send_status.sh Stop"]
54+
}
55+
]
56+
}
57+
],
58+
"Notification": [
59+
{
60+
"matcher": {
61+
"notification_type": "idle_prompt"
62+
},
63+
"hooks": [
64+
{
65+
"type": "command",
66+
"command": ["bash", "-c", "${HOME}/.multiplexer/hooks/send_status.sh IdlePrompt"]
67+
}
68+
]
69+
}
70+
]
71+
}
72+
}
73+
EOF
74+
75+
# Create hooks directory in ~/.multiplexer if it doesn't exist
76+
mkdir -p "${HOME}/.multiplexer/hooks"
77+
78+
# Copy hook script to ~/.multiplexer/hooks
79+
cp "$HOOK_DIR/send_status.sh" "${HOME}/.multiplexer/hooks/send_status.sh"
80+
chmod +x "${HOME}/.multiplexer/hooks/send_status.sh"
81+
82+
echo "✓ Claude Code hooks installed to $CLAUDE_SETTINGS"
83+
echo "✓ Hook script copied to ${HOME}/.multiplexer/hooks/send_status.sh"
84+
echo ""
85+
echo "Multiplexer status tracking is now active!"
86+
echo "Claude's working status will be displayed in the session manager TUI."
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
#!/usr/bin/env bash
2+
# Send hook event to multiplexer daemon
3+
# Usage: send_status.sh <event_type>
4+
5+
set -euo pipefail
6+
7+
EVENT_TYPE="$1"
8+
9+
# Validate HOME is set
10+
if [ -z "${HOME:-}" ]; then
11+
exit 0
12+
fi
13+
14+
SOCKET_PATH="${HOME}/.multiplexer/hooks.sock"
15+
MUX_SOCKET="${HOME}/.multiplexer/mux.sock"
16+
17+
# Validate sockets exist
18+
if [ ! -S "$SOCKET_PATH" ]; then
19+
# Hooks socket doesn't exist yet (daemon not started), exit silently
20+
exit 0
21+
fi
22+
23+
if [ ! -S "$MUX_SOCKET" ]; then
24+
# Daemon socket doesn't exist, exit silently
25+
exit 0
26+
fi
27+
28+
# Extract session name from worktree path
29+
# Worktree path format: ~/.multiplexer/worktrees/<session-name>
30+
# CLAUDE_CWD is provided by Claude Code hooks
31+
WORKTREE_PATH="${CLAUDE_CWD:-}"
32+
33+
if [ -z "$WORKTREE_PATH" ]; then
34+
# Not running in Claude Code context, exit silently
35+
exit 0
36+
fi
37+
38+
# Check if this is a multiplexer worktree
39+
if [[ "$WORKTREE_PATH" != *"/.multiplexer/worktrees/"* ]]; then
40+
# Not a multiplexer session, exit silently
41+
exit 0
42+
fi
43+
44+
# Parse session name from path (last component)
45+
SESSION_NAME=$(basename "$WORKTREE_PATH")
46+
47+
# Query daemon for session ID by name
48+
# Using the Unix socket API
49+
SESSION_ID=$(echo '{"type":"GetSessionIdByName","payload":{"name":"'"$SESSION_NAME"'"}}' | \
50+
nc -U "$MUX_SOCKET" 2>/dev/null | \
51+
jq -r '.payload.session_id // empty' 2>/dev/null)
52+
53+
if [ -z "$SESSION_ID" ]; then
54+
# Session not found in daemon, exit silently
55+
exit 0
56+
fi
57+
58+
# Build hook message
59+
MESSAGE=$(cat <<EOF
60+
{
61+
"session_id": "$SESSION_ID",
62+
"event": {"type": "$EVENT_TYPE"},
63+
"timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
64+
}
65+
EOF
66+
)
67+
68+
# Send to hook socket (with timeout)
69+
echo "$MESSAGE" | timeout 1s nc -U "$SOCKET_PATH" 2>/dev/null || true
70+
71+
exit 0

packages/multiplexer/src/api/client.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,28 @@ impl Client {
287287
_ => anyhow::bail!("Unexpected response"),
288288
}
289289
}
290+
291+
/// Send a prompt to a session (for hotkey triggers)
292+
///
293+
/// # Errors
294+
///
295+
/// Returns an error if the session is not found or the request fails.
296+
pub async fn send_prompt(&mut self, session_name: &str, prompt: &str) -> anyhow::Result<()> {
297+
let response = self
298+
.send_request(Request::SendPrompt {
299+
session: session_name.to_string(),
300+
prompt: prompt.to_string(),
301+
})
302+
.await?;
303+
304+
match response {
305+
Response::Ok => Ok(()),
306+
Response::Error { code, message} => {
307+
anyhow::bail!("[{code}] {message}")
308+
}
309+
_ => anyhow::bail!("Unexpected response"),
310+
}
311+
}
290312
}
291313

292314
#[async_trait]

packages/multiplexer/src/api/handlers.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,32 @@ pub async fn handle_request(request: Request, manager: &SessionManager) -> Respo
170170
}
171171
}
172172
},
173+
174+
Request::SendPrompt { session, prompt } => {
175+
match manager.send_prompt_to_session(&session, &prompt).await {
176+
Ok(()) => {
177+
tracing::info!(session = %session, "Prompt sent to session");
178+
Response::Ok
179+
}
180+
Err(e) => {
181+
tracing::error!(session = %session, error = %e, "Failed to send prompt");
182+
Response::Error {
183+
code: "SEND_PROMPT_ERROR".to_string(),
184+
message: e.to_string(),
185+
}
186+
}
187+
}
188+
}
189+
190+
Request::GetSessionIdByName { name } => match manager.get_session(&name).await {
191+
Some(session) => Response::SessionId {
192+
session_id: session.id.to_string(),
193+
},
194+
None => Response::Error {
195+
code: "NOT_FOUND".to_string(),
196+
message: format!("Session not found: {name}"),
197+
},
198+
},
173199
}
174200
}
175201

packages/multiplexer/src/api/protocol.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ pub enum Request {
3939

4040
/// Get recent repositories
4141
GetRecentRepos,
42+
43+
/// Send a prompt to a session (for hotkey triggers)
44+
SendPrompt { session: String, prompt: String },
45+
46+
/// Get session ID by name (for hook scripts)
47+
GetSessionIdByName { name: String },
4248
}
4349

4450
/// Recent repository entry with timestamp
@@ -155,6 +161,12 @@ pub enum Response {
155161
/// Access mode updated successfully
156162
AccessModeUpdated,
157163

164+
/// Session ID returned
165+
SessionId { session_id: String },
166+
167+
/// Generic success response
168+
Ok,
169+
158170
/// Error response
159171
Error { code: String, message: String },
160172
}

packages/multiplexer/src/api/server.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,51 @@ pub async fn run_daemon_with_options(enable_proxy: bool) -> anyhow::Result<()> {
9090

9191
tracing::info!(socket = %socket_path.display(), "Daemon listening");
9292

93+
// Start hook listener for Claude status updates
94+
let hook_socket_path = paths::hooks_socket_path();
95+
let (hook_listener, mut hook_rx) = crate::hooks::HookListener::new(hook_socket_path);
96+
97+
let hook_manager = Arc::clone(&manager);
98+
tokio::spawn(async move {
99+
if let Err(e) = hook_listener.start().await {
100+
tracing::error!("Hook listener failed: {}", e);
101+
}
102+
});
103+
104+
// Process hook messages
105+
let process_manager = Arc::clone(&manager);
106+
tokio::spawn(async move {
107+
use crate::core::ClaudeWorkingStatus;
108+
use crate::hooks::HookEvent;
109+
110+
while let Some(msg) = hook_rx.recv().await {
111+
let new_status = match msg.event {
112+
HookEvent::UserPromptSubmit => ClaudeWorkingStatus::Working,
113+
HookEvent::PreToolUse { .. } => ClaudeWorkingStatus::Working,
114+
HookEvent::PermissionRequest => ClaudeWorkingStatus::WaitingApproval,
115+
HookEvent::Stop => ClaudeWorkingStatus::WaitingInput,
116+
HookEvent::IdlePrompt => ClaudeWorkingStatus::Idle,
117+
};
118+
119+
if let Err(e) = process_manager
120+
.update_claude_status(msg.session_id, new_status)
121+
.await
122+
{
123+
tracing::error!(
124+
session_id = %msg.session_id,
125+
error = %e,
126+
"Failed to update Claude status from hook"
127+
);
128+
}
129+
}
130+
});
131+
132+
// Start CI status poller for GitHub PR checks
133+
let ci_poller = crate::ci::CIPoller::new(Arc::clone(&manager));
134+
tokio::spawn(async move {
135+
ci_poller.start().await;
136+
});
137+
93138
// Accept connections
94139
loop {
95140
match listener.accept().await {

packages/multiplexer/src/ci/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub mod poller;
2+
3+
pub use poller::CIPoller;

0 commit comments

Comments
 (0)