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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +28 -2
  4. package/dist/acp-probe.d.ts +44 -0
  5. package/dist/acp-probe.js +37 -0
  6. package/dist/antigravity-probe.d.ts +34 -0
  7. package/dist/antigravity-probe.js +95 -0
  8. package/dist/command.d.ts +5 -0
  9. package/dist/command.js +33 -0
  10. package/dist/doctor.d.ts +13 -0
  11. package/dist/doctor.js +587 -0
  12. package/dist/index.d.ts +151 -0
  13. package/dist/index.js +533 -0
  14. package/dist/integration-lock.d.ts +35 -0
  15. package/dist/integration-lock.js +179 -0
  16. package/dist/ipc.d.ts +42 -0
  17. package/dist/ipc.js +69 -0
  18. package/dist/library-test.d.ts +94 -0
  19. package/dist/library-test.js +37 -0
  20. package/dist/mcp-probe.d.ts +24 -0
  21. package/dist/mcp-probe.js +147 -0
  22. package/dist/portable.d.ts +19 -0
  23. package/dist/portable.js +33 -0
  24. package/dist/prompt.d.ts +32 -0
  25. package/dist/prompt.js +59 -0
  26. package/dist/render.d.ts +6 -0
  27. package/dist/render.js +120 -0
  28. package/dist/rpc/client.d.ts +82 -0
  29. package/dist/rpc/client.js +292 -0
  30. package/dist/rpc/errors.d.ts +42 -0
  31. package/dist/rpc/errors.js +34 -0
  32. package/dist/rpc/framing.d.ts +21 -0
  33. package/dist/rpc/framing.js +57 -0
  34. package/dist/rpc/handshake.d.ts +19 -0
  35. package/dist/rpc/handshake.js +8 -0
  36. package/dist/rpc/reconnect.d.ts +97 -0
  37. package/dist/rpc/reconnect.js +227 -0
  38. package/dist/rpc/registry.d.ts +211 -0
  39. package/dist/rpc/registry.js +145 -0
  40. package/dist/rpc/timers.d.ts +11 -0
  41. package/dist/rpc/timers.js +11 -0
  42. package/dist/service.d.ts +50 -0
  43. package/dist/service.js +143 -0
  44. package/dist/setup.d.ts +2 -0
  45. package/dist/setup.js +146 -0
  46. package/dist/supervise.d.ts +14 -0
  47. package/dist/supervise.js +87 -0
  48. package/dist/websocket.d.ts +51 -0
  49. package/dist/websocket.js +190 -0
  50. package/package.json +174 -4
  51. package/src/acp-probe.js +46 -0
  52. package/src/antigravity-probe.js +106 -0
  53. package/src/command.js +18 -0
  54. package/src/doctor.d.ts +13 -0
  55. package/src/doctor.js +570 -0
  56. package/src/index.js +596 -0
  57. package/src/integration-lock.js +179 -0
  58. package/src/ipc.js +92 -0
  59. package/src/library-test.js +41 -0
  60. package/src/mcp-probe.js +118 -0
  61. package/src/portable.js +28 -0
  62. package/src/prompt.js +64 -0
  63. package/src/render.js +136 -0
  64. package/src/rpc/client.js +296 -0
  65. package/src/rpc/errors.js +54 -0
  66. package/src/rpc/framing.js +61 -0
  67. package/src/rpc/handshake.js +16 -0
  68. package/src/rpc/reconnect.js +226 -0
  69. package/src/rpc/registry.js +161 -0
  70. package/src/rpc/timers.js +16 -0
  71. package/src/service.js +154 -0
  72. package/src/setup.js +150 -0
  73. package/src/supervise.d.ts +14 -0
  74. package/src/supervise.js +92 -0
  75. package/src/websocket.js +259 -0
@@ -0,0 +1,161 @@
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
+ return {
5
+ /** @param {string} workspaceId @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.options']['result']>} */
6
+ worktreeOptions: workspaceId => client.request('workspace.worktree.options', { workspaceId }),
7
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.create']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.create']['result']>} */
8
+ createWorktree: params => client.request('workspace.worktree.create', params),
9
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.remove']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.remove']['result']>} */
10
+ removeWorktree: params => client.request('workspace.worktree.remove', params),
11
+ /** 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']>} */
12
+ gitStatus: workspaceId => client.request('workspace.git.status', { workspaceId }),
13
+ /** 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']>} */
14
+ gitDiff: params => client.request('workspace.git.diff', params),
15
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['project.list']['params']} [params] @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['project.list']['result']>} */
16
+ projects: (params = {}) => client.request('project.list', params),
17
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.list']['params']} [params] @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.list']['result']>} */
18
+ workspaces: (params = {}) => client.request('workspace.list', params),
19
+ /** @param {string} workspaceId @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.get']['result']>} */
20
+ get: workspaceId => client.request('workspace.get', { workspaceId }),
21
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['project.register']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['project.register']['result']>} */
22
+ register: params => client.request('project.register', params),
23
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['project.remove']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['project.remove']['result']>} */
24
+ remove: params => client.request('project.remove', params),
25
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.update']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.update']['result']>} */
26
+ update: params => client.request('workspace.update', params),
27
+ };
28
+ }
29
+
30
+ const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
31
+
32
+ /** Canonical base64 of `bytes`, without Buffer or btoa, so it runs in browsers and Node alike. @param {Uint8Array} bytes */
33
+ export function base64Encode(bytes) {
34
+ let out = '';
35
+ let index = 0;
36
+ for (; index + 2 < bytes.length; index += 3) {
37
+ const n = (bytes[index] << 16) | (bytes[index + 1] << 8) | bytes[index + 2];
38
+ out += BASE64[n >> 18] + BASE64[(n >> 12) & 63] + BASE64[(n >> 6) & 63] + BASE64[n & 63];
39
+ }
40
+ if (index < bytes.length) {
41
+ const n = (bytes[index] << 16) | ((index + 1 < bytes.length ? bytes[index + 1] : 0) << 8);
42
+ out += BASE64[n >> 18] + BASE64[(n >> 12) & 63] + (index + 1 < bytes.length ? BASE64[(n >> 6) & 63] : '=') + '=';
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** Lowercase hex SHA-256 of `bytes` through WebCrypto (browsers' secure contexts, Node 19+). @param {Uint8Array} bytes */
48
+ export async function sha256Hex(bytes) {
49
+ const subtle = globalThis.crypto?.subtle;
50
+ if (!subtle) throw new Error('SHA-256 needs WebCrypto here; pass sha256 yourself');
51
+ const digest = new Uint8Array(await subtle.digest('SHA-256', bytes));
52
+ return Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
53
+ }
54
+
55
+ /**
56
+ * Typed attachment methods (capability `attachments`, docs/protocol/alp-json-rpc-v1.md §12.6) and a
57
+ * chunked upload that keeps to the limits the daemon (or gateway) advertised. Mutations are sent
58
+ * once: `upload` resumes an interrupted upload by repeating its begin with the same requestId and
59
+ * continues from what the daemon holds.
60
+ * @param {{request<T = any>(method: string, params?: unknown): Promise<T>}} client
61
+ */
62
+ export function attachments(client) {
63
+ /** @typedef {import('@anhlp/alp-protocol').RpcMethodMap} Map */
64
+ const api = {
65
+ /** @param {Map['attachment.begin']['params']} params @returns {Promise<Map['attachment.begin']['result']>} */
66
+ begin: params => client.request('attachment.begin', params),
67
+ /** @param {Map['attachment.chunk']['params']} params @returns {Promise<Map['attachment.chunk']['result']>} */
68
+ chunk: params => client.request('attachment.chunk', params),
69
+ /** @param {Map['attachment.commit']['params']} params @returns {Promise<Map['attachment.commit']['result']>} */
70
+ commit: params => client.request('attachment.commit', params),
71
+ /** @param {string} uploadId @returns {Promise<Map['attachment.abort']['result']>} */
72
+ abort: uploadId => client.request('attachment.abort', { uploadId }),
73
+ /** Metadata only (no bytes, no path). @param {string} attachmentId @returns {Promise<Map['attachment.get']['result']>} */
74
+ get: attachmentId => client.request('attachment.get', { attachmentId }),
75
+ /** Refused (1004 attachment_referenced) while a prompt references it. @param {string} attachmentId @returns {Promise<Map['attachment.delete']['result']>} */
76
+ delete: attachmentId => client.request('attachment.delete', { attachmentId }),
77
+ /**
78
+ * Uploads `data` and commits it: begin (or resume, with the same requestId), chunks of at most
79
+ * the smaller of `maxChunkBytes` and what begin answered, commit with its SHA-256. A chunk
80
+ * refused `offset_mismatch` continues from the daemon's `received`. Returns the attachment.
81
+ * @param {{ sessionId: string, name: string, mimeType: string, data: Uint8Array, requestId: string, sha256?: string, maxChunkBytes?: number, onProgress?: (received: number, size: number) => void }} options
82
+ */
83
+ async upload({ sessionId, name, mimeType, data, requestId, sha256, maxChunkBytes, onProgress }) {
84
+ const digest = sha256 ?? await sha256Hex(data);
85
+ const begun = await api.begin({ sessionId, name, size: data.length, mimeType, sha256: digest, requestId });
86
+ if (begun.state === 'committed' && begun.attachmentId) return (await api.get(begun.attachmentId)).attachment;
87
+ const size = Math.max(1, Math.min(begun.maxChunkBytes, maxChunkBytes ?? Infinity));
88
+ let offset = begun.received;
89
+ for (let realigned = 0; offset < data.length;) {
90
+ const end = Math.min(data.length, offset + size);
91
+ try {
92
+ const { received } = await api.chunk({ uploadId: begun.uploadId, offset, data: base64Encode(data.subarray(offset, end)) });
93
+ offset = received;
94
+ } catch (error) {
95
+ const held = /** @type {any} */ (error)?.data;
96
+ // Another attempt got further (or less far) than this one knew: continue where the daemon is.
97
+ if (held?.reason !== 'offset_mismatch' || !Number.isSafeInteger(held.received) || ++realigned > 3) throw error;
98
+ offset = held.received;
99
+ continue;
100
+ }
101
+ onProgress?.(offset, data.length);
102
+ }
103
+ return (await api.commit({ uploadId: begun.uploadId, sha256: digest })).attachment;
104
+ },
105
+ };
106
+ return api;
107
+ }
108
+
109
+ /**
110
+ * Typed conversation methods (capabilities `session.fork` and `session.rewind`, docs/protocol/
111
+ * 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
112
+ * once: keep `requestId` (and `ifGeneration`) to repeat the identical request after a timeout, which
113
+ * answers the first result with `duplicate: true`. After a rewind, attached clients get a
114
+ * `history.reset` event: drop cached history, fetch a new tail and attach with its generation.
115
+ * @param {{request<T = any>(method: string, params?: unknown): Promise<T>}} client
116
+ */
117
+ export function conversation(client) {
118
+ /** @typedef {import('@anhlp/alp-protocol').RpcMethodMap} Map */
119
+ return {
120
+ /**
121
+ * A new closed root whose conversation is the source root's through or before a turn (`boundary`),
122
+ * or a branch a rewind set aside (`branchId`); resume it with session.create { sessionId, resume: true }.
123
+ * Principal fields are set by a trusted gateway on its own connection, never by a remote client.
124
+ * @param {Map['session.fork']['params']} params @returns {Promise<Map['session.fork']['result']>}
125
+ */
126
+ fork: params => client.request('session.fork', params),
127
+ /**
128
+ * Continues the root's conversation from before `beforeTurnId` under the same session id; the
129
+ * later turns stay a branch (`branch.branchId`) that `fork({ branchId })` recovers.
130
+ * @param {Map['session.rewind']['params']} params @returns {Promise<Map['session.rewind']['result']>}
131
+ */
132
+ rewind: params => client.request('session.rewind', params),
133
+ /**
134
+ * Restarts a live root's native process on its own conversation and configuration at the idle
135
+ * barrier (capability `session.reload`, §12.8); id, history and receipts stay. Sent once: repeat
136
+ * the same `requestId` after a lost answer.
137
+ * @param {Map['session.reload']['params']} params @returns {Promise<Map['session.reload']['result']>}
138
+ */
139
+ reload: params => client.request('session.reload', params),
140
+ /**
141
+ * The ALP CLI command that follows the session in a terminal (capability `session.resumeCommand`,
142
+ * §12.8), built by alpd: for the clipboard only, never to run.
143
+ * @param {Map['session.resumeCommand']['params']} params @returns {Promise<Map['session.resumeCommand']['result']>}
144
+ */
145
+ resumeCommand: params => client.request('session.resumeCommand', params),
146
+ /**
147
+ * Changes a live root's model, effort or team (capability `session.configure.live`, §12.9) at the
148
+ * idle barrier, through a restart of its native process on the same conversation; `configured` says
149
+ * what it runs with now. Sent once: repeat the same `requestId` after a lost answer. A change to
150
+ * another runtime is refused (1005 runtime_mismatch, `data.action: 'session.create'`).
151
+ * @param {Map['session.configure']['params'] & { requestId: string; ifGeneration: string }} params @returns {Promise<Map['session.configure']['result']>}
152
+ */
153
+ configure: params => client.request('session.configure', params),
154
+ /**
155
+ * What a change of model, effort, team or mode would run a live root with, and whether it would be
156
+ * taken now (capability `session.configure.live`, §12.9). Reads only.
157
+ * @param {Map['session.configure.preview']['params']} params @returns {Promise<Map['session.configure.preview']['result']>}
158
+ */
159
+ configurePreview: params => client.request('session.configure.preview', params),
160
+ };
161
+ }
@@ -0,0 +1,16 @@
1
+ /** The longest delay setTimeout honours; a longer one fires at once, in browsers, Node and Hermes. */
2
+ export const MAX_TIMER_MS = 2_147_483_647;
3
+
4
+ /**
5
+ * A timeout option as a setTimeout delay. `undefined` means no timer: no timeout given, Infinity,
6
+ * or a wait longer than any timer can (about 24.8 days). Anything that is not a non-negative
7
+ * number is a TypeError, so a typo cannot turn into an instant timeout.
8
+ * @param {unknown} value
9
+ * @param {string} [name]
10
+ * @returns {number | undefined}
11
+ */
12
+ export function timerDelay(value, name = 'timeoutMs') {
13
+ if (value === undefined || value === Infinity) return undefined;
14
+ if (typeof value !== 'number' || Number.isNaN(value) || value < 0) throw new TypeError(`${name} must be a non-negative number of milliseconds`);
15
+ return value > MAX_TIMER_MS ? undefined : value;
16
+ }
package/src/service.js ADDED
@@ -0,0 +1,154 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { runCommand } from './command.js';
6
+
7
+ /**
8
+ * alpd as a user service (ALPD §37): a LaunchAgent on macOS, a systemd user unit on
9
+ * Linux. The service manager starts alpd at login and starts it again after a crash;
10
+ * a clean stop (`alp daemon stop`, exit 0) stays stopped until `alp daemon start`.
11
+ * Paths can be redirected for tests: ALP_SERVICE_DIR for the definition's directory,
12
+ * ALP_LAUNCHCTL and ALP_SYSTEMCTL for the managers.
13
+ */
14
+
15
+ /** Variables alpd reads, carried into the service when the installing shell sets them. */
16
+ const CARRIED = ['ALP_RUN_LOG_DIR', 'ALP_CODEX_BIN', 'ALP_CLAUDE_BIN', 'ALP_ANTIGRAVITY_BIN', 'ALP_GH_BIN'];
17
+
18
+ const escapeXml = value => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
19
+ const quoteUnit = value => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
20
+
21
+ /**
22
+ * Where and how alpd for `home` is defined as a service on `platform`, or undefined
23
+ * where ALP has no service support. Each ALP_HOME gets its own service.
24
+ */
25
+ export function serviceFor({ home, env = process.env, platform = process.platform, uid = process.getuid?.() ?? 0 }) {
26
+ const custom = path.resolve(home) !== path.join(os.homedir(), '.alp');
27
+ const suffix = custom ? createHash('sha256').update(path.resolve(home)).digest('hex').slice(0, 8) : '';
28
+ if (platform === 'darwin') {
29
+ const label = `com.alp.alpd${suffix ? `.${suffix}` : ''}`;
30
+ const directory = env.ALP_SERVICE_DIR || path.join(os.homedir(), 'Library', 'LaunchAgents');
31
+ const launchctl = env.ALP_LAUNCHCTL || 'launchctl';
32
+ const target = `gui/${uid}/${label}`;
33
+ const file = path.join(directory, `${label}.plist`);
34
+ return {
35
+ kind: 'launchd', name: label, file,
36
+ install: [[launchctl, ['bootout', target], { ignore: true }], [launchctl, ['bootstrap', `gui/${uid}`, file]]],
37
+ start: [[launchctl, ['kickstart', target]]],
38
+ uninstall: [[launchctl, ['bootout', target], { ignore: true }]],
39
+ };
40
+ }
41
+ if (platform === 'linux') {
42
+ const name = `alpd${suffix ? `-${suffix}` : ''}`;
43
+ const directory = env.ALP_SERVICE_DIR || path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
44
+ const systemctl = env.ALP_SYSTEMCTL || 'systemctl';
45
+ return {
46
+ kind: 'systemd', name, file: path.join(directory, `${name}.service`),
47
+ install: [[systemctl, ['--user', 'daemon-reload']], [systemctl, ['--user', 'enable', '--now', `${name}.service`]]],
48
+ start: [[systemctl, ['--user', 'start', `${name}.service`]]],
49
+ uninstall: [[systemctl, ['--user', 'disable', '--now', `${name}.service`], { ignore: true }]],
50
+ afterUninstall: [[systemctl, ['--user', 'daemon-reload'], { ignore: true }]],
51
+ };
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ /** The service definition that runs `entry` with `execPath` for `home`. */
57
+ export function serviceDefinition(service, { home, entry, execPath = process.execPath, env = process.env }) {
58
+ const variables = { ALP_HOME: path.resolve(home), PATH: env.PATH ?? '' };
59
+ for (const name of CARRIED) if (env[name]) variables[name] = env[name];
60
+ const program = [execPath, entry, '--service'];
61
+ const fatal = path.join(path.resolve(home), 'logs', 'alpd.service.log');
62
+ if (service.kind === 'launchd') {
63
+ return `<?xml version="1.0" encoding="UTF-8"?>
64
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
65
+ <plist version="1.0">
66
+ <dict>
67
+ <key>Label</key>
68
+ <string>${escapeXml(service.name)}</string>
69
+ <key>ProgramArguments</key>
70
+ <array>
71
+ ${program.map(arg => ` <string>${escapeXml(arg)}</string>`).join('\n')}
72
+ </array>
73
+ <key>EnvironmentVariables</key>
74
+ <dict>
75
+ ${Object.entries(variables).map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`).join('\n')}
76
+ </dict>
77
+ <key>RunAtLoad</key>
78
+ <true/>
79
+ <key>KeepAlive</key>
80
+ <dict>
81
+ <key>Crashed</key>
82
+ <true/>
83
+ <key>SuccessfulExit</key>
84
+ <false/>
85
+ </dict>
86
+ <key>ThrottleInterval</key>
87
+ <integer>10</integer>
88
+ <key>ProcessType</key>
89
+ <string>Background</string>
90
+ <key>StandardOutPath</key>
91
+ <string>${escapeXml(fatal)}</string>
92
+ <key>StandardErrorPath</key>
93
+ <string>${escapeXml(fatal)}</string>
94
+ </dict>
95
+ </plist>
96
+ `;
97
+ }
98
+ return `[Unit]
99
+ Description=ALP daemon (alpd) for ${path.resolve(home)}
100
+
101
+ [Service]
102
+ ExecStart=${program.map(quoteUnit).join(' ')}
103
+ ${Object.entries(variables).map(([key, value]) => `Environment=${quoteUnit(`${key}=${value}`)}`).join('\n')}
104
+ Restart=on-failure
105
+ RestartSec=10
106
+ StandardOutput=append:${fatal}
107
+ StandardError=append:${fatal}
108
+
109
+ [Install]
110
+ WantedBy=default.target
111
+ `;
112
+ }
113
+
114
+ /** The program an installed definition runs: [node, alpd.js], or undefined when it is not installed. */
115
+ export async function installedProgram(service) {
116
+ const text = await readFile(service.file, 'utf8').catch(() => undefined);
117
+ if (text === undefined) return undefined;
118
+ if (service.kind === 'launchd') {
119
+ const array = text.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/)?.[1] ?? '';
120
+ const unescape = value => value.replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
121
+ return [...array.matchAll(/<string>([\s\S]*?)<\/string>/g)].map(match => unescape(match[1])).slice(0, 2);
122
+ }
123
+ const line = text.match(/^ExecStart=(.*)$/m)?.[1] ?? '';
124
+ return [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map(match => match[1].replace(/\\(.)/g, '$1')).slice(0, 2);
125
+ }
126
+
127
+ async function runAll(steps, run) {
128
+ for (const [command, args, { ignore = false } = {}] of steps) {
129
+ const result = await run(command, args);
130
+ if (result.code !== 0 && !ignore) throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim() || `exit ${result.code}`}`);
131
+ }
132
+ }
133
+
134
+ /** Writes the definition and loads it, which starts alpd. */
135
+ export async function installService(service, options, run = runCommand) {
136
+ await mkdir(path.dirname(service.file), { recursive: true });
137
+ await mkdir(path.join(path.resolve(options.home), 'logs'), { recursive: true, mode: 0o700 });
138
+ await writeFile(service.file, serviceDefinition(service, options), { mode: 0o644 });
139
+ await runAll(service.install, run);
140
+ }
141
+
142
+ /** Starts an installed service's alpd. */
143
+ export async function startService(service, run = runCommand) {
144
+ await runAll(service.start, run);
145
+ }
146
+
147
+ /** Unloads the service, which stops its alpd, and removes the definition; false when none was installed. */
148
+ export async function uninstallService(service, run = runCommand) {
149
+ if (await installedProgram(service) === undefined) return false;
150
+ await runAll(service.uninstall, run);
151
+ await unlink(service.file).catch(error => { if (error.code !== 'ENOENT') throw error; });
152
+ await runAll(service.afterUninstall ?? [], run);
153
+ return true;
154
+ }
package/src/setup.js ADDED
@@ -0,0 +1,150 @@
1
+ import { copyFile, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { initProject } from '@anhlp/alp-core/init';
5
+ import { MODELS } from '@anhlp/alp-core/models';
6
+ import { fitEffort, modelEfforts } from '@anhlp/alp-core/profiles';
7
+ import { resolveTeam } from '@anhlp/alp-core/teams';
8
+ import { validateSettings } from '@anhlp/alp-core/validation';
9
+ import { Cancelled, select, text } from './prompt.js';
10
+
11
+ const PROVIDERS = ['claude', 'codex'];
12
+ const PROVIDER_LABEL = { claude: 'Claude', codex: 'Codex' };
13
+ /** Each seat's model and effort per provider. Main runs on the preferred provider's only; peers may run on any model of an enabled provider. */
14
+ const DEFAULTS = {
15
+ main: { claude: ['claude-opus-5-5', 'high'], codex: ['gpt-6.1-sol', 'high'] },
16
+ worker: { claude: ['claude-opus-5-5', 'high'], codex: ['gpt-6.1-sol', 'high'] },
17
+ supervisor: { claude: ['claude-sonnet-5', 'medium'], codex: ['gpt-6-luna', 'medium'] },
18
+ oracle: { claude: ['claude-fable-5-1', 'high'], codex: ['gpt-6-astra', 'high'] },
19
+ };
20
+ const FLAGS = { main: 'model', worker: 'model', supervisor: 'supervisor', oracle: 'oracle' };
21
+ const SEAT_LABEL = { main: 'main', worker: 'lead, reviewer', supervisor: 'supervisor', oracle: 'oracle' };
22
+ const ANY_EFFORT = ['low', 'medium', 'high', 'xhigh', 'max'];
23
+
24
+ /** Write Phở and Cafe's agent profiles into a project's .alp/settings.json, without installing software or calling models. */
25
+ export async function setup(args, home) {
26
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: Object.fromEntries(
27
+ ['runtime', 'prefer', ...PROVIDERS.flatMap(provider => Object.values(FLAGS).map(flag => `${provider}-${flag}`))].map(name => [name, { type: 'string' }]),
28
+ ) });
29
+ if (positionals.length > 1) throw new Error('setup takes at most one directory');
30
+ const root = path.resolve(positionals[0] ?? process.cwd());
31
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
32
+ try {
33
+ const runtime = values.runtime ?? (interactive ? await select({ message: 'Runtime', initial: 'mix', choices: [
34
+ { label: 'Mix', value: 'mix', hint: 'Claude and Codex: main runs on one, the others can run on either' },
35
+ { label: 'Codex only', value: 'codex' },
36
+ { label: 'Claude only', value: 'claude' },
37
+ ] }) : undefined);
38
+ if (runtime === undefined) throw new Error('Pass --runtime codex, claude or mix when setup is not running in a terminal');
39
+ if (!['codex', 'claude', 'mix'].includes(runtime)) throw new Error('--runtime must be codex, claude or mix');
40
+ const prefer = values.prefer ?? (runtime !== 'mix' ? runtime : interactive ? await select({ message: 'Main runs on', initial: 'claude', choices: [
41
+ { label: 'Claude', value: 'claude', hint: 'Claude first for the others too' },
42
+ { label: 'Codex', value: 'codex', hint: 'Codex first for the others too' },
43
+ ] }) : 'claude');
44
+ if (!PROVIDERS.includes(prefer) || (runtime !== 'mix' && prefer !== runtime)) throw new Error('--prefer must be an enabled runtime');
45
+ // The preferred provider comes first: it is each agent's default, the other one a choice.
46
+ const enabled = runtime === 'mix' ? [prefer, ...PROVIDERS.filter(provider => provider !== prefer)] : [runtime];
47
+ for (const provider of PROVIDERS) {
48
+ for (const flag of Object.values(FLAGS)) {
49
+ if (!enabled.includes(provider) && values[`${provider}-${flag}`]) throw new Error(`--${provider}-${flag} is unavailable in ${runtime}-only mode`);
50
+ }
51
+ }
52
+ /** @type {Record<string, Array<{ provider: string, model: string, effort?: string }>>} */
53
+ const seats = {};
54
+ for (const [seat, flag] of Object.entries(FLAGS)) {
55
+ seats[seat] = (seat === 'main' ? [prefer] : enabled).map(provider => {
56
+ const option = `${provider}-${flag}`;
57
+ const [fallback, effort] = DEFAULTS[seat][provider];
58
+ const model = nativeModel(values[option] ?? fallback, provider, `--${option}`);
59
+ return { provider, model, effort: fitEffort(`${provider}:${model}`, effort) };
60
+ });
61
+ }
62
+ const peers = enabled.map(provider => `${provider}:*`);
63
+
64
+ const file = path.join(root, '.alp/settings.json');
65
+ const settings = await readFile(file, 'utf8').then(content => {
66
+ try { return JSON.parse(content); } catch (error) { throw new Error(`${file} is not valid JSON: ${error.message}`); }
67
+ }, error => {
68
+ if (error.code === 'ENOENT') return undefined;
69
+ throw new Error(`${file}: ${error.message}`);
70
+ });
71
+ const show = () => {
72
+ for (const [seat, choices] of Object.entries(seats)) console.log(` ${SEAT_LABEL[seat]}: ${choices.map(describe).join(', ')}`);
73
+ console.log(` peer: ${peers.join(', ')} (any model: whoever assigns a peer picks its model and effort for the task)`);
74
+ };
75
+ console.log(`Profiles for Phở and Cafe in ${file}:`);
76
+ show();
77
+ const replaced = ['pho', 'cafe'].filter(id => (settings?.team ?? settings?.profile)?.[id]);
78
+ if (replaced.length) console.log(`These replace the ${replaced.join(' and ')} profiles already in the file; a backup is kept in ${file}.bak.`);
79
+ if (interactive) {
80
+ for (;;) {
81
+ const next = await select({ message: 'Save these profiles?', initial: 'save', choices: [
82
+ { label: 'Save', value: 'save' },
83
+ { label: 'Change a model', value: 'change' },
84
+ { label: 'Cancel', value: 'cancel' },
85
+ ] });
86
+ if (next === 'cancel') throw new Cancelled();
87
+ if (next === 'save') break;
88
+ await change(seats);
89
+ show();
90
+ }
91
+ }
92
+ const profile = await profiles(root, home, seats, peers, settings);
93
+ validateSettings({ ...(settings ?? {}), team: { ...(settings?.profile ?? {}), ...(settings?.team ?? {}), ...profile } }, file);
94
+ if (!settings) await initProject(root, { library: home });
95
+ else if (replaced.length) await copyFile(file, `${file}.bak`);
96
+ const written = settings ?? JSON.parse(await readFile(file, 'utf8'));
97
+ const next = { ...written, team: { ...(written.profile ?? {}), ...(written.team ?? {}), ...profile } };
98
+ delete next.profile;
99
+ await writeFile(file, JSON.stringify(next, null, 2) + '\n');
100
+ console.log('Setup saved. New sessions in this project use these profiles; edit the file to change one agent. Run alp doctor to check that each CLI is installed and logged in.');
101
+ } catch (error) {
102
+ if (!(error instanceof Cancelled)) throw error;
103
+ console.log(error.message);
104
+ }
105
+ }
106
+
107
+ const describe = choice => `${choice.provider}:${choice.model}${choice.effort ? `:${choice.effort}` : ''}`;
108
+
109
+ /** Pick one seat and provider, then its model and effort. */
110
+ async function change(seats) {
111
+ const [seat, at] = await select({ message: 'Which model?', choices: Object.entries(seats).flatMap(([seat, choices]) =>
112
+ choices.map((choice, at) => ({ label: `${SEAT_LABEL[seat]} · ${PROVIDER_LABEL[choice.provider]}`, value: [seat, at], hint: describe(choice) }))) });
113
+ const current = seats[seat][at];
114
+ const offered = MODELS.filter(model => model.runtime === current.provider);
115
+ const picked = await select({ message: `${SEAT_LABEL[seat]} on ${PROVIDER_LABEL[current.provider]}`, initial: current.model, choices: [
116
+ ...offered.map(model => ({ label: model.label, value: model.id, hint: `${model.id} · ${model.description}` })),
117
+ { label: 'Other…', value: undefined, hint: 'type a model ID' },
118
+ ] });
119
+ const model = picked ?? nativeModel(await text(`${PROVIDER_LABEL[current.provider]} model ID:`), current.provider, 'The model ID');
120
+ const efforts = modelEfforts(`${current.provider}:${model}`) ?? ANY_EFFORT;
121
+ const effort = efforts.length > 1 ? await select({ message: 'Effort', initial: efforts.includes(current.effort) ? current.effort : DEFAULTS[seat][current.provider][1],
122
+ choices: efforts.map(effort => ({ label: effort, value: effort })) }) : efforts[0];
123
+ seats[seat][at] = { provider: current.provider, model, ...(effort ? { effort } : {}) };
124
+ }
125
+
126
+ /** Every Phở and Cafe agent's profiles, keeping each agent's own compaction setting. */
127
+ async function profiles(root, home, seats, peers, settings) {
128
+ const profile = {};
129
+ for (const id of ['pho', 'cafe']) {
130
+ const team = await resolveTeam(root, id, { library: home });
131
+ const agents = {};
132
+ const seat = (name, choices) => {
133
+ agents[name] = { profiles: choices.map(choice => typeof choice === 'string' ? choice : describe(choice)), autocompact: (settings?.team ?? settings?.profile)?.[id]?.[name]?.autocompact ?? 'auto' };
134
+ };
135
+ for (const [name, member] of Object.entries(team.members)) {
136
+ if (name !== 'oracle') seat(name, name === team.main ? seats.main : member.role === 'peer' ? peers : seats.worker);
137
+ }
138
+ if (team.supervisor) seat(team.supervisor.agent, seats.supervisor);
139
+ if (team.members.oracle) seat('oracle', seats.oracle);
140
+ profile[id] = agents;
141
+ }
142
+ return profile;
143
+ }
144
+
145
+ /** A model for one provider: a native ID, or one prefixed with that provider. */
146
+ function nativeModel(id, provider, where) {
147
+ const model = id.startsWith(`${provider}:`) ? id.slice(provider.length + 1) : id;
148
+ if (!model || /\s/.test(model) || /^(codex|claude):/.test(model) || (model.includes(':') && !id.startsWith(`${provider}:`))) throw new Error(`${where} expects a ${provider} model, got '${id}'`);
149
+ return model;
150
+ }
@@ -0,0 +1,14 @@
1
+ export function holdDaemon(home: string, by?: string): Promise<void>;
2
+ export function releaseHold(home: string): Promise<void>;
3
+ export function daemonHeld(home: string): Promise<boolean>;
4
+ export function startDaemon(options: { home: string; entry?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }): Promise<string>;
5
+ export function superviseDaemon(options: {
6
+ home: string;
7
+ entry?: string | (() => string | undefined);
8
+ env?: NodeJS.ProcessEnv;
9
+ intervalMs?: number;
10
+ misses?: number;
11
+ log?: (message: string) => void;
12
+ /** Who keeps alpd up, for the log: "alpd started with <by>". */
13
+ by?: string;
14
+ }): (() => Promise<void>) & { started: Promise<void> };
@@ -0,0 +1,92 @@
1
+ import { mkdir, unlink, writeFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ensureDaemon, lockAlive, readLock, withDaemonLifecycle } from './index.js';
4
+ import { installedProgram, serviceFor, startService } from './service.js';
5
+
6
+ /**
7
+ * Keeping alpd up (ALPD §40). Whoever starts alpd goes through its login service when
8
+ * one is installed, so the service keeps managing it; otherwise alpd starts detached.
9
+ * A deliberate `alp daemon stop` leaves a hold, which watchers respect: they start
10
+ * alpd again only when someone asks for it.
11
+ */
12
+
13
+ const holdFile = home => path.join(home, 'state', 'alpd.held');
14
+
15
+ /** Records that the user stopped alpd on purpose. */
16
+ export async function holdDaemon(home, by = 'alp daemon stop') {
17
+ await mkdir(path.dirname(holdFile(home)), { recursive: true, mode: 0o700 });
18
+ await writeFile(holdFile(home), JSON.stringify({ by, at: new Date().toISOString() }) + '\n', { mode: 0o600 });
19
+ }
20
+
21
+ export async function releaseHold(home) {
22
+ await unlink(holdFile(home)).catch(() => {});
23
+ }
24
+
25
+ export async function daemonHeld(home) {
26
+ return stat(holdFile(home)).then(() => true, () => false);
27
+ }
28
+
29
+ /** Polls until an alpd for `home` is ready; returns its socket. */
30
+ async function whenReady(home, timeoutMs) {
31
+ const deadline = Date.now() + timeoutMs;
32
+ while (Date.now() < deadline) {
33
+ const lock = await readLock(home);
34
+ if (lockAlive(lock) && lock.ready) return lock.socket;
35
+ await new Promise(resolve => setTimeout(resolve, 100));
36
+ }
37
+ throw new Error(`alpd did not become ready within ${timeoutMs / 1000} s; see ${path.join(home, 'logs')}`);
38
+ }
39
+
40
+ /**
41
+ * Starts alpd for `home` unless it runs, and lifts a hold: someone asked for it.
42
+ * Returns its socket.
43
+ */
44
+ export async function startDaemon({ home, entry, env = process.env, timeoutMs = 15_000, automatic = false }) {
45
+ return withDaemonLifecycle(home, 'start', async () => {
46
+ if (automatic) {
47
+ if (await daemonHeld(home)) return;
48
+ } else await releaseHold(home);
49
+ const lock = await readLock(home);
50
+ if (lockAlive(lock)) return lock.ready ? lock.socket : whenReady(home, timeoutMs);
51
+ const service = serviceFor({ home, env });
52
+ if (service && await installedProgram(service)) {
53
+ await startService(service);
54
+ return whenReady(home, timeoutMs);
55
+ }
56
+ return ensureDaemon({ home, entry, env, timeoutMs, lifecycleLocked: true });
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Starts alpd now and starts it again whenever it is found down on `misses` checks in a
62
+ * row, `intervalMs` apart, unless the user holds it. Two misses let `alp daemon restart`
63
+ * and `install` finish their own stop and start first. Returns a function that stops
64
+ * watching, and resolves once a start it began has settled; alpd keeps running. `by` names the
65
+ * caller in the log line of the first start ("alpd started with <by>").
66
+ */
67
+ export function superviseDaemon({ home, entry, env = process.env, intervalMs = 5_000, misses = 2, log = () => {}, by }) {
68
+ let stopped = false;
69
+ let missed = 0;
70
+ let starting;
71
+ // The first start is the caller's own request (it lifts a hold); later ones are automatic.
72
+ const start = (reason, automatic = true) => {
73
+ starting ??= startDaemon({ home, entry: typeof entry === 'function' ? entry() : entry, env, automatic })
74
+ .then(socket => { if (socket) log(`alpd ${reason}`); }, error => log(`could not start alpd: ${error?.message ?? error}`))
75
+ .finally(() => { starting = undefined; missed = 0; });
76
+ return starting;
77
+ };
78
+ const check = async () => {
79
+ if (stopped || starting) return;
80
+ if (lockAlive(await readLock(home))) { missed = 0; return; }
81
+ if (await daemonHeld(home)) { missed = 0; return; }
82
+ // Stopped while this check looked: start nothing.
83
+ if (++missed >= misses && !stopped) await start('was down; started it again');
84
+ };
85
+ // `by` names the supervising service in the log, for example `Paseo` or `alp web`.
86
+ const initial = start(by ? `started with ${by}` : 'started', false);
87
+ const timer = setInterval(() => { void check(); }, intervalMs);
88
+ timer.unref?.();
89
+ const stop = () => { stopped = true; clearInterval(timer); return starting ?? Promise.resolve(); };
90
+ stop.started = initial;
91
+ return stop;
92
+ }