laohuang 0.3.2 → 0.4.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 +142 -16
- package/dist/agent.d.ts +166 -0
- package/dist/agent.js +858 -0
- package/dist/agent.js.map +1 -0
- package/dist/bash-runner.d.ts +79 -0
- package/dist/bash-runner.js +464 -0
- package/dist/bash-runner.js.map +1 -0
- package/dist/cancellation.d.ts +36 -0
- package/dist/cancellation.js +123 -0
- package/dist/cancellation.js.map +1 -0
- package/dist/cli.d.ts +117 -0
- package/dist/cli.js +1307 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.js +17 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +123 -0
- package/dist/commands.js +660 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +51 -0
- package/dist/config.js +183 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +12 -0
- package/dist/credentials.js +102 -0
- package/dist/credentials.js.map +1 -0
- package/dist/events.d.ts +288 -0
- package/dist/events.js +838 -0
- package/dist/events.js.map +1 -0
- package/dist/model-adapter.d.ts +138 -0
- package/dist/model-adapter.js +244 -0
- package/dist/model-adapter.js.map +1 -0
- package/dist/model-selection.d.ts +67 -0
- package/dist/model-selection.js +148 -0
- package/dist/model-selection.js.map +1 -0
- package/dist/model-stream.d.ts +128 -0
- package/dist/model-stream.js +582 -0
- package/dist/model-stream.js.map +1 -0
- package/dist/project-instructions.d.ts +99 -0
- package/dist/project-instructions.js +348 -0
- package/dist/project-instructions.js.map +1 -0
- package/dist/providers.d.ts +9 -0
- package/dist/providers.js +26 -0
- package/dist/providers.js.map +1 -0
- package/dist/routing.d.ts +170 -0
- package/dist/routing.js +669 -0
- package/dist/routing.js.map +1 -0
- package/dist/semantic-classifier.d.ts +66 -0
- package/dist/semantic-classifier.js +86 -0
- package/dist/semantic-classifier.js.map +1 -0
- package/dist/session.d.ts +162 -0
- package/dist/session.js +871 -0
- package/dist/session.js.map +1 -0
- package/dist/system-prompt.d.ts +16 -0
- package/dist/system-prompt.js +42 -0
- package/dist/system-prompt.js.map +1 -0
- package/dist/terminal/editor.d.ts +161 -0
- package/dist/terminal/editor.js +1060 -0
- package/dist/terminal/editor.js.map +1 -0
- package/dist/terminal/input.d.ts +61 -0
- package/dist/terminal/input.js +276 -0
- package/dist/terminal/input.js.map +1 -0
- package/dist/terminal/markdown.d.ts +20 -0
- package/dist/terminal/markdown.js +621 -0
- package/dist/terminal/markdown.js.map +1 -0
- package/dist/terminal/screen.d.ts +66 -0
- package/dist/terminal/screen.js +624 -0
- package/dist/terminal/screen.js.map +1 -0
- package/dist/terminal/theme.d.ts +23 -0
- package/dist/terminal/theme.js +101 -0
- package/dist/terminal/theme.js.map +1 -0
- package/dist/terminal/ui.d.ts +285 -0
- package/dist/terminal/ui.js +1815 -0
- package/dist/terminal/ui.js.map +1 -0
- package/dist/tools.d.ts +95 -0
- package/dist/tools.js +444 -0
- package/dist/tools.js.map +1 -0
- package/dist/ui-state.d.ts +51 -0
- package/dist/ui-state.js +194 -0
- package/dist/ui-state.js.map +1 -0
- package/dist/web.d.ts +56 -0
- package/dist/web.js +247 -0
- package/dist/web.js.map +1 -0
- package/package.json +24 -21
- package/bin/laohuang.js +0 -10
- package/lib/launcher.js +0 -133
- package/vendor/laohuangcode-0.3.2-py3-none-any.whl +0 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Command-line interface and subcommands for laoHuangCode. */
|
|
3
|
+
import { existsSync, readFileSync, readSync, realpathSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { AgentError, CodingAgent, } from "./agent.js";
|
|
7
|
+
import { createClient, } from "./client.js";
|
|
8
|
+
import { SessionCommands } from "./commands.js";
|
|
9
|
+
import { ConfigManager, defaultConfigPath, } from "./config.js";
|
|
10
|
+
import { CredentialStore } from "./credentials.js";
|
|
11
|
+
import { EventProjector } from "./events.js";
|
|
12
|
+
import { ModelSelector } from "./model-selection.js";
|
|
13
|
+
import { getProvider, providerNames } from "./providers.js";
|
|
14
|
+
import { findProjectRoot } from "./project-instructions.js";
|
|
15
|
+
import { SmallModelSemanticClassifier, } from "./semantic-classifier.js";
|
|
16
|
+
import { AgentSession } from "./session.js";
|
|
17
|
+
import { BufferedInputKind, EditorState, InputActionKind, RawInputDecoder, StdinBuffer, TerminalInputFilter, inputAction, } from "./terminal/editor.js";
|
|
18
|
+
import { PromptCancelledError, PromptEofError } from "./terminal/input.js";
|
|
19
|
+
import { PlainEventSink, StdTerminalDriver, TerminalUI, } from "./terminal/ui.js";
|
|
20
|
+
import { ToolRegistry } from "./tools.js";
|
|
21
|
+
import { EventLog, WebDashboard, consumePullBuffer } from "./web.js";
|
|
22
|
+
export const VERSION = readPackageVersion();
|
|
23
|
+
function readPackageVersion() {
|
|
24
|
+
try {
|
|
25
|
+
const packageJsonPath = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
26
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
27
|
+
if (typeof parsed === "object" &&
|
|
28
|
+
parsed !== null &&
|
|
29
|
+
typeof parsed.version === "string") {
|
|
30
|
+
return parsed.version;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Fall through to the placeholder when the package manifest is missing.
|
|
35
|
+
}
|
|
36
|
+
return "0.0.0";
|
|
37
|
+
}
|
|
38
|
+
function errorMessage(error) {
|
|
39
|
+
return error instanceof Error ? error.message : String(error);
|
|
40
|
+
}
|
|
41
|
+
function writeStderr(message) {
|
|
42
|
+
process.stderr.write(`${message}\n`);
|
|
43
|
+
}
|
|
44
|
+
function isEofError(error) {
|
|
45
|
+
return (error instanceof PromptEofError ||
|
|
46
|
+
(error instanceof Error &&
|
|
47
|
+
(error.name === "PromptEofError" || error.name === "EOFError")));
|
|
48
|
+
}
|
|
49
|
+
function isInterruptedError(error) {
|
|
50
|
+
return (error instanceof PromptCancelledError ||
|
|
51
|
+
(error instanceof Error &&
|
|
52
|
+
(error.name === "PromptCancelledError" ||
|
|
53
|
+
error.name === "KeyboardInterrupt")));
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Synchronous stdin line prompts (non-TTY and pre-loop setup questions)
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// These blocking readers are the non-interactive fallback: before the
|
|
59
|
+
// terminal loop starts (first-run setup, pipes, tests) nothing else owns
|
|
60
|
+
// fd 0, so reading lines synchronously is safe. Once the interactive loop
|
|
61
|
+
// runs, questions must go through TerminalUI.prompt/promptSecret instead —
|
|
62
|
+
// see terminalUiPrompts below.
|
|
63
|
+
const stdinBuffer = { pending: "", eof: false };
|
|
64
|
+
function readLineFromStdin() {
|
|
65
|
+
const chunk = Buffer.alloc(4096);
|
|
66
|
+
for (;;) {
|
|
67
|
+
const newline = stdinBuffer.pending.indexOf("\n");
|
|
68
|
+
if (newline !== -1) {
|
|
69
|
+
const line = stdinBuffer.pending.slice(0, newline);
|
|
70
|
+
stdinBuffer.pending = stdinBuffer.pending.slice(newline + 1);
|
|
71
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
72
|
+
}
|
|
73
|
+
if (stdinBuffer.eof) {
|
|
74
|
+
const rest = stdinBuffer.pending;
|
|
75
|
+
stdinBuffer.pending = "";
|
|
76
|
+
if (rest) {
|
|
77
|
+
return rest;
|
|
78
|
+
}
|
|
79
|
+
throw new PromptEofError();
|
|
80
|
+
}
|
|
81
|
+
let bytesRead = 0;
|
|
82
|
+
try {
|
|
83
|
+
bytesRead = readSync(0, chunk, 0, chunk.length, null);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code === "EAGAIN") {
|
|
87
|
+
// A non-blocking fd 0: wait briefly and retry (mirrors blocking input).
|
|
88
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
if (bytesRead === 0) {
|
|
94
|
+
stdinBuffer.eof = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
stdinBuffer.pending += chunk.toString("utf8", 0, bytesRead);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Default replacement for Python's ``input(prompt)``. */
|
|
101
|
+
function defaultInputFn(prompt) {
|
|
102
|
+
if (prompt) {
|
|
103
|
+
process.stdout.write(prompt);
|
|
104
|
+
}
|
|
105
|
+
return readLineFromStdin();
|
|
106
|
+
}
|
|
107
|
+
/** Default replacement for Python's ``getpass.getpass(prompt)``. */
|
|
108
|
+
function defaultSecretInputFn(prompt) {
|
|
109
|
+
if (prompt) {
|
|
110
|
+
process.stderr.write(prompt);
|
|
111
|
+
}
|
|
112
|
+
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") {
|
|
113
|
+
return readLineFromStdin();
|
|
114
|
+
}
|
|
115
|
+
// Echo-less read of one line on a TTY: raw mode, byte at a time.
|
|
116
|
+
const byte = Buffer.alloc(1);
|
|
117
|
+
let answer = "";
|
|
118
|
+
process.stdin.setRawMode(true);
|
|
119
|
+
try {
|
|
120
|
+
for (;;) {
|
|
121
|
+
const bytesRead = readSync(0, byte, 0, 1, null);
|
|
122
|
+
if (bytesRead === 0) {
|
|
123
|
+
throw new PromptEofError();
|
|
124
|
+
}
|
|
125
|
+
const value = byte[0];
|
|
126
|
+
if (value === 0x03) {
|
|
127
|
+
process.stderr.write("\n");
|
|
128
|
+
throw new PromptCancelledError();
|
|
129
|
+
}
|
|
130
|
+
if (value === 0x04) {
|
|
131
|
+
process.stderr.write("\n");
|
|
132
|
+
throw new PromptEofError();
|
|
133
|
+
}
|
|
134
|
+
if (value === 0x0a || value === 0x0d) {
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
if (value === 0x7f || value === 0x08) {
|
|
138
|
+
answer = [...answer].slice(0, -1).join("");
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
answer += byte.toString("utf8", 0, 1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
process.stdin.setRawMode(false);
|
|
146
|
+
}
|
|
147
|
+
process.stderr.write("\n");
|
|
148
|
+
return answer;
|
|
149
|
+
}
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Argument parsing (argparse-compatible surface)
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
class CliUsageError extends Error {
|
|
154
|
+
}
|
|
155
|
+
const USAGE = "usage: laohuang [--version] [--profile PROFILE] [--model MODEL] " +
|
|
156
|
+
"[--base-url BASE_URL] [--theme {auto,dark,light}] [--web] " +
|
|
157
|
+
"[--web-port PORT] [config ...] [doctor]";
|
|
158
|
+
const HELP = `${USAGE}
|
|
159
|
+
|
|
160
|
+
A minimal coding agent
|
|
161
|
+
|
|
162
|
+
options:
|
|
163
|
+
--version show program's version number and exit
|
|
164
|
+
-h, --help show this help message and exit
|
|
165
|
+
--profile PROFILE model profile for this session
|
|
166
|
+
--model MODEL model override for this session
|
|
167
|
+
--base-url URL API base URL override for this session
|
|
168
|
+
--theme THEME interactive terminal theme: auto, dark, light (default: auto)
|
|
169
|
+
--web start the local agent trace dashboard
|
|
170
|
+
--web-port PORT dashboard port (default: 8765; use 0 for any free port)
|
|
171
|
+
|
|
172
|
+
subcommands:
|
|
173
|
+
config [set|list|use] [target] [--profile P] [--provider P] [--model M] [--base-url U]
|
|
174
|
+
doctor check local configuration`;
|
|
175
|
+
function splitOption(token) {
|
|
176
|
+
if (token.startsWith("--")) {
|
|
177
|
+
const equals = token.indexOf("=");
|
|
178
|
+
if (equals !== -1) {
|
|
179
|
+
return [token.slice(0, equals), token.slice(equals + 1)];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return [token, undefined];
|
|
183
|
+
}
|
|
184
|
+
function parseArgs(argv) {
|
|
185
|
+
const args = {
|
|
186
|
+
command: null,
|
|
187
|
+
profile: null,
|
|
188
|
+
model: null,
|
|
189
|
+
baseUrl: null,
|
|
190
|
+
theme: "auto",
|
|
191
|
+
web: false,
|
|
192
|
+
webPort: 8765,
|
|
193
|
+
configAction: "set",
|
|
194
|
+
configTarget: null,
|
|
195
|
+
configProfile: "default",
|
|
196
|
+
provider: null,
|
|
197
|
+
configModel: null,
|
|
198
|
+
configBaseUrl: null,
|
|
199
|
+
};
|
|
200
|
+
let index = 0;
|
|
201
|
+
const takeValue = (option, inline) => {
|
|
202
|
+
if (inline !== undefined) {
|
|
203
|
+
return inline;
|
|
204
|
+
}
|
|
205
|
+
index += 1;
|
|
206
|
+
const value = argv[index];
|
|
207
|
+
if (value === undefined) {
|
|
208
|
+
throw new CliUsageError(`argument ${option}: expected one argument`);
|
|
209
|
+
}
|
|
210
|
+
return value;
|
|
211
|
+
};
|
|
212
|
+
for (; index < argv.length; index += 1) {
|
|
213
|
+
const token = argv[index];
|
|
214
|
+
if (token === "config" || token === "doctor") {
|
|
215
|
+
args.command = token;
|
|
216
|
+
index += 1;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
const [name, inline] = splitOption(token);
|
|
220
|
+
switch (name) {
|
|
221
|
+
case "--version":
|
|
222
|
+
return { kind: "version" };
|
|
223
|
+
case "-h":
|
|
224
|
+
case "--help":
|
|
225
|
+
return { kind: "help" };
|
|
226
|
+
case "--profile":
|
|
227
|
+
args.profile = takeValue(name, inline);
|
|
228
|
+
break;
|
|
229
|
+
case "--model":
|
|
230
|
+
args.model = takeValue(name, inline);
|
|
231
|
+
break;
|
|
232
|
+
case "--base-url":
|
|
233
|
+
args.baseUrl = takeValue(name, inline);
|
|
234
|
+
break;
|
|
235
|
+
case "--theme": {
|
|
236
|
+
const theme = takeValue(name, inline);
|
|
237
|
+
if (theme !== "auto" && theme !== "dark" && theme !== "light") {
|
|
238
|
+
throw new CliUsageError(`argument --theme: invalid choice: '${theme}' (choose from 'auto', 'dark', 'light')`);
|
|
239
|
+
}
|
|
240
|
+
args.theme = theme;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case "--web":
|
|
244
|
+
if (inline !== undefined) {
|
|
245
|
+
throw new CliUsageError(`argument --web: ignored explicit argument`);
|
|
246
|
+
}
|
|
247
|
+
args.web = true;
|
|
248
|
+
break;
|
|
249
|
+
case "--web-port": {
|
|
250
|
+
const raw = takeValue(name, inline);
|
|
251
|
+
if (!/^[+-]?\d+$/.test(raw.trim())) {
|
|
252
|
+
throw new CliUsageError(`argument --web-port: invalid int value: '${raw}'`);
|
|
253
|
+
}
|
|
254
|
+
args.webPort = Number.parseInt(raw, 10);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
default:
|
|
258
|
+
throw new CliUsageError(`unrecognized arguments: ${token}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (args.command === "config") {
|
|
262
|
+
const positionals = [];
|
|
263
|
+
for (; index < argv.length; index += 1) {
|
|
264
|
+
const token = argv[index];
|
|
265
|
+
const [name, inline] = splitOption(token);
|
|
266
|
+
switch (name) {
|
|
267
|
+
case "--profile":
|
|
268
|
+
args.configProfile = takeValue(name, inline);
|
|
269
|
+
break;
|
|
270
|
+
case "--provider": {
|
|
271
|
+
const provider = takeValue(name, inline);
|
|
272
|
+
if (!providerNames().includes(provider)) {
|
|
273
|
+
throw new CliUsageError(`argument --provider: invalid choice: '${provider}' (choose from ${providerNames()
|
|
274
|
+
.map((item) => `'${item}'`)
|
|
275
|
+
.join(", ")})`);
|
|
276
|
+
}
|
|
277
|
+
args.provider = provider;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
case "--model":
|
|
281
|
+
args.configModel = takeValue(name, inline);
|
|
282
|
+
break;
|
|
283
|
+
case "--base-url":
|
|
284
|
+
args.configBaseUrl = takeValue(name, inline);
|
|
285
|
+
break;
|
|
286
|
+
case "-h":
|
|
287
|
+
case "--help":
|
|
288
|
+
return { kind: "help" };
|
|
289
|
+
default:
|
|
290
|
+
if (token.startsWith("-")) {
|
|
291
|
+
throw new CliUsageError(`unrecognized arguments: ${token}`);
|
|
292
|
+
}
|
|
293
|
+
positionals.push(token);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (positionals.length > 2) {
|
|
297
|
+
throw new CliUsageError(`unrecognized arguments: ${positionals.slice(2).join(" ")}`);
|
|
298
|
+
}
|
|
299
|
+
const action = positionals[0];
|
|
300
|
+
if (action !== undefined) {
|
|
301
|
+
if (action !== "set" && action !== "list" && action !== "use") {
|
|
302
|
+
throw new CliUsageError(`argument config_action: invalid choice: '${action}' (choose from 'set', 'list', 'use')`);
|
|
303
|
+
}
|
|
304
|
+
args.configAction = action;
|
|
305
|
+
}
|
|
306
|
+
args.configTarget = positionals[1] ?? null;
|
|
307
|
+
}
|
|
308
|
+
else if (args.command === "doctor") {
|
|
309
|
+
if (index < argv.length) {
|
|
310
|
+
throw new CliUsageError(`unrecognized arguments: ${argv.slice(index).join(" ")}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { kind: "run", args };
|
|
314
|
+
}
|
|
315
|
+
/** Only enable the interactive UI for the process's real TTY streams. */
|
|
316
|
+
export function supportsTerminalUI(options = {}) {
|
|
317
|
+
if (options.inputFn !== undefined || options.outputFn !== undefined) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
const stdin = options.stdin ?? process.stdin;
|
|
321
|
+
const stdout = options.stdout ?? process.stdout;
|
|
322
|
+
return Boolean(stdin.isTTY) && Boolean(stdout.isTTY);
|
|
323
|
+
}
|
|
324
|
+
export async function runRepl(agent, options = {}) {
|
|
325
|
+
const inputFn = options.inputFn ?? defaultInputFn;
|
|
326
|
+
const outputFn = options.outputFn ?? ((message) => console.log(message));
|
|
327
|
+
const ui = options.ui ?? null;
|
|
328
|
+
if (ui !== null) {
|
|
329
|
+
ui.showWelcome();
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
outputFn("laoHuangCode is ready. Type /help for commands or /exit to quit.");
|
|
333
|
+
}
|
|
334
|
+
for (;;) {
|
|
335
|
+
let userInput;
|
|
336
|
+
try {
|
|
337
|
+
userInput = (await (ui !== null ? ui.prompt() : inputFn("\nyou> "))).trim();
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
if (isEofError(error)) {
|
|
341
|
+
if (ui !== null) {
|
|
342
|
+
ui.showGoodbye();
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
outputFn("\nGoodbye.");
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (isInterruptedError(error)) {
|
|
350
|
+
if (ui !== null) {
|
|
351
|
+
ui.showInterrupted();
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
outputFn("\nInterrupted. Type /exit to quit.");
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
if (userInput === "/exit") {
|
|
361
|
+
if (ui !== null) {
|
|
362
|
+
ui.showGoodbye();
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
outputFn("Goodbye.");
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (!userInput) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (userInput.startsWith("/")) {
|
|
373
|
+
if (options.commandHandler !== undefined &&
|
|
374
|
+
(await options.commandHandler(userInput))) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const message = `Unknown command: ${userInput.split(/\s/)[0] ?? userInput}`;
|
|
378
|
+
if (ui !== null) {
|
|
379
|
+
ui.write(message);
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
outputFn(message);
|
|
383
|
+
}
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
let response;
|
|
387
|
+
try {
|
|
388
|
+
if (ui !== null && typeof ui.thinking === "function") {
|
|
389
|
+
const thinking = ui.thinking();
|
|
390
|
+
try {
|
|
391
|
+
response = await agent.run(userInput);
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
thinking?.close();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
response = await agent.run(userInput);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
if (error instanceof AgentError) {
|
|
403
|
+
if (ui !== null) {
|
|
404
|
+
ui.showError(error.message);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
outputFn(`\nError: ${error.message}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else if (isInterruptedError(error)) {
|
|
411
|
+
if (ui !== null) {
|
|
412
|
+
ui.showInterrupted({ operation: true });
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
outputFn("\nOperation interrupted.");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (ui !== null) {
|
|
424
|
+
ui.showAssistant(response);
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
outputFn(`\nlaoHuangCode> ${response}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function suggestCommand(ui, name) {
|
|
432
|
+
const registry = ui?.commandRegistry;
|
|
433
|
+
return typeof registry?.suggest === "function" ? registry.suggest(name) : null;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Shared input routing for the session REPLs. Returns false only for /exit,
|
|
437
|
+
* which the persistent loop reports back to the terminal instead.
|
|
438
|
+
*/
|
|
439
|
+
async function handleSessionInput(session, commandHandler, ui, userInput) {
|
|
440
|
+
if (userInput === "/exit") {
|
|
441
|
+
await session.submitInput(userInput);
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
if (!userInput) {
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
if (userInput.startsWith("/")) {
|
|
448
|
+
if (userInput === "/cancel") {
|
|
449
|
+
const submission = await session.submitInput(userInput);
|
|
450
|
+
if (submission.taskId === null) {
|
|
451
|
+
session.publishNotice("No active task to cancel.");
|
|
452
|
+
}
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
await session.submitInput(userInput);
|
|
456
|
+
if (commandHandler !== undefined && (await commandHandler(userInput))) {
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
const commandName = userInput.split(/\s/)[0] ?? userInput;
|
|
460
|
+
const suggestion = suggestCommand(ui, commandName);
|
|
461
|
+
const suffix = suggestion ? ` Did you mean ${suggestion}?` : "";
|
|
462
|
+
session.publishNotice(`Unknown command: ${commandName}.${suffix}`);
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
let submission;
|
|
466
|
+
try {
|
|
467
|
+
submission = await session.submitInput(userInput);
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
session.publishNotice(`Error: ${errorMessage(error)}`, { style: "bold red" });
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
if (submission.queued) {
|
|
474
|
+
const status = session.queueStatus();
|
|
475
|
+
session.publishNotice(`Message queued (pending ${status.pending ?? 0} · held ${status.held ?? 0}).`);
|
|
476
|
+
}
|
|
477
|
+
else if (submission.rejected) {
|
|
478
|
+
session.publishNotice(`Message rejected: ${submission.reason}`, {
|
|
479
|
+
style: "bold red",
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
/** Keep accepting input while AgentSession executes in the background. */
|
|
485
|
+
export async function runSessionRepl(session, options) {
|
|
486
|
+
if (options.runUi !== undefined || typeof options.ui.run === "function") {
|
|
487
|
+
return runPersistentSessionRepl(session, options);
|
|
488
|
+
}
|
|
489
|
+
return runClassicSessionRepl(session, options);
|
|
490
|
+
}
|
|
491
|
+
async function runClassicSessionRepl(session, options) {
|
|
492
|
+
const { commandHandler, ui } = options;
|
|
493
|
+
ui.startEventRenderer?.();
|
|
494
|
+
ui.showWelcome?.();
|
|
495
|
+
let cleanShutdown = false;
|
|
496
|
+
try {
|
|
497
|
+
for (;;) {
|
|
498
|
+
let userInput;
|
|
499
|
+
try {
|
|
500
|
+
userInput = (await ui.prompt()).trim();
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (isEofError(error)) {
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
if (isInterruptedError(error)) {
|
|
507
|
+
session.publishNotice("Interrupted.", { style: "yellow" });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
const keepGoing = await handleSessionInput(session, commandHandler, ui, userInput);
|
|
513
|
+
if (!keepGoing) {
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
finally {
|
|
519
|
+
cleanShutdown = await session.close({ wait: true, timeoutMs: 10_000 });
|
|
520
|
+
if (cleanShutdown) {
|
|
521
|
+
await session.eventBus.flush();
|
|
522
|
+
ui.flushEventRenderer?.();
|
|
523
|
+
}
|
|
524
|
+
ui.stopEventRenderer?.();
|
|
525
|
+
}
|
|
526
|
+
if (cleanShutdown) {
|
|
527
|
+
ui.showGoodbye?.();
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
ui.showError?.("Task worker did not stop before the shutdown timeout.");
|
|
531
|
+
}
|
|
532
|
+
return cleanShutdown;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Serialize routing and command handling on one async worker so the terminal
|
|
536
|
+
* loop never blocks on semantic classification (the Python original used a
|
|
537
|
+
* dedicated coordinator thread fed by a queue).
|
|
538
|
+
*/
|
|
539
|
+
function startCoordinator(session, commandHandler, ui) {
|
|
540
|
+
const queue = [];
|
|
541
|
+
let wake = null;
|
|
542
|
+
let stopping = false;
|
|
543
|
+
let unfinished = 0;
|
|
544
|
+
const errors = [];
|
|
545
|
+
const done = (async () => {
|
|
546
|
+
for (;;) {
|
|
547
|
+
if (queue.length === 0) {
|
|
548
|
+
await new Promise((resolve) => {
|
|
549
|
+
wake = resolve;
|
|
550
|
+
});
|
|
551
|
+
wake = null;
|
|
552
|
+
}
|
|
553
|
+
const item = queue.shift();
|
|
554
|
+
if (item === undefined) {
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
if (item === null || stopping) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
await handleSessionInput(session, commandHandler, ui, item);
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
errors.push(error);
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
unfinished -= 1;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
})();
|
|
571
|
+
return {
|
|
572
|
+
submit(text) {
|
|
573
|
+
unfinished += 1;
|
|
574
|
+
queue.push(text);
|
|
575
|
+
wake?.();
|
|
576
|
+
},
|
|
577
|
+
stop() {
|
|
578
|
+
stopping = true;
|
|
579
|
+
queue.push(null);
|
|
580
|
+
wake?.();
|
|
581
|
+
},
|
|
582
|
+
done,
|
|
583
|
+
errors,
|
|
584
|
+
pending() {
|
|
585
|
+
return unfinished;
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function delay(ms) {
|
|
590
|
+
return new Promise((resolve) => {
|
|
591
|
+
setTimeout(resolve, ms);
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
/** Resolve true when the promise settles within the timeout, false otherwise. */
|
|
595
|
+
async function settlesWithin(promise, ms) {
|
|
596
|
+
let timer;
|
|
597
|
+
const timeout = new Promise((resolve) => {
|
|
598
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
599
|
+
});
|
|
600
|
+
const settled = promise.then(() => true, () => true);
|
|
601
|
+
try {
|
|
602
|
+
return await Promise.race([settled, timeout]);
|
|
603
|
+
}
|
|
604
|
+
finally {
|
|
605
|
+
if (timer !== undefined) {
|
|
606
|
+
clearTimeout(timer);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async function waitForDrain(isDrained, timeoutMs) {
|
|
611
|
+
const deadline = Date.now() + timeoutMs;
|
|
612
|
+
for (;;) {
|
|
613
|
+
if (isDrained()) {
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
if (Date.now() >= deadline) {
|
|
617
|
+
return isDrained();
|
|
618
|
+
}
|
|
619
|
+
await delay(5);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/** Drive a single-renderer terminal without writing outside its layout. */
|
|
623
|
+
async function runPersistentSessionRepl(session, options) {
|
|
624
|
+
const { commandHandler, ui } = options;
|
|
625
|
+
const runUi = options.runUi ?? ((onSubmit) => ui.run(onSubmit));
|
|
626
|
+
ui.showWelcome?.();
|
|
627
|
+
const coordinator = startCoordinator(session, commandHandler, ui);
|
|
628
|
+
let cleanShutdown = false;
|
|
629
|
+
try {
|
|
630
|
+
const enqueue = (userInput) => {
|
|
631
|
+
// Exit is a local UI operation and should not wait behind a slow
|
|
632
|
+
// semantic classification of an earlier queued message.
|
|
633
|
+
if (userInput === "/exit") {
|
|
634
|
+
void (async () => {
|
|
635
|
+
try {
|
|
636
|
+
await session.submitInput(userInput);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
try {
|
|
640
|
+
session.publishNotice(`Error: ${errorMessage(error)}`, {
|
|
641
|
+
style: "bold red",
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
// The event bus may already be closed during shutdown.
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
})();
|
|
649
|
+
ui.requestExit?.();
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
coordinator.submit(userInput);
|
|
653
|
+
};
|
|
654
|
+
await runUi(enqueue);
|
|
655
|
+
}
|
|
656
|
+
finally {
|
|
657
|
+
const queueDrained = await waitForDrain(() => coordinator.pending() === 0, 50);
|
|
658
|
+
coordinator.stop();
|
|
659
|
+
let coordinatorAlive = !(await settlesWithin(coordinator.done, queueDrained ? 2000 : 50));
|
|
660
|
+
const sessionStopped = await session.close({ wait: true, timeoutMs: 10_000 });
|
|
661
|
+
if (coordinatorAlive) {
|
|
662
|
+
coordinatorAlive = !(await settlesWithin(coordinator.done, 250));
|
|
663
|
+
}
|
|
664
|
+
cleanShutdown =
|
|
665
|
+
sessionStopped && !coordinatorAlive && coordinator.errors.length === 0;
|
|
666
|
+
if (cleanShutdown) {
|
|
667
|
+
await session.eventBus.flush();
|
|
668
|
+
ui.flushEventRenderer?.();
|
|
669
|
+
}
|
|
670
|
+
const renderError = ui.renderError ?? null;
|
|
671
|
+
try {
|
|
672
|
+
if (!cleanShutdown && renderError === null) {
|
|
673
|
+
const shutdownMessage = coordinatorAlive
|
|
674
|
+
? "Input coordinator did not stop before the shutdown timeout."
|
|
675
|
+
: coordinator.errors.length > 0
|
|
676
|
+
? "Input coordinator failed during shutdown."
|
|
677
|
+
: "Task worker did not stop before the shutdown timeout.";
|
|
678
|
+
ui.showError?.(shutdownMessage);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
finally {
|
|
682
|
+
ui.close?.();
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const renderError = ui.renderError ?? null;
|
|
686
|
+
return cleanShutdown && renderError === null;
|
|
687
|
+
}
|
|
688
|
+
/** Run the same event-driven session with append-only plain output. */
|
|
689
|
+
export async function runPlainSessionRepl(session, options) {
|
|
690
|
+
const inputFn = options.inputFn ?? defaultInputFn;
|
|
691
|
+
const { commandHandler, sink } = options;
|
|
692
|
+
session.publishNotice("laoHuangCode is ready. Type /help for commands or /exit to quit.");
|
|
693
|
+
await session.eventBus.flush();
|
|
694
|
+
sink.flush();
|
|
695
|
+
let cleanShutdown = false;
|
|
696
|
+
try {
|
|
697
|
+
for (;;) {
|
|
698
|
+
let userInput;
|
|
699
|
+
try {
|
|
700
|
+
userInput = (await inputFn("")).trim();
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
if (isEofError(error)) {
|
|
704
|
+
// A pipe may close immediately after submitting work. Let the
|
|
705
|
+
// active task and its compatible pending batch finish normally.
|
|
706
|
+
await session.waitForIdle();
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
if (isInterruptedError(error)) {
|
|
710
|
+
session.publishNotice("Interrupted. Type /exit to quit.");
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
if (userInput === "/exit") {
|
|
716
|
+
await session.submitInput(userInput);
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
if (!userInput) {
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (userInput.startsWith("/")) {
|
|
723
|
+
if (userInput === "/cancel") {
|
|
724
|
+
const submission = await session.submitInput(userInput);
|
|
725
|
+
if (submission.taskId === null) {
|
|
726
|
+
session.publishNotice("No active task to cancel.");
|
|
727
|
+
}
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
await session.submitInput(userInput);
|
|
731
|
+
if (commandHandler !== undefined && (await commandHandler(userInput))) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
session.publishNotice(`Unknown command: ${userInput.split(/\s/)[0] ?? userInput}`);
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
let submission;
|
|
738
|
+
try {
|
|
739
|
+
submission = await session.submitInput(userInput);
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
session.publishNotice(`Error: ${errorMessage(error)}`);
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
if (submission.queued) {
|
|
746
|
+
const status = session.queueStatus();
|
|
747
|
+
session.publishNotice(`Message queued (pending ${status.pending ?? 0} · held ${status.held ?? 0}).`);
|
|
748
|
+
}
|
|
749
|
+
else if (submission.rejected) {
|
|
750
|
+
session.publishNotice(`Message rejected: ${submission.reason}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
finally {
|
|
755
|
+
cleanShutdown = await session.close({ wait: true, timeoutMs: 10_000 });
|
|
756
|
+
if (cleanShutdown) {
|
|
757
|
+
await session.eventBus.flush();
|
|
758
|
+
sink.flush();
|
|
759
|
+
}
|
|
760
|
+
sink.stop({ drain: cleanShutdown });
|
|
761
|
+
}
|
|
762
|
+
const output = sink.outputFn;
|
|
763
|
+
if (cleanShutdown) {
|
|
764
|
+
output("Goodbye.");
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
output("Error: Task worker did not stop before the shutdown timeout.");
|
|
768
|
+
}
|
|
769
|
+
return cleanShutdown;
|
|
770
|
+
}
|
|
771
|
+
// ---------------------------------------------------------------------------
|
|
772
|
+
// Production wiring helpers
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
/**
|
|
775
|
+
* The full input pipeline from terminal/editor.ts (buffer → negotiation
|
|
776
|
+
* filter → raw decoder), mirroring PiInputSession. Replaces the Basic*
|
|
777
|
+
* defaults of terminal/ui.ts inside the interactive loop.
|
|
778
|
+
*/
|
|
779
|
+
class ProductionInputDecoder {
|
|
780
|
+
#stdinBuffer = new StdinBuffer();
|
|
781
|
+
#filter;
|
|
782
|
+
#decoder = new RawInputDecoder();
|
|
783
|
+
constructor(hooks) {
|
|
784
|
+
this.#filter = new TerminalInputFilter({
|
|
785
|
+
enableModifyOtherKeys: () => {
|
|
786
|
+
hooks.enableModifyOtherKeys();
|
|
787
|
+
},
|
|
788
|
+
disableModifyOtherKeys: () => {
|
|
789
|
+
hooks.disableModifyOtherKeys();
|
|
790
|
+
},
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
get kittyProtocolActive() {
|
|
794
|
+
return this.#filter.kittyProtocolActive;
|
|
795
|
+
}
|
|
796
|
+
set kittyProtocolActive(value) {
|
|
797
|
+
this.#filter.kittyProtocolActive = value;
|
|
798
|
+
}
|
|
799
|
+
feed(data) {
|
|
800
|
+
const actions = [];
|
|
801
|
+
for (const event of this.#stdinBuffer.feed(data)) {
|
|
802
|
+
if (event.kind === BufferedInputKind.Paste) {
|
|
803
|
+
actions.push(inputAction(InputActionKind.Insert, event.data.toString("utf8")));
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
for (const sequence of this.#filter.feed(event.data)) {
|
|
807
|
+
actions.push(...this.#decoder.feed(sequence));
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return actions;
|
|
811
|
+
}
|
|
812
|
+
flush() {
|
|
813
|
+
const actions = [];
|
|
814
|
+
for (const event of this.#stdinBuffer.flush()) {
|
|
815
|
+
for (const sequence of this.#filter.feed(event.data)) {
|
|
816
|
+
actions.push(...this.#decoder.feed(sequence));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
for (const sequence of this.#filter.flush()) {
|
|
820
|
+
actions.push(...this.#decoder.feed(sequence));
|
|
821
|
+
}
|
|
822
|
+
actions.push(...this.#decoder.flush());
|
|
823
|
+
return actions;
|
|
824
|
+
}
|
|
825
|
+
clear() {
|
|
826
|
+
this.#stdinBuffer.clear();
|
|
827
|
+
this.#filter.clear();
|
|
828
|
+
// RawInputDecoder has no clear(); a fresh instance drops partial escapes.
|
|
829
|
+
this.#decoder = new RawInputDecoder();
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Wraps the production editor from terminal/editor.ts with the render result
|
|
834
|
+
* shape terminal/ui.ts's EditorLike expects (cursorColumn → cursorCol).
|
|
835
|
+
* Composition rather than inheritance: EditorState.renderLines has a
|
|
836
|
+
* different, incompatible return type.
|
|
837
|
+
*/
|
|
838
|
+
class ProductionEditor {
|
|
839
|
+
#state = new EditorState();
|
|
840
|
+
get text() {
|
|
841
|
+
return this.#state.text;
|
|
842
|
+
}
|
|
843
|
+
set text(value) {
|
|
844
|
+
this.#state.text = value;
|
|
845
|
+
}
|
|
846
|
+
get cursor() {
|
|
847
|
+
return this.#state.cursor;
|
|
848
|
+
}
|
|
849
|
+
set cursor(value) {
|
|
850
|
+
this.#state.cursor = value;
|
|
851
|
+
}
|
|
852
|
+
get historyIndex() {
|
|
853
|
+
return this.#state.historyIndex;
|
|
854
|
+
}
|
|
855
|
+
set historyIndex(value) {
|
|
856
|
+
this.#state.historyIndex = value;
|
|
857
|
+
}
|
|
858
|
+
get completions() {
|
|
859
|
+
return this.#state.completions;
|
|
860
|
+
}
|
|
861
|
+
get selectedCompletion() {
|
|
862
|
+
return this.#state.selectedCompletion;
|
|
863
|
+
}
|
|
864
|
+
set selectedCompletion(value) {
|
|
865
|
+
this.#state.selectedCompletion = value;
|
|
866
|
+
}
|
|
867
|
+
apply(action, options) {
|
|
868
|
+
return this.#state.apply(action, options);
|
|
869
|
+
}
|
|
870
|
+
setCompletions(values) {
|
|
871
|
+
this.#state.setCompletions(values);
|
|
872
|
+
}
|
|
873
|
+
renderLines(width, options = {}) {
|
|
874
|
+
const rendered = this.#state.renderLines(width, options);
|
|
875
|
+
return {
|
|
876
|
+
lines: rendered.lines,
|
|
877
|
+
cursorRow: rendered.cursorRow,
|
|
878
|
+
cursorCol: rendered.cursorColumn,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/** Drive the interactive loop of a real TerminalUI with process stdin. */
|
|
883
|
+
async function runTerminalUi(ui, driver, onSubmit) {
|
|
884
|
+
const loop = ui.interactiveLoop;
|
|
885
|
+
if (loop === null) {
|
|
886
|
+
throw new Error("interactive terminal loop is unavailable");
|
|
887
|
+
}
|
|
888
|
+
// TerminalUI.run() does not forward an input source to the loop yet, so
|
|
889
|
+
// the CLI starts the loop itself with the driver's stdin stream.
|
|
890
|
+
loop.start(onSubmit);
|
|
891
|
+
await loop.run(driver.inputStream);
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Route setup/command questions through the running terminal UI loop
|
|
895
|
+
* (mirrors the Python original's `input_fn = terminal_ui.prompt` and
|
|
896
|
+
* `session_secret_input_fn = terminal_ui.prompt_secret`). The loop's ask()
|
|
897
|
+
* coordinates the question with its editor, so no pause/resume is needed —
|
|
898
|
+
* and nothing reads fd 0 behind the driver's raw-mode stream.
|
|
899
|
+
*/
|
|
900
|
+
export function terminalUiPrompts(ui) {
|
|
901
|
+
return {
|
|
902
|
+
input: (prompt) => ui.prompt(prompt),
|
|
903
|
+
secretInput: (prompt) => ui.promptSecret(prompt),
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
export async function main(argv, options = {}) {
|
|
907
|
+
let parsed;
|
|
908
|
+
try {
|
|
909
|
+
parsed = parseArgs(argv ?? process.argv.slice(2));
|
|
910
|
+
}
|
|
911
|
+
catch (error) {
|
|
912
|
+
if (error instanceof CliUsageError) {
|
|
913
|
+
writeStderr(USAGE);
|
|
914
|
+
writeStderr(`laohuang: error: ${error.message}`);
|
|
915
|
+
return 2;
|
|
916
|
+
}
|
|
917
|
+
throw error;
|
|
918
|
+
}
|
|
919
|
+
if (parsed.kind === "version") {
|
|
920
|
+
process.stdout.write(`laohuang ${VERSION}\n`);
|
|
921
|
+
return 0;
|
|
922
|
+
}
|
|
923
|
+
if (parsed.kind === "help") {
|
|
924
|
+
process.stdout.write(`${HELP}\n`);
|
|
925
|
+
return 0;
|
|
926
|
+
}
|
|
927
|
+
const args = parsed.args;
|
|
928
|
+
const environ = options.environ ?? process.env;
|
|
929
|
+
const projectRoot = process.cwd();
|
|
930
|
+
// Instruction loading roots at the nearest .git ancestor; the tool
|
|
931
|
+
// registry keeps the plain cwd as its root.
|
|
932
|
+
const instructionRoot = findProjectRoot(process.cwd(), projectRoot);
|
|
933
|
+
const interactive = supportsTerminalUI({
|
|
934
|
+
inputFn: options.inputFn,
|
|
935
|
+
outputFn: options.outputFn,
|
|
936
|
+
stdin: options.stdin,
|
|
937
|
+
stdout: options.stdout,
|
|
938
|
+
});
|
|
939
|
+
const inputFn = options.inputFn ?? defaultInputFn;
|
|
940
|
+
const outputFn = options.outputFn ?? ((message) => console.log(message));
|
|
941
|
+
const secretInputFn = options.secretInputFn ?? defaultSecretInputFn;
|
|
942
|
+
const configPath = options.configPath ?? defaultConfigPath(environ);
|
|
943
|
+
const manager = new ConfigManager(configPath);
|
|
944
|
+
const credentials = new CredentialStore(options.credentialsPath ?? join(dirname(configPath), "credentials.json"));
|
|
945
|
+
// The selector's input/output targets are rewired once the session exists,
|
|
946
|
+
// mirroring the Python original which mutated selector.input_fn/output_fn.
|
|
947
|
+
// Like the Python original, the selector's own secret prompts (first-run
|
|
948
|
+
// setup, `config` subcommand) always use the default secret reader — only
|
|
949
|
+
// the session commands switch to the terminal UI's secret prompt.
|
|
950
|
+
let selectorInput = async (prompt) => inputFn(prompt);
|
|
951
|
+
const selectorSecretInput = async (prompt) => secretInputFn(prompt);
|
|
952
|
+
let selectorOutput = outputFn;
|
|
953
|
+
const selector = new ModelSelector({
|
|
954
|
+
credentials,
|
|
955
|
+
registry: { get: getProvider, names: providerNames },
|
|
956
|
+
createClient,
|
|
957
|
+
input: (prompt) => selectorInput(prompt),
|
|
958
|
+
secretInput: (prompt) => selectorSecretInput(prompt),
|
|
959
|
+
output: (message) => {
|
|
960
|
+
selectorOutput(message);
|
|
961
|
+
},
|
|
962
|
+
clientFactory: options.clientFactory,
|
|
963
|
+
});
|
|
964
|
+
if (args.command === "config") {
|
|
965
|
+
if (args.configAction === "list") {
|
|
966
|
+
let profiles;
|
|
967
|
+
try {
|
|
968
|
+
profiles = manager.listProfiles();
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
writeStderr(`Configuration error: ${errorMessage(error)}`);
|
|
972
|
+
return 2;
|
|
973
|
+
}
|
|
974
|
+
for (const profile of profiles) {
|
|
975
|
+
const marker = profile.active ? "*" : " ";
|
|
976
|
+
outputFn(`${marker} ${profile.name} ${profile.provider} ${profile.model}`);
|
|
977
|
+
}
|
|
978
|
+
return 0;
|
|
979
|
+
}
|
|
980
|
+
if (args.configAction === "use") {
|
|
981
|
+
if (!args.configTarget) {
|
|
982
|
+
writeStderr("Configuration error: profile name is required");
|
|
983
|
+
return 2;
|
|
984
|
+
}
|
|
985
|
+
try {
|
|
986
|
+
manager.setActive(args.configTarget);
|
|
987
|
+
}
|
|
988
|
+
catch (error) {
|
|
989
|
+
writeStderr(`Configuration error: ${errorMessage(error)}`);
|
|
990
|
+
return 2;
|
|
991
|
+
}
|
|
992
|
+
outputFn(`Active profile: ${args.configTarget}`);
|
|
993
|
+
return 0;
|
|
994
|
+
}
|
|
995
|
+
try {
|
|
996
|
+
const selection = await selector.select({
|
|
997
|
+
providerName: args.provider ?? undefined,
|
|
998
|
+
modelName: args.configModel ?? undefined,
|
|
999
|
+
});
|
|
1000
|
+
if (selection === null) {
|
|
1001
|
+
return 2;
|
|
1002
|
+
}
|
|
1003
|
+
manager.configure({
|
|
1004
|
+
name: args.configProfile,
|
|
1005
|
+
provider: selection.config.provider,
|
|
1006
|
+
model: selection.config.model,
|
|
1007
|
+
baseUrl: args.configBaseUrl ?? selection.config.baseUrl,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
catch (error) {
|
|
1011
|
+
writeStderr(`Configuration error: ${errorMessage(error)}`);
|
|
1012
|
+
return 2;
|
|
1013
|
+
}
|
|
1014
|
+
outputFn(`Saved profile '${args.configProfile}' to ${configPath}`);
|
|
1015
|
+
return 0;
|
|
1016
|
+
}
|
|
1017
|
+
if (args.command === "doctor") {
|
|
1018
|
+
let settings;
|
|
1019
|
+
try {
|
|
1020
|
+
settings = manager.resolveSettings({
|
|
1021
|
+
environ,
|
|
1022
|
+
profile: args.profile,
|
|
1023
|
+
model: args.model,
|
|
1024
|
+
baseUrl: args.baseUrl,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
writeStderr(`Configuration error: ${errorMessage(error)}`);
|
|
1029
|
+
return 2;
|
|
1030
|
+
}
|
|
1031
|
+
const keyConfigured = credentials.get(settings.provider) !== null;
|
|
1032
|
+
outputFn(`Provider: ${settings.provider}`);
|
|
1033
|
+
outputFn(`Model: ${settings.model}`);
|
|
1034
|
+
outputFn(`Base URL: ${settings.baseUrl ?? "SDK default"}`);
|
|
1035
|
+
outputFn(`API key: ${keyConfigured ? "configured" : "not configured"}`);
|
|
1036
|
+
outputFn(`Configuration: ${configPath}`);
|
|
1037
|
+
outputFn(`Node: ${process.version}`);
|
|
1038
|
+
outputFn(`Bash: ${existsSync("/bin/bash") ? "available" : "missing"}`);
|
|
1039
|
+
return keyConfigured ? 0 : 1;
|
|
1040
|
+
}
|
|
1041
|
+
let config;
|
|
1042
|
+
let runtimeClient = null;
|
|
1043
|
+
try {
|
|
1044
|
+
if (existsSync(configPath)) {
|
|
1045
|
+
config = manager.resolve({
|
|
1046
|
+
credentials,
|
|
1047
|
+
environ,
|
|
1048
|
+
profile: args.profile,
|
|
1049
|
+
model: args.model,
|
|
1050
|
+
baseUrl: args.baseUrl,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
const selection = await selector.select();
|
|
1055
|
+
if (selection === null) {
|
|
1056
|
+
return 2;
|
|
1057
|
+
}
|
|
1058
|
+
config = {
|
|
1059
|
+
model: selection.config.model,
|
|
1060
|
+
baseUrl: selection.config.baseUrl,
|
|
1061
|
+
provider: selection.config.provider,
|
|
1062
|
+
profile: null,
|
|
1063
|
+
apiKey: selection.config.apiKey,
|
|
1064
|
+
};
|
|
1065
|
+
runtimeClient = selection.client;
|
|
1066
|
+
manager.configure({
|
|
1067
|
+
name: "default",
|
|
1068
|
+
provider: config.provider,
|
|
1069
|
+
model: config.model,
|
|
1070
|
+
baseUrl: config.baseUrl,
|
|
1071
|
+
});
|
|
1072
|
+
outputFn(`Configured ${config.provider} / ${config.model} as default.`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
catch (error) {
|
|
1076
|
+
const message = errorMessage(error);
|
|
1077
|
+
if (existsSync(configPath) && message.startsWith("No API key configured")) {
|
|
1078
|
+
// A stored profile without its API key re-runs interactive selection
|
|
1079
|
+
// for the resolved provider/model (first-run setup flow).
|
|
1080
|
+
let selection;
|
|
1081
|
+
try {
|
|
1082
|
+
const settings = manager.resolveSettings({
|
|
1083
|
+
environ,
|
|
1084
|
+
profile: args.profile,
|
|
1085
|
+
model: args.model,
|
|
1086
|
+
baseUrl: args.baseUrl,
|
|
1087
|
+
});
|
|
1088
|
+
selection = await selector.select({
|
|
1089
|
+
providerName: settings.provider,
|
|
1090
|
+
modelName: settings.model,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
catch (selectionError) {
|
|
1094
|
+
writeStderr(`Configuration error: ${errorMessage(selectionError)}`);
|
|
1095
|
+
return 2;
|
|
1096
|
+
}
|
|
1097
|
+
if (selection === null) {
|
|
1098
|
+
return 2;
|
|
1099
|
+
}
|
|
1100
|
+
config = {
|
|
1101
|
+
model: selection.config.model,
|
|
1102
|
+
baseUrl: selection.config.baseUrl,
|
|
1103
|
+
provider: selection.config.provider,
|
|
1104
|
+
profile: null,
|
|
1105
|
+
apiKey: selection.config.apiKey,
|
|
1106
|
+
};
|
|
1107
|
+
runtimeClient = selection.client;
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
writeStderr(`Configuration error: ${message}`);
|
|
1111
|
+
return 2;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
const client = runtimeClient ??
|
|
1115
|
+
createClient(config, { clientFactory: options.clientFactory });
|
|
1116
|
+
let eventLog = null;
|
|
1117
|
+
let dashboard = null;
|
|
1118
|
+
if (args.web) {
|
|
1119
|
+
eventLog = new EventLog();
|
|
1120
|
+
eventLog.record("session_start", {
|
|
1121
|
+
project_root: projectRoot,
|
|
1122
|
+
provider: config.provider,
|
|
1123
|
+
model: config.model,
|
|
1124
|
+
profile: config.profile,
|
|
1125
|
+
});
|
|
1126
|
+
dashboard = new WebDashboard(eventLog, { port: args.webPort });
|
|
1127
|
+
try {
|
|
1128
|
+
await dashboard.start();
|
|
1129
|
+
}
|
|
1130
|
+
catch (error) {
|
|
1131
|
+
writeStderr(`Web dashboard error: ${errorMessage(error)}`);
|
|
1132
|
+
return 2;
|
|
1133
|
+
}
|
|
1134
|
+
if (!interactive) {
|
|
1135
|
+
outputFn(`Web dashboard: ${dashboard.url}`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// The interactive UI is constructed only once configuration (and the
|
|
1139
|
+
// dashboard URL) are known; its provider/model are read-only in TS.
|
|
1140
|
+
let terminalUi = null;
|
|
1141
|
+
let terminalDriver = null;
|
|
1142
|
+
if (interactive) {
|
|
1143
|
+
terminalDriver = new StdTerminalDriver();
|
|
1144
|
+
terminalUi = new TerminalUI({
|
|
1145
|
+
projectRoot,
|
|
1146
|
+
provider: config.provider,
|
|
1147
|
+
model: config.model,
|
|
1148
|
+
dashboardUrl: dashboard?.url,
|
|
1149
|
+
theme: args.theme,
|
|
1150
|
+
driver: terminalDriver,
|
|
1151
|
+
editorFactory: () => new ProductionEditor(),
|
|
1152
|
+
decoderFactory: (hooks) => new ProductionInputDecoder(hooks),
|
|
1153
|
+
});
|
|
1154
|
+
terminalUi.state.provider = config.provider;
|
|
1155
|
+
terminalUi.state.model = config.model;
|
|
1156
|
+
}
|
|
1157
|
+
const agent = new CodingAgent({
|
|
1158
|
+
client: client,
|
|
1159
|
+
model: config.model,
|
|
1160
|
+
tools: new ToolRegistry(projectRoot),
|
|
1161
|
+
provider: config.provider,
|
|
1162
|
+
projectRoot: instructionRoot,
|
|
1163
|
+
startupCwd: process.cwd(),
|
|
1164
|
+
});
|
|
1165
|
+
const semanticClassifier = new SmallModelSemanticClassifier({
|
|
1166
|
+
client: client,
|
|
1167
|
+
model: config.model,
|
|
1168
|
+
});
|
|
1169
|
+
// session.ts asserts at compile time that CodingAgent satisfies its
|
|
1170
|
+
// AgentRunnerLike contract (run(userInput, TaskContext)), so the agent can
|
|
1171
|
+
// be handed to the session directly — no adapter needed.
|
|
1172
|
+
const runtime = new AgentSession(agent, { semanticClassifier });
|
|
1173
|
+
const plainSink = terminalUi === null ? new PlainEventSink(outputFn) : null;
|
|
1174
|
+
const sessionSink = terminalUi ?? plainSink;
|
|
1175
|
+
let replInputFn = inputFn;
|
|
1176
|
+
let commandsSecretInput = selectorSecretInput;
|
|
1177
|
+
if (plainSink !== null) {
|
|
1178
|
+
const underlyingInput = inputFn;
|
|
1179
|
+
const underlyingSecretInput = secretInputFn;
|
|
1180
|
+
// Setup/command prompts go through the event pipeline in plain mode so
|
|
1181
|
+
// they interleave with task output. The Python original also flushed the
|
|
1182
|
+
// bus here; TS delivery is microtask-driven and catches up at the next
|
|
1183
|
+
// await.
|
|
1184
|
+
const plainInput = async (prompt) => {
|
|
1185
|
+
if (prompt) {
|
|
1186
|
+
runtime.publishNotice(prompt);
|
|
1187
|
+
}
|
|
1188
|
+
return underlyingInput("");
|
|
1189
|
+
};
|
|
1190
|
+
const plainSecretInput = async (prompt) => {
|
|
1191
|
+
if (prompt) {
|
|
1192
|
+
runtime.publishNotice(prompt);
|
|
1193
|
+
}
|
|
1194
|
+
return underlyingSecretInput("");
|
|
1195
|
+
};
|
|
1196
|
+
replInputFn = plainInput;
|
|
1197
|
+
selectorInput = plainInput;
|
|
1198
|
+
commandsSecretInput = plainSecretInput;
|
|
1199
|
+
}
|
|
1200
|
+
else if (terminalUi !== null) {
|
|
1201
|
+
// /login and interactive /model run while the terminal loop owns stdin in
|
|
1202
|
+
// raw mode: their questions must be asked through the UI loop, not read
|
|
1203
|
+
// from fd 0 directly.
|
|
1204
|
+
const prompts = terminalUiPrompts(terminalUi);
|
|
1205
|
+
selectorInput = prompts.input;
|
|
1206
|
+
commandsSecretInput = prompts.secretInput;
|
|
1207
|
+
}
|
|
1208
|
+
selectorOutput = (message) => {
|
|
1209
|
+
runtime.publishNotice(message);
|
|
1210
|
+
};
|
|
1211
|
+
const sessionOutput = (message) => {
|
|
1212
|
+
runtime.publishNotice(message);
|
|
1213
|
+
};
|
|
1214
|
+
const commands = new SessionCommands({
|
|
1215
|
+
agent,
|
|
1216
|
+
selector,
|
|
1217
|
+
credentials,
|
|
1218
|
+
currentConfig: {
|
|
1219
|
+
apiKey: config.apiKey,
|
|
1220
|
+
model: config.model,
|
|
1221
|
+
baseUrl: config.baseUrl,
|
|
1222
|
+
provider: config.provider,
|
|
1223
|
+
},
|
|
1224
|
+
input: (prompt) => selectorInput(prompt),
|
|
1225
|
+
secretInput: (prompt) => commandsSecretInput(prompt),
|
|
1226
|
+
output: sessionOutput,
|
|
1227
|
+
session: runtime,
|
|
1228
|
+
});
|
|
1229
|
+
const unsubscribers = [];
|
|
1230
|
+
const projector = new EventProjector();
|
|
1231
|
+
unsubscribers.push(runtime.eventBus.subscribe((event) => {
|
|
1232
|
+
sessionSink.publishEvent(projector.project(event, "terminal"));
|
|
1233
|
+
}));
|
|
1234
|
+
if (eventLog !== null) {
|
|
1235
|
+
// The dashboard feeds from the canonical pull buffer; the promise
|
|
1236
|
+
// resolves when the bus closes. Unexpected failures are dropped the same
|
|
1237
|
+
// way the Python daemon thread lost them.
|
|
1238
|
+
void consumePullBuffer(runtime.eventBus, eventLog).catch(() => { });
|
|
1239
|
+
}
|
|
1240
|
+
if (terminalUi !== null) {
|
|
1241
|
+
terminalUi.setCommandRegistry(commands.registry);
|
|
1242
|
+
terminalUi.setCancelCallback(() => {
|
|
1243
|
+
void commands.handle("/cancel");
|
|
1244
|
+
});
|
|
1245
|
+
terminalUi.setRuntimeRunningCallback(() => runtime.activeTask !== null);
|
|
1246
|
+
}
|
|
1247
|
+
const handleCommand = async (command) => {
|
|
1248
|
+
const handled = await commands.handle(command);
|
|
1249
|
+
semanticClassifier.configure({
|
|
1250
|
+
client: agent.client,
|
|
1251
|
+
model: agent.model,
|
|
1252
|
+
});
|
|
1253
|
+
return handled;
|
|
1254
|
+
};
|
|
1255
|
+
let cleanShutdown = false;
|
|
1256
|
+
try {
|
|
1257
|
+
if (terminalUi !== null && terminalDriver !== null) {
|
|
1258
|
+
const ui = terminalUi;
|
|
1259
|
+
const driver = terminalDriver;
|
|
1260
|
+
cleanShutdown = await runSessionRepl(runtime, {
|
|
1261
|
+
commandHandler: handleCommand,
|
|
1262
|
+
ui,
|
|
1263
|
+
runUi: (enqueue) => runTerminalUi(ui, driver, enqueue),
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
cleanShutdown = await runPlainSessionRepl(runtime, {
|
|
1268
|
+
commandHandler: handleCommand,
|
|
1269
|
+
inputFn: replInputFn,
|
|
1270
|
+
sink: plainSink,
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
finally {
|
|
1275
|
+
for (const unsubscribe of unsubscribers) {
|
|
1276
|
+
unsubscribe();
|
|
1277
|
+
}
|
|
1278
|
+
if (dashboard !== null) {
|
|
1279
|
+
await dashboard.stop();
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
return cleanShutdown ? 0 : 1;
|
|
1283
|
+
}
|
|
1284
|
+
// ---------------------------------------------------------------------------
|
|
1285
|
+
// Direct execution (dist/cli.js is the npm bin entry)
|
|
1286
|
+
// ---------------------------------------------------------------------------
|
|
1287
|
+
function invokedAsScript() {
|
|
1288
|
+
const entry = process.argv[1];
|
|
1289
|
+
if (!entry) {
|
|
1290
|
+
return false;
|
|
1291
|
+
}
|
|
1292
|
+
try {
|
|
1293
|
+
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
|
|
1294
|
+
}
|
|
1295
|
+
catch {
|
|
1296
|
+
return false;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if (invokedAsScript()) {
|
|
1300
|
+
main().then((code) => {
|
|
1301
|
+
process.exitCode = code;
|
|
1302
|
+
}, (error) => {
|
|
1303
|
+
writeStderr(`laohuang: ${errorMessage(error)}`);
|
|
1304
|
+
process.exitCode = 1;
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
//# sourceMappingURL=cli.js.map
|