@staix/agent-hub 0.32.0 → 0.34.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 +33 -0
- package/README.md +2 -2
- package/docs/design/bus.md +68 -2
- package/docs/dogfood.md +3 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +124 -20
- package/docs/quickstart.md +32 -2
- package/docs/security.md +28 -9
- package/docs/specs/2026-09-19-agent-hub-design.md +36 -6
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
- package/docs/verification/2026-11-06-0.33.0.md +222 -0
- package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
- package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
- package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
- package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
- package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
- package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
- package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
- package/docs/verification/2026-11-07-0.34.0.md +71 -0
- package/docs/verification/2026-11-07-compat-receipt-audit-1248.md +256 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +11 -11
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +528 -206
- package/src/adapters/compat.ts +11 -11
- package/src/adapters/definitions/native-cli-claude-setup.ts +14 -5
- package/src/broker/protocol.ts +5 -0
- package/src/broker/server.ts +54 -4
- package/src/broker/store.ts +6 -3
- package/src/cli/autonomy-homes.ts +12 -3
- package/src/cli/claude-homes.ts +5 -5
- package/src/cli/claude-profile-hook.ts +38 -9
- package/src/cli/install-paths.ts +71 -0
- package/src/cli/main.ts +20 -1
- package/src/cli/output.ts +5 -1
- package/src/cli/room.ts +17 -5
- package/src/cli/runtime-version-warning.ts +8 -6
- package/src/cli/setup-homes.ts +13 -10
- package/src/cli/status-lines.ts +8 -1
- package/src/cli/terminal-recovery.ts +96 -18
- package/src/cli/upgrade-interactive.ts +29 -6
- package/src/cli/upgrade-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +34 -4
- package/src/cli/upgrade.ts +3 -1
- package/src/hub/bus.ts +7 -0
- package/src/hub/daemon.ts +13 -4
- package/src/hub/handoff.ts +1 -1
- package/src/rooms/application.ts +15 -0
- package/src/rooms/commands.ts +76 -16
- package/src/rooms/health.ts +32 -0
- package/src/rooms/link.ts +267 -43
- package/src/rooms/remote-peer.ts +18 -3
- package/src/rooms/service.ts +27 -12
- package/src/rooms/status.ts +57 -1
- package/src/rooms/store.ts +20 -1
- package/src/rooms/wire.ts +10 -8
- package/src/ui/index.html +1 -1
package/src/adapters/compat.ts
CHANGED
|
@@ -34,21 +34,21 @@ 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 and 1.5.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
|
|
36
36
|
receipt: "docs/verification/2026-11-03-pi-150-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; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; 2.1.318: docs/verification/2026-11-05-claude-implementer-2.1.318.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", "2.1.318"], 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; 2.1.318: docs/verification/2026-11-05-claude-implementer-2.1.318.md; 2.1.319: docs/verification/2026-11-06-claude-implementer-2.1.319.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", "2.1.318", "2.1.319"], 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-11-
|
|
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
|
|
41
|
+
receipt: "docs/verification/2026-11-06-claude-implementer-2.1.319.md" },
|
|
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; 0.167.0 model-free only, not verified until its model-backed run: docs/verification/2026-11-06-codex-0167-reviewer-isolation.md
|
|
43
43
|
"codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0", "0.166.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
|
|
44
44
|
parse: out => plain(out.trim().replace(/^codex-cli /, "")),
|
|
45
45
|
trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md).",
|
|
46
|
-
receipt: "docs/verification/2026-
|
|
47
|
-
// Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md; Kimi 2.3.1: docs/verification/2026-10-25-kimi-231-reviewer-compat.md; Kimi 2.3.2: docs/verification/2026-10-26-kimi-232-reviewer-compat.md; Kimi 2.4.1: docs/verification/2026-10-31-kimi-241-reviewer-compat.md
|
|
48
|
-
"kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0", "2.3.1", "2.3.2", "2.4.1"], gate: "isolation", probe: ["kimi", "--version"],
|
|
46
|
+
receipt: "docs/verification/2026-11-06-codex-0167-reviewer-isolation.md" },
|
|
47
|
+
// Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md; Kimi 2.3.1: docs/verification/2026-10-25-kimi-231-reviewer-compat.md; Kimi 2.3.2: docs/verification/2026-10-26-kimi-232-reviewer-compat.md; Kimi 2.4.1: docs/verification/2026-10-31-kimi-241-reviewer-compat.md; Kimi 2.5.1: docs/verification/2026-11-06-kimi-251-reviewer-compat.md
|
|
48
|
+
"kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0", "2.3.1", "2.3.2", "2.4.1", "2.5.1"], gate: "isolation", probe: ["kimi", "--version"],
|
|
49
49
|
parse: out => plain(out.trim()),
|
|
50
50
|
trustNote: "Every launch still builds the private config, exact two-tool agent file and task-bound MCP; the native tool allowlist behaviour of an untested version is not re-proven.",
|
|
51
|
-
receipt: "docs/verification/2026-
|
|
51
|
+
receipt: "docs/verification/2026-11-06-kimi-251-reviewer-compat.md" },
|
|
52
52
|
jcode: { label: "Jcode", verified: ["0.98.1", "0.99.0", "0.99.1"], line: { min: "0.98.0", below: "1.0.0" }, gate: "contract", probe: ["jcode", "--quiet", "version", "--json"], config: "jcode_cmd",
|
|
53
53
|
parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
|
|
54
54
|
trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and 0.99.1 and steering on 0.99.0 and 0.99.1; an untested version may change them.",
|
|
@@ -57,10 +57,10 @@ export const COMPAT = {
|
|
|
57
57
|
parse: out => plain(out.trim()),
|
|
58
58
|
trustNote: "Only 1.4.2, 1.4.7 and 1.4.12 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
|
|
59
59
|
receipt: "docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md" },
|
|
60
|
-
qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4", "0.26.5"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
|
|
60
|
+
qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4", "0.26.5", "0.26.8"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
|
|
61
61
|
parse: out => plain(out.trim()),
|
|
62
|
-
trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4 and 0.26.
|
|
63
|
-
receipt: "docs/verification/2026-11-
|
|
62
|
+
trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4, 0.26.5 and 0.26.8 (macOS arm64 and Linux x86_64); real model turns remain unverified. Another version may change those contracts.",
|
|
63
|
+
receipt: "docs/verification/2026-11-06-qwen-0268-acp.md" },
|
|
64
64
|
// Receipt: docs/verification/2026-10-25-qwen-dual-output-t0.md. The dual-output contract (input file, event file, can_use_tool control requests) is its own entry:
|
|
65
65
|
// the ACP receipts of the headless `qwen` entry say nothing about it, so that entry is not widened and this one carries no `line`.
|
|
66
66
|
"qwen-dual": { label: "Qwen Code dual-output TUI", verified: ["0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { stablePackageRoot } from "../../cli/install-paths.ts";
|
|
3
5
|
import { realPath } from "../../hub/project.ts";
|
|
4
6
|
import { VERSION } from "../../version.ts";
|
|
5
7
|
|
|
@@ -53,11 +55,11 @@ export type PluginState =
|
|
|
53
55
|
| { state: "stale"; version: string; why: string; notices?: string[] };
|
|
54
56
|
|
|
55
57
|
const safeDisplayValue = (value: unknown): string | undefined =>
|
|
56
|
-
typeof value === "string" && value.length <= 4096 && !/[\
|
|
58
|
+
typeof value === "string" && value.length <= 4096 && value.isWellFormed() && !/[\p{Cc}\p{Cf}]/u.test(value) ? value : undefined;
|
|
57
59
|
|
|
58
60
|
function nonUserPluginNotices(plugins: InstalledPlugin[]): string[] {
|
|
59
61
|
return plugins.filter(plugin => plugin.id === PLUGIN && plugin.scope !== "user").map(plugin => {
|
|
60
|
-
const scope = plugin.scope
|
|
62
|
+
const scope = safeDisplayValue(plugin.scope) ?? "unknown-scope";
|
|
61
63
|
const path = safeDisplayValue(plugin.projectPath) ?? safeDisplayValue(plugin.installPath);
|
|
62
64
|
const version = safeDisplayValue(plugin.version) ?? "unknown";
|
|
63
65
|
return `${scope} plugin row preserved${path ? ` at ${path}` : ""} (version ${version}); setup never removes non-user scopes`;
|
|
@@ -90,14 +92,21 @@ export interface SetupStep {
|
|
|
90
92
|
* The next thing `ahub setup` has to do, or nothing. One step at a time: the state is read again after every step,
|
|
91
93
|
* because what a step leaves behind is Claude Code's business (removing a marketplace may uninstall its plugins).
|
|
92
94
|
*/
|
|
93
|
-
export function nextStep(plugins: InstalledPlugin[], marketplaces: Marketplace[], packageRoot: string): SetupStep | undefined {
|
|
95
|
+
export function nextStep(plugins: InstalledPlugin[], marketplaces: Marketplace[], packageRoot: string, personHome = homedir()): SetupStep | undefined {
|
|
94
96
|
const root = real(packageRoot);
|
|
95
97
|
const market = marketplaces.find((m) => m.name === MARKETPLACE);
|
|
96
98
|
if (market && real(market.path ?? "") !== root) {
|
|
97
|
-
|
|
99
|
+
const blockers = plugins.filter(plugin => plugin.id === PLUGIN && plugin.scope !== "user");
|
|
100
|
+
if (blockers.length) {
|
|
101
|
+
const display = (value: unknown) => JSON.stringify(safeDisplayValue(value) ?? "unknown");
|
|
102
|
+
const rows = blockers.map(plugin => `scope=${display(plugin.scope)}, projectPath=${display(plugin.projectPath)}, version=${display(plugin.version)}, installPath=${display(plugin.installPath)}`).join("; ");
|
|
103
|
+
const homeProject = blockers.some(plugin => safeDisplayValue(plugin.projectPath) !== undefined && real(plugin.projectPath!) === real(personHome));
|
|
104
|
+
const danger = homeProject ? ` Do not run 'claude plugin uninstall --scope project' from $HOME (${display(personHome)}): its project settings file ~/.claude/settings.json (${display(join(personHome, ".claude/settings.json"))}) is also the default home's user settings.` : "";
|
|
105
|
+
return { why: `the ${MARKETPLACE} marketplace points at another source; removing it may remove non-user plugin rows. Blocking rows: ${rows}. Hub-launched Claude sessions load their plugin from ~/.agenthub/releases (${display(join(personHome, ".agenthub/releases"))}), not from these plugin-cache rows. Standalone Claude sessions may still use them.${danger} Next: back up the selected Claude home's plugins/installed_plugins.json, review every listed row, and manually remove only rows confirmed stale and unused, preserving every other row; then rerun ahub setup. Setup never removes these rows automatically`, argv: [], blocked: true };
|
|
106
|
+
}
|
|
98
107
|
return { why: `the ${MARKETPLACE} marketplace points at ${market.path ?? "another source"}, not at this package`, argv: ["claude", "plugin", "marketplace", "remove", MARKETPLACE] };
|
|
99
108
|
}
|
|
100
|
-
if (!market) return { why: "register this package as a Claude Code plugin marketplace", argv: ["claude", "plugin", "marketplace", "add", root] };
|
|
109
|
+
if (!market) return { why: "register this package as a Claude Code plugin marketplace", argv: ["claude", "plugin", "marketplace", "add", stablePackageRoot(root)] };
|
|
101
110
|
const state = pluginState(plugins, root);
|
|
102
111
|
if (state.state === "missing") return { why: "install the user-scope channel plugin", argv: ["claude", "plugin", "install", PLUGIN, "--scope", "user"] };
|
|
103
112
|
// Uninstall rather than update: with an unchanged version number an update has nothing to do.
|
package/src/broker/protocol.ts
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
type OwnerPin, type VerifiedPublicRoster,
|
|
9
9
|
} from "../rooms/wire.ts";
|
|
10
10
|
|
|
11
|
+
/** Optional codec header token; the existing ephemeral opt-in remains byte-for-byte unchanged. */
|
|
12
|
+
export const BROKER_CODEC_FEATURES_OPT_IN = "1";
|
|
11
13
|
export const BROKER_CONTROL_LIMITS = Object.freeze({ control: 1_048_576, challenge: 8192, nonceBytes: 32 });
|
|
12
14
|
export type BrokerProtocolCode = "invalid_wire" | "limit" | "untrusted" | "expired" | "authentication_failed";
|
|
13
15
|
export class BrokerProtocolError extends Error {
|
|
@@ -35,6 +37,9 @@ export interface BrokerAuth {
|
|
|
35
37
|
roster: string | null;
|
|
36
38
|
signature: string;
|
|
37
39
|
}
|
|
40
|
+
/** Broker-to-client transport evidence; never accepted as client control or authority. */
|
|
41
|
+
export interface BrokerCodecPeer { member: string; session: string | null; supported: boolean }
|
|
42
|
+
export interface BrokerCodecPeers { v: 1; type: "codec_peers"; digest: string; peers: BrokerCodecPeer[] }
|
|
38
43
|
export interface BrokerRosterReady { v: 1; type: "roster_ready"; digest: string }
|
|
39
44
|
export interface BrokerReceived { v: 1; type: "received"; digest: string }
|
|
40
45
|
export type BrokerControl = BrokerAuth | BrokerRosterReady | BrokerReceived;
|
package/src/broker/server.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { ServerWebSocket } from "bun";
|
|
3
3
|
import { assertRoomCryptoReady, decodeBase64url, encodeBase64url, randomBytes } from "../rooms/crypto.ts";
|
|
4
4
|
import { brokerUrl, canonicalBytes, decodeFrame, decodeRoster, publicRosterWire, verifyPublicFrame, verifyPublicRoster, type VerifiedPublicRoster } from "../rooms/wire.ts";
|
|
5
|
-
import { BROKER_CONTROL_LIMITS, decodeBrokerControl, encodeBrokerChallenge, verifyBrokerAuth, type BrokerChallenge } from "./protocol.ts";
|
|
5
|
+
import { BROKER_CODEC_FEATURES_OPT_IN, BROKER_CONTROL_LIMITS, decodeBrokerControl, encodeBrokerChallenge, verifyBrokerAuth, type BrokerChallenge } from "./protocol.ts";
|
|
6
6
|
import { BrokerStore, BrokerStoreError, type BrokerLimits } from "./store.ts";
|
|
7
7
|
|
|
8
8
|
export const BROKER_SOCKET_LIMITS = Object.freeze({ connections: 128, verifications: 16, authMs: 10_000, rosterAckMs: 30_000, backpressureBytes: 1_048_576, queuedMessages: 8, queuedBytes: 2_097_152, globalQueuedBytes: 33_554_432 });
|
|
@@ -26,6 +26,10 @@ interface Client {
|
|
|
26
26
|
ws?: ServerWebSocket<Client>;
|
|
27
27
|
room: string;
|
|
28
28
|
ephemeral: boolean;
|
|
29
|
+
codecFeatures: boolean;
|
|
30
|
+
codecSession: string;
|
|
31
|
+
pendingCodecMembers: Set<string>;
|
|
32
|
+
codecFlushQueued: boolean;
|
|
29
33
|
challenge: BrokerChallenge;
|
|
30
34
|
phase: "challenge" | "authenticating" | "roster" | "ready" | "closed";
|
|
31
35
|
busy: boolean;
|
|
@@ -108,11 +112,13 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
108
112
|
function close(client: Client, code: string): void {
|
|
109
113
|
if (client.phase === "closed") return;
|
|
110
114
|
client.phase = "closed";
|
|
115
|
+
client.pendingCodecMembers.clear(); client.codecFlushQueued = false;
|
|
111
116
|
client.inFlight = undefined; client.inFlightEphemeral = undefined; client.inFlightExpiresAt = undefined; client.inFlightEpoch = undefined;
|
|
112
117
|
queuedBytes -= client.inboxBytes; client.inboxBytes = 0; client.inbox.length = 0;
|
|
113
118
|
if (client.member && members.get(memberKey(client)) === client) {
|
|
114
119
|
members.delete(memberKey(client));
|
|
115
120
|
try { store.disconnect(client.room, client.member); } catch { emit("refused", client, "state_unavailable"); }
|
|
121
|
+
notifyCodecPeers(client.room, client.member);
|
|
116
122
|
}
|
|
117
123
|
if (client.ws) { try { client.ws.close(4403, code); } catch { /* already closed */ } }
|
|
118
124
|
else clients.delete(client);
|
|
@@ -137,15 +143,55 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
137
143
|
if (!roster || !member || identityKey(member) !== client.key) throw new BrokerServerError("untrusted");
|
|
138
144
|
return roster;
|
|
139
145
|
}
|
|
146
|
+
/** Connection evidence only: the sender must still echo its session in signed presence. */
|
|
147
|
+
function sendCodecPeers(recipient: Client, memberIds: readonly string[]): void {
|
|
148
|
+
if (!recipient.codecFeatures || recipient.phase !== "ready") return;
|
|
149
|
+
try {
|
|
150
|
+
const roster = active(recipient);
|
|
151
|
+
const peers = memberIds.filter(member => member !== recipient.member && roster.members.some(row => row.fingerprint === member)).map(member => {
|
|
152
|
+
const source = members.get(`${recipient.room}|${member}`);
|
|
153
|
+
const current = source?.phase === "ready" && active(source).digest === roster.digest ? source : undefined;
|
|
154
|
+
return { member, session: current?.codecSession ?? null, supported: current?.codecFeatures === true };
|
|
155
|
+
});
|
|
156
|
+
if (peers.length) control(recipient, { v: 1, type: "codec_peers", digest: roster.digest, peers });
|
|
157
|
+
} catch { close(recipient, "state_changed"); }
|
|
158
|
+
}
|
|
159
|
+
function flushCodecPeers(client: Client): void {
|
|
160
|
+
const pending = [...client.pendingCodecMembers]; client.pendingCodecMembers.clear();
|
|
161
|
+
if (pending.length) sendCodecPeers(client, pending);
|
|
162
|
+
}
|
|
163
|
+
function notifyCodecPeers(room: string, member: string): void {
|
|
164
|
+
for (const recipient of clients) if (recipient.room === room && recipient.codecFeatures && recipient.phase === "ready") {
|
|
165
|
+
try {
|
|
166
|
+
const current = new Set(active(recipient).members.map(row => row.fingerprint));
|
|
167
|
+
// At most the signed roster's members can wait; churn replaces evidence
|
|
168
|
+
// rather than growing a control queue beyond the existing packet ceiling.
|
|
169
|
+
for (const pending of recipient.pendingCodecMembers) if (!current.has(pending)) recipient.pendingCodecMembers.delete(pending);
|
|
170
|
+
if (member === recipient.member || !current.has(member)) continue;
|
|
171
|
+
recipient.pendingCodecMembers.add(member);
|
|
172
|
+
if (!recipient.codecFlushQueued) {
|
|
173
|
+
recipient.codecFlushQueued = true;
|
|
174
|
+
queueMicrotask(() => { recipient.codecFlushQueued = false; flushCodecPeers(recipient); });
|
|
175
|
+
}
|
|
176
|
+
} catch { close(recipient, "state_changed"); }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function codecInventory(client: Client): void {
|
|
180
|
+
if (!client.codecFeatures || client.phase !== "ready") return;
|
|
181
|
+
client.pendingCodecMembers.clear();
|
|
182
|
+
sendCodecPeers(client, active(client).members.map(member => member.fingerprint));
|
|
183
|
+
}
|
|
140
184
|
function sendRoster(client: Client): void {
|
|
141
185
|
const roster = active(client);
|
|
142
186
|
client.phase = "roster"; client.rosterDigest = roster.digest;
|
|
143
187
|
client.rosterDeadline = now() + BROKER_SOCKET_LIMITS.rosterAckMs;
|
|
144
188
|
// Legacy hubs have a closed ready grammar; advertise only after HTTP opt-in.
|
|
145
|
-
if (send(client, publicRosterWire(roster))) control(client, { v: 1, type: "ready", room: client.room, member: client.member, epoch: roster.epoch, digest: roster.digest, ...(client.ephemeral ? { ephemeral: true } : {}) });
|
|
189
|
+
if (send(client, publicRosterWire(roster))) control(client, { v: 1, type: "ready", room: client.room, member: client.member, epoch: roster.epoch, digest: roster.digest, ...(client.ephemeral ? { ephemeral: true } : {}), ...(client.codecFeatures ? { codecFeatures: true, codecSession: client.codecSession } : {}) });
|
|
146
190
|
}
|
|
147
191
|
function pump(client: Client): void {
|
|
148
192
|
if (stopping || client.phase === "closed" || !client.member) return;
|
|
193
|
+
flushCodecPeers(client); // Source-session invalidation always precedes ciphertext handoff.
|
|
194
|
+
if (members.get(memberKey(client)) !== client) return;
|
|
149
195
|
const roster = active(client);
|
|
150
196
|
const next = store.next(client.room, client.member);
|
|
151
197
|
if (client.inFlightEphemeral && client.inFlightEpoch !== roster.epoch) {
|
|
@@ -192,6 +238,7 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
192
238
|
if (previous && previous !== client) close(previous, "replaced");
|
|
193
239
|
members.set(memberKey(client), client);
|
|
194
240
|
client.phase = "roster";
|
|
241
|
+
notifyCodecPeers(client.room, client.member);
|
|
195
242
|
sendRoster(client);
|
|
196
243
|
emit("connected", client);
|
|
197
244
|
}
|
|
@@ -218,7 +265,7 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
218
265
|
const proof = await (ports.verifyFrame ?? verifyPublicFrame)({ wire: bytes, roster: selected });
|
|
219
266
|
const onlineRecipients = proof.recipients.filter(recipient => {
|
|
220
267
|
const target = members.get(`${client.room}|${recipient}`);
|
|
221
|
-
return target?.phase === "ready" && (!proof.ephemeral || target.ephemeral);
|
|
268
|
+
return target?.phase === "ready" && (!proof.ephemeral || target.ephemeral) && (!proof.codecFeatures || target.codecFeatures);
|
|
222
269
|
});
|
|
223
270
|
try {
|
|
224
271
|
active(client);
|
|
@@ -241,6 +288,8 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
241
288
|
const latest = active(client);
|
|
242
289
|
if (packet.digest !== client.rosterDigest || packet.digest !== latest.digest) { sendRoster(client); return; }
|
|
243
290
|
client.phase = "ready"; client.rosterDeadline = undefined;
|
|
291
|
+
codecInventory(client);
|
|
292
|
+
if (client.member) notifyCodecPeers(client.room, client.member);
|
|
244
293
|
safePump(client); return;
|
|
245
294
|
}
|
|
246
295
|
if (packet.digest !== client.inFlight) { refused(client, "unexpected_ack", false); return; }
|
|
@@ -290,7 +339,8 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
290
339
|
const room = roomId(url.pathname.slice("/v1/rooms/".length));
|
|
291
340
|
if (clients.size >= BROKER_SOCKET_LIMITS.connections) return new Response("busy", { status: 503 });
|
|
292
341
|
const current = store.current(room)?.current;
|
|
293
|
-
const
|
|
342
|
+
const transportOptIn = req.headers.get("x-agent-hub-ephemeral");
|
|
343
|
+
const client: Client = { room, ephemeral: transportOptIn === "1", codecFeatures: transportOptIn === "1" && req.headers.get("x-agent-hub-codecs") === BROKER_CODEC_FEATURES_OPT_IN, codecSession: encodeBase64url(randomBytes(32)), pendingCodecMembers: new Set(), codecFlushQueued: false, challenge: { v: 1, type: "challenge", room, broker: publicUrl, nonce: encodeBase64url(randomBytes(32)), expiresAt: now() + BROKER_SOCKET_LIMITS.authMs, rosterDigest: current?.digest ?? null }, phase: "challenge", busy: false, inbox: [], inboxBytes: 0 };
|
|
294
344
|
clients.add(client);
|
|
295
345
|
if (host.upgrade(req, { data: client })) return;
|
|
296
346
|
clients.delete(client); return new Response("websocket required", { status: 400 });
|
package/src/broker/store.ts
CHANGED
|
@@ -408,7 +408,9 @@ export class BrokerStore {
|
|
|
408
408
|
// ponytail: while A awaits C, A's broadcast reply to B may replace C's request;
|
|
409
409
|
// C waits for A's next state change/reconnect. Task #736 idle refresh or
|
|
410
410
|
// per-(sender,recipient) coalescing closes this latest-per-sender ceiling.
|
|
411
|
-
|
|
411
|
+
// Codec advertisements must not evict a legacy snapshot that a mixed
|
|
412
|
+
// member has not received yet. Both groups share the existing queue caps.
|
|
413
|
+
this.removeEphemeral(proof.room, proof.sender, undefined, proof.codecFeatures === true);
|
|
412
414
|
const connected = new Set(onlineRecipients);
|
|
413
415
|
recipients = recipients.filter(recipient => connected.has(recipient));
|
|
414
416
|
}
|
|
@@ -438,12 +440,13 @@ export class BrokerStore {
|
|
|
438
440
|
this.transaction(() => { this.removeEphemeral(room, undefined, recipient); });
|
|
439
441
|
}
|
|
440
442
|
|
|
441
|
-
private removeEphemeral(room?: string, sender?: string, recipient?: string): void {
|
|
443
|
+
private removeEphemeral(room?: string, sender?: string, recipient?: string, codecFeatures?: boolean): void {
|
|
442
444
|
const rows = this.db.query<{ rowid: number; wire: Uint8Array; recipients: string }, [string | null, string | null, string | null, string | null]>(
|
|
443
445
|
"SELECT rowid,wire,recipients FROM queued_frames WHERE (? IS NULL OR room=?) AND (? IS NULL OR sender=?)",
|
|
444
446
|
).all(room ?? null, room ?? null, sender ?? null, sender ?? null);
|
|
445
447
|
for (const row of rows) {
|
|
446
|
-
|
|
448
|
+
const frame = decodeFrame(row.wire);
|
|
449
|
+
if (!frame.ephemeral || codecFeatures !== undefined && (frame.codecFeatures === true) !== codecFeatures) continue;
|
|
447
450
|
const remaining = recipient === undefined ? [] : parseRecipients(row.recipients).filter(id => id !== recipient);
|
|
448
451
|
if (remaining.length) this.db.query("UPDATE queued_frames SET recipients=? WHERE rowid=?").run(JSON.stringify(remaining), row.rowid);
|
|
449
452
|
else this.db.query("DELETE FROM queued_frames WHERE rowid=?").run(row.rowid);
|
|
@@ -22,12 +22,21 @@ export async function usedClaudeHomes(registry: Registry, home: string, env: Nod
|
|
|
22
22
|
return { ...found, complete: found.complete && !notices.length, notices: [...notices, ...found.notices] };
|
|
23
23
|
}
|
|
24
24
|
export async function autonomyHomeStatus(registry: Registry, home: string, env: NodeJS.ProcessEnv, packageRoot: string, probe: ClaudeHomeProbe) {
|
|
25
|
-
const found = await usedClaudeHomes(registry, home, env)
|
|
25
|
+
const found = await usedClaudeHomes(registry, home, env);
|
|
26
|
+
// Doctor also inspects the existing default home; setup's selected write inventory stays unchanged.
|
|
27
|
+
const defaults = await discoverClaudeHomes({ home, env: { ...env, CLAUDE_CONFIG_DIR: undefined } });
|
|
28
|
+
// Discovery canonicalizes existing homes through realPath, so lexical aliases share one inspection.
|
|
29
|
+
const inspected = new Map(found.homes.map(selected => [selected.path, selected]));
|
|
30
|
+
for (const selected of defaults.homes.filter(selected => selected.exists)) {
|
|
31
|
+
const previous = inspected.get(selected.path);
|
|
32
|
+
inspected.set(selected.path, previous ? { ...previous, sources: [...new Set([...previous.sources, ...selected.sources])] } : selected);
|
|
33
|
+
}
|
|
34
|
+
const notices = [...new Set([...found.notices, ...defaults.notices])], deadline = performance.now() + 6000;
|
|
26
35
|
const homes = [];
|
|
27
|
-
for (const selected of
|
|
36
|
+
for (const selected of inspected.values()) {
|
|
28
37
|
const remaining = Math.floor(deadline - performance.now());
|
|
29
38
|
if (remaining < 1) homes.push({ home: selected, plugin: { state: "unknown", reason: "The machine profile inventory time bound was reached." }, hook: checkClaudeProfileHook(selected) });
|
|
30
39
|
else homes.push(await checkClaudeHome(selected, packageRoot, env, probe, Math.min(remaining, 6000)));
|
|
31
40
|
}
|
|
32
|
-
return { homes, notices
|
|
41
|
+
return { homes, notices, complete: found.complete && defaults.complete && performance.now() <= deadline };
|
|
33
42
|
}
|
package/src/cli/claude-homes.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { isAbsolute, join } from "node:path";
|
|
|
3
3
|
import { realPath } from "../hub/project.ts";
|
|
4
4
|
import { launcherOf, recordPath, MAX_COMMAND_MS, type ProcessIdentity } from "./terminal-recovery.ts";
|
|
5
5
|
import { CLAUDE_PLUGIN_INVENTORY, PLUGIN, nextStep, pluginState, type InstalledPlugin, type Marketplace, type PluginState, type SetupStep } from "./setup.ts";
|
|
6
|
-
import { checkClaudeProfileHook, type ClaudeProfileHookCheck } from "./claude-profile-hook.ts";
|
|
6
|
+
import { checkClaudeProfileHook, type ClaudeProfileHookCheck, type ClaudeProfileHookOptions } from "./claude-profile-hook.ts";
|
|
7
7
|
|
|
8
8
|
export type ClaudeHomeSource = "environment" | "default" | "recorded-launch";
|
|
9
9
|
export interface ClaudeHome { path: string; exists: boolean; sources: ClaudeHomeSource[] }
|
|
@@ -93,12 +93,12 @@ export interface ClaudeHomeCheck {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
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> {
|
|
96
|
+
export async function checkClaudeHome(home: ClaudeHome, packageRoot: string, env: NodeJS.ProcessEnv, probe: ClaudeHomeProbe, timeoutMs = MAX_COMMAND_MS, probeMissingDefault = false, hookOptions?: ClaudeProfileHookOptions): Promise<ClaudeHomeCheck> {
|
|
97
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);
|
|
98
|
+
const hook = checkClaudeProfileHook(home, hookOptions);
|
|
99
99
|
const unknown = (): ClaudeHomeCheck => ({ home, plugin: { state: "unknown", reason: "Claude plugin inventory is unavailable or invalid for this home." }, hook });
|
|
100
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 };
|
|
101
|
+
if (!home.exists && !(probeMissingDefault && home.sources.includes("default"))) return { home, plugin: { state: "missing" }, next: nextStep([], [], packageRoot, validPath(env.HOME) ? env.HOME : undefined), hook };
|
|
102
102
|
const scoped = claudeHomeEnvironment(home, env), deadline = performance.now() + timeoutMs;
|
|
103
103
|
const read = async (argv: readonly string[]): Promise<unknown[] | undefined> => {
|
|
104
104
|
const remaining = Math.floor(deadline - performance.now());
|
|
@@ -118,6 +118,6 @@ export async function checkClaudeHome(home: ClaudeHome, packageRoot: string, env
|
|
|
118
118
|
(row.version === undefined || typeof row.version === "string" && row.version.length <= 64 && (row.id !== PLUGIN ? !/[\u0000-\u001f\u007f-\u009f]/.test(row.version) : /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(row.version))) &&
|
|
119
119
|
(row.installPath === undefined || validPath(row.installPath))) ||
|
|
120
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 };
|
|
121
|
+
return { home, plugin: pluginState(plugins as InstalledPlugin[], packageRoot), next: nextStep(plugins as InstalledPlugin[], marketplaces as Marketplace[], packageRoot, validPath(env.HOME) ? env.HOME : undefined), hook };
|
|
122
122
|
} catch { return unknown(); }
|
|
123
123
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync, type Stats } from "node:fs";
|
|
1
|
+
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync, type Stats } from "node:fs";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
-
import { canonicalJson } from "../hub/packs.ts";
|
|
5
4
|
import { factsHook } from "./facts-hook.ts";
|
|
6
5
|
import { shellQuote } from "./shell-quote.ts";
|
|
6
|
+
import { stableBunRuntime, stablePackageRoot } from "./install-paths.ts";
|
|
7
7
|
import type { ClaudeHome } from "./claude-homes.ts";
|
|
8
8
|
|
|
9
9
|
export interface ClaudeProfileHookOptions { script?: string; runtime?: string }
|
|
@@ -25,11 +25,29 @@ interface HookPlan {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function hookEntry(options: ClaudeProfileHookOptions): Record<string, unknown> {
|
|
28
|
-
const script = options.script ?? join(import.meta.dir, "claude-profile-hook.ts"), runtime = options.runtime ??
|
|
28
|
+
const script = options.script ?? join(stablePackageRoot(join(import.meta.dir, "../..")), "src/cli/claude-profile-hook.ts"), runtime = options.runtime ?? stableBunRuntime();
|
|
29
29
|
if (!validPath(script) || !validPath(runtime)) throw new Error("invalid profile hook command");
|
|
30
30
|
return { matcher: "*", hooks: [{ type: "command", command: `${shellQuote(runtime)} ${shellQuote(script)} --run`, timeout: 5 }] };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
interface ManagedProfileHook { entry: Record<string, unknown>; hook: Record<string, unknown>; runtime: string; script: string }
|
|
34
|
+
/** Own only the generated command grammar; matcher, timeout and caller metadata remain theirs. */
|
|
35
|
+
function managedProfileHook(candidate: unknown): ManagedProfileHook | undefined {
|
|
36
|
+
if (!object(candidate) || !Array.isArray(candidate.hooks) || candidate.hooks.length !== 1) return;
|
|
37
|
+
const hook = candidate.hooks[0];
|
|
38
|
+
if (!object(hook) || hook.type !== "command" || typeof hook.command !== "string") return;
|
|
39
|
+
const match = /^'((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)' --run$/.exec(hook.command);
|
|
40
|
+
if (!match) return;
|
|
41
|
+
const runtime = match[1]!.replace(/'\\''/g, "'"), script = match[2]!.replace(/'\\''/g, "'");
|
|
42
|
+
if (!validPath(runtime) || !validPath(script) || !script.endsWith("/src/cli/claude-profile-hook.ts")
|
|
43
|
+
|| hook.command !== `${shellQuote(runtime)} ${shellQuote(script)} --run`) return;
|
|
44
|
+
return { entry: candidate, hook, runtime, script };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hookPathPresent(file: string): boolean {
|
|
48
|
+
try { return statSync(file).isFile(); } catch { return false; }
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
function settingsSource(file: string): { value: Record<string, unknown>; source?: HookPlan["source"] } {
|
|
34
52
|
let fd: number | undefined;
|
|
35
53
|
try {
|
|
@@ -67,13 +85,24 @@ function planClaudeProfileHook(home: ClaudeHome, options: ClaudeProfileHookOptio
|
|
|
67
85
|
if (value.hooks !== undefined && !object(value.hooks)) return refuse("Claude's hook configuration is malformed.");
|
|
68
86
|
const hooks = value.hooks ?? {}, existing = hooks.PreToolUse ?? [];
|
|
69
87
|
if (!Array.isArray(existing)) return refuse("Claude's PreToolUse hook list is malformed.");
|
|
70
|
-
const entry = hookEntry(options),
|
|
71
|
-
const
|
|
72
|
-
const
|
|
88
|
+
const entry = hookEntry(options), expectedCommand = (entry.hooks as { command: string }[])[0]!.command;
|
|
89
|
+
const managed = existing.map(managedProfileHook), first = managed.find(candidate => candidate !== undefined);
|
|
90
|
+
const count = managed.filter(candidate => candidate !== undefined).length;
|
|
91
|
+
const stale = managed.some(candidate => candidate && (!hookPathPresent(candidate.runtime) || !hookPathPresent(candidate.script)));
|
|
92
|
+
const configured = count === 1 && first?.hook.command === expectedCommand && !stale;
|
|
93
|
+
const normalized = existing.flatMap((candidate, index) => {
|
|
94
|
+
const owned = managed[index];
|
|
95
|
+
if (!owned) return [candidate];
|
|
96
|
+
return owned === first ? [{ ...owned.entry, hooks: [{ ...owned.hook, command: expectedCommand }] }] : [];
|
|
97
|
+
});
|
|
98
|
+
if (!first) normalized.push(entry);
|
|
99
|
+
const next = configured ? undefined : `${JSON.stringify({ ...value, hooks: { ...hooks, PreToolUse: normalized } }, null, 2)}\n`;
|
|
73
100
|
if (next !== undefined && Buffer.byteLength(next, "utf8") > MAX_SETTINGS_BYTES) return refuse("Claude's settings would exceed the bounded profile settings size.");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
101
|
+
let reason = "The project-neutral profile hook is not configured for this install; run ahub setup.";
|
|
102
|
+
if (count > 1) reason = "Duplicate managed profile hooks need normalization; run ahub setup.";
|
|
103
|
+
if (stale) reason = "A managed profile hook script or runtime is missing; run ahub setup to refresh the selected home's hook.";
|
|
104
|
+
if (configured) reason = "The project-neutral profile hook is configured; this does not prove live permission enforcement.";
|
|
105
|
+
return { file, home: directory, parent, source, next, check: { state: configured ? "installed" : "missing", reason } };
|
|
77
106
|
} catch { return refuse("Claude profile settings could not be safely inspected; no settings were changed."); }
|
|
78
107
|
}
|
|
79
108
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { lstatSync, readlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { realPath } from "../hub/project.ts";
|
|
5
|
+
|
|
6
|
+
export interface InstallPathOptions {
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
home?: string;
|
|
9
|
+
globalFlag?: "-g" | "--global";
|
|
10
|
+
}
|
|
11
|
+
// Contract: #1245 limits lexical symlink inspection to eight hops; unknown paths use the resolved fallback.
|
|
12
|
+
const MAX_LINK_HOPS = 8;
|
|
13
|
+
const samePath = (left: string, right: string): boolean => {
|
|
14
|
+
try { return realPath(left) === realPath(right); } catch { return false; }
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Follow only the leaf links, preserving lexical parent aliases such as current/. */
|
|
18
|
+
function lexicalTarget(entry: string): string | undefined {
|
|
19
|
+
let link = resolve(entry);
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
try {
|
|
22
|
+
for (let hops = 0; hops <= MAX_LINK_HOPS; hops++) {
|
|
23
|
+
if (seen.has(link)) return;
|
|
24
|
+
seen.add(link);
|
|
25
|
+
if (!lstatSync(link).isSymbolicLink()) return link;
|
|
26
|
+
if (hops === MAX_LINK_HOPS) return;
|
|
27
|
+
link = resolve(dirname(link), readlinkSync(link));
|
|
28
|
+
}
|
|
29
|
+
} catch { /* Missing, unreadable and cyclic aliases are not evidence of this install. */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The PATH entry's canonical target must also be a CLI entry, not just a lookalike parent. */
|
|
33
|
+
function pathPackageRoot(invocation: string): string | undefined {
|
|
34
|
+
const target = lexicalTarget(invocation);
|
|
35
|
+
if (!target?.endsWith(`${sep}src${sep}cli${sep}main.js`)) return;
|
|
36
|
+
try {
|
|
37
|
+
const canonical = realPath(invocation);
|
|
38
|
+
if (!canonical.endsWith(`${sep}src${sep}cli${sep}main.js`)) return;
|
|
39
|
+
const candidate = dirname(dirname(dirname(target)));
|
|
40
|
+
return samePath(candidate, dirname(dirname(dirname(canonical)))) ? candidate : undefined;
|
|
41
|
+
} catch { return; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Bun resolves argv[1], Bun.main and import.meta.dir, through both bun <link> and a shebang (native probe, #1245). */
|
|
45
|
+
export function stablePackageRoot(root: string, options: { invocation?: string } = {}): string {
|
|
46
|
+
const invocation = options.invocation ?? Bun.which("ahub", { PATH: process.env.PATH });
|
|
47
|
+
const candidate = invocation ? pathPackageRoot(invocation) : undefined;
|
|
48
|
+
return candidate && samePath(candidate, root) ? candidate : root;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function stableBunRuntime(options: { runtime?: string; pathRuntime?: string } = {}): string {
|
|
52
|
+
const runtime = options.runtime ?? process.execPath, candidate = options.pathRuntime ?? Bun.which("bun", { PATH: process.env.PATH });
|
|
53
|
+
return candidate && lexicalTarget(candidate) && samePath(candidate, runtime) ? candidate : runtime;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Advice only; a staged daemon upgrade does not update the person's global CLI installation. */
|
|
57
|
+
export function globalCliUpdateStep(version?: string, options: InstallPathOptions = {}): string {
|
|
58
|
+
const env = options.env ?? process.env;
|
|
59
|
+
const invocation = Bun.which("ahub", { PATH: env.PATH ?? "" });
|
|
60
|
+
const root = invocation ? pathPackageRoot(invocation) : undefined;
|
|
61
|
+
const generic = `update the global CLI${version ? ` to ${version}` : ""} the way it was installed`;
|
|
62
|
+
if (!root) return generic;
|
|
63
|
+
const global = join(env.BUN_INSTALL || join(options.home ?? homedir(), ".bun"), "install/global/node_modules");
|
|
64
|
+
let bunGlobal = false;
|
|
65
|
+
try {
|
|
66
|
+
const within = relative(realPath(global), realPath(root));
|
|
67
|
+
bunGlobal = within !== "" && within !== ".." && !within.startsWith(`..${sep}`) && !isAbsolute(within);
|
|
68
|
+
} catch { /* An unproven Bun prefix must not suggest updating a different CLI. */ }
|
|
69
|
+
if (bunGlobal) return `run \`bun add ${options.globalFlag ?? "-g"} @staix/agent-hub${version ? `@${version}` : ""}\``;
|
|
70
|
+
return `${generic} (running from ${root})`;
|
|
71
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { readAutonomyDefaults, writeAutonomyDefaults } from "../hub/autonomy-def
|
|
|
43
43
|
import { inheritMachineAutonomy, prepareAutonomyProject, runAllProjectAutonomy, type PreparedAutonomyProject } from "./autonomy-projects.ts";
|
|
44
44
|
import { offlineAutonomyStatus } from "./autonomy-offline.ts";
|
|
45
45
|
import { autonomyHomeStatus, usedClaudeHomes } from "./autonomy-homes.ts";
|
|
46
|
+
import { shellQuote } from "./shell-quote.ts";
|
|
46
47
|
import { discoverClaudeHomes } from "./claude-homes.ts";
|
|
47
48
|
import { setupClaudeHomes } from "./setup-homes.ts";
|
|
48
49
|
import { registerAconPlugin } from "./acon-register.ts";
|
|
@@ -89,7 +90,7 @@ import { createInterface as lineReader } from "node:readline";
|
|
|
89
90
|
import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, writeOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner, supportsAction } from "../hub/recovery-store.ts";
|
|
90
91
|
import { childEnv, personEnv } from "../hub/child-process.ts";
|
|
91
92
|
import { abortRecovery, createOperation, disposeRecovery, recordRecoveryDelegation, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
|
|
92
|
-
import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
93
|
+
import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource, reattachPlannedTerminals } from "./upgrade-runtime.ts";
|
|
93
94
|
import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
|
|
94
95
|
import { globalUpdateAdvice, runtimeVersionWarnings } from "./runtime-version-warning.ts";
|
|
95
96
|
import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
|
|
@@ -722,6 +723,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
|
|
|
722
723
|
endInspection: recoveryEndInspection,
|
|
723
724
|
afterCompleted: (op) => globalUpdateLines(op.plan.version),
|
|
724
725
|
endPeer: (planned, peer) => endPlannedPeer(planned, peer),
|
|
726
|
+
reattachTerminals: (planned, terminals) => reattachPlannedTerminals(planned, terminals),
|
|
725
727
|
runnerStoppable: (id) => signedRunner(id) !== undefined,
|
|
726
728
|
stopRunner: (id) => stopSignedRunner(id),
|
|
727
729
|
entry: join(import.meta.dir, "main.js"),
|
|
@@ -2931,6 +2933,23 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2931
2933
|
}
|
|
2932
2934
|
const plugin = pluginState(parseList<InstalledPlugin>(spawnSync(DOCTOR_PLUGIN_PROBE.command, [...DOCTOR_PLUGIN_PROBE.argv], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
|
|
2933
2935
|
row(plugin.state === "current", DOCTOR_PLUGIN_PROBE.name, plugin.state === "missing" ? "missing: run ahub setup" : plugin.state === "current" ? `agent-hub@agent-hub ${plugin.version}` : `agent-hub@agent-hub is stale (${plugin.why}): run ahub setup`);
|
|
2936
|
+
let profileRegistry: Registry | undefined;
|
|
2937
|
+
try {
|
|
2938
|
+
profileRegistry = new Registry();
|
|
2939
|
+
const profiles = await autonomyHomeStatus(profileRegistry, homedir(), process.env, join(import.meta.dir, "..", ".."), (argv, options) => {
|
|
2940
|
+
const result = spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8", env: options.env, timeout: options.timeoutMs, maxBuffer: 1024 * 1024 });
|
|
2941
|
+
return { status: result.status ?? -1, stdout: result.stdout ?? "" };
|
|
2942
|
+
});
|
|
2943
|
+
for (const profile of profiles.homes) {
|
|
2944
|
+
const installed = profile.hook.state === "installed";
|
|
2945
|
+
checks.push({ name: `Claude profile hook: ${profile.home.path}`, section: "Hub", level: installed ? "ok" : "warn",
|
|
2946
|
+
detail: installed ? profile.hook.reason : `${profile.hook.reason} Run CLAUDE_CONFIG_DIR=${shellQuote(profile.home.path)} ahub setup.` });
|
|
2947
|
+
}
|
|
2948
|
+
if (!profiles.complete) checks.push({ name: "Claude profile discovery", section: "Hub", level: "unknown",
|
|
2949
|
+
detail: profiles.notices.join("; ") || "Claude profile discovery is incomplete; inspect the registered projects and selected homes before setup." });
|
|
2950
|
+
} catch {
|
|
2951
|
+
checks.push({ name: "Claude profile discovery", section: "Hub", level: "unknown", detail: "Claude profile inventory is unavailable; inspect the machine registry and selected homes before setup." });
|
|
2952
|
+
} finally { profileRegistry?.close(); }
|
|
2934
2953
|
if (up) checks.push(...runtimeVersionWarnings(daemonVersion, VERSION, plugin.state === "missing" ? undefined : plugin.version));
|
|
2935
2954
|
|
|
2936
2955
|
const config = loadConfig(cwd);
|
package/src/cli/output.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
|
|
|
5
5
|
import { peerStyle } from "../ui/peer-palette.ts";
|
|
6
6
|
import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
|
|
7
7
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
8
|
-
import { backendLabel, memorySyncLine, reviewInputHealthLine } from "./status-lines.ts";
|
|
8
|
+
import { backendLabel, memorySyncLine, reviewInputHealthLine, roomsUnavailableLine } from "./status-lines.ts";
|
|
9
9
|
import { ownerIsolationText } from "./role-holders.ts";
|
|
10
10
|
import { schedulePeriodText, scheduleWhen } from "../hub/schedules.ts";
|
|
11
11
|
import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
|
|
@@ -14,6 +14,8 @@ import type { SpecsStatus } from "../hub/specs-task.ts";
|
|
|
14
14
|
|
|
15
15
|
export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
|
|
16
16
|
export interface StatusOutput {
|
|
17
|
+
roomsUnavailable?: unknown;
|
|
18
|
+
roomsOpening?: boolean;
|
|
17
19
|
controlWriteTiming?: { active: number; slowRequests: number; blockedLoopSamples: number; droppedActive: number; droppedRecent: number; droppedLogs: number; droppedSpans?: number; droppedMetadata?: number; expiredRequests?: number };
|
|
18
20
|
specs?: SpecsStatus & { missingBlockHint?: boolean };
|
|
19
21
|
queueBackpressure?: QueueBackpressureStatus;
|
|
@@ -263,6 +265,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
263
265
|
const text = ownerIsolationText(isolated, data.ownerIsolationNotes?.[id]);
|
|
264
266
|
if (text) out.push(...detail(`${id} owner isolation`, text, columns, isolated ? undefined : "attention"));
|
|
265
267
|
}
|
|
268
|
+
if (data.roomsOpening === true) out.push(...lines("rooms: opening; wait for local room storage initialization", columns, "muted"));
|
|
269
|
+
if (data.roomsUnavailable !== undefined) out.push(...lines(roomsUnavailableLine(data.roomsUnavailable), columns, "failure"));
|
|
266
270
|
if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
|
|
267
271
|
for (const [peer, count] of Object.entries(data.deliveryFailures ?? {})) {
|
|
268
272
|
if (Number.isSafeInteger(count) && count > 0) out.push(...detail(`${peer} failed deliveries`, `${count}; next: ahub queue discard --peer ${peer} --state failed`, columns, "attention"));
|