@staix/agent-hub 0.12.43 → 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 (89) hide show
  1. package/CHANGELOG.md +16 -3
  2. package/README.md +3 -3
  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 +8 -3
  6. package/docs/agent-notes/budget.md +2 -2
  7. package/docs/agent-notes/daemon.md +9 -5
  8. package/docs/agent-notes/tasks.md +6 -2
  9. package/docs/agent-notes/tests.md +3 -1
  10. package/docs/operations.md +64 -5
  11. package/docs/quickstart.md +6 -2
  12. package/docs/security.md +5 -3
  13. package/docs/specs/2026-09-19-agent-hub-design.md +160 -4
  14. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  15. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  16. package/docs/verified.json +34 -19
  17. package/package.json +1 -1
  18. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  19. package/plugins/agent-hub/server.js +743 -229
  20. package/src/adapters/acp-owned-group.ts +124 -0
  21. package/src/adapters/acp.ts +92 -25
  22. package/src/adapters/agy/definition.ts +1 -3
  23. package/src/adapters/agy/entry.ts +4 -1
  24. package/src/adapters/channel-peer.ts +143 -0
  25. package/src/adapters/claude-channel.ts +7 -7
  26. package/src/adapters/codex-appserver.ts +16 -2
  27. package/src/adapters/configured-acp.ts +124 -0
  28. package/src/adapters/definitions/builtin-metadata.ts +37 -33
  29. package/src/adapters/definitions/native-channel-state.ts +41 -0
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
  31. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  32. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  33. package/src/adapters/definitions/qwen.ts +3 -8
  34. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  35. package/src/adapters/jcode.ts +4 -0
  36. package/src/adapters/local-worker.ts +4 -0
  37. package/src/adapters/pi.ts +4 -0
  38. package/src/adapters/registry-types.ts +18 -16
  39. package/src/adapters/registry.ts +68 -6
  40. package/src/adapters/reviewer-print.ts +4 -0
  41. package/src/cli/agy-mcp.ts +3 -2
  42. package/src/cli/configured-acp-entry.ts +58 -0
  43. package/src/cli/console-state.ts +9 -4
  44. package/src/cli/facts-hook.ts +2 -1
  45. package/src/cli/help.ts +6 -0
  46. package/src/cli/identity.ts +7 -6
  47. package/src/cli/launch.ts +5 -4
  48. package/src/cli/main.ts +78 -27
  49. package/src/cli/memory.ts +13 -10
  50. package/src/cli/output.ts +1 -0
  51. package/src/cli/preview.ts +9 -0
  52. package/src/cli/recovery-guidance.ts +1 -1
  53. package/src/cli/terminal-recovery.ts +16 -13
  54. package/src/cli/upgrade-restore-state.ts +3 -2
  55. package/src/cli/upgrade-runtime.ts +21 -6
  56. package/src/cli/upgrade.ts +3 -2
  57. package/src/hub/board.ts +9 -6
  58. package/src/hub/budget.ts +9 -2
  59. package/src/hub/bus.ts +6 -2
  60. package/src/hub/claude-state.ts +2 -37
  61. package/src/hub/conductor.ts +8 -6
  62. package/src/hub/config-apply.ts +26 -14
  63. package/src/hub/config-reload.ts +39 -11
  64. package/src/hub/config-trust.ts +1 -1
  65. package/src/hub/context-window.ts +6 -0
  66. package/src/hub/control-client.ts +1 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +341 -313
  69. package/src/hub/hub-tools.ts +36 -0
  70. package/src/hub/instance-config.ts +20 -11
  71. package/src/hub/instance-execution.ts +4 -4
  72. package/src/hub/instances.ts +45 -21
  73. package/src/hub/lifecycle-schema.ts +5 -3
  74. package/src/hub/memory-profile.ts +16 -3
  75. package/src/hub/no-writes.ts +50 -0
  76. package/src/hub/permission-mode.ts +9 -7
  77. package/src/hub/roster.ts +7 -4
  78. package/src/hub/routing.ts +21 -20
  79. package/src/hub/settings.ts +50 -16
  80. package/src/hub/snapshots.ts +26 -2
  81. package/src/hub/start-mode.ts +10 -8
  82. package/src/hub/task-sweep.ts +8 -2
  83. package/src/hub/tasks.ts +85 -17
  84. package/src/memory/capture.ts +2 -1
  85. package/src/ui/index.html +5 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/profiles/general.json +1 -1
  88. package/templates/profiles/research.json +1 -1
  89. package/templates/profiles/writing.json +1 -1
@@ -90,6 +90,7 @@ export interface VersionContext {
90
90
  env: Readonly<Record<string, string>>;
91
91
  }
92
92
 
93
+ /** Compatibility type for frozen A1 permission metadata, not a runtime permission port. */
93
94
  export type NativeModeSpec =
94
95
  | { kind: "acp-mode"; modeId: string }
95
96
  | { kind: "codex-approval"; policy: string }
@@ -100,7 +101,6 @@ export type NativeModeSpec =
100
101
  export interface QuotaReader {
101
102
  source: string;
102
103
  read(value: unknown): UsageWindow[];
103
- resetOnWindow?: boolean;
104
104
  }
105
105
 
106
106
  export interface ContextReader {
@@ -145,12 +145,7 @@ export interface NativeFactsPort {
145
145
 
146
146
  /** Only the frozen native seams are extensible; a definition cannot add an arbitrary daemon callback. */
147
147
  export interface NativeHooks {
148
- agentIdentity(init: unknown): void;
149
- onAttach(instance: string): void;
150
- onDetach(instance: string): void;
151
148
  factsHook: NativeFactsPort;
152
- conversationRevert(turnId: string): Promise<void>;
153
- replaceSession(req: StartRequest): Promise<unknown>;
154
149
  pluginInventory(ctx: { home: string; cwd?: string; env: Readonly<Record<string, string | undefined>> }): Addon[];
155
150
  recallSource: string;
156
151
  recallInjected: boolean;
@@ -182,8 +177,8 @@ export interface AdapterDefinition {
182
177
  start: {
183
178
  forms: readonly StartForm[];
184
179
  defaultForm: StartForm;
185
- ownsTerminal(form: StartForm): boolean;
186
- launch(ctx: LaunchContext): LaunchPlan;
180
+ /** A real native argv planner; absent when startup belongs to a separate verified host port. */
181
+ launch?(ctx: LaunchContext): LaunchPlan;
187
182
  create?(ctx: CreateContext): PeerAdapter;
188
183
  daemonPort?: DaemonEngine;
189
184
  preflight?(ctx: StartRequest): string | undefined;
@@ -203,16 +198,20 @@ export interface AdapterDefinition {
203
198
  conductorStartArgs?(args: string[]): { mode?: StartForm } | Error;
204
199
  };
205
200
  identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin" };
201
+ /** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
202
+ instances?: { mode: "external" | "headless"; roles?: readonly string[] };
206
203
  attach?: {
204
+ /** The authenticated external hello may claim this id; independent of MCP configuration shape. */
205
+ externalHello?: boolean;
207
206
  pullOnlyForms?: readonly StartForm[];
208
207
  recoveryLaunch?(instance: string): Record<string, unknown>;
209
208
  pullOnlyNotice?: string;
210
209
  admission?: { reservedRefusal: string; watchRefusal: string };
211
210
  };
212
211
  permission: {
213
- modes: Partial<Record<PermissionMode, NativeModeSpec>>;
212
+ /** Consumed configured-ACP mode capability; native runtime and instance ceiling use the same validated mapping. */
213
+ genericAcp?: { modes?: Partial<Record<PermissionMode, string>>; readOnly?: string };
214
214
  grants?: Partial<Record<PermissionMode, string>>;
215
- readOnly?: NativeModeSpec;
216
215
  unmanaged?: string;
217
216
  unmanagedForms?: readonly StartForm[];
218
217
  unmanagedNote?: string;
@@ -273,12 +272,6 @@ export interface AdapterDefinition {
273
272
  validateEffective?(raw: Record<string, unknown>, fail: (key: string, reason: string) => never): void;
274
273
  };
275
274
  capabilities?: { manifest?(peer: PeerAdapter): Promise<CapabilityManifest>; watch?: boolean };
276
- init?: {
277
- templateFragment?: string;
278
- mcpFile?: string;
279
- /** Pure planner; the CLI host owns every write and cleanup. */
280
- mcpPlan?(ctx: { projectRoot: string; stateDir: string; entrypoint: string; form: StartForm }): { file: string; tracked: boolean; changed: boolean; action: string };
281
- };
282
275
  cli: {
283
276
  command: string;
284
277
  /** Configuration leaves that select this peer's next native launch command or provider profile. */
@@ -302,3 +295,12 @@ export interface AdapterDefinition {
302
295
 
303
296
  /** Explicit project declarations, passed by the bound host. No process-global instance registration. */
304
297
  export type KindMap = Readonly<Record<string, { readonly kind: string; readonly roles?: readonly string[]; readonly start_mode?: "tui" | "headless"; readonly review_mode?: "fresh" }>>;
298
+
299
+ /** Immutable per-project resolver; exported globals remain builtin-only. */
300
+ export interface ProjectAdapterCatalog {
301
+ readonly definitions: readonly AdapterDefinition[];
302
+ definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined;
303
+ kindOf(id: string, instances?: KindMap): string | undefined;
304
+ familyOf(id: string, instances?: KindMap): string | undefined;
305
+ isSupportedPeer(id: unknown, instances?: KindMap): id is string;
306
+ }
@@ -6,7 +6,8 @@ import { DAEMON_PORTS } from "./definitions/native-factories.ts";
6
6
  export { DAEMON_PORTS };
7
7
  import { NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN } from "./definitions/builtin-metadata.ts";
8
8
  import { QWEN_DEFINITION } from "./definitions/qwen.ts";
9
- import type { AdapterDefinition, KindMap } from "./registry-types.ts";
9
+ import { configuredAcpDefinitions } from "./configured-acp.ts";
10
+ import type { AdapterDefinition, KindMap, ProjectAdapterCatalog } from "./registry-types.ts";
10
11
  import { types } from "node:util";
11
12
 
12
13
  export type * from "./registry-types.ts";
@@ -41,9 +42,9 @@ export const ADAPTERS: readonly AdapterDefinition[] = immutable([
41
42
  const declarationOnlyDefinitions: readonly AdapterDefinition[] = immutable([]);
42
43
  const definitions = new Map<string, AdapterDefinition>();
43
44
  for (const definition of [...ADAPTERS, ...declarationOnlyDefinitions]) {
44
- assertDaemonEngine(definition);
45
+ assertDefinitionCoherence(definition);
45
46
  assertNativeCapabilityScope(definition);
46
- if (definition.reviewerVariant) { assertDaemonEngine(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
47
+ if (definition.reviewerVariant) { assertDefinitionCoherence(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
47
48
  if (definition.api !== ADAPTER_API || definitions.has(definition.kind)) throw new Error("invalid builtin adapter registry");
48
49
  definitions.set(definition.kind, definition);
49
50
  }
@@ -65,7 +66,7 @@ function ownField(value: unknown, key: string): unknown {
65
66
  return field && Object.hasOwn(field, "value") && field.enumerable ? field.value : undefined;
66
67
  }
67
68
  /** Declaration reads never execute caller accessors or inherit another project's mapping. */
68
- function declaredKind(id: string, instances: KindMap | undefined): string | undefined {
69
+ function declaredKind(id: string, instances: KindMap | undefined, catalog: ReadonlyMap<string, AdapterDefinition> = definitions): string | undefined {
69
70
  if (!instances || typeof instances !== "object" || types.isProxy(instances) || Array.isArray(instances)
70
71
  || ![Object.prototype, null].includes(Object.getPrototypeOf(instances))) return undefined;
71
72
  const field = Object.getOwnPropertyDescriptor(instances, id);
@@ -74,7 +75,7 @@ function declaredKind(id: string, instances: KindMap | undefined): string | unde
74
75
  if (!entry || typeof entry !== "object" || types.isProxy(entry) || Array.isArray(entry)
75
76
  || ![Object.prototype, null].includes(Object.getPrototypeOf(entry))) return undefined;
76
77
  const kind = Object.getOwnPropertyDescriptor(entry, "kind");
77
- if (!kind || !Object.hasOwn(kind, "value") || !kind.enumerable || typeof kind.value !== "string" || !definitions.has(kind.value)) return undefined;
78
+ if (!kind || !Object.hasOwn(kind, "value") || !kind.enumerable || typeof kind.value !== "string" || !catalog.has(kind.value)) return undefined;
78
79
  const prefix = `${kind.value}-`;
79
80
  return /^[a-z][a-z0-9-]{0,31}$/.test(id) && id.startsWith(prefix) && /^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length)) ? kind.value : undefined;
80
81
  }
@@ -84,7 +85,10 @@ export function kindOf(id: string, instances?: KindMap): string | undefined {
84
85
  }
85
86
  export function definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined {
86
87
  const kind = kindOf(id, instances);
87
- const definition = kind === undefined ? undefined : definitions.get(kind);
88
+ return resolvedDefinition(id, kind, instances, definitions);
89
+ }
90
+ function resolvedDefinition(id: string, kind: string | undefined, instances: KindMap | undefined, catalog: ReadonlyMap<string, AdapterDefinition>): AdapterDefinition | undefined {
91
+ const definition = kind === undefined ? undefined : catalog.get(kind);
88
92
  // #223: only a declared extra (never the builtin id) with start_mode "headless" runs as the kind's reviewer variant.
89
93
  const declaration = ownField(instances, id), roles = ownField(declaration, "roles");
90
94
  const reviewerOnly = Array.isArray(roles) && !types.isProxy(roles) && roles.length === 1 && Object.getOwnPropertyDescriptor(roles, "0")?.value === "reviewer";
@@ -96,6 +100,24 @@ export function familyOf(id: string, instances?: KindMap): string | undefined {
96
100
  return definitionOf(id, instances)?.family;
97
101
  }
98
102
 
103
+ /** Compile one captured, host-trusted declaration set; never register native policy globally. */
104
+ export function createProjectAdapterCatalog(rawAdapters?: unknown): ProjectAdapterCatalog {
105
+ const configured = configuredAcpDefinitions(rawAdapters, [...definitions.keys()]);
106
+ for (const definition of configured) { assertDefinitionCoherence(definition); assertNativeCapabilityScope(definition); }
107
+ const entries = Object.freeze([...ADAPTERS, ...configured]);
108
+ const catalog = new Map([...definitions, ...configured.map(definition => [definition.kind, definition] as const)]);
109
+ const kindOf = (id: string, instances?: KindMap): string | undefined => catalog.has(id) ? id : typeof id === "string" ? declaredKind(id, instances, catalog) : undefined;
110
+ const definitionOf = (id: string, instances?: KindMap): AdapterDefinition | undefined => resolvedDefinition(id, kindOf(id, instances), instances, catalog);
111
+ return Object.freeze({ definitions: entries, kindOf, definitionOf,
112
+ familyOf: (id: string, instances?: KindMap) => definitionOf(id, instances)?.family,
113
+ isSupportedPeer: (id: unknown, instances?: KindMap): id is string => {
114
+ if (typeof id !== "string") return false;
115
+ const kind = kindOf(id, instances), runtime = kind === undefined ? undefined : catalog.get(kind), resolved = definitionOf(id, instances);
116
+ return runtime !== undefined && resolved !== undefined && (kind === id || resolved === runtime || resolved.reviewer?.unavailable === undefined);
117
+ },
118
+ });
119
+ }
120
+
99
121
  /** Compatibility projection order is observable in default role maps and planner selection. */
100
122
  const ROLE_ORDER = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode", "agy"] as const;
101
123
  export const PLANNER_PEERS = ["claude", "codex", "kimi", "qwen", "jcode"] as const;
@@ -126,6 +148,46 @@ export function assertDaemonEngine(definition: Pick<AdapterDefinition, "start">)
126
148
  if (definition.start.create && engine === undefined || engine !== undefined && !Object.hasOwn(DAEMON_PORTS, engine)) throw new Error("invalid adapter daemon engine");
127
149
  }
128
150
 
151
+ /** Structural registry contract only: native telemetry readers are never executed during loading. */
152
+ export function assertDefinitionCoherence(definition: AdapterDefinition): void {
153
+ const fail = (field: string, reason: string): never => { throw new Error(`${definition.kind}.${field}: ${reason}`); };
154
+ const nonempty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
155
+ assertDaemonEngine(definition);
156
+ if (definition.identity.mcp === "channel-plugin" && definition.transport !== "claude-channel" && definition.transport !== "ws-pull") {
157
+ fail("identity.mcp", "channel-plugin requires claude-channel or ws-pull transport");
158
+ }
159
+ if (definition.turnEnd === "native-hook" && definition.transport !== "claude-channel" && !definition.hooks?.factsHook) {
160
+ fail("turnEnd", "native-hook requires the Claude transport or a native factsHook");
161
+ }
162
+ const attach = definition.attach, pullOnly = attach?.pullOnlyForms;
163
+ if (pullOnly !== undefined) {
164
+ if (!Array.isArray(pullOnly) || pullOnly.some(form => !definition.start.forms.includes(form))) fail("attach.pullOnlyForms", "each pull-only form must be a supported start form");
165
+ }
166
+ if (definition.transport === "ws-pull" || definition.transport === "claude-channel" || definition.instances?.mode === "external") {
167
+ if (attach?.externalHello !== true) fail("attach.externalHello", "external transport or instance admission requires an explicit hello policy");
168
+ }
169
+ if (definition.transport === "ws-pull" && !pullOnly?.length) fail("attach.pullOnlyForms", "ws-pull requires a nonempty pull-only contract");
170
+ if (attach?.admission !== undefined) {
171
+ if (!attach.admission || typeof attach.admission !== "object") fail("attach.admission", "admission must declare its refusal policy");
172
+ if (!definition.start.forms.includes("tui") || !pullOnly?.includes("tui")) fail("attach.admission", "native admission requires a supported pull-only TUI form");
173
+ for (const field of ["reservedRefusal", "watchRefusal"] as const) {
174
+ if (!nonempty(attach.admission[field])) fail(`attach.admission.${field}`, "admission refusal text must be nonempty");
175
+ }
176
+ }
177
+ if (definition.budget !== undefined) {
178
+ if (!nonempty(definition.budget?.account)) fail("budget.account", "declared budget account must be a nonempty string");
179
+ for (const field of ["reader", "context"] as const) {
180
+ const reader = definition.budget[field];
181
+ if (reader === undefined) continue;
182
+ if (!reader || typeof reader !== "object") fail(`budget.${field}`, "telemetry reader must be an object");
183
+ if (!nonempty(reader.source)) fail(`budget.${field}.source`, "telemetry source must be a nonempty string");
184
+ if (typeof reader.read !== "function") fail(`budget.${field}.read`, "telemetry read must be callable");
185
+ }
186
+ }
187
+ if (definition.reviewerVariant && !definition.reviewerVariant.reviewer) fail("reviewerVariant.reviewer", "reviewer variant lacks reviewer policy");
188
+ if (definition.reviewer?.unavailable && (definition.start.create || definition.reviewer.backend)) fail("reviewer", "unavailable reviewer must remain metadata-only");
189
+ }
190
+
129
191
  /** Host-global native quirks are not capabilities a config-only or external definition may opt into. */
130
192
  export function assertNativeCapabilityScope(definition: AdapterDefinition): void {
131
193
  if (fixedBuiltinDefinitions.has(definition)) return;
@@ -380,3 +380,7 @@ export class ReviewerPrintPeer extends BasePeer {
380
380
  if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.childStop = undefined; this.childCleanup = undefined; this.childCurrent = undefined; }
381
381
  }
382
382
  }
383
+
384
+ export function isReviewerPrintPeer(value: unknown): value is ReviewerPrintPeer {
385
+ return value instanceof ReviewerPrintPeer;
386
+ }
@@ -1,3 +1,4 @@
1
+ import { AGY_DEFINITION } from "../adapters/agy/definition.ts";
1
2
  import type { Stats } from "node:fs";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
@@ -53,7 +54,7 @@ function shellUnquote(value: string): string | undefined {
53
54
  }
54
55
 
55
56
  function validateOptions(options: AgyMcpOptions) {
56
- const peer = options.peer ?? "agy";
57
+ const peer = options.peer ?? AGY_DEFINITION.kind;
57
58
  if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer)) throw new Error("agy peer id is invalid");
58
59
  for (const [name, value] of Object.entries({ projectRoot: options.projectRoot, stateDir: options.stateDir, serverJs: options.serverJs, hookScript: options.hookScript })) {
59
60
  if (typeof value !== "string" || !isAbsolute(value) || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) throw new Error(`${name} must be an absolute control-free path`);
@@ -326,7 +327,7 @@ export function cleanupAgyMcp(options: AgyMcpOptions): void {
326
327
  if (!present.length) return;
327
328
  if (present.length !== FILES.length) throw new Error("managed agy plugin is incomplete; inspect it before cleanup");
328
329
  const currentHooks = parseJson(readFileSync(join(plan.root, HOOKS_FILE)), HOOKS_FILE);
329
- const launch = managedHookLaunch(currentHooks, options, options.peer ?? "agy");
330
+ const launch = managedHookLaunch(currentHooks, options, options.peer ?? AGY_DEFINITION.kind);
330
331
  if (!launch || launch.instanceId !== options.instanceId || launch.launchId !== options.launchId) return;
331
332
  // A stale cleanup never removes files a newer config update has replaced.
332
333
  if (plan.changed) return;
@@ -0,0 +1,58 @@
1
+ import type { ProjectAdapterCatalog } from "../adapters/registry.ts";
2
+ import type { InstancesConfig } from "../hub/instances.ts";
3
+ import { requireActiveInstance, requirePeerInstances } from "./launch.ts";
4
+ import { configuredAcpPreview } from "./preview.ts";
5
+
6
+ export interface ConfiguredAcpSelection { peer: string; mode: "headless"; sessionId?: string; preview: boolean }
7
+ export function parseConfiguredAcpArguments(kind: string, raw: readonly string[], catalog: ProjectAdapterCatalog, instances?: InstancesConfig): ConfiguredAcpSelection {
8
+ const definition = catalog.definitionOf(kind);
9
+ if (!definition?.permission.genericAcp) throw new Error("unknown configured ACP kind");
10
+ const values = new Map<string, string>();
11
+ let preview = false;
12
+ for (let index = 0; index < raw.length; index++) {
13
+ const arg = raw[index]!;
14
+ if (arg === "--print-command" || arg === "--dry-run") { if (preview) throw new Error("preview option may appear only once"); preview = true; continue; }
15
+ const equal = arg.indexOf("="), flag = equal < 0 ? arg : arg.slice(0, equal);
16
+ if (!["--as", "--mode", "--session-id"].includes(flag) || values.has(flag)) throw new Error("configured ACP accepts only --mode headless, --as, --session-id and one preview option");
17
+ const value = equal < 0 ? raw[++index] : arg.slice(equal + 1);
18
+ if (!value || value.startsWith("--") || Buffer.byteLength(value) > 4096 || /[\p{Cc}\p{Cf}]/u.test(value)) throw new Error(`${flag} needs a bounded control-free value`);
19
+ values.set(flag, value);
20
+ }
21
+ if (values.has("--mode") && values.get("--mode") !== "headless") throw new Error("configured ACP supports headless only");
22
+ const peer = values.get("--as") ?? kind;
23
+ if (values.has("--as") && (!instances || !Object.hasOwn(instances, peer) || instances[peer]?.kind !== kind || !catalog.definitionOf(peer, instances))) throw new Error("--as must name a declared instance of this configured kind");
24
+ return { peer, mode: "headless", ...(values.has("--session-id") ? { sessionId: values.get("--session-id")! } : {}), preview };
25
+ }
26
+ export interface ConfiguredAcpControl {
27
+ features: readonly string[];
28
+ request(message: Record<string, unknown>, timeout?: number): Promise<any>;
29
+ close(): void;
30
+ }
31
+ export async function requireActiveConfiguredAcp(hub: ConfiguredAcpControl, kind: string, peer: string, warn?: (text: string) => void): Promise<void> {
32
+ if (!hub.features.includes("configured_acp")) throw new Error("running hub lacks configured_acp; upgrade and restart it first");
33
+ if (peer !== kind) requirePeerInstances(hub.features);
34
+ const reply = await hub.request({ t: "status" });
35
+ const active = reply.status?.configuredAdapters;
36
+ if (!Array.isArray(active) || !active.includes(kind)) throw new Error(`running hub has not loaded ${kind}; restart it after declaring the adapter`);
37
+ if (peer !== kind) requireActiveInstance(reply.status, peer);
38
+ const namespace = `adapters.${kind}`;
39
+ const retained = [reply.status?.retainedConfig, reply.status?.pendingRestart].some(rows => Array.isArray(rows) && rows.some(row => {
40
+ const key = row && typeof row === "object" ? row.key : undefined;
41
+ return typeof key === "string" && (key === "adapters" || key === namespace || key.startsWith(`${namespace}.`));
42
+ }));
43
+ if (retained) warn?.(`${kind}: configured adapter changes are retained; this start uses the running definition. Restart the hub to apply the file's definition.`);
44
+ }
45
+ export async function runConfiguredAcpEntry(context: {
46
+ kind: string; raw: readonly string[]; catalog: ProjectAdapterCatalog; instances?: InstancesConfig;
47
+ connect(): Promise<ConfiguredAcpControl>; print(text: string): void; operationId?: string;
48
+ }): Promise<void> {
49
+ const selected = parseConfiguredAcpArguments(context.kind, context.raw, context.catalog, context.instances);
50
+ if (selected.preview) { context.print(JSON.stringify(configuredAcpPreview(context.kind, selected.peer, selected.sessionId !== undefined), null, 2)); return; }
51
+ const hub = await context.connect();
52
+ try {
53
+ await requireActiveConfiguredAcp(hub, context.kind, selected.peer, context.print);
54
+ const reply = await hub.request({ t: "start", peer: selected.peer, args: { mode: "headless", ...(selected.sessionId ? { sessionId: selected.sessionId } : {}) }, ...(context.operationId ? { operationId: context.operationId } : {}) }, 70_000);
55
+ if (!reply.ok) throw new Error(typeof reply.error === "string" ? reply.error : "configured ACP start refused");
56
+ context.print(reply.already ? `${selected.peer} is already attached` : `${selected.peer} attached (headless); use ahub say @${selected.peer} and the console for approvals`);
57
+ } finally { hub.close(); }
58
+ }
@@ -2,7 +2,7 @@ import { consoleNeeds, characterAttentionLine, attentionCommand } from "./attent
2
2
  import { CHARACTERS, petFrame, petSize, petFooterRows, type CharacterId, type PetSize, type PetPose } from "../pet/frame.ts";
3
3
  import { PEER_PALETTE } from "../ui/peer-palette.ts";
4
4
  import { PERMISSION_MODES, permissionBoundary } from "../hub/permission-mode.ts";
5
- import { PLANNER_PEERS, definitionOf, nativeStreamTone } from "../adapters/registry.ts";
5
+ import { PLANNER_PEERS, definitionOf, nativeStreamTone, createProjectAdapterCatalog } from "../adapters/registry.ts";
6
6
  import { taskProgress } from "../ui/task-progress.ts";
7
7
  import type { ProgressStage } from "../ui/task-progress.ts";
8
8
  import { sanitize } from "../hub/envelope.ts";
@@ -91,7 +91,7 @@ export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean
91
91
  return true;
92
92
  }
93
93
  export function permissionDefaultText(row: Pick<PermissionDefault, "peer" | "source">): string {
94
- return `never-ask default for ${row.peer} from ${quoted(row.source)}\n${permissionBoundary(row.peer)}\nUntil you answer y, this default stays ask. y enables; n cancels for this hub; Esc defers.`;
94
+ return `never-ask default for ${row.peer} from ${quoted(row.source)}\n${permissionBoundary(row.peer, undefined, createProjectAdapterCatalog())}\nUntil you answer y, this default stays ask. y enables; n cancels for this hub; Esc defers.`;
95
95
  }
96
96
  /** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
97
97
  export function terminalText(value: unknown): string {
@@ -471,6 +471,8 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
471
471
  if (s.settingsEdit) {
472
472
  const edit = { ...s.settingsEdit }; s.settingsEdit = edit;
473
473
  if (key === "\x1b") { s.settingsEdit = undefined; return done(); }
474
+ const locked = s.settingsRows?.find(row => row.key === edit.key)?.locked;
475
+ if (locked) { s.settingsEdit = undefined; notify(s, locked, now); return done(); }
474
476
  if (edit.phase === "preview") return done();
475
477
  if (edit.phase === "confirm") {
476
478
  s.settingsEdit = undefined;
@@ -632,6 +634,8 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
632
634
  const row = selectedPanelRow(s);
633
635
  if (key === "u" && !s.settingsUndo) { notify(s, "nothing to undo", now); return done(); }
634
636
  if (key === "e" && !row) return done();
637
+ const locked = (key === "e" ? row : s.settingsRows?.find(row => row.key === s.settingsUndo))?.locked;
638
+ if (locked) { notify(s, locked, now); return done(); }
635
639
  const token = (s.settingsSequence ?? 0) + 1; s.settingsSequence = token;
636
640
  s.settingsEdit = { token, undo: key === "u", ...(key === "e" ? { key: row.key } : {}), phase: key === "u" ? "preview" : "value", text: key === "e" ? settingValueText(row.fileValue) : "" };
637
641
  if (key === "u") effects.push({ type: "settings_preview", token, undo: true });
@@ -975,6 +979,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
975
979
  if (p.paused) summary.push(span(" paused", "attention"));
976
980
  if (typeof p.importantWaitingSince === "number") summary.push(span(` important waiting ${duration(now - p.importantWaitingSince)}`, "attention")); // #572 AC1: a busy ACP peer that cannot be steered
977
981
  if (p.toolsOnly) summary.push(span(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") ? ` pull-only, read ${typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never"}` : " tools-only: ahub claude", "attention"));
982
+ if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) summary.push(span(` ${flat(p.statusNotice.message)}`, "attention"));
978
983
  }
979
984
  if (s.pendingRestart?.length) summary.push(span(`${summary.length ? " | " : ""}restart pending: ${s.pendingRestart.map(row => row.key).join(", ")}`, "attention"));
980
985
  if (s.retainedConfig?.length) summary.push(span(`${summary.length ? " | " : ""}config retained: ${s.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, "attention"));
@@ -988,7 +993,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
988
993
  const neverPeer = s.confirm?.type === "permission_default" ? s.confirm.peer : s.confirm?.type === "command" && s.confirm.args[0] === "permission" && s.confirm.args[2] === "never-ask" ? s.confirm.args[1] : undefined;
989
994
  const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
990
995
  if (s.confirm?.type === "permission_default") prompt = `enable never-ask default for ${s.confirm.peer}? y/n Esc defer`;
991
- const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer), "attention")] : summary, columns),
996
+ const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer, undefined, createProjectAdapterCatalog()), "attention")] : summary, columns),
992
997
  fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : approvals, columns),
993
998
  fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
994
999
  const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
@@ -1006,7 +1011,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1006
1011
  const noticeLines = noticeShown && s.petEnabled && petSize(s.petSize, columns, rows) !== "line" ? wrap(`Notice: ${s.notice}`, columns).slice(0, Math.max(0, bodyHeight - 6)).map(text => [span(text, s.noticeTone ?? "failure")]) : [];
1007
1012
  const height = bodyHeight - noticeLines.length;
1008
1013
  const lines: Span[][] = [];
1009
- if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer)], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
1014
+ if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer, undefined, createProjectAdapterCatalog())], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
1010
1015
  else if (s.settingsEdit) {
1011
1016
  const edit = s.settingsEdit;
1012
1017
  const row = s.settingsRows?.find(row => row.key === edit.key);
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
2
3
  import { definitionOf } from "../adapters/registry.ts";
3
4
  // Managed Claude hook transport for turn-free facts, native observations and runtime permissions.
4
5
  // PreToolUse preserves facts additionalContext and adds permission decisions; other phases observe only.
@@ -101,7 +102,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
101
102
  if (import.meta.main) {
102
103
  try {
103
104
  const stateDir = process.env.AGENTHUB_STATE_DIR;
104
- const out = stateDir ? await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? "claude", 2000, process.env.AGENTHUB_HOOK_EVENT) : undefined;
105
+ const out = stateDir ? await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? defaultChannelPeer(undefined), 2000, process.env.AGENTHUB_HOOK_EVENT) : undefined;
105
106
  if (out) console.log(out);
106
107
  } catch {
107
108
  // a hook that fails must not get in the way of the tool call
package/src/cli/help.ts CHANGED
@@ -48,6 +48,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
48
48
  ["ahub recovery-run <operation-id>", "internal: the detached runner of an upgrade, restart or recovery resume"],
49
49
  ]],
50
50
  ["Agents", [
51
+ ["ahub <configured-kind> [--mode headless] [--as <instance>] [--session-id <id>] [--print-command | --dry-run]", "start a trusted config.local.json ACP kind (default off roster); native argv is withheld in previews"],
51
52
  ["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"],
52
53
  ["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"],
53
54
  ["ahub kimi [--print-command] [--mode tui|headless] [--model <alias>]", "default: Kimi's own TUI, pull-only via hub_inbox; no push, steer, watchdog, console approvals or automatic quota handoff. Select headless for ACP"],
@@ -178,3 +179,8 @@ export function renderHelp(columns: number, color: boolean, command?: string): s
178
179
  }
179
180
  return lines.map(line => paint(line, color)).join("\n");
180
181
  }
182
+
183
+ /** Body-free configured command guidance; no native argv or environment values enter help. */
184
+ export function configuredAcpHelp(kind: string): string {
185
+ return `ahub ${kind} [--mode headless] [--as <instance>] [--session-id <id>] [--print-command | --dry-run]\nConfigured ACP runs headless through the hub, with native permissions and NO hub sandbox.\nDeclare it in untracked .agenthub/config.local.json, restart the hub, then enable roster.${kind}.\nPreview withholds native argv and starts nothing. Native/model overrides and --unattended are refused.`;
186
+ }
@@ -1,5 +1,6 @@
1
+ import { nativeCliMarkerPeer } from "../adapters/definitions/native-identity-defaults.ts";
1
2
  import { recoveryPresentationFlag } from "./recovery-presentation-options.ts";
2
- import { definitionOf, kindOf, type KindMap } from "../adapters/registry.ts";
3
+ import { definitionOf, kindOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
3
4
 
4
5
  /** Recovery reads this declaration before delegating to another package's CLI. */
5
6
  export const LIVE_TEST_SUPPORTED = true;
@@ -42,7 +43,7 @@ export function detectCliIdentity(env: Readonly<Record<string, string | undefine
42
43
  if (codex !== undefined && (!codex || /\s/.test(codex))) return invalid("invalid CODEX_THREAD_ID marker");
43
44
  if (session !== undefined && claude === undefined) return invalid("CLAUDE_CODE_SESSION_ID without CLAUDECODE marker");
44
45
  if (claude !== undefined && codex !== undefined) return invalid("conflicting native agent markers");
45
- const native = claude !== undefined ? "claude" : codex !== undefined ? "codex" : undefined;
46
+ const native = nativeCliMarkerPeer(claude !== undefined, codex !== undefined);
46
47
  if (hub !== undefined && native !== undefined && (kindOf(hub, instances) ?? hub) !== native) return invalid("AGENTHUB_PEER_ID conflicts with native agent marker");
47
48
  const peer = hub ?? native;
48
49
  return peer === undefined ? { role: "console" } : { role: "tools", peer };
@@ -51,9 +52,9 @@ export function detectCliIdentity(env: Readonly<Record<string, string | undefine
51
52
  export type PeerCommandAccess = "allowed" | "conductor" | "console";
52
53
 
53
54
  /** A closed list: unknown commands cannot acquire console authority through an agent shell. */
54
- export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
55
+ export function classifyPeerCommand(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): PeerCommandAccess {
55
56
  if (command === "recovery" && recoveryAction(args) === "reattach") return "console";
56
- const native = definitionOf(command);
57
+ const native = catalog?.definitionOf(command) ?? definitionOf(command);
57
58
  if (native?.cli.personOnlyFlags?.some(flag => args.includes(flag))) return "console";
58
59
  if (command === "pet" && args.includes("--window")) return "console";
59
60
  if (command === "permission" || command === "stop" || command === "watch") return "console";
@@ -82,11 +83,11 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
82
83
  }
83
84
 
84
85
  /** No argument text is included: notices and refusal logs must not copy task text or credentials. */
85
- export function cliCommandLabel(command: string, args: readonly string[]): string {
86
+ export function cliCommandLabel(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): string {
86
87
  if (command === "backlog") return ["list", "add", "pull", "pull-ready", "split", "return", "plan", "pin", "undo"].includes(args[0] ?? "") ? `backlog ${args[0]}` : "backlog";
87
88
  if (command === "board" && args[0] === "import") return "board import";
88
89
  if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
89
- if (!definitionOf(command) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
90
+ if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
90
91
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
91
92
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
92
93
  if (sub && ["propose", "show", "assign", "unassign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
package/src/cli/launch.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
1
2
  // Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
2
3
  import { claudeStateFile } from "../hub/claude-state.ts";
3
4
  import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync, openSync, closeSync, readSync, accessSync, constants, linkSync } from "node:fs";
@@ -159,7 +160,7 @@ async function withClaudeLaunchRecord(stateDir: string, action: () => void): Pro
159
160
  }
160
161
 
161
162
  function writeClaudeLaunchRecord(stateDir: string, record: Record<string, unknown>): void {
162
- const file = join(stateDir, claudeStateFile("claude-launch", typeof record.peer === "string" ? record.peer : "claude")), temp = `${file}.${randomUUID()}.tmp`;
163
+ const file = join(stateDir, claudeStateFile("claude-launch", typeof record.peer === "string" ? record.peer : defaultChannelPeer(undefined))), temp = `${file}.${randomUUID()}.tmp`;
163
164
  try { writeFileSync(temp, JSON.stringify(record), { mode: 0o600 }); renameSync(temp, file); }
164
165
  finally { try { unlinkSync(temp); } catch { /* the rename normally consumed it */ } }
165
166
  }
@@ -202,7 +203,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
202
203
  const publication = await withClaudeLaunchRecord(stateDir, () => {
203
204
  let current: ClaudeLaunchRecord;
204
205
  try {
205
- const file = join(stateDir, claudeStateFile("claude-launch", launch.peer ?? "claude")), stat = lstatSync(file);
206
+ const file = join(stateDir, claudeStateFile("claude-launch", launch.peer ?? defaultChannelPeer(undefined))), stat = lstatSync(file);
206
207
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
207
208
  current = JSON.parse(readFileSync(file, "utf8"));
208
209
  } catch { return; }
@@ -219,7 +220,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
219
220
  writeFileSync(temp, JSON.stringify({ ...launch, nativePid: pid, nativeSignature: signature, nativeIdentity: "direct-child" }), { mode: 0o600, flag: "wx" });
220
221
  renameSync(temp, file);
221
222
  } finally { try { unlinkSync(temp); } catch { /* atomic rename consumed our private temporary file */ } }
222
- try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", launch.peer ?? "claude")), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
223
+ try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", launch.peer ?? defaultChannelPeer(undefined))), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
223
224
  });
224
225
  if (publication.warning) report?.(publication.warning);
225
226
  return publication.recorded && recorded;
@@ -333,7 +334,7 @@ export function buildLaunch(
333
334
  ): Launch {
334
335
  const adapter = definitionOf(tool);
335
336
  if (!adapter) throw new Error("native launch definition unavailable");
336
- const { metadata: _metadata, ...launch } = adapter.start.launch({
337
+ const { metadata: _metadata, ...launch } = adapter.start.launch!({
337
338
  instance: ctx.instance ?? tool, form: "tui", projectRoot: process.cwd(), stateDir: ctx.facts?.stateDir ?? ctx.statusLine?.stateDir ?? "",
338
339
  entrypoint: resolve(import.meta.dir, "main.js"), userArgs, env: {}, ...ctx,
339
340
  });