No longer using Docker containers — agents run as direct host processes. The directory name now reflects the actual purpose. Updated all references across source code, docs, and skills.
785 lines
26 KiB
TypeScript
785 lines
26 KiB
TypeScript
/**
|
|
* NanoClaw Agent Runner
|
|
* Runs inside a container, receives config via stdin, outputs result to stdout
|
|
*
|
|
* Input protocol:
|
|
* Stdin: Full ContainerInput JSON (read until EOF, like before)
|
|
* IPC: Follow-up messages written as JSON files to /workspace/ipc/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 (defaults to container paths for backwards compat)
|
|
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
|
|
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
|
|
const GLOBAL_DIR = process.env.NANOCLAW_GLOBAL_DIR || '/workspace/global';
|
|
const EXTRA_BASE = process.env.NANOCLAW_EXTRA_DIR || '/workspace/extra';
|
|
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
|
|
const WORK_DIR = process.env.NANOCLAW_WORK_DIR || '';
|
|
|
|
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;
|
|
|
|
// Load global CLAUDE.md as additional system context (shared across all groups)
|
|
const globalClaudeMdPath = path.join(GLOBAL_DIR, 'CLAUDE.md');
|
|
let globalClaudeMd: string | undefined;
|
|
if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
|
|
globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
|
|
}
|
|
|
|
// Discover additional directories
|
|
const extraDirs: string[] = [];
|
|
|
|
// 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)`);
|
|
} else {
|
|
// Only scan EXTRA_BASE when no WORK_DIR override (legacy container mount support)
|
|
const extraBase = EXTRA_BASE;
|
|
if (extraBase && fs.existsSync(extraBase)) {
|
|
for (const entry of fs.readdirSync(extraBase)) {
|
|
const fullPath = path.join(extraBase, entry);
|
|
if (fs.statSync(fullPath).isDirectory()) {
|
|
extraDirs.push(fullPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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,
|
|
systemPrompt: globalClaudeMd
|
|
? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd }
|
|
: undefined,
|
|
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();
|