mini-coder 0.5.7 → 0.5.9

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/README.md CHANGED
@@ -39,10 +39,12 @@ $ mc
39
39
 
40
40
  ## Tools
41
41
 
42
- Two built-in tools, plus a read-only image tool:
42
+ Four built-in tools, plus a read-only image tool:
43
43
 
44
44
  - **`shell`** — runs commands in the user's shell. Returns stdout, stderr, and exit code. Large output is truncated to protect model context.
45
45
  - **`edit`** — exact-text replacement in a single file. Fails deterministically if the target is missing or ambiguous. Creates new files when old text is empty.
46
+ - **`todoWrite`** — creates or updates the session todo list incrementally and returns the full current snapshot.
47
+ - **`todoRead`** — returns the full current session todo list snapshot.
46
48
  - **`readImage`** — reads PNG, JPEG, GIF, and WebP files as model input. Only registered when the active model supports images.
47
49
 
48
50
  Plugins can add more tools, but the core stays intentionally small.
@@ -68,6 +70,7 @@ Plugins can add more tools, but the core stays intentionally small.
68
70
  | `/undo` | Remove the last conversational turn without touching filesystem changes. |
69
71
  | `/reasoning` | Show or hide model thinking. The setting is saved and restored on launch. |
70
72
  | `/verbose` | Expand shell output plus edit previews and edit errors in the conversation log. |
73
+ | `/todo` | Show the current session todo list in the conversation log as a UI-only checklist block. |
71
74
  | `/login` | Sign in with a supported OAuth provider. |
72
75
  | `/logout` | Remove saved OAuth credentials for a logged-in provider. |
73
76
  | `/effort` | Choose low, medium, high, or xhigh reasoning effort. |
@@ -100,7 +103,7 @@ $ printf '%s\n' 'fix the failing tests' | mc
100
103
  - Starts when `-p/--prompt` is provided or when stdin or stdout is not a TTY.
101
104
  - If stdout is redirected but stdin is still interactive, pass `-p`; headless mode will not fall back to an interactive prompt.
102
105
  - Uses the same parser as the TUI for plain text, `/skill:name`, and standalone image paths.
103
- - Writes raw NDJSON events to stdout for text deltas, tool activity, final messages, and `done` / `error` / `aborted` outcomes.
106
+ - With `--json`, writes NDJSON events for completed assistant/tool-result messages plus `done` / `error` / `aborted` outcomes; streaming deltas are omitted.
104
107
  - Headless runs still persist like normal sessions and show up in `/session` history for that working directory.
105
108
  - Interactive slash commands such as `/model`, `/session`, and `/help` are not available in headless mode.
106
109
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "A small, fast CLI coding agent",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
package/src/agent.ts CHANGED
@@ -22,7 +22,7 @@ import type {
22
22
  } from "@mariozechner/pi-ai";
23
23
  import { streamSimple } from "@mariozechner/pi-ai";
24
24
  import { appendMessage } from "./session.ts";
25
- import type { ToolExecResult } from "./tools.ts";
25
+ import { getTodoItems, type ToolExecResult } from "./tools.ts";
26
26
 
27
27
  // ---------------------------------------------------------------------------
28
28
  // Types
@@ -605,28 +605,111 @@ function appendToolResultMessage(
605
605
  onEvent?.({ type: "tool_result", message: toolResultMessage });
606
606
  }
607
607
 
608
- function finalizeStoppedAssistantMessage(
609
- db: Database,
610
- sessionId: string,
611
- messages: Message[],
608
+ function getIncompleteTodos(messages: readonly Message[]) {
609
+ return getTodoItems(messages).filter((todo) => todo.status !== "completed");
610
+ }
611
+
612
+ function getTodoReminderSignature(
613
+ todos: ReturnType<typeof getIncompleteTodos>,
614
+ ): string {
615
+ return JSON.stringify(
616
+ [...todos].sort((left, right) => {
617
+ const contentOrder = left.content.localeCompare(right.content);
618
+ if (contentOrder !== 0) {
619
+ return contentOrder;
620
+ }
621
+ return left.status.localeCompare(right.status);
622
+ }),
623
+ );
624
+ }
625
+
626
+ function createTodoReminderMessage(
627
+ messages: readonly Message[],
628
+ remindedTodoSignatures: Set<string>,
629
+ ): UserMessage | null {
630
+ const incompleteTodos = getIncompleteTodos(messages);
631
+ if (incompleteTodos.length === 0) {
632
+ return null;
633
+ }
634
+
635
+ const signature = getTodoReminderSignature(incompleteTodos);
636
+ if (remindedTodoSignatures.has(signature)) {
637
+ return null;
638
+ }
639
+ remindedTodoSignatures.add(signature);
640
+
641
+ const lines = [
642
+ "You have pending todo items that must be completed before finishing the task:",
643
+ "",
644
+ ...incompleteTodos.map((todo) => {
645
+ const status = todo.status === "in_progress" ? "IN_PROGRESS" : "PENDING";
646
+ return `- [${status}] ${todo.content}`;
647
+ }),
648
+ "",
649
+ "Please complete all pending items before finishing.",
650
+ ];
651
+
652
+ return {
653
+ role: "user",
654
+ content: lines.join("\n"),
655
+ timestamp: Date.now(),
656
+ };
657
+ }
658
+
659
+ interface StoppedAssistantResolution {
660
+ /** Next turn number when a queued steering message was consumed. */
661
+ nextTurn: number | null;
662
+ /** Ephemeral context messages to include on the next model request. */
663
+ pendingContextMessages: Message[];
664
+ /** Final loop result when the turn should stop immediately. */
665
+ finalResult: AgentLoopResult | null;
666
+ }
667
+
668
+ function resolveStoppedAssistantMessage(
612
669
  assistantMessage: AssistantMessage,
613
670
  stopReason: "stop" | "length",
614
- takeQueuedUserMessage: RunAgentOpts["takeQueuedUserMessage"],
615
- onEvent: RunAgentOpts["onEvent"],
616
- ): AgentLoopResult | number {
671
+ opts: Pick<
672
+ RunAgentOpts,
673
+ "db" | "sessionId" | "messages" | "takeQueuedUserMessage" | "onEvent"
674
+ >,
675
+ remindedTodoSignatures: Set<string>,
676
+ ): StoppedAssistantResolution {
617
677
  const queuedTurn = consumeQueuedUserMessage(
618
- db,
619
- sessionId,
620
- messages,
621
- takeQueuedUserMessage,
622
- onEvent,
678
+ opts.db,
679
+ opts.sessionId,
680
+ opts.messages,
681
+ opts.takeQueuedUserMessage,
682
+ opts.onEvent,
623
683
  );
624
684
  if (queuedTurn !== null) {
625
- return queuedTurn;
685
+ return {
686
+ nextTurn: queuedTurn,
687
+ pendingContextMessages: [],
688
+ finalResult: null,
689
+ };
626
690
  }
627
691
 
628
- onEvent?.({ type: "done", message: assistantMessage });
629
- return { messages, stopReason };
692
+ const todoReminder = createTodoReminderMessage(
693
+ opts.messages,
694
+ remindedTodoSignatures,
695
+ );
696
+ if (todoReminder) {
697
+ return {
698
+ nextTurn: null,
699
+ pendingContextMessages: [todoReminder],
700
+ finalResult: null,
701
+ };
702
+ }
703
+
704
+ opts.onEvent?.({ type: "done", message: assistantMessage });
705
+ return {
706
+ nextTurn: null,
707
+ pendingContextMessages: [],
708
+ finalResult: {
709
+ messages: opts.messages,
710
+ stopReason,
711
+ },
712
+ };
630
713
  }
631
714
 
632
715
  async function executeAssistantToolCalls(
@@ -671,6 +754,95 @@ async function executeAssistantToolCalls(
671
754
  return null;
672
755
  }
673
756
 
757
+ interface AgentIterationOutcome {
758
+ /** Final loop result when the run should stop immediately. */
759
+ finalResult: AgentLoopResult | null;
760
+ /** Next turn number when a queued steering message starts a new turn. */
761
+ nextTurn: number;
762
+ /** Ephemeral context messages for the next model request. */
763
+ pendingContextMessages: Message[];
764
+ }
765
+
766
+ async function resolveAgentIteration(
767
+ assistantMessage: AssistantMessage,
768
+ currentTurn: number,
769
+ remindedTodoSignatures: Set<string>,
770
+ opts: Pick<
771
+ RunAgentOpts,
772
+ | "db"
773
+ | "sessionId"
774
+ | "messages"
775
+ | "toolHandlers"
776
+ | "cwd"
777
+ | "signal"
778
+ | "onEvent"
779
+ | "model"
780
+ | "takeQueuedUserMessage"
781
+ >,
782
+ ): Promise<AgentIterationOutcome> {
783
+ const stopResult = resolveLoopStopReason(
784
+ assistantMessage,
785
+ opts.messages,
786
+ opts.onEvent,
787
+ );
788
+ if (stopResult) {
789
+ return {
790
+ finalResult: stopResult,
791
+ nextTurn: currentTurn,
792
+ pendingContextMessages: [],
793
+ };
794
+ }
795
+
796
+ if (
797
+ assistantMessage.stopReason === "stop" ||
798
+ assistantMessage.stopReason === "length"
799
+ ) {
800
+ const stopResolution = resolveStoppedAssistantMessage(
801
+ assistantMessage,
802
+ assistantMessage.stopReason,
803
+ {
804
+ db: opts.db,
805
+ sessionId: opts.sessionId,
806
+ messages: opts.messages,
807
+ takeQueuedUserMessage: opts.takeQueuedUserMessage,
808
+ onEvent: opts.onEvent,
809
+ },
810
+ remindedTodoSignatures,
811
+ );
812
+ return {
813
+ finalResult: stopResolution.finalResult,
814
+ nextTurn: stopResolution.nextTurn ?? currentTurn,
815
+ pendingContextMessages: stopResolution.pendingContextMessages,
816
+ };
817
+ }
818
+
819
+ const toolStopResult = await executeAssistantToolCalls(
820
+ assistantMessage,
821
+ opts,
822
+ currentTurn,
823
+ );
824
+ if (toolStopResult) {
825
+ return {
826
+ finalResult: toolStopResult,
827
+ nextTurn: currentTurn,
828
+ pendingContextMessages: [],
829
+ };
830
+ }
831
+
832
+ const queuedTurn = consumeQueuedUserMessage(
833
+ opts.db,
834
+ opts.sessionId,
835
+ opts.messages,
836
+ opts.takeQueuedUserMessage,
837
+ opts.onEvent,
838
+ );
839
+ return {
840
+ finalResult: null,
841
+ nextTurn: queuedTurn ?? currentTurn,
842
+ pendingContextMessages: [],
843
+ };
844
+ }
845
+
674
846
  // ---------------------------------------------------------------------------
675
847
  // Agent loop
676
848
  // ---------------------------------------------------------------------------
@@ -702,9 +874,18 @@ export async function runAgentLoop(
702
874
  takeQueuedUserMessage,
703
875
  } = opts;
704
876
  let currentTurn = turn;
877
+ let pendingContextMessages: Message[] = [];
878
+ const remindedTodoSignatures = new Set<string>();
705
879
 
706
880
  while (true) {
707
- const assistantMessage = await streamAssistantMessage(opts);
881
+ const assistantMessage = await streamAssistantMessage({
882
+ ...opts,
883
+ messages:
884
+ pendingContextMessages.length > 0
885
+ ? [...messages, ...pendingContextMessages]
886
+ : messages,
887
+ });
888
+ pendingContextMessages = [];
708
889
  appendAssistantMessage(
709
890
  db,
710
891
  sessionId,
@@ -714,37 +895,10 @@ export async function runAgentLoop(
714
895
  onEvent,
715
896
  );
716
897
 
717
- const stopResult = resolveLoopStopReason(
718
- assistantMessage,
719
- messages,
720
- onEvent,
721
- );
722
- if (stopResult) {
723
- return stopResult;
724
- }
725
-
726
- if (
727
- assistantMessage.stopReason === "stop" ||
728
- assistantMessage.stopReason === "length"
729
- ) {
730
- const finalResult = finalizeStoppedAssistantMessage(
731
- db,
732
- sessionId,
733
- messages,
734
- assistantMessage,
735
- assistantMessage.stopReason,
736
- takeQueuedUserMessage,
737
- onEvent,
738
- );
739
- if (typeof finalResult === "number") {
740
- currentTurn = finalResult;
741
- continue;
742
- }
743
- return finalResult;
744
- }
745
-
746
- const toolStopResult = await executeAssistantToolCalls(
898
+ const iterationOutcome = await resolveAgentIteration(
747
899
  assistantMessage,
900
+ currentTurn,
901
+ remindedTodoSignatures,
748
902
  {
749
903
  db,
750
904
  sessionId,
@@ -754,22 +908,14 @@ export async function runAgentLoop(
754
908
  signal,
755
909
  onEvent,
756
910
  model,
911
+ takeQueuedUserMessage,
757
912
  },
758
- currentTurn,
759
913
  );
760
- if (toolStopResult) {
761
- return toolStopResult;
914
+ if (iterationOutcome.finalResult) {
915
+ return iterationOutcome.finalResult;
762
916
  }
763
917
 
764
- const queuedTurn = consumeQueuedUserMessage(
765
- db,
766
- sessionId,
767
- messages,
768
- takeQueuedUserMessage,
769
- onEvent,
770
- );
771
- if (queuedTurn !== null) {
772
- currentTurn = queuedTurn;
773
- }
918
+ currentTurn = iterationOutcome.nextTurn;
919
+ pendingContextMessages = iterationOutcome.pendingContextMessages;
774
920
  }
775
921
  }
package/src/cli.ts CHANGED
@@ -12,6 +12,8 @@
12
12
  export interface CliOptions {
13
13
  /** One-shot prompt text, or `null` when not provided. */
14
14
  prompt: string | null;
15
+ /** Whether to stream headless output as NDJSON instead of final text. */
16
+ json: boolean;
15
17
  }
16
18
 
17
19
  /** TTY availability for stdin/stdout. */
@@ -29,7 +31,8 @@ export interface TtyState {
29
31
  /**
30
32
  * Parse supported CLI arguments.
31
33
  *
32
- * Currently supports only `-p, --prompt <text>` for headless one-shot mode.
34
+ * Supports `-p, --prompt <text>` for headless one-shot mode and `--json`
35
+ * to stream NDJSON events instead of the default final-text output.
33
36
  * Unknown flags and positional arguments fail eagerly.
34
37
  *
35
38
  * @param argv - Process arguments excluding the Bun executable and script path.
@@ -37,6 +40,7 @@ export interface TtyState {
37
40
  */
38
41
  export function parseCliArgs(argv: readonly string[]): CliOptions {
39
42
  let prompt: string | null = null;
43
+ let json = false;
40
44
 
41
45
  for (let index = 0; index < argv.length; index += 1) {
42
46
  const arg = argv[index];
@@ -59,6 +63,11 @@ export function parseCliArgs(argv: readonly string[]): CliOptions {
59
63
  continue;
60
64
  }
61
65
 
66
+ if (arg === "--json") {
67
+ json = true;
68
+ continue;
69
+ }
70
+
62
71
  if (arg.startsWith("-")) {
63
72
  throw new Error(`Unknown argument: ${arg}`);
64
73
  }
@@ -66,7 +75,7 @@ export function parseCliArgs(argv: readonly string[]): CliOptions {
66
75
  throw new Error(`Unexpected positional argument: ${arg}`);
67
76
  }
68
77
 
69
- return { prompt };
78
+ return { prompt, json };
70
79
  }
71
80
 
72
81
  // ---------------------------------------------------------------------------
package/src/headless.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * @module
5
5
  */
6
6
 
7
+ import type { AssistantMessage, UserMessage } from "@mariozechner/pi-ai";
8
+ import type { AgentEvent } from "./agent.ts";
7
9
  import type { AppState } from "./index.ts";
8
10
  import {
9
11
  resolveRawInput,
@@ -15,18 +17,37 @@ import {
15
17
  // Types
16
18
  // ---------------------------------------------------------------------------
17
19
 
18
- /** Options for a headless one-shot run. */
20
+ type HeadlessStopReason = "stop" | "length" | "error" | "aborted";
21
+
22
+ /** Options for a headless NDJSON run. */
19
23
  export interface HeadlessRunOptions {
20
- /** Optional line writer for NDJSON event output. */
24
+ /** Optional line writer for completed NDJSON event output. */
21
25
  writeLine?: (line: string) => void;
22
26
  }
23
27
 
28
+ /** Options for a headless final-text run. */
29
+ export interface HeadlessTextRunOptions {
30
+ /** Optional writer for the final assistant text output. */
31
+ writeText?: (text: string) => void;
32
+ }
33
+
34
+ interface HeadlessOutputController {
35
+ /** Write text to stdout with broken-pipe handling. */
36
+ write(text: string): void;
37
+ /** Attach SIGINT/stdout error handlers for the active run. */
38
+ attach(): void;
39
+ /** Remove SIGINT/stdout error handlers after the run. */
40
+ detach(): void;
41
+ /** Resolve the final stop reason, converting broken pipes into quiet shutdowns. */
42
+ finalize(stopReason: HeadlessStopReason): HeadlessStopReason;
43
+ }
44
+
24
45
  // ---------------------------------------------------------------------------
25
46
  // Helpers
26
47
  // ---------------------------------------------------------------------------
27
48
 
28
- function defaultWriteLine(line: string): void {
29
- process.stdout.write(`${line}\n`);
49
+ function defaultWrite(text: string): void {
50
+ process.stdout.write(text);
30
51
  }
31
52
 
32
53
  function isBrokenPipeError(error: unknown): boolean {
@@ -52,23 +73,10 @@ function buildCommandError(command: string): Error {
52
73
  );
53
74
  }
54
75
 
55
- /**
56
- * Run a single headless prompt to completion and stream NDJSON events.
57
- *
58
- * The raw input is parsed with the same rules as interactive input. Slash
59
- * commands are rejected in headless mode. Assistant/tool events are written as
60
- * one JSON object per line.
61
- *
62
- * @param state - Mutable application state for the run.
63
- * @param rawInput - Exact raw prompt text supplied by the user.
64
- * @param options - Optional event-output overrides.
65
- * @returns The terminal stop reason for the agent loop.
66
- */
67
- export async function runHeadlessPrompt(
76
+ function resolveHeadlessContent(
68
77
  state: AppState,
69
78
  rawInput: string,
70
- options?: HeadlessRunOptions,
71
- ): Promise<"stop" | "length" | "error" | "aborted"> {
79
+ ): UserMessage["content"] {
72
80
  const resolved = resolveRawInput(rawInput, state);
73
81
  switch (resolved.type) {
74
82
  case "empty":
@@ -78,11 +86,52 @@ export async function runHeadlessPrompt(
78
86
  case "command":
79
87
  throw buildCommandError(resolved.command);
80
88
  case "message":
81
- break;
89
+ return resolved.content;
90
+ }
91
+ }
92
+
93
+ function extractAssistantText(message: AssistantMessage | null): string {
94
+ if (!message) {
95
+ return "";
82
96
  }
83
97
 
98
+ return message.content
99
+ .filter(
100
+ (
101
+ block,
102
+ ): block is Extract<
103
+ AssistantMessage["content"][number],
104
+ { type: "text" }
105
+ > => {
106
+ return block.type === "text";
107
+ },
108
+ )
109
+ .map((block) => block.text)
110
+ .join("");
111
+ }
112
+
113
+ function shouldWriteHeadlessJsonEvent(event: AgentEvent): boolean {
114
+ switch (event.type) {
115
+ case "user_message":
116
+ case "assistant_message":
117
+ case "tool_result":
118
+ case "done":
119
+ case "error":
120
+ case "aborted":
121
+ return true;
122
+ default:
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function createHeadlessOutputController(
128
+ state: AppState,
129
+ writeImpl: (text: string) => void,
130
+ ): HeadlessOutputController {
84
131
  let brokenPipe = false;
85
132
  let outputError: unknown = null;
133
+ const sigintHandler = createSigintHandler(state);
134
+
86
135
  const stopForBrokenPipe = (): void => {
87
136
  if (brokenPipe) {
88
137
  return;
@@ -90,51 +139,140 @@ export async function runHeadlessPrompt(
90
139
  brokenPipe = true;
91
140
  state.abortController?.abort();
92
141
  };
93
- const writeLineImpl = options?.writeLine ?? defaultWriteLine;
94
- const writeLine = (line: string): void => {
95
- if (brokenPipe) {
142
+
143
+ const stdoutErrorHandler = (error: unknown): void => {
144
+ if (isBrokenPipeError(error)) {
145
+ stopForBrokenPipe();
96
146
  return;
97
147
  }
98
148
 
99
- try {
100
- writeLineImpl(line);
101
- } catch (error) {
102
- if (!isBrokenPipeError(error)) {
103
- throw error;
149
+ outputError = error;
150
+ state.abortController?.abort();
151
+ };
152
+
153
+ return {
154
+ write(text) {
155
+ if (brokenPipe) {
156
+ return;
104
157
  }
105
- stopForBrokenPipe();
106
- }
158
+
159
+ try {
160
+ writeImpl(text);
161
+ } catch (error) {
162
+ if (!isBrokenPipeError(error)) {
163
+ throw error;
164
+ }
165
+ stopForBrokenPipe();
166
+ }
167
+ },
168
+ attach() {
169
+ process.stdout.on("error", stdoutErrorHandler);
170
+ process.on("SIGINT", sigintHandler);
171
+ },
172
+ detach() {
173
+ process.stdout.off("error", stdoutErrorHandler);
174
+ process.off("SIGINT", sigintHandler);
175
+ },
176
+ finalize(stopReason) {
177
+ if (outputError) {
178
+ throw outputError;
179
+ }
180
+ return brokenPipe ? "stop" : stopReason;
181
+ },
107
182
  };
183
+ }
184
+
185
+ /**
186
+ * Run a single headless prompt to completion and stream completed NDJSON events.
187
+ *
188
+ * The raw input is parsed with the same rules as interactive input. Slash
189
+ * commands are rejected in headless mode. Persisted messages and terminal
190
+ * events are written as one JSON object per line; streaming delta/progress
191
+ * events are omitted.
192
+ *
193
+ * @param state - Mutable application state for the run.
194
+ * @param rawInput - Exact raw prompt text supplied by the user.
195
+ * @param options - Optional event-output overrides.
196
+ * @returns The terminal stop reason for the agent loop.
197
+ */
198
+ export async function runHeadlessPrompt(
199
+ state: AppState,
200
+ rawInput: string,
201
+ options?: HeadlessRunOptions,
202
+ ): Promise<HeadlessStopReason> {
203
+ const content = resolveHeadlessContent(state, rawInput);
204
+ const output = createHeadlessOutputController(
205
+ state,
206
+ options?.writeLine ?? ((line) => defaultWrite(`${line}\n`)),
207
+ );
108
208
  const hooks: SubmitTurnHooks = {
109
209
  onEvent: (event) => {
110
- writeLine(JSON.stringify(event));
210
+ if (!shouldWriteHeadlessJsonEvent(event)) {
211
+ return;
212
+ }
213
+ output.write(JSON.stringify(event));
111
214
  },
112
215
  };
113
- const sigintHandler = createSigintHandler(state);
114
- const stdoutErrorHandler = (error: unknown): void => {
115
- if (isBrokenPipeError(error)) {
116
- stopForBrokenPipe();
117
- return;
118
- }
119
- outputError = error;
120
- state.abortController?.abort();
216
+
217
+ output.attach();
218
+ try {
219
+ const stopReason = await submitResolvedInput(
220
+ rawInput,
221
+ content,
222
+ state,
223
+ hooks,
224
+ );
225
+ return output.finalize(stopReason);
226
+ } finally {
227
+ output.detach();
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Run a single headless prompt to completion and write only the final assistant text.
233
+ *
234
+ * The raw input is parsed with the same rules as interactive input. Slash
235
+ * commands are rejected in headless mode. Only the final persisted assistant
236
+ * message's text content is written to stdout.
237
+ *
238
+ * @param state - Mutable application state for the run.
239
+ * @param rawInput - Exact raw prompt text supplied by the user.
240
+ * @param options - Optional final-text output overrides.
241
+ * @returns The terminal stop reason for the agent loop.
242
+ */
243
+ export async function runHeadlessPromptText(
244
+ state: AppState,
245
+ rawInput: string,
246
+ options?: HeadlessTextRunOptions,
247
+ ): Promise<HeadlessStopReason> {
248
+ const content = resolveHeadlessContent(state, rawInput);
249
+ const output = createHeadlessOutputController(
250
+ state,
251
+ options?.writeText ?? defaultWrite,
252
+ );
253
+ let finalAssistantMessage: AssistantMessage | null = null;
254
+ const hooks: SubmitTurnHooks = {
255
+ onEvent: (event) => {
256
+ if (event.type === "assistant_message") {
257
+ finalAssistantMessage = event.message;
258
+ }
259
+ },
121
260
  };
122
261
 
123
- process.stdout.on("error", stdoutErrorHandler);
124
- process.on("SIGINT", sigintHandler);
262
+ output.attach();
125
263
  try {
126
264
  const stopReason = await submitResolvedInput(
127
265
  rawInput,
128
- resolved.content,
266
+ content,
129
267
  state,
130
268
  hooks,
131
269
  );
132
- if (outputError) {
133
- throw outputError;
270
+ const finalText = extractAssistantText(finalAssistantMessage);
271
+ if (finalText.length > 0) {
272
+ output.write(finalText);
134
273
  }
135
- return brokenPipe ? "stop" : stopReason;
274
+ return output.finalize(stopReason);
136
275
  } finally {
137
- process.stdout.off("error", stdoutErrorHandler);
138
- process.off("SIGINT", sigintHandler);
276
+ output.detach();
139
277
  }
140
278
  }