Stabilize paired-room orchestration and Codex app-server flow

This commit is contained in:
Eyejoker
2026-03-19 02:57:14 +09:00
parent b710d4a33d
commit 779d57c392
32 changed files with 2517 additions and 185 deletions

View File

@@ -0,0 +1,20 @@
# Claude Paired Room Rules
This room has both Claude and Codex.
Both of you can read the same room conversation and respond in the same thread.
Your default role is review, test planning, verification, and risk checking.
Discussion and design debate are shared responsibilities. You can challenge Codex, refine its approach, and propose alternatives when they are stronger.
Keep coordination with Codex public by default. Use `<internal>` only for content that truly needs to stay hidden from the room.
When Codex is already implementing, prefer:
- clarifying requirements
- surfacing edge cases and regressions
- proposing focused tests
- reviewing results and calling out risks
Let Codex take the lead on implementation in most cases.
You can still implement when the user explicitly asks you to, when Codex is blocked, or when a small targeted patch is the fastest way to verify a point.

View File

@@ -0,0 +1,46 @@
# Claude Platform Rules
You are Andy, a personal assistant.
## Communication
Your output is sent directly to the user or Discord group.
You also have `mcp__nanoclaw__send_message`, which sends a message immediately while you are still working. Use it when you want to acknowledge a request before starting longer work.
### Internal thoughts
Use `<internal>` only for genuinely hidden content.
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:
```text
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research...
```
Text inside `<internal>` tags is logged but not sent to the user.
Prefer public replies for coordination, status updates, review comments, and anything Codex or the user should react to.
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to.
## Memory
The group folder may contain a `conversations/` directory with searchable history from earlier sessions. Use it when you need prior context.
When you learn something important:
- Create files for structured data when that is genuinely useful
- Split files larger than 500 lines into smaller folders or documents
- Keep an index if you start building a larger memory structure
## Message formatting
Do not use markdown headings in chat replies. Keep messages clean and readable for Discord.
- Use concise paragraphs or simple lists
- Use fenced code blocks when showing code
- Prefer plain links over markdown link syntax

View File

@@ -0,0 +1,18 @@
# Codex Paired Room Rules
This room has both Claude and Codex.
Both of you can read the same room conversation and respond in the same thread.
Your default role is implementation, debugging, command execution, and concrete code changes.
Take the lead on implementation in this room unless the user explicitly redirects the work.
Discussion and design debate are shared responsibilities. Engage with Claude critically and evaluate its feedback on the merits.
Treat Claude's feedback as input to inspect, test, and reason through, not as something to accept automatically.
When Claude is already reviewing or testing, prefer:
- making the code change
- running commands and checks
- narrowing the bug or failure
- reporting concrete results back to the room

27
prompts/codex-platform.md Normal file
View File

@@ -0,0 +1,27 @@
# Codex Platform Rules
You are 코덱스, a participant in a Discord chat.
## Core rules
- Respond directly to messages. Do not provide reply suggestions or draft responses for someone else to send.
- Respond in Korean.
- When coding, debugging, or file work is needed, do it directly.
## Communication
Your output is sent directly to the Discord group.
- Keep answers concise unless more detail is genuinely needed
- Give conclusions and concrete next steps, not hidden reasoning
- Use code blocks for commands or code when helpful
- Do not claim you will keep watching, monitor later, report back later, or continue tracking unless you actually scheduled a NanoClaw task with `schedule_task`
- If no task was scheduled, do not imply that background tracking is active. If future follow-up is needed, tell the user to ping you again or explicitly ask for scheduling
- When you do schedule background follow-up, mention that it was scheduled. Include the task ID only when it is useful for later reference
## Working style
- Prefer reading the current workspace before making assumptions
- Modify only what is needed for the task
- Verify changes when you can instead of claiming they should work
- For CI/status/watch requests that require future follow-up, prefer `schedule_task` over leaving the chat session idle

View File

@@ -62,7 +62,6 @@ interface SDKUserMessage {
// Paths configurable via env vars (defaults to container paths for backwards compat)
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const GLOBAL_DIR = process.env.NANOCLAW_GLOBAL_DIR || '/workspace/global';
const EXTRA_BASE = process.env.NANOCLAW_EXTRA_DIR || '/workspace/extra';
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
const WORK_DIR = process.env.NANOCLAW_WORK_DIR || '';
@@ -450,13 +449,6 @@ async function runQuery(
let messageCount = 0;
let resultCount = 0;
// Load global CLAUDE.md as additional system context (shared across all groups)
const globalClaudeMdPath = path.join(GLOBAL_DIR, 'CLAUDE.md');
let globalClaudeMd: string | undefined;
if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
}
// Discover additional directories
const extraDirs: string[] = [];
@@ -507,9 +499,6 @@ async function runQuery(
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
resume: sessionId,
resumeSessionAt: resumeAt,
systemPrompt: globalClaudeMd
? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd }
: undefined,
allowedTools: [
'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep',

View File

@@ -9,7 +9,7 @@
"start": "node dist/index.js"
},
"dependencies": {
"@openai/codex-sdk": "^0.114.0"
"@openai/codex-sdk": "^0.115.0"
},
"devDependencies": {
"@types/node": "^22.10.7",

119
runners/codex-runner/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,119 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@openai/codex-sdk':
specifier: ^0.115.0
version: 0.115.0
devDependencies:
'@types/node':
specifier: ^22.10.7
version: 22.19.15
typescript:
specifier: ^5.7.3
version: 5.9.3
packages:
'@openai/codex-sdk@0.115.0':
resolution: {integrity: sha512-BPoPhim0uUm3rzugY7JFaFJ+rPG/wMRhvKNFii//Dp3kTb+gFy3jrip7ijXqhswsnqXM3nwTiv7kYHt1+TMUPg==}
engines: {node: '>=18'}
'@openai/codex@0.115.0':
resolution: {integrity: sha512-uu689DHUzvuPcb39hJ+7fqy++TAvY32w9VggDpcz3HS0Sx0WadWoAPPcMK547P2T6AqfMsAtA8kspkR3tqErOg==}
engines: {node: '>=16'}
hasBin: true
'@openai/codex@0.115.0-darwin-arm64':
resolution: {integrity: sha512-wTWV+YlDTL0y0mL+FMmbzhilm+dPkbIZTe/lH3LwBzcEMhgSGLsPdZLfAiMND4wFE21HS7H0pjMogNQEMLQmqg==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@openai/codex@0.115.0-darwin-x64':
resolution: {integrity: sha512-EPzgymU4CFp83qjv29wXFwhWib3zC8g6SLTJGh2OpcJiOYyLY0bO53FB+QchL1jC9gm1uLyD5j5F3ddI0AbjIg==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@openai/codex@0.115.0-linux-arm64':
resolution: {integrity: sha512-40+SCyI+LvVx/iX30qH7dTQzWt9MZxDaK2E6YRB4hoYej5UALrhkFNzweHa5uvqvhmqGZCuagZOYB8285eGCgw==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@openai/codex@0.115.0-linux-x64':
resolution: {integrity: sha512-+6eRd2p4KMrhQvuF7XpjYIdCA2Ok2LbhOq+ywZdLpHbmwQ/Yynd0gJ/Q90xCeo2vloCwl9WsbsiVVSahG5FyWg==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@openai/codex@0.115.0-win32-arm64':
resolution: {integrity: sha512-yaYhQ0kPL9Kithmrr1vh5A4c+gqAMhMZeA/htTtkpWW8RQkmwgcYYpX/Ky2cEzu0w51pvxQQy63LgHftc+pg1Q==}
engines: {node: '>=16'}
cpu: [arm64]
os: [win32]
'@openai/codex@0.115.0-win32-x64':
resolution: {integrity: sha512-wtYf2HJCB+p+Uj7o1W08fRgZMzKCVGRQ4YdSfLRNfF4wwPqX5XsK9blsv7brukn5J9lclYxagiR6qGvbfHbD7w==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@types/node@22.19.15':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@openai/codex-sdk@0.115.0':
dependencies:
'@openai/codex': 0.115.0
'@openai/codex@0.115.0':
optionalDependencies:
'@openai/codex-darwin-arm64': '@openai/codex@0.115.0-darwin-arm64'
'@openai/codex-darwin-x64': '@openai/codex@0.115.0-darwin-x64'
'@openai/codex-linux-arm64': '@openai/codex@0.115.0-linux-arm64'
'@openai/codex-linux-x64': '@openai/codex@0.115.0-linux-x64'
'@openai/codex-win32-arm64': '@openai/codex@0.115.0-win32-arm64'
'@openai/codex-win32-x64': '@openai/codex@0.115.0-win32-x64'
'@openai/codex@0.115.0-darwin-arm64':
optional: true
'@openai/codex@0.115.0-darwin-x64':
optional: true
'@openai/codex@0.115.0-linux-arm64':
optional: true
'@openai/codex@0.115.0-linux-x64':
optional: true
'@openai/codex@0.115.0-win32-arm64':
optional: true
'@openai/codex@0.115.0-win32-x64':
optional: true
'@types/node@22.19.15':
dependencies:
undici-types: 6.21.0
typescript@5.9.3: {}
undici-types@6.21.0: {}

View File

@@ -0,0 +1,459 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
import { createRequire } from 'module';
import path from 'path';
import {
createInitialAppServerTurnState,
getAppServerTurnResult,
isAppServerTurnFinished,
reduceAppServerTurnState,
type AppServerTurnEvent,
type AppServerTurnState,
} from './app-server-state.js';
export interface AppServerInputItemText {
type: 'text';
text: string;
}
export interface AppServerInputItemLocalImage {
type: 'localImage';
path: string;
}
export type AppServerInputItem =
| AppServerInputItemText
| AppServerInputItemLocalImage;
export interface CodexAppServerThreadOptions {
cwd: string;
model?: string;
}
export interface CodexAppServerTurnOptions {
cwd: string;
model?: string;
effort?: string;
onProgress?: (message: string) => void;
}
export interface CodexAppServerTurnResult {
state: AppServerTurnState;
result: string | null;
}
interface JsonRpcResponse {
id: number;
result?: unknown;
error?: {
code?: number;
message?: string;
data?: unknown;
};
}
interface JsonRpcNotification {
method: string;
params?: Record<string, unknown>;
}
interface JsonRpcServerRequest extends JsonRpcNotification {
id: number;
}
interface PendingRequest {
method: string;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
}
interface ActiveTurn {
threadId: string;
state: AppServerTurnState;
onProgress?: (message: string) => void;
resolve: (value: CodexAppServerTurnResult) => void;
reject: (reason?: unknown) => void;
}
export interface CodexAppServerClientOptions {
cwd: string;
env?: NodeJS.ProcessEnv;
log: (message: string) => void;
}
export class CodexAppServerClient {
private readonly cwd: string;
private readonly env: NodeJS.ProcessEnv;
private readonly log: (message: string) => void;
private readonly pending = new Map<number, PendingRequest>();
private readonly require = createRequire(import.meta.url);
private nextId = 1;
private stdoutBuffer = '';
private activeTurn: ActiveTurn | null = null;
private proc: ChildProcessWithoutNullStreams | null = null;
constructor(options: CodexAppServerClientOptions) {
this.cwd = options.cwd;
this.env = options.env || process.env;
this.log = options.log;
}
async start(): Promise<void> {
if (this.proc) return;
const codexPackagePath = this.require.resolve('@openai/codex/package.json');
const codexBin = path.join(path.dirname(codexPackagePath), 'bin', 'codex.js');
this.proc = spawn(process.execPath, [codexBin, 'app-server'], {
cwd: this.cwd,
env: this.env,
stdio: ['pipe', 'pipe', 'pipe'],
});
this.proc.stdout.setEncoding('utf8');
this.proc.stdout.on('data', (chunk: string) => {
this.stdoutBuffer += chunk;
const lines = this.stdoutBuffer.split('\n');
this.stdoutBuffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
this.handleStdoutLine(trimmed);
}
});
this.proc.stderr.setEncoding('utf8');
this.proc.stderr.on('data', (chunk: string) => {
for (const line of chunk.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
this.log(`[app-server] ${trimmed}`);
}
});
this.proc.on('close', (code) => {
const error = new Error(
`Codex app-server exited with code ${code ?? 'unknown'}`,
);
this.rejectAll(error);
});
this.proc.on('error', (error) => {
this.rejectAll(error);
});
await this.request('initialize', {
clientInfo: {
name: 'nanoclaw_codex_runner',
title: 'NanoClaw Codex Runner',
version: '1.0.0',
},
capabilities: {
experimentalApi: true,
optOutNotificationMethods: [
'item/agentMessage/delta',
'item/plan/delta',
'item/reasoning/textDelta',
'item/reasoning/summaryTextDelta',
'item/reasoning/summaryPartAdded',
],
},
});
this.notify('initialized', {});
}
async close(): Promise<void> {
if (!this.proc) return;
const proc = this.proc;
this.proc = null;
try {
proc.kill('SIGTERM');
} catch {
/* ignore */
}
}
async startOrResumeThread(
sessionId: string | undefined,
options: CodexAppServerThreadOptions,
): Promise<string> {
const params = {
cwd: options.cwd,
model: options.model,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
serviceName: 'nanoclaw',
};
const result = sessionId
? await this.request('thread/resume', {
threadId: sessionId,
...params,
})
: await this.request('thread/start', params);
const thread = (result as { thread?: { id?: string } }).thread;
if (!thread?.id) {
throw new Error('Codex app-server did not return a thread id.');
}
return thread.id;
}
async startTurn(
threadId: string,
input: AppServerInputItem[],
options: CodexAppServerTurnOptions,
): Promise<{
turnId: string;
steer: (nextInput: AppServerInputItem[]) => Promise<void>;
interrupt: () => Promise<void>;
wait: () => Promise<CodexAppServerTurnResult>;
}> {
if (this.activeTurn) {
throw new Error('A Codex app-server turn is already active.');
}
const turnPromise = new Promise<CodexAppServerTurnResult>((resolve, reject) => {
this.activeTurn = {
threadId,
state: createInitialAppServerTurnState(),
onProgress: options.onProgress,
resolve,
reject,
};
});
let turnId = '';
try {
const response = (await this.request('turn/start', {
threadId,
input,
cwd: options.cwd,
approvalPolicy: 'never',
sandboxPolicy: {
type: 'dangerFullAccess',
networkAccess: true,
},
model: options.model,
effort: options.effort,
summary: 'concise',
})) as { turn?: { id?: string; status?: string } };
turnId = response.turn?.id || '';
if (!turnId) {
throw new Error('Codex app-server did not return a turn id.');
}
const activeTurn = this.activeTurn as ActiveTurn | null;
if (activeTurn !== null) {
activeTurn.state = reduceAppServerTurnState(activeTurn.state, {
method: 'turn/started',
params: {
turn: {
id: turnId,
status: response.turn?.status || 'inProgress',
},
},
});
}
} catch (error) {
this.activeTurn = null;
throw error;
}
return {
turnId,
steer: async (nextInput) => {
await this.request('turn/steer', {
threadId,
input: nextInput,
expectedTurnId: turnId,
});
},
interrupt: async () => {
await this.request('turn/interrupt', {
threadId,
turnId,
});
},
wait: async () => turnPromise,
};
}
async startCompaction(threadId: string): Promise<CodexAppServerTurnResult> {
if (this.activeTurn) {
throw new Error('A Codex app-server turn is already active.');
}
const turnPromise = new Promise<CodexAppServerTurnResult>((resolve, reject) => {
this.activeTurn = {
threadId,
state: createInitialAppServerTurnState(),
resolve,
reject,
};
});
try {
await this.request('thread/compact/start', { threadId });
} catch (error) {
this.activeTurn = null;
throw error;
}
return turnPromise;
}
private handleStdoutLine(line: string): void {
let message: JsonRpcResponse | JsonRpcNotification | JsonRpcServerRequest;
try {
message = JSON.parse(line);
} catch {
this.log(`[app-server] non-JSON stdout: ${line}`);
return;
}
if (
typeof (message as JsonRpcResponse).id === 'number' &&
('result' in message || 'error' in message) &&
!('method' in message)
) {
this.handleResponse(message as JsonRpcResponse);
return;
}
if (
typeof (message as JsonRpcServerRequest).id === 'number' &&
typeof (message as JsonRpcServerRequest).method === 'string'
) {
this.handleServerRequest(message as JsonRpcServerRequest);
return;
}
if (typeof (message as JsonRpcNotification).method === 'string') {
this.handleNotification(message as JsonRpcNotification);
}
}
private handleResponse(message: JsonRpcResponse): void {
const pending = this.pending.get(message.id);
if (!pending) return;
this.pending.delete(message.id);
if (message.error) {
pending.reject(
new Error(
message.error.message ||
`${pending.method} failed with JSON-RPC error ${message.error.code ?? 'unknown'}`,
),
);
return;
}
pending.resolve(message.result);
}
private handleServerRequest(message: JsonRpcServerRequest): void {
if (message.method.endsWith('/requestApproval')) {
this.respond(message.id, 'acceptForSession');
return;
}
this.respondError(
message.id,
-32601,
`NanoClaw does not handle server request ${message.method}`,
);
}
private handleNotification(message: JsonRpcNotification): void {
if (!this.activeTurn) return;
if (message.method === 'item/completed') {
const item = (message.params?.item as Record<string, unknown> | undefined) || undefined;
if (
item?.type === 'agentMessage' &&
item.phase !== 'final_answer' &&
typeof item.text === 'string' &&
item.text.trim().length > 0
) {
this.activeTurn.onProgress?.(item.text);
}
}
this.activeTurn.state = reduceAppServerTurnState(
this.activeTurn.state,
message as AppServerTurnEvent,
);
if (!isAppServerTurnFinished(this.activeTurn.state)) {
return;
}
const activeTurn = this.activeTurn;
this.activeTurn = null;
activeTurn.resolve({
state: activeTurn.state,
result: getAppServerTurnResult(activeTurn.state),
});
}
private rejectAll(error: unknown): void {
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
if (this.activeTurn) {
const activeTurn = this.activeTurn;
this.activeTurn = null;
activeTurn.reject(error);
}
}
private request(method: string, params?: unknown): Promise<unknown> {
const id = this.nextId++;
const payload = {
jsonrpc: '2.0',
id,
method,
params,
};
return new Promise((resolve, reject) => {
this.pending.set(id, { method, resolve, reject });
this.write(payload);
});
}
private notify(method: string, params?: unknown): void {
this.write({
jsonrpc: '2.0',
method,
params,
});
}
private respond(id: number, result: unknown): void {
this.write({
jsonrpc: '2.0',
id,
result,
});
}
private respondError(id: number, code: number, message: string): void {
this.write({
jsonrpc: '2.0',
id,
error: { code, message },
});
}
private write(message: Record<string, unknown>): void {
if (!this.proc?.stdin.writable) {
throw new Error('Codex app-server stdin is not writable.');
}
this.proc.stdin.write(JSON.stringify(message) + '\n');
}
}

View File

@@ -0,0 +1,174 @@
export interface AppServerAgentMessageItem {
type: 'agentMessage';
text?: string | null;
phase?: 'commentary' | 'final_answer' | string | null;
}
export interface AppServerContextCompactionItem {
type: 'contextCompaction';
id?: string;
}
export type AppServerItem =
| AppServerAgentMessageItem
| AppServerContextCompactionItem
| {
type: string;
[key: string]: unknown;
};
export interface AppServerTurnState {
turnId?: string;
status: 'pending' | 'inProgress' | 'completed' | 'failed' | 'interrupted';
finalAnswer: string | null;
latestAgentMessage: string | null;
errorMessage: string | null;
compactionCompleted: boolean;
}
export type AppServerTurnEvent =
| {
method: 'turn/started';
params?: { turn?: { id?: string | null; status?: string | null } };
}
| {
method: 'turn/completed';
params?: {
turn?: {
id?: string | null;
status?: string | null;
error?:
| { message?: string | null }
| string
| null;
};
};
}
| {
method: 'item/completed';
params?: { item?: AppServerItem | null };
}
| {
method: 'error';
params?: {
error?: {
message?: string | null;
codexErrorInfo?: {
httpStatusCode?: number | null;
type?: string | null;
} | null;
} | null;
};
};
export function createInitialAppServerTurnState(): AppServerTurnState {
return {
status: 'pending',
finalAnswer: null,
latestAgentMessage: null,
errorMessage: null,
compactionCompleted: false,
};
}
export function reduceAppServerTurnState(
state: AppServerTurnState,
event: AppServerTurnEvent,
): AppServerTurnState {
if (event.method === 'turn/started') {
return {
...state,
turnId: event.params?.turn?.id || state.turnId,
status: 'inProgress',
};
}
if (event.method === 'item/completed') {
const item = event.params?.item;
if (!item) return state;
if (item.type === 'agentMessage') {
const text =
typeof item.text === 'string' && item.text.trim().length > 0
? item.text
: null;
if (!text) return state;
if (item.phase === 'final_answer') {
return {
...state,
finalAnswer: text,
latestAgentMessage: text,
};
}
return {
...state,
latestAgentMessage: text,
};
}
if (item.type === 'contextCompaction') {
return {
...state,
compactionCompleted: true,
};
}
return state;
}
if (event.method === 'error') {
const error = event.params?.error;
const message =
typeof error?.message === 'string' && error.message.trim().length > 0
? error.message.trim()
: 'Codex app-server turn failed.';
const httpStatusCode = error?.codexErrorInfo?.httpStatusCode;
return {
...state,
errorMessage:
typeof httpStatusCode === 'number'
? `${message} (HTTP ${httpStatusCode})`
: message,
};
}
if (event.method === 'turn/completed') {
const turn = event.params?.turn;
const status =
turn?.status === 'completed' ||
turn?.status === 'failed' ||
turn?.status === 'interrupted'
? turn.status
: 'completed';
const turnError =
typeof turn?.error === 'string'
? turn.error
: turn?.error?.message || null;
return {
...state,
turnId: turn?.id || state.turnId,
status,
errorMessage: turnError || state.errorMessage,
};
}
return state;
}
export function isAppServerTurnFinished(state: AppServerTurnState): boolean {
return (
state.status === 'completed' ||
state.status === 'failed' ||
state.status === 'interrupted'
);
}
export function getAppServerTurnResult(
state: AppServerTurnState,
): string | null {
return state.finalAnswer;
}

View File

@@ -1,9 +1,8 @@
/**
* NanoClaw Codex Runner (SDK mode)
* NanoClaw Codex Runner
*
* Uses @openai/codex-sdk which wraps `codex exec`. This ensures complete
* task execution per turn — the agent finishes all work before responding,
* unlike app-server mode which can end turns prematurely.
* Default runtime is Codex app-server, with SDK fallback available via
* CODEX_RUNTIME=sdk or automatic fallback when app-server startup fails.
*
* Input protocol:
* Stdin: Full ContainerInput JSON (read until EOF)
@@ -14,15 +13,20 @@
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
*/
import { Codex, type Thread, type UserInput, type ThreadOptions } from '@openai/codex-sdk';
import { Codex, type Thread, type ThreadOptions, type UserInput } from '@openai/codex-sdk';
import fs from 'fs';
import path from 'path';
import {
CodexAppServerClient,
type AppServerInputItem,
} from './app-server-client.js';
// ── Types ──────────────────────────────────────────────────────────
interface ContainerInput {
prompt: string;
sessionId?: string; // threadId from previous session
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
@@ -34,6 +38,7 @@ interface ContainerInput {
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
phase?: 'progress' | 'final';
newSessionId?: string;
error?: string;
}
@@ -47,6 +52,7 @@ const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
const MAX_TURNS = 100;
const CODEX_RUNTIME = (process.env.CODEX_RUNTIME || 'app-server').toLowerCase();
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
@@ -55,6 +61,8 @@ const EFFECTIVE_CWD = WORK_DIR || GROUP_DIR;
const CODEX_MODEL = process.env.CODEX_MODEL || '';
const CODEX_EFFORT = process.env.CODEX_EFFORT || '';
let closeRequested = false;
// ── Helpers ────────────────────────────────────────────────────────
function writeOutput(output: ContainerOutput): void {
@@ -71,25 +79,33 @@ async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk: string) => { data += chunk; });
process.stdin.on('data', (chunk: string) => {
data += chunk;
});
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
function shouldClose(): boolean {
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
return true;
function consumeCloseSentinel(): boolean {
if (closeRequested) return true;
if (!fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) return false;
try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
return false;
closeRequested = true;
return true;
}
function drainIpcInput(): string[] {
try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
const files = fs.readdirSync(IPC_INPUT_DIR)
.filter(f => f.endsWith('.json'))
const files = fs
.readdirSync(IPC_INPUT_DIR)
.filter((file) => file.endsWith('.json'))
.sort();
const messages: string[] = [];
@@ -102,8 +118,16 @@ function drainIpcInput(): string[] {
messages.push(data.text);
}
} catch (err) {
log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`);
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
log(
`Failed to process input file ${file}: ${
err instanceof Error ? err.message : String(err)
}`,
);
try {
fs.unlinkSync(filePath);
} catch {
/* ignore */
}
}
}
return messages;
@@ -116,7 +140,7 @@ function drainIpcInput(): string[] {
function waitForIpcMessage(): Promise<string | null> {
return new Promise((resolve) => {
const poll = () => {
if (shouldClose()) {
if (consumeCloseSentinel()) {
resolve(null);
return;
}
@@ -131,23 +155,25 @@ function waitForIpcMessage(): Promise<string | null> {
});
}
// ── Input Parsing ─────────────────────────────────────────────────
/**
* Parse [Image: /path] patterns from text and build SDK input.
*/
function parseInput(text: string): string | UserInput[] {
function extractImagePaths(text: string): { cleanText: string; imagePaths: string[] } {
const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g;
const imagePaths: string[] = [];
let match;
let match: RegExpExecArray | null;
while ((match = imagePattern.exec(text)) !== null) {
imagePaths.push(match[1].trim());
}
return {
cleanText: text.replace(imagePattern, '').trim(),
imagePaths,
};
}
function parseSdkInput(text: string): string | UserInput[] {
const { cleanText, imagePaths } = extractImagePaths(text);
if (imagePaths.length === 0) return text;
const input: UserInput[] = [];
const cleanText = text.replace(imagePattern, '').trim();
if (cleanText) {
input.push({ type: 'text', text: cleanText });
}
@@ -162,23 +188,56 @@ function parseInput(text: string): string | UserInput[] {
return input.length > 0 ? input : text;
}
// ── Turn Execution ────────────────────────────────────────────────
function parseAppServerInput(text: string): AppServerInputItem[] {
const { cleanText, imagePaths } = extractImagePaths(text);
const input: AppServerInputItem[] = [];
/**
* Execute a single turn using the SDK. The SDK wraps `codex exec`,
* ensuring the agent completes all work before returning.
*/
async function executeTurn(
if (cleanText) {
input.push({ type: 'text', text: cleanText });
}
for (const imgPath of imagePaths) {
if (fs.existsSync(imgPath)) {
input.push({ type: 'localImage', path: imgPath });
log(`Adding image input: ${imgPath}`);
} else {
log(`Image not found, skipping: ${imgPath}`);
}
}
if (input.length === 0) {
input.push({ type: 'text', text });
}
return input;
}
function getThreadOptions(): ThreadOptions {
const threadOptions: ThreadOptions = {
workingDirectory: EFFECTIVE_CWD,
approvalPolicy: 'never',
sandboxMode: 'danger-full-access',
networkAccessEnabled: true,
webSearchMode: 'live',
};
if (CODEX_MODEL) threadOptions.model = CODEX_MODEL;
if (CODEX_EFFORT) {
threadOptions.modelReasoningEffort =
CODEX_EFFORT as ThreadOptions['modelReasoningEffort'];
}
return threadOptions;
}
async function executeSdkTurn(
thread: Thread,
input: string | UserInput[],
): Promise<{ result: string; error?: string }> {
const ac = new AbortController();
// Poll close sentinel + heartbeat during turn
let turnSeconds = 0;
const sentinel = setInterval(() => {
if (shouldClose()) {
log('Close sentinel detected during turn, aborting');
if (consumeCloseSentinel()) {
log('Close sentinel detected during SDK turn, aborting');
ac.abort();
return;
}
@@ -204,55 +263,97 @@ async function executeTurn(
}
}
// ── Main ──────────────────────────────────────────────────────────
async function executeAppServerTurn(
client: CodexAppServerClient,
threadId: string,
prompt: string,
): Promise<{ result: string | null; error?: string }> {
let lastProgressMessage: string | null = null;
const activeTurn = await client.startTurn(threadId, parseAppServerInput(prompt), {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
effort: CODEX_EFFORT || undefined,
onProgress: (message) => {
const trimmed = message.trim();
if (!trimmed || trimmed === lastProgressMessage) {
return;
}
lastProgressMessage = trimmed;
writeOutput({
status: 'success',
phase: 'progress',
result: trimmed,
newSessionId: threadId,
});
},
});
async function main(): Promise<void> {
let containerInput: ContainerInput;
let elapsedMs = 0;
let polling = true;
const pollDuringTurn = async () => {
if (!polling) return;
if (consumeCloseSentinel()) {
log('Close sentinel detected during app-server turn, interrupting');
polling = false;
try {
await activeTurn.interrupt();
} catch (err) {
log(
`Failed to interrupt active turn: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
return;
}
const messages = drainIpcInput();
if (messages.length > 0) {
const merged = messages.join('\n');
log(`Steering active turn with ${messages.length} queued message(s)`);
try {
await activeTurn.steer(parseAppServerInput(merged));
} catch (err) {
log(
`turn/steer failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
elapsedMs += IPC_POLL_MS;
if (elapsedMs > 0 && elapsedMs % 60000 === 0) {
log(`Turn in progress... (${Math.round(elapsedMs / 60000)}min)`);
}
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
};
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
try {
const stdinData = await readStdin();
containerInput = JSON.parse(stdinData);
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
log(`Received input for group: ${containerInput.groupFolder}`);
} catch (err) {
writeOutput({
status: 'error',
result: null,
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`,
});
process.exit(1);
const { state, result } = await activeTurn.wait();
if (state.status === 'completed') {
return { result };
}
if (state.status === 'interrupted' && consumeCloseSentinel()) {
return { result };
}
return {
result,
error: state.errorMessage || `Codex turn finished with status ${state.status}`,
};
} finally {
polling = false;
}
}
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
// Build initial prompt
let prompt = containerInput.prompt;
if (containerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK]\n\n${prompt}`;
}
const pending = drainIpcInput();
if (pending.length > 0) {
prompt += '\n' + pending.join('\n');
}
// Build thread options
const threadOptions: ThreadOptions = {
workingDirectory: EFFECTIVE_CWD,
approvalPolicy: 'never',
sandboxMode: 'danger-full-access',
networkAccessEnabled: true,
webSearchMode: 'live',
};
if (CODEX_MODEL) threadOptions.model = CODEX_MODEL;
if (CODEX_EFFORT) {
threadOptions.modelReasoningEffort = CODEX_EFFORT as ThreadOptions['modelReasoningEffort'];
}
// Create SDK instance (inherits env from parent — CODEX_HOME, OPENAI_API_KEY, etc.)
async function runSdkSession(
containerInput: ContainerInput,
prompt: string,
): Promise<void> {
const threadOptions = getThreadOptions();
const codex = new Codex();
// Start or resume thread (resume may fail on first run, fallback to new thread)
let thread: Thread;
if (containerInput.sessionId) {
thread = codex.resumeThread(containerInput.sessionId, threadOptions);
@@ -263,9 +364,147 @@ async function main(): Promise<void> {
}
let turnCount = 0;
while (true) {
turnCount++;
if (turnCount > MAX_TURNS) {
log(`Turn limit reached (${MAX_TURNS}), exiting`);
writeOutput({
status: 'success',
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
newSessionId: thread.id || undefined,
});
break;
}
const input = parseSdkInput(prompt);
log(`Starting SDK turn ${turnCount}/${MAX_TURNS}...`);
let { result, error } = await executeSdkTurn(thread, input);
if (error && turnCount === 1 && containerInput.sessionId) {
log(`Resume may have failed, retrying with new thread: ${error}`);
thread = codex.startThread(threadOptions);
({ result, error } = await executeSdkTurn(thread, input));
}
if (consumeCloseSentinel()) {
if (result) {
writeOutput({
status: 'success',
result,
newSessionId: thread.id || undefined,
});
}
log('Close sentinel detected, exiting SDK runtime');
break;
}
if (error) {
log(`SDK turn error: ${error}`);
writeOutput({
status: 'error',
result: result || null,
newSessionId: thread.id || undefined,
error,
});
} else {
writeOutput({
status: 'success',
result: result || null,
newSessionId: thread.id || undefined,
});
}
log('SDK turn done, waiting for next IPC message...');
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting SDK runtime');
break;
}
log(`Got new SDK message (${nextMessage.length} chars)`);
prompt = nextMessage;
}
}
async function runAppServerCompact(
client: CodexAppServerClient,
threadId: string | undefined,
): Promise<void> {
if (!threadId) {
writeOutput({
status: 'success',
result: '현재 활성 Codex 세션이 없어 compact를 건너뜁니다.',
});
return;
}
const { state } = await client.startCompaction(threadId);
if (state.status === 'failed') {
writeOutput({
status: 'error',
result: null,
newSessionId: threadId,
error: state.errorMessage || 'Conversation compaction failed.',
});
return;
}
writeOutput({
status: 'success',
result: state.compactionCompleted
? 'Conversation compacted.'
: 'Compaction requested but contextCompaction was not observed.',
newSessionId: threadId,
});
}
async function runAppServerSession(
containerInput: ContainerInput,
prompt: string,
): Promise<void> {
const client = new CodexAppServerClient({
cwd: EFFECTIVE_CWD,
env: process.env,
log,
});
await client.start();
let threadId: string | undefined;
try {
// Main turn loop
try {
threadId = await client.startOrResumeThread(containerInput.sessionId, {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
});
log(
containerInput.sessionId
? `App-server thread resumed (${threadId})`
: `App-server thread started (${threadId})`,
);
} catch (err) {
if (!containerInput.sessionId) throw err;
log(
`App-server resume failed, retrying with new thread: ${
err instanceof Error ? err.message : String(err)
}`,
);
threadId = await client.startOrResumeThread(undefined, {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
});
log(`App-server thread restarted (${threadId})`);
}
const trimmedPrompt = prompt.trim();
if (trimmedPrompt === '/compact') {
await runAppServerCompact(client, threadId);
return;
}
let turnCount = 0;
while (true) {
turnCount++;
if (turnCount > MAX_TURNS) {
@@ -273,59 +512,127 @@ async function main(): Promise<void> {
writeOutput({
status: 'success',
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
newSessionId: thread.id || undefined,
newSessionId: threadId,
});
break;
}
const input = parseInput(prompt);
log(`Starting turn ${turnCount}/${MAX_TURNS}...`);
log(`Starting app-server turn ${turnCount}/${MAX_TURNS}...`);
const { result, error } = await executeAppServerTurn(client, threadId, prompt);
let { result, error } = await executeTurn(thread, input);
// Fallback: if resume failed on first turn, retry with a new thread
if (error && turnCount === 1 && containerInput.sessionId) {
log(`Resume may have failed, retrying with new thread: ${error}`);
thread = codex.startThread(threadOptions);
({ result, error } = await executeTurn(thread, input));
}
// Check close sentinel
if (shouldClose()) {
if (consumeCloseSentinel()) {
if (result) {
writeOutput({ status: 'success', result, newSessionId: thread.id || undefined });
writeOutput({
status: 'success',
result,
newSessionId: threadId,
});
}
log('Close sentinel detected, exiting');
log('Close sentinel detected, exiting app-server runtime');
break;
}
if (error) {
log(`Turn error: ${error}`);
log(`App-server turn error: ${error}`);
writeOutput({
status: 'error',
result: result || null,
newSessionId: thread.id || undefined,
newSessionId: threadId,
error,
});
} else {
writeOutput({
status: 'success',
result: result || null,
newSessionId: thread.id || undefined,
...(result ? { phase: 'final' as const } : {}),
newSessionId: threadId,
});
}
log('Turn done, waiting for next IPC message...');
log('App-server turn done, waiting for next IPC message...');
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting');
log('Close sentinel received, exiting app-server runtime');
break;
}
log(`Got new message (${nextMessage.length} chars)`);
log(`Got new app-server message (${nextMessage.length} chars)`);
prompt = nextMessage;
}
} finally {
await client.close();
}
}
function shouldUseAppServer(): boolean {
return CODEX_RUNTIME !== 'sdk';
}
// ── Main ──────────────────────────────────────────────────────────
async function main(): Promise<void> {
let containerInput: ContainerInput;
try {
const stdinData = await readStdin();
containerInput = JSON.parse(stdinData);
try {
fs.unlinkSync('/tmp/input.json');
} catch {
/* may not exist */
}
log(`Received input for group: ${containerInput.groupFolder}`);
} catch (err) {
writeOutput({
status: 'error',
result: null,
error: `Failed to parse input: ${
err instanceof Error ? err.message : String(err)
}`,
});
process.exit(1);
}
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
closeRequested = false;
try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
let prompt = containerInput.prompt;
if (containerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK]\n\n${prompt}`;
}
const pending = drainIpcInput();
if (pending.length > 0) {
prompt += '\n' + pending.join('\n');
}
const preferAppServer = shouldUseAppServer();
try {
if (preferAppServer) {
try {
log(`Runtime selected: app-server (${CODEX_RUNTIME})`);
await runAppServerSession(containerInput, prompt);
return;
} catch (err) {
if (CODEX_RUNTIME === 'app-server') {
log(
`App-server runtime failed, falling back to SDK: ${
err instanceof Error ? err.message : String(err)
}`,
);
} else {
throw err;
}
}
}
log('Runtime selected: sdk');
await runSdkSession(containerInput, prompt);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Runner error: ${errorMessage}`);

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import {
createInitialAppServerTurnState,
getAppServerTurnResult,
reduceAppServerTurnState,
} from '../src/app-server-state.js';
describe('codex app-server turn state', () => {
it('prefers final_answer over commentary when building final output', () => {
let state = createInitialAppServerTurnState();
state = reduceAppServerTurnState(state, {
method: 'item/completed',
params: {
item: {
type: 'agentMessage',
phase: 'commentary',
text: '중간 설명입니다.',
},
},
});
state = reduceAppServerTurnState(state, {
method: 'item/completed',
params: {
item: {
type: 'agentMessage',
phase: 'final_answer',
text: '최종 답변입니다.',
},
},
});
expect(getAppServerTurnResult(state)).toBe('최종 답변입니다.');
});
it('does not treat commentary-only messages as a final output', () => {
const state = reduceAppServerTurnState(createInitialAppServerTurnState(), {
method: 'item/completed',
params: {
item: {
type: 'agentMessage',
phase: 'commentary',
text: '작업 중 상태입니다.',
},
},
});
expect(state.latestAgentMessage).toBe('작업 중 상태입니다.');
expect(getAppServerTurnResult(state)).toBeNull();
});
it('records compaction completion from contextCompaction items', () => {
const state = reduceAppServerTurnState(createInitialAppServerTurnState(), {
method: 'item/completed',
params: {
item: {
type: 'contextCompaction',
id: 'ctx_1',
},
},
});
expect(state.compactionCompleted).toBe(true);
});
it('captures upstream HTTP status from app-server error events', () => {
const state = reduceAppServerTurnState(createInitialAppServerTurnState(), {
method: 'error',
params: {
error: {
message: 'Too many requests',
codexErrorInfo: {
httpStatusCode: 429,
},
},
},
});
expect(state.errorMessage).toBe('Too many requests (HTTP 429)');
});
});

View File

@@ -12,14 +12,18 @@ import Database from 'better-sqlite3';
function createTestDb(): Database.Database {
const db = new Database(':memory:');
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
jid TEXT NOT NULL,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
folder TEXT NOT NULL,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0
is_main INTEGER DEFAULT 0,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
work_dir TEXT,
PRIMARY KEY (jid, agent_type),
UNIQUE (folder, agent_type)
)`);
return db;
}

View File

@@ -106,16 +106,18 @@ export async function run(args: string[]): Promise<void> {
const db = new Database(dbPath);
// Ensure schema exists
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
jid TEXT NOT NULL,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
folder TEXT NOT NULL,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0,
agent_type TEXT DEFAULT 'claude-code',
work_dir TEXT
agent_type TEXT NOT NULL DEFAULT 'claude-code',
work_dir TEXT,
PRIMARY KEY (jid, agent_type),
UNIQUE (folder, agent_type)
)`);
try {

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import { PassThrough } from 'stream';
import fs from 'fs';
// Sentinel markers must match agent-runner.ts
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
@@ -212,4 +213,41 @@ describe('agent-runner timeout behavior', () => {
expect(result.status).toBe('success');
expect(result.newSessionId).toBe('session-456');
});
it('materializes repo-managed Claude rules into the session CLAUDE.md', async () => {
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
const path = String(p);
return (
path.includes('dist/index.js') ||
path.endsWith('/prompts/claude-platform.md') ||
path.endsWith('/global/CLAUDE.md')
);
});
vi.mocked(fs.readFileSync).mockImplementation((p: fs.PathOrFileDescriptor) => {
const path = String(p);
if (path.endsWith('/prompts/claude-platform.md')) {
return 'Platform Claude Rules';
}
if (path.endsWith('/global/CLAUDE.md')) {
return 'Global Claude Memory';
}
return '';
});
const resultPromise = runAgentProcess(
testGroup,
testInput,
() => {},
undefined,
);
fakeProc.emit('close', 0);
await vi.advanceTimersByTimeAsync(10);
await resultPromise;
expect(fs.writeFileSync).toHaveBeenCalledWith(
'/tmp/nanoclaw-test-data/sessions/test-group/.claude/CLAUDE.md',
'Platform Claude Rules\n\n---\n\nGlobal Claude Memory\n',
);
});
});

View File

@@ -18,6 +18,11 @@ import {
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js';
import { readEnvFile } from './env.js';
import { isPairedRoomJid } from './db.js';
import {
readPairedRoomPrompt,
readPlatformPrompt,
} from './platform-prompts.js';
import { RegisteredGroup } from './types.js';
// Sentinel markers for robust output parsing (must match agent-runner)
@@ -39,6 +44,7 @@ export interface AgentInput {
export interface AgentOutput {
status: 'success' | 'error';
result: string | null;
phase?: 'progress' | 'final';
newSessionId?: string;
error?: string;
}
@@ -50,6 +56,7 @@ export interface AgentOutput {
function prepareGroupEnvironment(
group: RegisteredGroup,
isMain: boolean,
chatJid: string,
): { env: Record<string, string>; groupDir: string; runnerDir: string } {
const projectRoot = process.cwd();
const groupDir = resolveGroupFolderPath(group.folder);
@@ -115,6 +122,31 @@ function prepareGroupEnvironment(
// Global memory directory (for non-main groups)
const globalDir = path.join(GROUPS_DIR, 'global');
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
const isPairedRoom = isPairedRoomJid(chatJid);
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
const claudePairedRoomPrompt = isPairedRoom
? readPairedRoomPrompt('claude-code', projectRoot)
: undefined;
const globalClaudeMemory =
!isMain && fs.existsSync(globalClaudeMdPath)
? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
: undefined;
const sessionClaudeMd = [
claudePlatformPrompt,
claudePairedRoomPrompt,
globalClaudeMemory,
]
.filter((value): value is string => Boolean(value))
.join('\n\n---\n\n')
.trim();
const sessionClaudeMdPath = path.join(groupSessionsDir, 'CLAUDE.md');
if (sessionClaudeMd) {
fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
} else if (fs.existsSync(sessionClaudeMdPath)) {
fs.unlinkSync(sessionClaudeMdPath);
}
// Additional mount directories (validated)
const extraDirs: string[] = [];
@@ -215,13 +247,27 @@ function prepareGroupEnvironment(
const authSrc = path.join(hostCodexDir, 'auth.json');
const authDst = path.join(sessionCodexDir, 'auth.json');
if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst);
for (const file of ['config.toml', 'config.json', 'AGENTS.md']) {
for (const file of ['config.toml', 'config.json']) {
const src = path.join(hostCodexDir, file);
const dst = path.join(sessionCodexDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dst);
}
}
const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
const codexPlatformPrompt = readPlatformPrompt('codex', projectRoot);
const codexPairedRoomPrompt = isPairedRoom
? readPairedRoomPrompt('codex', projectRoot)
: undefined;
const sessionAgents = [codexPlatformPrompt, codexPairedRoomPrompt]
.filter((value): value is string => Boolean(value))
.join('\n\n---\n\n')
.trim();
if (sessionAgents) {
fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n');
} else if (fs.existsSync(sessionAgentsPath)) {
fs.unlinkSync(sessionAgentsPath);
}
// Sync skills into Codex session dir
// SSOT: ~/.claude/skills/ (shared with Claude Code) + runners/skills/
const codexSkillSources = [
@@ -324,6 +370,7 @@ export async function runAgentProcess(
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
group,
input.isMain,
input.chatJid,
);
if (input.runId) {
env.NANOCLAW_RUN_ID = input.runId;

View File

@@ -313,11 +313,55 @@ describe('DiscordChannel', () => {
expect.any(String),
expect.objectContaining({
content: 'I am a bot',
is_from_me: false,
is_bot_message: true,
}),
);
});
it('ignores self-authored bot messages', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
authorId: '999888777',
isBot: true,
content: 'I am this bot',
});
await triggerMessage(msg);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('identifies stored messages authored by the current bot', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
expect(
channel.isOwnMessage?.({
id: 'msg_bot',
chat_jid: 'dc:1234567890123456',
sender: '999888777',
sender_name: 'Andy',
content: 'bot message',
timestamp: '2024-01-01T00:00:00.000Z',
is_bot_message: true,
}),
).toBe(true);
expect(
channel.isOwnMessage?.({
id: 'msg_user',
chat_jid: 'dc:1234567890123456',
sender: '55512345',
sender_name: 'Alice',
content: 'user message',
timestamp: '2024-01-01T00:00:00.000Z',
}),
).toBe(false);
});
it('uses member displayName when available (server nickname)', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);

View File

@@ -19,6 +19,7 @@ import {
} from '../config.js';
import { readEnvFile } from '../env.js';
import { logger } from '../logger.js';
import { formatOutbound } from '../router.js';
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
@@ -158,6 +159,7 @@ import {
AgentType,
Channel,
ChannelMeta,
NewMessage,
OnChatMetadata,
OnInboundMessage,
RegisteredGroup,
@@ -446,6 +448,7 @@ export class DiscordChannel implements Channel {
for (const [name, id] of Object.entries(mentionMap)) {
cleaned = cleaned.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`);
}
cleaned = formatOutbound(cleaned);
// Discord has a 2000 character limit per message — split if needed
const MAX_LENGTH = 2000;
@@ -454,6 +457,11 @@ export class DiscordChannel implements Channel {
name: path.basename(f),
}));
if (!cleaned && files.length === 0) {
logger.debug({ jid }, 'Skipping empty Discord outbound message');
return;
}
if (cleaned.length <= MAX_LENGTH) {
await textChannel.send({
content: cleaned || undefined,
@@ -490,6 +498,10 @@ export class DiscordChannel implements Channel {
return groupType === this.agentTypeFilter;
}
isOwnMessage(msg: NewMessage): boolean {
return !!this.client?.user?.id && msg.sender === this.client.user.id;
}
async disconnect(): Promise<void> {
if (this.client) {
this.client.destroy();

View File

@@ -7,8 +7,10 @@ import {
deleteTask,
getAllChats,
getAllRegisteredGroups,
getRegisteredAgentTypesForJid,
getMessagesSince,
getNewMessages,
isPairedRoomJid,
getSession,
getTaskById,
setSession,
@@ -190,13 +192,12 @@ describe('getMessagesSince', () => {
'2024-01-01T00:00:02.000Z',
'Andy',
);
// Should exclude m1, m2 (before/at timestamp); m3 (bot) is now included
expect(msgs).toHaveLength(2);
expect(msgs[0].content).toBe('bot reply');
expect(msgs[1].content).toBe('third');
});
it('includes bot messages with is_bot_message flag', () => {
it('includes bot messages from other senders', () => {
const msgs = getMessagesSince(
'group@g.us',
'2024-01-01T00:00:00.000Z',
@@ -206,9 +207,8 @@ describe('getMessagesSince', () => {
expect(botMsgs).toHaveLength(1);
});
it('returns all messages including bot when sinceTimestamp is empty', () => {
it('returns all messages when sinceTimestamp is empty', () => {
const msgs = getMessagesSince('group@g.us', '', 'Andy');
// 3 user messages + 1 bot message
expect(msgs).toHaveLength(4);
});
@@ -279,7 +279,6 @@ describe('getNewMessages', () => {
'2024-01-01T00:00:00.000Z',
'Andy',
);
// Includes bot message, returns all 4 messages
expect(messages).toHaveLength(4);
expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z');
});
@@ -290,7 +289,6 @@ describe('getNewMessages', () => {
'2024-01-01T00:00:02.000Z',
'Andy',
);
// bot reply (00:00:03) + g1 msg2 (00:00:04) after ts
expect(messages).toHaveLength(2);
expect(messages[0].content).toBe('bot reply');
expect(messages[1].content).toBe('g1 msg2');
@@ -497,3 +495,41 @@ describe('registered group isMain', () => {
expect(group.isMain).toBeUndefined();
});
});
describe('paired room registration', () => {
it('detects when both Claude and Codex are registered on the same jid', () => {
setRegisteredGroup('dc:123', {
name: 'Paired Room Claude',
folder: 'paired-claude',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
setRegisteredGroup('dc:123', {
name: 'Paired Room Codex',
folder: 'paired-codex',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
});
expect(getRegisteredAgentTypesForJid('dc:123').sort()).toEqual([
'claude-code',
'codex',
]);
expect(isPairedRoomJid('dc:123')).toBe(true);
});
it('does not mark solo rooms as paired', () => {
setRegisteredGroup('dc:solo', {
name: 'Solo Claude Room',
folder: 'solo-claude',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
expect(getRegisteredAgentTypesForJid('dc:solo')).toEqual(['claude-code']);
expect(isPairedRoomJid('dc:solo')).toBe(false);
});
});

139
src/db.ts
View File

@@ -13,6 +13,7 @@ import { isValidGroupFolder } from './group-folder.js';
import { logger } from './logger.js';
import {
NewMessage,
AgentType,
RegisteredGroup,
ScheduledTask,
TaskRunLog,
@@ -82,13 +83,18 @@ function createSchema(database: Database.Database): void {
PRIMARY KEY (group_folder, agent_type)
);
CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
jid TEXT NOT NULL,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
folder TEXT NOT NULL,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
work_dir TEXT,
PRIMARY KEY (jid, agent_type),
UNIQUE (folder, agent_type)
);
`);
@@ -114,33 +120,80 @@ function createSchema(database: Database.Database): void {
/* column already exists */
}
// Add is_main column if it doesn't exist (migration for existing DBs)
try {
database.exec(
`ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`,
// Migrate registered_groups to composite keys so Claude/Codex can share a jid/folder.
const registeredGroupsSql = (
database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`,
)
.get() as { sql?: string } | undefined
)?.sql;
if (
registeredGroupsSql &&
!registeredGroupsSql.includes('PRIMARY KEY (jid, agent_type)')
) {
const registeredGroupCols = database
.prepare('PRAGMA table_info(registered_groups)')
.all() as Array<{ name: string }>;
const hasIsMain = registeredGroupCols.some((col) => col.name === 'is_main');
const hasAgentType = registeredGroupCols.some(
(col) => col.name === 'agent_type',
);
const hasWorkDir = registeredGroupCols.some((col) => col.name === 'work_dir');
database.exec(`
CREATE TABLE registered_groups_new (
jid TEXT NOT NULL,
name TEXT NOT NULL,
folder TEXT NOT NULL,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
work_dir TEXT,
PRIMARY KEY (jid, agent_type),
UNIQUE (folder, agent_type)
);
`);
database.exec(`
INSERT INTO registered_groups_new (
jid,
name,
folder,
trigger_pattern,
added_at,
container_config,
requires_trigger,
is_main,
agent_type,
work_dir
)
SELECT
jid,
name,
folder,
trigger_pattern,
added_at,
container_config,
requires_trigger,
${hasIsMain ? 'COALESCE(is_main, 0)' : "CASE WHEN folder = 'main' THEN 1 ELSE 0 END"},
${hasAgentType ? "COALESCE(agent_type, 'claude-code')" : "'claude-code'"},
${hasWorkDir ? 'work_dir' : 'NULL'}
FROM registered_groups;
`);
database.exec(`
DROP TABLE registered_groups;
ALTER TABLE registered_groups_new RENAME TO registered_groups;
`);
} else {
// Backfill: existing rows with folder = 'main' are the main group
database.exec(
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`,
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main' AND COALESCE(is_main, 0) = 0`,
);
} catch {
/* column already exists */
}
// Add agent_type column if it doesn't exist (migration for Codex support)
try {
database.exec(
`ALTER TABLE registered_groups ADD COLUMN agent_type TEXT DEFAULT 'claude-code'`,
);
} catch {
/* column already exists */
}
// Add work_dir column if it doesn't exist (migration for per-group working directory)
try {
database.exec(`ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`);
} catch {
/* column already exists */
}
// Migrate sessions table to composite PK (group_folder, agent_type)
@@ -300,9 +353,9 @@ export function getNewMessages(
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
const placeholders = jids.map(() => '?').join(',');
// Filter bot messages using both the is_bot_message flag AND the content
// prefix as a backstop for messages written before the migration ran.
// Subquery takes the N most recent, outer query re-sorts chronologically.
// Filter legacy prefixed outbound messages as a backstop for rows written
// before explicit bot flags existed. Self-message filtering is channel-specific
// and happens in message-runtime so cross-bot collaboration still works.
const sql = `
SELECT * FROM (
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
@@ -333,9 +386,9 @@ export function getMessagesSince(
botPrefix: string,
limit: number = 200,
): NewMessage[] {
// Filter bot messages using both the is_bot_message flag AND the content
// prefix as a backstop for messages written before the migration ran.
// Subquery takes the N most recent, outer query re-sorts chronologically.
// Filter legacy prefixed outbound messages as a backstop for rows written
// before explicit bot flags existed. Self-message filtering is channel-specific
// and happens in message-runtime so cross-bot collaboration still works.
const sql = `
SELECT * FROM (
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
@@ -681,6 +734,28 @@ export function getAllRegisteredGroups(
return result;
}
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
if (!db) return [];
const rows = db
.prepare('SELECT agent_type FROM registered_groups WHERE jid = ?')
.all(jid) as Array<{ agent_type: string | null }>;
const types = new Set<AgentType>();
for (const row of rows) {
const agentType = row.agent_type as AgentType | null;
if (agentType === 'claude-code' || agentType === 'codex') {
types.add(agentType);
}
}
return [...types];
}
export function isPairedRoomJid(jid: string): boolean {
const types = getRegisteredAgentTypesForJid(jid);
return types.includes('claude-code') && types.includes('codex');
}
// --- JSON migration ---
function migrateJsonState(): void {

View File

@@ -166,6 +166,29 @@ describe('GroupQueue', () => {
expect(callCount).toBe(3);
});
it('does not bypass retry backoff when new messages arrive', async () => {
let callCount = 0;
const processMessages = vi.fn(async () => {
callCount++;
return false;
});
queue.setProcessMessagesFn(processMessages);
queue.enqueueMessageCheck('group1@g.us');
await vi.advanceTimersByTimeAsync(10);
expect(callCount).toBe(1);
queue.enqueueMessageCheck('group1@g.us');
await vi.advanceTimersByTimeAsync(1000);
expect(callCount).toBe(1);
await vi.advanceTimersByTimeAsync(4000);
await vi.advanceTimersByTimeAsync(10);
expect(callCount).toBe(2);
});
// --- Shutdown prevents new enqueues ---
it('prevents new enqueues after shutdown', async () => {

View File

@@ -32,6 +32,8 @@ interface GroupState {
processName: string | null;
groupFolder: string | null;
retryCount: number;
retryTimer: ReturnType<typeof setTimeout> | null;
retryScheduledAt: number | null;
startedAt: number | null;
}
@@ -68,6 +70,8 @@ export class GroupQueue {
processName: null,
groupFolder: null,
retryCount: 0,
retryTimer: null,
retryScheduledAt: null,
startedAt: null,
};
this.groups.set(groupJid, state);
@@ -101,6 +105,22 @@ export class GroupQueue {
return;
}
if (
state.retryScheduledAt !== null &&
Date.now() < state.retryScheduledAt
) {
state.pendingMessages = true;
logger.debug(
{
groupJid,
retryCount: state.retryCount,
retryScheduledAt: state.retryScheduledAt,
},
'Retry backoff active, message queued until retry window opens',
);
return;
}
if (this.activeCount >= MAX_CONCURRENT_AGENTS) {
state.pendingMessages = true;
if (!this.waitingGroups.includes(groupJid)) {
@@ -335,6 +355,7 @@ export class GroupQueue {
});
if (success) {
state.retryCount = 0;
state.retryScheduledAt = null;
} else {
outcome = 'retry_scheduled';
this.scheduleRetry(groupJid, state, runId);
@@ -415,6 +436,11 @@ export class GroupQueue {
): void {
state.retryCount++;
if (state.retryCount > MAX_RETRIES) {
if (state.retryTimer) {
clearTimeout(state.retryTimer);
state.retryTimer = null;
}
state.retryScheduledAt = null;
logger.error(
{ groupJid, runId, retryCount: state.retryCount },
'Max retries exceeded, dropping messages (will retry on next incoming message)',
@@ -424,11 +450,17 @@ export class GroupQueue {
}
const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1);
state.retryScheduledAt = Date.now() + delayMs;
logger.info(
{ groupJid, runId, retryCount: state.retryCount, delayMs },
'Scheduling retry with backoff',
);
setTimeout(() => {
if (state.retryTimer) {
clearTimeout(state.retryTimer);
}
state.retryTimer = setTimeout(() => {
state.retryTimer = null;
state.retryScheduledAt = null;
if (!this.shuttingDown) {
this.enqueueMessageCheck(groupJid);
}

461
src/message-runtime.test.ts Normal file
View File

@@ -0,0 +1,461 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./agent-runner.js', () => ({
runAgentProcess: vi.fn(),
writeGroupsSnapshot: vi.fn(),
writeTasksSnapshot: vi.fn(),
}));
vi.mock('./config.js', () => ({
isSessionCommandSenderAllowed: vi.fn(() => false),
}));
vi.mock('./db.js', () => ({
getAllChats: vi.fn(() => []),
getAllTasks: vi.fn(() => []),
getLastHumanMessageTimestamp: vi.fn(() => null),
getMessagesSince: vi.fn(),
getNewMessages: vi.fn(() => ({ messages: [], newTimestamp: '' })),
}));
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('./sender-allowlist.js', () => ({
isTriggerAllowed: vi.fn(() => true),
loadSenderAllowlist: vi.fn(() => ({})),
}));
vi.mock('./session-commands.js', () => ({
extractSessionCommand: vi.fn(() => null),
handleSessionCommand: vi.fn(async () => ({ handled: false })),
isSessionCommandAllowed: vi.fn(() => true),
isSessionCommandControlMessage: vi.fn(() => false),
}));
import * as agentRunner from './agent-runner.js';
import * as db from './db.js';
import { createMessageRuntime } from './message-runtime.js';
import type { Channel, RegisteredGroup } from './types.js';
function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup {
return {
name: 'Test Group',
folder: `test-${agentType}`,
trigger: '@Andy',
added_at: new Date().toISOString(),
requiresTrigger: false,
agentType,
};
}
function makeChannel(chatJid: string): Channel {
return {
name: 'discord',
connect: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
ownsJid: vi.fn((jid: string) => jid === chatJid),
disconnect: vi.fn().mockResolvedValue(undefined),
setTyping: vi.fn().mockResolvedValue(undefined),
};
}
describe('createMessageRuntime', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('clears Claude sessions and closes stdin immediately on poisoned output', async () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');
const channel = makeChannel(chatJid);
const closeStdin = vi.fn();
const notifyIdle = vi.fn();
const persistSession = vi.fn();
const clearSession = vi.fn();
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-18T09:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result:
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
newSessionId: 'session-123',
});
return {
status: 'success',
result: null,
newSessionId: 'session-123',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin,
notifyIdle,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession,
clearSession,
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-1',
reason: 'messages',
});
expect(result).toBe(true);
expect(persistSession).toHaveBeenCalledWith(group.folder, 'session-123');
expect(clearSession).toHaveBeenCalledWith(group.folder);
expect(closeStdin).toHaveBeenCalledWith(chatJid, {
runId: 'run-1',
reason: 'poisoned-session',
});
expect(notifyIdle).not.toHaveBeenCalled();
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
);
expect(lastAgentTimestamps[chatJid]).toBe('2026-03-18T09:00:00.000Z');
expect(saveState).toHaveBeenCalled();
});
it('does not apply the poisoned-session handling to Codex groups', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const closeStdin = vi.fn();
const notifyIdle = vi.fn();
const clearSession = vi.fn();
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-18T09:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result:
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
newSessionId: 'session-456',
});
return {
status: 'success',
result: null,
newSessionId: 'session-456',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin,
notifyIdle,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession,
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-2',
reason: 'messages',
});
expect(result).toBe(true);
expect(clearSession).not.toHaveBeenCalled();
expect(closeStdin).not.toHaveBeenCalled();
expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-2');
});
it('streams Codex progress as a normal room message and only marks idle after the turn settles', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const notifyIdle = vi.fn();
const persistSession = vi.fn();
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'progress',
result: 'CI 상태 확인 중입니다.',
newSessionId: 'session-progress',
});
expect(notifyIdle).not.toHaveBeenCalled();
await onOutput?.({
status: 'success',
result: null,
newSessionId: 'session-progress',
});
return {
status: 'success',
result: null,
newSessionId: 'session-progress',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession,
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-progress',
reason: 'messages',
});
expect(result).toBe(true);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'CI 상태 확인 중입니다.',
);
expect(notifyIdle).toHaveBeenCalledTimes(1);
expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-progress');
expect(persistSession).toHaveBeenCalledWith(
group.folder,
'session-progress',
);
});
it('keeps progress separate from the final Codex answer', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const notifyIdle = vi.fn();
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'progress',
result: '테스트를 돌리는 중입니다.',
newSessionId: 'session-final',
});
await onOutput?.({
status: 'success',
phase: 'final',
result: '테스트가 끝났습니다.',
newSessionId: 'session-final',
});
return {
status: 'success',
result: null,
newSessionId: 'session-final',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-final',
reason: 'messages',
});
expect(result).toBe(true);
expect(channel.sendMessage).toHaveBeenNthCalledWith(
1,
chatJid,
'테스트를 돌리는 중입니다.',
);
expect(channel.sendMessage).toHaveBeenNthCalledWith(
2,
chatJid,
'테스트가 끝났습니다.',
);
expect(notifyIdle).toHaveBeenCalledTimes(1);
expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-final');
});
it('does not roll back when a streamed progress message was already posted before an error', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'progress',
result: '중간 진행상황입니다.',
newSessionId: 'session-error',
});
await onOutput?.({
status: 'error',
result: null,
newSessionId: 'session-error',
error: 'temporary failure',
});
return {
status: 'error',
result: null,
newSessionId: 'session-error',
error: 'temporary failure',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-progress-error',
reason: 'messages',
});
expect(result).toBe(true);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'중간 진행상황입니다.',
);
expect(lastAgentTimestamps[chatJid]).toBe('2026-03-19T00:00:00.000Z');
expect(saveState).toHaveBeenCalled();
});
});

View File

@@ -14,13 +14,15 @@ import {
} from './db.js';
import { isSessionCommandSenderAllowed } from './config.js';
import { GroupQueue, GroupRunContext } from './group-queue.js';
import { findChannel, formatMessages } from './router.js';
import { findChannel, formatMessages, formatOutbound } from './router.js';
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
import {
extractSessionCommand,
handleSessionCommand,
isSessionCommandAllowed,
isSessionCommandControlMessage,
} from './session-commands.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
@@ -68,6 +70,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
} {
let messageLoopRunning = false;
const filterOwnChannelMessages = (messages: NewMessage[]): NewMessage[] =>
messages.filter((msg) => {
const channel = findChannel(deps.channels, msg.chat_jid);
if (channel?.isOwnMessage?.(msg)) {
return false;
}
if (msg.is_bot_message && isSessionCommandControlMessage(msg.content)) {
return false;
}
return true;
});
const getCurrentAvailableGroups = (): AvailableGroup[] =>
getAvailableGroups(deps.getRegisteredGroups());
@@ -79,6 +93,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
onOutput?: (output: AgentOutput) => Promise<void>,
): Promise<'success' | 'error'> => {
const isMain = group.isMain === true;
const isClaudeCodeAgent = (group.agentType || 'claude-code') === 'claude-code';
const sessions = deps.getSessions();
const sessionId = sessions[group.folder];
@@ -99,11 +114,19 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
writeGroupsSnapshot(group.folder, isMain, getCurrentAvailableGroups());
let resetSessionRequested = false;
const wrappedOnOutput = onOutput
? async (output: AgentOutput) => {
if (output.newSessionId) {
deps.persistSession(group.folder, output.newSessionId);
}
if (
isClaudeCodeAgent &&
shouldResetSessionOnAgentFailure(output)
) {
resetSessionRequested = true;
}
await onOutput(output);
}
: undefined;
@@ -129,6 +152,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
deps.persistSession(group.folder, output.newSessionId);
}
if (
isClaudeCodeAgent &&
(resetSessionRequested ||
shouldResetSessionOnAgentFailure(output))
) {
deps.clearSession(group.folder);
logger.warn(
{ group: group.name, chatJid, runId },
'Cleared poisoned agent session after unrecoverable error',
);
}
if (output.status === 'error') {
logger.error(
{ group: group.name, chatJid, runId, error: output.error },
@@ -171,10 +206,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const isMainGroup = group.isMain === true;
const lastAgentTimestamps = deps.getLastAgentTimestamps();
const sinceTimestamp = lastAgentTimestamps[chatJid] || '';
const missedMessages = getMessagesSince(
chatJid,
sinceTimestamp,
deps.assistantName,
const missedMessages = filterOwnChannelMessages(
getMessagesSince(
chatJid,
sinceTimestamp,
deps.assistantName,
),
);
if (missedMessages.length === 0) {
@@ -280,6 +317,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
);
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let latestProgressText: string | null = null;
const resetIdleTimer = () => {
if (idleTimer) clearTimeout(idleTimer);
@@ -296,7 +334,20 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
};
let hadError = false;
let outputSentToUser = false;
let finalOutputSentToUser = false;
let progressOutputSentToUser = false;
let poisonedSessionDetected = false;
const isClaudeCodeAgent = (group.agentType || 'claude-code') === 'claude-code';
const sendProgressMessage = async (text: string) => {
if (!text || text === latestProgressText) {
return;
}
latestProgressText = text;
await channel.sendMessage(chatJid, text);
progressOutputSentToUser = true;
};
await channel.setTyping?.(chatJid, true);
@@ -306,14 +357,30 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chatJid,
runId,
async (result) => {
if (
isClaudeCodeAgent &&
shouldResetSessionOnAgentFailure(result) &&
!poisonedSessionDetected
) {
poisonedSessionDetected = true;
hadError = true;
deps.clearSession(group.folder);
deps.queue.closeStdin(chatJid, {
runId,
reason: 'poisoned-session',
});
logger.warn(
{ chatJid, group: group.name, groupFolder: group.folder, runId },
'Detected poisoned Claude session from streamed output, forcing close',
);
}
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();
const text = formatOutbound(raw);
logger.info(
{
chatJid,
@@ -324,16 +391,26 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
},
`Agent output: ${raw.slice(0, 200)}`,
);
if (result.phase === 'progress') {
if (text) {
await sendProgressMessage(text);
}
return;
}
if (text) {
await channel.sendMessage(chatJid, text);
outputSentToUser = true;
finalOutputSentToUser = true;
}
}
await channel.setTyping?.(chatJid, false);
resetIdleTimer();
if (result.status === 'success') {
if (!poisonedSessionDetected) {
resetIdleTimer();
}
if (result.status === 'success' && !poisonedSessionDetected) {
deps.queue.notifyIdle(chatJid, runId);
}
@@ -352,10 +429,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
if (idleTimer) clearTimeout(idleTimer);
if (hadError) {
if (outputSentToUser) {
if (finalOutputSentToUser || progressOutputSentToUser) {
logger.warn(
{ chatJid, group: group.name, groupFolder: group.folder, runId },
'Agent error after output was sent, skipping cursor rollback to prevent duplicates',
'Agent error after conversational output was sent, skipping cursor rollback to prevent duplicates',
);
return true;
}
@@ -374,7 +451,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
group: group.name,
groupFolder: group.folder,
runId,
outputSentToUser,
outputSentToUser: finalOutputSentToUser,
progressOutputSentToUser,
},
'Queued run completed successfully',
);
@@ -395,18 +473,23 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
try {
const registeredGroups = deps.getRegisteredGroups();
const jids = Object.keys(registeredGroups);
const { messages, newTimestamp } = getNewMessages(
const { messages: rawMessages, newTimestamp } = getNewMessages(
jids,
deps.getLastTimestamp(),
deps.assistantName,
);
const messages = filterOwnChannelMessages(rawMessages);
if (messages.length > 0) {
if (rawMessages.length > 0) {
logger.info({ count: messages.length }, 'New messages');
deps.setLastTimestamp(newTimestamp);
deps.saveState();
if (messages.length === 0) {
continue;
}
const messagesByGroup = new Map<string, NewMessage[]>();
for (const msg of messages) {
const existing = messagesByGroup.get(msg.chat_jid);
@@ -484,10 +567,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}
const lastAgentTimestamps = deps.getLastAgentTimestamps();
const allPending = getMessagesSince(
chatJid,
lastAgentTimestamps[chatJid] || '',
deps.assistantName,
const allPending = filterOwnChannelMessages(
getMessagesSince(
chatJid,
lastAgentTimestamps[chatJid] || '',
deps.assistantName,
),
);
const messagesToSend =
allPending.length > 0 ? allPending : groupMessages;
@@ -526,10 +611,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const lastAgentTimestamps = deps.getLastAgentTimestamps();
for (const [chatJid, group] of Object.entries(registeredGroups)) {
const sinceTimestamp = lastAgentTimestamps[chatJid] || '';
const pending = getMessagesSince(
chatJid,
sinceTimestamp,
deps.assistantName,
const pending = filterOwnChannelMessages(
getMessagesSince(
chatJid,
sinceTimestamp,
deps.assistantName,
),
);
if (pending.length > 0) {
logger.info(

View File

@@ -0,0 +1,58 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
getPairedRoomPromptPath,
getPlatformPromptPath,
readPairedRoomPrompt,
readPlatformPrompt,
} from './platform-prompts.js';
describe('platform-prompts', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-prompts-'));
vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('returns undefined when the prompt file is missing', () => {
expect(readPlatformPrompt('claude-code')).toBeUndefined();
});
it('reads and trims provider-specific prompt files', () => {
const promptsDir = path.join(tempDir, 'prompts');
fs.mkdirSync(promptsDir, { recursive: true });
fs.writeFileSync(
path.join(promptsDir, 'codex-platform.md'),
'\nCodex platform prompt\n',
);
expect(getPlatformPromptPath('codex')).toBe(
path.join(promptsDir, 'codex-platform.md'),
);
expect(readPlatformPrompt('codex')).toBe('Codex platform prompt');
});
it('reads and trims paired-room prompt files', () => {
const promptsDir = path.join(tempDir, 'prompts');
fs.mkdirSync(promptsDir, { recursive: true });
fs.writeFileSync(
path.join(promptsDir, 'claude-paired-room.md'),
'\nClaude paired prompt\n',
);
expect(getPairedRoomPromptPath('claude-code')).toBe(
path.join(promptsDir, 'claude-paired-room.md'),
);
expect(readPairedRoomPrompt('claude-code')).toBe('Claude paired prompt');
});
});

60
src/platform-prompts.ts Normal file
View File

@@ -0,0 +1,60 @@
import fs from 'fs';
import path from 'path';
import type { AgentType } from './types.js';
const PLATFORM_PROMPT_FILES: Record<AgentType, string> = {
'claude-code': 'claude-platform.md',
codex: 'codex-platform.md',
};
const PAIRED_ROOM_PROMPT_FILES: Record<AgentType, string> = {
'claude-code': 'claude-paired-room.md',
codex: 'codex-paired-room.md',
};
export function getPlatformPromptsDir(projectRoot = process.cwd()): string {
return path.join(projectRoot, 'prompts');
}
export function getPlatformPromptPath(
agentType: AgentType,
projectRoot = process.cwd(),
): string {
return path.join(
getPlatformPromptsDir(projectRoot),
PLATFORM_PROMPT_FILES[agentType],
);
}
export function readPlatformPrompt(
agentType: AgentType,
projectRoot = process.cwd(),
): string | undefined {
const promptPath = getPlatformPromptPath(agentType, projectRoot);
if (!fs.existsSync(promptPath)) return undefined;
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
return prompt || undefined;
}
export function getPairedRoomPromptPath(
agentType: AgentType,
projectRoot = process.cwd(),
): string {
return path.join(
getPlatformPromptsDir(projectRoot),
PAIRED_ROOM_PROMPT_FILES[agentType],
);
}
export function readPairedRoomPrompt(
agentType: AgentType,
projectRoot = process.cwd(),
): string | undefined {
const promptPath = getPairedRoomPromptPath(agentType, projectRoot);
if (!fs.existsSync(promptPath)) return undefined;
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
return prompt || undefined;
}

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
import {
extractSessionCommand,
handleSessionCommand,
isSessionCommandControlMessage,
isSessionCommandAllowed,
} from './session-commands.js';
import type { NewMessage } from './types.js';
@@ -67,6 +68,28 @@ describe('isSessionCommandAllowed', () => {
});
});
describe('isSessionCommandControlMessage', () => {
it('matches clear confirmation output', () => {
expect(
isSessionCommandControlMessage(
'Current session cleared. The next message will start a new conversation.',
),
).toBe(true);
});
it('matches admin denial output', () => {
expect(
isSessionCommandControlMessage('Session commands require admin access.'),
).toBe(true);
});
it('does not match regular bot conversation', () => {
expect(isSessionCommandControlMessage('좋네요. 필요해지면 바로 말씀드리겠습니다.')).toBe(
false,
);
});
});
function makeMsg(
content: string,
overrides: Partial<NewMessage> = {},

View File

@@ -1,5 +1,14 @@
import type { NewMessage } from './types.js';
import { logger } from './logger.js';
import { formatOutbound } from './router.js';
const SESSION_COMMAND_CONTROL_PATTERNS = [
/^Current session cleared\. The next message will start a new conversation\.$/,
/^Session commands require admin access\.$/,
/^Failed to process messages before \/compact\. Try again\.$/,
/^\/compact failed\. The session is unchanged\.$/,
/^Conversation compacted\.$/,
];
/**
* Extract a session slash command from a message, stripping the trigger prefix if present.
@@ -28,10 +37,18 @@ export function isSessionCommandAllowed(
return isMainGroup || isFromMe || isAdminSender;
}
export function isSessionCommandControlMessage(content: string): boolean {
const trimmed = content.trim();
return SESSION_COMMAND_CONTROL_PATTERNS.some((pattern) =>
pattern.test(trimmed),
);
}
/** Minimal agent result interface — matches the subset of AgentOutput used here. */
export interface AgentResult {
status: 'success' | 'error';
result?: string | object | null;
phase?: 'progress' | 'final';
}
/** Dependencies injected by the orchestrator. */
@@ -54,7 +71,7 @@ export interface SessionCommandDeps {
function resultToText(result: string | object | null | undefined): string {
if (!result) return '';
const raw = typeof result === 'string' ? result : JSON.stringify(result);
return raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
return formatOutbound(raw);
}
/**
@@ -133,6 +150,7 @@ export async function handleSessionCommand(opts: {
const preResult = await deps.runAgent(prePrompt, async (result) => {
if (result.status === 'error') hadPreError = true;
if (result.phase === 'progress') return;
const text = resultToText(result.result);
if (text) {
await deps.sendMessage(text);
@@ -169,6 +187,7 @@ export async function handleSessionCommand(opts: {
let hadCmdError = false;
const cmdOutput = await deps.runAgent(command, async (result) => {
if (result.status === 'error') hadCmdError = true;
if (result.phase === 'progress') return;
const text = resultToText(result.result);
if (text) await deps.sendMessage(text);
});

View File

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

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

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

View File

@@ -183,6 +183,9 @@ async function runTask(
(proc, processName) =>
deps.onProcess(task.chat_jid, proc, processName, task.group_folder),
async (streamedOutput: AgentOutput) => {
if (streamedOutput.phase === 'progress') {
return;
}
if (streamedOutput.result) {
result = streamedOutput.result;
// Forward result to user (sendMessage handles formatting)

View File

@@ -78,6 +78,8 @@ export interface Channel {
sendMessage(jid: string, text: string): Promise<void>;
isConnected(): boolean;
ownsJid(jid: string): boolean;
// Optional: whether a stored inbound message was authored by this channel's own bot/user.
isOwnMessage?(msg: NewMessage): boolean;
disconnect(): Promise<void>;
// Optional: typing indicator. Channels that support it implement it.
setTyping?(jid: string, isTyping: boolean): Promise<void>;

View File

@@ -2,6 +2,10 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts', 'setup/**/*.test.ts'],
include: [
'src/**/*.test.ts',
'setup/**/*.test.ts',
'runners/**/test/**/*.test.ts',
],
},
});