@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 CHANGED
@@ -35,11 +35,20 @@ export declare function getActiveUser(): AuthUser | null;
35
35
  export declare function setProfile(name: string, profile: AuthProfile): void;
36
36
  export declare function clearProfile(name: string): void;
37
37
  /**
38
- * Ensure a root profile exists for local sessions.
39
- * If no auth.json exists or token is expired, provision root.
40
- * Like Linux auto-login as root on the console.
38
+ * Local-root auto-provisioning is allowed UNLESS AITHER_REQUIRE_AUTH is set.
39
+ * Parity with the Python ADK (aither-adk/adk/shell/auth.py:_is_root_provisioning_allowed).
40
+ * Remote endpoints (e.g. the dev-workspace) set AITHER_REQUIRE_AUTH=1 to force a
41
+ * real device-flow login as the user instead of silently becoming root.
41
42
  */
42
- export declare function ensureRootProfile(): AuthProfile;
43
+ export declare function isRootProvisioningAllowed(): boolean;
44
+ /**
45
+ * Ensure a profile exists for local sessions.
46
+ * If a valid token already exists, return its profile.
47
+ * Otherwise auto-provision the built-in root profile (like Linux console
48
+ * auto-login) — UNLESS AITHER_REQUIRE_AUTH is set, in which case return null
49
+ * so the caller forces device-flow login.
50
+ */
51
+ export declare function ensureRootProfile(): AuthProfile | null;
43
52
  export declare function loginWithPassword(endpoint: string, username: string, password: string): Promise<any>;
44
53
  export declare function verify2FA(endpoint: string, tempToken: string, code: string): Promise<any>;
45
54
  export declare function register(endpoint: string, username: string, password: string, email: string, inviteCode?: string): Promise<any>;
package/dist/auth.js CHANGED
@@ -99,15 +99,30 @@ const ROOT_PROFILE = {
99
99
  },
100
100
  };
101
101
  /**
102
- * Ensure a root profile exists for local sessions.
103
- * If no auth.json exists or token is expired, provision root.
104
- * Like Linux auto-login as root on the console.
102
+ * Local-root auto-provisioning is allowed UNLESS AITHER_REQUIRE_AUTH is set.
103
+ * Parity with the Python ADK (aither-adk/adk/shell/auth.py:_is_root_provisioning_allowed).
104
+ * Remote endpoints (e.g. the dev-workspace) set AITHER_REQUIRE_AUTH=1 to force a
105
+ * real device-flow login as the user instead of silently becoming root.
106
+ */
107
+ export function isRootProvisioningAllowed() {
108
+ const v = (process.env.AITHER_REQUIRE_AUTH || '').toLowerCase();
109
+ return !['1', 'true', 'yes', 'on'].includes(v);
110
+ }
111
+ /**
112
+ * Ensure a profile exists for local sessions.
113
+ * If a valid token already exists, return its profile.
114
+ * Otherwise auto-provision the built-in root profile (like Linux console
115
+ * auto-login) — UNLESS AITHER_REQUIRE_AUTH is set, in which case return null
116
+ * so the caller forces device-flow login.
105
117
  */
106
118
  export function ensureRootProfile() {
107
119
  const token = getActiveToken();
108
120
  if (token) {
109
121
  return getActiveProfile();
110
122
  }
123
+ if (!isRootProvisioningAllowed()) {
124
+ return null; // require-auth — no silent root; caller triggers `aither login`
125
+ }
111
126
  // No valid session — provision root
112
127
  setProfile('local', ROOT_PROFILE);
113
128
  return ROOT_PROFILE;
package/dist/client.d.ts CHANGED
@@ -62,12 +62,15 @@ export interface BackendInfo {
62
62
  agents?: number;
63
63
  }
64
64
  export declare class GenesisClient {
65
- readonly baseUrl: string;
65
+ baseUrl: string;
66
66
  private _authToken;
67
67
  private _tenantId;
68
68
  private _userId;
69
69
  private _backend;
70
70
  constructor(baseUrl: string);
71
+ /** Repoint the client (e.g. when --gateway changes the endpoint after
72
+ * construction). Clears the cached backend so the next call re-detects. */
73
+ setBaseUrl(url: string): void;
71
74
  /** Probe the backend and detect its type (Genesis vs ADK vs unknown). */
72
75
  detectBackend(): Promise<BackendInfo>;
73
76
  /** Get cached backend info (call detectBackend first). */
@@ -94,6 +97,19 @@ export declare class GenesisClient {
94
97
  }): Promise<any>;
95
98
  getLogs(limit?: number, level?: string, service?: string): Promise<any>;
96
99
  getLLMStatus(): Promise<any>;
100
+ /** Resolve the MicroScheduler base URL the same way getLLMStatus does. */
101
+ private _msCandidates;
102
+ /** O(1) cached snapshot of which LLM backends are up and what models they
103
+ * serve (no live inference) — used for the banner's "model @ where" line. */
104
+ getBackendSnapshot(): Promise<any>;
105
+ /** Warm the orchestrator model with a tiny non-thinking generation so the
106
+ * first REAL turn isn't cold (the cold model intermittently streams zero
107
+ * tokens → the eager first pass falls through to the slow grounded path).
108
+ * Returns the model id + round-trip ms, or null on failure (never throws). */
109
+ warmupModel(model: string): Promise<{
110
+ model: string;
111
+ ms: number;
112
+ } | null>;
97
113
  getGpuStatus(): Promise<{
98
114
  zone: string;
99
115
  active: boolean;
@@ -113,4 +129,8 @@ export declare class GenesisClient {
113
129
  */
114
130
  private readSSE;
115
131
  private parseBlock;
132
+ /** Stream chat from an OpenAI-compatible backend, translated to native SSEEvents. */
133
+ private _streamOpenAI;
134
+ /** Parse an OpenAI delta SSE stream into native token/complete events. */
135
+ private _readOpenAISSE;
116
136
  }
package/dist/client.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * This client is a thin SSE consumer — no trivial intercept,
12
12
  * no fallback chains, no Veil dependency.
13
13
  */
14
+ import { getActiveConfig } from './config.js';
14
15
  export class GenesisClient {
15
16
  baseUrl;
16
17
  _authToken = null;
@@ -20,6 +21,12 @@ export class GenesisClient {
20
21
  constructor(baseUrl) {
21
22
  this.baseUrl = baseUrl.replace(/\/+$/, '');
22
23
  }
24
+ /** Repoint the client (e.g. when --gateway changes the endpoint after
25
+ * construction). Clears the cached backend so the next call re-detects. */
26
+ setBaseUrl(url) {
27
+ this.baseUrl = url.replace(/\/+$/, '');
28
+ this._backend = null;
29
+ }
23
30
  /** Probe the backend and detect its type (Genesis vs ADK vs unknown). */
24
31
  async detectBackend() {
25
32
  if (this._backend)
@@ -35,9 +42,13 @@ export class GenesisClient {
35
42
  return info;
36
43
  }
37
44
  const data = await r.json();
38
- // Genesis health returns generation_ready, vllm_slots_available, tracked_services
39
- // ADK health returns agent, llm_backend, version
40
- if (data.generation_ready !== undefined || data.tracked_services !== undefined) {
45
+ // Genesis health may return generation_ready/tracked_services (rich mode)
46
+ // OR just { status, service: "AitherGenesis", uptime_sec } (minimal mode).
47
+ // ADK health returns agent, llm_backend, version.
48
+ const isGenesis = data.generation_ready !== undefined
49
+ || data.tracked_services !== undefined
50
+ || data.service === 'AitherGenesis';
51
+ if (isGenesis) {
41
52
  info.type = 'genesis';
42
53
  info.name = 'Genesis';
43
54
  info.generationReady = data.generation_ready;
@@ -65,6 +76,15 @@ export class GenesisClient {
65
76
  }
66
77
  catch { }
67
78
  }
79
+ else if (typeof data.service === 'string' && /gateway/i.test(data.service)) {
80
+ // AitherOS edge gateway (gateway.aitherium.com) — OpenAI-compatible
81
+ // inference at /v1/chat/completions. Reuse the 'adk' code path (which
82
+ // posts there) and default the model to the orchestrator.
83
+ info.type = 'adk';
84
+ info.name = 'AitherGateway';
85
+ info.agent = 'aither-orchestrator';
86
+ info.llmBackend = 'aither-orchestrator';
87
+ }
68
88
  else if (data.agent !== undefined || data.llm_backend !== undefined) {
69
89
  info.type = 'adk';
70
90
  info.name = data.agent || 'agent';
@@ -121,6 +141,25 @@ export class GenesisClient {
121
141
  }
122
142
  /* ── Streaming chat — POST /chat/stream (SSE) ───────────── */
123
143
  async *streamChat(message, opts = {}) {
144
+ // Genesis serves native SSE at /chat/stream; AitherNode/ADK/gateway serve the
145
+ // OpenAI-compatible /v1/chat/completions. Branch so `aither` works against
146
+ // both. detectBackend() is cached (showBanner already warmed it).
147
+ //
148
+ // Inference-mode toggle: 'raw' forces the OpenAI path (bypass Genesis → hit
149
+ // the model directly via the gateway/MicroScheduler) even if Genesis is
150
+ // reachable; 'genesis' forces the orchestrated pipeline; 'auto' picks by
151
+ // detected backend. The raw path is what makes the shell portable — it runs
152
+ // anywhere with internet + an aither_sk_live_* key to the gateway.
153
+ const mode = getActiveConfig()?.inferenceMode || 'auto';
154
+ if (mode === 'raw') {
155
+ yield* this._streamOpenAI(message, opts);
156
+ return;
157
+ }
158
+ const backend = await this.detectBackend();
159
+ if (mode === 'auto' && backend.type === 'adk') {
160
+ yield* this._streamOpenAI(message, opts);
161
+ return;
162
+ }
124
163
  const body = {
125
164
  message,
126
165
  persona: opts.agent || 'aither',
@@ -241,6 +280,10 @@ export class GenesisClient {
241
280
  return this.get('/agents');
242
281
  }
243
282
  async forgeDispatch(task, opts = {}) {
283
+ // Forge is a Genesis-only subsystem (ADK/Node have no /forge endpoints).
284
+ if (this._backend?.type === 'adk') {
285
+ return { error: 'Forge requires the Genesis backend — this endpoint is ADK/Node (chat only).' };
286
+ }
244
287
  const res = await fetch(`${this.baseUrl}/forge/dispatch/sync`, {
245
288
  method: 'POST',
246
289
  headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
@@ -279,6 +322,55 @@ export class GenesisClient {
279
322
  return null;
280
323
  }
281
324
  }
325
+ /** Resolve the MicroScheduler base URL the same way getLLMStatus does. */
326
+ _msCandidates() {
327
+ return [process.env.AITHER_LLM_URL, 'https://localhost:8150', 'http://localhost:8150']
328
+ .filter(Boolean);
329
+ }
330
+ /** O(1) cached snapshot of which LLM backends are up and what models they
331
+ * serve (no live inference) — used for the banner's "model @ where" line. */
332
+ async getBackendSnapshot() {
333
+ for (const base of this._msCandidates()) {
334
+ try {
335
+ const r = await fetch(`${base}/llm/backends/snapshot`, { signal: AbortSignal.timeout(3000) });
336
+ if (r.ok)
337
+ return r.json();
338
+ }
339
+ catch { }
340
+ }
341
+ return null;
342
+ }
343
+ /** Warm the orchestrator model with a tiny non-thinking generation so the
344
+ * first REAL turn isn't cold (the cold model intermittently streams zero
345
+ * tokens → the eager first pass falls through to the slow grounded path).
346
+ * Returns the model id + round-trip ms, or null on failure (never throws). */
347
+ async warmupModel(model) {
348
+ const payload = {
349
+ model,
350
+ messages: [{ role: 'user', content: 'ok' }],
351
+ max_tokens: 1,
352
+ temperature: 0,
353
+ stream: false,
354
+ metadata: { enable_thinking: false, effort: 1, source: 'shell_warmup', priority: 'background' },
355
+ };
356
+ for (const base of this._msCandidates()) {
357
+ const t0 = Date.now();
358
+ try {
359
+ const r = await fetch(`${base}/v1/chat/completions`, {
360
+ method: 'POST',
361
+ headers: { 'Content-Type': 'application/json' },
362
+ body: JSON.stringify(payload),
363
+ signal: AbortSignal.timeout(30000),
364
+ });
365
+ if (r.ok) {
366
+ await r.text().catch(() => ''); // drain
367
+ return { model, ms: Date.now() - t0 };
368
+ }
369
+ }
370
+ catch { }
371
+ }
372
+ return null;
373
+ }
282
374
  async getGpuStatus() {
283
375
  try {
284
376
  const candidates = [process.env.AITHER_LLM_URL, 'https://localhost:8150', 'http://localhost:8150'].filter(Boolean);
@@ -327,7 +419,11 @@ export class GenesisClient {
327
419
  async getDetailed(path) {
328
420
  try {
329
421
  const r = await fetch(`${this.baseUrl}${path}`, {
330
- headers: { ...this.authHeaders() },
422
+ // X-Caller-Type: PLATFORM — consistent with post/put/patch/delete below.
423
+ // Without it, GETs resolved as anonymous, so /agents was filtered to []
424
+ // by AgentCatalog (public/anonymous → no agents) and the banner showed
425
+ // "0 agents" even though 53 identities are deployable.
426
+ headers: { 'X-Caller-Type': 'PLATFORM', ...this.authHeaders() },
331
427
  signal: AbortSignal.timeout(5000),
332
428
  });
333
429
  if (!r.ok)
@@ -450,7 +546,15 @@ export class GenesisClient {
450
546
  // Race reader.read() against a timeout so the client never hangs
451
547
  // if the backend stalls (e.g. reasoning model cold-start, 404 retry loop).
452
548
  const readPromise = reader.read();
453
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('SSE chunk timeout')), CHUNK_TIMEOUT_MS));
549
+ // IMPORTANT: keep a handle to the timeout timer and clear it once the
550
+ // chunk arrives. Otherwise every chunk (hundreds per streamed turn) leaks
551
+ // a live 120s timer; the swarm of late, post-settle rejections keeps the
552
+ // event loop hot and is a class of stray-rejection the crash reporter
553
+ // used to turn into process.exit(1).
554
+ let chunkTimer = null;
555
+ const timeoutPromise = new Promise((_, reject) => {
556
+ chunkTimer = setTimeout(() => reject(new Error('SSE chunk timeout')), CHUNK_TIMEOUT_MS);
557
+ });
454
558
  // Intermediate warning if no data for 60s
455
559
  let warnTimer = null;
456
560
  if (!warningSent) {
@@ -461,16 +565,40 @@ export class GenesisClient {
461
565
  let result;
462
566
  try {
463
567
  result = await Promise.race([readPromise, timeoutPromise]);
568
+ if (chunkTimer)
569
+ clearTimeout(chunkTimer);
464
570
  if (warnTimer)
465
571
  clearTimeout(warnTimer);
466
572
  }
467
- catch {
573
+ catch (err) {
574
+ if (chunkTimer)
575
+ clearTimeout(chunkTimer);
468
576
  if (warnTimer)
469
577
  clearTimeout(warnTimer);
470
- // Timeout emit a typed timeout event (not a hard error) so the
471
- // renderer and REPL can check if the agent is still processing.
578
+ // A user abort (Ctrl+C) must surface as AbortError to the caller
579
+ // swallowing it here made Ctrl+C look like a silent timeout.
580
+ if (err?.name === 'AbortError')
581
+ throw err;
472
582
  const silentMs = Date.now() - lastChunkAt;
473
- yield { type: 'stream_timeout', data: { type: 'stream_timeout', timeout_ms: CHUNK_TIMEOUT_MS, silent_ms: silentMs } };
583
+ if (err?.message === 'SSE chunk timeout') {
584
+ // Timeout — emit a typed timeout event (not a hard error) so the
585
+ // renderer and REPL can check if the agent is still processing.
586
+ yield { type: 'stream_timeout', data: { type: 'stream_timeout', timeout_ms: CHUNK_TIMEOUT_MS, silent_ms: silentMs } };
587
+ }
588
+ else {
589
+ // Mid-stream transport failure (connection reset — e.g. Genesis or
590
+ // the LB restarted under the turn). This used to be mislabeled as
591
+ // stream_timeout and rendered as NOTHING: the turn looked done and
592
+ // the grounded answer was silently thrown away. Surface it.
593
+ yield {
594
+ type: 'stream_interrupted',
595
+ data: {
596
+ type: 'stream_interrupted',
597
+ error: String(err?.cause?.message || err?.message || err || 'connection lost'),
598
+ silent_ms: silentMs,
599
+ },
600
+ };
601
+ }
474
602
  break;
475
603
  }
476
604
  lastChunkAt = Date.now();
@@ -525,4 +653,80 @@ export class GenesisClient {
525
653
  data: eventData,
526
654
  };
527
655
  }
656
+ /* ── ADK / AitherNode chat — OpenAI /v1/chat/completions → native events ── */
657
+ /** Stream chat from an OpenAI-compatible backend, translated to native SSEEvents. */
658
+ async *_streamOpenAI(message, opts) {
659
+ const messages = [];
660
+ if (opts.sessionContext?.summary) {
661
+ messages.push({ role: 'system', content: `Conversation so far:\n${opts.sessionContext.summary}` });
662
+ }
663
+ messages.push({ role: 'user', content: message });
664
+ const model = opts.model || this._backend?.agent || this._backend?.name || 'aither-orchestrator';
665
+ const body = { model, messages, stream: true };
666
+ // Prefer the configured raw-inference endpoint (gateway /v1 or MicroScheduler)
667
+ // so 'raw' mode reaches the model even when baseUrl points at Genesis.
668
+ const llmUrl = (getActiveConfig()?.llmUrl || '').replace(/\/+$/, '');
669
+ const completionsUrl = llmUrl
670
+ ? `${llmUrl}/chat/completions`
671
+ : `${this.baseUrl}/v1/chat/completions`;
672
+ let response;
673
+ try {
674
+ response = await fetch(completionsUrl, {
675
+ method: 'POST',
676
+ headers: { 'Content-Type': 'application/json', 'X-Caller-Type': 'PLATFORM', ...this.authHeaders() },
677
+ body: JSON.stringify(body),
678
+ signal: opts.signal,
679
+ });
680
+ }
681
+ catch (err) {
682
+ throw new Error(`Cannot connect to inference endpoint (${completionsUrl}): ${err.message}`);
683
+ }
684
+ if (!response.ok) {
685
+ const text = await response.text().catch(() => '');
686
+ throw new Error(`Inference failed: HTTP ${response.status} ${text.slice(0, 200)}`);
687
+ }
688
+ yield { type: 'session_start', data: { type: 'session_start', agent: this._backend?.agent || opts.agent || 'agent', model } };
689
+ yield* this._readOpenAISSE(response, model);
690
+ }
691
+ /** Parse an OpenAI delta SSE stream into native token/complete events. */
692
+ async *_readOpenAISSE(response, model) {
693
+ const stream = response.body;
694
+ if (!stream)
695
+ throw new Error('No response body');
696
+ const reader = stream.getReader();
697
+ const decoder = new TextDecoder();
698
+ let buffer = '';
699
+ const started = Date.now();
700
+ try {
701
+ while (true) {
702
+ const { done, value } = await reader.read();
703
+ if (done)
704
+ break;
705
+ buffer += decoder.decode(value, { stream: true });
706
+ buffer = buffer.replace(/\r\n/g, '\n');
707
+ const lines = buffer.split('\n');
708
+ buffer = lines.pop() || '';
709
+ for (const line of lines) {
710
+ const l = line.trim();
711
+ if (!l.startsWith('data:'))
712
+ continue;
713
+ const payload = l.slice(5).trim();
714
+ if (payload === '[DONE]')
715
+ continue;
716
+ try {
717
+ const json = JSON.parse(payload);
718
+ const choice = json.choices?.[0];
719
+ const delta = choice?.delta?.content ?? choice?.message?.content;
720
+ if (delta)
721
+ yield { type: 'token', data: { type: 'token', t: delta } };
722
+ }
723
+ catch { /* skip keep-alives / non-JSON */ }
724
+ }
725
+ }
726
+ }
727
+ finally {
728
+ reader.releaseLock();
729
+ }
730
+ yield { type: 'complete', data: { type: 'complete', model, duration_ms: Date.now() - started, eager: false } };
731
+ }
528
732
  }
@@ -46,7 +46,7 @@ export declare class CommandRegistry {
46
46
  * Merges discovered commands into the registry without overwriting
47
47
  * existing handlers. Call once at REPL startup.
48
48
  */
49
- loadDynamicCommands(client: GenesisClient): Promise<number>;
49
+ loadDynamicCommands(client: GenesisClient, config?: ShellConfig): Promise<number>;
50
50
  /** Get all discovered MCP tools. */
51
51
  getMcpTools(): DynamicCommandEntry[];
52
52
  /** Check if an MCP tool exists by name. */
@@ -14,6 +14,7 @@
14
14
  import { readFileSync } from 'node:fs';
15
15
  import { resolve, dirname } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
+ import { getRemoteMcpClient } from './mcp-client.js';
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
19
  // ── Tokenizer ────────────────────────────────────────────────────────
19
20
  const STOP_WORDS = new Set([
@@ -64,7 +65,7 @@ export class CommandRegistry {
64
65
  * Merges discovered commands into the registry without overwriting
65
66
  * existing handlers. Call once at REPL startup.
66
67
  */
67
- async loadDynamicCommands(client) {
68
+ async loadDynamicCommands(client, config) {
68
69
  let added = 0;
69
70
  try {
70
71
  const result = await client.get('/shell/commands');
@@ -109,7 +110,8 @@ export class CommandRegistry {
109
110
  catch {
110
111
  // Genesis unreachable — use static commands only
111
112
  }
112
- // Also fetch MCP tools (async, non-blocking)
113
+ // Also fetch MCP tools from the chat backend (Genesis exposes these via
114
+ // /shell/commands/mcp). Non-fatal — the gateway has no such endpoint.
113
115
  try {
114
116
  const mcpResult = await client.get('/shell/commands/mcp');
115
117
  const mcpCommands = mcpResult?.commands || [];
@@ -122,6 +124,30 @@ export class CommandRegistry {
122
124
  catch {
123
125
  // MCP discovery failed — non-fatal
124
126
  }
127
+ // Remote MCP gateway (config.mcpUrl, e.g. mcp.aitherium.com/mcp): list the
128
+ // tenant-scoped tool catalog over the MCP protocol. This is how AitherNode
129
+ // tools become available in-REPL when pointed at a cloud workspace.
130
+ const remote = getRemoteMcpClient(config);
131
+ if (remote) {
132
+ try {
133
+ const tools = await remote.listTools();
134
+ for (const tool of tools) {
135
+ if (!tool.name)
136
+ continue;
137
+ this.mcpTools.set(tool.name, {
138
+ name: tool.name,
139
+ category: 'mcp',
140
+ description: tool.description || '',
141
+ aliases: [],
142
+ source: 'remote-mcp',
143
+ params: tool.inputSchema ? [tool.inputSchema] : [],
144
+ });
145
+ }
146
+ }
147
+ catch {
148
+ // Remote MCP unreachable / unauthenticated — non-fatal.
149
+ }
150
+ }
125
151
  return added;
126
152
  }
127
153
  /** Get all discovered MCP tools. */
@@ -9,6 +9,14 @@ interface Command {
9
9
  usage?: string;
10
10
  handler: CommandHandler;
11
11
  }
12
+ /**
13
+ * Invoke an MCP tool, routing to the right transport:
14
+ * - remote MCP gateway (config.mcpUrl, MCP protocol) when configured —
15
+ * so AitherNode tools work against a cloud workspace; else
16
+ * - the chat backend's REST /tools/call (local Genesis).
17
+ * Returns the parsed tool output, or `{ error, status }` on failure.
18
+ */
19
+ export declare function invokeMcpTool(client: GenesisClient, tool: string, params: Record<string, any>): Promise<any>;
12
20
  export declare function getCommand(name: string): Command | undefined;
13
21
  export declare function getCommandNames(): string[];
14
22
  export {};