@staix/agent-hub 0.12.42 → 0.12.44
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 +30 -0
- package/README.md +5 -4
- package/docs/adding-an-ai-tool.md +140 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +11 -4
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/tasks.md +7 -2
- package/docs/agent-notes/tests.md +6 -2
- package/docs/operations.md +159 -26
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +7 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +209 -13
- package/docs/verification/2026-10-21-0.12.43.md +50 -0
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verification/2026-10-21-pii-screen.md +14 -0
- package/docs/verified.json +34 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +808 -255
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/agy/definition.ts +1 -3
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/agy/hooks.ts +7 -2
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +7 -7
- package/src/adapters/codex-appserver.ts +16 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/definitions/builtin-metadata.ts +37 -33
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +3 -8
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +50 -16
- package/src/adapters/registry-types.ts +18 -16
- package/src/adapters/registry.ts +68 -6
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/completion.ts +173 -0
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +116 -24
- package/src/cli/console.ts +9 -6
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.js +5 -1
- package/src/cli/main.ts +84 -27
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +33 -21
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +21 -6
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +9 -6
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +26 -14
- package/src/hub/config-reload.ts +39 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +471 -338
- package/src/hub/hub-tools.ts +37 -1
- package/src/hub/instance-config.ts +20 -11
- package/src/hub/instance-execution.ts +4 -4
- package/src/hub/instances.ts +45 -21
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/main-checkout.ts +2 -2
- package/src/hub/manager.ts +10 -2
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/roster.ts +7 -4
- package/src/hub/routing.ts +21 -20
- package/src/hub/settings.ts +51 -17
- package/src/hub/snapshots.ts +276 -17
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +101 -19
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +5 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/hub-tools.ts
CHANGED
|
@@ -97,7 +97,7 @@ export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
|
|
|
97
97
|
export const CONDUCTOR_TOOLS: HubTool[] = [
|
|
98
98
|
tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
|
|
99
99
|
tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
|
|
100
|
-
tool("hub_task_assign", "Name an owner (default role) or reviewer. Only the conductor may name a reviewer through this tool. A proposer may redirect an unaccepted proposed task's owner; a task that still waits reserves that owner. Pull-only peers receive work when named or when they claim it. With an explicit capabilities list, the conductor needs assign capability for either role, the proposer only to give ownership to another peer.", { id, peer: str, role: { type: "string", enum: ["owner", "reviewer"] } }, ["id", "peer"]),
|
|
100
|
+
tool("hub_task_assign", "Name an owner (default role) or reviewer. Only the conductor may name a reviewer through this tool. A proposer may redirect an unaccepted proposed task's owner; a task that still waits reserves that owner. Pull-only peers receive work when named while attached or when they claim it; a peer that cannot take the task is refused with the reason, and the task is unchanged. With an explicit capabilities list, the conductor needs assign capability for either role, the proposer only to give ownership to another peer.", { id, peer: str, role: { type: "string", enum: ["owner", "reviewer"] } }, ["id", "peer"]),
|
|
101
101
|
tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
|
|
102
102
|
tool("hub_peer_start", "Start local, kimi, qwen, jcode, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: CONDUCTOR_START_PEERS } }, ["peer"]),
|
|
103
103
|
tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
|
|
@@ -105,6 +105,42 @@ export const CONDUCTOR_TOOLS: HubTool[] = [
|
|
|
105
105
|
];
|
|
106
106
|
export const CONDUCTOR_TOOL_NAMES = new Set(CONDUCTOR_TOOLS.map((t) => t.name));
|
|
107
107
|
|
|
108
|
+
/** Authenticated status metadata only; malformed values never add a tool target. */
|
|
109
|
+
function projectPeerIds(value: unknown): string[] | undefined {
|
|
110
|
+
if (!Array.isArray(value) || value.length > 256 || value.some(id => typeof id !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(id) || ["user", "hub", "digest"].includes(id)) || new Set(value).size !== value.length) return undefined;
|
|
111
|
+
return [...value] as string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A project-local schema projection; released builtin arrays and all other tools keep their identity. */
|
|
115
|
+
export function projectConductorTools(startPeers?: unknown): HubTool[] {
|
|
116
|
+
const peers = startPeers === undefined ? undefined : projectPeerIds(startPeers);
|
|
117
|
+
if (peers === undefined) return CONDUCTOR_TOOLS;
|
|
118
|
+
return CONDUCTOR_TOOLS.map(tool => tool.name !== "hub_peer_start" ? tool : {
|
|
119
|
+
...tool, inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties,
|
|
120
|
+
peer: { ...(tool.inputSchema.properties.peer as Record<string, unknown>), enum: [...new Set([...CONDUCTOR_START_PEERS, ...peers])] },
|
|
121
|
+
} },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ProjectToolContext { startPeers?: string[]; readOnly?: boolean }
|
|
126
|
+
/** Ordinary projects retain their exact prior publication context; configured metadata is body-free and bounded. */
|
|
127
|
+
export function configuredToolContext(status: unknown, peer: string): ProjectToolContext {
|
|
128
|
+
if (!status || typeof status !== "object" || Array.isArray(status)) return {};
|
|
129
|
+
const raw = status as Record<string, unknown>, configured = projectPeerIds(raw.configuredAdapters);
|
|
130
|
+
if (!configured?.length) return {};
|
|
131
|
+
const startPeers = projectPeerIds(raw.startablePeers), readOnlyPeers = projectPeerIds(raw.readOnlyPeers);
|
|
132
|
+
// With an incomplete configured-project reading, publish only R2 reads until authenticated status is complete.
|
|
133
|
+
if (!startPeers || !readOnlyPeers) return { readOnly: true };
|
|
134
|
+
return { startPeers, readOnly: readOnlyPeers.includes(peer) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_status"]);
|
|
138
|
+
/** Publication restricts exposure only; server-side authority remains mandatory for every call. */
|
|
139
|
+
export function projectToolList<T extends { name: string }>(tools: T[], readOnly = false): T[] {
|
|
140
|
+
return readOnly ? tools.filter(tool => READ_ONLY_PROJECT_TOOLS.includes(tool.name)) : tools;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
108
144
|
/** Role contracts, by role name. Shown to each peer for the roles `.agenthub/config.json` gives it. */
|
|
109
145
|
export const ROLE_TEXT: Record<string, string> = {
|
|
110
146
|
conductor: "conductor: plan and split work into tasks with owners, watch the team with hub_status, move stalled work, and ensure every task is reviewed (review it yourself only if you also hold reviewer). Report results and open decisions to the person. Do not implement tasks you handed out. Never ask a peer to answer an approval; ask the person for human-only actions. You may release only holds you placed, never human holds or budget pauses.",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { defaultPeerRoles } from "../adapters/registry.ts";
|
|
3
|
+
import { createProjectAdapterCatalog, defaultPeerRoles, definitionOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
4
4
|
import { conductorPeer } from "./conductor.ts";
|
|
5
5
|
import { configRefusal } from "./config-trust.ts";
|
|
6
6
|
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
@@ -17,24 +17,33 @@ export function readInstanceConfigLayers(cwd: string): InstanceConfigLayer[] {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** Declaration authority is file provenance, never a suffix or a claim made by a native child. */
|
|
20
|
-
export
|
|
20
|
+
export interface InstanceRoleConfig {
|
|
21
|
+
adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
|
|
22
|
+
instances?: InstancesConfig;
|
|
23
|
+
catalog: ProjectAdapterCatalog;
|
|
24
|
+
ignored: string[];
|
|
25
|
+
}
|
|
26
|
+
export function readInstanceConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): InstanceRoleConfig {
|
|
21
27
|
const ignored: string[] = [];
|
|
22
|
-
let
|
|
28
|
+
let adapters: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>> | undefined, rawInstances: unknown;
|
|
23
29
|
for (const { name, raw } of layers) {
|
|
24
|
-
const
|
|
25
|
-
if (!
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
+
const keys = ["adapters", "instances"].filter(key => raw[key] !== undefined);
|
|
31
|
+
if (!keys.length) continue;
|
|
32
|
+
const refusal = name !== "config.local.json" ? "declarations are machine-local config.local.json values" : configRefusal(cwd, name);
|
|
33
|
+
if (refusal) { ignored.push(`${keys.join(", ")} in .agenthub/${name} ignored: ${refusal}`); continue; }
|
|
34
|
+
if (raw.adapters !== undefined) adapters = raw.adapters as Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
|
|
35
|
+
if (raw.instances !== undefined) rawInstances = raw.instances;
|
|
30
36
|
}
|
|
31
|
-
|
|
37
|
+
const catalog = createProjectAdapterCatalog(adapters);
|
|
38
|
+
const instances = validateInstances(rawInstances, catalog);
|
|
39
|
+
return { ...(adapters !== undefined ? { adapters } : {}), ...(Object.keys(instances).length ? { instances } : {}), catalog, ignored };
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
/** Capture ordinary role policy and trusted instance declarations together, without loading daemon configuration. */
|
|
35
|
-
export function readInstanceRoleConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)):
|
|
43
|
+
export function readInstanceRoleConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): InstanceRoleConfig & { roles: Record<string, string[]> } {
|
|
36
44
|
const declarationConfig = readInstanceConfig(cwd, layers);
|
|
37
45
|
const roles = defaultPeerRoles(false);
|
|
46
|
+
for (const definition of declarationConfig.catalog.definitions) if (!definitionOf(definition.kind)) roles[definition.kind] = [...definition.roles];
|
|
38
47
|
for (const name of INSTANCE_CONFIG_FILES) {
|
|
39
48
|
const raw = layers.find(layer => layer.name === name)?.raw;
|
|
40
49
|
if (raw?.roles !== undefined) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { lstatSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import type { ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
4
4
|
import { hubGitSync } from "./git.ts";
|
|
5
5
|
import { realPath } from "./project.ts";
|
|
6
6
|
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
@@ -15,9 +15,9 @@ function git(cwd: string, args: string[]): string {
|
|
|
15
15
|
const commonDirectory = (cwd: string): string => realPath(git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).replace(/\n$/, ""));
|
|
16
16
|
|
|
17
17
|
/** Point-in-time read only. No worktree is created, repaired or chosen implicitly. */
|
|
18
|
-
export function resolveInstanceExecutionCwd(projectRoot: string, id: string, declarations: InstancesConfig | undefined, roles: readonly string[]): string {
|
|
19
|
-
if (definitionOf(id)) return projectRoot;
|
|
20
|
-
const declaration = validateInstances(declarations)[id];
|
|
18
|
+
export function resolveInstanceExecutionCwd(projectRoot: string, id: string, declarations: InstancesConfig | undefined, roles: readonly string[], adapterCatalog: ProjectAdapterCatalog): string {
|
|
19
|
+
if (adapterCatalog.definitionOf(id)) return projectRoot;
|
|
20
|
+
const declaration = validateInstances(declarations, adapterCatalog)[id];
|
|
21
21
|
if (!declaration) return refuse("extra instance is not declared");
|
|
22
22
|
const implementer = roles.includes("implementer");
|
|
23
23
|
if (!declaration.worktree) {
|
package/src/hub/instances.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { types } from "node:util";
|
|
2
|
-
import {
|
|
2
|
+
import { defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
3
3
|
import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
|
|
4
4
|
|
|
5
5
|
export interface InstanceConfig {
|
|
@@ -9,6 +9,7 @@ export interface InstanceConfig {
|
|
|
9
9
|
permission_mode?: PermissionMode;
|
|
10
10
|
review_mode?: "fresh";
|
|
11
11
|
worktree?: string;
|
|
12
|
+
readOnly?: boolean;
|
|
12
13
|
}
|
|
13
14
|
export type InstancesConfig = Readonly<Record<string, InstanceConfig>>;
|
|
14
15
|
export interface InstanceCatalogEntry { id: string; kind: string; definition: AdapterDefinition; config?: InstanceConfig }
|
|
@@ -40,19 +41,30 @@ function roles(value: unknown, field: string): string[] {
|
|
|
40
41
|
return out;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/**
|
|
44
|
-
export function
|
|
44
|
+
/** Declaration support is explicit policy, independent of the native MCP binding mechanism. */
|
|
45
|
+
export function supportsHeadlessInstances(definition: AdapterDefinition): boolean {
|
|
46
|
+
return definition.instances?.mode === "headless" && !!definition.start.create && definition.start.forms.includes("headless");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolve project declarations only through their captured, explicit catalog. */
|
|
50
|
+
export function instanceDefinitionOf(id: string, declarations: InstancesConfig | undefined, catalog: ProjectAdapterCatalog): AdapterDefinition | undefined {
|
|
51
|
+
return catalog.definitionOf(id, declarations);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Pure I2 validation; physical worktree and startup authority stay host-bound. */
|
|
55
|
+
export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog): InstancesConfig {
|
|
56
|
+
const resolve = catalog.definitionOf;
|
|
45
57
|
const result: Record<string, InstanceConfig> = Object.create(null);
|
|
46
58
|
if (raw === undefined) return Object.freeze(result);
|
|
47
59
|
const declarations = fields(raw, "instances");
|
|
48
60
|
if (Object.keys(declarations).length > MAX_INSTANCES) return fail("instances", "at most 64 extra instances are supported");
|
|
49
61
|
for (const [id, descriptor] of Object.entries(declarations)) {
|
|
50
|
-
if (!PEER_ID.test(id) || RESERVED.has(id) ||
|
|
62
|
+
if (!PEER_ID.test(id) || RESERVED.has(id) || resolve(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
|
|
51
63
|
const path = `instances.${id}`, entry = fields(descriptor.value, path);
|
|
52
|
-
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode"].includes(key))) return fail(path, "unsupported field");
|
|
64
|
+
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly"].includes(key))) return fail(path, "unsupported field");
|
|
53
65
|
const kind: unknown = entry.kind?.value;
|
|
54
66
|
if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
|
|
55
|
-
const definition =
|
|
67
|
+
const definition = resolve(kind), prefix = `${kind}-`;
|
|
56
68
|
if (!definition) return fail(`${path}.kind`, "unknown native kind");
|
|
57
69
|
if (!id.startsWith(prefix) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length))) return fail(path, "id must be <kind>-<name>");
|
|
58
70
|
const declaredRoles = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
@@ -63,11 +75,12 @@ export function validateInstances(raw: unknown): InstancesConfig {
|
|
|
63
75
|
if (definition.routing?.reviewOnly && !freshReviewer) return fail(path, "a review-only kind requires headless roles exactly [\"reviewer\"]");
|
|
64
76
|
if (freshReviewer && entry.worktree) return fail(`${path}.worktree`, "a fresh reviewer reads the project checkout: worktree is not supported");
|
|
65
77
|
if (freshReviewer && entry.permission_mode) return fail(`${path}.permission_mode`, "a fresh reviewer has a fixed read-only policy");
|
|
66
|
-
//
|
|
67
|
-
const
|
|
78
|
+
// An external instance runs only in the person's terminal; its definition bounds the declared roles.
|
|
79
|
+
const external = definition.instances?.mode === "external";
|
|
80
|
+
const runtime = freshReviewer ? definition.reviewerVariant ?? definition : definition;
|
|
68
81
|
const unavailableReviewer = freshReviewer && !!(definition.reviewerVariant ?? definition).reviewer?.unavailable;
|
|
69
|
-
if (!
|
|
70
|
-
if (
|
|
82
|
+
if (!unavailableReviewer && runtime.instances?.mode !== "external" && !supportsHeadlessInstances(runtime)) return fail(`${path}.kind`, definition.reviewerVariant ? 'unsupported; this kind only declares headless reviewer instances with roles exactly ["reviewer"]' : "unsupported; explicit external instance policy or a headless instance policy with a headless factory is required");
|
|
83
|
+
if (external) {
|
|
71
84
|
const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
72
85
|
// #223: a headless one is the kind's reviewer variant, one fresh hub-run session per review request.
|
|
73
86
|
if (entry.start_mode && definition.reviewerVariant) {
|
|
@@ -75,10 +88,20 @@ export function validateInstances(raw: unknown): InstancesConfig {
|
|
|
75
88
|
if (declared.length !== 1 || declared[0] !== "reviewer") return fail(`${path}.roles`, "a headless channel-kind instance is a reviewer: roles must be exactly [\"reviewer\"]");
|
|
76
89
|
} else {
|
|
77
90
|
if (entry.start_mode || entry.worktree) return fail(path, "a channel instance starts only from the person's terminal (ahub <kind> --as <id>): start_mode and worktree are not supported; a headless reviewer declares roles [\"reviewer\"] and start_mode \"headless\"");
|
|
78
|
-
|
|
91
|
+
const allowed = definition.instances?.roles ?? definition.roles;
|
|
92
|
+
if (!declared.length || declared.some(role => !allowed.includes(role))) return fail(`${path}.roles`, "a channel instance must declare its roles: reviewer and/or planner");
|
|
79
93
|
}
|
|
94
|
+
} else {
|
|
95
|
+
const allowed = runtime.instances?.roles;
|
|
96
|
+
if (allowed && (declaredRoles.length ? declaredRoles : runtime.roles).some(role => !allowed.includes(role))) return fail(`${path}.roles`, "roles must fit the definition's instance policy");
|
|
97
|
+
}
|
|
98
|
+
if (entry.readOnly) {
|
|
99
|
+
if (typeof entry.readOnly.value !== "boolean") return fail(`${path}.readOnly`, "must be a boolean");
|
|
100
|
+
if (!runtime.permission.genericAcp?.readOnly) return fail(`${path}.readOnly`, "native adapter has no verified read-only mode");
|
|
101
|
+
if (entry.readOnly.value && entry.permission_mode) return fail(`${path}.permission_mode`, "read-only instances cannot select a permission mode");
|
|
80
102
|
}
|
|
81
103
|
const value: InstanceConfig = { kind, ...(entry.review_mode ? { review_mode: "fresh" as const } : {}) };
|
|
104
|
+
if (entry.readOnly) value.readOnly = entry.readOnly.value as boolean;
|
|
82
105
|
if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
|
|
83
106
|
if (entry.start_mode) {
|
|
84
107
|
if (entry.start_mode.value !== "headless") return fail(`${path}.start_mode`, "I2 extra instances require headless mode");
|
|
@@ -99,24 +122,25 @@ export function validateInstances(raw: unknown): InstancesConfig {
|
|
|
99
122
|
}
|
|
100
123
|
|
|
101
124
|
/** Each call derives a fresh project-local projection, without registration or suffix discovery. */
|
|
102
|
-
export function instanceCatalog(declarations
|
|
103
|
-
const instances = validateInstances(declarations);
|
|
125
|
+
export function instanceCatalog(declarations: InstancesConfig | undefined, catalog: ProjectAdapterCatalog): readonly InstanceCatalogEntry[] {
|
|
126
|
+
const instances = validateInstances(declarations, catalog);
|
|
104
127
|
return Object.freeze([
|
|
105
|
-
...
|
|
106
|
-
...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: definitionOf(id, instances)!, config })),
|
|
128
|
+
...catalog.definitions.map(definition => Object.freeze({ id: definition.kind, kind: definition.kind, definition })),
|
|
129
|
+
...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: catalog.definitionOf(id, instances)!, config })),
|
|
107
130
|
]);
|
|
108
131
|
}
|
|
109
|
-
export function instancePeerIds(declarations
|
|
110
|
-
export function instanceDefaultRoles(declarations
|
|
132
|
+
export function instancePeerIds(declarations: InstancesConfig | undefined, catalog: ProjectAdapterCatalog): string[] { return instanceCatalog(declarations, catalog).map(entry => entry.id); }
|
|
133
|
+
export function instanceDefaultRoles(declarations: InstancesConfig | undefined, includeOptional: boolean, catalog: ProjectAdapterCatalog): Record<string, string[]> {
|
|
111
134
|
const result = defaultPeerRoles(includeOptional);
|
|
112
|
-
const
|
|
113
|
-
|
|
135
|
+
for (const definition of catalog.definitions) if (!definitionOf(definition.kind) && (includeOptional || !definition.optional)) result[definition.kind] = [...definition.roles];
|
|
136
|
+
const instances = validateInstances(declarations, catalog);
|
|
137
|
+
for (const [id, config] of Object.entries(instances)) result[id] = [...(config.roles ?? catalog.definitionOf(id, instances)!.roles)];
|
|
114
138
|
return result;
|
|
115
139
|
}
|
|
116
140
|
|
|
117
141
|
/** One precedence rule for startup, native launch and recovery; ordinary roles may use the instance id or its kind. */
|
|
118
|
-
export function effectiveInstanceRoles(id: string, declarations: InstancesConfig | undefined, policyRoles: Readonly<Record<string, readonly string[]
|
|
119
|
-
const definition = definitionOf(id, declarations);
|
|
142
|
+
export function effectiveInstanceRoles(id: string, declarations: InstancesConfig | undefined, policyRoles: Readonly<Record<string, readonly string[]>>, catalog: ProjectAdapterCatalog): string[] {
|
|
143
|
+
const definition = catalog.definitionOf(id, declarations);
|
|
120
144
|
if (!definition) throw new Error(`${id}: no declared native instance`);
|
|
121
145
|
const declaration = declarations && Object.hasOwn(declarations, id) ? declarations[id] : undefined;
|
|
122
146
|
const ownRoles = (key: string): readonly string[] | undefined => Object.hasOwn(policyRoles, key) ? policyRoles[key] : undefined;
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { ControlClient } from "./control-client.ts";
|
|
2
|
+
import { configuredToolContext, type ProjectToolContext } from "./hub-tools.ts";
|
|
2
3
|
import { SDLC_STAGES } from "./sdlc.ts";
|
|
3
4
|
/** The daemon owns the active definition. Unavailable reads never invent a project-file stage set. */
|
|
4
5
|
/** The active stage ids and whether the lifecycle is enabled (`criteria`, #481 S8); an older daemon never says so. */
|
|
5
|
-
export async function activeLifecycle(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<{ stages: string[]; criteria: boolean } | undefined> {
|
|
6
|
+
export async function activeLifecycle(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<({ stages: string[]; criteria: boolean } & ProjectToolContext) | undefined> {
|
|
6
7
|
let client: ControlClient | undefined;
|
|
7
8
|
try {
|
|
8
9
|
client = active ?? await ControlClient.connect(stateDir, { role: "tools", peer, projectRoot }, 1000);
|
|
9
10
|
const reply = await client.request({ t: "status" }, 1000);
|
|
10
11
|
if (reply.t !== "status" || !reply.status || typeof reply.status !== "object" || Array.isArray(reply.status)) return undefined;
|
|
12
|
+
const projectTools = configuredToolContext(reply.status, peer);
|
|
11
13
|
// A live older daemon has the fixed seven-stage contract and no optional metadata field.
|
|
12
|
-
if (!Object.hasOwn(reply.status, "lifecycleStages")) return { stages: [...SDLC_STAGES], criteria: false };
|
|
14
|
+
if (!Object.hasOwn(reply.status, "lifecycleStages")) return { stages: [...SDLC_STAGES], criteria: false, ...projectTools };
|
|
13
15
|
const stages: unknown = reply.status.lifecycleStages;
|
|
14
16
|
if (!Array.isArray(stages) || !stages.length || stages.length > 32 || stages.some(s => typeof s !== "string" || !/^[a-z][a-z0-9_-]{0,47}$/.test(s)) || new Set(stages).size !== stages.length) return undefined;
|
|
15
|
-
return { stages: [...stages] as string[], criteria: reply.status.lifecycleCriteria === true };
|
|
17
|
+
return { stages: [...stages] as string[], criteria: reply.status.lifecycleCriteria === true, ...projectTools };
|
|
16
18
|
} catch { return undefined; }
|
|
17
19
|
finally { if (client !== active) client?.close(); }
|
|
18
20
|
}
|
package/src/hub/main-checkout.ts
CHANGED
|
@@ -27,8 +27,8 @@ export function mainCheckoutBranch(cwd: string): { branch: string; defaultBranch
|
|
|
27
27
|
* whole public-text screen (`Tasks.publicMetadataText`: the configured PII patterns, secret shapes and private paths). A name it refuses, or any text
|
|
28
28
|
* that is not a plain ref name, is replaced by a generic sentence that carries no part of it (#572 review I2).
|
|
29
29
|
*/
|
|
30
|
-
export function mainCheckoutWarning(cwd: string, nameable: (text: string) => boolean = () => false): string | undefined {
|
|
31
|
-
const found =
|
|
30
|
+
export function mainCheckoutWarning(cwd: string, nameable: (text: string) => boolean = () => false, read: typeof mainCheckoutBranch = mainCheckoutBranch): string | undefined {
|
|
31
|
+
const found = read(cwd);
|
|
32
32
|
if (!found) return undefined;
|
|
33
33
|
const plain = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,99}$/.test(found.branch) && nameable(found.branch);
|
|
34
34
|
return plain ? `the main checkout is on \`${found.branch}\`; move the work to a worktree under .worktrees/`
|
package/src/hub/manager.ts
CHANGED
|
@@ -241,8 +241,9 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
|
|
|
241
241
|
const f = files(home);
|
|
242
242
|
mkdirSync(f.dir, { recursive: true, mode: 0o700 });
|
|
243
243
|
const log = openSync(join(f.dir, "manager.log"), "a");
|
|
244
|
+
let proc: ReturnType<typeof spawn>;
|
|
244
245
|
try {
|
|
245
|
-
|
|
246
|
+
proc = spawn(process.execPath, [options.cli ?? join(import.meta.dir, "../cli/main.ts"), "manager"], {
|
|
246
247
|
cwd: home, detached: true, stdio: ["ignore", log, log], env: { ...scrubEnv(process.env), AGENTHUB_HOME: home },
|
|
247
248
|
});
|
|
248
249
|
proc.on("error", () => {}); proc.unref();
|
|
@@ -250,7 +251,14 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
|
|
|
250
251
|
const deadline = Date.now() + 8000;
|
|
251
252
|
let error: unknown;
|
|
252
253
|
while (Date.now() < deadline) {
|
|
253
|
-
try {
|
|
254
|
+
try {
|
|
255
|
+
const answered = await managerRequest(home, "/open", options.liveTest);
|
|
256
|
+
// #584: concurrent opens each spawn a manager and one wins the owner claim. A spawn that lost, or is still booting when
|
|
257
|
+
// another manager answers, would claim the owner row once that manager is stopped and then run with nobody to stop it.
|
|
258
|
+
// Only the manager that answered is wanted; stop the process this open started if it is a different one.
|
|
259
|
+
if (answered.status.pid !== proc.pid) proc.kill("SIGTERM");
|
|
260
|
+
return answered.body.url;
|
|
261
|
+
}
|
|
254
262
|
catch (caught) { error = caught; await Bun.sleep(100); }
|
|
255
263
|
}
|
|
256
264
|
throw new Error(`manager readiness not confirmed: ${safeError(error)}; see ${join(f.dir, "manager.log")}`);
|
|
@@ -117,6 +117,14 @@ export function memoryProjectFor(cwd: string, opts: Pick<StoreOptions, "home"> =
|
|
|
117
117
|
return resolveAlias(homeFor(opts), memoryProjectKey(cwd));
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* #240: the project key for a caller-injected store. Its alias log is the one in its own bound home (`profile.home`),
|
|
122
|
+
* never the ambient memory home's: a different alias log could hide that store's rows or refuse its writes.
|
|
123
|
+
*/
|
|
124
|
+
export function memoryProjectForStore(store: MemoryStore, cwd: string): string {
|
|
125
|
+
return resolveAlias(canonicalHome(store.profile.home), memoryProjectKey(cwd));
|
|
126
|
+
}
|
|
127
|
+
|
|
120
128
|
/** Inspect selection without constructing a store, index, or candidate sidecar. */
|
|
121
129
|
export function selectedMemoryProfile(opts: StoreOptions = {}): MemoryProfileReceipt {
|
|
122
130
|
assertLoadedAgentMem();
|
|
@@ -241,11 +249,16 @@ export function openProfileMemory(opts: ProfileMemoryOptions = {}, onUnavailable
|
|
|
241
249
|
}
|
|
242
250
|
}
|
|
243
251
|
|
|
244
|
-
/**
|
|
245
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Bind a caller-owned store only when it exactly matches the current person selection. `projectKey` is the raw key
|
|
254
|
+
* the caller resolves its project from (#240): a sharded store whose home now aliases that key to another project is
|
|
255
|
+
* refused, and a later remap latches the binding unavailable, so a command never runs as B through A's shard.
|
|
256
|
+
*/
|
|
257
|
+
export function bindProfileMemory(store: MemoryStore, onUnavailable?: (neutralReason: string) => void, projectKey?: string): {
|
|
246
258
|
store: MemoryStore; binding: MemoryProfileBinding;
|
|
247
259
|
} {
|
|
248
|
-
const
|
|
260
|
+
const sharded = store.profile.shardKey !== undefined;
|
|
261
|
+
const binding = new MemoryProfileBinding({ project: store.profile.shardKey, ...(sharded && projectKey !== undefined ? { projectKey } : {}) }, onUnavailable, store.profile.home);
|
|
249
262
|
binding.assertCurrent();
|
|
250
263
|
if (!sameProfile(store.profile, binding.profile)) throw new Error(UNAVAILABLE);
|
|
251
264
|
return { store, binding };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Task } from "./board.ts";
|
|
2
|
+
|
|
3
|
+
export interface NoWriteObservation {
|
|
4
|
+
task: number;
|
|
5
|
+
/** Opaque binding to the current owner and its latest acceptance. */
|
|
6
|
+
generation: string;
|
|
7
|
+
limit: number;
|
|
8
|
+
changed: boolean | undefined;
|
|
9
|
+
effect: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Pure per-task streaks; callers own evidence, privacy and warning delivery. */
|
|
13
|
+
export class NoWriteTurns {
|
|
14
|
+
private readonly streaks = new Map<number, { generation: string; count: number; emitted: boolean }>();
|
|
15
|
+
|
|
16
|
+
observe({ task, generation, limit, changed, effect }: NoWriteObservation): number | undefined {
|
|
17
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || changed !== false || effect !== false) {
|
|
18
|
+
this.reset(task);
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const previous = this.streaks.get(task);
|
|
22
|
+
const streak = previous?.generation === generation ? previous : { generation, count: 0, emitted: false };
|
|
23
|
+
if (streak !== previous) this.streaks.set(task, streak);
|
|
24
|
+
if (streak.emitted) return undefined;
|
|
25
|
+
if (streak.count < Number.MAX_SAFE_INTEGER) streak.count++;
|
|
26
|
+
if (streak.count < limit) return undefined;
|
|
27
|
+
streak.emitted = true;
|
|
28
|
+
return streak.count;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
reset(task: number): void { this.streaks.delete(task); }
|
|
32
|
+
|
|
33
|
+
retain(activeTaskIds: readonly number[]): void {
|
|
34
|
+
const active = new Set(activeTaskIds);
|
|
35
|
+
for (const task of this.streaks.keys()) if (!active.has(task)) this.reset(task);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut"]);
|
|
41
|
+
/** A read does not prove progress; failed/pending mutations are handled conservatively by the host. */
|
|
42
|
+
export function ownerToolEffect(op: string, input: Record<string, unknown> | undefined): boolean {
|
|
43
|
+
return OWNER_TOOL_EFFECTS.has(op) || op === "hub_task_accept" && !!input?.plan && typeof input.plan === "object" && Object.keys(input.plan).length > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Canonical snapshots cannot disprove work in the owner's separately recorded checkout/PR. */
|
|
47
|
+
export function canonicalWriteUnknown(task: Pick<Task, "refs" | "plan" | "sdlc">): boolean {
|
|
48
|
+
return !!(task.refs.branch || task.refs.commit || task.sdlc?.head || task.sdlc?.localHead || task.sdlc?.pr)
|
|
49
|
+
|| [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].some(path => /(?:^|[\\/])\.worktrees(?:[\\/]|$)/i.test(path));
|
|
50
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
|
|
2
2
|
import { lstatSync } from "node:fs";
|
|
3
3
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
4
|
|
|
@@ -8,13 +8,15 @@ export type PermissionMode = typeof PERMISSION_MODES[number];
|
|
|
8
8
|
export { KIMI_MODE_IDS } from "../adapters/definitions/kimi-native.ts";
|
|
9
9
|
export { QWEN_MODE_IDS } from "../adapters/definitions/qwen-native.ts";
|
|
10
10
|
export const isPermissionMode = (value: unknown): value is PermissionMode => typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value);
|
|
11
|
-
export function permissionDefaults(value: unknown, instances?: KindMap): Record<string, PermissionMode> {
|
|
11
|
+
export function permissionDefaults(value: unknown, instances?: KindMap, catalog?: ProjectAdapterCatalog): Record<string, PermissionMode> {
|
|
12
12
|
if (value === undefined) return {};
|
|
13
13
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("permission_modes must be an object with ask, ask-when-needed or never-ask values");
|
|
14
14
|
const result: Record<string, PermissionMode> = {};
|
|
15
15
|
for (const [peer, mode] of Object.entries(value)) {
|
|
16
|
-
const definition = typeof peer === "string" ? definitionOf(peer, instances) : undefined;
|
|
16
|
+
const definition = typeof peer === "string" ? (catalog?.definitionOf ?? definitionOf)(peer, instances) : undefined;
|
|
17
17
|
if (!definition) throw new Error(`permission_modes.${peer}: unsupported peer`);
|
|
18
|
+
if (instances && Object.hasOwn(instances, peer) && (instances[peer] as { readOnly?: boolean })?.readOnly) throw new Error(`permission_modes.${peer}: read-only ceiling is locked`);
|
|
19
|
+
if (definition.permission.genericAcp && (!definition.permission.genericAcp.modes || typeof mode !== "string" || !Object.hasOwn(definition.permission.genericAcp.modes, mode))) throw new Error(`permission_modes.${peer}: native mode mapping is unavailable`);
|
|
18
20
|
if (definition.permission.unmanaged) throw new Error(`${definition.displayName} native policy is unmanaged; no hub permission_modes.${peer} mapping exists`);
|
|
19
21
|
if (!isPermissionMode(mode)) throw new Error(`permission_modes.${peer} must be ask, ask-when-needed or never-ask`);
|
|
20
22
|
result[peer] = mode;
|
|
@@ -22,16 +24,16 @@ export function permissionDefaults(value: unknown, instances?: KindMap): Record<
|
|
|
22
24
|
return result;
|
|
23
25
|
}
|
|
24
26
|
/** Approval frequency does not create a common sandbox across native peers. */
|
|
25
|
-
export function permissionBoundary(peer: string, instances
|
|
26
|
-
const definition = definitionOf(peer, instances);
|
|
27
|
+
export function permissionBoundary(peer: string, instances: KindMap | undefined, catalog: ProjectAdapterCatalog): string {
|
|
28
|
+
const definition = catalog.definitionOf(peer, instances);
|
|
27
29
|
if (definition?.permission.unmanaged) return `${peer}: native policy unmanaged; NO hub sandbox; approvals belong to its native tools`;
|
|
28
30
|
if (definition?.trust.hubSandboxed) return `${peer}: inside hub sandbox, path guard and denylist`;
|
|
29
31
|
if (definition?.transport === "claude-channel" || definition?.transport === "codex-appserver") return `${peer}: native vendor bounds; no hub sandbox`;
|
|
30
32
|
return `${peer}: runs its own tools; NO hub sandbox`;
|
|
31
33
|
}
|
|
32
34
|
/** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
|
|
33
|
-
export function permissionGrant(peer: string, mode: PermissionMode, instances?: KindMap): string {
|
|
34
|
-
const grant = definitionOf(peer, instances)?.permission.grants?.[mode];
|
|
35
|
+
export function permissionGrant(peer: string, mode: PermissionMode, instances?: KindMap, catalog?: ProjectAdapterCatalog): string {
|
|
36
|
+
const grant = (catalog?.definitionOf ?? definitionOf)(peer, instances)?.permission.grants?.[mode];
|
|
35
37
|
if (grant !== undefined) return grant;
|
|
36
38
|
if (mode === "ask") return "every write, edit and shell command asks";
|
|
37
39
|
return mode === "never-ask" ? "every tool runs without asking, inside the hub sandbox and path guard" : "write and edit inside the project run without asking; shell and git writes still ask";
|
package/src/hub/roster.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
2
|
-
import { defaultRosterEnabled } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf as builtinDefinitionOf, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
|
|
2
|
+
import { defaultRosterEnabled as builtinDefaultRosterEnabled } from "../adapters/registry.ts";
|
|
3
3
|
|
|
4
4
|
export type PeerRoster = Partial<Record<string, boolean>>;
|
|
5
5
|
|
|
6
|
-
export function peerRosterConfig(value: unknown, instances?: KindMap): PeerRoster {
|
|
6
|
+
export function peerRosterConfig(value: unknown, instances?: KindMap, catalog?: ProjectAdapterCatalog): PeerRoster {
|
|
7
|
+
const definitionOf = catalog?.definitionOf ?? builtinDefinitionOf;
|
|
7
8
|
if (value === undefined) return {};
|
|
8
9
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("roster must be an object of boolean peer values");
|
|
9
10
|
const roster: PeerRoster = {};
|
|
@@ -16,7 +17,9 @@ export function peerRosterConfig(value: unknown, instances?: KindMap): PeerRoste
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
/** External ids keep their existing admission rules; Pi's legacy switch is only its default. */
|
|
19
|
-
export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean, instances
|
|
20
|
+
export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean, instances: KindMap | undefined, catalog: ProjectAdapterCatalog): boolean {
|
|
21
|
+
const definitionOf = catalog.definitionOf;
|
|
22
|
+
const defaultRosterEnabled = (id: string, enabled: boolean) => !builtinDefinitionOf(id) ? catalog.definitionOf(id)?.defaultOnRoster ?? false : builtinDefaultRosterEnabled(id, enabled);
|
|
20
23
|
const definition = definitionOf(peer, instances);
|
|
21
24
|
if (!definition) return true; // External clients do not acquire a native kind from a project declaration.
|
|
22
25
|
return roster[peer] ?? (definition.kind === peer ? defaultRosterEnabled(peer, piEnabled) : true);
|