@gonzih/cc-discord 0.2.39 → 0.2.41
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/dist/bot.d.ts +21 -0
- package/dist/bot.js +92 -0
- package/dist/meta-agent-manager.d.ts +0 -15
- package/dist/meta-agent-manager.js +117 -6
- package/dist/notifier.js +95 -38
- package/package.json +1 -1
package/dist/bot.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Discord bot that routes messages to/from a Claude Code subprocess.
|
|
3
3
|
* One ClaudeProcess per channel (or channel:thread) — sessions are isolated per channel.
|
|
4
4
|
*/
|
|
5
|
+
import { Message } from "discord.js";
|
|
5
6
|
import { Redis } from "ioredis";
|
|
6
7
|
/** Returns true if the attachment name/contentType indicates an audio file. */
|
|
7
8
|
export declare function isAudioAttachment(name: string, contentType: string): boolean;
|
|
@@ -56,10 +57,30 @@ export declare class CcDiscordBot {
|
|
|
56
57
|
loadChannelMappings(): Promise<void>;
|
|
57
58
|
/** Typing intervals for meta-agent routed channels — keyed by channelId. */
|
|
58
59
|
private metaAgentTypingTimers;
|
|
60
|
+
/** Live Discord messages per channel — edited in-place as Claude streams output. */
|
|
61
|
+
private liveMessages;
|
|
59
62
|
/** Start (or reset) the typing indicator for a meta-agent–routed channel. */
|
|
60
63
|
private startMetaAgentTyping;
|
|
61
64
|
/** Stop the typing indicator for a meta-agent–routed channel. Called by the notifier on flush. */
|
|
62
65
|
stopMetaAgentTyping(channelId: string): void;
|
|
66
|
+
/**
|
|
67
|
+
* Get or create a live Discord message for `channelId`.
|
|
68
|
+
* If a live message already exists (e.g. from a tool_start), returns it (for reuse).
|
|
69
|
+
* Otherwise sends `initial` to create the placeholder and stops the typing indicator.
|
|
70
|
+
*/
|
|
71
|
+
startOrGetLiveMessage(channelId: string, initial: string): Promise<Message | null>;
|
|
72
|
+
/**
|
|
73
|
+
* Throttled edit of the live message for `channelId`.
|
|
74
|
+
* Safe to call on every token/chunk — edits are coalesced at 750ms intervals.
|
|
75
|
+
* The latest `text` always wins (older pending updates are replaced).
|
|
76
|
+
*/
|
|
77
|
+
updateLiveMessage(channelId: string, text: string): void;
|
|
78
|
+
/**
|
|
79
|
+
* Final edit of the live message — applies `text` without cursor, clears tracker.
|
|
80
|
+
* If no live message exists, falls back to sendWithFileDetection (new message).
|
|
81
|
+
* Overflow past 2000 chars spawns continuation messages via sendToChannelById.
|
|
82
|
+
*/
|
|
83
|
+
finalizeLiveMessage(channelId: string, text: string): Promise<void>;
|
|
63
84
|
/** Session key: "channelId" or "channelId:threadId" for threads */
|
|
64
85
|
private sessionKey;
|
|
65
86
|
/** Get the channel/thread for sending messages */
|
package/dist/bot.js
CHANGED
|
@@ -231,6 +231,8 @@ export class CcDiscordBot {
|
|
|
231
231
|
}
|
|
232
232
|
/** Typing intervals for meta-agent routed channels — keyed by channelId. */
|
|
233
233
|
metaAgentTypingTimers = new Map();
|
|
234
|
+
/** Live Discord messages per channel — edited in-place as Claude streams output. */
|
|
235
|
+
liveMessages = new Map();
|
|
234
236
|
/** Start (or reset) the typing indicator for a meta-agent–routed channel. */
|
|
235
237
|
startMetaAgentTyping(channelId, channel) {
|
|
236
238
|
this.stopMetaAgentTyping(channelId);
|
|
@@ -245,6 +247,96 @@ export class CcDiscordBot {
|
|
|
245
247
|
this.metaAgentTypingTimers.delete(channelId);
|
|
246
248
|
}
|
|
247
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Get or create a live Discord message for `channelId`.
|
|
252
|
+
* If a live message already exists (e.g. from a tool_start), returns it (for reuse).
|
|
253
|
+
* Otherwise sends `initial` to create the placeholder and stops the typing indicator.
|
|
254
|
+
*/
|
|
255
|
+
async startOrGetLiveMessage(channelId, initial) {
|
|
256
|
+
const existing = this.liveMessages.get(channelId);
|
|
257
|
+
if (existing)
|
|
258
|
+
return existing.msg;
|
|
259
|
+
const channel = await this.getChannel(channelId);
|
|
260
|
+
if (!channel)
|
|
261
|
+
return null;
|
|
262
|
+
this.stopMetaAgentTyping(channelId);
|
|
263
|
+
try {
|
|
264
|
+
const msg = await channel.send(initial || "▋");
|
|
265
|
+
this.liveMessages.set(channelId, { msg, lastEditAt: Date.now(), editTimer: null, pendingText: null });
|
|
266
|
+
return msg;
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
console.warn(`[bot] startOrGetLiveMessage failed (${channelId}):`, err.message);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Throttled edit of the live message for `channelId`.
|
|
275
|
+
* Safe to call on every token/chunk — edits are coalesced at 750ms intervals.
|
|
276
|
+
* The latest `text` always wins (older pending updates are replaced).
|
|
277
|
+
*/
|
|
278
|
+
updateLiveMessage(channelId, text) {
|
|
279
|
+
const entry = this.liveMessages.get(channelId);
|
|
280
|
+
if (!entry)
|
|
281
|
+
return;
|
|
282
|
+
entry.pendingText = text;
|
|
283
|
+
if (entry.editTimer)
|
|
284
|
+
return; // timer already scheduled; will pick up pendingText when it fires
|
|
285
|
+
const msUntilSafe = Math.max(0, 750 - (Date.now() - entry.lastEditAt));
|
|
286
|
+
entry.editTimer = setTimeout(async () => {
|
|
287
|
+
entry.editTimer = null;
|
|
288
|
+
const t = entry.pendingText;
|
|
289
|
+
if (t == null)
|
|
290
|
+
return;
|
|
291
|
+
entry.pendingText = null;
|
|
292
|
+
try {
|
|
293
|
+
await entry.msg.edit(t.slice(0, 1990));
|
|
294
|
+
entry.lastEditAt = Date.now();
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
this.liveMessages.delete(channelId); // message deleted or perms lost — give up
|
|
298
|
+
}
|
|
299
|
+
}, msUntilSafe);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Final edit of the live message — applies `text` without cursor, clears tracker.
|
|
303
|
+
* If no live message exists, falls back to sendWithFileDetection (new message).
|
|
304
|
+
* Overflow past 2000 chars spawns continuation messages via sendToChannelById.
|
|
305
|
+
*/
|
|
306
|
+
async finalizeLiveMessage(channelId, text) {
|
|
307
|
+
const entry = this.liveMessages.get(channelId);
|
|
308
|
+
if (entry?.editTimer) {
|
|
309
|
+
clearTimeout(entry.editTimer);
|
|
310
|
+
entry.editTimer = null;
|
|
311
|
+
}
|
|
312
|
+
this.liveMessages.delete(channelId);
|
|
313
|
+
if (!entry) {
|
|
314
|
+
if (text.trim())
|
|
315
|
+
await this.sendWithFileDetection(channelId, text);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (!text.trim()) {
|
|
319
|
+
// Nothing to show — delete the placeholder message
|
|
320
|
+
entry.msg.delete().catch(() => { });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const formatted = formatForDiscord(text);
|
|
324
|
+
const chunks = splitLongMessage(formatted);
|
|
325
|
+
try {
|
|
326
|
+
await entry.msg.edit(chunks[0]);
|
|
327
|
+
for (const chunk of chunks.slice(1)) {
|
|
328
|
+
if (chunk.trim())
|
|
329
|
+
await this.sendToChannelById(channelId, chunk);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// Message was deleted or edit failed — send as new messages
|
|
334
|
+
for (const chunk of chunks) {
|
|
335
|
+
if (chunk.trim())
|
|
336
|
+
await this.sendWithFileDetection(channelId, chunk).catch(() => { });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
248
340
|
/** Session key: "channelId" or "channelId:threadId" for threads */
|
|
249
341
|
sessionKey(channelId, threadId) {
|
|
250
342
|
return threadId ? `${channelId}:${threadId}` : channelId;
|
|
@@ -65,21 +65,6 @@ export interface MetaAgentManager {
|
|
|
65
65
|
/** Write a raw line to the stdin of the running session, if any. */
|
|
66
66
|
sendToSession: (ns: string, line: string) => void;
|
|
67
67
|
}
|
|
68
|
-
/**
|
|
69
|
-
* Create a MetaAgentManager that maintains one persistent Claude process per namespace.
|
|
70
|
-
*
|
|
71
|
-
* On first message for a namespace:
|
|
72
|
-
* 1. ensureWorkspace + injectMcp
|
|
73
|
-
* 2. Drain any pending Redis queue entries to stdin
|
|
74
|
-
* 3. Write the new message to stdin
|
|
75
|
-
*
|
|
76
|
-
* On subsequent messages: write directly to stdin of the running process.
|
|
77
|
-
*
|
|
78
|
-
* On process exit: remove from sessions map. Next message triggers a respawn.
|
|
79
|
-
*
|
|
80
|
-
* The 3-second poll loop drains any messages that arrived while a session was
|
|
81
|
-
* starting up or temporarily unavailable.
|
|
82
|
-
*/
|
|
83
68
|
export declare function createMetaAgentManager(): MetaAgentManager;
|
|
84
69
|
/**
|
|
85
70
|
* Migrate the old cc-agent meta input key to the new cc-discord key.
|
|
@@ -16,7 +16,7 @@ import { spawn, execSync } from "child_process";
|
|
|
16
16
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
17
17
|
import { join } from "path";
|
|
18
18
|
import { homedir } from "os";
|
|
19
|
-
import { CC_DISCORD_WORKSPACE_ROOT, TIMING, DISCORD_INSTANCE_KEY, discordMetaInputKey, metaStreamChannel as ccWireMetaStreamChannel, metaLogKey as ccWireMetaLogKey, } from "@gonzih/cc-wire";
|
|
19
|
+
import { CC_DISCORD_WORKSPACE_ROOT, TIMING, DISCORD_INSTANCE_KEY, discordMetaInputKey, discordChatOutgoing, metaStreamChannel as ccWireMetaStreamChannel, metaLogKey as ccWireMetaLogKey, } from "@gonzih/cc-wire";
|
|
20
20
|
const WORKSPACE_ROOT = join(homedir(), CC_DISCORD_WORKSPACE_ROOT);
|
|
21
21
|
/**
|
|
22
22
|
* Returns the path to the workspace for the given namespace.
|
|
@@ -193,17 +193,28 @@ function wireStdoutToRedis(proc, ns, wire) {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
else if (type === "tool_use") {
|
|
196
|
+
const toolName = parsed.name ?? "tool";
|
|
196
197
|
structuredEvent = {
|
|
197
198
|
type: "tool_use",
|
|
198
|
-
name:
|
|
199
|
+
name: toolName,
|
|
199
200
|
input: parsed.input ?? {},
|
|
200
201
|
};
|
|
202
|
+
// Ephemeral signal: notifier shows tool activity overlay in the live message
|
|
203
|
+
rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
|
|
204
|
+
id: crypto.randomUUID(), source: "claude", role: "assistant",
|
|
205
|
+
content: toolName, event: "tool_start", timestamp: new Date().toISOString(), chatId: 0,
|
|
206
|
+
})).catch(() => { });
|
|
201
207
|
}
|
|
202
208
|
else if (type === "tool_result") {
|
|
203
209
|
structuredEvent = {
|
|
204
210
|
type: "tool_result",
|
|
205
211
|
content: parsed.content ?? "",
|
|
206
212
|
};
|
|
213
|
+
// Ephemeral signal: tool finished, notifier can restart finalize timer
|
|
214
|
+
rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
|
|
215
|
+
id: crypto.randomUUID(), source: "claude", role: "assistant",
|
|
216
|
+
content: "", event: "tool_end", timestamp: new Date().toISOString(), chatId: 0,
|
|
217
|
+
})).catch(() => { });
|
|
207
218
|
}
|
|
208
219
|
else if (type === "result") {
|
|
209
220
|
const resultText = typeof parsed.result === "string" ? parsed.result : JSON.stringify(parsed.result ?? "");
|
|
@@ -212,7 +223,7 @@ function wireStdoutToRedis(proc, ns, wire) {
|
|
|
212
223
|
result: resultText,
|
|
213
224
|
is_error: parsed.is_error ?? false,
|
|
214
225
|
};
|
|
215
|
-
if (resultText) {
|
|
226
|
+
if (resultText && !parsed.is_error) {
|
|
216
227
|
const msg = {
|
|
217
228
|
id: crypto.randomUUID(),
|
|
218
229
|
source: "claude",
|
|
@@ -221,10 +232,44 @@ function wireStdoutToRedis(proc, ns, wire) {
|
|
|
221
232
|
timestamp: new Date().toISOString(),
|
|
222
233
|
chatId: 0,
|
|
223
234
|
};
|
|
224
|
-
|
|
235
|
+
// Publish text first (logs to chat history), then signal done for immediate finalization
|
|
236
|
+
wire.discord.publishOutgoing(ns, msg).then(() => {
|
|
237
|
+
rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
|
|
238
|
+
id: crypto.randomUUID(), source: "claude", role: "assistant",
|
|
239
|
+
content: "", event: "done", timestamp: new Date().toISOString(), chatId: 0,
|
|
240
|
+
})).catch(() => { });
|
|
241
|
+
}).catch((err) => {
|
|
225
242
|
console.warn(`[meta-agent-manager] publishOutgoing (result) failed (ns=${ns}):`, err.message);
|
|
243
|
+
// Still signal done even if text publish failed
|
|
244
|
+
rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
|
|
245
|
+
id: crypto.randomUUID(), source: "claude", role: "assistant",
|
|
246
|
+
content: "", event: "done", timestamp: new Date().toISOString(), chatId: 0,
|
|
247
|
+
})).catch(() => { });
|
|
226
248
|
});
|
|
227
249
|
}
|
|
250
|
+
else if (parsed.is_error) {
|
|
251
|
+
// Error result: publish error text and signal done
|
|
252
|
+
const errMsg = {
|
|
253
|
+
id: crypto.randomUUID(),
|
|
254
|
+
source: "claude",
|
|
255
|
+
role: "assistant",
|
|
256
|
+
content: `⚠️ Error: ${resultText || "unknown error"}`,
|
|
257
|
+
timestamp: new Date().toISOString(),
|
|
258
|
+
chatId: 0,
|
|
259
|
+
};
|
|
260
|
+
wire.discord.publishOutgoing(ns, errMsg).catch(() => { });
|
|
261
|
+
rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
|
|
262
|
+
id: crypto.randomUUID(), source: "claude", role: "assistant",
|
|
263
|
+
content: "", event: "done", timestamp: new Date().toISOString(), chatId: 0,
|
|
264
|
+
})).catch(() => { });
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// Empty result — just signal done
|
|
268
|
+
rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
|
|
269
|
+
id: crypto.randomUUID(), source: "claude", role: "assistant",
|
|
270
|
+
content: "", event: "done", timestamp: new Date().toISOString(), chatId: 0,
|
|
271
|
+
})).catch(() => { });
|
|
272
|
+
}
|
|
228
273
|
}
|
|
229
274
|
else {
|
|
230
275
|
structuredEvent = parsed;
|
|
@@ -293,7 +338,7 @@ export function spawnSession(ns, message, token, wire) {
|
|
|
293
338
|
* Stdout is wired to Redis via wireStdoutToRedis.
|
|
294
339
|
* Returns the ChildProcess.
|
|
295
340
|
*/
|
|
296
|
-
function spawnPersistentSession(ns, token, wire, onExit) {
|
|
341
|
+
function spawnPersistentSession(ns, token, wire, onExit, onOutput) {
|
|
297
342
|
const wsPath = workspacePath(ns);
|
|
298
343
|
const claudeBin = resolveClaude();
|
|
299
344
|
const env = buildEnv(token);
|
|
@@ -312,6 +357,9 @@ function spawnPersistentSession(ns, token, wire, onExit) {
|
|
|
312
357
|
], { cwd: wsPath, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
313
358
|
proc.stdin.setDefaultEncoding("utf8");
|
|
314
359
|
wireStdoutToRedis(proc, ns, wire);
|
|
360
|
+
if (onOutput) {
|
|
361
|
+
proc.stdout.on("data", onOutput);
|
|
362
|
+
}
|
|
315
363
|
proc.on("exit", (code) => {
|
|
316
364
|
console.log(`[meta-agent-manager] persistent session exited (ns=${ns}, code=${code})`);
|
|
317
365
|
onExit();
|
|
@@ -337,15 +385,22 @@ function spawnPersistentSession(ns, token, wire, onExit) {
|
|
|
337
385
|
* The 3-second poll loop drains any messages that arrived while a session was
|
|
338
386
|
* starting up or temporarily unavailable.
|
|
339
387
|
*/
|
|
388
|
+
/** Kill a session after this many ms of no stdout (or no new stdin). */
|
|
389
|
+
const SESSION_INACTIVITY_MS = 5 * 60_000; // 5 minutes
|
|
340
390
|
export function createMetaAgentManager() {
|
|
341
391
|
let pollInterval = null;
|
|
392
|
+
let watchdogInterval = null;
|
|
342
393
|
/** One persistent ChildProcess per namespace. */
|
|
343
394
|
const sessions = new Map();
|
|
344
395
|
/** Namespaces currently being set up (workspace clone / first spawn). */
|
|
345
396
|
const startingUp = new Set();
|
|
397
|
+
/** Wire reference needed for watchdog notifications. Set in startPolling. */
|
|
398
|
+
let wireRef = null;
|
|
346
399
|
/**
|
|
347
400
|
* Write a line to the stdin of the persistent session for ns.
|
|
348
401
|
* Silently no-ops if no session exists.
|
|
402
|
+
* Resets the inactivity timer so the watchdog doesn't kill a session
|
|
403
|
+
* that just received a message but hasn't responded yet.
|
|
349
404
|
*/
|
|
350
405
|
function writeToStdin(ns, line) {
|
|
351
406
|
const session = sessions.get(ns);
|
|
@@ -354,11 +409,54 @@ export function createMetaAgentManager() {
|
|
|
354
409
|
try {
|
|
355
410
|
// Interactive mode: plain text line, Claude reads it as user input
|
|
356
411
|
session.proc.stdin.write(`${line}\n`);
|
|
412
|
+
// Reset inactivity timer — the session now has SESSION_INACTIVITY_MS to respond
|
|
413
|
+
session.lastOutputAt = Date.now();
|
|
357
414
|
}
|
|
358
415
|
catch (err) {
|
|
359
416
|
console.warn(`[meta-agent-manager] stdin write failed (ns=${ns}):`, err.message);
|
|
360
417
|
}
|
|
361
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* Watchdog: scan all sessions every 60s. If any session has been silent
|
|
421
|
+
* (no stdout and no new stdin) for SESSION_INACTIVITY_MS, kill it and
|
|
422
|
+
* notify Discord so the user can see why the session went quiet.
|
|
423
|
+
*/
|
|
424
|
+
function startWatchdog() {
|
|
425
|
+
if (watchdogInterval)
|
|
426
|
+
return;
|
|
427
|
+
watchdogInterval = setInterval(() => {
|
|
428
|
+
if (!wireRef)
|
|
429
|
+
return;
|
|
430
|
+
const now = Date.now();
|
|
431
|
+
for (const [ns, session] of sessions) {
|
|
432
|
+
const idleMs = now - session.lastOutputAt;
|
|
433
|
+
if (idleMs < SESSION_INACTIVITY_MS)
|
|
434
|
+
continue;
|
|
435
|
+
const idleMin = Math.round(idleMs / 60_000);
|
|
436
|
+
console.warn(`[meta-agent-manager] watchdog: ns=${ns} idle ${idleMin}m, killing stuck session`);
|
|
437
|
+
// Notify Discord before killing so the user knows what happened
|
|
438
|
+
const alertMsg = {
|
|
439
|
+
id: crypto.randomUUID(),
|
|
440
|
+
source: "claude",
|
|
441
|
+
role: "assistant",
|
|
442
|
+
content: `⚠️ Session for **${ns}** was idle for ${idleMin} minutes (likely stuck on a tool call). Killed and removed — send any message to restart.`,
|
|
443
|
+
timestamp: new Date().toISOString(),
|
|
444
|
+
chatId: 0,
|
|
445
|
+
};
|
|
446
|
+
wireRef.discord.publishOutgoing(ns, alertMsg).catch((err) => {
|
|
447
|
+
console.warn(`[meta-agent-manager] watchdog publishOutgoing failed (ns=${ns}):`, err.message);
|
|
448
|
+
});
|
|
449
|
+
try {
|
|
450
|
+
session.proc.stdin.end();
|
|
451
|
+
session.proc.kill("SIGTERM");
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// ignore kill errors
|
|
455
|
+
}
|
|
456
|
+
sessions.delete(ns);
|
|
457
|
+
}
|
|
458
|
+
}, 60_000);
|
|
459
|
+
}
|
|
362
460
|
/**
|
|
363
461
|
* Drain all pending messages from the Redis input queue into the session's stdin.
|
|
364
462
|
*/
|
|
@@ -405,12 +503,19 @@ export function createMetaAgentManager() {
|
|
|
405
503
|
const wsPath = workspacePath(ns);
|
|
406
504
|
await ensureWorkspace(ns, repoUrl);
|
|
407
505
|
injectMcp(ns, wsPath, token);
|
|
506
|
+
// Placeholder — filled in after proc is known so the onOutput closure can update it
|
|
507
|
+
let session;
|
|
408
508
|
const proc = spawnPersistentSession(ns, token, wire, () => {
|
|
409
509
|
// On exit: remove from map so next message triggers a respawn
|
|
410
510
|
sessions.delete(ns);
|
|
411
511
|
console.log(`[meta-agent-manager] session removed from map (ns=${ns})`);
|
|
512
|
+
}, () => {
|
|
513
|
+
// Reset inactivity timer on any stdout data
|
|
514
|
+
if (session)
|
|
515
|
+
session.lastOutputAt = Date.now();
|
|
412
516
|
});
|
|
413
|
-
|
|
517
|
+
session = { proc, ns, lastOutputAt: Date.now() };
|
|
518
|
+
sessions.set(ns, session);
|
|
414
519
|
// Drain any messages that arrived before this session started
|
|
415
520
|
await drainQueue(ns, wire);
|
|
416
521
|
// Write the triggering message last (after queue drain, in order)
|
|
@@ -443,6 +548,8 @@ export function createMetaAgentManager() {
|
|
|
443
548
|
startPolling(wire, getNamespaces, instanceId) {
|
|
444
549
|
if (pollInterval)
|
|
445
550
|
return; // already running
|
|
551
|
+
wireRef = wire;
|
|
552
|
+
startWatchdog();
|
|
446
553
|
pollInterval = setInterval(() => {
|
|
447
554
|
const namespaces = getNamespaces();
|
|
448
555
|
if (namespaces.length === 0)
|
|
@@ -499,6 +606,10 @@ export function createMetaAgentManager() {
|
|
|
499
606
|
pollInterval = null;
|
|
500
607
|
console.log("[meta-agent-manager] polling stopped");
|
|
501
608
|
}
|
|
609
|
+
if (watchdogInterval) {
|
|
610
|
+
clearInterval(watchdogInterval);
|
|
611
|
+
watchdogInterval = null;
|
|
612
|
+
}
|
|
502
613
|
// Kill all active sessions
|
|
503
614
|
for (const [ns, session] of sessions) {
|
|
504
615
|
try {
|
package/dist/notifier.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { createHash } from "crypto";
|
|
14
14
|
import { discordChatLog, discordChatOutgoing, discordNotify, notifyChannel, chatIncomingChannel, createCcWire, TIMING, dedupKey, } from "@gonzih/cc-wire";
|
|
15
|
-
import {
|
|
15
|
+
import { stripAnsi } from "./formatter.js";
|
|
16
16
|
/**
|
|
17
17
|
* Parse an `eval_report` object embedded in a raw notification JSON string.
|
|
18
18
|
* Returns null when the field is absent, malformed, or the input is not JSON.
|
|
@@ -270,30 +270,37 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
270
270
|
log("info", `psubscribed to ${outgoingPattern}`);
|
|
271
271
|
}
|
|
272
272
|
});
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (!buf || !buf.text.trim())
|
|
280
|
-
return;
|
|
281
|
-
const text = `← [${ns}] ` + stripAnsi(buf.text.trim());
|
|
282
|
-
if (text.length < 30) {
|
|
283
|
-
buf.text = "";
|
|
284
|
-
buf.timer = null;
|
|
285
|
-
return;
|
|
273
|
+
const liveStates = new Map();
|
|
274
|
+
function getLiveState(ns, targetChannelId) {
|
|
275
|
+
let state = liveStates.get(ns);
|
|
276
|
+
if (!state) {
|
|
277
|
+
state = { text: "", toolStatus: "", targetChannelId, starting: false, liveStarted: false, finalTimer: null };
|
|
278
|
+
liveStates.set(ns, state);
|
|
286
279
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
bot.sendWithFileDetection(deliverTo, chunk).catch((err) => {
|
|
294
|
-
log("warn", `meta-agent flush sendWithFileDetection failed (ns=${ns}):`, err.message);
|
|
295
|
-
});
|
|
280
|
+
return state;
|
|
281
|
+
}
|
|
282
|
+
async function finalizeState(ns, state) {
|
|
283
|
+
if (state.finalTimer) {
|
|
284
|
+
clearTimeout(state.finalTimer);
|
|
285
|
+
state.finalTimer = null;
|
|
296
286
|
}
|
|
287
|
+
liveStates.delete(ns);
|
|
288
|
+
const deliverTo = bot.getLoopThreadId(state.targetChannelId) ?? state.targetChannelId;
|
|
289
|
+
bot.stopMetaAgentTyping(deliverTo);
|
|
290
|
+
const trimmed = state.text.trim();
|
|
291
|
+
if (!trimmed && !state.liveStarted)
|
|
292
|
+
return;
|
|
293
|
+
const fullText = trimmed ? `← [${ns}]\n${stripAnsi(trimmed)}` : "";
|
|
294
|
+
await bot.finalizeLiveMessage(deliverTo, fullText);
|
|
295
|
+
}
|
|
296
|
+
function scheduleFinal(ns, state) {
|
|
297
|
+
if (state.finalTimer)
|
|
298
|
+
clearTimeout(state.finalTimer);
|
|
299
|
+
state.finalTimer = setTimeout(() => {
|
|
300
|
+
finalizeState(ns, state).catch((err) => {
|
|
301
|
+
log("warn", `meta-agent finalize failed (ns=${ns}):`, err.message);
|
|
302
|
+
});
|
|
303
|
+
}, TIMING.META_AGENT_FLUSH_DELAY_MS);
|
|
297
304
|
}
|
|
298
305
|
sub.on("pmessage", (pattern, channel, message) => {
|
|
299
306
|
void pattern;
|
|
@@ -305,28 +312,78 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
305
312
|
catch {
|
|
306
313
|
return;
|
|
307
314
|
}
|
|
308
|
-
if (parsed
|
|
315
|
+
if (parsed?.source !== "claude")
|
|
309
316
|
return;
|
|
310
|
-
const content = parsed.content;
|
|
311
|
-
if (!content)
|
|
312
|
-
return;
|
|
313
|
-
// For the primary namespace: deliver to the primary Discord channel.
|
|
314
|
-
// For other (routed) namespaces: only deliver to explicitly registered channelIds.
|
|
315
317
|
const targetChannelId = routedChannelIds.get(ns) ??
|
|
316
318
|
(ns === namespace ? (notifyChannelId ?? getActiveChannelId?.()) : undefined);
|
|
317
319
|
if (targetChannelId == null) {
|
|
318
|
-
log("warn", `meta-agent output: no channelId for namespace=${ns}, dropping
|
|
320
|
+
log("warn", `meta-agent output: no channelId for namespace=${ns}, dropping`);
|
|
319
321
|
return;
|
|
320
322
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
323
|
+
const deliverTo = bot.getLoopThreadId(targetChannelId) ?? targetChannelId;
|
|
324
|
+
const state = getLiveState(ns, targetChannelId);
|
|
325
|
+
const event = parsed.event;
|
|
326
|
+
// tool_start: show activity overlay, suspend finalize timer
|
|
327
|
+
if (event === "tool_start") {
|
|
328
|
+
const toolName = parsed.content || "tool";
|
|
329
|
+
state.toolStatus = toolName;
|
|
330
|
+
if (state.finalTimer) {
|
|
331
|
+
clearTimeout(state.finalTimer);
|
|
332
|
+
state.finalTimer = null;
|
|
333
|
+
}
|
|
334
|
+
if (state.liveStarted) {
|
|
335
|
+
const textPart = state.text.trim() ? `← [${ns}]\n${stripAnsi(state.text.trim())}\n\n` : "";
|
|
336
|
+
bot.updateLiveMessage(deliverTo, `${textPart}⚙️ \`${toolName}\`...`);
|
|
337
|
+
}
|
|
338
|
+
else if (!state.starting) {
|
|
339
|
+
state.starting = true;
|
|
340
|
+
bot.startOrGetLiveMessage(deliverTo, `⚙️ \`${toolName}\`...`).then((msg) => {
|
|
341
|
+
state.starting = false;
|
|
342
|
+
if (msg)
|
|
343
|
+
state.liveStarted = true;
|
|
344
|
+
}).catch(() => { state.starting = false; });
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// tool_end: clear overlay, resume finalize timer if text is pending
|
|
349
|
+
if (event === "tool_end") {
|
|
350
|
+
state.toolStatus = "";
|
|
351
|
+
if (state.text.trim())
|
|
352
|
+
scheduleFinal(ns, state);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// done: immediate finalization (fired by meta-agent-manager after result event)
|
|
356
|
+
if (event === "done") {
|
|
357
|
+
finalizeState(ns, state).catch((err) => {
|
|
358
|
+
log("warn", `meta-agent done finalize failed (ns=${ns}):`, err.message);
|
|
359
|
+
});
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
// Regular text chunk: accumulate, update live message
|
|
363
|
+
const content = parsed.content;
|
|
364
|
+
if (!content)
|
|
365
|
+
return;
|
|
366
|
+
state.toolStatus = ""; // text arrived — clear any tool overlay
|
|
367
|
+
state.text += (state.text ? "\n" : "") + content;
|
|
368
|
+
const textDisplay = `← [${ns}]\n${stripAnsi(state.text)} ▋`;
|
|
369
|
+
if (!state.liveStarted) {
|
|
370
|
+
if (!state.starting) {
|
|
371
|
+
state.starting = true;
|
|
372
|
+
// startOrGetLiveMessage reuses existing message if tool_start created one
|
|
373
|
+
bot.startOrGetLiveMessage(deliverTo, textDisplay).then((msg) => {
|
|
374
|
+
state.starting = false;
|
|
375
|
+
if (msg) {
|
|
376
|
+
state.liveStarted = true;
|
|
377
|
+
bot.updateLiveMessage(deliverTo, textDisplay);
|
|
378
|
+
}
|
|
379
|
+
}).catch(() => { state.starting = false; });
|
|
380
|
+
}
|
|
381
|
+
// Text is buffered in state.text; applied when startOrGetLiveMessage resolves
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
bot.updateLiveMessage(deliverTo, textDisplay);
|
|
325
385
|
}
|
|
326
|
-
|
|
327
|
-
if (buf.timer)
|
|
328
|
-
clearTimeout(buf.timer);
|
|
329
|
-
buf.timer = setTimeout(() => flushMetaAgentBuffer(ns, targetChannelId), TIMING.META_AGENT_FLUSH_DELAY_MS);
|
|
386
|
+
scheduleFinal(ns, state);
|
|
330
387
|
});
|
|
331
388
|
// Poll discordNotify(ns) LIST every 5 seconds — covers primary + all routed namespaces.
|
|
332
389
|
const MAX_PER_CYCLE = 20;
|