@hoverlover/cc-discord 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.
- package/.claude/settings.template.json +94 -0
- package/.env.example +41 -0
- package/.env.relay.example +46 -0
- package/.env.worker.example +40 -0
- package/README.md +313 -0
- package/hooks/check-discord-messages.ts +204 -0
- package/hooks/cleanup-attachment.ts +47 -0
- package/hooks/safe-bash.ts +157 -0
- package/hooks/steer-send.ts +108 -0
- package/hooks/track-activity.ts +220 -0
- package/memory/README.md +60 -0
- package/memory/core/MemoryCoordinator.ts +703 -0
- package/memory/core/MemoryStore.ts +72 -0
- package/memory/core/session-key.ts +14 -0
- package/memory/core/types.ts +59 -0
- package/memory/index.ts +19 -0
- package/memory/providers/sqlite/SqliteMemoryStore.ts +838 -0
- package/memory/providers/sqlite/index.ts +1 -0
- package/package.json +45 -0
- package/prompts/autoreply-system.md +32 -0
- package/prompts/channel-system.md +22 -0
- package/prompts/orchestrator-system.md +56 -0
- package/scripts/channel-agent.sh +159 -0
- package/scripts/generate-settings.sh +17 -0
- package/scripts/load-env.sh +79 -0
- package/scripts/migrate-memory-to-channel-keys.ts +148 -0
- package/scripts/orchestrator.sh +325 -0
- package/scripts/parse-claude-stream.ts +349 -0
- package/scripts/start-orchestrator.sh +82 -0
- package/scripts/start-relay.sh +17 -0
- package/scripts/start.sh +175 -0
- package/server/attachment.ts +182 -0
- package/server/busy-notify.ts +69 -0
- package/server/config.ts +121 -0
- package/server/db.ts +249 -0
- package/server/index.ts +311 -0
- package/server/memory.ts +88 -0
- package/server/messages.ts +111 -0
- package/server/trace-thread.ts +340 -0
- package/server/typing.ts +101 -0
- package/tools/memory-inspect.ts +94 -0
- package/tools/memory-smoke.ts +173 -0
- package/tools/send-discord +2 -0
- package/tools/send-discord.ts +82 -0
- package/tools/wait-for-discord-messages +2 -0
- package/tools/wait-for-discord-messages.ts +369 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# orchestrator.sh — Discover Discord channels and manage one channel-agent per channel.
|
|
3
|
+
#
|
|
4
|
+
# This is a pure shell script (no Claude instance). It:
|
|
5
|
+
# 1. Queries the relay API for active channels
|
|
6
|
+
# 2. Spawns one channel-agent.sh per channel as a child process
|
|
7
|
+
# 3. Monitors children and restarts any that exit
|
|
8
|
+
# 4. On SIGTERM/SIGINT, kills all children and exits cleanly
|
|
9
|
+
#
|
|
10
|
+
# Usage: orchestrator.sh
|
|
11
|
+
#
|
|
12
|
+
# Environment:
|
|
13
|
+
# RELAY_HOST, RELAY_PORT, RELAY_API_TOKEN — relay server coordinates
|
|
14
|
+
# HEALTH_CHECK_INTERVAL — seconds between health checks (default: 30)
|
|
15
|
+
# AGENT_RESTART_DELAY — seconds to wait before restarting a dead agent (default: 5)
|
|
16
|
+
# STUCK_AGENT_THRESHOLD — seconds without heartbeat + unread msgs = stuck (default: 900)
|
|
17
|
+
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
|
|
20
|
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
21
|
+
source "$ROOT_DIR/scripts/load-env.sh"
|
|
22
|
+
|
|
23
|
+
# Load relay connection env vars
|
|
24
|
+
load_env_file "$ROOT_DIR/.env.worker"
|
|
25
|
+
load_env_file "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env.worker"
|
|
26
|
+
load_env_file "$ROOT_DIR/.env"
|
|
27
|
+
load_env_file "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env"
|
|
28
|
+
|
|
29
|
+
# Ensure bun/curl are findable
|
|
30
|
+
export PATH="$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
31
|
+
|
|
32
|
+
RELAY_HOST="${RELAY_HOST:-127.0.0.1}"
|
|
33
|
+
RELAY_PORT="${RELAY_PORT:-3199}"
|
|
34
|
+
RELAY_API_TOKEN="${RELAY_API_TOKEN:-}"
|
|
35
|
+
HEALTH_CHECK_INTERVAL="${HEALTH_CHECK_INTERVAL:-30}"
|
|
36
|
+
AGENT_RESTART_DELAY="${AGENT_RESTART_DELAY:-5}"
|
|
37
|
+
|
|
38
|
+
RELAY_URL="http://${RELAY_HOST}:${RELAY_PORT}"
|
|
39
|
+
|
|
40
|
+
# Track child PIDs and names using parallel indexed arrays.
|
|
41
|
+
# (Associative arrays lose state inside piped subshells.)
|
|
42
|
+
KNOWN_CHANNEL_IDS=()
|
|
43
|
+
KNOWN_CHANNEL_NAMES=()
|
|
44
|
+
KNOWN_CHANNEL_PIDS=()
|
|
45
|
+
|
|
46
|
+
log() {
|
|
47
|
+
echo "[orchestrator] $(date '+%H:%M:%S') $*"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Return the array index for a channel_id, or -1 if not found
|
|
51
|
+
find_channel_index() {
|
|
52
|
+
local target="$1"
|
|
53
|
+
for i in "${!KNOWN_CHANNEL_IDS[@]}"; do
|
|
54
|
+
if [ "${KNOWN_CHANNEL_IDS[$i]}" = "$target" ]; then
|
|
55
|
+
echo "$i"
|
|
56
|
+
return
|
|
57
|
+
fi
|
|
58
|
+
done
|
|
59
|
+
echo "-1"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Clean shutdown: kill all channel agents
|
|
63
|
+
cleanup() {
|
|
64
|
+
log "Shutting down -- killing all channel agents..."
|
|
65
|
+
for i in "${!KNOWN_CHANNEL_PIDS[@]}"; do
|
|
66
|
+
local pid="${KNOWN_CHANNEL_PIDS[$i]}"
|
|
67
|
+
local name="${KNOWN_CHANNEL_NAMES[$i]:-unknown}"
|
|
68
|
+
if [ "$pid" -gt 0 ] && kill -0 "$pid" 2>/dev/null; then
|
|
69
|
+
log " Killing #${name} (PID $pid)"
|
|
70
|
+
kill -TERM "$pid" 2>/dev/null || true
|
|
71
|
+
fi
|
|
72
|
+
done
|
|
73
|
+
|
|
74
|
+
# Wait briefly for graceful shutdown, then force-kill stragglers
|
|
75
|
+
sleep 2
|
|
76
|
+
for i in "${!KNOWN_CHANNEL_PIDS[@]}"; do
|
|
77
|
+
local pid="${KNOWN_CHANNEL_PIDS[$i]}"
|
|
78
|
+
if [ "$pid" -gt 0 ] && kill -0 "$pid" 2>/dev/null; then
|
|
79
|
+
kill -9 "$pid" 2>/dev/null || true
|
|
80
|
+
fi
|
|
81
|
+
done
|
|
82
|
+
|
|
83
|
+
log "All channel agents stopped."
|
|
84
|
+
exit 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
trap cleanup SIGTERM SIGINT
|
|
88
|
+
|
|
89
|
+
# Query relay API and output "id name" lines
|
|
90
|
+
discover_channels_lines() {
|
|
91
|
+
local response
|
|
92
|
+
response=$(curl -s --max-time 10 \
|
|
93
|
+
-H "x-api-token: ${RELAY_API_TOKEN}" \
|
|
94
|
+
"${RELAY_URL}/api/channels" 2>/dev/null) || {
|
|
95
|
+
log "WARNING: Failed to reach relay at ${RELAY_URL}"
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
echo "$response" | bun -e "
|
|
100
|
+
const input = await Bun.stdin.text();
|
|
101
|
+
try {
|
|
102
|
+
const data = JSON.parse(input);
|
|
103
|
+
if (data.success && Array.isArray(data.channels)) {
|
|
104
|
+
for (const ch of data.channels) {
|
|
105
|
+
console.log(ch.id + ' ' + (ch.name || 'channel-' + ch.id));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {}
|
|
109
|
+
"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Start a channel agent as a child process
|
|
113
|
+
start_channel_agent() {
|
|
114
|
+
local channel_id="$1"
|
|
115
|
+
local channel_name="$2"
|
|
116
|
+
|
|
117
|
+
log "Starting agent for #${channel_name} (${channel_id})"
|
|
118
|
+
bash "$ROOT_DIR/scripts/channel-agent.sh" "$channel_id" "$channel_name" &
|
|
119
|
+
local pid=$!
|
|
120
|
+
|
|
121
|
+
local idx
|
|
122
|
+
idx=$(find_channel_index "$channel_id")
|
|
123
|
+
if [ "$idx" -ge 0 ]; then
|
|
124
|
+
KNOWN_CHANNEL_PIDS[$idx]=$pid
|
|
125
|
+
else
|
|
126
|
+
KNOWN_CHANNEL_IDS+=("$channel_id")
|
|
127
|
+
KNOWN_CHANNEL_NAMES+=("$channel_name")
|
|
128
|
+
KNOWN_CHANNEL_PIDS+=("$pid")
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
log " Agent #${channel_name} started (PID $pid)"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Check if a channel agent PID is still running
|
|
135
|
+
is_agent_alive() {
|
|
136
|
+
local pid="$1"
|
|
137
|
+
[ "$pid" -gt 0 ] && kill -0 "$pid" 2>/dev/null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# Kill and restart a stuck agent
|
|
141
|
+
kill_stuck_agent() {
|
|
142
|
+
local channel_id="$1"
|
|
143
|
+
local idx
|
|
144
|
+
idx=$(find_channel_index "$channel_id")
|
|
145
|
+
if [ "$idx" -lt 0 ]; then
|
|
146
|
+
log "WARNING: stuck agent $channel_id not in known list — skipping"
|
|
147
|
+
return
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
local pid="${KNOWN_CHANNEL_PIDS[$idx]}"
|
|
151
|
+
local name="${KNOWN_CHANNEL_NAMES[$idx]}"
|
|
152
|
+
|
|
153
|
+
if is_agent_alive "$pid"; then
|
|
154
|
+
log "Killing stuck agent #${name} (${channel_id}, PID $pid)"
|
|
155
|
+
kill -TERM "$pid" 2>/dev/null || true
|
|
156
|
+
sleep 2
|
|
157
|
+
if is_agent_alive "$pid"; then
|
|
158
|
+
kill -9 "$pid" 2>/dev/null || true
|
|
159
|
+
fi
|
|
160
|
+
wait "$pid" 2>/dev/null || true
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
log "Restarting stuck agent #${name} in ${AGENT_RESTART_DELAY}s..."
|
|
164
|
+
sleep "$AGENT_RESTART_DELAY"
|
|
165
|
+
start_channel_agent "$channel_id" "$name"
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Query /api/agent-health for stuck agents and restart them.
|
|
169
|
+
# An agent is truly stuck ONLY if ALL THREE conditions are met:
|
|
170
|
+
# 1. Heartbeat stale (>threshold) — agent isn't polling
|
|
171
|
+
# 2. Unread messages waiting — someone is expecting a response
|
|
172
|
+
# 3. Log file stale (>threshold) — agent isn't producing output either
|
|
173
|
+
# This avoids false kills during long-running tasks that are still producing output.
|
|
174
|
+
STUCK_THRESHOLD="${STUCK_AGENT_THRESHOLD:-900}" # default 15 min
|
|
175
|
+
|
|
176
|
+
# Check how many seconds since a file was last modified.
|
|
177
|
+
# Returns 999999 if file doesn't exist.
|
|
178
|
+
file_age_seconds() {
|
|
179
|
+
local filepath="$1"
|
|
180
|
+
if [ ! -f "$filepath" ]; then
|
|
181
|
+
echo 999999
|
|
182
|
+
return
|
|
183
|
+
fi
|
|
184
|
+
# macOS stat: -f %m gives mtime as epoch seconds
|
|
185
|
+
# Linux stat: -c %Y gives mtime as epoch seconds
|
|
186
|
+
local mtime
|
|
187
|
+
if stat -f %m "$filepath" >/dev/null 2>&1; then
|
|
188
|
+
mtime=$(stat -f %m "$filepath")
|
|
189
|
+
else
|
|
190
|
+
mtime=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
|
|
191
|
+
fi
|
|
192
|
+
local now
|
|
193
|
+
now=$(date +%s)
|
|
194
|
+
echo $(( now - mtime ))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# Map channel_id to its log file path
|
|
198
|
+
channel_log_file() {
|
|
199
|
+
local channel_id="$1"
|
|
200
|
+
local idx
|
|
201
|
+
idx=$(find_channel_index "$channel_id")
|
|
202
|
+
if [ "$idx" -lt 0 ]; then
|
|
203
|
+
echo ""
|
|
204
|
+
return
|
|
205
|
+
fi
|
|
206
|
+
local name="${KNOWN_CHANNEL_NAMES[$idx]}"
|
|
207
|
+
local cid="${KNOWN_CHANNEL_IDS[$idx]}"
|
|
208
|
+
echo "${LOG_DIR}/channel-${name}-${cid}.log"
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
check_stuck_agents() {
|
|
212
|
+
local response
|
|
213
|
+
response=$(curl -s --max-time 10 \
|
|
214
|
+
-H "x-api-token: ${RELAY_API_TOKEN}" \
|
|
215
|
+
"${RELAY_URL}/api/agent-health?stale_threshold=${STUCK_THRESHOLD}" 2>/dev/null) || {
|
|
216
|
+
log "WARNING: Failed to reach /api/agent-health"
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# Extract stuck agent IDs using bun (jq-like)
|
|
221
|
+
local stuck_ids
|
|
222
|
+
stuck_ids=$(echo "$response" | bun -e "
|
|
223
|
+
const input = await Bun.stdin.text();
|
|
224
|
+
try {
|
|
225
|
+
const data = JSON.parse(input);
|
|
226
|
+
if (data.success && Array.isArray(data.stuckAgents)) {
|
|
227
|
+
for (const id of data.stuckAgents) {
|
|
228
|
+
console.log(id);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch {}
|
|
232
|
+
" 2>/dev/null) || return
|
|
233
|
+
|
|
234
|
+
if [ -z "$stuck_ids" ]; then
|
|
235
|
+
return
|
|
236
|
+
fi
|
|
237
|
+
|
|
238
|
+
while IFS= read -r stuck_id; do
|
|
239
|
+
[ -z "$stuck_id" ] && continue
|
|
240
|
+
|
|
241
|
+
# Condition 3: check log file freshness
|
|
242
|
+
local log_path
|
|
243
|
+
log_path=$(channel_log_file "$stuck_id")
|
|
244
|
+
if [ -n "$log_path" ]; then
|
|
245
|
+
local log_age
|
|
246
|
+
log_age=$(file_age_seconds "$log_path")
|
|
247
|
+
if [ "$log_age" -lt "$STUCK_THRESHOLD" ]; then
|
|
248
|
+
log "Agent ${stuck_id}: heartbeat stale + unread msgs, but log updated ${log_age}s ago — NOT stuck (working)"
|
|
249
|
+
continue
|
|
250
|
+
fi
|
|
251
|
+
log "Stuck agent detected: ${stuck_id} (heartbeat stale, unread msgs, log stale ${log_age}s)"
|
|
252
|
+
else
|
|
253
|
+
log "Stuck agent detected: ${stuck_id} (heartbeat stale, unread msgs, no log file found)"
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
kill_stuck_agent "$stuck_id"
|
|
257
|
+
done <<< "$stuck_ids"
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# ---- Main ----
|
|
261
|
+
|
|
262
|
+
# Set up logging
|
|
263
|
+
LOG_DIR="${CC_DISCORD_LOG_DIR:-/tmp/cc-discord/logs}"
|
|
264
|
+
mkdir -p "$LOG_DIR"
|
|
265
|
+
LOG_FILE="${LOG_DIR}/orchestrator.log"
|
|
266
|
+
|
|
267
|
+
# Redirect all orchestrator output to log file
|
|
268
|
+
exec >> "$LOG_FILE" 2>&1
|
|
269
|
+
|
|
270
|
+
log "Starting (relay=${RELAY_URL}, health_check=${HEALTH_CHECK_INTERVAL}s)"
|
|
271
|
+
log "Logging to $LOG_FILE"
|
|
272
|
+
|
|
273
|
+
# Wait for relay to be reachable
|
|
274
|
+
MAX_WAIT=60
|
|
275
|
+
WAITED=0
|
|
276
|
+
while ! curl -s --max-time 3 -H "x-api-token: ${RELAY_API_TOKEN}" "${RELAY_URL}/api/channels" >/dev/null 2>&1; do
|
|
277
|
+
if [ "$WAITED" -ge "$MAX_WAIT" ]; then
|
|
278
|
+
log "ERROR: Relay not reachable after ${MAX_WAIT}s. Exiting."
|
|
279
|
+
exit 1
|
|
280
|
+
fi
|
|
281
|
+
log "Waiting for relay server at ${RELAY_URL}..."
|
|
282
|
+
sleep 3
|
|
283
|
+
WAITED=$((WAITED + 3))
|
|
284
|
+
done
|
|
285
|
+
|
|
286
|
+
log "Relay is up. Discovering channels..."
|
|
287
|
+
|
|
288
|
+
# Initial channel discovery — read lines into the current shell (no subshell)
|
|
289
|
+
while IFS=' ' read -r channel_id channel_name; do
|
|
290
|
+
[ -z "$channel_id" ] && continue
|
|
291
|
+
start_channel_agent "$channel_id" "$channel_name"
|
|
292
|
+
done < <(discover_channels_lines)
|
|
293
|
+
|
|
294
|
+
log "Initial spawn complete (${#KNOWN_CHANNEL_IDS[@]} channels). Entering health check loop."
|
|
295
|
+
|
|
296
|
+
# Health check loop
|
|
297
|
+
while true; do
|
|
298
|
+
sleep "$HEALTH_CHECK_INTERVAL"
|
|
299
|
+
|
|
300
|
+
# Check for dead agents and restart them
|
|
301
|
+
for i in "${!KNOWN_CHANNEL_IDS[@]}"; do
|
|
302
|
+
_pid="${KNOWN_CHANNEL_PIDS[$i]}"
|
|
303
|
+
if ! is_agent_alive "$_pid"; then
|
|
304
|
+
_name="${KNOWN_CHANNEL_NAMES[$i]}"
|
|
305
|
+
_cid="${KNOWN_CHANNEL_IDS[$i]}"
|
|
306
|
+
wait "$_pid" 2>/dev/null || true
|
|
307
|
+
log "Agent #${_name} (${_cid}) exited. Restarting in ${AGENT_RESTART_DELAY}s..."
|
|
308
|
+
sleep "$AGENT_RESTART_DELAY"
|
|
309
|
+
start_channel_agent "$_cid" "$_name"
|
|
310
|
+
fi
|
|
311
|
+
done
|
|
312
|
+
|
|
313
|
+
# Check for stuck agents (alive but not polling)
|
|
314
|
+
check_stuck_agents
|
|
315
|
+
|
|
316
|
+
# Check for new channels
|
|
317
|
+
while IFS=' ' read -r channel_id channel_name; do
|
|
318
|
+
[ -z "$channel_id" ] && continue
|
|
319
|
+
_idx=$(find_channel_index "$channel_id")
|
|
320
|
+
if [ "$_idx" -lt 0 ]; then
|
|
321
|
+
log "New channel discovered: #${channel_name} (${channel_id})"
|
|
322
|
+
start_channel_agent "$channel_id" "$channel_name"
|
|
323
|
+
fi
|
|
324
|
+
done < <(discover_channels_lines)
|
|
325
|
+
done
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* parse-claude-stream.ts — Transform Claude CLI stream-json output into
|
|
4
|
+
* human-readable log lines.
|
|
5
|
+
*
|
|
6
|
+
* Reads NDJSON from stdin (as produced by `claude -p --output-format stream-json --verbose`),
|
|
7
|
+
* extracts the interesting events (assistant text, tool use, tool results, errors,
|
|
8
|
+
* thinking/reasoning), and writes concise, timestamped log lines to stdout.
|
|
9
|
+
*
|
|
10
|
+
* Claude CLI stream-json event types (v2.x):
|
|
11
|
+
* type:"system" subtype:"init"|"hook_started"|"hook_response"
|
|
12
|
+
* type:"assistant" message:{content:[...], usage:{...}}
|
|
13
|
+
* type:"user" message:{content:[...]} (tool results)
|
|
14
|
+
* type:"result" subtype:"success"|"error"
|
|
15
|
+
* type:"rate_limit_event"
|
|
16
|
+
* type:"error"
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* claude -p --output-format stream-json --verbose ... | bun scripts/parse-claude-stream.ts
|
|
20
|
+
*
|
|
21
|
+
* Environment:
|
|
22
|
+
* LOG_LEVEL=debug|info|warn (default: info)
|
|
23
|
+
* SHOW_THINKING=1 (show extended-thinking blocks, default: 0)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Database as DatabaseSync } from "bun:sqlite";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
const LOG_LEVEL = (process.env.LOG_LEVEL ?? "info").toLowerCase();
|
|
30
|
+
const SHOW_THINKING = process.env.SHOW_THINKING === "1";
|
|
31
|
+
|
|
32
|
+
// ---- Trace thread integration ----
|
|
33
|
+
// Write reasoning/thinking blocks to the trace_events DB table so
|
|
34
|
+
// the relay server can post them to the channel's live Agent Trace thread.
|
|
35
|
+
|
|
36
|
+
const TRACE_ENABLED = String(process.env.TRACE_THREAD_ENABLED || "true").toLowerCase() !== "false";
|
|
37
|
+
const TRACE_SESSION_ID = process.env.DISCORD_SESSION_ID || process.env.SESSION_ID || "default";
|
|
38
|
+
const TRACE_AGENT_ID = process.env.AGENT_ID || process.env.CLAUDE_AGENT_ID || "claude";
|
|
39
|
+
const TRACE_CHANNEL_ID = TRACE_AGENT_ID; // In channel routing mode, agent_id IS the channel ID
|
|
40
|
+
const ORCHESTRATOR_DIR = process.env.ORCHESTRATOR_DIR || "";
|
|
41
|
+
|
|
42
|
+
let traceDb: InstanceType<typeof DatabaseSync> | null = null;
|
|
43
|
+
|
|
44
|
+
function getTraceDb(): InstanceType<typeof DatabaseSync> | null {
|
|
45
|
+
if (!TRACE_ENABLED || !ORCHESTRATOR_DIR) return null;
|
|
46
|
+
if (traceDb) return traceDb;
|
|
47
|
+
try {
|
|
48
|
+
const dbPath = join(ORCHESTRATOR_DIR, "data", "messages.db");
|
|
49
|
+
traceDb = new DatabaseSync(dbPath);
|
|
50
|
+
traceDb.exec(`
|
|
51
|
+
CREATE TABLE IF NOT EXISTS trace_events (
|
|
52
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
53
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
54
|
+
session_id TEXT NOT NULL,
|
|
55
|
+
agent_id TEXT NOT NULL,
|
|
56
|
+
channel_id TEXT,
|
|
57
|
+
event_type TEXT NOT NULL,
|
|
58
|
+
tool_name TEXT,
|
|
59
|
+
summary TEXT,
|
|
60
|
+
posted INTEGER DEFAULT 0
|
|
61
|
+
);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_trace_events_pending
|
|
63
|
+
ON trace_events(posted, created_at);
|
|
64
|
+
`);
|
|
65
|
+
return traceDb;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeTraceEvent(eventType: string, toolName: string | null, summary: string) {
|
|
72
|
+
try {
|
|
73
|
+
const db = getTraceDb();
|
|
74
|
+
if (!db) return;
|
|
75
|
+
db.prepare(`
|
|
76
|
+
INSERT INTO trace_events (session_id, agent_id, channel_id, event_type, tool_name, summary)
|
|
77
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
78
|
+
`).run(TRACE_SESSION_ID, TRACE_AGENT_ID, TRACE_CHANNEL_ID, eventType, toolName, summary);
|
|
79
|
+
} catch {
|
|
80
|
+
// fail-open — never break the parser for trace
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- Helpers ----
|
|
85
|
+
|
|
86
|
+
function ts(): string {
|
|
87
|
+
return new Date().toISOString().slice(11, 19); // HH:MM:SS
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function log(tag: string, msg: string) {
|
|
91
|
+
const line = `[${ts()}] [${tag}] ${msg}`;
|
|
92
|
+
process.stdout.write(`${line}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function debug(tag: string, msg: string) {
|
|
96
|
+
if (LOG_LEVEL === "debug") log(tag, msg);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Truncate long strings for log display */
|
|
100
|
+
function trunc(s: string, max = 200): string {
|
|
101
|
+
s = s.replace(/\n/g, "\\n");
|
|
102
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- Event handling ----
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handle a top-level NDJSON event from the Claude CLI stream-json output.
|
|
109
|
+
*/
|
|
110
|
+
function handleEvent(evt: any) {
|
|
111
|
+
if (!evt || typeof evt !== "object") return;
|
|
112
|
+
|
|
113
|
+
const type: string = evt.type ?? "";
|
|
114
|
+
|
|
115
|
+
// -- system events: init, hooks --
|
|
116
|
+
if (type === "system") {
|
|
117
|
+
const subtype: string = evt.subtype ?? "";
|
|
118
|
+
|
|
119
|
+
if (subtype === "init") {
|
|
120
|
+
const model = evt.model ?? "?";
|
|
121
|
+
const sid = evt.session_id ?? "?";
|
|
122
|
+
const mode = evt.permissionMode ?? "?";
|
|
123
|
+
log("init", `model=${model} session=${sid} mode=${mode}`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (subtype === "hook_started") {
|
|
128
|
+
debug("hook", `started ${evt.hook_name ?? "?"}`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (subtype === "hook_response") {
|
|
133
|
+
const code = evt.exit_code ?? "?";
|
|
134
|
+
const outcome = evt.outcome ?? "?";
|
|
135
|
+
if (outcome !== "success" || code !== 0) {
|
|
136
|
+
log("hook", `${evt.hook_name ?? "?"} exit=${code} outcome=${outcome}`);
|
|
137
|
+
if (evt.stderr) log("hook:stderr", trunc(String(evt.stderr), 300));
|
|
138
|
+
} else {
|
|
139
|
+
debug("hook", `${evt.hook_name ?? "?"} ok`);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Other system subtypes
|
|
145
|
+
debug("system", `subtype=${subtype} ${trunc(JSON.stringify(evt), 200)}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// -- assistant turn: contains message with content blocks --
|
|
150
|
+
if (type === "assistant") {
|
|
151
|
+
const msg = evt.message;
|
|
152
|
+
if (!msg) {
|
|
153
|
+
debug("assistant", "empty message");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const content = msg.content;
|
|
158
|
+
if (Array.isArray(content)) {
|
|
159
|
+
for (const block of content) {
|
|
160
|
+
if (block.type === "text" && block.text) {
|
|
161
|
+
log("assistant", trunc(block.text, 500));
|
|
162
|
+
} else if (block.type === "tool_use") {
|
|
163
|
+
const name = block.name ?? "?";
|
|
164
|
+
const input = block.input ?? {};
|
|
165
|
+
// Format tool inputs concisely
|
|
166
|
+
if (name === "Bash") {
|
|
167
|
+
const cmd = input.command ?? "";
|
|
168
|
+
const desc = input.description ?? "";
|
|
169
|
+
log("tool_use", `Bash: ${trunc(desc || cmd, 400)}`);
|
|
170
|
+
} else if (name === "Read") {
|
|
171
|
+
log("tool_use", `Read: ${input.file_path ?? "?"}`);
|
|
172
|
+
} else if (name === "Edit" || name === "Write") {
|
|
173
|
+
log("tool_use", `${name}: ${input.file_path ?? "?"}`);
|
|
174
|
+
} else {
|
|
175
|
+
log("tool_use", `${name} ${trunc(JSON.stringify(input), 300)}`);
|
|
176
|
+
}
|
|
177
|
+
} else if (block.type === "thinking" && block.thinking) {
|
|
178
|
+
if (SHOW_THINKING) {
|
|
179
|
+
log("thinking", trunc(block.thinking, 300));
|
|
180
|
+
} else {
|
|
181
|
+
debug("thinking", trunc(block.thinking, 300));
|
|
182
|
+
}
|
|
183
|
+
// Write full reasoning to trace thread (no truncation)
|
|
184
|
+
writeTraceEvent("thinking", null, block.thinking);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Log usage summary
|
|
190
|
+
const usage = msg.usage;
|
|
191
|
+
if (usage) {
|
|
192
|
+
const input_tokens = usage.input_tokens ?? 0;
|
|
193
|
+
const cache_read = usage.cache_read_input_tokens ?? 0;
|
|
194
|
+
const output_tokens = usage.output_tokens ?? 0;
|
|
195
|
+
debug("usage", `in=${input_tokens} cache=${cache_read} out=${output_tokens}`);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// -- user turn: typically tool results --
|
|
201
|
+
if (type === "user") {
|
|
202
|
+
const msg = evt.message;
|
|
203
|
+
if (!msg) return;
|
|
204
|
+
|
|
205
|
+
const content = msg.content;
|
|
206
|
+
if (Array.isArray(content)) {
|
|
207
|
+
for (const block of content) {
|
|
208
|
+
if (block.type === "tool_result") {
|
|
209
|
+
const output = block.output ?? block.content ?? "";
|
|
210
|
+
const str = typeof output === "string" ? output : JSON.stringify(output);
|
|
211
|
+
debug("tool_result", trunc(str, 300));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// -- result: session complete --
|
|
219
|
+
if (type === "result") {
|
|
220
|
+
const subtype = evt.subtype ?? "?";
|
|
221
|
+
const duration = evt.duration_ms ? `${(evt.duration_ms / 1000).toFixed(1)}s` : "?";
|
|
222
|
+
const cost = evt.total_cost_usd ? `$${evt.total_cost_usd.toFixed(4)}` : "";
|
|
223
|
+
const turns = evt.num_turns ?? "?";
|
|
224
|
+
log("result", `${subtype} duration=${duration} turns=${turns} ${cost}`.trim());
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// -- rate limit --
|
|
229
|
+
if (type === "rate_limit_event") {
|
|
230
|
+
const info = evt.rate_limit_info ?? {};
|
|
231
|
+
if (info.status !== "allowed") {
|
|
232
|
+
log("rate_limit", `status=${info.status} resets=${info.resetsAt ?? "?"}`);
|
|
233
|
+
} else {
|
|
234
|
+
debug("rate_limit", `allowed`);
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// -- error --
|
|
240
|
+
if (type === "error") {
|
|
241
|
+
const errMsg = typeof evt.error === "string" ? evt.error : JSON.stringify(evt.error ?? evt);
|
|
242
|
+
log("ERROR", trunc(errMsg, 500));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---- Legacy / raw Anthropic streaming API format (fallback) ----
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
type === "content_block_start" ||
|
|
250
|
+
type === "content_block_delta" ||
|
|
251
|
+
type === "content_block_stop" ||
|
|
252
|
+
type === "message_start" ||
|
|
253
|
+
type === "message_delta" ||
|
|
254
|
+
type === "message_stop"
|
|
255
|
+
) {
|
|
256
|
+
// Legacy streaming format — log at debug
|
|
257
|
+
debug("legacy_stream", `type=${type}`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// -- init (legacy format) --
|
|
262
|
+
if (type === "init") {
|
|
263
|
+
const sid = evt.session_id ?? "unknown";
|
|
264
|
+
log("init", `session=${sid}`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// -- message (legacy format) --
|
|
269
|
+
if (type === "message") {
|
|
270
|
+
const role = evt.role ?? "?";
|
|
271
|
+
if (Array.isArray(evt.content)) {
|
|
272
|
+
for (const block of evt.content) {
|
|
273
|
+
if (block.type === "text" && block.text) {
|
|
274
|
+
log(role, trunc(block.text, 500));
|
|
275
|
+
} else if (block.type === "tool_use") {
|
|
276
|
+
log("tool_use", `${block.name ?? "?"} ${trunc(JSON.stringify(block.input ?? {}), 300)}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Unknown event — log at info so we catch new event types
|
|
284
|
+
log("unknown_event", `type=${type} ${trunc(JSON.stringify(evt), 300)}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function processLine(line: string) {
|
|
288
|
+
line = line.trim();
|
|
289
|
+
if (!line) return;
|
|
290
|
+
|
|
291
|
+
let parsed: any;
|
|
292
|
+
try {
|
|
293
|
+
parsed = JSON.parse(line);
|
|
294
|
+
} catch {
|
|
295
|
+
// Not JSON — pass through as-is (e.g. stderr leaking into stdout)
|
|
296
|
+
log("raw", trunc(line, 300));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// stream_event wrapper (some versions wrap events)
|
|
301
|
+
if (parsed.type === "stream_event" && parsed.event) {
|
|
302
|
+
handleEvent(parsed.event);
|
|
303
|
+
} else {
|
|
304
|
+
handleEvent(parsed);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---- Main: read stdin line by line ----
|
|
309
|
+
|
|
310
|
+
async function main() {
|
|
311
|
+
log("parser", "Claude stream parser started");
|
|
312
|
+
|
|
313
|
+
const reader = Bun.stdin.stream().getReader();
|
|
314
|
+
const decoder = new TextDecoder();
|
|
315
|
+
let leftover = "";
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
while (true) {
|
|
319
|
+
const { done, value } = await reader.read();
|
|
320
|
+
if (done) break;
|
|
321
|
+
|
|
322
|
+
const chunk = leftover + decoder.decode(value, { stream: true });
|
|
323
|
+
const lines = chunk.split("\n");
|
|
324
|
+
// Last element might be incomplete — save for next chunk
|
|
325
|
+
leftover = lines.pop() ?? "";
|
|
326
|
+
|
|
327
|
+
for (const line of lines) {
|
|
328
|
+
processLine(line);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Process any remaining data
|
|
333
|
+
if (leftover.trim()) {
|
|
334
|
+
processLine(leftover);
|
|
335
|
+
}
|
|
336
|
+
} finally {
|
|
337
|
+
// no-op — buffers removed since CLI sends complete messages
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
log("parser", "Stream ended");
|
|
341
|
+
|
|
342
|
+
// Clean up trace DB connection
|
|
343
|
+
try { traceDb?.close(); } catch { /* ignore */ }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
main().catch((err) => {
|
|
347
|
+
log("FATAL", String(err));
|
|
348
|
+
process.exit(1);
|
|
349
|
+
});
|