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:
@@ -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: [],
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
18
src/db.ts
18
src/db.ts
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user