@sandagent/runner-cli 0.1.2-beta.1 → 0.1.2-beta.2

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.
package/dist/bundle.mjs CHANGED
@@ -3,6 +3,243 @@
3
3
  // src/cli.ts
4
4
  import { parseArgs } from "node:util";
5
5
 
6
+ // ../../packages/runner-claude/dist/ai-sdk-stream.js
7
+ import { writeFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ var UNKNOWN_TOOL_NAME = "unknown-tool";
10
+ function formatDataStream(data) {
11
+ return `data: ${JSON.stringify(data)}
12
+
13
+ `;
14
+ }
15
+ function generateId() {
16
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
17
+ }
18
+ function convertUsageToAISDK(usage) {
19
+ const inputTokens = usage.input_tokens ?? 0;
20
+ const outputTokens = usage.output_tokens ?? 0;
21
+ const cacheWrite = usage.cache_creation_input_tokens ?? 0;
22
+ const cacheRead = usage.cache_read_input_tokens ?? 0;
23
+ return {
24
+ inputTokens: {
25
+ total: inputTokens + cacheWrite + cacheRead,
26
+ noCache: inputTokens,
27
+ cacheRead,
28
+ cacheWrite
29
+ },
30
+ outputTokens: { total: outputTokens },
31
+ raw: usage
32
+ };
33
+ }
34
+ function mapFinishReason(subtype, isError) {
35
+ if (isError)
36
+ return "error";
37
+ switch (subtype) {
38
+ case "success":
39
+ return "stop";
40
+ case "error_max_turns":
41
+ return "length";
42
+ case "error_during_execution":
43
+ case "error_max_structured_output_retries":
44
+ return "error";
45
+ case void 0:
46
+ return "stop";
47
+ default:
48
+ return "other";
49
+ }
50
+ }
51
+ function extractToolUses(content) {
52
+ if (!Array.isArray(content))
53
+ return [];
54
+ return content.filter((item) => typeof item === "object" && item !== null && "type" in item && item.type === "tool_use").map((item) => ({
55
+ id: item.id || generateId(),
56
+ name: item.name || UNKNOWN_TOOL_NAME,
57
+ input: item.input
58
+ }));
59
+ }
60
+ var AISDKStreamConverter = class {
61
+ systemMessage;
62
+ hasEmittedStart = false;
63
+ accumulatedText = "";
64
+ textPartId;
65
+ streamedTextLength = 0;
66
+ // Track text already emitted via stream_events
67
+ hasReceivedStreamEvents = false;
68
+ sessionId;
69
+ partIdMap = /* @__PURE__ */ new Map();
70
+ /**
71
+ * Get the current session ID from the stream
72
+ */
73
+ get currentSessionId() {
74
+ if (!this.sessionId) {
75
+ throw new Error("Session ID is not set");
76
+ }
77
+ return this.sessionId;
78
+ }
79
+ /**
80
+ * Helper to emit SSE data
81
+ */
82
+ emit(data) {
83
+ return formatDataStream(data);
84
+ }
85
+ setPartId(index, partId) {
86
+ const partIdKey = `${this.currentSessionId}-${index}`;
87
+ this.partIdMap.set(partIdKey, partId);
88
+ }
89
+ getPartId(index) {
90
+ const partIdKey = `${this.currentSessionId}-${index}`;
91
+ if (this.partIdMap.has(partIdKey)) {
92
+ return this.partIdMap.get(partIdKey) ?? "";
93
+ }
94
+ throw new Error("Part ID not found");
95
+ }
96
+ /**
97
+ * Helper to emit tool call
98
+ */
99
+ *emitToolCall(message) {
100
+ const event = message.event;
101
+ if (event.type === "content_block_start" && event.content_block.type === "tool_use") {
102
+ const toolCallId = event.content_block.id;
103
+ this.setPartId(event.index, toolCallId);
104
+ yield this.emit({
105
+ type: "tool-input-start",
106
+ toolCallId,
107
+ toolName: event.content_block.name,
108
+ dynamic: true,
109
+ providerExecuted: true
110
+ });
111
+ }
112
+ if (event.type === "content_block_delta" && event.delta?.type === "input_json_delta") {
113
+ yield this.emit({
114
+ type: "tool-input-delta",
115
+ toolCallId: this.getPartId(event.index),
116
+ inputTextDelta: event.delta.partial_json
117
+ });
118
+ }
119
+ }
120
+ *emitTextBlockEvent(event) {
121
+ if (event.type === "content_block_start" && event.content_block.type === "text") {
122
+ const partId = `text_${generateId()}`;
123
+ this.setPartId(event.index, partId);
124
+ yield this.emit({
125
+ type: "text-start",
126
+ id: partId
127
+ });
128
+ }
129
+ if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
130
+ yield this.emit({
131
+ type: "text-delta",
132
+ id: this.getPartId(event.index),
133
+ delta: event.delta.text
134
+ });
135
+ }
136
+ if (event.type === "content_block_stop") {
137
+ const partId = this.getPartId(event.index);
138
+ if (partId.startsWith("text_")) {
139
+ yield this.emit({ type: "text-end", id: partId });
140
+ }
141
+ }
142
+ }
143
+ /**
144
+ * Stream SDK messages and convert to AI SDK UI Data Stream format
145
+ */
146
+ async *stream(messageIterator, options) {
147
+ const debugMessages = [];
148
+ const debugFile = `ai-sdk-stream-debug-${Date.now()}.json`;
149
+ try {
150
+ for await (const message of messageIterator) {
151
+ debugMessages.push(message);
152
+ if (message.type === "system" && message.subtype === "init") {
153
+ this.systemMessage = message;
154
+ this.sessionId = this.systemMessage.session_id;
155
+ }
156
+ if (message.type === "stream_event") {
157
+ const streamEvent = message;
158
+ const event = streamEvent.event;
159
+ if (event.type === "message_start" && !this.hasEmittedStart) {
160
+ this.hasEmittedStart = true;
161
+ yield this.emit({ type: "start", messageId: event.message.id });
162
+ yield this.emit({
163
+ type: "message-metadata",
164
+ messageMetadata: {
165
+ tools: this.systemMessage?.tools,
166
+ model: this.systemMessage?.model,
167
+ sessionId: this.systemMessage?.session_id,
168
+ agents: this.systemMessage?.agents,
169
+ skills: this.systemMessage?.skills
170
+ }
171
+ });
172
+ }
173
+ yield* this.emitTextBlockEvent(event);
174
+ yield* this.emitToolCall(streamEvent);
175
+ }
176
+ if (message.type === "assistant") {
177
+ const assistantMsg = message;
178
+ const content = assistantMsg.message?.content;
179
+ if (!content)
180
+ continue;
181
+ const tools = extractToolUses(content);
182
+ for (const tool of tools) {
183
+ yield this.emit({
184
+ type: "tool-input-available",
185
+ toolCallId: tool.id,
186
+ toolName: tool.name,
187
+ input: tool.input,
188
+ dynamic: true,
189
+ providerExecuted: true
190
+ });
191
+ }
192
+ }
193
+ if (message.type === "user") {
194
+ const userMsg = message;
195
+ const content = userMsg.message?.content;
196
+ for (const part of content) {
197
+ if (part.type === "tool_result") {
198
+ yield this.emit({
199
+ type: "tool-output-available",
200
+ toolCallId: part.tool_use_id,
201
+ output: message.tool_use_result || part.content,
202
+ dynamic: true,
203
+ providerExecuted: true
204
+ });
205
+ }
206
+ }
207
+ }
208
+ if (message.type === "result") {
209
+ const resultMsg = message;
210
+ const finishTime = (/* @__PURE__ */ new Date()).toISOString();
211
+ console.error(`[AISDKStream] Processing result message at ${finishTime}`);
212
+ const finishEvent = this.emit({
213
+ type: "finish",
214
+ finishReason: mapFinishReason(resultMsg.subtype, resultMsg.is_error),
215
+ messageMetadata: {
216
+ usage: convertUsageToAISDK(resultMsg.usage ?? {})
217
+ }
218
+ });
219
+ console.error(`[AISDKStream] Emitting finish event at ${(/* @__PURE__ */ new Date()).toISOString()}`);
220
+ yield finishEvent;
221
+ console.error(`[AISDKStream] Emitted [DONE] at ${(/* @__PURE__ */ new Date()).toISOString()}`);
222
+ }
223
+ }
224
+ } catch (error) {
225
+ debugMessages.push(error);
226
+ } finally {
227
+ if (debugMessages.length > 0) {
228
+ writeFile(join(process.cwd(), debugFile), JSON.stringify(debugMessages, null, 2), "utf-8").catch((writeError) => {
229
+ console.error(`[AISDKStream] Failed to write debug file:`, writeError);
230
+ });
231
+ }
232
+ options?.onCleanup?.();
233
+ yield `data: [DONE]
234
+
235
+ `;
236
+ }
237
+ }
238
+ };
239
+ function streamSDKMessagesToAISDKUI(messageIterator, options) {
240
+ return new AISDKStreamConverter().stream(messageIterator, options);
241
+ }
242
+
6
243
  // ../../packages/runner-claude/dist/claude-runner.js
7
244
  function createCanUseToolCallback(claudeOptions) {
8
245
  return async (toolName, input, options) => {
@@ -43,7 +280,7 @@ function createCanUseToolCallback(claudeOptions) {
43
280
  }
44
281
  };
45
282
  }
46
- } catch (error) {
283
+ } catch {
47
284
  }
48
285
  await new Promise((resolve) => setTimeout(resolve, 500));
49
286
  }
@@ -78,22 +315,19 @@ var OPTIONAL_MODULES = {
78
315
  };
79
316
  function createClaudeRunner(options) {
80
317
  return {
81
- async *run(userInput, signal) {
82
- if (signal?.aborted) {
83
- return;
84
- }
318
+ async *run(userInput) {
85
319
  const apiKey = process.env.ANTHROPIC_API_KEY || process.env.AWS_BEARER_TOKEN_BEDROCK;
86
320
  if (!apiKey) {
87
321
  console.error("[SandAgent] Warning: ANTHROPIC_API_KEY or AWS_BEARER_TOKEN_BEDROCK not set. Using mock response.\nTo use the real Claude Agent SDK:\n1. Set ANTHROPIC_API_KEY or AWS_BEARER_TOKEN_BEDROCK environment variable\n2. Install the SDK: npm install @anthropic-ai/claude-agent-sdk");
88
- yield* runMockAgent(options, userInput, signal);
322
+ yield* runMockAgent(options, userInput, options.abortController?.signal);
89
323
  return;
90
324
  }
91
325
  const sdk = await loadClaudeAgentSDK();
92
326
  if (sdk) {
93
- yield* runWithClaudeAgentSDK(sdk, options, userInput, signal);
327
+ yield* runWithClaudeAgentSDK(sdk, options, userInput);
94
328
  } else {
95
329
  console.error("[SandAgent] Warning: @anthropic-ai/claude-agent-sdk not installed. Using mock response.\nInstall the SDK: npm install @anthropic-ai/claude-agent-sdk");
96
- yield* runMockAgent(options, userInput, signal);
330
+ yield* runMockAgent(options, userInput, options.abortController?.signal);
97
331
  }
98
332
  }
99
333
  };
@@ -110,21 +344,21 @@ async function loadClaudeAgentSDK() {
110
344
  return null;
111
345
  }
112
346
  }
113
- async function* runWithClaudeAgentSDK(sdk, options, userInput, signal) {
347
+ async function* runWithClaudeAgentSDK(sdk, options, userInput) {
114
348
  const outputFormat = options.outputFormat || "stream-json";
115
349
  switch (outputFormat) {
116
350
  case "text":
117
- yield* runWithTextOutput(sdk, options, userInput, signal);
351
+ yield* runWithTextOutput(sdk, options, userInput);
118
352
  break;
119
353
  case "json":
120
- yield* runWithJSONOutput(sdk, options, userInput, signal);
354
+ yield* runWithJSONOutput(sdk, options, userInput);
121
355
  break;
122
356
  case "stream-json":
123
- yield* runWithStreamJSONOutput(sdk, options, userInput, signal);
357
+ yield* runWithStreamJSONOutput(sdk, options, userInput);
124
358
  break;
125
359
  // case "stream":
126
360
  default:
127
- yield* runWithAISDKUIOutput(sdk, options, userInput, signal);
361
+ yield* runWithAISDKUIOutput(sdk, options, userInput);
128
362
  break;
129
363
  }
130
364
  }
@@ -133,7 +367,12 @@ function createSDKOptions(options) {
133
367
  model: options.model,
134
368
  systemPrompt: options.systemPrompt,
135
369
  maxTurns: options.maxTurns,
136
- allowedTools: [...options.allowedTools ?? [], "Skill"],
370
+ allowedTools: [
371
+ ...options.allowedTools ?? [],
372
+ "Skill",
373
+ "WebSearch",
374
+ "WebFetch"
375
+ ],
137
376
  cwd: options.cwd,
138
377
  env: options.env,
139
378
  resume: options.resume,
@@ -141,13 +380,16 @@ function createSDKOptions(options) {
141
380
  canUseTool: createCanUseToolCallback(options),
142
381
  // Bypass all permission checks for automated execution
143
382
  permissionMode: "bypassPermissions",
144
- allowDangerouslySkipPermissions: true
383
+ allowDangerouslySkipPermissions: true,
384
+ // Enable partial messages for streaming
385
+ includePartialMessages: options.includePartialMessages
145
386
  };
146
387
  }
147
388
  function setupAbortHandler(queryIterator, signal) {
148
389
  const abortHandler = async () => {
149
- console.error("[ClaudeRunner] Operation aborted, calling query.interrupt()");
390
+ console.error("[ClaudeRunner] Abort signal received, will call query.interrupt()...");
150
391
  await queryIterator.interrupt();
392
+ console.error("[ClaudeRunner] query.interrupt() completed");
151
393
  };
152
394
  if (signal) {
153
395
  console.error("[ClaudeRunner] Signal provided, adding abort listener");
@@ -163,7 +405,7 @@ function setupAbortHandler(queryIterator, signal) {
163
405
  async function* runWithTextOutput(sdk, options, userInput, signal) {
164
406
  const sdkOptions = createSDKOptions(options);
165
407
  const queryIterator = sdk.query({ prompt: userInput, options: sdkOptions });
166
- const abortHandler = setupAbortHandler(queryIterator, signal);
408
+ const abortHandler = setupAbortHandler(queryIterator, options.abortController?.signal);
167
409
  try {
168
410
  let resultText = "";
169
411
  for await (const message of queryIterator) {
@@ -215,170 +457,14 @@ async function* runWithStreamJSONOutput(sdk, options, userInput, signal) {
215
457
  }
216
458
  }
217
459
  }
218
- async function* runWithAISDKUIOutput(sdk, options, userInput, signal) {
219
- const usage = { inputTokens: 0, outputTokens: 0 };
220
- let systemMessage;
221
- let messageId;
222
- const sdkOptions = createSDKOptions(options);
460
+ async function* runWithAISDKUIOutput(sdk, options, userInput) {
461
+ const sdkOptions = createSDKOptions({
462
+ ...options,
463
+ includePartialMessages: true
464
+ });
223
465
  const queryIterator = sdk.query({ prompt: userInput, options: sdkOptions });
224
- const abortHandler = setupAbortHandler(queryIterator, signal);
225
- try {
226
- for await (const message of queryIterator) {
227
- if (message.type === "system" && message.subtype === "init") {
228
- systemMessage = message;
229
- continue;
230
- }
231
- if (message.type === "assistant" && !messageId && systemMessage) {
232
- messageId = message.message.id;
233
- yield formatDataStream({
234
- type: "start",
235
- messageId
236
- });
237
- yield formatDataStream({
238
- type: "message-metadata",
239
- messageMetadata: {
240
- tools: systemMessage.tools,
241
- model: systemMessage.model,
242
- sessionId: systemMessage.session_id
243
- }
244
- });
245
- }
246
- const chunks = convertSDKMessageToAISDKUI(message);
247
- for (const chunk of chunks) {
248
- yield chunk;
249
- }
250
- }
251
- } catch (error) {
252
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
253
- console.error("[ClaudeRunner] Error:", errorMessage);
254
- yield formatDataStream({ type: "error", errorText: errorMessage });
255
- yield formatDataStream({
256
- type: "finish",
257
- finishReason: "error",
258
- usage: {
259
- promptTokens: usage.inputTokens,
260
- completionTokens: usage.outputTokens
261
- }
262
- });
263
- } finally {
264
- if (signal) {
265
- signal.removeEventListener("abort", abortHandler);
266
- }
267
- yield `data: [DONE]
268
-
269
- `;
270
- }
271
- }
272
- function convertSDKMessageToAISDKUI(message) {
273
- const chunks = [];
274
- switch (message.type) {
275
- case "assistant": {
276
- const assistantMsg = message;
277
- if (assistantMsg.error) {
278
- const errorDetail = message.message.content.map((c) => c.text).join("\n");
279
- chunks.push(formatDataStream({
280
- type: "error",
281
- errorText: `${assistantMsg.error}: ${errorDetail}`
282
- }));
283
- break;
284
- }
285
- if (assistantMsg.message) {
286
- if (typeof assistantMsg.message === "string") {
287
- const textId = generateId();
288
- chunks.push(formatDataStream({ type: "text-start", id: textId }));
289
- chunks.push(formatDataStream({
290
- type: "text-delta",
291
- id: textId,
292
- delta: assistantMsg.message
293
- }));
294
- chunks.push(formatDataStream({ type: "text-end", id: textId }));
295
- } else if (assistantMsg.message.content && Array.isArray(assistantMsg.message.content)) {
296
- for (const block of assistantMsg.message.content) {
297
- if (block.type === "text" && block.text) {
298
- const textId = generateId();
299
- chunks.push(formatDataStream({ type: "text-start", id: textId }));
300
- chunks.push(formatDataStream({
301
- type: "text-delta",
302
- id: textId,
303
- delta: block.text
304
- }));
305
- chunks.push(formatDataStream({ type: "text-end", id: textId }));
306
- } else if (block.type === "tool_use") {
307
- const toolCallId = block.id || generateId();
308
- chunks.push(formatDataStream({
309
- type: "tool-input-start",
310
- toolCallId,
311
- toolName: block.name,
312
- dynamic: true
313
- }));
314
- if (block.input) {
315
- chunks.push(formatDataStream({
316
- type: "tool-input-available",
317
- toolCallId,
318
- toolName: block.name,
319
- dynamic: true,
320
- input: block.input
321
- }));
322
- }
323
- }
324
- }
325
- }
326
- }
327
- break;
328
- }
329
- case "result": {
330
- const resultMsg = message;
331
- chunks.push(formatDataStream({
332
- type: "finish",
333
- finishReason: resultMsg.is_error ? "error" : "stop",
334
- messageMetadata: {
335
- usage: resultMsg.usage,
336
- duration_ms: resultMsg.duration_ms,
337
- num_turns: resultMsg.num_turns,
338
- total_cost_usd: resultMsg.total_cost_usd
339
- }
340
- }));
341
- break;
342
- }
343
- case "user": {
344
- const usrMsg = message;
345
- if (usrMsg.isSynthetic) {
346
- break;
347
- }
348
- const contentArray = usrMsg.message.content;
349
- if (usrMsg.tool_use_result || Array.isArray(contentArray) && contentArray.some((c) => c.type === "tool_result")) {
350
- for (const tool of contentArray) {
351
- if (tool.is_error) {
352
- chunks.push(formatDataStream({
353
- type: "tool-output-error",
354
- toolCallId: tool.tool_use_id,
355
- errorText: tool.content,
356
- dynamic: true
357
- }));
358
- } else {
359
- chunks.push(formatDataStream({
360
- type: "tool-output-available",
361
- toolCallId: tool.tool_use_id,
362
- output: usrMsg.tool_use_result || tool.content,
363
- dynamic: true
364
- }));
365
- }
366
- }
367
- }
368
- break;
369
- }
370
- default:
371
- break;
372
- }
373
- return chunks;
374
- }
375
- function formatDataStream(data) {
376
- return `data: ${JSON.stringify(data)}
377
-
378
- `;
379
- }
380
- function generateId() {
381
- return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
466
+ setupAbortHandler(queryIterator, options.abortController?.signal);
467
+ yield* streamSDKMessagesToAISDKUI(queryIterator);
382
468
  }
383
469
  async function* runMockAgent(options, userInput, signal) {
384
470
  if (signal?.aborted) {
@@ -386,6 +472,11 @@ async function* runMockAgent(options, userInput, signal) {
386
472
  return;
387
473
  }
388
474
  try {
475
+ const messageId = generateId();
476
+ yield formatDataStream({
477
+ type: "start",
478
+ messageId
479
+ });
389
480
  const response = `I received your request: "${userInput}"
390
481
 
391
482
  Model: ${options.model}
@@ -413,7 +504,13 @@ Documentation: https://platform.claude.com/docs/en/agent-sdk/typescript-v2-previ
413
504
  yield formatDataStream({ type: "text-end", id: textId });
414
505
  yield formatDataStream({
415
506
  type: "finish",
416
- finishReason: "stop"
507
+ finishReason: mapFinishReason("success"),
508
+ usage: convertUsageToAISDK({
509
+ input_tokens: 0,
510
+ output_tokens: 0,
511
+ cache_creation_input_tokens: 0,
512
+ cache_read_input_tokens: 0
513
+ })
417
514
  });
418
515
  yield `data: [DONE]
419
516
 
@@ -424,7 +521,13 @@ Documentation: https://platform.claude.com/docs/en/agent-sdk/typescript-v2-previ
424
521
  yield formatDataStream({ type: "error", errorText: errorMessage });
425
522
  yield formatDataStream({
426
523
  type: "finish",
427
- finishReason: "error"
524
+ finishReason: mapFinishReason("error_during_execution", true),
525
+ usage: convertUsageToAISDK({
526
+ input_tokens: 0,
527
+ output_tokens: 0,
528
+ cache_creation_input_tokens: 0,
529
+ cache_read_input_tokens: 0
530
+ })
428
531
  });
429
532
  yield `data: [DONE]
430
533
 
@@ -435,29 +538,47 @@ Documentation: https://platform.claude.com/docs/en/agent-sdk/typescript-v2-previ
435
538
  // src/runner.ts
436
539
  async function runAgent(options) {
437
540
  const abortController = new AbortController();
438
- const signalHandler = () => {
541
+ const signalHandler = async () => {
439
542
  console.error("[Runner] Received termination signal, stopping...");
440
543
  abortController.abort();
441
- console.error("[Runner] AbortController.abort() called");
544
+ console.error(
545
+ "[Runner] AbortController.abort() completed (listeners triggered)"
546
+ );
442
547
  };
443
548
  process.on("SIGTERM", signalHandler);
444
549
  process.on("SIGINT", signalHandler);
445
550
  console.error("[Runner] Signal handlers registered");
446
551
  try {
447
- const runnerOptions = {
448
- model: options.model,
449
- systemPrompt: options.systemPrompt,
450
- maxTurns: options.maxTurns,
451
- allowedTools: options.allowedTools,
452
- resume: options.resume,
453
- approvalDir: options.approvalDir,
454
- outputFormat: options.outputFormat
455
- };
456
- const runner = createClaudeRunner(runnerOptions);
457
- for await (const chunk of runner.run(
458
- options.userInput,
459
- abortController.signal
460
- )) {
552
+ let runner;
553
+ switch (options.runner) {
554
+ case "claude": {
555
+ const runnerOptions = {
556
+ model: options.model,
557
+ systemPrompt: options.systemPrompt,
558
+ maxTurns: options.maxTurns,
559
+ allowedTools: options.allowedTools,
560
+ resume: options.resume,
561
+ approvalDir: options.approvalDir,
562
+ outputFormat: options.outputFormat,
563
+ abortController
564
+ };
565
+ runner = createClaudeRunner(runnerOptions);
566
+ break;
567
+ }
568
+ case "codex":
569
+ throw new Error(
570
+ "Codex runner not yet implemented. Use --runner=claude for now."
571
+ );
572
+ case "copilot":
573
+ throw new Error(
574
+ "Copilot runner not yet implemented. Use --runner=claude for now."
575
+ );
576
+ default:
577
+ throw new Error(
578
+ `Unknown runner: ${options.runner}. Supported runners: claude, codex, copilot`
579
+ );
580
+ }
581
+ for await (const chunk of runner.run(options.userInput)) {
461
582
  process.stdout.write(chunk);
462
583
  }
463
584
  } finally {
@@ -470,6 +591,11 @@ async function runAgent(options) {
470
591
  function parseCliArgs() {
471
592
  const { values, positionals } = parseArgs({
472
593
  options: {
594
+ runner: {
595
+ type: "string",
596
+ short: "r",
597
+ default: "claude"
598
+ },
473
599
  model: {
474
600
  type: "string",
475
601
  short: "m",
@@ -532,6 +658,13 @@ function parseCliArgs() {
532
658
  console.error('Usage: sandagent run [options] -- "<user input>"');
533
659
  process.exit(1);
534
660
  }
661
+ const runner = values.runner;
662
+ if (!["claude", "codex", "copilot"].includes(runner)) {
663
+ console.error(
664
+ 'Error: --runner must be one of: "claude", "codex", "copilot"'
665
+ );
666
+ process.exit(1);
667
+ }
535
668
  const outputFormat = values["output-format"];
536
669
  if (outputFormat && !["text", "json", "stream-json", "stream"].includes(outputFormat)) {
537
670
  console.error(
@@ -540,6 +673,7 @@ function parseCliArgs() {
540
673
  process.exit(1);
541
674
  }
542
675
  return {
676
+ runner,
543
677
  model: values.model,
544
678
  cwd: values.cwd,
545
679
  systemPrompt: values["system-prompt"],
@@ -562,6 +696,7 @@ Usage:
562
696
  sandagent run [options] -- "<user input>"
563
697
 
564
698
  Options:
699
+ -r, --runner <runner> Runner to use: claude, codex, copilot (default: claude)
565
700
  -m, --model <model> Model to use (default: claude-sonnet-4-20250514)
566
701
  -c, --cwd <path> Working directory (default: current directory)
567
702
  -s, --system-prompt <prompt> Custom system prompt
@@ -592,6 +727,7 @@ async function main() {
592
727
  const args = parseCliArgs();
593
728
  process.chdir(args.cwd);
594
729
  await runAgent({
730
+ runner: args.runner,
595
731
  model: args.model,
596
732
  userInput: args.userInput,
597
733
  systemPrompt: args.systemPrompt,