@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.
@@ -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
+ };
@@ -9,4 +9,6 @@ export function superviseDaemon(options: {
9
9
  intervalMs?: number;
10
10
  misses?: number;
11
11
  log?: (message: string) => void;
12
+ /** Who keeps alpd up, for the log: "alpd started with <by>". */
13
+ by?: string;
12
14
  }): (() => Promise<void>) & { started: Promise<void> };
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: reason !== "started with Paseo" }).then((socket) => {
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("started with Paseo");
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",
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.3",
146
- "@anhlp/alp-protocol": "0.0.1-beta.3"
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 name === 'web' ? 'alp-web-integration' : `alp-${name}-integration`;
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
- // Explicit environment override always wins.
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.ALP_WEB_INTEGRATION,
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
- // Clear, actionable diagnostics for a disabled optional integration.
467
- let settings = {};
468
- try { settings = JSON.parse(await fs.readFile(pathMod.join(home, 'settings.json'), 'utf8')); } catch {}
469
- if (settings?.web?.enabled === false) {
470
- throw new Error('ALP web app is disabled by settings ("web.enabled": false).');
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 web.json must name the same pid.
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('ALP web integration started but did not become ready; see ~/.alp/logs/integrations/paseo.log');
496
- if (!lock) 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.');
497
- throw new Error('ALP web integration did not publish a matching web.json lock');
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('ALP web integration failed to start; see ~/.alp/logs/integrations/paseo.log');
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
  }