@staix/agent-hub 0.23.1 → 0.25.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/CHANGELOG.md +24 -0
- package/README.md +32 -5
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +16 -2
- package/docs/agent-notes/tasks.md +9 -2
- package/docs/agent-notes/tests.md +3 -0
- package/docs/dogfood.md +16 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +198 -5
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +19 -4
- package/docs/security.md +225 -8
- package/docs/specs/2026-09-19-agent-hub-design.md +54 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
- package/docs/verification/2026-10-31-0.24.0.md +63 -0
- package/docs/verification/2026-10-31-claude-implementer-2.1.316.md +65 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +31 -15
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5120 -4710
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/native-cli-claude-setup.ts +4 -0
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry.ts +1 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/autonomy-homes.ts +33 -0
- package/src/cli/autonomy-offline.ts +75 -0
- package/src/cli/autonomy-projects.ts +103 -0
- package/src/cli/autonomy.ts +44 -0
- package/src/cli/claude-homes.ts +123 -0
- package/src/cli/claude-profile-hook.ts +130 -0
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +8 -4
- package/src/cli/identity.ts +8 -1
- package/src/cli/main.ts +174 -33
- package/src/cli/output.ts +8 -5
- package/src/cli/schedule-output.ts +8 -4
- package/src/cli/setup-homes.ts +46 -0
- package/src/cli/setup.ts +1 -1
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +1 -0
- package/src/hub/action-class.ts +203 -14
- package/src/hub/action-enforce.ts +8 -6
- package/src/hub/action-policy.ts +95 -9
- package/src/hub/autonomy-consent.ts +173 -0
- package/src/hub/autonomy-defaults.ts +58 -0
- package/src/hub/autonomy.ts +232 -0
- package/src/hub/board.ts +871 -2
- package/src/hub/channel-project.ts +61 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/confirmation-matrix.ts +202 -0
- package/src/hub/control-client.ts +10 -1
- package/src/hub/daemon.ts +546 -27
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +12 -0
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/manager.ts +64 -57
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project.ts +14 -5
- package/src/hub/provenance.ts +30 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +445 -0
- package/src/hub/restart.ts +26 -0
- package/src/hub/schedules.ts +210 -9
- package/src/hub/session-identity.ts +49 -0
- package/src/hub/settings.ts +105 -8
- package/src/hub/task-repository.ts +11 -6
- package/src/hub/tasks.ts +894 -22
- package/src/hub/ui.ts +15 -6
- package/src/memory/capture.ts +2 -2
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/presets/autonomous.json +161 -0
- package/templates/presets/confirmation-matrix.json +943 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- package/templates/roles/research/statistician.md +1 -0
|
@@ -12,7 +12,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
12
12
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { ControlClient, INVOCATION_CONTROL_TIMEOUT_MS
|
|
15
|
+
import { ControlClient, INVOCATION_CONTROL_TIMEOUT_MS } from "../hub/control-client.ts";
|
|
16
|
+
import { channelProject } from "../hub/channel-project.ts";
|
|
16
17
|
import { VERSION } from "../version.ts";
|
|
17
18
|
import { IMPLEMENTER_HUB_TOOL_NAMES, IMPLEMENTER_PERMISSION_TOOL, ACTIONABLE_MESSAGE_INSTRUCTION, projectConductorTools, projectToolList, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
|
|
18
19
|
import { frame, replyParent, sanitize, HUB_MESSAGE_INSTRUCTION, type Envelope } from "../hub/envelope.ts";
|
|
@@ -22,8 +23,23 @@ import { acceptsNativePermissionRequest, buildNativePermissionProbe, NATIVE_PERM
|
|
|
22
23
|
// A native session is pinned at launch. Unlike a new CLI invocation after `cd`,
|
|
23
24
|
// its MCP server must not silently move to another hub (including while offline).
|
|
24
25
|
// ControlClient verifies the manifest identity on every connection.
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
//
|
|
27
|
+
// agent-hub#1036: which project this plugin joins is decided by `channelProject`, the same decision the CLI makes at startup. An inherited AGENTHUB_STATE_DIR is used as the peer only when the
|
|
28
|
+
// root recorded in it equals the resolved root of this process's cwd, or (a declared extra instance) when its managed worktree is this cwd. A session started in `<repo>/.worktrees/<name>`
|
|
29
|
+
// uses its own project. A session with no project of its own that inherited another project's state joins THAT hub as a distinct session (`<peer>~<id>`, minted by the hub): it can send and
|
|
30
|
+
// read, holds none of the peer's roles, and never replaces the attached session. Nothing is refused. Limit: this makes sessions distinguishable, it does not authenticate them.
|
|
31
|
+
const own = channelProject(process.cwd(), process.env, root => {
|
|
32
|
+
const read = (name: string) => { try { return JSON.parse(readFileSync(join(root, ".agenthub", name), "utf8")).roles ?? {}; } catch { return {}; } };
|
|
33
|
+
return { ...DEFAULT_ROLES, ...read("config.json"), ...read("config.local.json") };
|
|
34
|
+
});
|
|
35
|
+
/** Set when this process joins as a distinct session. The hub's answer names the minted id; until then the session is only "a session". */
|
|
36
|
+
const sessionJoin = own?.session;
|
|
37
|
+
/** A hub that predates `session_identity` would ignore the request and bind the real peer id, which is the takeover this exists to prevent: such a session does not connect, and says why. */
|
|
38
|
+
const NO_SESSION_SUPPORT = "this process has no project of its own and the hub it inherited predates session identities, so it would be taken for the peer it names; restart the hub (ahub restart) or start this session from its own project";
|
|
39
|
+
const NO_PROJECT = "agent-hub has no hub to join: this process has no project of its own and inherited no state directory. Start it from a project that runs agent-hub.";
|
|
40
|
+
if (process.env.AGENTHUB_STATE_DIR && own && !own.inherited && !own.session) console.error(`[agent-hub] ignoring an inherited AGENTHUB_STATE_DIR: it records another project, not ${own.root}. Using this project's own hub.`);
|
|
41
|
+
const stateDir = own?.stateDir ?? "";
|
|
42
|
+
const projectRoot = own?.root ?? "";
|
|
27
43
|
/** tools mode: the same server, run by Kimi (ACP mcpServers), Codex (mcp_servers override) or Jcode. Their messages arrive through their own adapters, so no channel here. Not the "tools-only" Claude below. */
|
|
28
44
|
const toolsMode = process.env.AGENTHUB_MODE === "tools";
|
|
29
45
|
const permissionTool = toolsMode && process.env.AGENTHUB_PERMISSION_TOOL === "1";
|
|
@@ -111,6 +127,10 @@ const INBOX_CAP = 200;
|
|
|
111
127
|
* whoever said hello last, so reconnecting blind would evict the session that just took it and the two
|
|
112
128
|
* would trade the peer forever. This file is advisory: a standby hello also checks live ownership at the daemon.
|
|
113
129
|
*/
|
|
130
|
+
/** Whether the hub this process would join as a session knows `session_identity`, from the daemon's own status file. Unknown reads as unsupported. */
|
|
131
|
+
function sessionSupported(): boolean {
|
|
132
|
+
try { return (JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8")).features ?? []).includes("session_identity"); } catch { return false; }
|
|
133
|
+
}
|
|
114
134
|
function peerHeld(): boolean {
|
|
115
135
|
try {
|
|
116
136
|
const status = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8")) as { peers?: Record<string, { state?: string; claiming?: boolean; toolsOnly?: string }> };
|
|
@@ -266,7 +286,8 @@ async function connectLoop(): Promise<void> {
|
|
|
266
286
|
try { executionCwd = realPath(process.cwd()); }
|
|
267
287
|
catch { /* A removed native cwd revokes root evidence, never the messaging connection. */ }
|
|
268
288
|
}
|
|
269
|
-
const client = await ControlClient.connect(stateDir, { role: toolsMode ? "tools" : "peer", peer: peerId, ...(toolsMode ? {} : { channel }),
|
|
289
|
+
const client = await ControlClient.connect(stateDir, { role: toolsMode || sessionJoin ? "tools" : "peer", peer: peerId, ...(toolsMode || sessionJoin ? {} : { channel }),
|
|
290
|
+
...(sessionJoin ? { session: sessionJoin.label } : {}),
|
|
270
291
|
...(process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}),
|
|
271
292
|
...(executionCwd !== undefined ? { cwd: executionCwd } : {}),
|
|
272
293
|
...(!toolsMode && standingBy ? { standby: true } : {}),
|
|
@@ -293,7 +314,7 @@ async function connectLoop(): Promise<void> {
|
|
|
293
314
|
attempt = -1;
|
|
294
315
|
standingBy = false;
|
|
295
316
|
detached = undefined;
|
|
296
|
-
log(`connected to hub as "${peerId}"`);
|
|
317
|
+
log(sessionJoin ? `connected to hub as the distinct session "${client.sessionId ?? "?"}", not as "${peerId}": it can send and read, and holds none of ${peerId}'s roles` : `connected to hub as "${peerId}"`);
|
|
297
318
|
// The tool list depends on the connected hub (its lifecycle): when it differs from what the host last listed,
|
|
298
319
|
// ask the host to list it again (#490).
|
|
299
320
|
if (listedTools !== undefined) void reannounce(client).catch(() => {});
|
|
@@ -394,6 +415,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
394
415
|
async function callTool(req: { params: { name: string; arguments?: Record<string, unknown> } }, signal?: AbortSignal) {
|
|
395
416
|
const { name, arguments: args } = req.params;
|
|
396
417
|
if (unidentified) return { ...text(UNIDENTIFIED), isError: true };
|
|
418
|
+
if (!own) return { ...text(NO_PROJECT), isError: true };
|
|
419
|
+
if (sessionJoin && !sessionSupported()) return { ...text(NO_SESSION_SUPPORT), isError: true };
|
|
397
420
|
if (permissionTool && !IMPLEMENTER_HUB_TOOL_NAMES.includes(name)) return { ...text("tool unavailable to this owned implementer child"), isError: true };
|
|
398
421
|
if (name === "hub_permission_request") {
|
|
399
422
|
const deny = (message: string) => text(JSON.stringify({ behavior: "deny", message }));
|
|
@@ -473,4 +496,4 @@ await server.connect(new StdioServerTransport());
|
|
|
473
496
|
// The host (Claude Code, Codex, Kimi) talks over stdin; once it is gone nothing can use this server, and a leftover would keep reconnecting.
|
|
474
497
|
process.stdin.on("end", () => process.exit(0));
|
|
475
498
|
process.stdin.on("close", () => process.exit(0));
|
|
476
|
-
if (unidentified) log(UNIDENTIFIED); else void connectLoop();
|
|
499
|
+
if (unidentified) log(UNIDENTIFIED); else if (!own) log(NO_PROJECT); else if (sessionJoin && !sessionSupported()) log(NO_SESSION_SUPPORT); else void connectLoop();
|
package/src/adapters/compat.ts
CHANGED
|
@@ -34,11 +34,11 @@ export const COMPAT = {
|
|
|
34
34
|
parse: out => plain(out.trim()),
|
|
35
35
|
trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
|
|
36
36
|
receipt: "docs/verification/2026-10-26-pi-140-compat.md" },
|
|
37
|
-
// Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; host-wrapper receipt below repeats with them.
|
|
38
|
-
"claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314"], gate: "contract", probe: ["claude", "--version"],
|
|
37
|
+
// Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; host-wrapper receipt below repeats with them.
|
|
38
|
+
"claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316"], gate: "contract", probe: ["claude", "--version"],
|
|
39
39
|
parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
|
|
40
40
|
trustNote: "The native prompt-tool transport and host-wrapper rewrite, environment isolation and replay contract have isolated receipts. No dontAsk/bypass fallback exists; another version's permission or rewrite behavior is unverified.",
|
|
41
|
-
receipt: "docs/verification/2026-10-31-claude-implementer-2.1.
|
|
41
|
+
receipt: "docs/verification/2026-10-31-claude-implementer-2.1.316.md" },
|
|
42
42
|
// 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md; 0.166.0: docs/verification/2026-10-31-codex-0166-reviewer-isolation.md
|
|
43
43
|
"codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
|
|
44
44
|
parse: out => plain(out.trim().replace(/^codex-cli /, "")),
|
|
@@ -4,6 +4,10 @@ import { realPath } from "../../hub/project.ts";
|
|
|
4
4
|
import { VERSION } from "../../version.ts";
|
|
5
5
|
|
|
6
6
|
export const PLUGIN = "agent-hub@agent-hub";
|
|
7
|
+
export const CLAUDE_PLUGIN_INVENTORY = Object.freeze({
|
|
8
|
+
plugins: Object.freeze(["claude", "plugin", "list", "--json"]),
|
|
9
|
+
marketplaces: Object.freeze(["claude", "plugin", "marketplace", "list", "--json"]),
|
|
10
|
+
});
|
|
7
11
|
const MARKETPLACE = "agent-hub";
|
|
8
12
|
|
|
9
13
|
export interface InstalledPlugin {
|
|
@@ -8,6 +8,36 @@ const nativeTools = [
|
|
|
8
8
|
] as const;
|
|
9
9
|
export const DOCTOR_TOOL_PROBES: readonly string[] = Object.freeze(["bun", ...nativeTools.filter(tool => tool.initial).map(tool => tool.id)]);
|
|
10
10
|
export const DOCTOR_MEMORY_CAPTURE_PEERS = Object.freeze(["codex", "kimi"] as const);
|
|
11
|
+
/**
|
|
12
|
+
* What one read of agent-mem's own setup state gave (`amem setup <tool> --json`, a preview that writes nothing): the parsed plan, or why there is none. Agent-mem owns the state;
|
|
13
|
+
* doctor never reads dotfiles text or a client's config itself (#1008).
|
|
14
|
+
*/
|
|
15
|
+
export type MemorySetupReading = { kind: "read"; plan: unknown } | { kind: "unavailable"; reason: string };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One memory-capture doctor row from agent-mem's structured setup plan: `applied` (the hub's receipt matches and the MCP entry and hooks are as setup would write them), `pending`
|
|
19
|
+
* (setup would add or replace something, so `amem setup <tool> --apply` is the fix), or `client missing` (the native CLI is not installed). A reading that cannot be trusted as the shape
|
|
20
|
+
* agent-mem documents is unknown, never ready: the row says it could not be read instead of guessing. Pure: the plan is data, never text, and nothing here runs a process.
|
|
21
|
+
* Codex also needs the person's native `/hooks` review before a newly registered hook runs, which agent-mem's setup does not grant, so an applied Codex row says so.
|
|
22
|
+
*/
|
|
23
|
+
export function nativeMemoryCaptureRow(tool: string, reading: MemorySetupReading): { ok: boolean | undefined; detail: string } {
|
|
24
|
+
const unknown = (why: string) => ({ ok: undefined, detail: `unknown (${why})` });
|
|
25
|
+
if (reading.kind !== "read") return unknown(reading.reason);
|
|
26
|
+
const plan = reading.plan as { mode?: unknown; actions?: unknown } | null;
|
|
27
|
+
const actions = plan && typeof plan === "object" && plan.mode === "preview" && Array.isArray(plan.actions) ? plan.actions : undefined;
|
|
28
|
+
const action = actions?.length === 1 ? actions[0] as Record<string, unknown> | null : undefined;
|
|
29
|
+
if (!action || typeof action !== "object" || action.target !== tool || typeof action.available !== "boolean" || !["add", "replace", "restore", "unchanged"].includes(String(action.mcpChange)))
|
|
30
|
+
return unknown("`amem setup " + tool + " --json` did not return the setup plan it documents");
|
|
31
|
+
if (action.available === false) return { ok: false, detail: `client missing: the ${tool} CLI is not installed, so there is nothing to set up` };
|
|
32
|
+
const setup = `amem setup ${tool} --apply`;
|
|
33
|
+
if (action.mcpChange !== "unchanged") {
|
|
34
|
+
const state = typeof action.state === "string" ? action.state.slice(0, 60).replace(/[^\x20-\x7e]/g, "?") : "unknown";
|
|
35
|
+
return { ok: false, detail: `not set up (${state}); run \`${setup}\`` };
|
|
36
|
+
}
|
|
37
|
+
const hooks = tool === "codex" ? "; newly registered hooks need the native /hooks review in Codex before they run (amem setup does not grant hook trust)" : "";
|
|
38
|
+
return { ok: true, detail: `set up, per \`amem setup ${tool}\`${hooks}` };
|
|
39
|
+
}
|
|
40
|
+
|
|
11
41
|
export const DOCTOR_PLUGIN_PROBE = Object.freeze({ command: "claude", argv: Object.freeze(["plugin", "list", "--json"] as const), name: "claude plugin" });
|
|
12
42
|
|
|
13
43
|
/** Exact legacy section and severity classification, including non-native and unknown diagnostic names. */
|
package/src/adapters/jcode.ts
CHANGED
|
@@ -210,6 +210,9 @@ export class JcodePeer extends AcpPeer {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
/** True only for the id of this peer's current owned launch, while it is not ending: the proof a hub-tools hello must carry (#368). */
|
|
213
|
+
/** The id of this peer's current owned launch, for the provenance of what it publishes (agent-hub#1036). Evidence for the log, never a credential: nothing admits a connection by reading it. */
|
|
214
|
+
get ownedLaunchId(): string | undefined { return this.ending ? undefined : this.launchId; }
|
|
215
|
+
|
|
213
216
|
ownsLaunch(launchId: unknown): boolean { return !this.ending && typeof launchId === "string" && launchId === this.launchId; }
|
|
214
217
|
|
|
215
218
|
override async start(): Promise<void> {
|
package/src/adapters/registry.ts
CHANGED
|
@@ -292,7 +292,7 @@ export function builtinPreviewOf(id: string) {
|
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
/** Fixed builtin doctor metadata and pure observed-value projections; the CLI retains all probe effects. */
|
|
295
|
-
export { DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "./definitions/native-cli-doctor.ts";
|
|
295
|
+
export { DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeMemoryCaptureRow, type MemorySetupReading, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "./definitions/native-cli-doctor.ts";
|
|
296
296
|
|
|
297
297
|
/** Definition-owned CLI entries are restricted to fixed builtins, never config or external execution extensions. */
|
|
298
298
|
export const BUILTIN_CLI_ENTRIES = Object.freeze(builtinCliEntries(ADAPTERS).map(entry => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Where `ahub setup` registers the acon plugin (#935): acon reads $AGENTHUB_HOME/console/plugins/<tool>.json. */
|
|
6
|
+
export function aconManifestPath(home: string): string {
|
|
7
|
+
return join(home, "console", "plugins", "ahub.json");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Write the acon registration for this package's plugin module with its sha256, so acon refuses a module that changed
|
|
12
|
+
* after setup. The directory is created 0700; the file is replaced atomically. Returns what was written, or why not.
|
|
13
|
+
*/
|
|
14
|
+
export function registerAconPlugin(home: string, module: string): { path: string; changed: boolean } | { error: string } {
|
|
15
|
+
if (!existsSync(module)) return { error: `acon plugin module ${module} is missing` };
|
|
16
|
+
const dir = join(home, "console", "plugins");
|
|
17
|
+
for (const part of [join(home, "console"), dir]) {
|
|
18
|
+
if (!existsSync(part)) mkdirSync(part, { mode: 0o700 });
|
|
19
|
+
if (!lstatSync(part).isDirectory()) return { error: `${part} is not a directory` };
|
|
20
|
+
chmodSync(part, 0o700);
|
|
21
|
+
}
|
|
22
|
+
const path = aconManifestPath(home);
|
|
23
|
+
const body = `${JSON.stringify({ api: 1, tool: "ahub", module, sha256: createHash("sha256").update(readFileSync(module)).digest("hex") })}\n`;
|
|
24
|
+
if (existsSync(path) && readFileSync(path, "utf8") === body) return { path, changed: false };
|
|
25
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
26
|
+
writeFileSync(temp, body, { mode: 0o600 });
|
|
27
|
+
renameSync(temp, path);
|
|
28
|
+
return { path, changed: true };
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readControl } from "../hub/control-client.ts";
|
|
2
|
+
import { readInstanceRoleConfig } from "../hub/instance-config.ts";
|
|
3
|
+
import type { Registry } from "../hub/registry.ts";
|
|
4
|
+
import { checkClaudeHome, discoverClaudeHomes, type ClaudeHomeProject, type ClaudeHomeProbe } from "./claude-homes.ts";
|
|
5
|
+
import { checkClaudeProfileHook } from "./claude-profile-hook.ts";
|
|
6
|
+
|
|
7
|
+
export async function usedClaudeHomes(registry: Registry, home: string, env: NodeJS.ProcessEnv) {
|
|
8
|
+
const projects: ClaudeHomeProject[] = [], notices: string[] = [];
|
|
9
|
+
const registered = registry.list();
|
|
10
|
+
if (registered.length > 256) notices.push("Claude profile discovery is limited to the first 256 registered projects.");
|
|
11
|
+
for (const project of registered.slice(0, 256)) {
|
|
12
|
+
const control = readControl(project.stateDir);
|
|
13
|
+
if (!control?.instanceId) continue;
|
|
14
|
+
try {
|
|
15
|
+
const scope = readInstanceRoleConfig(project.root);
|
|
16
|
+
const ids = [...new Set([...scope.catalog.definitions.map(definition => definition.kind), ...Object.keys(scope.instances ?? {})])];
|
|
17
|
+
const claudePeers = ids.filter(id => scope.catalog.definitionOf(id, scope.instances)?.transport === "claude-channel");
|
|
18
|
+
projects.push({ root: project.root, stateDir: project.stateDir, instanceId: control.instanceId, claudePeers });
|
|
19
|
+
} catch { notices.push(`Claude profile evidence is unavailable for project ${project.id}.`); }
|
|
20
|
+
}
|
|
21
|
+
const found = await discoverClaudeHomes({ home, env, projects });
|
|
22
|
+
return { ...found, complete: found.complete && !notices.length, notices: [...notices, ...found.notices] };
|
|
23
|
+
}
|
|
24
|
+
export async function autonomyHomeStatus(registry: Registry, home: string, env: NodeJS.ProcessEnv, packageRoot: string, probe: ClaudeHomeProbe) {
|
|
25
|
+
const found = await usedClaudeHomes(registry, home, env), deadline = performance.now() + 6000;
|
|
26
|
+
const homes = [];
|
|
27
|
+
for (const selected of found.homes) {
|
|
28
|
+
const remaining = Math.floor(deadline - performance.now());
|
|
29
|
+
if (remaining < 1) homes.push({ home: selected, plugin: { state: "unknown", reason: "The machine profile inventory time bound was reached." }, hook: checkClaudeProfileHook(selected) });
|
|
30
|
+
else homes.push(await checkClaudeHome(selected, packageRoot, env, probe, Math.min(remaining, 6000)));
|
|
31
|
+
}
|
|
32
|
+
return { homes, notices: found.notices, complete: found.complete && performance.now() <= deadline };
|
|
33
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { planAutonomy, autonomyRemovalKeys, autonomyStatus, autonomyPreset, type AutonomyOperation, type AutonomyPeer, type AutonomyFloorStatus } from "../hub/autonomy.ts";
|
|
4
|
+
import { configFingerprint, configSourceTexts } from "../hub/config-reload.ts";
|
|
5
|
+
import { configRefusal } from "../hub/config-trust.ts";
|
|
6
|
+
import { loadConfig } from "../hub/daemon.ts";
|
|
7
|
+
import { validateEffectiveConfig } from "../hub/config-apply.ts";
|
|
8
|
+
import { readInstanceConfigLayers, readInstanceRoleConfig } from "../hub/instance-config.ts";
|
|
9
|
+
import { checkSettingValue, settingDef, settingText, writeAutonomyPreset } from "../hub/settings.ts";
|
|
10
|
+
import type { Project } from "../hub/registry.ts";
|
|
11
|
+
|
|
12
|
+
export interface OfflineAutonomyPlan { fingerprint: string; operations: AutonomyOperation[]; lines: string[] }
|
|
13
|
+
type Target = Pick<Project, "root" | "stateDir">;
|
|
14
|
+
function scopeOf(root: string) {
|
|
15
|
+
const directory = lstatSync(join(root, ".agenthub"), { throwIfNoEntry: false });
|
|
16
|
+
if (!directory?.isDirectory() || directory.isSymbolicLink() || directory.uid !== process.getuid?.() || (directory.mode & 0o022) !== 0) throw new Error("project configuration directory is unsafe; autonomy was not changed");
|
|
17
|
+
const local = lstatSync(join(root, ".agenthub/config.local.json"), { throwIfNoEntry: false });
|
|
18
|
+
if (local && (!local.isFile() || local.isSymbolicLink() || local.nlink !== 1 || local.uid !== process.getuid?.() || (local.mode & 0o022) !== 0)) throw new Error("project config.local.json is unsafe; autonomy was not changed");
|
|
19
|
+
const refusal = configRefusal(root, "config.local.json");
|
|
20
|
+
if (refusal) throw new Error(refusal);
|
|
21
|
+
const scope = readInstanceRoleConfig(root);
|
|
22
|
+
// Runtime module policy is captured only by the daemon, never imported by a file writer.
|
|
23
|
+
if (scope.adapter_modules?.length) throw new Error("this project uses adapter modules; start its hub before applying autonomy");
|
|
24
|
+
const config = loadConfig(root);
|
|
25
|
+
validateEffectiveConfig(config, scope.catalog);
|
|
26
|
+
const ids = [...new Set([...scope.catalog.definitions.map(definition => definition.kind), ...Object.keys(scope.instances ?? {})])]
|
|
27
|
+
.filter(id => scope.catalog.isSupportedPeer(id, scope.instances));
|
|
28
|
+
const peers: AutonomyPeer[] = ids.map(id => {
|
|
29
|
+
const definition = scope.catalog.definitionOf(id, scope.instances)!;
|
|
30
|
+
return { id, kind: definition.kind, unmanaged: !!definition.permission.unmanaged, ...(scope.instances?.[id]?.start_mode ? { form: scope.instances[id]!.start_mode } : {}) };
|
|
31
|
+
});
|
|
32
|
+
return { scope, peers };
|
|
33
|
+
}
|
|
34
|
+
/** Read-only preview. The caller must hold the stopped project's registry claim while applying it. */
|
|
35
|
+
export function planOfflineAutonomy(project: Target, environment: string, enabled: boolean): OfflineAutonomyPlan {
|
|
36
|
+
const { scope, peers } = scopeOf(project.root);
|
|
37
|
+
const operations = enabled ? planAutonomy(environment, key => {
|
|
38
|
+
const def = settingDef(key, scope.instances, scope.catalog);
|
|
39
|
+
return typeof def === "string" || def.store !== "config" ? undefined : { store: def.store, values: def.values, locked: def.locked };
|
|
40
|
+
}, peers) : autonomyRemovalKeys(peers).map(key => ({ key, value: null, store: "config" as const }));
|
|
41
|
+
for (const operation of operations) if (enabled) {
|
|
42
|
+
const def = settingDef(operation.key, scope.instances, scope.catalog);
|
|
43
|
+
if (typeof def === "string") throw new Error(def);
|
|
44
|
+
checkSettingValue(def, operation.value);
|
|
45
|
+
}
|
|
46
|
+
return { fingerprint: configFingerprint(configSourceTexts(project.root)), operations,
|
|
47
|
+
lines: operations.map(operation => `${operation.key}: ${enabled ? settingText(operation.value) : "inherit"} (next hub/peer start)`) };
|
|
48
|
+
}
|
|
49
|
+
/** No consent or witnesses are created here. A trusted machine opt-in is checked independently by the next daemon. */
|
|
50
|
+
export function applyOfflineAutonomy(project: Target, environment: string, enabled: boolean, preview: OfflineAutonomyPlan): string[] {
|
|
51
|
+
const current = planOfflineAutonomy(project, environment, enabled);
|
|
52
|
+
if (preview.fingerprint !== current.fingerprint || JSON.stringify(preview.operations) !== JSON.stringify(current.operations)) throw new Error("project autonomy preview changed; nothing was written");
|
|
53
|
+
const { scope } = scopeOf(project.root);
|
|
54
|
+
const result = writeAutonomyPreset(project.root, project.stateDir, enabled, current.operations, scratch => {
|
|
55
|
+
// Keep original project path/trust semantics while validating the candidate bytes.
|
|
56
|
+
const candidate = loadConfig(project.root, readInstanceConfigLayers(scratch));
|
|
57
|
+
validateEffectiveConfig(candidate, scope.catalog);
|
|
58
|
+
});
|
|
59
|
+
return [`Project autonomy ${enabled ? "on" : "off"}: ${result.changed ? "saved" : "unchanged"}; applies at the next hub/peer start.`,
|
|
60
|
+
"Permission confirmation is checked against the trusted machine default when the hub starts."];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Stopped-hub status reports preset intent, never a claimed effective native mode or consent. */
|
|
64
|
+
export function offlineAutonomyStatus(project: Target, environment: string): Record<string, unknown> {
|
|
65
|
+
try {
|
|
66
|
+
const { peers } = scopeOf(project.root);
|
|
67
|
+
const floorEnforcement = Object.fromEntries(peers.map(peer => [peer.id, Object.fromEntries(autonomyPreset().floorClasses.map(action => [action,
|
|
68
|
+
{ class: action, status: "not-enforced", reason: "The hub is stopped; runtime floor enforcement is unverified." } satisfies AutonomyFloorStatus]))]));
|
|
69
|
+
const rows = autonomyStatus({ environment, peers, floorEnforcement });
|
|
70
|
+
return { ok: true, status: { environment, runtime: "stopped", consentState: "unknown", consentedPeers: [], peers: rows },
|
|
71
|
+
lines: ["The hub is stopped; effective runtime modes and retained consent are unknown.", ...rows.map(row => `${row.id}: ${row.effectiveMode}; preset ${row.nextMode ?? "unmanaged"} at ${row.applies}`)] };
|
|
72
|
+
} catch {
|
|
73
|
+
return { ok: true, status: { environment, runtime: "stopped", consentState: "unknown", consentedPeers: [], peers: [], missing: ["Project configuration could not be safely inspected."] }, lines: ["The hub is stopped and its project configuration could not be safely inspected; runtime modes and consent are unknown."] };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
|
+
import { readAutonomyDefaults, writeAutonomyDefaults } from "../hub/autonomy-defaults.ts";
|
|
4
|
+
import { autonomyConsentStateDir, createAutonomyConsent } from "../hub/autonomy-consent.ts";
|
|
5
|
+
import { canonicalPath, hubHome } from "../hub/project.ts";
|
|
6
|
+
import { insideRoot } from "../hub/path-policy.ts";
|
|
7
|
+
import { readInstanceRoleConfig } from "../hub/instance-config.ts";
|
|
8
|
+
import { ControlClient } from "../hub/control-client.ts";
|
|
9
|
+
import { inspectProject } from "../hub/lifecycle.ts";
|
|
10
|
+
import { processSignature } from "../pi/process-signature.ts";
|
|
11
|
+
import type { Project, Registry } from "../hub/registry.ts";
|
|
12
|
+
import { applyOfflineAutonomy, planOfflineAutonomy } from "./autonomy-offline.ts";
|
|
13
|
+
|
|
14
|
+
export interface PreparedAutonomyProject { lines: string[]; apply(): Promise<string[]>; close(): void }
|
|
15
|
+
export interface AllProjectAutonomyPorts {
|
|
16
|
+
home: string;
|
|
17
|
+
environment: string;
|
|
18
|
+
projects: readonly Project[];
|
|
19
|
+
prepare(project: Project, enabled: boolean): Promise<PreparedAutonomyProject>;
|
|
20
|
+
ask(prompt: string): Promise<string | null>;
|
|
21
|
+
print(line: string): void;
|
|
22
|
+
}
|
|
23
|
+
/** Init is already person-only. Inheritance writes no consent and starts no daemon. */
|
|
24
|
+
export async function inheritMachineAutonomy(project: Project, home: string, environment: string, registry: Registry): Promise<string[]> {
|
|
25
|
+
const defaults = readAutonomyDefaults(home, registry.list().map(row => row.root));
|
|
26
|
+
if (defaults.notice) return [defaults.notice];
|
|
27
|
+
if (!defaults.enabled) return [];
|
|
28
|
+
const prepared = await prepareAutonomyProject(project, environment, true, registry);
|
|
29
|
+
try { return ["Inherited the trusted machine autonomy default.", ...await prepared.apply()]; }
|
|
30
|
+
finally { prepared.close(); }
|
|
31
|
+
}
|
|
32
|
+
/** Registry ownership excludes a concurrent daemon starter while an offline write runs. */
|
|
33
|
+
export async function prepareAutonomyProject(project: Project, environment: string, enabled: boolean, registry: Registry): Promise<PreparedAutonomyProject> {
|
|
34
|
+
const consentHome = hubHome();
|
|
35
|
+
const inspection = await inspectProject(project);
|
|
36
|
+
if (inspection.state === "running") {
|
|
37
|
+
const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root, instanceId: inspection.status.instanceId });
|
|
38
|
+
const request = async (action: string, confirmation?: string) => {
|
|
39
|
+
const reply = await client.request({ t: "autonomy", action, environment, ...(enabled ? { confirmed: true, confirmation } : {}) }, 35_000);
|
|
40
|
+
if (reply.ok !== true) throw new Error(String(reply.error ?? "project autonomy request refused"));
|
|
41
|
+
return reply;
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
const preview = enabled ? await request("preview") : undefined;
|
|
45
|
+
if (enabled && typeof preview?.confirmation !== "string") throw new Error("project autonomy preview is unavailable; upgrade this hub");
|
|
46
|
+
return { lines: preview?.lines ?? ["Remove the fixed preset keys and retained consent, including manual edits to those keys."],
|
|
47
|
+
apply: async () => (await request(enabled ? "on" : "off", preview?.confirmation)).lines ?? [], close: () => client.close() };
|
|
48
|
+
} catch (error) { client.close(); throw error; }
|
|
49
|
+
}
|
|
50
|
+
if (inspection.state !== "stopped") throw new Error(inspection.error ?? `project is ${inspection.state}; autonomy was not changed`);
|
|
51
|
+
const preview = planOfflineAutonomy(project, environment, enabled);
|
|
52
|
+
return { lines: preview.lines, close: () => {}, apply: async () => {
|
|
53
|
+
const current = registry.get(project.id);
|
|
54
|
+
if (!current || current.root !== project.root || current.stateDir !== project.stateDir) throw new Error("project registration changed; autonomy was not changed");
|
|
55
|
+
const claim = randomUUID();
|
|
56
|
+
if (!registry.claim(project.id, claim, process.pid, processSignature(process.pid))) throw new Error("project acquired a live owner; autonomy was not changed");
|
|
57
|
+
try {
|
|
58
|
+
if (!enabled) {
|
|
59
|
+
try {
|
|
60
|
+
const scope = readInstanceRoleConfig(project.root), stateDir = autonomyConsentStateDir(consentHome, project.id), held = canonicalPath(stateDir);
|
|
61
|
+
const roots = [project.root, ...Object.values(scope.instances ?? {}).flatMap(instance => instance.worktree ? [resolve(project.root, instance.worktree)] : [])];
|
|
62
|
+
if (roots.some(root => insideRoot(relative(canonicalPath(root), held)))) throw new Error("private consent state is inside an execution root");
|
|
63
|
+
// Revocation only: this command never calls confirm or creates a consent key/witness.
|
|
64
|
+
// Keep the original name: clear's lstat must see and refuse a directory alias.
|
|
65
|
+
createAutonomyConsent(stateDir, project.id).clear();
|
|
66
|
+
} catch { throw new Error(`${project.root}: retained autonomy consent could not be revoked; project autonomy was not changed`); }
|
|
67
|
+
}
|
|
68
|
+
return applyOfflineAutonomy(project, environment, enabled, preview);
|
|
69
|
+
}
|
|
70
|
+
finally { registry.release(project.id, claim); }
|
|
71
|
+
} };
|
|
72
|
+
}
|
|
73
|
+
/** All on previews precede the one confirmation; partial apply failures are named rather than hidden. */
|
|
74
|
+
export async function runAllProjectAutonomy(args: readonly string[], ports: AllProjectAutonomyPorts): Promise<void> {
|
|
75
|
+
const [action, ...flags] = args;
|
|
76
|
+
if (!["on", "off"].includes(action ?? "") || !flags.includes("--all-projects") || new Set(flags).size !== flags.length || flags.some(flag => flag !== "--all-projects" && !(action === "on" && flag === "--yes"))) throw new Error("usage: ahub autonomy on [--yes] --all-projects | off --all-projects");
|
|
77
|
+
// ponytail: 256 project previews per confirmation; larger registries need paginated confirmations.
|
|
78
|
+
if (ports.projects.length > 256) throw new Error("at most 256 registered projects can be applied in one command");
|
|
79
|
+
const enabled = action === "on", roots = ports.projects.map(project => project.root), defaults = readAutonomyDefaults(ports.home, roots);
|
|
80
|
+
if (defaults.notice) ports.print(defaults.notice);
|
|
81
|
+
const prepared: { project: Project; plan: PreparedAutonomyProject }[] = [], failures: string[] = [];
|
|
82
|
+
try {
|
|
83
|
+
for (const project of ports.projects) {
|
|
84
|
+
try {
|
|
85
|
+
const plan = await ports.prepare(project, enabled);
|
|
86
|
+
prepared.push({ project, plan });
|
|
87
|
+
ports.print(`Project ${project.root}:`);
|
|
88
|
+
for (const line of plan.lines) ports.print(` ${line}`);
|
|
89
|
+
} catch (error) { failures.push(project.id); ports.print(`${project.root}: ${String((error as Error).message)}`); }
|
|
90
|
+
}
|
|
91
|
+
if (enabled && failures.length) throw new Error(`${failures.length} project preview(s) refused; no machine or project setting was changed`);
|
|
92
|
+
ports.print(`Machine autonomy default: ${enabled ? "on" : "off"}; ${prepared.length} registered project(s).`);
|
|
93
|
+
if (enabled && !flags.includes("--yes") && !["y", "yes"].includes((await ports.ask("Apply machine autonomy and every previewed project? [y/N] "))?.trim().toLowerCase() ?? "")) { ports.print("Machine and project autonomy were not changed."); return; }
|
|
94
|
+
if (!enabled && defaults.fingerprint === "untrusted") ports.print("Unsafe machine defaults are already ignored; their file was left unchanged.");
|
|
95
|
+
else writeAutonomyDefaults(ports.home, enabled, defaults.fingerprint, roots);
|
|
96
|
+
for (const { project, plan } of prepared) {
|
|
97
|
+
try { for (const line of await plan.apply()) ports.print(`${project.root}: ${line}`); }
|
|
98
|
+
catch (error) { failures.push(project.id); ports.print(`${project.root}: ${String((error as Error).message)}`); }
|
|
99
|
+
}
|
|
100
|
+
if (failures.length) throw new Error(`Machine default is ${enabled ? "on" : "off"}; ${failures.length} project(s) were not applied. Inspect the named projects before retrying.`);
|
|
101
|
+
ports.print(`Machine autonomy ${action}: applied to ${prepared.length} registered project(s).`);
|
|
102
|
+
} finally { for (const { plan } of prepared) plan.close(); }
|
|
103
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export { autonomyEnvironment } from "../hub/autonomy.ts";
|
|
2
|
+
export const AUTONOMY_USAGE = "usage: ahub autonomy on [--all-projects] [--yes] | off [--all-projects] | status [--json]";
|
|
3
|
+
export interface AutonomyCliPorts {
|
|
4
|
+
environment: string;
|
|
5
|
+
request(message: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
6
|
+
ask(prompt: string): Promise<string | null>;
|
|
7
|
+
print(line: string): void;
|
|
8
|
+
contextLines?: readonly string[];
|
|
9
|
+
confirmPrompt?: string;
|
|
10
|
+
beforeApply?(action: "on" | "off"): Promise<void> | void;
|
|
11
|
+
decorateStatus?(reply: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
12
|
+
}
|
|
13
|
+
/** Authority is checked by the ordinary CLI identity gate and independently by the daemon. */
|
|
14
|
+
export async function runAutonomy(args: string[], ports: AutonomyCliPorts): Promise<string | undefined> {
|
|
15
|
+
const [action, flag, ...extra] = args;
|
|
16
|
+
if (!action || !["on", "off", "status"].includes(action) || extra.length || flag !== undefined && !(action === "on" && flag === "--yes" || action === "status" && flag === "--json")) return AUTONOMY_USAGE;
|
|
17
|
+
if (action !== "status") for (const line of ports.contextLines ?? []) ports.print(line);
|
|
18
|
+
let confirmation: string | undefined;
|
|
19
|
+
const request = async (action: string) => {
|
|
20
|
+
const reply = await ports.request({ t: "autonomy", action, environment: ports.environment, ...(action === "on" ? { confirmed: true, confirmation } : {}) });
|
|
21
|
+
if (reply.ok !== true) throw new Error(`${typeof reply.error === "string" ? reply.error : "autonomy request refused"}${reply.saved === true ? "; project configuration was saved; inspect ahub autonomy status or ahub settings undo" : ""}`);
|
|
22
|
+
return reply;
|
|
23
|
+
};
|
|
24
|
+
if (action === "on") {
|
|
25
|
+
const preview = await request("preview");
|
|
26
|
+
if (typeof preview.confirmation !== "string" || !preview.confirmation) throw new Error("autonomy confirmation preview is unavailable; upgrade the running hub");
|
|
27
|
+
confirmation = preview.confirmation;
|
|
28
|
+
for (const line of Array.isArray(preview.lines) ? preview.lines : []) if (typeof line === "string") ports.print(line);
|
|
29
|
+
if (flag !== "--yes" && (await ports.ask(ports.confirmPrompt ?? "Apply project autonomy? Approval prompts change; sandboxes and trust stay in force. [y/N] "))?.trim().toLowerCase() !== "y") {
|
|
30
|
+
ports.print("Project autonomy was not changed."); return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
let machineApplied = false;
|
|
34
|
+
if (action === "on" || action === "off") { await ports.beforeApply?.(action); machineApplied = ports.beforeApply !== undefined; }
|
|
35
|
+
let reply: Record<string, unknown>;
|
|
36
|
+
try { reply = await request(action); }
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (machineApplied) throw new Error(`${error instanceof Error ? error.message : String(error)}; Machine autonomy default was set to ${action}; project apply failed. Inspect ahub autonomy status before retrying.`);
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
if (action === "status" && ports.decorateStatus) reply = await ports.decorateStatus(reply);
|
|
42
|
+
if (action === "status" && args.includes("--json")) ports.print(JSON.stringify(reply.status));
|
|
43
|
+
else for (const line of Array.isArray(reply.lines) ? reply.lines : typeof reply.text === "string" ? [reply.text] : []) if (typeof line === "string") ports.print(line);
|
|
44
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { lstatSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { realPath } from "../hub/project.ts";
|
|
4
|
+
import { launcherOf, recordPath, MAX_COMMAND_MS, type ProcessIdentity } from "./terminal-recovery.ts";
|
|
5
|
+
import { CLAUDE_PLUGIN_INVENTORY, nextStep, pluginState, type InstalledPlugin, type Marketplace, type PluginState, type SetupStep } from "./setup.ts";
|
|
6
|
+
import { checkClaudeProfileHook, type ClaudeProfileHookCheck } from "./claude-profile-hook.ts";
|
|
7
|
+
|
|
8
|
+
export type ClaudeHomeSource = "environment" | "default" | "recorded-launch";
|
|
9
|
+
export interface ClaudeHome { path: string; exists: boolean; sources: ClaudeHomeSource[] }
|
|
10
|
+
/** The caller selects Claude channel peers from the resolved project catalog, never from an id spelling. */
|
|
11
|
+
export interface ClaudeHomeProject { root: string; stateDir: string; instanceId: string; claudePeers: readonly string[] }
|
|
12
|
+
export interface ClaudeHomeDiscovery { homes: ClaudeHome[]; notices: string[]; complete: boolean }
|
|
13
|
+
export interface ClaudeHomeDiscoveryInput {
|
|
14
|
+
home: string;
|
|
15
|
+
env: NodeJS.ProcessEnv;
|
|
16
|
+
projects?: readonly ClaudeHomeProject[];
|
|
17
|
+
processIdentity?: ProcessIdentity;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ponytail: 128 live launch candidates and 1 MiB per private record file bound local discovery;
|
|
21
|
+
// a larger machine needs a paginated host inventory, never a profile-directory scan.
|
|
22
|
+
const MAX_CANDIDATES = 128;
|
|
23
|
+
const MAX_RECORD_BYTES = 1024 * 1024;
|
|
24
|
+
const validPath = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 4096 && isAbsolute(value) && !/[\x00-\x1f\x7f]/.test(value);
|
|
25
|
+
|
|
26
|
+
/** No credentials, native profile caches or arbitrary profile directories are inspected. */
|
|
27
|
+
export async function discoverClaudeHomes(input: ClaudeHomeDiscoveryInput): Promise<ClaudeHomeDiscovery> {
|
|
28
|
+
const homes = new Map<string, ClaudeHome>(), notices: string[] = [];
|
|
29
|
+
let complete = true, candidates = 0;
|
|
30
|
+
const unavailable = (message: string) => { complete = false; if (!notices.includes(message)) notices.push(message); };
|
|
31
|
+
const add = (value: unknown, source: ClaudeHomeSource, requireExisting = false) => {
|
|
32
|
+
if (!validPath(value)) { unavailable("A Claude configuration home path is invalid; no home was selected from that evidence."); return; }
|
|
33
|
+
try {
|
|
34
|
+
const info = statSync(value, { throwIfNoEntry: false });
|
|
35
|
+
if (info && !info.isDirectory()) { unavailable("A selected Claude configuration home is not a directory."); return; }
|
|
36
|
+
if (!info && requireExisting) return;
|
|
37
|
+
const path = info ? realPath(value) : value;
|
|
38
|
+
const previous = homes.get(path);
|
|
39
|
+
if (previous) { if (!previous.sources.includes(source)) previous.sources.push(source); }
|
|
40
|
+
else homes.set(path, { path, exists: !!info, sources: [source] });
|
|
41
|
+
} catch { unavailable("A selected Claude configuration home could not be inspected."); }
|
|
42
|
+
};
|
|
43
|
+
if (input.env.CLAUDE_CONFIG_DIR !== undefined && input.env.CLAUDE_CONFIG_DIR !== "") add(input.env.CLAUDE_CONFIG_DIR, "environment");
|
|
44
|
+
else if (validPath(input.home)) add(join(input.home, ".claude"), "default", true);
|
|
45
|
+
else unavailable("The user home is unavailable; no default Claude home was selected.");
|
|
46
|
+
|
|
47
|
+
for (const project of input.projects ?? []) {
|
|
48
|
+
if (++candidates > MAX_CANDIDATES) { unavailable("Claude home discovery reached its live-launch candidate limit."); break; }
|
|
49
|
+
if (!validPath(project.root) || !validPath(project.stateDir) || !project.instanceId || project.instanceId.length > 256) {
|
|
50
|
+
unavailable("A project has invalid live-launch scope; its Claude homes were not selected."); continue;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const info = lstatSync(recordPath(project.stateDir), { throwIfNoEntry: false });
|
|
54
|
+
if (!info) continue;
|
|
55
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > MAX_RECORD_BYTES || (info.mode & 0o022) !== 0 || !process.getuid || info.uid !== process.getuid()) {
|
|
56
|
+
unavailable("A project launch record is unsafe or exceeds the discovery size limit."); continue;
|
|
57
|
+
}
|
|
58
|
+
} catch { unavailable("A project launch record could not be inspected."); continue; }
|
|
59
|
+
for (const peer of project.claudePeers) {
|
|
60
|
+
if (++candidates > MAX_CANDIDATES) { unavailable("Claude home discovery reached its live-launch candidate limit."); break; }
|
|
61
|
+
if (!/^[a-z][a-z0-9_-]{0,63}$/.test(peer)) { unavailable("A project has an invalid Claude peer id."); continue; }
|
|
62
|
+
try {
|
|
63
|
+
const launch = await launcherOf(peer, project.root, { stateDir: project.stateDir, instanceId: project.instanceId, ...(input.processIdentity ? { processIdentity: input.processIdentity } : {}) });
|
|
64
|
+
if (!launch || launch.state === "gone") continue;
|
|
65
|
+
if (launch.state !== "live" || !launch.record) { unavailable("A Claude launch cannot be proven live; its configuration home was not selected."); continue; }
|
|
66
|
+
const profile = launch.record.env.CLAUDE_CONFIG_DIR;
|
|
67
|
+
if (profile !== undefined && profile !== "") add(profile, "recorded-launch");
|
|
68
|
+
else if (validPath(input.home)) add(join(input.home, ".claude"), "recorded-launch", true);
|
|
69
|
+
else unavailable("A live Claude launch has no usable configuration home.");
|
|
70
|
+
} catch { unavailable("A Claude launch could not be inspected; its configuration home was not selected."); }
|
|
71
|
+
}
|
|
72
|
+
if (candidates > MAX_CANDIDATES) break;
|
|
73
|
+
}
|
|
74
|
+
// Claude uses its default home on first launch too; absence is not evidence of an unused profile.
|
|
75
|
+
if (!homes.size && !input.env.CLAUDE_CONFIG_DIR && validPath(input.home)) add(join(input.home, ".claude"), "default");
|
|
76
|
+
return { homes: [...homes.values()], notices, complete };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A fresh child environment for each home; never mutate process.env while visiting profiles. */
|
|
80
|
+
export function claudeHomeEnvironment(home: Pick<ClaudeHome, "path">, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
81
|
+
if (!validPath(home.path)) throw new Error("invalid Claude configuration home");
|
|
82
|
+
return { ...env, CLAUDE_CONFIG_DIR: home.path };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ClaudeHomeProbeOptions { env: NodeJS.ProcessEnv; timeoutMs: number }
|
|
86
|
+
/** Implementations must enforce timeoutMs at their subprocess boundary. Only the two JSON reads are requested. */
|
|
87
|
+
export type ClaudeHomeProbe = (argv: readonly string[], options: ClaudeHomeProbeOptions) => { status: number; stdout: string } | Promise<{ status: number; stdout: string }>;
|
|
88
|
+
export interface ClaudeHomeCheck {
|
|
89
|
+
home: ClaudeHome;
|
|
90
|
+
plugin: PluginState | { state: "unknown"; reason: string };
|
|
91
|
+
next?: SetupStep;
|
|
92
|
+
hook: ClaudeProfileHookCheck;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Read-only per-home plugin inspection. Installation never proves that a live permission hook is present. */
|
|
96
|
+
export async function checkClaudeHome(home: ClaudeHome, packageRoot: string, env: NodeJS.ProcessEnv, probe: ClaudeHomeProbe, timeoutMs = MAX_COMMAND_MS, probeMissingDefault = false): Promise<ClaudeHomeCheck> {
|
|
97
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_COMMAND_MS) throw new Error("invalid Claude plugin probe time bound");
|
|
98
|
+
const hook = checkClaudeProfileHook(home);
|
|
99
|
+
const unknown = (): ClaudeHomeCheck => ({ home, plugin: { state: "unknown", reason: "Claude plugin inventory is unavailable or invalid for this home." }, hook });
|
|
100
|
+
if (hook.state === "refused") return unknown();
|
|
101
|
+
if (!home.exists && !(probeMissingDefault && home.sources.includes("default"))) return { home, plugin: { state: "missing" }, next: nextStep([], [], packageRoot), hook };
|
|
102
|
+
const scoped = claudeHomeEnvironment(home, env), deadline = performance.now() + timeoutMs;
|
|
103
|
+
const read = async (argv: readonly string[]): Promise<unknown[] | undefined> => {
|
|
104
|
+
const remaining = Math.floor(deadline - performance.now());
|
|
105
|
+
if (remaining < 1) return;
|
|
106
|
+
const result = await probe(argv, { env: scoped, timeoutMs: remaining });
|
|
107
|
+
if (result.status !== 0 || Buffer.byteLength(result.stdout, "utf8") > MAX_RECORD_BYTES || performance.now() > deadline) return;
|
|
108
|
+
const value: unknown = JSON.parse(result.stdout);
|
|
109
|
+
return Array.isArray(value) ? value : undefined;
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
const plugins = await read(CLAUDE_PLUGIN_INVENTORY.plugins);
|
|
113
|
+
if (!plugins) return unknown();
|
|
114
|
+
const marketplaces = await read(CLAUDE_PLUGIN_INVENTORY.marketplaces);
|
|
115
|
+
if (!marketplaces) return unknown();
|
|
116
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
117
|
+
if (!plugins.every(row => object(row) && typeof row.id === "string" && row.id.length <= 256 &&
|
|
118
|
+
(row.version === undefined || typeof row.version === "string" && row.version.length <= 64 && /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(row.version)) &&
|
|
119
|
+
(row.installPath === undefined || validPath(row.installPath))) ||
|
|
120
|
+
!marketplaces.every(row => object(row) && typeof row.name === "string" && row.name.length <= 256 && (row.path === undefined || validPath(row.path)))) return unknown();
|
|
121
|
+
return { home, plugin: pluginState(plugins as InstalledPlugin[], packageRoot), next: nextStep(plugins as InstalledPlugin[], marketplaces as Marketplace[], packageRoot), hook };
|
|
122
|
+
} catch { return unknown(); }
|
|
123
|
+
}
|