refactor: extract dashboard and message-runtime from index.ts
- Extract dashboard code into src/dashboard.ts - Extract message runtime into src/message-runtime.ts - Improve group-queue with better concurrency handling - Update agent-runner with enhanced env/config passing - Simplify DB operations and cleanup unused code - Update README for Codex SDK architecture
This commit is contained in:
51
README.md
51
README.md
@@ -15,29 +15,26 @@
|
|||||||
Two AI agents running as parallel systemd services, communicating over Discord:
|
Two AI agents running as parallel systemd services, communicating over Discord:
|
||||||
|
|
||||||
- **Claude Code** — powered by Claude Agent SDK, trigger `@claude`
|
- **Claude Code** — powered by Claude Agent SDK, trigger `@claude`
|
||||||
- **Codex** — powered by Codex app-server (JSON-RPC), trigger `@codex`
|
- **Codex** — powered by Codex SDK, trigger `@codex`
|
||||||
|
|
||||||
Each agent has its own store, data, and groups directories. Discord channels can be registered with either agent, or both (`both` agent type for shared channels).
|
Each agent has its own store, data, and groups directories. Discord channels are registered per agent service.
|
||||||
|
|
||||||
### Key Features
|
### Key Features
|
||||||
|
|
||||||
- **Direct host processes** — no container overhead, agents run natively
|
- **Direct host processes** — no container overhead, agents run natively
|
||||||
- **Bidirectional image support** — receive images as multimodal input, send as Discord attachments
|
- **Bidirectional image support** — receive images as multimodal input, send as Discord attachments
|
||||||
- **Skill sync** — single source of truth (`~/.claude/skills/`), auto-synced to all sessions
|
- **Skill sync** — single source of truth (`~/.claude/skills/`), auto-synced to all sessions
|
||||||
- **OAuth auto-refresh** — token lifecycle managed automatically for headless environments
|
|
||||||
- **Priority queue** — per-group serialization, global concurrency limit, idle preemption
|
- **Priority queue** — per-group serialization, global concurrency limit, idle preemption
|
||||||
- **Auto-continue** — Codex text-only turns automatically retried to enforce task execution
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Discord ──► SQLite ──► GroupQueue ──┬──► Claude Agent SDK (host process)
|
Discord ──► SQLite ──► GroupQueue ──┬──► Claude Agent SDK (host process)
|
||||||
└──► Codex App-Server (JSON-RPC stdio)
|
└──► Codex SDK (long-lived thread runner)
|
||||||
├── thread/start, thread/resume
|
├── thread start/resume
|
||||||
├── turn/start (streaming, multimodal)
|
├── multimodal input
|
||||||
├── turn/steer (mid-turn injection)
|
├── per-group model/effort config
|
||||||
├── Auto-approval (bypass sandbox)
|
└── follow-up messages via IPC
|
||||||
└── Auto-continue (text-only turn retry)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Directory Layout
|
### Directory Layout
|
||||||
@@ -52,7 +49,6 @@ nanoclaw/
|
|||||||
│ ├── router.ts # Outbound message formatting and routing
|
│ ├── router.ts # Outbound message formatting and routing
|
||||||
│ ├── sender-allowlist.ts # Security: sender-based access control
|
│ ├── sender-allowlist.ts # Security: sender-based access control
|
||||||
│ ├── session-commands.ts # Session commands (/compact)
|
│ ├── session-commands.ts # Session commands (/compact)
|
||||||
│ ├── token-refresh.ts # OAuth auto-refresh + session directory sync
|
|
||||||
│ ├── task-scheduler.ts # Scheduled tasks (cron/interval/once)
|
│ ├── task-scheduler.ts # Scheduled tasks (cron/interval/once)
|
||||||
│ ├── ipc.ts # IPC watcher and task processing
|
│ ├── ipc.ts # IPC watcher and task processing
|
||||||
│ ├── db.ts # SQLite operations
|
│ ├── db.ts # SQLite operations
|
||||||
@@ -62,7 +58,7 @@ nanoclaw/
|
|||||||
│ └── discord.ts # Discord: mentions, images, typing, file attachments
|
│ └── discord.ts # Discord: mentions, images, typing, file attachments
|
||||||
├── runners/
|
├── runners/
|
||||||
│ ├── agent-runner/ # Claude Code runner (Agent SDK, multimodal input)
|
│ ├── agent-runner/ # Claude Code runner (Agent SDK, multimodal input)
|
||||||
│ ├── codex-runner/ # Codex runner (app-server JSON-RPC, auto-continue)
|
│ ├── codex-runner/ # Codex runner (SDK thread wrapper)
|
||||||
│ └── skills/ # Shared agent skills (browser, etc.)
|
│ └── skills/ # Shared agent skills (browser, etc.)
|
||||||
├── store/ # Claude Code service DB
|
├── store/ # Claude Code service DB
|
||||||
├── store-codex/ # Codex service DB
|
├── store-codex/ # Codex service DB
|
||||||
@@ -75,17 +71,15 @@ nanoclaw/
|
|||||||
└── logs/ # Service logs
|
└── logs/ # Service logs
|
||||||
```
|
```
|
||||||
|
|
||||||
### Codex App-Server Integration
|
### Codex SDK Integration
|
||||||
|
|
||||||
The Codex runner (`runners/codex-runner/`) communicates with `codex app-server` via JSON-RPC over stdio:
|
The Codex runner (`runners/codex-runner/`) uses `@openai/codex-sdk` with one long-lived thread per group:
|
||||||
|
|
||||||
- **Session persistence**: Thread IDs stored in DB, sessions saved as JSONL on disk
|
- **Session persistence**: Thread IDs stored in DB and resumed per group
|
||||||
- **Streaming**: `item/agentMessage/delta` notifications for real-time text
|
- **Follow-up messages**: Additional Discord messages are injected into the active runner via IPC
|
||||||
- **Mid-turn steering**: IPC messages injected via `turn/steer` during execution
|
- **Auto-approval**: `approvalPolicy: "never"` + `sandboxMode: "danger-full-access"`
|
||||||
- **Auto-approval**: `approvalPolicy: "never"` + `sandbox: "danger-full-access"`
|
- **Multimodal input**: Image attachments converted to `local_image` SDK inputs
|
||||||
- **Auto-continue**: Detects text-only turns (no tool execution) and automatically retries up to 5 times to nudge the agent into actually executing tasks
|
- **Per-group config**: Model and reasoning effort can be overridden per channel
|
||||||
- **Multimodal input**: Image attachments converted to `localImage` input blocks in `turn/start`
|
|
||||||
- **Per-group config**: Model, effort, MCP servers configured per channel
|
|
||||||
|
|
||||||
### Image Handling
|
### Image Handling
|
||||||
|
|
||||||
@@ -102,15 +96,6 @@ Skills are managed from a single source of truth (`~/.claude/skills/` on the ser
|
|||||||
- Codex sessions: Same sources, synced to per-group `.codex/` directories
|
- Codex sessions: Same sources, synced to per-group `.codex/` directories
|
||||||
- Skills auto-register as slash commands (`/name`) in Claude Code and `$name` in Codex
|
- Skills auto-register as slash commands (`/name`) in Claude Code and `$name` in Codex
|
||||||
|
|
||||||
### OAuth Token Auto-Refresh
|
|
||||||
|
|
||||||
`src/token-refresh.ts` handles Claude Code OAuth token lifecycle:
|
|
||||||
|
|
||||||
- Checks every 5 minutes, refreshes 30 minutes before expiry
|
|
||||||
- Tries `platform.claude.com` then falls back to `api.anthropic.com`
|
|
||||||
- Syncs refreshed credentials to all per-group session directories
|
|
||||||
- Solves the known headless environment token expiry issue
|
|
||||||
|
|
||||||
### GroupQueue
|
### GroupQueue
|
||||||
|
|
||||||
`src/group-queue.ts` manages agent execution with:
|
`src/group-queue.ts` manages agent execution with:
|
||||||
@@ -241,10 +226,10 @@ Channels are stored in each service's SQLite database (`registered_groups` table
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Example: register a channel for Claude Code
|
# Example: register a channel for Claude Code
|
||||||
sqlite3 store/nanoclaw.db "INSERT INTO registered_groups (jid, name, folder, agent_type, trigger_pattern) VALUES ('dc:CHANNEL_ID', 'my-channel', 'my-channel', 'claude-code', '@claude');"
|
sqlite3 store/messages.db "INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, agent_type) VALUES ('dc:CHANNEL_ID', 'my-channel', 'my-channel', '@claude', datetime('now'), 'claude-code');"
|
||||||
|
|
||||||
# Example: register a channel for Codex
|
# Example: register a channel for Codex
|
||||||
sqlite3 store-codex/nanoclaw.db "INSERT INTO registered_groups (jid, name, folder, agent_type, trigger_pattern) VALUES ('dc:CHANNEL_ID', 'my-channel', 'my-channel', 'codex', '@codex');"
|
sqlite3 store-codex/messages.db "INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, agent_type) VALUES ('dc:CHANNEL_ID', 'my-channel', 'my-channel', '@codex', datetime('now'), 'codex');"
|
||||||
```
|
```
|
||||||
|
|
||||||
Fields:
|
Fields:
|
||||||
@@ -254,7 +239,7 @@ Fields:
|
|||||||
| `jid` | `dc:<discord_channel_id>` |
|
| `jid` | `dc:<discord_channel_id>` |
|
||||||
| `name` | Display name |
|
| `name` | Display name |
|
||||||
| `folder` | Group folder name (workspace directory) |
|
| `folder` | Group folder name (workspace directory) |
|
||||||
| `agent_type` | `claude-code`, `codex`, or `both` |
|
| `agent_type` | `claude-code` or `codex` |
|
||||||
| `trigger_pattern` | Regex for activation (e.g., `@claude`) |
|
| `trigger_pattern` | Regex for activation (e.g., `@claude`) |
|
||||||
| `work_dir` | Optional working directory override |
|
| `work_dir` | Optional working directory override |
|
||||||
| `container_config` | Optional JSON (e.g., `{"codexEffort":"high"}`) |
|
| `container_config` | Optional JSON (e.g., `{"codexEffort":"high"}`) |
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
// Verify runner entry points exist
|
// Verify runner entry points exist
|
||||||
const agentRunner = path.join(
|
const agentRunner = path.join(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
'container',
|
'runners',
|
||||||
'agent-runner',
|
'agent-runner',
|
||||||
'dist',
|
'dist',
|
||||||
'index.js',
|
'index.js',
|
||||||
);
|
);
|
||||||
const codexRunner = path.join(
|
const codexRunner = path.join(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
'container',
|
'runners',
|
||||||
'codex-runner',
|
'codex-runner',
|
||||||
'dist',
|
'dist',
|
||||||
'index.js',
|
'index.js',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import path from 'path';
|
|||||||
|
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
import { STORE_DIR } from '../src/config.js';
|
import { SERVICE_AGENT_TYPE, STORE_DIR } from '../src/config.js';
|
||||||
import { isValidGroupFolder } from '../src/group-folder.js';
|
import { isValidGroupFolder } from '../src/group-folder.js';
|
||||||
import { logger } from '../src/logger.js';
|
import { logger } from '../src/logger.js';
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
@@ -113,15 +113,31 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
added_at TEXT NOT NULL,
|
added_at TEXT NOT NULL,
|
||||||
container_config TEXT,
|
container_config TEXT,
|
||||||
requires_trigger INTEGER DEFAULT 1,
|
requires_trigger INTEGER DEFAULT 1,
|
||||||
is_main INTEGER DEFAULT 0
|
is_main INTEGER DEFAULT 0,
|
||||||
|
agent_type TEXT DEFAULT 'claude-code',
|
||||||
|
work_dir TEXT
|
||||||
)`);
|
)`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(
|
||||||
|
`ALTER TABLE registered_groups ADD COLUMN agent_type TEXT DEFAULT 'claude-code'`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* column already exists */
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`);
|
||||||
|
} catch {
|
||||||
|
/* column already exists */
|
||||||
|
}
|
||||||
|
|
||||||
const isMainInt = parsed.isMain ? 1 : 0;
|
const isMainInt = parsed.isMain ? 1 : 0;
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
`INSERT OR REPLACE INTO registered_groups
|
||||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
|
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, agent_type, work_dir)
|
||||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, NULL)`,
|
||||||
).run(
|
).run(
|
||||||
parsed.jid,
|
parsed.jid,
|
||||||
parsed.name,
|
parsed.name,
|
||||||
@@ -130,6 +146,7 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
timestamp,
|
timestamp,
|
||||||
requiresTriggerInt,
|
requiresTriggerInt,
|
||||||
isMainInt,
|
isMainInt,
|
||||||
|
SERVICE_AGENT_TYPE,
|
||||||
);
|
);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface AgentInput {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
groupFolder: string;
|
groupFolder: string;
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
|
runId?: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
isScheduledTask?: boolean;
|
isScheduledTask?: boolean;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
@@ -324,15 +325,19 @@ export async function runAgentProcess(
|
|||||||
group,
|
group,
|
||||||
input.isMain,
|
input.isMain,
|
||||||
);
|
);
|
||||||
|
if (input.runId) {
|
||||||
|
env.NANOCLAW_RUN_ID = input.runId;
|
||||||
|
}
|
||||||
|
|
||||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||||
const processName = `nanoclaw-${safeName}-${Date.now()}`;
|
const processSuffix = input.runId || `${Date.now()}`;
|
||||||
|
const processName = `nanoclaw-${safeName}-${processSuffix}`;
|
||||||
|
|
||||||
// Check if runner is built
|
// Check if runner is built
|
||||||
const distEntry = path.join(runnerDir, 'dist', 'index.js');
|
const distEntry = path.join(runnerDir, 'dist', 'index.js');
|
||||||
if (!fs.existsSync(distEntry)) {
|
if (!fs.existsSync(distEntry)) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ runnerDir },
|
{ runnerDir, chatJid: input.chatJid, runId: input.runId },
|
||||||
'Runner not built. Run: cd runners/agent-runner && npm install && npm run build',
|
'Runner not built. Run: cd runners/agent-runner && npm install && npm run build',
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -345,6 +350,9 @@ export async function runAgentProcess(
|
|||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
group: group.name,
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
groupFolder: input.groupFolder,
|
||||||
|
runId: input.runId,
|
||||||
processName,
|
processName,
|
||||||
agentType: group.agentType || 'claude-code',
|
agentType: group.agentType || 'claude-code',
|
||||||
isMain: input.isMain,
|
isMain: input.isMain,
|
||||||
@@ -386,7 +394,7 @@ export async function runAgentProcess(
|
|||||||
stdout += chunk.slice(0, remaining);
|
stdout += chunk.slice(0, remaining);
|
||||||
stdoutTruncated = true;
|
stdoutTruncated = true;
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ group: group.name, size: stdout.length },
|
{ group: group.name, chatJid: input.chatJid, runId: input.runId, size: stdout.length },
|
||||||
'Agent stdout truncated due to size limit',
|
'Agent stdout truncated due to size limit',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -416,7 +424,7 @@ export async function runAgentProcess(
|
|||||||
outputChain = outputChain.then(() => onOutput(parsed));
|
outputChain = outputChain.then(() => onOutput(parsed));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ group: group.name, error: err },
|
{ group: group.name, chatJid: input.chatJid, runId: input.runId, error: err },
|
||||||
'Failed to parse streamed output chunk',
|
'Failed to parse streamed output chunk',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -432,7 +440,7 @@ export async function runAgentProcess(
|
|||||||
const killOnTimeout = () => {
|
const killOnTimeout = () => {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
logger.error(
|
logger.error(
|
||||||
{ group: group.name, processName },
|
{ group: group.name, chatJid: input.chatJid, runId: input.runId, processName },
|
||||||
'Agent timeout, sending SIGTERM',
|
'Agent timeout, sending SIGTERM',
|
||||||
);
|
);
|
||||||
proc.kill('SIGTERM');
|
proc.kill('SIGTERM');
|
||||||
@@ -455,9 +463,15 @@ export async function runAgentProcess(
|
|||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
if (line.includes('Turn in progress')) {
|
if (line.includes('Turn in progress')) {
|
||||||
logger.info({ group: group.name }, line.replace(/^\[.*?\]\s*/, ''));
|
logger.info(
|
||||||
|
{ group: group.name, chatJid: input.chatJid, runId: input.runId },
|
||||||
|
line.replace(/^\[.*?\]\s*/, ''),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.debug({ agent: group.folder }, line);
|
logger.debug(
|
||||||
|
{ agent: group.folder, chatJid: input.chatJid, runId: input.runId },
|
||||||
|
line,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Stderr activity means agent is alive — reset timeout
|
// Stderr activity means agent is alive — reset timeout
|
||||||
@@ -479,11 +493,13 @@ export async function runAgentProcess(
|
|||||||
if (timedOut) {
|
if (timedOut) {
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(logsDir, `agent-${ts}.log`),
|
path.join(logsDir, `agent-${input.runId || 'adhoc'}-${ts}.log`),
|
||||||
[
|
[
|
||||||
`=== Agent Run Log (TIMEOUT) ===`,
|
`=== Agent Run Log (TIMEOUT) ===`,
|
||||||
`Timestamp: ${new Date().toISOString()}`,
|
`Timestamp: ${new Date().toISOString()}`,
|
||||||
`Group: ${group.name}`,
|
`Group: ${group.name}`,
|
||||||
|
`ChatJid: ${input.chatJid}`,
|
||||||
|
`RunId: ${input.runId || 'n/a'}`,
|
||||||
`Process: ${processName}`,
|
`Process: ${processName}`,
|
||||||
`Duration: ${duration}ms`,
|
`Duration: ${duration}ms`,
|
||||||
`Exit Code: ${code}`,
|
`Exit Code: ${code}`,
|
||||||
@@ -493,7 +509,14 @@ export async function runAgentProcess(
|
|||||||
|
|
||||||
if (hadStreamingOutput) {
|
if (hadStreamingOutput) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ group: group.name, processName, duration, code },
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
processName,
|
||||||
|
duration,
|
||||||
|
code,
|
||||||
|
},
|
||||||
'Agent timed out after output (idle cleanup)',
|
'Agent timed out after output (idle cleanup)',
|
||||||
);
|
);
|
||||||
outputChain.then(() => {
|
outputChain.then(() => {
|
||||||
@@ -511,7 +534,10 @@ export async function runAgentProcess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
const logFile = path.join(logsDir, `agent-${timestamp}.log`);
|
const logFile = path.join(
|
||||||
|
logsDir,
|
||||||
|
`agent-${input.runId || 'adhoc'}-${timestamp}.log`,
|
||||||
|
);
|
||||||
const isVerbose =
|
const isVerbose =
|
||||||
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
||||||
|
|
||||||
@@ -519,6 +545,9 @@ export async function runAgentProcess(
|
|||||||
`=== Agent Run Log ===`,
|
`=== Agent Run Log ===`,
|
||||||
`Timestamp: ${new Date().toISOString()}`,
|
`Timestamp: ${new Date().toISOString()}`,
|
||||||
`Group: ${group.name}`,
|
`Group: ${group.name}`,
|
||||||
|
`ChatJid: ${input.chatJid}`,
|
||||||
|
`GroupFolder: ${input.groupFolder}`,
|
||||||
|
`RunId: ${input.runId || 'n/a'}`,
|
||||||
`IsMain: ${input.isMain}`,
|
`IsMain: ${input.isMain}`,
|
||||||
`AgentType: ${group.agentType || 'claude-code'}`,
|
`AgentType: ${group.agentType || 'claude-code'}`,
|
||||||
`Duration: ${duration}ms`,
|
`Duration: ${duration}ms`,
|
||||||
@@ -549,7 +578,14 @@ export async function runAgentProcess(
|
|||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ group: group.name, code, duration, logFile },
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
code,
|
||||||
|
duration,
|
||||||
|
logFile,
|
||||||
|
},
|
||||||
'Agent exited with error',
|
'Agent exited with error',
|
||||||
);
|
);
|
||||||
resolve({
|
resolve({
|
||||||
@@ -563,7 +599,13 @@ export async function runAgentProcess(
|
|||||||
if (onOutput) {
|
if (onOutput) {
|
||||||
outputChain.then(() => {
|
outputChain.then(() => {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ group: group.name, duration, newSessionId },
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
duration,
|
||||||
|
newSessionId,
|
||||||
|
},
|
||||||
'Agent completed (streaming mode)',
|
'Agent completed (streaming mode)',
|
||||||
);
|
);
|
||||||
resolve({ status: 'success', result: null, newSessionId });
|
resolve({ status: 'success', result: null, newSessionId });
|
||||||
@@ -586,13 +628,19 @@ export async function runAgentProcess(
|
|||||||
}
|
}
|
||||||
const output: AgentOutput = JSON.parse(jsonLine);
|
const output: AgentOutput = JSON.parse(jsonLine);
|
||||||
logger.info(
|
logger.info(
|
||||||
{ group: group.name, duration, status: output.status },
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
duration,
|
||||||
|
status: output.status,
|
||||||
|
},
|
||||||
'Agent completed',
|
'Agent completed',
|
||||||
);
|
);
|
||||||
resolve(output);
|
resolve(output);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ group: group.name, error: err },
|
{ group: group.name, chatJid: input.chatJid, runId: input.runId, error: err },
|
||||||
'Failed to parse agent output',
|
'Failed to parse agent output',
|
||||||
);
|
);
|
||||||
resolve({
|
resolve({
|
||||||
@@ -606,7 +654,7 @@ export async function runAgentProcess(
|
|||||||
proc.on('error', (err) => {
|
proc.on('error', (err) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
logger.error(
|
logger.error(
|
||||||
{ group: group.name, processName, error: err },
|
{ group: group.name, chatJid: input.chatJid, runId: input.runId, processName, error: err },
|
||||||
'Agent spawn error',
|
'Agent spawn error',
|
||||||
);
|
);
|
||||||
resolve({
|
resolve({
|
||||||
@@ -651,7 +699,6 @@ export function writeGroupsSnapshot(
|
|||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
groups: AvailableGroup[],
|
groups: AvailableGroup[],
|
||||||
registeredJids: Set<string>,
|
|
||||||
): void {
|
): void {
|
||||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||||
|
|||||||
520
src/dashboard.ts
Normal file
520
src/dashboard.ts
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
import { ChildProcess, execSync, spawn } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { readEnvFile } from './env.js';
|
||||||
|
import { GroupQueue, GroupStatus } from './group-queue.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import { Channel, ChannelMeta, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
|
interface DashboardOptions {
|
||||||
|
assistantName: string;
|
||||||
|
channels: Channel[];
|
||||||
|
queue: GroupQueue;
|
||||||
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
statusChannelId: string;
|
||||||
|
statusUpdateInterval: number;
|
||||||
|
usageUpdateInterval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaudeUsageData {
|
||||||
|
five_hour?: { utilization: number; resets_at: string };
|
||||||
|
seven_day?: { utilization: number; resets_at: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexRateLimit {
|
||||||
|
limitId?: string;
|
||||||
|
limitName: string | null;
|
||||||
|
primary: { usedPercent: number; resetsAt: string | number };
|
||||||
|
secondary: { usedPercent: number; resetsAt: string | number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ICONS: Record<string, string> = {
|
||||||
|
processing: '🟡',
|
||||||
|
idle: '🟢',
|
||||||
|
waiting: '🔵',
|
||||||
|
inactive: '⚪',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNEL_META_REFRESH_MS = 300000;
|
||||||
|
|
||||||
|
let statusMessageId: string | null = null;
|
||||||
|
let usageMessageId: string | null = null;
|
||||||
|
let usageUpdateInProgress = false;
|
||||||
|
let channelMetaCache = new Map<string, ChannelMeta>();
|
||||||
|
let channelMetaLastRefresh = 0;
|
||||||
|
|
||||||
|
function findDiscordChannel(channels: Channel[]): Channel | undefined {
|
||||||
|
return channels.find((c) => c.name.startsWith('discord') && c.isConnected());
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s < 3600) {
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const rem = s % 60;
|
||||||
|
return `${m}m${rem.toString().padStart(2, '0')}s`;
|
||||||
|
}
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
if (h < 24) return `${h}h${m.toString().padStart(2, '0')}m`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
const remH = h % 24;
|
||||||
|
return `${d}d${remH}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResetKST(value: string | number): string {
|
||||||
|
try {
|
||||||
|
const date =
|
||||||
|
typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
||||||
|
return date.toLocaleString('ko-KR', {
|
||||||
|
timeZone: 'Asia/Seoul',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshChannelMeta(opts: DashboardOptions): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - channelMetaLastRefresh < CHANNEL_META_REFRESH_MS) return;
|
||||||
|
|
||||||
|
const ch = opts.channels.find(
|
||||||
|
(c) => c.name.startsWith('discord') && c.isConnected() && c.getChannelMeta,
|
||||||
|
);
|
||||||
|
if (!ch?.getChannelMeta) return;
|
||||||
|
|
||||||
|
const jids = Object.keys(opts.registeredGroups()).filter((j) =>
|
||||||
|
j.startsWith('dc:'),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
channelMetaCache = await ch.getChannelMeta(jids);
|
||||||
|
channelMetaLastRefresh = now;
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ err }, 'Failed to refresh channel metadata');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: GroupStatus): string {
|
||||||
|
if (status.status === 'processing') {
|
||||||
|
return `처리 중 (${formatElapsed(status.elapsedMs || 0)})`;
|
||||||
|
}
|
||||||
|
if (status.status === 'idle') return '대기 중';
|
||||||
|
if (status.status === 'waiting') {
|
||||||
|
return status.pendingTasks > 0
|
||||||
|
? `큐 대기 (태스크 ${status.pendingTasks}개)`
|
||||||
|
: '큐 대기 (메시지)';
|
||||||
|
}
|
||||||
|
return '비활성';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatusContent(opts: DashboardOptions): string {
|
||||||
|
const registeredGroups = opts.registeredGroups();
|
||||||
|
const jids = Object.keys(registeredGroups);
|
||||||
|
const statuses = opts.queue.getStatuses(jids);
|
||||||
|
|
||||||
|
const entries = statuses
|
||||||
|
.map((status) => ({
|
||||||
|
status,
|
||||||
|
group: registeredGroups[status.jid],
|
||||||
|
meta: channelMetaCache.get(status.jid),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.group);
|
||||||
|
|
||||||
|
const categoryMap = new Map<string, typeof entries>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const category = entry.meta?.category || '기타';
|
||||||
|
if (!categoryMap.has(category)) categoryMap.set(category, []);
|
||||||
|
categoryMap.get(category)!.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedCategories = [...categoryMap.entries()].sort((a, b) => {
|
||||||
|
const posA = a[1][0]?.meta?.categoryPosition ?? 999;
|
||||||
|
const posB = b[1][0]?.meta?.categoryPosition ?? 999;
|
||||||
|
return posA - posB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sections: string[] = [];
|
||||||
|
let totalActive = 0;
|
||||||
|
let totalIdle = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const [categoryName, categoryEntries] of sortedCategories) {
|
||||||
|
categoryEntries.sort(
|
||||||
|
(a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999),
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines = categoryEntries.map((entry) => {
|
||||||
|
const icon = STATUS_ICONS[entry.status.status] || '⚪';
|
||||||
|
const label = getStatusLabel(entry.status);
|
||||||
|
const name = entry.meta?.name ? `#${entry.meta.name}` : entry.group.name;
|
||||||
|
return ` ${icon} **${name}** — ${label}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (channelMetaCache.size > 0 && categoryName !== '기타') {
|
||||||
|
sections.push(`📁 **${categoryName}**\n${lines.join('\n')}`);
|
||||||
|
} else {
|
||||||
|
sections.push(lines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
totalActive += categoryEntries.filter(
|
||||||
|
(entry) => entry.status.status === 'processing',
|
||||||
|
).length;
|
||||||
|
totalIdle += categoryEntries.filter(
|
||||||
|
(entry) => entry.status.status === 'idle',
|
||||||
|
).length;
|
||||||
|
total += categoryEntries.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = `**에이전트 상태** (${opts.assistantName}) — 활성 ${totalActive} | 대기 ${totalIdle} | 전체 ${total}`;
|
||||||
|
return `${header}\n\n${sections.join('\n\n')}\n\n_${new Date().toLocaleTimeString('ko-KR')}_`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
||||||
|
try {
|
||||||
|
const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']);
|
||||||
|
let token =
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||||
|
envToken.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||||
|
'';
|
||||||
|
if (!token) {
|
||||||
|
const configDir =
|
||||||
|
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||||
|
const credsPath = path.join(configDir, '.credentials.json');
|
||||||
|
if (!fs.existsSync(credsPath)) return null;
|
||||||
|
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
|
||||||
|
token = creds?.claudeAiOauth?.accessToken || '';
|
||||||
|
}
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const res = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'anthropic-beta': 'oauth-2025-04-20',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return (await res.json()) as ClaudeUsageData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCodexUsage(): Promise<CodexRateLimit[] | null> {
|
||||||
|
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
||||||
|
const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex';
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
const finish = (value: CodexRateLimit[] | null) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (proc) {
|
||||||
|
try {
|
||||||
|
proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => finish(null), 20000);
|
||||||
|
|
||||||
|
let proc: ChildProcess | null = null;
|
||||||
|
try {
|
||||||
|
proc = spawn(codexBin, ['app-server'], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: {
|
||||||
|
...(process.env as Record<string, string>),
|
||||||
|
PATH: [
|
||||||
|
path.dirname(process.execPath),
|
||||||
|
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||||
|
process.env.PATH || '',
|
||||||
|
].join(':'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proc.stdout || !proc.stdin) {
|
||||||
|
finish(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdout = proc.stdout;
|
||||||
|
const stdin = proc.stdin;
|
||||||
|
|
||||||
|
proc.on('error', () => finish(null));
|
||||||
|
proc.on('close', () => finish(null));
|
||||||
|
|
||||||
|
let buf = '';
|
||||||
|
stdout.on('data', (chunk: Buffer) => {
|
||||||
|
buf += chunk.toString();
|
||||||
|
const lines = buf.split('\n');
|
||||||
|
buf = lines.pop() || '';
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line);
|
||||||
|
if (msg.id === 1) {
|
||||||
|
stdin.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'account/rateLimits/read',
|
||||||
|
params: {},
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
} else if (msg.id === 2 && msg.result) {
|
||||||
|
const byId = msg.result.rateLimitsByLimitId;
|
||||||
|
if (byId && typeof byId === 'object') {
|
||||||
|
finish(Object.values(byId) as CodexRateLimit[]);
|
||||||
|
} else {
|
||||||
|
finish(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* non-JSON line */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stdin.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: { clientInfo: { name: 'usage-monitor', version: '1.0' } },
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildUsageContent(): Promise<string> {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const [claudeUsage, codexUsage] = await Promise.all([
|
||||||
|
fetchClaudeUsage(),
|
||||||
|
fetchCodexUsage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bar = (pct: number) => {
|
||||||
|
const filled = Math.round(pct / 10);
|
||||||
|
return '█'.repeat(filled) + '░'.repeat(10 - filled);
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push('📊 *사용량*');
|
||||||
|
|
||||||
|
type UsageRow = {
|
||||||
|
name: string;
|
||||||
|
h5pct: number;
|
||||||
|
h5reset: string;
|
||||||
|
d7pct: number;
|
||||||
|
d7reset: string;
|
||||||
|
};
|
||||||
|
const rows: UsageRow[] = [];
|
||||||
|
|
||||||
|
if (claudeUsage) {
|
||||||
|
const h5 = claudeUsage.five_hour;
|
||||||
|
const d7 = claudeUsage.seven_day;
|
||||||
|
rows.push({
|
||||||
|
name: 'Claude',
|
||||||
|
h5pct: h5
|
||||||
|
? h5.utilization > 1
|
||||||
|
? Math.round(h5.utilization)
|
||||||
|
: Math.round(h5.utilization * 100)
|
||||||
|
: -1,
|
||||||
|
h5reset: h5 ? formatResetKST(h5.resets_at) : '',
|
||||||
|
d7pct: d7
|
||||||
|
? d7.utilization > 1
|
||||||
|
? Math.round(d7.utilization)
|
||||||
|
: Math.round(d7.utilization * 100)
|
||||||
|
: -1,
|
||||||
|
d7reset: d7 ? formatResetKST(d7.resets_at) : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codexUsage && Array.isArray(codexUsage)) {
|
||||||
|
const relevant = codexUsage.filter(
|
||||||
|
(limit) =>
|
||||||
|
limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0,
|
||||||
|
);
|
||||||
|
const display = relevant.length > 0 ? relevant : codexUsage.slice(0, 1);
|
||||||
|
for (const limit of display) {
|
||||||
|
rows.push({
|
||||||
|
name: 'Codex',
|
||||||
|
h5pct: Math.round(limit.primary.usedPercent),
|
||||||
|
h5reset: formatResetKST(limit.primary.resetsAt),
|
||||||
|
d7pct: Math.round(limit.secondary.usedPercent),
|
||||||
|
d7reset: formatResetKST(limit.secondary.resetsAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
lines.push('```');
|
||||||
|
lines.push(' 5-Hour 7-Day');
|
||||||
|
for (const row of rows) {
|
||||||
|
const h5 =
|
||||||
|
row.h5pct >= 0
|
||||||
|
? `${bar(row.h5pct)} ${String(row.h5pct).padStart(3)}%`
|
||||||
|
: ' — ';
|
||||||
|
const d7 =
|
||||||
|
row.d7pct >= 0
|
||||||
|
? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%`
|
||||||
|
: ' — ';
|
||||||
|
lines.push(`${row.name.padEnd(8)}${h5} ${d7}`);
|
||||||
|
}
|
||||||
|
lines.push('```');
|
||||||
|
} else {
|
||||||
|
lines.push('_조회 불가_');
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
lines.push('🖥️ *서버*');
|
||||||
|
|
||||||
|
const loadAvg = os.loadavg();
|
||||||
|
const cpuCount = os.cpus().length;
|
||||||
|
const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100);
|
||||||
|
|
||||||
|
const totalMem = os.totalmem();
|
||||||
|
const usedMem = totalMem - os.freemem();
|
||||||
|
const memPct = Math.round((usedMem / totalMem) * 100);
|
||||||
|
const memUsedGB = (usedMem / 1073741824).toFixed(1);
|
||||||
|
const memTotalGB = (totalMem / 1073741824).toFixed(1);
|
||||||
|
|
||||||
|
let diskPct = 0;
|
||||||
|
let diskUsedGB = '?';
|
||||||
|
let diskTotalGB = '?';
|
||||||
|
try {
|
||||||
|
const df = execSync('df -B1 / | tail -1', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 5000,
|
||||||
|
}).trim();
|
||||||
|
const parts = df.split(/\s+/);
|
||||||
|
const diskUsed = parseInt(parts[2], 10);
|
||||||
|
const diskTotal = parseInt(parts[1], 10);
|
||||||
|
diskPct = Math.round((diskUsed / diskTotal) * 100);
|
||||||
|
diskUsedGB = (diskUsed / 1073741824).toFixed(0);
|
||||||
|
diskTotalGB = (diskTotal / 1073741824).toFixed(0);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('```');
|
||||||
|
lines.push(`${'CPU'.padEnd(8)}${bar(cpuPct)} ${String(cpuPct).padStart(3)}%`);
|
||||||
|
lines.push(
|
||||||
|
`${'Memory'.padEnd(8)}${bar(memPct)} ${String(memPct).padStart(3)}% ${memUsedGB}/${memTotalGB}GB`,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
`${'Disk'.padEnd(8)}${bar(diskPct)} ${String(diskPct).padStart(3)}% ${diskUsedGB}/${diskTotalGB}GB`,
|
||||||
|
);
|
||||||
|
lines.push(`${'Uptime'.padEnd(8)}${formatElapsed(os.uptime() * 1000)}`);
|
||||||
|
lines.push('```');
|
||||||
|
|
||||||
|
return (
|
||||||
|
lines.join('\n') +
|
||||||
|
`\n_${new Date().toLocaleTimeString('ko-KR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}_`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purgeDashboardChannel(
|
||||||
|
opts: Pick<DashboardOptions, 'channels' | 'statusChannelId'>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!opts.statusChannelId) return;
|
||||||
|
|
||||||
|
const statusJid = `dc:${opts.statusChannelId}`;
|
||||||
|
const ch = opts.channels.find(
|
||||||
|
(channel) =>
|
||||||
|
channel.name.startsWith('discord') &&
|
||||||
|
channel.isConnected() &&
|
||||||
|
channel.purgeChannel,
|
||||||
|
);
|
||||||
|
if (ch?.purgeChannel) {
|
||||||
|
await ch.purgeChannel(statusJid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startStatusDashboard(
|
||||||
|
opts: DashboardOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!opts.statusChannelId) return;
|
||||||
|
|
||||||
|
const statusJid = `dc:${opts.statusChannelId}`;
|
||||||
|
|
||||||
|
const updateStatus = async () => {
|
||||||
|
const ch = findDiscordChannel(opts.channels);
|
||||||
|
if (!ch) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshChannelMeta(opts);
|
||||||
|
const content = buildStatusContent(opts);
|
||||||
|
|
||||||
|
if (statusMessageId && ch.editMessage) {
|
||||||
|
await ch.editMessage(statusJid, statusMessageId, content);
|
||||||
|
} else if (ch.sendAndTrack) {
|
||||||
|
const id = await ch.sendAndTrack(statusJid, content);
|
||||||
|
if (id) statusMessageId = id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ err }, 'Status dashboard update failed');
|
||||||
|
statusMessageId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setInterval(updateStatus, opts.statusUpdateInterval);
|
||||||
|
await updateStatus();
|
||||||
|
logger.info({ channelId: opts.statusChannelId }, 'Status dashboard started');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startUsageDashboard(
|
||||||
|
opts: DashboardOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!opts.statusChannelId) return;
|
||||||
|
if (process.env.USAGE_DASHBOARD !== 'true') return;
|
||||||
|
|
||||||
|
const statusJid = `dc:${opts.statusChannelId}`;
|
||||||
|
|
||||||
|
const updateUsage = async () => {
|
||||||
|
if (usageUpdateInProgress) return;
|
||||||
|
usageUpdateInProgress = true;
|
||||||
|
|
||||||
|
const ch = findDiscordChannel(opts.channels);
|
||||||
|
if (!ch) {
|
||||||
|
usageUpdateInProgress = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await buildUsageContent();
|
||||||
|
|
||||||
|
if (usageMessageId && ch.editMessage) {
|
||||||
|
await ch.editMessage(statusJid, usageMessageId, content);
|
||||||
|
} else if (ch.sendAndTrack) {
|
||||||
|
const id = await ch.sendAndTrack(statusJid, content);
|
||||||
|
if (id) usageMessageId = id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ err }, 'Usage dashboard update failed');
|
||||||
|
usageMessageId = null;
|
||||||
|
} finally {
|
||||||
|
usageUpdateInProgress = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setInterval(updateUsage, opts.usageUpdateInterval);
|
||||||
|
await updateUsage();
|
||||||
|
logger.info('Usage dashboard started');
|
||||||
|
}
|
||||||
@@ -3,12 +3,15 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
_initTestDatabase,
|
_initTestDatabase,
|
||||||
createTask,
|
createTask,
|
||||||
|
deleteSession,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
getAllChats,
|
getAllChats,
|
||||||
getAllRegisteredGroups,
|
getAllRegisteredGroups,
|
||||||
getMessagesSince,
|
getMessagesSince,
|
||||||
getNewMessages,
|
getNewMessages,
|
||||||
|
getSession,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
|
setSession,
|
||||||
setRegisteredGroup,
|
setRegisteredGroup,
|
||||||
storeChatMetadata,
|
storeChatMetadata,
|
||||||
storeMessage,
|
storeMessage,
|
||||||
@@ -300,6 +303,16 @@ describe('getNewMessages', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('session accessors', () => {
|
||||||
|
it('deletes only the current service session for a group', () => {
|
||||||
|
setSession('group-a', 'session-123');
|
||||||
|
expect(getSession('group-a')).toBe('session-123');
|
||||||
|
|
||||||
|
deleteSession('group-a');
|
||||||
|
expect(getSession('group-a')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// --- storeChatMetadata ---
|
// --- storeChatMetadata ---
|
||||||
|
|
||||||
describe('storeChatMetadata', () => {
|
describe('storeChatMetadata', () => {
|
||||||
|
|||||||
68
src/db.ts
68
src/db.ts
@@ -249,20 +249,6 @@ export function storeChatMetadata(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update chat name without changing timestamp for existing chats.
|
|
||||||
* New chats get the current time as their initial timestamp.
|
|
||||||
* Used during group metadata sync.
|
|
||||||
*/
|
|
||||||
export function updateChatName(chatJid: string, name: string): void {
|
|
||||||
db.prepare(
|
|
||||||
`
|
|
||||||
INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(jid) DO UPDATE SET name = excluded.name
|
|
||||||
`,
|
|
||||||
).run(chatJid, name, new Date().toISOString());
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatInfo {
|
export interface ChatInfo {
|
||||||
jid: string;
|
jid: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -286,27 +272,6 @@ export function getAllChats(): ChatInfo[] {
|
|||||||
.all() as ChatInfo[];
|
.all() as ChatInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get timestamp of last group metadata sync.
|
|
||||||
*/
|
|
||||||
export function getLastGroupSync(): string | null {
|
|
||||||
// Store sync time in a special chat entry
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`)
|
|
||||||
.get() as { last_message_time: string } | undefined;
|
|
||||||
return row?.last_message_time || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record that group metadata was synced.
|
|
||||||
*/
|
|
||||||
export function setLastGroupSync(): void {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
db.prepare(
|
|
||||||
`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`,
|
|
||||||
).run(now);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a message with full content.
|
* Store a message with full content.
|
||||||
* Only call this for registered groups where message history is needed.
|
* Only call this for registered groups where message history is needed.
|
||||||
@@ -326,33 +291,6 @@ export function storeMessage(msg: NewMessage): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a message directly.
|
|
||||||
*/
|
|
||||||
export function storeMessageDirect(msg: {
|
|
||||||
id: string;
|
|
||||||
chat_jid: string;
|
|
||||||
sender: string;
|
|
||||||
sender_name: string;
|
|
||||||
content: string;
|
|
||||||
timestamp: string;
|
|
||||||
is_from_me: boolean;
|
|
||||||
is_bot_message?: boolean;
|
|
||||||
}): void {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
msg.id,
|
|
||||||
msg.chat_jid,
|
|
||||||
msg.sender,
|
|
||||||
msg.sender_name,
|
|
||||||
msg.content,
|
|
||||||
msg.timestamp,
|
|
||||||
msg.is_from_me ? 1 : 0,
|
|
||||||
msg.is_bot_message ? 1 : 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNewMessages(
|
export function getNewMessages(
|
||||||
jids: string[],
|
jids: string[],
|
||||||
lastTimestamp: string,
|
lastTimestamp: string,
|
||||||
@@ -606,6 +544,12 @@ export function setSession(groupFolder: string, sessionId: string): void {
|
|||||||
).run(groupFolder, SERVICE_AGENT_TYPE, sessionId);
|
).run(groupFolder, SERVICE_AGENT_TYPE, sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteSession(groupFolder: string): void {
|
||||||
|
db.prepare(
|
||||||
|
'DELETE FROM sessions WHERE group_folder = ? AND agent_type = ?',
|
||||||
|
).run(groupFolder, SERVICE_AGENT_TYPE);
|
||||||
|
}
|
||||||
|
|
||||||
export function getAllSessions(): Record<string, string> {
|
export function getAllSessions(): Record<string, string> {
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ interface QueuedTask {
|
|||||||
fn: () => Promise<void>;
|
fn: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupRunContext {
|
||||||
|
runId: string;
|
||||||
|
reason: 'messages' | 'drain';
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_RETRIES = 5;
|
const MAX_RETRIES = 5;
|
||||||
const BASE_RETRY_MS = 5000;
|
const BASE_RETRY_MS = 5000;
|
||||||
|
|
||||||
@@ -19,6 +24,7 @@ interface GroupState {
|
|||||||
idleWaiting: boolean;
|
idleWaiting: boolean;
|
||||||
isTaskProcess: boolean;
|
isTaskProcess: boolean;
|
||||||
runningTaskId: string | null;
|
runningTaskId: string | null;
|
||||||
|
currentRunId: string | null;
|
||||||
pendingMessages: boolean;
|
pendingMessages: boolean;
|
||||||
pendingTasks: QueuedTask[];
|
pendingTasks: QueuedTask[];
|
||||||
process: ChildProcess | null;
|
process: ChildProcess | null;
|
||||||
@@ -40,8 +46,9 @@ export class GroupQueue {
|
|||||||
private groups = new Map<string, GroupState>();
|
private groups = new Map<string, GroupState>();
|
||||||
private activeCount = 0;
|
private activeCount = 0;
|
||||||
private waitingGroups: string[] = [];
|
private waitingGroups: string[] = [];
|
||||||
private processMessagesFn: ((groupJid: string) => Promise<boolean>) | null =
|
private processMessagesFn:
|
||||||
null;
|
| ((groupJid: string, context: GroupRunContext) => Promise<boolean>)
|
||||||
|
| null = null;
|
||||||
private shuttingDown = false;
|
private shuttingDown = false;
|
||||||
|
|
||||||
private getGroup(groupJid: string): GroupState {
|
private getGroup(groupJid: string): GroupState {
|
||||||
@@ -52,6 +59,7 @@ export class GroupQueue {
|
|||||||
idleWaiting: false,
|
idleWaiting: false,
|
||||||
isTaskProcess: false,
|
isTaskProcess: false,
|
||||||
runningTaskId: null,
|
runningTaskId: null,
|
||||||
|
currentRunId: null,
|
||||||
pendingMessages: false,
|
pendingMessages: false,
|
||||||
pendingTasks: [],
|
pendingTasks: [],
|
||||||
process: null,
|
process: null,
|
||||||
@@ -65,10 +73,16 @@ export class GroupQueue {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProcessMessagesFn(fn: (groupJid: string) => Promise<boolean>): void {
|
setProcessMessagesFn(
|
||||||
|
fn: (groupJid: string, context: GroupRunContext) => Promise<boolean>,
|
||||||
|
): void {
|
||||||
this.processMessagesFn = fn;
|
this.processMessagesFn = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createRunId(): string {
|
||||||
|
return `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
enqueueMessageCheck(groupJid: string, groupFolder?: string): void {
|
enqueueMessageCheck(groupJid: string, groupFolder?: string): void {
|
||||||
if (this.shuttingDown) return;
|
if (this.shuttingDown) return;
|
||||||
|
|
||||||
@@ -154,17 +168,38 @@ export class GroupQueue {
|
|||||||
state.process = proc;
|
state.process = proc;
|
||||||
state.processName = processName;
|
state.processName = processName;
|
||||||
if (groupFolder) state.groupFolder = groupFolder;
|
if (groupFolder) state.groupFolder = groupFolder;
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
groupJid,
|
||||||
|
runId: state.currentRunId,
|
||||||
|
processName,
|
||||||
|
groupFolder: state.groupFolder,
|
||||||
|
isTaskProcess: state.isTaskProcess,
|
||||||
|
},
|
||||||
|
'Registered active process for group',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the agent process as idle-waiting (finished work, waiting for IPC input).
|
* Mark the agent process as idle-waiting (finished work, waiting for IPC input).
|
||||||
* If tasks are pending, preempt the idle agent process immediately.
|
* If tasks are pending, preempt the idle agent process immediately.
|
||||||
*/
|
*/
|
||||||
notifyIdle(groupJid: string): void {
|
notifyIdle(groupJid: string, runId?: string): void {
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
state.idleWaiting = true;
|
state.idleWaiting = true;
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
groupJid,
|
||||||
|
runId: runId ?? state.currentRunId,
|
||||||
|
pendingTasks: state.pendingTasks.length,
|
||||||
|
},
|
||||||
|
'Agent entered idle wait state',
|
||||||
|
);
|
||||||
if (state.pendingTasks.length > 0) {
|
if (state.pendingTasks.length > 0) {
|
||||||
this.closeStdin(groupJid);
|
this.closeStdin(groupJid, {
|
||||||
|
runId: runId ?? state.currentRunId ?? undefined,
|
||||||
|
reason: 'pending-task-preemption',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,8 +209,19 @@ export class GroupQueue {
|
|||||||
*/
|
*/
|
||||||
sendMessage(groupJid: string, text: string): boolean {
|
sendMessage(groupJid: string, text: string): boolean {
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
if (!state.active || !state.groupFolder || state.isTaskProcess)
|
if (!state.active || !state.groupFolder || state.isTaskProcess) {
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
groupJid,
|
||||||
|
runId: state.currentRunId,
|
||||||
|
active: state.active,
|
||||||
|
groupFolder: state.groupFolder,
|
||||||
|
isTaskProcess: state.isTaskProcess,
|
||||||
|
},
|
||||||
|
'Cannot pipe follow-up message to active agent',
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
state.idleWaiting = false; // Agent is about to receive work, no longer idle
|
state.idleWaiting = false; // Agent is about to receive work, no longer idle
|
||||||
|
|
||||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
||||||
@@ -186,8 +232,22 @@ export class GroupQueue {
|
|||||||
const tempPath = `${filepath}.tmp`;
|
const tempPath = `${filepath}.tmp`;
|
||||||
fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text }));
|
fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text }));
|
||||||
fs.renameSync(tempPath, filepath);
|
fs.renameSync(tempPath, filepath);
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
groupJid,
|
||||||
|
runId: state.currentRunId,
|
||||||
|
groupFolder: state.groupFolder,
|
||||||
|
textLength: text.length,
|
||||||
|
filename,
|
||||||
|
},
|
||||||
|
'Queued follow-up message for active agent',
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
{ groupJid, runId: state.currentRunId, groupFolder: state.groupFolder, err },
|
||||||
|
'Failed to queue follow-up message for active agent',
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,7 +255,10 @@ export class GroupQueue {
|
|||||||
/**
|
/**
|
||||||
* Signal the active agent process to wind down by writing a close sentinel.
|
* Signal the active agent process to wind down by writing a close sentinel.
|
||||||
*/
|
*/
|
||||||
closeStdin(groupJid: string): void {
|
closeStdin(
|
||||||
|
groupJid: string,
|
||||||
|
metadata?: { runId?: string; reason?: string },
|
||||||
|
): void {
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
if (!state.active || !state.groupFolder) return;
|
if (!state.active || !state.groupFolder) return;
|
||||||
|
|
||||||
@@ -203,8 +266,26 @@ export class GroupQueue {
|
|||||||
try {
|
try {
|
||||||
fs.mkdirSync(inputDir, { recursive: true });
|
fs.mkdirSync(inputDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
||||||
} catch {
|
logger.info(
|
||||||
// ignore
|
{
|
||||||
|
groupJid,
|
||||||
|
runId: metadata?.runId ?? state.currentRunId,
|
||||||
|
groupFolder: state.groupFolder,
|
||||||
|
reason: metadata?.reason ?? 'unspecified',
|
||||||
|
},
|
||||||
|
'Signaled active agent to close stdin',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
groupJid,
|
||||||
|
runId: metadata?.runId ?? state.currentRunId,
|
||||||
|
groupFolder: state.groupFolder,
|
||||||
|
reason: metadata?.reason ?? 'unspecified',
|
||||||
|
err,
|
||||||
|
},
|
||||||
|
'Failed to signal active agent to close stdin',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,36 +294,55 @@ export class GroupQueue {
|
|||||||
reason: 'messages' | 'drain',
|
reason: 'messages' | 'drain',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
|
const runId = this.createRunId();
|
||||||
state.active = true;
|
state.active = true;
|
||||||
state.idleWaiting = false;
|
state.idleWaiting = false;
|
||||||
state.isTaskProcess = false;
|
state.isTaskProcess = false;
|
||||||
|
state.currentRunId = runId;
|
||||||
state.pendingMessages = false;
|
state.pendingMessages = false;
|
||||||
state.startedAt = Date.now();
|
state.startedAt = Date.now();
|
||||||
this.activeCount++;
|
this.activeCount++;
|
||||||
|
|
||||||
logger.debug(
|
logger.info(
|
||||||
{ groupJid, reason, activeCount: this.activeCount },
|
{ groupJid, runId, reason, activeCount: this.activeCount },
|
||||||
'Starting agent process for group',
|
'Starting group message run',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let outcome: 'success' | 'retry_scheduled' | 'error' = 'success';
|
||||||
try {
|
try {
|
||||||
if (this.processMessagesFn) {
|
if (this.processMessagesFn) {
|
||||||
const success = await this.processMessagesFn(groupJid);
|
const success = await this.processMessagesFn(groupJid, { runId, reason });
|
||||||
if (success) {
|
if (success) {
|
||||||
state.retryCount = 0;
|
state.retryCount = 0;
|
||||||
} else {
|
} else {
|
||||||
this.scheduleRetry(groupJid, state);
|
outcome = 'retry_scheduled';
|
||||||
|
this.scheduleRetry(groupJid, state, runId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ groupJid, err }, 'Error processing messages for group');
|
outcome = 'error';
|
||||||
this.scheduleRetry(groupJid, state);
|
logger.error({ groupJid, runId, err }, 'Error processing messages for group');
|
||||||
|
this.scheduleRetry(groupJid, state, runId);
|
||||||
} finally {
|
} finally {
|
||||||
|
const durationMs = state.startedAt ? Date.now() - state.startedAt : null;
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
groupJid,
|
||||||
|
runId,
|
||||||
|
reason,
|
||||||
|
outcome,
|
||||||
|
durationMs,
|
||||||
|
pendingMessages: state.pendingMessages,
|
||||||
|
pendingTasks: state.pendingTasks.length,
|
||||||
|
},
|
||||||
|
'Finished group message run',
|
||||||
|
);
|
||||||
state.active = false;
|
state.active = false;
|
||||||
state.startedAt = null;
|
state.startedAt = null;
|
||||||
state.process = null;
|
state.process = null;
|
||||||
state.processName = null;
|
state.processName = null;
|
||||||
state.groupFolder = null;
|
state.groupFolder = null;
|
||||||
|
state.currentRunId = null;
|
||||||
this.activeCount--;
|
this.activeCount--;
|
||||||
this.drainGroup(groupJid);
|
this.drainGroup(groupJid);
|
||||||
}
|
}
|
||||||
@@ -279,11 +379,15 @@ export class GroupQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleRetry(groupJid: string, state: GroupState): void {
|
private scheduleRetry(
|
||||||
|
groupJid: string,
|
||||||
|
state: GroupState,
|
||||||
|
runId?: string,
|
||||||
|
): void {
|
||||||
state.retryCount++;
|
state.retryCount++;
|
||||||
if (state.retryCount > MAX_RETRIES) {
|
if (state.retryCount > MAX_RETRIES) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ groupJid, retryCount: state.retryCount },
|
{ groupJid, runId, retryCount: state.retryCount },
|
||||||
'Max retries exceeded, dropping messages (will retry on next incoming message)',
|
'Max retries exceeded, dropping messages (will retry on next incoming message)',
|
||||||
);
|
);
|
||||||
state.retryCount = 0;
|
state.retryCount = 0;
|
||||||
@@ -292,7 +396,7 @@ export class GroupQueue {
|
|||||||
|
|
||||||
const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1);
|
const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1);
|
||||||
logger.info(
|
logger.info(
|
||||||
{ groupJid, retryCount: state.retryCount, delayMs },
|
{ groupJid, runId, retryCount: state.retryCount, delayMs },
|
||||||
'Scheduling retry with backoff',
|
'Scheduling retry with backoff',
|
||||||
);
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -412,7 +516,7 @@ export class GroupQueue {
|
|||||||
// via idle timeout or agent timeout.
|
// via idle timeout or agent timeout.
|
||||||
// This prevents reconnection restarts from killing working agents.
|
// This prevents reconnection restarts from killing working agents.
|
||||||
const activeProcesses: string[] = [];
|
const activeProcesses: string[] = [];
|
||||||
for (const [jid, state] of this.groups) {
|
for (const [, state] of this.groups) {
|
||||||
if (state.process && !state.process.killed && state.processName) {
|
if (state.process && !state.process.killed && state.processName) {
|
||||||
activeProcesses.push(state.processName);
|
activeProcesses.push(state.processName);
|
||||||
}
|
}
|
||||||
|
|||||||
987
src/index.ts
987
src/index.ts
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,6 @@ export interface IpcDeps {
|
|||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
availableGroups: AvailableGroup[],
|
availableGroups: AvailableGroup[],
|
||||||
registeredJids: Set<string>,
|
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +404,6 @@ export async function processTaskIpc(
|
|||||||
sourceGroup,
|
sourceGroup,
|
||||||
true,
|
true,
|
||||||
availableGroups,
|
availableGroups,
|
||||||
new Set(Object.keys(registeredGroups)),
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|||||||
528
src/message-runtime.ts
Normal file
528
src/message-runtime.ts
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
import {
|
||||||
|
AgentOutput,
|
||||||
|
AvailableGroup,
|
||||||
|
runAgentProcess,
|
||||||
|
writeGroupsSnapshot,
|
||||||
|
writeTasksSnapshot,
|
||||||
|
} from './agent-runner.js';
|
||||||
|
import {
|
||||||
|
getAllChats,
|
||||||
|
getAllTasks,
|
||||||
|
getLastHumanMessageTimestamp,
|
||||||
|
getMessagesSince,
|
||||||
|
getNewMessages,
|
||||||
|
} from './db.js';
|
||||||
|
import { GroupQueue, GroupRunContext } from './group-queue.js';
|
||||||
|
import { findChannel, formatMessages } from './router.js';
|
||||||
|
import {
|
||||||
|
isTriggerAllowed,
|
||||||
|
loadSenderAllowlist,
|
||||||
|
} from './sender-allowlist.js';
|
||||||
|
import {
|
||||||
|
extractSessionCommand,
|
||||||
|
handleSessionCommand,
|
||||||
|
isSessionCommandAllowed,
|
||||||
|
} from './session-commands.js';
|
||||||
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
export interface MessageRuntimeDeps {
|
||||||
|
assistantName: string;
|
||||||
|
idleTimeout: number;
|
||||||
|
pollInterval: number;
|
||||||
|
timezone: string;
|
||||||
|
triggerPattern: RegExp;
|
||||||
|
channels: Channel[];
|
||||||
|
queue: GroupQueue;
|
||||||
|
getRegisteredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
getSessions: () => Record<string, string>;
|
||||||
|
getLastTimestamp: () => string;
|
||||||
|
setLastTimestamp: (timestamp: string) => void;
|
||||||
|
getLastAgentTimestamps: () => Record<string, string>;
|
||||||
|
saveState: () => void;
|
||||||
|
persistSession: (groupFolder: string, sessionId: string) => void;
|
||||||
|
clearSession: (groupFolder: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableGroups(
|
||||||
|
registeredGroups: Record<string, RegisteredGroup>,
|
||||||
|
): AvailableGroup[] {
|
||||||
|
const chats = getAllChats();
|
||||||
|
const registeredJids = new Set(Object.keys(registeredGroups));
|
||||||
|
|
||||||
|
return chats
|
||||||
|
.filter((chat) => chat.jid !== '__group_sync__' && chat.is_group)
|
||||||
|
.map((chat) => ({
|
||||||
|
jid: chat.jid,
|
||||||
|
name: chat.name,
|
||||||
|
lastActivity: chat.last_message_time,
|
||||||
|
isRegistered: registeredJids.has(chat.jid),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||||
|
processGroupMessages: (
|
||||||
|
chatJid: string,
|
||||||
|
context: GroupRunContext,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
recoverPendingMessages: () => void;
|
||||||
|
startMessageLoop: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
let messageLoopRunning = false;
|
||||||
|
|
||||||
|
const getCurrentAvailableGroups = (): AvailableGroup[] =>
|
||||||
|
getAvailableGroups(deps.getRegisteredGroups());
|
||||||
|
|
||||||
|
const runAgent = async (
|
||||||
|
group: RegisteredGroup,
|
||||||
|
prompt: string,
|
||||||
|
chatJid: string,
|
||||||
|
runId: string,
|
||||||
|
onOutput?: (output: AgentOutput) => Promise<void>,
|
||||||
|
): Promise<'success' | 'error'> => {
|
||||||
|
const isMain = group.isMain === true;
|
||||||
|
const sessions = deps.getSessions();
|
||||||
|
const sessionId = sessions[group.folder];
|
||||||
|
|
||||||
|
const tasks = getAllTasks();
|
||||||
|
writeTasksSnapshot(
|
||||||
|
group.folder,
|
||||||
|
isMain,
|
||||||
|
tasks.map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
groupFolder: task.group_folder,
|
||||||
|
prompt: task.prompt,
|
||||||
|
schedule_type: task.schedule_type,
|
||||||
|
schedule_value: task.schedule_value,
|
||||||
|
status: task.status,
|
||||||
|
next_run: task.next_run,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
writeGroupsSnapshot(group.folder, isMain, getCurrentAvailableGroups());
|
||||||
|
|
||||||
|
const wrappedOnOutput = onOutput
|
||||||
|
? async (output: AgentOutput) => {
|
||||||
|
if (output.newSessionId) {
|
||||||
|
deps.persistSession(group.folder, output.newSessionId);
|
||||||
|
}
|
||||||
|
await onOutput(output);
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await runAgentProcess(
|
||||||
|
group,
|
||||||
|
{
|
||||||
|
prompt,
|
||||||
|
sessionId,
|
||||||
|
groupFolder: group.folder,
|
||||||
|
chatJid,
|
||||||
|
runId,
|
||||||
|
isMain,
|
||||||
|
assistantName: deps.assistantName,
|
||||||
|
},
|
||||||
|
(proc, processName) =>
|
||||||
|
deps.queue.registerProcess(chatJid, proc, processName, group.folder),
|
||||||
|
wrappedOnOutput,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (output.newSessionId) {
|
||||||
|
deps.persistSession(group.folder, output.newSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output.status === 'error') {
|
||||||
|
logger.error(
|
||||||
|
{ group: group.name, chatJid, runId, error: output.error },
|
||||||
|
'Agent process error',
|
||||||
|
);
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'success';
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ group: group.name, chatJid, runId, err }, 'Agent error');
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processGroupMessages = async (
|
||||||
|
chatJid: string,
|
||||||
|
context: GroupRunContext,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const { runId, reason } = context;
|
||||||
|
const registeredGroups = deps.getRegisteredGroups();
|
||||||
|
const group = registeredGroups[chatJid];
|
||||||
|
if (!group) {
|
||||||
|
logger.warn({ chatJid, runId, reason }, 'Registered group missing for queued run');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = findChannel(deps.channels, chatJid);
|
||||||
|
if (!channel) {
|
||||||
|
logger.warn({ chatJid, runId, reason }, 'No channel owns JID, skipping messages');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMainGroup = group.isMain === true;
|
||||||
|
const lastAgentTimestamps = deps.getLastAgentTimestamps();
|
||||||
|
const sinceTimestamp = lastAgentTimestamps[chatJid] || '';
|
||||||
|
const missedMessages = getMessagesSince(
|
||||||
|
chatJid,
|
||||||
|
sinceTimestamp,
|
||||||
|
deps.assistantName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missedMessages.length === 0) {
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, group: group.name, groupFolder: group.folder, runId, reason },
|
||||||
|
'No pending messages for queued run',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
chatJid,
|
||||||
|
group: group.name,
|
||||||
|
groupFolder: group.folder,
|
||||||
|
runId,
|
||||||
|
reason,
|
||||||
|
messageCount: missedMessages.length,
|
||||||
|
sinceTimestamp,
|
||||||
|
},
|
||||||
|
'Loaded pending messages for queued run',
|
||||||
|
);
|
||||||
|
|
||||||
|
const cmdResult = await handleSessionCommand({
|
||||||
|
missedMessages,
|
||||||
|
isMainGroup,
|
||||||
|
groupName: group.name,
|
||||||
|
runId,
|
||||||
|
triggerPattern: deps.triggerPattern,
|
||||||
|
timezone: deps.timezone,
|
||||||
|
deps: {
|
||||||
|
sendMessage: (text) => channel.sendMessage(chatJid, text),
|
||||||
|
setTyping: (typing) =>
|
||||||
|
channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
|
||||||
|
runAgent: (prompt, onOutput) =>
|
||||||
|
runAgent(group, prompt, chatJid, runId, onOutput),
|
||||||
|
closeStdin: () =>
|
||||||
|
deps.queue.closeStdin(chatJid, {
|
||||||
|
runId,
|
||||||
|
reason: 'session-command',
|
||||||
|
}),
|
||||||
|
clearSession: () => deps.clearSession(group.folder),
|
||||||
|
advanceCursor: (timestamp) => {
|
||||||
|
lastAgentTimestamps[chatJid] = timestamp;
|
||||||
|
deps.saveState();
|
||||||
|
},
|
||||||
|
formatMessages,
|
||||||
|
canSenderInteract: (msg) => {
|
||||||
|
const hasTrigger = deps.triggerPattern.test(msg.content.trim());
|
||||||
|
const requiresTrigger =
|
||||||
|
!isMainGroup && group.requiresTrigger !== false;
|
||||||
|
return (
|
||||||
|
isMainGroup ||
|
||||||
|
!requiresTrigger ||
|
||||||
|
(hasTrigger &&
|
||||||
|
(msg.is_from_me ||
|
||||||
|
isTriggerAllowed(
|
||||||
|
chatJid,
|
||||||
|
msg.sender,
|
||||||
|
loadSenderAllowlist(),
|
||||||
|
)))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (cmdResult.handled) return cmdResult.success;
|
||||||
|
|
||||||
|
if (!isMainGroup && group.requiresTrigger !== false) {
|
||||||
|
const allowlistCfg = loadSenderAllowlist();
|
||||||
|
const hasTrigger = missedMessages.some(
|
||||||
|
(msg) =>
|
||||||
|
deps.triggerPattern.test(msg.content.trim()) &&
|
||||||
|
(msg.is_from_me ||
|
||||||
|
isTriggerAllowed(chatJid, msg.sender, allowlistCfg)),
|
||||||
|
);
|
||||||
|
if (!hasTrigger) {
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, group: group.name, groupFolder: group.folder, runId },
|
||||||
|
'Skipping queued run because no allowed trigger was found',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = formatMessages(missedMessages, deps.timezone);
|
||||||
|
const previousCursor = lastAgentTimestamps[chatJid] || '';
|
||||||
|
lastAgentTimestamps[chatJid] =
|
||||||
|
missedMessages[missedMessages.length - 1].timestamp;
|
||||||
|
deps.saveState();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
chatJid,
|
||||||
|
group: group.name,
|
||||||
|
groupFolder: group.folder,
|
||||||
|
runId,
|
||||||
|
messageCount: missedMessages.length,
|
||||||
|
},
|
||||||
|
'Dispatching queued messages to agent',
|
||||||
|
);
|
||||||
|
|
||||||
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const resetIdleTimer = () => {
|
||||||
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
|
idleTimer = setTimeout(() => {
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, group: group.name, groupFolder: group.folder, runId },
|
||||||
|
'Idle timeout reached, closing active agent stdin',
|
||||||
|
);
|
||||||
|
deps.queue.closeStdin(chatJid, {
|
||||||
|
runId,
|
||||||
|
reason: 'idle-timeout',
|
||||||
|
});
|
||||||
|
}, deps.idleTimeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
let hadError = false;
|
||||||
|
let outputSentToUser = false;
|
||||||
|
|
||||||
|
await channel.setTyping?.(chatJid, true);
|
||||||
|
|
||||||
|
const output = await runAgent(group, prompt, chatJid, runId, async (result) => {
|
||||||
|
if (result.result) {
|
||||||
|
const raw =
|
||||||
|
typeof result.result === 'string'
|
||||||
|
? result.result
|
||||||
|
: JSON.stringify(result.result);
|
||||||
|
const text = raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
chatJid,
|
||||||
|
group: group.name,
|
||||||
|
groupFolder: group.folder,
|
||||||
|
runId,
|
||||||
|
resultStatus: result.status,
|
||||||
|
},
|
||||||
|
`Agent output: ${raw.slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
if (text) {
|
||||||
|
await channel.sendMessage(chatJid, text);
|
||||||
|
outputSentToUser = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.setTyping?.(chatJid, false);
|
||||||
|
resetIdleTimer();
|
||||||
|
|
||||||
|
if (result.status === 'success') {
|
||||||
|
deps.queue.notifyIdle(chatJid, runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'error') {
|
||||||
|
hadError = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await channel.setTyping?.(chatJid, false);
|
||||||
|
|
||||||
|
if (output === 'error') {
|
||||||
|
hadError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
|
|
||||||
|
if (hadError) {
|
||||||
|
if (outputSentToUser) {
|
||||||
|
logger.warn(
|
||||||
|
{ chatJid, group: group.name, groupFolder: group.folder, runId },
|
||||||
|
'Agent error after output was sent, skipping cursor rollback to prevent duplicates',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
lastAgentTimestamps[chatJid] = previousCursor;
|
||||||
|
deps.saveState();
|
||||||
|
logger.warn(
|
||||||
|
{ chatJid, group: group.name, groupFolder: group.folder, runId },
|
||||||
|
'Agent error, rolled back message cursor for retry',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
chatJid,
|
||||||
|
group: group.name,
|
||||||
|
groupFolder: group.folder,
|
||||||
|
runId,
|
||||||
|
outputSentToUser,
|
||||||
|
},
|
||||||
|
'Queued run completed successfully',
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startMessageLoop = async (): Promise<void> => {
|
||||||
|
if (messageLoopRunning) {
|
||||||
|
logger.debug('Message loop already running, skipping duplicate start');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messageLoopRunning = true;
|
||||||
|
|
||||||
|
logger.info(`NanoClaw running (trigger: @${deps.assistantName})`);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const registeredGroups = deps.getRegisteredGroups();
|
||||||
|
const jids = Object.keys(registeredGroups);
|
||||||
|
const { messages, newTimestamp } = getNewMessages(
|
||||||
|
jids,
|
||||||
|
deps.getLastTimestamp(),
|
||||||
|
deps.assistantName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messages.length > 0) {
|
||||||
|
logger.info({ count: messages.length }, 'New messages');
|
||||||
|
|
||||||
|
deps.setLastTimestamp(newTimestamp);
|
||||||
|
deps.saveState();
|
||||||
|
|
||||||
|
const messagesByGroup = new Map<string, NewMessage[]>();
|
||||||
|
for (const msg of messages) {
|
||||||
|
const existing = messagesByGroup.get(msg.chat_jid);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(msg);
|
||||||
|
} else {
|
||||||
|
messagesByGroup.set(msg.chat_jid, [msg]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [chatJid, groupMessages] of messagesByGroup) {
|
||||||
|
const group = registeredGroups[chatJid];
|
||||||
|
if (!group) continue;
|
||||||
|
|
||||||
|
const channel = findChannel(deps.channels, chatJid);
|
||||||
|
if (!channel) {
|
||||||
|
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMainGroup = group.isMain === true;
|
||||||
|
const allFromBots = groupMessages.every(
|
||||||
|
(msg) => msg.is_from_me || !!msg.is_bot_message,
|
||||||
|
);
|
||||||
|
if (allFromBots) {
|
||||||
|
const lastHuman = getLastHumanMessageTimestamp(chatJid);
|
||||||
|
if (
|
||||||
|
!lastHuman ||
|
||||||
|
Date.now() - new Date(lastHuman).getTime() >
|
||||||
|
12 * 60 * 60 * 1000
|
||||||
|
) {
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, lastHuman },
|
||||||
|
'Bot-collaboration timeout: no human message within 12h, skipping',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loopCmdMsg = groupMessages.find(
|
||||||
|
(msg) =>
|
||||||
|
extractSessionCommand(msg.content, deps.triggerPattern) !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loopCmdMsg) {
|
||||||
|
if (
|
||||||
|
isSessionCommandAllowed(
|
||||||
|
isMainGroup,
|
||||||
|
loopCmdMsg.is_from_me === true,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
deps.queue.closeStdin(chatJid);
|
||||||
|
}
|
||||||
|
deps.queue.enqueueMessageCheck(chatJid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsTrigger =
|
||||||
|
!isMainGroup && group.requiresTrigger !== false;
|
||||||
|
if (needsTrigger) {
|
||||||
|
const allowlistCfg = loadSenderAllowlist();
|
||||||
|
const hasTrigger = groupMessages.some(
|
||||||
|
(msg) =>
|
||||||
|
deps.triggerPattern.test(msg.content.trim()) &&
|
||||||
|
(msg.is_from_me ||
|
||||||
|
isTriggerAllowed(chatJid, msg.sender, allowlistCfg)),
|
||||||
|
);
|
||||||
|
if (!hasTrigger) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastAgentTimestamps = deps.getLastAgentTimestamps();
|
||||||
|
const allPending = getMessagesSince(
|
||||||
|
chatJid,
|
||||||
|
lastAgentTimestamps[chatJid] || '',
|
||||||
|
deps.assistantName,
|
||||||
|
);
|
||||||
|
const messagesToSend =
|
||||||
|
allPending.length > 0 ? allPending : groupMessages;
|
||||||
|
const formatted = formatMessages(messagesToSend, deps.timezone);
|
||||||
|
|
||||||
|
if (deps.queue.sendMessage(chatJid, formatted)) {
|
||||||
|
logger.debug(
|
||||||
|
{ chatJid, count: messagesToSend.length },
|
||||||
|
'Piped messages to active agent',
|
||||||
|
);
|
||||||
|
lastAgentTimestamps[chatJid] =
|
||||||
|
messagesToSend[messagesToSend.length - 1].timestamp;
|
||||||
|
deps.saveState();
|
||||||
|
channel
|
||||||
|
.setTyping?.(chatJid, true)
|
||||||
|
?.catch((err) =>
|
||||||
|
logger.warn(
|
||||||
|
{ chatJid, err },
|
||||||
|
'Failed to set typing indicator',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
deps.queue.enqueueMessageCheck(chatJid, group.folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Error in message loop');
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, deps.pollInterval));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recoverPendingMessages = (): void => {
|
||||||
|
const registeredGroups = deps.getRegisteredGroups();
|
||||||
|
const lastAgentTimestamps = deps.getLastAgentTimestamps();
|
||||||
|
for (const [chatJid, group] of Object.entries(registeredGroups)) {
|
||||||
|
const sinceTimestamp = lastAgentTimestamps[chatJid] || '';
|
||||||
|
const pending = getMessagesSince(
|
||||||
|
chatJid,
|
||||||
|
sinceTimestamp,
|
||||||
|
deps.assistantName,
|
||||||
|
);
|
||||||
|
if (pending.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
{ group: group.name, pendingCount: pending.length },
|
||||||
|
'Recovery: found unprocessed messages',
|
||||||
|
);
|
||||||
|
deps.queue.enqueueMessageCheck(chatJid, group.folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
processGroupMessages,
|
||||||
|
recoverPendingMessages,
|
||||||
|
startMessageLoop,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -34,16 +34,6 @@ export function formatOutbound(rawText: string): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function routeOutbound(
|
|
||||||
channels: Channel[],
|
|
||||||
jid: string,
|
|
||||||
text: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected());
|
|
||||||
if (!channel) throw new Error(`No channel for JID: ${jid}`);
|
|
||||||
return channel.sendMessage(jid, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findChannel(
|
export function findChannel(
|
||||||
channels: Channel[],
|
channels: Channel[],
|
||||||
jid: string,
|
jid: string,
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ describe('extractSessionCommand', () => {
|
|||||||
expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
|
expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects bare /clear', () => {
|
||||||
|
expect(extractSessionCommand('/clear', trigger)).toBe('/clear');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects /compact with extra text', () => {
|
it('rejects /compact with extra text', () => {
|
||||||
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
|
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -82,6 +86,7 @@ function makeDeps(
|
|||||||
setTyping: vi.fn().mockResolvedValue(undefined),
|
setTyping: vi.fn().mockResolvedValue(undefined),
|
||||||
runAgent: vi.fn().mockResolvedValue('success'),
|
runAgent: vi.fn().mockResolvedValue('success'),
|
||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
|
clearSession: vi.fn(),
|
||||||
advanceCursor: vi.fn(),
|
advanceCursor: vi.fn(),
|
||||||
formatMessages: vi.fn().mockReturnValue('<formatted>'),
|
formatMessages: vi.fn().mockReturnValue('<formatted>'),
|
||||||
canSenderInteract: vi.fn().mockReturnValue(true),
|
canSenderInteract: vi.fn().mockReturnValue(true),
|
||||||
@@ -123,6 +128,26 @@ describe('handleSessionCommand', () => {
|
|||||||
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles authorized /clear without invoking the agent', async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const result = await handleSessionCommand({
|
||||||
|
missedMessages: [makeMsg('/clear')],
|
||||||
|
isMainGroup: true,
|
||||||
|
groupName: 'test',
|
||||||
|
triggerPattern: trigger,
|
||||||
|
timezone: 'UTC',
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
|
expect(deps.closeStdin).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.clearSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
||||||
|
expect(deps.runAgent).not.toHaveBeenCalled();
|
||||||
|
expect(deps.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'Current session cleared. The next message will start a new conversation.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('sends denial to interactable sender in non-main group', async () => {
|
it('sends denial to interactable sender in non-main group', async () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const result = await handleSessionCommand({
|
const result = await handleSessionCommand({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function extractSessionCommand(
|
|||||||
let text = content.trim();
|
let text = content.trim();
|
||||||
text = text.replace(triggerPattern, '').trim();
|
text = text.replace(triggerPattern, '').trim();
|
||||||
if (text === '/compact') return '/compact';
|
if (text === '/compact') return '/compact';
|
||||||
|
if (text === '/clear') return '/clear';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export interface SessionCommandDeps {
|
|||||||
onOutput: (result: AgentResult) => Promise<void>,
|
onOutput: (result: AgentResult) => Promise<void>,
|
||||||
) => Promise<'success' | 'error'>;
|
) => Promise<'success' | 'error'>;
|
||||||
closeStdin: () => void;
|
closeStdin: () => void;
|
||||||
|
clearSession: () => void;
|
||||||
advanceCursor: (timestamp: string) => void;
|
advanceCursor: (timestamp: string) => void;
|
||||||
formatMessages: (msgs: NewMessage[], timezone: string) => string;
|
formatMessages: (msgs: NewMessage[], timezone: string) => string;
|
||||||
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
|
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
|
||||||
@@ -63,6 +65,7 @@ export async function handleSessionCommand(opts: {
|
|||||||
missedMessages: NewMessage[];
|
missedMessages: NewMessage[];
|
||||||
isMainGroup: boolean;
|
isMainGroup: boolean;
|
||||||
groupName: string;
|
groupName: string;
|
||||||
|
runId?: string;
|
||||||
triggerPattern: RegExp;
|
triggerPattern: RegExp;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
deps: SessionCommandDeps;
|
deps: SessionCommandDeps;
|
||||||
@@ -71,6 +74,7 @@ export async function handleSessionCommand(opts: {
|
|||||||
missedMessages,
|
missedMessages,
|
||||||
isMainGroup,
|
isMainGroup,
|
||||||
groupName,
|
groupName,
|
||||||
|
runId,
|
||||||
triggerPattern,
|
triggerPattern,
|
||||||
timezone,
|
timezone,
|
||||||
deps,
|
deps,
|
||||||
@@ -98,7 +102,17 @@ export async function handleSessionCommand(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AUTHORIZED: process pre-compact messages first, then run the command
|
// AUTHORIZED: process pre-compact messages first, then run the command
|
||||||
logger.info({ group: groupName, command }, 'Session command');
|
logger.info({ group: groupName, runId, command }, 'Session command');
|
||||||
|
|
||||||
|
if (command === '/clear') {
|
||||||
|
deps.closeStdin();
|
||||||
|
deps.clearSession();
|
||||||
|
deps.advanceCursor(cmdMsg.timestamp);
|
||||||
|
await deps.sendMessage(
|
||||||
|
'Current session cleared. The next message will start a new conversation.',
|
||||||
|
);
|
||||||
|
return { handled: true, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
const cmdIndex = missedMessages.indexOf(cmdMsg);
|
const cmdIndex = missedMessages.indexOf(cmdMsg);
|
||||||
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
|
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
|
||||||
@@ -125,7 +139,7 @@ export async function handleSessionCommand(opts: {
|
|||||||
|
|
||||||
if (preResult === 'error' || hadPreError) {
|
if (preResult === 'error' || hadPreError) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ group: groupName },
|
{ group: groupName, runId },
|
||||||
'Pre-compact processing failed, aborting session command',
|
'Pre-compact processing failed, aborting session command',
|
||||||
);
|
);
|
||||||
await deps.sendMessage(
|
await deps.sendMessage(
|
||||||
|
|||||||
Reference in New Issue
Block a user