@anhlp/alp-client 0.0.1-beta.3 → 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/README.md +6 -1
- package/dist/index.d.ts +37 -2
- package/dist/index.js +55 -22
- package/dist/integration-lock.d.ts +35 -0
- package/dist/integration-lock.js +179 -0
- package/dist/portable.d.ts +1 -0
- package/dist/portable.js +7 -1
- package/dist/rpc/registry.d.ts +211 -0
- package/dist/rpc/registry.js +145 -0
- package/dist/supervise.d.ts +2 -0
- package/dist/supervise.js +4 -4
- package/package.json +11 -3
- package/src/index.js +89 -21
- package/src/integration-lock.js +179 -0
- package/src/portable.js +1 -0
- package/src/rpc/registry.js +161 -0
- package/src/supervise.d.ts +2 -0
- package/src/supervise.js +8 -5
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/** Typed registry methods for portable clients. Mutations are sent once: callers retain requestId
|
|
2
|
+
* and ifRevision to reconcile a timeout by repeating the identical request. @param {{request<T = any>(method: string, params?: unknown): Promise<T>}} client */
|
|
3
|
+
export function workspaceRegistry(client: {
|
|
4
|
+
request<T = any>(method: string, params?: unknown): Promise<T>;
|
|
5
|
+
}): {
|
|
6
|
+
/** @param {string} workspaceId @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.options']['result']>} */
|
|
7
|
+
worktreeOptions: (workspaceId: string) => Promise<import("@anhlp/alp-protocol").RpcMethodMap["workspace.worktree.options"]["result"]>;
|
|
8
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.create']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.create']['result']>} */
|
|
9
|
+
createWorktree: (params: import("@anhlp/alp-protocol").RpcMethodMap["workspace.worktree.create"]["params"]) => Promise<import("@anhlp/alp-protocol").RpcMethodMap["workspace.worktree.create"]["result"]>;
|
|
10
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.remove']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.remove']['result']>} */
|
|
11
|
+
removeWorktree: (params: import("@anhlp/alp-protocol").RpcMethodMap["workspace.worktree.remove"]["params"]) => Promise<import("@anhlp/alp-protocol").RpcMethodMap["workspace.worktree.remove"]["result"]>;
|
|
12
|
+
/** Read-only Git status of a registry workspace (capability `workspace.git`, §12.5). @param {string} workspaceId @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.git.status']['result']>} */
|
|
13
|
+
gitStatus: (workspaceId: string) => Promise<import("@anhlp/alp-protocol").RpcMethodMap["workspace.git.status"]["result"]>;
|
|
14
|
+
/** One page of a read-only Git diff; pass `nextCursor` back as `cursor` with the same comparison and path. @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.git.diff']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.git.diff']['result']>} */
|
|
15
|
+
gitDiff: (params: import("@anhlp/alp-protocol").RpcMethodMap["workspace.git.diff"]["params"]) => Promise<import("@anhlp/alp-protocol").RpcMethodMap["workspace.git.diff"]["result"]>;
|
|
16
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['project.list']['params']} [params] @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['project.list']['result']>} */
|
|
17
|
+
projects: (params?: import("@anhlp/alp-protocol").RpcMethodMap["project.list"]["params"]) => Promise<import("@anhlp/alp-protocol").RpcMethodMap["project.list"]["result"]>;
|
|
18
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.list']['params']} [params] @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.list']['result']>} */
|
|
19
|
+
workspaces: (params?: import("@anhlp/alp-protocol").RpcMethodMap["workspace.list"]["params"]) => Promise<import("@anhlp/alp-protocol").RpcMethodMap["workspace.list"]["result"]>;
|
|
20
|
+
/** @param {string} workspaceId @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.get']['result']>} */
|
|
21
|
+
get: (workspaceId: string) => Promise<import("@anhlp/alp-protocol").RpcMethodMap["workspace.get"]["result"]>;
|
|
22
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['project.register']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['project.register']['result']>} */
|
|
23
|
+
register: (params: import("@anhlp/alp-protocol").RpcMethodMap["project.register"]["params"]) => Promise<import("@anhlp/alp-protocol").RpcMethodMap["project.register"]["result"]>;
|
|
24
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['project.remove']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['project.remove']['result']>} */
|
|
25
|
+
remove: (params: import("@anhlp/alp-protocol").RpcMethodMap["project.remove"]["params"]) => Promise<import("@anhlp/alp-protocol").RpcMethodMap["project.remove"]["result"]>;
|
|
26
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.update']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.update']['result']>} */
|
|
27
|
+
update: (params: import("@anhlp/alp-protocol").RpcMethodMap["workspace.update"]["params"]) => Promise<import("@anhlp/alp-protocol").RpcMethodMap["workspace.update"]["result"]>;
|
|
28
|
+
};
|
|
29
|
+
/** Canonical base64 of `bytes`, without Buffer or btoa, so it runs in browsers and Node alike. @param {Uint8Array} bytes */
|
|
30
|
+
export function base64Encode(bytes: Uint8Array): string;
|
|
31
|
+
/** Lowercase hex SHA-256 of `bytes` through WebCrypto (browsers' secure contexts, Node 19+). @param {Uint8Array} bytes */
|
|
32
|
+
export function sha256Hex(bytes: Uint8Array): Promise<string>;
|
|
33
|
+
/**
|
|
34
|
+
* Typed attachment methods (capability `attachments`, docs/protocol/alp-json-rpc-v1.md §12.6) and a
|
|
35
|
+
* chunked upload that keeps to the limits the daemon (or gateway) advertised. Mutations are sent
|
|
36
|
+
* once: `upload` resumes an interrupted upload by repeating its begin with the same requestId and
|
|
37
|
+
* continues from what the daemon holds.
|
|
38
|
+
* @param {{request<T = any>(method: string, params?: unknown): Promise<T>}} client
|
|
39
|
+
*/
|
|
40
|
+
export function attachments(client: {
|
|
41
|
+
request<T = any>(method: string, params?: unknown): Promise<T>;
|
|
42
|
+
}): {
|
|
43
|
+
/** @param {Map['attachment.begin']['params']} params @returns {Promise<Map['attachment.begin']['result']>} */
|
|
44
|
+
begin: (params: import("@anhlp/alp-protocol").PrincipalContext & {
|
|
45
|
+
sessionId: string;
|
|
46
|
+
name: string;
|
|
47
|
+
size: number;
|
|
48
|
+
mimeType: string;
|
|
49
|
+
sha256?: string;
|
|
50
|
+
requestId: string;
|
|
51
|
+
}) => Promise<{
|
|
52
|
+
uploadId: string;
|
|
53
|
+
state: "uploading" | "committed";
|
|
54
|
+
received: number;
|
|
55
|
+
size: number;
|
|
56
|
+
maxChunkBytes: number;
|
|
57
|
+
expiresAt?: string;
|
|
58
|
+
attachmentId?: string;
|
|
59
|
+
}>;
|
|
60
|
+
/** @param {Map['attachment.chunk']['params']} params @returns {Promise<Map['attachment.chunk']['result']>} */
|
|
61
|
+
chunk: (params: import("@anhlp/alp-protocol").PrincipalContext & {
|
|
62
|
+
uploadId: string;
|
|
63
|
+
offset: number;
|
|
64
|
+
data: string;
|
|
65
|
+
}) => Promise<{
|
|
66
|
+
received: number;
|
|
67
|
+
size: number;
|
|
68
|
+
}>;
|
|
69
|
+
/** @param {Map['attachment.commit']['params']} params @returns {Promise<Map['attachment.commit']['result']>} */
|
|
70
|
+
commit: (params: import("@anhlp/alp-protocol").PrincipalContext & {
|
|
71
|
+
uploadId: string;
|
|
72
|
+
sha256: string;
|
|
73
|
+
}) => Promise<{
|
|
74
|
+
attachment: import("@anhlp/alp-protocol").AttachmentInfo;
|
|
75
|
+
}>;
|
|
76
|
+
/** @param {string} uploadId @returns {Promise<Map['attachment.abort']['result']>} */
|
|
77
|
+
abort: (uploadId: string) => Promise<{
|
|
78
|
+
aborted: true;
|
|
79
|
+
}>;
|
|
80
|
+
/** Metadata only (no bytes, no path). @param {string} attachmentId @returns {Promise<Map['attachment.get']['result']>} */
|
|
81
|
+
get: (attachmentId: string) => Promise<{
|
|
82
|
+
attachment: import("@anhlp/alp-protocol").AttachmentInfo;
|
|
83
|
+
}>;
|
|
84
|
+
/** Refused (1004 attachment_referenced) while a prompt references it. @param {string} attachmentId @returns {Promise<Map['attachment.delete']['result']>} */
|
|
85
|
+
delete: (attachmentId: string) => Promise<{
|
|
86
|
+
deleted: true;
|
|
87
|
+
}>;
|
|
88
|
+
/**
|
|
89
|
+
* Uploads `data` and commits it: begin (or resume, with the same requestId), chunks of at most
|
|
90
|
+
* the smaller of `maxChunkBytes` and what begin answered, commit with its SHA-256. A chunk
|
|
91
|
+
* refused `offset_mismatch` continues from the daemon's `received`. Returns the attachment.
|
|
92
|
+
* @param {{ sessionId: string, name: string, mimeType: string, data: Uint8Array, requestId: string, sha256?: string, maxChunkBytes?: number, onProgress?: (received: number, size: number) => void }} options
|
|
93
|
+
*/
|
|
94
|
+
upload({ sessionId, name, mimeType, data, requestId, sha256, maxChunkBytes, onProgress }: {
|
|
95
|
+
sessionId: string;
|
|
96
|
+
name: string;
|
|
97
|
+
mimeType: string;
|
|
98
|
+
data: Uint8Array;
|
|
99
|
+
requestId: string;
|
|
100
|
+
sha256?: string;
|
|
101
|
+
maxChunkBytes?: number;
|
|
102
|
+
onProgress?: (received: number, size: number) => void;
|
|
103
|
+
}): Promise<import("@anhlp/alp-protocol").AttachmentInfo>;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Typed conversation methods (capabilities `session.fork` and `session.rewind`, docs/protocol/
|
|
107
|
+
* alp-json-rpc-v1.md §12.7; `session.reload` and `session.resumeCommand`, §12.8). Both change the conversation only, never a file. Mutations are sent
|
|
108
|
+
* once: keep `requestId` (and `ifGeneration`) to repeat the identical request after a timeout, which
|
|
109
|
+
* answers the first result with `duplicate: true`. After a rewind, attached clients get a
|
|
110
|
+
* `history.reset` event: drop cached history, fetch a new tail and attach with its generation.
|
|
111
|
+
* @param {{request<T = any>(method: string, params?: unknown): Promise<T>}} client
|
|
112
|
+
*/
|
|
113
|
+
export function conversation(client: {
|
|
114
|
+
request<T = any>(method: string, params?: unknown): Promise<T>;
|
|
115
|
+
}): {
|
|
116
|
+
/**
|
|
117
|
+
* A new closed root whose conversation is the source root's through or before a turn (`boundary`),
|
|
118
|
+
* or a branch a rewind set aside (`branchId`); resume it with session.create { sessionId, resume: true }.
|
|
119
|
+
* Principal fields are set by a trusted gateway on its own connection, never by a remote client.
|
|
120
|
+
* @param {Map['session.fork']['params']} params @returns {Promise<Map['session.fork']['result']>}
|
|
121
|
+
*/
|
|
122
|
+
fork: (params: import("@anhlp/alp-protocol").PrincipalContext & {
|
|
123
|
+
sessionId: string;
|
|
124
|
+
requestId: string;
|
|
125
|
+
ifGeneration: string;
|
|
126
|
+
boundary?: import("@anhlp/alp-protocol").ForkBoundary;
|
|
127
|
+
branchId?: string;
|
|
128
|
+
workspaceId?: string;
|
|
129
|
+
newSessionId?: string;
|
|
130
|
+
title?: string;
|
|
131
|
+
}) => Promise<{
|
|
132
|
+
session: import("@anhlp/alp-protocol").SessionSummary;
|
|
133
|
+
forkedFrom: import("@anhlp/alp-protocol").ForkProvenance;
|
|
134
|
+
duplicate?: true;
|
|
135
|
+
}>;
|
|
136
|
+
/**
|
|
137
|
+
* Continues the root's conversation from before `beforeTurnId` under the same session id; the
|
|
138
|
+
* later turns stay a branch (`branch.branchId`) that `fork({ branchId })` recovers.
|
|
139
|
+
* @param {Map['session.rewind']['params']} params @returns {Promise<Map['session.rewind']['result']>}
|
|
140
|
+
*/
|
|
141
|
+
rewind: (params: {
|
|
142
|
+
sessionId: string;
|
|
143
|
+
requestId: string;
|
|
144
|
+
ifGeneration: string;
|
|
145
|
+
beforeTurnId: string;
|
|
146
|
+
}) => Promise<{
|
|
147
|
+
session: import("@anhlp/alp-protocol").SessionSummary;
|
|
148
|
+
generation: string;
|
|
149
|
+
branch: import("@anhlp/alp-protocol").ConversationBranch;
|
|
150
|
+
duplicate?: true;
|
|
151
|
+
}>;
|
|
152
|
+
/**
|
|
153
|
+
* Restarts a live root's native process on its own conversation and configuration at the idle
|
|
154
|
+
* barrier (capability `session.reload`, §12.8); id, history and receipts stay. Sent once: repeat
|
|
155
|
+
* the same `requestId` after a lost answer.
|
|
156
|
+
* @param {Map['session.reload']['params']} params @returns {Promise<Map['session.reload']['result']>}
|
|
157
|
+
*/
|
|
158
|
+
reload: (params: {
|
|
159
|
+
sessionId: string;
|
|
160
|
+
requestId: string;
|
|
161
|
+
ifGeneration: string;
|
|
162
|
+
}) => Promise<{
|
|
163
|
+
session: import("@anhlp/alp-protocol").SessionSummary;
|
|
164
|
+
duplicate?: true;
|
|
165
|
+
}>;
|
|
166
|
+
/**
|
|
167
|
+
* The ALP CLI command that follows the session in a terminal (capability `session.resumeCommand`,
|
|
168
|
+
* §12.8), built by alpd: for the clipboard only, never to run.
|
|
169
|
+
* @param {Map['session.resumeCommand']['params']} params @returns {Promise<Map['session.resumeCommand']['result']>}
|
|
170
|
+
*/
|
|
171
|
+
resumeCommand: (params: {
|
|
172
|
+
sessionId: string;
|
|
173
|
+
}) => Promise<import("@anhlp/alp-protocol").ResumeCommand>;
|
|
174
|
+
/**
|
|
175
|
+
* Changes a live root's model, effort or team (capability `session.configure.live`, §12.9) at the
|
|
176
|
+
* idle barrier, through a restart of its native process on the same conversation; `configured` says
|
|
177
|
+
* what it runs with now. Sent once: repeat the same `requestId` after a lost answer. A change to
|
|
178
|
+
* another runtime is refused (1005 runtime_mismatch, `data.action: 'session.create'`).
|
|
179
|
+
* @param {Map['session.configure']['params'] & { requestId: string; ifGeneration: string }} params @returns {Promise<Map['session.configure']['result']>}
|
|
180
|
+
*/
|
|
181
|
+
configure: (params: {
|
|
182
|
+
sessionId: string;
|
|
183
|
+
mode?: string;
|
|
184
|
+
model?: string;
|
|
185
|
+
thinking?: string;
|
|
186
|
+
team?: string;
|
|
187
|
+
requestId?: string;
|
|
188
|
+
ifGeneration?: string;
|
|
189
|
+
} & {
|
|
190
|
+
requestId: string;
|
|
191
|
+
ifGeneration: string;
|
|
192
|
+
}) => Promise<{
|
|
193
|
+
session: import("@anhlp/alp-protocol").SessionSnapshot | import("@anhlp/alp-protocol").SessionSummary;
|
|
194
|
+
configured?: import("@anhlp/alp-protocol").ConfigureOutcome;
|
|
195
|
+
duplicate?: true;
|
|
196
|
+
}>;
|
|
197
|
+
/**
|
|
198
|
+
* What a change of model, effort, team or mode would run a live root with, and whether it would be
|
|
199
|
+
* taken now (capability `session.configure.live`, §12.9). Reads only.
|
|
200
|
+
* @param {Map['session.configure.preview']['params']} params @returns {Promise<Map['session.configure.preview']['result']>}
|
|
201
|
+
*/
|
|
202
|
+
configurePreview: (params: {
|
|
203
|
+
sessionId: string;
|
|
204
|
+
mode?: string;
|
|
205
|
+
model?: string;
|
|
206
|
+
thinking?: string;
|
|
207
|
+
team?: string;
|
|
208
|
+
}) => Promise<{
|
|
209
|
+
preview: import("@anhlp/alp-protocol").ConfigurePreview;
|
|
210
|
+
}>;
|
|
211
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// packages/client/src/rpc/registry.js
|
|
2
|
+
function workspaceRegistry(client) {
|
|
3
|
+
return {
|
|
4
|
+
/** @param {string} workspaceId @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.options']['result']>} */
|
|
5
|
+
worktreeOptions: (workspaceId) => client.request("workspace.worktree.options", { workspaceId }),
|
|
6
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.create']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.create']['result']>} */
|
|
7
|
+
createWorktree: (params) => client.request("workspace.worktree.create", params),
|
|
8
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.remove']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.remove']['result']>} */
|
|
9
|
+
removeWorktree: (params) => client.request("workspace.worktree.remove", params),
|
|
10
|
+
/** Read-only Git status of a registry workspace (capability `workspace.git`, §12.5). @param {string} workspaceId @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.git.status']['result']>} */
|
|
11
|
+
gitStatus: (workspaceId) => client.request("workspace.git.status", { workspaceId }),
|
|
12
|
+
/** One page of a read-only Git diff; pass `nextCursor` back as `cursor` with the same comparison and path. @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.git.diff']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.git.diff']['result']>} */
|
|
13
|
+
gitDiff: (params) => client.request("workspace.git.diff", params),
|
|
14
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['project.list']['params']} [params] @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['project.list']['result']>} */
|
|
15
|
+
projects: (params = {}) => client.request("project.list", params),
|
|
16
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.list']['params']} [params] @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.list']['result']>} */
|
|
17
|
+
workspaces: (params = {}) => client.request("workspace.list", params),
|
|
18
|
+
/** @param {string} workspaceId @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.get']['result']>} */
|
|
19
|
+
get: (workspaceId) => client.request("workspace.get", { workspaceId }),
|
|
20
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['project.register']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['project.register']['result']>} */
|
|
21
|
+
register: (params) => client.request("project.register", params),
|
|
22
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['project.remove']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['project.remove']['result']>} */
|
|
23
|
+
remove: (params) => client.request("project.remove", params),
|
|
24
|
+
/** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.update']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.update']['result']>} */
|
|
25
|
+
update: (params) => client.request("workspace.update", params)
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
var BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
29
|
+
function base64Encode(bytes) {
|
|
30
|
+
let out = "";
|
|
31
|
+
let index = 0;
|
|
32
|
+
for (; index + 2 < bytes.length; index += 3) {
|
|
33
|
+
const n = bytes[index] << 16 | bytes[index + 1] << 8 | bytes[index + 2];
|
|
34
|
+
out += BASE64[n >> 18] + BASE64[n >> 12 & 63] + BASE64[n >> 6 & 63] + BASE64[n & 63];
|
|
35
|
+
}
|
|
36
|
+
if (index < bytes.length) {
|
|
37
|
+
const n = bytes[index] << 16 | (index + 1 < bytes.length ? bytes[index + 1] : 0) << 8;
|
|
38
|
+
out += BASE64[n >> 18] + BASE64[n >> 12 & 63] + (index + 1 < bytes.length ? BASE64[n >> 6 & 63] : "=") + "=";
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
async function sha256Hex(bytes) {
|
|
43
|
+
const subtle = globalThis.crypto?.subtle;
|
|
44
|
+
if (!subtle) throw new Error("SHA-256 needs WebCrypto here; pass sha256 yourself");
|
|
45
|
+
const digest = new Uint8Array(await subtle.digest("SHA-256", bytes));
|
|
46
|
+
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
47
|
+
}
|
|
48
|
+
function attachments(client) {
|
|
49
|
+
const api = {
|
|
50
|
+
/** @param {Map['attachment.begin']['params']} params @returns {Promise<Map['attachment.begin']['result']>} */
|
|
51
|
+
begin: (params) => client.request("attachment.begin", params),
|
|
52
|
+
/** @param {Map['attachment.chunk']['params']} params @returns {Promise<Map['attachment.chunk']['result']>} */
|
|
53
|
+
chunk: (params) => client.request("attachment.chunk", params),
|
|
54
|
+
/** @param {Map['attachment.commit']['params']} params @returns {Promise<Map['attachment.commit']['result']>} */
|
|
55
|
+
commit: (params) => client.request("attachment.commit", params),
|
|
56
|
+
/** @param {string} uploadId @returns {Promise<Map['attachment.abort']['result']>} */
|
|
57
|
+
abort: (uploadId) => client.request("attachment.abort", { uploadId }),
|
|
58
|
+
/** Metadata only (no bytes, no path). @param {string} attachmentId @returns {Promise<Map['attachment.get']['result']>} */
|
|
59
|
+
get: (attachmentId) => client.request("attachment.get", { attachmentId }),
|
|
60
|
+
/** Refused (1004 attachment_referenced) while a prompt references it. @param {string} attachmentId @returns {Promise<Map['attachment.delete']['result']>} */
|
|
61
|
+
delete: (attachmentId) => client.request("attachment.delete", { attachmentId }),
|
|
62
|
+
/**
|
|
63
|
+
* Uploads `data` and commits it: begin (or resume, with the same requestId), chunks of at most
|
|
64
|
+
* the smaller of `maxChunkBytes` and what begin answered, commit with its SHA-256. A chunk
|
|
65
|
+
* refused `offset_mismatch` continues from the daemon's `received`. Returns the attachment.
|
|
66
|
+
* @param {{ sessionId: string, name: string, mimeType: string, data: Uint8Array, requestId: string, sha256?: string, maxChunkBytes?: number, onProgress?: (received: number, size: number) => void }} options
|
|
67
|
+
*/
|
|
68
|
+
async upload({ sessionId, name, mimeType, data, requestId, sha256, maxChunkBytes, onProgress }) {
|
|
69
|
+
const digest = sha256 ?? await sha256Hex(data);
|
|
70
|
+
const begun = await api.begin({ sessionId, name, size: data.length, mimeType, sha256: digest, requestId });
|
|
71
|
+
if (begun.state === "committed" && begun.attachmentId) return (await api.get(begun.attachmentId)).attachment;
|
|
72
|
+
const size = Math.max(1, Math.min(begun.maxChunkBytes, maxChunkBytes ?? Infinity));
|
|
73
|
+
let offset = begun.received;
|
|
74
|
+
for (let realigned = 0; offset < data.length; ) {
|
|
75
|
+
const end = Math.min(data.length, offset + size);
|
|
76
|
+
try {
|
|
77
|
+
const { received } = await api.chunk({ uploadId: begun.uploadId, offset, data: base64Encode(data.subarray(offset, end)) });
|
|
78
|
+
offset = received;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const held = (
|
|
81
|
+
/** @type {any} */
|
|
82
|
+
error?.data
|
|
83
|
+
);
|
|
84
|
+
if (held?.reason !== "offset_mismatch" || !Number.isSafeInteger(held.received) || ++realigned > 3) throw error;
|
|
85
|
+
offset = held.received;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
onProgress?.(offset, data.length);
|
|
89
|
+
}
|
|
90
|
+
return (await api.commit({ uploadId: begun.uploadId, sha256: digest })).attachment;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
return api;
|
|
94
|
+
}
|
|
95
|
+
function conversation(client) {
|
|
96
|
+
return {
|
|
97
|
+
/**
|
|
98
|
+
* A new closed root whose conversation is the source root's through or before a turn (`boundary`),
|
|
99
|
+
* or a branch a rewind set aside (`branchId`); resume it with session.create { sessionId, resume: true }.
|
|
100
|
+
* Principal fields are set by a trusted gateway on its own connection, never by a remote client.
|
|
101
|
+
* @param {Map['session.fork']['params']} params @returns {Promise<Map['session.fork']['result']>}
|
|
102
|
+
*/
|
|
103
|
+
fork: (params) => client.request("session.fork", params),
|
|
104
|
+
/**
|
|
105
|
+
* Continues the root's conversation from before `beforeTurnId` under the same session id; the
|
|
106
|
+
* later turns stay a branch (`branch.branchId`) that `fork({ branchId })` recovers.
|
|
107
|
+
* @param {Map['session.rewind']['params']} params @returns {Promise<Map['session.rewind']['result']>}
|
|
108
|
+
*/
|
|
109
|
+
rewind: (params) => client.request("session.rewind", params),
|
|
110
|
+
/**
|
|
111
|
+
* Restarts a live root's native process on its own conversation and configuration at the idle
|
|
112
|
+
* barrier (capability `session.reload`, §12.8); id, history and receipts stay. Sent once: repeat
|
|
113
|
+
* the same `requestId` after a lost answer.
|
|
114
|
+
* @param {Map['session.reload']['params']} params @returns {Promise<Map['session.reload']['result']>}
|
|
115
|
+
*/
|
|
116
|
+
reload: (params) => client.request("session.reload", params),
|
|
117
|
+
/**
|
|
118
|
+
* The ALP CLI command that follows the session in a terminal (capability `session.resumeCommand`,
|
|
119
|
+
* §12.8), built by alpd: for the clipboard only, never to run.
|
|
120
|
+
* @param {Map['session.resumeCommand']['params']} params @returns {Promise<Map['session.resumeCommand']['result']>}
|
|
121
|
+
*/
|
|
122
|
+
resumeCommand: (params) => client.request("session.resumeCommand", params),
|
|
123
|
+
/**
|
|
124
|
+
* Changes a live root's model, effort or team (capability `session.configure.live`, §12.9) at the
|
|
125
|
+
* idle barrier, through a restart of its native process on the same conversation; `configured` says
|
|
126
|
+
* what it runs with now. Sent once: repeat the same `requestId` after a lost answer. A change to
|
|
127
|
+
* another runtime is refused (1005 runtime_mismatch, `data.action: 'session.create'`).
|
|
128
|
+
* @param {Map['session.configure']['params'] & { requestId: string; ifGeneration: string }} params @returns {Promise<Map['session.configure']['result']>}
|
|
129
|
+
*/
|
|
130
|
+
configure: (params) => client.request("session.configure", params),
|
|
131
|
+
/**
|
|
132
|
+
* What a change of model, effort, team or mode would run a live root with, and whether it would be
|
|
133
|
+
* taken now (capability `session.configure.live`, §12.9). Reads only.
|
|
134
|
+
* @param {Map['session.configure.preview']['params']} params @returns {Promise<Map['session.configure.preview']['result']>}
|
|
135
|
+
*/
|
|
136
|
+
configurePreview: (params) => client.request("session.configure.preview", params)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export {
|
|
140
|
+
attachments,
|
|
141
|
+
base64Encode,
|
|
142
|
+
conversation,
|
|
143
|
+
sha256Hex,
|
|
144
|
+
workspaceRegistry
|
|
145
|
+
};
|
package/dist/supervise.d.ts
CHANGED
package/dist/supervise.js
CHANGED
|
@@ -40,12 +40,12 @@ async function startDaemon({ home, entry, env = process.env, timeoutMs = 15e3, a
|
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
42
|
function superviseDaemon({ home, entry, env = process.env, intervalMs = 5e3, misses = 2, log = () => {
|
|
43
|
-
} }) {
|
|
43
|
+
}, by }) {
|
|
44
44
|
let stopped = false;
|
|
45
45
|
let missed = 0;
|
|
46
46
|
let starting;
|
|
47
|
-
const start = (reason) => {
|
|
48
|
-
starting ??= startDaemon({ home, entry: typeof entry === "function" ? entry() : entry, env, automatic
|
|
47
|
+
const start = (reason, automatic = true) => {
|
|
48
|
+
starting ??= startDaemon({ home, entry: typeof entry === "function" ? entry() : entry, env, automatic }).then((socket) => {
|
|
49
49
|
if (socket) log(`alpd ${reason}`);
|
|
50
50
|
}, (error) => log(`could not start alpd: ${error?.message ?? error}`)).finally(() => {
|
|
51
51
|
starting = void 0;
|
|
@@ -65,7 +65,7 @@ function superviseDaemon({ home, entry, env = process.env, intervalMs = 5e3, mis
|
|
|
65
65
|
}
|
|
66
66
|
if (++missed >= misses && !stopped) await start("was down; started it again");
|
|
67
67
|
};
|
|
68
|
-
const initial = start(
|
|
68
|
+
const initial = start(by ? `started with ${by}` : "started", false);
|
|
69
69
|
const timer = setInterval(() => {
|
|
70
70
|
void check();
|
|
71
71
|
}, intervalMs);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anhlp/alp-client",
|
|
3
|
-
"version": "0.0.1-beta.
|
|
3
|
+
"version": "0.0.1-beta.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ALP client: portable JSON-RPC SDK, WebSocket and Node IPC transports, daemon discovery and setup",
|
|
6
6
|
"files": [
|
|
@@ -57,6 +57,14 @@
|
|
|
57
57
|
"types": "./dist/index.d.ts",
|
|
58
58
|
"default": "./src/index.js"
|
|
59
59
|
},
|
|
60
|
+
"./integration-lock": {
|
|
61
|
+
"alp-source": {
|
|
62
|
+
"types": "./src/integration-lock.js",
|
|
63
|
+
"default": "./src/integration-lock.js"
|
|
64
|
+
},
|
|
65
|
+
"types": "./dist/integration-lock.d.ts",
|
|
66
|
+
"default": "./src/integration-lock.js"
|
|
67
|
+
},
|
|
60
68
|
"./ipc": {
|
|
61
69
|
"alp-source": {
|
|
62
70
|
"types": "./src/ipc.js",
|
|
@@ -142,8 +150,8 @@
|
|
|
142
150
|
"node": ">=22.12"
|
|
143
151
|
},
|
|
144
152
|
"dependencies": {
|
|
145
|
-
"@anhlp/alp-core": "0.0.1-beta.
|
|
146
|
-
"@anhlp/alp-protocol": "0.0.1-beta.
|
|
153
|
+
"@anhlp/alp-core": "0.0.1-beta.4",
|
|
154
|
+
"@anhlp/alp-protocol": "0.0.1-beta.4"
|
|
147
155
|
},
|
|
148
156
|
"scripts": {
|
|
149
157
|
"build": "node ../../scripts/build-workspace.mjs client",
|
package/src/index.js
CHANGED
|
@@ -114,6 +114,17 @@ export async function findDaemonEntry({ home = alpHome(), env = process.env, can
|
|
|
114
114
|
return undefined;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Integration names are single lowercase path segments: they name `<home>/integrations/<name>.lock`,
|
|
119
|
+
* its guard and `<home>/<name>.json`. Returns the name, or throws a TypeError.
|
|
120
|
+
* @param {unknown} name
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
export function assertIntegrationName(name) {
|
|
124
|
+
if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new TypeError(`Invalid integration name: ${JSON.stringify(name)}`);
|
|
125
|
+
return name;
|
|
126
|
+
}
|
|
127
|
+
|
|
117
128
|
/** Wall-clock boot time; a lock from an earlier boot is stale even if its pid is reused. */
|
|
118
129
|
export function bootTime() {
|
|
119
130
|
return Math.round((Date.now() - os.uptime() * 1000) / 1000);
|
|
@@ -178,6 +189,8 @@ export async function releaseDaemonLock(home) {
|
|
|
178
189
|
* CLI, alpd's tests and the Paseo plugin use.
|
|
179
190
|
*/
|
|
180
191
|
export class AlpClient extends AlpRpcClient {
|
|
192
|
+
/** Host features captured by connect's handshake. @type {readonly string[]} */
|
|
193
|
+
capabilities = [];
|
|
181
194
|
/**
|
|
182
195
|
* @param {import('node:net').Socket} socket a connected or connecting socket; the client owns it
|
|
183
196
|
* @param {import('./rpc/client.js').AlpRpcClientOptions & import('./ipc.js').SocketTransportOptions} [options]
|
|
@@ -217,7 +230,8 @@ export async function connectSocket(socketPath, options) {
|
|
|
217
230
|
export async function connect(socketPath, client = { name: 'alp', version: '0' }, options = {}) {
|
|
218
231
|
const connection = new AlpClient(await openSocket(socketPath), options);
|
|
219
232
|
try {
|
|
220
|
-
await hello(connection, client);
|
|
233
|
+
const greeting = await hello(connection, client);
|
|
234
|
+
connection.capabilities = Object.freeze(Array.isArray(greeting.capabilities) ? greeting.capabilities.filter(value => typeof value === 'string') : []);
|
|
221
235
|
} catch (error) {
|
|
222
236
|
connection.close();
|
|
223
237
|
throw error;
|
|
@@ -391,7 +405,7 @@ async function integrationCandidateFromHome(home, name) {
|
|
|
391
405
|
}
|
|
392
406
|
|
|
393
407
|
function integrationBinaryName(name) {
|
|
394
|
-
return
|
|
408
|
+
return `alp-${name}-integration`;
|
|
395
409
|
}
|
|
396
410
|
|
|
397
411
|
/** Distribution roots that may carry a bundled integration descriptor. */
|
|
@@ -415,12 +429,26 @@ async function distributionRoots() {
|
|
|
415
429
|
return [...new Set(roots)];
|
|
416
430
|
}
|
|
417
431
|
|
|
432
|
+
/**
|
|
433
|
+
* The entry of integration `name`: an environment override, a descriptor under the home or a
|
|
434
|
+
* distribution root, or an `alp-<name>-integration` binary. Discovery never returns an entry for
|
|
435
|
+
* `web`: that name belongs to alp-web, which ships with the CLI and is started with its explicit
|
|
436
|
+
* entry. Everything once registered under `web` (the descriptor `integrations/web.json`, the
|
|
437
|
+
* `alp-web-integration` binary, `ALP_WEB_INTEGRATION`) is the Paseo integration from before its
|
|
438
|
+
* rename to `paseo`, which must never run under the web lock. `ALP_WEB_INTEGRATION` is read for
|
|
439
|
+
* `paseo` only, after `ALP_PASEO_INTEGRATION`, for one release (like the alp-web-integration alias).
|
|
440
|
+
* @param {string} [name]
|
|
441
|
+
* @param {{ home?: string }} [options]
|
|
442
|
+
*/
|
|
418
443
|
export async function discoverIntegration(name = 'web', options = {}) {
|
|
419
|
-
//
|
|
444
|
+
// The name becomes a descriptor path under <home>/integrations, so it must stay one safe segment.
|
|
445
|
+
assertIntegrationName(name);
|
|
446
|
+
if (name === 'web') return undefined;
|
|
447
|
+
// Explicit environment override always wins: the integration's own, then the generic one.
|
|
420
448
|
const envCandidates = [
|
|
421
|
-
process.env.
|
|
449
|
+
process.env[`ALP_${name.toUpperCase()}_INTEGRATION`],
|
|
450
|
+
name === 'paseo' ? process.env.ALP_WEB_INTEGRATION : undefined,
|
|
422
451
|
process.env.ALP_INTEGRATION_ENTRY,
|
|
423
|
-
name !== 'web' ? process.env[`ALP_${name.toUpperCase()}_INTEGRATION`] : undefined,
|
|
424
452
|
].filter(Boolean);
|
|
425
453
|
for (const candidate of envCandidates) if (await isFile(candidate)) return candidate;
|
|
426
454
|
|
|
@@ -454,20 +482,62 @@ function integrationAlive(lock) {
|
|
|
454
482
|
return true;
|
|
455
483
|
}
|
|
456
484
|
|
|
485
|
+
/**
|
|
486
|
+
* What startIntegration says about integration `name`, and where its service writes its log: alp-web
|
|
487
|
+
* (`web`) is part of the CLI, Paseo (`paseo`) an optional package, anything else named as is.
|
|
488
|
+
* @param {string} name
|
|
489
|
+
* @param {string} home
|
|
490
|
+
*/
|
|
491
|
+
function integrationText(name, home) {
|
|
492
|
+
const logs = path.join(home, 'logs', 'integrations');
|
|
493
|
+
if (name === 'web') return {
|
|
494
|
+
what: 'alp web',
|
|
495
|
+
log: path.join(logs, 'alp-web.log'),
|
|
496
|
+
missing: 'alp web runs the alp-web service of the ALP CLI, which is started with its entry (packages/cli/dist/alp-web.js); run `alp web`',
|
|
497
|
+
};
|
|
498
|
+
if (name === 'paseo') return {
|
|
499
|
+
what: 'The Paseo integration',
|
|
500
|
+
log: path.join(logs, 'paseo.log'),
|
|
501
|
+
missing: 'The Paseo integration is an optional integration package. Install it (for example `npm install -g @anhlp/alp-paseo@beta`), then run the command again.',
|
|
502
|
+
};
|
|
503
|
+
return {
|
|
504
|
+
what: `ALP integration ${name}`,
|
|
505
|
+
log: path.join(logs, `${name}.log`),
|
|
506
|
+
missing: `ALP integration ${name} is not installed: no ALP_${name.toUpperCase()}_INTEGRATION, descriptor <home>/integrations/${name}.json or alp-${name}-integration binary was found.`,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Starts integration `name` (default `web`) detached with `--service` (then `args`) unless its lock
|
|
512
|
+
* already names a live owner, then waits until its ready lock and `<home>/<name>.json` name the same
|
|
513
|
+
* pid. `entry` names the script to run with this Node and skips descriptor, environment and PATH
|
|
514
|
+
* discovery; `web` (alp-web) is never discovered, so it needs its entry. `"web": { "enabled": false }`
|
|
515
|
+
* in the home's settings.json refuses `web` only.
|
|
516
|
+
* @param {string | { name?: string, entry?: string, home?: string, args?: string[] }} [nameOrOptions]
|
|
517
|
+
*/
|
|
457
518
|
export async function startIntegration(nameOrOptions = 'web') {
|
|
458
519
|
const options = typeof nameOrOptions === 'string' ? { name: nameOrOptions } : (nameOrOptions ?? {});
|
|
459
|
-
const name = options.name ?? 'web';
|
|
520
|
+
const name = assertIntegrationName(options.name ?? 'web');
|
|
521
|
+
if (options.entry !== undefined && (typeof options.entry !== 'string' || !options.entry)) throw new TypeError('startIntegration entry must be a non-empty path');
|
|
522
|
+
const args = options.args ?? [];
|
|
523
|
+
if (!Array.isArray(args) || !args.every(arg => typeof arg === 'string')) throw new TypeError('startIntegration args must be a list of strings');
|
|
460
524
|
const fs = await import('node:fs/promises');
|
|
461
525
|
const pathMod = await import('node:path');
|
|
462
526
|
const home = options.home ? pathMod.resolve(options.home) : alpHome();
|
|
527
|
+
const text = integrationText(name, home);
|
|
528
|
+
// An explicit entry is the caller's own service script: it must exist even when a live owner is
|
|
529
|
+
// reused, and it replaces descriptor, environment and PATH discovery.
|
|
530
|
+
const explicit = options.entry !== undefined ? pathMod.resolve(options.entry) : undefined;
|
|
531
|
+
if (explicit !== undefined && !(await isFile(explicit))) throw new Error(`${text.what}: integration entry not found: ${explicit}`);
|
|
463
532
|
const lockPath = pathMod.join(home, 'integrations', `${name}.lock`);
|
|
464
533
|
const webPath = pathMod.join(home, `${name}.json`);
|
|
534
|
+
const started = explicit ? ` (entry ${explicit})` : '';
|
|
465
535
|
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
throw new Error('
|
|
536
|
+
// web.enabled turns off alp web only; other integrations have their own switches.
|
|
537
|
+
if (name === 'web') {
|
|
538
|
+
let settings = {};
|
|
539
|
+
try { settings = JSON.parse(await fs.readFile(pathMod.join(home, 'settings.json'), 'utf8')); } catch {}
|
|
540
|
+
if (settings?.web?.enabled === false) throw new Error('alp web is disabled by settings ("web.enabled": false).');
|
|
471
541
|
}
|
|
472
542
|
|
|
473
543
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -478,7 +548,7 @@ export async function startIntegration(nameOrOptions = 'web') {
|
|
|
478
548
|
try { return JSON.parse(await fs.readFile(webPath, 'utf8')); } catch { return undefined; }
|
|
479
549
|
};
|
|
480
550
|
|
|
481
|
-
// The authoritative check: the ready integration lock and live
|
|
551
|
+
// The authoritative check: the ready integration lock and live <name>.json must name the same pid.
|
|
482
552
|
// A loser of a concurrent start accepts the winning pid after its own contender has exited or
|
|
483
553
|
// the lock already names the winner.
|
|
484
554
|
async function waitForReady() {
|
|
@@ -492,9 +562,9 @@ export async function startIntegration(nameOrOptions = 'web') {
|
|
|
492
562
|
await sleep(100);
|
|
493
563
|
}
|
|
494
564
|
const lock = await readLock();
|
|
495
|
-
if (lock && !lock.ready) throw new Error(
|
|
496
|
-
if (!lock) throw new Error(
|
|
497
|
-
throw new Error(
|
|
565
|
+
if (lock && !lock.ready) throw new Error(`${text.what}${started} started but did not become ready; see ${text.log}`);
|
|
566
|
+
if (!lock) throw new Error(explicit ? `${text.what}${started} did not start; see ${text.log}` : text.missing);
|
|
567
|
+
throw new Error(`${text.what}${started} did not publish a ${name}.json matching its lock; see ${text.log}`);
|
|
498
568
|
}
|
|
499
569
|
|
|
500
570
|
const existing = await readLock();
|
|
@@ -502,16 +572,14 @@ export async function startIntegration(nameOrOptions = 'web') {
|
|
|
502
572
|
return waitForReady();
|
|
503
573
|
}
|
|
504
574
|
|
|
505
|
-
const entry = await discoverIntegration(name, { home });
|
|
506
|
-
if (!entry)
|
|
507
|
-
throw new Error('ALP web app is provided by an optional integration package. Install it (for example `npm install -g @anhlp/alp-paseo@beta`) and run `alp web` again.');
|
|
508
|
-
}
|
|
575
|
+
const entry = explicit ?? await discoverIntegration(name, { home });
|
|
576
|
+
if (!entry) throw new Error(text.missing);
|
|
509
577
|
|
|
510
578
|
const { spawn } = await import('node:child_process');
|
|
511
579
|
// The child is detached with its stdio ignored, so it cannot rely on the parent's console. `--service`
|
|
512
580
|
// is the generic contract: the entry writes its own log (the path its error messages advertise) and
|
|
513
581
|
// keeps the process alive as a service. `--register` still prints to stdout and is not passed here.
|
|
514
|
-
const child = spawn(process.execPath, [entry, '--service'], { stdio: 'ignore', detached: true, env: { ...process.env, ALP_HOME: home } });
|
|
582
|
+
const child = spawn(process.execPath, [entry, '--service', ...args], { stdio: 'ignore', detached: true, env: { ...process.env, ALP_HOME: home } });
|
|
515
583
|
let spawnError = null;
|
|
516
584
|
child.on('error', error => { spawnError = error; });
|
|
517
585
|
let exited = false;
|
|
@@ -522,7 +590,7 @@ export async function startIntegration(nameOrOptions = 'web') {
|
|
|
522
590
|
// Give the process a moment to fail immediately on missing entry or bad permissions.
|
|
523
591
|
await sleep(150);
|
|
524
592
|
if (spawnError) throw spawnError;
|
|
525
|
-
if (exited) throw new Error(
|
|
593
|
+
if (exited) throw new Error(`${text.what}${explicit ? started : ` (${entry})`} failed to start; see ${text.log}`);
|
|
526
594
|
|
|
527
595
|
return waitForReady();
|
|
528
596
|
}
|