Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/windows-watch-multiline-prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bradygaster/squad-cli": patch
---

Spill multiline and oversized prompt arguments to temporary `.md` files on Windows so `cmd.exe` does not truncate `squad watch --execute` prompts at the first newline, and report dispatched issue counts in the round summary (#2070).
83 changes: 77 additions & 6 deletions packages/squad-cli/src/cli/commands/watch/agent-spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,19 @@
*/

import { execFile, execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import type { WatchContext } from './types.js';
import { withAdditionalMcpConfig } from '../../core/copilot-invocation.js';

/** True when running on Windows — used to gate `shell: true`. */
export const IS_WINDOWS = process.platform === 'win32';

/** Maximum single-arg length before spilling to a temp prompt file on Windows (cmd.exe limit is 8,191 chars). */
export const WINDOWS_MAX_INLINE_ARG_LENGTH = 4_000;

export interface CopilotUsageOutput {
currentModel: string;
lastCallInputTokens: number;
Expand Down Expand Up @@ -110,13 +114,78 @@ export function escapeForCmd(arg: string): string {
return `"${escaped}"`;
}

export interface PreparedWindowsArgs {
args: string[];
tempFiles: string[];
cleanup: () => void;
}

/**
* Spill a multiline or oversized prompt argument to a temporary `.md` file
* so `cmd.exe /d /s /c` does not truncate at the first newline or hit the
* 8,191-character command-line limit (#2070).
*/
export function spillPromptArgForCmd(
arg: string,
tempFiles?: string[],
): string {
if (
typeof arg !== 'string' ||
(!/[\r\n]/.test(arg) && arg.length <= WINDOWS_MAX_INLINE_ARG_LENGTH)
) {
return arg;
}

try {
const dir = path.join(os.tmpdir(), 'squad-prompts');
mkdirSync(dir, { recursive: true });
const file = path.join(
dir,
`prompt-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}.md`,
);
writeFileSync(file, arg, 'utf8');
tempFiles?.push(file);
return `Your complete instructions are in the file ${file} - read that whole file first and follow it exactly as if it were this prompt.`;
} catch {
// If temp-file creation fails (e.g. read-only tmpdir), collapse newlines so cmd.exe does not truncate at line 1.
return arg.replace(/\r?\n+/g, ' ');
}
}

/**
* Prepare and escape an array of args for cmd.exe shell invocation, tracking
* any temporary prompt files so the caller can clean them up when the process exits.
*/
export function prepareArgsForSpawn(
args: string[],
isWindows: boolean = IS_WINDOWS,
): PreparedWindowsArgs {
if (!isWindows) {
return { args, tempFiles: [], cleanup: () => {} };
}
const tempFiles: string[] = [];
const prepared = args
.map(arg => spillPromptArgForCmd(arg, tempFiles))
.map(escapeForCmd);
const cleanup = (): void => {
for (const file of tempFiles) {
try {
if (existsSync(file)) unlinkSync(file);
} catch {
// Best-effort cleanup of temp prompt files
}
}
};
return { args: prepared, tempFiles, cleanup };
}

/**
* Escape an array of args for cmd.exe shell invocation.
* Only applies on Windows — returns args unchanged on other platforms.
*/
export function escapeArgs(args: string[]): string[] {
if (!IS_WINDOWS) return args;
return args.map(escapeForCmd);
export function escapeArgs(args: string[], isWindows: boolean = IS_WINDOWS): string[] {
if (!isWindows) return args;
return args.map(arg => spillPromptArgForCmd(arg)).map(escapeForCmd);
}

/**
Expand Down Expand Up @@ -242,14 +311,15 @@ export function spawnWithTimeout(
cwd: string,
timeoutMs: number,
): Promise<void> {
const safeArgs = escapeArgs(args);
const { args: safeArgs, cleanup } = prepareArgsForSpawn(args);
return new Promise<void>((resolve, reject) => {
execFile(cmd, safeArgs, {
cwd,
timeout: timeoutMs,
maxBuffer: 50 * 1024 * 1024,
shell: IS_WINDOWS,
}, (err) => {
cleanup();
if (err) {
const execErr = err as Error & { killed?: boolean };
reject(new Error(
Expand Down Expand Up @@ -281,7 +351,7 @@ export function spawnAgent(
pidTracking?: { tracker: NonNullable<WatchContext['pidTracker']>; label: string },
usageOutputFile?: string,
): Promise<AgentSpawnResult> {
const safeArgs = escapeArgs(args);
const { args: safeArgs, cleanup } = prepareArgsForSpawn(args);
return new Promise<AgentSpawnResult>((resolve) => {
const cp = execFile(
cmd,
Expand All @@ -293,6 +363,7 @@ export function spawnAgent(
shell: IS_WINDOWS,
},
(err) => {
cleanup();
const usageResult = usageOutputFile
? readCopilotUsageOutput(usageOutputFile)
: {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ export class ExecuteCapability implements WatchCapability {
: `agent failed: ${result.error}${contextSummary}`,
data: {
dispatched: eligible.length,
executed: result.success ? eligible.length : 0,
success: result.success,
...(contextUtilization ? { contextUtilization } : {}),
...(contextTelemetryError ? { contextTelemetryError } : {}),
Expand Down
10 changes: 8 additions & 2 deletions packages/squad-cli/src/cli/commands/watch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1044,8 +1044,14 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig

// Update executed count from execute capability
const execResult = execResults.get('execute');
if (execResult?.data?.['executed']) {
roundState.executed = execResult.data['executed'] as number;
const executedCount =
typeof execResult?.data?.['executed'] === 'number'
? (execResult.data['executed'] as number)
: typeof execResult?.data?.['dispatched'] === 'number'
? (execResult.data['dispatched'] as number)
: 0;
if (executedCount > 0) {
roundState.executed = executedCount;
}

// Phase 4: housekeeping (monitoring, retro, decision hygiene)
Expand Down
63 changes: 63 additions & 0 deletions test/cli/watch-agent-spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
mockFsMkdirSync,
mockFsReadFileSync,
mockFsUnlinkSync,
mockFsWriteFileSync,
} = vi.hoisted(() => ({
mockExecFile: vi.fn((...args: unknown[]) => {
const cb = args.find(a => typeof a === 'function') as
Expand All @@ -27,6 +28,7 @@ const {
mockFsMkdirSync: vi.fn(),
mockFsReadFileSync: vi.fn((): string => ''),
mockFsUnlinkSync: vi.fn(),
mockFsWriteFileSync: vi.fn(),
}));

vi.mock('node:child_process', () => ({
Expand All @@ -39,13 +41,18 @@ vi.mock('node:fs', () => ({
mkdirSync: mockFsMkdirSync,
readFileSync: mockFsReadFileSync,
unlinkSync: mockFsUnlinkSync,
writeFileSync: mockFsWriteFileSync,
}));

import {
WINDOWS_MAX_INLINE_ARG_LENGTH,
buildAgentCommand,
buildCopilotCommand,
escapeArgs,
parseCopilotUsageOutput,
prepareArgsForSpawn,
spawnAgent,
spillPromptArgForCmd,
withCopilotUsageOutput,
} from '../../packages/squad-cli/src/cli/commands/watch/agent-spawn.js';

Expand Down Expand Up @@ -257,3 +264,59 @@ describe('agent-spawn: Copilot usage capture', () => {
expect(missing.usageError).toBe('Copilot usage output was not created');
});
});

describe('agent-spawn: Windows multiline and oversized prompt handling (#2070)', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFsExistsSync.mockReturnValue(true);
});

it('spills multiline prompt arguments to a temp .md file and returns a single-line pointer', () => {
const multilinePrompt = 'You are an AI agent on a software development team.\n\nYOUR CHARTER:\n# Ralph\n\nHere are the current open squad issues:\n- #2070: Fix Windows prompt truncation';
const tempFiles: string[] = [];

const rewritten = spillPromptArgForCmd(multilinePrompt, tempFiles);

expect(tempFiles).toHaveLength(1);
expect(tempFiles[0]).toMatch(/squad-prompts[\\/]prompt-.*\.md$/);
expect(mockFsWriteFileSync).toHaveBeenCalledWith(tempFiles[0], multilinePrompt, 'utf8');
expect(rewritten).not.toMatch(/[\r\n]/);
expect(rewritten).toContain(tempFiles[0]!);
expect(rewritten).toContain('Your complete instructions are in the file');
});

it('spills single-line prompts exceeding WINDOWS_MAX_INLINE_ARG_LENGTH to a temp file', () => {
const longPrompt = 'x'.repeat(WINDOWS_MAX_INLINE_ARG_LENGTH + 100);
const tempFiles: string[] = [];

const rewritten = spillPromptArgForCmd(longPrompt, tempFiles);

expect(tempFiles).toHaveLength(1);
expect(mockFsWriteFileSync).toHaveBeenCalledWith(tempFiles[0], longPrompt, 'utf8');
expect(rewritten.length).toBeLessThan(500);
});

it('prepareArgsForSpawn escapes spilled prompt for cmd.exe on Windows and cleans up temp file', () => {
const multilinePrompt = 'Line 1\nLine 2\nLine 3';
const prepared = prepareArgsForSpawn(['-p', multilinePrompt, '--yolo'], true);

expect(prepared.tempFiles).toHaveLength(1);
expect(prepared.args[0]).toBe('-p');
expect(prepared.args[1]).toMatch(/^"Your complete instructions are in the file .+ - read that whole file first and follow it exactly as if it were this prompt\."$/);
expect(prepared.args[1]).not.toMatch(/[\r\n]/);
expect(prepared.args[2]).toBe('--yolo');

prepared.cleanup();
expect(mockFsUnlinkSync).toHaveBeenCalledWith(prepared.tempFiles[0]);
});

it('leaves args untouched when isWindows is false', () => {
const multilinePrompt = 'Line 1\nLine 2';
const prepared = prepareArgsForSpawn(['-p', multilinePrompt], false);

expect(prepared.tempFiles).toHaveLength(0);
expect(prepared.args).toEqual(['-p', multilinePrompt]);
expect(escapeArgs(['-p', multilinePrompt], false)).toEqual(['-p', multilinePrompt]);
});
});

19 changes: 14 additions & 5 deletions test/cli/watch-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const {
mockFsMkdirSync,
mockFsReadFileSync,
mockFsUnlinkSync,
mockFsWriteFileSync,
mockRmSync,
mockListModels,
mockDisconnect,
Expand All @@ -41,6 +42,7 @@ const {
mockFsMkdirSync: vi.fn(),
mockFsReadFileSync: vi.fn((): string => ''),
mockFsUnlinkSync: vi.fn(),
mockFsWriteFileSync: vi.fn(),
mockRmSync: vi.fn(),
mockListModels: vi.fn().mockResolvedValue([]),
mockDisconnect: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -71,6 +73,7 @@ vi.mock('node:fs', () => ({
mkdirSync: mockFsMkdirSync,
readFileSync: mockFsReadFileSync,
unlinkSync: mockFsUnlinkSync,
writeFileSync: mockFsWriteFileSync,
rmSync: mockRmSync,
}));

Expand Down Expand Up @@ -227,18 +230,18 @@ describe('Watch Capabilities', () => {
// TEMPLATE_MANIFEST destination ('ralph-instructions.md' under .squad/)
// matches the lookup in execute.ts.
mockFsExistsSync.mockImplementation((p: unknown) => {
return typeof p === 'string' && p.endsWith('.squad/ralph-instructions.md');
return typeof p === 'string' && p.replace(/\\/g, '/').endsWith('.squad/ralph-instructions.md');
});
const issues: ExecutableWorkItem[] = [
{ number: 1, title: 'Task', labels: [{ name: 'squad' }], assignees: [] },
];
const prompt = buildAgentPrompt(issues, '/some/repo');
expect(mockFsExistsSync).toHaveBeenCalledWith(
expect.stringContaining('.squad/ralph-instructions.md'),
expect.stringMatching(/\.squad[\\/]ralph-instructions\.md$/),
);
// Path must be constructed from teamRoot, not a global path
const [calledPath] = mockFsExistsSync.mock.calls[0] as [string];
expect(calledPath).toContain('/some/repo');
expect(calledPath.replace(/\\/g, '/')).toContain('/some/repo');
expect(prompt).toContain('Ralph, Go!');
});

Expand Down Expand Up @@ -370,6 +373,7 @@ describe('Watch Capabilities', () => {
expect(result.success).toBe(true);
expect(result.summary).toContain('agent dispatched');
expect(result.data?.dispatched).toBe(1);
expect(result.data?.executed).toBe(1);
});

it('reports estimated context utilization from Copilot usage output', async () => {
Expand Down Expand Up @@ -400,7 +404,9 @@ describe('Watch Capabilities', () => {
utilization: 0.5,
source: 'estimated',
});
expect(mockFsUnlinkSync).toHaveBeenCalledOnce();
expect(mockFsUnlinkSync).toHaveBeenCalledWith(
expect.stringMatching(/context-usage-.*\.json$/),
);
expect(mockDisconnect).toHaveBeenCalledOnce();
});

Expand All @@ -415,7 +421,10 @@ describe('Watch Capabilities', () => {

const args = mockExecFile.mock.calls.at(-1)?.[1] as string[];
expect(args).not.toContain('--usage-output-file');
expect(mockFsMkdirSync).not.toHaveBeenCalled();
expect(mockFsMkdirSync).not.toHaveBeenCalledWith(
expect.stringMatching(/[\\/]log$/),
expect.anything(),
);
});

it('still dispatches when usage capture cannot be initialized', async () => {
Expand Down
Loading