11// Gateway-first agent CLI implementation with embedded fallback for local/runtime failures.
22import { randomUUID } from "node:crypto" ;
3+ import { readFile } from "node:fs/promises" ;
4+ import { TextDecoder } from "node:util" ;
35import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion" ;
46import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce" ;
57import {
@@ -64,7 +66,8 @@ const GATEWAY_TIMEOUT_FALLBACK_SESSION_PREFIX = "gateway-fallback-";
6466const GATEWAY_TRANSIENT_CONNECT_RETRY_DELAYS_MS = [ 1_000 , 2_000 , 5_000 , 10_000 , 15_000 ] as const ;
6567
6668type AgentCliOpts = {
67- message : string ;
69+ message ?: string ;
70+ messageFile ?: string ;
6871 agent ?: string ;
6972 model ?: string ;
7073 to ?: string ;
@@ -85,6 +88,9 @@ type AgentCliOpts = {
8588 extraSystemPrompt ?: string ;
8689 local ?: boolean ;
8790} ;
91+ type AgentDispatchOpts = Omit < AgentCliOpts , "messageFile" > & {
92+ message : string ;
93+ } ;
8894
8995type AgentCliSignal = "SIGINT" | "SIGTERM" ;
9096type AgentCliProcessLike = {
@@ -110,6 +116,7 @@ const AGENT_CLI_SIGNAL_EXIT_CODES: Record<AgentCliSignal, number> = {
110116 SIGINT : 130 ,
111117 SIGTERM : 143 ,
112118} ;
119+ const MESSAGE_FILE_DECODER = new TextDecoder ( "utf-8" , { fatal : true } ) ;
113120
114121let embeddedAgentCommandPromise : Promise < EmbeddedAgentCommandModule [ "agentCommand" ] > | undefined ;
115122let agentSessionModulePromise : Promise < AgentSessionModule > | undefined ;
@@ -172,6 +179,63 @@ function protectJsonStdout(opts: Pick<AgentCliOpts, "json">): void {
172179 }
173180}
174181
182+ function missingAgentMessageError ( ) : Error {
183+ return new Error (
184+ `Missing message. Use ${ formatCliCommand ( 'openclaw agent --message "..." --agent <id>' ) } or ${ formatCliCommand ( "openclaw agent --message-file <path> --agent <id>" ) } .` ,
185+ ) ;
186+ }
187+
188+ function formatMessageFileReadFailure ( messageFile : string , err : unknown ) : string {
189+ const code =
190+ typeof ( err as { code ?: unknown } ) ?. code === "string" ? ( err as { code : string } ) . code : "" ;
191+ if ( code === "ENOENT" ) {
192+ return `Message file not found: ${ messageFile } ` ;
193+ }
194+ if ( code === "EISDIR" ) {
195+ return `Message file is a directory: ${ messageFile } ` ;
196+ }
197+ const message = err instanceof Error ? err . message : String ( err ) ;
198+ return `Unable to read message file ${ messageFile } : ${ message } ` ;
199+ }
200+
201+ async function readAgentMessageFile ( messageFile : string ) : Promise < string > {
202+ let buffer : Buffer ;
203+ try {
204+ buffer = await readFile ( messageFile ) ;
205+ } catch ( err ) {
206+ throw new Error ( formatMessageFileReadFailure ( messageFile , err ) , { cause : err } ) ;
207+ }
208+ try {
209+ return MESSAGE_FILE_DECODER . decode ( buffer ) . replace ( / ^ \uFEFF / , "" ) ;
210+ } catch {
211+ throw new Error ( `Message file must be valid UTF-8: ${ messageFile } ` ) ;
212+ }
213+ }
214+
215+ async function resolveAgentMessageOpts ( opts : AgentCliOpts ) : Promise < AgentDispatchOpts > {
216+ const { messageFile : rawMessageFile , ...rest } = opts ;
217+ const messageFile = rawMessageFile ?. trim ( ) ;
218+ const hasInlineMessage = opts . message !== undefined ;
219+ if ( hasInlineMessage && messageFile ) {
220+ throw new Error ( "Use either --message or --message-file, not both." ) ;
221+ }
222+ if ( rawMessageFile !== undefined && ! messageFile ) {
223+ throw new Error ( "--message-file must not be empty." ) ;
224+ }
225+ if ( messageFile ) {
226+ const message = await readAgentMessageFile ( messageFile ) ;
227+ if ( ! message . trim ( ) ) {
228+ throw new Error ( `Message file is empty: ${ messageFile } ` ) ;
229+ }
230+ return { ...rest , message } ;
231+ }
232+ const message = ( opts . message ?? "" ) . trim ( ) ;
233+ if ( ! message ) {
234+ throw missingAgentMessageError ( ) ;
235+ }
236+ return { ...rest , message } ;
237+ }
238+
175239function parseTimeoutSeconds ( opts : { cfg : OpenClawConfig ; timeout ?: string } ) {
176240 const raw =
177241 opts . timeout !== undefined
@@ -287,7 +351,9 @@ function validateExplicitSessionKeyForDispatch(
287351 }
288352}
289353
290- async function normalizeSessionKeyOptsForDispatch ( opts : AgentCliOpts ) : Promise < AgentCliOpts > {
354+ async function normalizeSessionKeyOptsForDispatch (
355+ opts : AgentDispatchOpts ,
356+ ) : Promise < AgentDispatchOpts > {
291357 const rawSessionKey = opts . sessionKey ?. trim ( ) ;
292358 const rawTo = opts . to ?. trim ( ) ;
293359 if ( ! rawSessionKey && ! opts . sessionId ?. trim ( ) && classifySessionKeyShape ( rawTo ) === "agent" ) {
@@ -567,7 +633,7 @@ function createGatewayTimeoutFallbackSession(agentId?: string): {
567633}
568634
569635async function resolveAgentIdForGatewayTimeoutFallback (
570- opts : AgentCliOpts ,
636+ opts : AgentDispatchOpts ,
571637) : Promise < string | undefined > {
572638 const explicitSessionKey = opts . sessionKey ?. trim ( ) ;
573639 if ( classifySessionKeyShape ( explicitSessionKey ) === "agent" ) {
@@ -619,17 +685,15 @@ function formatInFlightGatewayAgentMessage(response: GatewayAgentResponse): stri
619685}
620686
621687async function agentViaGatewayCommand (
622- opts : AgentCliOpts ,
688+ opts : AgentDispatchOpts ,
623689 runtime : RuntimeEnv ,
624690 signalBridge : ReturnType < typeof createAgentCliSignalBridge > ,
625691) {
626692 protectJsonStdout ( opts ) ;
627- const body = ( opts . message ?? "" ) . trim ( ) ;
693+ const body = opts . message ;
628694 const explicitSessionKey = opts . sessionKey ?. trim ( ) ;
629- if ( ! body ) {
630- throw new Error (
631- `Missing message. Use ${ formatCliCommand ( 'openclaw agent --message "..." --agent <id>' ) } or pass --to/--session-key/--session-id for an existing conversation.` ,
632- ) ;
695+ if ( ! body . trim ( ) ) {
696+ throw missingAgentMessageError ( ) ;
633697 }
634698 if ( ! opts . to && ! opts . sessionId && ! opts . agent && ! explicitSessionKey ) {
635699 throw new Error (
@@ -805,7 +869,7 @@ async function agentViaGatewayCommand(
805869}
806870
807871async function agentViaGatewayCommandWithTransientRetries (
808- opts : AgentCliOpts ,
872+ opts : AgentDispatchOpts ,
809873 runtime : RuntimeEnv ,
810874 signalBridge : ReturnType < typeof createAgentCliSignalBridge > ,
811875) {
@@ -838,18 +902,19 @@ export async function agentCliCommand(
838902 deps ?: AgentCliDeps ,
839903) {
840904 protectJsonStdout ( opts ) ;
905+ const messageOpts = await resolveAgentMessageOpts ( opts ) ;
841906 // `/compact` cannot run as a plain CLI agent turn: the slash-command handler
842907 // rejects CLI-originated senders, so the message would fall through to a
843908 // normal turn and exit 0 without compacting anything (issue #90640 Gap B).
844909 // Fail loudly and point at the first-class command instead of no-opping.
845- if ( isCompactControlCommand ( opts . message ) ) {
910+ if ( isCompactControlCommand ( messageOpts . message ) ) {
846911 runtime . error ?.(
847912 "Slash commands cannot be executed via --message from the CLI. Use: openclaw sessions compact <key>" ,
848913 ) ;
849914 runtime . exit ( 1 ) ;
850915 return undefined ;
851916 }
852- const dispatchOpts = await normalizeSessionKeyOptsForDispatch ( opts ) ;
917+ const dispatchOpts = await normalizeSessionKeyOptsForDispatch ( messageOpts ) ;
853918 validateExplicitSessionKeyForDispatch ( dispatchOpts ) ;
854919 const gatewayDispatchOpts = dispatchOpts . runId
855920 ? dispatchOpts
0 commit comments