@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/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
- const VERSION = '1.1.0';
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
- if (positional.length > 0) {
129
- await oneShotChat(client, config, positional.join(' '));
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
- if (backend.type === 'genesis') {
146
- // Genesis-specific status enrichment
147
- try {
148
- const statusData = await client.getStatus().catch(() => null);
149
- const pain = statusData?.children_in_pain || [];
150
- if (statusData) {
151
- serviceLines.push({ name: 'Node', up: !pain.includes('Node') && !pain.includes('AitherNode') });
152
- }
153
- }
154
- catch { }
155
- if (backend.generationReady === false) {
156
- llm = 'BUSY (no slots)';
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
- else if (backend.generationReady === true) {
159
- llm = backend.slotsAvailable != null ? `LLM ready (${backend.slotsAvailable} slots)` : 'LLM ready';
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.llmBackend) {
162
- llm = backend.llmBackend;
439
+ else if (backend.type === 'adk') {
440
+ llm = backend.llmBackend || undefined;
163
441
  }
164
442
  }
165
- else if (backend.type === 'adk') {
166
- llm = backend.llmBackend || undefined;
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 renderer = createStreamRenderer();
286
- let failed = false;
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
- agent: config.defaultAgent,
290
- sessionId: config.sessionId,
291
- model: config.model,
292
- effort: config.effort,
293
- safetyLevel: config.safetyLevel,
294
- privateMode: config.privateMode,
295
- attachments: config.imageAttachments,
296
- })) {
297
- renderer.onEvent(event);
298
- }
299
- renderer.finish();
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
- process.exit(1);
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;