@anhlp/alp-client 0.0.0-beta.1

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 (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +24 -0
  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 +101 -0
  13. package/dist/index.js +422 -0
  14. package/dist/ipc.d.ts +42 -0
  15. package/dist/ipc.js +69 -0
  16. package/dist/library-test.d.ts +94 -0
  17. package/dist/library-test.js +37 -0
  18. package/dist/mcp-probe.d.ts +24 -0
  19. package/dist/mcp-probe.js +147 -0
  20. package/dist/portable.d.ts +18 -0
  21. package/dist/portable.js +27 -0
  22. package/dist/prompt.d.ts +32 -0
  23. package/dist/prompt.js +59 -0
  24. package/dist/render.d.ts +6 -0
  25. package/dist/render.js +120 -0
  26. package/dist/rpc/client.d.ts +82 -0
  27. package/dist/rpc/client.js +292 -0
  28. package/dist/rpc/errors.d.ts +42 -0
  29. package/dist/rpc/errors.js +34 -0
  30. package/dist/rpc/framing.d.ts +21 -0
  31. package/dist/rpc/framing.js +57 -0
  32. package/dist/rpc/handshake.d.ts +19 -0
  33. package/dist/rpc/handshake.js +8 -0
  34. package/dist/rpc/reconnect.d.ts +97 -0
  35. package/dist/rpc/reconnect.js +227 -0
  36. package/dist/rpc/timers.d.ts +11 -0
  37. package/dist/rpc/timers.js +11 -0
  38. package/dist/service.d.ts +50 -0
  39. package/dist/service.js +143 -0
  40. package/dist/setup.d.ts +2 -0
  41. package/dist/setup.js +146 -0
  42. package/dist/supervise.d.ts +12 -0
  43. package/dist/supervise.js +81 -0
  44. package/dist/websocket.d.ts +51 -0
  45. package/dist/websocket.js +190 -0
  46. package/package.json +168 -0
  47. package/src/acp-probe.js +46 -0
  48. package/src/antigravity-probe.js +106 -0
  49. package/src/command.js +18 -0
  50. package/src/doctor.d.ts +13 -0
  51. package/src/doctor.js +570 -0
  52. package/src/index.js +457 -0
  53. package/src/ipc.js +92 -0
  54. package/src/library-test.js +41 -0
  55. package/src/mcp-probe.js +118 -0
  56. package/src/portable.js +27 -0
  57. package/src/prompt.js +64 -0
  58. package/src/render.js +136 -0
  59. package/src/rpc/client.js +296 -0
  60. package/src/rpc/errors.js +54 -0
  61. package/src/rpc/framing.js +61 -0
  62. package/src/rpc/handshake.js +16 -0
  63. package/src/rpc/reconnect.js +226 -0
  64. package/src/rpc/timers.js +16 -0
  65. package/src/service.js +154 -0
  66. package/src/setup.js +150 -0
  67. package/src/supervise.d.ts +12 -0
  68. package/src/supervise.js +85 -0
  69. package/src/websocket.js +259 -0
package/src/doctor.js ADDED
@@ -0,0 +1,570 @@
1
+ import { access, chmod, constants, lstat, readdir, readFile, unlink, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { runCommand } from './command.js';
4
+ import { assertPrivateSocket, connect, daemonPaths, lockAlive, readLock } from './index.js';
5
+ import { installedProgram, serviceFor } from './service.js';
6
+ import { jsonObject, agentSources, resolveAgent } from '@anhlp/alp-core/resolver';
7
+ import { loadHooks } from '@anhlp/alp-core/hooks';
8
+ import { isTrusted } from '@anhlp/alp-core/trust';
9
+ import { listProviders } from '@anhlp/alp-core/providers';
10
+ import { settingsWarnings, validateSettings, validateUserSettings } from '@anhlp/alp-core/validation';
11
+ import { readLogTail, scrubText } from '@anhlp/alp-core/log';
12
+ import { STATE_SCHEMA, migrationFile, readStateSchema } from '@anhlp/alp-core/state-schema';
13
+
14
+ /**
15
+ * `alp doctor` (ALPD §36): checks what ALP needs from this machine and project, and
16
+ * what earlier runs may have left behind. Every check reports one of
17
+ * - ok: nothing to do;
18
+ * - info: worth knowing, nothing is wrong;
19
+ * - warn: something to fix, and ALP still works;
20
+ * - fail: ALP cannot work as configured.
21
+ * A check that `--fix` can repair carries a fix. Diagnosing only reads (alpd answers a
22
+ * status request and probes its own state directory); nothing is repaired unless the caller
23
+ * passes the checks to `repair`. Fixes only remove what nothing uses (stale locks, merged
24
+ * branches, entries for vanished projects, kept in a backup) or tighten modes; they never
25
+ * touch sessions, tasks, timelines, running work or unmerged branches. Every line a check
26
+ * reports is scrubbed of credentials, whatever it quotes.
27
+ */
28
+
29
+ const firstLine = text => text.trim().split('\n')[0] ?? '';
30
+
31
+ /** The executable `command` names, as a spawn without a shell would find it (on Windows that adds .exe and .com). */
32
+ async function executable(command, env) {
33
+ const names = process.platform === 'win32' ? [command, `${command}.exe`, `${command}.com`] : [command];
34
+ const candidates = path.isAbsolute(command) ? names : (env.PATH ?? '').split(path.delimiter).filter(Boolean).flatMap(directory => names.map(name => path.join(directory, name)));
35
+ for (const candidate of candidates) {
36
+ try { await access(candidate, constants.X_OK); if ((await lstat(candidate)).isDirectory()) continue; return candidate; } catch {}
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ /** How to tell whether each native runtime is logged in. */
42
+ const RUNTIMES = [
43
+ { kind: 'codex', variable: 'ALP_CODEX_BIN', login: ['login', 'status'], loggedIn: result => result.code === 0, hint: 'codex login' },
44
+ {
45
+ kind: 'claude', variable: 'ALP_CLAUDE_BIN', login: ['auth', 'status'], hint: 'claude auth login',
46
+ loggedIn: result => { try { return JSON.parse(result.stdout).loggedIn === true; } catch { return result.code === 0; } },
47
+ },
48
+ {
49
+ kind: 'antigravity', command: 'agy', variable: 'ALP_ANTIGRAVITY_BIN', login: ['models'], hint: 'agy and sign in',
50
+ loggedIn: result => result.code === 0,
51
+ },
52
+ ];
53
+
54
+ async function runtimeChecks({ env, run }) {
55
+ const checks = [];
56
+ for (const runtime of RUNTIMES) {
57
+ const command = env[runtime.variable] || runtime.command || runtime.kind;
58
+ const found = await executable(command, env);
59
+ if (!found) {
60
+ checks.push({ id: runtime.kind, status: 'warn', summary: `${command} not found on PATH; ${runtime.kind} agents cannot run (set ${runtime.variable} to its path)` });
61
+ continue;
62
+ }
63
+ const version = await run(found, ['--version'], { env });
64
+ if (version.code !== 0) {
65
+ checks.push({ id: runtime.kind, status: 'fail', summary: `${found} --version failed: ${firstLine(version.stderr || version.stdout) || `exit ${version.code}`}` });
66
+ continue;
67
+ }
68
+ const login = await run(found, runtime.login, { env });
69
+ checks.push(runtime.loggedIn(login)
70
+ ? { id: runtime.kind, status: 'ok', summary: `${firstLine(version.stdout)} at ${found}, logged in` }
71
+ : { id: runtime.kind, status: 'fail', summary: `${firstLine(version.stdout)} at ${found} is not logged in; run ${runtime.hint}` });
72
+ }
73
+ // One runtime is enough; with neither, nothing can run.
74
+ if (checks.every(check => check.status !== 'ok')) for (const check of checks) if (check.status === 'warn') check.status = 'fail';
75
+ return checks;
76
+ }
77
+
78
+ /** An installed alpd service whose node or alpd.js moved can no longer start alpd. */
79
+ async function serviceCheck(home, env) {
80
+ const service = serviceFor({ home, env });
81
+ const program = service && await installedProgram(service);
82
+ if (!program) return undefined;
83
+ const missing = [];
84
+ for (const file of program) if (!await lstat(file).then(() => true, () => false)) missing.push(file);
85
+ return missing.length
86
+ ? { id: 'service', status: 'fail', summary: `the ${service.kind} service ${service.name} runs files that are gone; run alp daemon install again`, details: missing }
87
+ : { id: 'service', status: 'ok', summary: `${service.kind} runs alpd as ${service.name}` };
88
+ }
89
+
90
+ const seconds = ms => `${Math.round(ms / 1000)} s`;
91
+
92
+ /** Codes of the failures a start can stop at, and of the ones that mean another alpd is running. */
93
+ const STARTUP_CODES = new Set(['ALP_HOME_UNUSABLE', 'ALP_LOCK_FAILED', 'ALP_STARTUP_FAILED', 'ALP_IPC_LISTEN_FAILED']);
94
+ const GROUPS = [
95
+ { id: 'persistence log', match: /^ALP_(PERSISTENCE|STATE|RECORD|TIMELINE)_/, hint: 'alpd could not read or write its state; check free space and the permissions of the state directory' },
96
+ { id: 'ipc log', match: /^ALP_(IPC|RPC)_/, hint: 'clients had trouble talking to alpd; alp daemon restart reopens the endpoint' },
97
+ { id: 'provider log', match: /^ALP_PROVIDER_/, hint: 'a runtime process failed or would not start; the runtime checks above say whether it is installed and logged in' },
98
+ ];
99
+ const STATE_FAILURES = { ENOENT: 'is missing', EACCES: 'is not accessible to you', EPERM: 'is not accessible to you', EROFS: 'is on a read-only filesystem', ENOSPC: 'has no space left' };
100
+
101
+ /** What a failed connection to alpd's socket means, as a code the log uses too. */
102
+ function ipcFailure(error) {
103
+ if (error?.code === 'ALP_IPC_TIMEOUT') return 'ALP_IPC_TIMEOUT';
104
+ if (error?.code === 'ENOENT') return 'ALP_IPC_SOCKET_MISSING';
105
+ if (error?.code === 'ECONNREFUSED') return 'ALP_IPC_REFUSED';
106
+ if (error?.code === 'EACCES' || error?.code === 'EPERM') return 'ALP_IPC_DENIED';
107
+ if (/private|owned by|ancestors|symlink/.test(error?.message ?? '')) return 'ALP_IPC_SOCKET_UNSAFE';
108
+ return 'ALP_IPC_FAILED';
109
+ }
110
+
111
+ /** Asks a running alpd for its status; `error` says why it could not be asked. */
112
+ async function askDaemon(lock, timeoutMs) {
113
+ let timer;
114
+ const attempt = connect(lock.socket, { name: 'alp-doctor', version: '1' }).then(async client => {
115
+ try { return await client.request('daemon.status'); } finally { client.close(); }
116
+ });
117
+ const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error(`no answer within ${seconds(timeoutMs)}`), { code: 'ALP_IPC_TIMEOUT' })), timeoutMs); });
118
+ try { return { status: await Promise.race([attempt, timeout]) }; }
119
+ catch (error) { attempt.catch(() => {}); return { error }; }
120
+ finally { clearTimeout(timer); }
121
+ }
122
+
123
+ /**
124
+ * alpd as a client sees it (ALPD §67): whether its IPC endpoint answers, and whether it is ready,
125
+ * which is more than alive: it accepts connections and its state is readable and writable now.
126
+ */
127
+ async function daemonHealthChecks(lock, timeoutMs) {
128
+ if (!lockAlive(lock) || lock.maintenance) return [];
129
+ const { status, error } = await askDaemon(lock, timeoutMs);
130
+ if (error) {
131
+ const code = ipcFailure(error);
132
+ // A socket that is missing right after start is still being opened.
133
+ const starting = !lock.ready && Date.now() - Date.parse(lock.startedAt) < 60_000;
134
+ return [{
135
+ id: 'ipc', status: starting ? 'info' : 'fail',
136
+ summary: `alpd (pid ${lock.pid}) is running but ${starting ? 'does not answer yet, it is still starting' : 'cannot be reached'} on ${lock.socket}: ${code}, ${error.message}`,
137
+ details: [`code ${code}`, ...(starting ? [] : ['alp daemon restart starts it again; its work is kept and continued'])],
138
+ }];
139
+ }
140
+ const checks = [{ id: 'ipc', status: 'ok', summary: `alpd answers on ${lock.socket} (protocol ${status.protocolVersion})` }];
141
+ const health = status.health;
142
+ if (!health) { checks.push({ id: 'readiness', status: 'info', summary: `alpd ${status.version} does not report readiness; alp daemon restart updates it` }); return checks; }
143
+ const failing = health.checks.filter(check => !check.ok);
144
+ checks.push(health.ready
145
+ ? { id: 'readiness', status: 'ok', summary: 'alpd is ready: it accepts connections and reads and writes its state' }
146
+ : {
147
+ id: 'readiness', status: 'fail',
148
+ summary: `alpd is alive but not ready: ${failing.map(check => check.code ?? check.id).join(', ')}`,
149
+ details: failing.map(check => `${check.code ?? check.id}: ${check.message ?? 'failed'}`),
150
+ hint: 'new prompts may fail until this clears; alp doctor shows the state directory below',
151
+ });
152
+ if (!lock.ready) checks.push({ id: 'startup', status: 'warn', summary: 'alpd answers but has not finished starting' });
153
+ return checks;
154
+ }
155
+
156
+ /** The state directory alpd keeps sessions in, without writing to it (alpd probes its own writes). */
157
+ async function stateCheck(home) {
158
+ const directory = path.join(home, 'state');
159
+ const info = await lstat(directory).catch(() => undefined);
160
+ const target = info ? directory : home;
161
+ if (!info && !await lstat(home).then(() => true, () => false)) return { id: 'state', status: 'ok', summary: `${directory} is created when alpd first starts` };
162
+ try { await access(target, constants.R_OK | constants.W_OK | constants.X_OK); }
163
+ catch (error) {
164
+ return {
165
+ id: 'state', status: 'fail', summary: `${target} ${STATE_FAILURES[error.code] ?? `cannot be used (${error.code ?? error.message})`}: alpd cannot ${error.code === 'ENOENT' ? 'find' : 'write'} its sessions; ALP_STATE_NOT_WRITABLE`,
166
+ details: [error.message],
167
+ };
168
+ }
169
+ if (!info) return { id: 'state', status: 'ok', summary: `${directory} is created when alpd first starts` };
170
+ const unreadable = [];
171
+ const leftovers = [];
172
+ let records = 0;
173
+ for (const name of await readdir(path.join(directory, 'sessions')).catch(() => []).then(names => names.slice(0, 2000))) {
174
+ if (name.endsWith('.tmp')) { leftovers.push(name); continue; }
175
+ if (!name.endsWith('.json')) continue;
176
+ records++;
177
+ try { const record = JSON.parse(await readFile(path.join(directory, 'sessions', name), 'utf8')); if (record?.version !== 1 || typeof record.id !== 'string') unreadable.push(name); }
178
+ catch { unreadable.push(name); }
179
+ }
180
+ if (unreadable.length) return {
181
+ id: 'state', status: 'warn', summary: `${unreadable.length} of ${records} session record${records > 1 ? 's' : ''} in ${directory} cannot be read; alpd skips ${unreadable.length > 1 ? 'them' : 'it'} and ${unreadable.length > 1 ? 'those sessions are' : 'that session is'} not listed (ALP_RECORD_UNREADABLE)`,
182
+ details: [...unreadable.slice(0, 10), 'doctor never deletes them: copy or repair the files by hand'],
183
+ };
184
+ return { id: 'state', status: 'ok', summary: `${directory} is readable and writable; ${records} session record${records === 1 ? '' : 's'}${leftovers.length ? `, ${leftovers.length} temporary file${leftovers.length > 1 ? 's' : ''} left by an interrupted write (alpd removes them)` : ''}` };
185
+ }
186
+
187
+ const brief = record => `${record.time} ${record.component} ${record.code ?? ''} ${record.msg}${record.sessionId ? ` [session ${record.sessionId}]` : ''}`.replace(/\s+/g, ' ').trim();
188
+
189
+ /** What alpd's own log says: where the last start stopped, and the failures logged since it was ready. */
190
+ async function logChecks(home, running) {
191
+ const file = daemonPaths(home).log;
192
+ let records;
193
+ try { records = (await readLogTail(file)).filter(record => !record.legacy); }
194
+ catch (error) { return [{ id: 'log', status: 'warn', summary: `${file} cannot be read: ${error.message}` }]; }
195
+ if (!records.length) return [];
196
+ const lastReady = records.findLastIndex(record => record.event === 'ready');
197
+ const since = records.slice(lastReady + 1);
198
+ const checks = [];
199
+ const failure = since.findLast(record => record.level === 'error' && STARTUP_CODES.has(record.code));
200
+ if (failure) checks.push({
201
+ id: 'startup', status: running ? 'info' : 'fail',
202
+ summary: `${running ? 'an attempt to start another alpd' : 'the last alpd'} failed at ${failure.time}: ${failure.code}, ${failure.msg}`,
203
+ details: [...(failure.error ? [`${failure.error.name ?? 'Error'}${failure.error.code ? ` ${failure.error.code}` : ''}: ${failure.error.message}`] : []), `log: ${file}`],
204
+ ...(running ? {} : { hint: 'fix the cause and run alp daemon start; its work is kept and continued' }),
205
+ });
206
+ for (const group of GROUPS) {
207
+ const found = since.filter(record => (record.level === 'error' || record.level === 'warn') && group.match.test(record.code ?? ''));
208
+ if (!found.length) continue;
209
+ const counts = new Map();
210
+ for (const record of found) counts.set(record.code, (counts.get(record.code) ?? 0) + 1);
211
+ checks.push({
212
+ id: group.id, status: 'warn',
213
+ summary: `${found.length} problem${found.length > 1 ? 's' : ''} logged since alpd was last ready: ${[...counts].map(([code, count]) => `${code} x${count}`).join(', ')}`,
214
+ details: [...found.slice(-5).map(brief), group.hint],
215
+ });
216
+ }
217
+ return checks;
218
+ }
219
+
220
+ /** Sessions that ended in failure, from their records: what a provider failure left, with the codes the log uses. */
221
+ async function failedSessionsCheck(home) {
222
+ const directory = path.join(home, 'state', 'sessions');
223
+ const failed = [];
224
+ for (const name of await readdir(directory).catch(() => []).then(names => names.slice(0, 2000))) {
225
+ if (!name.endsWith('.json')) continue;
226
+ try {
227
+ const record = JSON.parse(await readFile(path.join(directory, name), 'utf8'));
228
+ if (record?.status === 'failed' && record.lastError) failed.push(record);
229
+ } catch {}
230
+ }
231
+ if (!failed.length) return undefined;
232
+ failed.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
233
+ const recent = failed.filter(record => Date.now() - Date.parse(record.updatedAt) < 24 * 3600_000);
234
+ return {
235
+ id: 'failed sessions', status: recent.length ? 'warn' : 'info',
236
+ summary: `${failed.length} session${failed.length > 1 ? 's' : ''} ended in failure${recent.length ? `, ${recent.length} in the last day` : ''}; kept, nothing is deleted`,
237
+ details: failed.slice(0, 5).map(record => `${record.updatedAt} ${record.session?.agent ?? 'session'} ${record.id} (${record.session?.runtime ?? 'unknown runtime'}) ${record.lastError.code ?? ''} ${String(record.lastError.message).slice(0, 200)}`.replace(/\s+/g, ' ')),
238
+ };
239
+ }
240
+
241
+ async function daemonCheck(home) {
242
+ const paths = daemonPaths(home);
243
+ const lock = await readLock(home);
244
+ const exists = file => lstat(file).then(() => true, () => false);
245
+ if (lockAlive(lock) && lock.maintenance) return { id: 'alpd', status: 'info', summary: `not running while alp ${lock.maintenance} runs (pid ${lock.pid})` };
246
+ if (lockAlive(lock)) return { id: 'alpd', status: 'ok', summary: `running (pid ${lock.pid}, ${lock.version})${lock.ready ? '' : ', starting'}` };
247
+ const stale = [];
248
+ if (await exists(paths.lock)) stale.push(paths.lock);
249
+ if (await exists(paths.socket)) stale.push(paths.socket);
250
+ const crashed = await exists(path.join(home, 'state', 'alpd.running'));
251
+ const after = crashed ? '; the last alpd stopped unexpectedly, and the next start continues its work' : '';
252
+ if (!stale.length) return { id: 'alpd', status: crashed ? 'info' : 'ok', summary: `not running${after}` };
253
+ return {
254
+ id: 'alpd', status: 'warn', summary: `not running, and left ${stale.map(file => path.basename(file)).join(' and ')} behind${after}`,
255
+ fix: {
256
+ describe: `remove ${stale.join(', ')}`,
257
+ async apply() {
258
+ // Check again: an alpd may have started meanwhile.
259
+ if (lockAlive(await readLock(home))) return 'skipped: alpd started meanwhile';
260
+ for (const file of stale) await unlink(file).catch(() => {});
261
+ return `removed ${stale.length} stale file${stale.length > 1 ? 's' : ''}`;
262
+ },
263
+ },
264
+ };
265
+ }
266
+
267
+ /** Files and directories under ALP_HOME that hold logs, state or settings. */
268
+ async function privateEntries(home) {
269
+ const entries = [home];
270
+ for (const name of ['settings.json', 'alpd.lock', 'alpd.json', 'role-skills.json']) entries.push(path.join(home, name));
271
+ for (const directory of ['state', 'runs', 'logs', 'boards', 'worktrees', 'copies']) {
272
+ const full = path.join(home, directory);
273
+ entries.push(full);
274
+ if (directory === 'worktrees' || directory === 'copies') continue;
275
+ for (const name of await readdir(full).catch(() => [])) entries.push(path.join(full, name));
276
+ }
277
+ return entries;
278
+ }
279
+
280
+ async function permissionsCheck(home) {
281
+ // Windows files have no group/other mode bits to read or tighten; the profile's ACL keeps the home private.
282
+ if (process.platform === 'win32') return { id: 'permissions', status: 'ok', summary: `${home} follows your Windows profile access rules` };
283
+ const open = [];
284
+ for (const entry of await privateEntries(home)) {
285
+ const info = await lstat(entry).catch(() => undefined);
286
+ if (!info || info.isSymbolicLink() || !(info.mode & 0o077)) continue;
287
+ open.push({ entry, mode: info.mode & 0o777, directory: info.isDirectory() });
288
+ }
289
+ if (!open.length) return { id: 'permissions', status: 'ok', summary: `${home} is private to you` };
290
+ return {
291
+ id: 'permissions', status: 'warn',
292
+ summary: `${open.length} entr${open.length > 1 ? 'ies' : 'y'} under ${home} readable by others; they hold task briefs, results and settings`,
293
+ details: open.map(item => `${item.mode.toString(8).padStart(4, '0')} ${item.entry}`),
294
+ fix: {
295
+ describe: 'remove group and other access from them',
296
+ async apply() {
297
+ for (const item of open) await chmod(item.entry, item.mode & 0o700).catch(() => {});
298
+ return `tightened ${open.length} entr${open.length > 1 ? 'ies' : 'y'}`;
299
+ },
300
+ },
301
+ };
302
+ }
303
+
304
+ async function userSettingsCheck(home) {
305
+ const file = path.join(home, 'settings.json');
306
+ try {
307
+ const text = await readFile(file, 'utf8').catch(error => { if (error.code === 'ENOENT') return undefined; throw error; });
308
+ if (text === undefined) return { id: 'user settings', status: 'ok', summary: `none (${file})` };
309
+ const settings = JSON.parse(text);
310
+ validateUserSettings(settings, file);
311
+ const warnings = settingsWarnings(settings, 'user');
312
+ return warnings.length
313
+ ? { id: 'user settings', status: 'warn', summary: `${file}: ${warnings.length} key${warnings.length > 1 ? 's' : ''} ALP ignores`, details: warnings }
314
+ : { id: 'user settings', status: 'ok', summary: file };
315
+ } catch (error) {
316
+ return { id: 'user settings', status: 'fail', summary: `alpd ignores ${file}: ${error.message.replace(`${file}: `, '')}` };
317
+ }
318
+ }
319
+
320
+ async function projectChecks(project, home) {
321
+ const checks = [];
322
+ const file = path.join(project, '.alp', 'settings.json');
323
+ let settings;
324
+ try {
325
+ settings = await jsonObject(file, 'INVALID_SETTINGS');
326
+ validateSettings(settings, file);
327
+ const warnings = settingsWarnings(settings, 'project');
328
+ checks.push(warnings.length
329
+ ? { id: 'settings', status: 'warn', summary: `${file}: ${warnings.length} key${warnings.length > 1 ? 's' : ''} ALP ignores`, details: warnings }
330
+ : { id: 'settings', status: 'ok', summary: file });
331
+ } catch (error) {
332
+ checks.push({ id: 'settings', status: 'fail', summary: error.message });
333
+ return checks;
334
+ }
335
+ const sources = await agentSources(project, { library: home });
336
+ const agents = [...sources.keys()].sort((a, b) => a.localeCompare(b));
337
+ const broken = [];
338
+ const shadows = [];
339
+ const hooks = new Map();
340
+ // An override replaces the whole agent, so it misses later updates to the one it replaces.
341
+ for (const [agent, { source, overrides }] of sources) if (overrides) shadows.push(`${agent}: the ${source}'s copy replaces the ${overrides === 'builtin' ? 'built-in' : 'library'} agent`);
342
+ for (const agent of agents) {
343
+ try {
344
+ const resolved = await resolveAgent(project, { agent, library: home });
345
+ for (const hook of await loadHooks(resolved)) hooks.set(hook.path, { ...hook, agents: [...(hooks.get(hook.path)?.agents ?? []), agent] });
346
+ for (const skill of resolved.skills) if (!skill.path.startsWith(home + path.sep)) {
347
+ const library = path.join(home, 'skills', skill.name, 'SKILL.md');
348
+ if (await lstat(library).then(() => true, () => false)) shadows.push(`${agent}: ${skill.name} from the project replaces the library's`);
349
+ }
350
+ } catch (error) { broken.push(`${agent}: ${error.message}`); }
351
+ }
352
+ if (!agents.length) checks.push({ id: 'agents', status: 'fail', summary: 'no agents: neither ALP\'s built-ins nor the library nor the project define one' });
353
+ else if (broken.length) checks.push({ id: 'agents', status: 'fail', summary: `${broken.length} of ${agents.length} agents cannot start`, details: broken });
354
+ else checks.push({ id: 'agents', status: shadows.length ? 'info' : 'ok', summary: `${agents.length} agents resolve: ${agents.join(', ')}`, ...(shadows.length ? { details: shadows } : {}) });
355
+ if (hooks.size) checks.push(await hooksCheck(project, home, [...hooks.values()]));
356
+ if (settings.defaultAgent && !agents.includes(settings.defaultAgent)) checks.push({ id: 'agents', status: 'fail', summary: `defaultAgent ${settings.defaultAgent} is not an agent of this project` });
357
+ return checks;
358
+ }
359
+
360
+ /** The command a hook starts, when it is a plain program name or path: the first word of its shell command. */
361
+ const program = command => /^\s*([\w./-]+)/.exec(command)?.[1];
362
+ const SHELL_WORDS = new Set(['cd', 'test', '[', 'echo', 'exit', 'true', 'false', 'set', 'export', 'if', 'for', 'while', 'exec', 'env', 'command', 'printf', 'read', 'source', '.']);
363
+
364
+ /** Hooks the agents use: whether the project's may run, and whether their programs exist (ALPD §45). */
365
+ async function hooksCheck(project, home, hooks) {
366
+ const missing = [];
367
+ for (const hook of hooks) {
368
+ const name = program(hook.command);
369
+ if (!name || SHELL_WORDS.has(name)) continue;
370
+ const candidates = name.includes('/') ? [path.resolve(project, name)] : (process.env.PATH ?? '').split(path.delimiter).filter(Boolean).map(directory => path.join(directory, name));
371
+ let found = false;
372
+ for (const candidate of candidates) if (await access(candidate, constants.X_OK).then(() => true, () => false)) { found = true; break; }
373
+ if (!found) missing.push(`${hook.name} (${hook.event}, used by ${hook.agents.join(', ')}): ${name} is not found`);
374
+ }
375
+ const fromProject = hooks.filter(hook => hook.project);
376
+ const trusted = fromProject.length ? await isTrusted(home, project) : true;
377
+ const details = [...missing, ...(!trusted ? [`not trusted yet: ${fromProject.map(hook => hook.name).join(', ')}; alpd asks the first time one runs, or run alp trust`] : [])];
378
+ return {
379
+ id: 'hooks', status: missing.length ? 'warn' : trusted ? 'ok' : 'info',
380
+ summary: `${hooks.length} hook${hooks.length > 1 ? 's' : ''}${fromProject.length ? `, ${fromProject.length} from the project${trusted ? ', which the user trusts' : ', not trusted yet'}` : ''}${missing.length ? `; ${missing.length} cannot start` : ''}`,
381
+ ...(details.length ? { details } : {}),
382
+ };
383
+ }
384
+
385
+ /**
386
+ * ACP providers of the library (ALPD §46): whether their commands exist, and what ALP
387
+ * cannot do for an agent it reaches through ACP.
388
+ */
389
+ async function providersCheck(home, env) {
390
+ let providers;
391
+ try { providers = await listProviders(home); } catch (error) { return { id: 'providers', status: 'fail', summary: error.message }; }
392
+ if (!providers.length) return undefined;
393
+ const missing = [];
394
+ for (const provider of providers) if (!await executable(provider.command, { ...env, ...provider.env })) missing.push(`${provider.id}: ${provider.command} is not found`);
395
+ return {
396
+ id: 'providers', status: 'warn',
397
+ summary: `${providers.length} ACP provider${providers.length > 1 ? 's' : ''} (${providers.map(provider => provider.id).join(', ')})${missing.length ? `; ${missing.length} cannot start` : ''}`,
398
+ details: [...missing,
399
+ 'ALP has no sandbox around an ACP agent: it holds the session\'s mode only by answering the permission requests the agent sends, so a command the agent runs without asking runs unchecked',
400
+ 'ALP cannot steer an ACP agent mid-turn (mail waits for the turn to end), resumes its sessions only when it supports session/load, and cannot recall them or give them review copies'],
401
+ };
402
+ }
403
+
404
+ async function librarySkillsCheck(home) {
405
+ const file = path.join(home, 'role-skills.json');
406
+ let roles;
407
+ try {
408
+ const text = await readFile(file, 'utf8').catch(error => { if (error.code === 'ENOENT') return undefined; throw error; });
409
+ if (text === undefined) return undefined;
410
+ roles = JSON.parse(text);
411
+ } catch (error) { return { id: 'skills', status: 'fail', summary: `${file}: ${error.message}` }; }
412
+ if (!roles || typeof roles !== 'object' || Array.isArray(roles)) return { id: 'skills', status: 'fail', summary: `${file}: expected an object of role names to skill lists` };
413
+ const missing = [];
414
+ for (const [role, names] of Object.entries(roles)) {
415
+ if (!Array.isArray(names)) { missing.push(`${role}: not a list`); continue; }
416
+ for (const name of names) {
417
+ if (typeof name !== 'string' || !await lstat(path.join(home, 'skills', name, 'SKILL.md')).then(() => true, () => false)) missing.push(`${role}: ${name}`);
418
+ }
419
+ }
420
+ return missing.length
421
+ ? { id: 'skills', status: 'warn', summary: `${file} lists ${missing.length} skill${missing.length > 1 ? 's' : ''} the library lacks; agents start without them`, details: missing }
422
+ : { id: 'skills', status: 'ok', summary: `the library's skills exist (${file})` };
423
+ }
424
+
425
+ /** The live book alpd continues at start: entries whose project is gone can never continue. */
426
+ async function liveCheck(home, running) {
427
+ const file = path.join(home, 'state', 'live.json');
428
+ let entries;
429
+ try { entries = JSON.parse(await readFile(file, 'utf8')); } catch { return undefined; }
430
+ if (!Array.isArray(entries)) return { id: 'live work', status: 'warn', summary: `${file} is not a list; alpd ignores it` };
431
+ const gone = [];
432
+ for (const entry of entries) {
433
+ if (typeof entry?.project !== 'string' || !await lstat(entry.project).then(() => true, () => false)) gone.push(entry);
434
+ }
435
+ if (!gone.length) return { id: 'live work', status: entries.length && !running ? 'info' : 'ok', summary: entries.length ? `${entries.length} assignment${entries.length > 1 ? 's' : ''} ${running ? 'running' : 'waiting for alpd to continue'}` : 'no running assignments' };
436
+ return {
437
+ id: 'live work', status: 'warn',
438
+ summary: `${gone.length} interrupted assignment${gone.length > 1 ? 's' : ''} belong to projects that no longer exist`,
439
+ details: gone.map(entry => `${entry?.agent} ${entry?.assignmentId} in ${entry?.project}`),
440
+ fix: running ? undefined : {
441
+ describe: `drop them from ${file}, keeping a copy beside it`,
442
+ async apply() {
443
+ const current = JSON.parse(await readFile(file, 'utf8'));
444
+ const ids = new Set(gone.map(entry => entry?.assignmentId));
445
+ const kept = current.filter(entry => !ids.has(entry?.assignmentId));
446
+ // What is dropped stays on disk, so a repair never loses a record.
447
+ await writeFile(`${file}.dropped-${Date.now()}.json`, JSON.stringify(current.filter(entry => ids.has(entry?.assignmentId)), null, 2) + '\n', { mode: 0o600 });
448
+ await writeFile(file, JSON.stringify(kept, null, 2) + '\n', { mode: 0o600 });
449
+ return `dropped ${current.length - kept.length}`;
450
+ },
451
+ },
452
+ ...(running ? { hint: 'stop alpd to drop them' } : {}),
453
+ };
454
+ }
455
+
456
+ /** Worktrees git lost track of, and alp/* branches: merged ones are dead weight, unmerged ones hold kept work. */
457
+ async function branchCheck(project, home, run) {
458
+ const git = args => run('git', args, { cwd: project });
459
+ const top = await git(['rev-parse', '--show-toplevel']);
460
+ if (top.code !== 0) return [];
461
+ const checks = [];
462
+ const worktrees = (await git(['worktree', 'list', '--porcelain'])).stdout.split('\n\n').filter(Boolean);
463
+ const prunable = worktrees.filter(block => /^prunable/m.test(block)).map(block => block.match(/^worktree (.*)$/m)?.[1]).filter(Boolean);
464
+ if (prunable.length) checks.push({
465
+ id: 'worktrees', status: 'warn', summary: `git still lists ${prunable.length} worktree${prunable.length > 1 ? 's' : ''} that no longer exist`, details: prunable,
466
+ fix: { describe: 'git worktree prune', async apply() { await git(['worktree', 'prune']); return 'pruned'; } },
467
+ });
468
+ const checkedOut = new Set(worktrees.map(block => block.match(/^branch refs\/heads\/(.*)$/m)?.[1]).filter(Boolean));
469
+ let live = [];
470
+ try { live = JSON.parse(await readFile(path.join(home, 'state', 'live.json'), 'utf8')); } catch {}
471
+ const inUse = new Set([...checkedOut, ...(Array.isArray(live) ? live.map(entry => entry?.worktree?.branch).filter(Boolean) : [])]);
472
+ const branches = (await git(['for-each-ref', '--format=%(refname:short)', 'refs/heads/alp/'])).stdout.split('\n').filter(Boolean);
473
+ if (!branches.length) return checks;
474
+ const merged = new Set((await git(['branch', '--format=%(refname:short)', '--merged', 'HEAD', '--list', 'alp/*'])).stdout.split('\n').filter(Boolean));
475
+ const spare = branches.filter(branch => merged.has(branch) && !inUse.has(branch));
476
+ const kept = branches.filter(branch => !merged.has(branch) && !inUse.has(branch));
477
+ if (spare.length) checks.push({
478
+ id: 'branches', status: 'warn', summary: `${spare.length} alp/* branch${spare.length > 1 ? 'es are' : ' is'} already merged into HEAD`, details: spare,
479
+ fix: {
480
+ describe: `delete ${spare.length > 1 ? 'them' : 'it'} with git branch -d`,
481
+ async apply() {
482
+ let deleted = 0;
483
+ for (const branch of spare) if ((await git(['branch', '-d', branch])).code === 0) deleted++;
484
+ return `deleted ${deleted}`;
485
+ },
486
+ },
487
+ });
488
+ if (kept.length) checks.push({ id: 'branches', status: 'info', summary: `${kept.length} alp/* branch${kept.length > 1 ? 'es hold' : ' holds'} unmerged work from finished or interrupted assignments; merge or delete ${kept.length > 1 ? 'them' : 'it'} yourself`, details: kept });
489
+ if (!spare.length && !kept.length) checks.push({ id: 'branches', status: 'ok', summary: `${branches.length} alp/* branch${branches.length > 1 ? 'es' : ''} in use` });
490
+ return checks;
491
+ }
492
+
493
+ /** A check with every line scrubbed: a message may quote what a runtime, a file or a user wrote. */
494
+ function scrubCheck(check) {
495
+ const clean = text => scrubText(text, { max: 1000 });
496
+ return {
497
+ ...check, summary: clean(check.summary),
498
+ ...(check.details ? { details: check.details.map(clean) } : {}),
499
+ ...(check.hint ? { hint: clean(check.hint) } : {}),
500
+ };
501
+ }
502
+
503
+ /**
504
+ * Runs every check. `project` is an ALP project root or undefined; `sandbox` says whether
505
+ * Claude can sandbox Bash here (undefined when unknown); `daemonEntry` is alpd's path.
506
+ */
507
+ export { runCommand };
508
+
509
+ /** The state's schema (ALPD §68), and a migration or a restore cut short. Reads only. */
510
+ async function schemaChecks(home) {
511
+ let info;
512
+ try { info = await readStateSchema(home); }
513
+ catch (error) { return [{ id: 'schema', status: 'fail', summary: `${error.message} (ALP_${error.code ?? 'STATE_UNUSABLE'})` }]; }
514
+ const checks = [info.schema > STATE_SCHEMA
515
+ ? { id: 'schema', status: 'fail', summary: `state schema ${info.schema}${info.by ? ` from ${info.by}` : ''} is newer than this ALP knows (${STATE_SCHEMA}): alpd refuses to start and changes nothing in it (ALP_STATE_SCHEMA_TOO_NEW)`, hint: 'run the ALP that wrote it, or a newer one; docs/alp/data-recovery.md says more' }
516
+ : { id: 'schema', status: info.schema < STATE_SCHEMA ? 'info' : 'ok', summary: info.fresh ? `no state yet; alpd starts it at schema ${STATE_SCHEMA}` : info.schema < STATE_SCHEMA ? `state schema ${info.schema}: alpd migrates it to ${STATE_SCHEMA} at its next start, after saving a backup in ${path.join(home, 'backups')}` : `state schema ${info.schema}` }];
517
+ if (await lstat(migrationFile(home)).then(() => true, () => false)) checks.push({ id: 'migration', status: 'warn', summary: 'a state migration was cut short; alpd continues it at its next start, or puts the state back if it fails' });
518
+ if (await lstat(path.join(home, 'restore-journal.json')).then(() => true, () => false)) checks.push({ id: 'restore', status: 'warn', summary: 'a restore was cut short; alpd or the next alp restore moves the earlier data back first' });
519
+ return checks;
520
+ }
521
+
522
+ export async function diagnose({ home, project, env = process.env, run = runCommand, sandbox, daemonEntry, timeoutMs = 3000 } = {}) {
523
+ const checks = [];
524
+ if (daemonEntry) {
525
+ checks.push(await lstat(daemonEntry).then(() => ({ id: 'build', status: 'ok', summary: daemonEntry }), () => ({ id: 'build', status: 'fail', summary: `alpd is not built (${daemonEntry}); run npm run build` })));
526
+ }
527
+ checks.push(...await runtimeChecks({ env, run }));
528
+ const daemon = await daemonCheck(home);
529
+ checks.push(daemon);
530
+ const service = await serviceCheck(home, env);
531
+ if (service) checks.push(service);
532
+ const running = daemon.status === 'ok' && daemon.summary.startsWith('running');
533
+ checks.push(...await daemonHealthChecks(await readLock(home), timeoutMs));
534
+ checks.push(await stateCheck(home));
535
+ const schema = await schemaChecks(home);
536
+ checks.push(...schema);
537
+ // State an ALP cannot read is left alone, by --fix too.
538
+ const unreadable = schema.some(check => check.id === 'schema' && check.status === 'fail');
539
+ checks.push(...await logChecks(home, running));
540
+ const failedSessions = await failedSessionsCheck(home);
541
+ if (failedSessions) checks.push(failedSessions);
542
+ if (sandbox !== undefined) checks.push(sandbox
543
+ ? { id: 'sandbox', status: 'ok', summary: 'Claude sessions run Bash in an OS sandbox that holds their mode' }
544
+ : { id: 'sandbox', status: 'warn', summary: `no Bash sandbox for Claude (${process.platform === 'linux' ? 'install bubblewrap and socat' : 'Seatbelt is missing'}): read-only Claude agents rely on their permission rules alone, and review copies on Claude cannot start` });
545
+ checks.push(await permissionsCheck(home));
546
+ checks.push(await userSettingsCheck(home));
547
+ const skills = await librarySkillsCheck(home);
548
+ if (skills) checks.push(skills);
549
+ const providers = await providersCheck(home, env);
550
+ if (providers) checks.push(providers);
551
+ const live = await liveCheck(home, running);
552
+ if (live && unreadable) delete live.fix;
553
+ if (live) checks.push(live);
554
+ if (project) {
555
+ checks.push(...await projectChecks(project, home));
556
+ checks.push(...await branchCheck(project, home, run));
557
+ }
558
+ return checks.map(scrubCheck);
559
+ }
560
+
561
+ /** Applies the fixes of the given checks in order; returns what each did. */
562
+ export async function repair(checks) {
563
+ const done = [];
564
+ for (const check of checks) {
565
+ if (!check.fix) continue;
566
+ try { done.push({ id: check.id, result: await check.fix.apply() }); }
567
+ catch (error) { done.push({ id: check.id, result: `failed: ${error.message}` }); }
568
+ }
569
+ return done;
570
+ }