Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6979e40
Add JSON output support for AI commands
wesleyfantinel Mar 25, 2026
21c2375
Update `canUseTool` to use empty input updates during `autoApprove` h…
wesleyfantinel Mar 25, 2026
5804af6
Merge branch 'trunk' into add/cli-studio-ai-json-flag
wesleyfantinel Mar 28, 2026
7da4fcd
Merge branch 'trunk' into add/cli-studio-ai-json-flag
wesleyfantinel Mar 30, 2026
c74ffe6
Merge branch 'trunk' into add/cli-studio-ai-json-flag
wesleyfantinel Apr 1, 2026
b82d08f
Fix TypeScript errors in AI JSON mode output adapter
wesleyfantinel Apr 1, 2026
38decee
Use DEFAULT_MODEL constant in JsonAdapter
wesleyfantinel Apr 7, 2026
f70f01c
Remove InteractiveAdapter, make AiChatUI implement AiOutputAdapter di…
wesleyfantinel Apr 7, 2026
ec678b3
Decouple autoApprove from --json as a separate CLI flag
wesleyfantinel Apr 7, 2026
eb1284d
Add cleanup before process.exit in JsonAdapter.askUser()
wesleyfantinel Apr 7, 2026
e308fd0
Fix interactive mode with initial message exiting after first reply
wesleyfantinel Apr 7, 2026
2267559
Merge trunk and resolve conflicts
wesleyfantinel Apr 8, 2026
3f326ae
Merge branch 'trunk' into add/cli-studio-ai-json-flag
wesleyfantinel Apr 8, 2026
d2ebb24
Merge branch 'trunk' into add/cli-studio-ai-json-flag
Apr 8, 2026
f38d031
Skip legacy command notice in JSON mode
Apr 8, 2026
22642fb
Merge branch 'trunk' into add/cli-studio-ai-json-flag
wesleyfantinel Apr 8, 2026
9b57000
Update command alias from `ai` to `code` and adjust description for c…
Apr 8, 2026
caf3443
Merge remote-tracking branch 'origin/trunk' into add/cli-studio-ai-js…
wesleyfantinel Apr 13, 2026
8967b96
Merge branch 'trunk' into add/cli-studio-ai-json-flag
wesleyfantinel Apr 13, 2026
d0692d7
Harden JSON mode: replace process.exit with exitCode, fix type union …
sejas Apr 14, 2026
52e5a81
Fix screenshot browser close
sejas Apr 14, 2026
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/cli/ai/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface AiAgentConfig {
model?: AiModelId;
maxTurns?: number;
resume?: string;
autoApprove?: boolean;
activeSite?: SiteInfo | null;
wpcomAccessToken?: string;
onAskUser?: ( questions: AskUserQuestion[] ) => Promise< Record< string, string > >;
Expand Down Expand Up @@ -59,6 +60,7 @@ export function startAiAgent( config: AiAgentConfig ): Query {
model = DEFAULT_MODEL,
maxTurns = 50,
resume,
autoApprove,
activeSite,
wpcomAccessToken,
onAskUser,
Expand Down Expand Up @@ -108,6 +110,13 @@ export function startAiAgent( config: AiAgentConfig ): Query {
allowedTools,
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
12 changes: 12 additions & 0 deletions apps/cli/ai/browser-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ export async function getSharedBrowser(): Promise< Browser > {
return browserPromise;
}

/**
* Close the shared browser instance (if any) so the Node.js event loop can
* drain and the process can exit naturally without calling process.exit().
*/
export async function closeSharedBrowser(): Promise< void > {
if ( browserPromise ) {
const browser = await browserPromise;
await browser.close().catch( () => {} );
browserPromise = null;
}
}

/** Collect diagnostic info from the page to help debug editor load failures. */
async function getPageDiagnostics( page: Page ): Promise< string > {
try {
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' );
}
173 changes: 173 additions & 0 deletions apps/cli/ai/output-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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 =
| { type: 'result'; sessionId: string; success: boolean }
| { type: 'max_turns'; sessionId: string; 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;
showOnboarding(): void;
showCapabilities(): void;
showSuccess( message: string ): void;
showProgress( message: string ): void;
setBusy( active: boolean ): 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
}

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

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

showSuccess( _message: string ): void {
// No-op in JSON mode
}

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

setBusy( _active: boolean ): 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' ) {
this.sessionId = message.session_id;
if ( message.subtype === 'error_max_turns' ) {
return {
type: 'max_turns',
sessionId: message.session_id,
numTurns: message.num_turns,
costUsd: message.total_cost_usd,
};
}
return {
type: 'result',
sessionId: message.session_id,
success: message.subtype === 'success',
};
}

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.exitCode = 0;

// Return a never-resolving promise to halt execution while letting
// the event loop drain naturally (flushes stdout, completes async I/O).
return new Promise< Record< string, string > >( () => {} );
}

openActiveSiteInBrowser(): Promise< boolean > {
throw new Error( 'openActiveSiteInBrowser is not available in JSON mode' );
}
}
18 changes: 7 additions & 11 deletions 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 { getSitesRunningStatus, 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, HandleMessageResult } 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 @@ -433,7 +434,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 Expand Up @@ -2027,12 +2028,7 @@ export class AiChatUI {
* Process an SDK message and update the UI.
* Returns session result when the agent turn is complete.
*/
handleMessage(
message: SDKMessage
):
| { sessionId: string; success: boolean; maxTurnsReached?: undefined }
| { sessionId: string; maxTurnsReached: true; numTurns: number }
| undefined {
handleMessage( message: SDKMessage ): HandleMessageResult | undefined {
switch ( message.type ) {
case 'assistant': {
for ( const block of message.message.content ) {
Expand Down Expand Up @@ -2142,7 +2138,7 @@ export class AiChatUI {
message.num_turns
)
);
return { sessionId: message.session_id, success: true };
return { type: 'result', sessionId: message.session_id, success: true };
}

// User-initiated interruption: show friendly message instead of error
Expand All @@ -2162,7 +2158,7 @@ export class AiChatUI {
thinkingSec
)
);
return { sessionId: message.session_id, success: false };
return { type: 'result', sessionId: message.session_id, success: false };
}

// Build detailed error message
Expand All @@ -2172,8 +2168,8 @@ export class AiChatUI {
}
if ( message.subtype === 'error_max_turns' ) {
return {
type: 'max_turns',
sessionId: message.session_id,
maxTurnsReached: true,
numTurns: message.num_turns,
};
} else if ( message.subtype ) {
Expand All @@ -2191,7 +2187,7 @@ export class AiChatUI {
}
}
this.showError( parts.length > 0 ? parts.join( '\n' ) : __( 'Unknown error' ) );
return { sessionId: message.session_id, success: false };
return { type: 'result', sessionId: message.session_id, success: false };
}
}
return undefined;
Expand Down
Loading
Loading