diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index e144b85..a1af3e5 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -60,6 +60,8 @@ const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group'; const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; const GLOBAL_DIR = process.env.NANOCLAW_GLOBAL_DIR || '/workspace/global'; const EXTRA_BASE = process.env.NANOCLAW_EXTRA_DIR || '/workspace/extra'; +// Optional: override cwd (agent works in this directory instead of GROUP_DIR) +const WORK_DIR = process.env.NANOCLAW_WORK_DIR || ''; const IPC_INPUT_DIR = path.join(IPC_DIR, 'input'); const MAX_TURNS = 100; @@ -417,6 +419,12 @@ async function runQuery( } } } + // 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(', ')}`); } @@ -440,7 +448,7 @@ async function runQuery( for await (const message of query({ prompt: stream, options: { - cwd: GROUP_DIR, + cwd: effectiveCwd, model, thinking, effort, @@ -550,6 +558,9 @@ async function main(): Promise { // Clean up stale _close sentinel from previous container runs try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } + // Effective working directory (WORK_DIR overrides GROUP_DIR) + const mainEffectiveCwd = WORK_DIR || GROUP_DIR; + // Build initial prompt (drain any pending IPC messages too) let prompt = containerInput.prompt; if (containerInput.isScheduledTask) { @@ -579,7 +590,7 @@ async function main(): Promise { for await (const message of query({ prompt: trimmedPrompt, options: { - cwd: GROUP_DIR, + cwd: mainEffectiveCwd, resume: sessionId, systemPrompt: undefined, allowedTools: [], diff --git a/container/codex-runner/src/index.ts b/container/codex-runner/src/index.ts index 532d3b9..64168a3 100644 --- a/container/codex-runner/src/index.ts +++ b/container/codex-runner/src/index.ts @@ -36,6 +36,8 @@ interface ContainerOutput { // Paths configurable via env vars (defaults to container paths for backwards compat) const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group'; const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; +// Optional: override cwd (agent works in this directory instead of GROUP_DIR) +const WORK_DIR = process.env.NANOCLAW_WORK_DIR || ''; const IPC_INPUT_DIR = path.join(IPC_DIR, 'input'); const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); const IPC_POLL_MS = 500; @@ -125,6 +127,7 @@ function waitForIpcMessage(): Promise { async function runCodexExec(prompt: string, resume: boolean): Promise<{ result: string; error?: string }> { return new Promise((resolve) => { const args: string[] = []; + const effectiveCwd = WORK_DIR || GROUP_DIR; if (resume) { // Resume the most recent session with a new prompt @@ -143,7 +146,7 @@ async function runCodexExec(prompt: string, resume: boolean): Promise<{ result: args.push( 'exec', '--dangerously-bypass-approvals-and-sandbox', - '-C', GROUP_DIR, + '-C', effectiveCwd, '--skip-git-repo-check', '--color', 'never', prompt, @@ -154,7 +157,7 @@ async function runCodexExec(prompt: string, resume: boolean): Promise<{ result: const codex: ChildProcess = spawn('codex', args, { stdio: ['pipe', 'pipe', 'pipe'], - cwd: GROUP_DIR, + cwd: effectiveCwd, env: { ...process.env }, }); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 6a3e90d..855db36 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -227,14 +227,11 @@ export class DiscordChannel implements Channel { // Convert @username mentions to Discord mention format const mentionMap: Record = { - '눈쟁이': '216851709744513024', + 눈쟁이: '216851709744513024', }; let resolved = text; for (const [name, id] of Object.entries(mentionMap)) { - resolved = resolved.replace( - new RegExp(`@${name}`, 'g'), - `<@${id}>`, - ); + resolved = resolved.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`); } // Discord has a 2000 character limit per message — split if needed diff --git a/src/container-runner.ts b/src/container-runner.ts index 8f93ed6..c3b5ad3 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -155,6 +155,8 @@ function prepareGroupEnvironment( NANOCLAW_IPC_DIR: groupIpcDir, NANOCLAW_GLOBAL_DIR: globalDir, NANOCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '', + // Working directory override (agent uses this as cwd instead of group dir) + ...(group.workDir ? { NANOCLAW_WORK_DIR: group.workDir } : {}), // MCP server context NANOCLAW_CHAT_JID: group.folder, NANOCLAW_GROUP_FOLDER: group.folder, diff --git a/src/db.ts b/src/db.ts index 602f25f..f3087f8 100644 --- a/src/db.ts +++ b/src/db.ts @@ -128,6 +128,15 @@ function createSchema(database: Database.Database): void { /* column already exists */ } + // Add work_dir column if it doesn't exist (migration for per-group working directory) + try { + database.exec( + `ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`, + ); + } catch { + /* column already exists */ + } + // Add channel and is_group columns if they don't exist (migration for existing DBs) try { database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); @@ -576,6 +585,7 @@ export function getRegisteredGroup( requires_trigger: number | null; is_main: number | null; agent_type: string | null; + work_dir: string | null; } | undefined; if (!row) return undefined; @@ -599,6 +609,7 @@ export function getRegisteredGroup( row.requires_trigger === null ? undefined : row.requires_trigger === 1, isMain: row.is_main === 1 ? true : undefined, agentType: (row.agent_type as RegisteredGroup['agentType']) || undefined, + workDir: row.work_dir || undefined, }; } @@ -607,8 +618,8 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); } db.prepare( - `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, agent_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, agent_type, work_dir) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( jid, group.name, @@ -619,6 +630,7 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0, group.isMain ? 1 : 0, group.agentType || 'claude-code', + group.workDir || null, ); } @@ -633,6 +645,7 @@ export function getAllRegisteredGroups(): Record { requires_trigger: number | null; is_main: number | null; agent_type: string | null; + work_dir: string | null; }>; const result: Record = {}; for (const row of rows) { @@ -655,6 +668,7 @@ export function getAllRegisteredGroups(): Record { row.requires_trigger === null ? undefined : row.requires_trigger === 1, isMain: row.is_main === 1 ? true : undefined, agentType: (row.agent_type as RegisteredGroup['agentType']) || undefined, + workDir: row.work_dir || undefined, }; } return result; diff --git a/src/types.ts b/src/types.ts index d18f328..7b0e25e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,6 +20,7 @@ export interface RegisteredGroup { requiresTrigger?: boolean; // Default: true for groups, false for solo chats isMain?: boolean; // True for the main control group (no trigger, elevated privileges) agentType?: AgentType; + workDir?: string; // Working directory for the agent (defaults to group folder) } export interface NewMessage {