@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,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
|
package/scripts/start.sh
ADDED
|
@@ -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
|
+
}
|
package/server/config.ts
ADDED
|
@@ -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
|
+
}
|