@hoverlover/cc-discord 0.3.2 → 0.3.3

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.
@@ -43,6 +43,9 @@ const toolInput = input.tool_input || input.toolInput || null;
43
43
  let db: InstanceType<typeof DatabaseSync>;
44
44
  try {
45
45
  db = new DatabaseSync(dbPath);
46
+ // WAL mode for better concurrency with the relay server's flush loop
47
+ db.exec("PRAGMA journal_mode = WAL;");
48
+ db.exec("PRAGMA busy_timeout = 5000;");
46
49
  } catch {
47
50
  process.exit(0);
48
51
  }
@@ -107,7 +110,9 @@ try {
107
110
  INSERT INTO trace_events (session_id, agent_id, channel_id, event_type, tool_name, summary)
108
111
  VALUES (?, ?, ?, 'tool_start', ?, ?)
109
112
  `).run(sessionId, agentId, traceChannelId, toolName || "tool", summary);
110
- } catch { /* fail-open */ }
113
+ } catch {
114
+ /* fail-open */
115
+ }
111
116
  }
112
117
 
113
118
  process.exit(0);
@@ -118,17 +123,21 @@ try {
118
123
  let elapsedTag = "";
119
124
  if (traceEnabled && hookEvent === "PostToolUse") {
120
125
  try {
121
- const row = db.prepare(`
126
+ const row = db
127
+ .prepare(`
122
128
  SELECT started_at FROM agent_activity
123
129
  WHERE session_id = ? AND agent_id = ?
124
- `).get(sessionId, agentId) as { started_at?: string } | undefined;
130
+ `)
131
+ .get(sessionId, agentId) as { started_at?: string } | undefined;
125
132
  if (row?.started_at) {
126
133
  const elapsedMs = Date.now() - new Date(row.started_at).getTime();
127
134
  if (elapsedMs >= 0 && elapsedMs < 600_000) {
128
135
  elapsedTag = `elapsed:${elapsedMs}|`;
129
136
  }
130
137
  }
131
- } catch { /* best-effort */ }
138
+ } catch {
139
+ /* best-effort */
140
+ }
132
141
  }
133
142
 
134
143
  db.prepare(`
@@ -157,7 +166,9 @@ try {
157
166
  INSERT INTO trace_events (session_id, agent_id, channel_id, event_type, tool_name, summary)
158
167
  VALUES (?, ?, ?, 'tool_end', ?, ?)
159
168
  `).run(sessionId, agentId, traceChannelId, toolName || "tool", `${elapsedTag}${summary}`);
160
- } catch { /* fail-open */ }
169
+ } catch {
170
+ /* fail-open */
171
+ }
161
172
  }
162
173
  }
163
174
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoverlover/cc-discord",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Discord <-> Claude Code relay: use your Claude subscription to power per-channel AI bots",
5
5
  "type": "module",
6
6
  "bin": {
package/server/db.ts CHANGED
@@ -11,6 +11,11 @@ mkdirSync(DATA_DIR, { recursive: true });
11
11
 
12
12
  export const db = new DatabaseSync(join(DATA_DIR, "messages.db"));
13
13
 
14
+ // Enable WAL mode for better concurrent access between relay server and hook processes.
15
+ // WAL allows readers and writers to operate concurrently without SQLITE_BUSY errors.
16
+ db.exec("PRAGMA journal_mode = WAL;");
17
+ db.exec("PRAGMA busy_timeout = 5000;");
18
+
14
19
  db.exec(`
15
20
  CREATE TABLE IF NOT EXISTS messages (
16
21
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -170,14 +175,29 @@ export function getPendingTraceEvents(limit: number = 50): TraceEvent[] {
170
175
  }
171
176
  }
172
177
 
173
- export function markTraceEventsPosted(ids: number[]) {
174
- if (!ids.length) return;
175
- try {
176
- const placeholders = ids.map(() => "?").join(",");
177
- db.prepare(`UPDATE trace_events SET posted = 1 WHERE id IN (${placeholders})`).run(...ids);
178
- } catch {
179
- // fail-open
178
+ export function markTraceEventsPosted(ids: number[]): boolean {
179
+ if (!ids.length) return true;
180
+ // Retry up to 3 times to handle SQLITE_BUSY from concurrent hook writes
181
+ for (let attempt = 0; attempt < 3; attempt++) {
182
+ try {
183
+ const placeholders = ids.map(() => "?").join(",");
184
+ db.prepare(`UPDATE trace_events SET posted = 1 WHERE id IN (${placeholders})`).run(...ids);
185
+ return true;
186
+ } catch (err) {
187
+ const code = (err as any)?.code;
188
+ if (code === "SQLITE_BUSY" && attempt < 2) {
189
+ // Brief sync delay before retry (Bun doesn't have Atomics.wait, use a spin)
190
+ const end = Date.now() + 50 * (attempt + 1);
191
+ while (Date.now() < end) {
192
+ /* spin wait */
193
+ }
194
+ continue;
195
+ }
196
+ console.error(`[Trace] markTraceEventsPosted failed (attempt ${attempt + 1}/3, ${ids.length} ids):`, err);
197
+ return false;
198
+ }
180
199
  }
200
+ return false;
181
201
  }
182
202
 
183
203
  export function insertTraceEvent(
@@ -6,17 +6,8 @@
6
6
  * hooks write → trace_events table → flush loop reads → batches → posts to thread
7
7
  */
8
8
 
9
- import {
10
- ChannelType,
11
- type Client,
12
- type TextChannel,
13
- type ThreadChannel,
14
- } from "discord.js";
15
- import {
16
- TRACE_FLUSH_INTERVAL_MS,
17
- TRACE_THREAD_ENABLED,
18
- TRACE_THREAD_NAME,
19
- } from "./config.ts";
9
+ import { ChannelType, type Client, type TextChannel, type ThreadChannel } from "discord.js";
10
+ import { TRACE_FLUSH_INTERVAL_MS, TRACE_THREAD_ENABLED, TRACE_THREAD_NAME } from "./config.ts";
20
11
  import {
21
12
  getPendingTraceEvents,
22
13
  getTraceThreadId,
@@ -241,7 +232,7 @@ function formatTimestamp(iso: string): string {
241
232
  /** Clean up text for display — convert literal \n to real newlines, preserve full content. */
242
233
  function cleanWhitespace(text: string): string {
243
234
  return String(text || "")
244
- .replace(/\\n/g, "\n") // convert literal \n to real newlines
235
+ .replace(/\\n/g, "\n") // convert literal \n to real newlines
245
236
  .replace(/[ \t]+/g, " ") // collapse horizontal whitespace (but keep newlines)
246
237
  .trim();
247
238
  }
@@ -284,14 +275,25 @@ async function flushTraceEvents(client: Client) {
284
275
  return true;
285
276
  });
286
277
 
287
- // Even if filtered out, mark all as posted
288
- postedIds.push(...channelEvents.map((e) => e.id));
289
-
290
- if (!meaningful.length) continue;
278
+ // If all events were filtered out, mark them as posted immediately
279
+ if (!meaningful.length) {
280
+ postedIds.push(...channelEvents.map((e) => e.id));
281
+ continue;
282
+ }
291
283
 
292
284
  try {
293
285
  const thread = await ensureTraceThread(client, channelId);
294
- if (!thread) continue;
286
+ if (!thread) {
287
+ // Can't get thread (cooldown or missing channel) — DON'T mark as posted
288
+ // so they'll be retried after cooldown expires. To prevent infinite
289
+ // growth, mark them posted if they're older than the cooldown period.
290
+ const now = Date.now();
291
+ for (const e of channelEvents) {
292
+ const age = now - new Date(e.created_at).getTime();
293
+ if (age > FAILURE_COOLDOWN_MS) postedIds.push(e.id);
294
+ }
295
+ continue;
296
+ }
295
297
 
296
298
  // Batch into a single message (Discord max 2000 chars)
297
299
  const lines = meaningful.map(formatTraceEvent);
@@ -300,6 +302,9 @@ async function flushTraceEvents(client: Client) {
300
302
  for (const batch of batches) {
301
303
  await thread.send(batch);
302
304
  }
305
+
306
+ // Only mark as posted AFTER successful Discord send
307
+ postedIds.push(...channelEvents.map((e) => e.id));
303
308
  } catch (err) {
304
309
  const code = (err as any)?.code;
305
310
  if (code === 50001 || code === 50013) {
@@ -307,13 +312,23 @@ async function flushTraceEvents(client: Client) {
307
312
  console.warn(`[Trace] No access to trace thread for channel ${channelId} (${code}) — backing off 5m`);
308
313
  threadCache.delete(channelId);
309
314
  failedChannels.set(channelId, Date.now());
315
+ // Mark as posted to avoid infinite retry on permanent access errors
316
+ postedIds.push(...channelEvents.map((e) => e.id));
310
317
  } else {
311
318
  console.error(`[Trace] Failed to post trace events for channel ${channelId}:`, err);
319
+ // Don't mark as posted — they'll be retried on next flush
312
320
  }
313
321
  }
314
322
  }
315
323
 
316
- markTraceEventsPosted(postedIds);
324
+ if (postedIds.length > 0) {
325
+ const success = markTraceEventsPosted(postedIds);
326
+ if (!success) {
327
+ console.error(
328
+ `[Trace] CRITICAL: Failed to mark ${postedIds.length} events as posted — duplicates likely on next flush`,
329
+ );
330
+ }
331
+ }
317
332
  }
318
333
 
319
334
  /** Split lines into batches that fit within maxLen characters.
@@ -16,6 +16,21 @@ const textParts: string[] = [];
16
16
 
17
17
  for (let i = 0; i < args.length; i++) {
18
18
  const arg = args[i];
19
+ if (arg === "--help" || arg === "-h") {
20
+ console.log(`
21
+ Usage: send-discord [--channel <id>] [--reply <messageId>] "message"
22
+
23
+ Options:
24
+ --channel Target channel ID (defaults to AGENT_ID env var)
25
+ --reply Message ID to reply to
26
+
27
+ Examples:
28
+ send-discord "Build started"
29
+ send-discord --channel 123456789012345678 "Hello from Claude"
30
+ send-discord --reply 123456789012345678 "Thanks, on it"
31
+ `);
32
+ process.exit(0);
33
+ }
19
34
  if (arg === "--channel" && args[i + 1]) {
20
35
  channelId = args[++i];
21
36
  continue;
@@ -24,6 +39,11 @@ for (let i = 0; i < args.length; i++) {
24
39
  replyTo = args[++i];
25
40
  continue;
26
41
  }
42
+ // Reject unrecognized flags — don't let them become message content
43
+ if (arg.startsWith("--")) {
44
+ console.error(`Unknown flag: ${arg}\nUse --help for usage.`);
45
+ process.exit(1);
46
+ }
27
47
  textParts.push(arg);
28
48
  }
29
49