@anhlp/alp-client 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @anhlp/alp-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Local IPC client, daemon discovery, supervision, services and setup helpers.
4
+
5
+ `startIntegration` discovers an optional integration's descriptor and starts it detached with `--service`, so the entry writes its own log; discovery and the selected `ALP_HOME` are pinned by tests. Restarting is non-destructive: a daemon left running by an older install is reported as older than the current CLI and is never killed in place.
6
+
7
+ Build: `npm run build --workspace=@anhlp/alp-client`. Test: `npm run test --workspace=@anhlp/alp-client`.
8
+
9
+ Public imports use the package root and its explicitly exported subpaths. See [architecture, provider extension, compatibility and release instructions](../../docs/modular-monorepo.md).
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Starts an ACP provider once and runs initialize (ALPD §46), for `alp provider test`
3
+ * and the settings screen. It reports who the agent says it is and what it supports;
4
+ * a provider that needs a login usually says so here, in authMethods. With `discover`,
5
+ * it also opens a session in `cwd`, as Paseo does, to read the models and modes the agent
6
+ * offers (ALPD §65); an agent that offers none leaves them out.
7
+ * @param {{ command: string, args?: string[], env?: Record<string, string>, label?: string, id?: string }} provider
8
+ * @param {{ cwd?: string, timeoutMs?: number, discover?: boolean }} [options]
9
+ * @returns {Promise<{ protocolVersion: number, agent?: { name?: string, title?: string, version?: string }, loadSession: boolean, mcpHttp: boolean, authMethods: string[],
10
+ * models?: Array<{ id: string, label: string, description?: string }>, currentModel?: string, modes?: Array<{ id: string, label: string, description?: string }>, currentMode?: string }>}
11
+ */
12
+ export function probeAcp(provider: {
13
+ command: string;
14
+ args?: string[];
15
+ env?: Record<string, string>;
16
+ label?: string;
17
+ id?: string;
18
+ }, { cwd, timeoutMs, discover }?: {
19
+ cwd?: string;
20
+ timeoutMs?: number;
21
+ discover?: boolean;
22
+ }): Promise<{
23
+ protocolVersion: number;
24
+ agent?: {
25
+ name?: string;
26
+ title?: string;
27
+ version?: string;
28
+ };
29
+ loadSession: boolean;
30
+ mcpHttp: boolean;
31
+ authMethods: string[];
32
+ models?: Array<{
33
+ id: string;
34
+ label: string;
35
+ description?: string;
36
+ }>;
37
+ currentModel?: string;
38
+ modes?: Array<{
39
+ id: string;
40
+ label: string;
41
+ description?: string;
42
+ }>;
43
+ currentMode?: string;
44
+ }>;
@@ -0,0 +1,37 @@
1
+ // packages/client/src/acp-probe.js
2
+ import { stdioSession } from "./mcp-probe.js";
3
+ async function probeAcp(provider, { cwd, timeoutMs = 2e4, discover = false } = {}) {
4
+ const session = stdioSession({ command: provider.command, args: provider.args ?? [], env: provider.env ?? {} }, cwd, timeoutMs);
5
+ try {
6
+ const result = await session.request("initialize", {
7
+ protocolVersion: 1,
8
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
9
+ clientInfo: { name: "alp", title: "ALP", version: "1" }
10
+ });
11
+ if (result.protocolVersion !== 1) throw new Error(`${provider.label ?? provider.id} speaks ACP version ${result.protocolVersion}; ALP speaks version 1`);
12
+ const info = result.agentInfo;
13
+ const offered = discover ? await session.request("session/new", { cwd, mcpServers: [] }) : void 0;
14
+ const choices = (list) => Array.isArray(list) ? list.filter((item) => typeof (item?.modelId ?? item?.id) === "string").map((item) => ({ id: item.modelId ?? item.id, label: typeof item.name === "string" && item.name ? item.name : item.modelId ?? item.id, ...typeof item.description === "string" && item.description ? { description: item.description } : {} })) : void 0;
15
+ const config = offered?.configOptions?.find((option) => option.category === "model" && option.type === "select");
16
+ const modeConfig = offered?.configOptions?.find((option) => option.category === "mode" && option.type === "select");
17
+ const flatten = (options) => options?.flatMap((option) => option.options ?? [option]).map((option) => ({ ...option, id: option.value }));
18
+ const modelState = offered?.models ?? (config ? { availableModels: flatten(config.options), currentModelId: config.currentValue } : void 0);
19
+ const modeState = offered?.modes ?? (modeConfig ? { availableModes: flatten(modeConfig.options), currentModeId: modeConfig.currentValue } : void 0);
20
+ const models = choices(modelState?.availableModels);
21
+ const modes = choices(modeState?.availableModes);
22
+ return {
23
+ ...models ? { models, ...typeof modelState.currentModelId === "string" ? { currentModel: modelState.currentModelId } : {} } : {},
24
+ ...modes ? { modes, ...typeof modeState.currentModeId === "string" ? { currentMode: modeState.currentModeId } : {} } : {},
25
+ protocolVersion: result.protocolVersion,
26
+ ...info ? { agent: { ...info.name ? { name: info.name } : {}, ...info.title ? { title: info.title } : {}, ...info.version ? { version: info.version } : {} } } : {},
27
+ loadSession: Boolean(result.agentCapabilities?.loadSession),
28
+ mcpHttp: Boolean(result.agentCapabilities?.mcpCapabilities?.http),
29
+ authMethods: (result.authMethods ?? []).map((method) => method?.name ?? method?.id).filter(Boolean)
30
+ };
31
+ } finally {
32
+ await session.close();
33
+ }
34
+ }
35
+ export {
36
+ probeAcp
37
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Probes Antigravity CLI for version, status and available models.
3
+ * @param {{ command?: string, args?: string[], env?: Record<string, string>, label?: string, id?: string, readOnlyMode?: 'instructions' | 'reject' }} [provider]
4
+ * @param {{ cwd?: string, timeoutMs?: number }} [options]
5
+ */
6
+ export function probeAntigravity(provider?: {
7
+ command?: string;
8
+ args?: string[];
9
+ env?: Record<string, string>;
10
+ label?: string;
11
+ id?: string;
12
+ readOnlyMode?: "instructions" | "reject";
13
+ }, { cwd, timeoutMs }?: {
14
+ cwd?: string;
15
+ timeoutMs?: number;
16
+ }): Promise<{
17
+ version: string;
18
+ agent: {
19
+ name: string;
20
+ title: string;
21
+ version: string;
22
+ };
23
+ models: {
24
+ id: any;
25
+ label: any;
26
+ }[];
27
+ currentModel: any;
28
+ modes: {
29
+ id: string;
30
+ label: string;
31
+ description: string;
32
+ }[];
33
+ currentMode: string;
34
+ }>;
@@ -0,0 +1,95 @@
1
+ // packages/client/src/antigravity-probe.js
2
+ import { spawn } from "node:child_process";
3
+ var DEFAULT_ANTIGRAVITY_MODELS = [
4
+ { id: "gemini-3.8-flash-high", label: "Gemini 3.8 Flash (High)" },
5
+ { id: "gemini-3.8-flash-medium", label: "Gemini 3.8 Flash (Medium)" },
6
+ { id: "gemini-3.8-flash-low", label: "Gemini 3.8 Flash (Low)" },
7
+ { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
8
+ { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
9
+ { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Thinking)" },
10
+ { id: "claude-opus-4-6-thinking", label: "Claude Opus 4.6 (Thinking)" },
11
+ { id: "gpt-oss-120b-medium", label: "GPT-OSS 120B (Medium)" }
12
+ ];
13
+ var ANTIGRAVITY_MODES = [
14
+ {
15
+ id: "full-access",
16
+ label: "Full access",
17
+ description: "Antigravity runs with full access (--dangerously-skip-permissions)."
18
+ }
19
+ ];
20
+ function runCommand(command, args, { env = process.env, cwd, timeoutMs = 2e4 } = {}) {
21
+ return new Promise((resolve, reject) => {
22
+ const child = spawn(command, args, {
23
+ cwd,
24
+ env,
25
+ stdio: ["ignore", "pipe", "pipe"],
26
+ windowsHide: true
27
+ });
28
+ let stdout = "";
29
+ let stderr = "";
30
+ const timer = setTimeout(() => {
31
+ child.kill("SIGKILL");
32
+ reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs / 1e3}s`));
33
+ }, timeoutMs);
34
+ child.stdout.on("data", (chunk) => {
35
+ stdout += chunk.toString();
36
+ });
37
+ child.stderr.on("data", (chunk) => {
38
+ stderr += chunk.toString();
39
+ });
40
+ child.on("error", (error) => {
41
+ clearTimeout(timer);
42
+ reject(error);
43
+ });
44
+ child.on("close", (code) => {
45
+ clearTimeout(timer);
46
+ if (code === 0) resolve({ stdout, stderr });
47
+ else reject(new Error(`${command} exited with code ${code}: ${stderr.trim() || stdout.trim()}`));
48
+ });
49
+ });
50
+ }
51
+ async function probeAntigravity(provider = {}, { cwd, timeoutMs = 2e4 } = {}) {
52
+ const command = provider.command || "agy";
53
+ const args = provider.args ?? [];
54
+ const env = { ...process.env, ...provider.env ?? {} };
55
+ let version = "";
56
+ try {
57
+ const res = await runCommand(command, [...args, "--version"], { env, cwd, timeoutMs: Math.min(timeoutMs, 5e3) });
58
+ version = res.stdout.trim().split("\n")[0] ?? "";
59
+ } catch (error) {
60
+ throw new Error(`Antigravity command '${command}' is not available: ${error.message}`);
61
+ }
62
+ let models = [];
63
+ try {
64
+ const res = await runCommand(command, [...args, "models"], { env, cwd, timeoutMs });
65
+ for (const line of res.stdout.trim().split(/\r?\n/)) {
66
+ if (!line.trim()) continue;
67
+ const [id, label] = line.split(" ");
68
+ if (id && label) {
69
+ models.push({ id: id.trim(), label: label.trim() });
70
+ } else if (id) {
71
+ models.push({ id: id.trim(), label: id.trim() });
72
+ }
73
+ }
74
+ } catch {
75
+ models = [...DEFAULT_ANTIGRAVITY_MODELS];
76
+ }
77
+ if (!models.length) {
78
+ models = [...DEFAULT_ANTIGRAVITY_MODELS];
79
+ }
80
+ return {
81
+ version,
82
+ agent: { name: "antigravity", title: "Antigravity", version },
83
+ models,
84
+ currentModel: models[0]?.id,
85
+ modes: provider.readOnlyMode === "instructions" ? [...ANTIGRAVITY_MODES, {
86
+ id: "read-only",
87
+ label: "Read only (instructions)",
88
+ description: "For new sessions: prompt-only read-only behavior. agy still runs with full access; no OS sandbox enforces this mode."
89
+ }] : ANTIGRAVITY_MODES,
90
+ currentMode: "full-access"
91
+ };
92
+ }
93
+ export {
94
+ probeAntigravity
95
+ };
@@ -0,0 +1,5 @@
1
+ /** Runs a command; resolves with its exit code and output, never rejects. */
2
+ export function runCommand(command: any, args: any, { cwd, env, timeoutMs }?: {
3
+ env?: NodeJS.ProcessEnv | undefined;
4
+ timeoutMs?: number | undefined;
5
+ }): Promise<any>;
@@ -0,0 +1,32 @@
1
+ // packages/client/src/command.js
2
+ import { spawn } from "node:child_process";
3
+ function runCommand(command, args, { cwd, env = process.env, timeoutMs = 15e3 } = {}) {
4
+ return new Promise((resolve) => {
5
+ let child;
6
+ try {
7
+ child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
8
+ } catch (error) {
9
+ resolve({ code: -1, stdout: "", stderr: error.message });
10
+ return;
11
+ }
12
+ let stdout = "", stderr = "";
13
+ child.stdout.on("data", (chunk) => {
14
+ stdout += chunk;
15
+ });
16
+ child.stderr.on("data", (chunk) => {
17
+ stderr += chunk;
18
+ });
19
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
20
+ child.on("error", (error) => {
21
+ clearTimeout(timer);
22
+ resolve({ code: -1, stdout, stderr: error.message });
23
+ });
24
+ child.on("close", (code) => {
25
+ clearTimeout(timer);
26
+ resolve({ code: code ?? -1, stdout, stderr });
27
+ });
28
+ });
29
+ }
30
+ export {
31
+ runCommand
32
+ };
@@ -0,0 +1,13 @@
1
+ export type DoctorCheck = {
2
+ id: string;
3
+ status: 'ok' | 'info' | 'warn' | 'fail';
4
+ summary: string;
5
+ details?: string[];
6
+ hint?: string;
7
+ fix?: { describe: string; apply(): Promise<string> };
8
+ };
9
+ export type CommandResult = { code: number; stdout: string; stderr: string };
10
+ export type CommandRunner = (command: string, args: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }) => Promise<CommandResult>;
11
+ export const runCommand: CommandRunner;
12
+ export function diagnose(options?: { home?: string; project?: string; env?: NodeJS.ProcessEnv; run?: CommandRunner; sandbox?: boolean; daemonEntry?: string }): Promise<DoctorCheck[]>;
13
+ export function repair(checks: DoctorCheck[]): Promise<Array<{ id: string; result: string }>>;
package/dist/doctor.js ADDED
@@ -0,0 +1,386 @@
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 { 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
+ var firstLine = (text) => text.trim().split("\n")[0] ?? "";
13
+ async function executable(command, env) {
14
+ const candidates = path.isAbsolute(command) ? [command] : (env.PATH ?? "").split(path.delimiter).filter(Boolean).map((directory) => path.join(directory, command));
15
+ for (const candidate of candidates) {
16
+ try {
17
+ await access(candidate, constants.X_OK);
18
+ if ((await lstat(candidate)).isDirectory()) continue;
19
+ return candidate;
20
+ } catch {
21
+ }
22
+ }
23
+ return void 0;
24
+ }
25
+ var RUNTIMES = [
26
+ { kind: "codex", variable: "ALP_CODEX_BIN", login: ["login", "status"], loggedIn: (result) => result.code === 0, hint: "codex login" },
27
+ {
28
+ kind: "claude",
29
+ variable: "ALP_CLAUDE_BIN",
30
+ login: ["auth", "status"],
31
+ hint: "claude auth login",
32
+ loggedIn: (result) => {
33
+ try {
34
+ return JSON.parse(result.stdout).loggedIn === true;
35
+ } catch {
36
+ return result.code === 0;
37
+ }
38
+ }
39
+ },
40
+ {
41
+ kind: "antigravity",
42
+ command: "agy",
43
+ variable: "ALP_ANTIGRAVITY_BIN",
44
+ login: ["models"],
45
+ hint: "agy and sign in",
46
+ loggedIn: (result) => result.code === 0
47
+ }
48
+ ];
49
+ async function runtimeChecks({ env, run }) {
50
+ const checks = [];
51
+ for (const runtime of RUNTIMES) {
52
+ const command = env[runtime.variable] || runtime.command || runtime.kind;
53
+ const found = await executable(command, env);
54
+ if (!found) {
55
+ checks.push({ id: runtime.kind, status: "warn", summary: `${command} not found on PATH; ${runtime.kind} agents cannot run (set ${runtime.variable} to its path)` });
56
+ continue;
57
+ }
58
+ const version = await run(found, ["--version"], { env });
59
+ if (version.code !== 0) {
60
+ checks.push({ id: runtime.kind, status: "fail", summary: `${found} --version failed: ${firstLine(version.stderr || version.stdout) || `exit ${version.code}`}` });
61
+ continue;
62
+ }
63
+ const login = await run(found, runtime.login, { env });
64
+ 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}` });
65
+ }
66
+ if (checks.every((check) => check.status !== "ok")) {
67
+ for (const check of checks) if (check.status === "warn") check.status = "fail";
68
+ }
69
+ return checks;
70
+ }
71
+ async function serviceCheck(home, env) {
72
+ const service = serviceFor({ home, env });
73
+ const program2 = service && await installedProgram(service);
74
+ if (!program2) return void 0;
75
+ const missing = [];
76
+ for (const file of program2) if (!await lstat(file).then(() => true, () => false)) missing.push(file);
77
+ 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}` };
78
+ }
79
+ async function daemonCheck(home) {
80
+ const paths = daemonPaths(home);
81
+ const lock = await readLock(home);
82
+ const exists = (file) => lstat(file).then(() => true, () => false);
83
+ if (lockAlive(lock)) return { id: "alpd", status: "ok", summary: `running (pid ${lock.pid}, ${lock.version})${lock.ready ? "" : ", starting"}` };
84
+ const stale = [];
85
+ if (await exists(paths.lock)) stale.push(paths.lock);
86
+ if (await exists(paths.socket)) stale.push(paths.socket);
87
+ const crashed = await exists(path.join(home, "state", "alpd.running"));
88
+ const after = crashed ? "; the last alpd stopped unexpectedly, and the next start continues its work" : "";
89
+ if (!stale.length) return { id: "alpd", status: crashed ? "info" : "ok", summary: `not running${after}` };
90
+ return {
91
+ id: "alpd",
92
+ status: "warn",
93
+ summary: `not running, and left ${stale.map((file) => path.basename(file)).join(" and ")} behind${after}`,
94
+ fix: {
95
+ describe: `remove ${stale.join(", ")}`,
96
+ async apply() {
97
+ if (lockAlive(await readLock(home))) return "skipped: alpd started meanwhile";
98
+ for (const file of stale) await unlink(file).catch(() => {
99
+ });
100
+ return `removed ${stale.length} stale file${stale.length > 1 ? "s" : ""}`;
101
+ }
102
+ }
103
+ };
104
+ }
105
+ async function privateEntries(home) {
106
+ const entries = [home];
107
+ for (const name of ["settings.json", "alpd.lock", "alpd.json", "role-skills.json"]) entries.push(path.join(home, name));
108
+ for (const directory of ["state", "runs", "logs", "boards", "worktrees", "copies"]) {
109
+ const full = path.join(home, directory);
110
+ entries.push(full);
111
+ if (directory === "worktrees" || directory === "copies") continue;
112
+ for (const name of await readdir(full).catch(() => [])) entries.push(path.join(full, name));
113
+ }
114
+ return entries;
115
+ }
116
+ async function permissionsCheck(home) {
117
+ const open = [];
118
+ for (const entry of await privateEntries(home)) {
119
+ const info = await lstat(entry).catch(() => void 0);
120
+ if (!info || info.isSymbolicLink() || !(info.mode & 63)) continue;
121
+ open.push({ entry, mode: info.mode & 511, directory: info.isDirectory() });
122
+ }
123
+ if (!open.length) return { id: "permissions", status: "ok", summary: `${home} is private to you` };
124
+ return {
125
+ id: "permissions",
126
+ status: "warn",
127
+ summary: `${open.length} entr${open.length > 1 ? "ies" : "y"} under ${home} readable by others; they hold task briefs, results and settings`,
128
+ details: open.map((item) => `${item.mode.toString(8).padStart(4, "0")} ${item.entry}`),
129
+ fix: {
130
+ describe: "remove group and other access from them",
131
+ async apply() {
132
+ for (const item of open) await chmod(item.entry, item.mode & 448).catch(() => {
133
+ });
134
+ return `tightened ${open.length} entr${open.length > 1 ? "ies" : "y"}`;
135
+ }
136
+ }
137
+ };
138
+ }
139
+ async function userSettingsCheck(home) {
140
+ const file = path.join(home, "settings.json");
141
+ try {
142
+ const text = await readFile(file, "utf8").catch((error) => {
143
+ if (error.code === "ENOENT") return void 0;
144
+ throw error;
145
+ });
146
+ if (text === void 0) return { id: "user settings", status: "ok", summary: `none (${file})` };
147
+ const settings = JSON.parse(text);
148
+ validateUserSettings(settings, file);
149
+ const warnings = settingsWarnings(settings, "user");
150
+ 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 };
151
+ } catch (error) {
152
+ return { id: "user settings", status: "fail", summary: `alpd ignores ${file}: ${error.message.replace(`${file}: `, "")}` };
153
+ }
154
+ }
155
+ async function projectChecks(project, home) {
156
+ const checks = [];
157
+ const file = path.join(project, ".alp", "settings.json");
158
+ let settings;
159
+ try {
160
+ settings = await jsonObject(file, "INVALID_SETTINGS");
161
+ validateSettings(settings, file);
162
+ const warnings = settingsWarnings(settings, "project");
163
+ 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 });
164
+ } catch (error) {
165
+ checks.push({ id: "settings", status: "fail", summary: error.message });
166
+ return checks;
167
+ }
168
+ const sources = await agentSources(project, { library: home });
169
+ const agents = [...sources.keys()].sort((a, b) => a.localeCompare(b));
170
+ const broken = [];
171
+ const shadows = [];
172
+ const hooks = /* @__PURE__ */ new Map();
173
+ for (const [agent, { source, overrides }] of sources) if (overrides) shadows.push(`${agent}: the ${source}'s copy replaces the ${overrides === "builtin" ? "built-in" : "library"} agent`);
174
+ for (const agent of agents) {
175
+ try {
176
+ const resolved = await resolveAgent(project, { agent, library: home });
177
+ for (const hook of await loadHooks(resolved)) hooks.set(hook.path, { ...hook, agents: [...hooks.get(hook.path)?.agents ?? [], agent] });
178
+ for (const skill of resolved.skills) if (!skill.path.startsWith(home + path.sep)) {
179
+ const library = path.join(home, "skills", skill.name, "SKILL.md");
180
+ if (await lstat(library).then(() => true, () => false)) shadows.push(`${agent}: ${skill.name} from the project replaces the library's`);
181
+ }
182
+ } catch (error) {
183
+ broken.push(`${agent}: ${error.message}`);
184
+ }
185
+ }
186
+ 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" });
187
+ else if (broken.length) checks.push({ id: "agents", status: "fail", summary: `${broken.length} of ${agents.length} agents cannot start`, details: broken });
188
+ else checks.push({ id: "agents", status: shadows.length ? "info" : "ok", summary: `${agents.length} agents resolve: ${agents.join(", ")}`, ...shadows.length ? { details: shadows } : {} });
189
+ if (hooks.size) checks.push(await hooksCheck(project, home, [...hooks.values()]));
190
+ if (settings.defaultAgent && !agents.includes(settings.defaultAgent)) checks.push({ id: "agents", status: "fail", summary: `defaultAgent ${settings.defaultAgent} is not an agent of this project` });
191
+ return checks;
192
+ }
193
+ var program = (command) => /^\s*([\w./-]+)/.exec(command)?.[1];
194
+ var SHELL_WORDS = /* @__PURE__ */ new Set(["cd", "test", "[", "echo", "exit", "true", "false", "set", "export", "if", "for", "while", "exec", "env", "command", "printf", "read", "source", "."]);
195
+ async function hooksCheck(project, home, hooks) {
196
+ const missing = [];
197
+ for (const hook of hooks) {
198
+ const name = program(hook.command);
199
+ if (!name || SHELL_WORDS.has(name)) continue;
200
+ const candidates = name.includes("/") ? [path.resolve(project, name)] : (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).map((directory) => path.join(directory, name));
201
+ let found = false;
202
+ for (const candidate of candidates) if (await access(candidate, constants.X_OK).then(() => true, () => false)) {
203
+ found = true;
204
+ break;
205
+ }
206
+ if (!found) missing.push(`${hook.name} (${hook.event}, used by ${hook.agents.join(", ")}): ${name} is not found`);
207
+ }
208
+ const fromProject = hooks.filter((hook) => hook.project);
209
+ const trusted = fromProject.length ? await isTrusted(home, project) : true;
210
+ const details = [...missing, ...!trusted ? [`not trusted yet: ${fromProject.map((hook) => hook.name).join(", ")}; alpd asks the first time one runs, or run alp trust`] : []];
211
+ return {
212
+ id: "hooks",
213
+ status: missing.length ? "warn" : trusted ? "ok" : "info",
214
+ 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` : ""}`,
215
+ ...details.length ? { details } : {}
216
+ };
217
+ }
218
+ async function providersCheck(home, env) {
219
+ let providers;
220
+ try {
221
+ providers = await listProviders(home);
222
+ } catch (error) {
223
+ return { id: "providers", status: "fail", summary: error.message };
224
+ }
225
+ if (!providers.length) return void 0;
226
+ const missing = [];
227
+ for (const provider of providers) if (!await executable(provider.command, { ...env, ...provider.env })) missing.push(`${provider.id}: ${provider.command} is not found`);
228
+ return {
229
+ id: "providers",
230
+ status: "warn",
231
+ summary: `${providers.length} ACP provider${providers.length > 1 ? "s" : ""} (${providers.map((provider) => provider.id).join(", ")})${missing.length ? `; ${missing.length} cannot start` : ""}`,
232
+ details: [
233
+ ...missing,
234
+ "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",
235
+ "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"
236
+ ]
237
+ };
238
+ }
239
+ async function librarySkillsCheck(home) {
240
+ const file = path.join(home, "role-skills.json");
241
+ let roles;
242
+ try {
243
+ const text = await readFile(file, "utf8").catch((error) => {
244
+ if (error.code === "ENOENT") return void 0;
245
+ throw error;
246
+ });
247
+ if (text === void 0) return void 0;
248
+ roles = JSON.parse(text);
249
+ } catch (error) {
250
+ return { id: "skills", status: "fail", summary: `${file}: ${error.message}` };
251
+ }
252
+ if (!roles || typeof roles !== "object" || Array.isArray(roles)) return { id: "skills", status: "fail", summary: `${file}: expected an object of role names to skill lists` };
253
+ const missing = [];
254
+ for (const [role, names] of Object.entries(roles)) {
255
+ if (!Array.isArray(names)) {
256
+ missing.push(`${role}: not a list`);
257
+ continue;
258
+ }
259
+ for (const name of names) {
260
+ if (typeof name !== "string" || !await lstat(path.join(home, "skills", name, "SKILL.md")).then(() => true, () => false)) missing.push(`${role}: ${name}`);
261
+ }
262
+ }
263
+ 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})` };
264
+ }
265
+ async function liveCheck(home, running) {
266
+ const file = path.join(home, "state", "live.json");
267
+ let entries;
268
+ try {
269
+ entries = JSON.parse(await readFile(file, "utf8"));
270
+ } catch {
271
+ return void 0;
272
+ }
273
+ if (!Array.isArray(entries)) return { id: "live work", status: "warn", summary: `${file} is not a list; alpd ignores it` };
274
+ const gone = [];
275
+ for (const entry of entries) {
276
+ if (typeof entry?.project !== "string" || !await lstat(entry.project).then(() => true, () => false)) gone.push(entry);
277
+ }
278
+ 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" };
279
+ return {
280
+ id: "live work",
281
+ status: "warn",
282
+ summary: `${gone.length} interrupted assignment${gone.length > 1 ? "s" : ""} belong to projects that no longer exist`,
283
+ details: gone.map((entry) => `${entry?.agent} ${entry?.assignmentId} in ${entry?.project}`),
284
+ fix: running ? void 0 : {
285
+ describe: `drop them from ${file}`,
286
+ async apply() {
287
+ const current = JSON.parse(await readFile(file, "utf8"));
288
+ const ids = new Set(gone.map((entry) => entry?.assignmentId));
289
+ const kept = current.filter((entry) => !ids.has(entry?.assignmentId));
290
+ await writeFile(file, JSON.stringify(kept, null, 2) + "\n", { mode: 384 });
291
+ return `dropped ${current.length - kept.length}`;
292
+ }
293
+ },
294
+ ...running ? { hint: "stop alpd to drop them" } : {}
295
+ };
296
+ }
297
+ async function branchCheck(project, home, run) {
298
+ const git = (args) => run("git", args, { cwd: project });
299
+ const top = await git(["rev-parse", "--show-toplevel"]);
300
+ if (top.code !== 0) return [];
301
+ const checks = [];
302
+ const worktrees = (await git(["worktree", "list", "--porcelain"])).stdout.split("\n\n").filter(Boolean);
303
+ const prunable = worktrees.filter((block) => /^prunable/m.test(block)).map((block) => block.match(/^worktree (.*)$/m)?.[1]).filter(Boolean);
304
+ if (prunable.length) checks.push({
305
+ id: "worktrees",
306
+ status: "warn",
307
+ summary: `git still lists ${prunable.length} worktree${prunable.length > 1 ? "s" : ""} that no longer exist`,
308
+ details: prunable,
309
+ fix: { describe: "git worktree prune", async apply() {
310
+ await git(["worktree", "prune"]);
311
+ return "pruned";
312
+ } }
313
+ });
314
+ const checkedOut = new Set(worktrees.map((block) => block.match(/^branch refs\/heads\/(.*)$/m)?.[1]).filter(Boolean));
315
+ let live = [];
316
+ try {
317
+ live = JSON.parse(await readFile(path.join(home, "state", "live.json"), "utf8"));
318
+ } catch {
319
+ }
320
+ const inUse = /* @__PURE__ */ new Set([...checkedOut, ...Array.isArray(live) ? live.map((entry) => entry?.worktree?.branch).filter(Boolean) : []]);
321
+ const branches = (await git(["for-each-ref", "--format=%(refname:short)", "refs/heads/alp/"])).stdout.split("\n").filter(Boolean);
322
+ if (!branches.length) return checks;
323
+ const merged = new Set((await git(["branch", "--format=%(refname:short)", "--merged", "HEAD", "--list", "alp/*"])).stdout.split("\n").filter(Boolean));
324
+ const spare = branches.filter((branch) => merged.has(branch) && !inUse.has(branch));
325
+ const kept = branches.filter((branch) => !merged.has(branch) && !inUse.has(branch));
326
+ if (spare.length) checks.push({
327
+ id: "branches",
328
+ status: "warn",
329
+ summary: `${spare.length} alp/* branch${spare.length > 1 ? "es are" : " is"} already merged into HEAD`,
330
+ details: spare,
331
+ fix: {
332
+ describe: `delete ${spare.length > 1 ? "them" : "it"} with git branch -d`,
333
+ async apply() {
334
+ let deleted = 0;
335
+ for (const branch of spare) if ((await git(["branch", "-d", branch])).code === 0) deleted++;
336
+ return `deleted ${deleted}`;
337
+ }
338
+ }
339
+ });
340
+ 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 });
341
+ if (!spare.length && !kept.length) checks.push({ id: "branches", status: "ok", summary: `${branches.length} alp/* branch${branches.length > 1 ? "es" : ""} in use` });
342
+ return checks;
343
+ }
344
+ async function diagnose({ home, project, env = process.env, run = runCommand, sandbox, daemonEntry } = {}) {
345
+ const checks = [];
346
+ if (daemonEntry) {
347
+ 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` })));
348
+ }
349
+ checks.push(...await runtimeChecks({ env, run }));
350
+ const daemon = await daemonCheck(home);
351
+ checks.push(daemon);
352
+ const service = await serviceCheck(home, env);
353
+ if (service) checks.push(service);
354
+ const running = daemon.status === "ok" && daemon.summary.startsWith("running");
355
+ 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` });
356
+ checks.push(await permissionsCheck(home));
357
+ checks.push(await userSettingsCheck(home));
358
+ const skills = await librarySkillsCheck(home);
359
+ if (skills) checks.push(skills);
360
+ const providers = await providersCheck(home, env);
361
+ if (providers) checks.push(providers);
362
+ const live = await liveCheck(home, running);
363
+ if (live) checks.push(live);
364
+ if (project) {
365
+ checks.push(...await projectChecks(project, home));
366
+ checks.push(...await branchCheck(project, home, run));
367
+ }
368
+ return checks;
369
+ }
370
+ async function repair(checks) {
371
+ const done = [];
372
+ for (const check of checks) {
373
+ if (!check.fix) continue;
374
+ try {
375
+ done.push({ id: check.id, result: await check.fix.apply() });
376
+ } catch (error) {
377
+ done.push({ id: check.id, result: `failed: ${error.message}` });
378
+ }
379
+ }
380
+ return done;
381
+ }
382
+ export {
383
+ diagnose,
384
+ repair,
385
+ runCommand
386
+ };