@gonzih/cc-discord 0.2.40 → 0.2.42
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.js +59 -13
- package/dist/notifier.js +95 -33
- 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;
|
|
@@ -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;
|
|
@@ -298,15 +343,15 @@ function spawnPersistentSession(ns, token, wire, onExit, onOutput) {
|
|
|
298
343
|
const claudeBin = resolveClaude();
|
|
299
344
|
const env = buildEnv(token);
|
|
300
345
|
console.log(`[meta-agent-manager] spawning persistent session (ns=${ns})`);
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
// --output-format stream-json ...` responds correctly; the -p variant does not.
|
|
346
|
+
// --input-format stream-json: Claude reads newline-delimited JSON messages from stdin.
|
|
347
|
+
// Each message must be: {"type":"user","message":{"role":"user","content":"..."}}
|
|
348
|
+
// Claude responds immediately to each message and stays alive waiting for the next one.
|
|
349
|
+
// DO NOT omit --input-format stream-json: without it, Claude ignores stdin when the
|
|
350
|
+
// pipe stays open (no TTY), producing no output until stdin is closed (EOF).
|
|
307
351
|
const proc = spawn(claudeBin, [
|
|
308
352
|
"--continue",
|
|
309
353
|
"--output-format", "stream-json",
|
|
354
|
+
"--input-format", "stream-json",
|
|
310
355
|
"--verbose",
|
|
311
356
|
"--dangerously-skip-permissions",
|
|
312
357
|
], { cwd: wsPath, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
@@ -357,13 +402,14 @@ export function createMetaAgentManager() {
|
|
|
357
402
|
* Resets the inactivity timer so the watchdog doesn't kill a session
|
|
358
403
|
* that just received a message but hasn't responded yet.
|
|
359
404
|
*/
|
|
360
|
-
function writeToStdin(ns,
|
|
405
|
+
function writeToStdin(ns, content) {
|
|
361
406
|
const session = sessions.get(ns);
|
|
362
407
|
if (!session)
|
|
363
408
|
return;
|
|
364
409
|
try {
|
|
365
|
-
//
|
|
366
|
-
|
|
410
|
+
// --input-format stream-json: each message must be a newline-delimited JSON object.
|
|
411
|
+
const jsonMsg = JSON.stringify({ type: "user", message: { role: "user", content } });
|
|
412
|
+
session.proc.stdin.write(`${jsonMsg}\n`);
|
|
367
413
|
// Reset inactivity timer — the session now has SESSION_INACTIVITY_MS to respond
|
|
368
414
|
session.lastOutputAt = Date.now();
|
|
369
415
|
}
|
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,25 +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
|
-
|
|
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);
|
|
279
|
+
}
|
|
280
|
+
return state;
|
|
281
|
+
}
|
|
282
|
+
async function finalizeState(ns, state) {
|
|
283
|
+
if (state.finalTimer) {
|
|
284
|
+
clearTimeout(state.finalTimer);
|
|
285
|
+
state.finalTimer = null;
|
|
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)
|
|
280
292
|
return;
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
log("warn", `meta-agent
|
|
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);
|
|
290
302
|
});
|
|
291
|
-
}
|
|
303
|
+
}, TIMING.META_AGENT_FLUSH_DELAY_MS);
|
|
292
304
|
}
|
|
293
305
|
sub.on("pmessage", (pattern, channel, message) => {
|
|
294
306
|
void pattern;
|
|
@@ -300,28 +312,78 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
300
312
|
catch {
|
|
301
313
|
return;
|
|
302
314
|
}
|
|
303
|
-
if (parsed
|
|
304
|
-
return;
|
|
305
|
-
const content = parsed.content;
|
|
306
|
-
if (!content)
|
|
315
|
+
if (parsed?.source !== "claude")
|
|
307
316
|
return;
|
|
308
|
-
// For the primary namespace: deliver to the primary Discord channel.
|
|
309
|
-
// For other (routed) namespaces: only deliver to explicitly registered channelIds.
|
|
310
317
|
const targetChannelId = routedChannelIds.get(ns) ??
|
|
311
318
|
(ns === namespace ? (notifyChannelId ?? getActiveChannelId?.()) : undefined);
|
|
312
319
|
if (targetChannelId == null) {
|
|
313
|
-
log("warn", `meta-agent output: no channelId for namespace=${ns}, dropping
|
|
320
|
+
log("warn", `meta-agent output: no channelId for namespace=${ns}, dropping`);
|
|
314
321
|
return;
|
|
315
322
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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);
|
|
320
385
|
}
|
|
321
|
-
|
|
322
|
-
if (buf.timer)
|
|
323
|
-
clearTimeout(buf.timer);
|
|
324
|
-
buf.timer = setTimeout(() => flushMetaAgentBuffer(ns, targetChannelId), TIMING.META_AGENT_FLUSH_DELAY_MS);
|
|
386
|
+
scheduleFinal(ns, state);
|
|
325
387
|
});
|
|
326
388
|
// Poll discordNotify(ns) LIST every 5 seconds — covers primary + all routed namespaces.
|
|
327
389
|
const MAX_PER_CYCLE = 20;
|