@aitherium/shell-cli 1.11.1 → 1.12.1

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.
@@ -0,0 +1,397 @@
1
+ /**
2
+ * AitherShell Installer Wizard
3
+ * ===========================
4
+ *
5
+ * Non-technical user flow: one-click installer via interactive prompts.
6
+ * Guides the user through device-flow login, license provisioning,
7
+ * endpoint registration, and adk quickstart bootstrap.
8
+ *
9
+ * Endpoints called:
10
+ * POST /v1/licenses/issue (Bearer token auth)
11
+ * POST /v1/endpoints/register (X-License-Key header)
12
+ * POST /v1/workspace/sync (X-License-Key header)
13
+ */
14
+ import { execSync } from 'node:child_process';
15
+ import { writeFileSync, mkdirSync, chmodSync, existsSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { homedir, cpus } from 'node:os';
18
+ import { randomUUID } from 'node:crypto';
19
+ import chalk from 'chalk';
20
+ import ora from 'ora';
21
+ import { requestDeviceCode, pollDeviceToken, buildProfile, setProfile, } from './auth.js';
22
+ /* ── Onboarding Client ───────────────────────────────────────── */
23
+ export class OnboardingClient {
24
+ portalUrl;
25
+ authToken = null;
26
+ constructor(portalUrl = 'https://portal.aitherium.com') {
27
+ this.portalUrl = portalUrl.replace(/\/+$/, '');
28
+ }
29
+ setAuthToken(token) {
30
+ this.authToken = token;
31
+ }
32
+ /**
33
+ * POST /v1/licenses/issue — Mint a license.
34
+ * Requires: Bearer token in Authorization header
35
+ * Returns: license_key, tier, entitlements, expires_at
36
+ */
37
+ async issueLicense(tierRequest, hardwareId) {
38
+ if (!this.authToken) {
39
+ throw new Error('Not authenticated — run device-flow login first');
40
+ }
41
+ const body = { tier_request: tierRequest };
42
+ if (hardwareId)
43
+ body.hardware_id = hardwareId;
44
+ const resp = await fetch(`${this.portalUrl}/v1/licenses/issue`, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ Authorization: `Bearer ${this.authToken}`,
49
+ },
50
+ body: JSON.stringify(body),
51
+ signal: AbortSignal.timeout(15000),
52
+ });
53
+ if (!resp.ok) {
54
+ const text = await resp.text().catch(() => '');
55
+ throw new Error(`License issue failed (HTTP ${resp.status}): ${text}`);
56
+ }
57
+ return resp.json();
58
+ }
59
+ /**
60
+ * POST /v1/endpoints/register — Register a compute endpoint.
61
+ * Requires: license_key in request body
62
+ * Returns: endpoint_id, tenant_id, workspace_id, sync_interval
63
+ */
64
+ async registerEndpoint(licenseKey, nodeId, hostname, systemInfo) {
65
+ const body = {
66
+ node_id: nodeId,
67
+ hostname,
68
+ system_info: systemInfo,
69
+ license_key: licenseKey,
70
+ };
71
+ const resp = await fetch(`${this.portalUrl}/v1/endpoints/register`, {
72
+ method: 'POST',
73
+ headers: {
74
+ 'Content-Type': 'application/json',
75
+ },
76
+ body: JSON.stringify(body),
77
+ signal: AbortSignal.timeout(15000),
78
+ });
79
+ if (!resp.ok) {
80
+ const text = await resp.text().catch(() => '');
81
+ throw new Error(`Endpoint register failed (HTTP ${resp.status}): ${text}`);
82
+ }
83
+ return resp.json();
84
+ }
85
+ /**
86
+ * POST /v1/workspace/sync — Sync workspace configuration.
87
+ * Requires: X-License-Key header
88
+ * Returns: agent_roster, provider_key_refs, settings, cache_ttl
89
+ */
90
+ async syncWorkspace(licenseKey, workspaceId) {
91
+ const body = {
92
+ workspace_id: workspaceId,
93
+ heartbeat: false,
94
+ };
95
+ const resp = await fetch(`${this.portalUrl}/v1/workspace/sync`, {
96
+ method: 'POST',
97
+ headers: {
98
+ 'Content-Type': 'application/json',
99
+ 'X-License-Key': licenseKey,
100
+ },
101
+ body: JSON.stringify(body),
102
+ signal: AbortSignal.timeout(15000),
103
+ });
104
+ if (!resp.ok) {
105
+ const text = await resp.text().catch(() => '');
106
+ throw new Error(`Workspace sync failed (HTTP ${resp.status}): ${text}`);
107
+ }
108
+ return resp.json();
109
+ }
110
+ }
111
+ /* ── System Detection ────────────────────────────────────────── */
112
+ /**
113
+ * Detect CPU count (always available).
114
+ */
115
+ function detectCpuCount() {
116
+ return cpus().length;
117
+ }
118
+ /**
119
+ * Best-effort GPU detection via nvidia-smi (NVIDIA only).
120
+ * Returns { name, vram } or null if not found.
121
+ */
122
+ function detectGpu() {
123
+ try {
124
+ const out = execSync('nvidia-smi --query-gpu=name,memory.total --format=csv,noheader', {
125
+ encoding: 'utf-8',
126
+ timeout: 5000,
127
+ }).trim();
128
+ const parts = out.split(',').map((s) => s.trim());
129
+ if (parts.length >= 2) {
130
+ const name = parts[0];
131
+ const vramMb = parseInt(parts[1].replace(/[^0-9]/g, ''), 10);
132
+ return { name, vram: vramMb };
133
+ }
134
+ }
135
+ catch {
136
+ // nvidia-smi not found or failed — skip
137
+ }
138
+ return null;
139
+ }
140
+ /**
141
+ * Collect basic system info for the portal.
142
+ */
143
+ export function collectSystemInfo() {
144
+ const info = {
145
+ cpu_count: detectCpuCount(),
146
+ models_present: [],
147
+ };
148
+ const gpu = detectGpu();
149
+ if (gpu) {
150
+ info.gpu_name = gpu.name;
151
+ info.gpu_vram = gpu.vram;
152
+ }
153
+ return info;
154
+ }
155
+ /**
156
+ * Check if Docker is running (best-effort).
157
+ * Returns true if 'docker ps' succeeds, false otherwise.
158
+ */
159
+ export function isDockerRunning() {
160
+ try {
161
+ execSync('docker ps', { stdio: 'ignore', timeout: 5000 });
162
+ return true;
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
168
+ /**
169
+ * Check if 'adk' command is on PATH.
170
+ */
171
+ export function isAdkAvailable() {
172
+ try {
173
+ execSync('adk --version', { stdio: 'ignore', timeout: 5000 });
174
+ return true;
175
+ }
176
+ catch {
177
+ return false;
178
+ }
179
+ }
180
+ /* ── Installation Flow ───────────────────────────────────────── */
181
+ /**
182
+ * Run the interactive installation wizard.
183
+ *
184
+ * Flow:
185
+ * 1. Welcome banner
186
+ * 2. Check Docker (optional)
187
+ * 3. Device-flow portal login
188
+ * 4. Request license tier
189
+ * 5. Detect system info
190
+ * 6. Register endpoint
191
+ * 7. Sync workspace
192
+ * 8. Run 'adk quickstart-local' (bootstrap Ollama + model)
193
+ * 9. Persist workspace config
194
+ * 10. Print success message
195
+ */
196
+ export async function runWizard(client, config) {
197
+ console.log();
198
+ console.log(chalk.bold(chalk.cyan('╔════════════════════════════════════════╗')));
199
+ console.log(chalk.bold(chalk.cyan('║ AitherOS Installer Wizard ║')));
200
+ console.log(chalk.bold(chalk.cyan('╚════════════════════════════════════════╝')));
201
+ console.log();
202
+ // ── Step 1: Check Docker ──────────────────────────────────────────
203
+ console.log(chalk.bold('Step 1: Pre-flight checks'));
204
+ const spinner = ora({ prefixText: ' ' });
205
+ spinner.start('Checking Docker...');
206
+ const dockerRunning = isDockerRunning();
207
+ if (dockerRunning) {
208
+ spinner.succeed('Docker is running');
209
+ }
210
+ else {
211
+ spinner.warn('Docker not running');
212
+ console.log();
213
+ console.log(chalk.yellow(' ⚠ Docker is recommended for full AitherOS features.'));
214
+ console.log(chalk.dim(' Install: https://docker.com'));
215
+ console.log(chalk.dim(' Or use standalone ADK: pip install aither-adk'));
216
+ console.log();
217
+ }
218
+ // ── Step 2: Device-flow login ────────────────────────────────────
219
+ console.log();
220
+ console.log(chalk.bold('Step 2: Sign in to your account'));
221
+ console.log();
222
+ let authToken = null;
223
+ try {
224
+ const dc = await requestDeviceCode(config.identityUrl, 'AitherShell-Installer');
225
+ console.log(' Open this URL in your browser:');
226
+ console.log(' ' + chalk.cyan.underline(dc.verification_uri_complete || dc.verification_uri));
227
+ if (dc.user_code) {
228
+ console.log();
229
+ console.log(' Code: ' + chalk.bold.yellow(dc.user_code));
230
+ }
231
+ console.log();
232
+ spinner.start('Waiting for authorization');
233
+ const deadline = Date.now() + (dc.expires_in || 900) * 1000;
234
+ const interval = Math.max(2, dc.interval || 5) * 1000;
235
+ while (Date.now() < deadline) {
236
+ await new Promise((r) => setTimeout(r, interval));
237
+ try {
238
+ const result = await pollDeviceToken(config.identityUrl, dc.device_code);
239
+ if ((result.status === 'complete' || result.status === 'authorized') &&
240
+ result.access_token) {
241
+ authToken = result.access_token;
242
+ const profile = buildProfile(config.identityUrl, config.genesisUrl, result);
243
+ setProfile('local', profile);
244
+ config.authToken = authToken;
245
+ config.authUser = profile.user;
246
+ spinner.succeed(chalk.green(`Signed in as ${profile.user.display_name || profile.user.username}`));
247
+ break;
248
+ }
249
+ }
250
+ catch (err) {
251
+ if (String(err?.message || '').includes('expired')) {
252
+ spinner.fail('Device code expired');
253
+ throw err;
254
+ }
255
+ }
256
+ }
257
+ if (!authToken) {
258
+ spinner.fail('Sign-in timed out');
259
+ throw new Error('Authorization timeout');
260
+ }
261
+ }
262
+ catch (err) {
263
+ console.log();
264
+ console.error(chalk.red(` Error during sign-in: ${err.message}`));
265
+ process.exit(1);
266
+ }
267
+ console.log();
268
+ // ── Step 3: Issue license ────────────────────────────────────────
269
+ console.log(chalk.bold('Step 3: Provision license'));
270
+ const onboarding = new OnboardingClient(process.env.AITHER_PORTAL_URL || 'https://portal.aitherium.com');
271
+ onboarding.setAuthToken(authToken);
272
+ let licenseKey;
273
+ let workspaceId;
274
+ spinner.start('Requesting license...');
275
+ try {
276
+ const licenseResp = await onboarding.issueLicense('developer');
277
+ licenseKey = licenseResp.license_key;
278
+ spinner.succeed(`License issued: ${licenseResp.tier} (expires ${new Date(licenseResp.expires_at * 1000).toLocaleDateString()})`);
279
+ }
280
+ catch (err) {
281
+ spinner.fail(`License request failed: ${err.message}`);
282
+ console.error(chalk.red(` ${err.message}`));
283
+ process.exit(1);
284
+ }
285
+ console.log();
286
+ // ── Step 4: Collect system info ──────────────────────────────────
287
+ console.log(chalk.bold('Step 4: Detect system hardware'));
288
+ spinner.start('Collecting system info...');
289
+ const systemInfo = collectSystemInfo();
290
+ spinner.succeed(`CPU: ${systemInfo.cpu_count} cores${systemInfo.gpu_name ? `, GPU: ${systemInfo.gpu_name}` : ''}`);
291
+ console.log();
292
+ // ── Step 5: Register endpoint ────────────────────────────────────
293
+ console.log(chalk.bold('Step 5: Register endpoint'));
294
+ const nodeId = process.env.HOSTNAME || randomUUID().slice(0, 12);
295
+ const hostname = nodeId;
296
+ spinner.start('Registering endpoint...');
297
+ try {
298
+ const regResp = await onboarding.registerEndpoint(licenseKey, nodeId, hostname, systemInfo);
299
+ workspaceId = regResp.workspace_id;
300
+ spinner.succeed(`Endpoint registered: ${regResp.endpoint_id}`);
301
+ }
302
+ catch (err) {
303
+ spinner.fail(`Endpoint registration failed: ${err.message}`);
304
+ console.error(chalk.red(` ${err.message}`));
305
+ process.exit(1);
306
+ }
307
+ console.log();
308
+ // ── Step 6: Sync workspace ───────────────────────────────────────
309
+ console.log(chalk.bold('Step 6: Sync workspace'));
310
+ spinner.start('Syncing workspace configuration...');
311
+ try {
312
+ await onboarding.syncWorkspace(licenseKey, workspaceId);
313
+ spinner.succeed('Workspace synced');
314
+ }
315
+ catch (err) {
316
+ spinner.warn(`Workspace sync failed (non-fatal): ${err.message}`);
317
+ }
318
+ console.log();
319
+ // ── Step 7: ADK Quickstart ───────────────────────────────────────
320
+ console.log(chalk.bold('Step 7: Bootstrap ADK and models'));
321
+ console.log();
322
+ let adkAvailable = isAdkAvailable();
323
+ if (!adkAvailable) {
324
+ spinner.start('Installing aither-adk...');
325
+ try {
326
+ execSync('pip install aither-adk', { stdio: 'inherit', timeout: 60000 });
327
+ spinner.succeed('aither-adk installed');
328
+ adkAvailable = true;
329
+ }
330
+ catch (err) {
331
+ spinner.warn('Could not auto-install aither-adk. Please run: pip install aither-adk');
332
+ }
333
+ }
334
+ if (adkAvailable) {
335
+ console.log();
336
+ console.log(chalk.dim(' Running: adk quickstart-local'));
337
+ console.log(chalk.dim(' (This bootstraps Ollama and downloads a model — may take a few minutes)'));
338
+ console.log();
339
+ try {
340
+ execSync('adk quickstart-local', { stdio: 'inherit' });
341
+ }
342
+ catch (err) {
343
+ console.error(chalk.yellow('\n ⚠ ADK quickstart encountered an error'));
344
+ console.error(chalk.dim(` Error: ${err.message}`));
345
+ console.log();
346
+ console.log(chalk.dim(' You can retry manually: adk quickstart-local'));
347
+ }
348
+ }
349
+ console.log();
350
+ // ── Step 8: Persist workspace config ─────────────────────────────
351
+ console.log(chalk.bold('Step 8: Save configuration'));
352
+ const configDir = join(homedir(), '.aither');
353
+ const workspaceConfPath = join(configDir, 'workspace.conf');
354
+ const workspaceConf = {
355
+ workspace_id: workspaceId,
356
+ license_key: licenseKey,
357
+ node_id: nodeId,
358
+ endpoint_id: nodeId,
359
+ registered_at: new Date().toISOString(),
360
+ };
361
+ try {
362
+ if (!existsSync(configDir)) {
363
+ mkdirSync(configDir, { recursive: true });
364
+ }
365
+ writeFileSync(workspaceConfPath, JSON.stringify(workspaceConf, null, 2), 'utf-8');
366
+ try {
367
+ chmodSync(workspaceConfPath, 0o600);
368
+ }
369
+ catch {
370
+ // Windows — ignore chmod errors
371
+ }
372
+ spinner.succeed(`Workspace config saved to ${workspaceConfPath}`);
373
+ }
374
+ catch (err) {
375
+ spinner.warn(`Could not save workspace config: ${err.message}`);
376
+ }
377
+ console.log();
378
+ // ── Success banner ───────────────────────────────────────────────
379
+ console.log(chalk.green(chalk.bold('✓ Installation complete!')));
380
+ console.log();
381
+ console.log(' Next steps:');
382
+ console.log();
383
+ console.log(chalk.cyan(' 1. Start the shell:'));
384
+ console.log(chalk.dim(' aither'));
385
+ console.log();
386
+ console.log(chalk.cyan(' 2. View dashboard (if Docker is running):'));
387
+ console.log(chalk.dim(' http://localhost:3000'));
388
+ console.log();
389
+ console.log(chalk.cyan(' 3. Try a quick command:'));
390
+ console.log(chalk.dim(' aither "What services are running?"'));
391
+ console.log();
392
+ if (!dockerRunning) {
393
+ console.log(chalk.yellow(' ℹ Docker is not running.'));
394
+ console.log(chalk.dim(' Start Docker Desktop to unlock the full dashboard and GPU inference.'));
395
+ console.log();
396
+ }
397
+ }
package/dist/main.js CHANGED
@@ -1,7 +1,19 @@
1
1
  #!/usr/bin/env node
2
- // Trust AitherOS self-signed TLS certs without disabling all verification.
3
- // Prefer NODE_EXTRA_CA_CERTS (adds our CA to the trust chain) if available;
4
- // only fall back to NODE_TLS_REJECT_UNAUTHORIZED=0 when no CA chain exists.
2
+ // Trust AitherOS internal TLS certs.
3
+ //
4
+ // Reality of the fleet's PKI: each internal service ships a SELF-SIGNED leaf
5
+ // (e.g. Identity at :8115 → subject==issuer CN=aitheros-identity, no SAN for
6
+ // 127.0.0.1). Those certs are NOT issued by ca-chain.pem and carry no loopback
7
+ // SAN, so NODE_EXTRA_CA_CERTS can never validate them — under Node, fetch() to an
8
+ // https-only internal service (Identity) fails cert/hostname verification and the
9
+ // status banner false-flags it DOWN even though it's up. (Bun ignores
10
+ // NODE_EXTRA_CA_CERTS entirely; its per-request `tls` opt is handled in the probe.)
11
+ //
12
+ // So: when the primary endpoint is a LOCAL/loopback fleet (the self-signed trust
13
+ // domain), disable verification. For a PUBLIC endpoint (idp/gateway.aitherium.com,
14
+ // real CA-issued certs) keep strict TLS — those validate normally and must stay
15
+ // protected against MITM. NODE_EXTRA_CA_CERTS is still set when present (harmless,
16
+ // and helps any service that IS chain-issued).
5
17
  import { existsSync } from 'fs';
6
18
  import { join, basename } from 'path';
7
19
  const _caChainPaths = [
@@ -10,11 +22,26 @@ const _caChainPaths = [
10
22
  '/app/AitherOS/Library/Data/tls/ca-chain.pem',
11
23
  ];
12
24
  const _caChain = _caChainPaths.find(p => p && existsSync(p));
13
- if (_caChain) {
25
+ if (_caChain)
14
26
  process.env.NODE_EXTRA_CA_CERTS = _caChain;
15
- }
16
- else {
17
- // No CA chain found disable verification as last resort (suppress warning via env)
27
+ // Resolve the primary endpoint the same way loadConfig() does (env precedence),
28
+ // then decide if we're in the internal self-signed trust domain.
29
+ const _primaryUrl = process.env.AITHER_API_URL || process.env.AITHER_GENESIS_URL ||
30
+ process.env.AITHER_GATEWAY_URL || 'http://127.0.0.1:8001';
31
+ const _isLoopbackFleet = (() => {
32
+ try {
33
+ const h = new URL(_primaryUrl).hostname;
34
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' ||
35
+ /^10\./.test(h) || /^192\.168\./.test(h) ||
36
+ /^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
37
+ h.endsWith('.local') || h.endsWith('.internal');
38
+ }
39
+ catch {
40
+ return true;
41
+ } // unparseable → assume local dev
42
+ })();
43
+ // Explicit override always wins; otherwise relax only for the loopback fleet.
44
+ if (process.env.NODE_TLS_REJECT_UNAUTHORIZED == null && _isLoopbackFleet) {
18
45
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
19
46
  }
20
47
  /**
@@ -79,7 +106,7 @@ async function resolveResume(client, config, mode, id) {
79
106
  // Install global crash reporter — catches uncaught exceptions/rejections,
80
107
  // prompts user to send error report, creates GitHub issue automatically.
81
108
  installCrashReporter();
82
- const VERSION = '1.10.0'; // keep in sync with package.json
109
+ const VERSION = '1.12.0'; // keep in sync with package.json
83
110
  async function main() {
84
111
  const args = process.argv.slice(2);
85
112
  if (args.includes('--help') || args.includes('-h')) {
@@ -224,7 +251,7 @@ async function main() {
224
251
  // ── Explicit one-shot subcommands that must NOT fall through to chat ──
225
252
  // e.g. `aither node ls`, `aither node enroll --tenant acme`. Without this,
226
253
  // "node ls" became a positional chat prompt → intent-classified as a query.
227
- const ONESHOT_CMDS = new Set(['node', 'nodes']);
254
+ const ONESHOT_CMDS = new Set(['node', 'nodes', 'install']);
228
255
  if (args[0] && ONESHOT_CMDS.has(args[0].toLowerCase())) {
229
256
  const { getCommand } = await import('./commands.js');
230
257
  const cmd = getCommand(args[0].toLowerCase());
package/dist/renderer.js CHANGED
@@ -33,7 +33,7 @@ export function osc8Link(url, label) {
33
33
  }
34
34
  /* ── Banner ─────────────────────────────────────────────────── */
35
35
  export function renderBanner(info) {
36
- const version = 'v1.11.1'; // keep in sync with package.json
36
+ const version = 'v1.12.0'; // keep in sync with package.json
37
37
  const title = `AitherShell ${version}`;
38
38
  let connected;
39
39
  if (info.genesisOnline === true) {
@@ -379,6 +379,11 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
379
379
  let lastStage = ''; // Current pipeline stage — shown in heartbeat spinner
380
380
  let eagerActive = false; // Eager streaming: multiple answer_segments this turn
381
381
  let eagerSegments = 0; // Count of segments rendered
382
+ let segmentTokenStreamed = false; // Did THIS segment receive token events? If a
383
+ // segment header printed but no tokens arrived
384
+ // (engine sent the answer as one 'answer' event),
385
+ // we must print the answer ourselves or the user
386
+ // is left staring at just the preamble line.
382
387
  // ── Session trace collection ──
383
388
  const traceEvents = [];
384
389
  const traceToolCalls = [];
@@ -592,6 +597,7 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
592
597
  log(chalk.cyan(' 💬 Initial') + segReason);
593
598
  }
594
599
  tokensStarted = false; // re-enter first-token path for this segment
600
+ segmentTokenStreamed = false; // reset per-segment token tracking
595
601
  content = ''; // per-segment accumulator (final captured at complete)
596
602
  break;
597
603
  }
@@ -614,6 +620,7 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
614
620
  process.stdout.write(t);
615
621
  hasOutput = true;
616
622
  tokenStreamed = true;
623
+ segmentTokenStreamed = true;
617
624
  contentDisplayed = true;
618
625
  break;
619
626
  }
@@ -622,10 +629,21 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
622
629
  case 'final_answer': {
623
630
  stopSpinner();
624
631
  // Eager streaming already rendered the answer as live segments —
625
- // the terminal `answer` event is just the canonical copy for
626
- // trace/getContent. Capture it, don't re-print.
632
+ // the terminal `answer` event is normally just the canonical copy
633
+ // for trace/getContent. BUT if the current segment printed a header
634
+ // ("💬 Initial …") yet no token events streamed (the engine returned
635
+ // the answer as a single 'answer' event, e.g. the search fastpath),
636
+ // the user would see only the preamble. In that case print it now.
627
637
  if (eagerActive) {
628
- content = event.data.answer || event.data.content || content;
638
+ const eagerAnswer = event.data.answer || event.data.content || content;
639
+ if (!segmentTokenStreamed && eagerAnswer && eagerAnswer.trim()) {
640
+ process.stdout.write('\r\x1b[2K');
641
+ process.stdout.write(renderMarkdown(wrapBareCode(eagerAnswer)));
642
+ if (!eagerAnswer.endsWith('\n'))
643
+ process.stdout.write('\n');
644
+ segmentTokenStreamed = true; // guard against double-print at complete
645
+ }
646
+ content = eagerAnswer;
629
647
  contentDisplayed = true;
630
648
  hasOutput = true;
631
649
  break;
@@ -686,6 +704,25 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
686
704
  }
687
705
  break;
688
706
  }
707
+ case 'tool_decision': {
708
+ // Pre-execution preamble: names the chosen tools the instant they're
709
+ // parsed, before the (silent) tool execution wait.
710
+ stopSpinner();
711
+ const preamble = event.data.preamble
712
+ || `Calling tools: ${(event.data.tools || []).join(', ')}`;
713
+ log(' ' + chalk.yellow(`⚡ ${preamble}`));
714
+ break;
715
+ }
716
+ case 'tool_call_preview': {
717
+ // Trace-only: the model committed to a tool NAME mid-stream. Shown the
718
+ // instant it's known; execution still happens from the done chunk.
719
+ stopSpinner();
720
+ const argHint = event.data.arguments_preview
721
+ ? chalk.gray(` ${String(event.data.arguments_preview).slice(0, 80)}`)
722
+ : '';
723
+ log(' ' + chalk.yellow(`🔧 Calling ${event.data.name || 'tool'}…`) + argHint);
724
+ break;
725
+ }
689
726
  case 'tool_call': {
690
727
  stopSpinner();
691
728
  const turnLabel2 = event.data.turn != null ? chalk.cyan(`[Turn ${event.data.turn}] `) : '';
@@ -778,6 +815,17 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
778
815
  hasOutput = true;
779
816
  break;
780
817
  }
818
+ case 'suggested_followups': {
819
+ // Proactive next-steps. Plain REPL is append-only (no digit-select);
820
+ // the eager answer already streamed above, so these land beneath it.
821
+ const fups = Array.isArray(event.data.followups) ? event.data.followups : [];
822
+ if (fups.length) {
823
+ process.stdout.write('\n' + chalk.dim(' Next →') + '\n');
824
+ fups.forEach((t, i) => process.stdout.write(' ' + chalk.cyan(`[${i + 1}]`) + ' ' + chalk.dim(String(t)) + '\n'));
825
+ hasOutput = true;
826
+ }
827
+ break;
828
+ }
781
829
  case 'done':
782
830
  case 'complete': {
783
831
  stopSpinner();