Skip to content
Draft
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
34 changes: 32 additions & 2 deletions src/main/core/conversations/impl/agent-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { AgentProviderId } from '@shared/agent-provider-registry';
import type { ProviderCustomConfig } from '@shared/app-settings';
import { providerConfigDefaults } from '@main/core/settings/schema';
import { buildAgentCommand } from './agent-command';
import { buildAgentCommand, wrapAgentCommandWithStdinPipe } from './agent-command';

function makeConfig(overrides: Partial<ProviderCustomConfig> = {}): ProviderCustomConfig {
return {
Expand Down Expand Up @@ -122,7 +122,7 @@ describe('buildAgentCommand', () => {
resumeArgs: string[];
}>([
{ providerId: 'cursor', freshArgs: ['Fix the bug'], resumeArgs: ['--resume'] },
{ providerId: 'opencode', freshArgs: [], resumeArgs: ['--continue'] },
{ providerId: 'opencode', freshArgs: ['--prompt', 'Fix the bug'], resumeArgs: ['--continue'] },
{ providerId: 'copilot', freshArgs: ['Fix the bug'], resumeArgs: ['--resume'] },
{
providerId: 'auggie',
Expand Down Expand Up @@ -198,3 +198,33 @@ describe('buildAgentCommand', () => {
).toThrow(/executable command prefixes/);
});
});

describe('wrapAgentCommandWithStdinPipe', () => {
it('pipes the prompt into the agent and reattaches /dev/tty', () => {
const result = wrapAgentCommandWithStdinPipe(
{ command: 'amp', args: ['--dangerously-allow-all'] },
'Fix the bug'
);

expect(result.command).toBe('bash');
expect(result.args).toEqual([
'-c',
"{ printf '%s\\n' 'Fix the bug'; exec </dev/tty; } | 'amp' '--dangerously-allow-all'",
]);
});

it('escapes prompts containing single quotes', () => {
const result = wrapAgentCommandWithStdinPipe({ command: 'amp', args: [] }, "it's broken");

expect(result.args[1]).toContain("'it'\\''s broken'");
});

it('preserves multi-line prompts so the agent receives them verbatim', () => {
const result = wrapAgentCommandWithStdinPipe(
{ command: 'amp', args: [] },
'line one\nline two'
);

expect(result.args[1]).toContain("'line one\nline two'");
});
});
12 changes: 12 additions & 0 deletions src/main/core/conversations/impl/agent-command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getProvider, type AgentProviderId } from '@shared/agent-provider-registry';
import type { ProviderCustomConfig } from '@shared/app-settings';
import { quoteShellArg } from '@main/utils/shellEscape';

export type AgentCommand = {
command: string;
Expand Down Expand Up @@ -137,3 +138,14 @@ export function buildAgentCommand({

return { command, args };
}

/**
* Wraps an agent argv with a bash invocation that pipes the prompt into the
* agent's stdin, then reattaches /dev/tty so the TUI can read keyboard input.
* Mirrors amp's documented `echo "msg" | amp` interactive entry pattern.
*/
export function wrapAgentCommandWithStdinPipe(agent: AgentCommand, prompt: string): AgentCommand {
const agentLine = [agent.command, ...agent.args].map(quoteShellArg).join(' ');
const shellLine = `{ printf '%s\\n' ${quoteShellArg(prompt)}; exec </dev/tty; } | ${agentLine}`;
return { command: 'bash', args: ['-c', shellLine] };
}
157 changes: 157 additions & 0 deletions src/main/core/conversations/impl/keystroke-injection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Conversation } from '@shared/conversations';
import type { Pty, PtyExitInfo } from '@main/core/pty/pty';
import { scheduleInitialPromptInjection } from './keystroke-injection';

function makeConversation(providerId: Conversation['providerId']): Conversation {
return {
id: 'conv-1',
projectId: 'proj-1',
taskId: 'task-1',
providerId,
title: '',
autoApprove: false,
lastInteractedAt: null,
isInitialConversation: false,
};
}

function makePty(): {
pty: Pty;
write: ReturnType<typeof vi.fn>;
emitData: (chunk: string) => void;
emitExit: (info?: PtyExitInfo) => void;
} {
const write = vi.fn();
let dataHandler: ((data: string) => void) | undefined;
let exitHandler: ((info: PtyExitInfo) => void) | undefined;
const pty: Pty = {
write,
resize: vi.fn(),
kill: vi.fn(),
onData: (handler: (data: string) => void) => {
dataHandler = handler;
},
onExit: (handler: (info: PtyExitInfo) => void) => {
exitHandler = handler;
},
} as unknown as Pty;
return {
pty,
write,
emitData: (chunk) => dataHandler?.(chunk),
emitExit: (info = { exitCode: 0, signal: undefined }) => exitHandler?.(info),
};
}

describe('scheduleInitialPromptInjection', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('injects after PTY output goes quiet', () => {
const { pty, write, emitData } = makePty();
scheduleInitialPromptInjection({
pty,
conversation: makeConversation('hermes'),
initialPrompt: 'Fix the bug',
isResuming: false,
});

emitData('booting…');
vi.advanceTimersByTime(200);
emitData('still booting…');
expect(write).not.toHaveBeenCalled();

vi.advanceTimersByTime(900);
expect(write).toHaveBeenCalledExactlyOnceWith('Fix the bug\r');
});

it('falls back to a max wait when no output ever arrives', () => {
const { pty, write } = makePty();
scheduleInitialPromptInjection({
pty,
conversation: makeConversation('hermes'),
initialPrompt: 'Fix the bug',
isResuming: false,
});

vi.advanceTimersByTime(15_000);
expect(write).toHaveBeenCalledExactlyOnceWith('Fix the bug\r');
});

it('wraps multi-line prompts in bracketed paste sequences', () => {
const { pty, write, emitData } = makePty();
scheduleInitialPromptInjection({
pty,
conversation: makeConversation('hermes'),
initialPrompt: 'line one\nline two',
isResuming: false,
});

emitData('ready');
vi.advanceTimersByTime(900);
expect(write).toHaveBeenCalledExactlyOnceWith('\x1b[200~line one\nline two\x1b[201~\r');
});

it('does nothing for providers without keystroke injection', () => {
const { pty, write, emitData } = makePty();
scheduleInitialPromptInjection({
pty,
conversation: makeConversation('claude'),
initialPrompt: 'Fix the bug',
isResuming: false,
});

emitData('ready');
vi.advanceTimersByTime(20_000);
expect(write).not.toHaveBeenCalled();
});

it('skips when resuming an existing session', () => {
const { pty, write, emitData } = makePty();
scheduleInitialPromptInjection({
pty,
conversation: makeConversation('hermes'),
initialPrompt: 'Fix the bug',
isResuming: true,
});

emitData('ready');
vi.advanceTimersByTime(20_000);
expect(write).not.toHaveBeenCalled();
});

it('skips when the prompt is empty or whitespace', () => {
const { pty, write, emitData } = makePty();
scheduleInitialPromptInjection({
pty,
conversation: makeConversation('hermes'),
initialPrompt: ' ',
isResuming: false,
});

emitData('ready');
vi.advanceTimersByTime(20_000);
expect(write).not.toHaveBeenCalled();
});

it('cancels injection when the PTY exits before idle', () => {
const { pty, write, emitData, emitExit } = makePty();
scheduleInitialPromptInjection({
pty,
conversation: makeConversation('hermes'),
initialPrompt: 'Fix the bug',
isResuming: false,
});

emitData('starting');
emitExit();
vi.advanceTimersByTime(20_000);
expect(write).not.toHaveBeenCalled();
});
});
69 changes: 69 additions & 0 deletions src/main/core/conversations/impl/keystroke-injection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { getProvider } from '@shared/agent-provider-registry';
import type { Conversation } from '@shared/conversations';
import { buildPromptInjectionPayload } from '@shared/prompt-injection';
import type { Pty } from '@main/core/pty/pty';
import { log } from '@main/lib/logger';

// Inject only after the TUI has produced output and stayed idle for a beat —
// fixed delays race the agent's startup (auth, sync, model load).
const QUIET_PERIOD_MS = 800;
const MAX_WAIT_MS = 15_000;

export function scheduleInitialPromptInjection(args: {
pty: Pty;
conversation: Conversation;
initialPrompt: string | undefined;
isResuming: boolean;
}): void {
if (args.isResuming) return;
if (!args.initialPrompt?.trim()) return;

const provider = getProvider(args.conversation.providerId);
if (!provider?.useKeystrokeInjection) return;

const payload = buildPromptInjectionPayload({
providerId: args.conversation.providerId,
text: args.initialPrompt,
});

let injected = false;
let sawAnyOutput = false;
let quietTimer: ReturnType<typeof setTimeout> | null = null;

const inject = () => {
if (injected) return;
injected = true;
if (quietTimer) clearTimeout(quietTimer);
clearTimeout(maxWaitTimer);
try {
args.pty.write(`${payload}\r`);
} catch (error) {
log.warn('ConversationProvider: failed to inject initial prompt', {
providerId: args.conversation.providerId,
conversationId: args.conversation.id,
error: String(error),
});
}
};

const maxWaitTimer = setTimeout(inject, MAX_WAIT_MS);

args.pty.onData(() => {
if (injected) return;
sawAnyOutput = true;
if (quietTimer) clearTimeout(quietTimer);
quietTimer = setTimeout(inject, QUIET_PERIOD_MS);
});

args.pty.onExit(() => {
injected = true;
if (quietTimer) clearTimeout(quietTimer);
clearTimeout(maxWaitTimer);
if (!sawAnyOutput) {
log.warn('ConversationProvider: PTY exited before any output; prompt not injected', {
providerId: args.conversation.providerId,
conversationId: args.conversation.id,
});
}
});
}
15 changes: 11 additions & 4 deletions src/main/core/conversations/impl/local-conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import { appSettingsService } from '@main/core/settings/settings-service';
import { events } from '@main/lib/events';
import { log } from '@main/lib/logger';
import { telemetryService } from '@main/lib/telemetry';
import { buildAgentCommand } from './agent-command';
import { buildAgentCommand, wrapAgentCommandWithStdinPipe } from './agent-command';
import { scheduleInitialPromptInjection } from './keystroke-injection';
import { resolveProviderEnv } from './provider-env';

const DEFAULT_COLS = 80;
Expand Down Expand Up @@ -92,14 +93,20 @@ export class LocalConversationProvider implements ConversationProvider {
await this.prepareHookConfig(conversation.providerId);

const providerConfig = await providerOverrideSettings.getItem(conversation.providerId);
const { command, args } = buildAgentCommand({
const baseCommand = buildAgentCommand({
providerId: conversation.providerId,
providerConfig,
autoApprove: conversation.autoApprove,
sessionId: conversation.id,
isResuming,
initialPrompt,
});
const providerDef = getProvider(conversation.providerId);
const useStdinPipe =
!isResuming && !!initialPrompt?.trim() && !!providerDef?.initialPromptViaStdinPipe;
const { command, args } = useStdinPipe
? wrapAgentCommandWithStdinPipe(baseCommand, initialPrompt!.trim())
: baseCommand;
const providerEnv = resolveProviderEnv(providerConfig);

const tmuxSessionName = this.tmux ? makeTmuxSessionName(sessionId) : undefined;
Expand Down Expand Up @@ -141,8 +148,7 @@ export class LocalConversationProvider implements ConversationProvider {
});

const hookActive = port > 0;
const provider = getProvider(conversation.providerId);
const useHooksOnly = hookActive && provider?.supportsHooks;
const useHooksOnly = hookActive && providerDef?.supportsHooks;

if (!useHooksOnly) {
wireAgentClassifier({
Expand Down Expand Up @@ -200,6 +206,7 @@ export class LocalConversationProvider implements ConversationProvider {

ptySessionRegistry.register(sessionId, pty);
this.sessions.set(sessionId, pty);
scheduleInitialPromptInjection({ pty, conversation, initialPrompt, isResuming });
telemetryService.capture('agent_run_started', {
provider: conversation.providerId,
project_id: conversation.projectId,
Expand Down
13 changes: 11 additions & 2 deletions src/main/core/conversations/impl/ssh-conversation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getProvider } from '@shared/agent-provider-registry';
import type { AgentSessionConfig } from '@shared/agent-session';
import type { Conversation } from '@shared/conversations';
import { agentSessionExitedChannel } from '@shared/events/agentEvents';
Expand All @@ -17,7 +18,8 @@ import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy';
import { events } from '@main/lib/events';
import { log } from '@main/lib/logger';
import { telemetryService } from '@main/lib/telemetry';
import { buildAgentCommand } from './agent-command';
import { buildAgentCommand, wrapAgentCommandWithStdinPipe } from './agent-command';
import { scheduleInitialPromptInjection } from './keystroke-injection';
import { resolveProviderEnv } from './provider-env';

const DEFAULT_COLS = 80;
Expand Down Expand Up @@ -89,14 +91,20 @@ export class SshConversationProvider implements ConversationProvider {
});

const providerConfig = await providerOverrideSettings.getItem(conversation.providerId);
const { command, args } = buildAgentCommand({
const baseCommand = buildAgentCommand({
providerId: conversation.providerId,
providerConfig,
autoApprove: conversation.autoApprove,
sessionId: conversation.id,
isResuming,
initialPrompt,
});
const providerDef = getProvider(conversation.providerId);
const useStdinPipe =
!isResuming && !!initialPrompt?.trim() && !!providerDef?.initialPromptViaStdinPipe;
const { command, args } = useStdinPipe
? wrapAgentCommandWithStdinPipe(baseCommand, initialPrompt!.trim())
: baseCommand;
const providerEnv = resolveProviderEnv(providerConfig);

const tmuxSessionName = this.tmux ? makeTmuxSessionName(sessionId) : undefined;
Expand Down Expand Up @@ -194,6 +202,7 @@ export class SshConversationProvider implements ConversationProvider {

ptySessionRegistry.register(sessionId, pty);
this.sessions.set(sessionId, pty);
scheduleInitialPromptInjection({ pty, conversation, initialPrompt, isResuming });
telemetryService.capture('agent_run_started', {
provider: conversation.providerId,
project_id: conversation.projectId,
Expand Down
Loading
Loading