claude-telegram-mirror 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +331 -0
  2. package/dist/bot/commands.d.ts +41 -0
  3. package/dist/bot/commands.d.ts.map +1 -0
  4. package/dist/bot/commands.js +231 -0
  5. package/dist/bot/commands.js.map +1 -0
  6. package/dist/bot/formatting.d.ts +62 -0
  7. package/dist/bot/formatting.d.ts.map +1 -0
  8. package/dist/bot/formatting.js +295 -0
  9. package/dist/bot/formatting.js.map +1 -0
  10. package/dist/bot/telegram.d.ts +93 -0
  11. package/dist/bot/telegram.d.ts.map +1 -0
  12. package/dist/bot/telegram.js +378 -0
  13. package/dist/bot/telegram.js.map +1 -0
  14. package/dist/bot/types.d.ts +28 -0
  15. package/dist/bot/types.d.ts.map +1 -0
  16. package/dist/bot/types.js +5 -0
  17. package/dist/bot/types.js.map +1 -0
  18. package/dist/bridge/daemon.d.ts +93 -0
  19. package/dist/bridge/daemon.d.ts.map +1 -0
  20. package/dist/bridge/daemon.js +626 -0
  21. package/dist/bridge/daemon.js.map +1 -0
  22. package/dist/bridge/index.d.ts +10 -0
  23. package/dist/bridge/index.d.ts.map +1 -0
  24. package/dist/bridge/index.js +9 -0
  25. package/dist/bridge/index.js.map +1 -0
  26. package/dist/bridge/injector.d.ts +97 -0
  27. package/dist/bridge/injector.d.ts.map +1 -0
  28. package/dist/bridge/injector.js +289 -0
  29. package/dist/bridge/injector.js.map +1 -0
  30. package/dist/bridge/session.d.ts +108 -0
  31. package/dist/bridge/session.d.ts.map +1 -0
  32. package/dist/bridge/session.js +381 -0
  33. package/dist/bridge/session.js.map +1 -0
  34. package/dist/bridge/socket.d.ts +97 -0
  35. package/dist/bridge/socket.d.ts.map +1 -0
  36. package/dist/bridge/socket.js +436 -0
  37. package/dist/bridge/socket.js.map +1 -0
  38. package/dist/bridge/types.d.ts +38 -0
  39. package/dist/bridge/types.d.ts.map +1 -0
  40. package/dist/bridge/types.js +5 -0
  41. package/dist/bridge/types.js.map +1 -0
  42. package/dist/cli.d.ts +7 -0
  43. package/dist/cli.d.ts.map +1 -0
  44. package/dist/cli.js +332 -0
  45. package/dist/cli.js.map +1 -0
  46. package/dist/hooks/handler.d.ts +94 -0
  47. package/dist/hooks/handler.d.ts.map +1 -0
  48. package/dist/hooks/handler.js +431 -0
  49. package/dist/hooks/handler.js.map +1 -0
  50. package/dist/hooks/index.d.ts +8 -0
  51. package/dist/hooks/index.d.ts.map +1 -0
  52. package/dist/hooks/index.js +7 -0
  53. package/dist/hooks/index.js.map +1 -0
  54. package/dist/hooks/installer.d.ts +46 -0
  55. package/dist/hooks/installer.d.ts.map +1 -0
  56. package/dist/hooks/installer.js +317 -0
  57. package/dist/hooks/installer.js.map +1 -0
  58. package/dist/hooks/types.d.ts +88 -0
  59. package/dist/hooks/types.d.ts.map +1 -0
  60. package/dist/hooks/types.js +6 -0
  61. package/dist/hooks/types.js.map +1 -0
  62. package/dist/index.d.ts +19 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +20 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/service/doctor.d.ts +10 -0
  67. package/dist/service/doctor.d.ts.map +1 -0
  68. package/dist/service/doctor.js +424 -0
  69. package/dist/service/doctor.js.map +1 -0
  70. package/dist/service/manager.d.ts +48 -0
  71. package/dist/service/manager.d.ts.map +1 -0
  72. package/dist/service/manager.js +584 -0
  73. package/dist/service/manager.js.map +1 -0
  74. package/dist/service/setup.d.ts +10 -0
  75. package/dist/service/setup.d.ts.map +1 -0
  76. package/dist/service/setup.js +266 -0
  77. package/dist/service/setup.js.map +1 -0
  78. package/dist/utils/chunker.d.ts +24 -0
  79. package/dist/utils/chunker.d.ts.map +1 -0
  80. package/dist/utils/chunker.js +123 -0
  81. package/dist/utils/chunker.js.map +1 -0
  82. package/dist/utils/config.d.ts +48 -0
  83. package/dist/utils/config.d.ts.map +1 -0
  84. package/dist/utils/config.js +154 -0
  85. package/dist/utils/config.js.map +1 -0
  86. package/dist/utils/logger.d.ts +7 -0
  87. package/dist/utils/logger.d.ts.map +1 -0
  88. package/dist/utils/logger.js +28 -0
  89. package/dist/utils/logger.js.map +1 -0
  90. package/package.json +88 -0
  91. package/postinstall.cjs +76 -0
  92. package/scripts/claude-wrapper.sh +122 -0
  93. package/scripts/doctor.sh +433 -0
  94. package/scripts/get-chat-id.sh +64 -0
  95. package/scripts/global-hooks.sh +39 -0
  96. package/scripts/install.sh +831 -0
  97. package/scripts/start-daemon.sh +49 -0
  98. package/scripts/telegram-hook.sh +449 -0
  99. package/scripts/uninstall.sh +261 -0
@@ -0,0 +1,49 @@
1
+ #!/bin/bash
2
+ #
3
+ # Start the Claude Code Telegram Mirror daemon
4
+ #
5
+ # This script sources environment variables from ~/.telegram-env
6
+ # to work around .bashrc's non-interactive shell exit.
7
+ #
8
+ # Usage:
9
+ # ./start-daemon.sh # Run in foreground
10
+ # nohup ./start-daemon.sh & # Run in background
11
+ #
12
+
13
+ set -e
14
+
15
+ # Get script directory
16
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ PACKAGE_DIR="$(dirname "$SCRIPT_DIR")"
18
+
19
+ # Source environment file (bypasses .bashrc non-interactive exit)
20
+ if [[ -f "$HOME/.telegram-env" ]]; then
21
+ source "$HOME/.telegram-env"
22
+ elif [[ -f "$PACKAGE_DIR/.env" ]]; then
23
+ source "$PACKAGE_DIR/.env"
24
+ else
25
+ echo "⚠️ No environment file found."
26
+ echo ""
27
+ echo "Create ~/.telegram-env with:"
28
+ echo ' export TELEGRAM_BOT_TOKEN="your-token"'
29
+ echo ' export TELEGRAM_CHAT_ID="your-chat-id"'
30
+ echo ' export TELEGRAM_MIRROR=true'
31
+ echo ""
32
+ echo "Or create $PACKAGE_DIR/.env with the same exports."
33
+ exit 1
34
+ fi
35
+
36
+ # Verify required variables
37
+ if [[ -z "$TELEGRAM_BOT_TOKEN" ]]; then
38
+ echo "❌ TELEGRAM_BOT_TOKEN is not set"
39
+ exit 1
40
+ fi
41
+
42
+ if [[ -z "$TELEGRAM_CHAT_ID" ]]; then
43
+ echo "❌ TELEGRAM_CHAT_ID is not set"
44
+ exit 1
45
+ fi
46
+
47
+ # Change to package directory and start
48
+ cd "$PACKAGE_DIR"
49
+ exec node dist/cli.js start
@@ -0,0 +1,449 @@
1
+ #!/bin/bash
2
+ #
3
+ # Claude Code Telegram Hook
4
+ # Captures hook events and forwards to bridge daemon
5
+ #
6
+ # Usage: This script is called by Claude Code's hook system
7
+ # It reads JSON from stdin and forwards to the bridge
8
+ #
9
+ # The hook is ENABLED when the bridge daemon is running (socket exists).
10
+ # No environment variable needed - just start the bridge!
11
+ #
12
+
13
+ # Debug logging disabled by default - set TELEGRAM_HOOK_DEBUG=1 to enable
14
+ TELEGRAM_HOOK_DEBUG="${TELEGRAM_HOOK_DEBUG:-0}"
15
+
16
+ set -e
17
+
18
+ # Trap to ensure clean exit and helpful error message
19
+ trap 'debug_log "Script exiting with code $?"' EXIT
20
+
21
+ # Get the directory of this script
22
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+ PACKAGE_DIR="$(dirname "$SCRIPT_DIR")"
24
+ CONFIG_DIR="$HOME/.config/claude-telegram-mirror"
25
+
26
+ # Socket path for bridge communication (now in user config dir, not /tmp)
27
+ SOCKET_PATH="${TELEGRAM_BRIDGE_SOCKET:-$CONFIG_DIR/bridge.sock}"
28
+
29
+ # Debug logging (set TELEGRAM_HOOK_DEBUG=1 to enable)
30
+ debug_log() {
31
+ if [[ "${TELEGRAM_HOOK_DEBUG}" == "1" ]]; then
32
+ # Log to config dir instead of world-readable /tmp
33
+ mkdir -p "$CONFIG_DIR" 2>/dev/null || true
34
+ echo "[telegram-hook] $(date '+%Y-%m-%d %H:%M:%S') $1" >> "$CONFIG_DIR/hook-debug.log"
35
+ fi
36
+ }
37
+
38
+ debug_log "Hook called, checking socket at $SOCKET_PATH"
39
+
40
+ # Check if bridge is running (socket exists = enabled)
41
+ if [[ ! -S "$SOCKET_PATH" ]]; then
42
+ # Bridge not running, pass through silently
43
+ debug_log "Bridge not running (no socket), passing through"
44
+ cat
45
+ exit 0
46
+ fi
47
+
48
+ debug_log "Bridge socket found, processing..."
49
+
50
+ # Read stdin into variable
51
+ INPUT=$(cat)
52
+
53
+ # If empty, just exit
54
+ if [[ -z "$INPUT" ]]; then
55
+ debug_log "Empty input, exiting"
56
+ exit 0
57
+ fi
58
+
59
+ # Log raw input for debugging
60
+ debug_log "Raw input: $INPUT"
61
+
62
+ # Parse hook type from input (field is "hook_event_name" not "type")
63
+ HOOK_TYPE=$(echo "$INPUT" | jq -r '.hook_event_name // .type // empty' 2>/dev/null || echo "")
64
+ debug_log "Hook type: $HOOK_TYPE"
65
+
66
+ # Use Claude's native session_id from the hook input - this is the canonical session identifier
67
+ # This ensures all events from the same Claude session go to the same Telegram topic
68
+ CLAUDE_SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "")
69
+ debug_log "Claude session_id: $CLAUDE_SESSION_ID"
70
+
71
+ # Session tracking - detect first event per Claude session
72
+ get_session_tracking_path() {
73
+ mkdir -p "$CONFIG_DIR" 2>/dev/null || true
74
+ # Use Claude's session_id as the key - this is stable for the entire session
75
+ if [[ -n "$CLAUDE_SESSION_ID" ]]; then
76
+ echo "$CONFIG_DIR/.session_active_${CLAUDE_SESSION_ID}"
77
+ else
78
+ # Fallback to tmux pane if no Claude session_id
79
+ local session_key=""
80
+ if [[ -n "$TMUX" ]]; then
81
+ session_key=$(tmux display-message -p '#{session_id}_#{window_id}_#{pane_id}' 2>/dev/null || echo "")
82
+ fi
83
+ if [[ -z "$session_key" ]]; then
84
+ session_key=$(tty 2>/dev/null | tr '/' '_' || echo "default")
85
+ fi
86
+ local safe_id=$(echo "$session_key" | tr -cd '[:alnum:]_')
87
+ echo "$CONFIG_DIR/.session_active_${safe_id}"
88
+ fi
89
+ }
90
+
91
+ is_first_event() {
92
+ local tracking_path=$(get_session_tracking_path)
93
+ if [[ -f "$tracking_path" ]]; then
94
+ return 1 # Not first event
95
+ fi
96
+ # Mark session as started
97
+ echo "$SESSION_ID" > "$tracking_path"
98
+ return 0 # First event
99
+ }
100
+
101
+ clear_session_tracking() {
102
+ local tracking_path=$(get_session_tracking_path)
103
+ rm -f "$tracking_path" 2>/dev/null || true
104
+ }
105
+
106
+ # Use Claude's native session_id, or generate one as fallback
107
+ SESSION_ID="${CLAUDE_SESSION_ID:-$(date +%s)-$$}"
108
+ debug_log "Using session ID: $SESSION_ID"
109
+
110
+ # Get tmux info if available
111
+ # Extracts socket path from $TMUX env var for explicit targeting
112
+ get_tmux_info() {
113
+ if [[ -z "$TMUX" ]]; then
114
+ echo "{}"
115
+ return
116
+ fi
117
+
118
+ # $TMUX format: /path/to/socket,pid,index
119
+ # Extract socket path (everything before first comma)
120
+ local socket_path="${TMUX%%,*}"
121
+
122
+ local session=$(tmux display-message -p "#S" 2>/dev/null || echo "")
123
+ local pane=$(tmux display-message -p "#P" 2>/dev/null || echo "")
124
+ local window=$(tmux display-message -p "#I" 2>/dev/null || echo "")
125
+
126
+ if [[ -n "$session" && -n "$window" && -n "$pane" ]]; then
127
+ local target="${session}:${window}.${pane}"
128
+ jq -cn \
129
+ --arg session "$session" \
130
+ --arg pane "$pane" \
131
+ --arg target "$target" \
132
+ --arg socket "$socket_path" \
133
+ '{tmuxSession: $session, tmuxPane: $pane, tmuxTarget: $target, tmuxSocket: $socket}'
134
+ else
135
+ echo "{}"
136
+ fi
137
+ }
138
+
139
+ # Send message to bridge via netcat (fast)
140
+ # Works on both Linux (GNU netcat) and macOS (BSD netcat)
141
+ send_to_bridge() {
142
+ local message="$1"
143
+ debug_log "Sending to bridge: ${message:0:100}..."
144
+
145
+ if command -v nc &> /dev/null; then
146
+ local nc_stderr
147
+ local nc_exit
148
+ # Try GNU netcat first (-q0 = quit after EOF), fall back to BSD netcat (no -q flag)
149
+ if nc -h 2>&1 | grep -q '\-q'; then
150
+ # GNU netcat - supports -q flag
151
+ nc_stderr=$(echo "$message" | nc -U -q0 "$SOCKET_PATH" 2>&1)
152
+ nc_exit=$?
153
+ else
154
+ # BSD netcat (macOS) - no -q flag needed, closes on EOF
155
+ nc_stderr=$(echo "$message" | nc -U "$SOCKET_PATH" 2>&1)
156
+ nc_exit=$?
157
+ fi
158
+ debug_log "nc result: exit=$nc_exit, stderr=$nc_stderr"
159
+ if [[ $nc_exit -ne 0 ]]; then
160
+ debug_log "nc FAILED! socket=$SOCKET_PATH"
161
+ fi
162
+ elif [[ -S "$SOCKET_PATH" ]]; then
163
+ echo "$message" > "$SOCKET_PATH" 2>/dev/null || true
164
+ debug_log "Used direct write to socket"
165
+ else
166
+ debug_log "No nc and no socket!"
167
+ fi
168
+ }
169
+
170
+ # ============================================
171
+ # REAL-TIME TRANSCRIPT SYNC
172
+ # Extract new assistant text on EVERY hook event
173
+ # This provides real-time mirroring of Claude's responses
174
+ # ============================================
175
+ sync_new_assistant_text() {
176
+ local transcript_path=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null)
177
+
178
+ if [[ -z "$transcript_path" || ! -f "$transcript_path" ]]; then
179
+ return
180
+ fi
181
+
182
+ # Use separate state file for continuous sync (different from Stop hook)
183
+ local state_file="$CONFIG_DIR/.sync_${SESSION_ID}"
184
+ local last_line=0
185
+ if [[ -f "$state_file" ]]; then
186
+ last_line=$(cat "$state_file" 2>/dev/null || echo 0)
187
+ fi
188
+
189
+ local current_line=$(wc -l < "$transcript_path")
190
+
191
+ # No new lines
192
+ if [[ $current_line -le $last_line ]]; then
193
+ return
194
+ fi
195
+
196
+ local new_count=$((current_line - last_line))
197
+ local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
198
+ local found_text=0
199
+
200
+ # Process new lines for assistant text messages
201
+ while IFS= read -r line; do
202
+ local msg_type=$(echo "$line" | jq -r '.type // empty' 2>/dev/null)
203
+
204
+ if [[ "$msg_type" == "assistant" ]]; then
205
+ # Extract text content (skip tool_use blocks)
206
+ local text=$(echo "$line" | jq -r '.message.content[]? | select(.type == "text") | .text' 2>/dev/null)
207
+
208
+ if [[ -n "$text" && ${#text} -gt 10 ]]; then
209
+ debug_log "Sync: Found assistant text (${#text} chars)"
210
+ # Use printf + pipe to handle large content (avoids "Argument list too long")
211
+ local msg=$(printf '%s' "$text" | jq -Rsc \
212
+ --arg type "agent_response" \
213
+ --arg sessionId "$SESSION_ID" \
214
+ --arg timestamp "$timestamp" \
215
+ '{type: $type, sessionId: $sessionId, timestamp: $timestamp, content: .}')
216
+ send_to_bridge "$msg"
217
+ found_text=1
218
+ fi
219
+ fi
220
+ done < <(tail -n "$new_count" "$transcript_path")
221
+
222
+ # Update state (always, even if no text found - to track line position)
223
+ echo "$current_line" > "$state_file"
224
+
225
+ if [[ $found_text -eq 1 ]]; then
226
+ debug_log "Sync: Sent new assistant text"
227
+ fi
228
+ }
229
+
230
+ # ============================================
231
+ # REAL-TIME SYNC: DISABLED - causing replay issues
232
+ # TODO: Fix race conditions before re-enabling
233
+ # ============================================
234
+ # sync_new_assistant_text &
235
+
236
+ # Format bridge message based on hook type
237
+ format_message() {
238
+ local hook_type="$1"
239
+ local input="$2"
240
+ local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
241
+
242
+ case "$hook_type" in
243
+ "PreToolUse")
244
+ local tool_name=$(echo "$input" | jq -r '.tool_name // "unknown"')
245
+ local tool_input=$(echo "$input" | jq -c '.tool_input // {}')
246
+
247
+ # Send tool info (not just dangerous ones - for visibility)
248
+ jq -cn \
249
+ --arg type "tool_start" \
250
+ --arg sessionId "$SESSION_ID" \
251
+ --arg timestamp "$timestamp" \
252
+ --arg tool "$tool_name" \
253
+ --argjson input "$tool_input" \
254
+ '{type: $type, sessionId: $sessionId, timestamp: $timestamp, content: "Tool: \($tool)", metadata: {tool: $tool, input: $input}}'
255
+ ;;
256
+
257
+ "PostToolUse")
258
+ local tool_name=$(echo "$input" | jq -r '.tool_name // "unknown"')
259
+ local tool_output=$(echo "$input" | jq -r '.tool_output // ""' | head -c 2000)
260
+
261
+ # Only send significant outputs
262
+ if [[ ${#tool_output} -gt 10 ]]; then
263
+ jq -cn \
264
+ --arg type "tool_result" \
265
+ --arg sessionId "$SESSION_ID" \
266
+ --arg timestamp "$timestamp" \
267
+ --arg tool "$tool_name" \
268
+ --arg output "$tool_output" \
269
+ '{type: $type, sessionId: $sessionId, timestamp: $timestamp, content: $output, metadata: {tool: $tool}}'
270
+ fi
271
+ ;;
272
+
273
+ "Notification")
274
+ local message=$(echo "$input" | jq -r '.message // ""')
275
+ local notification_type=$(echo "$input" | jq -r '.notification_type // ""')
276
+ local level=$(echo "$input" | jq -r '.level // "info"')
277
+
278
+ # Skip idle_prompt notifications - they're just noise
279
+ if [[ "$notification_type" == "idle_prompt" ]]; then
280
+ debug_log "Skipping idle_prompt notification"
281
+ return
282
+ fi
283
+
284
+ if [[ -n "$message" ]]; then
285
+ jq -cn \
286
+ --arg type "agent_response" \
287
+ --arg sessionId "$SESSION_ID" \
288
+ --arg timestamp "$timestamp" \
289
+ --arg message "$message" \
290
+ --arg level "$level" \
291
+ '{type: $type, sessionId: $sessionId, timestamp: $timestamp, content: $message, metadata: {level: $level}}'
292
+ fi
293
+ ;;
294
+
295
+ "Stop")
296
+ local transcript_path=$(echo "$input" | jq -r '.transcript_path // ""')
297
+ debug_log "Stop: transcript_path='$transcript_path'"
298
+ debug_log "Stop: file exists=$(test -f "$transcript_path" && echo yes || echo no)"
299
+
300
+ # Extract NEW text content since last Stop
301
+ # Track what we've sent to avoid duplicates
302
+ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
303
+ debug_log "Reading transcript: $transcript_path"
304
+
305
+ # Track last processed line using a state file
306
+ local state_file="$CONFIG_DIR/.last_line_${SESSION_ID}"
307
+ local last_line=0
308
+ if [[ -f "$state_file" ]]; then
309
+ last_line=$(cat "$state_file" 2>/dev/null || echo 0)
310
+ fi
311
+
312
+ local current_line=$(wc -l < "$transcript_path")
313
+ debug_log "Last processed: $last_line, Current: $current_line"
314
+
315
+ # Only process new lines
316
+ if [[ $current_line -gt $last_line ]]; then
317
+ local new_lines=$((current_line - last_line))
318
+ local all_text=""
319
+
320
+ # Process only NEW lines
321
+ while IFS= read -r line; do
322
+ local text=$(echo "$line" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "text") | .text' 2>/dev/null)
323
+ if [[ -n "$text" ]]; then
324
+ if [[ -n "$all_text" ]]; then
325
+ all_text="${all_text}
326
+
327
+ ${text}"
328
+ else
329
+ all_text="$text"
330
+ fi
331
+ fi
332
+ done < <(tail -n "$new_lines" "$transcript_path")
333
+
334
+ debug_log "New text length: ${#all_text}"
335
+ debug_log "New text preview: ${all_text:0:200}"
336
+
337
+ if [[ -n "$all_text" ]]; then
338
+ # Use printf + pipe to handle large content (avoids "Argument list too long")
339
+ local agent_msg=$(printf '%s' "$all_text" | jq -Rsc \
340
+ --arg type "agent_response" \
341
+ --arg sessionId "$SESSION_ID" \
342
+ --arg timestamp "$timestamp" \
343
+ '{type: $type, sessionId: $sessionId, timestamp: $timestamp, content: .}')
344
+ debug_log "Stop: sending agent_response: ${agent_msg:0:150}"
345
+ echo "$agent_msg"
346
+ else
347
+ debug_log "Stop: NO text extracted from new lines"
348
+ fi
349
+
350
+ # Update state
351
+ echo "$current_line" > "$state_file"
352
+ else
353
+ debug_log "Stop: no new lines (current=$current_line, last=$last_line)"
354
+ fi
355
+ else
356
+ debug_log "Stop: transcript not accessible (path='$transcript_path', exists=$(test -f "$transcript_path" && echo yes || echo no))"
357
+ fi
358
+
359
+ # DON'T send session_end on every Stop - Claude fires Stop after every turn!
360
+ # The session is still active. Only send a turn_complete notification.
361
+ # Session end should happen when user explicitly exits or connection drops.
362
+ local turn_msg=$(jq -cn \
363
+ --arg type "turn_complete" \
364
+ --arg sessionId "$SESSION_ID" \
365
+ --arg timestamp "$timestamp" \
366
+ '{type: $type, sessionId: $sessionId, timestamp: $timestamp, content: "Turn complete"}')
367
+ debug_log "Stop: sending turn_complete: $turn_msg"
368
+ echo "$turn_msg"
369
+ ;;
370
+
371
+ "UserPromptSubmit")
372
+ local prompt=$(echo "$input" | jq -r '.prompt // ""')
373
+
374
+ if [[ -n "$prompt" ]]; then
375
+ jq -cn \
376
+ --arg type "user_input" \
377
+ --arg sessionId "$SESSION_ID" \
378
+ --arg timestamp "$timestamp" \
379
+ --arg prompt "$prompt" \
380
+ '{type: $type, sessionId: $sessionId, timestamp: $timestamp, content: $prompt, metadata: {source: "cli"}}'
381
+ fi
382
+ ;;
383
+
384
+ "PreCompact")
385
+ # Fires just before context compaction (manual or auto)
386
+ local trigger=$(echo "$input" | jq -r '.trigger // "auto"')
387
+ local custom_instructions=$(echo "$input" | jq -r '.custom_instructions // ""')
388
+
389
+ debug_log "PreCompact: trigger=$trigger"
390
+
391
+ jq -cn \
392
+ --arg type "pre_compact" \
393
+ --arg sessionId "$SESSION_ID" \
394
+ --arg timestamp "$timestamp" \
395
+ --arg trigger "$trigger" \
396
+ --arg instructions "$custom_instructions" \
397
+ '{type: $type, sessionId: $sessionId, timestamp: $timestamp, content: "Context compaction starting", metadata: {trigger: $trigger, custom_instructions: $instructions}}'
398
+ ;;
399
+ esac
400
+ }
401
+
402
+ # Check if this is the first event of this session
403
+ if is_first_event; then
404
+ debug_log "First event of session - sending session_start"
405
+
406
+ # Get tmux info and hostname
407
+ TMUX_INFO=$(get_tmux_info)
408
+ HOSTNAME=$(hostname)
409
+ PROJECT_DIR=$(pwd)
410
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
411
+
412
+ # Send session_start message (compact JSON for NDJSON protocol)
413
+ SESSION_START=$(jq -cn \
414
+ --arg type "session_start" \
415
+ --arg sessionId "$SESSION_ID" \
416
+ --arg timestamp "$TIMESTAMP" \
417
+ --arg hostname "$HOSTNAME" \
418
+ --arg projectDir "$PROJECT_DIR" \
419
+ --argjson tmux "$TMUX_INFO" \
420
+ '{type: $type, sessionId: $sessionId, timestamp: $timestamp, content: "Claude Code session started", metadata: ({hostname: $hostname, projectDir: $projectDir} + $tmux)}')
421
+
422
+ send_to_bridge "$SESSION_START"
423
+ fi
424
+
425
+ # Clear session tracking on Stop event
426
+ # Note: We keep the session active even after Stop because Notification events
427
+ # may come after Stop and should still go to the same session thread
428
+ if [[ "$HOOK_TYPE" == "Stop" ]]; then
429
+ debug_log "Stop event received (keeping session for potential follow-up events)"
430
+ # Don't clear immediately - let the session timeout naturally or clear on next UserPromptSubmit
431
+ fi
432
+
433
+ # Format and send message(s)
434
+ if [[ -n "$HOOK_TYPE" ]]; then
435
+ # format_message may return multiple JSON lines (e.g., Stop returns responses + session_end)
436
+ MESSAGES=$(format_message "$HOOK_TYPE" "$INPUT")
437
+
438
+ if [[ -n "$MESSAGES" ]]; then
439
+ # Send each line as a separate message
440
+ while IFS= read -r msg; do
441
+ if [[ -n "$msg" ]]; then
442
+ send_to_bridge "$msg"
443
+ fi
444
+ done <<< "$MESSAGES"
445
+ fi
446
+ fi
447
+
448
+ # Pass through original input for Claude Code
449
+ echo "$INPUT"