refactor: remove legacy container and non-discord remnants
This commit is contained in:
1524
runners/agent-runner/package-lock.json
generated
Normal file
1524
runners/agent-runner/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
runners/agent-runner/package.json
Normal file
21
runners/agent-runner/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
761
runners/agent-runner/src/index.ts
Normal file
761
runners/agent-runner/src/index.ts
Normal 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();
|
||||
338
runners/agent-runner/src/ipc-mcp-stdio.ts
Normal file
338
runners/agent-runner/src/ipc-mcp-stdio.ts
Normal 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);
|
||||
15
runners/agent-runner/tsconfig.json
Normal file
15
runners/agent-runner/tsconfig.json
Normal 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
184
runners/codex-runner/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
runners/codex-runner/package.json
Normal file
18
runners/codex-runner/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
444
runners/codex-runner/src/app-server-client.ts
Normal file
444
runners/codex-runner/src/app-server-client.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
174
runners/codex-runner/src/app-server-state.ts
Normal file
174
runners/codex-runner/src/app-server-state.ts
Normal 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;
|
||||
}
|
||||
631
runners/codex-runner/src/index.ts
Normal file
631
runners/codex-runner/src/index.ts
Normal 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();
|
||||
15
runners/codex-runner/tsconfig.json
Normal file
15
runners/codex-runner/tsconfig.json
Normal 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"]
|
||||
}
|
||||
159
runners/skills/agent-browser/SKILL.md
Normal file
159
runners/skills/agent-browser/SKILL.md
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user