@ocis/myagent-cli 0.2.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.
- package/README.md +357 -0
- package/dist/agent/context.d.ts +33 -0
- package/dist/agent/context.js +169 -0
- package/dist/agent/modes.d.ts +21 -0
- package/dist/agent/modes.js +84 -0
- package/dist/agent/prompt-builder.d.ts +9 -0
- package/dist/agent/prompt-builder.js +31 -0
- package/dist/agent/sessions.d.ts +38 -0
- package/dist/agent/sessions.js +130 -0
- package/dist/agent/todo.d.ts +18 -0
- package/dist/agent/todo.js +61 -0
- package/dist/agent/turn.d.ts +309 -0
- package/dist/agent/turn.js +1253 -0
- package/dist/approval/policy.d.ts +81 -0
- package/dist/approval/policy.js +157 -0
- package/dist/config.d.ts +49 -0
- package/dist/config.js +156 -0
- package/dist/git/status.d.ts +89 -0
- package/dist/git/status.js +226 -0
- package/dist/headless.d.ts +72 -0
- package/dist/headless.js +330 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +511 -0
- package/dist/protocol/client.d.ts +123 -0
- package/dist/protocol/client.js +250 -0
- package/dist/protocol/sse-frames.d.ts +6 -0
- package/dist/protocol/sse-frames.js +75 -0
- package/dist/protocol/types.d.ts +200 -0
- package/dist/protocol/types.js +8 -0
- package/dist/runtime.d.ts +38 -0
- package/dist/runtime.js +166 -0
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +21 -0
- package/dist/skills/discovery.d.ts +24 -0
- package/dist/skills/discovery.js +109 -0
- package/dist/tools/binary.d.ts +2 -0
- package/dist/tools/binary.js +22 -0
- package/dist/tools/diff.d.ts +1 -0
- package/dist/tools/diff.js +49 -0
- package/dist/tools/find.d.ts +2 -0
- package/dist/tools/find.js +61 -0
- package/dist/tools/fs.d.ts +2 -0
- package/dist/tools/fs.js +276 -0
- package/dist/tools/glob.d.ts +6 -0
- package/dist/tools/glob.js +131 -0
- package/dist/tools/grep.d.ts +3 -0
- package/dist/tools/grep.js +228 -0
- package/dist/tools/paths.d.ts +27 -0
- package/dist/tools/paths.js +124 -0
- package/dist/tools/registry.d.ts +13 -0
- package/dist/tools/registry.js +38 -0
- package/dist/tools/shell.d.ts +2 -0
- package/dist/tools/shell.js +136 -0
- package/dist/tools/skills.d.ts +2 -0
- package/dist/tools/skills.js +36 -0
- package/dist/tools/todo.d.ts +2 -0
- package/dist/tools/todo.js +43 -0
- package/dist/tools/transfer.d.ts +2 -0
- package/dist/tools/transfer.js +145 -0
- package/dist/tools/truncate.d.ts +12 -0
- package/dist/tools/truncate.js +46 -0
- package/dist/tools/types.d.ts +85 -0
- package/dist/tools/types.js +63 -0
- package/dist/ui/app.d.ts +39 -0
- package/dist/ui/app.js +1061 -0
- package/dist/ui/colors.d.ts +100 -0
- package/dist/ui/colors.js +169 -0
- package/dist/ui/components.d.ts +267 -0
- package/dist/ui/components.js +811 -0
- package/dist/ui/diff.d.ts +37 -0
- package/dist/ui/diff.js +143 -0
- package/dist/ui/format.d.ts +28 -0
- package/dist/ui/format.js +76 -0
- package/dist/ui/help.d.ts +6 -0
- package/dist/ui/help.js +45 -0
- package/dist/ui/highlight.d.ts +20 -0
- package/dist/ui/highlight.js +210 -0
- package/dist/ui/logo.d.ts +24 -0
- package/dist/ui/logo.js +106 -0
- package/dist/ui/model-list.d.ts +10 -0
- package/dist/ui/model-list.js +33 -0
- package/dist/ui/quit-confirm.d.ts +8 -0
- package/dist/ui/quit-confirm.js +40 -0
- package/dist/ui/select-popup.d.ts +30 -0
- package/dist/ui/select-popup.js +54 -0
- package/dist/ui/theme.d.ts +4 -0
- package/dist/ui/theme.js +41 -0
- package/dist/ui/tool-view.d.ts +20 -0
- package/dist/ui/tool-view.js +326 -0
- package/package.json +44 -0
- package/skills/git-commit/SKILL.md +27 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Git working-tree status for the TUI's "changes" panel, plus the per-file
|
|
3
|
+
// diff shown in the sidebar's popup viewer.
|
|
4
|
+
//
|
|
5
|
+
// Pure parsers (tested) + a collector that shells out to git. Any failure
|
|
6
|
+
// (no repo, no git binary) yields `isRepo: false` and the panel hides itself.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
export const EMPTY_GIT_STATUS = {
|
|
9
|
+
isRepo: false,
|
|
10
|
+
branch: null,
|
|
11
|
+
files: [],
|
|
12
|
+
totalInsertions: 0,
|
|
13
|
+
totalDeletions: 0,
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Strip terminal-control sequences from a git-derived string (file paths,
|
|
17
|
+
* branch names). A malicious repository can carry filenames embedding ANSI/OSC
|
|
18
|
+
* escape sequences; rendering those raw would inject cursor moves, terminal
|
|
19
|
+
* title changes, or clipboard writes into the user's terminal. Applied at
|
|
20
|
+
* the parse boundary so every consumer (sidebar, footer, diff popup) is
|
|
21
|
+
* covered without each one having to remember.
|
|
22
|
+
*/
|
|
23
|
+
import { sanitizeForTerminal } from "../sanitize.js";
|
|
24
|
+
import { spawnProcess } from "../runtime.js";
|
|
25
|
+
export { sanitizeForTerminal };
|
|
26
|
+
/**
|
|
27
|
+
* Parse `git status --porcelain=v1 -z --branch` output. The -z form is
|
|
28
|
+
* NUL-separated and never quotes paths, so filenames with spaces, quotes,
|
|
29
|
+
* tabs, or non-ASCII bytes arrive verbatim — no unquoting step to get wrong.
|
|
30
|
+
* Renames are `XY PATH\0ORIG_PATH\0` (PATH is already the destination).
|
|
31
|
+
*/
|
|
32
|
+
export function parsePorcelain(output) {
|
|
33
|
+
let branch = null;
|
|
34
|
+
const files = [];
|
|
35
|
+
const parts = output.split("\0");
|
|
36
|
+
for (let i = 0; i < parts.length; i++) {
|
|
37
|
+
const entry = parts[i];
|
|
38
|
+
if (!entry)
|
|
39
|
+
continue;
|
|
40
|
+
if (entry.startsWith("## ")) {
|
|
41
|
+
const info = entry.slice(3).trim();
|
|
42
|
+
// "main...origin/main [ahead 1]" → "main"; "HEAD (no branch)" → "HEAD"
|
|
43
|
+
const name = info.split("...")[0].split(" ")[0];
|
|
44
|
+
branch = name ? sanitizeForTerminal(name) : null;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (entry.length < 3)
|
|
48
|
+
continue;
|
|
49
|
+
const rawStatus = entry.slice(0, 2);
|
|
50
|
+
const status = rawStatus.trim() || rawStatus;
|
|
51
|
+
files.push({ path: sanitizeForTerminal(entry.slice(3)), status });
|
|
52
|
+
// Rename/copy entries carry the origin path as the next NUL part.
|
|
53
|
+
if (status.includes("R") || status.includes("C"))
|
|
54
|
+
i++;
|
|
55
|
+
}
|
|
56
|
+
return { branch, files };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Parse `git diff --numstat -z` output into a path → line-count map.
|
|
60
|
+
*
|
|
61
|
+
* The -z form never quotes or abbreviates: a regular record is
|
|
62
|
+
* `ins\tdel\tpath\0`; a rename is `ins\tdel\t\0ORIG\0DEST\0` (the stats field
|
|
63
|
+
* ends at the tab, then the two paths follow as their own NUL fields, orig
|
|
64
|
+
* first). Keying on DEST matches porcelain -z's destination path, so the
|
|
65
|
+
* panel's +/- counts line up with the file list for every filename — CJK,
|
|
66
|
+
* spaces, quotes — instead of silently zeroing on a quoted/abbreviated key.
|
|
67
|
+
*/
|
|
68
|
+
export function parseNumstat(output) {
|
|
69
|
+
const map = new Map();
|
|
70
|
+
const parts = output.split("\0");
|
|
71
|
+
const statsPattern = /^(-|\d+)\t(-|\d+)\t(.*)$/;
|
|
72
|
+
for (let i = 0; i < parts.length; i++) {
|
|
73
|
+
const match = statsPattern.exec(parts[i]);
|
|
74
|
+
if (!match)
|
|
75
|
+
continue; // not a stats field (trailing NUL, stray part)
|
|
76
|
+
const insertions = match[1] === "-" ? 0 : parseInt(match[1], 10) || 0;
|
|
77
|
+
const deletions = match[2] === "-" ? 0 : parseInt(match[2], 10) || 0;
|
|
78
|
+
// Rename: the stats field ends at the tab and the next two NUL fields are
|
|
79
|
+
// ORIG then DEST — key on DEST (porcelain's destination path).
|
|
80
|
+
const rename = match[3] === "";
|
|
81
|
+
const path = rename ? parts[i + 2] : match[3];
|
|
82
|
+
if (rename)
|
|
83
|
+
i += 2;
|
|
84
|
+
if (!path)
|
|
85
|
+
continue;
|
|
86
|
+
const sanitized = sanitizeForTerminal(path);
|
|
87
|
+
const existing = map.get(sanitized) ?? { insertions: 0, deletions: 0 };
|
|
88
|
+
existing.insertions += insertions;
|
|
89
|
+
existing.deletions += deletions;
|
|
90
|
+
map.set(sanitized, existing);
|
|
91
|
+
}
|
|
92
|
+
return map;
|
|
93
|
+
}
|
|
94
|
+
/** Combine porcelain + staged/unstaged numstat into the panel model. */
|
|
95
|
+
export function mergeGitStatus(porcelain, unstaged, staged) {
|
|
96
|
+
const files = porcelain.files.map(({ path, status }) => {
|
|
97
|
+
const a = unstaged.get(path);
|
|
98
|
+
const b = staged.get(path);
|
|
99
|
+
const insertions = (a?.insertions ?? 0) + (b?.insertions ?? 0);
|
|
100
|
+
const deletions = (a?.deletions ?? 0) + (b?.deletions ?? 0);
|
|
101
|
+
return { path, status, insertions, deletions };
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
isRepo: true,
|
|
105
|
+
branch: porcelain.branch,
|
|
106
|
+
files,
|
|
107
|
+
totalInsertions: files.reduce((sum, f) => sum + f.insertions, 0),
|
|
108
|
+
totalDeletions: files.reduce((sum, f) => sum + f.deletions, 0),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Hard cap on any single git output held in memory. The diff popup only ever
|
|
113
|
+
* shows MAX_DIFF_CHARS, but the collector used to buffer the child's entire
|
|
114
|
+
* stdout first — a huge tracked file would balloon memory before truncation.
|
|
115
|
+
* Past the cap the stream keeps draining (discarding) so the child still
|
|
116
|
+
* exits cleanly instead of dying on a broken pipe.
|
|
117
|
+
*/
|
|
118
|
+
const MAX_GIT_OUTPUT_CHARS = 512 * 1024;
|
|
119
|
+
async function gitOutput(workspace, args, signal, okExitCodes) {
|
|
120
|
+
try {
|
|
121
|
+
const proc = spawnProcess(["git", "-C", workspace, ...args], { stdout: "pipe", stderr: "ignore", stdin: "ignore" });
|
|
122
|
+
const onAbort = () => { try {
|
|
123
|
+
proc.kill();
|
|
124
|
+
}
|
|
125
|
+
catch { /* gone */ } };
|
|
126
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
127
|
+
try {
|
|
128
|
+
const reader = proc.stdout.getReader();
|
|
129
|
+
const decoder = new TextDecoder();
|
|
130
|
+
let text = "";
|
|
131
|
+
let capped = false;
|
|
132
|
+
for (;;) {
|
|
133
|
+
const { done, value } = await reader.read();
|
|
134
|
+
if (done)
|
|
135
|
+
break;
|
|
136
|
+
if (!capped) {
|
|
137
|
+
text += decoder.decode(value, { stream: true });
|
|
138
|
+
if (text.length > MAX_GIT_OUTPUT_CHARS)
|
|
139
|
+
capped = true;
|
|
140
|
+
}
|
|
141
|
+
// Past the cap: drain and discard.
|
|
142
|
+
}
|
|
143
|
+
// Flush any incomplete multibyte sequence buffered by the streaming decoder.
|
|
144
|
+
if (!capped)
|
|
145
|
+
text += decoder.decode();
|
|
146
|
+
const code = await proc.exited;
|
|
147
|
+
return okExitCodes.includes(code) ? text : null;
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
signal?.removeEventListener("abort", onAbort);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function git(workspace, args, signal) {
|
|
158
|
+
return gitOutput(workspace, args, signal, [0]);
|
|
159
|
+
}
|
|
160
|
+
/** Collect the current git status; never throws. */
|
|
161
|
+
export async function collectGitStatus(workspace, signal) {
|
|
162
|
+
const porcelainText = await git(workspace, ["status", "--porcelain=v1", "-z", "--branch"], signal);
|
|
163
|
+
if (porcelainText === null)
|
|
164
|
+
return { ...EMPTY_GIT_STATUS, files: [] };
|
|
165
|
+
const porcelain = parsePorcelain(porcelainText);
|
|
166
|
+
const [unstagedText, stagedText] = await Promise.all([
|
|
167
|
+
git(workspace, ["diff", "--numstat", "-z"], signal),
|
|
168
|
+
git(workspace, ["diff", "--cached", "--numstat", "-z"], signal),
|
|
169
|
+
]);
|
|
170
|
+
return mergeGitStatus(porcelain, parseNumstat(unstagedText ?? ""), parseNumstat(stagedText ?? ""));
|
|
171
|
+
}
|
|
172
|
+
export const MAX_DIFF_LINES = 5000;
|
|
173
|
+
export const MAX_DIFF_CHARS = 256 * 1024;
|
|
174
|
+
/** Cut the diff to the char/line budget, reporting whether anything was lost. */
|
|
175
|
+
export function truncateDiff(text) {
|
|
176
|
+
// Diff bodies embed file content from an untrusted repo — strip terminal
|
|
177
|
+
// sequences before the text reaches the popup renderer.
|
|
178
|
+
let out = sanitizeForTerminal(text);
|
|
179
|
+
let truncated = false;
|
|
180
|
+
if (out.length > MAX_DIFF_CHARS) {
|
|
181
|
+
// Code-point-safe cut: a trailing high surrogate would render as a lone
|
|
182
|
+
// replacement character (mojibake) at the end of the popup.
|
|
183
|
+
let end = MAX_DIFF_CHARS;
|
|
184
|
+
if (end < out.length) {
|
|
185
|
+
const prev = out.charCodeAt(end - 1);
|
|
186
|
+
if (prev >= 0xd800 && prev <= 0xdbff)
|
|
187
|
+
end -= 1;
|
|
188
|
+
}
|
|
189
|
+
out = out.slice(0, end);
|
|
190
|
+
truncated = true;
|
|
191
|
+
}
|
|
192
|
+
const lines = out.split("\n");
|
|
193
|
+
if (lines.length > MAX_DIFF_LINES) {
|
|
194
|
+
out = lines.slice(0, MAX_DIFF_LINES).join("\n");
|
|
195
|
+
truncated = true;
|
|
196
|
+
}
|
|
197
|
+
return { text: out, truncated };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Unified diff for one working-tree file. Tracked files diff against HEAD
|
|
201
|
+
* (staged + unstaged); when there is no HEAD yet (fresh `git init`) the
|
|
202
|
+
* index/worktree pair is diffed instead, so staged edits still show.
|
|
203
|
+
* Untracked files diff against /dev/null, where git exits 1 even on success.
|
|
204
|
+
*
|
|
205
|
+
* Porcelain paths are relative to the repository root, so the diff runs from
|
|
206
|
+
* the root too — otherwise a workspace that is a subdirectory of the repo
|
|
207
|
+
* resolves the pathspec against the wrong prefix and produces nothing.
|
|
208
|
+
* Returns an empty text when git cannot produce a diff.
|
|
209
|
+
*/
|
|
210
|
+
export async function collectFileDiff(workspace, file, signal) {
|
|
211
|
+
const root = (await gitOutput(workspace, ["rev-parse", "--show-toplevel"], signal, [0]))?.trim() || workspace;
|
|
212
|
+
if (file.status === "??") {
|
|
213
|
+
const text = await gitOutput(root, ["diff", "--no-index", "--", "/dev/null", file.path], signal, [0, 1]);
|
|
214
|
+
return truncateDiff(text ?? "");
|
|
215
|
+
}
|
|
216
|
+
let text = await gitOutput(root, ["diff", "HEAD", "--", file.path], signal, [0]);
|
|
217
|
+
if (text === null) {
|
|
218
|
+
// No HEAD commits yet: staged (index vs empty tree) + unstaged (worktree vs index).
|
|
219
|
+
const [staged, unstaged] = await Promise.all([
|
|
220
|
+
gitOutput(root, ["diff", "--cached", "--", file.path], signal, [0]),
|
|
221
|
+
gitOutput(root, ["diff", "--", file.path], signal, [0]),
|
|
222
|
+
]);
|
|
223
|
+
text = [staged, unstaged].filter((part) => part !== null && part.trim().length > 0).join("\n");
|
|
224
|
+
}
|
|
225
|
+
return truncateDiff(text);
|
|
226
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { IntegrationClient } from "./protocol/client.js";
|
|
2
|
+
import type { AgentUsage } from "./protocol/types.js";
|
|
3
|
+
import type { ToolRegistry } from "./tools/registry.js";
|
|
4
|
+
import { SessionIndex } from "./agent/sessions.js";
|
|
5
|
+
import { type ApprovalMode } from "./approval/policy.js";
|
|
6
|
+
import type { AgentMode } from "./agent/modes.js";
|
|
7
|
+
export type OutputFormat = "text" | "json" | "stream-json";
|
|
8
|
+
export interface HeadlessOptions {
|
|
9
|
+
client: IntegrationClient;
|
|
10
|
+
registry: ToolRegistry;
|
|
11
|
+
sessionIndex: SessionIndex;
|
|
12
|
+
workspace: string;
|
|
13
|
+
allowOutside: boolean;
|
|
14
|
+
skillDirs: string[];
|
|
15
|
+
mode: AgentMode;
|
|
16
|
+
approval: ApprovalMode;
|
|
17
|
+
/** Client-choice model for a new thread (resumed threads keep their model). */
|
|
18
|
+
model?: string;
|
|
19
|
+
thinkingLevel?: string;
|
|
20
|
+
extraInstructions?: string;
|
|
21
|
+
noContextFiles?: boolean;
|
|
22
|
+
resumeThreadId?: string;
|
|
23
|
+
prompt: string;
|
|
24
|
+
outputFormat: OutputFormat;
|
|
25
|
+
quiet?: boolean;
|
|
26
|
+
/** Print only the final assistant message (prompt mode). */
|
|
27
|
+
lastMessageOnly?: boolean;
|
|
28
|
+
/** Abort the run after this many ms (headless only). */
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}
|
|
31
|
+
/** The single JSON object `--output-format json` emits — success and failure
|
|
32
|
+
* alike, so a CI parser always gets a parseable object. */
|
|
33
|
+
export interface HeadlessResult {
|
|
34
|
+
thread_id: string | null;
|
|
35
|
+
model: string | null;
|
|
36
|
+
text: string;
|
|
37
|
+
usage: AgentUsage | null;
|
|
38
|
+
error: string | null;
|
|
39
|
+
error_code: string | null;
|
|
40
|
+
}
|
|
41
|
+
/** Failure shape for pre-run errors (config/connection/usage) — same object
|
|
42
|
+
* as a failed run, so `--output-format json` never emits non-JSON. */
|
|
43
|
+
export declare function failureResult(message: string, code?: string): string;
|
|
44
|
+
/** Terminal stream-json event for a pre-run failure (same shape as a failed
|
|
45
|
+
* run's `done`). */
|
|
46
|
+
export declare function failureDoneEvent(message: string, code?: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* First-error lock for a headless run: text and code are captured together,
|
|
49
|
+
* so a later notice's code can never pair with an earlier notice's text (two
|
|
50
|
+
* independent `??=` would let a null code be overwritten while the text stays
|
|
51
|
+
* locked to the first error).
|
|
52
|
+
*/
|
|
53
|
+
export declare class ErrorNotice {
|
|
54
|
+
text: string | null;
|
|
55
|
+
code: string | null;
|
|
56
|
+
/** Record the first error only; later calls are ignored. */
|
|
57
|
+
record(text: string, code?: string): void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Tracks the last assistant message across a run. AG-UI text deltas carry a
|
|
61
|
+
* messageId per assistant message; a new id starts a new message. The last
|
|
62
|
+
* message that received any text wins — an empty final message (the agent
|
|
63
|
+
* ended on a tool call) falls back to the last one with text.
|
|
64
|
+
*/
|
|
65
|
+
export declare class FinalMessageTracker {
|
|
66
|
+
private currentId;
|
|
67
|
+
private current;
|
|
68
|
+
private last;
|
|
69
|
+
push(delta: string, messageId?: string): void;
|
|
70
|
+
get text(): string;
|
|
71
|
+
}
|
|
72
|
+
export declare function runHeadless(opts: HeadlessOptions): Promise<number>;
|
package/dist/headless.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Non-interactive mode (`myagent -p`, `run`, `--prompt`, piped stdin).
|
|
3
|
+
//
|
|
4
|
+
// Same runner as the TUI, different renderer:
|
|
5
|
+
// text — assistant text to stdout, tool activity to stderr
|
|
6
|
+
// json — one JSON object at the end (always parseable, failures too)
|
|
7
|
+
// stream-json — JSONL events as they happen (for scripting/GUI wrappers)
|
|
8
|
+
//
|
|
9
|
+
// Prompt mode (`--prompt`) additionally prints only the FINAL assistant
|
|
10
|
+
// message, bounds the run with `--timeout`, and stops the remote run
|
|
11
|
+
// gracefully on SIGINT/SIGTERM (CI cancellation).
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
import { AgentRunner, usageFromSession } from "./agent/turn.js";
|
|
14
|
+
import { createSystemPromptBuilder } from "./agent/prompt-builder.js";
|
|
15
|
+
import { deriveTitle } from "./agent/sessions.js";
|
|
16
|
+
import { TodoStore } from "./agent/todo.js";
|
|
17
|
+
import { ApprovalPolicy } from "./approval/policy.js";
|
|
18
|
+
import { style } from "./ui/colors.js";
|
|
19
|
+
import { sanitizeForTerminal } from "./sanitize.js";
|
|
20
|
+
/** Grace period after a stop is initiated before the process force-exits —
|
|
21
|
+
* the runner's post-run refresh has no timeout of its own. */
|
|
22
|
+
const STOP_GRACE_MS = 10_000;
|
|
23
|
+
/**
|
|
24
|
+
* Programmatic output contract for `--prompt` (CI) mode. The server no longer
|
|
25
|
+
* injects one for integration sessions — phrasing is the client's call, and
|
|
26
|
+
* this is the CLI's: the final message is the only thing CI reads, so it must
|
|
27
|
+
* be the answer, not a narration of how the agent got there. Sent every turn
|
|
28
|
+
* (the runner's system block is per-request); identical text is a no-op
|
|
29
|
+
* server-side, so the prompt cache holds.
|
|
30
|
+
*/
|
|
31
|
+
const FINAL_ANSWER_INSTRUCTION = "Your response will be consumed programmatically. Respond with the final answer only. " +
|
|
32
|
+
"Do not describe your internal tool usage or reasoning process unless the user explicitly asks for it.";
|
|
33
|
+
/** Failure shape for pre-run errors (config/connection/usage) — same object
|
|
34
|
+
* as a failed run, so `--output-format json` never emits non-JSON. */
|
|
35
|
+
export function failureResult(message, code) {
|
|
36
|
+
return JSON.stringify({
|
|
37
|
+
thread_id: null,
|
|
38
|
+
model: null,
|
|
39
|
+
text: "",
|
|
40
|
+
usage: null,
|
|
41
|
+
error: message,
|
|
42
|
+
error_code: code ?? null,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Terminal stream-json event for a pre-run failure (same shape as a failed
|
|
46
|
+
* run's `done`). */
|
|
47
|
+
export function failureDoneEvent(message, code) {
|
|
48
|
+
return JSON.stringify({
|
|
49
|
+
type: "done",
|
|
50
|
+
thread_id: null,
|
|
51
|
+
error: message,
|
|
52
|
+
...(code ? { error_code: code } : {}),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* First-error lock for a headless run: text and code are captured together,
|
|
57
|
+
* so a later notice's code can never pair with an earlier notice's text (two
|
|
58
|
+
* independent `??=` would let a null code be overwritten while the text stays
|
|
59
|
+
* locked to the first error).
|
|
60
|
+
*/
|
|
61
|
+
export class ErrorNotice {
|
|
62
|
+
text = null;
|
|
63
|
+
code = null;
|
|
64
|
+
/** Record the first error only; later calls are ignored. */
|
|
65
|
+
record(text, code) {
|
|
66
|
+
if (this.text !== null)
|
|
67
|
+
return;
|
|
68
|
+
this.text = text;
|
|
69
|
+
this.code = code ?? null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Tracks the last assistant message across a run. AG-UI text deltas carry a
|
|
74
|
+
* messageId per assistant message; a new id starts a new message. The last
|
|
75
|
+
* message that received any text wins — an empty final message (the agent
|
|
76
|
+
* ended on a tool call) falls back to the last one with text.
|
|
77
|
+
*/
|
|
78
|
+
export class FinalMessageTracker {
|
|
79
|
+
currentId;
|
|
80
|
+
current = "";
|
|
81
|
+
last = "";
|
|
82
|
+
push(delta, messageId) {
|
|
83
|
+
if (messageId !== this.currentId) {
|
|
84
|
+
if (this.current)
|
|
85
|
+
this.last = this.current;
|
|
86
|
+
this.current = "";
|
|
87
|
+
this.currentId = messageId;
|
|
88
|
+
}
|
|
89
|
+
this.current += delta;
|
|
90
|
+
}
|
|
91
|
+
get text() {
|
|
92
|
+
return this.current || this.last;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Timeout + signal control for a headless run. Both funnel into one stop
|
|
97
|
+
* path: abort the stream, stop the remote run, and arm a hard-exit grace so
|
|
98
|
+
* a wedged post-run refresh can never hang CI.
|
|
99
|
+
*/
|
|
100
|
+
function createStopControl(runner, timeoutMs) {
|
|
101
|
+
let timedOut = false;
|
|
102
|
+
let signalCode = null;
|
|
103
|
+
let stopping = false;
|
|
104
|
+
let hardExit = null;
|
|
105
|
+
const beginStop = () => {
|
|
106
|
+
if (stopping)
|
|
107
|
+
return;
|
|
108
|
+
stopping = true;
|
|
109
|
+
void runner.stop().catch(() => { });
|
|
110
|
+
hardExit = setTimeout(() => process.exit(signalCode ?? 1), STOP_GRACE_MS);
|
|
111
|
+
hardExit.unref?.();
|
|
112
|
+
};
|
|
113
|
+
const onSignal = (signal) => {
|
|
114
|
+
signalCode = signal === "SIGINT" ? 130 : 143;
|
|
115
|
+
// A second signal means "stop waiting" — exit immediately.
|
|
116
|
+
if (stopping)
|
|
117
|
+
process.exit(signalCode);
|
|
118
|
+
beginStop();
|
|
119
|
+
};
|
|
120
|
+
const onSigint = () => onSignal("SIGINT");
|
|
121
|
+
const onSigterm = () => onSignal("SIGTERM");
|
|
122
|
+
process.on("SIGINT", onSigint);
|
|
123
|
+
process.on("SIGTERM", onSigterm);
|
|
124
|
+
const timer = timeoutMs
|
|
125
|
+
? setTimeout(() => {
|
|
126
|
+
// The run may have finished in the same tick the timer fired, and a
|
|
127
|
+
// signal-initiated stop owns the outcome — only a live run that no
|
|
128
|
+
// one is already stopping is a timeout.
|
|
129
|
+
if (stopping || runner.state === "idle")
|
|
130
|
+
return;
|
|
131
|
+
timedOut = true;
|
|
132
|
+
beginStop();
|
|
133
|
+
}, timeoutMs)
|
|
134
|
+
: null;
|
|
135
|
+
timer?.unref?.();
|
|
136
|
+
return {
|
|
137
|
+
get timedOut() {
|
|
138
|
+
return timedOut;
|
|
139
|
+
},
|
|
140
|
+
get signalCode() {
|
|
141
|
+
return signalCode;
|
|
142
|
+
},
|
|
143
|
+
dispose() {
|
|
144
|
+
if (timer)
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
if (hardExit)
|
|
147
|
+
clearTimeout(hardExit);
|
|
148
|
+
process.off("SIGINT", onSigint);
|
|
149
|
+
process.off("SIGTERM", onSigterm);
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export async function runHeadless(opts) {
|
|
154
|
+
const todos = new TodoStore();
|
|
155
|
+
const streamJson = opts.outputFormat === "stream-json";
|
|
156
|
+
const json = opts.outputFormat === "json";
|
|
157
|
+
const tracker = new FinalMessageTracker();
|
|
158
|
+
let assistantText = "";
|
|
159
|
+
let usage = null;
|
|
160
|
+
const firstError = new ErrorNotice();
|
|
161
|
+
let titleUpdate = Promise.resolve();
|
|
162
|
+
const emitJson = (event) => {
|
|
163
|
+
process.stdout.write(JSON.stringify(event) + "\n");
|
|
164
|
+
};
|
|
165
|
+
const notice = (text, level, code) => {
|
|
166
|
+
if (level === "error")
|
|
167
|
+
firstError.record(text, code);
|
|
168
|
+
if (streamJson)
|
|
169
|
+
emitJson({ type: "notice", level, text, ...(code ? { code } : {}) });
|
|
170
|
+
else if (!json && !opts.quiet) {
|
|
171
|
+
process.stderr.write(style.dim(`${level === "error" ? "error: " : ""}${sanitizeForTerminal(text)}\n`));
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const buildSystemPrompt = createSystemPromptBuilder({
|
|
175
|
+
workspace: opts.workspace,
|
|
176
|
+
skillDirs: opts.skillDirs,
|
|
177
|
+
noContextFiles: opts.noContextFiles,
|
|
178
|
+
extraInstructions: [
|
|
179
|
+
opts.extraInstructions,
|
|
180
|
+
opts.lastMessageOnly ? FINAL_ANSWER_INSTRUCTION : undefined,
|
|
181
|
+
].filter(Boolean).join("\n\n") || undefined,
|
|
182
|
+
});
|
|
183
|
+
const runner = new AgentRunner({
|
|
184
|
+
client: opts.client,
|
|
185
|
+
registry: opts.registry,
|
|
186
|
+
workspace: opts.workspace,
|
|
187
|
+
allowOutside: opts.allowOutside,
|
|
188
|
+
skillDirs: opts.skillDirs,
|
|
189
|
+
todos,
|
|
190
|
+
// No one is present to answer an approval prompt, so `--ask` denies every
|
|
191
|
+
// mutating tool — say so instead of claiming the user declined.
|
|
192
|
+
approval: new ApprovalPolicy(opts.approval, async () => "denied", () => opts.mode, "Error: mutating tools are denied — no user is present to approve them in non-interactive mode. Re-run interactively or use --auto."),
|
|
193
|
+
buildSystemPrompt,
|
|
194
|
+
callbacks: {
|
|
195
|
+
onUserMessage: (text, queued) => {
|
|
196
|
+
if (streamJson)
|
|
197
|
+
emitJson({ type: "user", text, queued });
|
|
198
|
+
},
|
|
199
|
+
// Steers float in the TUI; headless just reports the attempt on the
|
|
200
|
+
// stream-json line (same event the old queued user message produced).
|
|
201
|
+
onSteerQueued: (text) => {
|
|
202
|
+
if (streamJson)
|
|
203
|
+
emitJson({ type: "user", text, queued: true });
|
|
204
|
+
},
|
|
205
|
+
onContent: (delta, messageId) => {
|
|
206
|
+
assistantText += delta;
|
|
207
|
+
tracker.push(delta, messageId);
|
|
208
|
+
if (streamJson)
|
|
209
|
+
emitJson({ type: "content", delta });
|
|
210
|
+
// Assistant text can echo untrusted content (a prompt-injected model,
|
|
211
|
+
// a file body it read) — strip terminal escapes before the user's
|
|
212
|
+
// real terminal sees them. Per-delta is safe: a sequence split across
|
|
213
|
+
// deltas degrades to harmless literal text, never a live escape.
|
|
214
|
+
else if (!json && !opts.lastMessageOnly)
|
|
215
|
+
process.stdout.write(sanitizeForTerminal(delta));
|
|
216
|
+
},
|
|
217
|
+
onReasoning: (delta) => {
|
|
218
|
+
if (streamJson)
|
|
219
|
+
emitJson({ type: "reasoning", delta });
|
|
220
|
+
},
|
|
221
|
+
onUsage: (u) => {
|
|
222
|
+
usage = u;
|
|
223
|
+
if (streamJson)
|
|
224
|
+
emitJson({ type: "usage", usage: u });
|
|
225
|
+
},
|
|
226
|
+
onServerTool: (tool) => {
|
|
227
|
+
if (streamJson)
|
|
228
|
+
emitJson({ type: "server_tool", tool });
|
|
229
|
+
else if (!json && !opts.quiet) {
|
|
230
|
+
process.stderr.write(style.dim(` ⚙ ${sanitizeForTerminal(tool.name)} (server · ${tool.phase})\n`));
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
onToolStart: (call, summary) => {
|
|
234
|
+
if (streamJson)
|
|
235
|
+
emitJson({ type: "tool_start", id: call.id, name: call.name, summary });
|
|
236
|
+
else if (!json && !opts.quiet) {
|
|
237
|
+
process.stderr.write(style.dim(` ⚙ ${sanitizeForTerminal(call.name)}: ${sanitizeForTerminal(summary)}\n`));
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
onToolEnd: (call, output, isError) => {
|
|
241
|
+
if (streamJson)
|
|
242
|
+
emitJson({ type: "tool_end", id: call.id, name: call.name, isError, output: truncate(output) });
|
|
243
|
+
else if (!json && !opts.quiet && isError) {
|
|
244
|
+
process.stderr.write(style.red(` ✗ ${sanitizeForTerminal(firstLine(output))}\n`));
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
onNotice: notice,
|
|
248
|
+
onRunState: (state) => {
|
|
249
|
+
if (streamJson)
|
|
250
|
+
emitJson({ type: "run_state", state });
|
|
251
|
+
},
|
|
252
|
+
onThread: (threadId) => {
|
|
253
|
+
opts.sessionIndex.remember(opts.workspace, threadId, deriveTitle(opts.prompt));
|
|
254
|
+
// Best-effort remote title update — tracked so the await below keeps
|
|
255
|
+
// process exit from cutting the request off when onThread fires late
|
|
256
|
+
// in a run. Bounded: a hung server must not hang the exit.
|
|
257
|
+
titleUpdate = opts.client
|
|
258
|
+
.patchSessionTitle(threadId, deriveTitle(opts.prompt), { signal: AbortSignal.timeout(5_000) })
|
|
259
|
+
.catch(() => { });
|
|
260
|
+
if (streamJson)
|
|
261
|
+
emitJson({ type: "thread", threadId });
|
|
262
|
+
},
|
|
263
|
+
onSession: (detail) => {
|
|
264
|
+
if (detail.model)
|
|
265
|
+
runner.model = detail.model;
|
|
266
|
+
const mapped = usageFromSession(detail.usage);
|
|
267
|
+
if (mapped)
|
|
268
|
+
usage = mapped;
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
runner.mode = opts.mode;
|
|
273
|
+
runner.thinkingLevel = opts.thinkingLevel;
|
|
274
|
+
runner.setModel(opts.model ?? null);
|
|
275
|
+
runner.threadId = opts.resumeThreadId ?? null;
|
|
276
|
+
const stop = createStopControl(runner, opts.timeoutMs);
|
|
277
|
+
try {
|
|
278
|
+
await runner.send(opts.prompt);
|
|
279
|
+
// Deliver any queued follow-up turn before reporting — process exit must
|
|
280
|
+
// not cut it off.
|
|
281
|
+
await runner.waitForIdle();
|
|
282
|
+
// Same for the best-effort title update latched by onThread above.
|
|
283
|
+
await titleUpdate;
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
stop.dispose();
|
|
287
|
+
}
|
|
288
|
+
if (stop.timedOut)
|
|
289
|
+
firstError.record(`Timed out after ${(opts.timeoutMs ?? 0) / 1000}s`, "timeout");
|
|
290
|
+
if (stop.signalCode !== null)
|
|
291
|
+
firstError.record("Run cancelled by signal", "cancelled");
|
|
292
|
+
const finalText = opts.lastMessageOnly ? tracker.text : assistantText;
|
|
293
|
+
if (!streamJson && !json) {
|
|
294
|
+
if (opts.lastMessageOnly) {
|
|
295
|
+
// Whole-message sanitize (not per delta): the final text is written in
|
|
296
|
+
// one go, so a sequence split across deltas is still stripped.
|
|
297
|
+
const safeFinal = sanitizeForTerminal(finalText);
|
|
298
|
+
if (safeFinal)
|
|
299
|
+
process.stdout.write(safeFinal.endsWith("\n") ? safeFinal : `${safeFinal}\n`);
|
|
300
|
+
}
|
|
301
|
+
else if (!assistantText.endsWith("\n")) {
|
|
302
|
+
process.stdout.write("\n");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (json) {
|
|
306
|
+
process.stdout.write(JSON.stringify({
|
|
307
|
+
thread_id: runner.threadId,
|
|
308
|
+
model: runner.model,
|
|
309
|
+
text: finalText,
|
|
310
|
+
usage,
|
|
311
|
+
error: firstError.text,
|
|
312
|
+
error_code: firstError.code,
|
|
313
|
+
}) + "\n");
|
|
314
|
+
}
|
|
315
|
+
else if (streamJson) {
|
|
316
|
+
emitJson({
|
|
317
|
+
type: "done",
|
|
318
|
+
thread_id: runner.threadId,
|
|
319
|
+
error: firstError.text,
|
|
320
|
+
...(firstError.code ? { error_code: firstError.code } : {}),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return stop.signalCode ?? (firstError.text ? 1 : 0);
|
|
324
|
+
}
|
|
325
|
+
function truncate(text) {
|
|
326
|
+
return text.length > 2000 ? `${text.slice(0, 2000)}…` : text;
|
|
327
|
+
}
|
|
328
|
+
function firstLine(text) {
|
|
329
|
+
return text.split("\n")[0] ?? "";
|
|
330
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { type StoredConfig } from "./config.js";
|
|
3
|
+
import { type AgentMode } from "./agent/modes.js";
|
|
4
|
+
import { type OutputFormat } from "./headless.js";
|
|
5
|
+
interface Args {
|
|
6
|
+
command: "chat" | "run" | "config" | "skills" | "sessions" | "help" | "version";
|
|
7
|
+
configSub?: string;
|
|
8
|
+
flags: Partial<StoredConfig> & {
|
|
9
|
+
continue?: boolean;
|
|
10
|
+
resume?: boolean;
|
|
11
|
+
session?: string;
|
|
12
|
+
mode?: AgentMode;
|
|
13
|
+
ask?: boolean;
|
|
14
|
+
auto?: boolean;
|
|
15
|
+
model?: string;
|
|
16
|
+
thinking?: string;
|
|
17
|
+
allowOutside?: boolean;
|
|
18
|
+
noContextFiles?: boolean;
|
|
19
|
+
noLogo?: boolean;
|
|
20
|
+
quiet?: boolean;
|
|
21
|
+
outputFormat?: OutputFormat;
|
|
22
|
+
workspace?: string;
|
|
23
|
+
prompt?: string;
|
|
24
|
+
timeout?: number;
|
|
25
|
+
};
|
|
26
|
+
skillsDirs: string[];
|
|
27
|
+
promptParts: string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function parseArgs(argv: string[]): Args;
|
|
30
|
+
/**
|
|
31
|
+
* Interactive means the TUI: a plain `chat` invocation with both streams on a
|
|
32
|
+
* TTY. `run`/`-p`, `--prompt`, `--output-format`, and piped stdin all mean
|
|
33
|
+
* headless.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isInteractiveMode(args: Args, stdinIsTty: boolean, stdoutIsTty: boolean): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Whether stdin should be read as prompt text. Piped stdin is a prompt for
|
|
38
|
+
* every headless path (`echo fix | myagent`); an inline `--prompt` never reads
|
|
39
|
+
* it (a CI stdin may be an open pipe — reading it would hang), while
|
|
40
|
+
* `--prompt -` reads it explicitly.
|
|
41
|
+
*/
|
|
42
|
+
export declare function shouldReadStdin(args: Args, interactive: boolean): boolean;
|
|
43
|
+
/** Join the prompt sources: positional words, `--prompt` (or stdin), piped stdin. */
|
|
44
|
+
export declare function assemblePrompt(args: Args, stdinText: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Report a pre-run failure (usage/config/connection) in the requested output
|
|
47
|
+
* format and return the exit code. `json` always emits a parseable object — a
|
|
48
|
+
* CI parser must never see an empty stdout; `stream-json` emits a terminal
|
|
49
|
+
* `done` event; text goes to stderr.
|
|
50
|
+
*/
|
|
51
|
+
export declare function fail(format: OutputFormat | undefined, message: string, errorCode?: string): number;
|
|
52
|
+
/**
|
|
53
|
+
* Read piped stdin as prompt text. Bounded by the run's `--timeout` when one
|
|
54
|
+
* is set: a non-TTY stdin that never closes (a CI misconfiguration like
|
|
55
|
+
* `docker run -i` without `< /dev/null`) would otherwise hang before the
|
|
56
|
+
* run's own timeout control is even created. Returns null on timeout so the
|
|
57
|
+
* caller can fail with a clear message instead of running an empty prompt.
|
|
58
|
+
*/
|
|
59
|
+
export declare function readStdin(timeoutMs?: number): Promise<string | null>;
|
|
60
|
+
export {};
|