@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,82 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ source "$ROOT_DIR/scripts/load-env.sh"
6
+
7
+ WORKER_KEYS=(
8
+ DISCORD_SESSION_ID
9
+ CLAUDE_AGENT_ID
10
+ RELAY_HOST
11
+ RELAY_PORT
12
+ RELAY_URL
13
+ RELAY_API_TOKEN
14
+ AUTO_REPLY_PERMISSION_MODE
15
+ CLAUDE_RUNTIME_ID
16
+ WAIT_QUIET_TIMEOUT
17
+ BASH_POLICY_MODE
18
+ ALLOW_BASH_RUN_IN_BACKGROUND
19
+ ALLOW_BASH_BACKGROUND_OPS
20
+ BASH_POLICY_NOTIFY_ON_BLOCK
21
+ BASH_POLICY_NOTIFY_CHANNEL_ID
22
+ )
23
+
24
+ # Preferred split env file for worker process
25
+ load_env_keys "$ROOT_DIR/.env.worker" "${WORKER_KEYS[@]}"
26
+ load_env_keys "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env.worker" "${WORKER_KEYS[@]}"
27
+ # Legacy fallback (single-file mode)
28
+ load_env_keys "$ROOT_DIR/.env" "${WORKER_KEYS[@]}"
29
+ load_env_keys "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env" "${WORKER_KEYS[@]}"
30
+
31
+ SETTINGS_PATH="$ROOT_DIR/.claude/settings.json"
32
+ SYSTEM_PROMPT_PATH="$ROOT_DIR/prompts/orchestrator-system.md"
33
+
34
+ if ! command -v claude >/dev/null 2>&1; then
35
+ echo "Error: 'claude' CLI not found on PATH"
36
+ exit 1
37
+ fi
38
+
39
+ if [ ! -f "$SETTINGS_PATH" ]; then
40
+ echo "Generating settings..."
41
+ bash "$ROOT_DIR/scripts/generate-settings.sh"
42
+ fi
43
+
44
+ if [ ! -f "$SYSTEM_PROMPT_PATH" ]; then
45
+ echo "Missing prompt file: $SYSTEM_PROMPT_PATH"
46
+ exit 1
47
+ fi
48
+
49
+ # Defensive cleanup in case parent shell exported relay-only secrets
50
+ unset DISCORD_BOT_TOKEN DISCORD_CHANNEL_ID DISCORD_ALLOWED_CHANNEL_IDS
51
+
52
+ export PATH="$ROOT_DIR/tools:$PATH"
53
+ export ORCHESTRATOR_DIR="$ROOT_DIR"
54
+ export DISCORD_SESSION_ID="${DISCORD_SESSION_ID:-default}"
55
+ export CLAUDE_RUNTIME_ID="${CLAUDE_RUNTIME_ID:-rt_$(date +%s)_$RANDOM}"
56
+
57
+ # Routing identity for this Claude instance:
58
+ # - AGENT_ID is what hooks/tools use while running
59
+ # - CLAUDE_AGENT_ID is what relay writes to in SQLite
60
+ # Default behavior keeps them aligned.
61
+ if [ -z "${AGENT_ID:-}" ]; then
62
+ export AGENT_ID="${CLAUDE_AGENT_ID:-claude}"
63
+ fi
64
+ export CLAUDE_AGENT_ID="${CLAUDE_AGENT_ID:-$AGENT_ID}"
65
+
66
+ SYSTEM_PROMPT="$(cat "$SYSTEM_PROMPT_PATH")"
67
+ INITIAL_PROMPT="Start now. Discover channels and spawn subagents. Then begin your health check loop."
68
+
69
+ # Default to non-interactive permission behavior so orchestrator does not stall.
70
+ # Override with AUTO_REPLY_PERMISSION_MODE=accept-edits for safer interactive-ish behavior.
71
+ if [ "${AUTO_REPLY_PERMISSION_MODE:-skip}" = "accept-edits" ]; then
72
+ PERMISSION_ARGS=(--permission-mode acceptEdits)
73
+ else
74
+ PERMISSION_ARGS=(--dangerously-skip-permissions)
75
+ fi
76
+
77
+ echo "Starting Claude orchestrator (session=$DISCORD_SESSION_ID, agent=$AGENT_ID, runtime=$CLAUDE_RUNTIME_ID, permission_mode=${AUTO_REPLY_PERMISSION_MODE:-skip})..."
78
+ exec claude \
79
+ --settings "$SETTINGS_PATH" \
80
+ "${PERMISSION_ARGS[@]}" \
81
+ --append-system-prompt "$SYSTEM_PROMPT" \
82
+ -- "$INITIAL_PROMPT"
@@ -0,0 +1,17 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ source "$ROOT_DIR/scripts/load-env.sh"
6
+ # Preferred split env file for relay process
7
+ load_env_file "$ROOT_DIR/.env.relay"
8
+ load_env_file "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env.relay"
9
+ # Legacy fallback (single-file mode)
10
+ load_env_file "$ROOT_DIR/.env"
11
+ load_env_file "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env"
12
+
13
+ # Ensure bun is on PATH (launchd doesn't inherit user shell PATH)
14
+ export PATH="$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
15
+
16
+ cd "$ROOT_DIR"
17
+ exec bun server/index.ts
@@ -0,0 +1,175 @@
1
+ #!/bin/bash
2
+ # start.sh β€” Master startup script for cc-discord.
3
+ #
4
+ # Starts both:
5
+ # 1. The relay server (bun server/index.ts) β€” as a child process
6
+ # 2. The orchestrator (orchestrator.sh) β€” which spawns channel agents
7
+ #
8
+ # On SIGTERM/SIGINT, cleanly shuts down both.
9
+ #
10
+ # Usage: start.sh
11
+ #
12
+ # Prerequisites:
13
+ # - bun installed
14
+ # - claude CLI installed
15
+ # - .env.relay and/or .env.worker configured
16
+ #
17
+ # Logs:
18
+ # CC_DISCORD_LOG_DIR (default: /tmp/cc-discord/logs)
19
+ # - relay.log β€” relay server output
20
+ # - orchestrator.log β€” orchestrator process management
21
+ # - channel-<name>.log β€” per-channel Claude agent output
22
+
23
+ set -euo pipefail
24
+
25
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
26
+
27
+ # User config directory (~/.config/cc-discord by default, override with CC_DISCORD_CONFIG_DIR)
28
+ export CC_DISCORD_CONFIG_DIR="${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}"
29
+ if [ ! -d "$CC_DISCORD_CONFIG_DIR" ]; then
30
+ mkdir -p "$CC_DISCORD_CONFIG_DIR"
31
+ log_setup=true
32
+ else
33
+ log_setup=false
34
+ fi
35
+
36
+ # Seed example env files into config dir if they don't exist.
37
+ # If the user's config already exists but the example is newer, print a hint.
38
+ _is_newer() {
39
+ # Return 0 if $1 is newer than $2. Works on both macOS and Linux.
40
+ if stat -f %m "$1" >/dev/null 2>&1; then
41
+ [ "$(stat -f %m "$1")" -gt "$(stat -f %m "$2")" ]
42
+ else
43
+ [ "$(stat -c %Y "$1")" -gt "$(stat -c %Y "$2")" ]
44
+ fi
45
+ }
46
+ _seed_config() {
47
+ local src="$1" dest="$2"
48
+ [ -f "$src" ] || return 0
49
+ if [ ! -f "$dest" ]; then
50
+ cp "$src" "$dest"
51
+ echo "[start] Seeded $dest β€” edit this file to configure cc-discord."
52
+ elif _is_newer "$src" "$dest"; then
53
+ echo "[start] NOTE: $(basename "$src") has new options. Compare with your config:"
54
+ echo "[start] diff $dest $src"
55
+ fi
56
+ }
57
+ _seed_config "$ROOT_DIR/.env.relay.example" "$CC_DISCORD_CONFIG_DIR/.env.relay"
58
+ _seed_config "$ROOT_DIR/.env.worker.example" "$CC_DISCORD_CONFIG_DIR/.env.worker"
59
+
60
+ if $log_setup; then
61
+ echo "[start] Created config directory: $CC_DISCORD_CONFIG_DIR"
62
+ echo "[start] Edit .env.relay and .env.worker in that directory, then restart."
63
+ fi
64
+
65
+ # Log directory (shared with orchestrator and channel agents)
66
+ export CC_DISCORD_LOG_DIR="${CC_DISCORD_LOG_DIR:-/tmp/cc-discord/logs}"
67
+ mkdir -p "$CC_DISCORD_LOG_DIR"
68
+
69
+ RELAY_LOG="$CC_DISCORD_LOG_DIR/relay.log"
70
+ RELAY_PID=""
71
+ ORCHESTRATOR_PID=""
72
+
73
+ log() {
74
+ echo "[start] $(date '+%H:%M:%S') $*"
75
+ }
76
+
77
+ cleanup() {
78
+ log "Shutting down..."
79
+
80
+ if [ -n "$ORCHESTRATOR_PID" ] && kill -0 "$ORCHESTRATOR_PID" 2>/dev/null; then
81
+ log "Stopping orchestrator (PID $ORCHESTRATOR_PID)..."
82
+ kill -TERM "$ORCHESTRATOR_PID" 2>/dev/null || true
83
+ wait "$ORCHESTRATOR_PID" 2>/dev/null || true
84
+ fi
85
+
86
+ if [ -n "$RELAY_PID" ] && kill -0 "$RELAY_PID" 2>/dev/null; then
87
+ log "Stopping relay server (PID $RELAY_PID)..."
88
+ kill -TERM "$RELAY_PID" 2>/dev/null || true
89
+ wait "$RELAY_PID" 2>/dev/null || true
90
+ fi
91
+
92
+ log "All processes stopped."
93
+ exit 0
94
+ }
95
+
96
+ trap cleanup SIGTERM SIGINT
97
+
98
+ # ---- Start relay server ----
99
+ log "Starting relay server..."
100
+ bash "$ROOT_DIR/scripts/start-relay.sh" >> "$RELAY_LOG" 2>&1 &
101
+ RELAY_PID=$!
102
+ log "Relay server started (PID $RELAY_PID, log: $RELAY_LOG)"
103
+
104
+ # Wait for relay to be ready
105
+ MAX_WAIT=30
106
+ WAITED=0
107
+ source "$ROOT_DIR/scripts/load-env.sh"
108
+ load_env_file "$ROOT_DIR/.env.worker"
109
+ load_env_file "$CC_DISCORD_CONFIG_DIR/.env.worker"
110
+ load_env_file "$ROOT_DIR/.env"
111
+ load_env_file "$CC_DISCORD_CONFIG_DIR/.env"
112
+ RELAY_HOST="${RELAY_HOST:-127.0.0.1}"
113
+ RELAY_PORT="${RELAY_PORT:-3199}"
114
+ RELAY_API_TOKEN="${RELAY_API_TOKEN:-}"
115
+
116
+ # Ensure bun/curl/claude are findable
117
+ export PATH="$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
118
+
119
+ # ---- Pre-flight: Claude auth check ----
120
+ if ! claude auth status >/dev/null 2>&1; then
121
+ log "ERROR: Claude CLI is not authenticated."
122
+ log "Run 'claude auth login' to log in, then try again."
123
+ kill -TERM "$RELAY_PID" 2>/dev/null || true
124
+ wait "$RELAY_PID" 2>/dev/null || true
125
+ exit 1
126
+ fi
127
+ log "Claude auth verified."
128
+
129
+ while ! curl -s --max-time 2 -H "x-api-token: ${RELAY_API_TOKEN}" "http://${RELAY_HOST}:${RELAY_PORT}/api/channels" >/dev/null 2>&1; do
130
+ if ! kill -0 "$RELAY_PID" 2>/dev/null; then
131
+ log "ERROR: Relay server exited unexpectedly. Check $RELAY_LOG"
132
+ exit 1
133
+ fi
134
+ if [ "$WAITED" -ge "$MAX_WAIT" ]; then
135
+ log "ERROR: Relay server did not become ready within ${MAX_WAIT}s. Check $RELAY_LOG"
136
+ cleanup
137
+ fi
138
+ sleep 1
139
+ WAITED=$((WAITED + 1))
140
+ done
141
+
142
+ log "Relay server is ready."
143
+
144
+ # ---- Start orchestrator ----
145
+ log "Starting orchestrator..."
146
+ bash "$ROOT_DIR/scripts/orchestrator.sh" &
147
+ ORCHESTRATOR_PID=$!
148
+ log "Orchestrator started (PID $ORCHESTRATOR_PID, log: $CC_DISCORD_LOG_DIR/orchestrator.log)"
149
+
150
+ # Print monitoring instructions
151
+ log ""
152
+ log "=== cc-discord is running ==="
153
+ log " Relay: PID $RELAY_PID"
154
+ log " Orchestrator: PID $ORCHESTRATOR_PID"
155
+ log ""
156
+ log " Log directory: $CC_DISCORD_LOG_DIR"
157
+ log " Monitor all: tail -f $CC_DISCORD_LOG_DIR/*.log"
158
+ log " Monitor relay: tail -f $RELAY_LOG"
159
+ log " Monitor agent: tail -f $CC_DISCORD_LOG_DIR/channel-<name>.log"
160
+ log ""
161
+ log " Press Ctrl+C to stop all processes."
162
+ log "==============================="
163
+
164
+ # Monitor both processes β€” if either exits, shut down the other
165
+ while true; do
166
+ if ! kill -0 "$RELAY_PID" 2>/dev/null; then
167
+ log "Relay server exited. Shutting down orchestrator..."
168
+ cleanup
169
+ fi
170
+ if ! kill -0 "$ORCHESTRATOR_PID" 2>/dev/null; then
171
+ log "Orchestrator exited. Shutting down relay..."
172
+ cleanup
173
+ fi
174
+ sleep 5
175
+ done
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Attachment handling: fetch text attachments inline, download binary attachments locally.
3
+ */
4
+
5
+ import { mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import {
8
+ ATTACHMENT_DIR,
9
+ ATTACHMENT_TTL_MS,
10
+ MAX_ATTACHMENT_DOWNLOAD_BYTES,
11
+ MAX_ATTACHMENT_INLINE_BYTES,
12
+ } from "./config.ts";
13
+
14
+ mkdirSync(ATTACHMENT_DIR, { recursive: true });
15
+
16
+ // File extensions we will fetch and inline as text content
17
+ const TEXT_ATTACHMENT_EXTENSIONS = new Set([
18
+ "txt",
19
+ "md",
20
+ "markdown",
21
+ "rst",
22
+ "log",
23
+ "js",
24
+ "mjs",
25
+ "cjs",
26
+ "ts",
27
+ "tsx",
28
+ "jsx",
29
+ "py",
30
+ "rb",
31
+ "go",
32
+ "rs",
33
+ "java",
34
+ "kt",
35
+ "swift",
36
+ "c",
37
+ "cpp",
38
+ "h",
39
+ "hpp",
40
+ "sh",
41
+ "bash",
42
+ "zsh",
43
+ "fish",
44
+ "json",
45
+ "yaml",
46
+ "yml",
47
+ "toml",
48
+ "ini",
49
+ "env",
50
+ "cfg",
51
+ "conf",
52
+ "html",
53
+ "css",
54
+ "scss",
55
+ "sass",
56
+ "less",
57
+ "xml",
58
+ "svg",
59
+ "sql",
60
+ "csv",
61
+ "tsv",
62
+ "diff",
63
+ "patch",
64
+ ]);
65
+
66
+ /**
67
+ * Periodically clean up old downloaded attachment files.
68
+ * Runs every 10 minutes; removes files older than ATTACHMENT_TTL_MS.
69
+ */
70
+ export function cleanupOldAttachments() {
71
+ try {
72
+ const files = readdirSync(ATTACHMENT_DIR);
73
+ const now = Date.now();
74
+ let cleaned = 0;
75
+ for (const file of files) {
76
+ const filePath = join(ATTACHMENT_DIR, file);
77
+ try {
78
+ const stat = statSync(filePath);
79
+ if (now - stat.mtimeMs > ATTACHMENT_TTL_MS) {
80
+ unlinkSync(filePath);
81
+ cleaned++;
82
+ }
83
+ } catch {
84
+ // file may have been removed concurrently; ignore
85
+ }
86
+ }
87
+ if (cleaned > 0) {
88
+ console.log(`[Relay] Cleaned up ${cleaned} old attachment file(s)`);
89
+ }
90
+ } catch (err: unknown) {
91
+ console.warn(`[Relay] Attachment cleanup error: ${(err as Error).message}`);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Download a non-text attachment from Discord CDN to local disk.
97
+ * Returns the local file path on success, or null on failure.
98
+ */
99
+ async function downloadAttachmentToLocal(attachment: any, messageId: string): Promise<string | null> {
100
+ const name = attachment.name || "unknown";
101
+ const url = attachment.url;
102
+
103
+ try {
104
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
105
+ if (!response.ok) {
106
+ console.warn(`[Relay] Failed to download attachment ${name}: HTTP ${response.status}`);
107
+ return null;
108
+ }
109
+
110
+ const contentLength = Number(response.headers.get("content-length") || 0);
111
+ if (contentLength > MAX_ATTACHMENT_DOWNLOAD_BYTES) {
112
+ console.warn(
113
+ `[Relay] Attachment ${name} too large to download (${contentLength} bytes, max ${MAX_ATTACHMENT_DOWNLOAD_BYTES})`,
114
+ );
115
+ return null;
116
+ }
117
+
118
+ const buffer = Buffer.from(await response.arrayBuffer());
119
+ if (buffer.length > MAX_ATTACHMENT_DOWNLOAD_BYTES) {
120
+ console.warn(`[Relay] Attachment ${name} body too large (${buffer.length} bytes), skipping download`);
121
+ return null;
122
+ }
123
+
124
+ // Sanitize filename: prefix with messageId to avoid collisions
125
+ const safeName = name.replace(/[^a-zA-Z0-9._-]/g, "_");
126
+ const localFilename = `${messageId || Date.now()}-${safeName}`;
127
+ const localPath = join(ATTACHMENT_DIR, localFilename);
128
+ writeFileSync(localPath, buffer);
129
+ console.log(`[Relay] Downloaded attachment ${name} (${buffer.length} bytes) -> ${localPath}`);
130
+ return localPath;
131
+ } catch (err: unknown) {
132
+ console.warn(`[Relay] Error downloading attachment ${name}: ${(err as Error).message}`);
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Fetch attachment content for inclusion in the message.
139
+ * Text files are inlined; binary files are downloaded to disk for Claude to read.
140
+ */
141
+ export async function fetchAttachmentContent(attachment: any, messageId: string): Promise<string> {
142
+ const name = attachment.name || "";
143
+ const ext = name.split(".").pop()?.toLowerCase() || "";
144
+ const url = attachment.url;
145
+
146
+ if (!TEXT_ATTACHMENT_EXTENSIONS.has(ext)) {
147
+ // Non-text file: download to local disk so Claude Code can read it via file path
148
+ const localPath = await downloadAttachmentToLocal(attachment, messageId);
149
+ if (localPath) {
150
+ return `Attachment: ${localPath}`;
151
+ }
152
+ // Fallback to URL if download failed
153
+ return `Attachment: ${url}`;
154
+ }
155
+
156
+ try {
157
+ const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
158
+ if (!response.ok) {
159
+ console.warn(`[Relay] Failed to fetch attachment ${name}: HTTP ${response.status}`);
160
+ return `Attachment: ${url}`;
161
+ }
162
+
163
+ const contentLength = Number(response.headers.get("content-length") || 0);
164
+ if (contentLength > MAX_ATTACHMENT_INLINE_BYTES) {
165
+ console.warn(`[Relay] Attachment ${name} too large (${contentLength} bytes), including URL only`);
166
+ return `Attachment: ${url}`;
167
+ }
168
+
169
+ const text = await response.text();
170
+ if (text.length > MAX_ATTACHMENT_INLINE_BYTES) {
171
+ const truncated = text.slice(0, MAX_ATTACHMENT_INLINE_BYTES);
172
+ console.warn(`[Relay] Attachment ${name} content truncated to ${MAX_ATTACHMENT_INLINE_BYTES} bytes`);
173
+ return `Attachment (${name}):\n\`\`\`\n${truncated}\n... [truncated]\n\`\`\``;
174
+ }
175
+
176
+ console.log(`[Relay] Inlined attachment ${name} (${text.length} bytes)`);
177
+ return `Attachment (${name}):\n\`\`\`\n${text}\n\`\`\``;
178
+ } catch (err: unknown) {
179
+ console.warn(`[Relay] Error fetching attachment ${name}: ${(err as Error).message}`);
180
+ return `Attachment: ${url}`;
181
+ }
182
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Busy queue notification: send a Discord message when Claude is working on another task
3
+ * and a new message arrives.
4
+ */
5
+
6
+ import {
7
+ BUSY_NOTIFY_COOLDOWN_MS,
8
+ BUSY_NOTIFY_MIN_DURATION_MS,
9
+ BUSY_NOTIFY_ON_QUEUE,
10
+ CLAUDE_AGENT_ID,
11
+ DISCORD_SESSION_ID,
12
+ MESSAGE_ROUTING_MODE,
13
+ } from "./config.ts";
14
+ import { getCurrentAgentActivity } from "./db.ts";
15
+
16
+ // Deduplicate busy queue notifications per activity window
17
+ const busyQueueNotifyCache = new Map<string, number>();
18
+
19
+ function isWaitActivity(row: any): boolean {
20
+ const text = `${row?.activity_type || ""} ${row?.activity_summary || ""}`.toLowerCase();
21
+ return text.includes("wait-for-discord-messages");
22
+ }
23
+
24
+ function isSleepActivity(row: any): boolean {
25
+ const text = `${row?.activity_type || ""} ${row?.activity_summary || ""}`.toLowerCase();
26
+ return /\bsleep\b/.test(text);
27
+ }
28
+
29
+ export function maybeNotifyBusyQueued(message: any, client: any, persistOutbound: (...args: any[]) => any) {
30
+ if (!BUSY_NOTIFY_ON_QUEUE) return;
31
+
32
+ // In channel routing mode, check the channel's subagent activity, not the orchestrator
33
+ const channelAgentId = MESSAGE_ROUTING_MODE === "channel" ? message.channelId : null;
34
+ const activity = channelAgentId
35
+ ? getCurrentAgentActivity(DISCORD_SESSION_ID, CLAUDE_AGENT_ID, channelAgentId)
36
+ : (getCurrentAgentActivity(DISCORD_SESSION_ID, CLAUDE_AGENT_ID) as any);
37
+ if (!activity || activity.status !== "busy") return;
38
+ if (isWaitActivity(activity)) return;
39
+ if (isSleepActivity(activity)) return;
40
+
41
+ // Only notify if the current activity has been running long enough to warrant it.
42
+ // Short operations finish quickly and the agent will naturally address the message via steering prompts.
43
+ const activityStart = activity.started_at ? new Date(activity.started_at).getTime() : 0;
44
+ const now = Date.now();
45
+ if (activityStart && now - activityStart < BUSY_NOTIFY_MIN_DURATION_MS) return;
46
+
47
+ const activityKey = `${message.channelId}:${activity.started_at || activity.updated_at || activity.activity_summary || activity.activity_type || "busy"}`;
48
+ const lastSent = busyQueueNotifyCache.get(activityKey) || 0;
49
+ if (now - lastSent < BUSY_NOTIFY_COOLDOWN_MS) return;
50
+ busyQueueNotifyCache.set(activityKey, now);
51
+
52
+ const content = "πŸ‘‹ Got your message β€” I'll address it when my current task finishes.";
53
+
54
+ void (async () => {
55
+ try {
56
+ const channel = await client.channels.fetch(message.channelId);
57
+ if (!channel || !channel.isTextBased()) return;
58
+ const sent = await channel.send(content);
59
+ persistOutbound({
60
+ content,
61
+ channelId: message.channelId,
62
+ externalId: sent.id,
63
+ fromAgent: "relay",
64
+ });
65
+ } catch {
66
+ // best effort only
67
+ }
68
+ })();
69
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Configuration: load env vars and export typed settings for the relay server.
3
+ */
4
+
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ export const ROOT_DIR = join(__dirname, "..");
11
+ export const DATA_DIR = join(ROOT_DIR, "data");
12
+
13
+ // User config directory (~/.config/cc-discord by default, override with CC_DISCORD_CONFIG_DIR)
14
+ const CONFIG_DIR =
15
+ process.env.CC_DISCORD_CONFIG_DIR ||
16
+ join(process.env.HOME || "", ".config", "cc-discord");
17
+
18
+ // Preferred split env file for relay process; legacy fallback for compatibility.
19
+ // Precedence: ROOT_DIR > CONFIG_DIR (loadDotEnv skips keys already set)
20
+ loadDotEnv(join(ROOT_DIR, ".env.relay"));
21
+ loadDotEnv(join(CONFIG_DIR, ".env.relay"));
22
+ loadDotEnv(join(ROOT_DIR, ".env"));
23
+ loadDotEnv(join(CONFIG_DIR, ".env"));
24
+
25
+ export const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
26
+ export const DEFAULT_CHANNEL_ID = process.env.DISCORD_CHANNEL_ID;
27
+
28
+ export const ALLOWED_CHANNEL_IDS = (process.env.DISCORD_ALLOWED_CHANNEL_IDS || "")
29
+ .split(",")
30
+ .map((s) => s.trim())
31
+ .filter(Boolean);
32
+
33
+ export const IGNORED_CHANNEL_IDS = new Set(
34
+ (process.env.DISCORD_IGNORED_CHANNEL_IDS || "")
35
+ .split(",")
36
+ .map((s) => s.trim())
37
+ .filter(Boolean),
38
+ );
39
+
40
+ export const ALLOWED_DISCORD_USER_IDS = (process.env.ALLOWED_DISCORD_USER_IDS || "")
41
+ .split(",")
42
+ .map((s) => s.trim())
43
+ .filter(Boolean);
44
+
45
+ export const DISCORD_SESSION_ID = process.env.DISCORD_SESSION_ID || "default";
46
+ export const CLAUDE_AGENT_ID = process.env.CLAUDE_AGENT_ID || "claude";
47
+
48
+ /**
49
+ * Message routing mode:
50
+ * - 'channel' (default): route inbound messages to to_agent=channelId (orchestrator/subagent mode)
51
+ * - 'agent': route to to_agent=CLAUDE_AGENT_ID (legacy single-agent mode)
52
+ */
53
+ export const MESSAGE_ROUTING_MODE = String(process.env.MESSAGE_ROUTING_MODE || "channel")
54
+ .toLowerCase()
55
+ .trim();
56
+
57
+ export const RELAY_HOST = process.env.RELAY_HOST || "127.0.0.1";
58
+ export const RELAY_PORT = Number(process.env.RELAY_PORT || 3199);
59
+ export const RELAY_API_TOKEN = process.env.RELAY_API_TOKEN || "";
60
+ export const RELAY_ALLOW_NO_AUTH = String(process.env.RELAY_ALLOW_NO_AUTH || "false").toLowerCase() === "true";
61
+
62
+ export const TYPING_INTERVAL_MS = Number(process.env.TYPING_INTERVAL_MS || 8000);
63
+ export const TYPING_MAX_MS = Number(process.env.TYPING_MAX_MS || 120000);
64
+ export const THINKING_FALLBACK_ENABLED =
65
+ String(process.env.THINKING_FALLBACK_ENABLED || "true").toLowerCase() !== "false";
66
+ export const THINKING_FALLBACK_TEXT =
67
+ process.env.THINKING_FALLBACK_TEXT || "Still working on thatβ€”thanks for your patience.";
68
+
69
+ export const BUSY_NOTIFY_ON_QUEUE = String(process.env.BUSY_NOTIFY_ON_QUEUE || "true").toLowerCase() !== "false";
70
+ export const BUSY_NOTIFY_COOLDOWN_MS = Number(process.env.BUSY_NOTIFY_COOLDOWN_MS || 30000);
71
+ /** Only send a busy notification if the current activity has been running for at least this long (ms). */
72
+ export const BUSY_NOTIFY_MIN_DURATION_MS = Number(process.env.BUSY_NOTIFY_MIN_DURATION_MS || 30000);
73
+
74
+ // ── Live Trace Thread ───────────────────────────────────────────────
75
+ export const TRACE_THREAD_ENABLED =
76
+ String(process.env.TRACE_THREAD_ENABLED || "true").toLowerCase() !== "false";
77
+ export const TRACE_THREAD_NAME = process.env.TRACE_THREAD_NAME || "πŸ” Agent Trace";
78
+ export const TRACE_FLUSH_INTERVAL_MS = Number(process.env.TRACE_FLUSH_INTERVAL_MS || 3000);
79
+
80
+ export const MAX_ATTACHMENT_INLINE_BYTES = Number(process.env.MAX_ATTACHMENT_INLINE_BYTES || 100_000);
81
+ export const MAX_ATTACHMENT_DOWNLOAD_BYTES = Number(process.env.MAX_ATTACHMENT_DOWNLOAD_BYTES || 10_000_000);
82
+ export const ATTACHMENT_TTL_MS = Number(process.env.ATTACHMENT_TTL_MS || 3_600_000);
83
+
84
+ export const ATTACHMENT_DIR = join("/tmp", "cc-discord", "attachments");
85
+
86
+ function loadDotEnv(path: string) {
87
+ if (!existsSync(path)) return;
88
+ const raw = readFileSync(path, "utf8");
89
+ for (const line of raw.split(/\r?\n/)) {
90
+ if (!line || line.trim().startsWith("#")) continue;
91
+ const idx = line.indexOf("=");
92
+ if (idx === -1) continue;
93
+ const key = line.slice(0, idx).trim();
94
+ let value = line.slice(idx + 1).trim();
95
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
96
+ value = value.slice(1, -1);
97
+ }
98
+ if (!(key in process.env)) {
99
+ process.env[key] = value;
100
+ }
101
+ }
102
+ }
103
+
104
+ export function validateConfig() {
105
+ if (!DISCORD_BOT_TOKEN) {
106
+ console.error("Missing DISCORD_BOT_TOKEN (set in .env.relay or env var).");
107
+ process.exit(1);
108
+ }
109
+
110
+ if (!DEFAULT_CHANNEL_ID) {
111
+ console.error("Missing DISCORD_CHANNEL_ID (set in .env.relay or env var).");
112
+ process.exit(1);
113
+ }
114
+
115
+ if (!RELAY_API_TOKEN && !RELAY_ALLOW_NO_AUTH) {
116
+ console.error(
117
+ "Missing RELAY_API_TOKEN. Set RELAY_API_TOKEN in .env.relay (recommended), or explicitly set RELAY_ALLOW_NO_AUTH=true for local-only dev.",
118
+ );
119
+ process.exit(1);
120
+ }
121
+ }