refactor: remove legacy container and non-discord remnants

This commit is contained in:
Eyejoker
2026-03-20 01:07:46 +09:00
parent ea09560128
commit bb0628e8f4
82 changed files with 2712 additions and 10523 deletions

1524
runners/agent-runner/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "nanoclaw-agent-runner",
"version": "1.0.0",
"type": "module",
"description": "Container-side agent runner for NanoClaw",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.34",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,761 @@
/**
* NanoClaw Agent Runner
* Runs as a child process, receives config via stdin, outputs result to stdout
*
* Input protocol:
* Stdin: Full RunnerInput JSON (read until EOF, like before)
* IPC: Follow-up messages written as JSON files to $NANOCLAW_IPC_DIR/input/
* Files: {type:"message", text:"..."}.json — polled and consumed
* Sentinel: /workspace/ipc/input/_close — signals session end
*
* Stdout protocol:
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
* Multiple results may be emitted (one per agent teams result).
* Final marker after loop ends signals completion.
*/
import fs from 'fs';
import path from 'path';
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
import { fileURLToPath } from 'url';
interface ContainerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
secrets?: Record<string, string>;
}
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
interface SessionEntry {
sessionId: string;
fullPath: string;
summary: string;
firstPrompt: string;
}
interface SessionsIndex {
entries: SessionEntry[];
}
type ContentBlock =
| { type: 'text'; text: string }
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } };
interface SDKUserMessage {
type: 'user';
message: { role: 'user'; content: string | ContentBlock[] };
parent_tool_use_id: null;
session_id: string;
}
// Paths configurable via env vars.
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
const WORK_DIR = process.env.NANOCLAW_WORK_DIR || '';
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const MAX_TURNS = 100;
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
const MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
};
/**
* Parse [Image: /absolute/path] tags from text and build multimodal content.
* Returns a plain string if no images found, or ContentBlock[] with text + image blocks.
*/
function buildMultimodalContent(text: string): string | ContentBlock[] {
const imagePaths: string[] = [];
let match;
while ((match = IMAGE_TAG_RE.exec(text)) !== null) {
imagePaths.push(match[1].trim());
}
IMAGE_TAG_RE.lastIndex = 0; // reset regex state
if (imagePaths.length === 0) return text;
const blocks: ContentBlock[] = [];
const cleanText = text.replace(IMAGE_TAG_RE, '').trim();
if (cleanText) {
blocks.push({ type: 'text', text: cleanText });
}
for (const imgPath of imagePaths) {
try {
if (!fs.existsSync(imgPath)) {
log(`Image not found, skipping: ${imgPath}`);
continue;
}
const data = fs.readFileSync(imgPath).toString('base64');
const ext = path.extname(imgPath).toLowerCase();
const mediaType = MIME_TYPES[ext] || 'image/png';
blocks.push({ type: 'image', source: { type: 'base64', media_type: mediaType, data } });
log(`Added image block: ${imgPath} (${mediaType})`);
} catch (err) {
log(`Failed to read image ${imgPath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return blocks.length > 0 ? blocks : text;
}
/**
* Push-based async iterable for streaming user messages to the SDK.
* Keeps the iterable alive until end() is called, preventing isSingleUserTurn.
*/
class MessageStream {
private queue: SDKUserMessage[] = [];
private waiting: (() => void) | null = null;
private done = false;
push(text: string): void {
const content = buildMultimodalContent(text);
this.queue.push({
type: 'user',
message: { role: 'user', content },
parent_tool_use_id: null,
session_id: '',
});
this.waiting?.();
}
end(): void {
this.done = true;
this.waiting?.();
}
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
while (true) {
while (this.queue.length > 0) {
yield this.queue.shift()!;
}
if (this.done) return;
await new Promise<void>(r => { this.waiting = r; });
this.waiting = null;
}
}
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { data += chunk; });
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
function writeOutput(output: ContainerOutput): void {
console.log(OUTPUT_START_MARKER);
console.log(JSON.stringify(output));
console.log(OUTPUT_END_MARKER);
}
function log(message: string): void {
console.error(`[agent-runner] ${message}`);
}
function getSessionSummary(sessionId: string, transcriptPath: string): string | null {
const projectDir = path.dirname(transcriptPath);
const indexPath = path.join(projectDir, 'sessions-index.json');
if (!fs.existsSync(indexPath)) {
log(`Sessions index not found at ${indexPath}`);
return null;
}
try {
const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
const entry = index.entries.find(e => e.sessionId === sessionId);
if (entry?.summary) {
return entry.summary;
}
} catch (err) {
log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`);
}
return null;
}
/**
* Archive the full transcript to conversations/ before compaction.
*/
function createPreCompactHook(assistantName?: string): HookCallback {
return async (input, _toolUseId, _context) => {
const preCompact = input as PreCompactHookInput;
const transcriptPath = preCompact.transcript_path;
const sessionId = preCompact.session_id;
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
log('No transcript found for archiving');
return {};
}
try {
const content = fs.readFileSync(transcriptPath, 'utf-8');
const messages = parseTranscript(content);
if (messages.length === 0) {
log('No messages to archive');
return {};
}
const summary = getSessionSummary(sessionId, transcriptPath);
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
const conversationsDir = path.join(GROUP_DIR, 'conversations');
fs.mkdirSync(conversationsDir, { recursive: true });
const date = new Date().toISOString().split('T')[0];
const filename = `${date}-${name}.md`;
const filePath = path.join(conversationsDir, filename);
const markdown = formatTranscriptMarkdown(messages, summary, assistantName);
fs.writeFileSync(filePath, markdown);
log(`Archived conversation to ${filePath}`);
} catch (err) {
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
}
return {};
};
}
// Secrets to strip from Bash tool subprocess environments.
// These are needed by claude-code for API auth but should never
// be visible to commands Kit runs.
const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
function createSanitizeBashHook(): HookCallback {
return async (input, _toolUseId, _context) => {
const preInput = input as PreToolUseHookInput;
const command = (preInput.tool_input as { command?: string })?.command;
if (!command) return {};
const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
updatedInput: {
...(preInput.tool_input as Record<string, unknown>),
command: unsetPrefix + command,
},
},
};
};
}
function sanitizeFilename(summary: string): string {
return summary
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50);
}
function generateFallbackName(): string {
const time = new Date();
return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`;
}
interface ParsedMessage {
role: 'user' | 'assistant';
content: string;
}
function parseTranscript(content: string): ParsedMessage[] {
const messages: ParsedMessage[] = [];
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.content) {
const text = typeof entry.message.content === 'string'
? entry.message.content
: entry.message.content.map((c: { text?: string }) => c.text || '').join('');
if (text) messages.push({ role: 'user', content: text });
} else if (entry.type === 'assistant' && entry.message?.content) {
const textParts = entry.message.content
.filter((c: { type: string }) => c.type === 'text')
.map((c: { text: string }) => c.text);
const text = textParts.join('');
if (text) messages.push({ role: 'assistant', content: text });
}
} catch {
}
}
return messages;
}
function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string {
const now = new Date();
const formatDateTime = (d: Date) => d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
const lines: string[] = [];
lines.push(`# ${title || 'Conversation'}`);
lines.push('');
lines.push(`Archived: ${formatDateTime(now)}`);
lines.push('');
lines.push('---');
lines.push('');
for (const msg of messages) {
const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant');
const content = msg.content.length > 2000
? msg.content.slice(0, 2000) + '...'
: msg.content;
lines.push(`**${sender}**: ${content}`);
lines.push('');
}
return lines.join('\n');
}
/**
* Check for _close sentinel.
*/
function shouldClose(): boolean {
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
return true;
}
return false;
}
/**
* Drain all pending IPC input messages.
* Returns messages found, or empty array.
*/
function drainIpcInput(): string[] {
try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
const files = fs.readdirSync(IPC_INPUT_DIR)
.filter(f => f.endsWith('.json'))
.sort();
const messages: string[] = [];
for (const file of files) {
const filePath = path.join(IPC_INPUT_DIR, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
fs.unlinkSync(filePath);
if (data.type === 'message' && data.text) {
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 */ }
}
}
return messages;
} catch (err) {
log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
return [];
}
}
/**
* Wait for a new IPC message or _close sentinel.
* Returns the messages as a single string, or null if _close.
*/
function waitForIpcMessage(): Promise<string | null> {
return new Promise((resolve) => {
const poll = () => {
if (shouldClose()) {
resolve(null);
return;
}
const messages = drainIpcInput();
if (messages.length > 0) {
resolve(messages.join('\n'));
return;
}
setTimeout(poll, IPC_POLL_MS);
};
poll();
});
}
/**
* Run a single query and stream results via writeOutput.
* Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
* allowing agent teams subagents to run to completion.
* Also pipes IPC messages into the stream during the query.
*/
async function runQuery(
prompt: string,
sessionId: string | undefined,
mcpServerPath: string,
containerInput: ContainerInput,
sdkEnv: Record<string, string | undefined>,
resumeAt?: string,
): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> {
const stream = new MessageStream();
stream.push(prompt);
// Poll IPC for follow-up messages and _close sentinel during the query
let ipcPolling = true;
let closedDuringQuery = false;
const pollIpcDuringQuery = () => {
if (!ipcPolling) return;
if (shouldClose()) {
log('Close sentinel detected during query, ending stream');
closedDuringQuery = true;
stream.end();
ipcPolling = false;
return;
}
const messages = drainIpcInput();
for (const text of messages) {
log(`Piping IPC message into active query (${text.length} chars)`);
stream.push(text);
}
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
};
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
let newSessionId: string | undefined;
let lastAssistantUuid: string | undefined;
let messageCount = 0;
let resultCount = 0;
// Discover additional directories
const extraDirs: string[] = [];
// When WORK_DIR is set, use it as cwd and include GROUP_DIR as additional directory
const effectiveCwd = WORK_DIR || GROUP_DIR;
if (WORK_DIR && WORK_DIR !== GROUP_DIR) {
extraDirs.push(GROUP_DIR);
log(`Work directory override: ${WORK_DIR} (group dir added to additionalDirectories)`);
}
if (extraDirs.length > 0) {
log(`Additional directories: ${extraDirs.join(', ')}`);
}
// Model and thinking configuration from environment
const model = process.env.CLAUDE_MODEL || undefined;
const thinkingType = process.env.CLAUDE_THINKING || undefined; // 'adaptive' | 'enabled' | 'disabled'
const thinkingBudget = process.env.CLAUDE_THINKING_BUDGET
? parseInt(process.env.CLAUDE_THINKING_BUDGET, 10)
: undefined;
const effort = (process.env.CLAUDE_EFFORT as 'low' | 'medium' | 'high' | 'max') || undefined;
const thinking = thinkingType === 'adaptive' ? { type: 'adaptive' as const }
: thinkingType === 'enabled' ? { type: 'enabled' as const, budgetTokens: thinkingBudget }
: thinkingType === 'disabled' ? { type: 'disabled' as const }
: undefined;
if (model) log(`Using model: ${model}`);
if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`);
if (effort) log(`Effort: ${effort}`);
for await (const message of query({
prompt: stream,
options: {
cwd: effectiveCwd,
model,
thinking,
effort,
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
resume: sessionId,
resumeSessionAt: resumeAt,
allowedTools: [
'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'Task', 'TaskOutput', 'TaskStop',
'TeamCreate', 'TeamDelete', 'SendMessage',
'TodoWrite', 'ToolSearch', 'Skill',
'NotebookEdit',
'mcp__nanoclaw__*'
],
env: sdkEnv,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'],
mcpServers: {
nanoclaw: {
command: 'node',
args: [mcpServerPath],
env: {
NANOCLAW_CHAT_JID: containerInput.chatJid,
NANOCLAW_GROUP_FOLDER: containerInput.groupFolder,
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
},
},
},
hooks: {
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
},
}
})) {
messageCount++;
const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type;
log(`[msg #${messageCount}] type=${msgType}`);
if (message.type === 'assistant' && 'uuid' in message) {
lastAssistantUuid = (message as { uuid: string }).uuid;
}
if (message.type === 'system' && message.subtype === 'init') {
newSessionId = message.session_id;
log(`Session initialized: ${newSessionId}`);
}
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
const tn = message as { task_id: string; status: string; summary: string };
log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`);
}
if (message.type === 'result') {
resultCount++;
const textResult = 'result' in message ? (message as { result?: string }).result : null;
log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
writeOutput({
status: 'success',
result: textResult || null,
newSessionId
});
}
}
ipcPolling = false;
log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`);
return { newSessionId, lastAssistantUuid, closedDuringQuery };
}
async function main(): Promise<void> {
let containerInput: ContainerInput;
try {
const stdinData = await readStdin();
containerInput = JSON.parse(stdinData);
// Delete the temp file the entrypoint wrote — it contains secrets
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);
}
// Build SDK env: merge secrets into process.env for the SDK only.
// Secrets never touch process.env itself, so Bash subprocesses can't see them.
const sdkEnv: Record<string, string | undefined> = { ...process.env };
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
sdkEnv[key] = value;
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
let sessionId = containerInput.sessionId;
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
// Clean up stale _close sentinel from previous container runs
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
// Effective working directory (WORK_DIR overrides GROUP_DIR)
const mainEffectiveCwd = WORK_DIR || GROUP_DIR;
// Build initial prompt (drain any pending IPC messages too)
let prompt = containerInput.prompt;
if (containerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`;
}
const pending = drainIpcInput();
if (pending.length > 0) {
log(`Draining ${pending.length} pending IPC messages into initial prompt`);
prompt += '\n' + pending.join('\n');
}
// --- Slash command handling ---
// Only known session slash commands are handled here. This prevents
// accidental interception of user prompts that happen to start with '/'.
const KNOWN_SESSION_COMMANDS = new Set(['/compact']);
const trimmedPrompt = prompt.trim();
const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(trimmedPrompt);
if (isSessionSlashCommand) {
log(`Handling session command: ${trimmedPrompt}`);
let slashSessionId: string | undefined;
let compactBoundarySeen = false;
let hadError = false;
let resultEmitted = false;
try {
for await (const message of query({
prompt: trimmedPrompt,
options: {
cwd: mainEffectiveCwd,
resume: sessionId,
systemPrompt: undefined,
allowedTools: [],
env: sdkEnv,
permissionMode: 'bypassPermissions' as const,
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'] as const,
hooks: {
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
},
},
})) {
const msgType = message.type === 'system'
? `system/${(message as { subtype?: string }).subtype}`
: message.type;
log(`[slash-cmd] type=${msgType}`);
if (message.type === 'system' && message.subtype === 'init') {
slashSessionId = message.session_id;
log(`Session after slash command: ${slashSessionId}`);
}
// Observe compact_boundary to confirm compaction completed
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
compactBoundarySeen = true;
log('Compact boundary observed — compaction completed');
}
if (message.type === 'result') {
const resultSubtype = (message as { subtype?: string }).subtype;
const textResult = 'result' in message ? (message as { result?: string }).result : null;
if (resultSubtype?.startsWith('error')) {
hadError = true;
writeOutput({
status: 'error',
result: null,
error: textResult || 'Session command failed.',
newSessionId: slashSessionId,
});
} else {
writeOutput({
status: 'success',
result: textResult || 'Conversation compacted.',
newSessionId: slashSessionId,
});
}
resultEmitted = true;
}
}
} catch (err) {
hadError = true;
const errorMsg = err instanceof Error ? err.message : String(err);
log(`Slash command error: ${errorMsg}`);
writeOutput({ status: 'error', result: null, error: errorMsg });
}
log(`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`);
// Warn if compact_boundary was never observed — compaction may not have occurred
if (!hadError && !compactBoundarySeen) {
log('WARNING: compact_boundary was not observed. Compaction may not have completed.');
}
// Only emit final session marker if no result was emitted yet and no error occurred
if (!resultEmitted && !hadError) {
writeOutput({
status: 'success',
result: compactBoundarySeen
? 'Conversation compacted.'
: 'Compaction requested but compact_boundary was not observed.',
newSessionId: slashSessionId,
});
} else if (!hadError) {
// Emit session-only marker so host updates session tracking
writeOutput({ status: 'success', result: null, newSessionId: slashSessionId });
}
return;
}
// --- End slash command handling ---
// Query loop: run query → wait for IPC message → run new query → repeat
let resumeAt: string | undefined;
let turnCount = 0;
try {
while (true) {
turnCount++;
if (turnCount > MAX_TURNS) {
log(`Turn limit reached (${MAX_TURNS}), exiting`);
writeOutput({ status: 'success', result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]', newSessionId: sessionId });
break;
}
log(`Starting query (session: ${sessionId || 'new'}, turn: ${turnCount}/${MAX_TURNS}, resumeAt: ${resumeAt || 'latest'})...`);
const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt);
if (queryResult.newSessionId) {
sessionId = queryResult.newSessionId;
}
if (queryResult.lastAssistantUuid) {
resumeAt = queryResult.lastAssistantUuid;
}
// If _close was consumed during the query, exit immediately.
// Don't emit a session-update marker (it would reset the host's
// idle timer and cause a 30-min delay before the next _close).
if (queryResult.closedDuringQuery) {
log('Close sentinel consumed during query, exiting');
break;
}
// Emit session update so host can track it
writeOutput({ status: 'success', result: null, newSessionId: sessionId });
log('Query ended, waiting for next IPC message...');
// Wait for the next message or _close sentinel
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting');
break;
}
log(`Got new message (${nextMessage.length} chars), starting new query`);
prompt = nextMessage;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Agent error: ${errorMessage}`);
writeOutput({
status: 'error',
result: null,
newSessionId: sessionId,
error: errorMessage
});
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,338 @@
/**
* Stdio MCP Server for NanoClaw
* Standalone process that agent teams subagents can inherit.
* Reads context from environment variables, writes IPC files for the host.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'fs';
import path from 'path';
import { CronExpressionParser } from 'cron-parser';
const 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';
function writeIpcFile(dir: string, data: object): string {
fs.mkdirSync(dir, { recursive: true });
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
const filepath = path.join(dir, filename);
// Atomic write: temp file then rename
const tempPath = `${filepath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
fs.renameSync(tempPath, filepath);
return filename;
}
const server = new McpServer({
name: 'nanoclaw',
version: '1.0.0',
});
server.tool(
'send_message',
"Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times.",
{
text: z.string().describe('The message text to send'),
sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'),
},
async (args) => {
const data: Record<string, string | undefined> = {
type: 'message',
chatJid,
text: args.text,
sender: args.sender || undefined,
groupFolder,
timestamp: new Date().toISOString(),
};
writeIpcFile(MESSAGES_DIR, data);
return { content: [{ type: 'text' as const, text: 'Message sent.' }] };
},
);
server.tool(
'schedule_task',
`Schedule a recurring or one-time task. The task will run as a full agent with access to all tools. Returns the task ID for future reference. To modify an existing task, use update_task instead.
CONTEXT MODE - Choose based on task type:
\u2022 "group": Task runs in the group's conversation context, with access to chat history. Use for tasks that need context about ongoing discussions, user preferences, or recent interactions.
\u2022 "isolated": Task runs in a fresh session with no conversation history. Use for independent tasks that don't need prior context. When using isolated mode, include all necessary context in the prompt itself.
If unsure which mode to use, you can ask the user. Examples:
- "Remind me about our discussion" \u2192 group (needs conversation context)
- "Check the weather every morning" \u2192 isolated (self-contained task)
- "Follow up on my request" \u2192 group (needs to know what was requested)
- "Generate a daily report" \u2192 isolated (just needs instructions in prompt)
MESSAGING BEHAVIOR - The task agent's output is sent to the user or group. It can also use send_message for immediate delivery, or wrap output in <internal> tags to suppress it. Include guidance in the prompt about whether the agent should:
\u2022 Always send a message (e.g., reminders, daily briefings)
\u2022 Only send a message when there's something to report (e.g., "notify me if...")
\u2022 Never send a message (background maintenance tasks)
SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
\u2022 cron: Standard cron expression (e.g., "*/5 * * * *" for every 5 minutes, "0 9 * * *" for daily at 9am LOCAL time)
\u2022 interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour)
\u2022 once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`,
{
prompt: z.string().describe('What the agent should do when the task runs. For isolated mode, include all necessary context here.'),
schedule_type: z.enum(['cron', 'interval', 'once']).describe('cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time'),
schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'),
context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'),
target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'),
},
async (args) => {
// Validate schedule_value before writing IPC
if (args.schedule_type === 'cron') {
try {
CronExpressionParser.parse(args.schedule_value);
} catch {
return {
content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).` }],
isError: true,
};
}
} else if (args.schedule_type === 'interval') {
const ms = parseInt(args.schedule_value, 10);
if (isNaN(ms) || ms <= 0) {
return {
content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).` }],
isError: true,
};
}
} else if (args.schedule_type === 'once') {
if (/[Zz]$/.test(args.schedule_value) || /[+-]\d{2}:\d{2}$/.test(args.schedule_value)) {
return {
content: [{ type: 'text' as const, text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".` }],
isError: true,
};
}
const date = new Date(args.schedule_value);
if (isNaN(date.getTime())) {
return {
content: [{ type: 'text' as const, text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".` }],
isError: true,
};
}
}
// Non-main groups can only schedule for themselves
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 data = {
type: 'schedule_task',
taskId,
prompt: args.prompt,
schedule_type: args.schedule_type,
schedule_value: args.schedule_value,
context_mode: args.context_mode || 'group',
targetJid,
createdBy: groupFolder,
timestamp: new Date().toISOString(),
};
writeIpcFile(TASKS_DIR, data);
return {
content: [{ type: 'text' as const, text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}` }],
};
},
);
server.tool(
'list_tasks',
"List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.",
{},
async () => {
const tasksFile = path.join(IPC_DIR, 'current_tasks.json');
try {
if (!fs.existsSync(tasksFile)) {
return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] };
}
const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'));
const tasks = isMain
? allTasks
: allTasks.filter((t: { groupFolder: string }) => t.groupFolder === groupFolder);
if (tasks.length === 0) {
return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] };
}
const formatted = tasks
.map(
(t: { id: string; prompt: string; schedule_type: string; schedule_value: string; status: string; next_run: string }) =>
`- [${t.id}] ${t.prompt.slice(0, 50)}... (${t.schedule_type}: ${t.schedule_value}) - ${t.status}, next: ${t.next_run || 'N/A'}`,
)
.join('\n');
return { content: [{ type: 'text' as const, text: `Scheduled tasks:\n${formatted}` }] };
} catch (err) {
return {
content: [{ type: 'text' as const, text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}` }],
};
}
},
);
server.tool(
'pause_task',
'Pause a scheduled task. It will not run until resumed.',
{ task_id: z.string().describe('The task ID to pause') },
async (args: { task_id: string }) => {
const data = {
type: 'pause_task',
taskId: args.task_id,
groupFolder,
isMain,
timestamp: new Date().toISOString(),
};
writeIpcFile(TASKS_DIR, data);
return { content: [{ type: 'text' as const, text: `Task ${args.task_id} pause requested.` }] };
},
);
server.tool(
'resume_task',
'Resume a paused task.',
{ task_id: z.string().describe('The task ID to resume') },
async (args: { task_id: string }) => {
const data = {
type: 'resume_task',
taskId: args.task_id,
groupFolder,
isMain,
timestamp: new Date().toISOString(),
};
writeIpcFile(TASKS_DIR, data);
return { content: [{ type: 'text' as const, text: `Task ${args.task_id} resume requested.` }] };
},
);
server.tool(
'cancel_task',
'Cancel and delete a scheduled task.',
{ task_id: z.string().describe('The task ID to cancel') },
async (args: { task_id: string }) => {
const data = {
type: 'cancel_task',
taskId: args.task_id,
groupFolder,
isMain,
timestamp: new Date().toISOString(),
};
writeIpcFile(TASKS_DIR, data);
return { content: [{ type: 'text' as const, text: `Task ${args.task_id} cancellation requested.` }] };
},
);
server.tool(
'update_task',
'Update an existing scheduled task. Only provided fields are changed; omitted fields stay the same.',
{
task_id: z.string().describe('The task ID to update'),
prompt: z.string().optional().describe('New prompt for the task'),
schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'),
schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'),
},
async (args: { task_id: string; prompt?: string; schedule_type?: 'cron' | 'interval' | 'once'; schedule_value?: string }) => {
// Validate schedule_value if provided
if (args.schedule_type === 'cron' || (!args.schedule_type && args.schedule_value)) {
if (args.schedule_value) {
try {
CronExpressionParser.parse(args.schedule_value);
} catch {
return {
content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}".` }],
isError: true,
};
}
}
}
if (args.schedule_type === 'interval' && args.schedule_value) {
const ms = parseInt(args.schedule_value, 10);
if (isNaN(ms) || ms <= 0) {
return {
content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}".` }],
isError: true,
};
}
}
const data: Record<string, string | undefined> = {
type: 'update_task',
taskId: args.task_id,
groupFolder,
isMain: String(isMain),
timestamp: new Date().toISOString(),
};
if (args.prompt !== undefined) data.prompt = args.prompt;
if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type;
if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value;
writeIpcFile(TASKS_DIR, data);
return { content: [{ type: 'text' as const, text: `Task ${args.task_id} update requested.` }] };
},
);
server.tool(
'register_group',
`Register a new chat/group so the agent can respond to messages there. Main group only.
Use available_groups.json to find the JID for a group. The folder name must be channel-prefixed: "{channel}_{group-name}" (e.g., "whatsapp_family-chat", "telegram_dev-team", "discord_general"). Use lowercase with hyphens for the group name part.`,
{
jid: z.string().describe('The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")'),
name: z.string().describe('Display name for the group'),
folder: z.string().describe('Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")'),
trigger: z.string().describe('Trigger word (e.g., "@Andy")'),
},
async (args: { jid: string; name: string; folder: string; trigger: string }) => {
if (!isMain) {
return {
content: [{ type: 'text' as const, text: 'Only the main group can register new groups.' }],
isError: true,
};
}
const data = {
type: 'register_group',
jid: args.jid,
name: args.name,
folder: args.folder,
trigger: args.trigger,
timestamp: new Date().toISOString(),
};
writeIpcFile(TASKS_DIR, data);
return {
content: [{ type: 'text' as const, text: `Group "${args.name}" registered. It will start receiving messages immediately.` }],
};
},
);
// Start the stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

184
runners/codex-runner/package-lock.json generated Normal file
View File

@@ -0,0 +1,184 @@
{
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"dependencies": {
"@openai/codex-sdk": "^0.115.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3"
}
},
"node_modules/@openai/codex": {
"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"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"@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.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"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-darwin-x64": {
"name": "@openai/codex",
"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"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-linux-arm64": {
"name": "@openai/codex",
"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"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-linux-x64": {
"name": "@openai/codex",
"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"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-sdk": {
"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.115.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@openai/codex-win32-arm64": {
"name": "@openai/codex",
"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"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-win32-x64": {
"name": "@openai/codex",
"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"
],
"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",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"type": "module",
"description": "Container-side Codex CLI runner for NanoClaw",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@openai/codex-sdk": "^0.115.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3"
}
}

View File

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

View File

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

View File

@@ -0,0 +1,631 @@
/**
* NanoClaw 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/
* Sentinel: _close — signals session end
*
* Stdout protocol:
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
*/
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;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
agentType?: string;
}
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
// ── 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 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---';
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 {
console.log(OUTPUT_START_MARKER);
console.log(JSON.stringify(output));
console.log(OUTPUT_END_MARKER);
}
function log(message: string): void {
console.error(`[codex-runner] ${message}`);
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk: string) => {
data += chunk;
});
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
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 */
}
closeRequested = true;
return true;
}
function drainIpcInput(): string[] {
try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
const files = fs
.readdirSync(IPC_INPUT_DIR)
.filter((file) => file.endsWith('.json'))
.sort();
const messages: string[] = [];
for (const file of files) {
const filePath = path.join(IPC_INPUT_DIR, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
fs.unlinkSync(filePath);
if (data.type === 'message' && data.text) {
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 */
}
}
}
return messages;
} catch (err) {
log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
return [];
}
}
function waitForIpcMessage(): Promise<string | null> {
return new Promise((resolve) => {
const poll = () => {
if (consumeCloseSentinel()) {
resolve(null);
return;
}
const messages = drainIpcInput();
if (messages.length > 0) {
resolve(messages.join('\n'));
return;
}
setTimeout(poll, IPC_POLL_MS);
};
poll();
});
}
function extractImagePaths(text: string): { cleanText: string; imagePaths: string[] } {
const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g;
const imagePaths: string[] = [];
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[] = [];
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;
}
function parseAppServerInput(text: string): AppServerInputItem[] {
const { cleanText, imagePaths } = extractImagePaths(text);
const input: AppServerInputItem[] = [];
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();
let turnSeconds = 0;
const sentinel = setInterval(() => {
if (consumeCloseSentinel()) {
log('Close sentinel detected during SDK turn, aborting');
ac.abort();
return;
}
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 });
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);
}
}
async function executeAppServerTurn(
client: CodexAppServerClient,
threadId: string,
prompt: string,
): Promise<{ result: string; error?: string }> {
const activeTurn = await client.startTurn(threadId, parseAppServerInput(prompt), {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
effort: CODEX_EFFORT || undefined,
});
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 { state, result } = await activeTurn.wait();
if (state.status === 'completed') {
return { result: result || '' };
}
if (state.status === 'interrupted' && consumeCloseSentinel()) {
return { result: result || '' };
}
return {
result: result || '',
error: state.errorMessage || `Codex turn finished with status ${state.status}`,
};
} finally {
polling = false;
}
}
async function runSdkSession(
containerInput: ContainerInput,
prompt: string,
): Promise<void> {
const threadOptions = getThreadOptions();
const codex = new Codex();
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;
while (true) {
turnCount++;
if (turnCount > MAX_TURNS) {
log(`Turn limit reached (${MAX_TURNS}), exiting`);
writeOutput({
status: 'success',
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
newSessionId: thread.id || undefined,
});
break;
}
const input = parseSdkInput(prompt);
log(`Starting SDK turn ${turnCount}/${MAX_TURNS}...`);
let { result, error } = await executeSdkTurn(thread, input);
if (error && turnCount === 1 && containerInput.sessionId) {
log(`Resume may have failed, retrying with new thread: ${error}`);
thread = codex.startThread(threadOptions);
({ result, error } = await executeSdkTurn(thread, input));
}
if (consumeCloseSentinel()) {
if (result) {
writeOutput({
status: 'success',
result,
newSessionId: thread.id || undefined,
});
}
log('Close sentinel detected, exiting SDK runtime');
break;
}
if (error) {
log(`SDK turn error: ${error}`);
writeOutput({
status: 'error',
result: result || null,
newSessionId: thread.id || undefined,
error,
});
} else {
writeOutput({
status: 'success',
result: result || null,
newSessionId: thread.id || undefined,
});
}
log('SDK turn done, waiting for next IPC message...');
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting SDK runtime');
break;
}
log(`Got new SDK message (${nextMessage.length} chars)`);
prompt = nextMessage;
}
}
async function runAppServerCompact(
client: CodexAppServerClient,
threadId: string | undefined,
): Promise<void> {
if (!threadId) {
writeOutput({
status: 'success',
result: '현재 활성 Codex 세션이 없어 compact를 건너뜁니다.',
});
return;
}
const { state } = await client.startCompaction(threadId);
if (state.status === 'failed') {
writeOutput({
status: 'error',
result: null,
newSessionId: threadId,
error: state.errorMessage || 'Conversation compaction failed.',
});
return;
}
writeOutput({
status: 'success',
result: state.compactionCompleted
? 'Conversation compacted.'
: 'Compaction requested but contextCompaction was not observed.',
newSessionId: threadId,
});
}
async function runAppServerSession(
containerInput: ContainerInput,
prompt: string,
): Promise<void> {
const client = new CodexAppServerClient({
cwd: EFFECTIVE_CWD,
env: process.env,
log,
});
await client.start();
let threadId: string | undefined;
try {
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) {
log(`Turn limit reached (${MAX_TURNS}), exiting`);
writeOutput({
status: 'success',
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
newSessionId: threadId,
});
break;
}
log(`Starting app-server turn ${turnCount}/${MAX_TURNS}...`);
const { result, error } = await executeAppServerTurn(client, threadId, prompt);
if (consumeCloseSentinel()) {
if (result) {
writeOutput({
status: 'success',
result,
newSessionId: threadId,
});
}
log('Close sentinel detected, exiting app-server runtime');
break;
}
if (error) {
log(`App-server turn error: ${error}`);
writeOutput({
status: 'error',
result: result || null,
newSessionId: threadId,
error,
});
} else {
writeOutput({
status: 'success',
result: result || null,
newSessionId: threadId,
});
}
log('App-server turn done, waiting for next IPC message...');
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting app-server runtime');
break;
}
log(`Got new app-server message (${nextMessage.length} chars)`);
prompt = nextMessage;
}
} finally {
await client.close();
}
}
function shouldUseAppServer(): boolean {
return CODEX_RUNTIME !== 'sdk';
}
// ── Main ──────────────────────────────────────────────────────────
async function main(): Promise<void> {
let containerInput: ContainerInput;
try {
const stdinData = await readStdin();
containerInput = JSON.parse(stdinData);
try {
fs.unlinkSync('/tmp/input.json');
} catch {
/* may not exist */
}
log(`Received input for group: ${containerInput.groupFolder}`);
} catch (err) {
writeOutput({
status: 'error',
result: null,
error: `Failed to parse input: ${
err instanceof Error ? err.message : String(err)
}`,
});
process.exit(1);
}
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
closeRequested = false;
try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
let prompt = containerInput.prompt;
if (containerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK]\n\n${prompt}`;
}
const pending = drainIpcInput();
if (pending.length > 0) {
prompt += '\n' + pending.join('\n');
}
const preferAppServer = shouldUseAppServer();
try {
if (preferAppServer) {
try {
log(`Runtime selected: app-server (${CODEX_RUNTIME})`);
await runAppServerSession(containerInput, prompt);
return;
} catch (err) {
if (CODEX_RUNTIME === 'app-server') {
log(
`App-server runtime failed, falling back to SDK: ${
err instanceof Error ? err.message : String(err)
}`,
);
} else {
throw err;
}
}
}
log('Runtime selected: sdk');
await runSdkSession(containerInput, prompt);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Runner error: ${errorMessage}`);
writeOutput({
status: 'error',
result: null,
error: errorMessage,
});
}
}
main();

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,159 @@
---
name: agent-browser
description: Browse the web for any task — research topics, read articles, interact with web apps, fill forms, take screenshots, extract data, and test web pages. Use whenever a browser would be useful, not just when the user explicitly asks.
allowed-tools: Bash(agent-browser:*)
---
# Browser Automation with agent-browser
## Quick start
```bash
agent-browser open <url> # Navigate to page
agent-browser snapshot -i # Get interactive elements with refs
agent-browser click @e1 # Click element by ref
agent-browser fill @e2 "text" # Fill input by ref
agent-browser close # Close browser
```
## Core workflow
1. Navigate: `agent-browser open <url>`
2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`)
3. Interact using refs from the snapshot
4. Re-snapshot after navigation or significant DOM changes
## Commands
### Navigation
```bash
agent-browser open <url> # Navigate to URL
agent-browser back # Go back
agent-browser forward # Go forward
agent-browser reload # Reload page
agent-browser close # Close browser
```
### Snapshot (page analysis)
```bash
agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3
agent-browser snapshot -s "#main" # Scope to CSS selector
```
### Interactions (use @refs from snapshot)
```bash
agent-browser click @e1 # Click
agent-browser dblclick @e1 # Double-click
agent-browser fill @e2 "text" # Clear and type
agent-browser type @e2 "text" # Type without clearing
agent-browser press Enter # Press key
agent-browser hover @e1 # Hover
agent-browser check @e1 # Check checkbox
agent-browser uncheck @e1 # Uncheck checkbox
agent-browser select @e1 "value" # Select dropdown option
agent-browser scroll down 500 # Scroll page
agent-browser upload @e1 file.pdf # Upload files
```
### Get information
```bash
agent-browser get text @e1 # Get element text
agent-browser get html @e1 # Get innerHTML
agent-browser get value @e1 # Get input value
agent-browser get attr @e1 href # Get attribute
agent-browser get title # Get page title
agent-browser get url # Get current URL
agent-browser get count ".item" # Count matching elements
```
### Screenshots & PDF
```bash
agent-browser screenshot # Save to temp directory
agent-browser screenshot path.png # Save to specific path
agent-browser screenshot --full # Full page
agent-browser pdf output.pdf # Save as PDF
```
### Wait
```bash
agent-browser wait @e1 # Wait for element
agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Success" # Wait for text
agent-browser wait --url "**/dashboard" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for network idle
```
### Semantic locators (alternative to refs)
```bash
agent-browser find role button click --name "Submit"
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find placeholder "Search" type "query"
```
### Authentication with saved state
```bash
# Login once
agent-browser open https://app.example.com/login
agent-browser snapshot -i
agent-browser fill @e1 "username"
agent-browser fill @e2 "password"
agent-browser click @e3
agent-browser wait --url "**/dashboard"
agent-browser state save auth.json
# Later: load saved state
agent-browser state load auth.json
agent-browser open https://app.example.com/dashboard
```
### Cookies & Storage
```bash
agent-browser cookies # Get all cookies
agent-browser cookies set name value # Set cookie
agent-browser cookies clear # Clear cookies
agent-browser storage local # Get localStorage
agent-browser storage local set k v # Set value
```
### JavaScript
```bash
agent-browser eval "document.title" # Run JavaScript
```
## Example: Form submission
```bash
agent-browser open https://example.com/form
agent-browser snapshot -i
# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3]
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser snapshot -i # Check result
```
## Example: Data extraction
```bash
agent-browser open https://example.com/products
agent-browser snapshot -i
agent-browser get text @e1 # Get product title
agent-browser get attr @e2 href # Get link URL
agent-browser screenshot products.png
```