871 lines
28 KiB
TypeScript
871 lines
28 KiB
TypeScript
/**
|
|
* EJClaw 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 $EJCLAW_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 } from '@anthropic-ai/claude-agent-sdk';
|
|
import {
|
|
IPC_CLOSE_SENTINEL,
|
|
IPC_INPUT_SUBDIR,
|
|
IPC_POLL_MS,
|
|
} from 'ejclaw-runners-shared';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
import { resolveBundledClaudeCodeExecutable } from './bundled-cli-path.js';
|
|
import {
|
|
prependRoomRoleHeader,
|
|
type RoomRoleContext,
|
|
} from './room-role-context.js';
|
|
import {
|
|
assertReadonlyWorkspaceRepoConnectivity,
|
|
buildClaudeReadonlySandboxSettings,
|
|
buildReviewerGitGuardEnv,
|
|
getClaudeReadonlySandboxMode,
|
|
isArbiterRuntimeEnvEnabled,
|
|
isClaudeReadonlyReviewerRuntime,
|
|
isReviewerRuntime,
|
|
isReviewerRuntimeEnvEnabled,
|
|
} from './reviewer-runtime.js';
|
|
import { drainIpcInput, shouldClose } from './ipc-input.js';
|
|
import {
|
|
MessageStream,
|
|
buildMultimodalContent,
|
|
extractAssistantText,
|
|
normalizeStructuredOutput,
|
|
readStdin,
|
|
writeOutput,
|
|
} from './output-protocol.js';
|
|
import {
|
|
TopLevelAgentTaskTracker,
|
|
buildTaskNotificationOutput,
|
|
buildTaskProgressOutput,
|
|
buildTaskStartedOutput,
|
|
} from './task-progress-mapping.js';
|
|
import {
|
|
createPreCompactHook,
|
|
createReviewerBashGuardHook,
|
|
createSanitizeBashHook,
|
|
} from './runner-hooks.js';
|
|
import { buildClaudeSdkEnv } from './sdk-env.js';
|
|
import { buildEjclawMcpServerConfig } from './mcp-config.js';
|
|
|
|
interface RunnerInput {
|
|
prompt: string;
|
|
sessionId?: string;
|
|
groupFolder: string;
|
|
chatJid: string;
|
|
isMain: boolean;
|
|
isScheduledTask?: boolean;
|
|
assistantName?: string;
|
|
secrets?: Record<string, string>;
|
|
roomRoleContext?: RoomRoleContext;
|
|
}
|
|
|
|
// Paths configurable via env vars.
|
|
const GROUP_DIR = process.env.EJCLAW_GROUP_DIR || '/workspace/group';
|
|
const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc';
|
|
const HOST_IPC_DIR = process.env.EJCLAW_HOST_IPC_DIR || IPC_DIR;
|
|
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
|
|
const WORK_DIR = process.env.EJCLAW_WORK_DIR || '';
|
|
const GROUP_FOLDER = process.env.EJCLAW_GROUP_FOLDER || '';
|
|
|
|
const IPC_INPUT_DIR = path.join(IPC_DIR, IPC_INPUT_SUBDIR);
|
|
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, IPC_CLOSE_SENTINEL);
|
|
const HOST_TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
|
|
|
|
function log(message: string): void {
|
|
console.error(`[agent-runner] ${message}`);
|
|
}
|
|
|
|
// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함.
|
|
// (SDK 0.2.114의 W7() 헬퍼는 linux-x64-musl를 linux-x64보다 먼저 시도하므로
|
|
// glibc 호스트에서 musl 패키지가 빈 껍데기로 설치돼 있으면 실패한다.)
|
|
let cachedClaudeCliPath: string | null = null;
|
|
function getClaudeCliPath(): string {
|
|
if (cachedClaudeCliPath) return cachedClaudeCliPath;
|
|
cachedClaudeCliPath = resolveBundledClaudeCodeExecutable();
|
|
log(`Resolved bundled Claude Code CLI: ${cachedClaudeCliPath}`);
|
|
return cachedClaudeCliPath;
|
|
}
|
|
|
|
// Graceful shutdown: SIGTERM → abort SDK query, allowing cleanup
|
|
const agentAbortController = new AbortController();
|
|
process.on('SIGTERM', () => {
|
|
log('Received SIGTERM, aborting agent query...');
|
|
agentAbortController.abort();
|
|
});
|
|
|
|
/**
|
|
* 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,
|
|
runnerInput: RunnerInput,
|
|
sdkEnv: Record<string, string | undefined>,
|
|
reviewerRuntime: boolean,
|
|
claudeReadonlyReviewerRuntime: boolean,
|
|
claudeReadonlySandboxMode: 'strict' | 'best-effort' | null,
|
|
): Promise<{
|
|
newSessionId?: string;
|
|
closedDuringQuery: boolean;
|
|
terminalResultObserved: boolean;
|
|
}> {
|
|
const stream = new MessageStream((text) => buildMultimodalContent(text, log));
|
|
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(IPC_INPUT_CLOSE_SENTINEL)) {
|
|
log('Close sentinel detected during query, ending stream');
|
|
// Flush any buffered text before closing — the for-await loop may not
|
|
// reach the post-loop flush code after stream.end().
|
|
if (pendingProgressText && !terminalResultObserved) {
|
|
log(
|
|
`Flushing pending text before close (${pendingProgressText.length} chars)`,
|
|
);
|
|
writeOutput({
|
|
status: 'success',
|
|
...normalizeStructuredOutput(pendingProgressText),
|
|
newSessionId,
|
|
});
|
|
pendingProgressText = null;
|
|
terminalResultObserved = true;
|
|
resultCount++;
|
|
}
|
|
closedDuringQuery = true;
|
|
stream.end();
|
|
ipcPolling = false;
|
|
return;
|
|
}
|
|
const messages = drainIpcInput(IPC_INPUT_DIR, log);
|
|
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 messageCount = 0;
|
|
let resultCount = 0;
|
|
let terminalResultObserved = false;
|
|
let pendingProgressText: string | null = null;
|
|
const trackedAgentTasks = new TopLevelAgentTaskTracker();
|
|
|
|
// 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}`);
|
|
if (reviewerRuntime) log('Reviewer runtime restrictions enabled');
|
|
if (claudeReadonlyReviewerRuntime) {
|
|
log(
|
|
`Claude host reviewer read-only sandbox enabled (${claudeReadonlySandboxMode || 'unknown'})`,
|
|
);
|
|
}
|
|
if (claudeReadonlySandboxMode === 'best-effort') {
|
|
log(
|
|
'Claude host reviewer sandbox capability unavailable on this host, using best-effort read-only mode',
|
|
);
|
|
}
|
|
|
|
const readonlyReviewerRuntime =
|
|
reviewerRuntime || claudeReadonlyReviewerRuntime;
|
|
const allowedTools = readonlyReviewerRuntime
|
|
? [
|
|
'Bash',
|
|
'Read',
|
|
'Glob',
|
|
'Grep',
|
|
'WebSearch',
|
|
'WebFetch',
|
|
'Task',
|
|
'TaskOutput',
|
|
'TaskStop',
|
|
'TeamCreate',
|
|
'TeamDelete',
|
|
'SendMessage',
|
|
'TodoWrite',
|
|
'ToolSearch',
|
|
'Skill',
|
|
'mcp__ejclaw__*',
|
|
]
|
|
: [
|
|
'Bash',
|
|
'Read',
|
|
'Write',
|
|
'Edit',
|
|
'Glob',
|
|
'Grep',
|
|
'WebSearch',
|
|
'WebFetch',
|
|
'Task',
|
|
'TaskOutput',
|
|
'TaskStop',
|
|
'TeamCreate',
|
|
'TeamDelete',
|
|
'SendMessage',
|
|
'TodoWrite',
|
|
'ToolSearch',
|
|
'Skill',
|
|
'NotebookEdit',
|
|
'mcp__ejclaw__*',
|
|
];
|
|
|
|
const readonlyProtectedPaths = [effectiveCwd, ...extraDirs].filter(
|
|
(value): value is string => Boolean(value),
|
|
);
|
|
const claudeReadonlySandboxSettings =
|
|
claudeReadonlyReviewerRuntime && claudeReadonlySandboxMode
|
|
? buildClaudeReadonlySandboxSettings(
|
|
readonlyProtectedPaths,
|
|
undefined,
|
|
claudeReadonlySandboxMode,
|
|
)
|
|
: undefined;
|
|
|
|
for await (const message of query({
|
|
prompt: stream,
|
|
options: {
|
|
// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함.
|
|
pathToClaudeCodeExecutable: getClaudeCliPath(),
|
|
cwd: effectiveCwd,
|
|
model,
|
|
thinking,
|
|
effort,
|
|
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
|
resume: sessionId,
|
|
allowedTools,
|
|
env: sdkEnv,
|
|
permissionMode: 'bypassPermissions',
|
|
allowDangerouslySkipPermissions: true,
|
|
...(claudeReadonlySandboxSettings
|
|
? {
|
|
sandbox: claudeReadonlySandboxSettings,
|
|
}
|
|
: {}),
|
|
settingSources: ['project', 'user'],
|
|
abortController: agentAbortController,
|
|
mcpServers: {
|
|
ejclaw: buildEjclawMcpServerConfig(mcpServerPath, {
|
|
chatJid: runnerInput.chatJid,
|
|
groupFolder: runnerInput.groupFolder,
|
|
isMain: runnerInput.isMain,
|
|
agentType: process.env.EJCLAW_AGENT_TYPE || 'claude-code',
|
|
roomRole: runnerInput.roomRoleContext?.role || '',
|
|
ipcDir: process.env.EJCLAW_IPC_DIR,
|
|
hostIpcDir: process.env.EJCLAW_HOST_IPC_DIR,
|
|
}),
|
|
},
|
|
hooks: {
|
|
PreCompact: [
|
|
{
|
|
hooks: [
|
|
createPreCompactHook({
|
|
assistantName: runnerInput.assistantName,
|
|
groupDir: GROUP_DIR,
|
|
groupFolder: GROUP_FOLDER,
|
|
hostTasksDir: HOST_TASKS_DIR,
|
|
log,
|
|
writeOutput,
|
|
}),
|
|
],
|
|
},
|
|
],
|
|
PreToolUse: [
|
|
{
|
|
matcher: 'Bash',
|
|
hooks: readonlyReviewerRuntime
|
|
? [createReviewerBashGuardHook(), createSanitizeBashHook()]
|
|
: [createSanitizeBashHook()],
|
|
},
|
|
],
|
|
},
|
|
agentProgressSummaries: true,
|
|
},
|
|
})) {
|
|
messageCount++;
|
|
const msgType =
|
|
message.type === 'system'
|
|
? `system/${(message as { subtype?: string }).subtype}`
|
|
: message.type;
|
|
log(`[msg #${messageCount}] type=${msgType}`);
|
|
|
|
// Flush pending intermediate text as a regular message on non-assistant events.
|
|
if (message.type !== 'assistant' && pendingProgressText) {
|
|
writeOutput({
|
|
status: 'success',
|
|
phase: 'intermediate',
|
|
...normalizeStructuredOutput(pendingProgressText),
|
|
newSessionId,
|
|
});
|
|
pendingProgressText = null;
|
|
}
|
|
|
|
if (message.type === 'system' && message.subtype === 'init') {
|
|
newSessionId = message.session_id;
|
|
log(`Session initialized: ${newSessionId}`);
|
|
}
|
|
|
|
if (
|
|
message.type === 'system' &&
|
|
(message as { subtype?: string }).subtype === 'compact_boundary'
|
|
) {
|
|
const meta = (
|
|
message as {
|
|
compact_metadata?: { trigger?: string; pre_tokens?: number };
|
|
}
|
|
).compact_metadata;
|
|
log(
|
|
`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
message.type === 'system' &&
|
|
(message as { subtype?: string }).subtype === 'task_notification'
|
|
) {
|
|
const tn = message as {
|
|
task_id: string;
|
|
tool_use_id?: string;
|
|
status: string;
|
|
summary: string;
|
|
};
|
|
log(
|
|
`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`,
|
|
);
|
|
const mapped = buildTaskNotificationOutput(
|
|
trackedAgentTasks,
|
|
tn,
|
|
newSessionId,
|
|
);
|
|
if (mapped) {
|
|
writeOutput(mapped);
|
|
}
|
|
}
|
|
|
|
if (
|
|
message.type === 'system' &&
|
|
(message as { subtype?: string }).subtype === 'task_progress'
|
|
) {
|
|
const tp = message as Record<string, unknown>;
|
|
const description =
|
|
typeof tp.description === 'string' ? tp.description : '';
|
|
const mapped = buildTaskProgressOutput(
|
|
trackedAgentTasks,
|
|
tp,
|
|
newSessionId,
|
|
);
|
|
if (mapped) {
|
|
writeOutput(mapped);
|
|
} else if (description) {
|
|
// Long AI summary → skip (too long for progress sub-line)
|
|
log(
|
|
`Skipping long task_progress description (${description.length} chars)`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
message.type === 'system' &&
|
|
(message as { subtype?: string }).subtype === 'task_started'
|
|
) {
|
|
const ts = message as { task_id: string; description?: string };
|
|
const desc = ts.description || '';
|
|
log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`);
|
|
const mapped = buildTaskStartedOutput(
|
|
trackedAgentTasks,
|
|
ts,
|
|
newSessionId,
|
|
);
|
|
if (mapped) {
|
|
writeOutput(mapped);
|
|
}
|
|
}
|
|
|
|
if (message.type === 'tool_progress') {
|
|
const tp = message as {
|
|
tool_name: string;
|
|
elapsed_time_seconds: number;
|
|
};
|
|
const label = `${tp.tool_name} (${Math.round(tp.elapsed_time_seconds)}s)`;
|
|
log(`Tool progress: ${label}`);
|
|
const normalized = normalizeStructuredOutput(label);
|
|
writeOutput({
|
|
status: 'success',
|
|
phase: 'progress',
|
|
...normalized,
|
|
newSessionId,
|
|
});
|
|
}
|
|
|
|
if (message.type === 'tool_use_summary') {
|
|
const ts = message as { summary: string };
|
|
log(`Tool use summary: ${ts.summary.slice(0, 200)}`);
|
|
const normalized = normalizeStructuredOutput(ts.summary);
|
|
writeOutput({
|
|
status: 'success',
|
|
phase: 'progress',
|
|
...normalized,
|
|
newSessionId,
|
|
});
|
|
}
|
|
|
|
if (message.type === 'result') {
|
|
resultCount++;
|
|
let textResult =
|
|
'result' in message ? (message as { result?: string }).result : null;
|
|
const isError = message.subtype?.startsWith('error');
|
|
// Discard pending progress if it matches the final result (prevent duplicate)
|
|
if (
|
|
pendingProgressText &&
|
|
textResult &&
|
|
pendingProgressText === textResult
|
|
) {
|
|
log(`Discarding pending progress (matches result)`);
|
|
pendingProgressText = null;
|
|
} else if (pendingProgressText) {
|
|
// If the result has no text, promote pending progress to the result
|
|
// so it gets delivered as the final output instead of being lost.
|
|
if (!textResult) {
|
|
log(
|
|
`Promoting pending progress text to result (${pendingProgressText.length} chars)`,
|
|
);
|
|
textResult = pendingProgressText;
|
|
} else {
|
|
writeOutput({
|
|
status: 'success',
|
|
phase: 'intermediate',
|
|
result: pendingProgressText,
|
|
newSessionId,
|
|
});
|
|
}
|
|
pendingProgressText = null;
|
|
}
|
|
log(
|
|
`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`,
|
|
);
|
|
if (isError) {
|
|
// Log full error details for debugging
|
|
const msg = message as Record<string, unknown>;
|
|
const sdkErrors = Array.isArray(msg.errors)
|
|
? (msg.errors as string[])
|
|
: [];
|
|
const errorDetail = JSON.stringify({
|
|
subtype: message.subtype,
|
|
result: textResult?.slice(0, 500),
|
|
errors: sdkErrors,
|
|
stop_reason: msg.stop_reason,
|
|
duration_ms: msg.duration_ms,
|
|
duration_api_ms: msg.duration_api_ms,
|
|
session_id: msg.session_id,
|
|
});
|
|
log(`Error result detail: ${errorDetail}`);
|
|
// Pass SDK errors through so host can detect session issues
|
|
const errorText =
|
|
sdkErrors.length > 0 ? sdkErrors.join('; ') : undefined;
|
|
writeOutput({
|
|
status: 'error',
|
|
result: textResult || null,
|
|
newSessionId,
|
|
error: errorText || `Agent error: ${message.subtype}`,
|
|
});
|
|
} else {
|
|
const normalized = normalizeStructuredOutput(textResult || null);
|
|
writeOutput({
|
|
status: 'success',
|
|
...normalized,
|
|
newSessionId,
|
|
});
|
|
}
|
|
|
|
// Single-turn runtimes must terminate the query after the first
|
|
// terminal result. Leaving the message stream open can keep the SDK
|
|
// query alive indefinitely, which pins the host queue after a reply.
|
|
terminalResultObserved = true;
|
|
ipcPolling = false;
|
|
stream.end();
|
|
log('Terminal result observed, ending query stream');
|
|
break;
|
|
}
|
|
|
|
if (message.type === 'assistant') {
|
|
trackedAgentTasks.rememberAssistantMessage(message);
|
|
const stopReason = (message as { stop_reason?: string }).stop_reason;
|
|
const textResult = extractAssistantText(message);
|
|
// Only log when there's something interesting (text or terminal)
|
|
if (textResult || stopReason === 'end_turn') {
|
|
log(
|
|
`Assistant: stop=${stopReason} text=${textResult ? textResult.length + ' chars' : 'null'}`,
|
|
);
|
|
}
|
|
if (stopReason === 'end_turn' && textResult) {
|
|
resultCount++;
|
|
log(
|
|
`Terminal assistant turn observed without result event (${textResult.length} chars), ending query stream`,
|
|
);
|
|
writeOutput({
|
|
status: 'success',
|
|
...normalizeStructuredOutput(textResult),
|
|
newSessionId,
|
|
});
|
|
terminalResultObserved = true;
|
|
ipcPolling = false;
|
|
stream.end();
|
|
break;
|
|
}
|
|
// Intermediate assistant text between tool calls → buffer as pending progress.
|
|
// Don't emit immediately — if the next message is a result with the same text,
|
|
// this would cause a duplicate. The pending text is flushed when the next
|
|
// non-result message arrives, or discarded if result matches.
|
|
if (stopReason !== 'end_turn' && textResult) {
|
|
// Flush previous pending as a regular message (not progress heading)
|
|
if (pendingProgressText) {
|
|
const normalized = normalizeStructuredOutput(pendingProgressText);
|
|
writeOutput({
|
|
status: 'success',
|
|
phase: 'intermediate',
|
|
...normalized,
|
|
newSessionId,
|
|
});
|
|
}
|
|
pendingProgressText = textResult;
|
|
log(
|
|
`Intermediate assistant text buffered (${textResult.length} chars, stop=${stopReason})`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flush any remaining buffered text that was never followed by a result event.
|
|
// This happens when the agent produces a short response without a formal end_turn.
|
|
if (pendingProgressText && !terminalResultObserved) {
|
|
log(
|
|
`Flushing remaining pending progress text as final output (${pendingProgressText.length} chars)`,
|
|
);
|
|
writeOutput({
|
|
status: 'success',
|
|
...normalizeStructuredOutput(pendingProgressText),
|
|
newSessionId,
|
|
});
|
|
terminalResultObserved = true;
|
|
resultCount++;
|
|
}
|
|
|
|
ipcPolling = false;
|
|
log(
|
|
`Query done. Messages: ${messageCount}, results: ${resultCount}, closedDuringQuery: ${closedDuringQuery}`,
|
|
);
|
|
return { newSessionId, closedDuringQuery, terminalResultObserved };
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
let runnerInput: RunnerInput;
|
|
|
|
try {
|
|
const stdinData = await readStdin();
|
|
runnerInput = 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: ${runnerInput.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 = buildClaudeSdkEnv(process.env, runnerInput.secrets || {});
|
|
const reviewerRuntime =
|
|
isReviewerRuntimeEnvEnabled(process.env) ||
|
|
isReviewerRuntime(runnerInput.roomRoleContext);
|
|
const claudeReadonlyReviewerRuntime = isClaudeReadonlyReviewerRuntime(
|
|
runnerInput.roomRoleContext,
|
|
);
|
|
const claudeReadonlySandboxMode = claudeReadonlyReviewerRuntime
|
|
? getClaudeReadonlySandboxMode()
|
|
: null;
|
|
const readonlyRuntime =
|
|
reviewerRuntime ||
|
|
claudeReadonlyReviewerRuntime ||
|
|
isArbiterRuntimeEnvEnabled(process.env);
|
|
const guardedSdkEnv = buildReviewerGitGuardEnv(
|
|
sdkEnv,
|
|
reviewerRuntime || claudeReadonlyReviewerRuntime,
|
|
);
|
|
assertReadonlyWorkspaceRepoConnectivity(guardedSdkEnv, readonlyRuntime);
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
|
|
|
let sessionId = runnerInput.sessionId;
|
|
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
|
|
|
// Clean up stale _close sentinel from previous runner sessions
|
|
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 = runnerInput.prompt;
|
|
if (runnerInput.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(IPC_INPUT_DIR, log);
|
|
if (pending.length > 0) {
|
|
log(`Draining ${pending.length} pending IPC messages into initial prompt`);
|
|
prompt += '\n' + pending.join('\n');
|
|
}
|
|
// --- Slash command handling ---
|
|
// Check BEFORE prepending room role header so /compact isn't masked.
|
|
const KNOWN_SESSION_COMMANDS = new Set(['/compact']);
|
|
const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(
|
|
runnerInput.prompt.trim(),
|
|
);
|
|
|
|
if (!isSessionSlashCommand) {
|
|
prompt = prependRoomRoleHeader(prompt, runnerInput.roomRoleContext);
|
|
}
|
|
const trimmedPrompt = prompt.trim();
|
|
|
|
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: {
|
|
// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함.
|
|
pathToClaudeCodeExecutable: getClaudeCliPath(),
|
|
cwd: mainEffectiveCwd,
|
|
resume: sessionId,
|
|
systemPrompt: undefined,
|
|
allowedTools: [],
|
|
env: guardedSdkEnv,
|
|
permissionMode: 'bypassPermissions' as const,
|
|
allowDangerouslySkipPermissions: true,
|
|
settingSources: ['project', 'user'] as const,
|
|
abortController: agentAbortController,
|
|
hooks: {
|
|
PreCompact: [
|
|
{
|
|
hooks: [
|
|
createPreCompactHook({
|
|
assistantName: runnerInput.assistantName,
|
|
groupDir: GROUP_DIR,
|
|
groupFolder: GROUP_FOLDER,
|
|
hostTasksDir: HOST_TASKS_DIR,
|
|
log,
|
|
writeOutput,
|
|
}),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})) {
|
|
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;
|
|
const meta = (
|
|
message as {
|
|
compact_metadata?: { trigger?: string; pre_tokens?: number };
|
|
}
|
|
).compact_metadata;
|
|
log(
|
|
`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`,
|
|
);
|
|
}
|
|
|
|
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 ---
|
|
|
|
try {
|
|
log(`Starting query (session: ${sessionId || 'new'})...`);
|
|
|
|
const queryResult = await runQuery(
|
|
prompt,
|
|
sessionId,
|
|
mcpServerPath,
|
|
runnerInput,
|
|
guardedSdkEnv,
|
|
reviewerRuntime,
|
|
claudeReadonlyReviewerRuntime,
|
|
claudeReadonlySandboxMode,
|
|
);
|
|
if (queryResult.newSessionId) {
|
|
sessionId = queryResult.newSessionId;
|
|
}
|
|
|
|
if (!queryResult.closedDuringQuery && !queryResult.terminalResultObserved) {
|
|
writeOutput({ status: 'success', result: null, newSessionId: sessionId });
|
|
} else if (queryResult.terminalResultObserved) {
|
|
log('Terminal result already emitted, exiting single-turn runtime');
|
|
} else {
|
|
log('Close sentinel consumed during query, exiting');
|
|
}
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
const errorStack = err instanceof Error ? err.stack : undefined;
|
|
const errorCause =
|
|
err instanceof Error && err.cause ? String(err.cause) : undefined;
|
|
log(`Agent error: ${errorMessage}`);
|
|
if (errorStack) log(`Stack: ${errorStack}`);
|
|
if (errorCause) log(`Cause: ${errorCause}`);
|
|
writeOutput({
|
|
status: 'error',
|
|
result: null,
|
|
newSessionId: sessionId,
|
|
error: errorMessage,
|
|
});
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|