@aitherium/shell-cli 1.12.0 → 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.
package/dist/client.d.ts CHANGED
@@ -96,6 +96,16 @@ export declare class GenesisClient {
96
96
  effort?: number;
97
97
  }): Promise<any>;
98
98
  getLogs(limit?: number, level?: string, service?: string): Promise<any>;
99
+ /** List all expeditions (unified chat jobs, promoted jobs, real expeditions). */
100
+ listExpeditions(): Promise<any>;
101
+ /** Get expedition status summary. */
102
+ getExpeditionStatus(id: string): Promise<any>;
103
+ /** Get expedition tasks. */
104
+ getExpeditionTasks(id: string): Promise<any>;
105
+ /** Steer an expedition with a message (append or hint). */
106
+ steerExpedition(id: string, message: string, action?: 'append' | 'hint'): Promise<any>;
107
+ /** Stream SSE events from an expedition. */
108
+ streamExpeditionEvents(id: string): AsyncGenerator<SSEEvent>;
99
109
  getLLMStatus(): Promise<any>;
100
110
  /** Resolve the MicroScheduler base URL the same way getLLMStatus does. */
101
111
  private _msCandidates;
package/dist/client.js CHANGED
@@ -305,6 +305,49 @@ export class GenesisClient {
305
305
  params.set('service', service);
306
306
  return this.get(`/chronicle/logs?${params}`);
307
307
  }
308
+ /* ── Cloud Expeditions (durable jobs) ─────────────────────── */
309
+ // NOTE: use getDetailed (NOT get) — get() collapses any error to `null`, which
310
+ // is indistinguishable from "no expeditions". getDetailed preserves {error} so
311
+ // the REPL can show a real diagnostic (genesis down / 404) vs an empty list.
312
+ /** List all expeditions (unified chat jobs, promoted jobs, real expeditions). */
313
+ async listExpeditions() {
314
+ return this.getDetailed('/expedition/list');
315
+ }
316
+ /** Get expedition status summary. */
317
+ async getExpeditionStatus(id) {
318
+ return this.getDetailed(`/expedition/${id}/status`);
319
+ }
320
+ /** Get expedition tasks. */
321
+ async getExpeditionTasks(id) {
322
+ return this.getDetailed(`/expedition/${id}/tasks`);
323
+ }
324
+ /** Steer an expedition with a message (append or hint). */
325
+ async steerExpedition(id, message, action = 'append') {
326
+ return this.post(`/expedition/${id}/steer`, { message, action });
327
+ }
328
+ /** Stream SSE events from an expedition. */
329
+ async *streamExpeditionEvents(id) {
330
+ try {
331
+ const r = await fetch(`${this.baseUrl}/expedition/${id}/stream`, {
332
+ headers: { 'X-Caller-Type': 'PLATFORM', ...this.authHeaders() },
333
+ signal: AbortSignal.timeout(600_000), // 10 min timeout for watching
334
+ });
335
+ if (!r.ok) {
336
+ yield {
337
+ type: 'error',
338
+ data: { type: 'error', error: `HTTP ${r.status}` },
339
+ };
340
+ return;
341
+ }
342
+ yield* this.readSSE(r);
343
+ }
344
+ catch (err) {
345
+ yield {
346
+ type: 'error',
347
+ data: { type: 'error', error: err?.message || 'Stream failed' },
348
+ };
349
+ }
350
+ }
308
351
  async getLLMStatus() {
309
352
  try {
310
353
  const candidates = [process.env.AITHER_LLM_URL, 'https://localhost:8150', 'http://localhost:8150'].filter(Boolean);
@@ -541,11 +584,15 @@ export class GenesisClient {
541
584
  const WARN_TIMEOUT_MS = 60_000; // warn after 60s of silence
542
585
  let lastChunkAt = Date.now();
543
586
  let warningSent = false;
587
+ // Retain the in-flight read across a warning-yield so reader.read() is
588
+ // never called twice concurrently (that throws on a locked reader).
589
+ let pendingRead = null;
544
590
  try {
545
591
  while (true) {
546
592
  // Race reader.read() against a timeout so the client never hangs
547
593
  // if the backend stalls (e.g. reasoning model cold-start, 404 retry loop).
548
- const readPromise = reader.read();
594
+ const readPromise = pendingRead ?? reader.read();
595
+ pendingRead = readPromise;
549
596
  // IMPORTANT: keep a handle to the timeout timer and clear it once the
550
597
  // chunk arrives. Otherwise every chunk (hundreds per streamed turn) leaks
551
598
  // a live 120s timer; the swarm of late, post-settle rejections keeps the
@@ -555,16 +602,18 @@ export class GenesisClient {
555
602
  const timeoutPromise = new Promise((_, reject) => {
556
603
  chunkTimer = setTimeout(() => reject(new Error('SSE chunk timeout')), CHUNK_TIMEOUT_MS);
557
604
  });
558
- // Intermediate warning if no data for 60s
605
+ // Warn after 60s of silence WITHOUT consuming the read: race a sentinel
606
+ // alongside it so we can surface "still working" and keep waiting.
559
607
  let warnTimer = null;
608
+ const racers = [readPromise, timeoutPromise];
560
609
  if (!warningSent) {
561
- warnTimer = setTimeout(() => {
562
- warningSent = true;
563
- }, WARN_TIMEOUT_MS);
610
+ racers.push(new Promise((res) => {
611
+ warnTimer = setTimeout(() => res({ __warn: true }), WARN_TIMEOUT_MS);
612
+ }));
564
613
  }
565
614
  let result;
566
615
  try {
567
- result = await Promise.race([readPromise, timeoutPromise]);
616
+ result = await Promise.race(racers);
568
617
  if (chunkTimer)
569
618
  clearTimeout(chunkTimer);
570
619
  if (warnTimer)
@@ -601,6 +650,13 @@ export class GenesisClient {
601
650
  }
602
651
  break;
603
652
  }
653
+ // Silence warning fired — surface it and keep waiting on the SAME read.
654
+ if (result.__warn) {
655
+ warningSent = true;
656
+ yield { type: 'stream_warning', data: { type: 'stream_warning', silent_ms: Date.now() - lastChunkAt, message: 'Still working — model may be warming up…' } };
657
+ continue; // pendingRead retained → no second reader.read()
658
+ }
659
+ pendingRead = null; // real chunk consumed
604
660
  lastChunkAt = Date.now();
605
661
  const { done, value } = result;
606
662
  if (done)
package/dist/commands.js CHANGED
@@ -14,6 +14,7 @@ import { getRemoteMcpClient } from './mcp-client.js';
14
14
  import { formatTable, getSessionArtifacts, clearSessionArtifacts, resolveImagePath, osc8Link, addSessionArtifact } from './renderer.js';
15
15
  import { loadSession, saveSession, listSessions, deleteSession, transcriptMarkdown, sessionTokens, } from './session-store.js';
16
16
  import { loginWithPassword, verify2FA, register, logoutSession, validateToken, buildProfile, setProfile, clearProfile, getActiveProfile, getActiveUser, getActiveToken, requestEmailOTP, verifyEmailOTP, requestDeviceCode, pollDeviceToken, } from './auth.js';
17
+ import { runWizard } from './install-wizard.js';
17
18
  const ONBOARD_CODE_EXTENSIONS = new Set([
18
19
  '.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.java', '.go', '.rs', '.cs',
19
20
  '.cpp', '.cc', '.c', '.h', '.hpp', '.rb', '.php', '.swift', '.kt', '.kts', '.scala',
@@ -279,6 +280,13 @@ const COMMANDS = {
279
280
  console.log();
280
281
  },
281
282
  },
283
+ // ── Installation & onboarding ──
284
+ install: {
285
+ description: 'One-click installer wizard (non-technical setup)',
286
+ handler: async (client, _args, config) => {
287
+ await runWizard(client, config);
288
+ },
289
+ },
282
290
  // ── Conversation QoL (transcript-backed) ──
283
291
  chats: {
284
292
  description: 'List, resume, or delete saved chat conversations',
@@ -0,0 +1,94 @@
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 type { GenesisClient } from './client.js';
15
+ import type { ShellConfig } from './config.js';
16
+ interface SystemInfo {
17
+ cpu_count: number;
18
+ gpu_name?: string;
19
+ gpu_vram?: number;
20
+ models_present: string[];
21
+ }
22
+ interface LicenseIssueResponse {
23
+ license_key: string;
24
+ tier: string;
25
+ entitlements: Record<string, unknown>;
26
+ expires_at: number;
27
+ sync_interval: number;
28
+ }
29
+ interface EndpointRegisterResponse {
30
+ endpoint_id: string;
31
+ tenant_id: string;
32
+ workspace_id: string;
33
+ sync_interval: number;
34
+ }
35
+ interface WorkspaceSyncResponse {
36
+ agent_roster: Record<string, unknown>[];
37
+ provider_key_refs: string[];
38
+ settings: Record<string, unknown>;
39
+ cache_ttl: number;
40
+ }
41
+ export declare class OnboardingClient {
42
+ private portalUrl;
43
+ private authToken;
44
+ constructor(portalUrl?: string);
45
+ setAuthToken(token: string): void;
46
+ /**
47
+ * POST /v1/licenses/issue — Mint a license.
48
+ * Requires: Bearer token in Authorization header
49
+ * Returns: license_key, tier, entitlements, expires_at
50
+ */
51
+ issueLicense(tierRequest: string, hardwareId?: string): Promise<LicenseIssueResponse>;
52
+ /**
53
+ * POST /v1/endpoints/register — Register a compute endpoint.
54
+ * Requires: license_key in request body
55
+ * Returns: endpoint_id, tenant_id, workspace_id, sync_interval
56
+ */
57
+ registerEndpoint(licenseKey: string, nodeId: string, hostname: string, systemInfo: SystemInfo): Promise<EndpointRegisterResponse>;
58
+ /**
59
+ * POST /v1/workspace/sync — Sync workspace configuration.
60
+ * Requires: X-License-Key header
61
+ * Returns: agent_roster, provider_key_refs, settings, cache_ttl
62
+ */
63
+ syncWorkspace(licenseKey: string, workspaceId: string): Promise<WorkspaceSyncResponse>;
64
+ }
65
+ /**
66
+ * Collect basic system info for the portal.
67
+ */
68
+ export declare function collectSystemInfo(): SystemInfo;
69
+ /**
70
+ * Check if Docker is running (best-effort).
71
+ * Returns true if 'docker ps' succeeds, false otherwise.
72
+ */
73
+ export declare function isDockerRunning(): boolean;
74
+ /**
75
+ * Check if 'adk' command is on PATH.
76
+ */
77
+ export declare function isAdkAvailable(): boolean;
78
+ /**
79
+ * Run the interactive installation wizard.
80
+ *
81
+ * Flow:
82
+ * 1. Welcome banner
83
+ * 2. Check Docker (optional)
84
+ * 3. Device-flow portal login
85
+ * 4. Request license tier
86
+ * 5. Detect system info
87
+ * 6. Register endpoint
88
+ * 7. Sync workspace
89
+ * 8. Run 'adk quickstart-local' (bootstrap Ollama + model)
90
+ * 9. Persist workspace config
91
+ * 10. Print success message
92
+ */
93
+ export declare function runWizard(client: GenesisClient, config: ShellConfig): Promise<void>;
94
+ export {};
@@ -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
@@ -251,7 +251,7 @@ async function main() {
251
251
  // ── Explicit one-shot subcommands that must NOT fall through to chat ──
252
252
  // e.g. `aither node ls`, `aither node enroll --tenant acme`. Without this,
253
253
  // "node ls" became a positional chat prompt → intent-classified as a query.
254
- const ONESHOT_CMDS = new Set(['node', 'nodes']);
254
+ const ONESHOT_CMDS = new Set(['node', 'nodes', 'install']);
255
255
  if (args[0] && ONESHOT_CMDS.has(args[0].toLowerCase())) {
256
256
  const { getCommand } = await import('./commands.js');
257
257
  const cmd = getCommand(args[0].toLowerCase());
package/dist/renderer.js CHANGED
@@ -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();
package/dist/repl.js CHANGED
@@ -602,7 +602,7 @@ export async function startRepl(client, config) {
602
602
  }
603
603
  // ── /jobs — built-in job management ──
604
604
  if (cmdName === 'jobs') {
605
- handleJobsCommand(cmdArgs);
605
+ await handleJobsCommand(cmdArgs);
606
606
  return;
607
607
  }
608
608
  // ── /relay — native group chat (humans + agents) ──
@@ -1046,9 +1046,47 @@ export async function startRepl(client, config) {
1046
1046
  }
1047
1047
  }
1048
1048
  /* ── /jobs command handler ──────────────────────────────────── */
1049
- function handleJobsCommand(args) {
1049
+ async function handleJobsCommand(args) {
1050
1050
  const parts = args.trim().split(/\s+/);
1051
1051
  const sub = parts[0] || '';
1052
+ // ── Cloud expeditions ──
1053
+ if (sub === 'cloud' || sub === 'cjobs') {
1054
+ const expId = parts[1] || '';
1055
+ if (expId) {
1056
+ // /jobs cloud <id>
1057
+ await handleExpeditionDetail(client, expId);
1058
+ }
1059
+ else {
1060
+ // /jobs cloud
1061
+ await handleExpeditionList(client);
1062
+ }
1063
+ return;
1064
+ }
1065
+ // ── Steer & hint ──
1066
+ if (sub === 'steer' || sub === 'hint') {
1067
+ const expId = parts[1];
1068
+ if (!expId) {
1069
+ console.log(chalk.yellow(` Usage: /jobs ${sub} <expedition-id> <message...>`));
1070
+ return;
1071
+ }
1072
+ const message = parts.slice(2).join(' ');
1073
+ if (!message) {
1074
+ console.log(chalk.yellow(` Usage: /jobs ${sub} <expedition-id> <message...>`));
1075
+ return;
1076
+ }
1077
+ await handleExpeditionSteer(client, expId, message, sub === 'hint' ? 'hint' : 'append');
1078
+ return;
1079
+ }
1080
+ // ── Watch ──
1081
+ if (sub === 'watch') {
1082
+ const expId = parts[1];
1083
+ if (!expId) {
1084
+ console.log(chalk.yellow(' Usage: /jobs watch <expedition-id>'));
1085
+ return;
1086
+ }
1087
+ await handleExpeditionWatch(client, expId);
1088
+ return;
1089
+ }
1052
1090
  // /jobs cancel <id>
1053
1091
  if (sub === 'cancel' || sub === 'kill') {
1054
1092
  const id = Number(parts[1]);
@@ -1076,19 +1114,175 @@ export async function startRepl(client, config) {
1076
1114
  console.log(formatJobOutput(job));
1077
1115
  return;
1078
1116
  }
1079
- // /jobs — list all
1117
+ // /jobs — list all local + help
1080
1118
  const allJobs = listJobs();
1081
1119
  if (allJobs.length === 0) {
1082
- console.log(chalk.dim(' No background jobs.'));
1083
- return;
1120
+ console.log(chalk.dim(' No local background jobs.'));
1084
1121
  }
1085
- console.log(chalk.bold('\n Background Jobs\n'));
1086
- for (const job of allJobs) {
1087
- console.log(formatJobLine(job));
1122
+ else {
1123
+ console.log(chalk.bold('\n Local Background Jobs\n'));
1124
+ for (const job of allJobs) {
1125
+ console.log(formatJobLine(job));
1126
+ }
1088
1127
  }
1089
- console.log(chalk.dim(`\n /jobs <id> View output`));
1090
- console.log(chalk.dim(` /jobs cancel <id> Cancel a running job`));
1091
1128
  console.log();
1129
+ console.log(chalk.bold(' Commands\n'));
1130
+ console.log(chalk.dim(' Local jobs:'));
1131
+ console.log(` ${chalk.cyan('/jobs')} List local background jobs`);
1132
+ console.log(` ${chalk.cyan('/jobs <id>')} View job output`);
1133
+ console.log(` ${chalk.cyan('/jobs cancel <id>')} Cancel a running job`);
1134
+ console.log();
1135
+ console.log(chalk.dim(' Cloud expeditions (durable):'));
1136
+ console.log(` ${chalk.cyan('/jobs cloud')} List expeditions`);
1137
+ console.log(` ${chalk.cyan('/jobs cloud <id>')} View expedition details`);
1138
+ console.log(` ${chalk.cyan('/jobs steer <id> msg')} Send message to expedition`);
1139
+ console.log(` ${chalk.cyan('/jobs hint <id> msg')} Send invisible hint to expedition`);
1140
+ console.log(` ${chalk.cyan('/jobs watch <id>')} Watch expedition stream live`);
1141
+ console.log();
1142
+ }
1143
+ /* ── Expedition helpers ────────────────────────────────────── */
1144
+ async function handleExpeditionList(client) {
1145
+ try {
1146
+ const result = await client.listExpeditions();
1147
+ if (!result || result.error) {
1148
+ console.log(chalk.red(` Error: ${result?.error || 'Failed to list expeditions'}`));
1149
+ return;
1150
+ }
1151
+ const expeditions = result.expeditions || [];
1152
+ if (!expeditions.length) {
1153
+ console.log(chalk.dim(' No expeditions.'));
1154
+ return;
1155
+ }
1156
+ console.log(chalk.bold('\n Cloud Expeditions (Durable Jobs)\n'));
1157
+ // Format as a table: ID | Status | Title | Owner
1158
+ const headers = ['ID', 'Status', 'Title', 'Owner'];
1159
+ const rows = expeditions.map((exp) => [
1160
+ exp.id.slice(0, 8),
1161
+ formatExpeditionStatus(exp.status),
1162
+ (exp.title || '').slice(0, 40),
1163
+ exp.owner || 'unknown',
1164
+ ]);
1165
+ // Simple table format
1166
+ const maxLens = [8, 12, 40, 15];
1167
+ console.log(` ${headers.map((h, i) => h.padEnd(maxLens[i])).join(' ')}`);
1168
+ console.log(` ${headers.map(() => '─'.repeat(10)).join(' ')}`);
1169
+ for (const row of rows) {
1170
+ console.log(` ${row.map((c, i) => String(c).padEnd(maxLens[i])).join(' ')}`);
1171
+ }
1172
+ console.log(chalk.dim(`\n /jobs cloud <id> View expedition details`));
1173
+ console.log();
1174
+ }
1175
+ catch (err) {
1176
+ console.log(chalk.red(` Error: ${err.message}`));
1177
+ }
1178
+ }
1179
+ async function handleExpeditionDetail(client, expId) {
1180
+ try {
1181
+ const status = await client.getExpeditionStatus(expId);
1182
+ if (!status || status.error) {
1183
+ console.log(chalk.red(` Expedition not found: ${expId}`));
1184
+ return;
1185
+ }
1186
+ const tasks = await client.getExpeditionTasks(expId);
1187
+ const taskList = tasks?.tasks || [];
1188
+ console.log(chalk.bold(`\n Expedition: ${expId}\n`));
1189
+ console.log(` Status: ${formatExpeditionStatus(status.status || 'unknown')}`);
1190
+ if (status.title)
1191
+ console.log(` Title: ${status.title}`);
1192
+ if (status.owner)
1193
+ console.log(` Owner: ${status.owner}`);
1194
+ if (status.created_at)
1195
+ console.log(` Created: ${new Date(status.created_at).toLocaleString()}`);
1196
+ if (taskList.length > 0) {
1197
+ console.log(chalk.bold('\n Tasks\n'));
1198
+ for (const task of taskList) {
1199
+ const icon = task.status === 'completed' ? chalk.green('✓') : task.status === 'failed' ? chalk.red('✗') : chalk.blue('•');
1200
+ const title = task.title || task.id.slice(0, 8);
1201
+ console.log(` ${icon} ${title.padEnd(30)} ${chalk.dim(task.status)}`);
1202
+ if (task.result_summary) {
1203
+ const summary = typeof task.result_summary === 'string'
1204
+ ? task.result_summary
1205
+ : JSON.stringify(task.result_summary, null, 2);
1206
+ const lines = summary.split('\n').slice(0, 5);
1207
+ for (const line of lines) {
1208
+ console.log(` ${chalk.dim(line)}`);
1209
+ }
1210
+ if (summary.split('\n').length > 5) {
1211
+ console.log(` ${chalk.dim('...')}`);
1212
+ }
1213
+ }
1214
+ if (task.error) {
1215
+ console.log(` ${chalk.red(task.error)}`);
1216
+ }
1217
+ }
1218
+ }
1219
+ console.log();
1220
+ }
1221
+ catch (err) {
1222
+ console.log(chalk.red(` Error: ${err.message}`));
1223
+ }
1224
+ }
1225
+ async function handleExpeditionSteer(client, expId, message, action) {
1226
+ try {
1227
+ const result = await client.steerExpedition(expId, message, action);
1228
+ if (!result || result.error) {
1229
+ console.log(chalk.red(` Error: ${result?.error || 'Failed to steer expedition'}`));
1230
+ return;
1231
+ }
1232
+ const verb = action === 'hint' ? 'hint sent' : 'message sent';
1233
+ console.log(chalk.green(` ✓ ${verb} to expedition ${result.expedition_id}`));
1234
+ refreshPrompt();
1235
+ }
1236
+ catch (err) {
1237
+ console.log(chalk.red(` Error: ${err.message}`));
1238
+ }
1239
+ }
1240
+ async function handleExpeditionWatch(client, expId) {
1241
+ try {
1242
+ console.log(chalk.cyan(` Watching expedition ${expId}... (Ctrl+C to stop)\n`));
1243
+ let eventCount = 0;
1244
+ for await (const event of client.streamExpeditionEvents(expId)) {
1245
+ eventCount++;
1246
+ if (event.type === 'error') {
1247
+ console.log(chalk.red(` Error: ${event.data.error}`));
1248
+ break;
1249
+ }
1250
+ // Print event summaries
1251
+ const ts = new Date().toLocaleTimeString();
1252
+ const typeIcon = event.type === 'EXPEDITION_COMPLETED' ? chalk.green('✓')
1253
+ : event.type === 'EXPEDITION_FAILED' ? chalk.red('✗')
1254
+ : '•';
1255
+ console.log(` ${ts} ${typeIcon} ${event.type}`);
1256
+ if (event.data.message) {
1257
+ console.log(` ${chalk.dim(event.data.message)}`);
1258
+ }
1259
+ if (event.type === 'EXPEDITION_COMPLETED' || event.type === 'EXPEDITION_FAILED') {
1260
+ console.log(chalk.cyan(`\n Expedition ${event.data.status || 'done'}`));
1261
+ break;
1262
+ }
1263
+ }
1264
+ if (eventCount === 0) {
1265
+ console.log(chalk.yellow(' No events received (expedition may not exist or is already done).'));
1266
+ }
1267
+ }
1268
+ catch (err) {
1269
+ if (err.name === 'AbortError' || err.message?.includes('abort')) {
1270
+ console.log(chalk.yellow('\n Watch cancelled.'));
1271
+ }
1272
+ else {
1273
+ console.log(chalk.red(` Error: ${err.message}`));
1274
+ }
1275
+ }
1276
+ }
1277
+ function formatExpeditionStatus(status) {
1278
+ const normalized = (status || '').toLowerCase();
1279
+ if (normalized === 'completed' || normalized === 'done')
1280
+ return chalk.green(status);
1281
+ if (normalized === 'failed' || normalized === 'error')
1282
+ return chalk.red(status);
1283
+ if (normalized === 'running' || normalized === 'active')
1284
+ return chalk.blue(status);
1285
+ return chalk.yellow(status);
1092
1286
  }
1093
1287
  /* ── Background forge launcher ──────────────────────────────── */
1094
1288
  function launchBgForge(client, args) {
@@ -16,6 +16,7 @@ export function createTuiRenderer(surface, sessionId, prompt) {
16
16
  let completePrinted = false;
17
17
  let terminalSeen = false; // complete/error/timeout/interrupt reached us
18
18
  let answerCheckpoint = null; // OUTPUT offset where the answer text begins
19
+ let pendingFollowups = []; // stashed on suggested_followups; rendered AFTER complete
19
20
  const traceEvents = [];
20
21
  const toolCalls = [];
21
22
  const thinking = [];
@@ -145,6 +146,16 @@ export function createTuiRenderer(surface, sessionId, prompt) {
145
146
  // (replaceOutputFrom) instead of leaving the weak first pass on screen.
146
147
  // Matches renderer.ts (the plain REPL) which does `content = data.answer`.
147
148
  content = String(text);
149
+ // If this eager segment printed a header ("💬 Initial …") but never
150
+ // streamed any tokens (the engine returned the answer as one event,
151
+ // e.g. the search fastpath), show it NOW rather than waiting for
152
+ // 'complete' — an interrupted/timed-out stream would otherwise leave
153
+ // the user staring at just the preamble. 'complete' re-renders via
154
+ // replaceOutputFrom(answerCheckpoint).
155
+ if (!tokenStreamed && answerCheckpoint == null) {
156
+ answerCheckpoint = surface.markCheckpoint();
157
+ surface.appendOutput(String(text));
158
+ }
148
159
  }
149
160
  break;
150
161
  }
@@ -259,10 +270,36 @@ export function createTuiRenderer(surface, sessionId, prompt) {
259
270
  }
260
271
  const ms = d.duration_ms != null ? `${(Number(d.duration_ms) / 1000).toFixed(1)}s` : '';
261
272
  surface.outputLine(chalk.dim(`── ${[agent, model, ms].filter(Boolean).join(' · ')}`));
273
+ // Follow-up chips AFTER the answer (complete just rewrote the answer
274
+ // region via replaceOutputFrom, so they couldn't be printed earlier).
275
+ if (pendingFollowups.length) {
276
+ surface.outputLine(chalk.dim(' Next →'));
277
+ pendingFollowups.forEach((t, i) => surface.outputLine(' ' + chalk.cyan(`[${i + 1}]`) + ' ' + chalk.dim(String(t))));
278
+ surface.setPendingFollowups(pendingFollowups); // enable digit-select for next input
279
+ }
262
280
  autoOpenImagesFromText(content); // pop generated images in the OS viewer
263
281
  surface.finishTraceTurn('done');
264
282
  break;
265
283
  }
284
+ case 'suggested_followups': {
285
+ // Stash now; render in 'complete' (after the answer is finalised).
286
+ const items = Array.isArray(d.followups) ? d.followups.map((x) => String(x)) : [];
287
+ if (items.length)
288
+ pendingFollowups = items;
289
+ break;
290
+ }
291
+ case 'notebook_ready': {
292
+ // A clarified plan was turned into a notebook. formatTrace drops this
293
+ // (no message/stage field), so surface the id or the user never knows.
294
+ const id = String(d.notebook_id || d.plan_id || '').trim();
295
+ surface.outputLine(chalk.cyan(` 📓 Notebook ready${id ? `: ${id}` : ''}`));
296
+ break;
297
+ }
298
+ case 'stream_warning':
299
+ // 60s+ of stream silence (e.g. reasoning model cold-start). Reassure
300
+ // the user the turn isn't dead.
301
+ surface.traceLine(chalk.yellow(` ⏳ ${d.message || 'still working — model may be warming up…'}`));
302
+ break;
266
303
  case 'heartbeat':
267
304
  case 'keepalive':
268
305
  case 'debug':
@@ -109,6 +109,11 @@ export async function startTuiRepl(client, config) {
109
109
  let histIdx = history.length;
110
110
  let activeAbort = null;
111
111
  let processing = false;
112
+ // Type-ahead queue: messages typed while a turn is streaming. Run as fresh
113
+ // turns the instant the current one finishes (Claude-Code style) instead of
114
+ // being dropped into a steer that oneshot/trivial turns silently discard.
115
+ let queuedInputs = [];
116
+ let draining = false;
112
117
  let sigintCount = 0;
113
118
  let sigintTimer = null;
114
119
  let gpuTag = '';
@@ -298,8 +303,13 @@ export async function startTuiRepl(client, config) {
298
303
  }
299
304
  sigintCount++;
300
305
  if (sigintCount >= 2) {
306
+ const q = queuedInputs.length;
301
307
  setTuiActive(null);
302
308
  surface.destroy();
309
+ // Write AFTER destroy so it lands in the restored terminal, not the
310
+ // torn-down blessed screen — otherwise the user never sees it.
311
+ if (q)
312
+ process.stderr.write(`\n⚠ Discarded ${q} queued message(s).\n`);
303
313
  process.exit(0);
304
314
  }
305
315
  surface.setStatus('Ctrl+C again to exit');
@@ -390,18 +400,54 @@ export async function startTuiRepl(client, config) {
390
400
  relayStatus('sent · agents may reply…');
391
401
  return;
392
402
  }
393
- // Steer an active stream.
403
+ // A turn is in flight. cancel/stop/abort interrupt it; anything else is
404
+ // queued and run as a fresh turn the moment this one finishes. (The old
405
+ // fire-and-forget steer was silently dropped on oneshot/trivial turns —
406
+ // which is every plain question — so the message just vanished.)
394
407
  if (activeAbort && processing) {
395
408
  const lower = clean.toLowerCase();
396
409
  if (lower === 'cancel' || lower === 'stop' || lower === '@abort') {
397
410
  onInterrupt();
398
411
  return;
399
412
  }
400
- surface.traceLine(chalk.yellow(` 🔄 steer: ${clean}`));
401
- client.steer(config.sessionId, clean, 'append').catch(() => { });
413
+ queuedInputs.push(clean);
414
+ surface.traceLine(chalk.cyan(` ⏳ queued (${queuedInputs.length}) sends after this turn`));
402
415
  return;
403
416
  }
404
- await runChat(clean, explicitBg);
417
+ // Bare digit (1-9) → expand to the Nth follow-up suggestion from the last turn.
418
+ const _pick = clean.match(/^([1-9])$/);
419
+ if (_pick) {
420
+ const fups = surface.getPendingFollowups();
421
+ const idx = parseInt(_pick[1], 10) - 1;
422
+ if (idx >= 0 && idx < fups.length) {
423
+ const chosen = fups[idx];
424
+ surface.setPendingFollowups([]); // consume
425
+ surface.outputLine(chalk.dim(` → ${chosen}`));
426
+ await runChatDraining(chosen, explicitBg);
427
+ return;
428
+ }
429
+ }
430
+ surface.setPendingFollowups([]); // any new message invalidates last turn's chips
431
+ await runChatDraining(clean, explicitBg);
432
+ }
433
+ /** Run a chat turn, then drain any messages the user typed while it streamed,
434
+ * running each as its own turn in order. The `draining` guard keeps two
435
+ * rapid submits from spinning up overlapping drain loops. */
436
+ async function runChatDraining(input, bg = false) {
437
+ await runChat(input, bg);
438
+ if (draining)
439
+ return;
440
+ draining = true;
441
+ try {
442
+ while (queuedInputs.length && !processing) {
443
+ const next = queuedInputs.shift();
444
+ surface.outputLine(chalk.green('› ') + chalk.dim('(queued) ') + next);
445
+ await runChat(next, false);
446
+ }
447
+ }
448
+ finally {
449
+ draining = false;
450
+ }
405
451
  }
406
452
  function showHelp() {
407
453
  surface.outputLine(chalk.bold('Keys: ') + chalk.dim('Edit: ←/→ move · Ctrl+A/E line start/end · Ctrl+W del word · Ctrl+U/K kill · Home/End (empty line=scroll) | Panes: wheel/PgUp scroll · Ctrl+←/→ resize split · Ctrl+T trace · Ctrl+E expand all · click thread | Ctrl+C abort/exit · Tab complete'));
@@ -427,9 +473,76 @@ export async function startTuiRepl(client, config) {
427
473
  }
428
474
  surface.focusInput();
429
475
  }
430
- function handleJobs(args) {
476
+ async function handleJobs(args) {
431
477
  const parts = args.trim().split(/\s+/);
432
478
  const sub = parts[0] || '';
479
+ // ── Cloud (durable expedition) jobs — mirror the classic REPL surface ──
480
+ if (sub === 'cloud') {
481
+ const expId = parts[1];
482
+ if (!expId) {
483
+ const result = await client.listExpeditions();
484
+ if (!result || result.error) {
485
+ surface.outputLine(chalk.red(` Error: ${result?.error || 'Failed to list expeditions'}`));
486
+ return;
487
+ }
488
+ const exps = result.expeditions || [];
489
+ if (!exps.length) {
490
+ surface.outputLine(chalk.dim(' No cloud expeditions.'));
491
+ return;
492
+ }
493
+ surface.outputLine(chalk.bold(' Cloud Expeditions (Durable Jobs)'));
494
+ for (const e of exps) {
495
+ surface.outputLine(` ${chalk.bold(String(e.id).slice(0, 8))} ${e.status || '?'} ${(e.title || '').slice(0, 44)}`);
496
+ }
497
+ surface.outputLine(chalk.dim(' /jobs cloud <id> · /jobs steer <id> <msg> · /jobs watch <id>'));
498
+ return;
499
+ }
500
+ const status = await client.getExpeditionStatus(expId);
501
+ if (!status || status.error) {
502
+ surface.outputLine(chalk.red(` Expedition not found: ${expId}`));
503
+ return;
504
+ }
505
+ surface.outputLine(` ${chalk.bold(expId)} status: ${status.status || '?'}`);
506
+ const tasks = await client.getExpeditionTasks(expId);
507
+ for (const t of (tasks?.tasks || [])) {
508
+ const out = (t.result_summary || t.error || '').slice(0, 500);
509
+ surface.outputLine(` • ${t.title || t.id}: ${t.status}${out ? `\n ${out}` : ''}`);
510
+ }
511
+ return;
512
+ }
513
+ if (sub === 'steer' || sub === 'hint') {
514
+ const expId = parts[1];
515
+ const msg = parts.slice(2).join(' ');
516
+ if (!expId || !msg) {
517
+ surface.outputLine(chalk.yellow(`Usage: /jobs ${sub} <id> <message>`));
518
+ return;
519
+ }
520
+ const r = await client.steerExpedition(expId, msg, sub === 'hint' ? 'hint' : 'append');
521
+ surface.outputLine(r?.ok ? chalk.green(` Steered ${expId} (${sub}).`) : chalk.red(` Steer failed: ${r?.error || r?.detail || 'unknown'}`));
522
+ return;
523
+ }
524
+ if (sub === 'watch') {
525
+ const expId = parts[1];
526
+ if (!expId) {
527
+ surface.outputLine(chalk.yellow('Usage: /jobs watch <id>'));
528
+ return;
529
+ }
530
+ surface.outputLine(chalk.dim(` Watching ${expId}…`));
531
+ try {
532
+ for await (const ev of client.streamExpeditionEvents(expId)) {
533
+ const d = ev.data || {};
534
+ const line = d.message || d.error || ev.type;
535
+ if (line)
536
+ surface.outputLine(` [${ev.type}] ${line}`);
537
+ if (ev.type === 'error' || d.status === 'completed' || d.status === 'failed')
538
+ break;
539
+ }
540
+ }
541
+ catch (err) {
542
+ surface.outputLine(chalk.red(` Watch error: ${err.message}`));
543
+ }
544
+ return;
545
+ }
433
546
  if (sub === 'cancel' || sub === 'kill') {
434
547
  const id = Number(parts[1]);
435
548
  if (!id) {
@@ -738,7 +851,7 @@ export async function startTuiRepl(client, config) {
738
851
  return;
739
852
  }
740
853
  if (name === 'jobs') {
741
- handleJobs(args);
854
+ await handleJobs(args);
742
855
  return;
743
856
  }
744
857
  if (name === 'relay') {
@@ -868,7 +981,9 @@ export async function startTuiRepl(client, config) {
868
981
  surface.traceLine(chalk.yellow(' ⚠ LLM pool busy (0 slots) — sending anyway'));
869
982
  }
870
983
  }
871
- catch { /* */ }
984
+ catch {
985
+ surface.traceLine(chalk.yellow(' ⚠ pre-flight check failed (backend slow or down) — sending anyway'));
986
+ }
872
987
  surface.outputLine(chalk.green('› ') + input);
873
988
  surface.setStatus('working…');
874
989
  // Open a fresh, collapsible trace thread for this turn (older turns collapse).
@@ -936,8 +1051,11 @@ export async function startTuiRepl(client, config) {
936
1051
  aborted = true;
937
1052
  surface.outputLine(chalk.dim(' (interrupted)'));
938
1053
  }
939
- else
940
- surface.outputLine(chalk.red(` Error: ${err?.message || err}`));
1054
+ else {
1055
+ // First line only, bounded — never spray a raw stack/JSON at the user.
1056
+ const raw = typeof err?.message === 'string' ? err.message : String(err);
1057
+ surface.outputLine(chalk.red(` Error: ${raw.split('\n')[0].slice(0, 200)}`));
1058
+ }
941
1059
  renderer.finish(aborted);
942
1060
  break;
943
1061
  }
@@ -33,6 +33,11 @@ export interface TuiSurface {
33
33
  clearPanes(): void;
34
34
  /** Show/hide the TRACE pane (OUTPUT widens to fill). */
35
35
  toggleTrace(): void;
36
+ /** Stash the suggested follow-ups from the last turn so a bare digit in the
37
+ * input box can expand to "send the Nth follow-up". Cleared when consumed. */
38
+ setPendingFollowups(items: string[]): void;
39
+ /** The currently-stashed follow-ups (empty if none / already consumed). */
40
+ getPendingFollowups(): string[];
36
41
  /** Coalesced repaint. */
37
42
  render(): void;
38
43
  /** Focus the input box for typing. */
@@ -155,6 +155,9 @@ export function createTuiScreen(opts) {
155
155
  scheduleRender();
156
156
  }
157
157
  function markCheckpoint() { return outputBuf.length; }
158
+ let pendingFollowups = [];
159
+ function setPendingFollowups(items) { pendingFollowups = Array.isArray(items) ? items.slice(0, 9) : []; }
160
+ function getPendingFollowups() { return pendingFollowups; }
158
161
  function replaceOutputFrom(offset, text) {
159
162
  if (offset < 0 || offset > outputBuf.length)
160
163
  return;
@@ -199,6 +202,18 @@ export function createTuiScreen(opts) {
199
202
  for (const l of t.lines)
200
203
  out.push(l);
201
204
  });
205
+ // Belt-and-suspenders against blessed "character bleed": when the trace
206
+ // SHRINKS (a long turn collapses into a short new one) blessed's wide-char
207
+ // (emoji) miscount in wrapped lines leaves stale glyphs in the now-uncovered
208
+ // rows. clearRegion alone proved unreliable for this, so when the content is
209
+ // shorter than the pane, pad with blank lines so setContent paints over every
210
+ // visible row. Skip when the user has scrolled up (don't perturb their view)
211
+ // or when content already fills/overflows the pane (no uncovered rows).
212
+ if (!userPinnedTrace) {
213
+ const innerH = (typeof trace.height === 'number' ? trace.height : 0) - 2;
214
+ while (innerH > out.length)
215
+ out.push('');
216
+ }
202
217
  const base = trace.childBase || 0;
203
218
  try {
204
219
  trace.setContent(out.join('\n'));
@@ -1040,6 +1055,7 @@ export function createTuiScreen(opts) {
1040
1055
  return {
1041
1056
  screen, input, appendOutput, outputLine, markCheckpoint, replaceOutputFrom, prependOutput,
1042
1057
  traceLine, startTraceTurn, finishTraceTurn, setStatus, setTracePanel, setOutputLabel, clearPanes, toggleTrace, render, focusInput, destroy,
1058
+ setPendingFollowups, getPendingFollowups,
1043
1059
  pickerOpen, showPicker, showViewer, showEditor, runDetached,
1044
1060
  };
1045
1061
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aitherium/shell-cli",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
4
4
  "description": "Interactive terminal for AitherOS and ADK agents",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",