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 +5 -2
- package/package.json +1 -1
- package/src/agent.ts +206 -60
- package/src/cli.ts +11 -2
- package/src/headless.ts +185 -47
- package/src/index.ts +78 -10
- package/src/input.ts +1 -0
- package/src/prompt.ts +8 -5
- package/src/session.ts +56 -8
- package/src/submit.ts +0 -5
- package/src/tools.ts +243 -3
- package/src/ui/commands.test.ts +71 -0
- package/src/ui/commands.ts +13 -0
- package/src/ui/conversation.test.ts +91 -0
- package/src/ui/conversation.ts +122 -3
- package/src/ui/help.test.ts +1 -0
- package/src/ui/help.ts +1 -0
- package/src/ui.ts +228 -25
package/README.md
CHANGED
|
@@ -39,10 +39,12 @@ $ mc
|
|
|
39
39
|
|
|
40
40
|
## Tools
|
|
41
41
|
|
|
42
|
-
|
|
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
|
-
-
|
|
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
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
|
|
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
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
|
685
|
+
return {
|
|
686
|
+
nextTurn: queuedTurn,
|
|
687
|
+
pendingContextMessages: [],
|
|
688
|
+
finalResult: null,
|
|
689
|
+
};
|
|
626
690
|
}
|
|
627
691
|
|
|
628
|
-
|
|
629
|
-
|
|
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(
|
|
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
|
|
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 (
|
|
761
|
-
return
|
|
914
|
+
if (iterationOutcome.finalResult) {
|
|
915
|
+
return iterationOutcome.finalResult;
|
|
762
916
|
}
|
|
763
917
|
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
29
|
-
process.stdout.write(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
142
|
+
|
|
143
|
+
const stdoutErrorHandler = (error: unknown): void => {
|
|
144
|
+
if (isBrokenPipeError(error)) {
|
|
145
|
+
stopForBrokenPipe();
|
|
96
146
|
return;
|
|
97
147
|
}
|
|
98
148
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
149
|
+
outputError = error;
|
|
150
|
+
state.abortController?.abort();
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
write(text) {
|
|
155
|
+
if (brokenPipe) {
|
|
156
|
+
return;
|
|
104
157
|
}
|
|
105
|
-
|
|
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
|
-
|
|
210
|
+
if (!shouldWriteHeadlessJsonEvent(event)) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
output.write(JSON.stringify(event));
|
|
111
214
|
},
|
|
112
215
|
};
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
process.on("SIGINT", sigintHandler);
|
|
262
|
+
output.attach();
|
|
125
263
|
try {
|
|
126
264
|
const stopReason = await submitResolvedInput(
|
|
127
265
|
rawInput,
|
|
128
|
-
|
|
266
|
+
content,
|
|
129
267
|
state,
|
|
130
268
|
hooks,
|
|
131
269
|
);
|
|
132
|
-
|
|
133
|
-
|
|
270
|
+
const finalText = extractAssistantText(finalAssistantMessage);
|
|
271
|
+
if (finalText.length > 0) {
|
|
272
|
+
output.write(finalText);
|
|
134
273
|
}
|
|
135
|
-
return
|
|
274
|
+
return output.finalize(stopReason);
|
|
136
275
|
} finally {
|
|
137
|
-
|
|
138
|
-
process.off("SIGINT", sigintHandler);
|
|
276
|
+
output.detach();
|
|
139
277
|
}
|
|
140
278
|
}
|