@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 +10 -0
- package/dist/client.js +62 -6
- package/dist/commands.js +8 -0
- package/dist/install-wizard.d.ts +94 -0
- package/dist/install-wizard.js +397 -0
- package/dist/main.js +1 -1
- package/dist/renderer.js +51 -3
- package/dist/repl.js +204 -10
- package/dist/tui/controller.js +37 -0
- package/dist/tui/repl-tui.js +127 -9
- package/dist/tui/screen.d.ts +5 -0
- package/dist/tui/screen.js +16 -0
- package/package.json +1 -1
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
|
-
//
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
}
|
|
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(
|
|
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
|
|
626
|
-
// trace/getContent.
|
|
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
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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) {
|
package/dist/tui/controller.js
CHANGED
|
@@ -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':
|
package/dist/tui/repl-tui.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
401
|
-
|
|
413
|
+
queuedInputs.push(clean);
|
|
414
|
+
surface.traceLine(chalk.cyan(` ⏳ queued (${queuedInputs.length}) — sends after this turn`));
|
|
402
415
|
return;
|
|
403
416
|
}
|
|
404
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/tui/screen.d.ts
CHANGED
|
@@ -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. */
|
package/dist/tui/screen.js
CHANGED
|
@@ -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
|
}
|