Skip to content
Closed
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
18 changes: 17 additions & 1 deletion apps/cli/ai/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface AiAgentConfig {
model?: AiModelId;
maxTurns?: number;
resume?: string;
autoApprove?: boolean;
onAskUser?: ( questions: AskUserQuestion[] ) => Promise< Record< string, string > >;
}

Expand Down Expand Up @@ -48,7 +49,15 @@ process.on( 'unhandledRejection', ( reason ) => {
* Caller can iterate messages with `for await` and call `interrupt()` to stop.
*/
export function startAiAgent( config: AiAgentConfig ): Query {
const { prompt, env, model = DEFAULT_MODEL, maxTurns = 50, resume, onAskUser } = config;
const {
prompt,
env,
model = DEFAULT_MODEL,
maxTurns = 50,
resume,
autoApprove,
onAskUser,
} = config;
const resolvedEnv = env ?? { ...( process.env as Record< string, string > ) };

return query( {
Expand All @@ -69,6 +78,13 @@ export function startAiAgent( config: AiAgentConfig ): Query {
allowedTools: [ ...ALLOWED_TOOLS ],
permissionMode: 'default',
canUseTool: async ( toolName, input, metadata ) => {
if ( autoApprove ) {
return {
behavior: 'allow' as const,
updatedInput: input as Record< string, unknown >,
};
}

if ( toolName === 'AskUserQuestion' && onAskUser ) {
const typedInput = input as {
questions?: AskUserQuestion[];
Expand Down
61 changes: 61 additions & 0 deletions apps/cli/ai/json-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import type { AskUserQuestion } from 'cli/ai/agent';

export type JsonEventType =
| 'message'
| 'progress'
| 'info'
| 'error'
| 'question.asked'
| 'turn.started'
| 'turn.completed';

export type TurnCompletedStatus = 'success' | 'error' | 'paused' | 'max_turns';

export type JsonEvent =
| {
type: 'message';
timestamp: string;
message: SDKMessage;
}
| {
type: 'progress';
timestamp: string;
message: string;
}
| {
type: 'info';
timestamp: string;
message: string;
}
| {
type: 'error';
timestamp: string;
message: string;
}
| {
type: 'question.asked';
timestamp: string;
questions: Array< {
question: string;
options: AskUserQuestion[ 'options' ];
} >;
}
| {
type: 'turn.started';
timestamp: string;
}
| {
type: 'turn.completed';
timestamp: string;
sessionId: string;
status: TurnCompletedStatus;
usage?: {
numTurns: number;
costUsd?: number;
};
};

export function emitEvent( event: JsonEvent ): void {
process.stdout.write( JSON.stringify( event ) + '\n' );
}
148 changes: 148 additions & 0 deletions apps/cli/ai/output-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { DEFAULT_MODEL, type AiModelId, type AskUserQuestion } from 'cli/ai/agent';
import { emitEvent, type TurnCompletedStatus } from 'cli/ai/json-events';
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import type { AiProviderId } from 'cli/ai/providers';
import type { SiteInfo } from 'cli/ai/ui';

export type HandleMessageResult =
| { sessionId: string; success: boolean; maxTurnsReached?: undefined }
| { sessionId: string; maxTurnsReached: true; numTurns: number; costUsd?: number };

export interface AiOutputAdapter {
currentProvider: AiProviderId;
currentModel: AiModelId;
activeSite: SiteInfo | null;
onSiteSelected: ( ( site: SiteInfo ) => void ) | null;
onInterrupt: ( () => void ) | null;

start(): void;
stop(): void;
showWelcome(): void;

showInfo( message: string ): void;
showError( message: string ): void;
setStatusMessage( message: string | null ): void;
setLoaderMessage( message: string ): void;

beginAgentTurn(): void;
endAgentTurn(): void;
addUserMessage( text: string ): void;
handleMessage( message: SDKMessage ): HandleMessageResult | undefined;

waitForInput(): Promise< string >;
askUser( questions: AskUserQuestion[] ): Promise< Record< string, string > >;
openActiveSiteInBrowser(): Promise< boolean >;
}

export class JsonAdapter implements AiOutputAdapter {
currentProvider: AiProviderId = 'wpcom';
currentModel: AiModelId = DEFAULT_MODEL;
activeSite: SiteInfo | null = null;
onSiteSelected: ( ( site: SiteInfo ) => void ) | null = null;
onInterrupt: ( () => void ) | null = null;
onBeforeExit: ( () => Promise< void > ) | null = null;

private sessionId: string | undefined;

start(): void {
// No-op in JSON mode
}

stop(): void {
// No-op in JSON mode
}

showWelcome(): void {
// No-op in JSON mode
}

showInfo( message: string ): void {
emitEvent( { type: 'info', timestamp: new Date().toISOString(), message } );
}

showError( message: string ): void {
emitEvent( { type: 'error', timestamp: new Date().toISOString(), message } );
}

setStatusMessage(): void {
// No-op in JSON mode
}

setLoaderMessage( message: string ): void {
emitEvent( { type: 'progress', timestamp: new Date().toISOString(), message } );
}

beginAgentTurn(): void {
emitEvent( { type: 'turn.started', timestamp: new Date().toISOString() } );
}

endAgentTurn(): void {
// turn.completed is emitted separately with status and usage
}

addUserMessage( _text: string ): void {
// No-op in JSON mode — the service already knows the message it sent
}

handleMessage( message: SDKMessage ): HandleMessageResult | undefined {
emitEvent( { type: 'message', timestamp: new Date().toISOString(), message } );

if ( message.type === 'result' ) {
if ( message.subtype === 'success' ) {
this.sessionId = message.session_id;
return { sessionId: message.session_id, success: true };
}
if ( message.subtype === 'error_max_turns' ) {
this.sessionId = message.session_id;
return {
sessionId: message.session_id,
maxTurnsReached: true,
numTurns: message.num_turns,
costUsd: message.total_cost_usd,
};
}
this.sessionId = message.session_id;
return { sessionId: message.session_id, success: false };
}

return undefined;
}

emitTurnCompleted(
status: TurnCompletedStatus,
usage?: { numTurns: number; costUsd?: number }
): void {
emitEvent( {
type: 'turn.completed',
timestamp: new Date().toISOString(),
sessionId: this.sessionId ?? '',
status,
usage,
} );
}

waitForInput(): Promise< string > {
throw new Error( 'waitForInput is not available in JSON mode' );
}

async askUser( questions: AskUserQuestion[] ): Promise< Record< string, string > > {
emitEvent( {
type: 'question.asked',
timestamp: new Date().toISOString(),
questions: questions.map( ( q ) => ( {
question: q.question,
options: q.options,
} ) ),
} );
this.emitTurnCompleted( 'paused' );
await this.onBeforeExit?.();
process.exit( 0 );

// Unreachable, but satisfies TypeScript
return {};
}

openActiveSiteInBrowser(): Promise< boolean > {
throw new Error( 'openActiveSiteInBrowser is not available in JSON mode' );
}
}
3 changes: 2 additions & 1 deletion apps/cli/ai/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { getSiteUrl } from 'cli/lib/cli-config/sites';
import { isSiteRunning } from 'cli/lib/site-utils';
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import type { TodoWriteInput } from '@anthropic-ai/claude-agent-sdk/sdk-tools';
import type { AiOutputAdapter } from 'cli/ai/output-adapter';

const SITE_PICKER_TAB_LOCAL = 'local' as const;
const SITE_PICKER_TAB_REMOTE = 'remote' as const;
Expand Down Expand Up @@ -432,7 +433,7 @@ function normalizeToolUseResult( result: unknown ): ToolUseResultContent | null

return null;
}
export class AiChatUI {
export class AiChatUI implements AiOutputAdapter {
private tui: TUI;
private editor: PromptEditor;
private loader: Loader;
Expand Down
Loading
Loading