@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.
Files changed (100) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +5 -4
  3. package/docs/adding-an-ai-tool.md +140 -0
  4. package/docs/agent-notes/README.md +5 -0
  5. package/docs/agent-notes/adapters.md +11 -4
  6. package/docs/agent-notes/budget.md +2 -2
  7. package/docs/agent-notes/bus.md +2 -0
  8. package/docs/agent-notes/daemon.md +15 -6
  9. package/docs/agent-notes/tasks.md +7 -2
  10. package/docs/agent-notes/tests.md +6 -2
  11. package/docs/operations.md +159 -26
  12. package/docs/quickstart.md +11 -3
  13. package/docs/sdlc.md +1 -1
  14. package/docs/security.md +7 -5
  15. package/docs/specs/2026-09-19-agent-hub-design.md +209 -13
  16. package/docs/verification/2026-10-21-0.12.43.md +50 -0
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
  19. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  20. package/docs/verification/2026-10-21-pii-screen.md +14 -0
  21. package/docs/verified.json +34 -19
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +808 -255
  25. package/src/adapters/acp-owned-group.ts +124 -0
  26. package/src/adapters/acp.ts +92 -25
  27. package/src/adapters/agy/definition.ts +1 -3
  28. package/src/adapters/agy/entry.ts +4 -1
  29. package/src/adapters/agy/hooks.ts +7 -2
  30. package/src/adapters/channel-peer.ts +143 -0
  31. package/src/adapters/claude-channel.ts +7 -7
  32. package/src/adapters/codex-appserver.ts +16 -2
  33. package/src/adapters/configured-acp.ts +124 -0
  34. package/src/adapters/definitions/builtin-metadata.ts +37 -33
  35. package/src/adapters/definitions/native-channel-state.ts +41 -0
  36. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
  37. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  38. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  39. package/src/adapters/definitions/qwen.ts +3 -8
  40. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  41. package/src/adapters/jcode.ts +4 -0
  42. package/src/adapters/local-worker.ts +4 -0
  43. package/src/adapters/pi.ts +50 -16
  44. package/src/adapters/registry-types.ts +18 -16
  45. package/src/adapters/registry.ts +68 -6
  46. package/src/adapters/reviewer-print.ts +4 -0
  47. package/src/cli/agy-mcp.ts +3 -2
  48. package/src/cli/completion.ts +173 -0
  49. package/src/cli/configured-acp-entry.ts +58 -0
  50. package/src/cli/console-state.ts +116 -24
  51. package/src/cli/console.ts +9 -6
  52. package/src/cli/facts-hook.ts +2 -1
  53. package/src/cli/help.ts +8 -1
  54. package/src/cli/identity.ts +8 -7
  55. package/src/cli/launch.ts +5 -4
  56. package/src/cli/main.js +5 -1
  57. package/src/cli/main.ts +84 -27
  58. package/src/cli/memory.ts +13 -10
  59. package/src/cli/output.ts +1 -0
  60. package/src/cli/preview.ts +9 -0
  61. package/src/cli/recovery-guidance.ts +1 -1
  62. package/src/cli/terminal-recovery.ts +33 -21
  63. package/src/cli/upgrade-restore-state.ts +3 -2
  64. package/src/cli/upgrade-runtime.ts +21 -6
  65. package/src/cli/upgrade.ts +3 -2
  66. package/src/hub/board.ts +9 -6
  67. package/src/hub/budget.ts +9 -2
  68. package/src/hub/bus.ts +6 -2
  69. package/src/hub/claude-state.ts +2 -37
  70. package/src/hub/conductor.ts +8 -6
  71. package/src/hub/config-apply.ts +26 -14
  72. package/src/hub/config-reload.ts +39 -11
  73. package/src/hub/config-trust.ts +1 -1
  74. package/src/hub/context-window.ts +6 -0
  75. package/src/hub/control-client.ts +1 -1
  76. package/src/hub/crash.ts +8 -6
  77. package/src/hub/daemon.ts +471 -338
  78. package/src/hub/hub-tools.ts +37 -1
  79. package/src/hub/instance-config.ts +20 -11
  80. package/src/hub/instance-execution.ts +4 -4
  81. package/src/hub/instances.ts +45 -21
  82. package/src/hub/lifecycle-schema.ts +5 -3
  83. package/src/hub/main-checkout.ts +2 -2
  84. package/src/hub/manager.ts +10 -2
  85. package/src/hub/memory-profile.ts +16 -3
  86. package/src/hub/no-writes.ts +50 -0
  87. package/src/hub/permission-mode.ts +9 -7
  88. package/src/hub/roster.ts +7 -4
  89. package/src/hub/routing.ts +21 -20
  90. package/src/hub/settings.ts +51 -17
  91. package/src/hub/snapshots.ts +276 -17
  92. package/src/hub/start-mode.ts +10 -8
  93. package/src/hub/task-sweep.ts +8 -2
  94. package/src/hub/tasks.ts +101 -19
  95. package/src/memory/capture.ts +2 -1
  96. package/src/ui/index.html +5 -0
  97. package/templates/AGENTS.block.md +1 -1
  98. package/templates/profiles/general.json +1 -1
  99. package/templates/profiles/research.json +1 -1
  100. package/templates/profiles/writing.json +1 -1
@@ -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 function readInstanceConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): { instances?: InstancesConfig; ignored: string[] } {
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 instances: InstancesConfig | undefined;
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 checked = validateInstances(raw.instances);
25
- if (!Object.keys(checked).length) continue;
26
- if (name !== "config.local.json") { ignored.push(`instances in .agenthub/${name} ignored: instances are machine-local config.local.json declarations`); continue; }
27
- const refusal = configRefusal(cwd, name);
28
- if (refusal) ignored.push(`instances in .agenthub/${name} ignored: ${refusal}`);
29
- else instances = checked;
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
- return { ...(instances ? { instances } : {}), ignored };
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)): { instances?: InstancesConfig; roles: Record<string, string[]>; ignored: string[] } {
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 { definitionOf } from "../adapters/registry.ts";
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) {
@@ -1,5 +1,5 @@
1
1
  import { types } from "node:util";
2
- import { ADAPTERS, defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm } from "../adapters/registry.ts";
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
- /** Pure I2 declaration validation. Physical worktree and startup authority checks belong to the bound host. */
44
- export function validateInstances(raw: unknown): InstancesConfig {
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) || definitionOf(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
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 = definitionOf(kind), prefix = `${kind}-`;
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
- // #450 I3: a channel-plugin kind (Claude) runs only in the person's terminal, so it reviews or plans and never owns writes.
67
- const channel = definition.identity.mcp === "channel-plugin";
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 (!channel && !unavailableReviewer && (!definition.start.create || !definition.start.forms.includes("headless") || !["env", "acp-session"].includes(definition.identity.mcp))) return fail(`${path}.kind`, "unsupported; a headless factory with env or ACP-session identity, or a channel-plugin kind, is required, not project-file identity");
70
- if (channel) {
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
- if (!declared.length || declared.some(role => role !== "reviewer" && role !== "planner")) return fail(`${path}.roles`, "a channel instance must declare its roles: reviewer and/or planner");
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?: InstancesConfig): readonly InstanceCatalogEntry[] {
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
- ...ADAPTERS.map(definition => Object.freeze({ id: definition.kind, kind: definition.kind, definition })),
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?: InstancesConfig): string[] { return instanceCatalog(declarations).map(entry => entry.id); }
110
- export function instanceDefaultRoles(declarations?: InstancesConfig, includeOptional = true): Record<string, string[]> {
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 instances = validateInstances(declarations);
113
- for (const [id, config] of Object.entries(instances)) result[id] = [...(config.roles ?? definitionOf(id, instances)!.roles)];
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[]>>): 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
  }
@@ -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 = mainCheckoutBranch(cwd);
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/`
@@ -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
- const proc = spawn(process.execPath, [options.cli ?? join(import.meta.dir, "../cli/main.ts"), "manager"], {
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 { return (await managerRequest(home, "/open", options.liveTest)).body.url; }
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
- /** Bind a caller-owned store only when it exactly matches the current person selection. */
245
- export function bindProfileMemory(store: MemoryStore, onUnavailable?: (neutralReason: string) => void): {
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 binding = new MemoryProfileBinding({ project: store.profile.shardKey }, onUnavailable, store.profile.home);
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?: KindMap): string {
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?: KindMap): boolean {
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);