feat: add per-group workDir for agent working directory override

Agents can now run in a specified project directory instead of the group
folder. The group's CLAUDE.md is still loaded via additionalDirectories.
This commit is contained in:
Eyejoker
2026-03-11 02:02:51 +09:00
parent 82becfdfd7
commit cb51ef36f9
6 changed files with 39 additions and 11 deletions

View File

@@ -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 IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const GLOBAL_DIR = process.env.NANOCLAW_GLOBAL_DIR || '/workspace/global'; const GLOBAL_DIR = process.env.NANOCLAW_GLOBAL_DIR || '/workspace/global';
const EXTRA_BASE = process.env.NANOCLAW_EXTRA_DIR || '/workspace/extra'; 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 IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const MAX_TURNS = 100; 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) { if (extraDirs.length > 0) {
log(`Additional directories: ${extraDirs.join(', ')}`); log(`Additional directories: ${extraDirs.join(', ')}`);
} }
@@ -440,7 +448,7 @@ async function runQuery(
for await (const message of query({ for await (const message of query({
prompt: stream, prompt: stream,
options: { options: {
cwd: GROUP_DIR, cwd: effectiveCwd,
model, model,
thinking, thinking,
effort, effort,
@@ -550,6 +558,9 @@ async function main(): Promise<void> {
// Clean up stale _close sentinel from previous container runs // Clean up stale _close sentinel from previous container runs
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)
const mainEffectiveCwd = WORK_DIR || GROUP_DIR;
// Build initial prompt (drain any pending IPC messages too) // Build initial prompt (drain any pending IPC messages too)
let prompt = containerInput.prompt; let prompt = containerInput.prompt;
if (containerInput.isScheduledTask) { if (containerInput.isScheduledTask) {
@@ -579,7 +590,7 @@ async function main(): Promise<void> {
for await (const message of query({ for await (const message of query({
prompt: trimmedPrompt, prompt: trimmedPrompt,
options: { options: {
cwd: GROUP_DIR, cwd: mainEffectiveCwd,
resume: sessionId, resume: sessionId,
systemPrompt: undefined, systemPrompt: undefined,
allowedTools: [], allowedTools: [],

View File

@@ -36,6 +36,8 @@ interface ContainerOutput {
// Paths configurable via env vars (defaults to container paths for backwards compat) // Paths configurable via env vars (defaults to container paths for backwards compat)
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group'; const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; 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_DIR = path.join(IPC_DIR, 'input');
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500; const IPC_POLL_MS = 500;
@@ -125,6 +127,7 @@ function waitForIpcMessage(): Promise<string | null> {
async function runCodexExec(prompt: string, resume: boolean): Promise<{ result: string; error?: string }> { async function runCodexExec(prompt: string, resume: boolean): Promise<{ result: string; error?: string }> {
return new Promise((resolve) => { return new Promise((resolve) => {
const args: string[] = []; const args: string[] = [];
const effectiveCwd = WORK_DIR || GROUP_DIR;
if (resume) { if (resume) {
// Resume the most recent session with a new prompt // Resume the most recent session with a new prompt
@@ -143,7 +146,7 @@ async function runCodexExec(prompt: string, resume: boolean): Promise<{ result:
args.push( args.push(
'exec', 'exec',
'--dangerously-bypass-approvals-and-sandbox', '--dangerously-bypass-approvals-and-sandbox',
'-C', GROUP_DIR, '-C', effectiveCwd,
'--skip-git-repo-check', '--skip-git-repo-check',
'--color', 'never', '--color', 'never',
prompt, prompt,
@@ -154,7 +157,7 @@ async function runCodexExec(prompt: string, resume: boolean): Promise<{ result:
const codex: ChildProcess = spawn('codex', args, { const codex: ChildProcess = spawn('codex', args, {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
cwd: GROUP_DIR, cwd: effectiveCwd,
env: { ...process.env }, env: { ...process.env },
}); });

View File

@@ -227,14 +227,11 @@ export class DiscordChannel implements Channel {
// Convert @username mentions to Discord mention format // Convert @username mentions to Discord mention format
const mentionMap: Record<string, string> = { const mentionMap: Record<string, string> = {
'눈쟁이': '216851709744513024', : '216851709744513024',
}; };
let resolved = text; let resolved = text;
for (const [name, id] of Object.entries(mentionMap)) { for (const [name, id] of Object.entries(mentionMap)) {
resolved = resolved.replace( resolved = resolved.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`);
new RegExp(`@${name}`, 'g'),
`<@${id}>`,
);
} }
// Discord has a 2000 character limit per message — split if needed // Discord has a 2000 character limit per message — split if needed

View File

@@ -155,6 +155,8 @@ function prepareGroupEnvironment(
NANOCLAW_IPC_DIR: groupIpcDir, NANOCLAW_IPC_DIR: groupIpcDir,
NANOCLAW_GLOBAL_DIR: globalDir, NANOCLAW_GLOBAL_DIR: globalDir,
NANOCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '', 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 // MCP server context
NANOCLAW_CHAT_JID: group.folder, NANOCLAW_CHAT_JID: group.folder,
NANOCLAW_GROUP_FOLDER: group.folder, NANOCLAW_GROUP_FOLDER: group.folder,

View File

@@ -128,6 +128,15 @@ function createSchema(database: Database.Database): void {
/* column already exists */ /* 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) // Add channel and is_group columns if they don't exist (migration for existing DBs)
try { try {
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
@@ -576,6 +585,7 @@ export function getRegisteredGroup(
requires_trigger: number | null; requires_trigger: number | null;
is_main: number | null; is_main: number | null;
agent_type: string | null; agent_type: string | null;
work_dir: string | null;
} }
| undefined; | undefined;
if (!row) return undefined; if (!row) return undefined;
@@ -599,6 +609,7 @@ export function getRegisteredGroup(
row.requires_trigger === null ? undefined : row.requires_trigger === 1, row.requires_trigger === null ? undefined : row.requires_trigger === 1,
isMain: row.is_main === 1 ? true : undefined, isMain: row.is_main === 1 ? true : undefined,
agentType: (row.agent_type as RegisteredGroup['agentType']) || 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}`); throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
} }
db.prepare( db.prepare(
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, agent_type) `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, agent_type, work_dir)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
jid, jid,
group.name, group.name,
@@ -619,6 +630,7 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0, group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
group.isMain ? 1 : 0, group.isMain ? 1 : 0,
group.agentType || 'claude-code', group.agentType || 'claude-code',
group.workDir || null,
); );
} }
@@ -633,6 +645,7 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
requires_trigger: number | null; requires_trigger: number | null;
is_main: number | null; is_main: number | null;
agent_type: string | null; agent_type: string | null;
work_dir: string | null;
}>; }>;
const result: Record<string, RegisteredGroup> = {}; const result: Record<string, RegisteredGroup> = {};
for (const row of rows) { for (const row of rows) {
@@ -655,6 +668,7 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
row.requires_trigger === null ? undefined : row.requires_trigger === 1, row.requires_trigger === null ? undefined : row.requires_trigger === 1,
isMain: row.is_main === 1 ? true : undefined, isMain: row.is_main === 1 ? true : undefined,
agentType: (row.agent_type as RegisteredGroup['agentType']) || undefined, agentType: (row.agent_type as RegisteredGroup['agentType']) || undefined,
workDir: row.work_dir || undefined,
}; };
} }
return result; return result;

View File

@@ -20,6 +20,7 @@ export interface RegisteredGroup {
requiresTrigger?: boolean; // Default: true for groups, false for solo chats requiresTrigger?: boolean; // Default: true for groups, false for solo chats
isMain?: boolean; // True for the main control group (no trigger, elevated privileges) isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
agentType?: AgentType; agentType?: AgentType;
workDir?: string; // Working directory for the agent (defaults to group folder)
} }
export interface NewMessage { export interface NewMessage {