crewx-agent-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1085 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { once } from 'node:events';
3
+ import { chmod, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { AgentAdapterSchema } from 'crewx-agent-protocol';
7
+ import { CLI_VERSION, DEFAULT_RUN_TIMEOUT_MS, MAX_AGENT_OUTPUT_BYTES } from './constants.js';
8
+ import { CliError } from './errors.js';
9
+ function terminateChild(child, signal) {
10
+ if (process.platform !== 'win32' && child.pid) {
11
+ try {
12
+ process.kill(-child.pid, signal);
13
+ return;
14
+ }
15
+ catch {
16
+ // Fall back to signalling the direct child when the process group is gone.
17
+ }
18
+ }
19
+ try {
20
+ child.kill(signal);
21
+ }
22
+ catch {
23
+ // The child may have exited between the state check and the signal.
24
+ }
25
+ }
26
+ function observeChild(child) {
27
+ let closed = false;
28
+ return {
29
+ close: new Promise((resolve) => {
30
+ child.once('close', (exitCode, signal) => {
31
+ closed = true;
32
+ resolve([exitCode, signal]);
33
+ });
34
+ }),
35
+ isClosed: () => closed,
36
+ };
37
+ }
38
+ function destroyChildPipes(child) {
39
+ child.stdin.destroy();
40
+ child.stdout.destroy();
41
+ child.stderr.destroy();
42
+ }
43
+ async function terminateAndWait(child, lifecycle, graceMs) {
44
+ if (lifecycle.isClosed() || child.exitCode !== null)
45
+ return;
46
+ terminateChild(child, 'SIGTERM');
47
+ let graceTimer;
48
+ await Promise.race([
49
+ lifecycle.close,
50
+ new Promise((resolve) => {
51
+ graceTimer = setTimeout(resolve, graceMs);
52
+ }),
53
+ ]);
54
+ if (graceTimer)
55
+ clearTimeout(graceTimer);
56
+ if (lifecycle.isClosed() || child.exitCode !== null)
57
+ return;
58
+ terminateChild(child, 'SIGKILL');
59
+ let killTimer;
60
+ await Promise.race([
61
+ lifecycle.close,
62
+ new Promise((resolve) => {
63
+ killTimer = setTimeout(resolve, 1_000);
64
+ }),
65
+ ]);
66
+ if (killTimer)
67
+ clearTimeout(killTimer);
68
+ if (!lifecycle.isClosed())
69
+ destroyChildPipes(child);
70
+ }
71
+ export function parseAdapter(value) {
72
+ const result = AgentAdapterSchema.safeParse(value);
73
+ if (!result.success) {
74
+ throw new CliError(`Unknown adapter "${value}". Choose codex, claude, pi, hermes, or openclaw.`);
75
+ }
76
+ return result.data;
77
+ }
78
+ /** Adapter commands are represented as argv and are never evaluated by a shell. */
79
+ export function buildAdapterInvocation(adapter, profile = {}, codingCommand) {
80
+ if (codingCommand?.trim())
81
+ return buildCustomInvocation(codingCommand);
82
+ const modelArgs = profile.model ? ['--model', profile.model] : [];
83
+ const restricted = profile.chatOnly === true || profile.permissionPreset === 'read_only';
84
+ const codexPermissionArgs = profile.permissionPreset === 'full_access'
85
+ ? ['--dangerously-bypass-approvals-and-sandbox']
86
+ : [
87
+ '-c',
88
+ 'approval_policy="never"',
89
+ '--sandbox',
90
+ restricted ? 'read-only' : 'workspace-write',
91
+ ];
92
+ switch (adapter) {
93
+ case 'codex':
94
+ return {
95
+ command: 'codex',
96
+ args: [
97
+ 'exec',
98
+ '--json',
99
+ '--color',
100
+ 'never',
101
+ '--skip-git-repo-check',
102
+ '--ephemeral',
103
+ ...modelArgs,
104
+ ...codexPermissionArgs,
105
+ '-',
106
+ ],
107
+ output: 'jsonl',
108
+ prompt: { type: 'stdin' },
109
+ };
110
+ case 'claude':
111
+ return {
112
+ command: 'claude',
113
+ args: [
114
+ '--print',
115
+ '--output-format',
116
+ 'stream-json',
117
+ '--verbose',
118
+ '--no-session-persistence',
119
+ ...modelArgs,
120
+ ...(restricted
121
+ ? ['--permission-mode', 'plan']
122
+ : profile.permissionPreset === 'full_access'
123
+ ? ['--dangerously-skip-permissions']
124
+ : ['--permission-mode', 'acceptEdits']),
125
+ ],
126
+ output: 'jsonl',
127
+ prompt: { type: 'stdin' },
128
+ };
129
+ case 'pi':
130
+ return {
131
+ command: 'pi',
132
+ args: [
133
+ '--mode',
134
+ 'json',
135
+ '--print',
136
+ '--no-approve',
137
+ '--no-session',
138
+ ...modelArgs,
139
+ ...(restricted ? ['--tools', 'read,grep,find,ls'] : []),
140
+ ],
141
+ output: 'jsonl',
142
+ prompt: { type: 'stdin' },
143
+ };
144
+ case 'hermes':
145
+ return {
146
+ command: 'hermes',
147
+ args: ['acp'],
148
+ output: 'jsonl',
149
+ prompt: { type: 'stdin' },
150
+ };
151
+ case 'openclaw':
152
+ if (!profile.runtimeAgentId?.trim()) {
153
+ throw new CliError('OpenClaw requires an explicit configured agent ID.');
154
+ }
155
+ return {
156
+ command: 'openclaw',
157
+ args: [
158
+ 'agent',
159
+ '--local',
160
+ '--agent',
161
+ profile.runtimeAgentId.trim(),
162
+ '--session-key',
163
+ profile.sessionKey ?? 'main',
164
+ '--json',
165
+ '--timeout',
166
+ '0',
167
+ ...modelArgs,
168
+ ],
169
+ output: 'json',
170
+ prompt: { type: 'file', flag: '--message-file' },
171
+ };
172
+ }
173
+ }
174
+ export function parseCodingCommand(value) {
175
+ const tokens = [];
176
+ let token = '';
177
+ let quote;
178
+ let escaped = false;
179
+ let started = false;
180
+ for (const character of value.trim()) {
181
+ if (escaped) {
182
+ token += character;
183
+ escaped = false;
184
+ started = true;
185
+ continue;
186
+ }
187
+ if (character === '\\' && quote !== 'single') {
188
+ escaped = true;
189
+ started = true;
190
+ continue;
191
+ }
192
+ if (character === "'" && quote !== 'double') {
193
+ quote = quote === 'single' ? undefined : 'single';
194
+ started = true;
195
+ continue;
196
+ }
197
+ if (character === '"' && quote !== 'single') {
198
+ quote = quote === 'double' ? undefined : 'double';
199
+ started = true;
200
+ continue;
201
+ }
202
+ if (/\s/.test(character) && !quote) {
203
+ if (started) {
204
+ tokens.push(token);
205
+ token = '';
206
+ started = false;
207
+ }
208
+ continue;
209
+ }
210
+ token += character;
211
+ started = true;
212
+ }
213
+ if (escaped || quote)
214
+ throw new CliError('The coding command contains an unfinished quote or escape.');
215
+ if (started)
216
+ tokens.push(token);
217
+ if (tokens.length === 0 || !tokens[0])
218
+ throw new CliError('The coding command cannot be empty.');
219
+ return tokens;
220
+ }
221
+ function buildCustomInvocation(value) {
222
+ const tokens = parseCodingCommand(value);
223
+ const command = tokens[0];
224
+ const args = tokens.slice(1);
225
+ const promptIndexes = args.flatMap((argument, index) => (argument === '{prompt}' ? [index] : []));
226
+ if (promptIndexes.length > 1)
227
+ throw new CliError('Use {prompt} at most once in the coding command.');
228
+ const index = promptIndexes[0] ?? args.length;
229
+ if (promptIndexes.length === 1)
230
+ args.splice(index, 1);
231
+ return {
232
+ command,
233
+ args,
234
+ output: 'text',
235
+ prompt: { type: 'argument', index },
236
+ };
237
+ }
238
+ function contentText(value) {
239
+ if (typeof value === 'string' && value.trim())
240
+ return value;
241
+ if (!Array.isArray(value))
242
+ return undefined;
243
+ const parts = [];
244
+ for (const part of value) {
245
+ if (typeof part === 'string') {
246
+ parts.push(part);
247
+ }
248
+ else if (typeof part === 'object' && part !== null) {
249
+ const record = part;
250
+ if ((record.type === 'text' || record.type === 'output_text') && typeof record.text === 'string') {
251
+ parts.push(record.text);
252
+ }
253
+ }
254
+ }
255
+ return parts.length > 0 ? parts.join('') : undefined;
256
+ }
257
+ function finalPiAssistantMessage(event) {
258
+ // Pi's message_start/message_update/message_end events all carry the current,
259
+ // cumulative assistant snapshot. Publishing those snapshots produces a new
260
+ // CrewX message for every token. agent_end is Pi's single terminal event for
261
+ // a run, and its messages array contains the completed agent turns.
262
+ if (event.type !== 'agent_end' || event.willRetry === true || !Array.isArray(event.messages)) {
263
+ return undefined;
264
+ }
265
+ for (let index = event.messages.length - 1; index >= 0; index -= 1) {
266
+ const candidate = event.messages[index];
267
+ if (typeof candidate !== 'object' || candidate === null)
268
+ continue;
269
+ const message = candidate;
270
+ if (message.role !== 'assistant')
271
+ continue;
272
+ const text = contentText(message.content);
273
+ if (text)
274
+ return text;
275
+ }
276
+ return undefined;
277
+ }
278
+ export function parseAdapterLine(adapter, line) {
279
+ let raw;
280
+ try {
281
+ raw = JSON.parse(line);
282
+ }
283
+ catch {
284
+ return { raw: line };
285
+ }
286
+ if (typeof raw !== 'object' || raw === null)
287
+ return { raw };
288
+ const event = raw;
289
+ const eventType = typeof event.type === 'string' ? event.type : undefined;
290
+ let message;
291
+ let role;
292
+ if (adapter === 'codex') {
293
+ const item = typeof event.item === 'object' && event.item !== null ? event.item : undefined;
294
+ if (item?.type === 'agent_message') {
295
+ message = contentText(item.text) ?? contentText(item.content);
296
+ role = 'assistant';
297
+ }
298
+ else if (eventType === 'agent_message') {
299
+ message = contentText(event.message) ?? contentText(event.text);
300
+ role = 'assistant';
301
+ }
302
+ }
303
+ else if (adapter === 'claude') {
304
+ const messageObject = typeof event.message === 'object' && event.message !== null
305
+ ? event.message
306
+ : undefined;
307
+ if (eventType === 'result' && event.is_error !== true) {
308
+ message = contentText(event.result);
309
+ role = 'assistant';
310
+ }
311
+ else if (eventType === 'assistant' || messageObject?.role === 'assistant') {
312
+ message = contentText(messageObject?.content ?? event.content);
313
+ role = 'assistant';
314
+ }
315
+ else if (eventType === 'content_block_delta') {
316
+ const delta = typeof event.delta === 'object' && event.delta !== null ? event.delta : undefined;
317
+ message = contentText(delta?.text);
318
+ role = 'assistant';
319
+ }
320
+ }
321
+ else if (adapter === 'pi') {
322
+ message = finalPiAssistantMessage(event);
323
+ if (message) {
324
+ role = 'assistant';
325
+ }
326
+ }
327
+ return {
328
+ raw,
329
+ ...(message ? { message } : {}),
330
+ ...(role ? { role } : {}),
331
+ ...(eventType ? { eventType } : {}),
332
+ };
333
+ }
334
+ function record(value) {
335
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
336
+ ? value
337
+ : undefined;
338
+ }
339
+ async function resolvedPath(value, label) {
340
+ try {
341
+ return await realpath(value);
342
+ }
343
+ catch (error) {
344
+ throw new CliError(`${label} does not resolve to an accessible local directory: ${value}`, 1, { cause: error });
345
+ }
346
+ }
347
+ async function openClawJsonProbe(args, options) {
348
+ const spawnProcess = options.spawnProcess ?? spawn;
349
+ let child;
350
+ try {
351
+ child = spawnProcess('openclaw', args, {
352
+ cwd: options.cwd,
353
+ env: { ...process.env, ...options.environment },
354
+ shell: false,
355
+ detached: process.platform !== 'win32',
356
+ stdio: ['pipe', 'pipe', 'pipe'],
357
+ });
358
+ }
359
+ catch (error) {
360
+ throw new CliError('Unable to start openclaw. Is it installed and on PATH?', 1, { cause: error });
361
+ }
362
+ let stdout = '';
363
+ let stderr = '';
364
+ let bytes = 0;
365
+ let exceeded = false;
366
+ let forceKill;
367
+ const stopProbe = () => {
368
+ if (child.exitCode !== null)
369
+ return;
370
+ terminateChild(child, 'SIGTERM');
371
+ forceKill ??= setTimeout(() => terminateChild(child, 'SIGKILL'), 5_000);
372
+ forceKill.unref();
373
+ };
374
+ const consume = (stream, chunk) => {
375
+ if (exceeded)
376
+ return;
377
+ bytes += Buffer.byteLength(chunk);
378
+ if (bytes > 1024 * 1024) {
379
+ exceeded = true;
380
+ stopProbe();
381
+ return;
382
+ }
383
+ if (stream === 'stdout')
384
+ stdout += chunk.toString();
385
+ else
386
+ stderr += chunk.toString();
387
+ };
388
+ child.stdout.on('data', (chunk) => consume('stdout', chunk));
389
+ child.stderr.on('data', (chunk) => consume('stderr', chunk));
390
+ child.stdin.on('error', () => undefined);
391
+ child.stdin.end();
392
+ let timedOut = false;
393
+ const timeout = setTimeout(() => {
394
+ timedOut = true;
395
+ stopProbe();
396
+ }, 15_000);
397
+ timeout.unref();
398
+ try {
399
+ const [code] = (await Promise.race([
400
+ once(child, 'close'),
401
+ once(child, 'error').then(([error]) => {
402
+ throw error;
403
+ }),
404
+ ]));
405
+ if (timedOut)
406
+ throw new CliError(`OpenClaw preflight timed out: openclaw ${args.join(' ')}`);
407
+ if (exceeded)
408
+ throw new CliError('OpenClaw preflight exceeded its 1048576 byte output limit.');
409
+ if (code !== 0) {
410
+ throw new CliError(`OpenClaw preflight failed: ${stderr.trim() || `exit ${String(code)}`}`);
411
+ }
412
+ try {
413
+ return JSON.parse(stdout);
414
+ }
415
+ catch (error) {
416
+ throw new CliError('OpenClaw preflight returned invalid JSON.', 1, { cause: error });
417
+ }
418
+ }
419
+ finally {
420
+ clearTimeout(timeout);
421
+ if (forceKill)
422
+ clearTimeout(forceKill);
423
+ if (child.exitCode === null && !child.killed)
424
+ stopProbe();
425
+ }
426
+ }
427
+ export async function verifyOpenClawRuntime(options) {
428
+ const agentId = options.profile.runtimeAgentId?.trim();
429
+ if (!agentId)
430
+ throw new CliError('OpenClaw requires an explicit configured agent ID.');
431
+ const desiredWorkspace = await resolvedPath(options.cwd, 'CrewX assignment directory');
432
+ const agents = await openClawJsonProbe(['agents', 'list', '--json'], options);
433
+ if (!Array.isArray(agents))
434
+ throw new CliError('OpenClaw returned an invalid agent list.');
435
+ const selected = agents.map(record).find((agent) => agent?.id === agentId);
436
+ if (!selected) {
437
+ throw new CliError(`OpenClaw agent "${agentId}" is not configured. Run \`openclaw agents list --json\` to choose one.`);
438
+ }
439
+ if (typeof selected.workspace !== 'string' || !selected.workspace.trim()) {
440
+ throw new CliError(`OpenClaw agent "${agentId}" does not report a workspace.`);
441
+ }
442
+ const configuredWorkspace = await resolvedPath(selected.workspace, `OpenClaw agent "${agentId}" workspace`);
443
+ if (configuredWorkspace !== desiredWorkspace) {
444
+ throw new CliError(`OpenClaw agent "${agentId}" uses workspace "${configuredWorkspace}", but this CrewX assignment requires "${desiredWorkspace}". OpenClaw has no per-run --cwd. Create a dedicated agent with: openclaw agents add <name> --workspace "${desiredWorkspace}" --non-interactive --json`);
445
+ }
446
+ const sessionLeaf = options.profile.sessionKey ?? 'main';
447
+ const fullSessionKey = `agent:${agentId}:${sessionLeaf}`;
448
+ const explanation = record(await openClawJsonProbe(['sandbox', 'explain', '--session', fullSessionKey, '--json'], options));
449
+ const sandbox = record(explanation?.sandbox);
450
+ if (explanation?.agentId !== agentId || explanation?.sessionKey !== fullSessionKey || !sandbox) {
451
+ throw new CliError('OpenClaw sandbox preflight did not describe the requested agent session.');
452
+ }
453
+ const effectiveHostRoot = typeof sandbox.effectiveHostWorkspaceRoot === 'string'
454
+ ? await resolvedPath(sandbox.effectiveHostWorkspaceRoot, 'OpenClaw effective workspace')
455
+ : undefined;
456
+ if (effectiveHostRoot !== desiredWorkspace) {
457
+ throw new CliError('OpenClaw sandbox policy does not expose the exact CrewX assignment directory.');
458
+ }
459
+ if (sandbox.sessionIsSandboxed === true) {
460
+ if (sandbox.backend !== 'docker' ||
461
+ sandbox.workspaceAccess !== 'rw' ||
462
+ typeof sandbox.runtimeWorkdir !== 'string') {
463
+ throw new CliError('OpenClaw must grant rw workspace access for CrewX coding assignments.');
464
+ }
465
+ const mounts = Array.isArray(sandbox.workspaceMounts) ? sandbox.workspaceMounts.map(record) : [];
466
+ let writableWorkspaceMount = false;
467
+ for (const mount of mounts) {
468
+ if (typeof mount?.hostRoot === 'string' &&
469
+ typeof mount.containerRoot === 'string' &&
470
+ mount.containerRoot === sandbox.runtimeWorkdir &&
471
+ mount.writable === true &&
472
+ (await resolvedPath(mount.hostRoot, 'OpenClaw sandbox mount')) === desiredWorkspace) {
473
+ writableWorkspaceMount = true;
474
+ break;
475
+ }
476
+ }
477
+ if (!writableWorkspaceMount) {
478
+ throw new CliError('OpenClaw sandbox policy does not mount the CrewX assignment directory read-write at its runtime workdir.');
479
+ }
480
+ }
481
+ else {
482
+ if (typeof sandbox.runtimeWorkdir !== 'string') {
483
+ throw new CliError('OpenClaw did not report its direct runtime working directory.');
484
+ }
485
+ const runtimeWorkdir = await resolvedPath(sandbox.runtimeWorkdir, 'OpenClaw runtime working directory');
486
+ if (runtimeWorkdir !== desiredWorkspace) {
487
+ throw new CliError('OpenClaw direct runtime workdir does not match the CrewX assignment directory.');
488
+ }
489
+ }
490
+ }
491
+ function openClawMessages(value) {
492
+ const payloads = record(value)?.payloads;
493
+ if (!Array.isArray(payloads))
494
+ return [];
495
+ return payloads.flatMap((payload) => {
496
+ const text = record(payload)?.text;
497
+ return typeof text === 'string' && text.trim() ? [text] : [];
498
+ });
499
+ }
500
+ function createLineConsumer(callback) {
501
+ let pending = '';
502
+ return {
503
+ write(chunk) {
504
+ pending += chunk.toString();
505
+ while (true) {
506
+ const newline = pending.indexOf('\n');
507
+ if (newline < 0)
508
+ break;
509
+ const line = pending.slice(0, newline).replace(/\r$/, '');
510
+ pending = pending.slice(newline + 1);
511
+ if (line.length > 0)
512
+ callback(line);
513
+ }
514
+ },
515
+ end() {
516
+ const line = pending.replace(/\r$/, '');
517
+ if (line.length > 0)
518
+ callback(line);
519
+ pending = '';
520
+ },
521
+ };
522
+ }
523
+ async function materializeInvocation(invocation, prompt) {
524
+ if (invocation.prompt.type === 'stdin') {
525
+ return { args: invocation.args, stdin: prompt, cleanup: async () => undefined };
526
+ }
527
+ if (invocation.prompt.type === 'argument') {
528
+ const args = [...invocation.args];
529
+ args.splice(invocation.prompt.index, 0, prompt);
530
+ return { args, stdin: '', cleanup: async () => undefined };
531
+ }
532
+ const directory = await mkdtemp(join(tmpdir(), 'crewx-prompt-'));
533
+ try {
534
+ await chmod(directory, 0o700);
535
+ const path = join(directory, 'prompt.txt');
536
+ await writeFile(path, prompt, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
537
+ await chmod(path, 0o600);
538
+ return {
539
+ args: [...invocation.args, invocation.prompt.flag, path],
540
+ stdin: '',
541
+ cleanup: async () => rm(directory, { recursive: true, force: true }),
542
+ };
543
+ }
544
+ catch (error) {
545
+ await rm(directory, { recursive: true, force: true });
546
+ throw error;
547
+ }
548
+ }
549
+ function emitMessage(options, messages, message, role = 'assistant') {
550
+ if (!message.trim())
551
+ return;
552
+ messages.push(message);
553
+ options.onMessage?.(message, role);
554
+ }
555
+ async function runSpawnAdapter(options) {
556
+ if (!options.prompt.trim())
557
+ throw new CliError('Agent prompt cannot be empty.');
558
+ const invocation = buildAdapterInvocation(options.adapter, options.profile, options.codingCommand);
559
+ if (options.adapter === 'openclaw' && !options.codingCommand) {
560
+ await verifyOpenClawRuntime({
561
+ cwd: options.cwd,
562
+ profile: options.profile ?? {},
563
+ ...(options.environment ? { environment: options.environment } : {}),
564
+ ...(options.spawnProcess ? { spawnProcess: options.spawnProcess } : {}),
565
+ });
566
+ }
567
+ const materialized = await materializeInvocation(invocation, options.prompt);
568
+ const spawnProcess = options.spawnProcess ?? spawn;
569
+ let child;
570
+ try {
571
+ child = spawnProcess(invocation.command, materialized.args, {
572
+ cwd: options.cwd,
573
+ env: {
574
+ ...process.env,
575
+ ...options.environment,
576
+ },
577
+ shell: false,
578
+ detached: process.platform !== 'win32',
579
+ stdio: ['pipe', 'pipe', 'pipe'],
580
+ });
581
+ }
582
+ catch (error) {
583
+ await materialized.cleanup();
584
+ throw new CliError(`Unable to start ${invocation.command}. Is it installed and on PATH?`, 1, { cause: error });
585
+ }
586
+ const lifecycle = observeChild(child);
587
+ const stdout = [];
588
+ const stderr = [];
589
+ const messages = [];
590
+ const finalMessageOnly = !options.codingCommand &&
591
+ (options.adapter === 'codex' || options.adapter === 'claude' || options.adapter === 'pi');
592
+ let pendingFinalMessage;
593
+ const maxOutputBytes = options.maxOutputBytes ?? MAX_AGENT_OUTPUT_BYTES;
594
+ let outputBytes = 0;
595
+ let outputLimitExceeded = false;
596
+ const stdoutLines = createLineConsumer((line) => {
597
+ stdout.push(line);
598
+ const parsed = parseAdapterLine(options.adapter, line);
599
+ options.onStdout?.(line, parsed);
600
+ if (parsed.message) {
601
+ if (finalMessageOnly) {
602
+ pendingFinalMessage = { message: parsed.message, role: parsed.role ?? 'assistant' };
603
+ }
604
+ else {
605
+ emitMessage(options, messages, parsed.message, parsed.role);
606
+ }
607
+ }
608
+ });
609
+ const stderrLines = createLineConsumer((line) => {
610
+ stderr.push(line);
611
+ options.onStderr?.(line);
612
+ });
613
+ let forceKill;
614
+ let hardStop;
615
+ let forcedClosed = false;
616
+ let rejectTermination;
617
+ const terminationFailure = new Promise((_resolve, reject) => {
618
+ rejectTermination = reject;
619
+ });
620
+ let timedOut = false;
621
+ let terminating = false;
622
+ const terminationGraceMs = options.adapter === 'openclaw' ? 60_000 : 5_000;
623
+ const abort = () => {
624
+ if (lifecycle.isClosed() || child.exitCode !== null || terminating)
625
+ return;
626
+ terminating = true;
627
+ terminateChild(child, 'SIGTERM');
628
+ forceKill = setTimeout(() => {
629
+ if (!lifecycle.isClosed())
630
+ terminateChild(child, 'SIGKILL');
631
+ }, terminationGraceMs);
632
+ hardStop = setTimeout(() => {
633
+ if (lifecycle.isClosed())
634
+ return;
635
+ forcedClosed = true;
636
+ terminateChild(child, 'SIGKILL');
637
+ destroyChildPipes(child);
638
+ rejectTermination?.(new CliError(`${invocation.command} did not close after forced termination.`));
639
+ }, terminationGraceMs + 1_000);
640
+ };
641
+ const consumeOutput = (consumer, chunk) => {
642
+ if (outputLimitExceeded)
643
+ return;
644
+ outputBytes += Buffer.byteLength(chunk);
645
+ if (outputBytes > maxOutputBytes) {
646
+ outputLimitExceeded = true;
647
+ abort();
648
+ return;
649
+ }
650
+ consumer(chunk);
651
+ };
652
+ child.stdout.on('data', (chunk) => consumeOutput(stdoutLines.write, chunk));
653
+ child.stderr.on('data', (chunk) => consumeOutput(stderrLines.write, chunk));
654
+ if (options.signal?.aborted)
655
+ abort();
656
+ options.signal?.addEventListener('abort', abort, { once: true });
657
+ const runTimeout = setTimeout(() => {
658
+ timedOut = true;
659
+ abort();
660
+ }, options.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS);
661
+ const spawnError = new Promise((_resolve, reject) => {
662
+ child.once('error', (error) => {
663
+ reject(new CliError(`Unable to start ${invocation.command}. Is it installed and on PATH?`, 1, { cause: error }));
664
+ });
665
+ });
666
+ const stdinError = new Promise((_resolve, reject) => {
667
+ child.stdin.once('error', (error) => {
668
+ reject(new CliError(`${invocation.command} closed its input before receiving the prompt.`, 1, { cause: error }));
669
+ });
670
+ });
671
+ try {
672
+ child.stdin.end(materialized.stdin, 'utf8');
673
+ const [exitCode, signal] = (await Promise.race([
674
+ lifecycle.close,
675
+ spawnError,
676
+ stdinError,
677
+ terminationFailure,
678
+ ]));
679
+ stdoutLines.end();
680
+ stderrLines.end();
681
+ if (timedOut)
682
+ throw new CliError(`${invocation.command} exceeded the CrewX run timeout.`);
683
+ if (outputLimitExceeded) {
684
+ throw new CliError(`${invocation.command} exceeded the ${String(maxOutputBytes)} byte CrewX output limit.`);
685
+ }
686
+ if (exitCode === 0 && !options.signal?.aborted && pendingFinalMessage) {
687
+ emitMessage(options, messages, pendingFinalMessage.message, pendingFinalMessage.role);
688
+ }
689
+ const completeOutput = stdout.join('\n').trim();
690
+ if (exitCode === 0 && messages.length === 0 && completeOutput) {
691
+ if (invocation.output === 'json') {
692
+ let parsed;
693
+ try {
694
+ parsed = JSON.parse(completeOutput);
695
+ }
696
+ catch (error) {
697
+ throw new CliError(`${invocation.command} returned invalid JSON output.`, 1, { cause: error });
698
+ }
699
+ for (const message of openClawMessages(parsed))
700
+ emitMessage(options, messages, message);
701
+ }
702
+ else if (invocation.output === 'text') {
703
+ emitMessage(options, messages, completeOutput);
704
+ }
705
+ }
706
+ if (exitCode === 0 && !options.signal?.aborted && options.adapter === 'openclaw' && messages.length === 0) {
707
+ throw new CliError('OpenClaw completed without a visible assistant response.');
708
+ }
709
+ if (exitCode === 0 && !options.signal?.aborted && finalMessageOnly && messages.length === 0) {
710
+ throw new CliError(`${invocation.command} completed without a visible assistant response.`);
711
+ }
712
+ return { exitCode, signal, messages, stdout, stderr };
713
+ }
714
+ finally {
715
+ options.signal?.removeEventListener('abort', abort);
716
+ clearTimeout(runTimeout);
717
+ if (forceKill)
718
+ clearTimeout(forceKill);
719
+ if (hardStop)
720
+ clearTimeout(hardStop);
721
+ if (!forcedClosed && !lifecycle.isClosed() && child.exitCode === null) {
722
+ await terminateAndWait(child, lifecycle, terminationGraceMs);
723
+ }
724
+ await materialized.cleanup();
725
+ }
726
+ }
727
+ async function runHermesAcp(options) {
728
+ const restricted = options.profile?.chatOnly === true || options.profile?.permissionPreset === 'read_only';
729
+ if (restricted) {
730
+ throw new CliError('Hermes does not expose a trustworthy read-only ACP mode. Use a sandboxed custom command or choose standard/full access.');
731
+ }
732
+ const spawnProcess = options.spawnProcess ?? spawn;
733
+ let child;
734
+ try {
735
+ child = spawnProcess('hermes', ['acp'], {
736
+ cwd: options.cwd,
737
+ env: { ...process.env, ...options.environment },
738
+ shell: false,
739
+ detached: process.platform !== 'win32',
740
+ stdio: ['pipe', 'pipe', 'pipe'],
741
+ });
742
+ }
743
+ catch (error) {
744
+ throw new CliError('Unable to start hermes. Is it installed and on PATH?', 1, { cause: error });
745
+ }
746
+ const lifecycle = observeChild(child);
747
+ const stdout = [];
748
+ const stderr = [];
749
+ const messages = [];
750
+ const pending = new Map();
751
+ let nextId = 1;
752
+ let sessionId;
753
+ let finalText = '';
754
+ let forceKill;
755
+ let hardStop;
756
+ let forcedClosed = false;
757
+ let timedOut = false;
758
+ const maxOutputBytes = options.maxOutputBytes ?? MAX_AGENT_OUTPUT_BYTES;
759
+ let outputBytes = 0;
760
+ let outputLimitExceeded = false;
761
+ let abortRuntime = () => undefined;
762
+ let terminating = false;
763
+ const write = (value) => {
764
+ child.stdin.write(`${JSON.stringify(value)}\n`, 'utf8');
765
+ };
766
+ const request = (method, params) => {
767
+ const id = nextId++;
768
+ return new Promise((resolve, reject) => {
769
+ pending.set(id, { resolve, reject });
770
+ write({ jsonrpc: '2.0', id, method, params });
771
+ });
772
+ };
773
+ const resultOf = async (method, params) => {
774
+ const response = await request(method, params);
775
+ if (response.error)
776
+ throw new CliError(`Hermes ACP ${method} failed: ${response.error.message ?? 'unknown error'}`);
777
+ return response.result;
778
+ };
779
+ const stdoutLines = createLineConsumer((line) => {
780
+ stdout.push(line);
781
+ let value;
782
+ try {
783
+ value = JSON.parse(line);
784
+ }
785
+ catch {
786
+ options.onStdout?.(line, { raw: line });
787
+ return;
788
+ }
789
+ const message = record(value);
790
+ const method = typeof message?.method === 'string' ? message.method : undefined;
791
+ options.onStdout?.(line, { raw: value, ...(method ? { eventType: method } : {}) });
792
+ if (typeof message?.id === 'number' && !method) {
793
+ const waiter = pending.get(message.id);
794
+ if (waiter) {
795
+ pending.delete(message.id);
796
+ const response = {};
797
+ if (Object.hasOwn(message, 'result'))
798
+ response.result = message.result;
799
+ const rpcError = record(message.error);
800
+ if (rpcError)
801
+ response.error = rpcError;
802
+ waiter.resolve(response);
803
+ }
804
+ return;
805
+ }
806
+ if (method === 'session/update') {
807
+ const update = record(record(message?.params)?.update);
808
+ const content = record(update?.content);
809
+ if (update?.sessionUpdate === 'agent_message_chunk' && content?.type === 'text' && typeof content.text === 'string') {
810
+ finalText += content.text;
811
+ }
812
+ return;
813
+ }
814
+ if (method === 'session/request_permission' && (typeof message?.id === 'number' || typeof message?.id === 'string')) {
815
+ const offered = record(message.params)?.options;
816
+ const allowed = Array.isArray(offered)
817
+ ? ['allow_once', 'allow_session'].find((optionId) => offered.some((option) => record(option)?.optionId === optionId))
818
+ : undefined;
819
+ const fullAccess = options.profile?.permissionPreset === 'full_access';
820
+ write({
821
+ jsonrpc: '2.0',
822
+ id: message.id,
823
+ result: {
824
+ outcome: fullAccess && allowed
825
+ ? { outcome: 'selected', optionId: allowed }
826
+ : { outcome: 'cancelled' },
827
+ },
828
+ });
829
+ }
830
+ });
831
+ const stderrLines = createLineConsumer((line) => {
832
+ stderr.push(line);
833
+ options.onStderr?.(line);
834
+ });
835
+ const consumeOutput = (consumer, chunk) => {
836
+ if (outputLimitExceeded)
837
+ return;
838
+ outputBytes += Buffer.byteLength(chunk);
839
+ if (outputBytes > maxOutputBytes) {
840
+ outputLimitExceeded = true;
841
+ abortRuntime();
842
+ return;
843
+ }
844
+ consumer(chunk);
845
+ };
846
+ child.stdout.on('data', (chunk) => consumeOutput(stdoutLines.write, chunk));
847
+ child.stderr.on('data', (chunk) => consumeOutput(stderrLines.write, chunk));
848
+ const failPending = (error) => {
849
+ for (const waiter of pending.values())
850
+ waiter.reject(error);
851
+ pending.clear();
852
+ };
853
+ child.once('error', (error) => failPending(new CliError('Hermes ACP process failed.', 1, { cause: error })));
854
+ child.stdin.once('error', (error) => failPending(new CliError('Hermes closed its ACP input unexpectedly.', 1, { cause: error })));
855
+ child.once('close', () => failPending(new CliError('Hermes ACP process closed before the request completed.')));
856
+ const abort = () => {
857
+ if (sessionId)
858
+ write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } });
859
+ if (lifecycle.isClosed() || child.exitCode !== null || terminating)
860
+ return;
861
+ terminating = true;
862
+ terminateChild(child, 'SIGTERM');
863
+ forceKill = setTimeout(() => {
864
+ if (!lifecycle.isClosed())
865
+ terminateChild(child, 'SIGKILL');
866
+ }, 5_000);
867
+ forceKill.unref();
868
+ hardStop = setTimeout(() => {
869
+ if (lifecycle.isClosed())
870
+ return;
871
+ forcedClosed = true;
872
+ terminateChild(child, 'SIGKILL');
873
+ destroyChildPipes(child);
874
+ failPending(new CliError('Hermes did not close after forced termination.'));
875
+ }, 6_000);
876
+ };
877
+ abortRuntime = abort;
878
+ if (options.signal?.aborted)
879
+ abort();
880
+ options.signal?.addEventListener('abort', abort, { once: true });
881
+ const runTimeout = setTimeout(() => {
882
+ timedOut = true;
883
+ abort();
884
+ }, options.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS);
885
+ try {
886
+ const initialized = record(await resultOf('initialize', {
887
+ protocolVersion: 1,
888
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
889
+ clientInfo: { name: 'crewx', version: CLI_VERSION },
890
+ }));
891
+ if (initialized?.protocolVersion !== 1)
892
+ throw new CliError('Hermes returned an unsupported ACP protocol version.');
893
+ const created = record(await resultOf('session/new', { cwd: options.cwd, mcpServers: [] }));
894
+ if (typeof created?.sessionId !== 'string' || !created.sessionId) {
895
+ throw new CliError('Hermes did not return an ACP session id.');
896
+ }
897
+ sessionId = created.sessionId;
898
+ await resultOf('session/set_mode', {
899
+ sessionId,
900
+ modeId: options.profile?.permissionPreset === 'full_access' ? 'dont_ask' : 'accept_edits',
901
+ });
902
+ if (options.profile?.model) {
903
+ await resultOf('session/set_model', { sessionId, modelId: options.profile.model });
904
+ }
905
+ await resultOf('session/prompt', {
906
+ sessionId,
907
+ prompt: [{ type: 'text', text: options.prompt }],
908
+ });
909
+ if (timedOut)
910
+ throw new CliError('Hermes exceeded the CrewX run timeout.');
911
+ if (outputLimitExceeded) {
912
+ throw new CliError(`hermes exceeded the ${String(maxOutputBytes)} byte CrewX output limit.`);
913
+ }
914
+ if (!finalText.trim())
915
+ throw new CliError('Hermes completed without a visible assistant response.');
916
+ emitMessage(options, messages, finalText);
917
+ child.stdin.end();
918
+ const terminateTimer = setTimeout(() => terminateChild(child, 'SIGTERM'), 2_000);
919
+ const killTimer = setTimeout(() => terminateChild(child, 'SIGKILL'), 7_000);
920
+ let shutdownTimer;
921
+ terminateTimer.unref();
922
+ killTimer.unref();
923
+ const [exitCode, signal] = await Promise.race([
924
+ lifecycle.close,
925
+ new Promise((resolve) => {
926
+ shutdownTimer = setTimeout(() => {
927
+ forcedClosed = true;
928
+ terminateChild(child, 'SIGKILL');
929
+ destroyChildPipes(child);
930
+ resolve([child.exitCode, null]);
931
+ }, 8_000);
932
+ }),
933
+ ]);
934
+ clearTimeout(terminateTimer);
935
+ clearTimeout(killTimer);
936
+ if (shutdownTimer)
937
+ clearTimeout(shutdownTimer);
938
+ stdoutLines.end();
939
+ stderrLines.end();
940
+ return {
941
+ exitCode: options.signal?.aborted ? exitCode : 0,
942
+ signal: options.signal?.aborted ? signal : null,
943
+ messages,
944
+ stdout,
945
+ stderr,
946
+ };
947
+ }
948
+ finally {
949
+ options.signal?.removeEventListener('abort', abort);
950
+ clearTimeout(runTimeout);
951
+ if (forceKill)
952
+ clearTimeout(forceKill);
953
+ if (hardStop)
954
+ clearTimeout(hardStop);
955
+ if (!forcedClosed && !lifecycle.isClosed() && child.exitCode === null) {
956
+ await terminateAndWait(child, lifecycle, 5_000);
957
+ }
958
+ }
959
+ }
960
+ export async function runAdapter(options) {
961
+ if (!options.prompt.trim())
962
+ throw new CliError('Agent prompt cannot be empty.');
963
+ if (options.codingCommand &&
964
+ (options.profile?.chatOnly === true || options.profile?.permissionPreset === 'read_only')) {
965
+ throw new CliError('A custom coding command cannot prove read-only or chat-only enforcement.');
966
+ }
967
+ if (options.adapter === 'openclaw' &&
968
+ (options.profile?.chatOnly === true || options.profile?.permissionPreset === 'read_only')) {
969
+ throw new CliError('OpenClaw read-only and chat-only policy cannot be verified per run.');
970
+ }
971
+ if (options.adapter === 'hermes' && !options.codingCommand)
972
+ return runHermesAcp(options);
973
+ return runSpawnAdapter(options);
974
+ }
975
+ export async function probeAdapter(adapter, spawnProcess = spawn, timeoutMs = 5_000) {
976
+ const command = adapter;
977
+ const probeArgs = adapter === 'hermes'
978
+ ? ['acp', '--check']
979
+ : adapter === 'openclaw'
980
+ ? ['agents', 'list', '--json']
981
+ : ['--version'];
982
+ let child;
983
+ try {
984
+ child = spawnProcess(command, probeArgs, {
985
+ env: process.env,
986
+ shell: false,
987
+ detached: process.platform !== 'win32',
988
+ stdio: ['pipe', 'pipe', 'pipe'],
989
+ });
990
+ }
991
+ catch (error) {
992
+ return { available: false, error: error instanceof Error ? error.message : String(error) };
993
+ }
994
+ const lifecycle = observeChild(child);
995
+ let output = '';
996
+ let outputBytes = 0;
997
+ let outputLimitExceeded = false;
998
+ let forceKill;
999
+ let hardStop;
1000
+ let forcedClosed = false;
1001
+ let rejectTermination;
1002
+ const terminationFailure = new Promise((_resolve, reject) => {
1003
+ rejectTermination = reject;
1004
+ });
1005
+ let timedOut = false;
1006
+ let terminating = false;
1007
+ const terminationGraceMs = Math.min(1_000, Math.max(25, timeoutMs));
1008
+ const abort = () => {
1009
+ if (lifecycle.isClosed() || child.exitCode !== null || terminating)
1010
+ return;
1011
+ terminating = true;
1012
+ terminateChild(child, 'SIGTERM');
1013
+ forceKill = setTimeout(() => {
1014
+ if (!lifecycle.isClosed())
1015
+ terminateChild(child, 'SIGKILL');
1016
+ }, terminationGraceMs);
1017
+ hardStop = setTimeout(() => {
1018
+ if (lifecycle.isClosed())
1019
+ return;
1020
+ forcedClosed = true;
1021
+ terminateChild(child, 'SIGKILL');
1022
+ destroyChildPipes(child);
1023
+ const message = outputLimitExceeded
1024
+ ? 'probe output exceeded 1048576 bytes'
1025
+ : `probe timed out after ${String(timeoutMs)}ms`;
1026
+ rejectTermination?.(new CliError(message));
1027
+ }, terminationGraceMs + 100);
1028
+ };
1029
+ const consume = (chunk) => {
1030
+ if (outputLimitExceeded)
1031
+ return;
1032
+ outputBytes += Buffer.byteLength(chunk);
1033
+ if (outputBytes > 1024 * 1024) {
1034
+ outputLimitExceeded = true;
1035
+ abort();
1036
+ return;
1037
+ }
1038
+ output += chunk.toString();
1039
+ };
1040
+ child.stdout.on('data', (chunk) => {
1041
+ consume(chunk);
1042
+ });
1043
+ child.stderr.on('data', (chunk) => {
1044
+ consume(chunk);
1045
+ });
1046
+ child.stdin.on('error', () => undefined);
1047
+ child.stdin.end();
1048
+ const timeout = setTimeout(() => {
1049
+ timedOut = true;
1050
+ abort();
1051
+ }, timeoutMs);
1052
+ try {
1053
+ const [code] = (await Promise.race([
1054
+ lifecycle.close,
1055
+ terminationFailure,
1056
+ once(child, 'error').then(([error]) => {
1057
+ throw error;
1058
+ }),
1059
+ ]));
1060
+ if (timedOut)
1061
+ return { available: false, error: `probe timed out after ${String(timeoutMs)}ms` };
1062
+ if (outputLimitExceeded)
1063
+ return { available: false, error: 'probe output exceeded 1048576 bytes' };
1064
+ const version = adapter === 'hermes' || adapter === 'openclaw'
1065
+ ? 'ready'
1066
+ : output.trim().split(/\r?\n/, 1)[0];
1067
+ return code === 0
1068
+ ? { available: true, ...(version ? { version } : {}) }
1069
+ : { available: false, error: version || `exited with code ${String(code)}` };
1070
+ }
1071
+ catch (error) {
1072
+ return { available: false, error: error instanceof Error ? error.message : String(error) };
1073
+ }
1074
+ finally {
1075
+ clearTimeout(timeout);
1076
+ if (forceKill)
1077
+ clearTimeout(forceKill);
1078
+ if (hardStop)
1079
+ clearTimeout(hardStop);
1080
+ if (!forcedClosed && !lifecycle.isClosed() && child.exitCode === null) {
1081
+ await terminateAndWait(child, lifecycle, terminationGraceMs);
1082
+ }
1083
+ }
1084
+ }
1085
+ //# sourceMappingURL=adapters.js.map