@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.
Files changed (46) hide show
  1. package/.claude/settings.template.json +94 -0
  2. package/.env.example +41 -0
  3. package/.env.relay.example +46 -0
  4. package/.env.worker.example +40 -0
  5. package/README.md +313 -0
  6. package/hooks/check-discord-messages.ts +204 -0
  7. package/hooks/cleanup-attachment.ts +47 -0
  8. package/hooks/safe-bash.ts +157 -0
  9. package/hooks/steer-send.ts +108 -0
  10. package/hooks/track-activity.ts +220 -0
  11. package/memory/README.md +60 -0
  12. package/memory/core/MemoryCoordinator.ts +703 -0
  13. package/memory/core/MemoryStore.ts +72 -0
  14. package/memory/core/session-key.ts +14 -0
  15. package/memory/core/types.ts +59 -0
  16. package/memory/index.ts +19 -0
  17. package/memory/providers/sqlite/SqliteMemoryStore.ts +838 -0
  18. package/memory/providers/sqlite/index.ts +1 -0
  19. package/package.json +45 -0
  20. package/prompts/autoreply-system.md +32 -0
  21. package/prompts/channel-system.md +22 -0
  22. package/prompts/orchestrator-system.md +56 -0
  23. package/scripts/channel-agent.sh +159 -0
  24. package/scripts/generate-settings.sh +17 -0
  25. package/scripts/load-env.sh +79 -0
  26. package/scripts/migrate-memory-to-channel-keys.ts +148 -0
  27. package/scripts/orchestrator.sh +325 -0
  28. package/scripts/parse-claude-stream.ts +349 -0
  29. package/scripts/start-orchestrator.sh +82 -0
  30. package/scripts/start-relay.sh +17 -0
  31. package/scripts/start.sh +175 -0
  32. package/server/attachment.ts +182 -0
  33. package/server/busy-notify.ts +69 -0
  34. package/server/config.ts +121 -0
  35. package/server/db.ts +249 -0
  36. package/server/index.ts +311 -0
  37. package/server/memory.ts +88 -0
  38. package/server/messages.ts +111 -0
  39. package/server/trace-thread.ts +340 -0
  40. package/server/typing.ts +101 -0
  41. package/tools/memory-inspect.ts +94 -0
  42. package/tools/memory-smoke.ts +173 -0
  43. package/tools/send-discord +2 -0
  44. package/tools/send-discord.ts +82 -0
  45. package/tools/wait-for-discord-messages +2 -0
  46. 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
+ });