@aitherium/shell-cli 1.1.0 → 1.11.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/dist/auth.d.ts +13 -4
- package/dist/auth.js +18 -3
- package/dist/client.d.ts +21 -1
- package/dist/client.js +213 -9
- package/dist/command-registry.d.ts +1 -1
- package/dist/command-registry.js +28 -2
- package/dist/commands.d.ts +8 -0
- package/dist/commands.js +2068 -48
- package/dist/completions.js +236 -2
- package/dist/config.d.ts +30 -0
- package/dist/config.js +37 -4
- package/dist/crash-reporter.d.ts +22 -0
- package/dist/crash-reporter.js +275 -0
- package/dist/formatters.d.ts +16 -0
- package/dist/formatters.js +280 -0
- package/dist/interactive.d.ts +45 -0
- package/dist/interactive.js +232 -0
- package/dist/main.js +439 -41
- package/dist/mcp-client.d.ts +58 -0
- package/dist/mcp-client.js +202 -0
- package/dist/relay.d.ts +96 -0
- package/dist/relay.js +234 -0
- package/dist/renderer.d.ts +47 -17
- package/dist/renderer.js +506 -164
- package/dist/repl.js +205 -23
- package/dist/session-store.d.ts +30 -5
- package/dist/session-store.js +56 -0
- package/dist/status-banner.d.ts +59 -0
- package/dist/status-banner.js +239 -0
- package/dist/tui/controller.d.ts +3 -0
- package/dist/tui/controller.js +310 -0
- package/dist/tui/repl-tui.d.ts +3 -0
- package/dist/tui/repl-tui.js +898 -0
- package/dist/tui/screen.d.ts +68 -0
- package/dist/tui/screen.js +736 -0
- package/package.json +9 -2
package/dist/main.js
CHANGED
|
@@ -30,11 +30,56 @@ else {
|
|
|
30
30
|
import chalk from 'chalk';
|
|
31
31
|
import { readFileSync } from 'fs';
|
|
32
32
|
import { extname } from 'path';
|
|
33
|
-
import { loadConfig } from './config.js';
|
|
33
|
+
import { loadConfig, setActiveConfig } from './config.js';
|
|
34
34
|
import { GenesisClient } from './client.js';
|
|
35
35
|
import { renderBanner, createStreamRenderer } from './renderer.js';
|
|
36
|
+
import { buildProbes, probeHealth, pickServingModel } from './status-banner.js';
|
|
36
37
|
import { startRepl } from './repl.js';
|
|
37
|
-
|
|
38
|
+
import { installCrashReporter } from './crash-reporter.js';
|
|
39
|
+
import { configureRemoteSync, recordTurn, loadSession, loadRemoteSession, mostRecentSessionId, buildContextSummary, } from './session-store.js';
|
|
40
|
+
/** Read piped stdin as text (headless prompt body). Empty if attached to a TTY. */
|
|
41
|
+
async function readStdin() {
|
|
42
|
+
if (process.stdin.isTTY)
|
|
43
|
+
return '';
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
let data = '';
|
|
46
|
+
let done = false;
|
|
47
|
+
const finish = () => { if (!done) {
|
|
48
|
+
done = true;
|
|
49
|
+
resolve(data.trim());
|
|
50
|
+
} };
|
|
51
|
+
process.stdin.setEncoding('utf8');
|
|
52
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
53
|
+
process.stdin.on('end', finish);
|
|
54
|
+
process.stdin.on('error', finish);
|
|
55
|
+
// Safety: a non-TTY stdin that never closes (some pipes) must not hang the
|
|
56
|
+
// process — if no EOF arrives quickly, resolve with whatever we have.
|
|
57
|
+
setTimeout(finish, 1500);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Resolve a --continue/--resume/--session target into a session id + transcript. */
|
|
61
|
+
async function resolveResume(client, config, mode, id) {
|
|
62
|
+
let sid = mode === 'continue' ? mostRecentSessionId() : (id || null);
|
|
63
|
+
if (!sid)
|
|
64
|
+
return null;
|
|
65
|
+
let entry = loadSession(sid);
|
|
66
|
+
if (!entry) {
|
|
67
|
+
try {
|
|
68
|
+
entry = await loadRemoteSession(sid);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
entry = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
config.sessionId = sid;
|
|
75
|
+
config.resumeSessionId = sid;
|
|
76
|
+
config.resumed = true;
|
|
77
|
+
return { sessionId: sid, summary: entry ? buildContextSummary(entry.messages) : '' };
|
|
78
|
+
}
|
|
79
|
+
// Install global crash reporter — catches uncaught exceptions/rejections,
|
|
80
|
+
// prompts user to send error report, creates GitHub issue automatically.
|
|
81
|
+
installCrashReporter();
|
|
82
|
+
const VERSION = '1.10.0'; // keep in sync with package.json
|
|
38
83
|
async function main() {
|
|
39
84
|
const args = process.argv.slice(2);
|
|
40
85
|
if (args.includes('--help') || args.includes('-h')) {
|
|
@@ -46,6 +91,7 @@ async function main() {
|
|
|
46
91
|
return;
|
|
47
92
|
}
|
|
48
93
|
const config = loadConfig();
|
|
94
|
+
setActiveConfig(config);
|
|
49
95
|
const client = new GenesisClient(config.genesisUrl);
|
|
50
96
|
// Wire auth token from config to client
|
|
51
97
|
if (config.authToken) {
|
|
@@ -74,6 +120,7 @@ async function main() {
|
|
|
74
120
|
}
|
|
75
121
|
// Parse creative flags (--will, --safety, --private, --effort) early
|
|
76
122
|
// so they apply to both runOneShot and positional one-shot chat.
|
|
123
|
+
let printPrompt = ''; // prompt captured from `-p <prompt>`
|
|
77
124
|
for (let i = 0; i < args.length; i++) {
|
|
78
125
|
if ((args[i] === '--will' || args[i] === '--agent' || args[i] === '-a') && args[i + 1]) {
|
|
79
126
|
config.defaultAgent = args[i + 1];
|
|
@@ -103,6 +150,44 @@ async function main() {
|
|
|
103
150
|
process.exit(1);
|
|
104
151
|
}
|
|
105
152
|
}
|
|
153
|
+
else if (args[i] === '--print' || args[i] === '-p') {
|
|
154
|
+
config.printMode = true;
|
|
155
|
+
if (args[i + 1] && !args[i + 1].startsWith('-'))
|
|
156
|
+
printPrompt = args[++i];
|
|
157
|
+
}
|
|
158
|
+
else if (args[i] === '--output-format' && args[i + 1]) {
|
|
159
|
+
const f = args[++i];
|
|
160
|
+
config.outputFormat = (f === 'json' || f === 'stream-json') ? f : 'text';
|
|
161
|
+
}
|
|
162
|
+
else if (args[i] === '--json') {
|
|
163
|
+
config.outputFormat = 'json';
|
|
164
|
+
config.printMode = true;
|
|
165
|
+
}
|
|
166
|
+
else if (args[i] === '--continue' || args[i] === '-C') {
|
|
167
|
+
config.resumeSessionId = '__continue__'; // sentinel → most-recent session
|
|
168
|
+
}
|
|
169
|
+
else if ((args[i] === '--resume' || args[i] === '--session') && args[i + 1]) {
|
|
170
|
+
config.resumeSessionId = args[++i];
|
|
171
|
+
}
|
|
172
|
+
else if (args[i] === '--gateway') {
|
|
173
|
+
// Portable thin-client mode: point inference + tools at the public gateway.
|
|
174
|
+
// Accepts an optional URL (default mcp.aitherium.com). Forces raw inference
|
|
175
|
+
// unless --inference-mode overrides it later in the loop.
|
|
176
|
+
const url = (args[i + 1] && !args[i + 1].startsWith('-'))
|
|
177
|
+
? args[++i].replace(/\/+$/, '')
|
|
178
|
+
: 'https://mcp.aitherium.com';
|
|
179
|
+
config.genesisUrl = url;
|
|
180
|
+
config.mcpUrl = config.mcpUrl || `${url}/mcp`;
|
|
181
|
+
config.llmUrl = config.llmUrl || `${url}/v1`;
|
|
182
|
+
if (config.inferenceMode === 'auto')
|
|
183
|
+
config.inferenceMode = 'raw';
|
|
184
|
+
client.setBaseUrl(url); // repoint the already-constructed client
|
|
185
|
+
}
|
|
186
|
+
else if (args[i] === '--inference-mode' && args[i + 1]) {
|
|
187
|
+
const m = args[++i].toLowerCase();
|
|
188
|
+
if (m === 'auto' || m === 'genesis' || m === 'raw')
|
|
189
|
+
config.inferenceMode = m;
|
|
190
|
+
}
|
|
106
191
|
}
|
|
107
192
|
// If invoked as "aither-install" (binary name contains "install"), run install flow directly
|
|
108
193
|
const binaryName = process.argv[1] ? basename(process.argv[1]).toLowerCase() : '';
|
|
@@ -114,10 +199,61 @@ async function main() {
|
|
|
114
199
|
process.exit(0);
|
|
115
200
|
}
|
|
116
201
|
}
|
|
202
|
+
// ── Explicit auth subcommands ──────────────────────────────────────────
|
|
203
|
+
// `aither login [--browser]`, `aither logout`, `aither whoami`. Without these,
|
|
204
|
+
// "login" fell through to positional one-shot chat → POSTed to the gateway's
|
|
205
|
+
// /chat/stream (which the OpenAI-compat gateway doesn't serve) → 404, surfaced
|
|
206
|
+
// as a confusing "Error: not_found". Device login always uses the IDENTITY URL.
|
|
207
|
+
if (args[0] === 'login') {
|
|
208
|
+
await ensureDeviceLogin(client, config);
|
|
209
|
+
process.exit(0);
|
|
210
|
+
}
|
|
211
|
+
if (args[0] === 'logout') {
|
|
212
|
+
const { clearProfile } = await import('./auth.js');
|
|
213
|
+
clearProfile('local');
|
|
214
|
+
console.log(chalk.green(' ✓ Signed out (cleared local profile).'));
|
|
215
|
+
process.exit(0);
|
|
216
|
+
}
|
|
217
|
+
if (args[0] === 'whoami') {
|
|
218
|
+
const u = config.authUser;
|
|
219
|
+
console.log(u
|
|
220
|
+
? ` ${chalk.bold(u.display_name || u.username)} <${u.email}>`
|
|
221
|
+
: chalk.yellow(' Not signed in. Run `aither login`.'));
|
|
222
|
+
process.exit(0);
|
|
223
|
+
}
|
|
224
|
+
// ── Explicit one-shot subcommands that must NOT fall through to chat ──
|
|
225
|
+
// e.g. `aither node ls`, `aither node enroll --tenant acme`. Without this,
|
|
226
|
+
// "node ls" became a positional chat prompt → intent-classified as a query.
|
|
227
|
+
const ONESHOT_CMDS = new Set(['node', 'nodes']);
|
|
228
|
+
if (args[0] && ONESHOT_CMDS.has(args[0].toLowerCase())) {
|
|
229
|
+
const { getCommand } = await import('./commands.js');
|
|
230
|
+
const cmd = getCommand(args[0].toLowerCase());
|
|
231
|
+
if (cmd) {
|
|
232
|
+
await cmd.handler(client, args.slice(1).join(' '), config);
|
|
233
|
+
// Return (don't process.exit) so libuv drains the fetch keep-alive socket
|
|
234
|
+
// cleanly — a bare process.exit() here races teardown → UV_HANDLE_CLOSING
|
|
235
|
+
// assert on Windows. undici idle sockets are unref'd, so the loop ends.
|
|
236
|
+
process.exitCode = 0;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
117
240
|
// Try non-interactive (flag-based) execution first
|
|
118
241
|
const handled = await runOneShot(client, config);
|
|
119
242
|
if (handled)
|
|
120
243
|
process.exit(0);
|
|
244
|
+
// Enable remote session sync, then resolve any --continue/--resume/--session.
|
|
245
|
+
configureRemoteSync(config.genesisUrl, config.authToken);
|
|
246
|
+
let resumeSummary = '';
|
|
247
|
+
if (config.resumeSessionId === '__continue__') {
|
|
248
|
+
const r = await resolveResume(client, config, 'continue');
|
|
249
|
+
resumeSummary = r?.summary || '';
|
|
250
|
+
if (!r)
|
|
251
|
+
console.error(chalk.yellow('No previous session to continue.'));
|
|
252
|
+
}
|
|
253
|
+
else if (config.resumeSessionId) {
|
|
254
|
+
const r = await resolveResume(client, config, 'id', config.resumeSessionId);
|
|
255
|
+
resumeSummary = r?.summary || '';
|
|
256
|
+
}
|
|
121
257
|
// Collect positional args (everything before first -- flag)
|
|
122
258
|
const positional = [];
|
|
123
259
|
for (const arg of args) {
|
|
@@ -125,15 +261,138 @@ async function main() {
|
|
|
125
261
|
break;
|
|
126
262
|
positional.push(arg);
|
|
127
263
|
}
|
|
128
|
-
|
|
129
|
-
|
|
264
|
+
const wantsHeadless = positional.length > 0 || config.printMode;
|
|
265
|
+
if (wantsHeadless) {
|
|
266
|
+
let prompt = positional.join(' ') || printPrompt;
|
|
267
|
+
if (config.printMode) {
|
|
268
|
+
const piped = await readStdin();
|
|
269
|
+
prompt = [prompt, piped].filter(Boolean).join('\n\n');
|
|
270
|
+
}
|
|
271
|
+
if (!prompt.trim()) {
|
|
272
|
+
console.error(chalk.red('No prompt provided (positional argument or piped stdin).'));
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
const ok = await oneShotChat(client, config, prompt, resumeSummary);
|
|
276
|
+
process.exit(ok ? 0 : 1);
|
|
130
277
|
}
|
|
131
278
|
else {
|
|
279
|
+
// Remote endpoints force a real sign-in before the shell opens.
|
|
280
|
+
if (config.requireAuth && !config.authToken) {
|
|
281
|
+
await ensureDeviceLogin(client, config);
|
|
282
|
+
}
|
|
132
283
|
await showBanner(client, config);
|
|
133
284
|
await startRepl(client, config);
|
|
134
285
|
}
|
|
135
286
|
}
|
|
136
287
|
/* ── Banner with live status ────────────────────────────────── */
|
|
288
|
+
// probeHealth / buildProbes / pickServingModel moved to ./status-banner.ts
|
|
289
|
+
// (shared with the in-TUI live status header).
|
|
290
|
+
/** Force a device-flow login when the endpoint requires auth and no valid
|
|
291
|
+
* token exists. Reuses the existing device-code helpers; prints the
|
|
292
|
+
* server-provided verification URL (portal.aitherium.com/link) + user code,
|
|
293
|
+
* then polls until the user approves in the browser. */
|
|
294
|
+
async function ensureDeviceLogin(client, config) {
|
|
295
|
+
const { requestDeviceCode, pollDeviceToken, buildProfile, setProfile } = await import('./auth.js');
|
|
296
|
+
console.log();
|
|
297
|
+
console.log(chalk.bold(' 🔐 This endpoint requires sign-in (no local root).'));
|
|
298
|
+
try {
|
|
299
|
+
const dc = await requestDeviceCode(config.identityUrl, 'AitherShell');
|
|
300
|
+
console.log();
|
|
301
|
+
console.log(' Open this URL in your browser and approve the device:');
|
|
302
|
+
console.log(' ' + chalk.cyan(dc.verification_uri_complete || dc.verification_uri));
|
|
303
|
+
if (dc.user_code) {
|
|
304
|
+
console.log();
|
|
305
|
+
console.log(' Code: ' + chalk.bold.yellow(dc.user_code));
|
|
306
|
+
}
|
|
307
|
+
console.log();
|
|
308
|
+
process.stdout.write(chalk.dim(' Waiting for authorization'));
|
|
309
|
+
const deadline = Date.now() + (dc.expires_in || 900) * 1000;
|
|
310
|
+
const interval = Math.max(2, dc.interval || 5) * 1000;
|
|
311
|
+
while (Date.now() < deadline) {
|
|
312
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
313
|
+
try {
|
|
314
|
+
const result = await pollDeviceToken(config.identityUrl, dc.device_code);
|
|
315
|
+
if ((result.status === 'complete' || result.status === 'authorized') && result.access_token) {
|
|
316
|
+
const profile = buildProfile(config.identityUrl, config.genesisUrl, result);
|
|
317
|
+
setProfile('local', profile);
|
|
318
|
+
config.authToken = profile.access_token;
|
|
319
|
+
config.authUser = profile.user;
|
|
320
|
+
client.setAuthToken(profile.access_token, profile.user.tenant_id || null, profile.user.id || null);
|
|
321
|
+
console.log();
|
|
322
|
+
console.log(chalk.green(` ✓ Signed in as ${chalk.bold(profile.user.display_name || profile.user.username)} (${profile.user.email})`));
|
|
323
|
+
// Register this endpoint/agent with the mesh under the signed-in user.
|
|
324
|
+
await registerRemoteEndpoint(config);
|
|
325
|
+
console.log();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
if (String(err?.message || '').includes('expired')) {
|
|
331
|
+
console.log();
|
|
332
|
+
console.log(chalk.red(' Device code expired — re-run `aither` to retry.'));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// authorization_pending — keep polling
|
|
336
|
+
}
|
|
337
|
+
process.stdout.write(chalk.dim('.'));
|
|
338
|
+
}
|
|
339
|
+
console.log();
|
|
340
|
+
console.log(chalk.yellow(' Sign-in timed out. Run `/login --browser` from the shell to retry.'));
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
console.log();
|
|
344
|
+
console.log(chalk.red(` Could not start sign-in: ${err.message}`));
|
|
345
|
+
console.log(chalk.dim(' Run `/login` from the shell to try another method.'));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/** Best-effort: register this workspace as a mesh node/agent under the
|
|
349
|
+
* signed-in user via the public gateway (POST /v1/mesh/join). Ties the
|
|
350
|
+
* endpoint/agent registration to the authenticated identity. Never throws. */
|
|
351
|
+
async function registerRemoteEndpoint(config) {
|
|
352
|
+
if (!config.authToken)
|
|
353
|
+
return;
|
|
354
|
+
const base = config.genesisUrl.replace(/\/+$/, '');
|
|
355
|
+
const who = config.authUser?.username || config.authUser?.email || 'agent';
|
|
356
|
+
const nodeName = `devws-${who}-${(process.env.HOSTNAME || '').slice(0, 12)}`.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 60);
|
|
357
|
+
try {
|
|
358
|
+
const r = await fetch(`${base}/v1/mesh/join`, {
|
|
359
|
+
method: 'POST',
|
|
360
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.authToken}` },
|
|
361
|
+
body: JSON.stringify({
|
|
362
|
+
node_name: nodeName,
|
|
363
|
+
capabilities: ['dev-workspace', 'aither-cli'],
|
|
364
|
+
endpoint: process.env.AITHER_MESH_IP || '',
|
|
365
|
+
}),
|
|
366
|
+
signal: AbortSignal.timeout(10000),
|
|
367
|
+
});
|
|
368
|
+
if (r.ok)
|
|
369
|
+
console.log(chalk.dim(' ✓ Endpoint registered with AitherMesh.'));
|
|
370
|
+
}
|
|
371
|
+
catch { /* best-effort — registration is also done server-side at spawn */ }
|
|
372
|
+
}
|
|
373
|
+
// _BACKEND_WHERE / pickServingModel moved to ./status-banner.ts.
|
|
374
|
+
/** Warm the orchestrator model on connect so the first real turn isn't cold.
|
|
375
|
+
* Visible (the user asked to SEE it) and best-effort — never blocks for long
|
|
376
|
+
* and never throws. Also fires a non-awaited Genesis context nudge so the
|
|
377
|
+
* code/knowledge-graph gather is warm by the first message too. */
|
|
378
|
+
async function warmOnConnect(client, config, model) {
|
|
379
|
+
if (process.env.AITHER_NO_WARMUP)
|
|
380
|
+
return;
|
|
381
|
+
const target = model || config.model || 'aither-orchestrator';
|
|
382
|
+
process.stdout.write(chalk.dim(` ⚡ Warming ${target}… `));
|
|
383
|
+
// Fire-and-forget context warm (don't block the prompt on the heavy pipeline).
|
|
384
|
+
void fetch(`${config.genesisUrl.replace(/\/+$/, '')}/status`, {
|
|
385
|
+
signal: AbortSignal.timeout(8000),
|
|
386
|
+
}).catch(() => { });
|
|
387
|
+
const res = await client.warmupModel(target).catch(() => null);
|
|
388
|
+
if (res) {
|
|
389
|
+
console.log(chalk.green(`✓ warm (${(res.ms / 1000).toFixed(1)}s)`));
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
console.log(chalk.yellow('skipped (cold start may add a few seconds to the first reply)'));
|
|
393
|
+
}
|
|
394
|
+
console.log();
|
|
395
|
+
}
|
|
137
396
|
async function showBanner(client, config) {
|
|
138
397
|
const backend = await client.detectBackend();
|
|
139
398
|
// Propagate detected backend into config for downstream use
|
|
@@ -142,28 +401,54 @@ async function showBanner(client, config) {
|
|
|
142
401
|
const genesisOnline = backend.type !== 'unknown';
|
|
143
402
|
let llm;
|
|
144
403
|
const serviceLines = [];
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
404
|
+
// ── Always probe key services independently ────────────────
|
|
405
|
+
// These run regardless of Genesis status so the banner never
|
|
406
|
+
// shows "no services detected" when services ARE actually up.
|
|
407
|
+
// The probe set adapts to the configured endpoints: a remote endpoint
|
|
408
|
+
// probes the PUBLIC authenticated edges it actually talks to, not 127.0.0.1.
|
|
409
|
+
const probes = buildProbes(config);
|
|
410
|
+
const probeResults = await Promise.all(probes.map(async (p) => ({ name: p.name, up: await probeHealth(p.url) })));
|
|
411
|
+
for (const r of probeResults) {
|
|
412
|
+
serviceLines.push(r);
|
|
413
|
+
}
|
|
414
|
+
// ── Loaded model + where (from the MicroScheduler backend snapshot) ──
|
|
415
|
+
// Shows the ACTUAL serving model and its location (vLLM/DGX/cloud) instead of
|
|
416
|
+
// a vague "LLM ready", so the banner answers "what model is loaded and where".
|
|
417
|
+
let warmModel;
|
|
418
|
+
const snapshot = await client.getBackendSnapshot().catch(() => null);
|
|
419
|
+
if (snapshot?.backends) {
|
|
420
|
+
const pick = pickServingModel(snapshot.backends);
|
|
421
|
+
if (pick) {
|
|
422
|
+
llm = `${pick.model} @ ${pick.where}`;
|
|
423
|
+
warmModel = pick.model;
|
|
157
424
|
}
|
|
158
|
-
|
|
159
|
-
|
|
425
|
+
}
|
|
426
|
+
// ── Genesis-enriched status (supplements direct probes) ────
|
|
427
|
+
if (!llm) {
|
|
428
|
+
if (backend.type === 'genesis') {
|
|
429
|
+
if (backend.generationReady === false) {
|
|
430
|
+
llm = 'BUSY (no slots)';
|
|
431
|
+
}
|
|
432
|
+
else if (backend.generationReady === true) {
|
|
433
|
+
llm = backend.slotsAvailable != null ? `LLM ready (${backend.slotsAvailable} slots)` : 'LLM ready';
|
|
434
|
+
}
|
|
435
|
+
else if (backend.llmBackend) {
|
|
436
|
+
llm = backend.llmBackend;
|
|
437
|
+
}
|
|
160
438
|
}
|
|
161
|
-
else if (backend.
|
|
162
|
-
llm = backend.llmBackend;
|
|
439
|
+
else if (backend.type === 'adk') {
|
|
440
|
+
llm = backend.llmBackend || undefined;
|
|
163
441
|
}
|
|
164
442
|
}
|
|
165
|
-
|
|
166
|
-
|
|
443
|
+
// If we still have no LLM info, try MicroScheduler /health directly
|
|
444
|
+
if (!llm) {
|
|
445
|
+
try {
|
|
446
|
+
const llmData = await client.getLLMStatus();
|
|
447
|
+
if (llmData?.model || llmData?.default_model) {
|
|
448
|
+
llm = llmData.model || llmData.default_model;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch { }
|
|
167
452
|
}
|
|
168
453
|
const host = (() => {
|
|
169
454
|
try {
|
|
@@ -173,11 +458,12 @@ async function showBanner(client, config) {
|
|
|
173
458
|
return config.genesisUrl;
|
|
174
459
|
}
|
|
175
460
|
})();
|
|
461
|
+
const upCount = serviceLines.filter(s => s.up).length;
|
|
176
462
|
const authUser = config.authUser?.display_name || config.authUser?.username;
|
|
177
463
|
renderBanner({
|
|
178
464
|
genesis: host,
|
|
179
465
|
genesisOnline,
|
|
180
|
-
services: backend.services,
|
|
466
|
+
services: backend.services ?? (upCount > 0 ? upCount : undefined),
|
|
181
467
|
agents: backend.agents,
|
|
182
468
|
llm,
|
|
183
469
|
user: authUser,
|
|
@@ -185,6 +471,10 @@ async function showBanner(client, config) {
|
|
|
185
471
|
backendType: backend.type,
|
|
186
472
|
backendName: backend.name,
|
|
187
473
|
});
|
|
474
|
+
// Warm the orchestrator so the first real turn isn't cold (the "first blip").
|
|
475
|
+
if (genesisOnline) {
|
|
476
|
+
await warmOnConnect(client, config, warmModel);
|
|
477
|
+
}
|
|
188
478
|
}
|
|
189
479
|
/* ── Non-interactive mode (flag-based) ────────────────────── */
|
|
190
480
|
async function runOneShot(client, config) {
|
|
@@ -281,28 +571,89 @@ async function runOneShot(client, config) {
|
|
|
281
571
|
return true;
|
|
282
572
|
}
|
|
283
573
|
/* ── One-shot chat (positional args) ─────────────────────── */
|
|
284
|
-
async function oneShotChat(client, config, message) {
|
|
285
|
-
const
|
|
286
|
-
|
|
574
|
+
async function oneShotChat(client, config, message, resumeSummary = '') {
|
|
575
|
+
const fmt = config.outputFormat || 'text';
|
|
576
|
+
const sessionContext = resumeSummary
|
|
577
|
+
? { summary: resumeSummary, tools_used: [], model: config.model || '', errors: [] }
|
|
578
|
+
: undefined;
|
|
579
|
+
const opts = {
|
|
580
|
+
agent: config.defaultAgent, sessionId: config.sessionId, model: config.model,
|
|
581
|
+
effort: config.effort, safetyLevel: config.safetyLevel, privateMode: config.privateMode,
|
|
582
|
+
attachments: config.imageAttachments, sessionContext,
|
|
583
|
+
};
|
|
584
|
+
// Collect answer/model/tools/tokens for recording + JSON output.
|
|
585
|
+
let answer = '';
|
|
586
|
+
let model = config.model || '';
|
|
587
|
+
let agent = config.defaultAgent;
|
|
588
|
+
const tools = [];
|
|
589
|
+
let tokens = 0;
|
|
590
|
+
const errors = [];
|
|
591
|
+
const renderer = (fmt === 'text') ? createStreamRenderer() : null;
|
|
287
592
|
try {
|
|
288
|
-
for await (const event of client.streamChat(message, {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
593
|
+
for await (const event of client.streamChat(message, opts)) {
|
|
594
|
+
if (renderer)
|
|
595
|
+
renderer.onEvent(event);
|
|
596
|
+
if (fmt === 'stream-json')
|
|
597
|
+
process.stdout.write(JSON.stringify(event) + '\n');
|
|
598
|
+
const d = event.data || {};
|
|
599
|
+
switch (event.type) {
|
|
600
|
+
case 'session_start':
|
|
601
|
+
if (d.agent)
|
|
602
|
+
agent = d.agent;
|
|
603
|
+
break;
|
|
604
|
+
case 'token':
|
|
605
|
+
answer += (d.t || d.token || '');
|
|
606
|
+
break;
|
|
607
|
+
case 'message':
|
|
608
|
+
if (!answer)
|
|
609
|
+
answer = String(d.content || d.message || '');
|
|
610
|
+
break;
|
|
611
|
+
// Terminal answer events are AUTHORITATIVE — overwrite the token
|
|
612
|
+
// accumulation (eager streaming emits TWO segments worth of tokens, so
|
|
613
|
+
// the concatenation would otherwise double the answer).
|
|
614
|
+
case 'answer':
|
|
615
|
+
case 'final_answer': {
|
|
616
|
+
const t = d.answer || d.content || d.message;
|
|
617
|
+
if (t)
|
|
618
|
+
answer = String(t);
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
case 'tool_call':
|
|
622
|
+
for (const t of (d.tools || d.tool_calls || []))
|
|
623
|
+
tools.push(t.name || t.function?.name || 'tool');
|
|
624
|
+
break;
|
|
625
|
+
case 'llm_done':
|
|
626
|
+
case 'llm_end':
|
|
627
|
+
if (d.model_used || d.model)
|
|
628
|
+
model = d.model_used || d.model;
|
|
629
|
+
tokens += Number(d.tokens_used || d.tokens || 0);
|
|
630
|
+
break;
|
|
631
|
+
case 'complete':
|
|
632
|
+
case 'done':
|
|
633
|
+
if (d.model)
|
|
634
|
+
model = d.model;
|
|
635
|
+
if (d.content)
|
|
636
|
+
answer = String(d.content);
|
|
637
|
+
break;
|
|
638
|
+
case 'error':
|
|
639
|
+
case 'llm_error':
|
|
640
|
+
errors.push(String(d.message || d.error || 'error'));
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (renderer)
|
|
645
|
+
renderer.finish();
|
|
646
|
+
if (renderer && answer === '')
|
|
647
|
+
answer = renderer.getContent();
|
|
300
648
|
}
|
|
301
649
|
catch (err) {
|
|
302
650
|
if (err.name === 'AbortError')
|
|
303
|
-
return;
|
|
304
|
-
failed = true;
|
|
651
|
+
return false;
|
|
305
652
|
const msg = err.message || 'unknown error';
|
|
653
|
+
if (fmt === 'json') {
|
|
654
|
+
process.stdout.write(JSON.stringify({ ok: false, error: msg, session_id: config.sessionId }) + '\n');
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
306
657
|
if (msg.includes('took too long')) {
|
|
307
658
|
console.error(chalk.yellow(msg));
|
|
308
659
|
console.error(chalk.dim('Try: docker compose -f docker-compose.aitheros.yml --profile chat-full up -d'));
|
|
@@ -315,8 +666,21 @@ async function oneShotChat(client, config, message) {
|
|
|
315
666
|
else {
|
|
316
667
|
console.error(chalk.red(`Error: ${msg}`));
|
|
317
668
|
}
|
|
318
|
-
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
// Persist the turn (local transcript + remote sync) so --continue/--resume work.
|
|
672
|
+
try {
|
|
673
|
+
recordTurn(config.sessionId, agent, message, answer, { model, tools, tokens });
|
|
674
|
+
}
|
|
675
|
+
catch { /* */ }
|
|
676
|
+
if (fmt === 'json') {
|
|
677
|
+
process.stdout.write(JSON.stringify({
|
|
678
|
+
ok: errors.length === 0, answer, model, agent,
|
|
679
|
+
session_id: config.sessionId, tools, tokens,
|
|
680
|
+
errors: errors.length ? errors : undefined,
|
|
681
|
+
}) + '\n');
|
|
319
682
|
}
|
|
683
|
+
return errors.length === 0;
|
|
320
684
|
}
|
|
321
685
|
/* ── Help ───────────────────────────────────────────────────── */
|
|
322
686
|
function printUsage() {
|
|
@@ -341,6 +705,40 @@ ${chalk.bold('One-shot flags:')}
|
|
|
341
705
|
--private Private mode (prompt hidden from logs/training)
|
|
342
706
|
-i, --image <path> Attach image for vision analysis (repeatable)
|
|
343
707
|
|
|
708
|
+
${chalk.bold('Portable gateway mode (run anywhere with internet + an aither_sk_live_* key):')}
|
|
709
|
+
--gateway [url] Point inference + MCP tools at the public gateway
|
|
710
|
+
(default https://mcp.aitherium.com); forces raw inference
|
|
711
|
+
--inference-mode <mode> auto (default) | genesis (orchestrated pipeline) |
|
|
712
|
+
raw (bypass Genesis → model direct via gateway/MicroScheduler)
|
|
713
|
+
Env: AITHER_GATEWAY_URL, AITHER_INFERENCE_MODE, AITHER_MCP_URL,
|
|
714
|
+
AITHER_LLM_URL. Auth via your aither_sk_live_* key (aither login).
|
|
715
|
+
|
|
716
|
+
${chalk.bold('Headless / scripting:')}
|
|
717
|
+
-p, --print [prompt] Headless: read prompt from arg and/or piped stdin, print, exit
|
|
718
|
+
--output-format <fmt> text (default) | json | stream-json
|
|
719
|
+
--json Shorthand for -p --output-format json
|
|
720
|
+
Exit code 0 on success, 1 on error. Examples:
|
|
721
|
+
aither -p "summarize" < notes.md
|
|
722
|
+
cat error.log | aither -p "what failed?" --output-format json
|
|
723
|
+
git diff | aither -p "review this diff" --json
|
|
724
|
+
|
|
725
|
+
${chalk.bold('Sessions (persistent transcripts in ~/.aither/sessions):')}
|
|
726
|
+
--continue, -C Resume the most recent conversation
|
|
727
|
+
--resume <id> Resume a specific session id (replays context)
|
|
728
|
+
--session <id> Alias for --resume
|
|
729
|
+
In-shell: /chats [resume|delete <id>] · /export [path] [--json] · /copy · /tokens · /rewind [N] · /compact
|
|
730
|
+
|
|
731
|
+
${chalk.bold('Auth:')}
|
|
732
|
+
--login Device-flow sign-in (portal.aitherium.com/link)
|
|
733
|
+
--key <token> Authenticate with an API key
|
|
734
|
+
In-shell: /login · /whoami · /logout (remote endpoints require sign-in)
|
|
735
|
+
|
|
736
|
+
${chalk.bold('TUI:')}
|
|
737
|
+
Interactive mode defaults to the blessed 3-pane TUI: seamless answer pane (left) +
|
|
738
|
+
collapsible per-turn trace threads (right; Ctrl+E expand/collapse, click a header to toggle).
|
|
739
|
+
AITHER_TUI=0 falls back to the native readline shell (line editing, history, paste).
|
|
740
|
+
AITHER_STEER=1 enables the fixed bottom steering bar (limits terminal scrollback).
|
|
741
|
+
|
|
344
742
|
${chalk.bold('Quick actions:')}
|
|
345
743
|
aither -c gaming Toggle gaming mode (free VRAM for games)
|
|
346
744
|
aither -c "gaming on" Activate gaming mode
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal MCP StreamableHTTP client.
|
|
3
|
+
*
|
|
4
|
+
* Talks JSON-RPC to a remote MCP gateway (e.g. https://mcp.aitherium.com/mcp)
|
|
5
|
+
* using the MCP "Streamable HTTP" transport: each request is a POST whose
|
|
6
|
+
* response is either a single `application/json` body or a `text/event-stream`
|
|
7
|
+
* carrying the JSON-RPC reply. No external deps — mirrors client.ts's thin SSE
|
|
8
|
+
* style so the bundle still compiles via `bun build --compile`.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: the shell's chat backend (genesisUrl) speaks a REST
|
|
11
|
+
* `/tools/call`, but the cloud gateway (config.mcpUrl) speaks the MCP protocol
|
|
12
|
+
* at `/mcp`. When mcpUrl is set we route tool discovery + invocation here so
|
|
13
|
+
* AitherNode's tools work in-REPL against the cloud workspace.
|
|
14
|
+
*/
|
|
15
|
+
import type { ShellConfig } from './config.js';
|
|
16
|
+
export interface McpToolDef {
|
|
17
|
+
name: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
inputSchema?: Record<string, any>;
|
|
20
|
+
}
|
|
21
|
+
export interface McpCallResult {
|
|
22
|
+
content?: Array<{
|
|
23
|
+
type: string;
|
|
24
|
+
text?: string;
|
|
25
|
+
[k: string]: any;
|
|
26
|
+
}>;
|
|
27
|
+
isError?: boolean;
|
|
28
|
+
[k: string]: any;
|
|
29
|
+
}
|
|
30
|
+
export declare class McpHttpClient {
|
|
31
|
+
readonly url: string;
|
|
32
|
+
private token;
|
|
33
|
+
private sessionId;
|
|
34
|
+
private nextId;
|
|
35
|
+
private initialized;
|
|
36
|
+
private initInFlight;
|
|
37
|
+
constructor(url: string, token?: string | null);
|
|
38
|
+
/** Send a JSON-RPC request (or notification) and return its `result`. */
|
|
39
|
+
private rpc;
|
|
40
|
+
/** Read an SSE response body and return the JSON-RPC message matching `id`. */
|
|
41
|
+
private readSseForId;
|
|
42
|
+
/** Initialize the MCP session (idempotent, single-flight). */
|
|
43
|
+
connect(): Promise<void>;
|
|
44
|
+
/** List the tools the authenticated caller is entitled to. */
|
|
45
|
+
listTools(): Promise<McpToolDef[]>;
|
|
46
|
+
/** Invoke a tool by name. Returns the MCP `{content, isError}` result. */
|
|
47
|
+
callTool(name: string, args?: Record<string, any>): Promise<McpCallResult>;
|
|
48
|
+
/** Cheap reachability + auth check. */
|
|
49
|
+
ping(): Promise<boolean>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Return a remote MCP client for the active config, or null when no MCP
|
|
53
|
+
* gateway URL is configured (local/Genesis mode uses the chat backend's
|
|
54
|
+
* REST /tools/call instead).
|
|
55
|
+
*/
|
|
56
|
+
export declare function getRemoteMcpClient(config: ShellConfig | null | undefined): McpHttpClient | null;
|
|
57
|
+
/** Drop the cached client (e.g. after logout/endpoint change). */
|
|
58
|
+
export declare function resetRemoteMcpClient(): void;
|