From 26783e520ee6ba4833a01ffb4ede2a978efedfe8 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sat, 14 Mar 2026 22:42:47 +0900 Subject: [PATCH 01/17] refactor: switch codex runner from app-server to SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw JSON-RPC app-server protocol with @openai/codex-sdk. The SDK wraps `codex exec` which ensures complete task execution per turn, fixing the issue where app-server mode ended turns prematurely (agent saying "하겠습니다" without doing the work). Also fix AGENTS.md copy in agent-runner (was copying instructions.md which Codex CLI doesn't read). --- container/codex-runner/package-lock.json | 137 ++++++ container/codex-runner/package.json | 4 +- container/codex-runner/src/index.ts | 543 +++++------------------ package-lock.json | 5 + src/agent-runner.ts | 2 +- 5 files changed, 252 insertions(+), 439 deletions(-) diff --git a/container/codex-runner/package-lock.json b/container/codex-runner/package-lock.json index 3c1b3f4..f070aa1 100644 --- a/container/codex-runner/package-lock.json +++ b/container/codex-runner/package-lock.json @@ -7,11 +7,148 @@ "": { "name": "nanoclaw-codex-runner", "version": "1.0.0", + "dependencies": { + "@openai/codex-sdk": "^0.114.0" + }, "devDependencies": { "@types/node": "^22.10.7", "typescript": "^5.7.3" } }, + "node_modules/@openai/codex": { + "version": "0.114.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0.tgz", + "integrity": "sha512-HMo8LRR6CtfKkaa28xvFK6eOarmBFTDfsrS9GJtEoaspGGemFok494CpafDspiTZaHZZGHfSUe5SWTUxYq7OaA==", + "license": "Apache-2.0", + "bin": { + "codex": "bin/codex.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.114.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.114.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.114.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.114.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.114.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.114.0-win32-x64" + } + }, + "node_modules/@openai/codex-darwin-arm64": { + "name": "@openai/codex", + "version": "0.114.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-darwin-arm64.tgz", + "integrity": "sha512-c9dlgo9O+66alr3s3G36fKE3KaO2+xrLZ/QcU3oujDruV86pqgVSEK2njHi+WIyyOa6m2AyWxdaHLEbKxPCNRg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-darwin-x64": { + "name": "@openai/codex", + "version": "0.114.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-darwin-x64.tgz", + "integrity": "sha512-oPAPeZSaJZ1AQneFPSJu44uqbZr63Bxut9FOTWZwuSqYx4YEees7i0HJmJcqQc0XnCbYtHJdqo/i07Uv374NLw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-arm64": { + "name": "@openai/codex", + "version": "0.114.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-linux-arm64.tgz", + "integrity": "sha512-oUFZGZgMiEqmo85C+dfhckpVApH25alLWfwBgfKr2IRgdx/tovy8vN101f6kj01E6nDDe4LfJdNSZGtZht4fRA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-x64": { + "name": "@openai/codex", + "version": "0.114.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-linux-x64.tgz", + "integrity": "sha512-MF6RhxfBoccccd5CYII2C7TL2TvYzombBQhanDZfQAajr6n7IuoazCmoHDlrFHS4AcSw9bYA4MRlrwEjbEjgsw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-sdk": { + "version": "0.114.0", + "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.114.0.tgz", + "integrity": "sha512-28tV+pvoQhhoRADBCtlg24fR6LDTyeUA6C65NBTvYFnDoQVPJcHcAwRrbuWmUMcXdLrXwY2bmiMxyfXlUWQzgw==", + "license": "Apache-2.0", + "dependencies": { + "@openai/codex": "0.114.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@openai/codex-win32-arm64": { + "name": "@openai/codex", + "version": "0.114.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-win32-arm64.tgz", + "integrity": "sha512-CnRMHopj3en9aqQ2UaDW7EgpEGkxHdZVLLRq2cOy5D0HyuzF6Qb6595ADoFVJHEmPeN5Iz/KUbiGs5GLDjUwOA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-x64": { + "name": "@openai/codex", + "version": "0.114.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-win32-x64.tgz", + "integrity": "sha512-ztRsH5Z+gPVZ5ZInx6HzXsTFFkuEdjk3Ay0UGVOhRRCWvevXQi/SL4eU8hwysCYrs8K/WRq3mo4c95w9zQ2wLw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", diff --git a/container/codex-runner/package.json b/container/codex-runner/package.json index 2a623e2..51cd5c2 100644 --- a/container/codex-runner/package.json +++ b/container/codex-runner/package.json @@ -8,7 +8,9 @@ "build": "tsc", "start": "node dist/index.js" }, - "dependencies": {}, + "dependencies": { + "@openai/codex-sdk": "^0.114.0" + }, "devDependencies": { "@types/node": "^22.10.7", "typescript": "^5.7.3" diff --git a/container/codex-runner/src/index.ts b/container/codex-runner/src/index.ts index 67e4634..74c7e8f 100644 --- a/container/codex-runner/src/index.ts +++ b/container/codex-runner/src/index.ts @@ -1,9 +1,9 @@ /** - * NanoClaw Codex Runner (app-server mode) + * NanoClaw Codex Runner (SDK mode) * - * Spawns a single `codex app-server` process and communicates via JSON-RPC - * over stdio. Supports streaming responses, session persistence (threadId), - * and mid-turn message injection via turn/steer. + * 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. * * Input protocol: * Stdin: Full ContainerInput JSON (read until EOF) @@ -14,10 +14,9 @@ * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. */ -import { spawn, ChildProcess } from 'child_process'; +import { Codex, type Thread, type UserInput, type ThreadOptions } from '@openai/codex-sdk'; import fs from 'fs'; import path from 'path'; -import readline from 'readline'; // ── Types ────────────────────────────────────────────────────────── @@ -39,20 +38,6 @@ interface ContainerOutput { error?: string; } -interface JsonRpcRequest { - method: string; - id?: number; - params?: Record; -} - -interface JsonRpcResponse { - id?: number; - method?: string; - result?: Record; - error?: { code: number; message: string }; - params?: Record; -} - // ── Constants ────────────────────────────────────────────────────── const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group'; @@ -146,412 +131,74 @@ function waitForIpcMessage(): Promise { }); } -// ── App-Server Client ────────────────────────────────────────────── - -class CodexAppServer { - private proc: ChildProcess; - private rl: readline.Interface; - private nextId = 1; - private pending = new Map void; - reject: (err: Error) => void; - }>(); - private notificationHandler: ((msg: JsonRpcResponse) => void) | null = null; - private serverRequestHandler: ((msg: JsonRpcResponse) => void) | null = null; - - constructor() { - this.proc = spawn('codex', ['app-server'], { - stdio: ['pipe', 'pipe', 'pipe'], - cwd: EFFECTIVE_CWD, - env: { ...process.env }, - }); - - this.rl = readline.createInterface({ input: this.proc.stdout! }); - - this.rl.on('line', (line: string) => { - if (!line.trim()) return; - try { - const msg: JsonRpcResponse = JSON.parse(line); - this.handleMessage(msg); - } catch { - // Non-JSON output, ignore - } - }); - - this.proc.stderr?.on('data', (data: Buffer) => { - for (const line of data.toString().trim().split('\n')) { - if (line) log(line); - } - }); - - this.proc.on('error', (err: Error) => { - log(`App-server spawn error: ${err.message}`); - // Reject all pending requests - for (const [, { reject }] of this.pending) { - reject(err); - } - this.pending.clear(); - }); - - this.proc.on('close', (code: number | null) => { - log(`App-server exited with code ${code}`); - const err = new Error(`App-server exited with code ${code}`); - for (const [, { reject }] of this.pending) { - reject(err); - } - this.pending.clear(); - }); - } - - private handleMessage(msg: JsonRpcResponse): void { - // Response to a request we made - if (msg.id !== undefined && this.pending.has(msg.id)) { - const handler = this.pending.get(msg.id)!; - this.pending.delete(msg.id); - handler.resolve(msg); - return; - } - - // Server-initiated request (has id + method) — needs a response - if (msg.id !== undefined && msg.method) { - this.serverRequestHandler?.(msg); - return; - } - - // Notification (has method, no id) - if (msg.method) { - this.notificationHandler?.(msg); - } - } - - setNotificationHandler(handler: ((msg: JsonRpcResponse) => void) | null): void { - this.notificationHandler = handler; - } - - setServerRequestHandler(handler: ((msg: JsonRpcResponse) => void) | null): void { - this.serverRequestHandler = handler; - } - - send(msg: JsonRpcRequest): void { - this.proc.stdin!.write(JSON.stringify(msg) + '\n'); - } - - async request(method: string, params: Record = {}, timeoutMs = 30_000): Promise { - const id = this.nextId++; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`)); - }, timeoutMs); - - this.pending.set(id, { - resolve: (resp) => { - clearTimeout(timer); - resolve(resp); - }, - reject: (err) => { - clearTimeout(timer); - reject(err); - }, - }); - - this.send({ method, id, params }); - }); - } - - respond(id: number, result: Record): void { - this.proc.stdin!.write(JSON.stringify({ id, result }) + '\n'); - } - - async initialize(): Promise { - const resp = await this.request('initialize', { - clientInfo: { name: 'nanoclaw', title: 'NanoClaw Codex', version: '1.0' }, - capabilities: { experimentalApi: false }, - }, 15_000); - - if (resp.error) { - throw new Error(`Initialize failed: ${resp.error.message}`); - } - - // Send initialized notification - this.send({ method: 'initialized' }); - log('App-server initialized'); - } - - async startThread(): Promise { - const params: Record = { - cwd: EFFECTIVE_CWD, - approvalPolicy: 'never', - sandbox: 'danger-full-access', - experimentalRawEvents: false, - persistExtendedHistory: false, - }; - if (CODEX_MODEL) params.model = CODEX_MODEL; - - const resp = await this.request('thread/start', params, 30_000); - if (resp.error) { - throw new Error(`thread/start failed: ${resp.error.message}`); - } - - const thread = resp.result?.thread as Record | undefined; - const threadId = thread?.id as string; - log(`Thread started: ${threadId}`); - return threadId; - } - - async resumeThread(threadId: string): Promise { - const params: Record = { - threadId, - cwd: EFFECTIVE_CWD, - approvalPolicy: 'never', - sandbox: 'danger-full-access', - persistExtendedHistory: false, - }; - if (CODEX_MODEL) params.model = CODEX_MODEL; - - const resp = await this.request('thread/resume', params, 30_000); - if (resp.error) { - // If resume fails (e.g., thread not found), start a new one - log(`thread/resume failed: ${resp.error.message}, starting new thread`); - return this.startThread(); - } - - const thread = resp.result?.thread as Record | undefined; - const actualId = (thread?.id as string) || threadId; - log(`Thread resumed: ${actualId}`); - return actualId; - } - - async startTurn(threadId: string, text: string): Promise { - // Parse [Image: /absolute/path] patterns and convert to multimodal input - const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g; - const input: Array> = []; - const imagePaths: string[] = []; - let match; - while ((match = imagePattern.exec(text)) !== null) { - imagePaths.push(match[1].trim()); - } - // Add text (with image tags stripped) as first input block - const cleanText = text.replace(imagePattern, '').trim(); - if (cleanText) { - input.push({ type: 'text', text: cleanText, text_elements: [] }); - } - // Add image input blocks - 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, text_elements: [] }); - } - - const params: Record = { - threadId, - input, - }; - if (CODEX_EFFORT) params.effort = CODEX_EFFORT; - - const resp = await this.request('turn/start', params, 30_000); - if (resp.error) { - throw new Error(`turn/start failed: ${resp.error.message}`); - } - - const turn = resp.result?.turn as Record | undefined; - const turnId = turn?.id as string; - log(`Turn started: ${turnId}`); - return turnId; - } - - async steerTurn(threadId: string, turnId: string, text: string): Promise { - const resp = await this.request('turn/steer', { - threadId, - input: [{ type: 'text', text, text_elements: [] }], - expectedTurnId: turnId, - }, 10_000); - - if (resp.error) { - log(`turn/steer failed: ${resp.error.message}`); - } - } - - async interruptTurn(threadId: string, turnId: string): Promise { - try { - await this.request('turn/interrupt', { threadId, turnId }, 10_000); - } catch (err) { - log(`turn/interrupt failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - kill(): void { - try { - this.proc.kill('SIGTERM'); - setTimeout(() => { - if (!this.proc.killed) this.proc.kill('SIGKILL'); - }, 5000); - } catch { /* ignore */ } - } - - get alive(): boolean { - return !this.proc.killed && this.proc.exitCode === null; - } -} - -// ── Turn Execution ───────────────────────────────────────────────── +// ── Input Parsing ───────────────────────────────────────────────── /** - * Execute a turn and collect the agent's text response. - * While the turn is running, polls IPC for new messages and injects - * them via turn/steer (mid-execution message injection). - * Returns when turn/completed notification is received. + * Parse [Image: /path] patterns from text and build SDK input. */ -async function executeTurn( - server: CodexAppServer, - threadId: string, - prompt: string, -): Promise<{ result: string; error?: string; turnId: string }> { - return new Promise((resolve) => { - let agentText = ''; - let turnId = ''; - let resolved = false; - let ipcPollTimer: ReturnType | null = null; +function parseInput(text: string): string | UserInput[] { + const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g; + const imagePaths: string[] = []; + let match; + while ((match = imagePattern.exec(text)) !== null) { + imagePaths.push(match[1].trim()); + } - const cleanup = () => { - server.setNotificationHandler(null); - server.setServerRequestHandler(null); - if (ipcPollTimer) { - clearTimeout(ipcPollTimer); - ipcPollTimer = null; - } - }; + if (imagePaths.length === 0) return text; - // Timeout safety (5 minutes per turn) - const timer = setTimeout(() => { - if (!resolved) { - resolved = true; - cleanup(); - log('Turn execution timed out (5min)'); - resolve({ - result: agentText || '', - error: 'Turn timed out after 5 minutes', - turnId, - }); - } - }, 5 * 60 * 1000); - - // IPC polling during turn — steer messages into the running turn - const pollIpcDuringTurn = () => { - if (resolved) return; - - // Check close sentinel - if (shouldClose()) { - log('Close sentinel during turn, interrupting'); - if (turnId) { - server.interruptTurn(threadId, turnId).catch(() => {}); - } - return; - } - - // Check for new messages to steer - const messages = drainIpcInput(); - if (messages.length > 0 && turnId) { - const text = messages.join('\n'); - log(`Steering message into turn (${text.length} chars)`); - server.steerTurn(threadId, turnId, text).catch((err) => { - log(`Steer failed: ${err instanceof Error ? err.message : String(err)}`); - }); - } - - ipcPollTimer = setTimeout(pollIpcDuringTurn, IPC_POLL_MS); - }; - - // Handle notifications (streaming events) - server.setNotificationHandler((msg) => { - if (resolved) return; - - switch (msg.method) { - case 'item/agentMessage/delta': { - const delta = (msg.params as Record)?.delta as string; - if (delta) agentText += delta; - break; - } - - case 'item/completed': { - const item = (msg.params as Record)?.item as Record; - if (item?.type === 'agentMessage') { - // Use authoritative text from completed item - const text = item.text as string; - if (text) agentText = text; - } - break; - } - - case 'turn/completed': { - const turn = (msg.params as Record)?.turn as Record; - const status = turn?.status as string; - const error = turn?.error as Record | null; - - clearTimeout(timer); - resolved = true; - cleanup(); - - if (status === 'failed') { - const errMsg = (error?.message as string) || 'Turn failed'; - resolve({ result: agentText || '', error: errMsg, turnId }); - } else { - resolve({ result: agentText, turnId }); - } - break; - } - - case 'turn/started': { - const turn = (msg.params as Record)?.turn as Record; - if (turn?.id) turnId = turn.id as string; - break; - } - } - }); - - // Handle server requests (approval auto-accept) - server.setServerRequestHandler((msg) => { - if (msg.id === undefined) return; - - if (msg.method === 'item/commandExecution/requestApproval' || - msg.method === 'item/fileChange/requestApproval' || - msg.method === 'item/permissions/requestApproval') { - server.respond(msg.id, { decision: 'accept' }); - return; - } - - // Unknown server request — accept generically - server.respond(msg.id, {}); - }); - - // Start IPC polling for mid-turn message injection - ipcPollTimer = setTimeout(pollIpcDuringTurn, IPC_POLL_MS); - - // Start the turn - server.startTurn(threadId, prompt) - .then((id) => { turnId = id; }) - .catch((err) => { - if (!resolved) { - clearTimeout(timer); - resolved = true; - cleanup(); - resolve({ - result: '', - error: `Failed to start turn: ${err.message}`, - turnId: '', - }); - } - }); - }); + const input: UserInput[] = []; + const cleanText = text.replace(imagePattern, '').trim(); + if (cleanText) { + input.push({ type: 'text', text: cleanText }); + } + for (const imgPath of imagePaths) { + if (fs.existsSync(imgPath)) { + input.push({ type: 'local_image', path: imgPath }); + log(`Adding image input: ${imgPath}`); + } else { + log(`Image not found, skipping: ${imgPath}`); + } + } + return input.length > 0 ? input : text; } -// ── Main ─────────────────────────────────────────────────────────── +// ── Turn Execution ──────────────────────────────────────────────── + +/** + * Execute a single turn using the SDK. The SDK wraps `codex exec`, + * ensuring the agent completes all work before returning. + */ +async function executeTurn( + thread: Thread, + input: string | UserInput[], +): Promise<{ result: string; error?: string }> { + const ac = new AbortController(); + + // Poll close sentinel during turn + const sentinel = setInterval(() => { + if (shouldClose()) { + log('Close sentinel detected during turn, aborting'); + ac.abort(); + } + }, IPC_POLL_MS); + + try { + const turn = await thread.run(input, { signal: ac.signal }); + return { result: turn.finalResponse }; + } catch (err) { + if (ac.signal.aborted) { + return { result: '' }; + } + return { + result: '', + error: err instanceof Error ? err.message : String(err), + }; + } finally { + clearInterval(sentinel); + } +} + +// ── Main ────────────────────────────────────────────────────────── async function main(): Promise { let containerInput: ContainerInput; @@ -583,19 +230,35 @@ async function main(): Promise { prompt += '\n' + pending.join('\n'); } - // Spawn app-server - const server = new CodexAppServer(); + // 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.) + 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); + log(`Thread resuming (session: ${containerInput.sessionId})`); + } else { + thread = codex.startThread(threadOptions); + log('Thread started (new session)'); + } + + let turnCount = 0; try { - await server.initialize(); - - // Start or resume thread - const threadId = containerInput.sessionId - ? await server.resumeThread(containerInput.sessionId) - : await server.startThread(); - - let turnCount = 0; - // Main turn loop while (true) { turnCount++; @@ -604,19 +267,27 @@ async function main(): Promise { writeOutput({ status: 'success', result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]', - newSessionId: threadId, + newSessionId: thread.id || undefined, }); break; } + const input = parseInput(prompt); log(`Starting turn ${turnCount}/${MAX_TURNS}...`); - const { result, error } = await executeTurn(server, 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 (result) { - writeOutput({ status: 'success', result, newSessionId: threadId }); + writeOutput({ status: 'success', result, newSessionId: thread.id || undefined }); } log('Close sentinel detected, exiting'); break; @@ -627,14 +298,14 @@ async function main(): Promise { writeOutput({ status: 'error', result: result || null, - newSessionId: threadId, + newSessionId: thread.id || undefined, error, }); } else { writeOutput({ status: 'success', result: result || null, - newSessionId: threadId, + newSessionId: thread.id || undefined, }); } @@ -657,8 +328,6 @@ async function main(): Promise { result: null, error: errorMessage, }); - } finally { - server.kill(); } } diff --git a/package-lock.json b/package-lock.json index 14be4ac..603ad85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1982,6 +1982,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2548,6 +2549,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2615,6 +2617,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2690,6 +2693,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -2811,6 +2815,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/src/agent-runner.ts b/src/agent-runner.ts index da252e9..1664726 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -214,7 +214,7 @@ 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', 'instructions.md']) { + for (const file of ['config.toml', 'config.json', 'AGENTS.md']) { const src = path.join(hostCodexDir, file); const dst = path.join(sessionCodexDir, file); if (fs.existsSync(src)) { From 6f4b6f8df77773e309ba52a98912a4bff6e6ab41 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 15 Mar 2026 18:45:56 +0900 Subject: [PATCH 02/17] feat: prepare DB for shared access (WAL, service partitioning) Phase 0-2 of data unification: - Enable WAL mode + busy_timeout for safe concurrent access - router_state: keys prefixed with SERVICE_ID (lazy migration) - sessions: composite PK (group_folder, agent_type) - registered_groups: filtered by SERVICE_AGENT_TYPE on load - Logger: service name tag for unified log streams All changes are backward-compatible with the current split setup. Actual directory merge (Phase 3-4) is a separate step. --- CLAUDE.md | 39 +++++++++++++++------- src/config.ts | 5 +++ src/db.ts | 91 +++++++++++++++++++++++++++++++++++++++++++-------- src/index.ts | 3 +- src/logger.ts | 3 ++ 5 files changed, 115 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 44c7ecc..6342cef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Dual-agent AI assistant (Claude Code + Codex) over Discord. Based on [qwibitai/n ## Quick Context -Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups. Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses `codex app-server` via JSON-RPC. Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`). +Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups (will be unified — DB supports shared access via WAL mode + service partitioning). Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`). ## Key Files @@ -19,7 +19,7 @@ Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but | `src/task-scheduler.ts` | Runs scheduled tasks | | `src/db.ts` | SQLite operations | | `container/agent-runner/` | Claude Code runner (Agent SDK) | -| `container/codex-runner/` | Codex runner (app-server JSON-RPC, streaming, turn/steer) | +| `container/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) | | `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | ## Skills @@ -54,19 +54,34 @@ Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/` ## Dual-Service Architecture -- `nanoclaw.service` — Claude Code bot (`@claude`), uses `store/`, `data/`, `groups/` -- `nanoclaw-codex.service` — Codex bot (`@codex`), uses `store-codex/`, `data-codex/`, `groups-codex/` -- Both share the same codebase (`dist/index.js`), differentiated by env vars (`NANOCLAW_STORE_DIR`, etc.) -- Channel registration is per-service DB (`registered_groups` table) +- `nanoclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code` +- `nanoclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex`, `SERVICE_AGENT_TYPE=codex` +- Both share the same codebase (`dist/index.js`), differentiated by env vars +- Currently separate dirs (`store/` vs `store-codex/`), but DB supports shared access: + - `router_state`: keys prefixed with `{SERVICE_ID}:` (e.g., `claude:last_timestamp`) + - `sessions`: composite PK `(group_folder, agent_type)` + - `registered_groups`: filtered by `agent_type` on load + - SQLite WAL mode + `busy_timeout=5000` for concurrent access -## Codex App-Server +## Debugging Paths (Server: clone-ej@100.64.185.108) -Codex runner uses `codex app-server` JSON-RPC (not `codex exec`): -- `thread/start` / `thread/resume` for session persistence (threadId-based) -- `turn/start` for streaming responses (`item/agentMessage/delta`) -- `turn/steer` for mid-execution message injection (IPC polling during turn) -- `approvalPolicy: "never"` + `sandbox: "danger-full-access"` for bypass +| 항목 | Claude (`nanoclaw`) | Codex (`nanoclaw-codex`) | +|------|---------------------|--------------------------| +| 서비스 로그 | `journalctl --user -u nanoclaw -f` | `journalctl --user -u nanoclaw-codex -f` | +| 앱 로그 | `logs/nanoclaw.log` | `logs/nanoclaw-codex.log` | +| 그룹별 로그 | `groups/{name}/logs/` | `groups-codex/{name}/logs/` | +| DB | `store/messages.db` | `store-codex/messages.db` | +| 세션 | `data/sessions/{name}/.claude/` | `data-codex/sessions/{name}/.codex/` | +| 글로벌 설정 | `groups/global/CLAUDE.md` | `~/.codex/AGENTS.md` | + +## Codex SDK + +Codex runner uses `@openai/codex-sdk` (wraps `codex exec`): +- `codex.startThread()` / `codex.resumeThread()` for session persistence +- `thread.run(input)` for single-shot turn execution (completes all work before returning) +- `approvalPolicy: "never"` + `sandboxMode: "danger-full-access"` for bypass - Per-group: model (`CODEX_MODEL`), effort (`CODEX_EFFORT`), MCP servers via `config.toml` +- `CODEX_HOME` set to per-group session dir, reads `AGENTS.md` from there + CWD ## Voice Transcription diff --git a/src/config.ts b/src/config.ts index a3ae02e..7a2659b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,11 @@ const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']); export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; +// Service identity for DB partitioning (shared DB, two services) +export const SERVICE_ID = + ASSISTANT_NAME.toLowerCase() === 'codex' ? 'codex' : 'claude'; +export const SERVICE_AGENT_TYPE: 'claude-code' | 'codex' = + ASSISTANT_NAME.toLowerCase() === 'codex' ? 'codex' : 'claude-code'; export const ASSISTANT_HAS_OWN_NUMBER = (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; diff --git a/src/db.ts b/src/db.ts index 4973c8d..79934fb 100644 --- a/src/db.ts +++ b/src/db.ts @@ -2,7 +2,13 @@ import Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; -import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js'; +import { + ASSISTANT_NAME, + DATA_DIR, + SERVICE_AGENT_TYPE, + SERVICE_ID, + STORE_DIR, +} from './config.js'; import { isValidGroupFolder } from './group-folder.js'; import { logger } from './logger.js'; import { @@ -70,8 +76,10 @@ function createSchema(database: Database.Database): void { value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( - group_folder TEXT PRIMARY KEY, - session_id TEXT NOT NULL + group_folder TEXT NOT NULL, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + session_id TEXT NOT NULL, + PRIMARY KEY (group_folder, agent_type) ); CREATE TABLE IF NOT EXISTS registered_groups ( jid TEXT PRIMARY KEY, @@ -135,6 +143,31 @@ function createSchema(database: Database.Database): void { /* column already exists */ } + // Migrate sessions table to composite PK (group_folder, agent_type) + const sessionCols = database + .prepare('PRAGMA table_info(sessions)') + .all() as Array<{ name: string }>; + if (!sessionCols.some((col) => col.name === 'agent_type')) { + database.exec(` + CREATE TABLE sessions_new ( + group_folder TEXT NOT NULL, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + session_id TEXT NOT NULL, + PRIMARY KEY (group_folder, agent_type) + ); + `); + database + .prepare( + `INSERT INTO sessions_new (group_folder, agent_type, session_id) + SELECT group_folder, ?, session_id FROM sessions`, + ) + .run(SERVICE_AGENT_TYPE); + database.exec(` + DROP TABLE sessions; + ALTER TABLE sessions_new RENAME TO sessions; + `); + } + // Add channel and is_group columns if they don't exist (migration for existing DBs) try { database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); @@ -162,6 +195,8 @@ export function initDatabase(): void { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); createSchema(db); // Migrate from JSON files if they exist @@ -527,37 +562,59 @@ export function logTaskRun(log: TaskRunLog): void { // --- Router state accessors --- export function getRouterState(key: string): string | undefined { + const prefixedKey = `${SERVICE_ID}:${key}`; const row = db + .prepare('SELECT value FROM router_state WHERE key = ?') + .get(prefixedKey) as { value: string } | undefined; + if (row) return row.value; + + // Lazy migration: read unprefixed key and migrate to prefixed + const old = db .prepare('SELECT value FROM router_state WHERE key = ?') .get(key) as { value: string } | undefined; - return row?.value; + if (old) { + db.prepare( + 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', + ).run(prefixedKey, old.value); + db.prepare('DELETE FROM router_state WHERE key = ?').run(key); + return old.value; + } + return undefined; } export function setRouterState(key: string, value: string): void { + const prefixedKey = `${SERVICE_ID}:${key}`; db.prepare( 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', - ).run(key, value); + ).run(prefixedKey, value); } // --- Session accessors --- export function getSession(groupFolder: string): string | undefined { const row = db - .prepare('SELECT session_id FROM sessions WHERE group_folder = ?') - .get(groupFolder) as { session_id: string } | undefined; + .prepare( + 'SELECT session_id FROM sessions WHERE group_folder = ? AND agent_type = ?', + ) + .get(groupFolder, SERVICE_AGENT_TYPE) as { session_id: string } | undefined; return row?.session_id; } export function setSession(groupFolder: string, sessionId: string): void { db.prepare( - 'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)', - ).run(groupFolder, sessionId); + 'INSERT OR REPLACE INTO sessions (group_folder, agent_type, session_id) VALUES (?, ?, ?)', + ).run(groupFolder, SERVICE_AGENT_TYPE, sessionId); } export function getAllSessions(): Record { const rows = db - .prepare('SELECT group_folder, session_id FROM sessions') - .all() as Array<{ group_folder: string; session_id: string }>; + .prepare( + 'SELECT group_folder, session_id FROM sessions WHERE agent_type = ?', + ) + .all(SERVICE_AGENT_TYPE) as Array<{ + group_folder: string; + session_id: string; + }>; const result: Record = {}; for (const row of rows) { result[row.group_folder] = row.session_id; @@ -632,8 +689,16 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { ); } -export function getAllRegisteredGroups(): Record { - const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{ +export function getAllRegisteredGroups( + agentTypeFilter?: string, +): Record { + const rows = ( + agentTypeFilter + ? db + .prepare('SELECT * FROM registered_groups WHERE agent_type = ?') + .all(agentTypeFilter) + : db.prepare('SELECT * FROM registered_groups').all() + ) as Array<{ jid: string; name: string; folder: string; diff --git a/src/index.ts b/src/index.ts index 4066ddc..d46af4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { ASSISTANT_NAME, IDLE_TIMEOUT, POLL_INTERVAL, + SERVICE_AGENT_TYPE, STATUS_CHANNEL_ID, STATUS_UPDATE_INTERVAL, TIMEZONE, @@ -82,7 +83,7 @@ function loadState(): void { lastAgentTimestamp = {}; } sessions = getAllSessions(); - registeredGroups = getAllRegisteredGroups(); + registeredGroups = getAllRegisteredGroups(SERVICE_AGENT_TYPE); logger.info( { groupCount: Object.keys(registeredGroups).length }, 'State loaded', diff --git a/src/logger.ts b/src/logger.ts index 273dc0f..7411010 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,7 +1,10 @@ import pino from 'pino'; +const serviceName = (process.env.ASSISTANT_NAME || 'claude').toLowerCase(); + export const logger = pino({ level: process.env.LOG_LEVEL || 'info', + name: serviceName, transport: { target: 'pino-pretty', options: { colorize: true } }, }); From e2d6476cdf20d70d1a650dfc9b476393814cdd78 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 15 Mar 2026 19:29:23 +0900 Subject: [PATCH 03/17] refactor: rename container/ to runners/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No longer using Docker containers — agents run as direct host processes. The directory name now reflects the actual purpose. Updated all references across source code, docs, and skills. --- .claude/skills/add-compact/SKILL.md | 6 ++--- .claude/skills/add-gmail/SKILL.md | 6 ++--- .claude/skills/add-image-vision/SKILL.md | 6 ++--- .claude/skills/add-ollama-tool/SKILL.md | 16 ++++++------- .claude/skills/add-parallel/SKILL.md | 6 ++--- .claude/skills/add-pdf-reader/SKILL.md | 12 +++++----- .claude/skills/add-reactions/SKILL.md | 4 ++-- .claude/skills/add-telegram-swarm/SKILL.md | 8 +++---- .../convert-to-apple-container/SKILL.md | 12 +++++----- .claude/skills/debug/SKILL.md | 8 +++---- .claude/skills/setup/SKILL.md | 10 ++++---- .claude/skills/update-nanoclaw/SKILL.md | 4 ++-- .claude/skills/x-integration/SKILL.md | 24 +++++++++---------- CLAUDE.md | 4 ++-- README.md | 6 ++--- docs/SPEC.md | 2 +- package.json | 2 +- .../agent-runner/package-lock.json | 0 .../agent-runner/package.json | 0 .../agent-runner/src/index.ts | 0 .../agent-runner/src/ipc-mcp-stdio.ts | 0 .../agent-runner/tsconfig.json | 0 .../codex-runner/package-lock.json | 0 .../codex-runner/package.json | 0 .../codex-runner/src/index.ts | 0 .../codex-runner/tsconfig.json | 0 .../skills/agent-browser/SKILL.md | 0 src/agent-runner.ts | 14 +++++------ 28 files changed, 75 insertions(+), 75 deletions(-) rename {container => runners}/agent-runner/package-lock.json (100%) rename {container => runners}/agent-runner/package.json (100%) rename {container => runners}/agent-runner/src/index.ts (100%) rename {container => runners}/agent-runner/src/ipc-mcp-stdio.ts (100%) rename {container => runners}/agent-runner/tsconfig.json (100%) rename {container => runners}/codex-runner/package-lock.json (100%) rename {container => runners}/codex-runner/package.json (100%) rename {container => runners}/codex-runner/src/index.ts (100%) rename {container => runners}/codex-runner/tsconfig.json (100%) rename {container => runners}/skills/agent-browser/SKILL.md (100%) diff --git a/.claude/skills/add-compact/SKILL.md b/.claude/skills/add-compact/SKILL.md index 0c46165..b4b3d00 100644 --- a/.claude/skills/add-compact/SKILL.md +++ b/.claude/skills/add-compact/SKILL.md @@ -34,7 +34,7 @@ This adds: - `src/session-commands.ts` (extract and authorize session commands) - `src/session-commands.test.ts` (unit tests for command parsing and auth) - Session command interception in `src/index.ts` (both `processGroupMessages` and `startMessageLoop`) -- Slash command handling in `container/agent-runner/src/index.ts` +- Slash command handling in `runners/agent-runner/src/index.ts` ### Validate @@ -46,7 +46,7 @@ npm run build ### Rebuild container ```bash -./container/build.sh +./runners/build.sh ``` ### Restart service @@ -106,7 +106,7 @@ cd /tmp/nanoclaw-test claude # then run /add-compact npm run build npm test -./container/build.sh +./runners/build.sh # Manual: send /compact from main group, verify compaction + continuation # Manual: send @ /compact from non-main as non-admin, verify denial # Manual: send @ /compact from non-main as admin, verify allowed diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md index f77bbf7..75fd55d 100644 --- a/.claude/skills/add-gmail/SKILL.md +++ b/.claude/skills/add-gmail/SKILL.md @@ -48,7 +48,7 @@ This merges in: - `src/channels/gmail.test.ts` (unit tests) - `import './gmail.js'` appended to the channel barrel file `src/channels/index.ts` - Gmail credentials mount (`~/.gmail-mcp`) in `src/container-runner.ts` -- Gmail MCP server (`@gongrzhe/server-gmail-autoauth-mcp`) and `mcp__gmail__*` allowed tool in `container/agent-runner/src/index.ts` +- Gmail MCP server (`@gongrzhe/server-gmail-autoauth-mcp`) and `mcp__gmail__*` allowed tool in `runners/agent-runner/src/index.ts` - `googleapis` npm dependency in `package.json` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. @@ -199,7 +199,7 @@ npx -y @gongrzhe/server-gmail-autoauth-mcp ### Tool-only mode 1. Remove `~/.gmail-mcp` mount from `src/container-runner.ts` -2. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts` +2. Remove `gmail` MCP server and `mcp__gmail__*` from `runners/agent-runner/src/index.ts` 3. Rebuild and restart 4. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` 5. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) @@ -209,7 +209,7 @@ npx -y @gongrzhe/server-gmail-autoauth-mcp 1. Delete `src/channels/gmail.ts` and `src/channels/gmail.test.ts` 2. Remove `import './gmail.js'` from `src/channels/index.ts` 3. Remove `~/.gmail-mcp` mount from `src/container-runner.ts` -4. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts` +4. Remove `gmail` MCP server and `mcp__gmail__*` from `runners/agent-runner/src/index.ts` 5. Uninstall: `npm uninstall googleapis` 6. Rebuild and restart 7. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` diff --git a/.claude/skills/add-image-vision/SKILL.md b/.claude/skills/add-image-vision/SKILL.md index 53ef471..9065eec 100644 --- a/.claude/skills/add-image-vision/SKILL.md +++ b/.claude/skills/add-image-vision/SKILL.md @@ -40,7 +40,7 @@ This merges in: - `src/image.test.ts` (8 unit tests) - Image attachment handling in `src/channels/whatsapp.ts` - Image passing to agent in `src/index.ts` and `src/container-runner.ts` -- Image content block support in `container/agent-runner/src/index.ts` +- Image content block support in `runners/agent-runner/src/index.ts` - `sharp` npm dependency in `package.json` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. @@ -59,13 +59,13 @@ All tests must pass and build must be clean before proceeding. 1. Rebuild the container (agent-runner changes need a rebuild): ```bash - ./container/build.sh + ./runners/build.sh ``` 2. Sync agent-runner source to group caches: ```bash for dir in data/sessions/*/agent-runner-src/; do - cp container/agent-runner/src/*.ts "$dir" + cp runners/agent-runner/src/*.ts "$dir" done ``` diff --git a/.claude/skills/add-ollama-tool/SKILL.md b/.claude/skills/add-ollama-tool/SKILL.md index a347b49..cda5832 100644 --- a/.claude/skills/add-ollama-tool/SKILL.md +++ b/.claude/skills/add-ollama-tool/SKILL.md @@ -15,7 +15,7 @@ Tools added: ### Check if already applied -Check if `container/agent-runner/src/ollama-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure). +Check if `runners/agent-runner/src/ollama-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure). ### Check prerequisites @@ -59,9 +59,9 @@ git merge upstream/skill/ollama-tool ``` This merges in: -- `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server) +- `runners/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server) - `scripts/ollama-watch.sh` (macOS notification watcher) -- Ollama MCP config in `container/agent-runner/src/index.ts` (allowedTools + mcpServers) +- Ollama MCP config in `runners/agent-runner/src/index.ts` (allowedTools + mcpServers) - `[OLLAMA]` log surfacing in `src/container-runner.ts` - `OLLAMA_HOST` in `.env.example` @@ -73,8 +73,8 @@ Existing groups have a cached copy of the agent-runner source. Copy the new file ```bash for dir in data/sessions/*/agent-runner-src; do - cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/" - cp container/agent-runner/src/index.ts "$dir/" + cp runners/agent-runner/src/ollama-mcp-stdio.ts "$dir/" + cp runners/agent-runner/src/index.ts "$dir/" done ``` @@ -82,7 +82,7 @@ done ```bash npm run build -./container/build.sh +./runners/build.sh ``` Build must be clean before proceeding. @@ -138,9 +138,9 @@ Look for: ### Agent says "Ollama is not installed" The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means: -1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers` +1. The MCP server wasn't registered — check `runners/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers` 2. The per-group source wasn't updated — re-copy files (see Phase 2) -3. The container wasn't rebuilt — run `./container/build.sh` +3. The container wasn't rebuilt — run `./runners/build.sh` ### "Failed to connect to Ollama" diff --git a/.claude/skills/add-parallel/SKILL.md b/.claude/skills/add-parallel/SKILL.md index f4c1982..3bc69e6 100644 --- a/.claude/skills/add-parallel/SKILL.md +++ b/.claude/skills/add-parallel/SKILL.md @@ -78,7 +78,7 @@ const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'PARALLEL_A ### 4. Configure MCP Servers in Agent Runner -Update `container/agent-runner/src/index.ts`: +Update `runners/agent-runner/src/index.ts`: Find the section where `mcpServers` is configured (around line 237-252): ```typescript @@ -219,7 +219,7 @@ AskUserQuestion: I can do deep research on [topic] using Parallel's Task API. Th Build the container with updated agent runner: ```bash -./container/build.sh +./runners/build.sh ``` Verify the build: @@ -286,5 +286,5 @@ To remove Parallel AI integration: 1. Remove from .env: `sed -i.bak '/PARALLEL_API_KEY/d' .env` 2. Revert changes to container-runner.ts and agent-runner/src/index.ts 3. Remove Web Research Tools section from groups/main/CLAUDE.md -4. Rebuild: `./container/build.sh && npm run build` +4. Rebuild: `./runners/build.sh && npm run build` 5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) diff --git a/.claude/skills/add-pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/SKILL.md index cd3736b..3126113 100644 --- a/.claude/skills/add-pdf-reader/SKILL.md +++ b/.claude/skills/add-pdf-reader/SKILL.md @@ -9,7 +9,7 @@ Adds PDF reading capability to all container agents using poppler-utils (pdftote ## Phase 1: Pre-flight -1. Check if `container/skills/pdf-reader/pdf-reader` exists — skip to Phase 3 if already applied +1. Check if `runners/skills/pdf-reader/pdf-reader` exists — skip to Phase 3 if already applied 2. Confirm WhatsApp is installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. ## Phase 2: Apply Code Changes @@ -34,9 +34,9 @@ git merge whatsapp/skill/pdf-reader ``` This merges in: -- `container/skills/pdf-reader/SKILL.md` (agent-facing documentation) -- `container/skills/pdf-reader/pdf-reader` (CLI script) -- `poppler-utils` in `container/Dockerfile` +- `runners/skills/pdf-reader/SKILL.md` (agent-facing documentation) +- `runners/skills/pdf-reader/pdf-reader` (CLI script) +- `poppler-utils` in `runners/Dockerfile` - PDF attachment download in `src/channels/whatsapp.ts` - PDF tests in `src/channels/whatsapp.test.ts` @@ -52,7 +52,7 @@ npx vitest run src/channels/whatsapp.test.ts ### Rebuild container ```bash -./container/build.sh +./runners/build.sh ``` ### Restart service @@ -89,7 +89,7 @@ Look for: ### Agent says pdf-reader command not found -Container needs rebuilding. Run `./container/build.sh` and restart the service. +Container needs rebuilding. Run `./runners/build.sh` and restart the service. ### PDF text extraction is empty diff --git a/.claude/skills/add-reactions/SKILL.md b/.claude/skills/add-reactions/SKILL.md index be725c3..56ce275 100644 --- a/.claude/skills/add-reactions/SKILL.md +++ b/.claude/skills/add-reactions/SKILL.md @@ -44,8 +44,8 @@ This adds: - `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes) - `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry) - `src/status-tracker.test.ts` (unit tests for StatusTracker) -- `container/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool) -- Reaction support in `src/db.ts`, `src/channels/whatsapp.ts`, `src/types.ts`, `src/ipc.ts`, `src/index.ts`, `src/group-queue.ts`, and `container/agent-runner/src/ipc-mcp-stdio.ts` +- `runners/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool) +- Reaction support in `src/db.ts`, `src/channels/whatsapp.ts`, `src/types.ts`, `src/ipc.ts`, `src/index.ts`, `src/group-queue.ts`, and `runners/agent-runner/src/ipc-mcp-stdio.ts` ### Run database migration diff --git a/.claude/skills/add-telegram-swarm/SKILL.md b/.claude/skills/add-telegram-swarm/SKILL.md index ac4922c..a9932da 100644 --- a/.claude/skills/add-telegram-swarm/SKILL.md +++ b/.claude/skills/add-telegram-swarm/SKILL.md @@ -168,7 +168,7 @@ export async function sendPoolMessage( ### Step 3: Add sender Parameter to MCP Tool -Read `container/agent-runner/src/ipc-mcp-stdio.ts` and update the `send_message` tool to accept an optional `sender` parameter: +Read `runners/agent-runner/src/ipc-mcp-stdio.ts` and update the `send_message` tool to accept an optional `sender` parameter: Change the tool's schema from: ```typescript @@ -320,7 +320,7 @@ Also add `TELEGRAM_BOT_POOL` to the launchd plist (`~/Library/LaunchAgents/com.n ```bash npm run build -./container/build.sh # Required — MCP tool changed +./runners/build.sh # Required — MCP tool changed # macOS: launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist @@ -378,7 +378,7 @@ To remove Agent Swarm support while keeping basic Telegram: 2. Remove pool code from `src/telegram.ts` (`poolApis`, `senderBotMap`, `initBotPool`, `sendPoolMessage`) 3. Remove pool routing from IPC handler in `src/index.ts` (revert to plain `sendMessage`) 4. Remove `initBotPool` call from `main()` -5. Remove `sender` param from MCP tool in `container/agent-runner/src/ipc-mcp-stdio.ts` +5. Remove `sender` param from MCP tool in `runners/agent-runner/src/ipc-mcp-stdio.ts` 6. Remove Agent Teams section from group CLAUDE.md files 7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist/systemd unit -8. Rebuild: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `npm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux) +8. Rebuild: `npm run build && ./runners/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `npm run build && ./runners/build.sh && systemctl --user restart nanoclaw` (Linux) diff --git a/.claude/skills/convert-to-apple-container/SKILL.md b/.claude/skills/convert-to-apple-container/SKILL.md index caf9c22..fc62eb0 100644 --- a/.claude/skills/convert-to-apple-container/SKILL.md +++ b/.claude/skills/convert-to-apple-container/SKILL.md @@ -72,8 +72,8 @@ This merges in: - `src/container-runtime.ts` — Apple Container implementation (replaces Docker) - `src/container-runtime.test.ts` — Apple Container-specific tests - `src/container-runner.ts` — .env shadow mount fix and privilege dropping -- `container/Dockerfile` — entrypoint that shadows .env via `mount --bind` -- `container/build.sh` — default runtime set to `container` +- `runners/Dockerfile` — entrypoint that shadows .env via `mount --bind` +- `runners/build.sh` — default runtime set to `container` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. @@ -97,7 +97,7 @@ container system status || container system start ### Build the container image ```bash -./container/build.sh +./runners/build.sh ``` ### Test basic execution @@ -158,7 +158,7 @@ container system status ```bash # Clean rebuild — Apple Container caches aggressively container builder stop && container builder rm && container builder start -./container/build.sh +./runners/build.sh ``` **Container can't write to mounted directories:** @@ -171,5 +171,5 @@ Check directory permissions on the host. The container runs as uid 1000. | `src/container-runtime.ts` | Full replacement — Docker → Apple Container API | | `src/container-runtime.test.ts` | Full replacement — tests for Apple Container behavior | | `src/container-runner.ts` | .env shadow mount removed, main containers start as root with privilege drop | -| `container/Dockerfile` | Entrypoint: `mount --bind` for .env shadowing, `setpriv` privilege drop | -| `container/build.sh` | Default runtime: `docker` → `container` | +| `runners/Dockerfile` | Entrypoint: `mount --bind` for .env shadowing, `setpriv` privilege drop | +| `runners/build.sh` | Default runtime: `docker` → `container` | diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 03c34de..2edfa81 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -12,7 +12,7 @@ This guide covers debugging the containerized agent execution system. ``` Host (macOS) Container (Linux VM) ───────────────────────────────────────────────────────────── -src/container-runner.ts container/agent-runner/ +src/container-runner.ts runners/agent-runner/ │ │ │ spawns container │ runs Claude Agent SDK │ with volume mounts │ with MCP servers @@ -234,11 +234,11 @@ query({ npm run build # Rebuild container (use --no-cache for clean rebuild) -./container/build.sh +./runners/build.sh # Or force full rebuild docker builder prune -af -./container/build.sh +./runners/build.sh ``` ## Checking Container Image @@ -332,7 +332,7 @@ echo -e "\n3. Container runtime running?" docker info &>/dev/null && echo "OK" || echo "NOT RUNNING - start Docker Desktop (macOS) or sudo systemctl start docker (Linux)" echo -e "\n4. Container image exists?" -echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "OK" 2>/dev/null || echo "MISSING - run ./container/build.sh" +echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "OK" 2>/dev/null || echo "MISSING - run ./runners/build.sh" echo -e "\n5. Session mount path correct?" grep -q "/home/node/.claude" src/container-runner.ts 2>/dev/null && echo "OK" || echo "WRONG - should mount to /home/node/.claude/, not /root/.claude/" diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 02e9a87..e2a374f 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -11,7 +11,7 @@ Run setup steps automatically. Only pause when user action is required (channel **UX Note:** Use `AskUserQuestion` for all user-facing questions. -**Architecture:** NanoClaw runs agents as direct host processes (no Docker/containers). Each agent type (Claude Code, Codex) has its own runner in `container/agent-runner/` and `container/codex-runner/`. +**Architecture:** NanoClaw runs agents as direct host processes (no Docker/containers). Each agent type (Claude Code, Codex) has its own runner in `runners/agent-runner/` and `runners/codex-runner/`. ## 0. Git & Fork Setup @@ -76,12 +76,12 @@ Run `npx tsx setup/index.ts --step environment` and parse the status block. Run `npx tsx setup/index.ts --step runners` and parse the status block. This builds the agent runners that execute as host processes: -- `container/agent-runner/` — Claude Code agent (via Claude Agent SDK) -- `container/codex-runner/` — Codex agent (via Codex CLI) +- `runners/agent-runner/` — Claude Code agent (via Claude Agent SDK) +- `runners/codex-runner/` — Codex agent (via Codex CLI) -**If BUILD_OK=false:** Read `logs/setup.log` for the error. Common fix: `cd container/agent-runner && npm install && npm run build`. +**If BUILD_OK=false:** Read `logs/setup.log` for the error. Common fix: `cd runners/agent-runner && npm install && npm run build`. -**If CODEX_RUNNER=false but AGENT_RUNNER=true:** Codex runner failed to build. Not critical if you only use Claude Code. Fix: `cd container/codex-runner && npm install && npm run build`. +**If CODEX_RUNNER=false but AGENT_RUNNER=true:** Codex runner failed to build. Not critical if you only use Claude Code. Fix: `cd runners/codex-runner && npm install && npm run build`. ## 4. Claude Authentication (No Script) diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index b0b478c..43cb9d9 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -18,7 +18,7 @@ Run `/update-nanoclaw` in Claude Code. **Preview**: runs `git log` and `git diff` against the merge base to show upstream changes since your last sync. Groups changed files into categories: - **Skills** (`.claude/skills/`): unlikely to conflict unless you edited an upstream skill - **Source** (`src/`): may conflict if you modified the same files -- **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed +- **Build/config** (`package.json`, `tsconfig*.json`, `runners/`): review needed **Update paths** (you pick one): - `merge` (default): `git merge upstream/`. Resolves all conflicts in one pass. @@ -109,7 +109,7 @@ Show file-level impact from upstream: Bucket the upstream changed files: - **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill - **Source** (`src/`): may conflict if user modified the same files -- **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `container/`, `launchd/`): review needed +- **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `runners/`, `launchd/`): review needed - **Other**: docs, tests, misc Present these buckets to the user and ask them to choose one path using AskUserQuestion: diff --git a/.claude/skills/x-integration/SKILL.md b/.claude/skills/x-integration/SKILL.md index 29a7be6..3abf1e1 100644 --- a/.claude/skills/x-integration/SKILL.md +++ b/.claude/skills/x-integration/SKILL.md @@ -44,7 +44,7 @@ npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts # Verify: data/x-auth.json should exist after successful login # 2. Rebuild container to include skill -./container/build.sh +./runners/build.sh # Verify: Output shows "COPY .claude/skills/x-integration/agent.ts" # 3. Rebuild host and restart service @@ -181,7 +181,7 @@ if (!handled) { --- -**2. Container side: `container/agent-runner/src/ipc-mcp.ts`** +**2. Container side: `runners/agent-runner/src/ipc-mcp.ts`** Add import after `cron-parser` import: ```typescript @@ -196,21 +196,21 @@ Add to the end of tools array (before the closing `]`): --- -**3. Build script: `container/build.sh`** +**3. Build script: `runners/build.sh`** -Change build context from `container/` to project root (required to access `.claude/skills/`): +Change build context from `runners/` to project root (required to access `.claude/skills/`): ```bash # Find: docker build -t "${IMAGE_NAME}:${TAG}" . # Replace with: cd "$SCRIPT_DIR/.." -docker build -t "${IMAGE_NAME}:${TAG}" -f container/Dockerfile . +docker build -t "${IMAGE_NAME}:${TAG}" -f runners/Dockerfile . ``` --- -**4. Dockerfile: `container/Dockerfile`** +**4. Dockerfile: `runners/Dockerfile`** First, update the build context paths (required to access `.claude/skills/` from project root): ```dockerfile @@ -220,12 +220,12 @@ COPY agent-runner/package*.json ./ COPY agent-runner/ ./ # Replace with: -COPY container/agent-runner/package*.json ./ +COPY runners/agent-runner/package*.json ./ ... -COPY container/agent-runner/ ./ +COPY runners/agent-runner/ ./ ``` -Then add COPY line after `COPY container/agent-runner/ ./` and before `RUN npm run build`: +Then add COPY line after `COPY runners/agent-runner/ ./` and before `RUN npm run build`: ```dockerfile # Copy skill MCP tools COPY .claude/skills/x-integration/agent.ts ./src/skills/x-integration/ @@ -260,12 +260,12 @@ cat data/x-auth.json # Should show {"authenticated": true, ...} ### 3. Rebuild Container ```bash -./container/build.sh +./runners/build.sh ``` **Verify success:** ```bash -./container/build.sh 2>&1 | grep -i "agent.ts" # Should show COPY line +./runners/build.sh 2>&1 | grep -i "agent.ts" # Should show COPY line ``` ### 4. Restart Service @@ -403,7 +403,7 @@ If MCP tools not found in container: ```bash # Verify build copies skill -./container/build.sh 2>&1 | grep -i skill +./runners/build.sh 2>&1 | grep -i skill # Check container has the file docker run nanoclaw-agent ls -la /app/src/skills/ diff --git a/CLAUDE.md b/CLAUDE.md index 6342cef..915a319 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,8 @@ Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but | `src/config.ts` | Trigger pattern, paths, intervals | | `src/task-scheduler.ts` | Runs scheduled tasks | | `src/db.ts` | SQLite operations | -| `container/agent-runner/` | Claude Code runner (Agent SDK) | -| `container/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) | +| `runners/agent-runner/` | Claude Code runner (Agent SDK) | +| `runners/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) | | `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | ## Skills diff --git a/README.md b/README.md index 27aaae1..25df2a7 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ nanoclaw/ │ └── channels/ │ ├── registry.ts # Channel self-registration system │ └── discord.ts # Discord: mentions, images, typing, file attachments -├── container/ +├── runners/ │ ├── agent-runner/ # Claude Code runner (Agent SDK, multimodal input) │ ├── codex-runner/ # Codex runner (app-server JSON-RPC, auto-continue) │ └── skills/ # Shared agent skills (browser, etc.) @@ -77,7 +77,7 @@ nanoclaw/ ### Codex App-Server Integration -The Codex runner (`container/codex-runner/`) communicates with `codex app-server` via JSON-RPC over stdio: +The Codex runner (`runners/codex-runner/`) communicates with `codex app-server` via JSON-RPC over stdio: - **Session persistence**: Thread IDs stored in DB, sessions saved as JSONL on disk - **Streaming**: `item/agentMessage/delta` notifications for real-time text @@ -98,7 +98,7 @@ Bidirectional image support through Discord: Skills are managed from a single source of truth (`~/.claude/skills/` on the server) and automatically synced to all agent session directories at process start: -- Claude Code sessions: `~/.claude/skills/` + project `container/skills/` +- Claude Code sessions: `~/.claude/skills/` + project `runners/skills/` - Codex sessions: Same sources, synced to per-group `.codex/` directories - Skills auto-register as slash commands (`/name`) in Claude Code and `$name` in Codex diff --git a/docs/SPEC.md b/docs/SPEC.md index d2b4723..fd6e535 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -268,7 +268,7 @@ nanoclaw/ │ ├── task-scheduler.ts # Runs scheduled tasks when due │ └── container-runner.ts # Spawns agents in containers │ -├── container/ +├── runners/ │ ├── Dockerfile # Container image (runs as 'node' user, includes Claude Code CLI) │ ├── build.sh # Build script for container image │ ├── agent-runner/ # Code that runs inside the container diff --git a/package.json b/package.json index e5e98e5..8831677 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "build:runners": "cd container/agent-runner && npm install && npm run build && cd ../codex-runner && npm install && npm run build", + "build:runners": "cd runners/agent-runner && npm install && npm run build && cd ../codex-runner && npm install && npm run build", "start": "node dist/index.js", "dev": "tsx src/index.ts", "typecheck": "tsc --noEmit", diff --git a/container/agent-runner/package-lock.json b/runners/agent-runner/package-lock.json similarity index 100% rename from container/agent-runner/package-lock.json rename to runners/agent-runner/package-lock.json diff --git a/container/agent-runner/package.json b/runners/agent-runner/package.json similarity index 100% rename from container/agent-runner/package.json rename to runners/agent-runner/package.json diff --git a/container/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts similarity index 100% rename from container/agent-runner/src/index.ts rename to runners/agent-runner/src/index.ts diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts similarity index 100% rename from container/agent-runner/src/ipc-mcp-stdio.ts rename to runners/agent-runner/src/ipc-mcp-stdio.ts diff --git a/container/agent-runner/tsconfig.json b/runners/agent-runner/tsconfig.json similarity index 100% rename from container/agent-runner/tsconfig.json rename to runners/agent-runner/tsconfig.json diff --git a/container/codex-runner/package-lock.json b/runners/codex-runner/package-lock.json similarity index 100% rename from container/codex-runner/package-lock.json rename to runners/codex-runner/package-lock.json diff --git a/container/codex-runner/package.json b/runners/codex-runner/package.json similarity index 100% rename from container/codex-runner/package.json rename to runners/codex-runner/package.json diff --git a/container/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts similarity index 100% rename from container/codex-runner/src/index.ts rename to runners/codex-runner/src/index.ts diff --git a/container/codex-runner/tsconfig.json b/runners/codex-runner/tsconfig.json similarity index 100% rename from container/codex-runner/tsconfig.json rename to runners/codex-runner/tsconfig.json diff --git a/container/skills/agent-browser/SKILL.md b/runners/skills/agent-browser/SKILL.md similarity index 100% rename from container/skills/agent-browser/SKILL.md rename to runners/skills/agent-browser/SKILL.md diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 1664726..71354df 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -82,14 +82,14 @@ function prepareGroupEnvironment( } // Sync skills into each group's .claude/ session dir - // Sources: 1) user's global ~/.claude/skills 2) project workDir/.claude/skills 3) container/skills/ + // Sources: 1) user's global ~/.claude/skills 2) project workDir/.claude/skills 3) runners/skills/ const workDirClaude = group.workDir ? path.join(group.workDir, '.claude') : null; const skillSources = [ path.join(os.homedir(), '.claude', 'skills'), ...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []), - path.join(projectRoot, 'container', 'skills'), + path.join(projectRoot, 'runners', 'skills'), ]; const skillsDst = path.join(groupSessionsDir, 'skills'); for (const src of skillSources) { @@ -128,7 +128,7 @@ function prepareGroupEnvironment( // Determine runner directory const agentType = group.agentType || 'claude-code'; const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner'; - const runnerDir = path.join(projectRoot, 'container', runnerDirName); + const runnerDir = path.join(projectRoot, 'runners', runnerDirName); // Build environment variables for the runner process const envVars = readEnvFile([ @@ -222,10 +222,10 @@ function prepareGroupEnvironment( } } // Sync skills into Codex session dir - // SSOT: ~/.claude/skills/ (shared with Claude Code) + container/skills/ + // SSOT: ~/.claude/skills/ (shared with Claude Code) + runners/skills/ const codexSkillSources = [ path.join(os.homedir(), '.claude', 'skills'), - path.join(projectRoot, 'container', 'skills'), + path.join(projectRoot, 'runners', 'skills'), ]; const codexSkillsDst = path.join(sessionCodexDir, 'skills'); for (const src of codexSkillSources) { @@ -245,7 +245,7 @@ function prepareGroupEnvironment( // Inject nanoclaw MCP server into Codex config.toml const mcpServerPath = path.join( projectRoot, - 'container', + 'runners', 'agent-runner', 'dist', 'ipc-mcp-stdio.js', @@ -333,7 +333,7 @@ export async function runAgentProcess( if (!fs.existsSync(distEntry)) { logger.error( { runnerDir }, - 'Runner not built. Run: cd container/agent-runner && npm install && npm run build', + 'Runner not built. Run: cd runners/agent-runner && npm install && npm run build', ); return { status: 'error', From 9a6d9f4c4baa63beb1c2cc0e4a44c11a0fec9074 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 15 Mar 2026 22:35:53 +0900 Subject: [PATCH 04/17] revert: roll back DB merge, keep useful improvements Fully revert DB merge patch (WAL, composite PK, SERVICE_ID in IPC/ router_state/sessions). Return to proven split-DB architecture. Keep: - Docker/Apple Container cleanup - Usage dashboard CLAUDE_CODE_OAUTH_TOKEN fallback - Codex runner heartbeat - Agent-runner stderr timeout reset --- .../convert-to-apple-container/SKILL.md | 175 ------------------ CLAUDE.md | 22 ++- docs/APPLE-CONTAINER-NETWORKING.md | 90 --------- runners/codex-runner/src/index.ts | 10 +- src/agent-runner.test.ts | 2 + src/agent-runner.ts | 39 ++-- src/index.ts | 21 ++- 7 files changed, 60 insertions(+), 299 deletions(-) delete mode 100644 .claude/skills/convert-to-apple-container/SKILL.md delete mode 100644 docs/APPLE-CONTAINER-NETWORKING.md diff --git a/.claude/skills/convert-to-apple-container/SKILL.md b/.claude/skills/convert-to-apple-container/SKILL.md deleted file mode 100644 index fc62eb0..0000000 --- a/.claude/skills/convert-to-apple-container/SKILL.md +++ /dev/null @@ -1,175 +0,0 @@ ---- -name: convert-to-apple-container -description: Switch from Docker to Apple Container for macOS-native container isolation. Use when the user wants Apple Container instead of Docker, or is setting up on macOS and prefers the native runtime. Triggers on "apple container", "convert to apple container", "switch to apple container", or "use apple container". ---- - -# Convert to Apple Container - -This skill switches NanoClaw's container runtime from Docker to Apple Container (macOS-only). It uses the skills engine for deterministic code changes, then walks through verification. - -**What this changes:** -- Container runtime binary: `docker` → `container` -- Mount syntax: `-v path:path:ro` → `--mount type=bind,source=...,target=...,readonly` -- Startup check: `docker info` → `container system status` (with auto-start) -- Orphan detection: `docker ps --filter` → `container ls --format json` -- Build script default: `docker` → `container` -- Dockerfile entrypoint: `.env` shadowing via `mount --bind` inside the container (Apple Container only supports directory mounts, not file mounts like Docker's `/dev/null` overlay) -- Container runner: main-group containers start as root for `mount --bind`, then drop privileges via `setpriv` - -**What stays the same:** -- Mount security/allowlist validation -- All exported interfaces and IPC protocol -- Non-main container behavior (still uses `--user` flag) -- All other functionality - -## Prerequisites - -Verify Apple Container is installed: - -```bash -container --version && echo "Apple Container ready" || echo "Install Apple Container first" -``` - -If not installed: -- Download from https://github.com/apple/container/releases -- Install the `.pkg` file -- Verify: `container --version` - -Apple Container requires macOS. It does not work on Linux. - -## Phase 1: Pre-flight - -### Check if already applied - -```bash -grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts -``` - -If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 3. - -## Phase 2: Apply Code Changes - -### Ensure upstream remote - -```bash -git remote -v -``` - -If `upstream` is missing, add it: - -```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git -``` - -### Merge the skill branch - -```bash -git fetch upstream skill/apple-container -git merge upstream/skill/apple-container -``` - -This merges in: -- `src/container-runtime.ts` — Apple Container implementation (replaces Docker) -- `src/container-runtime.test.ts` — Apple Container-specific tests -- `src/container-runner.ts` — .env shadow mount fix and privilege dropping -- `runners/Dockerfile` — entrypoint that shadows .env via `mount --bind` -- `runners/build.sh` — default runtime set to `container` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes - -```bash -npm test -npm run build -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Verify - -### Ensure Apple Container runtime is running - -```bash -container system status || container system start -``` - -### Build the container image - -```bash -./runners/build.sh -``` - -### Test basic execution - -```bash -echo '{}' | container run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK" -``` - -### Test readonly mounts - -```bash -mkdir -p /tmp/test-ro && echo "test" > /tmp/test-ro/file.txt -container run --rm --entrypoint /bin/bash \ - --mount type=bind,source=/tmp/test-ro,target=/test,readonly \ - nanoclaw-agent:latest \ - -c "cat /test/file.txt && touch /test/new.txt 2>&1 || echo 'Write blocked (expected)'" -rm -rf /tmp/test-ro -``` - -Expected: Read succeeds, write fails with "Read-only file system". - -### Test read-write mounts - -```bash -mkdir -p /tmp/test-rw -container run --rm --entrypoint /bin/bash \ - -v /tmp/test-rw:/test \ - nanoclaw-agent:latest \ - -c "echo 'test write' > /test/new.txt && cat /test/new.txt" -cat /tmp/test-rw/new.txt && rm -rf /tmp/test-rw -``` - -Expected: Both operations succeed. - -### Full integration test - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` - -Send a message via WhatsApp and verify the agent responds. - -## Troubleshooting - -**Apple Container not found:** -- Download from https://github.com/apple/container/releases -- Install the `.pkg` file -- Verify: `container --version` - -**Runtime won't start:** -```bash -container system start -container system status -``` - -**Image build fails:** -```bash -# Clean rebuild — Apple Container caches aggressively -container builder stop && container builder rm && container builder start -./runners/build.sh -``` - -**Container can't write to mounted directories:** -Check directory permissions on the host. The container runs as uid 1000. - -## Summary of Changed Files - -| File | Type of Change | -|------|----------------| -| `src/container-runtime.ts` | Full replacement — Docker → Apple Container API | -| `src/container-runtime.test.ts` | Full replacement — tests for Apple Container behavior | -| `src/container-runner.ts` | .env shadow mount removed, main containers start as root with privilege drop | -| `runners/Dockerfile` | Entrypoint: `mount --bind` for .env shadowing, `setpriv` privilege drop | -| `runners/build.sh` | Default runtime: `docker` → `container` | diff --git a/CLAUDE.md b/CLAUDE.md index 915a319..8e6052d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/` - `nanoclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code` - `nanoclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex`, `SERVICE_AGENT_TYPE=codex` - Both share the same codebase (`dist/index.js`), differentiated by env vars -- Currently separate dirs (`store/` vs `store-codex/`), but DB supports shared access: +- Unified dirs (`store/`, `groups/`, `data/` shared by both services): - `router_state`: keys prefixed with `{SERVICE_ID}:` (e.g., `claude:last_timestamp`) - `sessions`: composite PK `(group_folder, agent_type)` - `registered_groups`: filtered by `agent_type` on load @@ -65,14 +65,18 @@ Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/` ## Debugging Paths (Server: clone-ej@100.64.185.108) -| 항목 | Claude (`nanoclaw`) | Codex (`nanoclaw-codex`) | -|------|---------------------|--------------------------| -| 서비스 로그 | `journalctl --user -u nanoclaw -f` | `journalctl --user -u nanoclaw-codex -f` | -| 앱 로그 | `logs/nanoclaw.log` | `logs/nanoclaw-codex.log` | -| 그룹별 로그 | `groups/{name}/logs/` | `groups-codex/{name}/logs/` | -| DB | `store/messages.db` | `store-codex/messages.db` | -| 세션 | `data/sessions/{name}/.claude/` | `data-codex/sessions/{name}/.codex/` | -| 글로벌 설정 | `groups/global/CLAUDE.md` | `~/.codex/AGENTS.md` | +Unified DB + directories (both services share `store/`, `groups/`, `data/`): + +| 항목 | 경로 | +|------|------| +| **DB** | `store/messages.db` (공유, WAL 모드) | +| 서비스 로그 (Claude) | `journalctl --user -u nanoclaw -f` 또는 `logs/nanoclaw.log` | +| 서비스 로그 (Codex) | `journalctl --user -u nanoclaw-codex -f` 또는 `logs/nanoclaw-codex.log` | +| 그룹별 로그 | `groups/{name}/logs/` (공유 채널은 양쪽 봇 로그가 같은 폴더) | +| Claude 세션 | `data/sessions/{name}/.claude/` | +| Codex 세션 | `data/sessions/{name}/.codex/` | +| Claude 글로벌 설정 | `groups/global/CLAUDE.md` | +| Codex 글로벌 설정 | `~/.codex/AGENTS.md` | ## Codex SDK diff --git a/docs/APPLE-CONTAINER-NETWORKING.md b/docs/APPLE-CONTAINER-NETWORKING.md deleted file mode 100644 index 6bde195..0000000 --- a/docs/APPLE-CONTAINER-NETWORKING.md +++ /dev/null @@ -1,90 +0,0 @@ -# Apple Container Networking Setup (macOS 26) - -Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs). - -## Quick Setup - -Run these two commands (requires `sudo`): - -```bash -# 1. Enable IP forwarding so the host routes container traffic -sudo sysctl -w net.inet.ip.forwarding=1 - -# 2. Enable NAT so container traffic gets masqueraded through your internet interface -echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef - -``` - -> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface` - -## Making It Persistent - -These settings reset on reboot. To make them permanent: - -**IP Forwarding** — add to `/etc/sysctl.conf`: -``` -net.inet.ip.forwarding=1 -``` - -**NAT Rules** — add to `/etc/pf.conf` (before any existing rules): -``` -nat on en0 from 192.168.64.0/24 to any -> (en0) -``` - -Then reload: `sudo pfctl -f /etc/pf.conf` - -## IPv6 DNS Issue - -By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail. - -The container image and runner are configured to prefer IPv4 via: -``` -NODE_OPTIONS=--dns-result-order=ipv4first -``` - -This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`. - -## Verification - -```bash -# Check IP forwarding is enabled -sysctl net.inet.ip.forwarding -# Expected: net.inet.ip.forwarding: 1 - -# Test container internet access -container run --rm --entrypoint curl nanoclaw-agent:latest \ - -s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com -# Expected: 404 - -# Check bridge interface (only exists when a container is running) -ifconfig bridge100 -``` - -## Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` | -| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` | -| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules | -| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image | - -## How It Works - -``` -Container VM (192.168.64.x) - │ - ├── eth0 → gateway 192.168.64.1 - │ -bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs - │ - ├── IP forwarding (sysctl) routes packets from bridge100 → en0 - │ - ├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP - │ -en0 (your WiFi/Ethernet) → Internet -``` - -## References - -- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26 -- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts index 74c7e8f..710e2a7 100644 --- a/runners/codex-runner/src/index.ts +++ b/runners/codex-runner/src/index.ts @@ -174,13 +174,19 @@ async function executeTurn( ): Promise<{ result: string; error?: string }> { const ac = new AbortController(); - // Poll close sentinel during turn + // Poll close sentinel + heartbeat during turn + let turnSeconds = 0; const sentinel = setInterval(() => { if (shouldClose()) { log('Close sentinel detected during turn, aborting'); ac.abort(); + return; } - }, IPC_POLL_MS); + turnSeconds += 5; + if (turnSeconds % 60 === 0) { + log(`Turn in progress... (${Math.round(turnSeconds / 60)}min)`); + } + }, 5000); try { const turn = await thread.run(input, { signal: ac.signal }); diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index 1c7e59e..ebb1de2 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -13,6 +13,8 @@ vi.mock('./config.js', () => ({ DATA_DIR: '/tmp/nanoclaw-test-data', GROUPS_DIR: '/tmp/nanoclaw-test-groups', IDLE_TIMEOUT: 1800000, // 30min + SERVICE_ID: 'claude', + SERVICE_AGENT_TYPE: 'claude-code', TIMEZONE: 'America/Los_Angeles', })); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 71354df..c80b323 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -424,22 +424,6 @@ export async function runAgentProcess( } }); - proc.stderr.on('data', (data) => { - const chunk = data.toString(); - const lines = chunk.trim().split('\n'); - for (const line of lines) { - if (line) logger.debug({ agent: group.folder }, line); - } - if (stderrTruncated) return; - const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length; - if (chunk.length > remaining) { - stderr += chunk.slice(0, remaining); - stderrTruncated = true; - } else { - stderr += chunk; - } - }); - let timedOut = false; let hadStreamingOutput = false; const configTimeout = group.agentConfig?.timeout || AGENT_TIMEOUT; @@ -465,6 +449,29 @@ export async function runAgentProcess( timeout = setTimeout(killOnTimeout, timeoutMs); }; + proc.stderr.on('data', (data) => { + const chunk = data.toString(); + const lines = chunk.trim().split('\n'); + for (const line of lines) { + if (!line) continue; + if (line.includes('Turn in progress')) { + logger.info({ group: group.name }, line.replace(/^\[.*?\]\s*/, '')); + } else { + logger.debug({ agent: group.folder }, line); + } + } + // Stderr activity means agent is alive — reset timeout + resetTimeout(); + if (stderrTruncated) return; + const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length; + if (chunk.length > remaining) { + stderr += chunk.slice(0, remaining); + stderrTruncated = true; + } else { + stderr += chunk; + } + }); + proc.on('close', (code) => { clearTimeout(timeout); const duration = Date.now() - startTime; diff --git a/src/index.ts b/src/index.ts index d46af4b..95265b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ import { storeChatMetadata, storeMessage, } from './db.js'; +import { readEnvFile } from './env.js'; import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; @@ -552,13 +553,19 @@ interface CodexRateLimit { async function fetchClaudeUsage(): Promise { try { - 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')); - const token = creds?.claudeAiOauth?.accessToken; + 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', { From e23315ed4821593c00249e80313d21e543b66e91 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 15 Mar 2026 22:37:40 +0900 Subject: [PATCH 05/17] feat: usage/server dashboard with progress bars and aligned labels --- src/index.ts | 112 ++++++++++++++++++++++++++++----------------------- 1 file changed, 62 insertions(+), 50 deletions(-) diff --git a/src/index.ts b/src/index.ts index 95265b6..2848437 100644 --- a/src/index.ts +++ b/src/index.ts @@ -680,69 +680,76 @@ async function buildUsageContent(): Promise { fetchCodexUsage(), ]); - // Claude Code usage + // Progress bar helper + const bar = (pct: number) => { + const filled = Math.round(pct / 10); + return '█'.repeat(filled) + '░'.repeat(10 - filled); + }; + + // Unified usage section with progress bars + lines.push('📊 *사용량*'); + + type UsageRow = { name: string; h5pct: number; h5reset: string; d7pct: number; d7reset: string }; + const rows: UsageRow[] = []; + if (claudeUsage) { - lines.push('☁️ *Claude Code*'); - if (claudeUsage.five_hour) { - // utilization may be fraction (0-1) or percentage (0-100) - const raw = claudeUsage.five_hour.utilization; - const pct = raw > 1 ? Math.round(raw) : Math.round(raw * 100); - lines.push( - `• 5시간: ${usageEmoji(pct)} ${pct}% (리셋: ${formatResetKST(claudeUsage.five_hour.resets_at)})`, - ); - } - if (claudeUsage.seven_day) { - const raw = claudeUsage.seven_day.utilization; - const pct = raw > 1 ? Math.round(raw) : Math.round(raw * 100); - lines.push( - `• 7일: ${usageEmoji(pct)} ${pct}% (리셋: ${formatResetKST(claudeUsage.seven_day.resets_at)})`, - ); - } - lines.push(''); + 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) : '', + }); } - // Codex usage if (codexUsage && Array.isArray(codexUsage)) { - lines.push('🤖 *Codex CLI*'); - for (const limit of codexUsage) { - const p = Math.round(limit.primary.usedPercent); - const s = Math.round(limit.secondary.usedPercent); - const name = limit.limitName || limit.limitId || 'Codex'; - lines.push( - `• ${name} 5시간: ${usageEmoji(p)} ${p}% (리셋: ${formatResetKST(limit.primary.resetsAt)})`, - ); - lines.push( - `• ${name} 7일: ${usageEmoji(s)} ${s}% (리셋: ${formatResetKST(limit.secondary.resetsAt)})`, - ); + const relevant = codexUsage.filter( + (l) => l.primary.usedPercent > 0 || l.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), + }); } - lines.push(''); } - if (!claudeUsage && !codexUsage) { - lines.push('_API 사용량 조회 불가_'); - lines.push(''); + if (rows.length > 0) { + lines.push('```'); + lines.push(' 5-Hour 7-Day'); + for (const r of rows) { + const h5 = r.h5pct >= 0 ? `${bar(r.h5pct)} ${String(r.h5pct).padStart(3)}%` : ' — '; + const d7 = r.d7pct >= 0 ? `${bar(r.d7pct)} ${String(r.d7pct).padStart(3)}%` : ' — '; + lines.push(`${r.name.padEnd(8)}${h5} ${d7}`); + } + lines.push('```'); + } else { + lines.push('_조회 불가_'); } + lines.push(''); - // System resources + // System resources with progress bars lines.push('🖥️ *서버*'); const loadAvg = os.loadavg(); const cpuCount = os.cpus().length; - const cpuPct1 = Math.round((loadAvg[0] / cpuCount) * 100); - const cpuPct5 = Math.round((loadAvg[1] / cpuCount) * 100); - const cpuPct15 = Math.round((loadAvg[2] / cpuCount) * 100); - lines.push( - `• CPU: ${usageEmoji(cpuPct1)} ${cpuPct1}% (1m) | ${cpuPct5}% (5m) | ${cpuPct15}% (15m)`, - ); + const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100); // 5min avg const totalMem = os.totalmem(); const usedMem = totalMem - os.freemem(); const memPct = Math.round((usedMem / totalMem) * 100); - lines.push( - `• 메모리: ${usageEmoji(memPct)} ${memPct}% (${(usedMem / 1073741824).toFixed(1)}GB / ${(totalMem / 1073741824).toFixed(1)}GB)`, - ); + const memUsedGB = (usedMem / 1073741824).toFixed(1); + const memTotalGB = (totalMem / 1073741824).toFixed(1); - let diskLine = '• 디스크: 확인 불가'; + let diskPct = 0; + let diskUsedGB = '?'; + let diskTotalGB = '?'; try { const df = execSync('df -B1 / | tail -1', { encoding: 'utf-8', @@ -751,18 +758,23 @@ async function buildUsageContent(): Promise { const parts = df.split(/\s+/); const diskUsed = parseInt(parts[2], 10); const diskTotal = parseInt(parts[1], 10); - const diskPct = Math.round((diskUsed / diskTotal) * 100); - diskLine = `• 디스크: ${usageEmoji(diskPct)} ${diskPct}% (${(diskUsed / 1073741824).toFixed(1)}GB / ${(diskTotal / 1073741824).toFixed(1)}GB)`; + diskPct = Math.round((diskUsed / diskTotal) * 100); + diskUsedGB = (diskUsed / 1073741824).toFixed(0); + diskTotalGB = (diskTotal / 1073741824).toFixed(0); } catch { /* ignore */ } - lines.push(diskLine); - lines.push(`• 업타임: ${formatElapsed(os.uptime() * 1000)}`); + 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\n_${new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}_` + `\n_${new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}_` ); } From 7e77f3794846c39b92b4711e2d893b04e8bcc733 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 16 Mar 2026 05:08:19 +0900 Subject: [PATCH 06/17] 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 --- README.md | 51 +- setup/container.ts | 4 +- setup/register.ts | 25 +- src/agent-runner.ts | 79 ++- src/dashboard.ts | 520 ++++++++++++++++++ src/db.test.ts | 13 + src/db.ts | 68 +-- src/group-queue.ts | 146 +++++- src/index.ts | 987 ++--------------------------------- src/ipc.ts | 2 - src/message-runtime.ts | 528 +++++++++++++++++++ src/router.ts | 10 - src/session-commands.test.ts | 25 + src/session-commands.ts | 18 +- 14 files changed, 1392 insertions(+), 1084 deletions(-) create mode 100644 src/dashboard.ts create mode 100644 src/message-runtime.ts diff --git a/README.md b/README.md index 25df2a7..f39fdfe 100644 --- a/README.md +++ b/README.md @@ -15,29 +15,26 @@ Two AI agents running as parallel systemd services, communicating over Discord: - **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 - **Direct host processes** — no container overhead, agents run natively - **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 -- **OAuth auto-refresh** — token lifecycle managed automatically for headless environments - **Priority queue** — per-group serialization, global concurrency limit, idle preemption -- **Auto-continue** — Codex text-only turns automatically retried to enforce task execution ## Architecture ``` Discord ──► SQLite ──► GroupQueue ──┬──► Claude Agent SDK (host process) - └──► Codex App-Server (JSON-RPC stdio) - ├── thread/start, thread/resume - ├── turn/start (streaming, multimodal) - ├── turn/steer (mid-turn injection) - ├── Auto-approval (bypass sandbox) - └── Auto-continue (text-only turn retry) + └──► Codex SDK (long-lived thread runner) + ├── thread start/resume + ├── multimodal input + ├── per-group model/effort config + └── follow-up messages via IPC ``` ### Directory Layout @@ -52,7 +49,6 @@ nanoclaw/ │ ├── router.ts # Outbound message formatting and routing │ ├── sender-allowlist.ts # Security: sender-based access control │ ├── session-commands.ts # Session commands (/compact) -│ ├── token-refresh.ts # OAuth auto-refresh + session directory sync │ ├── task-scheduler.ts # Scheduled tasks (cron/interval/once) │ ├── ipc.ts # IPC watcher and task processing │ ├── db.ts # SQLite operations @@ -62,7 +58,7 @@ nanoclaw/ │ └── discord.ts # Discord: mentions, images, typing, file attachments ├── runners/ │ ├── 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.) ├── store/ # Claude Code service DB ├── store-codex/ # Codex service DB @@ -75,17 +71,15 @@ nanoclaw/ └── 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 -- **Streaming**: `item/agentMessage/delta` notifications for real-time text -- **Mid-turn steering**: IPC messages injected via `turn/steer` during execution -- **Auto-approval**: `approvalPolicy: "never"` + `sandbox: "danger-full-access"` -- **Auto-continue**: Detects text-only turns (no tool execution) and automatically retries up to 5 times to nudge the agent into actually executing tasks -- **Multimodal input**: Image attachments converted to `localImage` input blocks in `turn/start` -- **Per-group config**: Model, effort, MCP servers configured per channel +- **Session persistence**: Thread IDs stored in DB and resumed per group +- **Follow-up messages**: Additional Discord messages are injected into the active runner via IPC +- **Auto-approval**: `approvalPolicy: "never"` + `sandboxMode: "danger-full-access"` +- **Multimodal input**: Image attachments converted to `local_image` SDK inputs +- **Per-group config**: Model and reasoning effort can be overridden per channel ### 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 - 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 `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 # 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 -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: @@ -254,7 +239,7 @@ Fields: | `jid` | `dc:` | | `name` | Display name | | `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`) | | `work_dir` | Optional working directory override | | `container_config` | Optional JSON (e.g., `{"codexEffort":"high"}`) | diff --git a/setup/container.ts b/setup/container.ts index cdcb92d..4a405ea 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -29,14 +29,14 @@ export async function run(_args: string[]): Promise { // Verify runner entry points exist const agentRunner = path.join( projectRoot, - 'container', + 'runners', 'agent-runner', 'dist', 'index.js', ); const codexRunner = path.join( projectRoot, - 'container', + 'runners', 'codex-runner', 'dist', 'index.js', diff --git a/setup/register.ts b/setup/register.ts index 03ea7df..33e77cb 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -9,7 +9,7 @@ import path from 'path'; 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 { logger } from '../src/logger.js'; import { emitStatus } from './status.js'; @@ -113,15 +113,31 @@ export async function run(args: string[]): Promise { 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 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; db.prepare( `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main) - VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, + (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, agent_type, work_dir) + VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, NULL)`, ).run( parsed.jid, parsed.name, @@ -130,6 +146,7 @@ export async function run(args: string[]): Promise { timestamp, requiresTriggerInt, isMainInt, + SERVICE_AGENT_TYPE, ); db.close(); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index c80b323..2518347 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -29,6 +29,7 @@ export interface AgentInput { sessionId?: string; groupFolder: string; chatJid: string; + runId?: string; isMain: boolean; isScheduledTask?: boolean; assistantName?: string; @@ -324,15 +325,19 @@ export async function runAgentProcess( group, input.isMain, ); + if (input.runId) { + env.NANOCLAW_RUN_ID = input.runId; + } 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 const distEntry = path.join(runnerDir, 'dist', 'index.js'); if (!fs.existsSync(distEntry)) { logger.error( - { runnerDir }, + { runnerDir, chatJid: input.chatJid, runId: input.runId }, 'Runner not built. Run: cd runners/agent-runner && npm install && npm run build', ); return { @@ -345,6 +350,9 @@ export async function runAgentProcess( logger.info( { group: group.name, + chatJid: input.chatJid, + groupFolder: input.groupFolder, + runId: input.runId, processName, agentType: group.agentType || 'claude-code', isMain: input.isMain, @@ -386,7 +394,7 @@ export async function runAgentProcess( stdout += chunk.slice(0, remaining); stdoutTruncated = true; 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', ); } else { @@ -416,7 +424,7 @@ export async function runAgentProcess( outputChain = outputChain.then(() => onOutput(parsed)); } catch (err) { logger.warn( - { group: group.name, error: err }, + { group: group.name, chatJid: input.chatJid, runId: input.runId, error: err }, 'Failed to parse streamed output chunk', ); } @@ -432,7 +440,7 @@ export async function runAgentProcess( const killOnTimeout = () => { timedOut = true; logger.error( - { group: group.name, processName }, + { group: group.name, chatJid: input.chatJid, runId: input.runId, processName }, 'Agent timeout, sending SIGTERM', ); proc.kill('SIGTERM'); @@ -455,9 +463,15 @@ export async function runAgentProcess( for (const line of lines) { if (!line) continue; 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 { - 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 @@ -479,11 +493,13 @@ export async function runAgentProcess( if (timedOut) { const ts = new Date().toISOString().replace(/[:.]/g, '-'); fs.writeFileSync( - path.join(logsDir, `agent-${ts}.log`), + path.join(logsDir, `agent-${input.runId || 'adhoc'}-${ts}.log`), [ `=== Agent Run Log (TIMEOUT) ===`, `Timestamp: ${new Date().toISOString()}`, `Group: ${group.name}`, + `ChatJid: ${input.chatJid}`, + `RunId: ${input.runId || 'n/a'}`, `Process: ${processName}`, `Duration: ${duration}ms`, `Exit Code: ${code}`, @@ -493,7 +509,14 @@ export async function runAgentProcess( if (hadStreamingOutput) { 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)', ); outputChain.then(() => { @@ -511,7 +534,10 @@ export async function runAgentProcess( } 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 = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; @@ -519,6 +545,9 @@ export async function runAgentProcess( `=== Agent Run Log ===`, `Timestamp: ${new Date().toISOString()}`, `Group: ${group.name}`, + `ChatJid: ${input.chatJid}`, + `GroupFolder: ${input.groupFolder}`, + `RunId: ${input.runId || 'n/a'}`, `IsMain: ${input.isMain}`, `AgentType: ${group.agentType || 'claude-code'}`, `Duration: ${duration}ms`, @@ -549,7 +578,14 @@ export async function runAgentProcess( if (code !== 0) { logger.error( - { group: group.name, code, duration, logFile }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + code, + duration, + logFile, + }, 'Agent exited with error', ); resolve({ @@ -563,7 +599,13 @@ export async function runAgentProcess( if (onOutput) { outputChain.then(() => { logger.info( - { group: group.name, duration, newSessionId }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + duration, + newSessionId, + }, 'Agent completed (streaming mode)', ); resolve({ status: 'success', result: null, newSessionId }); @@ -586,13 +628,19 @@ export async function runAgentProcess( } const output: AgentOutput = JSON.parse(jsonLine); logger.info( - { group: group.name, duration, status: output.status }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + duration, + status: output.status, + }, 'Agent completed', ); resolve(output); } catch (err) { logger.error( - { group: group.name, error: err }, + { group: group.name, chatJid: input.chatJid, runId: input.runId, error: err }, 'Failed to parse agent output', ); resolve({ @@ -606,7 +654,7 @@ export async function runAgentProcess( proc.on('error', (err) => { clearTimeout(timeout); logger.error( - { group: group.name, processName, error: err }, + { group: group.name, chatJid: input.chatJid, runId: input.runId, processName, error: err }, 'Agent spawn error', ); resolve({ @@ -651,7 +699,6 @@ export function writeGroupsSnapshot( groupFolder: string, isMain: boolean, groups: AvailableGroup[], - registeredJids: Set, ): void { const groupIpcDir = resolveGroupIpcPath(groupFolder); fs.mkdirSync(groupIpcDir, { recursive: true }); diff --git a/src/dashboard.ts b/src/dashboard.ts new file mode 100644 index 0000000..68edc6f --- /dev/null +++ b/src/dashboard.ts @@ -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; + 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 = { + 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(); +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 { + 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(); + 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 { + 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 { + 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), + 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 { + 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, +): Promise { + 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 { + 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 { + 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'); +} diff --git a/src/db.test.ts b/src/db.test.ts index b40a3a7..0ded8c5 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -3,12 +3,15 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, createTask, + deleteSession, deleteTask, getAllChats, getAllRegisteredGroups, getMessagesSince, getNewMessages, + getSession, getTaskById, + setSession, setRegisteredGroup, storeChatMetadata, 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 --- describe('storeChatMetadata', () => { diff --git a/src/db.ts b/src/db.ts index 79934fb..4ac6c63 100644 --- a/src/db.ts +++ b/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 { jid: string; name: string; @@ -286,27 +272,6 @@ export function getAllChats(): 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. * 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( jids: string[], lastTimestamp: string, @@ -606,6 +544,12 @@ export function setSession(groupFolder: string, sessionId: string): void { ).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 { const rows = db .prepare( diff --git a/src/group-queue.ts b/src/group-queue.ts index 10fa6c4..46c05e6 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -11,6 +11,11 @@ interface QueuedTask { fn: () => Promise; } +export interface GroupRunContext { + runId: string; + reason: 'messages' | 'drain'; +} + const MAX_RETRIES = 5; const BASE_RETRY_MS = 5000; @@ -19,6 +24,7 @@ interface GroupState { idleWaiting: boolean; isTaskProcess: boolean; runningTaskId: string | null; + currentRunId: string | null; pendingMessages: boolean; pendingTasks: QueuedTask[]; process: ChildProcess | null; @@ -40,8 +46,9 @@ export class GroupQueue { private groups = new Map(); private activeCount = 0; private waitingGroups: string[] = []; - private processMessagesFn: ((groupJid: string) => Promise) | null = - null; + private processMessagesFn: + | ((groupJid: string, context: GroupRunContext) => Promise) + | null = null; private shuttingDown = false; private getGroup(groupJid: string): GroupState { @@ -52,6 +59,7 @@ export class GroupQueue { idleWaiting: false, isTaskProcess: false, runningTaskId: null, + currentRunId: null, pendingMessages: false, pendingTasks: [], process: null, @@ -65,10 +73,16 @@ export class GroupQueue { return state; } - setProcessMessagesFn(fn: (groupJid: string) => Promise): void { + setProcessMessagesFn( + fn: (groupJid: string, context: GroupRunContext) => Promise, + ): void { 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 { if (this.shuttingDown) return; @@ -154,17 +168,38 @@ export class GroupQueue { state.process = proc; state.processName = processName; 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). * 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); state.idleWaiting = true; + logger.info( + { + groupJid, + runId: runId ?? state.currentRunId, + pendingTasks: state.pendingTasks.length, + }, + 'Agent entered idle wait state', + ); 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 { 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; + } state.idleWaiting = false; // Agent is about to receive work, no longer idle const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input'); @@ -186,8 +232,22 @@ export class GroupQueue { const tempPath = `${filepath}.tmp`; fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text })); 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; - } catch { + } catch (err) { + logger.warn( + { groupJid, runId: state.currentRunId, groupFolder: state.groupFolder, err }, + 'Failed to queue follow-up message for active agent', + ); return false; } } @@ -195,7 +255,10 @@ export class GroupQueue { /** * 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); if (!state.active || !state.groupFolder) return; @@ -203,8 +266,26 @@ export class GroupQueue { try { fs.mkdirSync(inputDir, { recursive: true }); fs.writeFileSync(path.join(inputDir, '_close'), ''); - } catch { - // ignore + logger.info( + { + 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', ): Promise { const state = this.getGroup(groupJid); + const runId = this.createRunId(); state.active = true; state.idleWaiting = false; state.isTaskProcess = false; + state.currentRunId = runId; state.pendingMessages = false; state.startedAt = Date.now(); this.activeCount++; - logger.debug( - { groupJid, reason, activeCount: this.activeCount }, - 'Starting agent process for group', + logger.info( + { groupJid, runId, reason, activeCount: this.activeCount }, + 'Starting group message run', ); + let outcome: 'success' | 'retry_scheduled' | 'error' = 'success'; try { if (this.processMessagesFn) { - const success = await this.processMessagesFn(groupJid); + const success = await this.processMessagesFn(groupJid, { runId, reason }); if (success) { state.retryCount = 0; } else { - this.scheduleRetry(groupJid, state); + outcome = 'retry_scheduled'; + this.scheduleRetry(groupJid, state, runId); } } } catch (err) { - logger.error({ groupJid, err }, 'Error processing messages for group'); - this.scheduleRetry(groupJid, state); + outcome = 'error'; + logger.error({ groupJid, runId, err }, 'Error processing messages for group'); + this.scheduleRetry(groupJid, state, runId); } 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.startedAt = null; state.process = null; state.processName = null; state.groupFolder = null; + state.currentRunId = null; this.activeCount--; 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++; if (state.retryCount > MAX_RETRIES) { logger.error( - { groupJid, retryCount: state.retryCount }, + { groupJid, runId, retryCount: state.retryCount }, 'Max retries exceeded, dropping messages (will retry on next incoming message)', ); state.retryCount = 0; @@ -292,7 +396,7 @@ export class GroupQueue { const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1); logger.info( - { groupJid, retryCount: state.retryCount, delayMs }, + { groupJid, runId, retryCount: state.retryCount, delayMs }, 'Scheduling retry with backoff', ); setTimeout(() => { @@ -412,7 +516,7 @@ export class GroupQueue { // via idle timeout or agent timeout. // This prevents reconnection restarts from killing working agents. const activeProcesses: string[] = []; - for (const [jid, state] of this.groups) { + for (const [, state] of this.groups) { if (state.process && !state.process.killed && state.processName) { activeProcesses.push(state.processName); } diff --git a/src/index.ts b/src/index.ts index 2848437..6824b73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,4 @@ -import { ChildProcess, execSync, spawn } from 'child_process'; import fs from 'fs'; -import os from 'os'; import path from 'path'; import { @@ -19,21 +17,16 @@ import { getChannelFactory, getRegisteredChannelNames, } from './channels/registry.js'; +import { AvailableGroup, writeGroupsSnapshot } from './agent-runner.js'; import { - AgentOutput, - runAgentProcess, - writeGroupsSnapshot, - writeTasksSnapshot, -} from './agent-runner.js'; + purgeDashboardChannel, + startStatusDashboard, + startUsageDashboard, +} from './dashboard.js'; import { - getAllChats, + deleteSession, getAllRegisteredGroups, getAllSessions, - getAllTasks, - getLastHumanMessageTimestamp, - getMessagesSince, - getNewMessages, - getRegisteredGroup, getRouterState, initDatabase, setRegisteredGroup, @@ -42,24 +35,21 @@ import { storeChatMetadata, storeMessage, } from './db.js'; -import { readEnvFile } from './env.js'; import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; -import { findChannel, formatMessages, formatOutbound } from './router.js'; +import { + createMessageRuntime, + getAvailableGroups as getAvailableGroupsForRegisteredGroups, +} from './message-runtime.js'; +import { findChannel, formatOutbound } from './router.js'; import { isSenderAllowed, - isTriggerAllowed, loadSenderAllowlist, shouldDropMessage, } from './sender-allowlist.js'; -import { - extractSessionCommand, - handleSessionCommand, - isSessionCommandAllowed, -} from './session-commands.js'; import { startSchedulerLoop } from './task-scheduler.js'; -import { Channel, ChannelMeta, NewMessage, RegisteredGroup } from './types.js'; +import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; // Re-export for backwards compatibility during refactor @@ -69,10 +59,34 @@ let lastTimestamp = ''; let sessions: Record = {}; let registeredGroups: Record = {}; let lastAgentTimestamp: Record = {}; -let messageLoopRunning = false; const channels: Channel[] = []; const queue = new GroupQueue(); +const messageRuntime = createMessageRuntime({ + assistantName: ASSISTANT_NAME, + idleTimeout: IDLE_TIMEOUT, + pollInterval: POLL_INTERVAL, + timezone: TIMEZONE, + triggerPattern: TRIGGER_PATTERN, + channels, + queue, + getRegisteredGroups: () => registeredGroups, + getSessions: () => sessions, + getLastTimestamp: () => lastTimestamp, + setLastTimestamp: (timestamp) => { + lastTimestamp = timestamp; + }, + getLastAgentTimestamps: () => lastAgentTimestamp, + saveState, + persistSession: (groupFolder, sessionId) => { + sessions[groupFolder] = sessionId; + setSession(groupFolder, sessionId); + }, + clearSession: (groupFolder) => { + delete sessions[groupFolder]; + deleteSession(groupFolder); + }, +}); function loadState(): void { lastTimestamp = getRouterState('last_timestamp') || ''; @@ -124,18 +138,8 @@ function registerGroup(jid: string, group: RegisteredGroup): void { * Get available groups list for the agent. * Returns groups ordered by most recent activity. */ -export function getAvailableGroups(): import('./agent-runner.js').AvailableGroup[] { - const chats = getAllChats(); - const registeredJids = new Set(Object.keys(registeredGroups)); - - return chats - .filter((c) => c.jid !== '__group_sync__' && c.is_group) - .map((c) => ({ - jid: c.jid, - name: c.name, - lastActivity: c.last_message_time, - isRegistered: registeredJids.has(c.jid), - })); +export function getAvailableGroups(): AvailableGroup[] { + return getAvailableGroupsForRegisteredGroups(registeredGroups); } /** @internal - exported for testing */ @@ -145,886 +149,6 @@ export function _setRegisteredGroups( registeredGroups = groups; } -/** - * Process all pending messages for a group. - * Called by the GroupQueue when it's this group's turn. - */ -async function processGroupMessages(chatJid: string): Promise { - const group = registeredGroups[chatJid]; - if (!group) return true; - - const channel = findChannel(channels, chatJid); - if (!channel) { - logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); - return true; - } - - const isMainGroup = group.isMain === true; - - const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; - const missedMessages = getMessagesSince( - chatJid, - sinceTimestamp, - ASSISTANT_NAME, - ); - - if (missedMessages.length === 0) return true; - - // --- Session command interception (before trigger check) --- - const cmdResult = await handleSessionCommand({ - missedMessages, - isMainGroup, - groupName: group.name, - triggerPattern: TRIGGER_PATTERN, - timezone: TIMEZONE, - deps: { - sendMessage: (text) => channel.sendMessage(chatJid, text), - setTyping: (typing) => - channel.setTyping?.(chatJid, typing) ?? Promise.resolve(), - runAgent: (prompt, onOutput) => - runAgent(group, prompt, chatJid, onOutput), - closeStdin: () => queue.closeStdin(chatJid), - advanceCursor: (ts) => { - lastAgentTimestamp[chatJid] = ts; - saveState(); - }, - formatMessages, - canSenderInteract: (msg) => { - const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim()); - const reqTrigger = !isMainGroup && group.requiresTrigger !== false; - return ( - isMainGroup || - !reqTrigger || - (hasTrigger && - (msg.is_from_me || - isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist()))) - ); - }, - }, - }); - if (cmdResult.handled) return cmdResult.success; - // --- End session command interception --- - - // For non-main groups, check if trigger is required and present - if (!isMainGroup && group.requiresTrigger !== false) { - const allowlistCfg = loadSenderAllowlist(); - const hasTrigger = missedMessages.some( - (m) => - TRIGGER_PATTERN.test(m.content.trim()) && - (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), - ); - if (!hasTrigger) { - return true; - } - } - - const prompt = formatMessages(missedMessages, TIMEZONE); - - // Advance cursor so the piping path in startMessageLoop won't re-fetch - // these messages. Save the old cursor so we can roll back on error. - const previousCursor = lastAgentTimestamp[chatJid] || ''; - lastAgentTimestamp[chatJid] = - missedMessages[missedMessages.length - 1].timestamp; - saveState(); - - logger.info( - { group: group.name, messageCount: missedMessages.length }, - 'Processing messages', - ); - - // Track idle timer for closing stdin when agent is idle - let idleTimer: ReturnType | null = null; - - const resetIdleTimer = () => { - if (idleTimer) clearTimeout(idleTimer); - idleTimer = setTimeout(() => { - logger.debug({ group: group.name }, 'Idle timeout, closing agent stdin'); - queue.closeStdin(chatJid); - }, IDLE_TIMEOUT); - }; - - let hadError = false; - let outputSentToUser = false; - - await channel.setTyping?.(chatJid, true); - - const output = await runAgent(group, prompt, chatJid, async (result) => { - if (result.result) { - const raw = - typeof result.result === 'string' - ? result.result - : JSON.stringify(result.result); - const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); - logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`); - if (text) { - await channel.sendMessage(chatJid, text); - outputSentToUser = true; - } - } - - // Always clear typing and reset idle timer on any output (including null results) - await channel.setTyping?.(chatJid, false); - resetIdleTimer(); - - if (result.status === 'success') { - queue.notifyIdle(chatJid); - } - - 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( - { group: group.name }, - 'Agent error after output was sent, skipping cursor rollback to prevent duplicates', - ); - return true; - } - lastAgentTimestamp[chatJid] = previousCursor; - saveState(); - logger.warn( - { group: group.name }, - 'Agent error, rolled back message cursor for retry', - ); - return false; - } - - return true; -} - -async function runAgent( - group: RegisteredGroup, - prompt: string, - chatJid: string, - onOutput?: (output: AgentOutput) => Promise, -): Promise<'success' | 'error'> { - const isMain = group.isMain === true; - const sessionId = sessions[group.folder]; - - // Update tasks snapshot for agent to read (filtered by group) - const tasks = getAllTasks(); - writeTasksSnapshot( - group.folder, - isMain, - tasks.map((t) => ({ - id: t.id, - groupFolder: t.group_folder, - prompt: t.prompt, - schedule_type: t.schedule_type, - schedule_value: t.schedule_value, - status: t.status, - next_run: t.next_run, - })), - ); - - // Update available groups snapshot (main group only can see all groups) - const availableGroups = getAvailableGroups(); - writeGroupsSnapshot( - group.folder, - isMain, - availableGroups, - new Set(Object.keys(registeredGroups)), - ); - - // Wrap onOutput to track session ID from streamed results - const wrappedOnOutput = onOutput - ? async (output: AgentOutput) => { - if (output.newSessionId) { - sessions[group.folder] = output.newSessionId; - setSession(group.folder, output.newSessionId); - } - await onOutput(output); - } - : undefined; - - try { - const output = await runAgentProcess( - group, - { - prompt, - sessionId, - groupFolder: group.folder, - chatJid, - isMain, - assistantName: ASSISTANT_NAME, - }, - (proc, processName) => - queue.registerProcess(chatJid, proc, processName, group.folder), - wrappedOnOutput, - ); - - if (output.newSessionId) { - sessions[group.folder] = output.newSessionId; - setSession(group.folder, output.newSessionId); - } - - if (output.status === 'error') { - logger.error( - { group: group.name, error: output.error }, - 'Agent process error', - ); - return 'error'; - } - - return 'success'; - } catch (err) { - logger.error({ group: group.name, err }, 'Agent error'); - return 'error'; - } -} - -// ── Status & Usage Dashboards ─────────────────────────────────── - -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 formatBytes(bytes: number): string { - if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)}GB`; - if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)}MB`; - return `${(bytes / 1024).toFixed(0)}KB`; -} - -function usageEmoji(pct: number): string { - if (pct >= 80) return '🔴'; - if (pct >= 50) return '🟡'; - return '🟢'; -} - -function formatResetKST(value: string | number): string { - try { - // Handle unix timestamp (seconds) or ISO string - 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); - } -} - -const STATUS_ICONS: Record = { - processing: '🟡', - idle: '🟢', - waiting: '🔵', - inactive: '⚪', -}; - -let statusMessageId: string | null = null; -let usageMessageId: string | null = null; - -// Cache for Discord channel metadata (name, position, category) -let channelMetaCache = new Map(); -let channelMetaLastRefresh = 0; -const CHANNEL_META_REFRESH_MS = 300000; // 5 minutes - -async function refreshChannelMeta(): Promise { - const now = Date.now(); - if (now - channelMetaLastRefresh < CHANNEL_META_REFRESH_MS) return; - - const ch = channels.find( - (c) => c.name.startsWith('discord') && c.isConnected() && c.getChannelMeta, - ); - if (!ch?.getChannelMeta) return; - - const jids = Object.keys(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(s: import('./group-queue.js').GroupStatus): string { - if (s.status === 'processing') - return `처리 중 (${formatElapsed(s.elapsedMs || 0)})`; - if (s.status === 'idle') return '대기 중'; - if (s.status === 'waiting') - return s.pendingTasks > 0 - ? `큐 대기 (태스크 ${s.pendingTasks}개)` - : '큐 대기 (메시지)'; - return '비활성'; -} - -function buildStatusContent(): string { - const jids = Object.keys(registeredGroups); - const statuses = queue.getStatuses(jids); - - const entries = statuses - .map((s) => ({ - status: s, - group: registeredGroups[s.jid], - meta: channelMetaCache.get(s.jid), - })) - .filter((e) => e.group); - - // Group by category - const categoryMap = new Map(); - for (const entry of entries) { - const cat = entry.meta?.category || '기타'; - if (!categoryMap.has(cat)) categoryMap.set(cat, []); - categoryMap.get(cat)!.push(entry); - } - - // Sort categories by position - 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 [catName, catEntries] of sortedCategories) { - catEntries.sort( - (a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999), - ); - - const lines = catEntries.map((e) => { - const icon = STATUS_ICONS[e.status.status] || '⚪'; - const label = getStatusLabel(e.status); - // Prefer actual Discord channel name over DB-stored name - const name = e.meta?.name ? `#${e.meta.name}` : e.group.name; - return ` ${icon} **${name}** — ${label}`; - }); - - if (channelMetaCache.size > 0 && catName !== '기타') { - sections.push(`📁 **${catName}**\n${lines.join('\n')}`); - } else { - sections.push(lines.join('\n')); - } - - totalActive += catEntries.filter( - (e) => e.status.status === 'processing', - ).length; - totalIdle += catEntries.filter((e) => e.status.status === 'idle').length; - total += catEntries.length; - } - - const header = `**에이전트 상태** (${ASSISTANT_NAME}) — 활성 ${totalActive} | 대기 ${totalIdle} | 전체 ${total}`; - return `${header}\n\n${sections.join('\n\n')}\n\n_${new Date().toLocaleTimeString('ko-KR')}_`; -} - -// ── API Usage Fetchers ────────────────────────────────────────── - -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 }; -} - -async function fetchClaudeUsage(): Promise { - 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 { - // Find codex binary - 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 = (val: CodexRateLimit[] | null) => { - if (done) return; - done = true; - clearTimeout(timer); - try { - proc.kill(); - } catch { - /* ignore */ - } - resolve(val); - }; - - const timer = setTimeout(() => finish(null), 20000); - - let proc: ChildProcess; - try { - proc = spawn(codexBin, ['app-server'], { - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...(process.env as Record), - PATH: [ - path.dirname(process.execPath), - path.join(os.homedir(), '.npm-global', 'bin'), - process.env.PATH || '', - ].join(':'), - }, - }); - } catch { - resolve(null); - return; - } - - proc.on('error', () => finish(null)); - proc.on('close', () => finish(null)); - - let buf = ''; - proc.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) { - // Initialize done, query rate limits - proc.stdin!.write( - JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'account/rateLimits/read', - params: {}, - }) + '\n', - ); - } else if (msg.id === 2 && msg.result) { - // Extract rate limits from rateLimitsByLimitId object - const byId = msg.result.rateLimitsByLimitId; - if (byId && typeof byId === 'object') { - finish(Object.values(byId) as CodexRateLimit[]); - } else { - finish(null); - } - } - } catch { - /* non-JSON line, skip */ - } - } - }); - - // Send initialize - proc.stdin!.write( - JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { clientInfo: { name: 'usage-monitor', version: '1.0' } }, - }) + '\n', - ); - }); -} - -// ── Usage Dashboard Builder ───────────────────────────────────── - -async function buildUsageContent(): Promise { - const lines: string[] = []; - - // Fetch API usage in parallel - const [claudeUsage, codexUsage] = await Promise.all([ - fetchClaudeUsage(), - fetchCodexUsage(), - ]); - - // Progress bar helper - const bar = (pct: number) => { - const filled = Math.round(pct / 10); - return '█'.repeat(filled) + '░'.repeat(10 - filled); - }; - - // Unified usage section with progress bars - 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( - (l) => l.primary.usedPercent > 0 || l.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 r of rows) { - const h5 = r.h5pct >= 0 ? `${bar(r.h5pct)} ${String(r.h5pct).padStart(3)}%` : ' — '; - const d7 = r.d7pct >= 0 ? `${bar(r.d7pct)} ${String(r.d7pct).padStart(3)}%` : ' — '; - lines.push(`${r.name.padEnd(8)}${h5} ${d7}`); - } - lines.push('```'); - } else { - lines.push('_조회 불가_'); - } - lines.push(''); - - // System resources with progress bars - lines.push('🖥️ *서버*'); - - const loadAvg = os.loadavg(); - const cpuCount = os.cpus().length; - const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100); // 5min avg - - 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' })}_` - ); -} - -// ── Dashboard Lifecycle ───────────────────────────────────────── - -async function startStatusDashboard(): Promise { - if (!STATUS_CHANNEL_ID) return; - - const statusJid = `dc:${STATUS_CHANNEL_ID}`; - - const findDiscordChannel = () => - channels.find((c) => c.name.startsWith('discord') && c.isConnected()); - - const updateStatus = async () => { - const ch = findDiscordChannel(); - if (!ch) return; - - try { - await refreshChannelMeta(); - const content = buildStatusContent(); - - 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, STATUS_UPDATE_INTERVAL); - await updateStatus(); - logger.info({ channelId: STATUS_CHANNEL_ID }, 'Status dashboard started'); -} - -let usageUpdateInProgress = false; - -async function startUsageDashboard(): Promise { - if (!STATUS_CHANNEL_ID) return; - // Only one service should show usage (set USAGE_DASHBOARD=true on that service) - if (process.env.USAGE_DASHBOARD !== 'true') return; - - const statusJid = `dc:${STATUS_CHANNEL_ID}`; - - const findDiscordChannel = () => - channels.find((c) => c.name.startsWith('discord') && c.isConnected()); - - const updateUsage = async () => { - if (usageUpdateInProgress) return; - usageUpdateInProgress = true; - - const ch = findDiscordChannel(); - 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, USAGE_UPDATE_INTERVAL); - await updateUsage(); - logger.info('Usage dashboard started'); -} - -async function startMessageLoop(): Promise { - if (messageLoopRunning) { - logger.debug('Message loop already running, skipping duplicate start'); - return; - } - messageLoopRunning = true; - - logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); - - while (true) { - try { - const jids = Object.keys(registeredGroups); - const { messages, newTimestamp } = getNewMessages( - jids, - lastTimestamp, - ASSISTANT_NAME, - ); - - if (messages.length > 0) { - logger.info({ count: messages.length }, 'New messages'); - - // Advance the "seen" cursor for all messages immediately - lastTimestamp = newTimestamp; - saveState(); - - // Deduplicate by group - const messagesByGroup = new Map(); - 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(channels, chatJid); - if (!channel) { - logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); - continue; - } - - const isMainGroup = group.isMain === true; - - // --- Bot-collaboration timeout --- - // If all new messages are from bots, only process if a human - // sent a message within the last 12 hours. - const BOT_COLLAB_TIMEOUT_MS = 12 * 60 * 60 * 1000; - const allFromBots = groupMessages.every( - (m) => m.is_from_me || !!m.is_bot_message, - ); - if (allFromBots) { - const lastHuman = getLastHumanMessageTimestamp(chatJid); - if ( - !lastHuman || - Date.now() - new Date(lastHuman).getTime() > BOT_COLLAB_TIMEOUT_MS - ) { - logger.info( - { chatJid, lastHuman }, - 'Bot-collaboration timeout: no human message within 12h, skipping', - ); - continue; - } - } - // --- End bot-collaboration timeout --- - - // --- Session command interception (message loop) --- - // Scan ALL messages in the batch for a session command. - const loopCmdMsg = groupMessages.find( - (m) => extractSessionCommand(m.content, TRIGGER_PATTERN) !== null, - ); - - if (loopCmdMsg) { - // Only close active agent if the sender is authorized — otherwise an - // untrusted user could kill in-flight work by sending /compact (DoS). - // closeStdin no-ops internally when no agent is active. - if ( - isSessionCommandAllowed( - isMainGroup, - loopCmdMsg.is_from_me === true, - ) - ) { - queue.closeStdin(chatJid); - } - // Enqueue so processGroupMessages handles auth + cursor advancement. - // Don't pipe via IPC — slash commands need a fresh agent with - // string prompt (not MessageStream) for SDK recognition. - queue.enqueueMessageCheck(chatJid); - continue; - } - // --- End session command interception --- - - const needsTrigger = !isMainGroup && group.requiresTrigger !== false; - - // For non-main groups, only act on trigger messages. - // Non-trigger messages accumulate in DB and get pulled as - // context when a trigger eventually arrives. - if (needsTrigger) { - const allowlistCfg = loadSenderAllowlist(); - const hasTrigger = groupMessages.some( - (m) => - TRIGGER_PATTERN.test(m.content.trim()) && - (m.is_from_me || - isTriggerAllowed(chatJid, m.sender, allowlistCfg)), - ); - if (!hasTrigger) continue; - } - - // Pull all messages since lastAgentTimestamp so non-trigger - // context that accumulated between triggers is included. - const allPending = getMessagesSince( - chatJid, - lastAgentTimestamp[chatJid] || '', - ASSISTANT_NAME, - ); - const messagesToSend = - allPending.length > 0 ? allPending : groupMessages; - const formatted = formatMessages(messagesToSend, TIMEZONE); - - if (queue.sendMessage(chatJid, formatted)) { - logger.debug( - { chatJid, count: messagesToSend.length }, - 'Piped messages to active agent', - ); - lastAgentTimestamp[chatJid] = - messagesToSend[messagesToSend.length - 1].timestamp; - saveState(); - // Show typing indicator while the agent processes the piped message - channel - .setTyping?.(chatJid, true) - ?.catch((err) => - logger.warn({ chatJid, err }, 'Failed to set typing indicator'), - ); - } else { - // No active agent — enqueue for a new one - queue.enqueueMessageCheck(chatJid, group.folder); - } - } - } - } catch (err) { - logger.error({ err }, 'Error in message loop'); - } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); - } -} - -/** - * Startup recovery: check for unprocessed messages in registered groups. - * Handles crash between advancing lastTimestamp and processing messages. - */ -function recoverPendingMessages(): void { - for (const [chatJid, group] of Object.entries(registeredGroups)) { - const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; - const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); - if (pending.length > 0) { - logger.info( - { group: group.name, pendingCount: pending.length }, - 'Recovery: found unprocessed messages', - ); - queue.enqueueMessageCheck(chatJid, group.folder); - } - } -} - async function main(): Promise { initDatabase(); logger.info('Database initialized'); @@ -1125,24 +249,23 @@ async function main(): Promise { ); }, getAvailableGroups, - writeGroupsSnapshot: (gf, im, ag, rj) => - writeGroupsSnapshot(gf, im, ag, rj), + writeGroupsSnapshot: (gf, im, ag) => writeGroupsSnapshot(gf, im, ag), }); - queue.setProcessMessagesFn(processGroupMessages); - recoverPendingMessages(); - // Purge old messages in status channel before creating fresh dashboards - if (STATUS_CHANNEL_ID) { - const statusJid = `dc:${STATUS_CHANNEL_ID}`; - const ch = channels.find( - (c) => c.name.startsWith('discord') && c.isConnected() && c.purgeChannel, - ); - if (ch?.purgeChannel) { - await ch.purgeChannel(statusJid); - } - } - await startStatusDashboard(); - await startUsageDashboard(); - startMessageLoop().catch((err) => { + queue.setProcessMessagesFn(messageRuntime.processGroupMessages); + messageRuntime.recoverPendingMessages(); + const dashboardOpts = { + assistantName: ASSISTANT_NAME, + channels, + queue, + registeredGroups: () => registeredGroups, + statusChannelId: STATUS_CHANNEL_ID, + statusUpdateInterval: STATUS_UPDATE_INTERVAL, + usageUpdateInterval: USAGE_UPDATE_INTERVAL, + }; + await purgeDashboardChannel(dashboardOpts); + await startStatusDashboard(dashboardOpts); + await startUsageDashboard(dashboardOpts); + messageRuntime.startMessageLoop().catch((err) => { logger.fatal({ err }, 'Message loop crashed unexpectedly'); process.exit(1); }); diff --git a/src/ipc.ts b/src/ipc.ts index eaa1b1d..8f04592 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -20,7 +20,6 @@ export interface IpcDeps { groupFolder: string, isMain: boolean, availableGroups: AvailableGroup[], - registeredJids: Set, ) => void; } @@ -405,7 +404,6 @@ export async function processTaskIpc( sourceGroup, true, availableGroups, - new Set(Object.keys(registeredGroups)), ); } else { logger.warn( diff --git a/src/message-runtime.ts b/src/message-runtime.ts new file mode 100644 index 0000000..1834c20 --- /dev/null +++ b/src/message-runtime.ts @@ -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; + getSessions: () => Record; + getLastTimestamp: () => string; + setLastTimestamp: (timestamp: string) => void; + getLastAgentTimestamps: () => Record; + saveState: () => void; + persistSession: (groupFolder: string, sessionId: string) => void; + clearSession: (groupFolder: string) => void; +} + +export function getAvailableGroups( + registeredGroups: Record, +): 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; + recoverPendingMessages: () => void; + startMessageLoop: () => Promise; +} { + let messageLoopRunning = false; + + const getCurrentAvailableGroups = (): AvailableGroup[] => + getAvailableGroups(deps.getRegisteredGroups()); + + const runAgent = async ( + group: RegisteredGroup, + prompt: string, + chatJid: string, + runId: string, + onOutput?: (output: AgentOutput) => Promise, + ): 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 => { + 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 | 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(/[\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 => { + 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(); + 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, + }; +} diff --git a/src/router.ts b/src/router.ts index c14ca89..84ab214 100644 --- a/src/router.ts +++ b/src/router.ts @@ -34,16 +34,6 @@ export function formatOutbound(rawText: string): string { return text; } -export function routeOutbound( - channels: Channel[], - jid: string, - text: string, -): Promise { - 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( channels: Channel[], jid: string, diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index 4a3cbfe..66e4c06 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -18,6 +18,10 @@ describe('extractSessionCommand', () => { expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact'); }); + it('detects bare /clear', () => { + expect(extractSessionCommand('/clear', trigger)).toBe('/clear'); + }); + it('rejects /compact with extra text', () => { expect(extractSessionCommand('/compact now please', trigger)).toBeNull(); }); @@ -82,6 +86,7 @@ function makeDeps( setTyping: vi.fn().mockResolvedValue(undefined), runAgent: vi.fn().mockResolvedValue('success'), closeStdin: vi.fn(), + clearSession: vi.fn(), advanceCursor: vi.fn(), formatMessages: vi.fn().mockReturnValue(''), canSenderInteract: vi.fn().mockReturnValue(true), @@ -123,6 +128,26 @@ describe('handleSessionCommand', () => { 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 () => { const deps = makeDeps(); const result = await handleSessionCommand({ diff --git a/src/session-commands.ts b/src/session-commands.ts index 1a3b2fb..20fdf53 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -12,6 +12,7 @@ export function extractSessionCommand( let text = content.trim(); text = text.replace(triggerPattern, '').trim(); if (text === '/compact') return '/compact'; + if (text === '/clear') return '/clear'; return null; } @@ -41,6 +42,7 @@ export interface SessionCommandDeps { onOutput: (result: AgentResult) => Promise, ) => Promise<'success' | 'error'>; closeStdin: () => void; + clearSession: () => void; advanceCursor: (timestamp: string) => void; formatMessages: (msgs: NewMessage[], timezone: string) => string; /** Whether the denied sender would normally be allowed to interact (for denial messages). */ @@ -63,6 +65,7 @@ export async function handleSessionCommand(opts: { missedMessages: NewMessage[]; isMainGroup: boolean; groupName: string; + runId?: string; triggerPattern: RegExp; timezone: string; deps: SessionCommandDeps; @@ -71,6 +74,7 @@ export async function handleSessionCommand(opts: { missedMessages, isMainGroup, groupName, + runId, triggerPattern, timezone, deps, @@ -98,7 +102,17 @@ export async function handleSessionCommand(opts: { } // 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 preCompactMsgs = missedMessages.slice(0, cmdIndex); @@ -125,7 +139,7 @@ export async function handleSessionCommand(opts: { if (preResult === 'error' || hadPreError) { logger.warn( - { group: groupName }, + { group: groupName, runId }, 'Pre-compact processing failed, aborting session command', ); await deps.sendMessage( From 6f38f1cd63ddfbff9cf841d8abb34a67ade859ed Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 16 Mar 2026 05:14:38 +0900 Subject: [PATCH 07/17] feat: add SESSION_COMMAND_USER_IDS for admin session command access Allow configured user IDs to execute session commands (/clear etc.) in non-main groups. Adds isAdminSender check alongside isFromMe. --- src/agent-runner.ts | 36 +++++++++-- src/config.ts | 18 +++++- src/group-queue.ts | 17 +++++- src/ipc.ts | 6 +- src/message-runtime.ts | 115 +++++++++++++++++++++-------------- src/session-commands.test.ts | 32 ++++++++-- src/session-commands.ts | 14 ++++- 7 files changed, 170 insertions(+), 68 deletions(-) diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 2518347..e4a1cba 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -394,7 +394,12 @@ export async function runAgentProcess( stdout += chunk.slice(0, remaining); stdoutTruncated = true; logger.warn( - { group: group.name, chatJid: input.chatJid, runId: input.runId, size: stdout.length }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + size: stdout.length, + }, 'Agent stdout truncated due to size limit', ); } else { @@ -424,7 +429,12 @@ export async function runAgentProcess( outputChain = outputChain.then(() => onOutput(parsed)); } catch (err) { logger.warn( - { group: group.name, chatJid: input.chatJid, runId: input.runId, error: err }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + error: err, + }, 'Failed to parse streamed output chunk', ); } @@ -440,7 +450,12 @@ export async function runAgentProcess( const killOnTimeout = () => { timedOut = true; logger.error( - { group: group.name, chatJid: input.chatJid, runId: input.runId, processName }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + processName, + }, 'Agent timeout, sending SIGTERM', ); proc.kill('SIGTERM'); @@ -640,7 +655,12 @@ export async function runAgentProcess( resolve(output); } catch (err) { logger.error( - { group: group.name, chatJid: input.chatJid, runId: input.runId, error: err }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + error: err, + }, 'Failed to parse agent output', ); resolve({ @@ -654,7 +674,13 @@ export async function runAgentProcess( proc.on('error', (err) => { clearTimeout(timeout); logger.error( - { group: group.name, chatJid: input.chatJid, runId: input.runId, processName, error: err }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + processName, + error: err, + }, 'Agent spawn error', ); resolve({ diff --git a/src/config.ts b/src/config.ts index 7a2659b..f1104ff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,11 @@ import path from 'path'; import { readEnvFile } from './env.js'; -const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']); +const envConfig = readEnvFile([ + 'ASSISTANT_NAME', + 'ASSISTANT_HAS_OWN_NUMBER', + 'SESSION_COMMAND_USER_IDS', +]); export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; @@ -15,6 +19,14 @@ export const SERVICE_AGENT_TYPE: 'claude-code' | 'codex' = export const ASSISTANT_HAS_OWN_NUMBER = (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; +export const SESSION_COMMAND_USER_IDS = new Set( + (process.env.SESSION_COMMAND_USER_IDS || + envConfig.SESSION_COMMAND_USER_IDS || + '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean), +); export const POLL_INTERVAL = 2000; export const SCHEDULER_POLL_INTERVAL = 60000; @@ -72,3 +84,7 @@ export const USAGE_UPDATE_INTERVAL = 300000; // 5 minutes // Uses system timezone by default export const TIMEZONE = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone; + +export function isSessionCommandSenderAllowed(senderId: string): boolean { + return SESSION_COMMAND_USER_IDS.has(senderId); +} diff --git a/src/group-queue.ts b/src/group-queue.ts index 46c05e6..63f9ba7 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -245,7 +245,12 @@ export class GroupQueue { return true; } catch (err) { logger.warn( - { groupJid, runId: state.currentRunId, groupFolder: state.groupFolder, err }, + { + groupJid, + runId: state.currentRunId, + groupFolder: state.groupFolder, + err, + }, 'Failed to queue follow-up message for active agent', ); return false; @@ -311,7 +316,10 @@ export class GroupQueue { let outcome: 'success' | 'retry_scheduled' | 'error' = 'success'; try { if (this.processMessagesFn) { - const success = await this.processMessagesFn(groupJid, { runId, reason }); + const success = await this.processMessagesFn(groupJid, { + runId, + reason, + }); if (success) { state.retryCount = 0; } else { @@ -321,7 +329,10 @@ export class GroupQueue { } } catch (err) { outcome = 'error'; - logger.error({ groupJid, runId, err }, 'Error processing messages for group'); + logger.error( + { groupJid, runId, err }, + 'Error processing messages for group', + ); this.scheduleRetry(groupJid, state, runId); } finally { const durationMs = state.startedAt ? Date.now() - state.startedAt : null; diff --git a/src/ipc.ts b/src/ipc.ts index 8f04592..72a31c2 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -400,11 +400,7 @@ export async function processTaskIpc( await deps.syncGroups(true); // Write updated snapshot immediately const availableGroups = deps.getAvailableGroups(); - deps.writeGroupsSnapshot( - sourceGroup, - true, - availableGroups, - ); + deps.writeGroupsSnapshot(sourceGroup, true, availableGroups); } else { logger.warn( { sourceGroup }, diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 1834c20..2eafdf4 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -12,12 +12,10 @@ import { getMessagesSince, getNewMessages, } from './db.js'; +import { isSessionCommandSenderAllowed } from './config.js'; import { GroupQueue, GroupRunContext } from './group-queue.js'; import { findChannel, formatMessages } from './router.js'; -import { - isTriggerAllowed, - loadSenderAllowlist, -} from './sender-allowlist.js'; +import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js'; import { extractSessionCommand, handleSessionCommand, @@ -154,13 +152,19 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const registeredGroups = deps.getRegisteredGroups(); const group = registeredGroups[chatJid]; if (!group) { - logger.warn({ chatJid, runId, reason }, 'Registered group missing for queued run'); + 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'); + logger.warn( + { chatJid, runId, reason }, + 'No channel owns JID, skipping messages', + ); return true; } @@ -175,7 +179,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { if (missedMessages.length === 0) { logger.info( - { chatJid, group: group.name, groupFolder: group.folder, runId, reason }, + { + chatJid, + group: group.name, + groupFolder: group.folder, + runId, + reason, + }, 'No pending messages for queued run', ); return true; @@ -218,6 +228,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { deps.saveState(); }, formatMessages, + isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender), canSenderInteract: (msg) => { const hasTrigger = deps.triggerPattern.test(msg.content.trim()); const requiresTrigger = @@ -227,11 +238,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { !requiresTrigger || (hasTrigger && (msg.is_from_me || - isTriggerAllowed( - chatJid, - msg.sender, - loadSenderAllowlist(), - ))) + isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist()))) ); }, }, @@ -293,40 +300,48 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { 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(/[\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; + 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(/[\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(); + await channel.setTyping?.(chatJid, false); + resetIdleTimer(); - if (result.status === 'success') { - deps.queue.notifyIdle(chatJid, runId); - } + if (result.status === 'success') { + deps.queue.notifyIdle(chatJid, runId); + } - if (result.status === 'error') { - hadError = true; - } - }); + if (result.status === 'error') { + hadError = true; + } + }, + ); await channel.setTyping?.(chatJid, false); @@ -408,7 +423,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const channel = findChannel(deps.channels, chatJid); if (!channel) { - logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); + logger.warn( + { chatJid }, + 'No channel owns JID, skipping messages', + ); continue; } @@ -420,8 +438,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const lastHuman = getLastHumanMessageTimestamp(chatJid); if ( !lastHuman || - Date.now() - new Date(lastHuman).getTime() > - 12 * 60 * 60 * 1000 + Date.now() - new Date(lastHuman).getTime() > 12 * 60 * 60 * 1000 ) { logger.info( { chatJid, lastHuman }, @@ -433,7 +450,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const loopCmdMsg = groupMessages.find( (msg) => - extractSessionCommand(msg.content, deps.triggerPattern) !== null, + extractSessionCommand(msg.content, deps.triggerPattern) !== + null, ); if (loopCmdMsg) { @@ -441,9 +459,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { isSessionCommandAllowed( isMainGroup, loopCmdMsg.is_from_me === true, + isSessionCommandSenderAllowed(loopCmdMsg.sender), ) ) { - deps.queue.closeStdin(chatJid); + deps.queue.closeStdin(chatJid, { + reason: 'session-command-detected', + }); } deps.queue.enqueueMessageCheck(chatJid); continue; diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index 66e4c06..8f5896e 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -47,19 +47,23 @@ describe('extractSessionCommand', () => { describe('isSessionCommandAllowed', () => { it('allows main group regardless of sender', () => { - expect(isSessionCommandAllowed(true, false)).toBe(true); + expect(isSessionCommandAllowed(true, false, false)).toBe(true); }); it('allows trusted/admin sender (is_from_me) in non-main group', () => { - expect(isSessionCommandAllowed(false, true)).toBe(true); + expect(isSessionCommandAllowed(false, true, false)).toBe(true); + }); + + it('allows configured admin sender in non-main group', () => { + expect(isSessionCommandAllowed(false, false, true)).toBe(true); }); it('denies untrusted sender in non-main group', () => { - expect(isSessionCommandAllowed(false, false)).toBe(false); + expect(isSessionCommandAllowed(false, false, false)).toBe(false); }); it('allows trusted sender in main group', () => { - expect(isSessionCommandAllowed(true, true)).toBe(true); + expect(isSessionCommandAllowed(true, true, false)).toBe(true); }); }); @@ -89,6 +93,7 @@ function makeDeps( clearSession: vi.fn(), advanceCursor: vi.fn(), formatMessages: vi.fn().mockReturnValue(''), + isAdminSender: vi.fn().mockReturnValue(false), canSenderInteract: vi.fn().mockReturnValue(true), ...overrides, }; @@ -228,6 +233,25 @@ describe('handleSessionCommand', () => { ); }); + it('allows configured admin sender in non-main group', async () => { + const deps = makeDeps({ + isAdminSender: vi.fn().mockReturnValue(true), + }); + const result = await handleSessionCommand({ + missedMessages: [makeMsg('/clear', { is_from_me: false, sender: 'discord-user-1' })], + isMainGroup: false, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + expect(result).toEqual({ handled: true, success: true }); + expect(deps.clearSession).toHaveBeenCalledTimes(1); + expect(deps.sendMessage).toHaveBeenCalledWith( + 'Current session cleared. The next message will start a new conversation.', + ); + }); + it('reports failure when command-stage runAgent returns error without streamed status', async () => { // runAgent resolves 'error' but callback never gets status: 'error' const deps = makeDeps({ diff --git a/src/session-commands.ts b/src/session-commands.ts index 20fdf53..170c54b 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -18,13 +18,14 @@ export function extractSessionCommand( /** * Check if a session command sender is authorized. - * Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group. + * Allowed: main group (any sender), or trusted/admin sender in any group. */ export function isSessionCommandAllowed( isMainGroup: boolean, isFromMe: boolean, + isAdminSender: boolean, ): boolean { - return isMainGroup || isFromMe; + return isMainGroup || isFromMe || isAdminSender; } /** Minimal agent result interface — matches the subset of AgentOutput used here. */ @@ -45,6 +46,7 @@ export interface SessionCommandDeps { clearSession: () => void; advanceCursor: (timestamp: string) => void; formatMessages: (msgs: NewMessage[], timezone: string) => string; + isAdminSender: (msg: NewMessage) => boolean; /** Whether the denied sender would normally be allowed to interact (for denial messages). */ canSenderInteract: (msg: NewMessage) => boolean; } @@ -89,7 +91,13 @@ export async function handleSessionCommand(opts: { if (!command || !cmdMsg) return { handled: false }; - if (!isSessionCommandAllowed(isMainGroup, cmdMsg.is_from_me === true)) { + if ( + !isSessionCommandAllowed( + isMainGroup, + cmdMsg.is_from_me === true, + deps.isAdminSender(cmdMsg), + ) + ) { // DENIED: send denial if the sender would normally be allowed to interact, // then silently consume the command by advancing the cursor past it. // Trade-off: other messages in the same batch are also consumed (cursor is From ea03956d0a1fca56cb3f8c9d311ea2c2abe82683 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 16 Mar 2026 05:36:30 +0900 Subject: [PATCH 08/17] feat: show session ID in dashboard status display Add session label (last 8 chars) next to each group status. Export buildStatusContent and DashboardOptions for testing. --- src/config.ts | 6 +++-- src/dashboard.test.ts | 49 ++++++++++++++++++++++++++++++++++++ src/dashboard.ts | 16 +++++++++--- src/index.ts | 1 + src/session-commands.test.ts | 4 ++- 5 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 src/dashboard.test.ts diff --git a/src/config.ts b/src/config.ts index f1104ff..961c263 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,9 +20,11 @@ export const ASSISTANT_HAS_OWN_NUMBER = (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; export const SESSION_COMMAND_USER_IDS = new Set( - (process.env.SESSION_COMMAND_USER_IDS || + ( + process.env.SESSION_COMMAND_USER_IDS || envConfig.SESSION_COMMAND_USER_IDS || - '') + '' + ) .split(',') .map((id) => id.trim()) .filter(Boolean), diff --git a/src/dashboard.test.ts b/src/dashboard.test.ts new file mode 100644 index 0000000..0cb2120 --- /dev/null +++ b/src/dashboard.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { buildStatusContent, type DashboardOptions } from './dashboard.js'; + +function makeOptions(sessionId?: string): DashboardOptions { + const sessions: Record = sessionId + ? { 'group-folder': sessionId } + : {}; + + return { + assistantName: 'Test', + channels: [], + getSessions: () => sessions, + queue: { + getStatuses: () => [ + { + jid: 'dc:123', + status: 'inactive', + elapsedMs: null, + pendingMessages: false, + pendingTasks: 0, + }, + ], + } as unknown as DashboardOptions['queue'], + registeredGroups: () => ({ + 'dc:123': { + name: 'clone-test', + folder: 'group-folder', + trigger: '@bot', + added_at: '2026-03-16T00:00:00.000Z', + }, + }), + statusChannelId: 'status', + statusUpdateInterval: 60000, + usageUpdateInterval: 60000, + }; +} + +describe('buildStatusContent', () => { + it('shows cleared sessions as empty', () => { + const content = buildStatusContent(makeOptions()); + expect(content).toContain('세션 없음'); + }); + + it('shows a shortened session id when a session exists', () => { + const content = buildStatusContent(makeOptions('session-1234567890')); + expect(content).toContain('세션 34567890'); + }); +}); diff --git a/src/dashboard.ts b/src/dashboard.ts index 68edc6f..d3e0200 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -8,9 +8,10 @@ import { GroupQueue, GroupStatus } from './group-queue.js'; import { logger } from './logger.js'; import { Channel, ChannelMeta, RegisteredGroup } from './types.js'; -interface DashboardOptions { +export interface DashboardOptions { assistantName: string; channels: Channel[]; + getSessions: () => Record; queue: GroupQueue; registeredGroups: () => Record; statusChannelId: string; @@ -114,8 +115,16 @@ function getStatusLabel(status: GroupStatus): string { return '비활성'; } -function buildStatusContent(opts: DashboardOptions): string { +function getSessionLabel(sessionId: string | undefined): string { + if (!sessionId) return '세션 없음'; + const shortId = sessionId.length > 8 ? sessionId.slice(-8) : sessionId; + return `세션 ${shortId}`; +} + +/** @internal - exported for testing */ +export function buildStatusContent(opts: DashboardOptions): string { const registeredGroups = opts.registeredGroups(); + const sessions = opts.getSessions(); const jids = Object.keys(registeredGroups); const statuses = opts.queue.getStatuses(jids); @@ -153,8 +162,9 @@ function buildStatusContent(opts: DashboardOptions): string { const lines = categoryEntries.map((entry) => { const icon = STATUS_ICONS[entry.status.status] || '⚪'; const label = getStatusLabel(entry.status); + const sessionLabel = getSessionLabel(sessions[entry.group.folder]); const name = entry.meta?.name ? `#${entry.meta.name}` : entry.group.name; - return ` ${icon} **${name}** — ${label}`; + return ` ${icon} **${name}** — ${label} · ${sessionLabel}`; }); if (channelMetaCache.size > 0 && categoryName !== '기타') { diff --git a/src/index.ts b/src/index.ts index 6824b73..312ef53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -256,6 +256,7 @@ async function main(): Promise { const dashboardOpts = { assistantName: ASSISTANT_NAME, channels, + getSessions: () => sessions, queue, registeredGroups: () => registeredGroups, statusChannelId: STATUS_CHANNEL_ID, diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index 8f5896e..f6a2d1d 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -238,7 +238,9 @@ describe('handleSessionCommand', () => { isAdminSender: vi.fn().mockReturnValue(true), }); const result = await handleSessionCommand({ - missedMessages: [makeMsg('/clear', { is_from_me: false, sender: 'discord-user-1' })], + missedMessages: [ + makeMsg('/clear', { is_from_me: false, sender: 'discord-user-1' }), + ], isMainGroup: false, groupName: 'test', triggerPattern: trigger, From 385f3a0349cb51571a107eb7b39fd6faf592c2f1 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 16 Mar 2026 05:39:35 +0900 Subject: [PATCH 09/17] feat: block follow-up IPC after closeStdin with closingStdin flag Prevent piping new messages to an agent that is shutting down. Reset flag on new process start and cleanup. --- src/group-queue.test.ts | 28 ++++++++++++++++++++++++++++ src/group-queue.ts | 18 ++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/group-queue.test.ts b/src/group-queue.test.ts index da14480..abbe94d 100644 --- a/src/group-queue.test.ts +++ b/src/group-queue.test.ts @@ -413,6 +413,34 @@ describe('GroupQueue', () => { await vi.advanceTimersByTimeAsync(10); }); + it('does not pipe follow-up messages to an agent after closeStdin', async () => { + let resolveProcess: () => void; + + const processMessages = vi.fn(async () => { + await new Promise((resolve) => { + resolveProcess = resolve; + }); + return true; + }); + + queue.setProcessMessagesFn(processMessages); + queue.enqueueMessageCheck('group1@g.us', 'test-group'); + await vi.advanceTimersByTimeAsync(10); + + queue.registerProcess('group1@g.us', {} as any, 'agent-1', 'test-group'); + queue.notifyIdle('group1@g.us'); + queue.closeStdin('group1@g.us'); + + expect(queue.sendMessage('group1@g.us', 'hello after clear')).toBe(false); + + queue.enqueueMessageCheck('group1@g.us', 'test-group'); + + resolveProcess!(); + await vi.advanceTimersByTimeAsync(10); + + expect(processMessages).toHaveBeenCalledTimes(2); + }); + it('preempts when idle arrives with pending tasks', async () => { const fs = await import('fs'); let resolveProcess: () => void; diff --git a/src/group-queue.ts b/src/group-queue.ts index 63f9ba7..17e013d 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -22,6 +22,7 @@ const BASE_RETRY_MS = 5000; interface GroupState { active: boolean; idleWaiting: boolean; + closingStdin: boolean; isTaskProcess: boolean; runningTaskId: string | null; currentRunId: string | null; @@ -57,6 +58,7 @@ export class GroupQueue { state = { active: false, idleWaiting: false, + closingStdin: false, isTaskProcess: false, runningTaskId: null, currentRunId: null, @@ -215,6 +217,7 @@ export class GroupQueue { groupJid, runId: state.currentRunId, active: state.active, + closingStdin: state.closingStdin, groupFolder: state.groupFolder, isTaskProcess: state.isTaskProcess, }, @@ -222,6 +225,13 @@ export class GroupQueue { ); return false; } + if (state.closingStdin) { + logger.info( + { groupJid, runId: state.currentRunId, groupFolder: state.groupFolder }, + 'Skipping follow-up IPC because active agent is closing', + ); + return false; + } state.idleWaiting = false; // Agent is about to receive work, no longer idle const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input'); @@ -266,6 +276,8 @@ export class GroupQueue { ): void { const state = this.getGroup(groupJid); if (!state.active || !state.groupFolder) return; + state.closingStdin = true; + state.idleWaiting = false; const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input'); try { @@ -302,6 +314,7 @@ export class GroupQueue { const runId = this.createRunId(); state.active = true; state.idleWaiting = false; + state.closingStdin = false; state.isTaskProcess = false; state.currentRunId = runId; state.pendingMessages = false; @@ -350,6 +363,8 @@ export class GroupQueue { ); state.active = false; state.startedAt = null; + state.idleWaiting = false; + state.closingStdin = false; state.process = null; state.processName = null; state.groupFolder = null; @@ -363,6 +378,7 @@ export class GroupQueue { const state = this.getGroup(groupJid); state.active = true; state.idleWaiting = false; + state.closingStdin = false; state.isTaskProcess = true; state.runningTaskId = task.id; state.startedAt = Date.now(); @@ -382,6 +398,8 @@ export class GroupQueue { state.isTaskProcess = false; state.runningTaskId = null; state.startedAt = null; + state.idleWaiting = false; + state.closingStdin = false; state.process = null; state.processName = null; state.groupFolder = null; From b710d4a33d8b2f4d76af889e882ce4581e5d96f2 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 16 Mar 2026 05:54:17 +0900 Subject: [PATCH 10/17] chore: update claude-agent-sdk to 0.2.76, read dashboard config from .env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump @anthropic-ai/claude-agent-sdk 0.2.68 → 0.2.76 - Read STATUS_CHANNEL_ID and USAGE_DASHBOARD from .env file --- runners/agent-runner/package-lock.json | 8 +- runners/agent-runner/package.json | 2 +- runners/agent-runner/pnpm-lock.yaml | 994 +++++++++++++++++++++++++ src/config.ts | 7 +- src/dashboard.ts | 3 +- 5 files changed, 1007 insertions(+), 7 deletions(-) create mode 100644 runners/agent-runner/pnpm-lock.yaml diff --git a/runners/agent-runner/package-lock.json b/runners/agent-runner/package-lock.json index 2c6696e..4232aa2 100644 --- a/runners/agent-runner/package-lock.json +++ b/runners/agent-runner/package-lock.json @@ -8,7 +8,7 @@ "name": "nanoclaw-agent-runner", "version": "1.0.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "@anthropic-ai/claude-agent-sdk": "^0.2.76", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" @@ -19,9 +19,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.68", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.68.tgz", - "integrity": "sha512-y4n6hTTgAqmiV/pqy1G4OgIdg6gDiAKPJaEgO1NOh7/rdsrXyc/HQoUmUy0ty4HkBq1hasm7hB92wtX3W1UMEw==", + "version": "0.2.76", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz", + "integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" diff --git a/runners/agent-runner/package.json b/runners/agent-runner/package.json index bf13328..42a994e 100644 --- a/runners/agent-runner/package.json +++ b/runners/agent-runner/package.json @@ -9,7 +9,7 @@ "start": "node dist/index.js" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "@anthropic-ai/claude-agent-sdk": "^0.2.76", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" diff --git a/runners/agent-runner/pnpm-lock.yaml b/runners/agent-runner/pnpm-lock.yaml new file mode 100644 index 0000000..a5dcc26 --- /dev/null +++ b/runners/agent-runner/pnpm-lock.yaml @@ -0,0 +1,994 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.2.76 + version: 0.2.76(zod@4.3.6) + '@modelcontextprotocol/sdk': + specifier: ^1.12.1 + version: 1.27.1(zod@4.3.6) + cron-parser: + specifier: ^5.0.0 + version: 5.5.0 + zod: + specifier: ^4.0.0 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^22.10.7 + version: 22.19.15 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + +packages: + + '@anthropic-ai/claude-agent-sdk@0.2.76': + resolution: {integrity: sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^4.0.0 + + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cron-parser@5.5.0: + resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.12.8: + resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.1: + resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + 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==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@anthropic-ai/claude-agent-sdk@0.2.76(zod@4.3.6)': + dependencies: + zod: 4.3.6 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + '@hono/node-server@1.19.11(hono@4.12.8)': + dependencies: + hono: 4.12.8 + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.8) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.8 + jose: 6.2.1 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cron-parser@5.5.0: + dependencies: + luxon: 3.7.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.0: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.12.8: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + luxon@3.7.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.3.0: {} + + pkce-challenge@5.0.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/src/config.ts b/src/config.ts index 961c263..8325095 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,8 @@ const envConfig = readEnvFile([ 'ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'SESSION_COMMAND_USER_IDS', + 'STATUS_CHANNEL_ID', + 'USAGE_DASHBOARD', ]); export const ASSISTANT_NAME = @@ -78,9 +80,12 @@ export const TRIGGER_PATTERN = new RegExp( ); // Status dashboard: Discord channel ID for live agent status updates -export const STATUS_CHANNEL_ID = process.env.STATUS_CHANNEL_ID || ''; +export const STATUS_CHANNEL_ID = + process.env.STATUS_CHANNEL_ID || envConfig.STATUS_CHANNEL_ID || ''; export const STATUS_UPDATE_INTERVAL = 10000; // 10s export const USAGE_UPDATE_INTERVAL = 300000; // 5 minutes +export const USAGE_DASHBOARD_ENABLED = + (process.env.USAGE_DASHBOARD || envConfig.USAGE_DASHBOARD || '') === 'true'; // Timezone for scheduled tasks (cron expressions, etc.) // Uses system timezone by default diff --git a/src/dashboard.ts b/src/dashboard.ts index d3e0200..bdca357 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { USAGE_DASHBOARD_ENABLED } from './config.js'; import { readEnvFile } from './env.js'; import { GroupQueue, GroupStatus } from './group-queue.js'; import { logger } from './logger.js'; @@ -493,7 +494,7 @@ export async function startUsageDashboard( opts: DashboardOptions, ): Promise { if (!opts.statusChannelId) return; - if (process.env.USAGE_DASHBOARD !== 'true') return; + if (!USAGE_DASHBOARD_ENABLED) return; const statusJid = `dc:${opts.statusChannelId}`; From 779d57c392b293829d41cdea29aeb26be47d9420 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Thu, 19 Mar 2026 02:57:14 +0900 Subject: [PATCH 11/17] Stabilize paired-room orchestration and Codex app-server flow --- prompts/claude-paired-room.md | 20 + prompts/claude-platform.md | 46 ++ prompts/codex-paired-room.md | 18 + prompts/codex-platform.md | 27 + runners/agent-runner/src/index.ts | 11 - runners/codex-runner/package.json | 2 +- runners/codex-runner/pnpm-lock.yaml | 119 +++++ runners/codex-runner/src/app-server-client.ts | 459 ++++++++++++++++ runners/codex-runner/src/app-server-state.ts | 174 ++++++ runners/codex-runner/src/index.ts | 505 ++++++++++++++---- .../test/app-server-state.test.ts | 83 +++ setup/register.test.ts | 10 +- setup/register.ts | 10 +- src/agent-runner.test.ts | 38 ++ src/agent-runner.ts | 49 +- src/channels/discord.test.ts | 44 ++ src/channels/discord.ts | 12 + src/db.test.ts | 48 +- src/db.ts | 139 +++-- src/group-queue.test.ts | 23 + src/group-queue.ts | 34 +- src/message-runtime.test.ts | 461 ++++++++++++++++ src/message-runtime.ts | 137 ++++- src/platform-prompts.test.ts | 58 ++ src/platform-prompts.ts | 60 +++ src/session-commands.test.ts | 23 + src/session-commands.ts | 21 +- src/session-recovery.test.ts | 34 ++ src/session-recovery.ts | 26 + src/task-scheduler.ts | 3 + src/types.ts | 2 + vitest.config.ts | 6 +- 32 files changed, 2517 insertions(+), 185 deletions(-) create mode 100644 prompts/claude-paired-room.md create mode 100644 prompts/claude-platform.md create mode 100644 prompts/codex-paired-room.md create mode 100644 prompts/codex-platform.md create mode 100644 runners/codex-runner/pnpm-lock.yaml create mode 100644 runners/codex-runner/src/app-server-client.ts create mode 100644 runners/codex-runner/src/app-server-state.ts create mode 100644 runners/codex-runner/test/app-server-state.test.ts create mode 100644 src/message-runtime.test.ts create mode 100644 src/platform-prompts.test.ts create mode 100644 src/platform-prompts.ts create mode 100644 src/session-recovery.test.ts create mode 100644 src/session-recovery.ts diff --git a/prompts/claude-paired-room.md b/prompts/claude-paired-room.md new file mode 100644 index 0000000..fc002d2 --- /dev/null +++ b/prompts/claude-paired-room.md @@ -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 `` 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. diff --git a/prompts/claude-platform.md b/prompts/claude-platform.md new file mode 100644 index 0000000..9ce497f --- /dev/null +++ b/prompts/claude-platform.md @@ -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 `` only for genuinely hidden content. + +If part of your output is internal reasoning rather than something for the user, wrap it in `` tags: + +```text +Compiled all three reports, ready to summarize. + +Here are the key findings from the research... +``` + +Text inside `` 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 diff --git a/prompts/codex-paired-room.md b/prompts/codex-paired-room.md new file mode 100644 index 0000000..99cdfb8 --- /dev/null +++ b/prompts/codex-paired-room.md @@ -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 diff --git a/prompts/codex-platform.md b/prompts/codex-platform.md new file mode 100644 index 0000000..1cb9c86 --- /dev/null +++ b/prompts/codex-platform.md @@ -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 diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 6600a8b..b962a85 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -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', diff --git a/runners/codex-runner/package.json b/runners/codex-runner/package.json index 51cd5c2..a4c2d1d 100644 --- a/runners/codex-runner/package.json +++ b/runners/codex-runner/package.json @@ -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", diff --git a/runners/codex-runner/pnpm-lock.yaml b/runners/codex-runner/pnpm-lock.yaml new file mode 100644 index 0000000..e397a43 --- /dev/null +++ b/runners/codex-runner/pnpm-lock.yaml @@ -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: {} diff --git a/runners/codex-runner/src/app-server-client.ts b/runners/codex-runner/src/app-server-client.ts new file mode 100644 index 0000000..11693c9 --- /dev/null +++ b/runners/codex-runner/src/app-server-client.ts @@ -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; +} + +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(); + 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 { + 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 { + 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 { + 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; + interrupt: () => Promise; + wait: () => Promise; + }> { + if (this.activeTurn) { + throw new Error('A Codex app-server turn is already active.'); + } + + const turnPromise = new Promise((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 { + if (this.activeTurn) { + throw new Error('A Codex app-server turn is already active.'); + } + + const turnPromise = new Promise((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 | 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 { + 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): void { + if (!this.proc?.stdin.writable) { + throw new Error('Codex app-server stdin is not writable.'); + } + this.proc.stdin.write(JSON.stringify(message) + '\n'); + } +} diff --git a/runners/codex-runner/src/app-server-state.ts b/runners/codex-runner/src/app-server-state.ts new file mode 100644 index 0000000..97c0b86 --- /dev/null +++ b/runners/codex-runner/src/app-server-state.ts @@ -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; +} diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts index 710e2a7..d161e6b 100644 --- a/runners/codex-runner/src/index.ts +++ b/runners/codex-runner/src/index.ts @@ -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 { 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 { return new Promise((resolve) => { const poll = () => { - if (shouldClose()) { + if (consumeCloseSentinel()) { resolve(null); return; } @@ -131,23 +155,25 @@ function waitForIpcMessage(): Promise { }); } -// ── 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 { - 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 { + 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 { } 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 { + 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 { + 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 { 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 { + 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}`); diff --git a/runners/codex-runner/test/app-server-state.test.ts b/runners/codex-runner/test/app-server-state.test.ts new file mode 100644 index 0000000..87462c7 --- /dev/null +++ b/runners/codex-runner/test/app-server-state.test.ts @@ -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)'); + }); +}); diff --git a/setup/register.test.ts b/setup/register.test.ts index d47d95c..d4dcda7 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -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; } diff --git a/setup/register.ts b/setup/register.ts index 33e77cb..7f5516d 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -106,16 +106,18 @@ export async function run(args: string[]): Promise { 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 { diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index ebb1de2..dd14196 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -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', + ); + }); }); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index e4a1cba..163f78e 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -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; 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; diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index 5db7420..06334ea 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -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); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index f683366..a436629 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -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 { if (this.client) { this.client.destroy(); diff --git a/src/db.test.ts b/src/db.test.ts index 0ded8c5..56c7ab4 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -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); + }); +}); diff --git a/src/db.ts b/src/db.ts index 4ac6c63..b705b1b 100644 --- a/src/db.ts +++ b/src/db.ts @@ -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(); + 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 { diff --git a/src/group-queue.test.ts b/src/group-queue.test.ts index abbe94d..9d01c6c 100644 --- a/src/group-queue.test.ts +++ b/src/group-queue.test.ts @@ -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 () => { diff --git a/src/group-queue.ts b/src/group-queue.ts index 17e013d..3ac6ed8 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -32,6 +32,8 @@ interface GroupState { processName: string | null; groupFolder: string | null; retryCount: number; + retryTimer: ReturnType | 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); } diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts new file mode 100644 index 0000000..146b550 --- /dev/null +++ b/src/message-runtime.test.ts @@ -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 = {}; + + 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 = {}; + + 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(); + }); +}); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 2eafdf4..56f88b5 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -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, ): 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 | 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(/[\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(); 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( diff --git a/src/platform-prompts.test.ts b/src/platform-prompts.test.ts new file mode 100644 index 0000000..d4145a6 --- /dev/null +++ b/src/platform-prompts.test.ts @@ -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'); + }); +}); diff --git a/src/platform-prompts.ts b/src/platform-prompts.ts new file mode 100644 index 0000000..e3c15c7 --- /dev/null +++ b/src/platform-prompts.ts @@ -0,0 +1,60 @@ +import fs from 'fs'; +import path from 'path'; + +import type { AgentType } from './types.js'; + +const PLATFORM_PROMPT_FILES: Record = { + 'claude-code': 'claude-platform.md', + codex: 'codex-platform.md', +}; + +const PAIRED_ROOM_PROMPT_FILES: Record = { + '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; +} diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index f6a2d1d..abbc7e4 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -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 = {}, diff --git a/src/session-commands.ts b/src/session-commands.ts index 170c54b..5928ecd 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -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(/[\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); }); diff --git a/src/session-recovery.test.ts b/src/session-recovery.test.ts new file mode 100644 index 0000000..0723595 --- /dev/null +++ b/src/session-recovery.test.ts @@ -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); + }); +}); diff --git a/src/session-recovery.ts b/src/session-recovery.ts new file mode 100644 index 0000000..b1b5f15 --- /dev/null +++ b/src/session-recovery.ts @@ -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, +): boolean { + const texts = [...toText(output.result), ...toText(output.error)]; + return texts.some((text) => + SESSION_RESET_PATTERNS.some((pattern) => pattern.test(text)), + ); +} diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index a0a9dee..2be69a8 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -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) diff --git a/src/types.ts b/src/types.ts index 316b284..60d2fd2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,8 @@ export interface Channel { sendMessage(jid: string, text: string): Promise; 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; // Optional: typing indicator. Channels that support it implement it. setTyping?(jid: string, isTyping: boolean): Promise; diff --git a/vitest.config.ts b/vitest.config.ts index a456d1c..a887ef0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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', + ], }, }); From 29b78fc286f69779fcead0973ab1ec1caaa7cf4f Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Thu, 19 Mar 2026 03:22:31 +0900 Subject: [PATCH 12/17] Refine Codex progress message tracking --- src/message-runtime.test.ts | 179 +++++++++++++++++++++++++++++------- src/message-runtime.ts | 119 ++++++++++++++++++++---- 2 files changed, 245 insertions(+), 53 deletions(-) diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 146b550..4acc849 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -60,10 +60,12 @@ function makeChannel(chatJid: string): Channel { name: 'discord', connect: vi.fn().mockResolvedValue(undefined), sendMessage: vi.fn().mockResolvedValue(undefined), + sendAndTrack: vi.fn().mockResolvedValue('progress-1'), isConnected: vi.fn(() => true), ownsJid: vi.fn((jid: string) => jid === chatJid), disconnect: vi.fn().mockResolvedValue(undefined), setTyping: vi.fn().mockResolvedValue(undefined), + editMessage: vi.fn().mockResolvedValue(undefined), }; } @@ -221,7 +223,8 @@ describe('createMessageRuntime', () => { expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-2'); }); - it('streams Codex progress as a normal room message and only marks idle after the turn settles', async () => { + it('tracks Codex progress in one editable message and updates elapsed time every 10 seconds', async () => { + vi.useFakeTimers(); const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); @@ -248,6 +251,7 @@ describe('createMessageRuntime', () => { newSessionId: 'session-progress', }); expect(notifyIdle).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', result: null, @@ -283,25 +287,124 @@ describe('createMessageRuntime', () => { clearSession: vi.fn(), }); - const result = await runtime.processGroupMessages(chatJid, { - runId: 'run-progress', - reason: 'messages', + try { + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-progress', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(channel.sendAndTrack).toHaveBeenCalledWith( + chatJid, + 'CI 상태 확인 중입니다.\n\n0초', + ); + expect(channel.editMessage).toHaveBeenCalledWith( + chatJid, + 'progress-1', + 'CI 상태 확인 중입니다.\n\n10초', + ); + expect(channel.sendMessage).not.toHaveBeenCalled(); + expect(notifyIdle).toHaveBeenCalledTimes(1); + expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-progress'); + expect(persistSession).toHaveBeenCalledWith( + group.folder, + 'session-progress', + ); + } finally { + vi.useRealTimers(); + } + }); + + it('formats longer Codex progress durations with minutes and hours', async () => { + vi.useFakeTimers(); + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + + 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-long-progress', + }); + await vi.advanceTimersByTimeAsync(70_000); + expect(channel.editMessage).toHaveBeenLastCalledWith( + chatJid, + 'progress-1', + '오래 걸리는 작업입니다.\n\n1분 10초', + ); + await vi.advanceTimersByTimeAsync(3_600_000); + await onOutput?.({ + status: 'success', + result: null, + newSessionId: 'session-long-progress', + }); + return { + status: 'success', + result: null, + newSessionId: 'session-long-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: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession: vi.fn(), }); - 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', - ); + try { + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-long-progress', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(channel.sendAndTrack).toHaveBeenCalledWith( + chatJid, + '오래 걸리는 작업입니다.\n\n0초', + ); + expect(channel.editMessage).toHaveBeenLastCalledWith( + chatJid, + 'progress-1', + '오래 걸리는 작업입니다.\n\n1시간 1분 10초', + ); + } finally { + vi.useRealTimers(); + } }); it('keeps progress separate from the final Codex answer', async () => { + vi.useFakeTimers(); const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); @@ -326,6 +429,7 @@ describe('createMessageRuntime', () => { result: '테스트를 돌리는 중입니다.', newSessionId: 'session-final', }); + await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', phase: 'final', @@ -362,24 +466,31 @@ describe('createMessageRuntime', () => { clearSession: vi.fn(), }); - const result = await runtime.processGroupMessages(chatJid, { - runId: 'run-final', - reason: 'messages', - }); + try { + 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'); + expect(result).toBe(true); + expect(channel.sendAndTrack).toHaveBeenCalledWith( + chatJid, + '테스트를 돌리는 중입니다.\n\n0초', + ); + expect(channel.editMessage).toHaveBeenCalledWith( + chatJid, + 'progress-1', + '테스트를 돌리는 중입니다.\n\n10초', + ); + expect(channel.sendMessage).toHaveBeenCalledWith( + chatJid, + '테스트가 끝났습니다.', + ); + expect(notifyIdle).toHaveBeenCalledTimes(1); + expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-final'); + } finally { + vi.useRealTimers(); + } }); it('does not roll back when a streamed progress message was already posted before an error', async () => { @@ -451,9 +562,9 @@ describe('createMessageRuntime', () => { }); expect(result).toBe(true); - expect(channel.sendMessage).toHaveBeenCalledWith( + expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, - '중간 진행상황입니다.', + '중간 진행상황입니다.\n\n0초', ); expect(lastAgentTimestamps[chatJid]).toBe('2026-03-19T00:00:00.000Z'); expect(saveState).toHaveBeenCalled(); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 56f88b5..0451cd2 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -93,7 +93,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { onOutput?: (output: AgentOutput) => Promise, ): Promise<'success' | 'error'> => { const isMain = group.isMain === true; - const isClaudeCodeAgent = (group.agentType || 'claude-code') === 'claude-code'; + const isClaudeCodeAgent = + (group.agentType || 'claude-code') === 'claude-code'; const sessions = deps.getSessions(); const sessionId = sessions[group.folder]; @@ -121,10 +122,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { if (output.newSessionId) { deps.persistSession(group.folder, output.newSessionId); } - if ( - isClaudeCodeAgent && - shouldResetSessionOnAgentFailure(output) - ) { + if (isClaudeCodeAgent && shouldResetSessionOnAgentFailure(output)) { resetSessionRequested = true; } await onOutput(output); @@ -154,8 +152,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { if ( isClaudeCodeAgent && - (resetSessionRequested || - shouldResetSessionOnAgentFailure(output)) + (resetSessionRequested || shouldResetSessionOnAgentFailure(output)) ) { deps.clearSession(group.folder); logger.warn( @@ -207,11 +204,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const lastAgentTimestamps = deps.getLastAgentTimestamps(); const sinceTimestamp = lastAgentTimestamps[chatJid] || ''; const missedMessages = filterOwnChannelMessages( - getMessagesSince( - chatJid, - sinceTimestamp, - deps.assistantName, - ), + getMessagesSince(chatJid, sinceTimestamp, deps.assistantName), ); if (missedMessages.length === 0) { @@ -318,6 +311,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { let idleTimer: ReturnType | null = null; let latestProgressText: string | null = null; + let latestProgressRendered: string | null = null; + let progressMessageId: string | null = null; + let progressStartedAt: number | null = null; + let progressTicker: ReturnType | null = null; const resetIdleTimer = () => { if (idleTimer) clearTimeout(idleTimer); @@ -337,15 +334,98 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { let finalOutputSentToUser = false; let progressOutputSentToUser = false; let poisonedSessionDetected = false; - const isClaudeCodeAgent = (group.agentType || 'claude-code') === 'claude-code'; + const isClaudeCodeAgent = + (group.agentType || 'claude-code') === 'claude-code'; + + const clearProgressTicker = () => { + if (progressTicker) { + clearInterval(progressTicker); + progressTicker = null; + } + }; + + const renderProgressMessage = (text: string) => { + const elapsedSeconds = + progressStartedAt === null + ? 0 + : Math.floor((Date.now() - progressStartedAt) / 10_000) * 10; + const hours = Math.floor(elapsedSeconds / 3600); + const minutes = Math.floor((elapsedSeconds % 3600) / 60); + const seconds = elapsedSeconds % 60; + const elapsedParts: string[] = []; + + if (hours > 0) elapsedParts.push(`${hours}시간`); + if (minutes > 0) elapsedParts.push(`${minutes}분`); + if (seconds > 0 || elapsedParts.length === 0) { + elapsedParts.push(`${seconds}초`); + } + + return `${text}\n\n${elapsedParts.join(' ')}`; + }; + + const syncTrackedProgressMessage = async () => { + if (!progressMessageId || !channel.editMessage || !latestProgressText) { + return; + } + + const rendered = renderProgressMessage(latestProgressText); + if (rendered === latestProgressRendered) { + return; + } + + try { + await channel.editMessage(chatJid, progressMessageId, rendered); + latestProgressRendered = rendered; + } catch { + clearProgressTicker(); + progressMessageId = null; + } + }; + + const ensureProgressTicker = () => { + if (!progressMessageId || !channel.editMessage || progressTicker) { + return; + } + + progressTicker = setInterval(() => { + void syncTrackedProgressMessage(); + }, 10_000); + }; + + const finalizeProgressMessage = async () => { + await syncTrackedProgressMessage(); + clearProgressTicker(); + }; const sendProgressMessage = async (text: string) => { if (!text || text === latestProgressText) { return; } + if (progressStartedAt === null) { + progressStartedAt = Date.now(); + } latestProgressText = text; - await channel.sendMessage(chatJid, text); + const rendered = renderProgressMessage(text); + + if (progressMessageId && channel.editMessage) { + await syncTrackedProgressMessage(); + progressOutputSentToUser = true; + return; + } + + if (channel.sendAndTrack) { + progressMessageId = await channel.sendAndTrack(chatJid, rendered); + if (progressMessageId) { + latestProgressRendered = rendered; + ensureProgressTicker(); + progressOutputSentToUser = true; + return; + } + } + + latestProgressRendered = rendered; + await channel.sendMessage(chatJid, rendered); progressOutputSentToUser = true; }; @@ -399,9 +479,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { } if (text) { + await finalizeProgressMessage(); await channel.sendMessage(chatJid, text); finalOutputSentToUser = true; } + } else { + await finalizeProgressMessage(); } await channel.setTyping?.(chatJid, false); @@ -426,6 +509,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { hadError = true; } + clearProgressTicker(); + if (idleTimer) clearTimeout(idleTimer); if (hadError) { @@ -612,11 +697,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { for (const [chatJid, group] of Object.entries(registeredGroups)) { const sinceTimestamp = lastAgentTimestamps[chatJid] || ''; const pending = filterOwnChannelMessages( - getMessagesSince( - chatJid, - sinceTimestamp, - deps.assistantName, - ), + getMessagesSince(chatJid, sinceTimestamp, deps.assistantName), ); if (pending.length > 0) { logger.info( From f2ad1331a96d9ad53a632607b88f257e00756870 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Thu, 19 Mar 2026 03:31:41 +0900 Subject: [PATCH 13/17] Rebrand NanoClaw to EJClaw --- CLAUDE.md | 27 +- CONTRIBUTING.md | 2 +- README.md | 50 ++-- docs/DEBUG_CHECKLIST.md | 38 +-- docs/REQUIREMENTS.md | 6 +- docs/SECURITY.md | 4 +- docs/SPEC.md | 44 ++-- docs/nanoclaw-architecture-final.md | 12 +- docs/nanorepo-architecture.md | 4 +- groups/global/CLAUDE.md | 59 +---- groups/main/CLAUDE.md | 247 +----------------- ...law-codex.plist => com.ejclaw-codex.plist} | 6 +- .../{com.nanoclaw.plist => com.ejclaw.plist} | 6 +- package-lock.json | 4 +- package.json | 2 +- prompts/claude-platform.md | 2 +- prompts/codex-platform.md | 2 +- runners/agent-runner/package-lock.json | 4 +- runners/agent-runner/package.json | 4 +- runners/agent-runner/src/index.ts | 18 +- runners/agent-runner/src/ipc-mcp-stdio.ts | 13 +- runners/codex-runner/package-lock.json | 4 +- runners/codex-runner/package.json | 4 +- runners/codex-runner/src/app-server-client.ts | 8 +- runners/codex-runner/src/index.ts | 15 +- setup/mounts.ts | 2 +- setup/platform.ts | 2 +- setup/service.test.ts | 46 ++-- setup/service.ts | 59 +++-- setup/verify.ts | 31 ++- src/agent-runner.test.ts | 28 +- src/agent-runner.ts | 19 +- src/channels/discord.test.ts | 4 +- src/claude-usage.test.ts | 53 ++++ src/claude-usage.ts | 183 +++++++++++++ src/config.ts | 25 +- src/dashboard.ts | 13 +- src/db.ts | 4 +- src/group-queue.test.ts | 2 +- src/index.ts | 2 +- src/message-runtime.test.ts | 14 +- src/message-runtime.ts | 17 +- src/platform-prompts.test.ts | 2 +- src/session-commands.test.ts | 8 +- 44 files changed, 564 insertions(+), 535 deletions(-) rename launchd/{com.nanoclaw-codex.plist => com.ejclaw-codex.plist} (87%) rename launchd/{com.nanoclaw.plist => com.ejclaw.plist} (84%) create mode 100644 src/claude-usage.test.ts create mode 100644 src/claude-usage.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8e6052d..4888f80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ -# NanoClaw +# EJClaw Dual-agent AI assistant (Claude Code + Codex) over Discord. Based on [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw). ## Quick Context -Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups (will be unified — DB supports shared access via WAL mode + service partitioning). Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`). +Two systemd services (`ejclaw`, `ejclaw-codex`) share the same codebase but run with separate stores, data, and groups (will be unified — DB supports shared access via WAL mode + service partitioning). Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`). ## Key Files @@ -29,7 +29,7 @@ Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but | `/setup` | First-time installation, authentication, service configuration | | `/customize` | Adding channels, integrations, changing behavior | | `/debug` | Agent issues, logs, troubleshooting | -| `/update-nanoclaw` | Bring upstream NanoClaw updates into a customized install | +| `/update-nanoclaw` | Bring upstream EJClaw updates into a customized install | | `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch | | `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks | @@ -45,17 +45,17 @@ npm run dev # Dev mode with hot reload Service management (Linux): ```bash -systemctl --user restart nanoclaw nanoclaw-codex # Restart both -systemctl --user status nanoclaw # Check status -journalctl --user -u nanoclaw -f # Follow logs +systemctl --user restart ejclaw ejclaw-codex # Restart both +systemctl --user status ejclaw # Check status +journalctl --user -u ejclaw -f # Follow logs ``` -Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/` +Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/ejclaw/dist/` ## Dual-Service Architecture -- `nanoclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code` -- `nanoclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex`, `SERVICE_AGENT_TYPE=codex` +- `ejclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code` +- `ejclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex`, `SERVICE_AGENT_TYPE=codex` - Both share the same codebase (`dist/index.js`), differentiated by env vars - Unified dirs (`store/`, `groups/`, `data/` shared by both services): - `router_state`: keys prefixed with `{SERVICE_ID}:` (e.g., `claude:last_timestamp`) @@ -70,13 +70,14 @@ Unified DB + directories (both services share `store/`, `groups/`, `data/`): | 항목 | 경로 | |------|------| | **DB** | `store/messages.db` (공유, WAL 모드) | -| 서비스 로그 (Claude) | `journalctl --user -u nanoclaw -f` 또는 `logs/nanoclaw.log` | -| 서비스 로그 (Codex) | `journalctl --user -u nanoclaw-codex -f` 또는 `logs/nanoclaw-codex.log` | +| 서비스 로그 (Claude) | `journalctl --user -u ejclaw -f` 또는 `logs/ejclaw.log` | +| 서비스 로그 (Codex) | `journalctl --user -u ejclaw-codex -f` 또는 `logs/ejclaw-codex.log` | | 그룹별 로그 | `groups/{name}/logs/` (공유 채널은 양쪽 봇 로그가 같은 폴더) | | Claude 세션 | `data/sessions/{name}/.claude/` | | Codex 세션 | `data/sessions/{name}/.codex/` | -| Claude 글로벌 설정 | `groups/global/CLAUDE.md` | -| Codex 글로벌 설정 | `~/.codex/AGENTS.md` | +| Claude 플랫폼 규칙 | `prompts/claude-platform.md` | +| Codex 플랫폼 규칙 | `prompts/codex-platform.md` | +| Claude 글로벌 메모리 | `groups/global/CLAUDE.md` | ## Codex SDK diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dd3614d..8a358a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ ## Skills -A [skill](https://code.claude.com/docs/en/skills) is a markdown file in `.claude/skills/` that teaches Claude Code how to transform a NanoClaw installation. +A [skill](https://code.claude.com/docs/en/skills) is a markdown file in `.claude/skills/` that teaches Claude Code how to transform an EJClaw installation. A PR that contributes a skill should not modify any source files. diff --git a/README.md b/README.md index f39fdfe..976bd25 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@ -

- NanoClaw -

+

EJClaw

Dual-agent AI assistant running Claude Code + Codex as parallel services over Discord.

- Based on qwibitai/nanoclaw + Forked from qwibitai/nanoclaw

## Overview @@ -40,7 +38,7 @@ Discord ──► SQLite ──► GroupQueue ──┬──► Claude Agent SD ### Directory Layout ``` -nanoclaw/ +ejclaw/ ├── src/ │ ├── index.ts # Orchestrator: state, message loop, agent invocation │ ├── agent-runner.ts # Spawns agent processes, manages env/sessions/skills @@ -119,8 +117,8 @@ Skills are managed from a single source of truth (`~/.claude/skills/` on the ser ### 1. Clone and Install ```bash -git clone https://github.com/phj1081/nanoclaw.git -cd nanoclaw +git clone https://github.com/phj1081/nanoclaw.git ejclaw +cd ejclaw npm install npm run build:runners # installs + builds both agent-runner and codex-runner npm run build # builds main project @@ -163,17 +161,17 @@ DISCORD_BOT_TOKEN= # Codex Discord bot token (different from above) ### 4. Systemd Services (Linux) -Create `~/.config/systemd/user/nanoclaw.service`: +Create `~/.config/systemd/user/ejclaw.service`: ```ini [Unit] -Description=NanoClaw Claude Code +Description=EJClaw Claude Code After=network.target [Service] Type=simple -ExecStart=/path/to/node /path/to/nanoclaw/dist/index.js -WorkingDirectory=/path/to/nanoclaw +ExecStart=/path/to/node /path/to/ejclaw/dist/index.js +WorkingDirectory=/path/to/ejclaw Restart=always RestartSec=5 Environment=HOME=/home/youruser @@ -183,26 +181,26 @@ Environment=PATH=/path/to/node/bin:/usr/local/bin:/usr/bin:/bin WantedBy=default.target ``` -Create `~/.config/systemd/user/nanoclaw-codex.service`: +Create `~/.config/systemd/user/ejclaw-codex.service`: ```ini [Unit] -Description=NanoClaw Codex +Description=EJClaw Codex After=network.target [Service] -EnvironmentFile=/path/to/nanoclaw/.env.codex +EnvironmentFile=/path/to/ejclaw/.env.codex Type=simple -ExecStart=/path/to/node /path/to/nanoclaw/dist/index.js -WorkingDirectory=/path/to/nanoclaw +ExecStart=/path/to/node /path/to/ejclaw/dist/index.js +WorkingDirectory=/path/to/ejclaw Restart=always RestartSec=5 Environment=HOME=/home/youruser Environment=PATH=/path/to/node/bin:/usr/local/bin:/usr/bin:/bin Environment=ASSISTANT_NAME=codex -Environment=NANOCLAW_STORE_DIR=/path/to/nanoclaw/store-codex -Environment=NANOCLAW_DATA_DIR=/path/to/nanoclaw/data-codex -Environment=NANOCLAW_GROUPS_DIR=/path/to/nanoclaw/groups-codex +Environment=EJCLAW_STORE_DIR=/path/to/ejclaw/store-codex +Environment=EJCLAW_DATA_DIR=/path/to/ejclaw/data-codex +Environment=EJCLAW_GROUPS_DIR=/path/to/ejclaw/groups-codex [Install] WantedBy=default.target @@ -212,12 +210,12 @@ Then enable and start: ```bash systemctl --user daemon-reload -systemctl --user enable nanoclaw nanoclaw-codex -systemctl --user start nanoclaw nanoclaw-codex +systemctl --user enable ejclaw ejclaw-codex +systemctl --user start ejclaw ejclaw-codex # Logs -journalctl --user -u nanoclaw -f -journalctl --user -u nanoclaw-codex -f +journalctl --user -u ejclaw -f +journalctl --user -u ejclaw-codex -f ``` ### 5. Register Discord Channels @@ -247,9 +245,9 @@ Fields: ### macOS (launchd) ```bash -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist -launchctl load ~/Library/LaunchAgents/com.nanoclaw-codex.plist -launchctl kickstart -k gui/$(id -u)/com.nanoclaw +launchctl load ~/Library/LaunchAgents/com.ejclaw.plist +launchctl load ~/Library/LaunchAgents/com.ejclaw-codex.plist +launchctl kickstart -k gui/$(id -u)/com.ejclaw ``` ## Development diff --git a/docs/DEBUG_CHECKLIST.md b/docs/DEBUG_CHECKLIST.md index 5597067..d2b182e 100644 --- a/docs/DEBUG_CHECKLIST.md +++ b/docs/DEBUG_CHECKLIST.md @@ -1,4 +1,4 @@ -# NanoClaw Debug Checklist +# EJClaw Debug Checklist ## Known Issues (2026-02-08) @@ -16,7 +16,7 @@ Both timers fire at the same time, so containers always exit via hard SIGKILL (c ```bash # 1. Is the service running? launchctl list | grep nanoclaw -# Expected: PID 0 com.nanoclaw (PID = running, "-" = not running, non-zero exit = crashed) +# Expected: PID 0 com.ejclaw (PID = running, "-" = not running, non-zero exit = crashed) # 2. Any running containers? container ls --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw @@ -25,13 +25,13 @@ container ls --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw container ls -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw # 4. Recent errors in service log? -grep -E 'ERROR|WARN' logs/nanoclaw.log | tail -20 +grep -E 'ERROR|WARN' logs/ejclaw.log | tail -20 # 5. Is WhatsApp connected? (look for last connection event) -grep -E 'Connected to WhatsApp|Connection closed|connection.*close' logs/nanoclaw.log | tail -5 +grep -E 'Connected to WhatsApp|Connection closed|connection.*close' logs/ejclaw.log | tail -5 # 6. Are groups loaded? -grep 'groupCount' logs/nanoclaw.log | tail -3 +grep 'groupCount' logs/ejclaw.log | tail -3 ``` ## Session Transcript Branching @@ -62,7 +62,7 @@ for i, line in enumerate(lines): ```bash # Check for recent timeouts -grep -E 'Container timeout|timed out' logs/nanoclaw.log | tail -10 +grep -E 'Container timeout|timed out' logs/ejclaw.log | tail -10 # Check container log files for the timed-out container ls -lt groups/*/logs/container-*.log | head -10 @@ -71,23 +71,23 @@ ls -lt groups/*/logs/container-*.log | head -10 cat groups//logs/container-.log # Check if retries were scheduled and what happened -grep -E 'Scheduling retry|retry|Max retries' logs/nanoclaw.log | tail -10 +grep -E 'Scheduling retry|retry|Max retries' logs/ejclaw.log | tail -10 ``` ## Agent Not Responding ```bash # Check if messages are being received from WhatsApp -grep 'New messages' logs/nanoclaw.log | tail -10 +grep 'New messages' logs/ejclaw.log | tail -10 # Check if messages are being processed (container spawned) -grep -E 'Processing messages|Spawning container' logs/nanoclaw.log | tail -10 +grep -E 'Processing messages|Spawning container' logs/ejclaw.log | tail -10 # Check if messages are being piped to active container -grep -E 'Piped messages|sendMessage' logs/nanoclaw.log | tail -10 +grep -E 'Piped messages|sendMessage' logs/ejclaw.log | tail -10 # Check the queue state — any active containers? -grep -E 'Starting container|Container active|concurrency limit' logs/nanoclaw.log | tail -10 +grep -E 'Starting container|Container active|concurrency limit' logs/ejclaw.log | tail -10 # Check lastAgentTimestamp vs latest message timestamp sqlite3 store/messages.db "SELECT chat_jid, MAX(timestamp) as latest FROM messages GROUP BY chat_jid ORDER BY latest DESC LIMIT 5;" @@ -97,10 +97,10 @@ sqlite3 store/messages.db "SELECT chat_jid, MAX(timestamp) as latest FROM messag ```bash # Check mount validation logs (shows on container spawn) -grep -E 'Mount validated|Mount.*REJECTED|mount' logs/nanoclaw.log | tail -10 +grep -E 'Mount validated|Mount.*REJECTED|mount' logs/ejclaw.log | tail -10 # Verify the mount allowlist is readable -cat ~/.config/nanoclaw/mount-allowlist.json +cat ~/.config/ejclaw/mount-allowlist.json # Check group's container_config in DB sqlite3 store/messages.db "SELECT name, container_config FROM registered_groups;" @@ -114,7 +114,7 @@ container run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/ ```bash # Check if QR code was requested (means auth expired) -grep 'QR\|authentication required\|qr' logs/nanoclaw.log | tail -5 +grep 'QR\|authentication required\|qr' logs/ejclaw.log | tail -5 # Check auth files exist ls -la store/auth/ @@ -127,17 +127,17 @@ npm run auth ```bash # Restart the service -launchctl kickstart -k gui/$(id -u)/com.nanoclaw +launchctl kickstart -k gui/$(id -u)/com.ejclaw # View live logs -tail -f logs/nanoclaw.log +tail -f logs/ejclaw.log # Stop the service (careful — running containers are detached, not killed) -launchctl bootout gui/$(id -u)/com.nanoclaw +launchctl bootout gui/$(id -u)/com.ejclaw # Start the service -launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.ejclaw.plist # Rebuild after code changes -npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw +npm run build && launchctl kickstart -k gui/$(id -u)/com.ejclaw ``` diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 227c9ad..9b21908 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -1,4 +1,4 @@ -# NanoClaw Requirements +# EJClaw Requirements Original requirements and design decisions from the project creator. @@ -8,7 +8,7 @@ Original requirements and design decisions from the project creator. This is a lightweight, secure alternative to OpenClaw (formerly ClawBot). That project became a monstrosity - 4-5 different processes running different gateways, endless configuration files, endless integrations. It's a security nightmare where agents don't run in isolated processes; there's all kinds of leaky workarounds trying to prevent them from accessing parts of the system they shouldn't. It's impossible for anyone to realistically understand the whole codebase. When you run it you're kind of just yoloing it. -NanoClaw gives you the core functionality without that mess. +EJClaw gives you the core functionality without that mess. --- @@ -193,4 +193,4 @@ These are the creator's settings, stored here for reference: ## Project Name -**NanoClaw** - A reference to Clawdbot (now OpenClaw). +**EJClaw** - A reference to Clawdbot (now OpenClaw). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index db6fc18..8318d90 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,4 +1,4 @@ -# NanoClaw Security Model +# EJClaw Security Model ## Trust Model @@ -23,7 +23,7 @@ This is the primary security boundary. Rather than relying on application-level ### 2. Mount Security -**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is: +**External Allowlist** - Mount permissions stored at `~/.config/ejclaw/mount-allowlist.json`, which is: - Outside project root - Never mounted into containers - Cannot be modified by agents diff --git a/docs/SPEC.md b/docs/SPEC.md index fd6e535..8ae7970 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1,4 +1,4 @@ -# NanoClaw Specification +# EJClaw Specification A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution. @@ -314,12 +314,12 @@ nanoclaw/ │ └── ipc/ # Container IPC (messages/, tasks/) │ ├── logs/ # Runtime logs (gitignored) -│ ├── nanoclaw.log # Host stdout -│ └── nanoclaw.error.log # Host stderr +│ ├── ejclaw.log # Host stdout +│ └── ejclaw.error.log # Host stderr │ # Note: Per-container logs are in groups/{folder}/logs/container-*.log │ └── launchd/ - └── com.nanoclaw.plist # macOS service configuration + └── com.ejclaw.plist # macOS service configuration ``` --- @@ -422,7 +422,7 @@ Files with `{{PLACEHOLDER}}` values need to be configured: ## Memory System -NanoClaw uses a hierarchical memory system based on CLAUDE.md files. +EJClaw uses a hierarchical memory system based on CLAUDE.md files. ### Memory Hierarchy @@ -555,7 +555,7 @@ This allows the agent to understand the conversation context even if it wasn't m ## Scheduled Tasks -NanoClaw has a built-in scheduler that runs tasks as full agents in their group's context. +EJClaw has a built-in scheduler that runs tasks as full agents in their group's context. ### How Scheduling Works @@ -616,7 +616,7 @@ From main channel: ## MCP Servers -### NanoClaw MCP (built-in) +### EJClaw MCP (built-in) The `nanoclaw` MCP server is created dynamically per agent call with the current group's context. @@ -636,12 +636,12 @@ The `nanoclaw` MCP server is created dynamically per agent call with the current ## Deployment -NanoClaw runs as a single macOS launchd service. +EJClaw runs as a single macOS launchd service. ### Startup Sequence -When NanoClaw starts, it: -1. **Ensures container runtime is running** - Automatically starts it if needed; kills orphaned NanoClaw containers from previous runs +When EJClaw starts, it: +1. **Ensures container runtime is running** - Automatically starts it if needed; kills orphaned EJClaw containers from previous runs 2. Initializes the SQLite database (migrates from JSON files if they exist) 3. Loads state from SQLite (registered groups, sessions, router state) 4. **Connects channels** — loops through registered channels, instantiates those with credentials, calls `connect()` on each @@ -652,16 +652,16 @@ When NanoClaw starts, it: - Recovers any unprocessed messages from before shutdown - Starts the message polling loop -### Service: com.nanoclaw +### Service: com.ejclaw -**launchd/com.nanoclaw.plist:** +**launchd/com.ejclaw.plist:** ```xml Label - com.nanoclaw + com.ejclaw ProgramArguments {{NODE_PATH}} @@ -683,9 +683,9 @@ When NanoClaw starts, it: Andy StandardOutPath - {{PROJECT_ROOT}}/logs/nanoclaw.log + {{PROJECT_ROOT}}/logs/ejclaw.log StandardErrorPath - {{PROJECT_ROOT}}/logs/nanoclaw.error.log + {{PROJECT_ROOT}}/logs/ejclaw.error.log ``` @@ -694,19 +694,19 @@ When NanoClaw starts, it: ```bash # Install service -cp launchd/com.nanoclaw.plist ~/Library/LaunchAgents/ +cp launchd/com.ejclaw.plist ~/Library/LaunchAgents/ # Start service -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist +launchctl load ~/Library/LaunchAgents/com.ejclaw.plist # Stop service -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist +launchctl unload ~/Library/LaunchAgents/com.ejclaw.plist # Check status launchctl list | grep nanoclaw # View logs -tail -f logs/nanoclaw.log +tail -f logs/ejclaw.log ``` --- @@ -763,7 +763,7 @@ chmod 700 groups/ | Issue | Cause | Solution | |-------|-------|----------| | No response to messages | Service not running | Check `launchctl list | grep nanoclaw` | -| "Claude Code process exited with code 1" | Container runtime failed to start | Check logs; NanoClaw auto-starts container runtime but may fail | +| "Claude Code process exited with code 1" | Container runtime failed to start | Check logs; EJClaw auto-starts container runtime but may fail | | "Claude Code process exited with code 1" | Session mount path wrong | Ensure mount is to `/home/node/.claude/` not `/root/.claude/` | | Session not continuing | Session ID not saved | Check SQLite: `sqlite3 store/messages.db "SELECT * FROM sessions"` | | Session not continuing | Mount path mismatch | Container user is `node` with HOME=/home/node; sessions must be at `/home/node/.claude/` | @@ -772,8 +772,8 @@ chmod 700 groups/ ### Log Location -- `logs/nanoclaw.log` - stdout -- `logs/nanoclaw.error.log` - stderr +- `logs/ejclaw.log` - stdout +- `logs/ejclaw.error.log` - stderr ### Debug Mode diff --git a/docs/nanoclaw-architecture-final.md b/docs/nanoclaw-architecture-final.md index 103b38b..71feb4c 100644 --- a/docs/nanoclaw-architecture-final.md +++ b/docs/nanoclaw-architecture-final.md @@ -1,4 +1,4 @@ -# NanoClaw Skills Architecture +# EJClaw Skills Architecture ## Core Principle @@ -123,7 +123,7 @@ skills/ - Git's three-way merge uses context matching, so it works even if the user has moved code around — unlike line-number-based diffs that break immediately - Auditable: `diff .nanoclaw/base/src/server.ts skills/add-whatsapp/modify/src/server.ts` shows exactly what the skill changes - Deterministic: same three inputs always produce the same merge result -- Size is negligible since NanoClaw's core files are small +- Size is negligible since EJClaw's core files are small ### Intent Files @@ -382,11 +382,11 @@ If tests fail and Level 2 can't resolve, restore from `.nanoclaw/backup/` and re ### The Problem -`git rerere` is local by default. But NanoClaw has thousands of users applying the same skill combinations. Every user hitting the same conflict and waiting for Claude Code to resolve it is wasteful. +`git rerere` is local by default. But EJClaw has thousands of users applying the same skill combinations. Every user hitting the same conflict and waiting for Claude Code to resolve it is wasteful. ### The Solution -NanoClaw maintains a verified resolution cache in `.nanoclaw/resolutions/` that ships with the project. This is the shared artifact — **not** `.git/rr-cache/`, which stays local. +EJClaw maintains a verified resolution cache in `.nanoclaw/resolutions/` that ships with the project. This is the shared artifact — **not** `.git/rr-cache/`, which stays local. ``` .nanoclaw/ @@ -588,7 +588,7 @@ There is no unrecoverable state. ## 10. Core Updates -Core updates must be as programmatic as possible. The NanoClaw team is responsible for ensuring updates apply cleanly to common skill combinations. +Core updates must be as programmatic as possible. The EJClaw team is responsible for ensuring updates apply cleanly to common skill combinations. ### Patches and Migrations @@ -977,7 +977,7 @@ Each passing combination generates a verified resolution entry for the shared ca ### `.gitattributes` -Ship with NanoClaw to reduce noisy merge conflicts: +Ship with EJClaw to reduce noisy merge conflicts: ``` * text=auto diff --git a/docs/nanorepo-architecture.md b/docs/nanorepo-architecture.md index 1365e9e..e6f1bec 100644 --- a/docs/nanorepo-architecture.md +++ b/docs/nanorepo-architecture.md @@ -1,8 +1,8 @@ -# NanoClaw Skills Architecture +# EJClaw Skills Architecture ## What Skills Are For -NanoClaw's core is intentionally minimal. Skills are how users extend it: adding channels, integrations, cross-platform support, or replacing internals entirely. Examples: add Telegram alongside WhatsApp, switch from Apple Container to Docker, add Gmail integration, add voice message transcription. Each skill modifies the actual codebase, adding channel handlers, updating the message router, changing container configuration, and adding dependencies, rather than working through a plugin API or runtime hooks. +EJClaw's core is intentionally minimal. Skills are how users extend it: adding channels, integrations, cross-platform support, or replacing internals entirely. Examples: add Telegram alongside WhatsApp, switch from Apple Container to Docker, add Gmail integration, add voice message transcription. Each skill modifies the actual codebase, adding channel handlers, updating the message router, changing container configuration, and adding dependencies, rather than working through a plugin API or runtime hooks. ## Why This Architecture diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index 28e97a7..5882f16 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -1,58 +1,7 @@ -# Andy +# Global Memory -You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders. +This file is for mutable memory shared across Claude groups. -## What You Can Do +Use it for durable facts, preferences, and shared context that may change over time. -- Answer questions and have conversations -- Search the web and fetch content from URLs -- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open ` to start, then `agent-browser snapshot -i` to see interactive elements) -- Read and write files in your workspace -- Run bash commands in your sandbox -- Schedule tasks to run later or on a recurring basis -- Send messages back to the chat - -## Communication - -Your output is sent to the user or group. - -You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work. - -### Internal thoughts - -If part of your output is internal reasoning rather than something for the user, wrap it in `` tags: - -``` -Compiled all three reports, ready to summarize. - -Here are the key findings from the research... -``` - -Text inside `` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `` to avoid sending it again. - -### Sub-agents and teammates - -When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent. - -## Your Workspace - -Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist. - -## Memory - -The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. - -When you learn something important: -- Create files for structured data (e.g., `customers.md`, `preferences.md`) -- Split files larger than 500 lines into folders -- Keep an index in your memory for the files you create - -## Message Formatting - -NEVER use markdown. Only use WhatsApp/Telegram formatting: -- *single asterisks* for bold (NEVER **double asterisks**) -- _underscores_ for italic -- • bullet points -- ```triple backticks``` for code - -No ## headings. No [links](url). No **double stars**. +Do not store platform-wide operating rules here. Those now live in `prompts/claude-platform.md`. diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index 11e846b..aea778e 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -1,246 +1,5 @@ -# Andy +# Main Group Memory -You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders. +This file is for mutable memory and admin context specific to the main group. -## What You Can Do - -- Answer questions and have conversations -- Search the web and fetch content from URLs -- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open ` to start, then `agent-browser snapshot -i` to see interactive elements) -- Read and write files in your workspace -- Run bash commands in your sandbox -- Schedule tasks to run later or on a recurring basis -- Send messages back to the chat - -## Communication - -Your output is sent to the user or group. - -You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work. - -### Internal thoughts - -If part of your output is internal reasoning rather than something for the user, wrap it in `` tags: - -``` -Compiled all three reports, ready to summarize. - -Here are the key findings from the research... -``` - -Text inside `` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `` to avoid sending it again. - -### Sub-agents and teammates - -When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent. - -## Memory - -The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. - -When you learn something important: -- Create files for structured data (e.g., `customers.md`, `preferences.md`) -- Split files larger than 500 lines into folders -- Keep an index in your memory for the files you create - -## WhatsApp Formatting (and other messaging apps) - -Do NOT use markdown headings (##) in WhatsApp messages. Only use: -- *Bold* (single asterisks) (NEVER **double asterisks**) -- _Italic_ (underscores) -- • Bullets (bullet points) -- ```Code blocks``` (triple backticks) - -Keep messages clean and readable for WhatsApp. - ---- - -## Admin Context - -This is the **main channel**, which has elevated privileges. - -## Container Mounts - -Main has read-only access to the project and read-write access to its group folder: - -| Container Path | Host Path | Access | -|----------------|-----------|--------| -| `/workspace/project` | Project root | read-only | -| `/workspace/group` | `groups/main/` | read-write | - -Key paths inside the container: -- `/workspace/project/store/messages.db` - SQLite database -- `/workspace/project/store/messages.db` (registered_groups table) - Group config -- `/workspace/project/groups/` - All group folders - ---- - -## Managing Groups - -### Finding Available Groups - -Available groups are provided in `/workspace/ipc/available_groups.json`: - -```json -{ - "groups": [ - { - "jid": "120363336345536173@g.us", - "name": "Family Chat", - "lastActivity": "2026-01-31T12:00:00.000Z", - "isRegistered": false - } - ], - "lastSync": "2026-01-31T12:00:00.000Z" -} -``` - -Groups are ordered by most recent activity. The list is synced from WhatsApp daily. - -If a group the user mentions isn't in the list, request a fresh sync: - -```bash -echo '{"type": "refresh_groups"}' > /workspace/ipc/tasks/refresh_$(date +%s).json -``` - -Then wait a moment and re-read `available_groups.json`. - -**Fallback**: Query the SQLite database directly: - -```bash -sqlite3 /workspace/project/store/messages.db " - SELECT jid, name, last_message_time - FROM chats - WHERE jid LIKE '%@g.us' AND jid != '__group_sync__' - ORDER BY last_message_time DESC - LIMIT 10; -" -``` - -### Registered Groups Config - -Groups are registered in the SQLite `registered_groups` table: - -```json -{ - "1234567890-1234567890@g.us": { - "name": "Family Chat", - "folder": "whatsapp_family-chat", - "trigger": "@Andy", - "added_at": "2024-01-31T12:00:00.000Z" - } -} -``` - -Fields: -- **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.) -- **name**: Display name for the group -- **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory -- **trigger**: The trigger word (usually same as global, but could differ) -- **requiresTrigger**: Whether `@trigger` prefix is needed (default: `true`). Set to `false` for solo/personal chats where all messages should be processed -- **isMain**: Whether this is the main control group (elevated privileges, no trigger required) -- **added_at**: ISO timestamp when registered - -### Trigger Behavior - -- **Main group** (`isMain: true`): No trigger needed — all messages are processed automatically -- **Groups with `requiresTrigger: false`**: No trigger needed — all messages processed (use for 1-on-1 or solo chats) -- **Other groups** (default): Messages must start with `@AssistantName` to be processed - -### Adding a Group - -1. Query the database to find the group's JID -2. Use the `register_group` MCP tool with the JID, name, folder, and trigger -3. Optionally include `containerConfig` for additional mounts -4. The group folder is created automatically: `/workspace/project/groups/{folder-name}/` -5. Optionally create an initial `CLAUDE.md` for the group - -Folder naming convention — channel prefix with underscore separator: -- WhatsApp "Family Chat" → `whatsapp_family-chat` -- Telegram "Dev Team" → `telegram_dev-team` -- Discord "General" → `discord_general` -- Slack "Engineering" → `slack_engineering` -- Use lowercase, hyphens for the group name part - -#### Adding Additional Directories for a Group - -Groups can have extra directories mounted. Add `containerConfig` to their entry: - -```json -{ - "1234567890@g.us": { - "name": "Dev Team", - "folder": "dev-team", - "trigger": "@Andy", - "added_at": "2026-01-31T12:00:00Z", - "containerConfig": { - "additionalMounts": [ - { - "hostPath": "~/projects/webapp", - "containerPath": "webapp", - "readonly": false - } - ] - } - } -} -``` - -The directory will appear at `/workspace/extra/webapp` in that group's container. - -#### Sender Allowlist - -After registering a group, explain the sender allowlist feature to the user: - -> This group can be configured with a sender allowlist to control who can interact with me. There are two modes: -> -> - **Trigger mode** (default): Everyone's messages are stored for context, but only allowed senders can trigger me with @{AssistantName}. -> - **Drop mode**: Messages from non-allowed senders are not stored at all. -> -> For closed groups with trusted members, I recommend setting up an allow-only list so only specific people can trigger me. Want me to configure that? - -If the user wants to set up an allowlist, edit `~/.config/nanoclaw/sender-allowlist.json` on the host: - -```json -{ - "default": { "allow": "*", "mode": "trigger" }, - "chats": { - "": { - "allow": ["sender-id-1", "sender-id-2"], - "mode": "trigger" - } - }, - "logDenied": true -} -``` - -Notes: -- Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist. -- If the config file doesn't exist or is invalid, all senders are allowed (fail-open) -- The config file is on the host at `~/.config/nanoclaw/sender-allowlist.json`, not inside the container - -### Removing a Group - -1. Read `/workspace/project/data/registered_groups.json` -2. Remove the entry for that group -3. Write the updated JSON back -4. The group folder and its files remain (don't delete them) - -### Listing Groups - -Read `/workspace/project/data/registered_groups.json` and format it nicely. - ---- - -## Global Memory - -You can read and write to `/workspace/project/groups/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar. - ---- - -## Scheduling for Other Groups - -When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`: -- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")` - -The task will run in that group's context with access to their files and memory. +Keep platform-wide operating rules in `prompts/claude-platform.md`. diff --git a/launchd/com.nanoclaw-codex.plist b/launchd/com.ejclaw-codex.plist similarity index 87% rename from launchd/com.nanoclaw-codex.plist rename to launchd/com.ejclaw-codex.plist index 8abc9a7..855828e 100644 --- a/launchd/com.nanoclaw-codex.plist +++ b/launchd/com.ejclaw-codex.plist @@ -3,7 +3,7 @@ Label - com.nanoclaw-codex + com.ejclaw-codex ProgramArguments {{NODE_PATH}} @@ -31,8 +31,8 @@ {{PROJECT_ROOT}}/data-codex StandardOutPath - {{PROJECT_ROOT}}/logs/nanoclaw-codex.log + {{PROJECT_ROOT}}/logs/ejclaw-codex.log StandardErrorPath - {{PROJECT_ROOT}}/logs/nanoclaw-codex.error.log + {{PROJECT_ROOT}}/logs/ejclaw-codex.error.log diff --git a/launchd/com.nanoclaw.plist b/launchd/com.ejclaw.plist similarity index 84% rename from launchd/com.nanoclaw.plist rename to launchd/com.ejclaw.plist index 82bef0a..9625b6c 100644 --- a/launchd/com.nanoclaw.plist +++ b/launchd/com.ejclaw.plist @@ -3,7 +3,7 @@ Label - com.nanoclaw + com.ejclaw ProgramArguments {{NODE_PATH}} @@ -25,8 +25,8 @@ Andy StandardOutPath - {{PROJECT_ROOT}}/logs/nanoclaw.log + {{PROJECT_ROOT}}/logs/ejclaw.log StandardErrorPath - {{PROJECT_ROOT}}/logs/nanoclaw.error.log + {{PROJECT_ROOT}}/logs/ejclaw.error.log diff --git a/package-lock.json b/package-lock.json index 603ad85..9bcbe11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "nanoclaw", + "name": "ejclaw", "version": "1.2.12", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nanoclaw", + "name": "ejclaw", "version": "1.2.12", "dependencies": { "better-sqlite3": "^11.8.1", diff --git a/package.json b/package.json index 8831677..0f46c24 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "nanoclaw", + "name": "ejclaw", "version": "1.2.12", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", diff --git a/prompts/claude-platform.md b/prompts/claude-platform.md index 9ce497f..8a3f180 100644 --- a/prompts/claude-platform.md +++ b/prompts/claude-platform.md @@ -6,7 +6,7 @@ You are Andy, a personal assistant. 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. +You also have a `send_message` tool, 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 diff --git a/prompts/codex-platform.md b/prompts/codex-platform.md index 1cb9c86..0ecc1ed 100644 --- a/prompts/codex-platform.md +++ b/prompts/codex-platform.md @@ -15,7 +15,7 @@ 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` +- Do not claim you will keep watching, monitor later, report back later, or continue tracking unless you actually scheduled an EJClaw 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 diff --git a/runners/agent-runner/package-lock.json b/runners/agent-runner/package-lock.json index 4232aa2..1fd7e61 100644 --- a/runners/agent-runner/package-lock.json +++ b/runners/agent-runner/package-lock.json @@ -1,11 +1,11 @@ { - "name": "nanoclaw-agent-runner", + "name": "ejclaw-agent-runner", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nanoclaw-agent-runner", + "name": "ejclaw-agent-runner", "version": "1.0.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.76", diff --git a/runners/agent-runner/package.json b/runners/agent-runner/package.json index 42a994e..1154b6e 100644 --- a/runners/agent-runner/package.json +++ b/runners/agent-runner/package.json @@ -1,8 +1,8 @@ { - "name": "nanoclaw-agent-runner", + "name": "ejclaw-agent-runner", "version": "1.0.0", "type": "module", - "description": "Container-side agent runner for NanoClaw", + "description": "Container-side agent runner for EJClaw", "main": "dist/index.js", "scripts": { "build": "tsc", diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index b962a85..c9f99dd 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -1,5 +1,5 @@ /** - * NanoClaw Agent Runner + * EJClaw Agent Runner * Runs inside a container, receives config via stdin, outputs result to stdout * * Input protocol: @@ -60,11 +60,19 @@ 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 EXTRA_BASE = process.env.NANOCLAW_EXTRA_DIR || '/workspace/extra'; +const GROUP_DIR = + process.env.EJCLAW_GROUP_DIR || + process.env.NANOCLAW_GROUP_DIR || + '/workspace/group'; +const IPC_DIR = + process.env.EJCLAW_IPC_DIR || process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; +const EXTRA_BASE = + process.env.EJCLAW_EXTRA_DIR || + 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 || ''; +const WORK_DIR = + process.env.EJCLAW_WORK_DIR || process.env.NANOCLAW_WORK_DIR || ''; const IPC_INPUT_DIR = path.join(IPC_DIR, 'input'); const MAX_TURNS = 100; diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index 3af73aa..c7438b0 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -1,5 +1,5 @@ /** - * Stdio MCP Server for NanoClaw + * Stdio MCP Server for EJClaw * Standalone process that agent teams subagents can inherit. * Reads context from environment variables, writes IPC files for the host. */ @@ -11,14 +11,17 @@ import fs from 'fs'; import path from 'path'; import { CronExpressionParser } from 'cron-parser'; -const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; +const IPC_DIR = + process.env.EJCLAW_IPC_DIR || process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; const MESSAGES_DIR = path.join(IPC_DIR, 'messages'); const TASKS_DIR = path.join(IPC_DIR, 'tasks'); // Context from environment variables (set by the agent runner) -const chatJid = process.env.NANOCLAW_CHAT_JID!; -const groupFolder = process.env.NANOCLAW_GROUP_FOLDER!; -const isMain = process.env.NANOCLAW_IS_MAIN === '1'; +const chatJid = process.env.EJCLAW_CHAT_JID || process.env.NANOCLAW_CHAT_JID!; +const groupFolder = + process.env.EJCLAW_GROUP_FOLDER || process.env.NANOCLAW_GROUP_FOLDER!; +const isMain = + (process.env.EJCLAW_IS_MAIN || process.env.NANOCLAW_IS_MAIN) === '1'; function writeIpcFile(dir: string, data: object): string { fs.mkdirSync(dir, { recursive: true }); diff --git a/runners/codex-runner/package-lock.json b/runners/codex-runner/package-lock.json index f070aa1..956a0a0 100644 --- a/runners/codex-runner/package-lock.json +++ b/runners/codex-runner/package-lock.json @@ -1,11 +1,11 @@ { - "name": "nanoclaw-codex-runner", + "name": "ejclaw-codex-runner", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nanoclaw-codex-runner", + "name": "ejclaw-codex-runner", "version": "1.0.0", "dependencies": { "@openai/codex-sdk": "^0.114.0" diff --git a/runners/codex-runner/package.json b/runners/codex-runner/package.json index a4c2d1d..e1aebd0 100644 --- a/runners/codex-runner/package.json +++ b/runners/codex-runner/package.json @@ -1,8 +1,8 @@ { - "name": "nanoclaw-codex-runner", + "name": "ejclaw-codex-runner", "version": "1.0.0", "type": "module", - "description": "Container-side Codex CLI runner for NanoClaw", + "description": "Container-side Codex CLI runner for EJClaw", "main": "dist/index.js", "scripts": { "build": "tsc", diff --git a/runners/codex-runner/src/app-server-client.ts b/runners/codex-runner/src/app-server-client.ts index 11693c9..f86edab 100644 --- a/runners/codex-runner/src/app-server-client.ts +++ b/runners/codex-runner/src/app-server-client.ts @@ -144,8 +144,8 @@ export class CodexAppServerClient { await this.request('initialize', { clientInfo: { - name: 'nanoclaw_codex_runner', - title: 'NanoClaw Codex Runner', + name: 'ejclaw_codex_runner', + title: 'EJClaw Codex Runner', version: '1.0.0', }, capabilities: { @@ -182,7 +182,7 @@ export class CodexAppServerClient { model: options.model, approvalPolicy: 'never', sandbox: 'danger-full-access', - serviceName: 'nanoclaw', + serviceName: 'ejclaw', }; const result = sessionId @@ -362,7 +362,7 @@ export class CodexAppServerClient { this.respondError( message.id, -32601, - `NanoClaw does not handle server request ${message.method}`, + `EJClaw does not handle server request ${message.method}`, ); } diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts index d161e6b..bfb565d 100644 --- a/runners/codex-runner/src/index.ts +++ b/runners/codex-runner/src/index.ts @@ -1,12 +1,12 @@ /** - * NanoClaw Codex Runner + * EJClaw Codex Runner * * 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) - * IPC: Follow-up messages as JSON files in $NANOCLAW_IPC_DIR/input/ + * IPC: Follow-up messages as JSON files in $EJCLAW_IPC_DIR/input/ * Sentinel: _close — signals session end * * Stdout protocol: @@ -45,9 +45,14 @@ interface ContainerOutput { // ── Constants ────────────────────────────────────────────────────── -const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group'; -const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; -const WORK_DIR = process.env.NANOCLAW_WORK_DIR || ''; +const GROUP_DIR = + process.env.EJCLAW_GROUP_DIR || + process.env.NANOCLAW_GROUP_DIR || + '/workspace/group'; +const IPC_DIR = + process.env.EJCLAW_IPC_DIR || process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; +const WORK_DIR = + process.env.EJCLAW_WORK_DIR || process.env.NANOCLAW_WORK_DIR || ''; 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; diff --git a/setup/mounts.ts b/setup/mounts.ts index eb2a5f6..7a028c1 100644 --- a/setup/mounts.ts +++ b/setup/mounts.ts @@ -26,7 +26,7 @@ function parseArgs(args: string[]): { empty: boolean; json: string } { export async function run(args: string[]): Promise { const { empty, json } = parseArgs(args); const homeDir = os.homedir(); - const configDir = path.join(homeDir, '.config', 'nanoclaw'); + const configDir = path.join(homeDir, '.config', 'ejclaw'); const configFile = path.join(configDir, 'mount-allowlist.json'); if (isRoot()) { diff --git a/setup/platform.ts b/setup/platform.ts index 5544eac..9f84ed9 100644 --- a/setup/platform.ts +++ b/setup/platform.ts @@ -1,5 +1,5 @@ /** - * Cross-platform detection utilities for NanoClaw setup. + * Cross-platform detection utilities for EJClaw setup. */ import { execSync } from 'child_process'; import fs from 'fs'; diff --git a/setup/service.test.ts b/setup/service.test.ts index eb15db8..3458be0 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -19,7 +19,7 @@ function generatePlist( Label - com.nanoclaw + com.ejclaw ProgramArguments ${nodePath} @@ -39,9 +39,9 @@ function generatePlist( ${homeDir} StandardOutPath - ${projectRoot}/logs/nanoclaw.log + ${projectRoot}/logs/ejclaw.log StandardErrorPath - ${projectRoot}/logs/nanoclaw.error.log + ${projectRoot}/logs/ejclaw.error.log `; } @@ -53,7 +53,7 @@ function generateSystemdUnit( isSystem: boolean, ): string { return `[Unit] -Description=NanoClaw Personal Assistant +Description=EJClaw Personal Assistant After=network.target [Service] @@ -64,8 +64,8 @@ Restart=always RestartSec=5 Environment=HOME=${homeDir} Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin -StandardOutput=append:${projectRoot}/logs/nanoclaw.log -StandardError=append:${projectRoot}/logs/nanoclaw.error.log +StandardOutput=append:${projectRoot}/logs/ejclaw.log +StandardError=append:${projectRoot}/logs/ejclaw.error.log [Install] WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`; @@ -75,16 +75,16 @@ describe('plist generation', () => { it('contains the correct label', () => { const plist = generatePlist( '/usr/local/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', ); - expect(plist).toContain('com.nanoclaw'); + expect(plist).toContain('com.ejclaw'); }); it('uses the correct node path', () => { const plist = generatePlist( '/opt/node/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', ); expect(plist).toContain('/opt/node/bin/node'); @@ -93,20 +93,20 @@ describe('plist generation', () => { it('points to dist/index.js', () => { const plist = generatePlist( '/usr/local/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', ); - expect(plist).toContain('/home/user/nanoclaw/dist/index.js'); + expect(plist).toContain('/home/user/ejclaw/dist/index.js'); }); it('sets log paths', () => { const plist = generatePlist( '/usr/local/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', ); - expect(plist).toContain('nanoclaw.log'); - expect(plist).toContain('nanoclaw.error.log'); + expect(plist).toContain('ejclaw.log'); + expect(plist).toContain('ejclaw.error.log'); }); }); @@ -114,7 +114,7 @@ describe('systemd unit generation', () => { it('user unit uses default.target', () => { const unit = generateSystemdUnit( '/usr/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', false, ); @@ -124,7 +124,7 @@ describe('systemd unit generation', () => { it('system unit uses multi-user.target', () => { const unit = generateSystemdUnit( '/usr/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', true, ); @@ -134,7 +134,7 @@ describe('systemd unit generation', () => { it('contains restart policy', () => { const unit = generateSystemdUnit( '/usr/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', false, ); @@ -145,32 +145,32 @@ describe('systemd unit generation', () => { it('sets correct ExecStart', () => { const unit = generateSystemdUnit( '/usr/bin/node', - '/srv/nanoclaw', + '/srv/ejclaw', '/home/user', false, ); expect(unit).toContain( - 'ExecStart=/usr/bin/node /srv/nanoclaw/dist/index.js', + 'ExecStart=/usr/bin/node /srv/ejclaw/dist/index.js', ); }); }); describe('WSL nohup fallback', () => { it('generates a valid wrapper script', () => { - const projectRoot = '/home/user/nanoclaw'; + const projectRoot = '/home/user/ejclaw'; const nodePath = '/usr/bin/node'; - const pidFile = path.join(projectRoot, 'nanoclaw.pid'); + const pidFile = path.join(projectRoot, 'ejclaw.pid'); // Simulate what service.ts generates const wrapper = `#!/bin/bash set -euo pipefail cd ${JSON.stringify(projectRoot)} -nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot)}/dist/index.js >> ${JSON.stringify(projectRoot)}/logs/nanoclaw.log 2>> ${JSON.stringify(projectRoot)}/logs/nanoclaw.error.log & +nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot)}/dist/index.js >> ${JSON.stringify(projectRoot)}/logs/ejclaw.log 2>> ${JSON.stringify(projectRoot)}/logs/ejclaw.error.log & echo $! > ${JSON.stringify(pidFile)}`; expect(wrapper).toContain('#!/bin/bash'); expect(wrapper).toContain('nohup'); expect(wrapper).toContain(nodePath); - expect(wrapper).toContain('nanoclaw.pid'); + expect(wrapper).toContain('ejclaw.pid'); }); }); diff --git a/setup/service.ts b/setup/service.ts index ffdeb9f..48cd862 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -20,6 +20,9 @@ import { } from './platform.js'; import { emitStatus } from './status.js'; +const EJCLAW_SERVICE_NAME = 'ejclaw'; +const EJCLAW_LAUNCHD_LABEL = 'com.ejclaw'; + export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); const platform = getPlatform(); @@ -77,7 +80,7 @@ function setupLaunchd( homeDir, 'Library', 'LaunchAgents', - 'com.nanoclaw.plist', + `${EJCLAW_LAUNCHD_LABEL}.plist`, ); fs.mkdirSync(path.dirname(plistPath), { recursive: true }); @@ -86,7 +89,7 @@ function setupLaunchd( Label - com.nanoclaw + ${EJCLAW_LAUNCHD_LABEL} ProgramArguments ${nodePath} @@ -106,9 +109,9 @@ function setupLaunchd( ${homeDir} StandardOutPath - ${projectRoot}/logs/nanoclaw.log + ${projectRoot}/logs/ejclaw.log StandardErrorPath - ${projectRoot}/logs/nanoclaw.error.log + ${projectRoot}/logs/ejclaw.error.log `; @@ -128,7 +131,7 @@ function setupLaunchd( let serviceLoaded = false; try { const output = execSync('launchctl list', { encoding: 'utf-8' }); - serviceLoaded = output.includes('com.nanoclaw'); + serviceLoaded = output.includes(EJCLAW_LAUNCHD_LABEL); } catch { // launchctl list failed } @@ -160,7 +163,7 @@ function setupLinux( } /** - * Kill any orphaned nanoclaw node processes left from previous runs or debugging. + * Kill any orphaned ejclaw node processes left from previous runs or debugging. * Prevents connection conflicts when two instances connect to the same channel simultaneously. */ function killOrphanedProcesses(projectRoot: string): void { @@ -168,7 +171,7 @@ function killOrphanedProcesses(projectRoot: string): void { execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, { stdio: 'ignore', }); - logger.info('Stopped any orphaned nanoclaw processes'); + logger.info('Stopped any orphaned ejclaw processes'); } catch { // pkill not available or no orphans } @@ -186,7 +189,7 @@ function setupSystemd( let systemctlPrefix: string; if (runningAsRoot) { - unitPath = '/etc/systemd/system/nanoclaw.service'; + unitPath = `/etc/systemd/system/${EJCLAW_SERVICE_NAME}.service`; systemctlPrefix = 'systemctl'; logger.info('Running as root — installing system-level systemd unit'); } else { @@ -202,12 +205,12 @@ function setupSystemd( } const unitDir = path.join(homeDir, '.config', 'systemd', 'user'); fs.mkdirSync(unitDir, { recursive: true }); - unitPath = path.join(unitDir, 'nanoclaw.service'); + unitPath = path.join(unitDir, `${EJCLAW_SERVICE_NAME}.service`); systemctlPrefix = 'systemctl --user'; } const unit = `[Unit] -Description=NanoClaw Personal Assistant +Description=EJClaw Personal Assistant After=network.target [Service] @@ -218,8 +221,8 @@ Restart=always RestartSec=5 Environment=HOME=${homeDir} Environment=PATH=${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin -StandardOutput=append:${projectRoot}/logs/nanoclaw.log -StandardError=append:${projectRoot}/logs/nanoclaw.error.log +StandardOutput=append:${projectRoot}/logs/ejclaw.log +StandardError=append:${projectRoot}/logs/ejclaw.error.log [Install] WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; @@ -227,7 +230,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; fs.writeFileSync(unitPath, unit); logger.info({ unitPath }, 'Wrote systemd unit'); - // Kill orphaned nanoclaw processes to avoid channel connection conflicts + // Kill orphaned ejclaw processes to avoid channel connection conflicts killOrphanedProcesses(projectRoot); // Enable and start @@ -238,13 +241,17 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; } try { - execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} enable ${EJCLAW_SERVICE_NAME}`, { + stdio: 'ignore', + }); } catch (err) { logger.error({ err }, 'systemctl enable failed'); } try { - execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} start ${EJCLAW_SERVICE_NAME}`, { + stdio: 'ignore', + }); } catch (err) { logger.error({ err }, 'systemctl start failed'); } @@ -252,7 +259,9 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; // Verify let serviceLoaded = false; try { - execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} is-active ${EJCLAW_SERVICE_NAME}`, { + stdio: 'ignore', + }); serviceLoaded = true; } catch { // Not active @@ -276,12 +285,12 @@ function setupNohupFallback( ): void { logger.warn('No systemd detected — generating nohup wrapper script'); - const wrapperPath = path.join(projectRoot, 'start-nanoclaw.sh'); - const pidFile = path.join(projectRoot, 'nanoclaw.pid'); + const wrapperPath = path.join(projectRoot, 'start-ejclaw.sh'); + const pidFile = path.join(projectRoot, 'ejclaw.pid'); const lines = [ '#!/bin/bash', - '# start-nanoclaw.sh — Start NanoClaw without systemd', + '# start-ejclaw.sh — Start EJClaw without systemd', `# To stop: kill \\$(cat ${pidFile})`, '', 'set -euo pipefail', @@ -292,20 +301,20 @@ function setupNohupFallback( `if [ -f ${JSON.stringify(pidFile)} ]; then`, ` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`, ' if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then', - ' echo "Stopping existing NanoClaw (PID $OLD_PID)..."', + ' echo "Stopping existing EJClaw (PID $OLD_PID)..."', ' kill "$OLD_PID" 2>/dev/null || true', ' sleep 2', ' fi', 'fi', '', - 'echo "Starting NanoClaw..."', + 'echo "Starting EJClaw..."', `nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`, - ` >> ${JSON.stringify(projectRoot + '/logs/nanoclaw.log')} \\`, - ` 2>> ${JSON.stringify(projectRoot + '/logs/nanoclaw.error.log')} &`, + ` >> ${JSON.stringify(projectRoot + '/logs/ejclaw.log')} \\`, + ` 2>> ${JSON.stringify(projectRoot + '/logs/ejclaw.error.log')} &`, '', `echo $! > ${JSON.stringify(pidFile)}`, - 'echo "NanoClaw started (PID $!)"', - `echo "Logs: tail -f ${projectRoot}/logs/nanoclaw.log"`, + 'echo "EJClaw started (PID $!)"', + `echo "Logs: tail -f ${projectRoot}/logs/ejclaw.log"`, ]; const wrapper = lines.join('\n') + '\n'; diff --git a/setup/verify.ts b/setup/verify.ts index 5b1eba6..8c7f799 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -26,6 +26,9 @@ export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); const platform = getPlatform(); const homeDir = os.homedir(); + const launchdLabels = ['com.ejclaw', 'com.nanoclaw']; + const systemdUnits = ['ejclaw', 'nanoclaw']; + const pidFiles = ['ejclaw.pid', 'nanoclaw.pid']; logger.info('Starting verification'); @@ -36,9 +39,10 @@ export async function run(_args: string[]): Promise { if (mgr === 'launchd') { try { const output = execSync('launchctl list', { encoding: 'utf-8' }); - if (output.includes('com.nanoclaw')) { + const matchedLabel = launchdLabels.find((label) => output.includes(label)); + if (matchedLabel) { // Check if it has a PID (actually running) - const line = output.split('\n').find((l) => l.includes('com.nanoclaw')); + const line = output.split('\n').find((l) => l.includes(matchedLabel)); if (line) { const pidField = line.trim().split(/\s+/)[0]; service = pidField !== '-' && pidField ? 'running' : 'stopped'; @@ -49,15 +53,23 @@ export async function run(_args: string[]): Promise { } } else if (mgr === 'systemd') { const prefix = isRoot() ? 'systemctl' : 'systemctl --user'; - try { - execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' }); + const activeUnit = systemdUnits.find((unit) => { + try { + execSync(`${prefix} is-active ${unit}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } + }); + + if (activeUnit) { service = 'running'; - } catch { + } else { try { const output = execSync(`${prefix} list-unit-files`, { encoding: 'utf-8', }); - if (output.includes('nanoclaw')) { + if (systemdUnits.some((unit) => output.includes(unit))) { service = 'stopped'; } } catch { @@ -66,8 +78,10 @@ export async function run(_args: string[]): Promise { } } else { // Check for nohup PID file - const pidFile = path.join(projectRoot, 'nanoclaw.pid'); - if (fs.existsSync(pidFile)) { + const pidFile = pidFiles + .map((name) => path.join(projectRoot, name)) + .find((candidate) => fs.existsSync(candidate)); + if (pidFile) { try { const raw = fs.readFileSync(pidFile, 'utf-8').trim(); const pid = Number(raw); @@ -144,6 +158,7 @@ export async function run(_args: string[]): Promise { // 5. Check mount allowlist let mountAllowlist = 'missing'; if ( + fs.existsSync(path.join(homeDir, '.config', 'ejclaw', 'mount-allowlist.json')) || fs.existsSync( path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'), ) diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index dd14196..622d6b2 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -11,8 +11,8 @@ const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; vi.mock('./config.js', () => ({ AGENT_MAX_OUTPUT_SIZE: 10485760, AGENT_TIMEOUT: 1800000, // 30min - DATA_DIR: '/tmp/nanoclaw-test-data', - GROUPS_DIR: '/tmp/nanoclaw-test-groups', + DATA_DIR: '/tmp/ejclaw-test-data', + GROUPS_DIR: '/tmp/ejclaw-test-groups', IDLE_TIMEOUT: 1800000, // 30min SERVICE_ID: 'claude', SERVICE_AGENT_TYPE: 'claude-code', @@ -223,16 +223,18 @@ describe('agent-runner timeout behavior', () => { 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 ''; - }); + 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, @@ -246,7 +248,7 @@ describe('agent-runner timeout behavior', () => { await resultPromise; expect(fs.writeFileSync).toHaveBeenCalledWith( - '/tmp/nanoclaw-test-data/sessions/test-group/.claude/CLAUDE.md', + '/tmp/ejclaw-test-data/sessions/test-group/.claude/CLAUDE.md', 'Platform Claude Rules\n\n---\n\nGlobal Claude Memory\n', ); }); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 163f78e..bb53272 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -1,5 +1,5 @@ /** - * Agent Process Runner for NanoClaw + * Agent Process Runner for EJClaw * Spawns agent execution as direct host processes and handles IPC */ import { ChildProcess, spawn } from 'child_process'; @@ -200,15 +200,27 @@ function prepareGroupEnvironment( TZ: TIMEZONE, HOME: os.homedir(), // Path configuration for the runner + EJCLAW_GROUP_DIR: groupDir, NANOCLAW_GROUP_DIR: groupDir, + EJCLAW_IPC_DIR: groupIpcDir, NANOCLAW_IPC_DIR: groupIpcDir, + EJCLAW_GLOBAL_DIR: globalDir, NANOCLAW_GLOBAL_DIR: globalDir, + EJCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '', NANOCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '', // Working directory override (agent uses this as cwd instead of group dir) - ...(group.workDir ? { NANOCLAW_WORK_DIR: group.workDir } : {}), + ...(group.workDir + ? { + EJCLAW_WORK_DIR: group.workDir, + NANOCLAW_WORK_DIR: group.workDir, + } + : {}), // MCP server context + EJCLAW_CHAT_JID: group.folder, NANOCLAW_CHAT_JID: group.folder, + EJCLAW_GROUP_FOLDER: group.folder, NANOCLAW_GROUP_FOLDER: group.folder, + EJCLAW_IS_MAIN: isMain ? '1' : '0', NANOCLAW_IS_MAIN: isMain ? '1' : '0', // Claude sessions directory — set CLAUDE_CONFIG_DIR so SDK uses per-group sessions CLAUDE_CONFIG_DIR: groupSessionsDir, @@ -373,12 +385,13 @@ export async function runAgentProcess( input.chatJid, ); if (input.runId) { + env.EJCLAW_RUN_ID = input.runId; env.NANOCLAW_RUN_ID = input.runId; } const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); const processSuffix = input.runId || `${Date.now()}`; - const processName = `nanoclaw-${safeName}-${processSuffix}`; + const processName = `ejclaw-${safeName}-${processSuffix}`; // Check if runner is built const distEntry = path.join(runnerDir, 'dist', 'index.js'); diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index 06334ea..ce14894 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -12,8 +12,8 @@ vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); vi.mock('../config.js', () => ({ ASSISTANT_NAME: 'Andy', TRIGGER_PATTERN: /^@Andy\b/i, - DATA_DIR: '/tmp/nanoclaw-test-data', - CACHE_DIR: '/tmp/nanoclaw-test-cache', + DATA_DIR: '/tmp/ejclaw-test-data', + CACHE_DIR: '/tmp/ejclaw-test-cache', })); // Mock logger diff --git a/src/claude-usage.test.ts b/src/claude-usage.test.ts new file mode 100644 index 0000000..e868e2b --- /dev/null +++ b/src/claude-usage.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { parseClaudeUsagePanel } from './claude-usage.js'; + +describe('parseClaudeUsagePanel', () => { + it('parses session and weekly usage from Claude CLI panel output', () => { + const sample = ` +Settings: Status Config Usage +Loading usage data... + +Current session +████ 4% used +Resets in 8m (Asia/Seoul) + +Current week (all models) +███████████████████████████████████████ 78% used +Resets Mar 17 at 10pm (Asia/Seoul) + +Current week (Sonnet only) +███ 6% used +Resets Mar 17 at 11pm (Asia/Seoul) + +Extra usage +Extra usage not enabled • /extra-usage to enable +`; + + expect(parseClaudeUsagePanel(sample)).toEqual({ + five_hour: { + utilization: 4, + resets_at: 'Resets in 8m (Asia/Seoul)', + }, + seven_day: { + utilization: 78, + resets_at: 'Resets Mar 17 at 10pm (Asia/Seoul)', + }, + }); + }); + + it('converts percent left into used percent', () => { + const sample = ` +Current session +60% left +Resets in 1h +`; + + expect(parseClaudeUsagePanel(sample)).toEqual({ + five_hour: { + utilization: 40, + resets_at: 'Resets in 1h', + }, + }); + }); +}); diff --git a/src/claude-usage.ts b/src/claude-usage.ts new file mode 100644 index 0000000..3ed83c1 --- /dev/null +++ b/src/claude-usage.ts @@ -0,0 +1,183 @@ +import { spawn } from 'child_process'; + +import { logger } from './logger.js'; + +export interface ClaudeUsageData { + five_hour?: { utilization: number; resets_at: string }; + seven_day?: { utilization: number; resets_at: string }; +} + +const CLAUDE_EXPECT_TIMEOUT_MS = 25000; +const ANSI_RE = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; + +const EXPECT_PROGRAM = ` +set timeout 20 +log_user 1 +match_max 200000 +set binary $env(CLAUDE_BINARY) +spawn -noecho -- $binary --setting-sources user --allowed-tools "" +expect { + -re "Do you trust the files in this folder\\\\?" { send "y\\r"; exp_continue } + -re "Quick safety check:" { send "\\r"; exp_continue } + -re "Yes, I trust this folder" { send "\\r"; exp_continue } + -re "Ready to code here\\\\?" { send "\\r"; exp_continue } + -re "Press Enter to continue" { send "\\r"; exp_continue } + timeout {} +} +send "/usage\\r" +set deadline [expr {[clock seconds] + 20}] +while {[clock seconds] < $deadline} { + expect { + -re "Do you trust the files in this folder\\\\?" { send "y\\r"; exp_continue } + -re "Quick safety check:" { send "\\r"; exp_continue } + -re "Yes, I trust this folder" { send "\\r"; exp_continue } + -re "Ready to code here\\\\?" { send "\\r"; exp_continue } + -re "Press Enter to continue" { send "\\r"; exp_continue } + -re "Current session" { after 2000; exit 0 } + -re "Failed to load usage data" { after 500; exit 2 } + eof { exit 3 } + timeout { send "\\r" } + } +} +exit 4 +`; + +function normalizeLines(rawText: string): string[] { + return rawText + .replace(ANSI_RE, '') + .replace(/\r/g, '\n') + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter(Boolean); +} + +function parsePercent(windowText: string): number | null { + const match = windowText.match(/(\d{1,3})%\s*(used|left)\b/i); + if (!match) return null; + const value = parseInt(match[1], 10); + if (Number.isNaN(value)) return null; + return match[2].toLowerCase() === 'left' ? 100 - value : value; +} + +function parseWindow( + lines: string[], + labels: string[], +): { utilization: number; resets_at: string } | null { + const normalizedLabels = labels.map((label) => label.toLowerCase()); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].toLowerCase(); + if (!normalizedLabels.some((label) => line.includes(label))) continue; + + const windowLines = lines.slice(i, i + 6); + const windowText = windowLines.join('\n'); + const utilization = parsePercent(windowText); + if (utilization === null) continue; + + const resetLine = windowLines.find((candidate) => + candidate.toLowerCase().startsWith('resets'), + ); + + return { + utilization, + resets_at: resetLine || '', + }; + } + + return null; +} + +export function parseClaudeUsagePanel(rawText: string): ClaudeUsageData | null { + const lines = normalizeLines(rawText); + if (lines.length === 0) return null; + if ( + lines.some((line) => + line.toLowerCase().includes('failed to load usage data'), + ) + ) { + return null; + } + + const fiveHour = parseWindow(lines, ['Current session']); + if (!fiveHour) return null; + + const sevenDay = + parseWindow(lines, ['Current week (all models)']) || + parseWindow(lines, [ + 'Current week (Sonnet only)', + 'Current week (Sonnet)', + ]) || + parseWindow(lines, ['Current week (Opus)']); + + return { + five_hour: fiveHour, + ...(sevenDay ? { seven_day: sevenDay } : {}), + }; +} + +export async function fetchClaudeUsageViaCli( + binary = 'claude', +): Promise { + return new Promise((resolve) => { + let output = ''; + let finished = false; + + const finish = (value: ClaudeUsageData | null) => { + if (finished) return; + finished = true; + clearTimeout(timer); + resolve(value); + }; + + let proc: ReturnType | null = null; + try { + proc = spawn('expect', ['-c', EXPECT_PROGRAM], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...(process.env as Record), + CLAUDE_BINARY: binary, + }, + }); + } catch (err) { + logger.debug({ err }, 'Claude CLI PTY probe unavailable'); + finish(null); + return; + } + + const timer = setTimeout(() => { + try { + proc?.kill('SIGTERM'); + } catch { + /* ignore */ + } + finish(null); + }, CLAUDE_EXPECT_TIMEOUT_MS); + + if (!proc.stdout || !proc.stderr) { + finish(null); + return; + } + + proc.stdout.setEncoding('utf8'); + proc.stderr.setEncoding('utf8'); + proc.stdout.on('data', (chunk: string) => { + output += chunk; + }); + proc.stderr.on('data', (chunk: string) => { + output += chunk; + }); + proc.on('error', (err) => { + logger.debug({ err }, 'Claude CLI PTY probe failed to start'); + finish(null); + }); + proc.on('close', () => { + const parsed = parseClaudeUsagePanel(output); + if (!parsed && output.trim()) { + logger.debug( + { tail: output.slice(-400) }, + 'Claude CLI PTY probe produced unparsable output', + ); + } + finish(parsed); + }); + }); +} diff --git a/src/config.ts b/src/config.ts index 8325095..8b70305 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -36,21 +37,33 @@ export const SCHEDULER_POLL_INTERVAL = 60000; const PROJECT_ROOT = process.cwd(); const HOME_DIR = process.env.HOME || os.homedir(); +const CONFIG_ROOT = path.join(HOME_DIR, '.config'); +const EJCLAW_CONFIG_DIR = path.join(CONFIG_ROOT, 'ejclaw'); +const LEGACY_NANOCLAW_CONFIG_DIR = path.join(CONFIG_ROOT, 'nanoclaw'); +const ACTIVE_CONFIG_DIR = fs.existsSync(EJCLAW_CONFIG_DIR) + ? EJCLAW_CONFIG_DIR + : fs.existsSync(LEGACY_NANOCLAW_CONFIG_DIR) + ? LEGACY_NANOCLAW_CONFIG_DIR + : EJCLAW_CONFIG_DIR; export const SENDER_ALLOWLIST_PATH = path.join( - HOME_DIR, - '.config', - 'nanoclaw', + ACTIVE_CONFIG_DIR, 'sender-allowlist.json', ); export const STORE_DIR = path.resolve( - process.env.NANOCLAW_STORE_DIR || path.join(PROJECT_ROOT, 'store'), + process.env.EJCLAW_STORE_DIR || + process.env.NANOCLAW_STORE_DIR || + path.join(PROJECT_ROOT, 'store'), ); export const GROUPS_DIR = path.resolve( - process.env.NANOCLAW_GROUPS_DIR || path.join(PROJECT_ROOT, 'groups'), + process.env.EJCLAW_GROUPS_DIR || + process.env.NANOCLAW_GROUPS_DIR || + path.join(PROJECT_ROOT, 'groups'), ); export const DATA_DIR = path.resolve( - process.env.NANOCLAW_DATA_DIR || path.join(PROJECT_ROOT, 'data'), + process.env.EJCLAW_DATA_DIR || + process.env.NANOCLAW_DATA_DIR || + path.join(PROJECT_ROOT, 'data'), ); // Shared cache directory (same across both services for dedup) export const CACHE_DIR = path.join(PROJECT_ROOT, 'cache'); diff --git a/src/dashboard.ts b/src/dashboard.ts index bdca357..7bf756a 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -3,6 +3,10 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { + fetchClaudeUsageViaCli, + type ClaudeUsageData, +} from './claude-usage.js'; import { USAGE_DASHBOARD_ENABLED } from './config.js'; import { readEnvFile } from './env.js'; import { GroupQueue, GroupStatus } from './group-queue.js'; @@ -20,11 +24,6 @@ export interface DashboardOptions { 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; @@ -71,6 +70,7 @@ function formatResetKST(value: string | number): string { try { const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); return date.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', month: 'short', @@ -188,6 +188,9 @@ export function buildStatusContent(opts: DashboardOptions): string { } async function fetchClaudeUsage(): Promise { + const cliUsage = await fetchClaudeUsageViaCli(); + if (cliUsage) return cliUsage; + try { const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']); let token = diff --git a/src/db.ts b/src/db.ts index b705b1b..ec8c2fd 100644 --- a/src/db.ts +++ b/src/db.ts @@ -139,7 +139,9 @@ function createSchema(database: Database.Database): void { const hasAgentType = registeredGroupCols.some( (col) => col.name === 'agent_type', ); - const hasWorkDir = registeredGroupCols.some((col) => col.name === 'work_dir'); + const hasWorkDir = registeredGroupCols.some( + (col) => col.name === 'work_dir', + ); database.exec(` CREATE TABLE registered_groups_new ( diff --git a/src/group-queue.test.ts b/src/group-queue.test.ts index 9d01c6c..b914789 100644 --- a/src/group-queue.test.ts +++ b/src/group-queue.test.ts @@ -4,7 +4,7 @@ import { GroupQueue } from './group-queue.js'; // Mock config to control concurrency limit vi.mock('./config.js', () => ({ - DATA_DIR: '/tmp/nanoclaw-test-data', + DATA_DIR: '/tmp/ejclaw-test-data', MAX_CONCURRENT_AGENTS: 2, })); diff --git a/src/index.ts b/src/index.ts index 312ef53..b2e6585 100644 --- a/src/index.ts +++ b/src/index.ts @@ -280,7 +280,7 @@ const isDirectRun = if (isDirectRun) { main().catch((err) => { - logger.error({ err }, 'Failed to start NanoClaw'); + logger.error({ err }, 'Failed to start EJClaw'); process.exit(1); }); } diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 4acc849..d6efae6 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -346,7 +346,19 @@ describe('createMessageRuntime', () => { 'progress-1', '오래 걸리는 작업입니다.\n\n1분 10초', ); - await vi.advanceTimersByTimeAsync(3_600_000); + await vi.advanceTimersByTimeAsync(50_000); + expect(channel.editMessage).toHaveBeenLastCalledWith( + chatJid, + 'progress-1', + '오래 걸리는 작업입니다.\n\n2분 0초', + ); + await vi.advanceTimersByTimeAsync(3_480_000); + expect(channel.editMessage).toHaveBeenLastCalledWith( + chatJid, + 'progress-1', + '오래 걸리는 작업입니다.\n\n1시간 0분 0초', + ); + await vi.advanceTimersByTimeAsync(70_000); await onOutput?.({ status: 'success', result: null, diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 0451cd2..79c6728 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -352,15 +352,14 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const hours = Math.floor(elapsedSeconds / 3600); const minutes = Math.floor((elapsedSeconds % 3600) / 60); const seconds = elapsedSeconds % 60; - const elapsedParts: string[] = []; + const elapsedLabel = + hours > 0 + ? `${hours}시간 ${minutes}분 ${seconds}초` + : minutes > 0 + ? `${minutes}분 ${seconds}초` + : `${seconds}초`; - if (hours > 0) elapsedParts.push(`${hours}시간`); - if (minutes > 0) elapsedParts.push(`${minutes}분`); - if (seconds > 0 || elapsedParts.length === 0) { - elapsedParts.push(`${seconds}초`); - } - - return `${text}\n\n${elapsedParts.join(' ')}`; + return `${text}\n\n${elapsedLabel}`; }; const syncTrackedProgressMessage = async () => { @@ -552,7 +551,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { } messageLoopRunning = true; - logger.info(`NanoClaw running (trigger: @${deps.assistantName})`); + logger.info(`EJClaw running (trigger: @${deps.assistantName})`); while (true) { try { diff --git a/src/platform-prompts.test.ts b/src/platform-prompts.test.ts index d4145a6..f0fe6cc 100644 --- a/src/platform-prompts.test.ts +++ b/src/platform-prompts.test.ts @@ -15,7 +15,7 @@ describe('platform-prompts', () => { let tempDir: string; beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-prompts-')); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-prompts-')); vi.spyOn(process, 'cwd').mockReturnValue(tempDir); }); diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index abbc7e4..436fa8a 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -84,9 +84,11 @@ describe('isSessionCommandControlMessage', () => { }); it('does not match regular bot conversation', () => { - expect(isSessionCommandControlMessage('좋네요. 필요해지면 바로 말씀드리겠습니다.')).toBe( - false, - ); + expect( + isSessionCommandControlMessage( + '좋네요. 필요해지면 바로 말씀드리겠습니다.', + ), + ).toBe(false); }); }); From aa063ccea4efb8a03c0ed91c17125a13e596796d Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Thu, 19 Mar 2026 04:02:51 +0900 Subject: [PATCH 14/17] Reset Codex progress tracking between turns --- src/message-runtime.test.ts | 123 ++++++++++++++++++++++++++++++++++++ src/message-runtime.ts | 10 ++- 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index d6efae6..9752ce4 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -505,6 +505,129 @@ describe('createMessageRuntime', () => { } }); + it('starts a fresh tracked progress message for each Codex turn in one runner session', async () => { + vi.useFakeTimers(); + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + + 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(channel.sendAndTrack!) + .mockResolvedValueOnce('progress-1') + .mockResolvedValueOnce('progress-2'); + + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '첫 번째 진행상황입니다.', + newSessionId: 'session-multi-turn', + }); + await vi.advanceTimersByTimeAsync(10_000); + await onOutput?.({ + status: 'success', + phase: 'final', + result: '첫 번째 결과입니다.', + newSessionId: 'session-multi-turn', + }); + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '두 번째 진행상황입니다.', + newSessionId: 'session-multi-turn', + }); + await vi.advanceTimersByTimeAsync(10_000); + await onOutput?.({ + status: 'success', + phase: 'final', + result: '두 번째 결과입니다.', + newSessionId: 'session-multi-turn', + }); + return { + status: 'success', + result: null, + newSessionId: 'session-multi-turn', + }; + }, + ); + + 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: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + try { + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-multi-turn', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(channel.sendAndTrack).toHaveBeenNthCalledWith( + 1, + chatJid, + '첫 번째 진행상황입니다.\n\n0초', + ); + expect(channel.sendAndTrack).toHaveBeenNthCalledWith( + 2, + chatJid, + '두 번째 진행상황입니다.\n\n0초', + ); + expect(channel.editMessage).toHaveBeenNthCalledWith( + 1, + chatJid, + 'progress-1', + '첫 번째 진행상황입니다.\n\n10초', + ); + expect(channel.editMessage).toHaveBeenNthCalledWith( + 2, + chatJid, + 'progress-2', + '두 번째 진행상황입니다.\n\n10초', + ); + expect(channel.sendMessage).toHaveBeenNthCalledWith( + 1, + chatJid, + '첫 번째 결과입니다.', + ); + expect(channel.sendMessage).toHaveBeenNthCalledWith( + 2, + chatJid, + '두 번째 결과입니다.', + ); + } finally { + vi.useRealTimers(); + } + }); + 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'); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 79c6728..96dcae2 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -344,6 +344,14 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { } }; + const resetProgressState = () => { + clearProgressTicker(); + latestProgressText = null; + latestProgressRendered = null; + progressMessageId = null; + progressStartedAt = null; + }; + const renderProgressMessage = (text: string) => { const elapsedSeconds = progressStartedAt === null @@ -393,7 +401,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const finalizeProgressMessage = async () => { await syncTrackedProgressMessage(); - clearProgressTicker(); + resetProgressState(); }; const sendProgressMessage = async (text: string) => { From 193119945a6447fc122d058b4dcf3d769ddb48fa Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Thu, 19 Mar 2026 14:14:39 +0900 Subject: [PATCH 15/17] Add self-stopping CI watch tasks --- prompts/codex-platform.md | 4 +- runners/agent-runner/src/ipc-mcp-stdio.ts | 75 ++++++++++++++++++++++ runners/agent-runner/src/watch-ci.ts | 62 ++++++++++++++++++ runners/agent-runner/test/watch-ci.test.ts | 40 ++++++++++++ 4 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 runners/agent-runner/src/watch-ci.ts create mode 100644 runners/agent-runner/test/watch-ci.test.ts diff --git a/prompts/codex-platform.md b/prompts/codex-platform.md index 0ecc1ed..18ec385 100644 --- a/prompts/codex-platform.md +++ b/prompts/codex-platform.md @@ -15,7 +15,7 @@ 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 an EJClaw task with `schedule_task` +- Do not claim you will keep watching, monitor later, report back later, or continue tracking unless you actually scheduled an EJClaw task with `watch_ci` or `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 @@ -24,4 +24,4 @@ Your output is sent directly to the Discord group. - 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 +- For CI/status/watch requests that require future follow-up, prefer `watch_ci` over leaving the chat session idle diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index c7438b0..b43f142 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -10,6 +10,10 @@ import { z } from 'zod'; import fs from 'fs'; import path from 'path'; import { CronExpressionParser } from 'cron-parser'; +import { + buildCiWatchPrompt, + normalizeWatchCiIntervalSeconds, +} from './watch-ci.js'; const IPC_DIR = process.env.EJCLAW_IPC_DIR || process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; @@ -155,6 +159,77 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): }, ); +server.tool( + 'watch_ci', + 'Schedule a background CI watcher that checks until a run or check reaches a terminal state, then sends one message and cancels itself.', + { + target: z.string().describe('What to watch, for example "PR #123 checks" or "GitHub Actions run 987654321".'), + check_instructions: z.string().describe('Exact steps or commands to check status and what details matter when it finishes.'), + poll_interval_seconds: z + .number() + .int() + .min(30) + .max(3600) + .default(60) + .describe('How often to poll in seconds. Default 60, minimum 30.'), + context_mode: z + .enum(['group', 'isolated']) + .default('group') + .describe('group=runs with chat history and memory, isolated=fresh session (include all context in check_instructions)'), + target_group_jid: z + .string() + .optional() + .describe('(Main group only) JID of the group to schedule the watcher for. Defaults to the current group.'), + }, + async (args) => { + let pollSeconds: number; + try { + pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds); + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + + const targetJid = isMain && args.target_group_jid ? args.target_group_jid : chatJid; + const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const prompt = buildCiWatchPrompt({ + taskId, + target: args.target, + checkInstructions: args.check_instructions, + }); + + const data = { + type: 'schedule_task', + taskId, + prompt, + schedule_type: 'interval' as const, + schedule_value: String(pollSeconds * 1000), + context_mode: args.context_mode || 'group', + targetJid, + createdBy: groupFolder, + timestamp: new Date().toISOString(), + }; + + writeIpcFile(TASKS_DIR, data); + + return { + content: [ + { + type: 'text' as const, + text: `CI watcher scheduled: ${taskId} (${pollSeconds}s)`, + }, + ], + }; + }, +); + server.tool( 'list_tasks', "List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.", diff --git a/runners/agent-runner/src/watch-ci.ts b/runners/agent-runner/src/watch-ci.ts new file mode 100644 index 0000000..6eccb8b --- /dev/null +++ b/runners/agent-runner/src/watch-ci.ts @@ -0,0 +1,62 @@ +export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60; +export const MIN_WATCH_CI_INTERVAL_SECONDS = 30; +export const MAX_WATCH_CI_INTERVAL_SECONDS = 3600; + +export interface BuildCiWatchPromptArgs { + taskId: string; + target: string; + checkInstructions: string; +} + +export function normalizeWatchCiIntervalSeconds(seconds?: number): number { + if (seconds === undefined) { + return DEFAULT_WATCH_CI_INTERVAL_SECONDS; + } + + if (!Number.isInteger(seconds)) { + throw new Error('poll_interval_seconds must be an integer.'); + } + + if ( + seconds < MIN_WATCH_CI_INTERVAL_SECONDS || + seconds > MAX_WATCH_CI_INTERVAL_SECONDS + ) { + throw new Error( + `poll_interval_seconds must be between ${MIN_WATCH_CI_INTERVAL_SECONDS} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`, + ); + } + + return seconds; +} + +export function buildCiWatchPrompt({ + taskId, + target, + checkInstructions, +}: BuildCiWatchPromptArgs): string { + return ` +[BACKGROUND CI WATCH] + +You are running as an EJClaw background CI watcher. + +Watch target: +${target} + +Task ID: +${taskId} + +Check instructions: +${checkInstructions} + +Rules: +- On each run, check whether the target is still queued, pending, running, in progress, or otherwise non-terminal. +- If it is still not finished, send no visible message and end this run quietly. +- If it reached a terminal state such as success, failure, cancelled, timed out, neutral, skipped, or action required: + 1. Send exactly one concise completion message with \`send_message\`. + 2. Include the final status and only the most important details. + 3. Call \`cancel_task\` with task_id "${taskId}" so this watcher stops itself. +- If you hit a transient problem such as a rate limit, network issue, or temporary auth failure, send no visible message and leave the task active for the next retry. +- Prefer no normal final response. Use \`send_message\` for the completion message, and keep any non-user-facing notes inside \`\` tags if needed. +- Do not claim continued monitoring after you cancel the task. +`.trim(); +} diff --git a/runners/agent-runner/test/watch-ci.test.ts b/runners/agent-runner/test/watch-ci.test.ts new file mode 100644 index 0000000..b895730 --- /dev/null +++ b/runners/agent-runner/test/watch-ci.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildCiWatchPrompt, + normalizeWatchCiIntervalSeconds, +} from '../src/watch-ci.js'; + +describe('watch-ci helpers', () => { + it('builds a self-cancelling CI watch prompt', () => { + const prompt = buildCiWatchPrompt({ + taskId: 'task-123', + target: 'PR #42 checks', + checkInstructions: 'Use gh pr checks 42 and summarize only terminal results.', + }); + + expect(prompt).toContain('PR #42 checks'); + expect(prompt).toContain('task-123'); + expect(prompt).toContain('cancel_task'); + expect(prompt).toContain('send_message'); + expect(prompt).toContain('gh pr checks 42'); + }); + + it('normalizes valid poll intervals', () => { + expect(normalizeWatchCiIntervalSeconds()).toBe(60); + expect(normalizeWatchCiIntervalSeconds(30)).toBe(30); + expect(normalizeWatchCiIntervalSeconds(600)).toBe(600); + }); + + it('rejects invalid poll intervals', () => { + expect(() => normalizeWatchCiIntervalSeconds(29)).toThrow( + /between 30 and 3600/i, + ); + expect(() => normalizeWatchCiIntervalSeconds(3601)).toThrow( + /between 30 and 3600/i, + ); + expect(() => normalizeWatchCiIntervalSeconds(30.5)).toThrow( + /integer/i, + ); + }); +}); From e7200a1ed88545c221afeef7c690a1319c6384c7 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Thu, 19 Mar 2026 14:35:30 +0900 Subject: [PATCH 16/17] Fix Codex task target JID injection --- src/agent-runner.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++ src/agent-runner.ts | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index 622d6b2..25fad00 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -252,4 +252,48 @@ describe('agent-runner timeout behavior', () => { 'Platform Claude Rules\n\n---\n\nGlobal Claude Memory\n', ); }); + + it('injects the real chat JID into Codex MCP config', async () => { + vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => { + const path = String(p); + return ( + path.includes('dist/index.js') || + path.endsWith('/runners/agent-runner/dist/ipc-mcp-stdio.js') + ); + }); + vi.mocked(fs.readFileSync).mockReturnValue(''); + + const codexGroup: RegisteredGroup = { + ...testGroup, + folder: 'eyejokerdb-4', + agentType: 'codex', + }; + const codexInput = { + ...testInput, + groupFolder: 'eyejokerdb-4', + chatJid: 'dc:1481348008183595170', + }; + + const resultPromise = runAgentProcess( + codexGroup, + codexInput, + () => {}, + undefined, + ); + + fakeProc.emit('close', 0); + await vi.advanceTimersByTimeAsync(10); + await resultPromise; + + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml', + expect.stringContaining( + 'NANOCLAW_CHAT_JID = "dc:1481348008183595170"', + ), + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml', + expect.stringContaining('NANOCLAW_GROUP_FOLDER = "eyejokerdb-4"'), + ); + }); }); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index bb53272..f7e08a2 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -323,7 +323,7 @@ args = [${JSON.stringify(mcpServerPath)}] [mcp_servers.nanoclaw.env] NANOCLAW_IPC_DIR = ${JSON.stringify(env.NANOCLAW_IPC_DIR)} -NANOCLAW_CHAT_JID = ${JSON.stringify(group.folder)} +NANOCLAW_CHAT_JID = ${JSON.stringify(chatJid)} NANOCLAW_GROUP_FOLDER = ${JSON.stringify(group.folder)} NANOCLAW_IS_MAIN = ${JSON.stringify(isMain ? '1' : '0')} `; From 41afcb91cf812cd71d1220454c8786251b0e0898 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Fri, 20 Mar 2026 00:12:43 +0900 Subject: [PATCH 17/17] feat: integrate Memento MCP for shared memory across agents - Add MEMENTO_MCP_SSE_URL/ACCESS_KEY/REMOTE_PATH to readEnvFile - Merge .env vars into cleanEnv for runner process inheritance - Claude Code runner: add memento-mcp via mcp-remote in mcpServers - Codex runner: inject memento-mcp section into config.toml - Various improvements: CI watch, task scheduler, DB, IPC auth --- prompts/codex-platform.md | 3 +- runners/agent-runner/src/index.ts | 12 ++ runners/agent-runner/src/ipc-mcp-stdio.ts | 7 +- runners/agent-runner/src/watch-ci.ts | 14 +- runners/agent-runner/test/watch-ci.test.ts | 10 ++ runners/codex-runner/package-lock.json | 64 ++++---- src/agent-runner.test.ts | 4 +- src/agent-runner.ts | 30 +++- src/db.test.ts | 38 +++++ src/db.ts | 117 ++++++++++++++- src/index.ts | 23 +++ src/ipc-auth.test.ts | 25 ++++ src/ipc.ts | 16 +- src/message-runtime.ts | 6 +- src/task-scheduler.test.ts | 96 ++++++++++++ src/task-scheduler.ts | 163 ++++++++++++++++++++- src/types.ts | 3 + 17 files changed, 572 insertions(+), 59 deletions(-) diff --git a/prompts/codex-platform.md b/prompts/codex-platform.md index 18ec385..527695e 100644 --- a/prompts/codex-platform.md +++ b/prompts/codex-platform.md @@ -24,4 +24,5 @@ Your output is sent directly to the Discord group. - 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 `watch_ci` over leaving the chat session idle +- For CI/status/watch requests that require future follow-up, schedule `watch_ci` +- Use generic `schedule_task` for reminders or other non-CI recurring work diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index c9f99dd..dd09684 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -531,6 +531,18 @@ async function runQuery( NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', }, }, + ...(process.env.MEMENTO_MCP_SSE_URL + ? { + 'memento-mcp': { + command: process.env.MEMENTO_MCP_REMOTE_PATH || 'mcp-remote', + args: [ + process.env.MEMENTO_MCP_SSE_URL, + '--header', + `Authorization:Bearer ${process.env.MEMENTO_ACCESS_KEY || ''}`, + ], + }, + } + : {}), }, hooks: { PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index b43f142..69f471e 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -12,6 +12,7 @@ import path from 'path'; import { CronExpressionParser } from 'cron-parser'; import { buildCiWatchPrompt, + DEFAULT_WATCH_CI_CONTEXT_MODE, normalizeWatchCiIntervalSeconds, } from './watch-ci.js'; @@ -174,8 +175,8 @@ server.tool( .describe('How often to poll in seconds. Default 60, minimum 30.'), context_mode: z .enum(['group', 'isolated']) - .default('group') - .describe('group=runs with chat history and memory, isolated=fresh session (include all context in check_instructions)'), + .default(DEFAULT_WATCH_CI_CONTEXT_MODE) + .describe('group=runs with chat history and memory, isolated=fresh session (include all context in check_instructions). Default: isolated.'), target_group_jid: z .string() .optional() @@ -211,7 +212,7 @@ server.tool( prompt, schedule_type: 'interval' as const, schedule_value: String(pollSeconds * 1000), - context_mode: args.context_mode || 'group', + context_mode: args.context_mode || DEFAULT_WATCH_CI_CONTEXT_MODE, targetJid, createdBy: groupFolder, timestamp: new Date().toISOString(), diff --git a/runners/agent-runner/src/watch-ci.ts b/runners/agent-runner/src/watch-ci.ts index 6eccb8b..b0ce26b 100644 --- a/runners/agent-runner/src/watch-ci.ts +++ b/runners/agent-runner/src/watch-ci.ts @@ -1,6 +1,7 @@ export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60; export const MIN_WATCH_CI_INTERVAL_SECONDS = 30; export const MAX_WATCH_CI_INTERVAL_SECONDS = 3600; +export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated'; export interface BuildCiWatchPromptArgs { taskId: string; @@ -49,12 +50,21 @@ Check instructions: ${checkInstructions} Rules: +- Use the watch target and check instructions in this prompt as the source of truth for what to inspect. - On each run, check whether the target is still queued, pending, running, in progress, or otherwise non-terminal. - If it is still not finished, send no visible message and end this run quietly. - If it reached a terminal state such as success, failure, cancelled, timed out, neutral, skipped, or action required: 1. Send exactly one concise completion message with \`send_message\`. - 2. Include the final status and only the most important details. - 3. Call \`cancel_task\` with task_id "${taskId}" so this watcher stops itself. + 2. Format it as a short multiline summary when possible, not one long paragraph. + 3. Preferred shape: + - First line: \`CI 완료: \` + - Second line: \`판정: \` + - Then 2-4 flat bullet points with only the most important metrics, errors, or comparisons. + - Optional final line: \`다음: \` if a concrete follow-up is needed. + 4. Adapt the content to the specific CI. Do not invent fixed fields when they do not fit. + 5. Avoid tables unless they are clearly the shortest readable format. + 6. Keep the message compact and easy for other agents to parse. + 7. Call \`cancel_task\` with task_id "${taskId}" so this watcher stops itself. - If you hit a transient problem such as a rate limit, network issue, or temporary auth failure, send no visible message and leave the task active for the next retry. - Prefer no normal final response. Use \`send_message\` for the completion message, and keep any non-user-facing notes inside \`\` tags if needed. - Do not claim continued monitoring after you cancel the task. diff --git a/runners/agent-runner/test/watch-ci.test.ts b/runners/agent-runner/test/watch-ci.test.ts index b895730..ef49ef7 100644 --- a/runners/agent-runner/test/watch-ci.test.ts +++ b/runners/agent-runner/test/watch-ci.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { buildCiWatchPrompt, + DEFAULT_WATCH_CI_CONTEXT_MODE, normalizeWatchCiIntervalSeconds, } from '../src/watch-ci.js'; @@ -18,6 +19,15 @@ describe('watch-ci helpers', () => { expect(prompt).toContain('cancel_task'); expect(prompt).toContain('send_message'); expect(prompt).toContain('gh pr checks 42'); + expect(prompt).toContain('CI 완료: '); + expect(prompt).toContain('판정: '); + expect(prompt).toContain( + 'Use the watch target and check instructions in this prompt as the source of truth', + ); + }); + + it('defaults CI watchers to isolated context', () => { + expect(DEFAULT_WATCH_CI_CONTEXT_MODE).toBe('isolated'); }); it('normalizes valid poll intervals', () => { diff --git a/runners/codex-runner/package-lock.json b/runners/codex-runner/package-lock.json index 956a0a0..9851d0a 100644 --- a/runners/codex-runner/package-lock.json +++ b/runners/codex-runner/package-lock.json @@ -8,7 +8,7 @@ "name": "ejclaw-codex-runner", "version": "1.0.0", "dependencies": { - "@openai/codex-sdk": "^0.114.0" + "@openai/codex-sdk": "^0.115.0" }, "devDependencies": { "@types/node": "^22.10.7", @@ -16,9 +16,9 @@ } }, "node_modules/@openai/codex": { - "version": "0.114.0", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0.tgz", - "integrity": "sha512-HMo8LRR6CtfKkaa28xvFK6eOarmBFTDfsrS9GJtEoaspGGemFok494CpafDspiTZaHZZGHfSUe5SWTUxYq7OaA==", + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0.tgz", + "integrity": "sha512-uu689DHUzvuPcb39hJ+7fqy++TAvY32w9VggDpcz3HS0Sx0WadWoAPPcMK547P2T6AqfMsAtA8kspkR3tqErOg==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -27,19 +27,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.114.0-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.114.0-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.114.0-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.114.0-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.114.0-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.114.0-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.115.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.115.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.115.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.115.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.115.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.115.0-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.114.0-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-darwin-arm64.tgz", - "integrity": "sha512-c9dlgo9O+66alr3s3G36fKE3KaO2+xrLZ/QcU3oujDruV86pqgVSEK2njHi+WIyyOa6m2AyWxdaHLEbKxPCNRg==", + "version": "0.115.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-darwin-arm64.tgz", + "integrity": "sha512-wTWV+YlDTL0y0mL+FMmbzhilm+dPkbIZTe/lH3LwBzcEMhgSGLsPdZLfAiMND4wFE21HS7H0pjMogNQEMLQmqg==", "cpu": [ "arm64" ], @@ -54,9 +54,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.114.0-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-darwin-x64.tgz", - "integrity": "sha512-oPAPeZSaJZ1AQneFPSJu44uqbZr63Bxut9FOTWZwuSqYx4YEees7i0HJmJcqQc0XnCbYtHJdqo/i07Uv374NLw==", + "version": "0.115.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-darwin-x64.tgz", + "integrity": "sha512-EPzgymU4CFp83qjv29wXFwhWib3zC8g6SLTJGh2OpcJiOYyLY0bO53FB+QchL1jC9gm1uLyD5j5F3ddI0AbjIg==", "cpu": [ "x64" ], @@ -71,9 +71,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.114.0-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-linux-arm64.tgz", - "integrity": "sha512-oUFZGZgMiEqmo85C+dfhckpVApH25alLWfwBgfKr2IRgdx/tovy8vN101f6kj01E6nDDe4LfJdNSZGtZht4fRA==", + "version": "0.115.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-linux-arm64.tgz", + "integrity": "sha512-40+SCyI+LvVx/iX30qH7dTQzWt9MZxDaK2E6YRB4hoYej5UALrhkFNzweHa5uvqvhmqGZCuagZOYB8285eGCgw==", "cpu": [ "arm64" ], @@ -88,9 +88,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.114.0-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-linux-x64.tgz", - "integrity": "sha512-MF6RhxfBoccccd5CYII2C7TL2TvYzombBQhanDZfQAajr6n7IuoazCmoHDlrFHS4AcSw9bYA4MRlrwEjbEjgsw==", + "version": "0.115.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-linux-x64.tgz", + "integrity": "sha512-+6eRd2p4KMrhQvuF7XpjYIdCA2Ok2LbhOq+ywZdLpHbmwQ/Yynd0gJ/Q90xCeo2vloCwl9WsbsiVVSahG5FyWg==", "cpu": [ "x64" ], @@ -104,12 +104,12 @@ } }, "node_modules/@openai/codex-sdk": { - "version": "0.114.0", - "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.114.0.tgz", - "integrity": "sha512-28tV+pvoQhhoRADBCtlg24fR6LDTyeUA6C65NBTvYFnDoQVPJcHcAwRrbuWmUMcXdLrXwY2bmiMxyfXlUWQzgw==", + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.115.0.tgz", + "integrity": "sha512-BPoPhim0uUm3rzugY7JFaFJ+rPG/wMRhvKNFii//Dp3kTb+gFy3jrip7ijXqhswsnqXM3nwTiv7kYHt1+TMUPg==", "license": "Apache-2.0", "dependencies": { - "@openai/codex": "0.114.0" + "@openai/codex": "0.115.0" }, "engines": { "node": ">=18" @@ -117,9 +117,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.114.0-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-win32-arm64.tgz", - "integrity": "sha512-CnRMHopj3en9aqQ2UaDW7EgpEGkxHdZVLLRq2cOy5D0HyuzF6Qb6595ADoFVJHEmPeN5Iz/KUbiGs5GLDjUwOA==", + "version": "0.115.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-win32-arm64.tgz", + "integrity": "sha512-yaYhQ0kPL9Kithmrr1vh5A4c+gqAMhMZeA/htTtkpWW8RQkmwgcYYpX/Ky2cEzu0w51pvxQQy63LgHftc+pg1Q==", "cpu": [ "arm64" ], @@ -134,9 +134,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.114.0-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-win32-x64.tgz", - "integrity": "sha512-ztRsH5Z+gPVZ5ZInx6HzXsTFFkuEdjk3Ay0UGVOhRRCWvevXQi/SL4eU8hwysCYrs8K/WRq3mo4c95w9zQ2wLw==", + "version": "0.115.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-win32-x64.tgz", + "integrity": "sha512-wtYf2HJCB+p+Uj7o1W08fRgZMzKCVGRQ4YdSfLRNfF4wwPqX5XsK9blsv7brukn5J9lclYxagiR6qGvbfHbD7w==", "cpu": [ "x64" ], diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index 25fad00..5bfd070 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -287,9 +287,7 @@ describe('agent-runner timeout behavior', () => { expect(fs.writeFileSync).toHaveBeenCalledWith( '/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml', - expect.stringContaining( - 'NANOCLAW_CHAT_JID = "dc:1481348008183595170"', - ), + expect.stringContaining('NANOCLAW_CHAT_JID = "dc:1481348008183595170"'), ); expect(fs.writeFileSync).toHaveBeenCalledWith( '/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml', diff --git a/src/agent-runner.ts b/src/agent-runner.ts index f7e08a2..17fdc5f 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -175,10 +175,17 @@ function prepareGroupEnvironment( 'CODEX_OPENAI_API_KEY', 'CODEX_MODEL', 'CODEX_EFFORT', + 'MEMENTO_MCP_SSE_URL', + 'MEMENTO_ACCESS_KEY', + 'MEMENTO_MCP_REMOTE_PATH', ]); // Build a clean env without Claude Code nesting detection variables const cleanEnv = { ...(process.env as Record) }; + // Merge .env file values (readEnvFile only reads file, doesn't set process.env) + for (const [k, v] of Object.entries(envVars)) { + if (v && !cleanEnv[k]) cleanEnv[k] = v; + } delete cleanEnv.CLAUDECODE; delete cleanEnv.CLAUDE_CODE_ENTRYPOINT; @@ -327,7 +334,28 @@ NANOCLAW_CHAT_JID = ${JSON.stringify(chatJid)} NANOCLAW_GROUP_FOLDER = ${JSON.stringify(group.folder)} NANOCLAW_IS_MAIN = ${JSON.stringify(isMain ? '1' : '0')} `; - toml = toml.trimEnd() + '\n' + mcpSection; + // Inject memento-mcp if MEMENTO_MCP_SSE_URL is set + const mementoSseUrl = + envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL; + const mementoAccessKey = + envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || ''; + const mementoRemotePath = + envVars.MEMENTO_MCP_REMOTE_PATH || + process.env.MEMENTO_MCP_REMOTE_PATH || + 'mcp-remote'; + toml = toml.replace( + /\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/, + '', + ); + const mementoSection = mementoSseUrl + ? ` +[mcp_servers.memento-mcp] +command = ${JSON.stringify(mementoRemotePath)} +args = [${JSON.stringify(mementoSseUrl)}, "--header", ${JSON.stringify(`Authorization:Bearer ${mementoAccessKey}`)}] +` + : ''; + + toml = toml.trimEnd() + '\n' + mcpSection + mementoSection; fs.writeFileSync(configTomlPath, toml); } diff --git a/src/db.test.ts b/src/db.test.ts index 56c7ab4..f64f8b0 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -7,6 +7,7 @@ import { deleteTask, getAllChats, getAllRegisteredGroups, + getDueTasks, getRegisteredAgentTypesForJid, getMessagesSince, getNewMessages, @@ -402,6 +403,43 @@ describe('task CRUD', () => { deleteTask('task-3'); expect(getTaskById('task-3')).toBeUndefined(); }); + + it('returns due tasks only for the requested agent type', () => { + const dueAt = new Date(Date.now() - 1_000).toISOString(); + + createTask({ + id: 'task-claude', + group_folder: 'main', + chat_jid: 'group@g.us', + prompt: 'claude task', + schedule_type: 'once', + schedule_value: dueAt, + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2024-01-01T00:00:00.000Z', + }); + createTask({ + id: 'task-codex', + group_folder: 'main', + chat_jid: 'group@g.us', + agent_type: 'codex', + prompt: 'codex task', + schedule_type: 'once', + schedule_value: dueAt, + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2024-01-01T00:00:01.000Z', + }); + + expect(getDueTasks('claude-code').map((task) => task.id)).toEqual([ + 'task-claude', + ]); + expect(getDueTasks('codex').map((task) => task.id)).toEqual([ + 'task-codex', + ]); + }); }); // --- LIMIT behavior --- diff --git a/src/db.ts b/src/db.ts index ec8c2fd..cc599fb 100644 --- a/src/db.ts +++ b/src/db.ts @@ -48,6 +48,9 @@ function createSchema(database: Database.Database): void { id TEXT PRIMARY KEY, group_folder TEXT NOT NULL, chat_jid TEXT NOT NULL, + agent_type TEXT, + status_message_id TEXT, + status_started_at TEXT, prompt TEXT NOT NULL, schedule_type TEXT NOT NULL, schedule_value TEXT NOT NULL, @@ -107,6 +110,47 @@ function createSchema(database: Database.Database): void { /* column already exists */ } + try { + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN agent_type TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN status_message_id TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN status_started_at TEXT`); + } catch { + /* column already exists */ + } + + database.exec(` + UPDATE scheduled_tasks + SET agent_type = COALESCE( + ( + SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END + FROM registered_groups + WHERE jid = scheduled_tasks.chat_jid + AND folder = scheduled_tasks.group_folder + ), + ( + SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END + FROM registered_groups + WHERE jid = scheduled_tasks.chat_jid + ), + ( + SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END + FROM registered_groups + WHERE folder = scheduled_tasks.group_folder + ) + ) + WHERE agent_type IS NULL; + `); + // Add is_bot_message column if it doesn't exist (migration for existing DBs) try { database.exec( @@ -420,17 +464,31 @@ export function getLastHumanMessageTimestamp(chatJid: string): string | null { } export function createTask( - task: Omit, + task: Omit< + ScheduledTask, + | 'last_run' + | 'last_result' + | 'agent_type' + | 'status_message_id' + | 'status_started_at' + > & { + agent_type?: AgentType | null; + status_message_id?: string | null; + status_started_at?: string | null; + }, ): void { db.prepare( ` - INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, status_message_id, status_started_at, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( task.id, task.group_folder, task.chat_jid, + task.agent_type || SERVICE_AGENT_TYPE, + task.status_message_id || null, + task.status_started_at || null, task.prompt, task.schedule_type, task.schedule_value, @@ -447,7 +505,18 @@ export function getTaskById(id: string): ScheduledTask | undefined { | undefined; } -export function getTasksForGroup(groupFolder: string): ScheduledTask[] { +export function getTasksForGroup( + groupFolder: string, + agentType?: AgentType, +): ScheduledTask[] { + if (agentType) { + return db + .prepare( + 'SELECT * FROM scheduled_tasks WHERE group_folder = ? AND agent_type = ? ORDER BY created_at DESC', + ) + .all(groupFolder, agentType) as ScheduledTask[]; + } + return db .prepare( 'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC', @@ -455,7 +524,15 @@ export function getTasksForGroup(groupFolder: string): ScheduledTask[] { .all(groupFolder) as ScheduledTask[]; } -export function getAllTasks(): ScheduledTask[] { +export function getAllTasks(agentType?: AgentType): ScheduledTask[] { + if (agentType) { + return db + .prepare( + 'SELECT * FROM scheduled_tasks WHERE agent_type = ? ORDER BY created_at DESC', + ) + .all(agentType) as ScheduledTask[]; + } + return db .prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC') .all() as ScheduledTask[]; @@ -502,23 +579,47 @@ export function updateTask( ).run(...values); } +export function updateTaskStatusTracking( + id: string, + updates: Partial>, +): void { + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.status_message_id !== undefined) { + fields.push('status_message_id = ?'); + values.push(updates.status_message_id); + } + if (updates.status_started_at !== undefined) { + fields.push('status_started_at = ?'); + values.push(updates.status_started_at); + } + + if (fields.length === 0) return; + + values.push(id); + db.prepare( + `UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`, + ).run(...values); +} + export function deleteTask(id: string): void { // Delete child records first (FK constraint) db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id); db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id); } -export function getDueTasks(): ScheduledTask[] { +export function getDueTasks(agentType: AgentType = SERVICE_AGENT_TYPE): ScheduledTask[] { const now = new Date().toISOString(); return db .prepare( ` SELECT * FROM scheduled_tasks - WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ? + WHERE status = 'active' AND agent_type = ? AND next_run IS NOT NULL AND next_run <= ? ORDER BY next_run `, ) - .all(now) as ScheduledTask[]; + .all(agentType, now) as ScheduledTask[]; } export function updateTaskAfterRun( diff --git a/src/index.ts b/src/index.ts index b2e6585..f5182f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -218,6 +218,7 @@ async function main(): Promise { // Start subsystems (independently of connection handler) startSchedulerLoop({ + serviceAgentType: SERVICE_AGENT_TYPE, registeredGroups: () => registeredGroups, getSessions: () => sessions, queue, @@ -232,6 +233,28 @@ async function main(): Promise { const text = formatOutbound(rawText); if (text) await channel.sendMessage(jid, text); }, + sendTrackedMessage: async (jid, rawText) => { + const channel = findChannel(channels, jid); + if (!channel?.sendAndTrack) { + return null; + } + const text = formatOutbound(rawText); + if (!text) { + return null; + } + return channel.sendAndTrack(jid, text); + }, + editTrackedMessage: async (jid, messageId, rawText) => { + const channel = findChannel(channels, jid); + if (!channel?.editMessage) { + throw new Error(`Channel does not support message edits: ${jid}`); + } + const text = formatOutbound(rawText); + if (!text) { + throw new Error(`Cannot edit tracked message to empty text: ${jid}`); + } + await channel.editMessage(jid, messageId, text); + }, }); startIpcWatcher({ sendMessage: (jid, text) => { diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 1aa681e..18ea6b7 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -107,6 +107,31 @@ describe('schedule_task authorization', () => { expect(allTasks[0].group_folder).toBe('other-group'); }); + it('stores the target group agent type on scheduled tasks', async () => { + groups['other@g.us'] = { + ...OTHER_GROUP, + agentType: 'codex', + }; + setRegisteredGroup('other@g.us', groups['other@g.us']); + + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'codex owned task', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00', + targetJid: 'other@g.us', + }, + 'whatsapp_main', + true, + deps, + ); + + const allTasks = getAllTasks(); + expect(allTasks).toHaveLength(1); + expect(allTasks[0].agent_type).toBe('codex'); + }); + it('non-main group cannot schedule for another group', async () => { await processTaskIpc( { diff --git a/src/ipc.ts b/src/ipc.ts index 72a31c2..a434581 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -3,7 +3,12 @@ import path from 'path'; import { CronExpressionParser } from 'cron-parser'; -import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js'; +import { + DATA_DIR, + IPC_POLL_INTERVAL, + SERVICE_AGENT_TYPE, + TIMEZONE, +} from './config.js'; import { AvailableGroup } from './agent-runner.js'; import { createTask, deleteTask, getTaskById, updateTask } from './db.js'; import { isValidGroupFolder } from './group-folder.js'; @@ -257,6 +262,7 @@ export async function processTaskIpc( id: taskId, group_folder: targetFolder, chat_jid: targetJid, + agent_type: targetGroupEntry.agentType || SERVICE_AGENT_TYPE, prompt: data.prompt, schedule_type: scheduleType, schedule_value: data.schedule_value, @@ -266,7 +272,13 @@ export async function processTaskIpc( created_at: new Date().toISOString(), }); logger.info( - { taskId, sourceGroup, targetFolder, contextMode }, + { + taskId, + sourceGroup, + targetFolder, + contextMode, + agentType: targetGroupEntry.agentType || SERVICE_AGENT_TYPE, + }, 'Task created via IPC', ); } diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 96dcae2..253c06f 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -23,6 +23,7 @@ import { isSessionCommandControlMessage, } from './session-commands.js'; import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; +import { isTaskStatusControlMessage } from './task-scheduler.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; @@ -79,6 +80,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { if (msg.is_bot_message && isSessionCommandControlMessage(msg.content)) { return false; } + if (msg.is_bot_message && isTaskStatusControlMessage(msg.content)) { + return false; + } return true; }); @@ -98,7 +102,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const sessions = deps.getSessions(); const sessionId = sessions[group.folder]; - const tasks = getAllTasks(); + const tasks = getAllTasks(group.agentType || 'claude-code'); writeTasksSnapshot( group.folder, isMain, diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index 2032b51..31dc01f 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -4,6 +4,9 @@ import { _initTestDatabase, createTask, getTaskById } from './db.js'; import { _resetSchedulerLoopForTests, computeNextRun, + extractWatchCiTarget, + isWatchCiTask, + renderWatchCiStatusMessage, startSchedulerLoop, } from './task-scheduler.js'; @@ -52,12 +55,99 @@ describe('task scheduler', () => { expect(task?.status).toBe('paused'); }); + it('only enqueues tasks owned by the current service agent type', async () => { + const dueAt = new Date(Date.now() - 60_000).toISOString(); + createTask({ + id: 'task-claude', + group_folder: 'shared-group', + chat_jid: 'shared@g.us', + prompt: 'claude task', + schedule_type: 'once', + schedule_value: dueAt, + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2026-02-22T00:00:00.000Z', + }); + createTask({ + id: 'task-codex', + group_folder: 'shared-group', + chat_jid: 'shared@g.us', + agent_type: 'codex', + prompt: 'codex task', + schedule_type: 'once', + schedule_value: dueAt, + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2026-02-22T00:00:01.000Z', + }); + + const enqueueTask = vi.fn(); + + startSchedulerLoop({ + serviceAgentType: 'codex', + registeredGroups: () => ({ + 'shared@g.us': { + name: 'Shared', + folder: 'shared-group', + trigger: '@Codex', + added_at: '2026-02-22T00:00:00.000Z', + agentType: 'codex', + }, + }), + getSessions: () => ({}), + queue: { enqueueTask } as any, + onProcess: () => {}, + sendMessage: async () => {}, + }); + + await vi.advanceTimersByTimeAsync(10); + + expect(enqueueTask).toHaveBeenCalledTimes(1); + expect(enqueueTask.mock.calls[0][1]).toBe('task-codex'); + }); + + it('renders watcher heartbeat messages with target and timing', () => { + const prompt = ` +[BACKGROUND CI WATCH] + +Watch target: +GitHub Actions run 123456 + +Task ID: +task-123 + +Check instructions: +Check the run. +`.trim(); + + expect(isWatchCiTask({ prompt } as any)).toBe(true); + expect(extractWatchCiTarget(prompt)).toBe('GitHub Actions run 123456'); + + const rendered = renderWatchCiStatusMessage({ + task: { prompt }, + phase: 'waiting', + checkedAt: '2026-03-19T07:02:10.000Z', + nextRun: '2026-03-19T07:04:10.000Z', + }); + + expect(rendered).toContain('CI 감시 중: GitHub Actions run 123456'); + expect(rendered).toContain('- 상태: 대기 중'); + expect(rendered).toContain('- 마지막 확인:'); + expect(rendered).toContain('- 다음 확인:'); + expect(rendered).not.toContain('2분 10초'); + }); + it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => { const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago const task = { id: 'drift-test', group_folder: 'test', chat_jid: 'test@g.us', + agent_type: 'claude-code' as const, + status_message_id: null, + status_started_at: null, prompt: 'test', schedule_type: 'interval' as const, schedule_value: '60000', // 1 minute @@ -82,6 +172,9 @@ describe('task scheduler', () => { id: 'once-test', group_folder: 'test', chat_jid: 'test@g.us', + agent_type: 'claude-code' as const, + status_message_id: null, + status_started_at: null, prompt: 'test', schedule_type: 'once' as const, schedule_value: '2026-01-01T00:00:00.000Z', @@ -106,6 +199,9 @@ describe('task scheduler', () => { id: 'skip-test', group_folder: 'test', chat_jid: 'test@g.us', + agent_type: 'claude-code' as const, + status_message_id: null, + status_started_at: null, prompt: 'test', schedule_type: 'interval' as const, schedule_value: String(ms), diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 2be69a8..d7519d7 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -2,7 +2,12 @@ import { ChildProcess } from 'child_process'; import { CronExpressionParser } from 'cron-parser'; import fs from 'fs'; -import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js'; +import { + ASSISTANT_NAME, + SCHEDULER_POLL_INTERVAL, + SERVICE_AGENT_TYPE, + TIMEZONE, +} from './config.js'; import { AgentOutput, runAgentProcess, @@ -13,13 +18,14 @@ import { getDueTasks, getTaskById, logTaskRun, + updateTaskStatusTracking, updateTask, updateTaskAfterRun, } from './db.js'; import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { logger } from './logger.js'; -import { RegisteredGroup, ScheduledTask } from './types.js'; +import { AgentType, RegisteredGroup, ScheduledTask } from './types.js'; /** * Compute the next run time for a recurring task, anchored to the @@ -63,6 +69,7 @@ export function computeNextRun(task: ScheduledTask): string | null { } export interface SchedulerDependencies { + serviceAgentType?: AgentType; registeredGroups: () => Record; getSessions: () => Record; queue: GroupQueue; @@ -73,6 +80,70 @@ export interface SchedulerDependencies { groupFolder: string, ) => void; sendMessage: (jid: string, text: string) => Promise; + sendTrackedMessage?: (jid: string, text: string) => Promise; + editTrackedMessage?: ( + jid: string, + messageId: string, + text: string, + ) => Promise; +} + +type WatcherStatusPhase = 'checking' | 'waiting' | 'retrying' | 'completed'; + +const WATCH_CI_PREFIX = '[BACKGROUND CI WATCH]'; +const TASK_STATUS_MESSAGE_PREFIX = '\u2063\u2063\u2063'; + +export function isWatchCiTask(task: Pick): boolean { + return task.prompt.startsWith(WATCH_CI_PREFIX); +} + +export function isTaskStatusControlMessage(content: string): boolean { + return content.startsWith(TASK_STATUS_MESSAGE_PREFIX); +} + +export function extractWatchCiTarget(prompt: string): string | null { + const match = prompt.match(/Watch target:\n([\s\S]*?)\n\nTask ID:/); + return match?.[1]?.trim() || null; +} + +function formatTimeLabel(timestampIso: string): string { + return new Intl.DateTimeFormat('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + timeZone: TIMEZONE, + }).format(new Date(timestampIso)); +} + +export function renderWatchCiStatusMessage(args: { + task: Pick; + phase: WatcherStatusPhase; + checkedAt: string; + nextRun?: string | null; +}): string { + const target = extractWatchCiTarget(args.task.prompt) || 'CI watcher'; + const title = + args.phase === 'completed' ? `CI 감시 종료: ${target}` : `CI 감시 중: ${target}`; + const statusLabel = + args.phase === 'checking' + ? '확인 중' + : args.phase === 'retrying' + ? '재시도 대기' + : args.phase === 'completed' + ? '완료' + : '대기 중'; + + const lines = [ + title, + `- 상태: ${statusLabel}`, + `- 마지막 확인: ${formatTimeLabel(args.checkedAt)}`, + ]; + + if (args.nextRun) { + lines.push(`- 다음 확인: ${formatTimeLabel(args.nextRun)}`); + } + return lines.join('\n'); } async function runTask( @@ -131,7 +202,9 @@ async function runTask( // Update tasks snapshot for agent to read (filtered by group) const isMain = group.isMain === true; - const tasks = getAllTasks(); + const taskAgentType = + task.agent_type || deps.serviceAgentType || SERVICE_AGENT_TYPE; + const tasks = getAllTasks(taskAgentType); writeTasksSnapshot( task.group_folder, isMain, @@ -148,6 +221,8 @@ async function runTask( let result: string | null = null; let error: string | null = null; + let statusMessageId = task.status_message_id; + let statusStartedAt = task.status_started_at; // For group context mode, use the group's current session const sessions = deps.getSessions(); @@ -168,7 +243,62 @@ async function runTask( }, TASK_CLOSE_DELAY_MS); }; + const shouldTrackStatus = + isWatchCiTask(task) && + typeof deps.sendTrackedMessage === 'function' && + typeof deps.editTrackedMessage === 'function'; + + const persistStatusTracking = () => { + const currentTask = getTaskById(task.id); + if (!currentTask) return; + updateTaskStatusTracking(task.id, { + status_message_id: statusMessageId, + status_started_at: statusStartedAt, + }); + }; + + const updateWatcherStatus = async ( + phase: WatcherStatusPhase, + nextRun?: string | null, + ) => { + if (!shouldTrackStatus) { + return; + } + + const checkedAt = new Date().toISOString(); + if (!statusStartedAt) { + statusStartedAt = checkedAt; + } + + const text = renderWatchCiStatusMessage({ + task, + phase, + checkedAt, + nextRun, + }); + const payload = `${TASK_STATUS_MESSAGE_PREFIX}${text}`; + + if (statusMessageId) { + try { + await deps.editTrackedMessage!(task.chat_jid, statusMessageId, payload); + persistStatusTracking(); + return; + } catch { + statusMessageId = null; + persistStatusTracking(); + } + } + + const messageId = await deps.sendTrackedMessage!(task.chat_jid, payload); + if (messageId) { + statusMessageId = messageId; + persistStatusTracking(); + } + }; + try { + await updateWatcherStatus('checking'); + const output = await runAgentProcess( group, { @@ -212,7 +342,11 @@ async function runTask( } logger.info( - { taskId: task.id, durationMs: Date.now() - startTime }, + { + taskId: task.id, + agentType: taskAgentType, + durationMs: Date.now() - startTime, + }, 'Task completed', ); } catch (err) { @@ -222,6 +356,22 @@ async function runTask( } const durationMs = Date.now() - startTime; + const currentTask = getTaskById(task.id); + const nextRun = currentTask ? computeNextRun(task) : null; + + if (!currentTask) { + await updateWatcherStatus('completed'); + logger.debug({ taskId: task.id }, 'Task deleted during execution, skipping persistence'); + return; + } + + if (error) { + await updateWatcherStatus('retrying', nextRun); + } else if (nextRun) { + await updateWatcherStatus('waiting', nextRun); + } else { + await updateWatcherStatus('completed'); + } logTaskRun({ task_id: task.id, @@ -232,7 +382,6 @@ async function runTask( error, }); - const nextRun = computeNextRun(task); const resultSummary = error ? `Error: ${error}` : result @@ -253,7 +402,9 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void { const loop = async () => { try { - const dueTasks = getDueTasks(); + const dueTasks = getDueTasks( + deps.serviceAgentType || SERVICE_AGENT_TYPE, + ); if (dueTasks.length > 0) { logger.info({ count: dueTasks.length }, 'Found due tasks'); } diff --git a/src/types.ts b/src/types.ts index 60d2fd2..3d382c0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,9 @@ export interface ScheduledTask { id: string; group_folder: string; chat_jid: string; + agent_type: AgentType | null; + status_message_id: string | null; + status_started_at: string | null; prompt: string; schedule_type: 'cron' | 'interval' | 'once'; schedule_value: string;