mini-coder 0.4.1 → 0.5.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.
Files changed (51) hide show
  1. package/README.md +87 -48
  2. package/assets/icon-1-minimal.svg +31 -0
  3. package/assets/icon-2-dark-terminal.svg +48 -0
  4. package/assets/icon-3-gradient-modern.svg +45 -0
  5. package/assets/icon-4-filled-bold.svg +54 -0
  6. package/assets/icon-5-community-badge.svg +63 -0
  7. package/assets/preview-0-5-0.png +0 -0
  8. package/assets/preview.gif +0 -0
  9. package/bin/mc.ts +14 -0
  10. package/bun.lock +438 -0
  11. package/package.json +12 -29
  12. package/src/agent.ts +592 -0
  13. package/src/cli.ts +124 -0
  14. package/src/git.ts +164 -0
  15. package/src/headless.ts +140 -0
  16. package/src/index.ts +645 -0
  17. package/src/input.ts +155 -0
  18. package/src/paths.ts +37 -0
  19. package/src/plugins.ts +183 -0
  20. package/src/prompt.ts +294 -0
  21. package/src/session.ts +838 -0
  22. package/src/settings.ts +184 -0
  23. package/src/skills.ts +258 -0
  24. package/src/submit.ts +323 -0
  25. package/src/theme.ts +147 -0
  26. package/src/tools.ts +636 -0
  27. package/src/ui/agent.test.ts +49 -0
  28. package/src/ui/agent.ts +210 -0
  29. package/src/ui/commands.test.ts +610 -0
  30. package/src/ui/commands.ts +638 -0
  31. package/src/ui/conversation.test.ts +892 -0
  32. package/src/ui/conversation.ts +926 -0
  33. package/src/ui/help.test.ts +26 -0
  34. package/src/ui/help.ts +119 -0
  35. package/src/ui/input.test.ts +74 -0
  36. package/src/ui/input.ts +138 -0
  37. package/src/ui/overlay.test.ts +42 -0
  38. package/src/ui/overlay.ts +59 -0
  39. package/src/ui/status.test.ts +450 -0
  40. package/src/ui/status.ts +357 -0
  41. package/src/ui.ts +615 -0
  42. package/.claude/settings.local.json +0 -54
  43. package/.prettierignore +0 -7
  44. package/dist/mc-edit.js +0 -275
  45. package/dist/mc.js +0 -7355
  46. package/docs/KNOWN_ISSUES.md +0 -13
  47. package/docs/design-decisions.md +0 -31
  48. package/docs/mini-coder.1.md +0 -227
  49. package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
  50. package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
  51. package/lefthook.yml +0 -4
package/src/agent.ts ADDED
@@ -0,0 +1,592 @@
1
+ /**
2
+ * Core agent loop.
3
+ *
4
+ * Streams LLM responses, executes tool calls, appends messages to the
5
+ * session history, and loops until the model stops or the user interrupts.
6
+ * Uses pi-ai's {@link streamSimple} for model communication.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { Database } from "bun:sqlite";
12
+ import type {
13
+ AssistantMessage,
14
+ AssistantMessageEvent,
15
+ Message,
16
+ Model,
17
+ ThinkingLevel,
18
+ Tool,
19
+ ToolCall,
20
+ ToolResultMessage,
21
+ } from "@mariozechner/pi-ai";
22
+ import { streamSimple } from "@mariozechner/pi-ai";
23
+ import { appendMessage } from "./session.ts";
24
+ import type { ToolExecResult } from "./tools.ts";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * A tool execution handler.
32
+ *
33
+ * Called by the agent loop when the model invokes a tool. Arguments are
34
+ * the JSON object parsed by pi-ai from the model's tool call.
35
+ */
36
+ /** Callback used by tool handlers to report progressive output updates. */
37
+ export type ToolUpdateCallback = (result: ToolExecResult) => void;
38
+
39
+ export type ToolHandler = (
40
+ args: Record<string, unknown>,
41
+ cwd: string,
42
+ signal?: AbortSignal,
43
+ onUpdate?: ToolUpdateCallback,
44
+ ) => Promise<ToolExecResult> | ToolExecResult;
45
+
46
+ export type { ToolExecResult };
47
+
48
+ /** Events emitted during the agent loop for UI updates. */
49
+ export type AgentEvent =
50
+ | {
51
+ type: "text_delta";
52
+ delta: string;
53
+ content: AssistantMessage["content"];
54
+ }
55
+ | {
56
+ type: "thinking_delta";
57
+ delta: string;
58
+ content: AssistantMessage["content"];
59
+ }
60
+ | {
61
+ type: "toolcall_start";
62
+ toolCallId: string;
63
+ name: string;
64
+ args: Record<string, unknown>;
65
+ content: AssistantMessage["content"];
66
+ }
67
+ | {
68
+ type: "toolcall_delta";
69
+ toolCallId: string;
70
+ name: string;
71
+ args: Record<string, unknown>;
72
+ delta: string;
73
+ content: AssistantMessage["content"];
74
+ }
75
+ | {
76
+ type: "toolcall_end";
77
+ toolCallId: string;
78
+ name: string;
79
+ args: Record<string, unknown>;
80
+ content: AssistantMessage["content"];
81
+ }
82
+ | { type: "assistant_message"; message: AssistantMessage }
83
+ | {
84
+ type: "tool_start";
85
+ toolCallId: string;
86
+ name: string;
87
+ args: Record<string, unknown>;
88
+ }
89
+ | {
90
+ type: "tool_delta";
91
+ toolCallId: string;
92
+ name: string;
93
+ result: ToolExecResult;
94
+ }
95
+ | {
96
+ type: "tool_end";
97
+ toolCallId: string;
98
+ name: string;
99
+ result: ToolExecResult;
100
+ }
101
+ | { type: "tool_result"; message: ToolResultMessage }
102
+ | { type: "done"; message: AssistantMessage }
103
+ | { type: "error"; message: AssistantMessage }
104
+ | { type: "aborted"; message: AssistantMessage };
105
+
106
+ /** Options for the agent loop. */
107
+ interface RunAgentOpts {
108
+ /** Open database handle. */
109
+ db: Database;
110
+ /** Current session ID. */
111
+ sessionId: string;
112
+ /** Turn number for this agent loop (all messages share this turn). */
113
+ turn: number;
114
+ /** The model to stream with. */
115
+ model: Model<string>;
116
+ /** The assembled system prompt. */
117
+ systemPrompt: string;
118
+ /** Tool definitions sent to the model. */
119
+ tools: Tool[];
120
+ /** Tool name → handler map for executing tool calls. */
121
+ toolHandlers: Map<string, ToolHandler>;
122
+ /** Current message history (mutated in-place as messages are appended). */
123
+ messages: Message[];
124
+ /** Working directory for tool execution. */
125
+ cwd: string;
126
+ /** API key for the provider. */
127
+ apiKey?: string;
128
+ /** Reasoning effort level (e.g. "low", "medium", "high", "xhigh"). */
129
+ effort?: ThinkingLevel;
130
+ /** Abort signal for interruption. */
131
+ signal?: AbortSignal;
132
+ /** Callback for UI events. */
133
+ onEvent?: (event: AgentEvent) => void;
134
+ }
135
+
136
+ /** Result of the agent loop. */
137
+ interface AgentLoopResult {
138
+ /** The updated message history. */
139
+ messages: Message[];
140
+ /** How the loop ended. */
141
+ stopReason: "stop" | "length" | "error" | "aborted";
142
+ }
143
+
144
+ function cloneAssistantContent(
145
+ content: AssistantMessage["content"],
146
+ ): AssistantMessage["content"] {
147
+ return content.map((block) => {
148
+ if (block.type === "toolCall") {
149
+ return {
150
+ ...block,
151
+ arguments: structuredClone(block.arguments),
152
+ };
153
+ }
154
+ return { ...block };
155
+ });
156
+ }
157
+
158
+ interface MergedAssistantBlock {
159
+ block: AssistantMessage["content"][number];
160
+ partialStep: number;
161
+ finalStep: number;
162
+ }
163
+
164
+ function mergeAssistantBlocks(
165
+ partialBlock: AssistantMessage["content"][number] | undefined,
166
+ finalBlock: AssistantMessage["content"][number] | undefined,
167
+ ): MergedAssistantBlock | null {
168
+ if (!partialBlock && !finalBlock) {
169
+ return null;
170
+ }
171
+ if (!partialBlock && finalBlock) {
172
+ return { block: finalBlock, partialStep: 0, finalStep: 1 };
173
+ }
174
+ if (partialBlock && !finalBlock) {
175
+ return { block: partialBlock, partialStep: 1, finalStep: 0 };
176
+ }
177
+ if (!partialBlock || !finalBlock) {
178
+ return null;
179
+ }
180
+ if (partialBlock.type === finalBlock.type) {
181
+ const shouldPreservePartialThinking =
182
+ partialBlock.type === "thinking" &&
183
+ finalBlock.type === "thinking" &&
184
+ partialBlock.thinking &&
185
+ !finalBlock.thinking;
186
+ return {
187
+ block: shouldPreservePartialThinking ? partialBlock : finalBlock,
188
+ partialStep: 1,
189
+ finalStep: 1,
190
+ };
191
+ }
192
+ if (partialBlock.type === "thinking") {
193
+ return { block: partialBlock, partialStep: 1, finalStep: 0 };
194
+ }
195
+ return { block: finalBlock, partialStep: 1, finalStep: 1 };
196
+ }
197
+
198
+ /** Merge streamed partial assistant content into the final message content. */
199
+ function mergeAssistantContent(
200
+ partialContent: AssistantMessage["content"],
201
+ finalContent: AssistantMessage["content"],
202
+ ): AssistantMessage["content"] {
203
+ if (partialContent.length === 0) return finalContent;
204
+ if (finalContent.length === 0) return partialContent;
205
+
206
+ const merged: AssistantMessage["content"] = [];
207
+ let partialIndex = 0;
208
+ let finalIndex = 0;
209
+
210
+ while (
211
+ partialIndex < partialContent.length ||
212
+ finalIndex < finalContent.length
213
+ ) {
214
+ const nextBlock = mergeAssistantBlocks(
215
+ partialContent[partialIndex],
216
+ finalContent[finalIndex],
217
+ );
218
+ if (!nextBlock) {
219
+ break;
220
+ }
221
+ merged.push(nextBlock.block);
222
+ partialIndex += nextBlock.partialStep;
223
+ finalIndex += nextBlock.finalStep;
224
+ }
225
+
226
+ return merged;
227
+ }
228
+
229
+ /** Merge a final assistant message with the richest streamed partial content seen. */
230
+ function mergeAssistantMessage(
231
+ partialMessage: AssistantMessage,
232
+ finalMessage: AssistantMessage,
233
+ ): AssistantMessage {
234
+ return {
235
+ ...finalMessage,
236
+ content: mergeAssistantContent(
237
+ partialMessage.content,
238
+ finalMessage.content,
239
+ ),
240
+ };
241
+ }
242
+
243
+ function toolErrorResult(name: string, error: unknown): ToolExecResult {
244
+ const message = error instanceof Error ? error.message : String(error);
245
+
246
+ return {
247
+ content: [{ type: "text", text: `Tool ${name} failed: ${message}` }],
248
+ isError: true,
249
+ };
250
+ }
251
+
252
+ function unknownToolResult(name: string): ToolExecResult {
253
+ return {
254
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
255
+ isError: true,
256
+ };
257
+ }
258
+
259
+ function buildAgentContext(
260
+ systemPrompt: string,
261
+ messages: Message[],
262
+ tools: Tool[],
263
+ ) {
264
+ return tools.length > 0
265
+ ? { systemPrompt, messages, tools }
266
+ : { systemPrompt, messages };
267
+ }
268
+
269
+ function buildStreamOptions(
270
+ apiKey: string | undefined,
271
+ effort: ThinkingLevel | undefined,
272
+ signal: AbortSignal | undefined,
273
+ ) {
274
+ return {
275
+ ...(apiKey ? { apiKey } : {}),
276
+ ...(effort ? { reasoning: effort } : {}),
277
+ ...(signal ? { signal } : {}),
278
+ };
279
+ }
280
+
281
+ interface StreamedToolCallEventPayload {
282
+ toolCallId: string;
283
+ name: string;
284
+ args: Record<string, unknown>;
285
+ content: AssistantMessage["content"];
286
+ }
287
+
288
+ function buildStreamedToolCallEventPayload(
289
+ partial: AssistantMessage,
290
+ contentIndex: number,
291
+ toolCall?: ToolCall,
292
+ ): StreamedToolCallEventPayload | null {
293
+ const block = toolCall ?? partial.content[contentIndex];
294
+ if (!block || block.type !== "toolCall") {
295
+ return null;
296
+ }
297
+
298
+ return {
299
+ toolCallId: block.id,
300
+ name: block.name,
301
+ args: structuredClone(block.arguments),
302
+ content: cloneAssistantContent(partial.content),
303
+ };
304
+ }
305
+
306
+ function handleAssistantStreamEvent(
307
+ event: AssistantMessageEvent,
308
+ onEvent: RunAgentOpts["onEvent"],
309
+ ): AssistantMessage | null {
310
+ switch (event.type) {
311
+ case "text_delta":
312
+ onEvent?.({
313
+ type: "text_delta",
314
+ delta: event.delta,
315
+ content: cloneAssistantContent(event.partial.content),
316
+ });
317
+ return null;
318
+ case "thinking_delta":
319
+ onEvent?.({
320
+ type: "thinking_delta",
321
+ delta: event.delta,
322
+ content: cloneAssistantContent(event.partial.content),
323
+ });
324
+ return null;
325
+ case "toolcall_start": {
326
+ const payload = buildStreamedToolCallEventPayload(
327
+ event.partial,
328
+ event.contentIndex,
329
+ );
330
+ if (payload) {
331
+ onEvent?.({
332
+ type: "toolcall_start",
333
+ ...payload,
334
+ });
335
+ }
336
+ return null;
337
+ }
338
+ case "toolcall_delta": {
339
+ const payload = buildStreamedToolCallEventPayload(
340
+ event.partial,
341
+ event.contentIndex,
342
+ );
343
+ if (payload) {
344
+ onEvent?.({
345
+ type: "toolcall_delta",
346
+ delta: event.delta,
347
+ ...payload,
348
+ });
349
+ }
350
+ return null;
351
+ }
352
+ case "toolcall_end": {
353
+ const payload = buildStreamedToolCallEventPayload(
354
+ event.partial,
355
+ event.contentIndex,
356
+ event.toolCall,
357
+ );
358
+ if (payload) {
359
+ onEvent?.({
360
+ type: "toolcall_end",
361
+ ...payload,
362
+ });
363
+ }
364
+ return null;
365
+ }
366
+ case "done":
367
+ return event.message;
368
+ case "error":
369
+ return event.error;
370
+ case "start":
371
+ case "text_start":
372
+ case "text_end":
373
+ case "thinking_start":
374
+ case "thinking_end":
375
+ return null;
376
+ }
377
+ }
378
+
379
+ async function streamAssistantMessage(
380
+ opts: Pick<
381
+ RunAgentOpts,
382
+ | "model"
383
+ | "systemPrompt"
384
+ | "tools"
385
+ | "messages"
386
+ | "apiKey"
387
+ | "effort"
388
+ | "signal"
389
+ | "onEvent"
390
+ >,
391
+ ): Promise<AssistantMessage> {
392
+ const eventStream = streamSimple(
393
+ opts.model,
394
+ buildAgentContext(opts.systemPrompt, opts.messages, opts.tools),
395
+ buildStreamOptions(opts.apiKey, opts.effort, opts.signal),
396
+ );
397
+ let assistantMessage: AssistantMessage | undefined;
398
+ let partialAssistantMessage: AssistantMessage | undefined;
399
+
400
+ for await (const event of eventStream) {
401
+ if ("partial" in event) {
402
+ partialAssistantMessage = event.partial;
403
+ }
404
+
405
+ assistantMessage =
406
+ handleAssistantStreamEvent(event, opts.onEvent) ?? assistantMessage;
407
+ }
408
+
409
+ const finalAssistantMessage =
410
+ assistantMessage ?? (await eventStream.result());
411
+ if (!partialAssistantMessage) {
412
+ return finalAssistantMessage;
413
+ }
414
+ return mergeAssistantMessage(partialAssistantMessage, finalAssistantMessage);
415
+ }
416
+
417
+ function appendAssistantMessage(
418
+ db: Database,
419
+ sessionId: string,
420
+ messages: Message[],
421
+ assistantMessage: AssistantMessage,
422
+ turn: number,
423
+ onEvent: RunAgentOpts["onEvent"],
424
+ ): void {
425
+ messages.push(assistantMessage);
426
+ appendMessage(db, sessionId, assistantMessage, turn);
427
+ onEvent?.({ type: "assistant_message", message: assistantMessage });
428
+ }
429
+
430
+ function resolveLoopStopReason(
431
+ assistantMessage: AssistantMessage,
432
+ messages: Message[],
433
+ onEvent: RunAgentOpts["onEvent"],
434
+ ): AgentLoopResult | null {
435
+ if (
436
+ assistantMessage.stopReason === "error" ||
437
+ assistantMessage.stopReason === "aborted"
438
+ ) {
439
+ const eventType =
440
+ assistantMessage.stopReason === "error" ? "error" : "aborted";
441
+ onEvent?.({ type: eventType, message: assistantMessage });
442
+ return { messages, stopReason: assistantMessage.stopReason };
443
+ }
444
+
445
+ if (
446
+ assistantMessage.stopReason === "stop" ||
447
+ assistantMessage.stopReason === "length"
448
+ ) {
449
+ onEvent?.({ type: "done", message: assistantMessage });
450
+ return { messages, stopReason: assistantMessage.stopReason };
451
+ }
452
+
453
+ return null;
454
+ }
455
+
456
+ function getAssistantToolCalls(message: AssistantMessage): ToolCall[] {
457
+ return message.content.filter((content): content is ToolCall => {
458
+ return content.type === "toolCall";
459
+ });
460
+ }
461
+
462
+ async function executeToolCall(
463
+ toolCall: ToolCall,
464
+ opts: Pick<RunAgentOpts, "toolHandlers" | "cwd" | "signal" | "onEvent">,
465
+ ): Promise<ToolExecResult> {
466
+ const handler = opts.toolHandlers.get(toolCall.name);
467
+ if (!handler) {
468
+ return unknownToolResult(toolCall.name);
469
+ }
470
+
471
+ opts.onEvent?.({
472
+ type: "tool_start",
473
+ toolCallId: toolCall.id,
474
+ name: toolCall.name,
475
+ args: toolCall.arguments,
476
+ });
477
+
478
+ let result: ToolExecResult;
479
+ try {
480
+ result = await handler(
481
+ toolCall.arguments,
482
+ opts.cwd,
483
+ opts.signal,
484
+ (partial) => {
485
+ opts.onEvent?.({
486
+ type: "tool_delta",
487
+ toolCallId: toolCall.id,
488
+ name: toolCall.name,
489
+ result: partial,
490
+ });
491
+ },
492
+ );
493
+ } catch (error) {
494
+ result = toolErrorResult(toolCall.name, error);
495
+ }
496
+
497
+ opts.onEvent?.({
498
+ type: "tool_end",
499
+ toolCallId: toolCall.id,
500
+ name: toolCall.name,
501
+ result,
502
+ });
503
+ return result;
504
+ }
505
+
506
+ function appendToolResultMessage(
507
+ db: Database,
508
+ sessionId: string,
509
+ messages: Message[],
510
+ toolCall: ToolCall,
511
+ result: ToolExecResult,
512
+ turn: number,
513
+ onEvent: RunAgentOpts["onEvent"],
514
+ ): void {
515
+ const toolResultMessage: ToolResultMessage = {
516
+ role: "toolResult",
517
+ toolCallId: toolCall.id,
518
+ toolName: toolCall.name,
519
+ content: result.content,
520
+ isError: result.isError,
521
+ timestamp: Date.now(),
522
+ };
523
+
524
+ messages.push(toolResultMessage);
525
+ appendMessage(db, sessionId, toolResultMessage, turn);
526
+ onEvent?.({ type: "tool_result", message: toolResultMessage });
527
+ }
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // Agent loop
531
+ // ---------------------------------------------------------------------------
532
+
533
+ /**
534
+ * Run the agent loop for a single turn.
535
+ *
536
+ * Streams an LLM response, executes any tool calls, appends all messages
537
+ * to the session (sharing the same turn number), and loops back when the
538
+ * model requests tool use. Returns when the model stops, hits a length
539
+ * limit, errors, or is aborted.
540
+ *
541
+ * @param opts - Agent loop options.
542
+ * @returns The loop result with updated messages and stop reason.
543
+ */
544
+ export async function runAgentLoop(
545
+ opts: RunAgentOpts,
546
+ ): Promise<AgentLoopResult> {
547
+ const { db, sessionId, turn, messages, signal, onEvent, toolHandlers, cwd } =
548
+ opts;
549
+
550
+ while (true) {
551
+ const assistantMessage = await streamAssistantMessage(opts);
552
+ appendAssistantMessage(
553
+ db,
554
+ sessionId,
555
+ messages,
556
+ assistantMessage,
557
+ turn,
558
+ onEvent,
559
+ );
560
+
561
+ const stopResult = resolveLoopStopReason(
562
+ assistantMessage,
563
+ messages,
564
+ onEvent,
565
+ );
566
+ if (stopResult) {
567
+ return stopResult;
568
+ }
569
+
570
+ for (const toolCall of getAssistantToolCalls(assistantMessage)) {
571
+ const result = await executeToolCall(toolCall, {
572
+ toolHandlers,
573
+ cwd,
574
+ signal,
575
+ onEvent,
576
+ });
577
+ appendToolResultMessage(
578
+ db,
579
+ sessionId,
580
+ messages,
581
+ toolCall,
582
+ result,
583
+ turn,
584
+ onEvent,
585
+ );
586
+
587
+ if (signal?.aborted) {
588
+ return { messages, stopReason: "aborted" };
589
+ }
590
+ }
591
+ }
592
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * CLI argument parsing and launch-mode selection.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Types
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /** Parsed CLI options for the current launch. */
12
+ export interface CliOptions {
13
+ /** One-shot prompt text, or `null` when not provided. */
14
+ prompt: string | null;
15
+ }
16
+
17
+ /** TTY availability for stdin/stdout. */
18
+ export interface TtyState {
19
+ /** Whether stdin is attached to a TTY. */
20
+ stdinIsTTY: boolean;
21
+ /** Whether stdout is attached to a TTY. */
22
+ stdoutIsTTY: boolean;
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Argument parsing
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Parse supported CLI arguments.
31
+ *
32
+ * Currently supports only `-p, --prompt <text>` for headless one-shot mode.
33
+ * Unknown flags and positional arguments fail eagerly.
34
+ *
35
+ * @param argv - Process arguments excluding the Bun executable and script path.
36
+ * @returns The parsed CLI options.
37
+ */
38
+ export function parseCliArgs(argv: readonly string[]): CliOptions {
39
+ let prompt: string | null = null;
40
+
41
+ for (let index = 0; index < argv.length; index += 1) {
42
+ const arg = argv[index];
43
+ if (!arg) {
44
+ continue;
45
+ }
46
+
47
+ if (arg === "-p" || arg === "--prompt") {
48
+ const value = argv[index + 1];
49
+ if (value == null) {
50
+ throw new Error("Missing value for -p/--prompt.");
51
+ }
52
+ prompt = value;
53
+ index += 1;
54
+ continue;
55
+ }
56
+
57
+ if (arg.startsWith("--prompt=")) {
58
+ prompt = arg.slice("--prompt=".length);
59
+ continue;
60
+ }
61
+
62
+ if (arg.startsWith("-")) {
63
+ throw new Error(`Unknown argument: ${arg}`);
64
+ }
65
+
66
+ throw new Error(`Unexpected positional argument: ${arg}`);
67
+ }
68
+
69
+ return { prompt };
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Mode selection
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Return whether the current launch should run in headless mode.
78
+ *
79
+ * Headless mode is selected when a one-shot prompt was provided or when
80
+ * either stdin or stdout is not attached to a TTY.
81
+ *
82
+ * @param options - Parsed CLI options.
83
+ * @param tty - Current TTY availability.
84
+ * @returns `true` when headless mode should be used.
85
+ */
86
+ export function shouldUseHeadlessMode(
87
+ options: CliOptions,
88
+ tty: TtyState,
89
+ ): boolean {
90
+ return options.prompt !== null || !tty.stdinIsTTY || !tty.stdoutIsTTY;
91
+ }
92
+
93
+ /**
94
+ * Resolve the raw prompt text for headless mode.
95
+ *
96
+ * Prefers the explicit CLI prompt. Otherwise reads all of stdin. Empty or
97
+ * whitespace-only input is rejected.
98
+ *
99
+ * @param options - Parsed CLI options.
100
+ * @param tty - Current TTY availability.
101
+ * @param readStdin - Callback that returns the full stdin contents.
102
+ * @returns The raw prompt text to submit.
103
+ */
104
+ export async function resolveHeadlessPrompt(
105
+ options: CliOptions,
106
+ tty: TtyState,
107
+ readStdin: () => Promise<string>,
108
+ ): Promise<string> {
109
+ const rawPrompt =
110
+ options.prompt !== null
111
+ ? options.prompt
112
+ : tty.stdinIsTTY
113
+ ? null
114
+ : await readStdin();
115
+
116
+ if (rawPrompt === null) {
117
+ throw new Error("Headless mode requires -p/--prompt or piped stdin.");
118
+ }
119
+ if (rawPrompt.trim().length === 0) {
120
+ throw new Error("Headless input is empty.");
121
+ }
122
+
123
+ return rawPrompt;
124
+ }