@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.
- package/hooks/track-activity.ts +16 -5
- package/package.json +1 -1
- package/server/db.ts +27 -7
- package/server/trace-thread.ts +33 -18
- package/tools/send-discord.ts +20 -0
package/hooks/track-activity.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
126
|
+
const row = db
|
|
127
|
+
.prepare(`
|
|
122
128
|
SELECT started_at FROM agent_activity
|
|
123
129
|
WHERE session_id = ? AND agent_id = ?
|
|
124
|
-
`)
|
|
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 {
|
|
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 {
|
|
169
|
+
} catch {
|
|
170
|
+
/* fail-open */
|
|
171
|
+
}
|
|
161
172
|
}
|
|
162
173
|
}
|
|
163
174
|
} catch {
|
package/package.json
CHANGED
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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(
|
package/server/trace-thread.ts
CHANGED
|
@@ -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
|
-
|
|
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")
|
|
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
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
package/tools/send-discord.ts
CHANGED
|
@@ -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
|
|