From 7e8d711325b4db5a26197dd7618d5dd3b3350cce Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 15 Mar 2026 00:01:20 -0300 Subject: [PATCH 1/2] fix: eliminate inline --system-prompt flags, use temp files for all prompts Inline --append-system-prompt and --system-prompt flags broke when passed through tmux send-keys with multi-line prompts containing code blocks. All system prompts are now written to temp files at /tmp/genie-prompts/ and passed via --append-system-prompt-file or --system-prompt-file. When both systemPrompt and systemPromptFile are set, they are merged into a single temp file. Extra --append-system-prompt-file args are also merged. Closes #568 --- src/genie-commands/setup.ts | 4 +- src/lib/provider-adapters.test.ts | 77 ++++++++++++++++++++++++++++++- src/lib/provider-adapters.ts | 35 ++++++++++++-- src/types/genie-config.ts | 2 +- 4 files changed, 109 insertions(+), 9 deletions(-) diff --git a/src/genie-commands/setup.ts b/src/genie-commands/setup.ts index b429a2d01..01fd7f0fc 100644 --- a/src/genie-commands/setup.ts +++ b/src/genie-commands/setup.ts @@ -275,8 +275,8 @@ async function configurePromptMode(config: GenieConfig, quick: boolean): Promise return config; } - console.log(' append — Uses --append-system-prompt (preserves Claude Code default system prompt)'); - console.log(' system — Uses --system-prompt (replaces Claude Code default system prompt)'); + console.log(' append — Uses --append-system-prompt-file (preserves Claude Code default system prompt)'); + console.log(' system — Uses --system-prompt-file (replaces Claude Code default system prompt)'); console.log(); const promptMode = await select({ diff --git a/src/lib/provider-adapters.test.ts b/src/lib/provider-adapters.test.ts index 3127a56d5..c5bc2ed90 100644 --- a/src/lib/provider-adapters.test.ts +++ b/src/lib/provider-adapters.test.ts @@ -144,7 +144,7 @@ describe('buildClaudeCommand', () => { expect(result.command).not.toContain("--system-prompt-file '/path"); }); - it('does not include prompt file flags when systemPromptFile is not set', () => { + it('does not include prompt file flags when neither systemPromptFile nor systemPrompt is set', () => { const result = buildClaudeCommand({ provider: 'claude', team: 'work', @@ -153,6 +153,81 @@ describe('buildClaudeCommand', () => { expect(result.command).not.toContain('--system-prompt-file'); expect(result.command).not.toContain('--append-system-prompt-file'); }); + + it('writes systemPrompt to temp file and uses --append-system-prompt-file', () => { + const result = buildClaudeCommand({ + provider: 'claude', + team: 'work', + role: 'implementor', + systemPrompt: 'You are an implementor agent.', + }); + expect(result.command).toContain('--append-system-prompt-file'); + expect(result.command).toContain('/tmp/genie-prompts/implementor-'); + // Must NOT contain inline --append-system-prompt (without -file) + expect(result.command).not.toMatch(/--append-system-prompt(?!-file)/); + }); + + it('writes systemPrompt to temp file with --system-prompt-file when promptMode is "system"', () => { + const result = buildClaudeCommand({ + provider: 'claude', + team: 'work', + role: 'implementor', + systemPrompt: 'You are an implementor agent.', + promptMode: 'system', + }); + expect(result.command).toContain('--system-prompt-file'); + expect(result.command).not.toContain('--append-system-prompt-file'); + expect(result.command).toContain('/tmp/genie-prompts/implementor-'); + }); + + it('never emits inline --system-prompt or --append-system-prompt flags', () => { + const result = buildClaudeCommand({ + provider: 'claude', + team: 'work', + role: 'tester', + systemPrompt: 'Multi-line prompt\nwith ```code blocks```\nand special chars: $VAR "quotes"', + }); + // Should use file-based flag + expect(result.command).toContain('--append-system-prompt-file'); + // Must NOT contain inline prompt flags (without -file suffix) + expect(result.command).not.toMatch(/--append-system-prompt(?!-file)/); + expect(result.command).not.toMatch(/--system-prompt(?!-file)/); + }); + + it('uses "agent" as fallback role in temp file name when no role set', () => { + const result = buildClaudeCommand({ + provider: 'claude', + team: 'work', + systemPrompt: 'Some prompt', + }); + expect(result.command).toContain('/tmp/genie-prompts/agent-'); + expect(result.command).toContain('--append-system-prompt-file'); + }); + + it('merges systemPromptFile and systemPrompt into one temp file', () => { + const fs = require('node:fs'); + const testFile = '/tmp/genie-prompts/test-agents.md'; + fs.mkdirSync('/tmp/genie-prompts', { recursive: true }); + fs.writeFileSync(testFile, 'User agent instructions'); + + const result = buildClaudeCommand({ + provider: 'claude', + team: 'work', + role: 'implementor', + systemPromptFile: testFile, + systemPrompt: 'Built-in prompt', + }); + expect(result.command).toContain('--append-system-prompt-file'); + // Should reference the NEW temp file, not the original + expect(result.command).toContain('/tmp/genie-prompts/implementor-'); + + // Verify merged content + const match = result.command.match(/\/tmp\/genie-prompts\/implementor-[^']+/); + expect(match).toBeTruthy(); + const content = fs.readFileSync(match![0], 'utf-8'); + expect(content).toContain('User agent instructions'); + expect(content).toContain('Built-in prompt'); + }); }); // ============================================================================ diff --git a/src/lib/provider-adapters.ts b/src/lib/provider-adapters.ts index 003474fb0..6d06539a6 100644 --- a/src/lib/provider-adapters.ts +++ b/src/lib/provider-adapters.ts @@ -68,7 +68,7 @@ export interface SpawnParams { resume?: string; /** Path to a system prompt file (AGENTS.md). Emits --system-prompt-file or --append-system-prompt-file. */ systemPromptFile?: string; - /** Inline system prompt text (for built-ins without an AGENTS.md file). Emits --append-system-prompt or --system-prompt. */ + /** Inline system prompt text (for built-ins without an AGENTS.md file). Written to temp file, emits --append-system-prompt-file or --system-prompt-file. */ systemPrompt?: string; /** How to inject the system prompt file: 'system' replaces CC default, 'append' adds to it. */ promptMode?: 'system' | 'append'; @@ -232,12 +232,37 @@ export function buildClaudeCommand(params: SpawnParams): LaunchCommand { if (params.model) parts.push('--model', escapeShellArg(params.model)); - if (params.systemPromptFile) { + if (params.systemPrompt) { + // Write built-in prompt to temp file — avoids shell escaping of complex content + const { mkdirSync, writeFileSync, readFileSync } = require('node:fs'); + const { join } = require('node:path'); + const dir = '/tmp/genie-prompts'; + mkdirSync(dir, { recursive: true }); + const ts = Date.now().toString(36); + const promptFile = join(dir, `${params.role || 'agent'}-${ts}.md`); + + // If there is also a systemPromptFile (user agent), merge both + let content = params.systemPrompt; + if (params.systemPromptFile) { + content = readFileSync(params.systemPromptFile, 'utf-8') + '\n\n' + content; + } + + // If extraArgs has --append-system-prompt-file, merge that too + if (params.extraArgs) { + const fileIdx = params.extraArgs.indexOf('--append-system-prompt-file'); + if (fileIdx !== -1 && params.extraArgs[fileIdx + 1]) { + content = content + '\n\n' + readFileSync(params.extraArgs[fileIdx + 1], 'utf-8'); + // Remove the extra arg since we merged it + params.extraArgs.splice(fileIdx, 2); + } + } + + writeFileSync(promptFile, content); + const flag = params.promptMode === 'system' ? '--system-prompt-file' : '--append-system-prompt-file'; + parts.push(flag, escapeShellArg(promptFile)); + } else if (params.systemPromptFile) { const flag = params.promptMode === 'system' ? '--system-prompt-file' : '--append-system-prompt-file'; parts.push(flag, escapeShellArg(params.systemPromptFile)); - } else if (params.systemPrompt) { - const flag = params.promptMode === 'system' ? '--system-prompt' : '--append-system-prompt'; - parts.push(flag, escapeShellArg(params.systemPrompt)); } if (params.extraArgs) { diff --git a/src/types/genie-config.ts b/src/types/genie-config.ts index 087eb6056..653417fbe 100644 --- a/src/types/genie-config.ts +++ b/src/types/genie-config.ts @@ -90,7 +90,7 @@ export const GenieConfigSchema = z.object({ councilPresets: z.record(z.string(), CouncilPresetSchema).optional(), // Default council preset name defaultCouncilPreset: z.string().optional(), - // Controls whether --system-prompt (replace CC default) or --append-system-prompt (preserve CC default) is used + // Controls whether --system-prompt-file (replace CC default) or --append-system-prompt-file (preserve CC default) is used promptMode: z.enum(['append', 'system']).default('append'), // Whether task leaders should auto-merge PRs to dev (default: false — leave PR open for human) autoMergeDev: z.boolean().default(false), From b15c18f673d23084a772c3e36adea9c500380b3d Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 15 Mar 2026 00:05:17 -0300 Subject: [PATCH 2/2] fix: use template literals to satisfy biome lint --- src/lib/provider-adapters.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/provider-adapters.ts b/src/lib/provider-adapters.ts index 6d06539a6..9c0abedd1 100644 --- a/src/lib/provider-adapters.ts +++ b/src/lib/provider-adapters.ts @@ -244,14 +244,14 @@ export function buildClaudeCommand(params: SpawnParams): LaunchCommand { // If there is also a systemPromptFile (user agent), merge both let content = params.systemPrompt; if (params.systemPromptFile) { - content = readFileSync(params.systemPromptFile, 'utf-8') + '\n\n' + content; + content = `${readFileSync(params.systemPromptFile, 'utf-8')}\n\n${content}`; } // If extraArgs has --append-system-prompt-file, merge that too if (params.extraArgs) { const fileIdx = params.extraArgs.indexOf('--append-system-prompt-file'); if (fileIdx !== -1 && params.extraArgs[fileIdx + 1]) { - content = content + '\n\n' + readFileSync(params.extraArgs[fileIdx + 1], 'utf-8'); + content = `${content}\n\n${readFileSync(params.extraArgs[fileIdx + 1], 'utf-8')}`; // Remove the extra arg since we merged it params.extraArgs.splice(fileIdx, 2); }