@anhlp/alp-client 0.0.0-beta.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/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +24 -0
- package/dist/acp-probe.d.ts +44 -0
- package/dist/acp-probe.js +37 -0
- package/dist/antigravity-probe.d.ts +34 -0
- package/dist/antigravity-probe.js +95 -0
- package/dist/command.d.ts +5 -0
- package/dist/command.js +33 -0
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.js +587 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +422 -0
- package/dist/ipc.d.ts +42 -0
- package/dist/ipc.js +69 -0
- package/dist/library-test.d.ts +94 -0
- package/dist/library-test.js +37 -0
- package/dist/mcp-probe.d.ts +24 -0
- package/dist/mcp-probe.js +147 -0
- package/dist/portable.d.ts +18 -0
- package/dist/portable.js +27 -0
- package/dist/prompt.d.ts +32 -0
- package/dist/prompt.js +59 -0
- package/dist/render.d.ts +6 -0
- package/dist/render.js +120 -0
- package/dist/rpc/client.d.ts +82 -0
- package/dist/rpc/client.js +292 -0
- package/dist/rpc/errors.d.ts +42 -0
- package/dist/rpc/errors.js +34 -0
- package/dist/rpc/framing.d.ts +21 -0
- package/dist/rpc/framing.js +57 -0
- package/dist/rpc/handshake.d.ts +19 -0
- package/dist/rpc/handshake.js +8 -0
- package/dist/rpc/reconnect.d.ts +97 -0
- package/dist/rpc/reconnect.js +227 -0
- package/dist/rpc/timers.d.ts +11 -0
- package/dist/rpc/timers.js +11 -0
- package/dist/service.d.ts +50 -0
- package/dist/service.js +143 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +146 -0
- package/dist/supervise.d.ts +12 -0
- package/dist/supervise.js +81 -0
- package/dist/websocket.d.ts +51 -0
- package/dist/websocket.js +190 -0
- package/package.json +168 -0
- package/src/acp-probe.js +46 -0
- package/src/antigravity-probe.js +106 -0
- package/src/command.js +18 -0
- package/src/doctor.d.ts +13 -0
- package/src/doctor.js +570 -0
- package/src/index.js +457 -0
- package/src/ipc.js +92 -0
- package/src/library-test.js +41 -0
- package/src/mcp-probe.js +118 -0
- package/src/portable.js +27 -0
- package/src/prompt.js +64 -0
- package/src/render.js +136 -0
- package/src/rpc/client.js +296 -0
- package/src/rpc/errors.js +54 -0
- package/src/rpc/framing.js +61 -0
- package/src/rpc/handshake.js +16 -0
- package/src/rpc/reconnect.js +226 -0
- package/src/rpc/timers.js +16 -0
- package/src/service.js +154 -0
- package/src/setup.js +150 -0
- package/src/supervise.d.ts +12 -0
- package/src/supervise.js +85 -0
- package/src/websocket.js +259 -0
package/src/mcp-probe.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { launchCommand } from '@anhlp/alp-core/providers';
|
|
4
|
+
|
|
5
|
+
const PROTOCOL = '2025-06-18';
|
|
6
|
+
const client = { name: 'alp', version: '1' };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Starts an MCP server once and lists its tools (ALPD §43), for the Test button and
|
|
10
|
+
* `alp mcp test`. `server` is a normalized definition: { command, args, env, cwd } for
|
|
11
|
+
* stdio, or { url, headers } for HTTP. The server is stopped afterwards.
|
|
12
|
+
* @returns {Promise<{ server?: { name?: string, version?: string }, tools: Array<{ name: string, description?: string }> }>}
|
|
13
|
+
*/
|
|
14
|
+
export async function probeMcp(server, { cwd, timeoutMs = 15_000 } = {}) {
|
|
15
|
+
const session = server.url ? await httpSession(server, timeoutMs) : stdioSession(server, cwd, timeoutMs);
|
|
16
|
+
try {
|
|
17
|
+
const init = await session.request('initialize', { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: client });
|
|
18
|
+
await session.notify('notifications/initialized');
|
|
19
|
+
const tools = [];
|
|
20
|
+
let cursor;
|
|
21
|
+
for (let page = 0; page < 20; page++) {
|
|
22
|
+
const result = await session.request('tools/list', cursor ? { cursor } : {});
|
|
23
|
+
for (const tool of result.tools ?? []) tools.push({ name: tool.name, ...(tool.description ? { description: tool.description } : {}) });
|
|
24
|
+
cursor = result.nextCursor;
|
|
25
|
+
if (!cursor) break;
|
|
26
|
+
}
|
|
27
|
+
return { ...(init.serverInfo ? { server: { name: init.serverInfo.name, version: init.serverInfo.version } } : {}), tools };
|
|
28
|
+
} finally {
|
|
29
|
+
await session.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A JSON-RPC session with a process over stdio; ACP providers are probed with it too. */
|
|
34
|
+
export function stdioSession(server, cwd, timeoutMs) {
|
|
35
|
+
// Its own process group, so a launcher such as npx is stopped with what it started.
|
|
36
|
+
const env = { ...process.env, ...(server.env ?? {}) };
|
|
37
|
+
const launch = launchCommand(server.command, server.args ?? [], env);
|
|
38
|
+
const child = spawn(launch.command, launch.args, { cwd: server.cwd ?? cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true });
|
|
39
|
+
const signal = name => { try { process.kill(-child.pid, name); } catch { child.kill(name); } };
|
|
40
|
+
let stderr = '';
|
|
41
|
+
child.stderr.setEncoding('utf8');
|
|
42
|
+
child.stderr.on('data', chunk => { if (stderr.length < 4000) stderr += chunk; });
|
|
43
|
+
const pending = new Map();
|
|
44
|
+
let exited;
|
|
45
|
+
const failAll = error => { for (const { reject } of pending.values()) reject(error); pending.clear(); };
|
|
46
|
+
child.on('error', error => { exited = error; failAll(error); });
|
|
47
|
+
child.on('exit', code => {
|
|
48
|
+
exited = new Error(`${server.command} exited (${code ?? 'signal'})${stderr.trim() ? `: ${stderr.trim().split('\n').slice(-3).join(' ')}` : ''}`);
|
|
49
|
+
failAll(exited);
|
|
50
|
+
});
|
|
51
|
+
createInterface({ input: child.stdout }).on('line', line => {
|
|
52
|
+
let message;
|
|
53
|
+
try { message = JSON.parse(line); } catch { return; }
|
|
54
|
+
const waiting = pending.get(message.id);
|
|
55
|
+
if (!waiting) return;
|
|
56
|
+
pending.delete(message.id);
|
|
57
|
+
if (message.error) waiting.reject(new Error(`${message.error.message ?? 'MCP error'} (${message.error.code})`));
|
|
58
|
+
else waiting.resolve(message.result ?? {});
|
|
59
|
+
});
|
|
60
|
+
child.stdin.on('error', () => {});
|
|
61
|
+
let id = 0;
|
|
62
|
+
return {
|
|
63
|
+
request(method, params) {
|
|
64
|
+
if (exited) return Promise.reject(exited);
|
|
65
|
+
const current = ++id;
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const timer = setTimeout(() => { pending.delete(current); reject(new Error(`${method} got no answer within ${timeoutMs / 1000} s`)); }, timeoutMs);
|
|
68
|
+
pending.set(current, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } });
|
|
69
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: current, method, params }) + '\n');
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
async notify(method) { if (!exited) child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method }) + '\n'); },
|
|
73
|
+
async close() {
|
|
74
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
75
|
+
const done = new Promise(resolve => child.once('exit', resolve));
|
|
76
|
+
child.stdin.end();
|
|
77
|
+
signal('SIGTERM');
|
|
78
|
+
const timer = setTimeout(() => signal('SIGKILL'), 2000);
|
|
79
|
+
await done;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function httpSession(server, timeoutMs) {
|
|
86
|
+
let sessionId;
|
|
87
|
+
let id = 0;
|
|
88
|
+
const post = async body => {
|
|
89
|
+
const response = await fetch(server.url, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { ...(server.headers ?? {}), 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...(sessionId ? { 'mcp-session-id': sessionId } : {}), 'mcp-protocol-version': PROTOCOL },
|
|
92
|
+
body: JSON.stringify(body),
|
|
93
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
94
|
+
});
|
|
95
|
+
sessionId = response.headers.get('mcp-session-id') ?? sessionId;
|
|
96
|
+
if (!response.ok) throw new Error(`${server.url} answered ${response.status} ${response.statusText}`);
|
|
97
|
+
return response;
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
async request(method, params) {
|
|
101
|
+
const current = ++id;
|
|
102
|
+
const response = await post({ jsonrpc: '2.0', id: current, method, params });
|
|
103
|
+
const text = await response.text();
|
|
104
|
+
// A streamable HTTP server answers with JSON or with server-sent events.
|
|
105
|
+
const messages = (response.headers.get('content-type') ?? '').includes('text/event-stream')
|
|
106
|
+
? text.split('\n').filter(line => line.startsWith('data:')).map(line => { try { return JSON.parse(line.slice(5)); } catch { return undefined; } })
|
|
107
|
+
: [JSON.parse(text)];
|
|
108
|
+
const message = messages.find(entry => entry?.id === current);
|
|
109
|
+
if (!message) throw new Error(`${method}: no answer in the response`);
|
|
110
|
+
if (message.error) throw new Error(`${message.error.message ?? 'MCP error'} (${message.error.code})`);
|
|
111
|
+
return message.result ?? {};
|
|
112
|
+
},
|
|
113
|
+
async notify(method) { await post({ jsonrpc: '2.0', method }); },
|
|
114
|
+
async close() {
|
|
115
|
+
if (sessionId) await fetch(server.url, { method: 'DELETE', headers: { ...(server.headers ?? {}), 'mcp-session-id': sessionId }, signal: AbortSignal.timeout(timeoutMs) }).catch(() => {});
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/portable.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The portable ALP client (docs/alp/client-sdk.md): the JSON-RPC engine, its errors, the reconnect
|
|
3
|
+
* helper and NDJSON framing. It runs in Node, browsers and React Native without polyfills: no Node
|
|
4
|
+
* built-ins, no process, no Buffer, no filesystem and no ALP core. Transports come separately:
|
|
5
|
+
* `@anhlp/alp-client/websocket` here, the Unix socket and named pipe in the Node entry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {import('./rpc/client.js').AlpTransport} AlpTransport
|
|
10
|
+
* @typedef {import('./rpc/client.js').TransportClose} TransportClose
|
|
11
|
+
* @typedef {import('./rpc/client.js').TransportState} TransportState
|
|
12
|
+
* @typedef {import('./rpc/client.js').AbortSignalLike} AbortSignalLike
|
|
13
|
+
* @typedef {import('./rpc/client.js').RequestOptions} RequestOptions
|
|
14
|
+
* @typedef {import('./rpc/client.js').AlpRpcClientOptions} AlpRpcClientOptions
|
|
15
|
+
* @typedef {import('./rpc/handshake.js').ClientInfo} ClientInfo
|
|
16
|
+
* @typedef {import('./rpc/reconnect.js').ReconnectOptions} ReconnectOptions
|
|
17
|
+
* @typedef {import('./rpc/reconnect.js').ReconnectingClient} ReconnectingClient
|
|
18
|
+
* @typedef {import('./rpc/reconnect.js').ReconnectState} ReconnectState
|
|
19
|
+
* @typedef {import('./rpc/reconnect.js').Backoff} Backoff
|
|
20
|
+
*/
|
|
21
|
+
export { PROTOCOL_VERSION, MAX_FRAME, AlpRpcError, ERROR } from '@anhlp/alp-protocol';
|
|
22
|
+
export { AlpRpcClient, DEFAULT_MAX_PENDING } from './rpc/client.js';
|
|
23
|
+
export { AlpClientError, CLIENT_ERROR, isAlpClientError } from './rpc/errors.js';
|
|
24
|
+
export { createLineDecoder, utf8Length } from './rpc/framing.js';
|
|
25
|
+
export { MAX_TIMER_MS, timerDelay } from './rpc/timers.js';
|
|
26
|
+
export { hello } from './rpc/handshake.js';
|
|
27
|
+
export { backoffDelay, createReconnectingClient, retryable } from './rpc/reconnect.js';
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
2
|
+
|
|
3
|
+
const dim = text => `\x1b[2m${text}\x1b[22m`;
|
|
4
|
+
const cyan = text => `\x1b[36m${text}\x1b[39m`;
|
|
5
|
+
|
|
6
|
+
export class Cancelled extends Error {
|
|
7
|
+
constructor() { super('Setup cancelled.'); }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A select box in the terminal: ↑/↓ (or k/j) move, Enter picks, Ctrl-C or Esc cancels.
|
|
12
|
+
* Choices are { label, value, hint? }; `initial` is the value highlighted first.
|
|
13
|
+
* @template T
|
|
14
|
+
* @param {{ message: string, choices: Array<{ label: string, value: T, hint?: string }>, initial?: T }} question
|
|
15
|
+
* @param {{ input?: NodeJS.ReadStream, output?: NodeJS.WriteStream }} [io]
|
|
16
|
+
* @returns {Promise<T>}
|
|
17
|
+
*/
|
|
18
|
+
export function select({ message, choices, initial }, { input = process.stdin, output = process.stdout } = {}) {
|
|
19
|
+
let index = Math.max(0, choices.findIndex(choice => choice.value === initial));
|
|
20
|
+
let drawn = 0;
|
|
21
|
+
const draw = () => {
|
|
22
|
+
if (drawn) output.write(`\x1b[${drawn}A\x1b[0J`);
|
|
23
|
+
const lines = [`${cyan('?')} ${message} ${dim('(↑/↓, Enter)')}`, ...choices.map((choice, at) =>
|
|
24
|
+
at === index ? cyan(`❯ ${choice.label}`) + (choice.hint ? ` ${dim(choice.hint)}` : '') : ` ${choice.label}${choice.hint ? ` ${dim(choice.hint)}` : ''}`)];
|
|
25
|
+
output.write(lines.join('\n') + '\n');
|
|
26
|
+
drawn = lines.length;
|
|
27
|
+
};
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
emitKeypressEvents(input);
|
|
30
|
+
const raw = input.isRaw;
|
|
31
|
+
input.setRawMode?.(true);
|
|
32
|
+
input.resume();
|
|
33
|
+
output.write('\x1b[?25l');
|
|
34
|
+
const done = (settle, value) => {
|
|
35
|
+
input.off('keypress', onKey);
|
|
36
|
+
input.setRawMode?.(raw ?? false);
|
|
37
|
+
input.pause();
|
|
38
|
+
output.write(`\x1b[${drawn}A\x1b[0J\x1b[?25h`);
|
|
39
|
+
if (settle === resolve) output.write(`${cyan('✔')} ${message} ${cyan(choices[index].label)}\n`);
|
|
40
|
+
settle(value);
|
|
41
|
+
};
|
|
42
|
+
const onKey = (text, key = {}) => {
|
|
43
|
+
if ((key.ctrl && key.name === 'c') || key.name === 'escape') return done(reject, new Cancelled());
|
|
44
|
+
if (key.name === 'return' || key.name === 'enter') return done(resolve, choices[index].value);
|
|
45
|
+
if (key.name === 'up' || key.name === 'k') index = (index + choices.length - 1) % choices.length;
|
|
46
|
+
else if (key.name === 'down' || key.name === 'j') index = (index + 1) % choices.length;
|
|
47
|
+
else return;
|
|
48
|
+
draw();
|
|
49
|
+
};
|
|
50
|
+
input.on('keypress', onKey);
|
|
51
|
+
draw();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A line of text, for a value the list does not offer.
|
|
57
|
+
* @param {string} message
|
|
58
|
+
* @param {{ input?: NodeJS.ReadStream, output?: NodeJS.WriteStream }} [io]
|
|
59
|
+
*/
|
|
60
|
+
export async function text(message, { input = process.stdin, output = process.stdout } = {}) {
|
|
61
|
+
const { createInterface } = await import('node:readline/promises');
|
|
62
|
+
const rl = createInterface({ input, output });
|
|
63
|
+
try { return (await rl.question(`${cyan('?')} ${message} `)).trim(); } finally { rl.close(); }
|
|
64
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text the CLI prints for sessions and run logs, kept apart from the commands so
|
|
3
|
+
* golden tests can render fixtures without an alpd (ALPD §39).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const TASK_PAST = { create: 'created', update: 'updated', link: 'linked', start: 'started', close: 'closed', reopen: 'reopened', delegate: 'delegated', submit: 'submitted', release: 'released', gate: 'gated', clear: 'cleared a gate of', orphaned: 'reopened (its assignment ended with alpd)' };
|
|
7
|
+
|
|
8
|
+
/** How long ago `since` was, as 42s, 3m05s or 2h10m. */
|
|
9
|
+
export const ago = (since, now = Date.now()) => {
|
|
10
|
+
const seconds = Math.max(0, Math.round((now - Date.parse(since)) / 1000));
|
|
11
|
+
return seconds < 60 ? `${seconds}s` : seconds < 3600 ? `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s` : `${Math.floor(seconds / 3600)}h${String(Math.floor(seconds % 3600 / 60)).padStart(2, '0')}m`;
|
|
12
|
+
};
|
|
13
|
+
/** A duration in the same form. */
|
|
14
|
+
export const duration = ms => ago(new Date(0).toISOString(), ms);
|
|
15
|
+
/** A token count as people say it: 164k, 1M. */
|
|
16
|
+
const tokens = count => typeof count !== 'number' ? '?' : count >= 1_000_000 && count % 1_000_000 < 50_000 ? `${Math.round(count / 1_000_000)}M` : `${Math.round(count / 1000)}k`;
|
|
17
|
+
|
|
18
|
+
/** `alp ps`: sessions as a tree, one line each. */
|
|
19
|
+
export function renderPs(sessions) {
|
|
20
|
+
const lines = [];
|
|
21
|
+
const children = new Map();
|
|
22
|
+
for (const session of sessions) {
|
|
23
|
+
const key = session.parentId ?? '';
|
|
24
|
+
children.set(key, [...(children.get(key) ?? []), session]);
|
|
25
|
+
}
|
|
26
|
+
const print = (session, depth) => {
|
|
27
|
+
const status = session.parked ? `parked (${session.parked})` : session.status === 'running' || session.status === 'idle' ? (session.activeTurnId ? 'running' : session.busy ? 'waiting' : 'idle') : session.lastError?.code ?? session.status;
|
|
28
|
+
lines.push(`${' '.repeat(depth)}${session.id} ${session.agent} ${session.runtime}:${session.model} ${session.mode} ${status}${depth ? '' : ` ${session.projectRoot}${session.title ? ` "${session.title}"` : ''}`}`);
|
|
29
|
+
for (const child of children.get(session.id) ?? []) print(child, depth + 1);
|
|
30
|
+
};
|
|
31
|
+
for (const root of children.get('') ?? []) print(root, 0);
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `alp log`: one line per run-log entry, with its time. */
|
|
36
|
+
export function renderLog(rootId, entries) {
|
|
37
|
+
const lines = [];
|
|
38
|
+
const agents = new Map();
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const time = entry.ts.slice(11, 19);
|
|
41
|
+
let text;
|
|
42
|
+
switch (entry.event) {
|
|
43
|
+
case 'assignment.started':
|
|
44
|
+
agents.set(entry.assignmentId, entry.agent);
|
|
45
|
+
text = `${entry.parentAgent} → ${entry.agent} (${entry.mode}${entry.isolation === 'worktree' ? ', worktree' : ''}, ${entry.model}${entry.wait === false ? ', async' : ''}): ${entry.task.split('\n')[0].slice(0, 120)}`;
|
|
46
|
+
break;
|
|
47
|
+
case 'assignment.finished':
|
|
48
|
+
text = `${entry.agent} ${entry.status} after ${duration(entry.durationMs ?? 0)}${entry.handoff ? `, handoff ${entry.handoff.outcome}${entry.handoff.verdict ? `, verdict ${entry.handoff.verdict.result.toUpperCase()}` : ''}: ${entry.handoff.summary.split('\n')[0].slice(0, 120)}` : ''}${entry.error ? `: ${entry.error}` : ''}${entry.reconciled ? ' (after a restart)' : ''}`;
|
|
49
|
+
break;
|
|
50
|
+
case 'mail':
|
|
51
|
+
// Board pins are logged once, as board.pin, not per reader.
|
|
52
|
+
if (entry.kind === 'board') continue;
|
|
53
|
+
text = `✉ ${entry.kind} ${entry.from} → ${entry.to === rootId ? 'main' : agents.get(entry.to) ?? entry.to}${entry.body ? `: ${entry.body.split('\n')[0].slice(0, 120)}` : ''}`;
|
|
54
|
+
break;
|
|
55
|
+
case 'board.pin':
|
|
56
|
+
text = `${entry.kind === 'claim' ? '⚑' : entry.kind === 'decision' ? '◆' : '•'} ${entry.agent} pins ${entry.kind} ${entry.pinId}${entry.task ? ` for ${entry.task}` : ''}${entry.paths ? ` [${entry.paths.join(', ')}]` : ''}: ${entry.body.split('\n')[0].slice(0, 120)}`;
|
|
57
|
+
break;
|
|
58
|
+
case 'task':
|
|
59
|
+
text = `☐ ${entry.agent} ${TASK_PAST[entry.action] ?? entry.action} ${entry.id} "${entry.title}" → ${entry.status}${entry.detail ? `: ${entry.detail.split('\n')[0].slice(0, 120)}` : ''}`;
|
|
60
|
+
break;
|
|
61
|
+
case 'board.unpin':
|
|
62
|
+
text = `⚐ ${entry.pinId} by ${entry.agent} ${entry.reason === 'session_ended' ? 'released when its session ended' : 'taken down'}`;
|
|
63
|
+
break;
|
|
64
|
+
case 'human.question':
|
|
65
|
+
text = `? ${entry.agent} asks the user [${entry.questionId}]: ${entry.body.split('\n')[0].slice(0, 120)}`;
|
|
66
|
+
break;
|
|
67
|
+
case 'verify':
|
|
68
|
+
text = `${entry.skipped ? '–' : entry.passed ? '✓' : '✗'} verify in the ${entry.where}${entry.taskId ? ` for ${entry.taskId}` : ''}${entry.assignmentId ? ` (${entry.assignmentId})` : ''}: ${entry.skipped ? `skipped: ${entry.skipped}` : entry.detail}`;
|
|
69
|
+
break;
|
|
70
|
+
case 'epic.landed':
|
|
71
|
+
text = `◆ ${entry.agent} closed ${entry.id} "${entry.title}": ${entry.tasks} tasks, ${entry.reworked} reworked`;
|
|
72
|
+
break;
|
|
73
|
+
case 'notice':
|
|
74
|
+
text = `${entry.level === 'error' ? '‼' : entry.level === 'warning' ? '!' : 'ℹ'} ${entry.text}`;
|
|
75
|
+
break;
|
|
76
|
+
case 'assignment.parked':
|
|
77
|
+
text = `⏸ ${entry.agent} ${entry.assignmentId} parked: ${entry.reason}`;
|
|
78
|
+
break;
|
|
79
|
+
case 'assignment.resumed':
|
|
80
|
+
text = `▶ ${entry.agent} ${entry.assignmentId} resumed`;
|
|
81
|
+
break;
|
|
82
|
+
case 'instructions':
|
|
83
|
+
text = `# ${entry.agent} instructions ${entry.sha} (ALP.md ${entry.parts?.project}, AGENT.md ${entry.parts?.agent}, ${entry.chars} chars)`;
|
|
84
|
+
break;
|
|
85
|
+
case 'context':
|
|
86
|
+
// Before 0.6 the advice came at 60% and 80% of the window (ALPD §35); since, once near compaction (§57).
|
|
87
|
+
text = entry.level === 'soon'
|
|
88
|
+
? `◔ ${entry.agent} context ${tokens(entry.tokens)}, compacts at ${tokens(entry.compactAt)}: told it is near`
|
|
89
|
+
: `◔ ${entry.agent} context ${entry.percent}% full: ${entry.level === 'now' ? 'told to hand off now' : 'told to plan a handoff'}`;
|
|
90
|
+
break;
|
|
91
|
+
case 'skill':
|
|
92
|
+
// alp_skill saving a skill (name, roles) shares the name with an agent using one (skill, via).
|
|
93
|
+
text = Array.isArray(entry.roles)
|
|
94
|
+
? `✦ ${entry.agent} saved skill ${entry.name} for ${entry.roles.join(', ')}`
|
|
95
|
+
: `✦ ${entry.agent} uses skill ${entry.skill}${entry.via === 'skill' ? ' (Skill tool)' : ''}`;
|
|
96
|
+
break;
|
|
97
|
+
case 'compacted':
|
|
98
|
+
text = entry.failed
|
|
99
|
+
? `✗ ${entry.agent}'s context could not be compacted${entry.error ? `: ${entry.error}` : ''}`
|
|
100
|
+
: `◑ ${entry.agent}'s context compacted${entry.preTokens !== undefined ? ` from ${tokens(entry.preTokens)}` : ''}${entry.postTokens !== undefined ? ` to ${tokens(entry.postTokens)}` : ''}${entry.trigger === 'manual' ? ' on request' : ''}`;
|
|
101
|
+
break;
|
|
102
|
+
case 'assignment.interrupted':
|
|
103
|
+
text = `⏹ ${entry.agent} ${entry.assignmentId} interrupted: ${entry.reason}; the next alpd continues it`;
|
|
104
|
+
break;
|
|
105
|
+
case 'assignment.recovered':
|
|
106
|
+
text = `↻ ${entry.agent} ${entry.assignmentId} reopened after alpd restarted`;
|
|
107
|
+
break;
|
|
108
|
+
case 'session.restarted':
|
|
109
|
+
text = `↻ ${entry.agent}'s native process stopped (${entry.error}); restart ${entry.restarts}`;
|
|
110
|
+
break;
|
|
111
|
+
case 'session.revived':
|
|
112
|
+
text = `▶ ${entry.agent} running again`;
|
|
113
|
+
break;
|
|
114
|
+
case 'session.revive_failed':
|
|
115
|
+
text = `✗ ${entry.agent} could not be restarted: ${entry.error}`;
|
|
116
|
+
break;
|
|
117
|
+
case 'human.answer':
|
|
118
|
+
text = `↳ ${entry.questionId} ${entry.outcome}${entry.answer !== undefined ? `: ${entry.answer.split('\n')[0].slice(0, 120)}` : ''}`;
|
|
119
|
+
break;
|
|
120
|
+
case 'hook':
|
|
121
|
+
text = entry.skipped ? `↪ ${entry.agent}'s ${entry.on} hook ${entry.hook} skipped: the workspace's hooks are not trusted`
|
|
122
|
+
: `${entry.blocked ? '⛔' : entry.error || entry.exitCode !== 0 || entry.timedOut ? '✗' : '↪'} ${entry.agent}'s ${entry.on} hook ${entry.hook} ${entry.error ? `could not run: ${entry.error}` : entry.timedOut ? 'timed out' : `exit ${entry.exitCode}`}${entry.durationMs !== undefined ? ` in ${duration(entry.durationMs)}` : ''}${entry.blocked ? ', refused it' : ''}${entry.output ? `: ${entry.output.split('\n')[0].slice(0, 120)}` : ''}`;
|
|
123
|
+
break;
|
|
124
|
+
case 'hook.trust':
|
|
125
|
+
text = `${entry.trusted ? '✓ the user trusts' : '✗ the user did not trust'} the hooks of ${entry.project}${entry.outcome ? ` (${entry.outcome})` : ''}`;
|
|
126
|
+
break;
|
|
127
|
+
case 'runtime.slip':
|
|
128
|
+
text = `⚠ ALP could not handle ${entry.agent}'s ${entry.method}: ${entry.error}; the session went on`;
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
text = `${entry.event}${entry.branch ? ` ${entry.branch}` : ''}${entry.status ? ` ${entry.status}` : ''}`;
|
|
132
|
+
}
|
|
133
|
+
lines.push(`${time} ${text}`);
|
|
134
|
+
}
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { AlpRpcError, MAX_FRAME } from '@anhlp/alp-protocol';
|
|
2
|
+
import { AlpClientError } from './errors.js';
|
|
3
|
+
import { utf8Length } from './framing.js';
|
|
4
|
+
import { timerDelay } from './timers.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The transport-independent JSON-RPC 2.0 engine of an ALP client (plans/reference/ALPD.md §3):
|
|
8
|
+
* request correlation, the daemon's `event` and other notifications, requests the daemon sends
|
|
9
|
+
* the client (extension.register), local timeouts and aborts, and cleanup when the connection
|
|
10
|
+
* ends. It runs unchanged in Node, browsers and React Native; a transport moves whole frames.
|
|
11
|
+
*
|
|
12
|
+
* A transport carries one complete JSON-RPC message per frame, as text. Framing (newlines on a
|
|
13
|
+
* socket, one WebSocket message per frame), byte decoding, connection setup and authentication
|
|
14
|
+
* belong to the transport and whoever creates it, never to this engine.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {'open' | 'closed'} TransportState
|
|
19
|
+
* @typedef {{ error?: Error, code?: number, reason?: string }} TransportClose
|
|
20
|
+
* Why a transport closed: `error` when it failed, `code`/`reason` when the peer said so.
|
|
21
|
+
* @typedef {{
|
|
22
|
+
* readonly state: TransportState,
|
|
23
|
+
* send(frame: string): void,
|
|
24
|
+
* onMessage(listener: (frame: string) => void): () => void,
|
|
25
|
+
* onClose(listener: (close: TransportClose) => void): () => void,
|
|
26
|
+
* close(): void,
|
|
27
|
+
* }} AlpTransport
|
|
28
|
+
* Contract (docs/alp/client-sdk.md): `send` delivers or throws, synchronously, and a throw means
|
|
29
|
+
* the frame did not leave; `onMessage` delivers frames in arrival order; `onClose` fires at most
|
|
30
|
+
* once, also after `close()`; nothing is delivered after it; `close` is idempotent.
|
|
31
|
+
* @typedef {{
|
|
32
|
+
* readonly aborted: boolean,
|
|
33
|
+
* readonly reason?: unknown,
|
|
34
|
+
* addEventListener(type: 'abort', listener: () => void, options?: { once?: boolean }): void,
|
|
35
|
+
* removeEventListener(type: 'abort', listener: () => void): void,
|
|
36
|
+
* }} AbortSignalLike
|
|
37
|
+
* @typedef {{ signal?: AbortSignalLike, timeoutMs?: number }} RequestOptions
|
|
38
|
+
* Both end only this client's wait; neither tells the daemon to stop. A timeout of Infinity, or
|
|
39
|
+
* longer than a timer can wait (2^31-1 ms), means none.
|
|
40
|
+
* @typedef {{
|
|
41
|
+
* maxPending?: number,
|
|
42
|
+
* maxFrame?: number,
|
|
43
|
+
* timeoutMs?: number,
|
|
44
|
+
* onListenerError?: (error: unknown) => void,
|
|
45
|
+
* }} AlpRpcClientOptions
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const CLOSED = 'ALP daemon connection closed';
|
|
49
|
+
/** Requests waiting for an answer at once; beyond it a request fails as `overloaded` and is not sent. */
|
|
50
|
+
export const DEFAULT_MAX_PENDING = 4096;
|
|
51
|
+
|
|
52
|
+
/** A listener's exception must not break dispatch to the others; it surfaces like an uncaught error. @param {unknown} error */
|
|
53
|
+
const rethrow = error => { setTimeout(() => { throw error; }, 0); };
|
|
54
|
+
|
|
55
|
+
/** @param {AlpTransport} transport */
|
|
56
|
+
function assertTransport(transport) {
|
|
57
|
+
for (const name of ['send', 'onMessage', 'onClose', 'close']) {
|
|
58
|
+
if (typeof /** @type {any} */ (transport)?.[name] !== 'function') throw new TypeError(`ALP transport must implement ${name}()`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {AbortSignalLike} signal */
|
|
63
|
+
const abortError = signal => new AlpClientError('ALP request aborted', { reason: 'aborted', cause: signal.reason });
|
|
64
|
+
|
|
65
|
+
/** @param {object} value @param {string} key */
|
|
66
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
67
|
+
|
|
68
|
+
export class AlpRpcClient {
|
|
69
|
+
/** @type {AlpTransport} */ #transport;
|
|
70
|
+
#sequence = 0;
|
|
71
|
+
/** @type {Map<number, { resolve(value: unknown): void, reject(error: unknown): void, cleanup(): void }>} */
|
|
72
|
+
#pending = new Map();
|
|
73
|
+
/** @type {Set<(envelope: any) => void>} */ #events = new Set();
|
|
74
|
+
/** @type {Set<(method: string, params: any) => void>} */ #notifications = new Set();
|
|
75
|
+
/** @type {Set<(error: Error) => void>} */ #closes = new Set();
|
|
76
|
+
/** @type {Set<(method: string, params: any) => unknown>} */ #requestHandlers = new Set();
|
|
77
|
+
/** @type {Array<() => void>} */ #detach = [];
|
|
78
|
+
/** @type {AlpClientError | undefined} */ #closeError;
|
|
79
|
+
#maxPending;
|
|
80
|
+
#maxFrame;
|
|
81
|
+
#timeoutMs;
|
|
82
|
+
#onListenerError;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {AlpTransport} transport an open transport; the client owns it from now on
|
|
86
|
+
* @param {AlpRpcClientOptions} [options]
|
|
87
|
+
*/
|
|
88
|
+
constructor(transport, { maxPending = DEFAULT_MAX_PENDING, maxFrame = MAX_FRAME, timeoutMs, onListenerError = rethrow } = {}) {
|
|
89
|
+
assertTransport(transport);
|
|
90
|
+
this.#transport = transport;
|
|
91
|
+
this.#maxPending = maxPending;
|
|
92
|
+
this.#maxFrame = maxFrame;
|
|
93
|
+
timerDelay(timeoutMs);
|
|
94
|
+
this.#timeoutMs = timeoutMs;
|
|
95
|
+
this.#onListenerError = onListenerError;
|
|
96
|
+
this.#detach.push(transport.onMessage(frame => this.#receive(frame)));
|
|
97
|
+
this.#detach.push(transport.onClose(close => this.#finish(this.#disconnected(close))));
|
|
98
|
+
if (transport.state === 'closed') this.#finish(new AlpClientError(CLOSED, { reason: 'disconnected' }));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** True once the connection is gone, for whatever reason. */
|
|
102
|
+
get closed() { return this.#closeError !== undefined; }
|
|
103
|
+
/** Requests sent and still waiting for an answer. */
|
|
104
|
+
get pendingCount() { return this.#pending.size; }
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Sends one request and resolves with its result, or rejects with AlpRpcError (the daemon
|
|
108
|
+
* answered with an error) or AlpClientError (the client gave up: see CLIENT_ERROR). Requests are
|
|
109
|
+
* never resent, also not after a reconnect.
|
|
110
|
+
* @template [T=any]
|
|
111
|
+
* @param {string} method
|
|
112
|
+
* @param {unknown} [params]
|
|
113
|
+
* @param {RequestOptions} [options]
|
|
114
|
+
* @returns {Promise<T>}
|
|
115
|
+
*/
|
|
116
|
+
request(method, params = {}, { signal, timeoutMs = this.#timeoutMs } = {}) {
|
|
117
|
+
if (this.#closeError) return Promise.reject(new AlpClientError(CLOSED, { reason: this.#closeError.reason === 'closed' ? 'closed' : 'disconnected', cause: this.#closeError }));
|
|
118
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
119
|
+
if (this.#pending.size >= this.#maxPending) return Promise.reject(new AlpClientError(`Too many ALP requests waiting for an answer (${this.#maxPending})`, { reason: 'overloaded' }));
|
|
120
|
+
/** @type {number | undefined} */
|
|
121
|
+
let delay;
|
|
122
|
+
try { delay = timerDelay(timeoutMs); } catch (error) { return Promise.reject(error); }
|
|
123
|
+
const id = ++this.#sequence;
|
|
124
|
+
let frame;
|
|
125
|
+
try { frame = JSON.stringify({ jsonrpc: '2.0', id, method, params }); } catch (error) { return Promise.reject(error); }
|
|
126
|
+
// The limit counts the newline a stream transport adds, as alpd counts it.
|
|
127
|
+
if (utf8Length(frame) >= this.#maxFrame) return Promise.reject(new AlpClientError(`ALP request ${method} exceeds the ${this.#maxFrame}-byte frame limit`, { reason: 'send_failed' }));
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
130
|
+
let timer;
|
|
131
|
+
const onAbort = () => { if (this.#pending.delete(id)) { cleanup(); reject(abortError(/** @type {AbortSignalLike} */ (signal))); } };
|
|
132
|
+
const cleanup = () => {
|
|
133
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
134
|
+
signal?.removeEventListener('abort', onAbort);
|
|
135
|
+
};
|
|
136
|
+
this.#pending.set(id, { resolve, reject, cleanup });
|
|
137
|
+
if (delay !== undefined) {
|
|
138
|
+
timer = setTimeout(() => {
|
|
139
|
+
if (!this.#pending.delete(id)) return;
|
|
140
|
+
cleanup();
|
|
141
|
+
reject(new AlpClientError(`ALP request ${method} timed out after ${timeoutMs} ms`, { reason: 'timeout' }));
|
|
142
|
+
}, delay);
|
|
143
|
+
}
|
|
144
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
145
|
+
try {
|
|
146
|
+
this.#transport.send(frame);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// A transport that is already gone (a socket half-closed by alpd, a WebSocket closing) ends
|
|
149
|
+
// the connection now, so `closed` is true when this request rejects, as it was before the
|
|
150
|
+
// transport reported its close. A transport still open refused only this frame.
|
|
151
|
+
if (this.#transport.state === 'closed') this.#finish(new AlpClientError(CLOSED, { reason: 'disconnected', cause: error }));
|
|
152
|
+
else if (this.#pending.delete(id)) {
|
|
153
|
+
cleanup();
|
|
154
|
+
reject(new AlpClientError(`Could not send ALP request ${method}: ${error instanceof Error ? error.message : String(error)}`, { reason: 'send_failed', cause: error }));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every `event` notification: an envelope of a session the connection attached to. Returns a remover. @param {(envelope: any) => void} listener */
|
|
161
|
+
onEvent(listener) { return this.#add(this.#events, listener); }
|
|
162
|
+
|
|
163
|
+
/** Every notification from the daemon, `event` included; for methods this client does not know yet. @param {(method: string, params: any) => void} listener */
|
|
164
|
+
onNotification(listener) { return this.#add(this.#notifications, listener); }
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Answers requests the daemon sends this client (the extension RPC of ALPD §61). The first
|
|
168
|
+
* handler answers; its result, or its error's numeric `code`, `message` and `data`, goes back.
|
|
169
|
+
* @param {(method: string, params: any) => unknown} handler
|
|
170
|
+
*/
|
|
171
|
+
onRequest(handler) { return this.#add(this.#requestHandlers, handler); }
|
|
172
|
+
|
|
173
|
+
/** Called once when the connection ends, with the error pending requests were rejected with. @param {(error: Error) => void} listener */
|
|
174
|
+
onClose(listener) { return this.#add(this.#closes, listener); }
|
|
175
|
+
|
|
176
|
+
/** Ends the connection; waiting requests reject with reason `closed`. Idempotent. */
|
|
177
|
+
close() {
|
|
178
|
+
if (this.#closeError) return;
|
|
179
|
+
this.#finish(new AlpClientError(CLOSED, { reason: 'closed' }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @template F @param {Set<F>} set @param {F} listener */
|
|
183
|
+
#add(set, listener) {
|
|
184
|
+
if (typeof listener !== 'function') throw new TypeError('ALP client listener must be a function');
|
|
185
|
+
if (this.#closeError) return () => false;
|
|
186
|
+
set.add(listener);
|
|
187
|
+
return () => set.delete(listener);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @param {TransportClose} [close] */
|
|
191
|
+
#disconnected(close = {}) {
|
|
192
|
+
if (close.error instanceof AlpClientError) return close.error;
|
|
193
|
+
const data = close.code !== undefined || close.reason ? { code: close.code, reason: close.reason } : undefined;
|
|
194
|
+
return new AlpClientError(close.error?.message ?? CLOSED, { reason: 'disconnected', cause: close.error, data });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @param {string} message */
|
|
198
|
+
#violation(message) {
|
|
199
|
+
this.#finish(new AlpClientError(message, { reason: 'protocol' }));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @param {Function} listener @param {unknown[]} args */
|
|
203
|
+
#call(listener, ...args) {
|
|
204
|
+
try { listener(...args); } catch (error) { this.#onListenerError(error); }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** @param {string} frame */
|
|
208
|
+
#receive(frame) {
|
|
209
|
+
if (this.#closeError) return;
|
|
210
|
+
if (typeof frame !== 'string') return this.#violation('Invalid frame from ALP daemon');
|
|
211
|
+
let message;
|
|
212
|
+
try { message = JSON.parse(frame); } catch { return this.#violation('Invalid frame from ALP daemon'); }
|
|
213
|
+
if (typeof message !== 'object' || message === null || Array.isArray(message)) return this.#violation('Invalid frame from ALP daemon');
|
|
214
|
+
const hasId = message.id !== undefined && message.id !== null;
|
|
215
|
+
if (message.method === 'event') {
|
|
216
|
+
for (const listener of [...this.#events]) this.#call(listener, message.params);
|
|
217
|
+
for (const listener of [...this.#notifications]) this.#call(listener, message.method, message.params);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (typeof message.method === 'string' && hasId && !own(message, 'result') && !own(message, 'error')) return this.#answer(message.id, message.method, message.params ?? {});
|
|
221
|
+
if (typeof message.method === 'string' && !hasId) {
|
|
222
|
+
for (const listener of [...this.#notifications]) this.#call(listener, message.method, message.params);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// A response to a request this client no longer waits for (timed out, aborted) or never sent,
|
|
226
|
+
// and an error the daemon could not tie to a request (id null), have nobody to go to.
|
|
227
|
+
const waiter = this.#pending.get(message.id);
|
|
228
|
+
if (!waiter) return;
|
|
229
|
+
this.#pending.delete(message.id);
|
|
230
|
+
waiter.cleanup();
|
|
231
|
+
if (own(message, 'error') && typeof message.error === 'object' && message.error !== null) waiter.reject(new AlpRpcError(message.error));
|
|
232
|
+
else if (own(message, 'result') && !own(message, 'error')) waiter.resolve(message.result);
|
|
233
|
+
else waiter.reject(new AlpClientError('Malformed response from ALP daemon', { reason: 'protocol', data: { id: message.id } }));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Answers a request from alpd. Every request gets exactly one answer: a result that cannot be
|
|
238
|
+
* serialized or does not fit a frame becomes an error, and an error whose data cannot go either
|
|
239
|
+
* goes without it, so alpd's caller never waits for a reply that was silently dropped.
|
|
240
|
+
* @param {unknown} id @param {string} method @param {unknown} params
|
|
241
|
+
*/
|
|
242
|
+
#answer(id, method, params) {
|
|
243
|
+
/** The frame for `body`, or undefined if it cannot be serialized or is over the limit. @param {object} body */
|
|
244
|
+
const frameOf = body => {
|
|
245
|
+
try {
|
|
246
|
+
const frame = JSON.stringify({ jsonrpc: '2.0', id, ...body });
|
|
247
|
+
return utf8Length(frame) < this.#maxFrame ? frame : undefined;
|
|
248
|
+
} catch { return undefined; }
|
|
249
|
+
};
|
|
250
|
+
/** @param {string | undefined} frame */
|
|
251
|
+
const send = frame => {
|
|
252
|
+
if (this.#closeError) return;
|
|
253
|
+
if (frame === undefined) return this.#finish(new AlpClientError('ALP reply exceeds the frame limit', { reason: 'protocol' }));
|
|
254
|
+
try { this.#transport.send(frame); } catch (cause) {
|
|
255
|
+
this.#finish(new AlpClientError('Could not send ALP reply', { reason: 'send_failed', cause }));
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
/** @param {unknown} error */
|
|
259
|
+
const failure = error => {
|
|
260
|
+
const code = (error && typeof (/** @type {any} */ (error)).code === 'number') ? /** @type {any} */ (error).code : 1000;
|
|
261
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
262
|
+
const message = text.length > 2000 ? `${text.slice(0, 2000)}...` : text;
|
|
263
|
+
const data = (error && /** @type {any} */ (error).data !== undefined) ? { data: /** @type {any} */ (error).data } : {};
|
|
264
|
+
return frameOf({ error: { code, message, ...data } }) ?? frameOf({ error: { code, message } })
|
|
265
|
+
?? frameOf({ error: { code: -32603, message: 'The reply could not be sent' } });
|
|
266
|
+
};
|
|
267
|
+
const [handler] = this.#requestHandlers;
|
|
268
|
+
if (!handler) return send(failure(Object.assign(new Error(`Unknown method '${method}'`), { code: -32601 })));
|
|
269
|
+
Promise.resolve().then(async () => {
|
|
270
|
+
try {
|
|
271
|
+
const result = await handler(method, params);
|
|
272
|
+
const frame = frameOf({ result: result === undefined ? {} : result });
|
|
273
|
+
send(frame ?? failure(new Error(`The result of ${method} cannot be serialized or exceeds the ${this.#maxFrame}-byte frame limit`)));
|
|
274
|
+
} catch (error) {
|
|
275
|
+
send(failure(error));
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** @param {AlpClientError} error */
|
|
281
|
+
#finish(error) {
|
|
282
|
+
if (this.#closeError) return;
|
|
283
|
+
this.#closeError = error;
|
|
284
|
+
for (const detach of this.#detach.splice(0)) { try { detach(); } catch {} }
|
|
285
|
+
try { this.#transport.close(); } catch {}
|
|
286
|
+
const pending = [...this.#pending.values()];
|
|
287
|
+
this.#pending.clear();
|
|
288
|
+
for (const waiter of pending) { waiter.cleanup(); waiter.reject(error); }
|
|
289
|
+
const closes = [...this.#closes];
|
|
290
|
+
this.#events.clear();
|
|
291
|
+
this.#notifications.clear();
|
|
292
|
+
this.#requestHandlers.clear();
|
|
293
|
+
this.#closes.clear();
|
|
294
|
+
for (const listener of closes) this.#call(listener, error);
|
|
295
|
+
}
|
|
296
|
+
}
|