@aitherium/shell-cli 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +68 -0
- package/dist/auth.js +288 -0
- package/dist/client.d.ts +116 -0
- package/dist/client.js +528 -0
- package/dist/command-registry.d.ts +71 -0
- package/dist/command-registry.js +223 -0
- package/dist/commands.d.ts +14 -0
- package/dist/commands.js +6785 -0
- package/dist/completions.d.ts +27 -0
- package/dist/completions.js +351 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +48 -0
- package/dist/gargbot.d.ts +11 -0
- package/dist/gargbot.js +230 -0
- package/dist/jobs.d.ts +65 -0
- package/dist/jobs.js +386 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +389 -0
- package/dist/notebooks.d.ts +19 -0
- package/dist/notebooks.js +685 -0
- package/dist/products.d.ts +12 -0
- package/dist/products.js +159 -0
- package/dist/renderer.d.ts +104 -0
- package/dist/renderer.js +1812 -0
- package/dist/repl.d.ts +16 -0
- package/dist/repl.js +1190 -0
- package/dist/session-store.d.ts +35 -0
- package/dist/session-store.js +153 -0
- package/dist/tunnel.d.ts +13 -0
- package/dist/tunnel.js +169 -0
- package/package.json +36 -0
package/dist/repl.js
ADDED
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive REPL — readline, streaming, history, Ctrl+C,
|
|
3
|
+
* shell escape (!), interactive slash-command picker (/), agent routing (@).
|
|
4
|
+
*
|
|
5
|
+
* NON-BLOCKING: Long-running tasks (forge, swarm, agent chat with &) run
|
|
6
|
+
* as background jobs. The REPL stays interactive — you can chat, queue more
|
|
7
|
+
* tasks, or check job status while agents work.
|
|
8
|
+
*
|
|
9
|
+
* "/" on an empty line immediately launches a searchable command picker
|
|
10
|
+
* (via @inquirer/prompts) WITHOUT pressing Enter. This is achieved by
|
|
11
|
+
* monkey-patching readline's _ttyWrite to intercept before the character
|
|
12
|
+
* is added to the line buffer.
|
|
13
|
+
*/
|
|
14
|
+
import * as readline from 'node:readline';
|
|
15
|
+
import { execSync } from 'node:child_process';
|
|
16
|
+
import { existsSync, readFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
import chalk from 'chalk';
|
|
19
|
+
import { search, Separator } from '@inquirer/prompts';
|
|
20
|
+
import { createStreamRenderer, SteeringBar } from './renderer.js';
|
|
21
|
+
import { getCommand, getCommandNames } from './commands.js';
|
|
22
|
+
import { getCommandRegistry } from './command-registry.js';
|
|
23
|
+
import { loadAgentNames, resolveAgentMention, completer, refreshCommandCompletions, SUBCOMMAND_DEFS } from './completions.js';
|
|
24
|
+
import { setJobNotifier, listJobs, getJob, cancelJob, runningCount, launchChatJob, launchForgeJob, launchSwarmJob, formatJobLine, formatJobOutput, } from './jobs.js';
|
|
25
|
+
import { configureRemoteSync, syncSessionToRemote } from './session-store.js';
|
|
26
|
+
/** Commands that are long-running and auto-background. */
|
|
27
|
+
const AUTO_BG_COMMANDS = new Set(['forge', 'swarm']);
|
|
28
|
+
/** Post session profile to Strata for observability (best-effort). */
|
|
29
|
+
async function postToStrata(client, profile) {
|
|
30
|
+
const strataUrl = client.baseUrl.replace(/:\d+$/, ':8136');
|
|
31
|
+
await fetch(`${strataUrl}/api/v1/ingest/ide-session`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
source: 'aither-shell',
|
|
36
|
+
session_id: profile.session_id,
|
|
37
|
+
prompt: profile.prompt,
|
|
38
|
+
model: profile.model,
|
|
39
|
+
agent: profile.agent,
|
|
40
|
+
event_count: profile.event_count,
|
|
41
|
+
tool_calls: profile.tool_calls.map(t => t.name),
|
|
42
|
+
errors: profile.errors,
|
|
43
|
+
duration_ms: profile.duration_ms,
|
|
44
|
+
thinking_traces: profile.thinking_traces.length,
|
|
45
|
+
}),
|
|
46
|
+
signal: AbortSignal.timeout(5000),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export async function startRepl(client, config) {
|
|
50
|
+
// Enable cross-client session sync (local CLI ↔ tunnel.aitherium.com)
|
|
51
|
+
configureRemoteSync(config.genesisUrl, config.authToken);
|
|
52
|
+
const agents = await loadAgentNames(client);
|
|
53
|
+
// Auto-discover commands from Genesis (MCP tools, ADK groups, tagged endpoints)
|
|
54
|
+
const registry = getCommandRegistry();
|
|
55
|
+
const dynamicCount = await registry.loadDynamicCommands(client);
|
|
56
|
+
if (dynamicCount > 0) {
|
|
57
|
+
refreshCommandCompletions();
|
|
58
|
+
}
|
|
59
|
+
const history = loadHistory(config.historyFile);
|
|
60
|
+
/** GPU status tag — updated by background poller. */
|
|
61
|
+
let gpuTag = '';
|
|
62
|
+
const rl = readline.createInterface({
|
|
63
|
+
input: process.stdin,
|
|
64
|
+
output: process.stdout,
|
|
65
|
+
prompt: buildPrompt(),
|
|
66
|
+
completer: completer(agents),
|
|
67
|
+
history,
|
|
68
|
+
historySize: 500,
|
|
69
|
+
terminal: true,
|
|
70
|
+
});
|
|
71
|
+
// ── Keep event loop alive ──
|
|
72
|
+
// inquirer and AbortSignal.timeout can drop stdin refs.
|
|
73
|
+
// This interval prevents premature exit in TTY mode.
|
|
74
|
+
process.stdin.ref();
|
|
75
|
+
let keepAlive = null;
|
|
76
|
+
if (process.stdin.isTTY) {
|
|
77
|
+
keepAlive = setInterval(() => { }, 2_147_483_647);
|
|
78
|
+
}
|
|
79
|
+
let gpuPollTimer = null;
|
|
80
|
+
function cleanExit(code = 0) {
|
|
81
|
+
if (keepAlive)
|
|
82
|
+
clearInterval(keepAlive);
|
|
83
|
+
if (gpuPollTimer)
|
|
84
|
+
clearInterval(gpuPollTimer);
|
|
85
|
+
process.exit(code);
|
|
86
|
+
}
|
|
87
|
+
// Catch unhandled rejections (orphan AbortSignal.timeout)
|
|
88
|
+
process.on('unhandledRejection', (err) => {
|
|
89
|
+
if (err?.name === 'AbortError' || err?.code === 'ABORT_ERR')
|
|
90
|
+
return;
|
|
91
|
+
console.error(chalk.red(` Unhandled error: ${err?.message || err}`));
|
|
92
|
+
});
|
|
93
|
+
let activeAbort = null;
|
|
94
|
+
const steeringBar = new SteeringBar();
|
|
95
|
+
let sigintCount = 0;
|
|
96
|
+
let pendingClose = false;
|
|
97
|
+
let closed = false;
|
|
98
|
+
let lastShellCmd = '';
|
|
99
|
+
let menuActive = false;
|
|
100
|
+
/** Pending clarification gate from the last plan response. */
|
|
101
|
+
let pendingGate = null;
|
|
102
|
+
/** Last session profile for RLM context injection across turns. */
|
|
103
|
+
let lastSessionProfile = null;
|
|
104
|
+
/** Build prompt with running job count and GPU status. */
|
|
105
|
+
function buildPrompt() {
|
|
106
|
+
const running = runningCount();
|
|
107
|
+
const parts = [];
|
|
108
|
+
if (running > 0)
|
|
109
|
+
parts.push(String(running));
|
|
110
|
+
if (gpuTag)
|
|
111
|
+
parts.push(gpuTag);
|
|
112
|
+
const suffix = parts.length > 0
|
|
113
|
+
? chalk.dim(`[${parts.join('|')}]`)
|
|
114
|
+
: '';
|
|
115
|
+
return chalk.green('aither') + suffix + chalk.green('> ');
|
|
116
|
+
}
|
|
117
|
+
/** Update the prompt to reflect current job count / GPU state. */
|
|
118
|
+
function refreshPrompt() {
|
|
119
|
+
rl.setPrompt(buildPrompt());
|
|
120
|
+
}
|
|
121
|
+
/** Poll MicroScheduler GPU zone every 10s and update prompt tag. */
|
|
122
|
+
gpuPollTimer = setInterval(async () => {
|
|
123
|
+
try {
|
|
124
|
+
const st = await client.getGpuStatus();
|
|
125
|
+
const zone = st?.zone || '';
|
|
126
|
+
const prev = gpuTag;
|
|
127
|
+
if (zone.includes('image')) {
|
|
128
|
+
gpuTag = chalk.yellow('GPU:3D');
|
|
129
|
+
}
|
|
130
|
+
else if (st?.active) {
|
|
131
|
+
gpuTag = chalk.cyan('GPU:R');
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
gpuTag = '';
|
|
135
|
+
}
|
|
136
|
+
if (gpuTag !== prev)
|
|
137
|
+
refreshPrompt();
|
|
138
|
+
}
|
|
139
|
+
catch { }
|
|
140
|
+
}, 10_000);
|
|
141
|
+
/** Restore readline + stdin after inquirer or any handler that disturbs them. */
|
|
142
|
+
function restoreReadline() {
|
|
143
|
+
process.stdin.ref();
|
|
144
|
+
process.stdin.resume();
|
|
145
|
+
rl.resume();
|
|
146
|
+
// inquirer turns off raw mode; readline needs it on for _ttyWrite to fire
|
|
147
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
|
|
148
|
+
process.stdin.setRawMode(true);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Print a notification above the current prompt line without disrupting
|
|
153
|
+
* what the user is typing. Clears the line, prints, then re-renders prompt.
|
|
154
|
+
* If a foreground task is active (readline paused), just print inline.
|
|
155
|
+
*/
|
|
156
|
+
function notifyAbovePrompt(text) {
|
|
157
|
+
if (closed)
|
|
158
|
+
return;
|
|
159
|
+
if (activeAbort || processing) {
|
|
160
|
+
// Foreground task active — just print, don't touch readline
|
|
161
|
+
console.log(text);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Save current line content
|
|
165
|
+
const currentLine = rl.line || '';
|
|
166
|
+
// Move to start, clear line, print notification
|
|
167
|
+
process.stdout.write('\r\x1B[2K');
|
|
168
|
+
console.log(text);
|
|
169
|
+
// Re-render prompt + whatever the user was typing
|
|
170
|
+
refreshPrompt();
|
|
171
|
+
rl.prompt(true);
|
|
172
|
+
// Restore the user's in-progress input
|
|
173
|
+
if (currentLine) {
|
|
174
|
+
rl.line = currentLine;
|
|
175
|
+
rl.cursor = currentLine.length;
|
|
176
|
+
process.stdout.write(currentLine);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// ── Background job notifications ──
|
|
180
|
+
setJobNotifier((job) => {
|
|
181
|
+
const icon = job.status === 'completed' ? chalk.green('\u2713')
|
|
182
|
+
: job.status === 'failed' ? chalk.red('\u2717')
|
|
183
|
+
: chalk.yellow('\u2015');
|
|
184
|
+
const statusColor = job.status === 'completed' ? chalk.green
|
|
185
|
+
: job.status === 'failed' ? chalk.red
|
|
186
|
+
: chalk.yellow;
|
|
187
|
+
const header = ` ${icon} Job ${chalk.bold('#' + job.id)} ${statusColor(job.status)}: ${job.label}`;
|
|
188
|
+
// Show content preview for completed jobs so user doesn't need /jobs <id> for short responses
|
|
189
|
+
let preview = '';
|
|
190
|
+
if (job.status === 'completed' && job.output.length > 0) {
|
|
191
|
+
// Find the actual content (after the --- separator, or last non-trace line)
|
|
192
|
+
const sepIdx = job.output.indexOf('---');
|
|
193
|
+
const contentLines = sepIdx >= 0
|
|
194
|
+
? job.output.slice(sepIdx + 1)
|
|
195
|
+
: job.output.filter(l => !l.startsWith('['));
|
|
196
|
+
const content = contentLines.join('\n').trim();
|
|
197
|
+
if (content.length > 0 && content.length <= 500) {
|
|
198
|
+
// Short enough to show inline
|
|
199
|
+
preview = '\n' + content.split('\n').map(l => ` ${l}`).join('\n');
|
|
200
|
+
}
|
|
201
|
+
else if (content.length > 500) {
|
|
202
|
+
// Show first few lines + hint
|
|
203
|
+
const firstLines = content.split('\n').slice(0, 4).join('\n');
|
|
204
|
+
preview = '\n' + firstLines.split('\n').map(l => ` ${l}`).join('\n')
|
|
205
|
+
+ chalk.dim(`\n ... (${content.length} chars — /jobs ${job.id} for full output)`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else if (job.status === 'failed' && job.error) {
|
|
209
|
+
preview = '\n' + chalk.red(` ${job.error}`);
|
|
210
|
+
}
|
|
211
|
+
if (preview) {
|
|
212
|
+
notifyAbovePrompt(header + preview);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
notifyAbovePrompt(header + chalk.dim(` (/jobs ${job.id} for output)`));
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
// ── Auth status in banner ──
|
|
219
|
+
if (config.authUser) {
|
|
220
|
+
console.log(chalk.green(` Logged in as ${config.authUser.display_name || config.authUser.username}`));
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
console.log(chalk.dim(' Not logged in. Use /login to authenticate.'));
|
|
224
|
+
}
|
|
225
|
+
console.log(chalk.dim(' / commands ! shell @ directives/agents & background ? help'));
|
|
226
|
+
console.log();
|
|
227
|
+
// ── Register shell session with Genesis (best effort) ──
|
|
228
|
+
const _sessionRegistered = client.post('/shell/session/start', {
|
|
229
|
+
client_type: 'ts-shell',
|
|
230
|
+
session_id: config.sessionId,
|
|
231
|
+
username: config.authUser?.username,
|
|
232
|
+
user_id: config.authUser?.id,
|
|
233
|
+
}).catch(() => { });
|
|
234
|
+
// ── Build command choices for the interactive picker ──
|
|
235
|
+
const cmdChoices = buildCommandChoices();
|
|
236
|
+
// ── Monkey-patch _ttyWrite: "/" on empty line opens picker instantly ──
|
|
237
|
+
// Also: update SteeringBar display with current input when active.
|
|
238
|
+
if (process.stdin.isTTY) {
|
|
239
|
+
const origTtyWrite = rl._ttyWrite;
|
|
240
|
+
rl._ttyWrite = function (s, key) {
|
|
241
|
+
if (menuActive)
|
|
242
|
+
return; // Block ALL keystrokes while picker is active
|
|
243
|
+
if (s === '/' && this.line.length === 0 && !closed) {
|
|
244
|
+
// ALWAYS open picker for / on empty line — even during steering.
|
|
245
|
+
// Slash commands must never be sent as chat/steer input.
|
|
246
|
+
menuActive = true;
|
|
247
|
+
setImmediate(() => launchCommandPicker());
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
origTtyWrite.call(this, s, key);
|
|
251
|
+
// Keep steering bar in sync with readline's internal buffer
|
|
252
|
+
if (steeringBar.active) {
|
|
253
|
+
steeringBar.setInput(this.line || '');
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
// ── Interactive command picker ──
|
|
258
|
+
async function launchCommandPicker() {
|
|
259
|
+
let selected;
|
|
260
|
+
// Track what the user typed so we can extract trailing args
|
|
261
|
+
let lastSearchInput = '';
|
|
262
|
+
try {
|
|
263
|
+
// Erase the prompt line and pause readline
|
|
264
|
+
process.stdout.write('\r\x1B[2K');
|
|
265
|
+
rl.pause();
|
|
266
|
+
selected = await search({
|
|
267
|
+
message: chalk.green('/'),
|
|
268
|
+
source: (input) => {
|
|
269
|
+
lastSearchInput = input || '';
|
|
270
|
+
// Match on the FIRST word only — rest is args
|
|
271
|
+
const term = lastSearchInput.split(/\s/)[0].toLowerCase();
|
|
272
|
+
if (!term)
|
|
273
|
+
return cmdChoices;
|
|
274
|
+
return cmdChoices.filter((c) => {
|
|
275
|
+
if (c.type === 'separator')
|
|
276
|
+
return false;
|
|
277
|
+
return c.value.includes(term)
|
|
278
|
+
|| c.name.toLowerCase().includes(term)
|
|
279
|
+
|| (c.description || '').toLowerCase().includes(term);
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
theme: {
|
|
283
|
+
prefix: ' ',
|
|
284
|
+
style: {
|
|
285
|
+
highlight: (text) => chalk.cyan.bold(text),
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Ctrl+C / Escape — go back to prompt
|
|
292
|
+
}
|
|
293
|
+
menuActive = false;
|
|
294
|
+
// Belt-and-suspenders: clear anything that got through
|
|
295
|
+
rl.line = '';
|
|
296
|
+
rl.cursor = 0;
|
|
297
|
+
restoreReadline();
|
|
298
|
+
if (selected) {
|
|
299
|
+
// Check if the user typed args after the command in the picker
|
|
300
|
+
// e.g. typed "nb list" — selected="nb", trailing args="list"
|
|
301
|
+
const pickerParts = (lastSearchInput || '').trim().split(/\s+/);
|
|
302
|
+
const trailingArgs = pickerParts.length > 1 ? pickerParts.slice(1).join(' ') : '';
|
|
303
|
+
if (trailingArgs) {
|
|
304
|
+
// User typed command + args in picker — execute immediately
|
|
305
|
+
lineQueue.push('/' + selected + ' ' + trailingArgs);
|
|
306
|
+
drainQueue();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// If the command has subcommands, show a second picker
|
|
310
|
+
const subDefs = SUBCOMMAND_DEFS['/' + selected];
|
|
311
|
+
if (subDefs && subDefs.length > 0) {
|
|
312
|
+
let subSelected;
|
|
313
|
+
let subSearchInput = '';
|
|
314
|
+
try {
|
|
315
|
+
process.stdout.write('\r\x1B[2K');
|
|
316
|
+
rl.pause();
|
|
317
|
+
const subChoices = subDefs.map(([name, argHint]) => ({
|
|
318
|
+
name: argHint
|
|
319
|
+
? `${chalk.cyan(name)} ${chalk.dim(argHint)}`
|
|
320
|
+
: chalk.cyan(name),
|
|
321
|
+
value: name,
|
|
322
|
+
description: '',
|
|
323
|
+
}));
|
|
324
|
+
subSelected = await search({
|
|
325
|
+
message: chalk.green(`/${selected} `),
|
|
326
|
+
source: (input) => {
|
|
327
|
+
subSearchInput = input || '';
|
|
328
|
+
const term = subSearchInput.split(/\s/)[0].toLowerCase();
|
|
329
|
+
if (!term)
|
|
330
|
+
return subChoices;
|
|
331
|
+
return subChoices.filter((c) => c.value.includes(term));
|
|
332
|
+
},
|
|
333
|
+
theme: {
|
|
334
|
+
prefix: ' ',
|
|
335
|
+
style: { highlight: (text) => chalk.cyan.bold(text) },
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// Ctrl+C / Escape
|
|
341
|
+
}
|
|
342
|
+
menuActive = false;
|
|
343
|
+
rl.line = '';
|
|
344
|
+
rl.cursor = 0;
|
|
345
|
+
restoreReadline();
|
|
346
|
+
if (subSelected) {
|
|
347
|
+
// Check for trailing args typed in sub-picker
|
|
348
|
+
const subParts = (subSearchInput || '').trim().split(/\s+/);
|
|
349
|
+
const subArgs = subParts.length > 1 ? subParts.slice(1).join(' ') : '';
|
|
350
|
+
if (subArgs) {
|
|
351
|
+
// User typed args in picker — execute immediately
|
|
352
|
+
lineQueue.push(`/${selected} ${subSelected} ${subArgs}`);
|
|
353
|
+
drainQueue();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// Check if this subcommand needs args
|
|
357
|
+
const subDef = subDefs.find(([n]) => n === subSelected);
|
|
358
|
+
const needsSubArgs = subDef && subDef[1] !== '';
|
|
359
|
+
if (needsSubArgs) {
|
|
360
|
+
// Prefill so user can type the arg, e.g. "/nb create "
|
|
361
|
+
rl.prompt();
|
|
362
|
+
rl.write(`/${selected} ${subSelected} `);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// No args needed — execute immediately
|
|
366
|
+
lineQueue.push(`/${selected} ${subSelected}`);
|
|
367
|
+
drainQueue();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
rl.prompt();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// No subcommands — execute immediately
|
|
374
|
+
// Handle MCP tool selections from the picker
|
|
375
|
+
if (selected.startsWith('mcp:')) {
|
|
376
|
+
const toolName = selected.slice(4);
|
|
377
|
+
lineQueue.push('/' + toolName);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
lineQueue.push('/' + selected);
|
|
381
|
+
}
|
|
382
|
+
drainQueue();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
rl.prompt();
|
|
386
|
+
}
|
|
387
|
+
// ── Line queue — serialize processing but DON'T block on bg tasks ──
|
|
388
|
+
const lineQueue = [];
|
|
389
|
+
let processing = false;
|
|
390
|
+
async function drainQueue() {
|
|
391
|
+
if (processing)
|
|
392
|
+
return;
|
|
393
|
+
processing = true;
|
|
394
|
+
try {
|
|
395
|
+
while (lineQueue.length > 0) {
|
|
396
|
+
const input = lineQueue.shift();
|
|
397
|
+
await processLine(input);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
console.error(chalk.red(` Internal error: ${err.message}`));
|
|
402
|
+
}
|
|
403
|
+
processing = false;
|
|
404
|
+
menuActive = false;
|
|
405
|
+
if (pendingClose) {
|
|
406
|
+
cleanExit(0);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (!closed) {
|
|
410
|
+
refreshPrompt();
|
|
411
|
+
restoreReadline();
|
|
412
|
+
rl.prompt();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async function processLine(input) {
|
|
416
|
+
if (!input)
|
|
417
|
+
return;
|
|
418
|
+
saveHistory(config.historyFile, input);
|
|
419
|
+
// Exit
|
|
420
|
+
if (input === 'exit' || input === 'quit') {
|
|
421
|
+
const running = runningCount();
|
|
422
|
+
if (running > 0) {
|
|
423
|
+
console.log(chalk.yellow(` ${running} background job(s) still running. They will be cancelled.`));
|
|
424
|
+
}
|
|
425
|
+
console.log(chalk.dim(' Goodbye.'));
|
|
426
|
+
rl.close();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
// ── ? — quick help ──
|
|
430
|
+
if (input === '?') {
|
|
431
|
+
printHelp(config);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
// ── ! — shell escape (execute in pwsh 7) ──
|
|
435
|
+
if (input.startsWith('!')) {
|
|
436
|
+
let shellCmd = input.slice(1).trim();
|
|
437
|
+
if (shellCmd === '!' || !shellCmd) {
|
|
438
|
+
if (input === '!!' && lastShellCmd) {
|
|
439
|
+
shellCmd = lastShellCmd;
|
|
440
|
+
console.log(chalk.dim(` > ${shellCmd}`));
|
|
441
|
+
}
|
|
442
|
+
else if (!shellCmd) {
|
|
443
|
+
console.log(chalk.yellow(' Usage: !command (run in PowerShell 7)'));
|
|
444
|
+
console.log(chalk.dim(' !! repeats last shell command'));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
lastShellCmd = shellCmd;
|
|
449
|
+
console.log();
|
|
450
|
+
try {
|
|
451
|
+
execSync(`pwsh -NoProfile -c "${shellCmd.replace(/"/g, '\\"')}"`, {
|
|
452
|
+
stdio: 'inherit',
|
|
453
|
+
timeout: 300000,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
if (err.status) {
|
|
458
|
+
console.log(chalk.yellow(` exit ${err.status}`));
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
console.log(chalk.red(` Error: ${err.message}`));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
console.log();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
// ── Check for trailing & — explicit background request ──
|
|
468
|
+
const explicitBg = input.endsWith(' &') || input.endsWith('\t&');
|
|
469
|
+
const cleanInput = explicitBg ? input.slice(0, -2).trimEnd() : input;
|
|
470
|
+
// ── / — slash commands (via Enter, if user typed /command directly) ──
|
|
471
|
+
if (cleanInput.startsWith('/')) {
|
|
472
|
+
const spaceIdx = cleanInput.indexOf(' ');
|
|
473
|
+
const cmdName = spaceIdx > 0 ? cleanInput.slice(1, spaceIdx) : cleanInput.slice(1);
|
|
474
|
+
const cmdArgs = spaceIdx > 0 ? cleanInput.slice(spaceIdx + 1) : '';
|
|
475
|
+
if (cmdName === 'exit' || cmdName === 'quit') {
|
|
476
|
+
console.log(chalk.dim(' Goodbye.'));
|
|
477
|
+
rl.close();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
// ── /jobs — built-in job management ──
|
|
481
|
+
if (cmdName === 'jobs') {
|
|
482
|
+
handleJobsCommand(cmdArgs);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
// Bare "/" via Enter — launch picker as fallback
|
|
486
|
+
if (!cmdName) {
|
|
487
|
+
if (process.stdin.isTTY && !menuActive) {
|
|
488
|
+
menuActive = true;
|
|
489
|
+
rl.pause();
|
|
490
|
+
await launchCommandPicker();
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
showCommandList();
|
|
494
|
+
}
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const cmd = getCommand(cmdName);
|
|
498
|
+
if (cmd) {
|
|
499
|
+
// Auto-background for forge/swarm, or explicit & for any command
|
|
500
|
+
const shouldBg = explicitBg || AUTO_BG_COMMANDS.has(cmdName);
|
|
501
|
+
if (shouldBg && cmdName === 'forge') {
|
|
502
|
+
// Parse forge args and launch as background job
|
|
503
|
+
launchBgForge(client, cmdArgs);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (shouldBg && cmdName === 'swarm') {
|
|
507
|
+
launchBgSwarm(client, cmdArgs);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
// Other commands with & — wrap in background job
|
|
511
|
+
if (explicitBg) {
|
|
512
|
+
const { launchCommandJob } = await import('./jobs.js');
|
|
513
|
+
const job = launchCommandJob(`/${cmdName} ${cmdArgs}`.trim(), () => cmd.handler(client, cmdArgs, config));
|
|
514
|
+
console.log(chalk.dim(` Background job ${chalk.bold('#' + job.id)} started: /${cmdName}`));
|
|
515
|
+
refreshPrompt();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
// Fully detach readline from stdin so command handlers that read
|
|
520
|
+
// stdin directly (e.g. /login) get exclusive access to keystrokes.
|
|
521
|
+
// rl.pause() alone doesn't work — readline's internal 'data'/'keypress'
|
|
522
|
+
// listeners still intercept and buffer input.
|
|
523
|
+
rl.pause();
|
|
524
|
+
const stdinListeners = process.stdin.rawListeners('data').slice();
|
|
525
|
+
const keypressListeners = process.stdin.rawListeners('keypress').slice();
|
|
526
|
+
process.stdin.removeAllListeners('data');
|
|
527
|
+
process.stdin.removeAllListeners('keypress');
|
|
528
|
+
if (process.stdin.isTTY)
|
|
529
|
+
process.stdin.setRawMode(false);
|
|
530
|
+
process.stdin.resume();
|
|
531
|
+
await cmd.handler(client, cmdArgs, config);
|
|
532
|
+
// Reattach readline's listeners
|
|
533
|
+
process.stdin.removeAllListeners('data');
|
|
534
|
+
process.stdin.removeAllListeners('keypress');
|
|
535
|
+
for (const fn of stdinListeners)
|
|
536
|
+
process.stdin.on('data', fn);
|
|
537
|
+
for (const fn of keypressListeners)
|
|
538
|
+
process.stdin.on('keypress', fn);
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
console.log(chalk.red(` Error: ${err.message}`));
|
|
542
|
+
}
|
|
543
|
+
finally {
|
|
544
|
+
restoreReadline();
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
// Try MCP tool fallback — auto-discovered tools are callable as slash commands
|
|
549
|
+
const mcpTool = registry.getMcpTool(cmdName);
|
|
550
|
+
if (mcpTool) {
|
|
551
|
+
try {
|
|
552
|
+
// Parse args as JSON params or key=value pairs
|
|
553
|
+
let params = {};
|
|
554
|
+
const trimmed = cmdArgs.trim();
|
|
555
|
+
if (trimmed.startsWith('{')) {
|
|
556
|
+
try {
|
|
557
|
+
params = JSON.parse(trimmed);
|
|
558
|
+
}
|
|
559
|
+
catch { /* not JSON */ }
|
|
560
|
+
}
|
|
561
|
+
else if (trimmed) {
|
|
562
|
+
for (const pair of trimmed.split(/\s+/)) {
|
|
563
|
+
const eq = pair.indexOf('=');
|
|
564
|
+
if (eq > 0) {
|
|
565
|
+
params[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
params['input'] = pair;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const spinner = (await import('ora')).default(`Calling ${cmdName}...`).start();
|
|
573
|
+
const result = await client.post('/tools/call', {
|
|
574
|
+
tool: cmdName,
|
|
575
|
+
params,
|
|
576
|
+
});
|
|
577
|
+
spinner.stop();
|
|
578
|
+
const output = result?.result || result?.output || result;
|
|
579
|
+
console.log(chalk.cyan(` [MCP: ${cmdName}]`));
|
|
580
|
+
console.log(typeof output === 'string' ? output : JSON.stringify(output, null, 2));
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
console.log(chalk.red(` MCP tool error: ${err.message}`));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
const names = getCommandNames();
|
|
588
|
+
const close = names.filter(n => n.startsWith(cmdName.toLowerCase().slice(0, 2)));
|
|
589
|
+
const hint = close.length
|
|
590
|
+
? ` Did you mean: ${close.map(n => chalk.cyan('/' + n)).join(', ')}?`
|
|
591
|
+
: ` Type ${chalk.cyan('/')} to open the command picker.`;
|
|
592
|
+
console.log(chalk.yellow(` Unknown command: /${cmdName}`));
|
|
593
|
+
console.log(hint);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
// ── HARD GUARD: special prefixes NEVER become chat messages ──
|
|
599
|
+
// If we reach this point with a "/" prefix, something went wrong above
|
|
600
|
+
// (picker edge case, unknown command, etc). Catch it — never send to LLM.
|
|
601
|
+
if (cleanInput.startsWith('/')) {
|
|
602
|
+
const attempted = cleanInput.split(/\s+/)[0];
|
|
603
|
+
console.log(chalk.yellow(` Unknown command: ${attempted}`));
|
|
604
|
+
console.log(chalk.dim(` Type / to open the command picker, or /help for a list.`));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
// "!" prefix should have been caught above — guard just in case
|
|
608
|
+
if (cleanInput.startsWith('!')) {
|
|
609
|
+
console.log(chalk.yellow(' Usage: !command (run in PowerShell 7)'));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
// @agent routing vs @strategy triggers
|
|
613
|
+
// Strategy triggers (@think, @research, @debug, etc.) should be passed
|
|
614
|
+
// through to Genesis as-is — the StrategyResolver detects them there.
|
|
615
|
+
// Agent routes (@demiurge, @lyra, @atlas, etc.) set the persona.
|
|
616
|
+
const STRATEGY_TRIGGERS = new Set([
|
|
617
|
+
'think', 'reason', 'research', 'council', 'deliberate',
|
|
618
|
+
'swarm', 'plan', 'agentic', 'compete', 'debug',
|
|
619
|
+
'troubleshoot', 'investigate', 'quick',
|
|
620
|
+
// Context-mode directives (ScopeContext)
|
|
621
|
+
'code', 'internal',
|
|
622
|
+
// Personal assistant mode
|
|
623
|
+
'personal',
|
|
624
|
+
// Companion / conversational mode
|
|
625
|
+
'chat', 'companion', 'talk',
|
|
626
|
+
// Creative / story mode
|
|
627
|
+
'story', 'saga', 'narrative',
|
|
628
|
+
// AppForge
|
|
629
|
+
'appforge', 'build',
|
|
630
|
+
// Security testing strategies
|
|
631
|
+
'redteam', 'pentest', 'security', 'hack',
|
|
632
|
+
]);
|
|
633
|
+
let agent = config.defaultAgent;
|
|
634
|
+
let message = cleanInput;
|
|
635
|
+
// Extract ALL leading @mentions (handles "@aither @demi hi" → agents=["aither","demiurge"])
|
|
636
|
+
// Each mention is resolved against the AitherDirectory. Unknown mentions
|
|
637
|
+
// produce a warning and are dropped. Short aliases (e.g., "demi" → "demiurge")
|
|
638
|
+
// are resolved automatically.
|
|
639
|
+
const allMentions = cleanInput.match(/^(?:@(\S+)\s+)+/);
|
|
640
|
+
const resolvedAgents = [];
|
|
641
|
+
if (allMentions) {
|
|
642
|
+
const mentionParts = cleanInput.match(/@(\S+)/g) || [];
|
|
643
|
+
for (const m of mentionParts) {
|
|
644
|
+
const raw = m.slice(1); // strip '@'
|
|
645
|
+
if (STRATEGY_TRIGGERS.has(raw.toLowerCase()))
|
|
646
|
+
continue;
|
|
647
|
+
const { resolved, unknown } = resolveAgentMention(raw);
|
|
648
|
+
if (resolved) {
|
|
649
|
+
if (!resolvedAgents.includes(resolved))
|
|
650
|
+
resolvedAgents.push(resolved);
|
|
651
|
+
}
|
|
652
|
+
else if (unknown) {
|
|
653
|
+
console.log(chalk.yellow(` ⚠ Unknown agent: @${unknown} — not in directory, skipped`));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (resolvedAgents.length > 0) {
|
|
657
|
+
agent = resolvedAgents[0];
|
|
658
|
+
// Keep the FULL message (with all @mentions) so the backend
|
|
659
|
+
// IntentClassifier sees them and prevents trivial classification.
|
|
660
|
+
// The backend strips @mentions internally when building the LLM prompt.
|
|
661
|
+
message = cleanInput;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// Strategy triggers: keep the full "@think ..." in message for Genesis
|
|
665
|
+
// Detect deep strategy triggers that should remove the effort cap
|
|
666
|
+
const DEEP_TRIGGERS = new Set([
|
|
667
|
+
'think', 'reason', 'research', 'council', 'agentic',
|
|
668
|
+
'deliberate', 'debug', 'investigate', 'swarm',
|
|
669
|
+
]);
|
|
670
|
+
const triggerParts = cleanInput.match(/@(\S+)/g) || [];
|
|
671
|
+
const hasDeepTrigger = triggerParts.some(m => DEEP_TRIGGERS.has(m.slice(1).toLowerCase()));
|
|
672
|
+
// ── Background chat (explicit & suffix) ──
|
|
673
|
+
if (explicitBg) {
|
|
674
|
+
const label = agent !== config.defaultAgent
|
|
675
|
+
? `@${agent}: ${message.slice(0, 40)}...`
|
|
676
|
+
: `Chat: ${message.slice(0, 50)}...`;
|
|
677
|
+
const job = launchChatJob(client, message, {
|
|
678
|
+
agent,
|
|
679
|
+
sessionId: config.sessionId,
|
|
680
|
+
model: config.model,
|
|
681
|
+
label,
|
|
682
|
+
});
|
|
683
|
+
console.log(chalk.dim(` Background job ${chalk.bold('#' + job.id)} started: ${label}`));
|
|
684
|
+
refreshPrompt();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
// ── Foreground stream chat ──
|
|
688
|
+
// Pre-flight readiness check — warn before wasting time on a stalled backend
|
|
689
|
+
try {
|
|
690
|
+
const healthResp = await fetch(`${config.genesisUrl}/health`, {
|
|
691
|
+
signal: AbortSignal.timeout(2000),
|
|
692
|
+
});
|
|
693
|
+
if (healthResp.ok) {
|
|
694
|
+
const hData = await healthResp.json();
|
|
695
|
+
if (hData.generation_ready === false) {
|
|
696
|
+
console.log(chalk.yellow(' System busy — LLM pool exhausted (0 slots available).'));
|
|
697
|
+
console.log(chalk.dim(' Sending anyway — pool auto-recovers. Use /pool reset to force.'));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch { /* don't block on health check failure */ }
|
|
702
|
+
// Do NOT pause readline — user can type to steer the active session.
|
|
703
|
+
// Lines entered during generation are routed to /chat/steer.
|
|
704
|
+
// The SteeringBar reserves the bottom terminal row for user input.
|
|
705
|
+
activeAbort = new AbortController();
|
|
706
|
+
steeringBar.activate();
|
|
707
|
+
const renderer = createStreamRenderer(config.sessionId, message);
|
|
708
|
+
// Build session context from previous turn for RLM continuity
|
|
709
|
+
const prevCtx = lastSessionProfile ? {
|
|
710
|
+
summary: `Previous prompt: "${lastSessionProfile.prompt.slice(0, 200)}" → ${lastSessionProfile.model}, ${lastSessionProfile.tool_calls.length} tools, ${lastSessionProfile.errors.length} errors`,
|
|
711
|
+
tools_used: lastSessionProfile.tool_calls.map(t => t.name),
|
|
712
|
+
model: lastSessionProfile.model,
|
|
713
|
+
errors: lastSessionProfile.errors,
|
|
714
|
+
} : undefined;
|
|
715
|
+
// If there's a pending clarification gate, auto-attach it so the
|
|
716
|
+
// backend resolves the gate and refines the plan instead of starting
|
|
717
|
+
// a brand new pipeline. The gate is consumed on use.
|
|
718
|
+
let clarificationResponse;
|
|
719
|
+
if (pendingGate) {
|
|
720
|
+
clarificationResponse = {
|
|
721
|
+
plan_id: pendingGate.planId,
|
|
722
|
+
gate_id: pendingGate.gateId,
|
|
723
|
+
answers: message, // user's natural-language answer
|
|
724
|
+
};
|
|
725
|
+
pendingGate = null; // consume — don't re-send on next message
|
|
726
|
+
}
|
|
727
|
+
try {
|
|
728
|
+
const stream = client.streamChat(message, {
|
|
729
|
+
agent,
|
|
730
|
+
mentions: resolvedAgents.length > 1 ? resolvedAgents : undefined,
|
|
731
|
+
sessionId: config.sessionId,
|
|
732
|
+
model: config.model,
|
|
733
|
+
signal: activeAbort.signal,
|
|
734
|
+
clarificationResponse,
|
|
735
|
+
sessionContext: prevCtx,
|
|
736
|
+
effort: config.effort,
|
|
737
|
+
safetyLevel: config.safetyLevel,
|
|
738
|
+
privateMode: config.privateMode,
|
|
739
|
+
attachments: config.imageAttachments,
|
|
740
|
+
maxEffort: hasDeepTrigger ? undefined : (config.effort || 5),
|
|
741
|
+
});
|
|
742
|
+
// Clear image attachments after including them — they're one-shot
|
|
743
|
+
if (config.imageAttachments?.length) {
|
|
744
|
+
config.imageAttachments = undefined;
|
|
745
|
+
}
|
|
746
|
+
let streamTimedOut = false;
|
|
747
|
+
for await (const event of stream) {
|
|
748
|
+
renderer.onEvent(event);
|
|
749
|
+
if (event.type === 'stream_timeout') {
|
|
750
|
+
streamTimedOut = true;
|
|
751
|
+
}
|
|
752
|
+
// Track clarification_needed events so the NEXT message
|
|
753
|
+
// auto-resolves the gate without user having to do anything special.
|
|
754
|
+
if (event.type === 'clarification_needed' && event.data.plan_id) {
|
|
755
|
+
pendingGate = {
|
|
756
|
+
planId: event.data.plan_id,
|
|
757
|
+
gateId: event.data.gate_id || '',
|
|
758
|
+
questions: event.data.questions || [],
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// Stream ended with timeout — check if forge agent is still running
|
|
763
|
+
if (streamTimedOut) {
|
|
764
|
+
console.log(chalk.yellow('\n ⏱ Stream timed out — no data received for 2 minutes.'));
|
|
765
|
+
console.log(chalk.dim(' Checking if the agent is still working...'));
|
|
766
|
+
try {
|
|
767
|
+
// Check both forge sessions and Genesis status in parallel
|
|
768
|
+
const [forgeData, statusData] = await Promise.all([
|
|
769
|
+
Promise.race([
|
|
770
|
+
client.get('/forge/sessions'),
|
|
771
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 5000)),
|
|
772
|
+
]),
|
|
773
|
+
Promise.race([
|
|
774
|
+
client.getStatus(),
|
|
775
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 5000)),
|
|
776
|
+
]),
|
|
777
|
+
]);
|
|
778
|
+
const sessions = forgeData?.sessions || forgeData?.active || [];
|
|
779
|
+
const active = Array.isArray(sessions) ? sessions.filter((s) => s.status === 'running' || s.status === 'active') : [];
|
|
780
|
+
if (active.length > 0) {
|
|
781
|
+
const agentDetails = active.map((s) => {
|
|
782
|
+
const name = s.agent || s.agent_id || 'agent';
|
|
783
|
+
const elapsed = s.elapsed_ms ? ` (${Math.round(s.elapsed_ms / 1000)}s)` : '';
|
|
784
|
+
return `${name}${elapsed}`;
|
|
785
|
+
}).join(', ');
|
|
786
|
+
console.log(chalk.cyan(` ✓ Agent still processing: ${agentDetails}`));
|
|
787
|
+
console.log(chalk.dim(' The work continues in background — send a follow-up or check /forge.'));
|
|
788
|
+
}
|
|
789
|
+
else if (statusData) {
|
|
790
|
+
const llm = statusData.llm_status || statusData.llm || 'unknown';
|
|
791
|
+
console.log(chalk.dim(` Genesis: ${statusData.status || 'unknown'} | LLM: ${typeof llm === 'object' ? llm.status || 'unknown' : llm}`));
|
|
792
|
+
console.log(chalk.dim(' No active forge sessions found. The request may have failed server-side.'));
|
|
793
|
+
console.log(chalk.dim(' Try again, or use & suffix to run in background: your message &'));
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
console.log(chalk.red(' Could not reach Genesis — services may be down.'));
|
|
797
|
+
console.log(chalk.dim(' Check: /status or docker ps'));
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
console.log(chalk.dim(' Could not check forge status. The agent may still be processing.'));
|
|
802
|
+
console.log(chalk.dim(' Try sending a follow-up message or check /status.'));
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch (err) {
|
|
807
|
+
if (err.name === 'AbortError') {
|
|
808
|
+
process.stdout.write(chalk.dim('\n (interrupted)\n'));
|
|
809
|
+
// Clear any pending gate — the user intentionally aborted
|
|
810
|
+
pendingGate = null;
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
const msg = err.message || 'unknown error';
|
|
814
|
+
if (msg.includes('took too long')) {
|
|
815
|
+
console.log(chalk.yellow(`\n ${msg}`));
|
|
816
|
+
console.log(chalk.dim(' Checking which services are down...'));
|
|
817
|
+
try {
|
|
818
|
+
const svcData = await client.getServices();
|
|
819
|
+
const services = svcData?.services || [];
|
|
820
|
+
const down = services.filter((s) => s.status !== 'healthy' && !s.healthy);
|
|
821
|
+
if (down.length) {
|
|
822
|
+
console.log(chalk.dim(` ${down.length} service(s) unreachable:`));
|
|
823
|
+
for (const s of down.slice(0, 8)) {
|
|
824
|
+
console.log(chalk.red(` ${s.name}:${s.port || '?'} — ${s.status || 'down'}`));
|
|
825
|
+
}
|
|
826
|
+
if (down.length > 8)
|
|
827
|
+
console.log(chalk.dim(` ... and ${down.length - 8} more`));
|
|
828
|
+
console.log(chalk.dim('\n Tip: Start more services with:'));
|
|
829
|
+
console.log(chalk.cyan(' docker compose -f docker-compose.aitheros.yml --profile chat-full up -d'));
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
console.log(chalk.dim(' All registered services appear healthy. LLM may be overloaded.'));
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
console.log(chalk.dim(' Could not fetch service status.'));
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
else if (msg.includes('Cannot connect') || msg.includes('ECONNREFUSED') || msg.includes('fetch failed')) {
|
|
840
|
+
console.log(chalk.red(` Genesis is not reachable at ${config.genesisUrl}`));
|
|
841
|
+
console.log(chalk.dim(' Start it with: docker compose -f docker-compose.aitheros.yml --profile chat-minimal up -d'));
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
console.log(chalk.red(` Error: ${msg}`));
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
finally {
|
|
849
|
+
steeringBar.deactivate();
|
|
850
|
+
renderer.finish();
|
|
851
|
+
// Capture session profile for RLM and Strata
|
|
852
|
+
lastSessionProfile = renderer.getSessionProfile();
|
|
853
|
+
// Post to Strata (best-effort, non-blocking)
|
|
854
|
+
postToStrata(client, lastSessionProfile).catch(() => { });
|
|
855
|
+
// Sync session to remote for cross-client resume
|
|
856
|
+
syncSessionToRemote({
|
|
857
|
+
sessionId: config.sessionId,
|
|
858
|
+
agent: lastSessionProfile.agent || 'aither',
|
|
859
|
+
messages: [{ role: 'user', content: lastSessionProfile.prompt, timestamp: new Date().toISOString() }],
|
|
860
|
+
createdAt: new Date().toISOString(),
|
|
861
|
+
updatedAt: new Date().toISOString(),
|
|
862
|
+
}).catch(() => { });
|
|
863
|
+
activeAbort = null;
|
|
864
|
+
if (!closed)
|
|
865
|
+
restoreReadline();
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/* ── /jobs command handler ──────────────────────────────────── */
|
|
869
|
+
function handleJobsCommand(args) {
|
|
870
|
+
const parts = args.trim().split(/\s+/);
|
|
871
|
+
const sub = parts[0] || '';
|
|
872
|
+
// /jobs cancel <id>
|
|
873
|
+
if (sub === 'cancel' || sub === 'kill') {
|
|
874
|
+
const id = Number(parts[1]);
|
|
875
|
+
if (!id) {
|
|
876
|
+
console.log(chalk.yellow(' Usage: /jobs cancel <id>'));
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (cancelJob(id)) {
|
|
880
|
+
console.log(chalk.green(` Job #${id} cancelled.`));
|
|
881
|
+
refreshPrompt();
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
console.log(chalk.yellow(` Job #${id} is not running or doesn't exist.`));
|
|
885
|
+
}
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
// /jobs <id> — show output
|
|
889
|
+
const id = Number(sub);
|
|
890
|
+
if (id > 0) {
|
|
891
|
+
const job = getJob(id);
|
|
892
|
+
if (!job) {
|
|
893
|
+
console.log(chalk.yellow(` Job #${id} not found.`));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
console.log(formatJobOutput(job));
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
// /jobs — list all
|
|
900
|
+
const allJobs = listJobs();
|
|
901
|
+
if (allJobs.length === 0) {
|
|
902
|
+
console.log(chalk.dim(' No background jobs.'));
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
console.log(chalk.bold('\n Background Jobs\n'));
|
|
906
|
+
for (const job of allJobs) {
|
|
907
|
+
console.log(formatJobLine(job));
|
|
908
|
+
}
|
|
909
|
+
console.log(chalk.dim(`\n /jobs <id> View output`));
|
|
910
|
+
console.log(chalk.dim(` /jobs cancel <id> Cancel a running job`));
|
|
911
|
+
console.log();
|
|
912
|
+
}
|
|
913
|
+
/* ── Background forge launcher ──────────────────────────────── */
|
|
914
|
+
function launchBgForge(client, args) {
|
|
915
|
+
let task = args;
|
|
916
|
+
let agent;
|
|
917
|
+
let effort;
|
|
918
|
+
const agentMatch = args.match(/--agent\s+(\S+)/);
|
|
919
|
+
if (agentMatch) {
|
|
920
|
+
agent = agentMatch[1];
|
|
921
|
+
task = task.replace(agentMatch[0], '');
|
|
922
|
+
}
|
|
923
|
+
const effortMatch = args.match(/--effort\s+(\d+)/);
|
|
924
|
+
if (effortMatch) {
|
|
925
|
+
effort = Number(effortMatch[1]);
|
|
926
|
+
task = task.replace(effortMatch[0], '');
|
|
927
|
+
}
|
|
928
|
+
task = task.replace(/^["']|["']$/g, '').trim();
|
|
929
|
+
if (!task) {
|
|
930
|
+
console.log(chalk.yellow(' Usage: /forge "task description" [--agent name] [--effort N]'));
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const job = launchForgeJob(client, task, { agent, effort });
|
|
934
|
+
const who = agent ? `@${agent}` : 'Forge';
|
|
935
|
+
console.log(chalk.dim(` ${who} dispatched as background job ${chalk.bold('#' + job.id)}`)
|
|
936
|
+
+ chalk.dim(` (effort ${effort ?? 5})`));
|
|
937
|
+
refreshPrompt();
|
|
938
|
+
}
|
|
939
|
+
/* ── Background swarm launcher ──────────────────────────────── */
|
|
940
|
+
function launchBgSwarm(client, args) {
|
|
941
|
+
let task = args;
|
|
942
|
+
let mode = 'llm';
|
|
943
|
+
const modeMatch = args.match(/--mode\s+(\S+)/);
|
|
944
|
+
if (modeMatch) {
|
|
945
|
+
mode = modeMatch[1];
|
|
946
|
+
task = task.replace(modeMatch[0], '');
|
|
947
|
+
}
|
|
948
|
+
task = task.replace(/^["']|["']$/g, '').trim();
|
|
949
|
+
if (!task) {
|
|
950
|
+
console.log(chalk.dim(' Usage: /swarm <task> [--mode forge|llm|plan_only]'));
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const job = launchSwarmJob(client, task, mode);
|
|
954
|
+
console.log(chalk.dim(` Swarm (${mode}) dispatched as background job ${chalk.bold('#' + job.id)}`));
|
|
955
|
+
refreshPrompt();
|
|
956
|
+
}
|
|
957
|
+
// ── Ctrl+C handling ──
|
|
958
|
+
rl.on('SIGINT', () => {
|
|
959
|
+
if (activeAbort) {
|
|
960
|
+
steeringBar.deactivate();
|
|
961
|
+
activeAbort.abort();
|
|
962
|
+
activeAbort = null;
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
sigintCount++;
|
|
966
|
+
if (sigintCount >= 2) {
|
|
967
|
+
console.log(chalk.dim('\n Goodbye.'));
|
|
968
|
+
cleanExit(0);
|
|
969
|
+
}
|
|
970
|
+
const running = runningCount();
|
|
971
|
+
const jobHint = running > 0 ? chalk.dim(` (${running} background job${running > 1 ? 's' : ''} running)`) : '';
|
|
972
|
+
console.log(chalk.dim('\n Press Ctrl+C again to exit, or type a message.') + jobHint);
|
|
973
|
+
rl.prompt();
|
|
974
|
+
setTimeout(() => { sigintCount = 0; }, 2000);
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
// ── Line handler ──
|
|
978
|
+
function onLine(line) {
|
|
979
|
+
sigintCount = 0;
|
|
980
|
+
const input = line.trim();
|
|
981
|
+
if (!input) {
|
|
982
|
+
refreshPrompt();
|
|
983
|
+
rl.prompt();
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
// Slash commands and shell escapes are ALWAYS dispatched locally,
|
|
987
|
+
// even during active generation — never send them as steering input.
|
|
988
|
+
if (input.startsWith('/') || input.startsWith('!')) {
|
|
989
|
+
lineQueue.push(input);
|
|
990
|
+
drainQueue();
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
// If a foreground generation is active, steer instead of queue
|
|
994
|
+
if (activeAbort && !closed) {
|
|
995
|
+
// Special: "cancel" / "stop" / Ctrl+C text → cancel the session
|
|
996
|
+
const lower = input.toLowerCase();
|
|
997
|
+
if (lower === 'cancel' || lower === 'stop' || lower === '@abort') {
|
|
998
|
+
activeAbort.abort();
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
// Steer the active session — inject this input
|
|
1002
|
+
steeringBar.clearInput();
|
|
1003
|
+
client.steer(config.sessionId, input, 'append').catch(() => { });
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
lineQueue.push(input);
|
|
1007
|
+
drainQueue();
|
|
1008
|
+
}
|
|
1009
|
+
rl.on('line', onLine);
|
|
1010
|
+
// ── EOF (Ctrl+D) / stdin close ──
|
|
1011
|
+
rl.on('close', () => {
|
|
1012
|
+
closed = true;
|
|
1013
|
+
// Deregister shell session (best effort, don't block exit)
|
|
1014
|
+
client.post('/shell/session/end', { session_id: config.sessionId }).catch(() => { });
|
|
1015
|
+
if (processing) {
|
|
1016
|
+
pendingClose = true;
|
|
1017
|
+
}
|
|
1018
|
+
else if (!process.stdin.isTTY) {
|
|
1019
|
+
setTimeout(() => cleanExit(0), 10);
|
|
1020
|
+
}
|
|
1021
|
+
else {
|
|
1022
|
+
console.log(chalk.dim('\n Goodbye.'));
|
|
1023
|
+
cleanExit(0);
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
rl.prompt();
|
|
1027
|
+
}
|
|
1028
|
+
/* ── Help text ─────────────────────────────────────────────── */
|
|
1029
|
+
function printHelp(config) {
|
|
1030
|
+
console.log(chalk.bold('\n AitherShell Quick Reference\n'));
|
|
1031
|
+
console.log(` ${chalk.cyan('/')} Interactive command picker (instant)`);
|
|
1032
|
+
console.log(` ${chalk.cyan('/command')} Run a command directly`);
|
|
1033
|
+
console.log(` ${chalk.cyan('@agent message')} Route message to specific agent`);
|
|
1034
|
+
console.log(` ${chalk.cyan('@mode message')} Set processing mode (see below)`);
|
|
1035
|
+
console.log(` ${chalk.cyan('!command')} Run shell command in PowerShell 7`);
|
|
1036
|
+
console.log(` ${chalk.cyan('!!')} Repeat last shell command`);
|
|
1037
|
+
console.log(` ${chalk.cyan('message')} Chat with default agent (${config.defaultAgent})`);
|
|
1038
|
+
console.log(` ${chalk.cyan('exit')} / ${chalk.cyan('Ctrl+D')} Quit`);
|
|
1039
|
+
console.log(` ${chalk.cyan('Ctrl+C')} Cancel current request / double-tap to quit`);
|
|
1040
|
+
console.log();
|
|
1041
|
+
console.log(chalk.bold(' @ Directives\n'));
|
|
1042
|
+
console.log(chalk.dim(' Context modes — change what gets loaded:'));
|
|
1043
|
+
console.log(` ${chalk.cyan('@code')} Force code context — codegraph + architecture + tools`);
|
|
1044
|
+
console.log(` ${chalk.cyan('@research')} Deep web research + synthesis pipeline`);
|
|
1045
|
+
console.log(` ${chalk.cyan('@internal')} Full AitherOS internal context (platform only)`);
|
|
1046
|
+
console.log(chalk.dim(' Processing modes — change how the model thinks:'));
|
|
1047
|
+
console.log(` ${chalk.cyan('@chat')} Companion mode — fast, natural conversation (persists)`);
|
|
1048
|
+
console.log(` ${chalk.cyan('@quick')} Fast response, no deliberation (effort 1–3)`);
|
|
1049
|
+
console.log(` ${chalk.cyan('@think')} Deep reasoning with extended deliberation`);
|
|
1050
|
+
console.log(` ${chalk.cyan('@reason')} Full reasoning model + structured analysis`);
|
|
1051
|
+
console.log(` ${chalk.cyan('@agentic')} Force agentic ReAct loop with tools`);
|
|
1052
|
+
console.log(chalk.dim(' Multi-agent:'));
|
|
1053
|
+
console.log(` ${chalk.cyan('@debug')} PRISM-powered debugging — 6 expert personas`);
|
|
1054
|
+
console.log(` ${chalk.cyan('@troubleshoot')} Systematic service troubleshooting`);
|
|
1055
|
+
console.log(` ${chalk.cyan('@investigate')} Deep exploratory investigation`);
|
|
1056
|
+
console.log(` ${chalk.cyan('@council')} 6-specialist council review`);
|
|
1057
|
+
console.log(` ${chalk.cyan('@deliberate')} Parallel thought streams + convergence`);
|
|
1058
|
+
console.log(` ${chalk.cyan('@swarm')} Full 11-agent swarm coding`);
|
|
1059
|
+
console.log(` ${chalk.cyan('@compete')} Multiple strategies in parallel — best judged`);
|
|
1060
|
+
console.log();
|
|
1061
|
+
console.log(chalk.bold(' Background Jobs\n'));
|
|
1062
|
+
console.log(` ${chalk.cyan('message &')} Run chat in background`);
|
|
1063
|
+
console.log(` ${chalk.cyan('@agent task &')} Run agent task in background`);
|
|
1064
|
+
console.log(` ${chalk.cyan('/forge task')} Auto-backgrounds (long-running)`);
|
|
1065
|
+
console.log(` ${chalk.cyan('/swarm task')} Auto-backgrounds (long-running)`);
|
|
1066
|
+
console.log(` ${chalk.cyan('/command &')} Background any slash command`);
|
|
1067
|
+
console.log(` ${chalk.cyan('/jobs')} List all background jobs`);
|
|
1068
|
+
console.log(` ${chalk.cyan('/jobs <id>')} View job output`);
|
|
1069
|
+
console.log(` ${chalk.cyan('/jobs cancel <id>')} Cancel a running job`);
|
|
1070
|
+
console.log();
|
|
1071
|
+
}
|
|
1072
|
+
/* ── Build choices for the interactive command picker ──────── */
|
|
1073
|
+
function buildCommandChoices() {
|
|
1074
|
+
const commands = getCommandNames();
|
|
1075
|
+
const registry = getCommandRegistry();
|
|
1076
|
+
const dynamicCmds = registry.allCommands();
|
|
1077
|
+
const categories = [
|
|
1078
|
+
['System', ['status', 'services', 'agents', 'logs', 'model', 'metrics']],
|
|
1079
|
+
['Agents', ['forge', 'sessions', 'resume', 'swarm', 'inbox', 'compose', 'monitor', 'notebook']],
|
|
1080
|
+
['Jobs', ['jobs']],
|
|
1081
|
+
['Search', ['search', 'codegraph', 'scope', 'onboard', 'obsidian', 'tools', 'context']],
|
|
1082
|
+
['AI', ['think', 'research', 'memory', 'soul']],
|
|
1083
|
+
['Ops', ['deploy', 'fleet', 'workflow', 'backup', 'benchmark', 'products', 'docker']],
|
|
1084
|
+
['Security', ['security', 'review', 'train', 'tool-scope', 'rbac']],
|
|
1085
|
+
['Automation', ['run', 'script', 'apps', 'gaming', 'routines']],
|
|
1086
|
+
['Auth', ['login', 'register', 'logout', 'whoami']],
|
|
1087
|
+
['Shell', ['help', 'clear', 'config']],
|
|
1088
|
+
];
|
|
1089
|
+
const choices = [];
|
|
1090
|
+
const used = new Set();
|
|
1091
|
+
// Merge static + dynamically discovered command names
|
|
1092
|
+
const allCommands = [...new Set([...commands, ...dynamicCmds.map((c) => c.name), 'jobs'])];
|
|
1093
|
+
for (const [cat, names] of categories) {
|
|
1094
|
+
const available = names.filter(n => allCommands.includes(n));
|
|
1095
|
+
if (!available.length)
|
|
1096
|
+
continue;
|
|
1097
|
+
choices.push(new Separator(chalk.bold.dim(` ── ${cat} ──`)));
|
|
1098
|
+
for (const name of available) {
|
|
1099
|
+
if (name === 'jobs') {
|
|
1100
|
+
choices.push({
|
|
1101
|
+
name: '/jobs',
|
|
1102
|
+
value: 'jobs',
|
|
1103
|
+
description: 'List and manage background jobs',
|
|
1104
|
+
});
|
|
1105
|
+
used.add(name);
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
const cmd = getCommand(name);
|
|
1109
|
+
choices.push({
|
|
1110
|
+
name: `/${name}`,
|
|
1111
|
+
value: name,
|
|
1112
|
+
description: cmd?.description || '',
|
|
1113
|
+
});
|
|
1114
|
+
used.add(name);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const extra = allCommands.filter(n => !used.has(n) && n !== 'exit' && n !== 'quit');
|
|
1118
|
+
if (extra.length) {
|
|
1119
|
+
choices.push(new Separator(chalk.bold.dim(' ── Other ──')));
|
|
1120
|
+
for (const name of extra) {
|
|
1121
|
+
const cmd = getCommand(name);
|
|
1122
|
+
const dynCmd = !cmd ? registry.resolve(name) : undefined;
|
|
1123
|
+
choices.push({
|
|
1124
|
+
name: `/${name}`,
|
|
1125
|
+
value: name,
|
|
1126
|
+
description: cmd?.description || dynCmd?.description || '',
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
// Add discovered MCP tools as a separate section
|
|
1131
|
+
const mcpTools = registry.getMcpTools();
|
|
1132
|
+
if (mcpTools.length) {
|
|
1133
|
+
choices.push(new Separator(chalk.bold.dim(' ── MCP Tools ──')));
|
|
1134
|
+
for (const tool of mcpTools.slice(0, 15)) {
|
|
1135
|
+
choices.push({
|
|
1136
|
+
name: `/${tool.name}`,
|
|
1137
|
+
value: `mcp:${tool.name}`,
|
|
1138
|
+
description: tool.description || 'MCP tool',
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
if (mcpTools.length > 15) {
|
|
1142
|
+
choices.push({
|
|
1143
|
+
name: chalk.dim(` ... and ${mcpTools.length - 15} more (/tools to see all)`),
|
|
1144
|
+
value: 'tools',
|
|
1145
|
+
description: '',
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
return choices;
|
|
1150
|
+
}
|
|
1151
|
+
/* ── Fallback static command list (non-TTY) ───────────────── */
|
|
1152
|
+
function showCommandList() {
|
|
1153
|
+
const registry = getCommandRegistry();
|
|
1154
|
+
const commands = getCommandNames();
|
|
1155
|
+
console.log(chalk.bold('\n Commands\n'));
|
|
1156
|
+
for (const name of commands) {
|
|
1157
|
+
if (name === 'exit' || name === 'quit')
|
|
1158
|
+
continue;
|
|
1159
|
+
const cmd = getCommand(name);
|
|
1160
|
+
const dynCmd = !cmd ? registry.resolve(name) : undefined;
|
|
1161
|
+
const desc = (cmd?.description || dynCmd?.description)
|
|
1162
|
+
? chalk.dim(` — ${cmd?.description || dynCmd?.description}`)
|
|
1163
|
+
: '';
|
|
1164
|
+
console.log(` ${chalk.cyan('/' + name)}${desc}`);
|
|
1165
|
+
}
|
|
1166
|
+
console.log(` ${chalk.cyan('/jobs')}${chalk.dim(' — List and manage background jobs')}`);
|
|
1167
|
+
console.log();
|
|
1168
|
+
}
|
|
1169
|
+
/* ── History persistence ──────────────────────────────────── */
|
|
1170
|
+
function loadHistory(file) {
|
|
1171
|
+
try {
|
|
1172
|
+
if (existsSync(file)) {
|
|
1173
|
+
return readFileSync(file, 'utf-8')
|
|
1174
|
+
.split('\n')
|
|
1175
|
+
.filter(Boolean)
|
|
1176
|
+
.slice(-500);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
catch { /* ignore */ }
|
|
1180
|
+
return [];
|
|
1181
|
+
}
|
|
1182
|
+
function saveHistory(file, line) {
|
|
1183
|
+
try {
|
|
1184
|
+
const dir = dirname(file);
|
|
1185
|
+
if (!existsSync(dir))
|
|
1186
|
+
mkdirSync(dir, { recursive: true });
|
|
1187
|
+
appendFileSync(file, line + '\n');
|
|
1188
|
+
}
|
|
1189
|
+
catch { /* ignore */ }
|
|
1190
|
+
}
|