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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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