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
57 changes: 12 additions & 45 deletions packages/cli/src/cli-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ import {
injectClaudeAgentRelayMcpConfig,
injectCodexSubcommandArgs,
resolveAgentRelayBrokerMcpArgs,
resolveRelayMcpFromEnv as resolveRelayMcpFromEnvShared
resolveRelayMcpFromEnv as resolveRelayMcpFromEnvShared,
spawnNonInteractiveAndCapture
} from '@agentworkforce/runtime';
import {
listBuiltInPersonas,
Expand Down Expand Up @@ -4502,50 +4503,16 @@ async function runPersonaImprover(args: {
const timeoutMs = selection.harnessSettings.timeoutSeconds
? selection.harnessSettings.timeoutSeconds * 1000
: undefined;
let captureResult: { exitCode: number | null; stderr: string };
let captureResult: { exitCode: number; stderr: string };
try {
captureResult = await new Promise<{ exitCode: number | null; stderr: string }>(
(resolveResult) => {
const child = spawn(spec.bin, [...spec.args], {
cwd,
env: childEnv,
stdio: ['ignore', 'pipe', 'pipe'],
shell: false
});
let stderrBuf = '';
let forceKillTimeout: NodeJS.Timeout | undefined;
child.stdout?.setEncoding('utf8');
child.stderr?.setEncoding('utf8');
child.stderr?.on('data', (chunk: string) => {
stderrBuf += chunk;
});
// SIGTERM first; if the harness traps or ignores it, escalate to
// SIGKILL after a 1s grace so the timeout is actually enforced.
const timeout =
timeoutMs !== undefined
? setTimeout(() => {
child.kill('SIGTERM');
forceKillTimeout = setTimeout(() => {
if (!child.killed) child.kill('SIGKILL');
}, 1000);
}, timeoutMs)
: undefined;
const clearTimers = () => {
if (timeout) clearTimeout(timeout);
if (forceKillTimeout) clearTimeout(forceKillTimeout);
};
child.on('error', (err) => {
clearTimers();
resolveResult({ exitCode: 1, stderr: `${stderrBuf}${err.message}\n` });
});
child.on('close', (code, signal) => {
clearTimers();
const exitCode =
typeof code === 'number' ? code : signal ? signalExitCode(signal) : null;
resolveResult({ exitCode, stderr: stderrBuf });
});
}
);
captureResult = await spawnNonInteractiveAndCapture({
bin: spec.bin,
args: [...spec.args],
prompt: spec.prompt,
cwd,
env: childEnv,
...(timeoutMs !== undefined ? { timeoutMs } : {})
});
} finally {
// Always restore — a synchronous spawn() throw or unexpected promise
// rejection must not leave orphaned `opencode.json` (or any other
Expand All @@ -4554,7 +4521,7 @@ async function runPersonaImprover(args: {
}
if (captureResult.exitCode !== 0) {
throw new Error(
`improver exited with code=${captureResult.exitCode ?? 'null'}.${captureResult.stderr ? ` stderr: ${captureResult.stderr.slice(0, 400)}` : ''}`
`improver exited with code=${captureResult.exitCode}.${captureResult.stderr ? ` stderr: ${captureResult.stderr.slice(0, 400)}` : ''}`
);
}
let raw: string;
Expand Down
11 changes: 11 additions & 0 deletions packages/persona-kit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Breaking Changes

- `buildNonInteractiveSpec` no longer includes the one-shot task in `args`.
Consumers must deliver the new required `prompt` descriptor through stdin or
a prompt file as directed; spawning only `bin` and `args` now omits the task.

### Fixed

- Keep large one-shot harness prompts off argv to avoid Linux
`MAX_ARG_STRLEN`/`E2BIG` failures.

## [4.1.23] - 2026-07-15

### Added
Expand Down
1 change: 1 addition & 0 deletions packages/persona-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export {
type BuildInteractiveSpecInput,
type InteractiveConfigFile,
type InteractiveSpec,
type NonInteractivePrompt,
type NonInteractiveSpec,
type RelayMcpConfig,
resolvePersonaRelayMcp,
Expand Down
63 changes: 53 additions & 10 deletions packages/persona-kit/src/interactive-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,9 +343,9 @@ test('opencode non-interactive spec omits cwd/model flags and normalizes the age
'--format',
'default',
'--title',
'daily-ship',
'say pong'
'daily-ship'
]);
assert.deepEqual(result.prompt, { mode: 'stdin', contents: 'say pong' });
assert.ok(!result.args.includes('--dir'));
assert.ok(!result.args.includes('--model'));

Expand Down Expand Up @@ -413,7 +413,7 @@ test('grok warns for unsupported permission fields', () => {
]);
});

test('grok non-interactive spec uses single-shot mode with cwd and always-approve', () => {
test('grok non-interactive spec uses a prompt file with cwd and always-approve', () => {
const result = buildNonInteractiveSpec({
harness: 'grok',
personaId: 'daily-ship',
Expand All @@ -432,10 +432,13 @@ test('grok non-interactive spec uses single-shot mode with cwd and always-approv
'plain',
'--cwd',
'/tmp/project',
'--always-approve',
'--single',
'Reply pong.\n\nUser task:\nsay pong'
'--always-approve'
]);
assert.deepEqual(result.prompt, {
mode: 'file',
contents: 'Reply pong.\n\nUser task:\nsay pong',
flag: '--prompt-file'
});
assert.deepEqual(result.configFiles, [
{ path: 'AGENTS.md', contents: 'Reply pong.\n' }
]);
Expand All @@ -458,10 +461,13 @@ test('grok non-interactive spec still adds always-approve for unsupported permis
'grok-build-0.1',
'--output-format',
'plain',
'--always-approve',
'--single',
'say pong'
'--always-approve'
]);
assert.deepEqual(result.prompt, {
mode: 'file',
contents: 'say pong',
flag: '--prompt-file'
});
assert.deepEqual(result.warnings, [
'persona declares permissions.mode "plan" but the grok harness only supports bypassPermissions via --always-approve; proceeding with Grok defaults.'
]);
Expand Down Expand Up @@ -534,7 +540,44 @@ test('claude non-interactive spec omits unsupported --name while preserving run
assert.equal(args[outputIdx + 1], 'text');
assert.ok(promptIdx >= 0);
assert.equal(args[promptIdx + 1], 'Reply pong.');
assert.equal(args[args.length - 1], 'say pong');
assert.ok(!args.includes('say pong'));
assert.deepEqual(result.prompt, { mode: 'stdin', contents: 'say pong' });
});

test('non-interactive tasks stay off argv and argv remains below Linux per-argument limits', () => {
const marker = 'TASK_CANARY_d7e9b9b0';
const task = `${marker}\n${'x'.repeat(200_000)}`;
const cases = [
{ harness: 'claude' as const, model: 'claude-sonnet-4-6', promptMode: 'stdin' },
{ harness: 'codex' as const, model: 'openai-codex/gpt-5.3-codex', promptMode: 'stdin' },
{ harness: 'opencode' as const, model: 'opencode/minimax-m2.5', promptMode: 'stdin' },
{ harness: 'grok' as const, model: 'grok-build-0.1', promptMode: 'file' }
];

for (const entry of cases) {
const result = buildNonInteractiveSpec({
harness: entry.harness,
personaId: 'argv-budget-test',
model: entry.model,
systemPrompt: 'short system prompt',
task,
name: 'argv-budget-test',
workingDirectory: '/tmp/project'
});
const argvBytes = [result.bin, ...result.args].reduce(
(total, arg) => total + Buffer.byteLength(arg) + 1,
0
);
const largestArgBytes = Math.max(...result.args.map((arg) => Buffer.byteLength(arg)));

assert.equal(result.prompt.mode, entry.promptMode, entry.harness);
assert.ok(result.prompt.contents.includes(marker), entry.harness);
assert.ok(result.args.every((arg) => !arg.includes(marker)), entry.harness);
// Linux rejects any single argv element at 128 KiB (MAX_ARG_STRLEN),
// independently of the larger combined argv + env budget.
assert.ok(largestArgBytes < 128 * 1024, `${entry.harness}: largest arg=${largestArgBytes}`);
assert.ok(argvBytes < 128 * 1024, `${entry.harness}: argv total=${argvBytes}`);
}
});

test('opencode omits agent.prompt when systemPrompt is empty', () => {
Expand Down
39 changes: 27 additions & 12 deletions packages/persona-kit/src/interactive-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,28 +662,42 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact
}
}

/** Off-argv delivery for a one-shot harness prompt. Keeping the potentially
* large per-run task out of `args` avoids Linux's 128 KiB single-argument
* limit as well as the combined argv + env limit. */
export type NonInteractivePrompt =
| { readonly mode: 'stdin'; readonly contents: string }
| {
readonly mode: 'file';
readonly contents: string;
readonly flag: '--prompt-file';
};

/** Result of translating a persona's runtime into a one-shot, non-interactive
* spawnable command. Caller writes `configFiles` before spawning. */
* spawnable command. Caller writes `configFiles` and delivers `prompt` using
* its declared off-argv mode before spawning. */
export interface NonInteractiveSpec {
bin: string;
args: readonly string[];
prompt: NonInteractivePrompt;
configFiles: readonly InteractiveConfigFile[];
warnings: readonly string[];
}

/**
* Translate a persona's runtime into a non-interactive, one-shot command.
* Layers harness-specific non-interactive flags on top of {@link buildInteractiveSpec},
* then appends the user task. Pure — no I/O.
* then declares how the caller must deliver the user task off argv. Pure — no I/O.
*
* - `claude`: appends `--print --output-format text <task>`.
* - `codex`: prefixes `exec`, appends `--skip-git-repo-check`, then a prompt
* built from any `initialPrompt` joined with the user task.
* - `claude`: appends `--print --output-format text`; task is written to stdin.
* - `codex`: prefixes `exec`, appends `--skip-git-repo-check -`; stdin receives
* a prompt built from any `initialPrompt` joined with the user task.
* - `opencode`: prefixes `run`, appends `--format default
* [--title <n>] <task>`; model selection stays in the generated agent config.
* [--title <n>]`; task is written to stdin and model selection stays in the
* generated agent config.
* - `grok`: appends `--output-format plain [--cwd <cwd>] --always-approve
* --single <prompt>`, where prompt includes the persona system prompt plus
* the one-shot task.
* --prompt-file <temp-path>` at spawn time, where the temporary file contains
* the persona system prompt plus the one-shot task.
*/
export function buildNonInteractiveSpec(
input: BuildInteractiveSpecInput & {
Expand All @@ -696,10 +710,10 @@ export function buildNonInteractiveSpec(
switch (input.harness) {
case 'claude': {
const args = [...interactive.args, '--print', '--output-format', 'text'];
args.push(input.task);
return {
bin: interactive.bin,
args,
prompt: { mode: 'stdin', contents: input.task },
configFiles: interactive.configFiles,
warnings: interactive.warnings
};
Expand All @@ -710,7 +724,8 @@ export function buildNonInteractiveSpec(
: input.task;
return {
bin: interactive.bin,
args: ['exec', ...interactive.args, '--skip-git-repo-check', prompt],
args: ['exec', ...interactive.args, '--skip-git-repo-check', '-'],
prompt: { mode: 'stdin', contents: prompt },
configFiles: interactive.configFiles,
warnings: interactive.warnings
};
Expand All @@ -723,10 +738,10 @@ export function buildNonInteractiveSpec(
// separately, and `opencode run` does not support `--dir`.
const args = ['run', ...interactive.args, '--format', 'default'];
if (input.name) args.push('--title', input.name);
args.push(input.task);
return {
bin: interactive.bin,
args,
prompt: { mode: 'stdin', contents: input.task },
configFiles: interactive.configFiles,
warnings: interactive.warnings
};
Expand All @@ -740,10 +755,10 @@ export function buildNonInteractiveSpec(
if (!args.includes('--always-approve')) {
args.push('--always-approve');
}
args.push('--single', prompt);
return {
bin: interactive.bin,
args,
prompt: { mode: 'file', contents: prompt, flag: '--prompt-file' },
configFiles: interactive.configFiles,
warnings: interactive.warnings
};
Expand Down
5 changes: 5 additions & 0 deletions packages/runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Deliver one-shot Claude, Codex, and OpenCode prompts through stdin and Grok
prompts through a private temporary file instead of oversized argv elements.

## [4.1.24] - 2026-07-16

### Fixed
Expand Down
Loading
Loading