@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.
- package/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +28 -2
- 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 +151 -0
- package/dist/index.js +533 -0
- package/dist/integration-lock.d.ts +35 -0
- package/dist/integration-lock.js +179 -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 +19 -0
- package/dist/portable.js +33 -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/registry.d.ts +211 -0
- package/dist/rpc/registry.js +145 -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 +14 -0
- package/dist/supervise.js +87 -0
- package/dist/websocket.d.ts +51 -0
- package/dist/websocket.js +190 -0
- package/package.json +174 -4
- 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 +596 -0
- package/src/integration-lock.js +179 -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 +28 -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/registry.js +161 -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 +14 -0
- package/src/supervise.js +92 -0
- package/src/websocket.js +259 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ pid: number, bootTime: number, startedAt: string, ready: boolean, lockVersion?: number, owner?: string }} IntegrationLockRecord
|
|
3
|
+
* @typedef {{
|
|
4
|
+
* acquire(): Promise<{ acquired: boolean, lock: IntegrationLockRecord }>,
|
|
5
|
+
* setReady(ready: boolean): Promise<boolean>,
|
|
6
|
+
* release(): Promise<boolean>,
|
|
7
|
+
* }} IntegrationLock
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The single-owner lock of one integration service, at `<home>/integrations/<name>.lock`, with its
|
|
11
|
+
* mutation guard `<name>.lock.reclaim`. Each instance is single-use: acquire once, then setReady and
|
|
12
|
+
* release while it owns the lock.
|
|
13
|
+
*
|
|
14
|
+
* Single-host, same PID namespace, private local filesystem only. Never reap the mutation guard.
|
|
15
|
+
* @param {string} home the ALP_HOME the integration runs under
|
|
16
|
+
* @param {string} name the integration name, for example `web`
|
|
17
|
+
* @returns {IntegrationLock}
|
|
18
|
+
*/
|
|
19
|
+
export function createIntegrationLock(home: string, name: string): IntegrationLock;
|
|
20
|
+
export type IntegrationLockRecord = {
|
|
21
|
+
pid: number;
|
|
22
|
+
bootTime: number;
|
|
23
|
+
startedAt: string;
|
|
24
|
+
ready: boolean;
|
|
25
|
+
lockVersion?: number;
|
|
26
|
+
owner?: string;
|
|
27
|
+
};
|
|
28
|
+
export type IntegrationLock = {
|
|
29
|
+
acquire(): Promise<{
|
|
30
|
+
acquired: boolean;
|
|
31
|
+
lock: IntegrationLockRecord;
|
|
32
|
+
}>;
|
|
33
|
+
setReady(ready: boolean): Promise<boolean>;
|
|
34
|
+
release(): Promise<boolean>;
|
|
35
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// packages/client/src/integration-lock.js
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, open, rename, rmdir, unlink } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { assertIntegrationName, bootTime } from "./index.js";
|
|
6
|
+
function createIntegrationLock(home, name) {
|
|
7
|
+
const file = path.join(home, "integrations", `${assertIntegrationName(name)}.lock`);
|
|
8
|
+
const guard = `${file}.reclaim`;
|
|
9
|
+
let phase = "new";
|
|
10
|
+
let owned;
|
|
11
|
+
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`);
|
|
12
|
+
async function guarded(run) {
|
|
13
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 448 });
|
|
14
|
+
const deadline = Date.now() + 2e4;
|
|
15
|
+
for (; ; ) {
|
|
16
|
+
try {
|
|
17
|
+
await mkdir(guard, { mode: 448 });
|
|
18
|
+
break;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (
|
|
21
|
+
/** @type {any} */
|
|
22
|
+
error?.code !== "EEXIST"
|
|
23
|
+
) throw error;
|
|
24
|
+
if (Date.now() >= deadline) throw recovery("Integration mutation guard remains busy or abandoned");
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const token = path.join(guard, `owner-${process.pid}-${randomUUID()}`);
|
|
29
|
+
try {
|
|
30
|
+
const handle = await open(token, "wx", 384);
|
|
31
|
+
await handle.close();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw recovery(`Integration guard initialization failed: ${String(error)}`);
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return await run();
|
|
37
|
+
} finally {
|
|
38
|
+
try {
|
|
39
|
+
await unlink(token);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw recovery(
|
|
42
|
+
/** @type {any} */
|
|
43
|
+
error?.code === "ENOENT" ? "Integration guard ownership was lost" : `Integration guard token cleanup failed: ${String(error)}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await rmdir(guard);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw recovery(`Integration guard cleanup failed: ${String(error)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function snapshot() {
|
|
54
|
+
let handle;
|
|
55
|
+
try {
|
|
56
|
+
handle = await open(file, "r");
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (
|
|
59
|
+
/** @type {any} */
|
|
60
|
+
error?.code === "ENOENT"
|
|
61
|
+
) return void 0;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const info = await handle.stat();
|
|
66
|
+
const raw = await handle.readFile("utf8");
|
|
67
|
+
let value;
|
|
68
|
+
try {
|
|
69
|
+
value = JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
value = void 0;
|
|
72
|
+
}
|
|
73
|
+
return { value, info };
|
|
74
|
+
} finally {
|
|
75
|
+
await handle.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function complete(value) {
|
|
79
|
+
return value && Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.bootTime === "number" && Number.isFinite(value.bootTime) && value.bootTime > 0 && typeof value.startedAt === "string" && Number.isFinite(Date.parse(value.startedAt)) && typeof value.ready === "boolean" && (value.lockVersion === void 0 && value.owner === void 0 || value.lockVersion === 1 && typeof value.owner === "string" && /^[a-f0-9-]{36}$/.test(value.owner));
|
|
80
|
+
}
|
|
81
|
+
function dead(value) {
|
|
82
|
+
try {
|
|
83
|
+
process.kill(value.pid, 0);
|
|
84
|
+
return false;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (
|
|
87
|
+
/** @type {any} */
|
|
88
|
+
error?.code !== "ESRCH"
|
|
89
|
+
) return false;
|
|
90
|
+
if (Math.abs(value.bootTime - bootTime()) > 30) throw recovery("Integration owner boot context is uncertain");
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function matches(current) {
|
|
95
|
+
return !!owned && current?.value?.owner === owned.owner && current.info.dev === owned.dev && current.info.ino === owned.ino;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
async acquire() {
|
|
99
|
+
if (phase !== "new") throw new Error("Integration lock instance has already been used");
|
|
100
|
+
phase = "acquiring";
|
|
101
|
+
try {
|
|
102
|
+
return await guarded(async () => {
|
|
103
|
+
const current = await snapshot();
|
|
104
|
+
if (current) {
|
|
105
|
+
if (!complete(current.value)) throw recovery("Integration lock is malformed, incomplete or unsupported");
|
|
106
|
+
if (!dead(current.value)) {
|
|
107
|
+
phase = "retired";
|
|
108
|
+
return { acquired: false, lock: current.value };
|
|
109
|
+
}
|
|
110
|
+
await unlink(file);
|
|
111
|
+
}
|
|
112
|
+
const value = { pid: process.pid, bootTime: bootTime(), startedAt: (/* @__PURE__ */ new Date()).toISOString(), ready: false, lockVersion: 1, owner: randomUUID() };
|
|
113
|
+
const handle = await open(file, "wx", 384);
|
|
114
|
+
let info;
|
|
115
|
+
try {
|
|
116
|
+
await handle.writeFile(JSON.stringify(value));
|
|
117
|
+
info = await handle.stat();
|
|
118
|
+
} finally {
|
|
119
|
+
await handle.close();
|
|
120
|
+
}
|
|
121
|
+
owned = { owner: value.owner, dev: info.dev, ino: info.ino };
|
|
122
|
+
phase = "owned";
|
|
123
|
+
return { acquired: true, lock: value };
|
|
124
|
+
});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
phase = "retired";
|
|
127
|
+
owned = void 0;
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
async setReady(ready) {
|
|
132
|
+
if (phase !== "owned") return false;
|
|
133
|
+
return guarded(async () => {
|
|
134
|
+
if (phase !== "owned") return false;
|
|
135
|
+
const current = await snapshot();
|
|
136
|
+
if (!current || !matches(current)) {
|
|
137
|
+
phase = "retired";
|
|
138
|
+
owned = void 0;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
const value = { ...current.value, ready };
|
|
142
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
143
|
+
const handle = await open(tmp, "wx", 384);
|
|
144
|
+
let info;
|
|
145
|
+
try {
|
|
146
|
+
try {
|
|
147
|
+
await handle.writeFile(JSON.stringify(value));
|
|
148
|
+
info = await handle.stat();
|
|
149
|
+
} finally {
|
|
150
|
+
await handle.close();
|
|
151
|
+
}
|
|
152
|
+
await rename(tmp, file);
|
|
153
|
+
owned = { owner: value.owner, dev: info.dev, ino: info.ino };
|
|
154
|
+
return true;
|
|
155
|
+
} finally {
|
|
156
|
+
await unlink(tmp).catch((error) => {
|
|
157
|
+
if (error?.code !== "ENOENT") throw error;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
async release() {
|
|
163
|
+
if (phase !== "owned") return false;
|
|
164
|
+
return guarded(async () => {
|
|
165
|
+
if (phase !== "owned") return false;
|
|
166
|
+
const current = await snapshot();
|
|
167
|
+
const ours = matches(current);
|
|
168
|
+
phase = "retired";
|
|
169
|
+
owned = void 0;
|
|
170
|
+
if (!ours) return false;
|
|
171
|
+
await unlink(file);
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export {
|
|
178
|
+
createIntegrationLock
|
|
179
|
+
};
|
package/dist/ipc.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node transport of the ALP client: a Unix socket or Windows named pipe carrying
|
|
3
|
+
* newline-delimited JSON (plans/reference/ALPD.md §3). Node-only; the portable engine
|
|
4
|
+
* (`@anhlp/alp-client/portable`) never imports it. Socket discovery, the private-socket checks
|
|
5
|
+
* and daemon startup stay in the Node entry (`connect`, `ensureDaemon`).
|
|
6
|
+
*
|
|
7
|
+
* @typedef {import('./rpc/client.js').AlpTransport} AlpTransport
|
|
8
|
+
* @typedef {import('./rpc/client.js').TransportClose} TransportClose
|
|
9
|
+
* @typedef {{ maxFrame?: number, maxBufferedBytes?: number }} SocketTransportOptions
|
|
10
|
+
* `maxBufferedBytes` bounds what may wait in the socket's write buffer; a send beyond it fails
|
|
11
|
+
* as `send_failed` instead of growing memory while the daemon does not read.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Wraps a connected or still-connecting socket (writes made while it connects are queued by
|
|
15
|
+
* Node). The transport owns the socket from then on.
|
|
16
|
+
* @param {import('node:net').Socket} socket
|
|
17
|
+
* @param {SocketTransportOptions} [options]
|
|
18
|
+
* @returns {AlpTransport}
|
|
19
|
+
*/
|
|
20
|
+
export function createSocketTransport(socket: import("node:net").Socket, { maxFrame, maxBufferedBytes }?: SocketTransportOptions): AlpTransport;
|
|
21
|
+
/**
|
|
22
|
+
* The Node transport of the ALP client: a Unix socket or Windows named pipe carrying
|
|
23
|
+
* newline-delimited JSON (plans/reference/ALPD.md §3). Node-only; the portable engine
|
|
24
|
+
* (`@anhlp/alp-client/portable`) never imports it. Socket discovery, the private-socket checks
|
|
25
|
+
* and daemon startup stay in the Node entry (`connect`, `ensureDaemon`).
|
|
26
|
+
*/
|
|
27
|
+
export type AlpTransport = import("./rpc/client.js").AlpTransport;
|
|
28
|
+
/**
|
|
29
|
+
* The Node transport of the ALP client: a Unix socket or Windows named pipe carrying
|
|
30
|
+
* newline-delimited JSON (plans/reference/ALPD.md §3). Node-only; the portable engine
|
|
31
|
+
* (`@anhlp/alp-client/portable`) never imports it. Socket discovery, the private-socket checks
|
|
32
|
+
* and daemon startup stay in the Node entry (`connect`, `ensureDaemon`).
|
|
33
|
+
*/
|
|
34
|
+
export type TransportClose = import("./rpc/client.js").TransportClose;
|
|
35
|
+
/**
|
|
36
|
+
* `maxBufferedBytes` bounds what may wait in the socket's write buffer; a send beyond it fails
|
|
37
|
+
* as `send_failed` instead of growing memory while the daemon does not read.
|
|
38
|
+
*/
|
|
39
|
+
export type SocketTransportOptions = {
|
|
40
|
+
maxFrame?: number;
|
|
41
|
+
maxBufferedBytes?: number;
|
|
42
|
+
};
|
package/dist/ipc.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// packages/client/src/ipc.js
|
|
2
|
+
import { MAX_FRAME } from "@anhlp/alp-protocol";
|
|
3
|
+
import { AlpClientError } from "./rpc/errors.js";
|
|
4
|
+
import { createLineDecoder } from "./rpc/framing.js";
|
|
5
|
+
function createSocketTransport(socket, { maxFrame = MAX_FRAME, maxBufferedBytes = 4 * MAX_FRAME } = {}) {
|
|
6
|
+
const decoder = createLineDecoder({ maxFrame });
|
|
7
|
+
const messages = /* @__PURE__ */ new Set();
|
|
8
|
+
const closes = /* @__PURE__ */ new Set();
|
|
9
|
+
let ended;
|
|
10
|
+
const end = (close) => {
|
|
11
|
+
if (ended) return;
|
|
12
|
+
ended = close;
|
|
13
|
+
socket.off("data", onData);
|
|
14
|
+
messages.clear();
|
|
15
|
+
const listeners = [...closes];
|
|
16
|
+
closes.clear();
|
|
17
|
+
for (const listener of listeners) listener(close);
|
|
18
|
+
};
|
|
19
|
+
const onData = (chunk) => {
|
|
20
|
+
if (ended) return;
|
|
21
|
+
let frames;
|
|
22
|
+
try {
|
|
23
|
+
frames = decoder.push(chunk);
|
|
24
|
+
} catch {
|
|
25
|
+
const error = new AlpClientError("ALP daemon frame exceeds size limit", { reason: "protocol" });
|
|
26
|
+
socket.destroy();
|
|
27
|
+
return end({ error });
|
|
28
|
+
}
|
|
29
|
+
for (const frame of frames) {
|
|
30
|
+
if (ended) return;
|
|
31
|
+
for (const listener of [...messages]) listener(frame);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
socket.setEncoding("utf8");
|
|
35
|
+
socket.on("data", onData);
|
|
36
|
+
socket.on("error", (error) => end({ error }));
|
|
37
|
+
socket.on("close", () => end({}));
|
|
38
|
+
if (socket.destroyed) end({});
|
|
39
|
+
const gone = () => socket.destroyed || socket.writableEnded;
|
|
40
|
+
return {
|
|
41
|
+
get state() {
|
|
42
|
+
return ended || gone() ? "closed" : "open";
|
|
43
|
+
},
|
|
44
|
+
send(frame) {
|
|
45
|
+
if (!ended && gone()) end({});
|
|
46
|
+
if (ended) throw new AlpClientError("ALP daemon connection closed", { reason: "send_failed" });
|
|
47
|
+
if (socket.writableLength + frame.length > maxBufferedBytes) throw new AlpClientError(`ALP socket write buffer is over ${maxBufferedBytes} bytes`, { reason: "send_failed" });
|
|
48
|
+
socket.write(`${frame}
|
|
49
|
+
`);
|
|
50
|
+
},
|
|
51
|
+
onMessage(listener) {
|
|
52
|
+
if (ended) return () => false;
|
|
53
|
+
messages.add(listener);
|
|
54
|
+
return () => messages.delete(listener);
|
|
55
|
+
},
|
|
56
|
+
onClose(listener) {
|
|
57
|
+
if (ended) return () => false;
|
|
58
|
+
closes.add(listener);
|
|
59
|
+
return () => closes.delete(listener);
|
|
60
|
+
},
|
|
61
|
+
close() {
|
|
62
|
+
if (!socket.writableEnded) socket.end(() => socket.destroy());
|
|
63
|
+
end({});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export {
|
|
68
|
+
createSocketTransport
|
|
69
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tries an MCP server, a hook or an ACP provider out (ALPD §43, §46). An MCP server is
|
|
3
|
+
* 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
|
|
4
|
+
* library when there is none), with the variables ALP gives hooks.
|
|
5
|
+
*/
|
|
6
|
+
export function testEntry(kind: any, name: any, { root, library, templates, scope, timeoutMs }?: {}): Promise<{
|
|
7
|
+
server?: {
|
|
8
|
+
name?: string;
|
|
9
|
+
version?: string;
|
|
10
|
+
};
|
|
11
|
+
tools: Array<{
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
}>;
|
|
15
|
+
kind: any;
|
|
16
|
+
name: any;
|
|
17
|
+
source: "library" | "project" | "builtin";
|
|
18
|
+
ok: boolean;
|
|
19
|
+
} | {
|
|
20
|
+
exitCode: number | null;
|
|
21
|
+
signal: string | null;
|
|
22
|
+
timedOut: boolean;
|
|
23
|
+
stdout: string;
|
|
24
|
+
stderr: string;
|
|
25
|
+
durationMs: number;
|
|
26
|
+
payload: {
|
|
27
|
+
branch?: string | undefined;
|
|
28
|
+
files?: never[] | undefined;
|
|
29
|
+
task?: any;
|
|
30
|
+
reason?: string | undefined;
|
|
31
|
+
handoff?: {
|
|
32
|
+
outcome: string;
|
|
33
|
+
summary: string;
|
|
34
|
+
} | undefined;
|
|
35
|
+
event: any;
|
|
36
|
+
sample: boolean;
|
|
37
|
+
project: any;
|
|
38
|
+
session: string;
|
|
39
|
+
agent: string;
|
|
40
|
+
};
|
|
41
|
+
wouldBlock?: boolean | undefined;
|
|
42
|
+
kind: any;
|
|
43
|
+
name: any;
|
|
44
|
+
source: "library" | "project" | "builtin";
|
|
45
|
+
ok: boolean;
|
|
46
|
+
} | {
|
|
47
|
+
protocolVersion: number;
|
|
48
|
+
agent?: {
|
|
49
|
+
name?: string;
|
|
50
|
+
title?: string;
|
|
51
|
+
version?: string;
|
|
52
|
+
};
|
|
53
|
+
loadSession: boolean;
|
|
54
|
+
mcpHttp: boolean;
|
|
55
|
+
authMethods: string[];
|
|
56
|
+
models?: Array<{
|
|
57
|
+
id: string;
|
|
58
|
+
label: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
}>;
|
|
61
|
+
currentModel?: string;
|
|
62
|
+
modes?: Array<{
|
|
63
|
+
id: string;
|
|
64
|
+
label: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
}>;
|
|
67
|
+
currentMode?: string;
|
|
68
|
+
kind: any;
|
|
69
|
+
name: any;
|
|
70
|
+
source: "library" | "project" | "builtin";
|
|
71
|
+
ok: boolean;
|
|
72
|
+
} | {
|
|
73
|
+
version: string;
|
|
74
|
+
agent: {
|
|
75
|
+
name: string;
|
|
76
|
+
title: string;
|
|
77
|
+
version: string;
|
|
78
|
+
};
|
|
79
|
+
models: {
|
|
80
|
+
id: any;
|
|
81
|
+
label: any;
|
|
82
|
+
}[];
|
|
83
|
+
currentModel: any;
|
|
84
|
+
modes: {
|
|
85
|
+
id: string;
|
|
86
|
+
label: string;
|
|
87
|
+
description: string;
|
|
88
|
+
}[];
|
|
89
|
+
currentMode: string;
|
|
90
|
+
kind: any;
|
|
91
|
+
name: any;
|
|
92
|
+
source: "library" | "project" | "builtin";
|
|
93
|
+
ok: boolean;
|
|
94
|
+
}>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// packages/client/src/library-test.js
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AlpError } from "@anhlp/alp-core/errors";
|
|
4
|
+
import { getEntry } from "@anhlp/alp-core/library-edit";
|
|
5
|
+
import { runHook, samplePayload } from "@anhlp/alp-core/hook-run";
|
|
6
|
+
import { normalizeMcp } from "@anhlp/alp-core/validation";
|
|
7
|
+
import { probeMcp } from "./mcp-probe.js";
|
|
8
|
+
import { probeAcp } from "./acp-probe.js";
|
|
9
|
+
import { probeAntigravity } from "./antigravity-probe.js";
|
|
10
|
+
import { loadProvider } from "@anhlp/alp-core/providers";
|
|
11
|
+
async function testEntry(kind, name, { root, library, templates, scope, timeoutMs } = {}) {
|
|
12
|
+
const entry = await getEntry(kind, name, { root, library, templates, scope });
|
|
13
|
+
const home = entry.source === "project" ? path.join(path.resolve(root), ".alp") : library;
|
|
14
|
+
if (kind === "mcp") {
|
|
15
|
+
const server = normalizeMcp({ mcpServers: { [name]: entry.content.server } }, path.join(home, "mcp"), `${name}.json`).mcpServers[name];
|
|
16
|
+
const result = await probeMcp(server, { cwd: root ? path.resolve(root) : library, ...timeoutMs ? { timeoutMs } : {} });
|
|
17
|
+
return { kind, name, source: entry.source, ok: true, ...result };
|
|
18
|
+
}
|
|
19
|
+
if (kind === "hooks") {
|
|
20
|
+
const hook = entry.content.hook;
|
|
21
|
+
const project = root ? path.resolve(root) : void 0;
|
|
22
|
+
const payload = samplePayload(hook.event, { project, agent: hook.match?.agent ?? "main" });
|
|
23
|
+
const env = { ALP_EVENT: hook.event, ALP_SESSION: payload.session, ALP_AGENT: payload.agent, ALP_PROJECT: project ?? "", ALP_TASK: payload.task ?? "" };
|
|
24
|
+
const result = await runHook(hook, payload, { cwd: project ?? library, env, ...timeoutMs ? { timeoutMs } : {} });
|
|
25
|
+
const ok = result.exitCode === 0 && !result.timedOut;
|
|
26
|
+
return { kind, name, source: entry.source, ok, ...hook.blocking && !ok ? { wouldBlock: true } : {}, payload, ...result };
|
|
27
|
+
}
|
|
28
|
+
if (kind === "providers") {
|
|
29
|
+
const provider = await loadProvider(library, name);
|
|
30
|
+
const result = provider.kind === "antigravity" ? await probeAntigravity(provider, { cwd: root ? path.resolve(root) : library, ...timeoutMs ? { timeoutMs } : {} }) : await probeAcp(provider, { cwd: root ? path.resolve(root) : library, ...timeoutMs ? { timeoutMs } : {} });
|
|
31
|
+
return { kind, name, source: entry.source, ok: true, ...result };
|
|
32
|
+
}
|
|
33
|
+
throw new AlpError("INVALID_KIND", `Only mcp servers, hooks and providers can be tested, not ${kind}`);
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
testEntry
|
|
37
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts an MCP server once and lists its tools (ALPD §43), for the Test button and
|
|
3
|
+
* `alp mcp test`. `server` is a normalized definition: { command, args, env, cwd } for
|
|
4
|
+
* stdio, or { url, headers } for HTTP. The server is stopped afterwards.
|
|
5
|
+
* @returns {Promise<{ server?: { name?: string, version?: string }, tools: Array<{ name: string, description?: string }> }>}
|
|
6
|
+
*/
|
|
7
|
+
export function probeMcp(server: any, { cwd, timeoutMs }?: {
|
|
8
|
+
timeoutMs?: number | undefined;
|
|
9
|
+
}): Promise<{
|
|
10
|
+
server?: {
|
|
11
|
+
name?: string;
|
|
12
|
+
version?: string;
|
|
13
|
+
};
|
|
14
|
+
tools: Array<{
|
|
15
|
+
name: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
}>;
|
|
18
|
+
}>;
|
|
19
|
+
/** A JSON-RPC session with a process over stdio; ACP providers are probed with it too. */
|
|
20
|
+
export function stdioSession(server: any, cwd: any, timeoutMs: any): {
|
|
21
|
+
request(method: any, params: any): Promise<any>;
|
|
22
|
+
notify(method: any): Promise<void>;
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// packages/client/src/mcp-probe.js
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { launchCommand } from "@anhlp/alp-core/providers";
|
|
5
|
+
var PROTOCOL = "2025-06-18";
|
|
6
|
+
var client = { name: "alp", version: "1" };
|
|
7
|
+
async function probeMcp(server, { cwd, timeoutMs = 15e3 } = {}) {
|
|
8
|
+
const session = server.url ? await httpSession(server, timeoutMs) : stdioSession(server, cwd, timeoutMs);
|
|
9
|
+
try {
|
|
10
|
+
const init = await session.request("initialize", { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: client });
|
|
11
|
+
await session.notify("notifications/initialized");
|
|
12
|
+
const tools = [];
|
|
13
|
+
let cursor;
|
|
14
|
+
for (let page = 0; page < 20; page++) {
|
|
15
|
+
const result = await session.request("tools/list", cursor ? { cursor } : {});
|
|
16
|
+
for (const tool of result.tools ?? []) tools.push({ name: tool.name, ...tool.description ? { description: tool.description } : {} });
|
|
17
|
+
cursor = result.nextCursor;
|
|
18
|
+
if (!cursor) break;
|
|
19
|
+
}
|
|
20
|
+
return { ...init.serverInfo ? { server: { name: init.serverInfo.name, version: init.serverInfo.version } } : {}, tools };
|
|
21
|
+
} finally {
|
|
22
|
+
await session.close();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function stdioSession(server, cwd, timeoutMs) {
|
|
26
|
+
const env = { ...process.env, ...server.env ?? {} };
|
|
27
|
+
const launch = launchCommand(server.command, server.args ?? [], env);
|
|
28
|
+
const child = spawn(launch.command, launch.args, { cwd: server.cwd ?? cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
29
|
+
const signal = (name) => {
|
|
30
|
+
try {
|
|
31
|
+
process.kill(-child.pid, name);
|
|
32
|
+
} catch {
|
|
33
|
+
child.kill(name);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
let stderr = "";
|
|
37
|
+
child.stderr.setEncoding("utf8");
|
|
38
|
+
child.stderr.on("data", (chunk) => {
|
|
39
|
+
if (stderr.length < 4e3) stderr += chunk;
|
|
40
|
+
});
|
|
41
|
+
const pending = /* @__PURE__ */ new Map();
|
|
42
|
+
let exited;
|
|
43
|
+
const failAll = (error) => {
|
|
44
|
+
for (const { reject } of pending.values()) reject(error);
|
|
45
|
+
pending.clear();
|
|
46
|
+
};
|
|
47
|
+
child.on("error", (error) => {
|
|
48
|
+
exited = error;
|
|
49
|
+
failAll(error);
|
|
50
|
+
});
|
|
51
|
+
child.on("exit", (code) => {
|
|
52
|
+
exited = new Error(`${server.command} exited (${code ?? "signal"})${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-3).join(" ")}` : ""}`);
|
|
53
|
+
failAll(exited);
|
|
54
|
+
});
|
|
55
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
56
|
+
let message;
|
|
57
|
+
try {
|
|
58
|
+
message = JSON.parse(line);
|
|
59
|
+
} catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const waiting = pending.get(message.id);
|
|
63
|
+
if (!waiting) return;
|
|
64
|
+
pending.delete(message.id);
|
|
65
|
+
if (message.error) waiting.reject(new Error(`${message.error.message ?? "MCP error"} (${message.error.code})`));
|
|
66
|
+
else waiting.resolve(message.result ?? {});
|
|
67
|
+
});
|
|
68
|
+
child.stdin.on("error", () => {
|
|
69
|
+
});
|
|
70
|
+
let id = 0;
|
|
71
|
+
return {
|
|
72
|
+
request(method, params) {
|
|
73
|
+
if (exited) return Promise.reject(exited);
|
|
74
|
+
const current = ++id;
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
pending.delete(current);
|
|
78
|
+
reject(new Error(`${method} got no answer within ${timeoutMs / 1e3} s`));
|
|
79
|
+
}, timeoutMs);
|
|
80
|
+
pending.set(current, { resolve: (value) => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
resolve(value);
|
|
83
|
+
}, reject: (error) => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
reject(error);
|
|
86
|
+
} });
|
|
87
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: current, method, params }) + "\n");
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
async notify(method) {
|
|
91
|
+
if (!exited) child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method }) + "\n");
|
|
92
|
+
},
|
|
93
|
+
async close() {
|
|
94
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
95
|
+
const done = new Promise((resolve) => child.once("exit", resolve));
|
|
96
|
+
child.stdin.end();
|
|
97
|
+
signal("SIGTERM");
|
|
98
|
+
const timer = setTimeout(() => signal("SIGKILL"), 2e3);
|
|
99
|
+
await done;
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
async function httpSession(server, timeoutMs) {
|
|
105
|
+
let sessionId;
|
|
106
|
+
let id = 0;
|
|
107
|
+
const post = async (body) => {
|
|
108
|
+
const response = await fetch(server.url, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { ...server.headers ?? {}, "content-type": "application/json", accept: "application/json, text/event-stream", ...sessionId ? { "mcp-session-id": sessionId } : {}, "mcp-protocol-version": PROTOCOL },
|
|
111
|
+
body: JSON.stringify(body),
|
|
112
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
113
|
+
});
|
|
114
|
+
sessionId = response.headers.get("mcp-session-id") ?? sessionId;
|
|
115
|
+
if (!response.ok) throw new Error(`${server.url} answered ${response.status} ${response.statusText}`);
|
|
116
|
+
return response;
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
async request(method, params) {
|
|
120
|
+
const current = ++id;
|
|
121
|
+
const response = await post({ jsonrpc: "2.0", id: current, method, params });
|
|
122
|
+
const text = await response.text();
|
|
123
|
+
const messages = (response.headers.get("content-type") ?? "").includes("text/event-stream") ? text.split("\n").filter((line) => line.startsWith("data:")).map((line) => {
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(line.slice(5));
|
|
126
|
+
} catch {
|
|
127
|
+
return void 0;
|
|
128
|
+
}
|
|
129
|
+
}) : [JSON.parse(text)];
|
|
130
|
+
const message = messages.find((entry) => entry?.id === current);
|
|
131
|
+
if (!message) throw new Error(`${method}: no answer in the response`);
|
|
132
|
+
if (message.error) throw new Error(`${message.error.message ?? "MCP error"} (${message.error.code})`);
|
|
133
|
+
return message.result ?? {};
|
|
134
|
+
},
|
|
135
|
+
async notify(method) {
|
|
136
|
+
await post({ jsonrpc: "2.0", method });
|
|
137
|
+
},
|
|
138
|
+
async close() {
|
|
139
|
+
if (sessionId) await fetch(server.url, { method: "DELETE", headers: { ...server.headers ?? {}, "mcp-session-id": sessionId }, signal: AbortSignal.timeout(timeoutMs) }).catch(() => {
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export {
|
|
145
|
+
probeMcp,
|
|
146
|
+
stdioSession
|
|
147
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { hello } from "./rpc/handshake.js";
|
|
2
|
+
export type AlpTransport = import("./rpc/client.js").AlpTransport;
|
|
3
|
+
export type TransportClose = import("./rpc/client.js").TransportClose;
|
|
4
|
+
export type TransportState = import("./rpc/client.js").TransportState;
|
|
5
|
+
export type AbortSignalLike = import("./rpc/client.js").AbortSignalLike;
|
|
6
|
+
export type RequestOptions = import("./rpc/client.js").RequestOptions;
|
|
7
|
+
export type AlpRpcClientOptions = import("./rpc/client.js").AlpRpcClientOptions;
|
|
8
|
+
export type ClientInfo = import("./rpc/handshake.js").ClientInfo;
|
|
9
|
+
export type ReconnectOptions = import("./rpc/reconnect.js").ReconnectOptions;
|
|
10
|
+
export type ReconnectingClient = import("./rpc/reconnect.js").ReconnectingClient;
|
|
11
|
+
export type ReconnectState = import("./rpc/reconnect.js").ReconnectState;
|
|
12
|
+
export type Backoff = import("./rpc/reconnect.js").Backoff;
|
|
13
|
+
export { PROTOCOL_VERSION, MAX_FRAME, AlpRpcError, ERROR } from "@anhlp/alp-protocol";
|
|
14
|
+
export { AlpRpcClient, DEFAULT_MAX_PENDING } from "./rpc/client.js";
|
|
15
|
+
export { AlpClientError, CLIENT_ERROR, isAlpClientError } from "./rpc/errors.js";
|
|
16
|
+
export { createLineDecoder, utf8Length } from "./rpc/framing.js";
|
|
17
|
+
export { MAX_TIMER_MS, timerDelay } from "./rpc/timers.js";
|
|
18
|
+
export { backoffDelay, createReconnectingClient, retryable } from "./rpc/reconnect.js";
|
|
19
|
+
export { attachments, base64Encode, conversation, sha256Hex, workspaceRegistry } from "./rpc/registry.js";
|
package/dist/portable.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// packages/client/src/portable.js
|
|
2
|
+
import { PROTOCOL_VERSION, MAX_FRAME, AlpRpcError, ERROR } from "@anhlp/alp-protocol";
|
|
3
|
+
import { AlpRpcClient, DEFAULT_MAX_PENDING } from "./rpc/client.js";
|
|
4
|
+
import { AlpClientError, CLIENT_ERROR, isAlpClientError } from "./rpc/errors.js";
|
|
5
|
+
import { createLineDecoder, utf8Length } from "./rpc/framing.js";
|
|
6
|
+
import { MAX_TIMER_MS, timerDelay } from "./rpc/timers.js";
|
|
7
|
+
import { hello } from "./rpc/handshake.js";
|
|
8
|
+
import { backoffDelay, createReconnectingClient, retryable } from "./rpc/reconnect.js";
|
|
9
|
+
import { attachments, base64Encode, conversation, sha256Hex, workspaceRegistry } from "./rpc/registry.js";
|
|
10
|
+
export {
|
|
11
|
+
AlpClientError,
|
|
12
|
+
AlpRpcClient,
|
|
13
|
+
AlpRpcError,
|
|
14
|
+
CLIENT_ERROR,
|
|
15
|
+
DEFAULT_MAX_PENDING,
|
|
16
|
+
ERROR,
|
|
17
|
+
MAX_FRAME,
|
|
18
|
+
MAX_TIMER_MS,
|
|
19
|
+
PROTOCOL_VERSION,
|
|
20
|
+
attachments,
|
|
21
|
+
backoffDelay,
|
|
22
|
+
base64Encode,
|
|
23
|
+
conversation,
|
|
24
|
+
createLineDecoder,
|
|
25
|
+
createReconnectingClient,
|
|
26
|
+
hello,
|
|
27
|
+
isAlpClientError,
|
|
28
|
+
retryable,
|
|
29
|
+
sha256Hex,
|
|
30
|
+
timerDelay,
|
|
31
|
+
utf8Length,
|
|
32
|
+
workspaceRegistry
|
|
33
|
+
};
|