mini-coder 0.4.1 → 0.5.1

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 +89 -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 +640 -0
  13. package/src/cli.ts +124 -0
  14. package/src/git.ts +171 -0
  15. package/src/headless.ts +140 -0
  16. package/src/index.ts +666 -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 +301 -0
  21. package/src/session.ts +1043 -0
  22. package/src/settings.ts +191 -0
  23. package/src/skills.ts +262 -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 +44 -0
  34. package/src/ui/help.ts +125 -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 +451 -0
  40. package/src/ui/status.ts +357 -0
  41. package/src/ui.ts +694 -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,640 @@
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
+ function buildIncompleteAssistantMessage(
380
+ opts: Pick<RunAgentOpts, "model" | "signal">,
381
+ partialAssistantMessage?: AssistantMessage,
382
+ ): AssistantMessage {
383
+ const stopReason = opts.signal?.aborted ? "aborted" : "error";
384
+ const errorMessage =
385
+ stopReason === "aborted"
386
+ ? "Request was aborted"
387
+ : "Stream ended without a final assistant message";
388
+
389
+ return {
390
+ role: "assistant",
391
+ content: partialAssistantMessage
392
+ ? cloneAssistantContent(partialAssistantMessage.content)
393
+ : [],
394
+ api: partialAssistantMessage?.api ?? opts.model.api,
395
+ provider: partialAssistantMessage?.provider ?? opts.model.provider,
396
+ model: partialAssistantMessage?.model ?? opts.model.id,
397
+ usage: partialAssistantMessage?.usage ?? {
398
+ input: 0,
399
+ output: 0,
400
+ cacheRead: 0,
401
+ cacheWrite: 0,
402
+ totalTokens: 0,
403
+ cost: {
404
+ input: 0,
405
+ output: 0,
406
+ cacheRead: 0,
407
+ cacheWrite: 0,
408
+ total: 0,
409
+ },
410
+ },
411
+ stopReason,
412
+ errorMessage,
413
+ timestamp: Date.now(),
414
+ };
415
+ }
416
+
417
+ async function streamAssistantMessage(
418
+ opts: Pick<
419
+ RunAgentOpts,
420
+ | "model"
421
+ | "systemPrompt"
422
+ | "tools"
423
+ | "messages"
424
+ | "apiKey"
425
+ | "effort"
426
+ | "signal"
427
+ | "onEvent"
428
+ >,
429
+ ): Promise<AssistantMessage> {
430
+ const eventStream = streamSimple(
431
+ opts.model,
432
+ buildAgentContext(opts.systemPrompt, opts.messages, opts.tools),
433
+ buildStreamOptions(opts.apiKey, opts.effort, opts.signal),
434
+ );
435
+ const streamResult = eventStream.result();
436
+ let settledStreamResult: AssistantMessage | undefined;
437
+ void streamResult.then((message) => {
438
+ settledStreamResult = message;
439
+ });
440
+
441
+ let assistantMessage: AssistantMessage | undefined;
442
+ let partialAssistantMessage: AssistantMessage | undefined;
443
+
444
+ for await (const event of eventStream) {
445
+ if ("partial" in event) {
446
+ partialAssistantMessage = event.partial;
447
+ }
448
+
449
+ assistantMessage =
450
+ handleAssistantStreamEvent(event, opts.onEvent) ?? assistantMessage;
451
+ }
452
+
453
+ // `end(result)` resolves the final result without emitting a terminal event.
454
+ await Promise.resolve();
455
+ const finalAssistantMessage =
456
+ assistantMessage ??
457
+ settledStreamResult ??
458
+ buildIncompleteAssistantMessage(opts, partialAssistantMessage);
459
+ if (!partialAssistantMessage) {
460
+ return finalAssistantMessage;
461
+ }
462
+ return mergeAssistantMessage(partialAssistantMessage, finalAssistantMessage);
463
+ }
464
+
465
+ function appendAssistantMessage(
466
+ db: Database,
467
+ sessionId: string,
468
+ messages: Message[],
469
+ assistantMessage: AssistantMessage,
470
+ turn: number,
471
+ onEvent: RunAgentOpts["onEvent"],
472
+ ): void {
473
+ messages.push(assistantMessage);
474
+ appendMessage(db, sessionId, assistantMessage, turn);
475
+ onEvent?.({ type: "assistant_message", message: assistantMessage });
476
+ }
477
+
478
+ function resolveLoopStopReason(
479
+ assistantMessage: AssistantMessage,
480
+ messages: Message[],
481
+ onEvent: RunAgentOpts["onEvent"],
482
+ ): AgentLoopResult | null {
483
+ if (
484
+ assistantMessage.stopReason === "error" ||
485
+ assistantMessage.stopReason === "aborted"
486
+ ) {
487
+ const eventType =
488
+ assistantMessage.stopReason === "error" ? "error" : "aborted";
489
+ onEvent?.({ type: eventType, message: assistantMessage });
490
+ return { messages, stopReason: assistantMessage.stopReason };
491
+ }
492
+
493
+ if (
494
+ assistantMessage.stopReason === "stop" ||
495
+ assistantMessage.stopReason === "length"
496
+ ) {
497
+ onEvent?.({ type: "done", message: assistantMessage });
498
+ return { messages, stopReason: assistantMessage.stopReason };
499
+ }
500
+
501
+ return null;
502
+ }
503
+
504
+ function getAssistantToolCalls(message: AssistantMessage): ToolCall[] {
505
+ return message.content.filter((content): content is ToolCall => {
506
+ return content.type === "toolCall";
507
+ });
508
+ }
509
+
510
+ async function executeToolCall(
511
+ toolCall: ToolCall,
512
+ opts: Pick<RunAgentOpts, "toolHandlers" | "cwd" | "signal" | "onEvent">,
513
+ ): Promise<ToolExecResult> {
514
+ const handler = opts.toolHandlers.get(toolCall.name);
515
+ if (!handler) {
516
+ return unknownToolResult(toolCall.name);
517
+ }
518
+
519
+ opts.onEvent?.({
520
+ type: "tool_start",
521
+ toolCallId: toolCall.id,
522
+ name: toolCall.name,
523
+ args: toolCall.arguments,
524
+ });
525
+
526
+ let result: ToolExecResult;
527
+ try {
528
+ result = await handler(
529
+ toolCall.arguments,
530
+ opts.cwd,
531
+ opts.signal,
532
+ (partial) => {
533
+ opts.onEvent?.({
534
+ type: "tool_delta",
535
+ toolCallId: toolCall.id,
536
+ name: toolCall.name,
537
+ result: partial,
538
+ });
539
+ },
540
+ );
541
+ } catch (error) {
542
+ result = toolErrorResult(toolCall.name, error);
543
+ }
544
+
545
+ opts.onEvent?.({
546
+ type: "tool_end",
547
+ toolCallId: toolCall.id,
548
+ name: toolCall.name,
549
+ result,
550
+ });
551
+ return result;
552
+ }
553
+
554
+ function appendToolResultMessage(
555
+ db: Database,
556
+ sessionId: string,
557
+ messages: Message[],
558
+ toolCall: ToolCall,
559
+ result: ToolExecResult,
560
+ turn: number,
561
+ onEvent: RunAgentOpts["onEvent"],
562
+ ): void {
563
+ const toolResultMessage: ToolResultMessage = {
564
+ role: "toolResult",
565
+ toolCallId: toolCall.id,
566
+ toolName: toolCall.name,
567
+ content: result.content,
568
+ isError: result.isError,
569
+ timestamp: Date.now(),
570
+ };
571
+
572
+ messages.push(toolResultMessage);
573
+ appendMessage(db, sessionId, toolResultMessage, turn);
574
+ onEvent?.({ type: "tool_result", message: toolResultMessage });
575
+ }
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // Agent loop
579
+ // ---------------------------------------------------------------------------
580
+
581
+ /**
582
+ * Run the agent loop for a single turn.
583
+ *
584
+ * Streams an LLM response, executes any tool calls, appends all messages
585
+ * to the session (sharing the same turn number), and loops back when the
586
+ * model requests tool use. Returns when the model stops, hits a length
587
+ * limit, errors, or is aborted.
588
+ *
589
+ * @param opts - Agent loop options.
590
+ * @returns The loop result with updated messages and stop reason.
591
+ */
592
+ export async function runAgentLoop(
593
+ opts: RunAgentOpts,
594
+ ): Promise<AgentLoopResult> {
595
+ const { db, sessionId, turn, messages, signal, onEvent, toolHandlers, cwd } =
596
+ opts;
597
+
598
+ while (true) {
599
+ const assistantMessage = await streamAssistantMessage(opts);
600
+ appendAssistantMessage(
601
+ db,
602
+ sessionId,
603
+ messages,
604
+ assistantMessage,
605
+ turn,
606
+ onEvent,
607
+ );
608
+
609
+ const stopResult = resolveLoopStopReason(
610
+ assistantMessage,
611
+ messages,
612
+ onEvent,
613
+ );
614
+ if (stopResult) {
615
+ return stopResult;
616
+ }
617
+
618
+ for (const toolCall of getAssistantToolCalls(assistantMessage)) {
619
+ const result = await executeToolCall(toolCall, {
620
+ toolHandlers,
621
+ cwd,
622
+ signal,
623
+ onEvent,
624
+ });
625
+ appendToolResultMessage(
626
+ db,
627
+ sessionId,
628
+ messages,
629
+ toolCall,
630
+ result,
631
+ turn,
632
+ onEvent,
633
+ );
634
+
635
+ if (signal?.aborted) {
636
+ return { messages, stopReason: "aborted" };
637
+ }
638
+ }
639
+ }
640
+ }