fix: restore regressions from discord-only refactor
This commit is contained in:
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
141
src/bot-message-filter.test.ts
Normal file
141
src/bot-message-filter.test.ts
Normal 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
31
src/bot-message-filter.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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
183
src/claude-usage.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
43
src/db.ts
43
src/db.ts
@@ -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> {
|
||||
|
||||
751
src/index.ts
751
src/index.ts
File diff suppressed because it is too large
Load Diff
34
src/session-recovery.test.ts
Normal file
34
src/session-recovery.test.ts
Normal 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
26
src/session-recovery.ts
Normal 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
65
src/status-dashboard.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user