build: unify bun quality gate

This commit is contained in:
ejclaw
2026-04-11 04:47:16 +09:00
parent f10833e818
commit 94d53e4cc3
29 changed files with 574 additions and 297 deletions

View File

@@ -12,14 +12,10 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: npm - uses: oven-sh/setup-bun@v2
- run: npm ci with:
bun-version: 1.3.11
- run: bun install --frozen-lockfile
- name: Format check - name: Check
run: npm run format:check run: bun run check
- name: Typecheck
run: npx tsc --noEmit
- name: Tests
run: npx vitest run

View File

@@ -1 +1 @@
npm run format:fix bun run format:fix

View File

@@ -7,15 +7,19 @@
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"build:runners": "bun install --cwd runners/agent-runner && bun run --cwd runners/agent-runner build && bun install --cwd runners/codex-runner && bun run --cwd runners/codex-runner build", "install:runners": "bun install --frozen-lockfile --cwd runners/shared && bun install --frozen-lockfile --cwd runners/agent-runner && bun install --frozen-lockfile --cwd runners/codex-runner",
"build:runtime": "bun run build && bun run build:runners", "build:runners": "bun run install:runners && bun run --cwd runners/shared build && bun run --cwd runners/agent-runner build && bun run --cwd runners/codex-runner build",
"deploy": "git pull --ff-only && bun run build:runtime && systemctl --user restart ejclaw", "build:all": "bun run build && bun run build:runners",
"build:runtime": "bun run build:all",
"deploy": "git pull --ff-only && bun run build:all && systemctl --user restart ejclaw",
"start": "bun dist/index.js", "start": "bun dist/index.js",
"dev": "bun --watch src/index.ts", "dev": "bun --watch src/index.ts",
"test": "vitest run", "test": "vitest run",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"format:fix": "prettier --write \"src/**/*.ts\"", "typecheck:all": "bun run install:runners && bunx tsc --noEmit -p tsconfig.check.json && bunx tsc --noEmit -p runners/shared/tsconfig.json && bunx tsc --noEmit -p runners/agent-runner/tsconfig.json && bunx tsc --noEmit -p runners/codex-runner/tsconfig.json",
"format:check": "prettier --check \"src/**/*.ts\"", "format:fix": "prettier --write \"{src,setup,test,scripts}/**/*.{ts,js}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
"format:check": "prettier --check \"{src,setup,test,scripts}/**/*.{ts,js}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
"check": "bun run format:check && bun run typecheck:all && bun run test && bun run build:all",
"prepare": "husky", "prepare": "husky",
"setup": "bun setup/index.ts" "setup": "bun setup/index.ts"
}, },

View File

@@ -58,9 +58,7 @@ export async function waitForHostEvidenceResponse(
await new Promise((resolve) => setTimeout(resolve, pollMs)); await new Promise((resolve) => setTimeout(resolve, pollMs));
} }
throw new Error( throw new Error(`Timed out waiting for host evidence response: ${requestId}`);
`Timed out waiting for host evidence response: ${requestId}`,
);
} }
export function formatHostEvidenceResponse( export function formatHostEvidenceResponse(

View File

@@ -16,7 +16,12 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; import {
query,
HookCallback,
PreCompactHookInput,
PreToolUseHookInput,
} from '@anthropic-ai/claude-agent-sdk';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { import {
@@ -76,7 +81,14 @@ interface SessionsIndex {
type ContentBlock = type ContentBlock =
| { type: 'text'; text: string } | { type: 'text'; text: string }
| { type: 'image'; source: { type: 'base64'; media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; data: string } }; | {
type: 'image';
source: {
type: 'base64';
media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
data: string;
};
};
interface SDKUserMessage { interface SDKUserMessage {
type: 'user'; type: 'user';
@@ -106,8 +118,11 @@ const HOST_TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
/** SSOT: src/agent-protocol.ts — keep in sync */ /** SSOT: src/agent-protocol.ts — keep in sync */
const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g; const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
const MIME_TYPES: Record<string, string> = { const MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg',
'.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
}; };
/** /**
@@ -138,11 +153,20 @@ function buildMultimodalContent(text: string): string | ContentBlock[] {
} }
const data = fs.readFileSync(imgPath).toString('base64'); const data = fs.readFileSync(imgPath).toString('base64');
const ext = path.extname(imgPath).toLowerCase(); const ext = path.extname(imgPath).toLowerCase();
const mediaType = (MIME_TYPES[ext] || 'image/png') as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; const mediaType = (MIME_TYPES[ext] || 'image/png') as
blocks.push({ type: 'image', source: { type: 'base64', media_type: mediaType, data } }); | 'image/jpeg'
| 'image/png'
| 'image/gif'
| 'image/webp';
blocks.push({
type: 'image',
source: { type: 'base64', media_type: mediaType, data },
});
log(`Added image block: ${imgPath} (${mediaType})`); log(`Added image block: ${imgPath} (${mediaType})`);
} catch (err) { } catch (err) {
log(`Failed to read image ${imgPath}: ${err instanceof Error ? err.message : String(err)}`); log(
`Failed to read image ${imgPath}: ${err instanceof Error ? err.message : String(err)}`,
);
} }
} }
@@ -180,7 +204,9 @@ class MessageStream {
yield this.queue.shift()!; yield this.queue.shift()!;
} }
if (this.done) return; if (this.done) return;
await new Promise<void>(r => { this.waiting = r; }); await new Promise<void>((r) => {
this.waiting = r;
});
this.waiting = null; this.waiting = null;
} }
} }
@@ -190,7 +216,9 @@ async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let data = ''; let data = '';
process.stdin.setEncoding('utf8'); process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { data += chunk; }); process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => resolve(data)); process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject); process.stdin.on('error', reject);
}); });
@@ -246,7 +274,10 @@ function extractAssistantText(message: unknown): string | null {
return text || null; return text || null;
} }
function getSessionSummary(sessionId: string, transcriptPath: string): string | null { function getSessionSummary(
sessionId: string,
transcriptPath: string,
): string | null {
const projectDir = path.dirname(transcriptPath); const projectDir = path.dirname(transcriptPath);
const indexPath = path.join(projectDir, 'sessions-index.json'); const indexPath = path.join(projectDir, 'sessions-index.json');
@@ -256,13 +287,17 @@ function getSessionSummary(sessionId: string, transcriptPath: string): string |
} }
try { try {
const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); const index: SessionsIndex = JSON.parse(
const entry = index.entries.find(e => e.sessionId === sessionId); fs.readFileSync(indexPath, 'utf-8'),
);
const entry = index.entries.find((e) => e.sessionId === sessionId);
if (entry?.summary) { if (entry?.summary) {
return entry.summary; return entry.summary;
} }
} catch (err) { } catch (err) {
log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`); log(
`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`,
);
} }
return null; return null;
@@ -283,7 +318,10 @@ function writeHostTaskIpcFile(data: object): string {
return filename; return filename;
} }
async function persistCompactMemory(summary: string, sessionId: string): Promise<void> { async function persistCompactMemory(
summary: string,
sessionId: string,
): Promise<void> {
const normalized = summary.trim(); const normalized = summary.trim();
if (!normalized || !GROUP_FOLDER) return; if (!normalized || !GROUP_FOLDER) return;
@@ -359,7 +397,11 @@ function createPreCompactHook(assistantName?: string): HookCallback {
const filename = `${date}-${name}.md`; const filename = `${date}-${name}.md`;
const filePath = path.join(conversationsDir, filename); const filePath = path.join(conversationsDir, filename);
const markdown = formatTranscriptMarkdown(messages, summary, assistantName); const markdown = formatTranscriptMarkdown(
messages,
summary,
assistantName,
);
fs.writeFileSync(filePath, markdown); fs.writeFileSync(filePath, markdown);
log(`Archived conversation to ${filePath}`); log(`Archived conversation to ${filePath}`);
@@ -368,7 +410,9 @@ function createPreCompactHook(assistantName?: string): HookCallback {
await persistCompactMemory(summary, sessionId); await persistCompactMemory(summary, sessionId);
} }
} catch (err) { } catch (err) {
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); log(
`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`,
);
} }
return {}; return {};
@@ -444,9 +488,12 @@ function parseTranscript(content: string): ParsedMessage[] {
try { try {
const entry = JSON.parse(line); const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.content) { if (entry.type === 'user' && entry.message?.content) {
const text = typeof entry.message.content === 'string' const text =
? entry.message.content typeof entry.message.content === 'string'
: entry.message.content.map((c: { text?: string }) => c.text || '').join(''); ? entry.message.content
: entry.message.content
.map((c: { text?: string }) => c.text || '')
.join('');
if (text) messages.push({ role: 'user', content: text }); if (text) messages.push({ role: 'user', content: text });
} else if (entry.type === 'assistant' && entry.message?.content) { } else if (entry.type === 'assistant' && entry.message?.content) {
const textParts = entry.message.content const textParts = entry.message.content
@@ -455,22 +502,26 @@ function parseTranscript(content: string): ParsedMessage[] {
const text = textParts.join(''); const text = textParts.join('');
if (text) messages.push({ role: 'assistant', content: text }); if (text) messages.push({ role: 'assistant', content: text });
} }
} catch { } catch {}
}
} }
return messages; return messages;
} }
function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string { function formatTranscriptMarkdown(
messages: ParsedMessage[],
title?: string | null,
assistantName?: string,
): string {
const now = new Date(); const now = new Date();
const formatDateTime = (d: Date) => d.toLocaleString('en-US', { const formatDateTime = (d: Date) =>
month: 'short', d.toLocaleString('en-US', {
day: 'numeric', month: 'short',
hour: 'numeric', day: 'numeric',
minute: '2-digit', hour: 'numeric',
hour12: true minute: '2-digit',
}); hour12: true,
});
const lines: string[] = []; const lines: string[] = [];
lines.push(`# ${title || 'Conversation'}`); lines.push(`# ${title || 'Conversation'}`);
@@ -481,10 +532,11 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu
lines.push(''); lines.push('');
for (const msg of messages) { for (const msg of messages) {
const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant'); const sender = msg.role === 'user' ? 'User' : assistantName || 'Assistant';
const content = msg.content.length > 2000 const content =
? msg.content.slice(0, 2000) + '...' msg.content.length > 2000
: msg.content; ? msg.content.slice(0, 2000) + '...'
: msg.content;
lines.push(`**${sender}**: ${content}`); lines.push(`**${sender}**: ${content}`);
lines.push(''); lines.push('');
} }
@@ -497,7 +549,11 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu
*/ */
function shouldClose(): boolean { function shouldClose(): boolean {
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
return true; return true;
} }
return false; return false;
@@ -510,8 +566,9 @@ function shouldClose(): boolean {
function drainIpcInput(): string[] { function drainIpcInput(): string[] {
try { try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
const files = fs.readdirSync(IPC_INPUT_DIR) const files = fs
.filter(f => f.endsWith('.json')) .readdirSync(IPC_INPUT_DIR)
.filter((f) => f.endsWith('.json'))
.sort(); .sort();
const messages: string[] = []; const messages: string[] = [];
@@ -524,8 +581,14 @@ function drainIpcInput(): string[] {
messages.push(data.text); messages.push(data.text);
} }
} catch (err) { } catch (err) {
log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`); log(
try { fs.unlinkSync(filePath); } catch { /* ignore */ } `Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`,
);
try {
fs.unlinkSync(filePath);
} catch {
/* ignore */
}
} }
} }
return messages; return messages;
@@ -568,7 +631,9 @@ async function runQuery(
// Flush any buffered text before closing — the for-await loop may not // Flush any buffered text before closing — the for-await loop may not
// reach the post-loop flush code after stream.end(). // reach the post-loop flush code after stream.end().
if (pendingProgressText && !terminalResultObserved) { if (pendingProgressText && !terminalResultObserved) {
log(`Flushing pending text before close (${pendingProgressText.length} chars)`); log(
`Flushing pending text before close (${pendingProgressText.length} chars)`,
);
writeOutput({ writeOutput({
status: 'success', status: 'success',
...normalizeStructuredOutput(pendingProgressText), ...normalizeStructuredOutput(pendingProgressText),
@@ -605,7 +670,9 @@ async function runQuery(
const effectiveCwd = WORK_DIR || GROUP_DIR; const effectiveCwd = WORK_DIR || GROUP_DIR;
if (WORK_DIR && WORK_DIR !== GROUP_DIR) { if (WORK_DIR && WORK_DIR !== GROUP_DIR) {
extraDirs.push(GROUP_DIR); extraDirs.push(GROUP_DIR);
log(`Work directory override: ${WORK_DIR} (group dir added to additionalDirectories)`); log(
`Work directory override: ${WORK_DIR} (group dir added to additionalDirectories)`,
);
} }
if (extraDirs.length > 0) { if (extraDirs.length > 0) {
log(`Additional directories: ${extraDirs.join(', ')}`); log(`Additional directories: ${extraDirs.join(', ')}`);
@@ -617,11 +684,17 @@ async function runQuery(
const thinkingBudget = process.env.CLAUDE_THINKING_BUDGET const thinkingBudget = process.env.CLAUDE_THINKING_BUDGET
? parseInt(process.env.CLAUDE_THINKING_BUDGET, 10) ? parseInt(process.env.CLAUDE_THINKING_BUDGET, 10)
: undefined; : undefined;
const effort = (process.env.CLAUDE_EFFORT as 'low' | 'medium' | 'high' | 'max') || undefined; const effort =
const thinking = thinkingType === 'adaptive' ? { type: 'adaptive' as const } (process.env.CLAUDE_EFFORT as 'low' | 'medium' | 'high' | 'max') ||
: thinkingType === 'enabled' ? { type: 'enabled' as const, budgetTokens: thinkingBudget } undefined;
: thinkingType === 'disabled' ? { type: 'disabled' as const } const thinking =
: undefined; 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 (model) log(`Using model: ${model}`);
if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`); if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`);
@@ -643,22 +716,42 @@ async function runQuery(
const allowedTools = readonlyReviewerRuntime const allowedTools = readonlyReviewerRuntime
? [ ? [
'Bash', 'Bash',
'Read', 'Glob', 'Grep', 'Read',
'WebSearch', 'WebFetch', 'Glob',
'Task', 'TaskOutput', 'TaskStop', 'Grep',
'TeamCreate', 'TeamDelete', 'SendMessage', 'WebSearch',
'TodoWrite', 'ToolSearch', 'Skill', 'WebFetch',
'Task',
'TaskOutput',
'TaskStop',
'TeamCreate',
'TeamDelete',
'SendMessage',
'TodoWrite',
'ToolSearch',
'Skill',
'mcp__ejclaw__*', 'mcp__ejclaw__*',
] ]
: [ : [
'Bash', 'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Read',
'WebSearch', 'WebFetch', 'Write',
'Task', 'TaskOutput', 'TaskStop', 'Edit',
'TeamCreate', 'TeamDelete', 'SendMessage', 'Glob',
'TodoWrite', 'ToolSearch', 'Skill', 'Grep',
'WebSearch',
'WebFetch',
'Task',
'TaskOutput',
'TaskStop',
'TeamCreate',
'TeamDelete',
'SendMessage',
'TodoWrite',
'ToolSearch',
'Skill',
'NotebookEdit', 'NotebookEdit',
'mcp__ejclaw__*' 'mcp__ejclaw__*',
]; ];
const readonlyProtectedPaths = [effectiveCwd, ...extraDirs].filter( const readonlyProtectedPaths = [effectiveCwd, ...extraDirs].filter(
@@ -701,8 +794,7 @@ async function runQuery(
EJCLAW_CHAT_JID: runnerInput.chatJid, EJCLAW_CHAT_JID: runnerInput.chatJid,
EJCLAW_GROUP_FOLDER: runnerInput.groupFolder, EJCLAW_GROUP_FOLDER: runnerInput.groupFolder,
EJCLAW_IS_MAIN: runnerInput.isMain ? '1' : '0', EJCLAW_IS_MAIN: runnerInput.isMain ? '1' : '0',
EJCLAW_AGENT_TYPE: EJCLAW_AGENT_TYPE: process.env.EJCLAW_AGENT_TYPE || 'claude-code',
process.env.EJCLAW_AGENT_TYPE || 'claude-code',
EJCLAW_ROOM_ROLE: runnerInput.roomRoleContext?.role || '', EJCLAW_ROOM_ROLE: runnerInput.roomRoleContext?.role || '',
...(process.env.EJCLAW_IPC_DIR && { ...(process.env.EJCLAW_IPC_DIR && {
EJCLAW_IPC_DIR: process.env.EJCLAW_IPC_DIR, EJCLAW_IPC_DIR: process.env.EJCLAW_IPC_DIR,
@@ -714,7 +806,9 @@ async function runQuery(
}, },
}, },
hooks: { hooks: {
PreCompact: [{ hooks: [createPreCompactHook(runnerInput.assistantName)] }], PreCompact: [
{ hooks: [createPreCompactHook(runnerInput.assistantName)] },
],
PreToolUse: [ PreToolUse: [
{ {
matcher: 'Bash', matcher: 'Bash',
@@ -725,10 +819,13 @@ async function runQuery(
], ],
}, },
agentProgressSummaries: true, agentProgressSummaries: true,
} },
})) { })) {
messageCount++; messageCount++;
const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type; const msgType =
message.type === 'system'
? `system/${(message as { subtype?: string }).subtype}`
: message.type;
log(`[msg #${messageCount}] type=${msgType}`); log(`[msg #${messageCount}] type=${msgType}`);
// Flush pending intermediate text as a regular message on non-assistant events. // Flush pending intermediate text as a regular message on non-assistant events.
@@ -747,15 +844,37 @@ async function runQuery(
log(`Session initialized: ${newSessionId}`); log(`Session initialized: ${newSessionId}`);
} }
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') { if (
const meta = (message as { compact_metadata?: { trigger?: string; pre_tokens?: number } }).compact_metadata; message.type === 'system' &&
log(`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`); (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') { if (
const tn = message as { task_id: string; status: string; summary: string }; message.type === 'system' &&
log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`); (message as { subtype?: string }).subtype === 'task_notification'
if (tn.status === 'completed' || tn.status === 'error' || tn.status === 'cancelled') { ) {
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 (
tn.status === 'completed' ||
tn.status === 'error' ||
tn.status === 'cancelled'
) {
writeOutput({ writeOutput({
status: 'success', status: 'success',
phase: 'progress', phase: 'progress',
@@ -767,11 +886,15 @@ async function runQuery(
} }
} }
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_progress') { if (
message.type === 'system' &&
(message as { subtype?: string }).subtype === 'task_progress'
) {
const tp = message as Record<string, unknown>; const tp = message as Record<string, unknown>;
const taskId = typeof tp.task_id === 'string' ? tp.task_id : undefined; const taskId = typeof tp.task_id === 'string' ? tp.task_id : undefined;
const summary = typeof tp.summary === 'string' ? tp.summary : ''; const summary = typeof tp.summary === 'string' ? tp.summary : '';
const description = typeof tp.description === 'string' ? tp.description : ''; const description =
typeof tp.description === 'string' ? tp.description : '';
if (description && description.length <= 80) { if (description && description.length <= 80) {
// Short tool description → show as sub-line in progress // Short tool description → show as sub-line in progress
const normalized = normalizeStructuredOutput(description); const normalized = normalizeStructuredOutput(description);
@@ -784,11 +907,16 @@ async function runQuery(
}); });
} else if (description) { } else if (description) {
// Long AI summary → skip (too long for progress sub-line) // Long AI summary → skip (too long for progress sub-line)
log(`Skipping long task_progress description (${description.length} chars)`); log(
`Skipping long task_progress description (${description.length} chars)`,
);
} }
} }
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_started') { if (
message.type === 'system' &&
(message as { subtype?: string }).subtype === 'task_started'
) {
const ts = message as { task_id: string; description?: string }; const ts = message as { task_id: string; description?: string };
const desc = ts.description || ''; const desc = ts.description || '';
log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`); log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`);
@@ -835,28 +963,44 @@ async function runQuery(
if (message.type === 'result') { if (message.type === 'result') {
resultCount++; resultCount++;
let textResult = 'result' in message ? (message as { result?: string }).result : null; let textResult =
'result' in message ? (message as { result?: string }).result : null;
const isError = message.subtype?.startsWith('error'); const isError = message.subtype?.startsWith('error');
// Discard pending progress if it matches the final result (prevent duplicate) // Discard pending progress if it matches the final result (prevent duplicate)
if (pendingProgressText && textResult && pendingProgressText === textResult) { if (
pendingProgressText &&
textResult &&
pendingProgressText === textResult
) {
log(`Discarding pending progress (matches result)`); log(`Discarding pending progress (matches result)`);
pendingProgressText = null; pendingProgressText = null;
} else if (pendingProgressText) { } else if (pendingProgressText) {
// If the result has no text, promote pending progress to the result // If the result has no text, promote pending progress to the result
// so it gets delivered as the final output instead of being lost. // so it gets delivered as the final output instead of being lost.
if (!textResult) { if (!textResult) {
log(`Promoting pending progress text to result (${pendingProgressText.length} chars)`); log(
`Promoting pending progress text to result (${pendingProgressText.length} chars)`,
);
textResult = pendingProgressText; textResult = pendingProgressText;
} else { } else {
writeOutput({ status: 'success', phase: 'intermediate', result: pendingProgressText, newSessionId }); writeOutput({
status: 'success',
phase: 'intermediate',
result: pendingProgressText,
newSessionId,
});
} }
pendingProgressText = null; pendingProgressText = null;
} }
log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`); log(
`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`,
);
if (isError) { if (isError) {
// Log full error details for debugging // Log full error details for debugging
const msg = message as Record<string, unknown>; const msg = message as Record<string, unknown>;
const sdkErrors = Array.isArray(msg.errors) ? msg.errors as string[] : []; const sdkErrors = Array.isArray(msg.errors)
? (msg.errors as string[])
: [];
const errorDetail = JSON.stringify({ const errorDetail = JSON.stringify({
subtype: message.subtype, subtype: message.subtype,
result: textResult?.slice(0, 500), result: textResult?.slice(0, 500),
@@ -868,7 +1012,8 @@ async function runQuery(
}); });
log(`Error result detail: ${errorDetail}`); log(`Error result detail: ${errorDetail}`);
// Pass SDK errors through so host can detect session issues // Pass SDK errors through so host can detect session issues
const errorText = sdkErrors.length > 0 ? sdkErrors.join('; ') : undefined; const errorText =
sdkErrors.length > 0 ? sdkErrors.join('; ') : undefined;
writeOutput({ writeOutput({
status: 'error', status: 'error',
result: textResult || null, result: textResult || null,
@@ -880,7 +1025,7 @@ async function runQuery(
writeOutput({ writeOutput({
status: 'success', status: 'success',
...normalized, ...normalized,
newSessionId newSessionId,
}); });
} }
@@ -899,7 +1044,9 @@ async function runQuery(
const textResult = extractAssistantText(message); const textResult = extractAssistantText(message);
// Only log when there's something interesting (text or terminal) // Only log when there's something interesting (text or terminal)
if (textResult || stopReason === 'end_turn') { if (textResult || stopReason === 'end_turn') {
log(`Assistant: stop=${stopReason} text=${textResult ? textResult.length + ' chars' : 'null'}`); log(
`Assistant: stop=${stopReason} text=${textResult ? textResult.length + ' chars' : 'null'}`,
);
} }
if (stopReason === 'end_turn' && textResult) { if (stopReason === 'end_turn' && textResult) {
resultCount++; resultCount++;
@@ -932,7 +1079,9 @@ async function runQuery(
}); });
} }
pendingProgressText = textResult; pendingProgressText = textResult;
log(`Intermediate assistant text buffered (${textResult.length} chars, stop=${stopReason})`); log(
`Intermediate assistant text buffered (${textResult.length} chars, stop=${stopReason})`,
);
} }
} }
} }
@@ -940,7 +1089,9 @@ async function runQuery(
// Flush any remaining buffered text that was never followed by a result event. // 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. // This happens when the agent produces a short response without a formal end_turn.
if (pendingProgressText && !terminalResultObserved) { if (pendingProgressText && !terminalResultObserved) {
log(`Flushing remaining pending progress text as final output (${pendingProgressText.length} chars)`); log(
`Flushing remaining pending progress text as final output (${pendingProgressText.length} chars)`,
);
writeOutput({ writeOutput({
status: 'success', status: 'success',
...normalizeStructuredOutput(pendingProgressText), ...normalizeStructuredOutput(pendingProgressText),
@@ -964,13 +1115,17 @@ async function main(): Promise<void> {
const stdinData = await readStdin(); const stdinData = await readStdin();
runnerInput = JSON.parse(stdinData); runnerInput = JSON.parse(stdinData);
// Delete the temp file the entrypoint wrote — it contains secrets // Delete the temp file the entrypoint wrote — it contains secrets
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ } try {
fs.unlinkSync('/tmp/input.json');
} catch {
/* may not exist */
}
log(`Received input for group: ${runnerInput.groupFolder}`); log(`Received input for group: ${runnerInput.groupFolder}`);
} catch (err) { } catch (err) {
writeOutput({ writeOutput({
status: 'error', status: 'error',
result: null, result: null,
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}` error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`,
}); });
process.exit(1); process.exit(1);
} }
@@ -984,8 +1139,9 @@ async function main(): Promise<void> {
const reviewerRuntime = const reviewerRuntime =
process.env.EJCLAW_REVIEWER_RUNTIME === '1' || process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
isReviewerRuntime(runnerInput.roomRoleContext); isReviewerRuntime(runnerInput.roomRoleContext);
const claudeReadonlyReviewerRuntime = const claudeReadonlyReviewerRuntime = isClaudeReadonlyReviewerRuntime(
isClaudeReadonlyReviewerRuntime(runnerInput.roomRoleContext); runnerInput.roomRoleContext,
);
const claudeReadonlySandboxMode = claudeReadonlyReviewerRuntime const claudeReadonlySandboxMode = claudeReadonlyReviewerRuntime
? getClaudeReadonlySandboxMode() ? getClaudeReadonlySandboxMode()
: null; : null;
@@ -1006,7 +1162,11 @@ async function main(): Promise<void> {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
// Clean up stale _close sentinel from previous runner sessions // Clean up stale _close sentinel from previous runner sessions
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
// Effective working directory (WORK_DIR overrides GROUP_DIR) // Effective working directory (WORK_DIR overrides GROUP_DIR)
const mainEffectiveCwd = WORK_DIR || GROUP_DIR; const mainEffectiveCwd = WORK_DIR || GROUP_DIR;
@@ -1024,7 +1184,9 @@ async function main(): Promise<void> {
// --- Slash command handling --- // --- Slash command handling ---
// Check BEFORE prepending room role header so /compact isn't masked. // Check BEFORE prepending room role header so /compact isn't masked.
const KNOWN_SESSION_COMMANDS = new Set(['/compact']); const KNOWN_SESSION_COMMANDS = new Set(['/compact']);
const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(runnerInput.prompt.trim()); const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(
runnerInput.prompt.trim(),
);
if (!isSessionSlashCommand) { if (!isSessionSlashCommand) {
prompt = prependRoomRoleHeader(prompt, runnerInput.roomRoleContext); prompt = prependRoomRoleHeader(prompt, runnerInput.roomRoleContext);
@@ -1052,13 +1214,16 @@ async function main(): Promise<void> {
settingSources: ['project', 'user'] as const, settingSources: ['project', 'user'] as const,
abortController: agentAbortController, abortController: agentAbortController,
hooks: { hooks: {
PreCompact: [{ hooks: [createPreCompactHook(runnerInput.assistantName)] }], PreCompact: [
{ hooks: [createPreCompactHook(runnerInput.assistantName)] },
],
}, },
}, },
})) { })) {
const msgType = message.type === 'system' const msgType =
? `system/${(message as { subtype?: string }).subtype}` message.type === 'system'
: message.type; ? `system/${(message as { subtype?: string }).subtype}`
: message.type;
log(`[slash-cmd] type=${msgType}`); log(`[slash-cmd] type=${msgType}`);
if (message.type === 'system' && message.subtype === 'init') { if (message.type === 'system' && message.subtype === 'init') {
@@ -1067,15 +1232,27 @@ async function main(): Promise<void> {
} }
// Observe compact_boundary to confirm compaction completed // Observe compact_boundary to confirm compaction completed
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') { if (
message.type === 'system' &&
(message as { subtype?: string }).subtype === 'compact_boundary'
) {
compactBoundarySeen = true; compactBoundarySeen = true;
const meta = (message as { compact_metadata?: { trigger?: string; pre_tokens?: number } }).compact_metadata; const meta = (
log(`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`); 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') { if (message.type === 'result') {
const resultSubtype = (message as { subtype?: string }).subtype; const resultSubtype = (message as { subtype?: string }).subtype;
const textResult = 'result' in message ? (message as { result?: string }).result : null; const textResult =
'result' in message
? (message as { result?: string }).result
: null;
if (resultSubtype?.startsWith('error')) { if (resultSubtype?.startsWith('error')) {
hadError = true; hadError = true;
@@ -1102,11 +1279,15 @@ async function main(): Promise<void> {
writeOutput({ status: 'error', result: null, error: errorMsg }); writeOutput({ status: 'error', result: null, error: errorMsg });
} }
log(`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`); log(
`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`,
);
// Warn if compact_boundary was never observed — compaction may not have occurred // Warn if compact_boundary was never observed — compaction may not have occurred
if (!hadError && !compactBoundarySeen) { if (!hadError && !compactBoundarySeen) {
log('WARNING: compact_boundary was not observed. Compaction may not have completed.'); 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 // Only emit final session marker if no result was emitted yet and no error occurred
@@ -1120,7 +1301,11 @@ async function main(): Promise<void> {
}); });
} else if (!hadError) { } else if (!hadError) {
// Emit session-only marker so host updates session tracking // Emit session-only marker so host updates session tracking
writeOutput({ status: 'success', result: null, newSessionId: slashSessionId }); writeOutput({
status: 'success',
result: null,
newSessionId: slashSessionId,
});
} }
return; return;
} }
@@ -1153,7 +1338,8 @@ async function main(): Promise<void> {
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
const errorStack = err instanceof Error ? err.stack : undefined; const errorStack = err instanceof Error ? err.stack : undefined;
const errorCause = err instanceof Error && err.cause ? String(err.cause) : undefined; const errorCause =
err instanceof Error && err.cause ? String(err.cause) : undefined;
log(`Agent error: ${errorMessage}`); log(`Agent error: ${errorMessage}`);
if (errorStack) log(`Stack: ${errorStack}`); if (errorStack) log(`Stack: ${errorStack}`);
if (errorCause) log(`Cause: ${errorCause}`); if (errorCause) log(`Cause: ${errorCause}`);
@@ -1161,7 +1347,7 @@ async function main(): Promise<void> {
status: 'error', status: 'error',
result: null, result: null,
newSessionId: sessionId, newSessionId: sessionId,
error: errorMessage error: errorMessage,
}); });
process.exit(1); process.exit(1);
} }

View File

@@ -11,10 +11,28 @@ const MEMORY_PATTERNS: Array<{
regex: RegExp; regex: RegExp;
keyword: string; keyword: string;
}> = [ }> = [
{ kind: 'room_norm', regex: /(규칙|원칙|금지|반드시|하지 않|세션 시작 시에만|새 세션 시작 시에만)/i, keyword: 'rule' }, {
{ kind: 'preference', regex: /(선호|원함|원한다|원하는|원했다)/i, keyword: 'preference' }, kind: 'room_norm',
{ kind: 'decision', regex: /(합의|결정|방향|기준|우선|책임지는 방향)/i, keyword: 'decision' }, regex:
{ kind: 'project_fact', regex: /(owner|reviewer|trigger|모드|메모리|기억|세션 리셋|recall|persist|compact)/i, keyword: 'memory' }, /(규칙|원칙|금지|반드시|하지 않|세션 시작 시에만|새 세션 시작 시에만)/i,
keyword: 'rule',
},
{
kind: 'preference',
regex: /(선호|원함|원한다|원하는|원했다)/i,
keyword: 'preference',
},
{
kind: 'decision',
regex: /(합의|결정|방향|기준|우선|책임지는 방향)/i,
keyword: 'decision',
},
{
kind: 'project_fact',
regex:
/(owner|reviewer|trigger|모드|메모리|기억|세션 리셋|recall|persist|compact)/i,
keyword: 'memory',
},
]; ];
function normalizeSentence(raw: string): string | null { function normalizeSentence(raw: string): string | null {
@@ -35,15 +53,15 @@ function splitSummaryIntoSentences(summary: string): string[] {
function classifyMemorySentence(content: string): SelectedCompactMemory | null { function classifyMemorySentence(content: string): SelectedCompactMemory | null {
if (!content) return null; if (!content) return null;
const matchedPatterns = MEMORY_PATTERNS.filter(({ regex }) => regex.test(content)); const matchedPatterns = MEMORY_PATTERNS.filter(({ regex }) =>
regex.test(content),
);
if (matchedPatterns.length === 0) return null; if (matchedPatterns.length === 0) return null;
const primary = matchedPatterns[0]; const primary = matchedPatterns[0];
const keywords = [ const keywords = [
...new Set( ...new Set(
matchedPatterns matchedPatterns.map((pattern) => pattern.keyword).filter(Boolean),
.map((pattern) => pattern.keyword)
.filter(Boolean),
), ),
]; ];

View File

@@ -1,15 +1,9 @@
import { execFile } from 'child_process'; import { execFile } from 'child_process';
import path from 'path'; import path from 'path';
import { pathToFileURL } from 'url'; import { pathToFileURL } from 'url';
export { export { computeVerificationSnapshotId } from '../../../shared/verification-snapshot.js';
computeVerificationSnapshotId,
} from '../../../shared/verification-snapshot.js';
export const VERIFICATION_PROFILES = [ export const VERIFICATION_PROFILES = ['test', 'typecheck', 'build'] as const;
'test',
'typecheck',
'build',
] as const;
export type VerificationProfile = (typeof VERIFICATION_PROFILES)[number]; export type VerificationProfile = (typeof VERIFICATION_PROFILES)[number];

View File

@@ -34,10 +34,7 @@ export function normalizeWatchCiIntervalSeconds(
throw new Error('poll_interval_seconds must be an integer.'); throw new Error('poll_interval_seconds must be an integer.');
} }
if ( if (seconds < minSeconds || seconds > MAX_WATCH_CI_INTERVAL_SECONDS) {
seconds < minSeconds ||
seconds > MAX_WATCH_CI_INTERVAL_SECONDS
) {
throw new Error( throw new Error(
`poll_interval_seconds must be between ${minSeconds} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`, `poll_interval_seconds must be between ${minSeconds} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`,
); );

View File

@@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import { isTaskScopedIpcDir, resolveIpcDirectories } from '../src/ipc-paths.js';
isTaskScopedIpcDir,
resolveIpcDirectories,
} from '../src/ipc-paths.js';
describe('ipc path helpers', () => { describe('ipc path helpers', () => {
it('detects task-scoped IPC directories', () => { it('detects task-scoped IPC directories', () => {

View File

@@ -14,11 +14,17 @@ describe('memory selection', () => {
); );
expect(selected).toHaveLength(2); expect(selected).toHaveLength(2);
expect(selected[0].content).toBe('방 메모리는 새 세션 시작 시에만 주입한다.'); expect(selected[0].content).toBe(
'방 메모리는 새 세션 시작 시에만 주입한다.',
);
expect(selected[0].memoryKind).toBe('room_norm'); expect(selected[0].memoryKind).toBe('room_norm');
expect(selected[1].content).toBe('사용자는 수동 명령보다 자동 기억 형성을 원한다.'); expect(selected[1].content).toBe(
'사용자는 수동 명령보다 자동 기억 형성을 원한다.',
);
expect(selected[1].memoryKind).toBe('preference'); expect(selected[1].memoryKind).toBe('preference');
expect(selected.every((memory) => memory.keywords.includes('room:ejclaw'))).toBe(true); expect(
selected.every((memory) => memory.keywords.includes('room:ejclaw')),
).toBe(true);
}); });
it('returns no memories for purely operational summaries', () => { it('returns no memories for purely operational summaries', () => {
@@ -37,7 +43,9 @@ describe('memory selection', () => {
); );
expect(selected).toHaveLength(1); expect(selected).toHaveLength(1);
expect(selected[0].content).toBe('배포 전에 항상 테스트를 돌리는 것이 원칙이다.'); expect(selected[0].content).toBe(
'배포 전에 항상 테스트를 돌리는 것이 원칙이다.',
);
expect(selected[0].memoryKind).toBe('room_norm'); expect(selected[0].memoryKind).toBe('room_norm');
}); });
}); });

View File

@@ -110,11 +110,11 @@ describe('claude reviewer runtime guard', () => {
}); });
it('builds a read-only sandbox with normalized protected paths', () => { it('builds a read-only sandbox with normalized protected paths', () => {
const sandbox = buildClaudeReadonlySandboxSettings([ const sandbox = buildClaudeReadonlySandboxSettings(
'/repo/work', ['/repo/work', '/repo/work', '/repo/owner/../owner'],
'/repo/work', 'linux',
'/repo/owner/../owner', 'strict',
], 'linux', 'strict'); );
expect(sandbox).toMatchObject({ expect(sandbox).toMatchObject({
enabled: true, enabled: true,
@@ -157,12 +157,10 @@ describe('claude reviewer runtime guard', () => {
it('flags mutating shell commands', () => { it('flags mutating shell commands', () => {
expect(isReviewerMutatingShellCommand('git commit -m "x"')).toBe(false); expect(isReviewerMutatingShellCommand('git commit -m "x"')).toBe(false);
expect(isReviewerMutatingShellCommand('git -c color.ui=false commit -m "x"')).toBe( expect(
false, isReviewerMutatingShellCommand('git -c color.ui=false commit -m "x"'),
); ).toBe(false);
expect(isReviewerMutatingShellCommand('sed -i s/a/b/ file.ts')).toBe( expect(isReviewerMutatingShellCommand('sed -i s/a/b/ file.ts')).toBe(true);
true,
);
expect(isReviewerMutatingShellCommand('git status')).toBe(false); expect(isReviewerMutatingShellCommand('git status')).toBe(false);
expect(isReviewerMutatingShellCommand('npm test')).toBe(false); expect(isReviewerMutatingShellCommand('npm test')).toBe(false);
}); });
@@ -263,7 +261,9 @@ describe('claude reviewer runtime guard', () => {
true, true,
); );
expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).not.toThrow(); expect(() =>
assertReadonlyWorkspaceRepoConnectivity(env, true),
).not.toThrow();
}); });
it.each([ it.each([
@@ -286,7 +286,9 @@ describe('claude reviewer runtime guard', () => {
true, true,
); );
expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).not.toThrow(); expect(() =>
assertReadonlyWorkspaceRepoConnectivity(env, true),
).not.toThrow();
}, },
); );

View File

@@ -11,10 +11,15 @@ import {
describe('runner verification helpers', () => { describe('runner verification helpers', () => {
it('computes the same snapshot when excluded files change', () => { it('computes the same snapshot when excluded files change', () => {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-runner-snapshot-')); const repoDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-runner-snapshot-'),
);
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true }); fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
fs.mkdirSync(path.join(repoDir, 'node_modules'), { recursive: true }); fs.mkdirSync(path.join(repoDir, 'node_modules'), { recursive: true });
fs.writeFileSync(path.join(repoDir, 'src', 'index.ts'), 'export const x = 1;\n'); fs.writeFileSync(
path.join(repoDir, 'src', 'index.ts'),
'export const x = 1;\n',
);
fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=1\n'); fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=1\n');
const first = computeVerificationSnapshotId(repoDir); const first = computeVerificationSnapshotId(repoDir);

View File

@@ -102,7 +102,11 @@ export class CodexAppServerClient {
if (this.proc) return; if (this.proc) return;
const codexPackagePath = this.require.resolve('@openai/codex/package.json'); const codexPackagePath = this.require.resolve('@openai/codex/package.json');
const codexBin = path.join(path.dirname(codexPackagePath), 'bin', 'codex.js'); const codexBin = path.join(
path.dirname(codexPackagePath),
'bin',
'codex.js',
);
this.proc = spawn(process.execPath, [codexBin, 'app-server'], { this.proc = spawn(process.execPath, [codexBin, 'app-server'], {
cwd: this.cwd, cwd: this.cwd,
@@ -213,15 +217,17 @@ export class CodexAppServerClient {
throw new Error('A Codex app-server turn is already active.'); throw new Error('A Codex app-server turn is already active.');
} }
const turnPromise = new Promise<CodexAppServerTurnResult>((resolve, reject) => { const turnPromise = new Promise<CodexAppServerTurnResult>(
this.activeTurn = { (resolve, reject) => {
threadId, this.activeTurn = {
state: createInitialAppServerTurnState(), threadId,
onProgress: options.onProgress, state: createInitialAppServerTurnState(),
resolve, onProgress: options.onProgress,
reject, resolve,
}; reject,
}); };
},
);
let turnId = ''; let turnId = '';
try { try {
@@ -285,14 +291,16 @@ export class CodexAppServerClient {
throw new Error('A Codex app-server turn is already active.'); throw new Error('A Codex app-server turn is already active.');
} }
const turnPromise = new Promise<CodexAppServerTurnResult>((resolve, reject) => { const turnPromise = new Promise<CodexAppServerTurnResult>(
this.activeTurn = { (resolve, reject) => {
threadId, this.activeTurn = {
state: createInitialAppServerTurnState(), threadId,
resolve, state: createInitialAppServerTurnState(),
reject, resolve,
}; reject,
}); };
},
);
try { try {
await this.request('thread/compact/start', { threadId }); await this.request('thread/compact/start', { threadId });

View File

@@ -37,10 +37,7 @@ export type AppServerTurnEvent =
turn?: { turn?: {
id?: string | null; id?: string | null;
status?: string | null; status?: string | null;
error?: error?: { message?: string | null } | string | null;
| { message?: string | null }
| string
| null;
}; };
}; };
} }

View File

@@ -99,10 +99,7 @@ function normalizeStructuredOutput(result: string | null): {
const envelope = parsed?.ejclaw; const envelope = parsed?.ejclaw;
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) { if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
if (envelope.visibility === 'silent') { if (envelope.visibility === 'silent') {
if ( if (envelope.verdict !== undefined && envelope.verdict !== 'silent') {
envelope.verdict !== undefined &&
envelope.verdict !== 'silent'
) {
return { return {
result, result,
output: { visibility: 'public', text: result }, output: { visibility: 'public', text: result },
@@ -225,7 +222,10 @@ function drainIpcInput(): string[] {
} }
} }
function extractImagePaths(text: string): { cleanText: string; imagePaths: string[] } { function extractImagePaths(text: string): {
cleanText: string;
imagePaths: string[];
} {
/** SSOT: src/agent-protocol.ts — keep in sync */ /** SSOT: src/agent-protocol.ts — keep in sync */
const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g; const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g;
const imagePaths: string[] = []; const imagePaths: string[] = [];
@@ -285,24 +285,28 @@ async function executeAppServerTurn(
retryCount = 0, retryCount = 0,
): Promise<{ result: string | null; error?: string }> { ): Promise<{ result: string | null; error?: string }> {
let lastProgressMessage: string | null = null; let lastProgressMessage: string | null = null;
const activeTurn = await client.startTurn(threadId, parseAppServerInput(prompt), { const activeTurn = await client.startTurn(
cwd: EFFECTIVE_CWD, threadId,
model: CODEX_MODEL || undefined, parseAppServerInput(prompt),
effort: CODEX_EFFORT || undefined, {
onProgress: (message) => { cwd: EFFECTIVE_CWD,
const trimmed = message.trim(); model: CODEX_MODEL || undefined,
if (!trimmed || trimmed === lastProgressMessage) { effort: CODEX_EFFORT || undefined,
return; onProgress: (message) => {
} const trimmed = message.trim();
lastProgressMessage = trimmed; if (!trimmed || trimmed === lastProgressMessage) {
writeOutput({ return;
status: 'success', }
phase: 'progress', lastProgressMessage = trimmed;
...normalizeStructuredOutput(trimmed), writeOutput({
newSessionId: threadId, status: 'success',
}); phase: 'progress',
...normalizeStructuredOutput(trimmed),
newSessionId: threadId,
});
},
}, },
}); );
let elapsedMs = 0; let elapsedMs = 0;
let polling = true; let polling = true;
@@ -360,7 +364,8 @@ async function executeAppServerTurn(
} }
return { return {
result, result,
error: state.errorMessage || `Codex turn finished with status ${state.status}`, error:
state.errorMessage || `Codex turn finished with status ${state.status}`,
}; };
} finally { } finally {
polling = false; polling = false;
@@ -451,7 +456,11 @@ async function runAppServerSession(
} }
log('Starting app-server turn...'); log('Starting app-server turn...');
const { result, error } = await executeAppServerTurn(client, threadId, prompt); const { result, error } = await executeAppServerTurn(
client,
threadId,
prompt,
);
if (error) { if (error) {
const normalized = normalizeStructuredOutput(result || null); const normalized = normalizeStructuredOutput(result || null);

View File

@@ -167,7 +167,9 @@ describe('codex reviewer runtime guard', () => {
true, true,
); );
expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).not.toThrow(); expect(() =>
assertReadonlyWorkspaceRepoConnectivity(env, true),
).not.toThrow();
}); });
it.each([ it.each([
@@ -190,7 +192,9 @@ describe('codex reviewer runtime guard', () => {
true, true,
); );
expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).not.toThrow(); expect(() =>
assertReadonlyWorkspaceRepoConnectivity(env, true),
).not.toThrow();
}, },
); );

View File

@@ -55,7 +55,9 @@ export function normalizeLegacyRegisteredGroupsTable(database: Database): void {
const hasIsMain = legacyCols.some((col) => col.name === 'is_main'); const hasIsMain = legacyCols.some((col) => col.name === 'is_main');
const hasAgentType = legacyCols.some((col) => col.name === 'agent_type'); const hasAgentType = legacyCols.some((col) => col.name === 'agent_type');
const hasWorkDir = legacyCols.some((col) => col.name === 'work_dir'); const hasWorkDir = legacyCols.some((col) => col.name === 'work_dir');
const hasAgentConfig = legacyCols.some((col) => col.name === 'agent_config'); const hasAgentConfig = legacyCols.some(
(col) => col.name === 'agent_config',
);
const hasContainerConfig = legacyCols.some( const hasContainerConfig = legacyCols.some(
(col) => col.name === 'container_config', (col) => col.name === 'container_config',
); );

View File

@@ -65,7 +65,18 @@ function backupLegacyRows(database: Database): number {
requires_trigger, is_main, agent_type, work_dir requires_trigger, is_main, agent_type, work_dir
FROM registered_groups`, FROM registered_groups`,
) )
.all() as Array<Record<string, unknown>>; .all() as Array<{
jid: string;
name: string;
folder: string;
trigger_pattern: string;
added_at: string;
agent_config: string | null;
requires_trigger: number | null;
is_main: number | null;
agent_type: string | null;
work_dir: string | null;
}>;
const insert = database.prepare( const insert = database.prepare(
`INSERT OR REPLACE INTO registered_groups_legacy_backup ( `INSERT OR REPLACE INTO registered_groups_legacy_backup (

View File

@@ -105,43 +105,30 @@ describe('commandExists', () => {
describe('canUseLinuxBubblewrapReadonlySandbox', () => { describe('canUseLinuxBubblewrapReadonlySandbox', () => {
it('returns false outside linux', () => { it('returns false outside linux', () => {
expect( expect(
canUseLinuxBubblewrapReadonlySandbox( canUseLinuxBubblewrapReadonlySandbox('macos', () => {
'macos', throw new Error('should not probe commands on macOS');
() => { }),
throw new Error('should not probe commands on macOS');
},
),
).toBe(false); ).toBe(false);
}); });
it('returns false when bwrap is not installed', () => { it('returns false when bwrap is not installed', () => {
expect( expect(canUseLinuxBubblewrapReadonlySandbox('linux', () => false)).toBe(
canUseLinuxBubblewrapReadonlySandbox( false,
'linux', );
() => false,
),
).toBe(false);
}); });
it('returns true when linux bwrap readonly probe succeeds', () => { it('returns true when linux bwrap readonly probe succeeds', () => {
expect( expect(
canUseLinuxBubblewrapReadonlySandbox( canUseLinuxBubblewrapReadonlySandbox('linux', () => true, (() =>
'linux', Buffer.from('')) as unknown as typeof import('child_process').execSync),
() => true,
(() => Buffer.from('')) as typeof import('child_process').execSync,
),
).toBe(true); ).toBe(true);
}); });
it('returns false when linux bwrap readonly probe fails', () => { it('returns false when linux bwrap readonly probe fails', () => {
expect( expect(
canUseLinuxBubblewrapReadonlySandbox( canUseLinuxBubblewrapReadonlySandbox('linux', () => true, (() => {
'linux', throw new Error('permission denied');
() => true, }) as typeof import('child_process').execSync),
(() => {
throw new Error('permission denied');
}) as typeof import('child_process').execSync,
),
).toBe(false); ).toBe(false);
}); });
}); });
@@ -157,8 +144,9 @@ describe('getAppArmorRestrictUnprivilegedUsernsValue', () => {
it('returns the trimmed proc sysctl value', () => { it('returns the trimmed proc sysctl value', () => {
expect( expect(
getAppArmorRestrictUnprivilegedUsernsValue((() => getAppArmorRestrictUnprivilegedUsernsValue(
'1\n') as typeof import('fs').readFileSync), (() => '1\n') as unknown as typeof import('fs').readFileSync,
),
).toBe('1'); ).toBe('1');
}); });
}); });
@@ -252,9 +240,11 @@ describe('ensureLinuxReadonlySandboxAppArmorSupport', () => {
const writes: string[] = []; const writes: string[] = [];
const commands: string[] = []; const commands: string[] = [];
const fileContents = new Map<string, string>([ const fileContents = new Map<string, string>([
['/etc/sysctl.d/90-ejclaw-sandbox.conf', [
'/etc/sysctl.d/90-ejclaw-sandbox.conf',
'# Managed by EJClaw setup to allow bubblewrap readonly sandboxing.\n' + '# Managed by EJClaw setup to allow bubblewrap readonly sandboxing.\n' +
'kernel.apparmor_restrict_unprivileged_userns=0\n'], 'kernel.apparmor_restrict_unprivileged_userns=0\n',
],
['/proc/sys/kernel/apparmor_restrict_unprivileged_userns', '1\n'], ['/proc/sys/kernel/apparmor_restrict_unprivileged_userns', '1\n'],
]); ]);
@@ -301,14 +291,17 @@ describe('ensureLinuxReadonlySandboxAppArmorSupport', () => {
apparmorRestrictUnprivilegedUserns: '1', apparmorRestrictUnprivilegedUserns: '1',
sandboxCapable: false, sandboxCapable: false,
isRootUser: true, isRootUser: true,
readFileSyncFn: ((target) => { readFileSyncFn: ((target: import('fs').PathOrFileDescriptor) => {
const value = fileContents.get(String(target)); const value = fileContents.get(String(target));
if (value == null) throw new Error('missing'); if (value == null) throw new Error('missing');
return value; return value;
}) as typeof import('fs').readFileSync, }) as unknown as typeof import('fs').readFileSync,
mkdirSyncFn: (() => undefined) as typeof import('fs').mkdirSync, mkdirSyncFn: (() => undefined) as typeof import('fs').mkdirSync,
writeFileSyncFn: (() => undefined) as typeof import('fs').writeFileSync, writeFileSyncFn: (() => undefined) as typeof import('fs').writeFileSync,
execFn: (() => Buffer.from('')) as typeof import('child_process').execSync, execFn: (() =>
Buffer.from(
'',
)) as unknown as typeof import('child_process').execSync,
}), }),
).toBe('failed'); ).toBe('failed');
}); });

View File

@@ -177,10 +177,7 @@ export function ensureLinuxReadonlySandboxAppArmorSupport(options?: {
const sandboxCapable = const sandboxCapable =
options?.sandboxCapable ?? canUseLinuxBubblewrapReadonlySandbox(platform); options?.sandboxCapable ?? canUseLinuxBubblewrapReadonlySandbox(platform);
if ( if (apparmorRestrictUnprivilegedUserns !== '1' || sandboxCapable) {
apparmorRestrictUnprivilegedUserns !== '1' ||
sandboxCapable
) {
return 'not-needed'; return 'not-needed';
} }
@@ -211,9 +208,8 @@ export function ensureLinuxReadonlySandboxAppArmorSupport(options?: {
stdio: 'ignore', stdio: 'ignore',
}); });
const appliedValue = getAppArmorRestrictUnprivilegedUsernsValue( const appliedValue =
readFileSyncFn, getAppArmorRestrictUnprivilegedUsernsValue(readFileSyncFn);
);
if (appliedValue !== '0') { if (appliedValue !== '0') {
throw new Error( throw new Error(
`kernel.apparmor_restrict_unprivileged_userns remained ${appliedValue ?? 'unavailable'} after sysctl apply`, `kernel.apparmor_restrict_unprivileged_userns remained ${appliedValue ?? 'unavailable'} after sysctl apply`,

View File

@@ -77,24 +77,24 @@ describe('restartStackServices', () => {
expect(() => expect(() =>
restartStackServices(tempRoot, { restartStackServices(tempRoot, {
serviceManager: 'nohup', serviceManager: 'none',
}), }),
).toThrow('restart:stack only supports Linux systemd services in this repo'); ).toThrow(
'restart:stack only supports Linux systemd services in this repo',
);
}); });
it('falls back to direct restart when the oneshot unit is not installed for an external caller', () => { it('falls back to direct restart when the oneshot unit is not installed for an external caller', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-')); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot); tempRoots.push(tempRoot);
const execFileSyncImpl = vi const execFileSyncImpl = vi.fn().mockImplementationOnce(() => {
.fn() const error = new Error('unit missing');
.mockImplementationOnce(() => { Object.assign(error, {
const error = new Error('unit missing'); stderr: 'Unit ejclaw-stack-restart.service not found.',
Object.assign(error, {
stderr: 'Unit ejclaw-stack-restart.service not found.',
});
throw error;
}); });
throw error;
});
const services = restartStackServices(tempRoot, { const services = restartStackServices(tempRoot, {
execFileSyncImpl, execFileSyncImpl,
@@ -147,7 +147,8 @@ describe('restartStackServices', () => {
const execFileSyncImpl = vi.fn().mockImplementation(() => { const execFileSyncImpl = vi.fn().mockImplementation(() => {
const error = new Error('job failed'); const error = new Error('job failed');
Object.assign(error, { Object.assign(error, {
stderr: 'Job for ejclaw-stack-restart.service failed because the control process exited with error code.', stderr:
'Job for ejclaw-stack-restart.service failed because the control process exited with error code.',
}); });
throw error; throw error;
}); });

View File

@@ -36,7 +36,7 @@ function restartStackServicesDirect(
} }
const execImpl = deps.execFileSyncImpl ?? execFileSync; const execImpl = deps.execFileSyncImpl ?? execFileSync;
const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user']; const systemctlArgs = (deps.runningAsRoot ?? isRoot()) ? [] : ['--user'];
execImpl('systemctl', [...systemctlArgs, 'restart', ...services], { execImpl('systemctl', [...systemctlArgs, 'restart', ...services], {
stdio: 'ignore', stdio: 'ignore',
@@ -64,7 +64,7 @@ export function restartStackServices(
const services = getConfiguredServiceNames(projectRoot); const services = getConfiguredServiceNames(projectRoot);
const execImpl = deps.execFileSyncImpl ?? execFileSync; const execImpl = deps.execFileSyncImpl ?? execFileSync;
const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user']; const systemctlArgs = (deps.runningAsRoot ?? isRoot()) ? [] : ['--user'];
if (deps.direct) { if (deps.direct) {
return restartStackServicesDirect(projectRoot, deps); return restartStackServicesDirect(projectRoot, deps);

View File

@@ -118,9 +118,7 @@ describe('systemd unit generation', () => {
'/home/user', '/home/user',
false, false,
); );
expect(unit).toContain( expect(unit).toContain('ExecStart=/usr/bin/bun /srv/ejclaw/dist/index.js');
'ExecStart=/usr/bin/bun /srv/ejclaw/dist/index.js',
);
}); });
it('preserves EnvironmentFile and extraEnv in the actual builder', () => { it('preserves EnvironmentFile and extraEnv in the actual builder', () => {

View File

@@ -52,9 +52,12 @@ describe('verify service checks', () => {
execSyncMock.mockReturnValue(undefined); execSyncMock.mockReturnValue(undefined);
expect(checkSystemdService('ejclaw')).toBe('running'); expect(checkSystemdService('ejclaw')).toBe('running');
expect(execSyncMock).toHaveBeenCalledWith('systemctl --user is-active ejclaw', { expect(execSyncMock).toHaveBeenCalledWith(
stdio: 'ignore', 'systemctl --user is-active ejclaw',
}); {
stdio: 'ignore',
},
);
}); });
it('checks systemd services in both explicit scopes', () => { it('checks systemd services in both explicit scopes', () => {
@@ -71,8 +74,12 @@ describe('verify service checks', () => {
throw new Error(`unexpected command: ${cmd}`); throw new Error(`unexpected command: ${cmd}`);
}); });
expect(checkSystemdServiceInScope('ejclaw-codex', 'system')).toBe('running'); expect(checkSystemdServiceInScope('ejclaw-codex', 'system')).toBe(
expect(checkSystemdServiceInScope('ejclaw-codex', 'user')).toBe('not_found'); 'running',
);
expect(checkSystemdServiceInScope('ejclaw-codex', 'user')).toBe(
'not_found',
);
}); });
it('treats an unloaded launchd plist as stopped when artifact detection is enabled', () => { it('treats an unloaded launchd plist as stopped when artifact detection is enabled', () => {
@@ -87,9 +94,9 @@ describe('verify service checks', () => {
fs.writeFileSync(plistPath, '<plist />'); fs.writeFileSync(plistPath, '<plist />');
execSyncMock.mockReturnValue(''); execSyncMock.mockReturnValue('');
expect( expect(checkLaunchdServiceArtifact('com.ejclaw-codex', plistPath)).toBe(
checkLaunchdServiceArtifact('com.ejclaw-codex', plistPath), 'stopped',
).toBe('stopped'); );
fs.rmSync(tempHome, { recursive: true, force: true }); fs.rmSync(tempHome, { recursive: true, force: true });
}); });
@@ -111,7 +118,7 @@ describe('verify service checks', () => {
const killSpy = vi const killSpy = vi
.spyOn(process, 'kill') .spyOn(process, 'kill')
.mockImplementation((_pid: number, _signal?: number | NodeJS.Signals) => true); .mockImplementation((_pid: number, _signal?: string | number) => true);
expect(checkNohupService(tempRoot, 'ejclaw')).toBe('running'); expect(checkNohupService(tempRoot, 'ejclaw')).toBe('running');
@@ -121,7 +128,10 @@ describe('verify service checks', () => {
it('treats a legacy nohup wrapper without a live pid as stopped when artifact detection is enabled', () => { it('treats a legacy nohup wrapper without a live pid as stopped when artifact detection is enabled', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
fs.writeFileSync(path.join(tempRoot, 'start-ejclaw-codex.sh'), '#!/bin/bash\n'); fs.writeFileSync(
path.join(tempRoot, 'start-ejclaw-codex.sh'),
'#!/bin/bash\n',
);
expect(checkNohupServiceArtifact(tempRoot, 'ejclaw-codex')).toBe('stopped'); expect(checkNohupServiceArtifact(tempRoot, 'ejclaw-codex')).toBe('stopped');

View File

@@ -27,7 +27,9 @@ export function checkLaunchdService(label: string): ServiceStatus {
try { try {
const output = execSync('launchctl list', { encoding: 'utf-8' }); const output = execSync('launchctl list', { encoding: 'utf-8' });
if (output.includes(label)) { if (output.includes(label)) {
const line = output.split('\n').find((candidate) => candidate.includes(label)); const line = output
.split('\n')
.find((candidate) => candidate.includes(label));
if (line) { if (line) {
const pidField = line.trim().split(/\s+/)[0]; const pidField = line.trim().split(/\s+/)[0];
return pidField !== '-' && pidField ? 'running' : 'stopped'; return pidField !== '-' && pidField ? 'running' : 'stopped';

View File

@@ -64,24 +64,22 @@ describe('getInterruptedRecoveryCandidates', () => {
], ],
}; };
expect(getInterruptedRecoveryCandidates(context, roomBindings)).toEqual( expect(getInterruptedRecoveryCandidates(context, roomBindings)).toEqual([
[ {
{ chatJid: 'dc:1',
chatJid: 'dc:1', groupFolder: 'group-one',
groupFolder: 'group-one', status: 'processing',
status: 'processing', pendingMessages: true,
pendingMessages: true, pendingTasks: 0,
pendingTasks: 0, },
}, {
{ chatJid: 'dc:2',
chatJid: 'dc:2', groupFolder: 'group-two',
groupFolder: 'group-two', status: 'idle',
status: 'idle', pendingMessages: false,
pendingMessages: false, pendingTasks: 0,
pendingTasks: 0, },
}, ]);
],
);
}); });
it('returns empty when there is no explicit restart context', () => { it('returns empty when there is no explicit restart context', () => {

23
test/vitest-env.ts Normal file
View File

@@ -0,0 +1,23 @@
const CANONICAL_DISCORD_KEYS = new Set([
'DISCORD_OWNER_BOT_TOKEN',
'DISCORD_REVIEWER_BOT_TOKEN',
'DISCORD_ARBITER_BOT_TOKEN',
]);
const CANONICAL_SESSION_COMMAND_KEYS = new Set([
'SESSION_COMMAND_ALLOWED_SENDERS',
]);
for (const key of Object.keys(process.env)) {
if (key.startsWith('DISCORD_') && key.endsWith('_BOT_TOKEN')) {
if (!CANONICAL_DISCORD_KEYS.has(key)) {
delete process.env[key];
}
continue;
}
if (key.startsWith('SESSION_COMMAND_')) {
if (!CANONICAL_SESSION_COMMAND_KEYS.has(key)) {
delete process.env[key];
}
}
}

19
tsconfig.check.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": ".",
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": [
"src/**/*",
"setup/**/*",
"scripts/**/*",
"test/**/*",
"vitest*.ts",
"runners/**/test/**/*.ts"
],
"exclude": ["node_modules", "dist", "runners/**/dist"]
}

View File

@@ -8,6 +8,7 @@ export default defineConfig({
}, },
}, },
test: { test: {
setupFiles: ['test/vitest-env.ts'],
include: [ include: [
'src/**/*.test.ts', 'src/**/*.test.ts',
'setup/**/*.test.ts', 'setup/**/*.test.ts',