@aitherium/shell-cli 1.1.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +13 -4
- package/dist/auth.js +18 -3
- package/dist/client.d.ts +21 -1
- package/dist/client.js +213 -9
- package/dist/command-registry.d.ts +1 -1
- package/dist/command-registry.js +28 -2
- package/dist/commands.d.ts +8 -0
- package/dist/commands.js +2068 -48
- package/dist/completions.js +236 -2
- package/dist/config.d.ts +30 -0
- package/dist/config.js +37 -4
- package/dist/crash-reporter.d.ts +22 -0
- package/dist/crash-reporter.js +275 -0
- package/dist/formatters.d.ts +16 -0
- package/dist/formatters.js +280 -0
- package/dist/interactive.d.ts +45 -0
- package/dist/interactive.js +232 -0
- package/dist/main.js +439 -41
- package/dist/mcp-client.d.ts +58 -0
- package/dist/mcp-client.js +202 -0
- package/dist/relay.d.ts +96 -0
- package/dist/relay.js +234 -0
- package/dist/renderer.d.ts +47 -17
- package/dist/renderer.js +506 -164
- package/dist/repl.js +205 -23
- package/dist/session-store.d.ts +30 -5
- package/dist/session-store.js +56 -0
- package/dist/status-banner.d.ts +59 -0
- package/dist/status-banner.js +239 -0
- package/dist/tui/controller.d.ts +3 -0
- package/dist/tui/controller.js +310 -0
- package/dist/tui/repl-tui.d.ts +3 -0
- package/dist/tui/repl-tui.js +898 -0
- package/dist/tui/screen.d.ts +68 -0
- package/dist/tui/screen.js +736 -0
- package/package.json +9 -2
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-featured interactive REPL on the blessed 3-pane surface.
|
|
3
|
+
*
|
|
4
|
+
* Feature parity with the readline REPL (repl.ts): chat streaming + steering,
|
|
5
|
+
* @agent routing, background jobs (`&`, /jobs, auto-bg forge/swarm), a native
|
|
6
|
+
* command picker (`/`), Tab completion, clarification-gate auto-resolve,
|
|
7
|
+
* !shell, history, Strata posting, remote session sync + RLM continuity,
|
|
8
|
+
* GPU status tag and a pre-flight readiness check. Interactive @inquirer
|
|
9
|
+
* pickers inside command handlers don't work under blessed — use AITHER_TUI=0
|
|
10
|
+
* for those; everything else runs in-pane.
|
|
11
|
+
*/
|
|
12
|
+
import { execSync } from 'node:child_process';
|
|
13
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { dirname } from 'node:path';
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import { setActiveConfig } from '../config.js';
|
|
17
|
+
import { getCommand, getCommandNames } from '../commands.js';
|
|
18
|
+
import { getCommandRegistry } from '../command-registry.js';
|
|
19
|
+
import { loadAgentNames, resolveAgentMention, completer, refreshCommandCompletions, SUBCOMMAND_DEFS, } from '../completions.js';
|
|
20
|
+
import { collectArgs } from '../interactive.js';
|
|
21
|
+
import { gatherStatus, formatStatusLines } from '../status-banner.js';
|
|
22
|
+
import { setJobNotifier, listJobs, getJob, cancelJob, runningCount, launchChatJob, launchForgeJob, launchSwarmJob, launchCommandJob, formatJobLine, formatJobOutput, } from '../jobs.js';
|
|
23
|
+
import { configureRemoteSync, recordTurn, loadSession, buildContextSummary } from '../session-store.js';
|
|
24
|
+
import { createTuiScreen } from './screen.js';
|
|
25
|
+
import { createTuiRenderer } from './controller.js';
|
|
26
|
+
import { setTuiActive } from '../crash-reporter.js';
|
|
27
|
+
import { RelayClient, resolveRelayUrl } from '../relay.js';
|
|
28
|
+
const AUTO_BG_COMMANDS = new Set(['forge', 'swarm']);
|
|
29
|
+
const STRATEGY_TRIGGERS = new Set([
|
|
30
|
+
'think', 'reason', 'research', 'council', 'deliberate', 'swarm', 'plan',
|
|
31
|
+
'agentic', 'compete', 'debug', 'troubleshoot', 'investigate', 'quick',
|
|
32
|
+
'code', 'internal', 'personal', 'chat', 'companion', 'talk', 'story', 'saga',
|
|
33
|
+
'narrative', 'appforge', 'build', 'redteam', 'pentest', 'security', 'hack',
|
|
34
|
+
]);
|
|
35
|
+
const DEEP_TRIGGERS = new Set([
|
|
36
|
+
'think', 'reason', 'research', 'council', 'agentic', 'deliberate', 'debug',
|
|
37
|
+
'investigate', 'swarm',
|
|
38
|
+
]);
|
|
39
|
+
function loadHistory(file) {
|
|
40
|
+
if (!file || !existsSync(file))
|
|
41
|
+
return [];
|
|
42
|
+
try {
|
|
43
|
+
return readFileSync(file, 'utf8').split('\n').map(l => l.trim()).filter(Boolean).slice(-500);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function saveHistory(file, line) {
|
|
50
|
+
if (!file)
|
|
51
|
+
return;
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
54
|
+
appendFileSync(file, line + '\n');
|
|
55
|
+
}
|
|
56
|
+
catch { /* */ }
|
|
57
|
+
}
|
|
58
|
+
function stringify(a) {
|
|
59
|
+
if (typeof a === 'string')
|
|
60
|
+
return a;
|
|
61
|
+
if (a instanceof Error)
|
|
62
|
+
return a.message;
|
|
63
|
+
try {
|
|
64
|
+
return JSON.stringify(a);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return String(a);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function sumTokens(events) {
|
|
71
|
+
let n = 0;
|
|
72
|
+
for (const e of events)
|
|
73
|
+
if (e.type === 'llm_done' || e.type === 'llm_end')
|
|
74
|
+
n += Number(e.data?.tokens_used || e.data?.tokens || 0);
|
|
75
|
+
return n;
|
|
76
|
+
}
|
|
77
|
+
async function postToStrata(client, profile) {
|
|
78
|
+
const strataUrl = client.baseUrl.replace(/:\d+$/, ':8136');
|
|
79
|
+
await fetch(`${strataUrl}/api/v1/ingest/ide-session`, {
|
|
80
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
source: 'aither-shell', session_id: profile.session_id, prompt: profile.prompt,
|
|
83
|
+
model: profile.model, agent: profile.agent, event_count: profile.event_count,
|
|
84
|
+
tool_calls: profile.tool_calls.map(t => t.name), errors: profile.errors,
|
|
85
|
+
duration_ms: profile.duration_ms, thinking_traces: profile.thinking_traces.length,
|
|
86
|
+
}),
|
|
87
|
+
signal: AbortSignal.timeout(5000),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export async function startTuiRepl(client, config) {
|
|
91
|
+
setActiveConfig(config);
|
|
92
|
+
configureRemoteSync(config.genesisUrl, config.authToken);
|
|
93
|
+
let agents = [];
|
|
94
|
+
try {
|
|
95
|
+
agents = await loadAgentNames(client);
|
|
96
|
+
}
|
|
97
|
+
catch { /* */ }
|
|
98
|
+
const registry = getCommandRegistry();
|
|
99
|
+
try {
|
|
100
|
+
if (await registry.loadDynamicCommands(client, config))
|
|
101
|
+
refreshCommandCompletions();
|
|
102
|
+
}
|
|
103
|
+
catch { /* */ }
|
|
104
|
+
const complete = completer(agents);
|
|
105
|
+
const history = loadHistory(config.historyFile);
|
|
106
|
+
let histIdx = history.length;
|
|
107
|
+
let activeAbort = null;
|
|
108
|
+
let processing = false;
|
|
109
|
+
let sigintCount = 0;
|
|
110
|
+
let sigintTimer = null;
|
|
111
|
+
let gpuTag = '';
|
|
112
|
+
let pendingGate = null;
|
|
113
|
+
let lastProfile = null;
|
|
114
|
+
// ── Relay (native group chat) state ──
|
|
115
|
+
let relay = null;
|
|
116
|
+
let rosterUsers = [];
|
|
117
|
+
let relayUnread = new Map(); // other channels' unread counts
|
|
118
|
+
let relayKnownChannels = []; // for tab-completion
|
|
119
|
+
let relayOldestTs = null; // oldest shown msg ts (scrollback)
|
|
120
|
+
let relayLoadingOlder = false;
|
|
121
|
+
let relayTypingThrottle = null;
|
|
122
|
+
/** Scrollback: fetch + prepend older history when the output is scrolled to top. */
|
|
123
|
+
async function relayLoadOlder() {
|
|
124
|
+
if (!relay || relayLoadingOlder || !relayOldestTs)
|
|
125
|
+
return;
|
|
126
|
+
relayLoadingOlder = true;
|
|
127
|
+
try {
|
|
128
|
+
const older = await relay.loadOlder(relayOldestTs, 50);
|
|
129
|
+
if (older.length) {
|
|
130
|
+
relayOldestTs = String(older[0].timestamp || relayOldestTs);
|
|
131
|
+
surface.prependOutput(older.map(formatRelayMsg).join('\n'));
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
surface.prependOutput(chalk.dim(' ── beginning of channel ──'));
|
|
135
|
+
relayOldestTs = null; // nothing older — stop further loads
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
relayLoadingOlder = false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Tab-complete a relay channel (#…) or roster nick from the last token. */
|
|
143
|
+
function relayComplete(value) {
|
|
144
|
+
if (!relay)
|
|
145
|
+
return null;
|
|
146
|
+
const idx = value.lastIndexOf(' ');
|
|
147
|
+
const head = idx >= 0 ? value.slice(0, idx + 1) : '';
|
|
148
|
+
const tok = value.slice(head.length);
|
|
149
|
+
if (!tok)
|
|
150
|
+
return null;
|
|
151
|
+
if (tok.startsWith('#')) {
|
|
152
|
+
const hits = relayKnownChannels.filter(c => c.toLowerCase().startsWith(tok.toLowerCase()));
|
|
153
|
+
return hits.length === 1 ? head + hits[0] + ' ' : null;
|
|
154
|
+
}
|
|
155
|
+
const bare = tok.replace(/^@/, '');
|
|
156
|
+
if (bare) {
|
|
157
|
+
const hits = rosterUsers.map(u => u.nick).filter(n => n.toLowerCase().startsWith(bare.toLowerCase()));
|
|
158
|
+
if (hits.length === 1)
|
|
159
|
+
return head + '@' + hits[0] + ' ';
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
function relayOnType() {
|
|
164
|
+
if (!relay || relayTypingThrottle)
|
|
165
|
+
return; // throttle to ≤1 typing ping / 2s
|
|
166
|
+
relay.typing();
|
|
167
|
+
relayTypingThrottle = setTimeout(() => { relayTypingThrottle = null; }, 2000);
|
|
168
|
+
}
|
|
169
|
+
function idleStatus() {
|
|
170
|
+
const running = runningCount();
|
|
171
|
+
const bits = ['ready', gpuTag, running > 0 ? `${running} job${running > 1 ? 's' : ''}` : '']
|
|
172
|
+
.filter(Boolean).join(' · ');
|
|
173
|
+
surface.setStatus(bits);
|
|
174
|
+
}
|
|
175
|
+
const surface = createTuiScreen({
|
|
176
|
+
title: 'AitherShell',
|
|
177
|
+
onSubmit: (line) => { void onSubmit(line); },
|
|
178
|
+
onInterrupt: () => onInterrupt(),
|
|
179
|
+
onHistory: (dir) => {
|
|
180
|
+
if (!history.length)
|
|
181
|
+
return null;
|
|
182
|
+
if (dir === 'up')
|
|
183
|
+
histIdx = Math.max(0, histIdx - 1);
|
|
184
|
+
else
|
|
185
|
+
histIdx = Math.min(history.length, histIdx + 1);
|
|
186
|
+
return histIdx >= history.length ? '' : history[histIdx];
|
|
187
|
+
},
|
|
188
|
+
onTab: (value) => {
|
|
189
|
+
if (!value)
|
|
190
|
+
return null;
|
|
191
|
+
if (relay) {
|
|
192
|
+
const r = relayComplete(value);
|
|
193
|
+
if (r != null)
|
|
194
|
+
return r;
|
|
195
|
+
}
|
|
196
|
+
const [hits] = complete(value);
|
|
197
|
+
if (!hits || !hits.length)
|
|
198
|
+
return null;
|
|
199
|
+
if (hits.length === 1)
|
|
200
|
+
return hits[0];
|
|
201
|
+
// Multiple: show options, complete to longest common prefix.
|
|
202
|
+
surface.traceLine(chalk.dim(' ⇥ ' + hits.slice(0, 12).join(' ')));
|
|
203
|
+
let lcp = hits[0];
|
|
204
|
+
for (const h of hits) {
|
|
205
|
+
while (!h.startsWith(lcp))
|
|
206
|
+
lcp = lcp.slice(0, -1);
|
|
207
|
+
}
|
|
208
|
+
return lcp || null;
|
|
209
|
+
},
|
|
210
|
+
onSlash: () => { void openPicker(); },
|
|
211
|
+
onType: () => relayOnType(),
|
|
212
|
+
onScrollTop: () => { void relayLoadOlder(); },
|
|
213
|
+
});
|
|
214
|
+
// Tell the global crash reporter the TUI owns the terminal: stray async
|
|
215
|
+
// rejections become non-fatal + silent, and a real uncaught crash restores
|
|
216
|
+
// the terminal (surface.destroy) before printing the report.
|
|
217
|
+
setTuiActive(() => { try {
|
|
218
|
+
surface.destroy();
|
|
219
|
+
}
|
|
220
|
+
catch { /* */ } });
|
|
221
|
+
// ── Background-job completion notifications → OUTPUT pane ──
|
|
222
|
+
setJobNotifier((job) => {
|
|
223
|
+
const icon = job.status === 'completed' ? chalk.green('✓') : job.status === 'failed' ? chalk.red('✗') : chalk.yellow('—');
|
|
224
|
+
const color = job.status === 'completed' ? chalk.green : job.status === 'failed' ? chalk.red : chalk.yellow;
|
|
225
|
+
surface.outputLine(`${icon} Job ${chalk.bold('#' + job.id)} ${color(job.status)}: ${job.label}`);
|
|
226
|
+
if (job.status === 'completed' && job.output.length) {
|
|
227
|
+
const sepIdx = job.output.indexOf('---');
|
|
228
|
+
const lines = sepIdx >= 0 ? job.output.slice(sepIdx + 1) : job.output.filter(l => !l.startsWith('['));
|
|
229
|
+
const text = lines.join('\n').trim();
|
|
230
|
+
if (text) {
|
|
231
|
+
if (text.length <= 800)
|
|
232
|
+
surface.outputLine(chalk.dim(text));
|
|
233
|
+
else
|
|
234
|
+
surface.outputLine(chalk.dim(text.split('\n').slice(0, 6).join('\n') + `\n … (/jobs ${job.id} for full)`));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else if (job.status === 'failed' && job.error) {
|
|
238
|
+
surface.outputLine(chalk.red(' ' + job.error));
|
|
239
|
+
}
|
|
240
|
+
idleStatus();
|
|
241
|
+
});
|
|
242
|
+
// ── GPU status poll (10s) ──
|
|
243
|
+
const gpuTimer = setInterval(async () => {
|
|
244
|
+
try {
|
|
245
|
+
const st = await client.getGpuStatus();
|
|
246
|
+
const zone = st?.zone || '';
|
|
247
|
+
gpuTag = zone.includes('image') ? chalk.yellow('GPU:3D') : st?.active ? chalk.cyan('GPU:R') : '';
|
|
248
|
+
if (!processing)
|
|
249
|
+
idleStatus();
|
|
250
|
+
}
|
|
251
|
+
catch { /* */ }
|
|
252
|
+
}, 10_000);
|
|
253
|
+
void gpuTimer;
|
|
254
|
+
// ── Register shell session (best-effort) ──
|
|
255
|
+
client.post('/shell/session/start', {
|
|
256
|
+
client_type: 'ts-shell', session_id: config.sessionId,
|
|
257
|
+
username: config.authUser?.username, user_id: config.authUser?.id,
|
|
258
|
+
}).catch(() => { });
|
|
259
|
+
// ── Resume previous conversation (--continue/--resume) ──
|
|
260
|
+
let seededContext = null;
|
|
261
|
+
if (config.resumed) {
|
|
262
|
+
const entry = loadSession(config.sessionId);
|
|
263
|
+
if (entry?.messages.length)
|
|
264
|
+
seededContext = buildContextSummary(entry.messages);
|
|
265
|
+
}
|
|
266
|
+
// ── Welcome ──
|
|
267
|
+
surface.outputLine(chalk.cyan.bold('AitherShell') + chalk.dim(` · ${config.genesisUrl}`));
|
|
268
|
+
if (config.authUser)
|
|
269
|
+
surface.outputLine(chalk.dim(`Logged in as ${config.authUser.display_name || config.authUser.username}`));
|
|
270
|
+
if (config.resumed) {
|
|
271
|
+
const n = loadSession(config.sessionId)?.messages.length || 0;
|
|
272
|
+
surface.outputLine(chalk.green(`↩ Resumed session ${config.sessionId.slice(0, 8)} (${n} messages)`));
|
|
273
|
+
}
|
|
274
|
+
surface.outputLine(chalk.dim('/ commands · @ agents · ! shell · Tab complete · mouse-wheel/PgUp scroll · Ctrl+G release mouse (copy/paste) · End=live · Ctrl+T trace · Ctrl+E expand · Ctrl+C abort/exit'));
|
|
275
|
+
idleStatus();
|
|
276
|
+
surface.focusInput();
|
|
277
|
+
// Live fleet/backend status — services up, local-vs-Genesis backend, and the
|
|
278
|
+
// orchestrator + reasoning models loaded. Fetched async so the prompt is usable
|
|
279
|
+
// immediately; the block fills in under the welcome a moment later. `/status`
|
|
280
|
+
// refreshes it on demand.
|
|
281
|
+
void gatherStatus(client, config).then((info) => {
|
|
282
|
+
surface.outputLine('');
|
|
283
|
+
for (const line of formatStatusLines(info))
|
|
284
|
+
surface.outputLine(line);
|
|
285
|
+
surface.outputLine('');
|
|
286
|
+
surface.focusInput();
|
|
287
|
+
}).catch(() => { });
|
|
288
|
+
function onInterrupt() {
|
|
289
|
+
if (activeAbort) {
|
|
290
|
+
activeAbort.abort();
|
|
291
|
+
activeAbort = null;
|
|
292
|
+
surface.setStatus('aborted');
|
|
293
|
+
surface.focusInput();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
sigintCount++;
|
|
297
|
+
if (sigintCount >= 2) {
|
|
298
|
+
setTuiActive(null);
|
|
299
|
+
surface.destroy();
|
|
300
|
+
process.exit(0);
|
|
301
|
+
}
|
|
302
|
+
surface.setStatus('Ctrl+C again to exit');
|
|
303
|
+
if (sigintTimer)
|
|
304
|
+
clearTimeout(sigintTimer);
|
|
305
|
+
sigintTimer = setTimeout(() => { sigintCount = 0; idleStatus(); }, 2000);
|
|
306
|
+
}
|
|
307
|
+
// ── Command picker (native blessed list) ──
|
|
308
|
+
function buildPickerItems() {
|
|
309
|
+
const names = getCommandNames();
|
|
310
|
+
const dyn = registry.allCommands().map((c) => c.name);
|
|
311
|
+
const all = [...new Set([...names, ...dyn, 'jobs'])];
|
|
312
|
+
const categories = [
|
|
313
|
+
['System', ['status', 'services', 'agents', 'logs', 'model', 'metrics']],
|
|
314
|
+
['Agents', ['forge', 'sessions', 'resume', 'swarm', 'inbox', 'compose', 'monitor', 'notebook']],
|
|
315
|
+
['Jobs', ['jobs']],
|
|
316
|
+
['Search', ['search', 'codegraph', 'scope', 'onboard', 'obsidian', 'tools', 'context']],
|
|
317
|
+
['AI', ['think', 'research', 'memory', 'soul']],
|
|
318
|
+
['Ops', ['deploy', 'fleet', 'workflow', 'backup', 'benchmark', 'products', 'docker']],
|
|
319
|
+
['Security', ['security', 'review', 'train', 'tool-scope', 'rbac']],
|
|
320
|
+
['Automation', ['run', 'script', 'apps', 'gaming', 'routines']],
|
|
321
|
+
['Auth', ['login', 'register', 'logout', 'whoami']],
|
|
322
|
+
['Shell', ['help', 'clear', 'config']],
|
|
323
|
+
];
|
|
324
|
+
const items = [];
|
|
325
|
+
const used = new Set();
|
|
326
|
+
for (const [cat, list] of categories) {
|
|
327
|
+
const avail = list.filter(n => all.includes(n));
|
|
328
|
+
if (!avail.length)
|
|
329
|
+
continue;
|
|
330
|
+
items.push({ label: `── ${cat} ──`, value: '', separator: true });
|
|
331
|
+
for (const n of avail) {
|
|
332
|
+
used.add(n);
|
|
333
|
+
items.push({ label: `/${n}`, value: n, description: getCommand(n)?.description || '' });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const extra = all.filter(n => !used.has(n) && n !== 'exit' && n !== 'quit');
|
|
337
|
+
if (extra.length) {
|
|
338
|
+
items.push({ label: '── Other ──', value: '', separator: true });
|
|
339
|
+
for (const n of extra)
|
|
340
|
+
items.push({ label: `/${n}`, value: n, description: getCommand(n)?.description || registry.resolve(n)?.description || '' });
|
|
341
|
+
}
|
|
342
|
+
return items;
|
|
343
|
+
}
|
|
344
|
+
async function openPicker() {
|
|
345
|
+
const choice = await surface.showPicker('/ commands', buildPickerItems());
|
|
346
|
+
if (!choice)
|
|
347
|
+
return;
|
|
348
|
+
// Defer to runCommand: when the command has a subcommand table, runCommand's
|
|
349
|
+
// interactive path picks the subcommand AND prompts for its arguments (the
|
|
350
|
+
// blessed overlay can't do text/enum arg entry). Bare invocation and the
|
|
351
|
+
// picker thus share one fully-interactive flow.
|
|
352
|
+
await runCommand('/' + choice);
|
|
353
|
+
}
|
|
354
|
+
async function onSubmit(input) {
|
|
355
|
+
sigintCount = 0;
|
|
356
|
+
if (!input)
|
|
357
|
+
return;
|
|
358
|
+
if (surface.pickerOpen())
|
|
359
|
+
return;
|
|
360
|
+
saveHistory(config.historyFile, input);
|
|
361
|
+
history.push(input);
|
|
362
|
+
histIdx = history.length;
|
|
363
|
+
if (input === 'exit' || input === 'quit' || input === '/exit' || input === '/quit') {
|
|
364
|
+
setTuiActive(null);
|
|
365
|
+
surface.destroy();
|
|
366
|
+
process.exit(0);
|
|
367
|
+
}
|
|
368
|
+
if (input === '?') {
|
|
369
|
+
showHelp();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (input.startsWith('!')) {
|
|
373
|
+
runShell(input.slice(1).trim());
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const explicitBg = input.endsWith(' &');
|
|
377
|
+
const clean = explicitBg ? input.slice(0, -2).trimEnd() : input;
|
|
378
|
+
if (clean.startsWith('/')) {
|
|
379
|
+
await runCommand(clean, explicitBg);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
// Relay mode: plain input is a message to the channel (humans + agents see it).
|
|
383
|
+
if (relay) {
|
|
384
|
+
relay.send(clean);
|
|
385
|
+
// Agents don't emit typing while generating, so hint that a reply is coming.
|
|
386
|
+
if (rosterUsers.some(u => u.is_agent))
|
|
387
|
+
relayStatus('sent · agents may reply…');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
// Steer an active stream.
|
|
391
|
+
if (activeAbort && processing) {
|
|
392
|
+
const lower = clean.toLowerCase();
|
|
393
|
+
if (lower === 'cancel' || lower === 'stop' || lower === '@abort') {
|
|
394
|
+
onInterrupt();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
surface.traceLine(chalk.yellow(` 🔄 steer: ${clean}`));
|
|
398
|
+
client.steer(config.sessionId, clean, 'append').catch(() => { });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
await runChat(clean, explicitBg);
|
|
402
|
+
}
|
|
403
|
+
function showHelp() {
|
|
404
|
+
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'));
|
|
405
|
+
surface.outputLine(chalk.dim('/ picker · /cmd · @agent route · @think/@research deep · ! shell · cmd & background · /jobs · exit'));
|
|
406
|
+
surface.outputLine(chalk.dim('/relay [#channel] native group chat (humans + agents) · /relay join #x · /relay channels [scope] · scroll up = older · /relay more · /leave'));
|
|
407
|
+
surface.focusInput();
|
|
408
|
+
}
|
|
409
|
+
function runShell(cmd) {
|
|
410
|
+
if (!cmd) {
|
|
411
|
+
surface.outputLine(chalk.yellow('Usage: !<command> (PowerShell 7)'));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
surface.outputLine(chalk.dim('$ ' + cmd));
|
|
415
|
+
try {
|
|
416
|
+
const out = execSync(`pwsh -NoProfile -c "${cmd.replace(/"/g, '\\"')}"`, { encoding: 'utf8', timeout: 300_000 });
|
|
417
|
+
if (out)
|
|
418
|
+
surface.outputLine(out.replace(/\n$/, ''));
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
if (err.stdout)
|
|
422
|
+
surface.outputLine(String(err.stdout).replace(/\n$/, ''));
|
|
423
|
+
surface.outputLine(chalk.red(err.status ? `exit ${err.status}` : `Error: ${err.message}`));
|
|
424
|
+
}
|
|
425
|
+
surface.focusInput();
|
|
426
|
+
}
|
|
427
|
+
function handleJobs(args) {
|
|
428
|
+
const parts = args.trim().split(/\s+/);
|
|
429
|
+
const sub = parts[0] || '';
|
|
430
|
+
if (sub === 'cancel' || sub === 'kill') {
|
|
431
|
+
const id = Number(parts[1]);
|
|
432
|
+
if (!id) {
|
|
433
|
+
surface.outputLine(chalk.yellow('Usage: /jobs cancel <id>'));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
surface.outputLine(cancelJob(id) ? chalk.green(`Job #${id} cancelled.`) : chalk.yellow(`Job #${id} not running.`));
|
|
437
|
+
idleStatus();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const id = Number(sub);
|
|
441
|
+
if (id > 0) {
|
|
442
|
+
const job = getJob(id);
|
|
443
|
+
surface.outputLine(job ? formatJobOutput(job) : chalk.yellow(`Job #${id} not found.`));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const all = listJobs();
|
|
447
|
+
if (!all.length) {
|
|
448
|
+
surface.outputLine(chalk.dim('No background jobs.'));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
surface.outputLine(chalk.bold('Background Jobs'));
|
|
452
|
+
for (const j of all)
|
|
453
|
+
surface.outputLine(formatJobLine(j));
|
|
454
|
+
surface.outputLine(chalk.dim('/jobs <id> view · /jobs cancel <id>'));
|
|
455
|
+
}
|
|
456
|
+
// ── Relay (native AitherRelay group chat — humans + agents in a channel) ──
|
|
457
|
+
function relayMentionsMe(content) {
|
|
458
|
+
if (!relay)
|
|
459
|
+
return false;
|
|
460
|
+
const n = relay.nick.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
461
|
+
return new RegExp(`(^|[^\\w])@?${n}\\b`, 'i').test(content || '');
|
|
462
|
+
}
|
|
463
|
+
function formatRelayMsg(m) {
|
|
464
|
+
let time = '';
|
|
465
|
+
try {
|
|
466
|
+
const t = typeof m.timestamp === 'number'
|
|
467
|
+
? new Date(m.timestamp > 1e12 ? m.timestamp : m.timestamp * 1000)
|
|
468
|
+
: (m.timestamp ? new Date(m.timestamp) : null);
|
|
469
|
+
if (t && !isNaN(t.getTime()))
|
|
470
|
+
time = chalk.dim(t.toTimeString().slice(0, 5) + ' ');
|
|
471
|
+
}
|
|
472
|
+
catch { /* */ }
|
|
473
|
+
const self = !!relay && m.nick === relay.nick;
|
|
474
|
+
const who = m.agent ? chalk.magenta(`@${m.nick}`) : (self ? chalk.green(m.nick) : chalk.cyan(m.nick));
|
|
475
|
+
const line = `${time}${who}: ${m.content}`;
|
|
476
|
+
// Highlight messages that mention you (not your own).
|
|
477
|
+
return (!self && relayMentionsMe(m.content)) ? chalk.yellow('★ ') + line : line;
|
|
478
|
+
}
|
|
479
|
+
function renderRoster(channel) {
|
|
480
|
+
const humans = rosterUsers.filter(u => !u.is_agent).map(u => ` ${chalk.cyan(u.nick)}`);
|
|
481
|
+
const agents = rosterUsers.filter(u => u.is_agent).map(u => ` ${chalk.magenta('@' + u.nick)}`);
|
|
482
|
+
const lines = [];
|
|
483
|
+
if (agents.length) {
|
|
484
|
+
lines.push(chalk.bold('Agents'));
|
|
485
|
+
lines.push(...agents, '');
|
|
486
|
+
}
|
|
487
|
+
lines.push(chalk.bold(`People (${humans.length})`));
|
|
488
|
+
lines.push(...(humans.length ? humans : [chalk.dim(' (just you)')]));
|
|
489
|
+
surface.setTracePanel(`${channel} · ${rosterUsers.length}`, lines);
|
|
490
|
+
}
|
|
491
|
+
function relayStatus(extra) {
|
|
492
|
+
if (!relay)
|
|
493
|
+
return;
|
|
494
|
+
const unread = [...relayUnread.entries()].filter(([, n]) => n > 0)
|
|
495
|
+
.map(([c, n]) => `${c}(${n})`).join(' ');
|
|
496
|
+
surface.setStatus(['📡 relay', relay.currentChannel, unread ? `unread ${unread}` : '', extra].filter(Boolean).join(' · '));
|
|
497
|
+
}
|
|
498
|
+
async function startRelay(initialChannel) {
|
|
499
|
+
if (relay) {
|
|
500
|
+
surface.outputLine(chalk.yellow('Already in relay — /leave first.'));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const nick = config.authUser?.username || config.authUser?.display_name || `dev_${Math.floor(Math.random() * 9000 + 1000)}`;
|
|
504
|
+
const client = new RelayClient({
|
|
505
|
+
url: resolveRelayUrl(), token: config.authToken || undefined, nick,
|
|
506
|
+
handlers: {
|
|
507
|
+
onStatus: (s) => relayStatus(s === 'open' ? undefined : s),
|
|
508
|
+
onHistory: (ch, msgs) => {
|
|
509
|
+
relayUnread.delete(ch);
|
|
510
|
+
relayOldestTs = msgs.length
|
|
511
|
+
? msgs.reduce((min, m) => (String(m.timestamp) < min ? String(m.timestamp) : min), String(msgs[0].timestamp))
|
|
512
|
+
: null;
|
|
513
|
+
surface.clearPanes();
|
|
514
|
+
surface.setOutputLabel(`relay ${ch}`);
|
|
515
|
+
surface.outputLine(chalk.dim(`── ${ch} — last ${msgs.length} message(s) · scroll up for more ──`));
|
|
516
|
+
for (const m of msgs)
|
|
517
|
+
surface.outputLine(formatRelayMsg(m));
|
|
518
|
+
renderRoster(ch);
|
|
519
|
+
relayStatus();
|
|
520
|
+
},
|
|
521
|
+
onMessage: (m) => {
|
|
522
|
+
// Messages for OTHER joined channels become unread badges, not noise.
|
|
523
|
+
if (relay && m.channel && m.channel !== relay.currentChannel) {
|
|
524
|
+
relayUnread.set(m.channel, (relayUnread.get(m.channel) || 0) + 1);
|
|
525
|
+
relayStatus();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
surface.outputLine(formatRelayMsg(m));
|
|
529
|
+
relayStatus(); // clears any "agents may reply…" hint once a reply lands
|
|
530
|
+
},
|
|
531
|
+
onJoin: (n, ch, isAgent) => { if (!rosterUsers.some(u => u.nick === n))
|
|
532
|
+
rosterUsers.push({ nick: n, is_agent: isAgent }); renderRoster(ch); },
|
|
533
|
+
onPart: (n, ch) => { rosterUsers = rosterUsers.filter(u => u.nick !== n); renderRoster(ch); },
|
|
534
|
+
onUserlist: (ch, users) => { rosterUsers = users; renderRoster(ch); },
|
|
535
|
+
onTyping: (n) => relayStatus(`${n} typing…`),
|
|
536
|
+
onError: (msg) => surface.outputLine(chalk.red(` relay: ${msg}`)),
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
let channel = initialChannel;
|
|
540
|
+
if (!channel) {
|
|
541
|
+
surface.setStatus('📡 relay · loading channels…');
|
|
542
|
+
const channels = await client.listChannels();
|
|
543
|
+
relayKnownChannels = channels.map(c => c.name);
|
|
544
|
+
if (channels.length) {
|
|
545
|
+
const items = channels.map(c => ({ label: c.name + (c.topic ? ` — ${c.topic}` : ''), value: c.name }));
|
|
546
|
+
const picked = await surface.showPicker('Join a relay channel', items);
|
|
547
|
+
if (!picked) {
|
|
548
|
+
idleStatus();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
channel = picked;
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
channel = '#general';
|
|
555
|
+
surface.outputLine(chalk.dim(' (no channel list returned — defaulting to #general)'));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
relay = client;
|
|
559
|
+
rosterUsers = [];
|
|
560
|
+
// Best-effort channel list for tab-completion (covers the /relay #x path).
|
|
561
|
+
if (!relayKnownChannels.length)
|
|
562
|
+
client.listChannels().then(cs => { relayKnownChannels = cs.map(c => c.name); }).catch(() => { });
|
|
563
|
+
surface.outputLine(chalk.green(` 📡 Joining ${channel} as ${nick}… (type to chat · /relay join #x · /leave)`));
|
|
564
|
+
client.connect(channel);
|
|
565
|
+
relayStatus('connecting');
|
|
566
|
+
}
|
|
567
|
+
async function handleRelay(args) {
|
|
568
|
+
const a = args.trim();
|
|
569
|
+
const parts = a.split(/\s+/);
|
|
570
|
+
const sub = (parts[0] || '').toLowerCase();
|
|
571
|
+
if (relay) {
|
|
572
|
+
if (sub === 'leave' || sub === 'exit' || sub === 'quit') {
|
|
573
|
+
leaveRelay();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (sub === 'join') {
|
|
577
|
+
if (parts[1]) {
|
|
578
|
+
rosterUsers = [];
|
|
579
|
+
relay.join(parts[1]);
|
|
580
|
+
}
|
|
581
|
+
else
|
|
582
|
+
surface.outputLine(chalk.yellow('Usage: /relay join #channel'));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (sub === 'channels') {
|
|
586
|
+
const scope = parts[1] || 'platform'; // platform | community | workspace:<slug>
|
|
587
|
+
const cs = await relay.listChannels(scope);
|
|
588
|
+
relayKnownChannels = cs.map(c => c.name);
|
|
589
|
+
surface.outputLine(chalk.bold(`Channels (${scope}): `) + chalk.dim(relayKnownChannels.join(' ') || '(none)'));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (sub === 'more' || sub === 'history') {
|
|
593
|
+
await relayLoadOlder();
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (a.startsWith('#')) {
|
|
597
|
+
rosterUsers = [];
|
|
598
|
+
relay.join(a);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
surface.outputLine(chalk.dim('In relay: type to chat · /relay join #x · /relay channels [scope] · /relay more · /leave'));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
await startRelay(a.startsWith('#') ? a : undefined);
|
|
605
|
+
}
|
|
606
|
+
function leaveRelay() {
|
|
607
|
+
if (!relay)
|
|
608
|
+
return;
|
|
609
|
+
try {
|
|
610
|
+
relay.part();
|
|
611
|
+
}
|
|
612
|
+
catch { /* */ } // tell the channel before closing
|
|
613
|
+
relay.disconnect();
|
|
614
|
+
relay = null;
|
|
615
|
+
rosterUsers = [];
|
|
616
|
+
relayUnread = new Map();
|
|
617
|
+
relayOldestTs = null;
|
|
618
|
+
relayLoadingOlder = false;
|
|
619
|
+
if (relayTypingThrottle) {
|
|
620
|
+
clearTimeout(relayTypingThrottle);
|
|
621
|
+
relayTypingThrottle = null;
|
|
622
|
+
}
|
|
623
|
+
surface.setOutputLabel('output');
|
|
624
|
+
surface.setTracePanel('trace', []);
|
|
625
|
+
surface.outputLine(chalk.dim(' 📡 Left relay — back to agent chat.'));
|
|
626
|
+
idleStatus();
|
|
627
|
+
}
|
|
628
|
+
async function runCommand(input, bg = false) {
|
|
629
|
+
const sp = input.indexOf(' ');
|
|
630
|
+
const name = sp > 0 ? input.slice(1, sp) : input.slice(1);
|
|
631
|
+
const args = sp > 0 ? input.slice(sp + 1).trim() : '';
|
|
632
|
+
if (!name) {
|
|
633
|
+
await openPicker();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (name === 'clear' || name === 'cls') {
|
|
637
|
+
surface.clearPanes();
|
|
638
|
+
idleStatus();
|
|
639
|
+
surface.focusInput();
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (name === 'help') {
|
|
643
|
+
showHelp();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (name === 'jobs') {
|
|
647
|
+
handleJobs(args);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (name === 'relay') {
|
|
651
|
+
await handleRelay(args);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if ((name === 'leave' || name === 'unrelay') && relay) {
|
|
655
|
+
leaveRelay();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const cmd = getCommand(name);
|
|
659
|
+
if (!cmd) {
|
|
660
|
+
surface.outputLine(chalk.yellow(`Unknown command: ${name}`) + chalk.dim(' (/ picker, or AITHER_TUI=0 for full UX)'));
|
|
661
|
+
surface.focusInput();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
// Background variants.
|
|
665
|
+
if ((bg || AUTO_BG_COMMANDS.has(name))) {
|
|
666
|
+
if (name === 'forge') {
|
|
667
|
+
bgForge(args);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (name === 'swarm') {
|
|
671
|
+
bgSwarm(args);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (bg) {
|
|
675
|
+
const job = launchCommandJob(`/${name} ${args}`.trim(), () => cmd.handler(client, args, config));
|
|
676
|
+
surface.outputLine(chalk.dim(`Background job #${job.id}: /${name}`));
|
|
677
|
+
idleStatus();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
// Foreground: suspend the TUI and run the command on the REAL terminal, so
|
|
682
|
+
// its console.log / ora spinners / @inquirer prompts (e.g. /login) all work
|
|
683
|
+
// and can't corrupt or deadlock the blessed screen. Returns on a keypress.
|
|
684
|
+
// When invoked bare (no args) and the command has a subcommand table, collect
|
|
685
|
+
// its arguments interactively first — same detached session, so the prompts
|
|
686
|
+
// and the handler output share one suspend/restore cycle.
|
|
687
|
+
const subDefs = SUBCOMMAND_DEFS['/' + name];
|
|
688
|
+
const interactive = !args && !!subDefs?.length;
|
|
689
|
+
await surface.runDetached(name, async () => {
|
|
690
|
+
let finalArgs = args;
|
|
691
|
+
if (interactive) {
|
|
692
|
+
const collected = await collectArgs(name, subDefs);
|
|
693
|
+
if (collected === null) {
|
|
694
|
+
console.log(chalk.dim('\n (cancelled)\n'));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
finalArgs = collected;
|
|
698
|
+
}
|
|
699
|
+
await cmd.handler(client, finalArgs, config);
|
|
700
|
+
});
|
|
701
|
+
idleStatus();
|
|
702
|
+
}
|
|
703
|
+
function bgForge(args) {
|
|
704
|
+
let task = args;
|
|
705
|
+
let agent;
|
|
706
|
+
let effort;
|
|
707
|
+
const am = args.match(/--agent\s+(\S+)/);
|
|
708
|
+
if (am) {
|
|
709
|
+
agent = am[1];
|
|
710
|
+
task = task.replace(am[0], '');
|
|
711
|
+
}
|
|
712
|
+
const em = args.match(/--effort\s+(\d+)/);
|
|
713
|
+
if (em) {
|
|
714
|
+
effort = Number(em[1]);
|
|
715
|
+
task = task.replace(em[0], '');
|
|
716
|
+
}
|
|
717
|
+
task = task.replace(/^["']|["']$/g, '').trim();
|
|
718
|
+
if (!task) {
|
|
719
|
+
surface.outputLine(chalk.yellow('Usage: /forge "task" [--agent name] [--effort N]'));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const job = launchForgeJob(client, task, { agent, effort });
|
|
723
|
+
surface.outputLine(chalk.dim(`${agent ? '@' + agent : 'Forge'} → background job #${job.id} (effort ${effort ?? 5})`));
|
|
724
|
+
idleStatus();
|
|
725
|
+
}
|
|
726
|
+
function bgSwarm(args) {
|
|
727
|
+
let task = args;
|
|
728
|
+
let mode = 'llm';
|
|
729
|
+
const mm = args.match(/--mode\s+(\S+)/);
|
|
730
|
+
if (mm) {
|
|
731
|
+
mode = mm[1];
|
|
732
|
+
task = task.replace(mm[0], '');
|
|
733
|
+
}
|
|
734
|
+
task = task.replace(/^["']|["']$/g, '').trim();
|
|
735
|
+
if (!task) {
|
|
736
|
+
surface.outputLine(chalk.yellow('Usage: /swarm <task> [--mode forge|llm|plan_only]'));
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const job = launchSwarmJob(client, task, mode);
|
|
740
|
+
surface.outputLine(chalk.dim(`Swarm (${mode}) → background job #${job.id}`));
|
|
741
|
+
idleStatus();
|
|
742
|
+
}
|
|
743
|
+
async function runChat(input, bg = false) {
|
|
744
|
+
// Resolve @mentions (agent routes) vs @strategy triggers.
|
|
745
|
+
let agent = config.defaultAgent;
|
|
746
|
+
const resolved = [];
|
|
747
|
+
const mentions = input.match(/@(\S+)/g) || [];
|
|
748
|
+
for (const m of mentions) {
|
|
749
|
+
const raw = m.slice(1);
|
|
750
|
+
if (STRATEGY_TRIGGERS.has(raw.toLowerCase()))
|
|
751
|
+
continue;
|
|
752
|
+
const { resolved: r, unknown } = resolveAgentMention(raw);
|
|
753
|
+
if (r && !resolved.includes(r))
|
|
754
|
+
resolved.push(r);
|
|
755
|
+
else if (unknown)
|
|
756
|
+
surface.traceLine(chalk.yellow(` ⚠ unknown agent @${unknown}`));
|
|
757
|
+
}
|
|
758
|
+
if (resolved.length)
|
|
759
|
+
agent = resolved[0];
|
|
760
|
+
const hasDeep = mentions.some(m => DEEP_TRIGGERS.has(m.slice(1).toLowerCase()));
|
|
761
|
+
if (bg) {
|
|
762
|
+
const label = agent !== config.defaultAgent ? `@${agent}: ${input.slice(0, 40)}` : `Chat: ${input.slice(0, 50)}`;
|
|
763
|
+
const job = launchChatJob(client, input, { agent, sessionId: config.sessionId, model: config.model, label });
|
|
764
|
+
surface.outputLine(chalk.dim(`Background job #${job.id}: ${label}`));
|
|
765
|
+
idleStatus();
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
// Pre-flight readiness (non-blocking warning).
|
|
769
|
+
try {
|
|
770
|
+
const h = await fetch(`${config.genesisUrl}/health`, { signal: AbortSignal.timeout(2000) });
|
|
771
|
+
if (h.ok) {
|
|
772
|
+
const hd = await h.json();
|
|
773
|
+
if (hd.generation_ready === false)
|
|
774
|
+
surface.traceLine(chalk.yellow(' ⚠ LLM pool busy (0 slots) — sending anyway'));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
catch { /* */ }
|
|
778
|
+
surface.outputLine(chalk.green('› ') + input);
|
|
779
|
+
surface.setStatus('working…');
|
|
780
|
+
// Open a fresh, collapsible trace thread for this turn (older turns collapse).
|
|
781
|
+
surface.startTraceTurn(input.length > 32 ? input.slice(0, 32) + '…' : input);
|
|
782
|
+
// Clarification gate auto-resolve.
|
|
783
|
+
let clarification;
|
|
784
|
+
if (pendingGate) {
|
|
785
|
+
clarification = { plan_id: pendingGate.planId, gate_id: pendingGate.gateId, answers: input };
|
|
786
|
+
pendingGate = null;
|
|
787
|
+
}
|
|
788
|
+
// RLM continuity: prior turn this session, or the resumed transcript summary.
|
|
789
|
+
const prevCtx = lastProfile ? {
|
|
790
|
+
summary: `Previous: "${lastProfile.prompt.slice(0, 160)}" → ${lastProfile.model}, ${lastProfile.tool_calls.length} tools`,
|
|
791
|
+
tools_used: lastProfile.tool_calls.map(t => t.name), model: lastProfile.model, errors: lastProfile.errors,
|
|
792
|
+
} : seededContext ? {
|
|
793
|
+
summary: seededContext, tools_used: [], model: config.model || '', errors: [],
|
|
794
|
+
} : undefined;
|
|
795
|
+
seededContext = null; // consume once
|
|
796
|
+
activeAbort = new AbortController();
|
|
797
|
+
processing = true;
|
|
798
|
+
let aborted = false;
|
|
799
|
+
let renderer = createTuiRenderer(surface, config.sessionId, input);
|
|
800
|
+
// Auto-retry once when the connection is severed mid-turn (Genesis or the
|
|
801
|
+
// LB recreated under us — a deploy rolling the stack). Only when NO tools
|
|
802
|
+
// fired yet (no double side-effects) and only after the backend is
|
|
803
|
+
// reachable again. A soft stream_timeout (agent may still be working) is
|
|
804
|
+
// NOT retried — that path keeps its "check forge status" semantics.
|
|
805
|
+
const MAX_ATTEMPTS = 2;
|
|
806
|
+
try {
|
|
807
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
808
|
+
if (attempt > 0) {
|
|
809
|
+
renderer = createTuiRenderer(surface, config.sessionId, input);
|
|
810
|
+
surface.startTraceTurn((input.length > 32 ? input.slice(0, 32) + '…' : input) + ' (retry)');
|
|
811
|
+
}
|
|
812
|
+
let sawTerminal = false; // complete/done/error/timeout/interrupt arrived
|
|
813
|
+
let interrupted = false; // transport died mid-turn
|
|
814
|
+
let toolFired = false; // tools ran — retry could double side-effects
|
|
815
|
+
try {
|
|
816
|
+
for await (const ev of client.streamChat(input, {
|
|
817
|
+
agent, mentions: resolved.length > 1 ? resolved : undefined,
|
|
818
|
+
sessionId: config.sessionId, model: config.model, signal: activeAbort.signal,
|
|
819
|
+
clarificationResponse: clarification, sessionContext: prevCtx,
|
|
820
|
+
effort: config.effort, safetyLevel: config.safetyLevel, privateMode: config.privateMode,
|
|
821
|
+
attachments: config.imageAttachments, maxEffort: hasDeep ? undefined : (config.effort || 5),
|
|
822
|
+
})) {
|
|
823
|
+
renderer.onEvent(ev);
|
|
824
|
+
if (ev.type === 'complete' || ev.type === 'done' || ev.type === 'error'
|
|
825
|
+
|| ev.type === 'llm_error' || ev.type === 'stream_timeout')
|
|
826
|
+
sawTerminal = true;
|
|
827
|
+
if (ev.type === 'stream_interrupted') {
|
|
828
|
+
sawTerminal = true;
|
|
829
|
+
interrupted = true;
|
|
830
|
+
}
|
|
831
|
+
if (ev.type === 'tool_call' || ev.type === 'tool_result')
|
|
832
|
+
toolFired = true;
|
|
833
|
+
if (ev.type === 'clarification_needed' && ev.data.plan_id) {
|
|
834
|
+
pendingGate = { planId: ev.data.plan_id, gateId: ev.data.gate_id || '' };
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (config.imageAttachments?.length)
|
|
838
|
+
config.imageAttachments = undefined;
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
if (err?.name === 'AbortError') {
|
|
842
|
+
aborted = true;
|
|
843
|
+
surface.outputLine(chalk.dim(' (interrupted)'));
|
|
844
|
+
}
|
|
845
|
+
else
|
|
846
|
+
surface.outputLine(chalk.red(` Error: ${err?.message || err}`));
|
|
847
|
+
renderer.finish(aborted);
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
renderer.finish(aborted);
|
|
851
|
+
const lostMidTurn = interrupted || !sawTerminal;
|
|
852
|
+
if (!lostMidTurn || aborted || toolFired || attempt === MAX_ATTEMPTS - 1)
|
|
853
|
+
break;
|
|
854
|
+
surface.setStatus('connection lost — waiting for backend…');
|
|
855
|
+
const back = await waitForBackend(config.genesisUrl, 120_000, activeAbort.signal);
|
|
856
|
+
if (!back) {
|
|
857
|
+
surface.outputLine(chalk.yellow(' Backend still unreachable after 2 minutes — resend when it returns.'));
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
surface.outputLine(chalk.cyan('↻ Backend is back — retrying the turn once…'));
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
finally {
|
|
864
|
+
activeAbort = null;
|
|
865
|
+
processing = false;
|
|
866
|
+
lastProfile = renderer.getSessionProfile();
|
|
867
|
+
postToStrata(client, lastProfile).catch(() => { });
|
|
868
|
+
// Persist the full turn (user + assistant) locally + remote → /export, --continue.
|
|
869
|
+
try {
|
|
870
|
+
recordTurn(config.sessionId, lastProfile.agent || agent, input, renderer.getContent(), {
|
|
871
|
+
model: lastProfile.model, tools: lastProfile.tool_calls.map(t => t.name),
|
|
872
|
+
tokens: sumTokens(lastProfile.events),
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
catch { /* */ }
|
|
876
|
+
idleStatus();
|
|
877
|
+
surface.focusInput();
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/** Poll /health until the backend answers 200, the timeout elapses, or the
|
|
882
|
+
* user aborts. Genesis cold-boots in ~110s after a recreate, so the budget
|
|
883
|
+
* must cover a full container roll, not just a blip. */
|
|
884
|
+
async function waitForBackend(baseUrl, timeoutMs, signal) {
|
|
885
|
+
const t0 = Date.now();
|
|
886
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
887
|
+
if (signal?.aborted)
|
|
888
|
+
return false;
|
|
889
|
+
try {
|
|
890
|
+
const r = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(3000) });
|
|
891
|
+
if (r.ok)
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
catch { /* still down */ }
|
|
895
|
+
await new Promise(res => setTimeout(res, 2500));
|
|
896
|
+
}
|
|
897
|
+
return false;
|
|
898
|
+
}
|