@hoverlover/cc-discord 0.5.4 → 0.5.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoverlover/cc-discord",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Discord <-> Claude Code relay: use your Claude subscription to power per-channel AI bots",
5
5
  "type": "module",
6
6
  "bin": {
@@ -299,33 +299,84 @@ done < <(discover_channels_lines)
299
299
 
300
300
  log "Initial spawn complete (${#KNOWN_CHANNEL_IDS[@]} channels). Entering health check loop."
301
301
 
302
- # Health check loop
303
- while true; do
304
- sleep "$HEALTH_CHECK_INTERVAL"
302
+ # Check for unread messages with no active agent (e.g. new threads).
303
+ # Spawns agents immediately so users don't wait for the next health check.
304
+ UNSERVICED_CHECK_INTERVAL="${UNSERVICED_CHECK_INTERVAL:-5}"
305
305
 
306
- # Check for dead agents and restart them
307
- for i in "${!KNOWN_CHANNEL_IDS[@]}"; do
308
- _pid="${KNOWN_CHANNEL_PIDS[$i]}"
309
- if ! is_agent_alive "$_pid"; then
310
- _name="${KNOWN_CHANNEL_NAMES[$i]}"
311
- _cid="${KNOWN_CHANNEL_IDS[$i]}"
312
- wait "$_pid" 2>/dev/null || true
313
- log "Agent #${_name} (${_cid}) exited. Restarting in ${AGENT_RESTART_DELAY}s..."
314
- sleep "$AGENT_RESTART_DELAY"
315
- start_channel_agent "$_cid" "$_name"
316
- fi
317
- done
306
+ check_unserviced() {
307
+ local response
308
+ response=$(curl -s --max-time 5 \
309
+ -H "x-api-token: ${RELAY_API_TOKEN}" \
310
+ "${RELAY_URL}/api/unserviced" 2>/dev/null) || return
311
+
312
+ local targets
313
+ targets=$(echo "$response" | bun -e "
314
+ const input = await Bun.stdin.text();
315
+ try {
316
+ const data = JSON.parse(input);
317
+ if (data.success && Array.isArray(data.targets)) {
318
+ for (const t of data.targets) {
319
+ // Only spawn for snowflake IDs (channels/threads), not agent names
320
+ if (/^\d{15,22}$/.test(t.toAgent)) {
321
+ console.log(t.toAgent);
322
+ }
323
+ }
324
+ }
325
+ } catch {}
326
+ " 2>/dev/null) || return
318
327
 
319
- # Check for stuck agents (alive but not polling)
320
- check_stuck_agents
328
+ [ -z "$targets" ] && return
321
329
 
322
- # Check for new channels
323
- while IFS=' ' read -r channel_id channel_name; do
324
- [ -z "$channel_id" ] && continue
325
- _idx=$(find_channel_index "$channel_id")
326
- if [ "$_idx" -lt 0 ]; then
327
- log "New channel discovered: #${channel_name} (${channel_id})"
328
- start_channel_agent "$channel_id" "$channel_name"
330
+ while IFS= read -r target_id; do
331
+ [ -z "$target_id" ] && continue
332
+ _idx=$(find_channel_index "$target_id")
333
+ if [ "$_idx" -ge 0 ]; then
334
+ # Agent exists but hasn't polled yet — skip
335
+ continue
329
336
  fi
330
- done < <(discover_channels_lines)
337
+ log "Unserviced messages for ${target_id} — spawning agent"
338
+ start_channel_agent "$target_id" "thread-${target_id}"
339
+ done <<< "$targets"
340
+ }
341
+
342
+ # Health check loop — runs unserviced check every UNSERVICED_CHECK_INTERVAL seconds,
343
+ # full health check every HEALTH_CHECK_INTERVAL seconds.
344
+ SECONDS_SINCE_HEALTH=0
345
+ while true; do
346
+ sleep "$UNSERVICED_CHECK_INTERVAL"
347
+ SECONDS_SINCE_HEALTH=$((SECONDS_SINCE_HEALTH + UNSERVICED_CHECK_INTERVAL))
348
+
349
+ # Fast check: spawn agents for unserviced threads/channels
350
+ check_unserviced
351
+
352
+ # Full health check on the slower interval
353
+ if [ "$SECONDS_SINCE_HEALTH" -ge "$HEALTH_CHECK_INTERVAL" ]; then
354
+ SECONDS_SINCE_HEALTH=0
355
+
356
+ # Check for dead agents and restart them
357
+ for i in "${!KNOWN_CHANNEL_IDS[@]}"; do
358
+ _pid="${KNOWN_CHANNEL_PIDS[$i]}"
359
+ if ! is_agent_alive "$_pid"; then
360
+ _name="${KNOWN_CHANNEL_NAMES[$i]}"
361
+ _cid="${KNOWN_CHANNEL_IDS[$i]}"
362
+ wait "$_pid" 2>/dev/null || true
363
+ log "Agent #${_name} (${_cid}) exited. Restarting in ${AGENT_RESTART_DELAY}s..."
364
+ sleep "$AGENT_RESTART_DELAY"
365
+ start_channel_agent "$_cid" "$_name"
366
+ fi
367
+ done
368
+
369
+ # Check for stuck agents (alive but not polling)
370
+ check_stuck_agents
371
+
372
+ # Check for new channels/threads
373
+ while IFS=' ' read -r channel_id channel_name; do
374
+ [ -z "$channel_id" ] && continue
375
+ _idx=$(find_channel_index "$channel_id")
376
+ if [ "$_idx" -lt 0 ]; then
377
+ log "New channel discovered: #${channel_name} (${channel_id})"
378
+ start_channel_agent "$channel_id" "$channel_name"
379
+ fi
380
+ done < <(discover_channels_lines)
381
+ fi
331
382
  done
package/server/db.ts CHANGED
@@ -227,6 +227,28 @@ export function insertTraceEvent(
227
227
  }
228
228
  }
229
229
 
230
+ /**
231
+ * Find message targets (to_agent) that have unread messages but no agent
232
+ * has ever polled for them (no agent_activity record). These are typically
233
+ * new threads that need an agent spawned.
234
+ */
235
+ export function getUnservicedTargets(sessionId: string): { toAgent: string; unreadCount: number; oldestAt: string }[] {
236
+ try {
237
+ const rows = db
238
+ .prepare(`
239
+ SELECT m.to_agent, COUNT(*) as unread_count, MIN(m.created_at) as oldest_at
240
+ FROM messages m
241
+ LEFT JOIN agent_activity a ON a.agent_id = m.to_agent AND a.session_id = m.session_id
242
+ WHERE m.session_id = ? AND m.read = 0 AND a.agent_id IS NULL
243
+ GROUP BY m.to_agent
244
+ `)
245
+ .all(sessionId) as any[];
246
+ return rows.map((r) => ({ toAgent: r.to_agent, unreadCount: r.unread_count, oldestAt: r.oldest_at }));
247
+ } catch {
248
+ return [];
249
+ }
250
+ }
251
+
230
252
  /**
231
253
  * Get health status for all agents in a session.
232
254
  * Returns each agent's last heartbeat time, status, and whether it
package/server/index.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  TYPING_MAX_MS,
28
28
  validateConfig,
29
29
  } from "./config.ts";
30
- import { clearChannelModel, db, getAgentHealthAll, getChannelModel, isTraceThread, setChannelModel } from "./db.ts";
30
+ import { clearChannelModel, db, getAgentHealthAll, getChannelModel, getUnservicedTargets, isTraceThread, setChannelModel } from "./db.ts";
31
31
  import { memoryStore } from "./memory.ts";
32
32
  import { persistInboundDiscordMessage, persistOutboundDiscordMessage } from "./messages.ts";
33
33
  import { startTraceFlushLoop, stopTraceFlushLoop } from "./trace-thread.ts";
@@ -253,6 +253,17 @@ app.get("/api/agent-health", (req: Request, res: Response) => {
253
253
  }
254
254
  });
255
255
 
256
+ app.get("/api/unserviced", (req: Request, res: Response) => {
257
+ try {
258
+ if (!requireAuth(req, res)) return;
259
+ const targets = getUnservicedTargets(DISCORD_SESSION_ID);
260
+ res.json({ success: true, targets });
261
+ } catch (err: unknown) {
262
+ console.error("[Relay] /api/unserviced failed:", err);
263
+ res.status(500).json({ success: false, error: (err as Error).message });
264
+ }
265
+ });
266
+
256
267
  app.post("/api/send", async (req: Request, res: Response) => {
257
268
  try {
258
269
  if (!requireAuth(req, res)) return;