feat: add Codex CLI support, dual Discord bots, and Discord typing fix
- Add Codex as alternative agent type (agentType: 'codex' per group) - Codex container runner with session resume and 100-turn limit - Dual Discord bot support (DISCORD_BOT_TOKEN + DISCORD_CODEX_BOT_TOKEN) - Each bot handles only its agentType's registered groups - Discord typing indicator refreshes every 8s (was single-shot, expired after ~10s) - Agent runner 100-turn limit to prevent infinite bot-to-bot loops - Apple Container runtime support (convert-to-apple-container skill) - Session commands (/compact) support - DB migration for agent_type column
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
interface ContainerInput {
|
||||
@@ -27,6 +27,7 @@ interface ContainerInput {
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
secrets?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ContainerOutput {
|
||||
@@ -55,6 +56,7 @@ interface SDKUserMessage {
|
||||
}
|
||||
|
||||
const IPC_INPUT_DIR = '/workspace/ipc/input';
|
||||
const MAX_TURNS = 100;
|
||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
||||
const IPC_POLL_MS = 500;
|
||||
|
||||
@@ -184,6 +186,30 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
};
|
||||
}
|
||||
|
||||
// Secrets to strip from Bash tool subprocess environments.
|
||||
// These are needed by claude-code for API auth but should never
|
||||
// be visible to commands Kit runs.
|
||||
const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
|
||||
function createSanitizeBashHook(): HookCallback {
|
||||
return async (input, _toolUseId, _context) => {
|
||||
const preInput = input as PreToolUseHookInput;
|
||||
const command = (preInput.tool_input as { command?: string })?.command;
|
||||
if (!command) return {};
|
||||
|
||||
const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
updatedInput: {
|
||||
...(preInput.tool_input as Record<string, unknown>),
|
||||
command: unsetPrefix + command,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeFilename(summary: string): string {
|
||||
return summary
|
||||
.toLowerCase()
|
||||
@@ -389,10 +415,29 @@ async function runQuery(
|
||||
log(`Additional directories: ${extraDirs.join(', ')}`);
|
||||
}
|
||||
|
||||
// Model and thinking configuration from environment
|
||||
const model = process.env.CLAUDE_MODEL || undefined;
|
||||
const thinkingType = process.env.CLAUDE_THINKING || undefined; // 'adaptive' | 'enabled' | 'disabled'
|
||||
const thinkingBudget = process.env.CLAUDE_THINKING_BUDGET
|
||||
? parseInt(process.env.CLAUDE_THINKING_BUDGET, 10)
|
||||
: undefined;
|
||||
const effort = (process.env.CLAUDE_EFFORT as 'low' | 'medium' | 'high' | 'max') || undefined;
|
||||
const thinking = thinkingType === 'adaptive' ? { type: 'adaptive' as const }
|
||||
: thinkingType === 'enabled' ? { type: 'enabled' as const, budgetTokens: thinkingBudget }
|
||||
: thinkingType === 'disabled' ? { type: 'disabled' as const }
|
||||
: undefined;
|
||||
|
||||
if (model) log(`Using model: ${model}`);
|
||||
if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`);
|
||||
if (effort) log(`Effort: ${effort}`);
|
||||
|
||||
for await (const message of query({
|
||||
prompt: stream,
|
||||
options: {
|
||||
cwd: '/workspace/group',
|
||||
model,
|
||||
thinking,
|
||||
effort,
|
||||
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
||||
resume: sessionId,
|
||||
resumeSessionAt: resumeAt,
|
||||
@@ -426,6 +471,7 @@ async function runQuery(
|
||||
},
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
|
||||
},
|
||||
}
|
||||
})) {
|
||||
@@ -470,6 +516,7 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
const stdinData = await readStdin();
|
||||
containerInput = JSON.parse(stdinData);
|
||||
// Delete the temp file the entrypoint wrote — it contains secrets
|
||||
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
|
||||
log(`Received input for group: ${containerInput.groupFolder}`);
|
||||
} catch (err) {
|
||||
@@ -481,9 +528,12 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL.
|
||||
// No real secrets exist in the container environment.
|
||||
// Build SDK env: merge secrets into process.env for the SDK only.
|
||||
// Secrets never touch process.env itself, so Bash subprocesses can't see them.
|
||||
const sdkEnv: Record<string, string | undefined> = { ...process.env };
|
||||
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
|
||||
sdkEnv[key] = value;
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
||||
@@ -505,11 +555,118 @@ async function main(): Promise<void> {
|
||||
prompt += '\n' + pending.join('\n');
|
||||
}
|
||||
|
||||
// --- Slash command handling ---
|
||||
// Only known session slash commands are handled here. This prevents
|
||||
// accidental interception of user prompts that happen to start with '/'.
|
||||
const KNOWN_SESSION_COMMANDS = new Set(['/compact']);
|
||||
const trimmedPrompt = prompt.trim();
|
||||
const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(trimmedPrompt);
|
||||
|
||||
if (isSessionSlashCommand) {
|
||||
log(`Handling session command: ${trimmedPrompt}`);
|
||||
let slashSessionId: string | undefined;
|
||||
let compactBoundarySeen = false;
|
||||
let hadError = false;
|
||||
let resultEmitted = false;
|
||||
|
||||
try {
|
||||
for await (const message of query({
|
||||
prompt: trimmedPrompt,
|
||||
options: {
|
||||
cwd: '/workspace/group',
|
||||
resume: sessionId,
|
||||
systemPrompt: undefined,
|
||||
allowedTools: [],
|
||||
env: sdkEnv,
|
||||
permissionMode: 'bypassPermissions' as const,
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user'] as const,
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||
},
|
||||
},
|
||||
})) {
|
||||
const msgType = message.type === 'system'
|
||||
? `system/${(message as { subtype?: string }).subtype}`
|
||||
: message.type;
|
||||
log(`[slash-cmd] type=${msgType}`);
|
||||
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
slashSessionId = message.session_id;
|
||||
log(`Session after slash command: ${slashSessionId}`);
|
||||
}
|
||||
|
||||
// Observe compact_boundary to confirm compaction completed
|
||||
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
||||
compactBoundarySeen = true;
|
||||
log('Compact boundary observed — compaction completed');
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
const resultSubtype = (message as { subtype?: string }).subtype;
|
||||
const textResult = 'result' in message ? (message as { result?: string }).result : null;
|
||||
|
||||
if (resultSubtype?.startsWith('error')) {
|
||||
hadError = true;
|
||||
writeOutput({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: textResult || 'Session command failed.',
|
||||
newSessionId: slashSessionId,
|
||||
});
|
||||
} else {
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: textResult || 'Conversation compacted.',
|
||||
newSessionId: slashSessionId,
|
||||
});
|
||||
}
|
||||
resultEmitted = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
hadError = true;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
log(`Slash command error: ${errorMsg}`);
|
||||
writeOutput({ status: 'error', result: null, error: errorMsg });
|
||||
}
|
||||
|
||||
log(`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`);
|
||||
|
||||
// Warn if compact_boundary was never observed — compaction may not have occurred
|
||||
if (!hadError && !compactBoundarySeen) {
|
||||
log('WARNING: compact_boundary was not observed. Compaction may not have completed.');
|
||||
}
|
||||
|
||||
// Only emit final session marker if no result was emitted yet and no error occurred
|
||||
if (!resultEmitted && !hadError) {
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: compactBoundarySeen
|
||||
? 'Conversation compacted.'
|
||||
: 'Compaction requested but compact_boundary was not observed.',
|
||||
newSessionId: slashSessionId,
|
||||
});
|
||||
} else if (!hadError) {
|
||||
// Emit session-only marker so host updates session tracking
|
||||
writeOutput({ status: 'success', result: null, newSessionId: slashSessionId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// --- End slash command handling ---
|
||||
|
||||
// Query loop: run query → wait for IPC message → run new query → repeat
|
||||
let resumeAt: string | undefined;
|
||||
let turnCount = 0;
|
||||
try {
|
||||
while (true) {
|
||||
log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`);
|
||||
turnCount++;
|
||||
if (turnCount > MAX_TURNS) {
|
||||
log(`Turn limit reached (${MAX_TURNS}), exiting`);
|
||||
writeOutput({ status: 'success', result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]', newSessionId: sessionId });
|
||||
break;
|
||||
}
|
||||
log(`Starting query (session: ${sessionId || 'new'}, turn: ${turnCount}/${MAX_TURNS}, resumeAt: ${resumeAt || 'latest'})...`);
|
||||
|
||||
const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt);
|
||||
if (queryResult.newSessionId) {
|
||||
|
||||
Reference in New Issue
Block a user