@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.
Files changed (91) hide show
  1. package/README.md +357 -0
  2. package/dist/agent/context.d.ts +33 -0
  3. package/dist/agent/context.js +169 -0
  4. package/dist/agent/modes.d.ts +21 -0
  5. package/dist/agent/modes.js +84 -0
  6. package/dist/agent/prompt-builder.d.ts +9 -0
  7. package/dist/agent/prompt-builder.js +31 -0
  8. package/dist/agent/sessions.d.ts +38 -0
  9. package/dist/agent/sessions.js +130 -0
  10. package/dist/agent/todo.d.ts +18 -0
  11. package/dist/agent/todo.js +61 -0
  12. package/dist/agent/turn.d.ts +309 -0
  13. package/dist/agent/turn.js +1253 -0
  14. package/dist/approval/policy.d.ts +81 -0
  15. package/dist/approval/policy.js +157 -0
  16. package/dist/config.d.ts +49 -0
  17. package/dist/config.js +156 -0
  18. package/dist/git/status.d.ts +89 -0
  19. package/dist/git/status.js +226 -0
  20. package/dist/headless.d.ts +72 -0
  21. package/dist/headless.js +330 -0
  22. package/dist/index.d.ts +60 -0
  23. package/dist/index.js +511 -0
  24. package/dist/protocol/client.d.ts +123 -0
  25. package/dist/protocol/client.js +250 -0
  26. package/dist/protocol/sse-frames.d.ts +6 -0
  27. package/dist/protocol/sse-frames.js +75 -0
  28. package/dist/protocol/types.d.ts +200 -0
  29. package/dist/protocol/types.js +8 -0
  30. package/dist/runtime.d.ts +38 -0
  31. package/dist/runtime.js +166 -0
  32. package/dist/sanitize.d.ts +1 -0
  33. package/dist/sanitize.js +21 -0
  34. package/dist/skills/discovery.d.ts +24 -0
  35. package/dist/skills/discovery.js +109 -0
  36. package/dist/tools/binary.d.ts +2 -0
  37. package/dist/tools/binary.js +22 -0
  38. package/dist/tools/diff.d.ts +1 -0
  39. package/dist/tools/diff.js +49 -0
  40. package/dist/tools/find.d.ts +2 -0
  41. package/dist/tools/find.js +61 -0
  42. package/dist/tools/fs.d.ts +2 -0
  43. package/dist/tools/fs.js +276 -0
  44. package/dist/tools/glob.d.ts +6 -0
  45. package/dist/tools/glob.js +131 -0
  46. package/dist/tools/grep.d.ts +3 -0
  47. package/dist/tools/grep.js +228 -0
  48. package/dist/tools/paths.d.ts +27 -0
  49. package/dist/tools/paths.js +124 -0
  50. package/dist/tools/registry.d.ts +13 -0
  51. package/dist/tools/registry.js +38 -0
  52. package/dist/tools/shell.d.ts +2 -0
  53. package/dist/tools/shell.js +136 -0
  54. package/dist/tools/skills.d.ts +2 -0
  55. package/dist/tools/skills.js +36 -0
  56. package/dist/tools/todo.d.ts +2 -0
  57. package/dist/tools/todo.js +43 -0
  58. package/dist/tools/transfer.d.ts +2 -0
  59. package/dist/tools/transfer.js +145 -0
  60. package/dist/tools/truncate.d.ts +12 -0
  61. package/dist/tools/truncate.js +46 -0
  62. package/dist/tools/types.d.ts +85 -0
  63. package/dist/tools/types.js +63 -0
  64. package/dist/ui/app.d.ts +39 -0
  65. package/dist/ui/app.js +1061 -0
  66. package/dist/ui/colors.d.ts +100 -0
  67. package/dist/ui/colors.js +169 -0
  68. package/dist/ui/components.d.ts +267 -0
  69. package/dist/ui/components.js +811 -0
  70. package/dist/ui/diff.d.ts +37 -0
  71. package/dist/ui/diff.js +143 -0
  72. package/dist/ui/format.d.ts +28 -0
  73. package/dist/ui/format.js +76 -0
  74. package/dist/ui/help.d.ts +6 -0
  75. package/dist/ui/help.js +45 -0
  76. package/dist/ui/highlight.d.ts +20 -0
  77. package/dist/ui/highlight.js +210 -0
  78. package/dist/ui/logo.d.ts +24 -0
  79. package/dist/ui/logo.js +106 -0
  80. package/dist/ui/model-list.d.ts +10 -0
  81. package/dist/ui/model-list.js +33 -0
  82. package/dist/ui/quit-confirm.d.ts +8 -0
  83. package/dist/ui/quit-confirm.js +40 -0
  84. package/dist/ui/select-popup.d.ts +30 -0
  85. package/dist/ui/select-popup.js +54 -0
  86. package/dist/ui/theme.d.ts +4 -0
  87. package/dist/ui/theme.js +41 -0
  88. package/dist/ui/tool-view.d.ts +20 -0
  89. package/dist/ui/tool-view.js +326 -0
  90. package/package.json +44 -0
  91. 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>;
@@ -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
+ }
@@ -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 {};