@aitherium/shell-cli 1.1.0 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +10 -2
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal MCP StreamableHTTP client.
|
|
3
|
+
*
|
|
4
|
+
* Talks JSON-RPC to a remote MCP gateway (e.g. https://mcp.aitherium.com/mcp)
|
|
5
|
+
* using the MCP "Streamable HTTP" transport: each request is a POST whose
|
|
6
|
+
* response is either a single `application/json` body or a `text/event-stream`
|
|
7
|
+
* carrying the JSON-RPC reply. No external deps — mirrors client.ts's thin SSE
|
|
8
|
+
* style so the bundle still compiles via `bun build --compile`.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: the shell's chat backend (genesisUrl) speaks a REST
|
|
11
|
+
* `/tools/call`, but the cloud gateway (config.mcpUrl) speaks the MCP protocol
|
|
12
|
+
* at `/mcp`. When mcpUrl is set we route tool discovery + invocation here so
|
|
13
|
+
* AitherNode's tools work in-REPL against the cloud workspace.
|
|
14
|
+
*/
|
|
15
|
+
import { getActiveToken } from './auth.js';
|
|
16
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
17
|
+
const CLIENT_INFO = { name: 'aither-shell', version: '1.7.3' };
|
|
18
|
+
export class McpHttpClient {
|
|
19
|
+
url;
|
|
20
|
+
token;
|
|
21
|
+
sessionId = null;
|
|
22
|
+
nextId = 1;
|
|
23
|
+
initialized = false;
|
|
24
|
+
initInFlight = null;
|
|
25
|
+
constructor(url, token) {
|
|
26
|
+
// Strip trailing slashes only — keep the `/mcp` path intact.
|
|
27
|
+
this.url = url.replace(/\/+$/, '');
|
|
28
|
+
this.token = token ?? null;
|
|
29
|
+
}
|
|
30
|
+
/** Send a JSON-RPC request (or notification) and return its `result`. */
|
|
31
|
+
async rpc(method, params, isNotification = false) {
|
|
32
|
+
const id = isNotification ? undefined : this.nextId++;
|
|
33
|
+
const body = { jsonrpc: '2.0', method };
|
|
34
|
+
if (params !== undefined)
|
|
35
|
+
body.params = params;
|
|
36
|
+
if (id !== undefined)
|
|
37
|
+
body.id = id;
|
|
38
|
+
const headers = {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
Accept: 'application/json, text/event-stream',
|
|
41
|
+
'MCP-Protocol-Version': PROTOCOL_VERSION,
|
|
42
|
+
};
|
|
43
|
+
if (this.token) {
|
|
44
|
+
headers['Authorization'] = `Bearer ${this.token}`;
|
|
45
|
+
headers['X-API-Key'] = this.token;
|
|
46
|
+
}
|
|
47
|
+
if (this.sessionId)
|
|
48
|
+
headers['Mcp-Session-Id'] = this.sessionId;
|
|
49
|
+
let resp;
|
|
50
|
+
try {
|
|
51
|
+
resp = await fetch(this.url, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers,
|
|
54
|
+
body: JSON.stringify(body),
|
|
55
|
+
signal: AbortSignal.timeout(60_000),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
throw new Error(`MCP gateway unreachable (${method}): ${err?.message || err}`);
|
|
60
|
+
}
|
|
61
|
+
// The initialize response assigns the session id for subsequent calls.
|
|
62
|
+
const sid = resp.headers.get('mcp-session-id');
|
|
63
|
+
if (sid)
|
|
64
|
+
this.sessionId = sid;
|
|
65
|
+
if (isNotification)
|
|
66
|
+
return undefined; // 202 Accepted, no JSON-RPC reply
|
|
67
|
+
if (!resp.ok) {
|
|
68
|
+
const text = await resp.text().catch(() => '');
|
|
69
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
70
|
+
throw new Error(`MCP auth failed (HTTP ${resp.status}) — run \`/login\` or check your token.`);
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`MCP ${method} failed: HTTP ${resp.status} ${text.slice(0, 200)}`);
|
|
73
|
+
}
|
|
74
|
+
const ctype = resp.headers.get('content-type') || '';
|
|
75
|
+
const message = ctype.includes('text/event-stream')
|
|
76
|
+
? await this.readSseForId(resp, id)
|
|
77
|
+
: await resp.json();
|
|
78
|
+
if (message?.error) {
|
|
79
|
+
const e = message.error;
|
|
80
|
+
throw new Error(`MCP ${method} error: ${e?.message || JSON.stringify(e)}`);
|
|
81
|
+
}
|
|
82
|
+
return message?.result;
|
|
83
|
+
}
|
|
84
|
+
/** Read an SSE response body and return the JSON-RPC message matching `id`. */
|
|
85
|
+
async readSseForId(resp, id) {
|
|
86
|
+
const stream = resp.body;
|
|
87
|
+
if (!stream)
|
|
88
|
+
throw new Error('No MCP response body');
|
|
89
|
+
const reader = stream.getReader();
|
|
90
|
+
const decoder = new TextDecoder();
|
|
91
|
+
let buffer = '';
|
|
92
|
+
try {
|
|
93
|
+
while (true) {
|
|
94
|
+
const { done, value } = await reader.read();
|
|
95
|
+
if (done)
|
|
96
|
+
break;
|
|
97
|
+
buffer += decoder.decode(value, { stream: true });
|
|
98
|
+
buffer = buffer.replace(/\r\n/g, '\n');
|
|
99
|
+
const parts = buffer.split('\n\n');
|
|
100
|
+
buffer = parts.pop() || '';
|
|
101
|
+
for (const part of parts) {
|
|
102
|
+
for (const line of part.split('\n')) {
|
|
103
|
+
if (!line.startsWith('data:'))
|
|
104
|
+
continue;
|
|
105
|
+
const payload = line.slice(line.indexOf(':') + 1).trim();
|
|
106
|
+
if (!payload)
|
|
107
|
+
continue;
|
|
108
|
+
try {
|
|
109
|
+
const msg = JSON.parse(payload);
|
|
110
|
+
// Skip server-initiated requests/notifications; we want our reply.
|
|
111
|
+
if (msg && msg.id === id)
|
|
112
|
+
return msg;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* keep-alive or partial — ignore */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
reader.releaseLock();
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`MCP stream ended without a reply for id ${id}`);
|
|
125
|
+
}
|
|
126
|
+
/** Initialize the MCP session (idempotent, single-flight). */
|
|
127
|
+
async connect() {
|
|
128
|
+
if (this.initialized)
|
|
129
|
+
return;
|
|
130
|
+
if (this.initInFlight)
|
|
131
|
+
return this.initInFlight;
|
|
132
|
+
this.initInFlight = (async () => {
|
|
133
|
+
await this.rpc('initialize', {
|
|
134
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
135
|
+
capabilities: {},
|
|
136
|
+
clientInfo: CLIENT_INFO,
|
|
137
|
+
});
|
|
138
|
+
// Best-effort "initialized" notification — some servers require it.
|
|
139
|
+
try {
|
|
140
|
+
await this.rpc('notifications/initialized', undefined, true);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
/* non-fatal */
|
|
144
|
+
}
|
|
145
|
+
this.initialized = true;
|
|
146
|
+
})();
|
|
147
|
+
try {
|
|
148
|
+
await this.initInFlight;
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
this.initInFlight = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** List the tools the authenticated caller is entitled to. */
|
|
155
|
+
async listTools() {
|
|
156
|
+
await this.connect();
|
|
157
|
+
const result = await this.rpc('tools/list', {});
|
|
158
|
+
return (result?.tools || []);
|
|
159
|
+
}
|
|
160
|
+
/** Invoke a tool by name. Returns the MCP `{content, isError}` result. */
|
|
161
|
+
async callTool(name, args = {}) {
|
|
162
|
+
await this.connect();
|
|
163
|
+
return (await this.rpc('tools/call', { name, arguments: args }));
|
|
164
|
+
}
|
|
165
|
+
/** Cheap reachability + auth check. */
|
|
166
|
+
async ping() {
|
|
167
|
+
try {
|
|
168
|
+
await this.connect();
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// ── Singleton keyed by (url, token) ───────────────────────────────────────
|
|
177
|
+
// The REPL reuses one session per endpoint; a token/endpoint change rebuilds it.
|
|
178
|
+
let _client = null;
|
|
179
|
+
let _key = '';
|
|
180
|
+
/**
|
|
181
|
+
* Return a remote MCP client for the active config, or null when no MCP
|
|
182
|
+
* gateway URL is configured (local/Genesis mode uses the chat backend's
|
|
183
|
+
* REST /tools/call instead).
|
|
184
|
+
*/
|
|
185
|
+
export function getRemoteMcpClient(config) {
|
|
186
|
+
if (!config?.mcpUrl)
|
|
187
|
+
return null;
|
|
188
|
+
// Prefer the live token from ~/.aither/auth.json so an in-REPL /login or
|
|
189
|
+
// /logout re-keys the client; fall back to the token captured at load time.
|
|
190
|
+
const token = getActiveToken() ?? config.authToken ?? null;
|
|
191
|
+
const key = `${config.mcpUrl}::${token || ''}`;
|
|
192
|
+
if (_client && _key === key)
|
|
193
|
+
return _client;
|
|
194
|
+
_client = new McpHttpClient(config.mcpUrl, token);
|
|
195
|
+
_key = key;
|
|
196
|
+
return _client;
|
|
197
|
+
}
|
|
198
|
+
/** Drop the cached client (e.g. after logout/endpoint change). */
|
|
199
|
+
export function resetRemoteMcpClient() {
|
|
200
|
+
_client = null;
|
|
201
|
+
_key = '';
|
|
202
|
+
}
|
package/dist/relay.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AitherRelay client — native IRC-style group chat (humans + agents) over the
|
|
3
|
+
* relay WebSocket (`/ws/chat`, served by CommunicationCore). Agents joined to a
|
|
4
|
+
* channel respond in-line (server-side `_trigger_group_chat`), so this is the
|
|
5
|
+
* same multiagent room model as the DaoOS room.
|
|
6
|
+
*
|
|
7
|
+
* Protocol (client → server):
|
|
8
|
+
* { type: 'join', channel, nick, token }
|
|
9
|
+
* { type: 'message', channel, content }
|
|
10
|
+
* { type: 'typing', channel }
|
|
11
|
+
* { type: 'command', command: '/part', channel }
|
|
12
|
+
* (server → client): history | message | join | part | userlist | typing | error | system
|
|
13
|
+
*
|
|
14
|
+
* Auth: the token rides in the join payload (the relay resolves identity from
|
|
15
|
+
* it) — no custom WS headers needed, so the platform global WebSocket works.
|
|
16
|
+
*/
|
|
17
|
+
export interface RelayMessage {
|
|
18
|
+
channel: string;
|
|
19
|
+
nick: string;
|
|
20
|
+
content: string;
|
|
21
|
+
agent?: boolean;
|
|
22
|
+
timestamp?: number | string;
|
|
23
|
+
id?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface RelayUser {
|
|
26
|
+
nick: string;
|
|
27
|
+
is_agent?: boolean;
|
|
28
|
+
status?: string;
|
|
29
|
+
}
|
|
30
|
+
export type RelayStatus = 'connecting' | 'open' | 'reconnecting' | 'closed';
|
|
31
|
+
export interface RelayHandlers {
|
|
32
|
+
onStatus?: (status: RelayStatus, detail?: string) => void;
|
|
33
|
+
onHistory?: (channel: string, messages: RelayMessage[]) => void;
|
|
34
|
+
onMessage?: (msg: RelayMessage) => void;
|
|
35
|
+
onJoin?: (nick: string, channel: string, isAgent?: boolean) => void;
|
|
36
|
+
onPart?: (nick: string, channel: string) => void;
|
|
37
|
+
onUserlist?: (channel: string, users: RelayUser[]) => void;
|
|
38
|
+
onTyping?: (nick: string, channel: string) => void;
|
|
39
|
+
onError?: (message: string) => void;
|
|
40
|
+
}
|
|
41
|
+
export interface RelayChannel {
|
|
42
|
+
name: string;
|
|
43
|
+
topic?: string;
|
|
44
|
+
mode?: string;
|
|
45
|
+
user_count?: number;
|
|
46
|
+
}
|
|
47
|
+
/** Resolve the relay WS URL: AITHER_RELAY_URL override, else local CommunicationCore. */
|
|
48
|
+
export declare function resolveRelayUrl(): string;
|
|
49
|
+
export declare class RelayClient {
|
|
50
|
+
private ws;
|
|
51
|
+
private readonly url;
|
|
52
|
+
private readonly token?;
|
|
53
|
+
readonly nick: string;
|
|
54
|
+
private channel;
|
|
55
|
+
private readonly handlers;
|
|
56
|
+
private closedByUser;
|
|
57
|
+
private backoff;
|
|
58
|
+
private outbox;
|
|
59
|
+
constructor(opts: {
|
|
60
|
+
url?: string;
|
|
61
|
+
token?: string;
|
|
62
|
+
nick: string;
|
|
63
|
+
handlers: RelayHandlers;
|
|
64
|
+
});
|
|
65
|
+
get currentChannel(): string;
|
|
66
|
+
/** Join payload. When authenticated, OMIT the requested nick — the relay
|
|
67
|
+
* rejects a join whose nick doesn't match the token's identity nick. Letting
|
|
68
|
+
* the server pick the identity nick avoids that hard failure. Anonymous
|
|
69
|
+
* sessions send their chosen nick. */
|
|
70
|
+
private joinPayload;
|
|
71
|
+
/** HTTP base for the relay REST API (derive from the ws url). */
|
|
72
|
+
private httpBase;
|
|
73
|
+
private authHeaders;
|
|
74
|
+
/** List channels via REST for the picker. `scope` selects platform (default —
|
|
75
|
+
* public + the channels you can access, including workspace channels you're a
|
|
76
|
+
* member of), 'community', or 'workspace:<slug>'. Anonymous sessions get the
|
|
77
|
+
* public global/platform channels. Never throws. */
|
|
78
|
+
listChannels(scope?: string): Promise<RelayChannel[]>;
|
|
79
|
+
/** Fetch messages OLDER than `beforeTimestamp` (ISO) for scrollback. Returns
|
|
80
|
+
* them oldest→newest. Never throws. */
|
|
81
|
+
loadOlder(beforeTimestamp: string, limit?: number): Promise<RelayMessage[]>;
|
|
82
|
+
/** Connect and join `channel`. Reconnects with backoff until disconnect(). */
|
|
83
|
+
connect(channel: string): void;
|
|
84
|
+
private open;
|
|
85
|
+
private scheduleReconnect;
|
|
86
|
+
private onData;
|
|
87
|
+
/** Switch channels (server sends fresh history + userlist). */
|
|
88
|
+
join(channel: string): void;
|
|
89
|
+
/** Send a chat message to the current channel. Queues if the socket is mid-
|
|
90
|
+
* reconnect so nothing is silently dropped (flushed on the next open). */
|
|
91
|
+
send(content: string): void;
|
|
92
|
+
typing(): void;
|
|
93
|
+
part(channel?: string): void;
|
|
94
|
+
disconnect(): void;
|
|
95
|
+
private sendRaw;
|
|
96
|
+
}
|
package/dist/relay.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AitherRelay client — native IRC-style group chat (humans + agents) over the
|
|
3
|
+
* relay WebSocket (`/ws/chat`, served by CommunicationCore). Agents joined to a
|
|
4
|
+
* channel respond in-line (server-side `_trigger_group_chat`), so this is the
|
|
5
|
+
* same multiagent room model as the DaoOS room.
|
|
6
|
+
*
|
|
7
|
+
* Protocol (client → server):
|
|
8
|
+
* { type: 'join', channel, nick, token }
|
|
9
|
+
* { type: 'message', channel, content }
|
|
10
|
+
* { type: 'typing', channel }
|
|
11
|
+
* { type: 'command', command: '/part', channel }
|
|
12
|
+
* (server → client): history | message | join | part | userlist | typing | error | system
|
|
13
|
+
*
|
|
14
|
+
* Auth: the token rides in the join payload (the relay resolves identity from
|
|
15
|
+
* it) — no custom WS headers needed, so the platform global WebSocket works.
|
|
16
|
+
*/
|
|
17
|
+
/** Resolve the relay WS URL: AITHER_RELAY_URL override, else local CommunicationCore. */
|
|
18
|
+
export function resolveRelayUrl() {
|
|
19
|
+
const env = process.env.AITHER_RELAY_URL;
|
|
20
|
+
if (env)
|
|
21
|
+
return env.replace(/\/+$/, '');
|
|
22
|
+
// Local default — CommunicationCore serves the relay (root-mounted) on 8205,
|
|
23
|
+
// TLS-only, so wss://. The shell sets NODE_TLS_REJECT_UNAUTHORIZED=0 for the
|
|
24
|
+
// self-signed localhost cert. Point at wss://irc.aitherium.com/ws/chat for the
|
|
25
|
+
// hosted relay via AITHER_RELAY_URL.
|
|
26
|
+
return 'wss://127.0.0.1:8205/ws/chat';
|
|
27
|
+
}
|
|
28
|
+
export class RelayClient {
|
|
29
|
+
ws = null;
|
|
30
|
+
url;
|
|
31
|
+
token;
|
|
32
|
+
nick;
|
|
33
|
+
channel = '#general';
|
|
34
|
+
handlers;
|
|
35
|
+
closedByUser = false;
|
|
36
|
+
backoff = 1000;
|
|
37
|
+
outbox = []; // messages typed while the socket is down
|
|
38
|
+
constructor(opts) {
|
|
39
|
+
this.url = (opts.url || resolveRelayUrl()).replace(/\/+$/, '');
|
|
40
|
+
this.token = opts.token;
|
|
41
|
+
this.nick = opts.nick;
|
|
42
|
+
this.handlers = opts.handlers;
|
|
43
|
+
}
|
|
44
|
+
get currentChannel() { return this.channel; }
|
|
45
|
+
/** Join payload. When authenticated, OMIT the requested nick — the relay
|
|
46
|
+
* rejects a join whose nick doesn't match the token's identity nick. Letting
|
|
47
|
+
* the server pick the identity nick avoids that hard failure. Anonymous
|
|
48
|
+
* sessions send their chosen nick. */
|
|
49
|
+
joinPayload(channel) {
|
|
50
|
+
const p = { type: 'join', channel };
|
|
51
|
+
if (this.token)
|
|
52
|
+
p.token = this.token;
|
|
53
|
+
else
|
|
54
|
+
p.nick = this.nick;
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
/** HTTP base for the relay REST API (derive from the ws url). */
|
|
58
|
+
httpBase() {
|
|
59
|
+
return this.url.replace(/^ws/, 'http').replace(/\/ws\/chat$/, '');
|
|
60
|
+
}
|
|
61
|
+
authHeaders() {
|
|
62
|
+
const h = { Accept: 'application/json' };
|
|
63
|
+
if (this.token)
|
|
64
|
+
h['Authorization'] = `Bearer ${this.token}`;
|
|
65
|
+
return h;
|
|
66
|
+
}
|
|
67
|
+
/** List channels via REST for the picker. `scope` selects platform (default —
|
|
68
|
+
* public + the channels you can access, including workspace channels you're a
|
|
69
|
+
* member of), 'community', or 'workspace:<slug>'. Anonymous sessions get the
|
|
70
|
+
* public global/platform channels. Never throws. */
|
|
71
|
+
async listChannels(scope = 'platform') {
|
|
72
|
+
try {
|
|
73
|
+
const u = `${this.httpBase()}/v1/channels?scope=${encodeURIComponent(scope)}&nick=${encodeURIComponent(this.nick)}`;
|
|
74
|
+
const r = await fetch(u, { headers: this.authHeaders(), signal: AbortSignal.timeout(6000) });
|
|
75
|
+
if (!r.ok)
|
|
76
|
+
return [];
|
|
77
|
+
const data = await r.json();
|
|
78
|
+
const raw = Array.isArray(data) ? data : (data.channels || data.items || []);
|
|
79
|
+
return raw.map((c) => ({
|
|
80
|
+
name: typeof c === 'string' ? c : (c.name || c.channel || c.id),
|
|
81
|
+
topic: c.topic,
|
|
82
|
+
mode: c.mode,
|
|
83
|
+
user_count: c.user_count ?? c.users ?? c.member_count,
|
|
84
|
+
})).filter((c) => c.name);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Fetch messages OLDER than `beforeTimestamp` (ISO) for scrollback. Returns
|
|
91
|
+
* them oldest→newest. Never throws. */
|
|
92
|
+
async loadOlder(beforeTimestamp, limit = 50) {
|
|
93
|
+
try {
|
|
94
|
+
const ch = this.channel.replace(/^#/, '');
|
|
95
|
+
const u = `${this.httpBase()}/v1/channels/${encodeURIComponent(ch)}/messages`
|
|
96
|
+
+ `?limit=${limit}&before=${encodeURIComponent(beforeTimestamp)}`;
|
|
97
|
+
const r = await fetch(u, { headers: this.authHeaders(), signal: AbortSignal.timeout(8000) });
|
|
98
|
+
if (!r.ok)
|
|
99
|
+
return [];
|
|
100
|
+
const data = await r.json();
|
|
101
|
+
const msgs = (Array.isArray(data) ? data : (data.messages || []));
|
|
102
|
+
return msgs.slice().sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Connect and join `channel`. Reconnects with backoff until disconnect(). */
|
|
109
|
+
connect(channel) {
|
|
110
|
+
this.channel = channel || this.channel;
|
|
111
|
+
this.closedByUser = false;
|
|
112
|
+
this.open();
|
|
113
|
+
}
|
|
114
|
+
open() {
|
|
115
|
+
const WS = globalThis.WebSocket;
|
|
116
|
+
if (!WS) {
|
|
117
|
+
this.handlers.onError?.('WebSocket unavailable (needs Node 22+/bun).');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.handlers.onStatus?.('connecting');
|
|
121
|
+
let ws;
|
|
122
|
+
try {
|
|
123
|
+
ws = new WS(this.url);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
this.handlers.onError?.(`relay connect failed: ${e?.message || e}`);
|
|
127
|
+
this.scheduleReconnect();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.ws = ws;
|
|
131
|
+
ws.addEventListener('open', () => {
|
|
132
|
+
this.backoff = 1000;
|
|
133
|
+
this.handlers.onStatus?.('open');
|
|
134
|
+
this.sendRaw(this.joinPayload(this.channel));
|
|
135
|
+
// Flush anything typed while we were down (after the rejoin).
|
|
136
|
+
if (this.outbox.length) {
|
|
137
|
+
const pending = this.outbox.splice(0);
|
|
138
|
+
for (const content of pending)
|
|
139
|
+
this.sendRaw({ type: 'message', channel: this.channel, content });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
ws.addEventListener('message', (ev) => this.onData(ev.data));
|
|
143
|
+
ws.addEventListener('close', () => {
|
|
144
|
+
this.ws = null;
|
|
145
|
+
if (this.closedByUser)
|
|
146
|
+
this.handlers.onStatus?.('closed');
|
|
147
|
+
else
|
|
148
|
+
this.scheduleReconnect();
|
|
149
|
+
});
|
|
150
|
+
ws.addEventListener('error', () => { });
|
|
151
|
+
}
|
|
152
|
+
scheduleReconnect() {
|
|
153
|
+
if (this.closedByUser)
|
|
154
|
+
return;
|
|
155
|
+
this.handlers.onStatus?.('reconnecting');
|
|
156
|
+
const wait = this.backoff;
|
|
157
|
+
this.backoff = Math.min(this.backoff * 2, 15000);
|
|
158
|
+
setTimeout(() => { if (!this.closedByUser)
|
|
159
|
+
this.open(); }, wait);
|
|
160
|
+
}
|
|
161
|
+
onData(raw) {
|
|
162
|
+
let d;
|
|
163
|
+
try {
|
|
164
|
+
d = JSON.parse(String(raw));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
switch (d.type) {
|
|
170
|
+
case 'history':
|
|
171
|
+
this.handlers.onHistory?.(d.channel, (d.messages || []));
|
|
172
|
+
break;
|
|
173
|
+
case 'message':
|
|
174
|
+
this.handlers.onMessage?.(d);
|
|
175
|
+
break;
|
|
176
|
+
case 'join':
|
|
177
|
+
this.handlers.onJoin?.(d.nick, d.channel, d.is_agent);
|
|
178
|
+
break;
|
|
179
|
+
case 'part':
|
|
180
|
+
this.handlers.onPart?.(d.nick, d.channel);
|
|
181
|
+
break;
|
|
182
|
+
case 'userlist':
|
|
183
|
+
this.handlers.onUserlist?.(d.channel, (d.users || []));
|
|
184
|
+
break;
|
|
185
|
+
case 'typing':
|
|
186
|
+
this.handlers.onTyping?.(d.nick, d.channel);
|
|
187
|
+
break;
|
|
188
|
+
case 'error':
|
|
189
|
+
case 'system':
|
|
190
|
+
this.handlers.onError?.(d.message || d.content || 'relay error');
|
|
191
|
+
break;
|
|
192
|
+
default:
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Switch channels (server sends fresh history + userlist). */
|
|
197
|
+
join(channel) {
|
|
198
|
+
this.channel = channel.startsWith('#') ? channel : `#${channel}`;
|
|
199
|
+
this.sendRaw(this.joinPayload(this.channel));
|
|
200
|
+
}
|
|
201
|
+
/** Send a chat message to the current channel. Queues if the socket is mid-
|
|
202
|
+
* reconnect so nothing is silently dropped (flushed on the next open). */
|
|
203
|
+
send(content) {
|
|
204
|
+
const c = (content || '').trim();
|
|
205
|
+
if (!c)
|
|
206
|
+
return;
|
|
207
|
+
const open = this.ws && this.ws.readyState === 1 /* OPEN */;
|
|
208
|
+
if (open)
|
|
209
|
+
this.sendRaw({ type: 'message', channel: this.channel, content: c });
|
|
210
|
+
else {
|
|
211
|
+
this.outbox.push(c);
|
|
212
|
+
if (this.outbox.length > 100)
|
|
213
|
+
this.outbox.shift();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
typing() { this.sendRaw({ type: 'typing', channel: this.channel }); }
|
|
217
|
+
part(channel) {
|
|
218
|
+
this.sendRaw({ type: 'command', command: '/part', channel: channel || this.channel });
|
|
219
|
+
}
|
|
220
|
+
disconnect() {
|
|
221
|
+
this.closedByUser = true;
|
|
222
|
+
try {
|
|
223
|
+
this.ws?.close();
|
|
224
|
+
}
|
|
225
|
+
catch { /* */ }
|
|
226
|
+
this.ws = null;
|
|
227
|
+
}
|
|
228
|
+
sendRaw(obj) {
|
|
229
|
+
try {
|
|
230
|
+
this.ws?.send(JSON.stringify(obj));
|
|
231
|
+
}
|
|
232
|
+
catch { /* dropped; reconnect will rejoin */ }
|
|
233
|
+
}
|
|
234
|
+
}
|
package/dist/renderer.d.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* Terminal renderer — banners, streaming tokens, markdown, tables.
|
|
3
3
|
*/
|
|
4
4
|
import type { SSEEvent } from './client.js';
|
|
5
|
+
export declare const TRACE_FULL: boolean;
|
|
6
|
+
/** Wrap text in an OSC 8 clickable hyperlink for terminals that support it. */
|
|
7
|
+
export declare function osc8Link(url: string, label?: string): string;
|
|
5
8
|
export declare function renderBanner(info: {
|
|
6
9
|
genesis: string;
|
|
7
10
|
genesisOnline?: boolean;
|
|
@@ -21,11 +24,21 @@ export declare function renderBanner(info: {
|
|
|
21
24
|
* and render as clickable OSC 8 terminal hyperlinks.
|
|
22
25
|
*/
|
|
23
26
|
export declare function resolveImagePath(relativePath: string): string;
|
|
27
|
+
/** Strip OSC-8 hyperlink escapes (blessed/TUI can't render them → raw `]8;;`). */
|
|
28
|
+
export declare function stripOsc8(text: string): string;
|
|
29
|
+
/** Open a local image in the OS default viewer (detached). Best-effort. */
|
|
30
|
+
export declare function openLocalImage(p: string): void;
|
|
31
|
+
/** Scan answer text for generated-image markdown and open any LOCAL ones in the
|
|
32
|
+
* OS viewer (so the user actually SEES the image — the TUI can't render it
|
|
33
|
+
* inline). Opt out with AITHER_AUTO_OPEN_IMAGES=0. */
|
|
34
|
+
export declare function autoOpenImagesFromText(text: string): void;
|
|
24
35
|
export declare function renderMarkdown(text: string): string;
|
|
25
36
|
export interface StreamRenderer {
|
|
26
37
|
onEvent(event: SSEEvent): void;
|
|
27
38
|
getContent(): string;
|
|
28
|
-
|
|
39
|
+
/** End-of-turn cleanup. `aborted` = the user interrupted (Ctrl+C) — skip
|
|
40
|
+
* the "stream ended before completion" warning in that case. */
|
|
41
|
+
finish(aborted?: boolean): void;
|
|
29
42
|
/** Full trace of all events received during this session prompt. */
|
|
30
43
|
getTrace(): SSEEvent[];
|
|
31
44
|
/** Session profile for persistence and future reference. */
|
|
@@ -55,43 +68,53 @@ export interface SessionArtifact {
|
|
|
55
68
|
size: number;
|
|
56
69
|
language: string;
|
|
57
70
|
retrieve_cmd: string;
|
|
71
|
+
download_url: string;
|
|
58
72
|
timestamp: string;
|
|
59
73
|
}
|
|
60
74
|
export declare function getSessionArtifacts(): SessionArtifact[];
|
|
61
75
|
export declare function clearSessionArtifacts(): void;
|
|
76
|
+
/** Register a locally-saved file as a session artifact (so /get + /artifacts find it). */
|
|
77
|
+
export declare function addSessionArtifact(a: {
|
|
78
|
+
path: string;
|
|
79
|
+
size?: number;
|
|
80
|
+
language?: string;
|
|
81
|
+
}): number;
|
|
62
82
|
export declare function createStreamRenderer(sessionId?: string, prompt?: string, steeringBar?: SteeringBar): StreamRenderer;
|
|
63
83
|
export declare function formatTable(headers: string[], rows: string[][]): string;
|
|
64
84
|
/**
|
|
65
|
-
* Keeps a
|
|
66
|
-
* stream output
|
|
67
|
-
*
|
|
85
|
+
* Keeps a fixed input bar at the bottom of the terminal while
|
|
86
|
+
* stream output scrolls above. Uses DECSTBM (Set Scrolling Region)
|
|
87
|
+
* to physically separate the output area from the bar — no stdout
|
|
88
|
+
* monkey-patching, no erase/redraw races.
|
|
68
89
|
*
|
|
69
|
-
*
|
|
70
|
-
* 1
|
|
71
|
-
* 2
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* 5. On Enter, the bar clears and the repl routes to /chat/steer
|
|
90
|
+
* Layout:
|
|
91
|
+
* Rows 1 to (rows-3): SCROLL REGION — all output, tokens, spinners
|
|
92
|
+
* Row (rows-2): Status spinner (e.g., "generation...")
|
|
93
|
+
* Row (rows-1): Separator line ────────
|
|
94
|
+
* Row (rows): [steer] > input
|
|
75
95
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
96
|
+
* Output cannot collide with the bar because they occupy different
|
|
97
|
+
* terminal regions.
|
|
78
98
|
*/
|
|
79
99
|
export declare class SteeringBar {
|
|
80
100
|
private _active;
|
|
81
101
|
private _inputText;
|
|
82
102
|
private _statusText;
|
|
83
|
-
private _origWrite;
|
|
84
|
-
private _barDrawn;
|
|
85
|
-
private _debounceTimer;
|
|
86
103
|
private _spinFrame;
|
|
87
104
|
private _spinTimer;
|
|
105
|
+
private _tokenMode;
|
|
106
|
+
private _resizeHandler;
|
|
107
|
+
private _resizeTimer;
|
|
108
|
+
private _lastRows;
|
|
88
109
|
private static readonly _SPIN_CHARS;
|
|
89
110
|
get active(): boolean;
|
|
90
111
|
get inputText(): string;
|
|
91
112
|
activate(): void;
|
|
113
|
+
/** Debounced, artifact-free resize repaint. */
|
|
114
|
+
private _handleResize;
|
|
92
115
|
deactivate(): void;
|
|
93
|
-
/**
|
|
94
|
-
private
|
|
116
|
+
/** Set scroll region and draw bar, preserving cursor position. */
|
|
117
|
+
private _setupRegion;
|
|
95
118
|
/** Show spinner + status text in the bar (replaces ora when bar is active). */
|
|
96
119
|
setStatus(text: string): void;
|
|
97
120
|
/** Clear status text. */
|
|
@@ -100,5 +123,12 @@ export declare class SteeringBar {
|
|
|
100
123
|
setInput(text: string): void;
|
|
101
124
|
/** Clear input after steer send. */
|
|
102
125
|
clearInput(): void;
|
|
126
|
+
enterTokenMode(): void;
|
|
127
|
+
exitTokenMode(): void;
|
|
128
|
+
/**
|
|
129
|
+
* Draw the fixed bottom bar — single atomic write.
|
|
130
|
+
* Uses absolute positioning outside the scroll region, then restores
|
|
131
|
+
* cursor back into the scroll region.
|
|
132
|
+
*/
|
|
103
133
|
private _drawBar;
|
|
104
134
|
}
|