fix: restore regressions from discord-only refactor

This commit is contained in:
Eyejoker
2026-03-20 03:41:53 +09:00
parent 2b08851c2f
commit a293a701f4
24 changed files with 1779 additions and 396 deletions

View File

@@ -210,4 +210,46 @@ describe('agent-runner timeout behavior', () => {
expect(result.status).toBe('success');
expect(result.newSessionId).toBe('session-456');
});
it('preserves streamed progress phase metadata', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runAgentProcess(
testGroup,
testInput,
() => {},
onOutput,
);
emitOutputMarker(fakeProc, {
status: 'success',
result: '생각 중...',
phase: 'progress',
newSessionId: 'session-progress',
});
emitOutputMarker(fakeProc, {
status: 'success',
result: '최종 답변',
phase: 'final',
newSessionId: 'session-progress',
});
await vi.advanceTimersByTimeAsync(10);
fakeProc.emit('close', 0);
await vi.advanceTimersByTimeAsync(10);
const result = await resultPromise;
expect(result.status).toBe('success');
expect(onOutput).toHaveBeenCalledWith(
expect.objectContaining({
result: '생각 중...',
phase: 'progress',
}),
);
expect(onOutput).toHaveBeenCalledWith(
expect.objectContaining({
result: '최종 답변',
phase: 'final',
}),
);
});
});

View File

@@ -44,6 +44,7 @@ export interface AgentInput {
export interface AgentOutput {
status: 'success' | 'error';
result: string | null;
phase?: 'progress' | 'final';
newSessionId?: string;
error?: string;
}
@@ -164,10 +165,17 @@ function prepareGroupEnvironment(
'CODEX_OPENAI_API_KEY',
'CODEX_MODEL',
'CODEX_EFFORT',
'MEMENTO_MCP_SSE_URL',
'MEMENTO_ACCESS_KEY',
'MEMENTO_MCP_REMOTE_PATH',
]);
// Build a clean env without Claude Code nesting detection variables
const cleanEnv = { ...(process.env as Record<string, string>) };
// Merge .env file values (readEnvFile only reads file, doesn't set process.env)
for (const [k, v] of Object.entries(envVars)) {
if (v && !cleanEnv[k]) cleanEnv[k] = v;
}
delete cleanEnv.CLAUDECODE;
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
@@ -290,8 +298,9 @@ function prepareGroupEnvironment(
let toml = fs.existsSync(configTomlPath)
? fs.readFileSync(configTomlPath, 'utf-8')
: '';
// Remove existing nanoclaw MCP section if present (to refresh env vars)
// Remove existing nanoclaw/memento MCP sections if present (to refresh env vars)
toml = toml.replace(/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/, '');
toml = toml.replace(/\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/, '');
const mcpSection = `
[mcp_servers.nanoclaw]
command = "node"
@@ -303,7 +312,24 @@ NANOCLAW_CHAT_JID = ${JSON.stringify(group.folder)}
NANOCLAW_GROUP_FOLDER = ${JSON.stringify(group.folder)}
NANOCLAW_IS_MAIN = ${JSON.stringify(isMain ? '1' : '0')}
`;
toml = toml.trimEnd() + '\n' + mcpSection;
// Inject memento-mcp if MEMENTO_MCP_SSE_URL is set
const mementoSseUrl =
envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL;
const mementoAccessKey =
envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || '';
const mementoRemotePath =
envVars.MEMENTO_MCP_REMOTE_PATH ||
process.env.MEMENTO_MCP_REMOTE_PATH ||
'mcp-remote';
const mementoSection = mementoSseUrl
? `
[mcp_servers.memento-mcp]
command = ${JSON.stringify(mementoRemotePath)}
args = [${JSON.stringify(mementoSseUrl)}, "--header", ${JSON.stringify(`Authorization:Bearer ${mementoAccessKey}`)}]
`
: '';
toml = toml.trimEnd() + '\n' + mcpSection + mementoSection;
fs.writeFileSync(configTomlPath, toml);
}
@@ -461,6 +487,18 @@ export async function runAgentProcess(
}
hadStreamingOutput = true;
resetTimeout();
if (parsed.status === 'error') {
logger.warn(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
error: parsed.error,
newSessionId: parsed.newSessionId,
},
'Streamed agent error output',
);
}
outputChain = outputChain.then(() => onOutput(parsed));
} catch (err) {
logger.warn(

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { filterProcessableMessages } from './bot-message-filter.js';
import { NewMessage } from './types.js';
function makeMsg(overrides: Partial<NewMessage> = {}): NewMessage {
return {
id: '1',
chat_jid: 'dc:1',
sender: 'user-1',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-20T00:00:00.000Z',
is_from_me: false,
is_bot_message: false,
...overrides,
};
}
const OWN_BOT_ID = 'my-bot-123';
describe('filterProcessableMessages', () => {
it('filters bot-authored messages in normal rooms', () => {
const result = filterProcessableMessages(
[
makeMsg({ id: 'human-1', content: 'human' }),
makeMsg({
id: 'bot-1',
sender: 'bot-1',
sender_name: 'Bot',
content: 'status report',
is_bot_message: true,
}),
],
false,
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('human-1');
});
it('keeps other bot messages in paired rooms', () => {
const isOwn = (m: NewMessage) =>
m.is_bot_message === true && m.sender === OWN_BOT_ID;
const result = filterProcessableMessages(
[
makeMsg({ id: 'human-1', content: 'human' }),
makeMsg({
id: 'bot-1',
sender: 'other-bot-456',
sender_name: 'OtherBot',
content: 'status report',
is_bot_message: true,
}),
],
true,
isOwn,
);
expect(result).toHaveLength(2);
});
it('filters own bot messages in paired rooms', () => {
const isOwn = (m: NewMessage) =>
m.is_bot_message === true && m.sender === OWN_BOT_ID;
const result = filterProcessableMessages(
[
makeMsg({ id: 'human-1', content: 'human' }),
makeMsg({
id: 'own-1',
sender: OWN_BOT_ID,
sender_name: 'MyBot',
content: 'my own output',
is_bot_message: true,
}),
makeMsg({
id: 'other-1',
sender: 'other-bot-456',
sender_name: 'OtherBot',
content: 'partner response',
is_bot_message: true,
}),
],
true,
isOwn,
);
expect(result).toHaveLength(2);
expect(result[0].id).toBe('human-1');
expect(result[1].id).toBe('other-1');
});
it('keeps all bot messages in paired rooms without isOwnMessage', () => {
const result = filterProcessableMessages(
[
makeMsg({ id: 'human-1', content: 'human' }),
makeMsg({
id: 'bot-1',
sender: 'bot-1',
sender_name: 'Bot',
content: 'status report',
is_bot_message: true,
}),
],
true,
);
expect(result).toHaveLength(2);
});
it('filters session command control bot messages in paired rooms', () => {
const isOwn = (m: NewMessage) =>
m.is_bot_message === true && m.sender === OWN_BOT_ID;
const result = filterProcessableMessages(
[
makeMsg({ id: 'human-1', content: 'human' }),
makeMsg({
id: 'control-1',
sender: 'other-bot-456',
sender_name: 'OtherBot',
content:
'Current session cleared. The next message will start a new conversation.',
is_bot_message: true,
}),
makeMsg({
id: 'other-1',
sender: 'other-bot-456',
sender_name: 'OtherBot',
content: 'partner response',
is_bot_message: true,
}),
],
true,
isOwn,
);
expect(result).toHaveLength(2);
expect(result[0].id).toBe('human-1');
expect(result[1].id).toBe('other-1');
});
});

31
src/bot-message-filter.ts Normal file
View File

@@ -0,0 +1,31 @@
import { isSessionCommandControlMessage } from './session-commands.js';
import { NewMessage } from './types.js';
/**
* Filter messages before processing.
* - Normal rooms: drop all bot messages.
* - Paired rooms (allowBotMessages=true): keep other bot's messages,
* but drop messages authored by this service's own bot (via isOwnMessage).
*/
export function filterProcessableMessages(
messages: NewMessage[],
allowBotMessages: boolean,
isOwnMessage?: (msg: NewMessage) => boolean,
): NewMessage[] {
const withoutControlMessages = messages.filter(
(message) =>
!(
message.is_bot_message &&
isSessionCommandControlMessage(message.content)
),
);
if (allowBotMessages) {
// In paired rooms, allow other bot messages but filter own bot's output
if (isOwnMessage) {
return withoutControlMessages.filter((m) => !isOwnMessage(m));
}
return withoutControlMessages;
}
return withoutControlMessages.filter((message) => !message.is_bot_message);
}

View File

@@ -26,6 +26,12 @@ vi.mock('../logger.js', () => ({
},
}));
const isPairedRoomJidMock = vi.hoisted(() => vi.fn(() => false));
vi.mock('../db.js', () => ({
isPairedRoomJid: isPairedRoomJidMock,
}));
// --- discord.js mock ---
type Handler = (...args: any[]) => any;
@@ -196,6 +202,7 @@ async function triggerMessage(message: any) {
describe('DiscordChannel', () => {
beforeEach(() => {
vi.clearAllMocks();
isPairedRoomJidMock.mockReturnValue(false);
});
afterEach(() => {
@@ -301,18 +308,54 @@ describe('DiscordChannel', () => {
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('delivers bot messages with is_bot_message flag', async () => {
it('ignores its own bot messages', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({ isBot: true, content: 'I am a bot' });
const msg = createMessage({
authorId: '999888777',
isBot: true,
content: 'I am the connected bot',
});
await triggerMessage(msg);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('ignores other bot messages in normal rooms', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
authorId: '111222333',
isBot: true,
content: 'I am another bot',
});
await triggerMessage(msg);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('delivers other bot messages in paired rooms', async () => {
isPairedRoomJidMock.mockReturnValue(true);
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
authorId: '111222333',
isBot: true,
content: 'I am another bot',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
expect.any(String),
'dc:1234567890123456',
expect.objectContaining({
content: 'I am a bot',
content: 'I am another bot',
is_bot_message: true,
}),
);

View File

@@ -17,6 +17,7 @@ import {
DATA_DIR,
TRIGGER_PATTERN,
} from '../config.js';
import { isPairedRoomJid } from '../db.js';
import { readEnvFile } from '../env.js';
import { logger } from '../logger.js';
@@ -158,6 +159,7 @@ import {
AgentType,
Channel,
ChannelMeta,
NewMessage,
OnChatMetadata,
OnInboundMessage,
RegisteredGroup,
@@ -202,11 +204,12 @@ export class DiscordChannel implements Channel {
});
this.client.on(Events.MessageCreate, async (message: Message) => {
// Ignore own messages only
if (message.author.id === this.client?.user?.id) return;
const channelId = message.channelId;
const chatJid = `dc:${channelId}`;
const isOwnBotMessage = message.author.id === this.client?.user?.id;
if (isOwnBotMessage) return;
if (message.author.bot && !isPairedRoomJid(chatJid)) return;
let content = message.content;
const timestamp = message.createdAt.toISOString();
const senderName =
@@ -481,6 +484,10 @@ export class DiscordChannel implements Channel {
return this.client !== null && this.client.isReady();
}
isOwnMessage(msg: NewMessage): boolean {
return !!msg.is_bot_message && msg.sender === this.client?.user?.id;
}
ownsJid(jid: string): boolean {
if (!jid.startsWith('dc:')) return false;
if (!this.agentTypeFilter) return true;

183
src/claude-usage.ts Normal file
View File

@@ -0,0 +1,183 @@
import { spawn } from 'child_process';
import { logger } from './logger.js';
export interface ClaudeUsageData {
five_hour?: { utilization: number; resets_at: string };
seven_day?: { utilization: number; resets_at: string };
}
const CLAUDE_EXPECT_TIMEOUT_MS = 25000;
const ANSI_RE = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
const EXPECT_PROGRAM = `
set timeout 20
log_user 1
match_max 200000
set binary $env(CLAUDE_BINARY)
spawn -noecho -- $binary --setting-sources user --allowed-tools ""
expect {
-re "Do you trust the files in this folder\\\\?" { send "y\\r"; exp_continue }
-re "Quick safety check:" { send "\\r"; exp_continue }
-re "Yes, I trust this folder" { send "\\r"; exp_continue }
-re "Ready to code here\\\\?" { send "\\r"; exp_continue }
-re "Press Enter to continue" { send "\\r"; exp_continue }
timeout {}
}
send "/usage\\r"
set deadline [expr {[clock seconds] + 20}]
while {[clock seconds] < $deadline} {
expect {
-re "Do you trust the files in this folder\\\\?" { send "y\\r"; exp_continue }
-re "Quick safety check:" { send "\\r"; exp_continue }
-re "Yes, I trust this folder" { send "\\r"; exp_continue }
-re "Ready to code here\\\\?" { send "\\r"; exp_continue }
-re "Press Enter to continue" { send "\\r"; exp_continue }
-re "Current session" { after 2000; exit 0 }
-re "Failed to load usage data" { after 500; exit 2 }
eof { exit 3 }
timeout { send "\\r" }
}
}
exit 4
`;
function normalizeLines(rawText: string): string[] {
return rawText
.replace(ANSI_RE, '')
.replace(/\r/g, '\n')
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter(Boolean);
}
function parsePercent(windowText: string): number | null {
const match = windowText.match(/(\d{1,3})%\s*(used|left)\b/i);
if (!match) return null;
const value = parseInt(match[1], 10);
if (Number.isNaN(value)) return null;
return match[2].toLowerCase() === 'left' ? 100 - value : value;
}
function parseWindow(
lines: string[],
labels: string[],
): { utilization: number; resets_at: string } | null {
const normalizedLabels = labels.map((label) => label.toLowerCase());
for (let i = 0; i < lines.length; i++) {
const line = lines[i].toLowerCase();
if (!normalizedLabels.some((label) => line.includes(label))) continue;
const windowLines = lines.slice(i, i + 6);
const windowText = windowLines.join('\n');
const utilization = parsePercent(windowText);
if (utilization === null) continue;
const resetLine = windowLines.find((candidate) =>
candidate.toLowerCase().startsWith('resets'),
);
return {
utilization,
resets_at: resetLine || '',
};
}
return null;
}
export function parseClaudeUsagePanel(rawText: string): ClaudeUsageData | null {
const lines = normalizeLines(rawText);
if (lines.length === 0) return null;
if (
lines.some((line) =>
line.toLowerCase().includes('failed to load usage data'),
)
) {
return null;
}
const fiveHour = parseWindow(lines, ['Current session']);
if (!fiveHour) return null;
const sevenDay =
parseWindow(lines, ['Current week (all models)']) ||
parseWindow(lines, [
'Current week (Sonnet only)',
'Current week (Sonnet)',
]) ||
parseWindow(lines, ['Current week (Opus)']);
return {
five_hour: fiveHour,
...(sevenDay ? { seven_day: sevenDay } : {}),
};
}
export async function fetchClaudeUsageViaCli(
binary = 'claude',
): Promise<ClaudeUsageData | null> {
return new Promise((resolve) => {
let output = '';
let finished = false;
const finish = (value: ClaudeUsageData | null) => {
if (finished) return;
finished = true;
clearTimeout(timer);
resolve(value);
};
let proc: ReturnType<typeof spawn> | null = null;
try {
proc = spawn('expect', ['-c', EXPECT_PROGRAM], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...(process.env as Record<string, string>),
CLAUDE_BINARY: binary,
},
});
} catch (err) {
logger.debug({ err }, 'Claude CLI PTY probe unavailable');
finish(null);
return;
}
const timer = setTimeout(() => {
try {
proc?.kill('SIGTERM');
} catch {
/* ignore */
}
finish(null);
}, CLAUDE_EXPECT_TIMEOUT_MS);
if (!proc.stdout || !proc.stderr) {
finish(null);
return;
}
proc.stdout.setEncoding('utf8');
proc.stderr.setEncoding('utf8');
proc.stdout.on('data', (chunk: string) => {
output += chunk;
});
proc.stderr.on('data', (chunk: string) => {
output += chunk;
});
proc.on('error', (err) => {
logger.debug({ err }, 'Claude CLI PTY probe failed to start');
finish(null);
});
proc.on('close', () => {
const parsed = parseClaudeUsagePanel(output);
if (!parsed && output.trim()) {
logger.debug(
{ tail: output.slice(-400) },
'Claude CLI PTY probe produced unparsable output',
);
}
finish(parsed);
});
});
}

View File

@@ -10,6 +10,7 @@ const envConfig = readEnvFile([
'SERVICE_ID',
'SERVICE_AGENT_TYPE',
'SESSION_COMMAND_ALLOWED_SENDERS',
'SESSION_COMMAND_USER_IDS',
'USAGE_DASHBOARD',
]);
@@ -89,12 +90,15 @@ export const USAGE_DASHBOARD_ENABLED =
export const TIMEZONE =
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
const rawSessionCommandAllowedSenders =
process.env.SESSION_COMMAND_ALLOWED_SENDERS ||
process.env.SESSION_COMMAND_USER_IDS ||
envConfig.SESSION_COMMAND_ALLOWED_SENDERS ||
envConfig.SESSION_COMMAND_USER_IDS ||
'';
const SESSION_COMMAND_ALLOWED_SENDERS = new Set(
(
process.env.SESSION_COMMAND_ALLOWED_SENDERS ||
envConfig.SESSION_COMMAND_ALLOWED_SENDERS ||
''
)
rawSessionCommandAllowedSenders
.split(',')
.map((value) => value.trim())
.filter(Boolean),

View File

@@ -201,6 +201,7 @@ describe('getMessagesSince', () => {
);
const botMsgs = msgs.filter((m) => m.content === 'bot reply');
expect(botMsgs).toHaveLength(1);
expect(botMsgs[0].is_bot_message).toBe(true);
});
it('returns all messages including bot when sinceTimestamp is empty', () => {
@@ -483,4 +484,29 @@ describe('registered group isMain', () => {
expect(group).toBeDefined();
expect(group.isMain).toBeUndefined();
});
it('filters duplicate jid registrations by agent type', () => {
setRegisteredGroup('dc:shared', {
name: 'Shared Room Claude',
folder: 'shared-room',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
setRegisteredGroup('dc:shared', {
name: 'Shared Room Codex',
folder: 'shared-room',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
});
const claudeGroups = getAllRegisteredGroups('claude-code');
const codexGroups = getAllRegisteredGroups('codex');
expect(claudeGroups['dc:shared']?.agentType).toBe('claude-code');
expect(claudeGroups['dc:shared']?.name).toBe('Shared Room Claude');
expect(codexGroups['dc:shared']?.agentType).toBe('codex');
expect(codexGroups['dc:shared']?.name).toBe('Shared Room Codex');
});
});

View File

@@ -378,6 +378,16 @@ export function storeMessage(msg: NewMessage): void {
);
}
function normalizeMessageRow(
row: NewMessage & { is_from_me?: boolean | number; is_bot_message?: boolean | number },
): NewMessage {
return {
...row,
is_from_me: !!row.is_from_me,
is_bot_message: !!row.is_bot_message,
};
}
export function getNewMessages(
jids: string[],
lastTimestamp: string,
@@ -404,14 +414,16 @@ export function getNewMessages(
const rows = db
.prepare(sql)
.all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[];
.all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as Array<
NewMessage & { is_from_me?: boolean | number; is_bot_message?: boolean | number }
>;
let newTimestamp = lastTimestamp;
for (const row of rows) {
if (row.timestamp > newTimestamp) newTimestamp = row.timestamp;
}
return { messages: rows, newTimestamp };
return { messages: rows.map(normalizeMessageRow), newTimestamp };
}
export function getMessagesSince(
@@ -434,9 +446,12 @@ export function getMessagesSince(
LIMIT ?
) ORDER BY timestamp
`;
return db
const rows = db
.prepare(sql)
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as Array<
NewMessage & { is_from_me?: boolean | number; is_bot_message?: boolean | number }
>;
return rows.map(normalizeMessageRow);
}
export function getLastHumanMessageTimestamp(chatJid: string): string | null {
@@ -657,10 +672,17 @@ export function getAllSessions(): Record<string, string> {
export function getRegisteredGroup(
jid: string,
agentType?: string,
): (RegisteredGroup & { jid: string }) | undefined {
const row = db
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
.get(jid) as
const row = (
agentType
? db
.prepare(
'SELECT * FROM registered_groups WHERE jid = ? AND agent_type = ?',
)
.get(jid, agentType)
: db.prepare('SELECT * FROM registered_groups WHERE jid = ?').get(jid)
) as
| {
jid: string;
name: string;
@@ -718,6 +740,13 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
);
}
export function updateRegisteredGroupName(jid: string, name: string): void {
db.prepare('UPDATE registered_groups SET name = ? WHERE jid = ?').run(
name,
jid,
);
}
export function getAllRegisteredGroups(
agentTypeFilter?: string,
): Record<string, RegisteredGroup> {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
describe('shouldResetSessionOnAgentFailure', () => {
it('matches many-image dimension limit errors', () => {
expect(
shouldResetSessionOnAgentFailure({
result:
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
error: undefined,
}),
).toBe(true);
});
it('matches the error field too', () => {
expect(
shouldResetSessionOnAgentFailure({
result: null,
error:
'fatal: An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
}),
).toBe(true);
});
it('does not match unrelated agent failures', () => {
expect(
shouldResetSessionOnAgentFailure({
result: null,
error: 'Claude Code process exited with code 1',
}),
).toBe(false);
});
});

26
src/session-recovery.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { AgentOutput } from './agent-runner.js';
const SESSION_RESET_PATTERNS = [
/An image in the conversation exceeds the dimension limit for many-image requests \(2000px\)\./i,
/Start a new session with fewer images\./i,
];
function toText(value: string | object | null | undefined): string[] {
if (!value) return [];
if (typeof value === 'string') return [value];
try {
return [JSON.stringify(value)];
} catch {
return [];
}
}
export function shouldResetSessionOnAgentFailure(
output: Pick<AgentOutput, 'result' | 'error'>,
): boolean {
const texts = [...toText(output.result), ...toText(output.error)];
return texts.some((text) =>
SESSION_RESET_PATTERNS.some((pattern) => pattern.test(text)),
);
}

65
src/status-dashboard.ts Normal file
View File

@@ -0,0 +1,65 @@
import fs from 'fs';
import path from 'path';
import { CACHE_DIR } from './config.js';
import type { GroupStatus } from './group-queue.js';
import type { AgentType } from './types.js';
export interface StatusSnapshotEntry {
jid: string;
name: string;
folder: string;
agentType: AgentType;
status: GroupStatus['status'];
elapsedMs: number | null;
pendingMessages: boolean;
pendingTasks: number;
}
export interface StatusSnapshot {
agentType: AgentType;
assistantName: string;
updatedAt: string;
entries: StatusSnapshotEntry[];
}
const STATUS_SNAPSHOT_DIR = path.join(CACHE_DIR, 'status-dashboard');
export function writeStatusSnapshot(snapshot: StatusSnapshot): void {
fs.mkdirSync(STATUS_SNAPSHOT_DIR, { recursive: true });
const targetPath = path.join(
STATUS_SNAPSHOT_DIR,
`${snapshot.agentType}.json`,
);
const tempPath = `${targetPath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(snapshot, null, 2));
fs.renameSync(tempPath, targetPath);
}
export function readStatusSnapshots(maxAgeMs: number): StatusSnapshot[] {
if (!fs.existsSync(STATUS_SNAPSHOT_DIR)) return [];
const now = Date.now();
const snapshots: StatusSnapshot[] = [];
for (const entry of fs.readdirSync(STATUS_SNAPSHOT_DIR)) {
if (!entry.endsWith('.json')) continue;
const snapshotPath = path.join(STATUS_SNAPSHOT_DIR, entry);
try {
const raw = fs.readFileSync(snapshotPath, 'utf-8');
const parsed = JSON.parse(raw) as StatusSnapshot;
if (!parsed.updatedAt || !parsed.agentType || !Array.isArray(parsed.entries))
continue;
const ageMs = now - new Date(parsed.updatedAt).getTime();
if (Number.isNaN(ageMs) || ageMs > maxAgeMs) continue;
snapshots.push(parsed);
} catch {
continue;
}
}
return snapshots;
}