@anhlp/alp-client 0.0.0-stage → 0.0.1-beta.4

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.
Files changed (75) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +28 -2
  4. package/dist/acp-probe.d.ts +44 -0
  5. package/dist/acp-probe.js +37 -0
  6. package/dist/antigravity-probe.d.ts +34 -0
  7. package/dist/antigravity-probe.js +95 -0
  8. package/dist/command.d.ts +5 -0
  9. package/dist/command.js +33 -0
  10. package/dist/doctor.d.ts +13 -0
  11. package/dist/doctor.js +587 -0
  12. package/dist/index.d.ts +151 -0
  13. package/dist/index.js +533 -0
  14. package/dist/integration-lock.d.ts +35 -0
  15. package/dist/integration-lock.js +179 -0
  16. package/dist/ipc.d.ts +42 -0
  17. package/dist/ipc.js +69 -0
  18. package/dist/library-test.d.ts +94 -0
  19. package/dist/library-test.js +37 -0
  20. package/dist/mcp-probe.d.ts +24 -0
  21. package/dist/mcp-probe.js +147 -0
  22. package/dist/portable.d.ts +19 -0
  23. package/dist/portable.js +33 -0
  24. package/dist/prompt.d.ts +32 -0
  25. package/dist/prompt.js +59 -0
  26. package/dist/render.d.ts +6 -0
  27. package/dist/render.js +120 -0
  28. package/dist/rpc/client.d.ts +82 -0
  29. package/dist/rpc/client.js +292 -0
  30. package/dist/rpc/errors.d.ts +42 -0
  31. package/dist/rpc/errors.js +34 -0
  32. package/dist/rpc/framing.d.ts +21 -0
  33. package/dist/rpc/framing.js +57 -0
  34. package/dist/rpc/handshake.d.ts +19 -0
  35. package/dist/rpc/handshake.js +8 -0
  36. package/dist/rpc/reconnect.d.ts +97 -0
  37. package/dist/rpc/reconnect.js +227 -0
  38. package/dist/rpc/registry.d.ts +211 -0
  39. package/dist/rpc/registry.js +145 -0
  40. package/dist/rpc/timers.d.ts +11 -0
  41. package/dist/rpc/timers.js +11 -0
  42. package/dist/service.d.ts +50 -0
  43. package/dist/service.js +143 -0
  44. package/dist/setup.d.ts +2 -0
  45. package/dist/setup.js +146 -0
  46. package/dist/supervise.d.ts +14 -0
  47. package/dist/supervise.js +87 -0
  48. package/dist/websocket.d.ts +51 -0
  49. package/dist/websocket.js +190 -0
  50. package/package.json +174 -4
  51. package/src/acp-probe.js +46 -0
  52. package/src/antigravity-probe.js +106 -0
  53. package/src/command.js +18 -0
  54. package/src/doctor.d.ts +13 -0
  55. package/src/doctor.js +570 -0
  56. package/src/index.js +596 -0
  57. package/src/integration-lock.js +179 -0
  58. package/src/ipc.js +92 -0
  59. package/src/library-test.js +41 -0
  60. package/src/mcp-probe.js +118 -0
  61. package/src/portable.js +28 -0
  62. package/src/prompt.js +64 -0
  63. package/src/render.js +136 -0
  64. package/src/rpc/client.js +296 -0
  65. package/src/rpc/errors.js +54 -0
  66. package/src/rpc/framing.js +61 -0
  67. package/src/rpc/handshake.js +16 -0
  68. package/src/rpc/reconnect.js +226 -0
  69. package/src/rpc/registry.js +161 -0
  70. package/src/rpc/timers.js +16 -0
  71. package/src/service.js +154 -0
  72. package/src/setup.js +150 -0
  73. package/src/supervise.d.ts +14 -0
  74. package/src/supervise.js +92 -0
  75. package/src/websocket.js +259 -0
@@ -0,0 +1,179 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, open, rename, rmdir, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { assertIntegrationName, bootTime } from './index.js';
5
+
6
+ /**
7
+ * @typedef {{ pid: number, bootTime: number, startedAt: string, ready: boolean, lockVersion?: number, owner?: string }} IntegrationLockRecord
8
+ * @typedef {{
9
+ * acquire(): Promise<{ acquired: boolean, lock: IntegrationLockRecord }>,
10
+ * setReady(ready: boolean): Promise<boolean>,
11
+ * release(): Promise<boolean>,
12
+ * }} IntegrationLock
13
+ */
14
+
15
+ /**
16
+ * The single-owner lock of one integration service, at `<home>/integrations/<name>.lock`, with its
17
+ * mutation guard `<name>.lock.reclaim`. Each instance is single-use: acquire once, then setReady and
18
+ * release while it owns the lock.
19
+ *
20
+ * Single-host, same PID namespace, private local filesystem only. Never reap the mutation guard.
21
+ * @param {string} home the ALP_HOME the integration runs under
22
+ * @param {string} name the integration name, for example `web`
23
+ * @returns {IntegrationLock}
24
+ */
25
+ export function createIntegrationLock(home, name) {
26
+ const file = path.join(home, 'integrations', `${assertIntegrationName(name)}.lock`);
27
+ const guard = `${file}.reclaim`;
28
+ /** @type {'new' | 'acquiring' | 'owned' | 'retired'} */
29
+ let phase = 'new';
30
+ /** This instance's lock while it owns it: the lifetime nonce and the file's identity. @type {{ owner: string, dev: number, ino: number } | undefined} */
31
+ let owned;
32
+ // The lock file as read: its record and the stats of the handle it was read through. Kept inside
33
+ // the factory so the published declarations name only IntegrationLock and its record.
34
+ /** @typedef {{ value: any, info: import('node:fs').Stats }} Snapshot */
35
+ /** @param {string} reason */
36
+ const recovery = reason => new Error(`${reason}. Stop ALL integration processes using this ALP_HOME before manual lock-only recovery of ${file} and ${guard}; see docs/modular-monorepo.md`);
37
+
38
+ /**
39
+ * @template T
40
+ * @param {() => Promise<T>} run
41
+ * @returns {Promise<T>}
42
+ */
43
+ async function guarded(run) {
44
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
45
+ const deadline = Date.now() + 20_000;
46
+ for (;;) {
47
+ try { await mkdir(guard, { mode: 0o700 }); break; }
48
+ catch (error) {
49
+ if (/** @type {any} */ (error)?.code !== 'EEXIST') throw error;
50
+ if (Date.now() >= deadline) throw recovery('Integration mutation guard remains busy or abandoned');
51
+ await new Promise(resolve => setTimeout(resolve, 25));
52
+ }
53
+ }
54
+ const token = path.join(guard, `owner-${process.pid}-${randomUUID()}`);
55
+ // If token initialization/close fails, leave the guard: no speculative cleanup of shared paths.
56
+ try {
57
+ const handle = await open(token, 'wx', 0o600);
58
+ await handle.close();
59
+ } catch (error) { throw recovery(`Integration guard initialization failed: ${String(error)}`); }
60
+ try { return await run(); }
61
+ finally {
62
+ // Only this successful mkdir owner finalizes, once. Missing token may mean a replacement
63
+ // generation: never rmdir in that case. No contender removes any other owner's token.
64
+ try { await unlink(token); }
65
+ catch (error) { throw recovery(/** @type {any} */ (error)?.code === 'ENOENT' ? 'Integration guard ownership was lost' : `Integration guard token cleanup failed: ${String(error)}`); }
66
+ try { await rmdir(guard); }
67
+ catch (error) { throw recovery(`Integration guard cleanup failed: ${String(error)}`); }
68
+ }
69
+ }
70
+
71
+ /** @returns {Promise<Snapshot | undefined>} */
72
+ async function snapshot() {
73
+ let handle;
74
+ try { handle = await open(file, 'r'); }
75
+ catch (error) { if (/** @type {any} */ (error)?.code === 'ENOENT') return undefined; throw error; }
76
+ try {
77
+ const info = await handle.stat();
78
+ const raw = await handle.readFile('utf8');
79
+ let value;
80
+ try { value = JSON.parse(raw); } catch { value = undefined; }
81
+ return { value, info };
82
+ } finally { await handle.close(); }
83
+ }
84
+
85
+ /**
86
+ * @param {any} value
87
+ * @returns {value is IntegrationLockRecord}
88
+ */
89
+ function complete(value) {
90
+ return value && Number.isSafeInteger(value.pid) && value.pid > 0
91
+ && typeof value.bootTime === 'number' && Number.isFinite(value.bootTime) && value.bootTime > 0
92
+ && typeof value.startedAt === 'string' && Number.isFinite(Date.parse(value.startedAt))
93
+ && typeof value.ready === 'boolean'
94
+ && (value.lockVersion === undefined && value.owner === undefined
95
+ || value.lockVersion === 1 && typeof value.owner === 'string' && /^[a-f0-9-]{36}$/.test(value.owner));
96
+ }
97
+
98
+ /**
99
+ * @param {IntegrationLockRecord} value
100
+ * @returns {boolean}
101
+ */
102
+ function dead(value) {
103
+ try { process.kill(value.pid, 0); return false; }
104
+ catch (error) {
105
+ if (/** @type {any} */ (error)?.code !== 'ESRCH') return false; // EPERM and unknown errors never grant takeover.
106
+ // Approximate wall-clock boot metadata is context, never affirmative death evidence.
107
+ if (Math.abs(value.bootTime - bootTime()) > 30) throw recovery('Integration owner boot context is uncertain');
108
+ return true;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * @param {Snapshot | undefined} current
114
+ * @returns {boolean}
115
+ */
116
+ function matches(current) {
117
+ return !!owned && current?.value?.owner === owned.owner
118
+ && current.info.dev === owned.dev && current.info.ino === owned.ino;
119
+ }
120
+
121
+ return {
122
+ async acquire() {
123
+ if (phase !== 'new') throw new Error('Integration lock instance has already been used');
124
+ phase = 'acquiring';
125
+ try {
126
+ return await guarded(async () => {
127
+ const current = await snapshot();
128
+ if (current) {
129
+ if (!complete(current.value)) throw recovery('Integration lock is malformed, incomplete or unsupported');
130
+ if (!dead(current.value)) { phase = 'retired'; return { acquired: false, lock: current.value }; }
131
+ await unlink(file);
132
+ }
133
+ /** @type {IntegrationLockRecord & { owner: string }} */
134
+ const value = { pid: process.pid, bootTime: bootTime(), startedAt: new Date().toISOString(), ready: false, lockVersion: 1, owner: randomUUID() };
135
+ const handle = await open(file, 'wx', 0o600);
136
+ let info;
137
+ try { await handle.writeFile(JSON.stringify(value)); info = await handle.stat(); }
138
+ finally { await handle.close(); }
139
+ owned = { owner: value.owner, dev: info.dev, ino: info.ino };
140
+ phase = 'owned';
141
+ return { acquired: true, lock: value };
142
+ });
143
+ } catch (error) { phase = 'retired'; owned = undefined; throw error; }
144
+ },
145
+
146
+ async setReady(ready) {
147
+ if (phase !== 'owned') return false;
148
+ return guarded(async () => {
149
+ if (phase !== 'owned') return false;
150
+ const current = await snapshot();
151
+ if (!current || !matches(current)) { phase = 'retired'; owned = undefined; return false; }
152
+ const value = { ...current.value, ready };
153
+ const tmp = `${file}.${randomUUID()}.tmp`;
154
+ const handle = await open(tmp, 'wx', 0o600);
155
+ let info;
156
+ try {
157
+ try { await handle.writeFile(JSON.stringify(value)); info = await handle.stat(); }
158
+ finally { await handle.close(); }
159
+ await rename(tmp, file);
160
+ owned = { owner: value.owner, dev: info.dev, ino: info.ino };
161
+ return true;
162
+ } finally { await unlink(tmp).catch(error => { if (error?.code !== 'ENOENT') throw error; }); }
163
+ });
164
+ },
165
+
166
+ async release() {
167
+ if (phase !== 'owned') return false;
168
+ return guarded(async () => {
169
+ if (phase !== 'owned') return false;
170
+ const current = await snapshot();
171
+ const ours = matches(current);
172
+ phase = 'retired'; owned = undefined;
173
+ if (!ours) return false;
174
+ await unlink(file);
175
+ return true;
176
+ });
177
+ },
178
+ };
179
+ }
package/src/ipc.js ADDED
@@ -0,0 +1,92 @@
1
+ import { MAX_FRAME } from '@anhlp/alp-protocol';
2
+ import { AlpClientError } from './rpc/errors.js';
3
+ import { createLineDecoder } from './rpc/framing.js';
4
+
5
+ /**
6
+ * The Node transport of the ALP client: a Unix socket or Windows named pipe carrying
7
+ * newline-delimited JSON (plans/reference/ALPD.md §3). Node-only; the portable engine
8
+ * (`@anhlp/alp-client/portable`) never imports it. Socket discovery, the private-socket checks
9
+ * and daemon startup stay in the Node entry (`connect`, `ensureDaemon`).
10
+ *
11
+ * @typedef {import('./rpc/client.js').AlpTransport} AlpTransport
12
+ * @typedef {import('./rpc/client.js').TransportClose} TransportClose
13
+ * @typedef {{ maxFrame?: number, maxBufferedBytes?: number }} SocketTransportOptions
14
+ * `maxBufferedBytes` bounds what may wait in the socket's write buffer; a send beyond it fails
15
+ * as `send_failed` instead of growing memory while the daemon does not read.
16
+ */
17
+
18
+ /**
19
+ * Wraps a connected or still-connecting socket (writes made while it connects are queued by
20
+ * Node). The transport owns the socket from then on.
21
+ * @param {import('node:net').Socket} socket
22
+ * @param {SocketTransportOptions} [options]
23
+ * @returns {AlpTransport}
24
+ */
25
+ export function createSocketTransport(socket, { maxFrame = MAX_FRAME, maxBufferedBytes = 4 * MAX_FRAME } = {}) {
26
+ const decoder = createLineDecoder({ maxFrame });
27
+ /** @type {Set<(frame: string) => void>} */
28
+ const messages = new Set();
29
+ /** @type {Set<(close: TransportClose) => void>} */
30
+ const closes = new Set();
31
+ /** @type {TransportClose | undefined} */
32
+ let ended;
33
+ /** @param {TransportClose} close */
34
+ const end = close => {
35
+ if (ended) return;
36
+ ended = close;
37
+ socket.off('data', onData);
38
+ messages.clear();
39
+ const listeners = [...closes];
40
+ closes.clear();
41
+ for (const listener of listeners) listener(close);
42
+ };
43
+ /** @param {string} chunk */
44
+ const onData = chunk => {
45
+ if (ended) return;
46
+ let frames;
47
+ try { frames = decoder.push(chunk); } catch {
48
+ const error = new AlpClientError('ALP daemon frame exceeds size limit', { reason: 'protocol' });
49
+ socket.destroy();
50
+ return end({ error });
51
+ }
52
+ for (const frame of frames) {
53
+ if (ended) return;
54
+ for (const listener of [...messages]) listener(frame);
55
+ }
56
+ };
57
+ socket.setEncoding('utf8');
58
+ socket.on('data', onData);
59
+ socket.on('error', error => end({ error }));
60
+ socket.on('close', () => end({}));
61
+ if (socket.destroyed) end({});
62
+ // A socket alpd has half-closed (Node ends our side at once) or destroyed is gone, even before
63
+ // Node emits 'close' a tick later.
64
+ const gone = () => socket.destroyed || socket.writableEnded;
65
+ return {
66
+ get state() { return ended || gone() ? 'closed' : 'open'; },
67
+ send(frame) {
68
+ // Report the close now rather than on 'close', so the client is closed when the send fails.
69
+ if (!ended && gone()) end({});
70
+ if (ended) throw new AlpClientError('ALP daemon connection closed', { reason: 'send_failed' });
71
+ if (socket.writableLength + frame.length > maxBufferedBytes) throw new AlpClientError(`ALP socket write buffer is over ${maxBufferedBytes} bytes`, { reason: 'send_failed' });
72
+ socket.write(`${frame}\n`);
73
+ },
74
+ onMessage(listener) {
75
+ if (ended) return () => false;
76
+ messages.add(listener);
77
+ return () => messages.delete(listener);
78
+ },
79
+ onClose(listener) {
80
+ if (ended) return () => false;
81
+ closes.add(listener);
82
+ return () => closes.delete(listener);
83
+ },
84
+ close() {
85
+ // end() lets frames already written reach the daemon, as the client always did; once they
86
+ // have, the socket is destroyed, so a peer that never closes its side cannot hold it open.
87
+ // A side already ended (by this, or by Node after alpd's FIN) is destroyed by Node itself.
88
+ if (!socket.writableEnded) socket.end(() => socket.destroy());
89
+ end({});
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,41 @@
1
+ import path from 'node:path';
2
+ import { AlpError } from '@anhlp/alp-core/errors';
3
+ import { getEntry } from '@anhlp/alp-core/library-edit';
4
+ import { runHook, samplePayload } from '@anhlp/alp-core/hook-run';
5
+ import { normalizeMcp } from '@anhlp/alp-core/validation';
6
+ import { probeMcp } from './mcp-probe.js';
7
+ import { probeAcp } from './acp-probe.js';
8
+ import { probeAntigravity } from './antigravity-probe.js';
9
+ import { loadProvider } from '@anhlp/alp-core/providers';
10
+
11
+ /**
12
+ * Tries an MCP server, a hook or an ACP provider out (ALPD §43, §46). An MCP server is
13
+ * started and its tools listed; a provider is started and initialized; a hook runs once with a sample payload for its event, in the project (or the
14
+ * library when there is none), with the variables ALP gives hooks.
15
+ */
16
+ export async function testEntry(kind, name, { root, library, templates, scope, timeoutMs } = {}) {
17
+ const entry = await getEntry(kind, name, { root, library, templates, scope });
18
+ const home = entry.source === 'project' ? path.join(path.resolve(root), '.alp') : library;
19
+ if (kind === 'mcp') {
20
+ const server = normalizeMcp({ mcpServers: { [name]: entry.content.server } }, path.join(home, 'mcp'), `${name}.json`).mcpServers[name];
21
+ const result = await probeMcp(server, { cwd: root ? path.resolve(root) : library, ...(timeoutMs ? { timeoutMs } : {}) });
22
+ return { kind, name, source: entry.source, ok: true, ...result };
23
+ }
24
+ if (kind === 'hooks') {
25
+ const hook = entry.content.hook;
26
+ const project = root ? path.resolve(root) : undefined;
27
+ const payload = samplePayload(hook.event, { project, agent: hook.match?.agent ?? 'main' });
28
+ const env = { ALP_EVENT: hook.event, ALP_SESSION: payload.session, ALP_AGENT: payload.agent, ALP_PROJECT: project ?? '', ALP_TASK: payload.task ?? '' };
29
+ const result = await runHook(hook, payload, { cwd: project ?? library, env, ...(timeoutMs ? { timeoutMs } : {}) });
30
+ const ok = result.exitCode === 0 && !result.timedOut;
31
+ return { kind, name, source: entry.source, ok, ...(hook.blocking && !ok ? { wouldBlock: true } : {}), payload, ...result };
32
+ }
33
+ if (kind === 'providers') {
34
+ const provider = await loadProvider(library, name);
35
+ const result = provider.kind === 'antigravity'
36
+ ? await probeAntigravity(provider, { cwd: root ? path.resolve(root) : library, ...(timeoutMs ? { timeoutMs } : {}) })
37
+ : await probeAcp(provider, { cwd: root ? path.resolve(root) : library, ...(timeoutMs ? { timeoutMs } : {}) });
38
+ return { kind, name, source: entry.source, ok: true, ...result };
39
+ }
40
+ throw new AlpError('INVALID_KIND', `Only mcp servers, hooks and providers can be tested, not ${kind}`);
41
+ }
@@ -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
+ }
@@ -0,0 +1,28 @@
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';
28
+ export { attachments, base64Encode, conversation, sha256Hex, workspaceRegistry } from './rpc/registry.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
+ }