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