mini-coder 0.5.6 → 0.5.8

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/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
  }
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
- import { dirname, join } from "node:path";
13
+ import { basename, dirname, join } from "node:path";
14
14
  import { isDeepStrictEqual } from "node:util";
15
15
  import type {
16
16
  KnownProvider,
@@ -19,14 +19,17 @@ import type {
19
19
  OAuthCredentials,
20
20
  ThinkingLevel,
21
21
  Tool,
22
+ UserMessage,
22
23
  } from "@mariozechner/pi-ai";
23
24
  import { getEnvApiKey, getModels, getProviders } from "@mariozechner/pi-ai";
24
25
  import { getOAuthApiKey, getOAuthProviders } from "@mariozechner/pi-ai/oauth";
25
26
  import type { ToolHandler } from "./agent.ts";
26
27
  import {
28
+ type CliOptions,
27
29
  parseCliArgs,
28
30
  resolveHeadlessPrompt,
29
31
  shouldUseHeadlessMode,
32
+ type TtyState,
30
33
  } from "./cli.ts";
31
34
  import { getErrorMessage } from "./errors.ts";
32
35
  import { type GitState, getGitState } from "./git.ts";
@@ -70,8 +73,12 @@ import {
70
73
  executeEdit,
71
74
  executeReadImage,
72
75
  executeShell,
76
+ executeTodoRead,
77
+ executeTodoWrite,
73
78
  readImageTool,
74
79
  shellTool,
80
+ todoReadTool,
81
+ todoWriteTool,
75
82
  } from "./tools.ts";
76
83
  import { resolveAppVersionLabel } from "./version.ts";
77
84
 
@@ -381,11 +388,28 @@ const BUILTIN_HANDLERS: Record<string, ToolHandler> = {
381
388
  function buildTools(
382
389
  model: Model<string>,
383
390
  plugins: LoadedPlugin[],
391
+ messages: AppState["messages"],
384
392
  ): { tools: Tool[]; toolHandlers: Map<string, ToolHandler> } {
385
- const tools: Tool[] = [editTool, shellTool];
393
+ const tools: Tool[] = [editTool, shellTool, todoWriteTool, todoReadTool];
386
394
  const toolHandlers = new Map<string, ToolHandler>([
387
395
  [editTool.name, BUILTIN_HANDLERS.edit!],
388
396
  [shellTool.name, BUILTIN_HANDLERS.shell!],
397
+ [
398
+ todoWriteTool.name,
399
+ (args) =>
400
+ executeTodoWrite(
401
+ {
402
+ todos: Array.isArray(args.todos)
403
+ ? (args.todos as Array<{
404
+ content: string;
405
+ status: "pending" | "in_progress" | "completed" | "cancelled";
406
+ }>)
407
+ : [],
408
+ },
409
+ messages,
410
+ ),
411
+ ],
412
+ [todoReadTool.name, () => executeTodoRead(messages)],
389
413
  ]);
390
414
 
391
415
  // Conditionally register readImage for vision-capable models
@@ -541,8 +565,6 @@ export interface AppState {
541
565
  versionLabel: string;
542
566
  /** Current git state (null if not in a repo). */
543
567
  git: GitState | null;
544
- /** Optional git state loader override used by tests. */
545
- loadGitState?: (cwd: string) => Promise<GitState | null>;
546
568
  /** Available provider credentials (provider → API key). */
547
569
  providers: Map<string, string>;
548
570
  /** OAuth credentials on disk. */
@@ -561,6 +583,8 @@ export interface AppState {
561
583
  abortController: AbortController | null;
562
584
  /** Promise for the active conversational turn, used to serialize cleanup like `/undo`. */
563
585
  activeTurnPromise: Promise<void> | null;
586
+ /** Resolved user messages queued while the current run is still active. */
587
+ queuedUserMessages: UserMessage[];
564
588
  /** Whether to show thinking content. */
565
589
  showReasoning: boolean;
566
590
  /** Whether to show full (un-truncated) tool output. */
@@ -641,6 +665,7 @@ export async function init(): Promise<AppState> {
641
665
  running: false,
642
666
  abortController: null,
643
667
  activeTurnPromise: null,
668
+ queuedUserMessages: [],
644
669
  showReasoning: startup.showReasoning,
645
670
  verbose: startup.verbose,
646
671
  customModels: customResult.models,
@@ -648,16 +673,37 @@ export async function init(): Promise<AppState> {
648
673
  };
649
674
  }
650
675
 
676
+ /** Resolve the shell label shown in the system prompt. */
677
+ function resolvePromptShell(): string {
678
+ return basename(process.env.SHELL || "/bin/sh");
679
+ }
680
+
681
+ /** Resolve the normalized OS label shown in the system prompt. */
682
+ function resolvePromptOs(): "linux" | "mac" | "docker" {
683
+ if (process.platform === "darwin") {
684
+ return "mac";
685
+ }
686
+ if (existsSync("/.dockerenv") || existsSync("/run/.containerenv")) {
687
+ return "docker";
688
+ }
689
+ return "linux";
690
+ }
691
+
651
692
  /**
652
693
  * Build the system prompt for the current state.
653
694
  *
654
- * Separated from `init` because it's called on every turn (git state
655
- * may change between turns).
695
+ * Separated from `init` because turns still rebuild the assembled prompt
696
+ * from the session-stable prompt context plus the current runtime state.
656
697
  */
657
698
  export function buildPrompt(state: AppState): string {
658
699
  return buildSystemPrompt({
659
700
  cwd: state.cwd,
660
- date: new Date().toISOString().slice(0, 10),
701
+ modelLabel: state.model
702
+ ? `${state.model.provider}/${state.model.id}`
703
+ : "unknown",
704
+ os: resolvePromptOs(),
705
+ shell: resolvePromptShell(),
706
+ supportsImages: state.model?.input.includes("image") ?? false,
661
707
  git: state.git,
662
708
  agentsMd: state.agentsMd,
663
709
  skills: state.skills,
@@ -673,7 +719,7 @@ export function buildToolList(state: AppState): {
673
719
  toolHandlers: Map<string, ToolHandler>;
674
720
  } {
675
721
  if (!state.model) return { tools: [], toolHandlers: new Map() };
676
- return buildTools(state.model, state.plugins);
722
+ return buildTools(state.model, state.plugins, state.messages);
677
723
  }
678
724
 
679
725
  /**
@@ -740,6 +786,47 @@ export {
740
786
  saveOAuthCredentials,
741
787
  };
742
788
 
789
+ // ---------------------------------------------------------------------------
790
+ // Headless CLI
791
+ // ---------------------------------------------------------------------------
792
+
793
+ type HeadlessCliStopReason = "stop" | "length" | "error" | "aborted";
794
+
795
+ /**
796
+ * Run one headless CLI prompt using the output mode selected by the parsed CLI flags.
797
+ *
798
+ * Non-TTY detection only decides whether headless mode should run at all.
799
+ * Once headless mode is selected, `--json` is the only switch that chooses
800
+ * NDJSON streaming versus final-text output.
801
+ *
802
+ * @param state - Initialized application state for the run.
803
+ * @param cli - Parsed CLI options.
804
+ * @param tty - Current TTY availability.
805
+ * @param deps - Injected I/O and runner callbacks.
806
+ * @returns The terminal stop reason for the headless run.
807
+ */
808
+ export async function runHeadlessCli(
809
+ state: AppState,
810
+ cli: CliOptions,
811
+ tty: TtyState,
812
+ deps: {
813
+ readStdin: () => Promise<string>;
814
+ runJson: (
815
+ state: AppState,
816
+ rawPrompt: string,
817
+ ) => Promise<HeadlessCliStopReason>;
818
+ runText: (
819
+ state: AppState,
820
+ rawPrompt: string,
821
+ ) => Promise<HeadlessCliStopReason>;
822
+ },
823
+ ): Promise<HeadlessCliStopReason> {
824
+ const rawPrompt = await resolveHeadlessPrompt(cli, tty, deps.readStdin);
825
+ return cli.json
826
+ ? deps.runJson(state, rawPrompt)
827
+ : deps.runText(state, rawPrompt);
828
+ }
829
+
743
830
  // ---------------------------------------------------------------------------
744
831
  // Main
745
832
  // ---------------------------------------------------------------------------
@@ -762,11 +849,17 @@ export async function main(): Promise<void> {
762
849
 
763
850
  if (shouldUseHeadlessMode(cli, tty)) {
764
851
  try {
765
- const rawPrompt = await resolveHeadlessPrompt(cli, tty, async () => {
766
- return Bun.stdin.text();
852
+ const stopReason = await runHeadlessCli(state, cli, tty, {
853
+ readStdin: async () => Bun.stdin.text(),
854
+ runJson: async (headlessState, rawPrompt) => {
855
+ const { runHeadlessPrompt } = await import("./headless.ts");
856
+ return runHeadlessPrompt(headlessState, rawPrompt);
857
+ },
858
+ runText: async (headlessState, rawPrompt) => {
859
+ const { runHeadlessPromptText } = await import("./headless.ts");
860
+ return runHeadlessPromptText(headlessState, rawPrompt);
861
+ },
767
862
  });
768
- const { runHeadlessPrompt } = await import("./headless.ts");
769
- const stopReason = await runHeadlessPrompt(state, rawPrompt);
770
863
  if (stopReason === "aborted") {
771
864
  process.exitCode = 130;
772
865
  } else if (stopReason === "error") {
package/src/input.ts CHANGED
@@ -24,6 +24,7 @@ export const COMMANDS = [
24
24
  "undo",
25
25
  "reasoning",
26
26
  "verbose",
27
+ "todo",
27
28
  "login",
28
29
  "logout",
29
30
  "help",