@staix/agent-hub 0.12.44 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +83 -116
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +15 -0
  6. package/docs/agent-notes/adapters.md +63 -46
  7. package/docs/agent-notes/budget.md +7 -2
  8. package/docs/agent-notes/bus.md +6 -4
  9. package/docs/agent-notes/daemon.md +15 -8
  10. package/docs/agent-notes/local-worker.md +6 -4
  11. package/docs/agent-notes/tasks.md +18 -5
  12. package/docs/agent-notes/tests.md +11 -9
  13. package/docs/dogfood.md +92 -0
  14. package/docs/events.md +14 -1
  15. package/docs/operations.md +349 -26
  16. package/docs/profiles.md +2 -2
  17. package/docs/quickstart.md +12 -2
  18. package/docs/sdlc.md +61 -13
  19. package/docs/security.md +24 -12
  20. package/docs/smoke.md +9 -4
  21. package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
  22. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  24. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  25. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  26. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  27. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  28. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  29. package/docs/verification/2026-10-22-code-facts.md +78 -0
  30. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  31. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  32. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  33. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  34. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  35. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  36. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  37. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  39. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  40. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  44. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  45. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  48. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  53. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  54. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  56. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  57. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  58. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  60. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  65. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  66. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  68. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  71. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  72. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  73. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  74. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  75. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  76. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  77. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  78. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  79. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  80. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  81. package/docs/verified.json +19 -16
  82. package/package.json +5 -1
  83. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  84. package/plugins/agent-hub/server.js +10120 -9623
  85. package/src/adapters/adapter-api.ts +2 -0
  86. package/src/adapters/agy/definition.ts +3 -1
  87. package/src/adapters/agy/entry.ts +5 -4
  88. package/src/adapters/claude-channel.ts +32 -12
  89. package/src/adapters/codex-appserver.ts +19 -2
  90. package/src/adapters/codex-review-isolation.ts +12 -5
  91. package/src/adapters/compat.ts +140 -0
  92. package/src/adapters/conformance/acp.ts +114 -0
  93. package/src/adapters/conformance/codex.ts +191 -0
  94. package/src/adapters/conformance/enrollment.ts +43 -0
  95. package/src/adapters/conformance/factories.ts +17 -0
  96. package/src/adapters/conformance/fake-paths.ts +12 -0
  97. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  98. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  99. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  100. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  101. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  102. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  103. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  104. package/src/adapters/conformance/index.ts +11 -0
  105. package/src/adapters/conformance/kit.ts +50 -0
  106. package/src/adapters/conformance/local-pi.ts +100 -0
  107. package/src/adapters/conformance/owned-acp.ts +44 -0
  108. package/src/adapters/definitions/builtin-metadata.ts +25 -7
  109. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  110. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  111. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  112. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  113. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  114. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  115. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  116. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  117. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  118. package/src/adapters/definitions/qwen-native.ts +7 -3
  119. package/src/adapters/definitions/qwen.ts +5 -1
  120. package/src/adapters/external-metadata.ts +48 -0
  121. package/src/adapters/external-module-scan.ts +126 -0
  122. package/src/adapters/external-modules.ts +240 -0
  123. package/src/adapters/jcode.ts +72 -31
  124. package/src/adapters/kimi-review.ts +6 -3
  125. package/src/adapters/pi.ts +2 -0
  126. package/src/adapters/public.ts +7 -0
  127. package/src/adapters/registry-types.ts +13 -1
  128. package/src/adapters/registry.ts +52 -15
  129. package/src/cli/agents.ts +40 -0
  130. package/src/cli/console-review.ts +54 -0
  131. package/src/cli/console-state.ts +124 -29
  132. package/src/cli/console-symbols.ts +74 -0
  133. package/src/cli/console-usage.ts +89 -0
  134. package/src/cli/console.ts +26 -1
  135. package/src/cli/help.ts +36 -21
  136. package/src/cli/identity.ts +30 -7
  137. package/src/cli/init.ts +109 -11
  138. package/src/cli/main.ts +145 -16
  139. package/src/cli/output.ts +44 -1
  140. package/src/cli/preview.ts +1 -1
  141. package/src/cli/sdlc-release-guard.ts +82 -0
  142. package/src/cli/upgrade-labels.ts +1 -1
  143. package/src/cli/upgrade-presentation.ts +1 -1
  144. package/src/cli/upgrade-restore-state.ts +3 -2
  145. package/src/cli/upgrade-runtime.ts +4 -1
  146. package/src/hub/action-class.ts +362 -0
  147. package/src/hub/action-policy.ts +106 -0
  148. package/src/hub/board.ts +72 -11
  149. package/src/hub/budget.ts +71 -2
  150. package/src/hub/bus.ts +43 -3
  151. package/src/hub/checks.ts +80 -31
  152. package/src/hub/code-facts.ts +223 -0
  153. package/src/hub/config-apply.ts +28 -11
  154. package/src/hub/config-reload.ts +8 -5
  155. package/src/hub/config-trust.ts +7 -4
  156. package/src/hub/control-client.ts +4 -1
  157. package/src/hub/daemon.ts +332 -61
  158. package/src/hub/dogfood-read.ts +199 -0
  159. package/src/hub/dogfood.ts +163 -0
  160. package/src/hub/envelope.ts +1 -1
  161. package/src/hub/events.ts +11 -2
  162. package/src/hub/facts.ts +76 -13
  163. package/src/hub/hub-tools.ts +45 -7
  164. package/src/hub/inbox-polling.ts +25 -0
  165. package/src/hub/instance-config.ts +17 -5
  166. package/src/hub/instance-execution.ts +20 -8
  167. package/src/hub/instances.ts +75 -2
  168. package/src/hub/peers.ts +9 -0
  169. package/src/hub/plan-conformance.ts +83 -0
  170. package/src/hub/profiles.ts +7 -2
  171. package/src/hub/release-worktree.ts +56 -0
  172. package/src/hub/review-brief.ts +36 -0
  173. package/src/hub/review-findings.ts +104 -0
  174. package/src/hub/roster.ts +31 -5
  175. package/src/hub/sdlc-queue.ts +48 -11
  176. package/src/hub/sdlc-release-authorization.ts +173 -0
  177. package/src/hub/sdlc-release.ts +16 -1
  178. package/src/hub/sdlc.ts +71 -18
  179. package/src/hub/settings.ts +10 -3
  180. package/src/hub/snapshots.ts +101 -14
  181. package/src/hub/symbol-normalizer.ts +26 -0
  182. package/src/hub/task-code-symbols.ts +90 -0
  183. package/src/hub/task-sweep.ts +3 -2
  184. package/src/hub/tasks.ts +556 -88
  185. package/src/hub/test-integrity.ts +162 -0
  186. package/src/hub/ui.ts +2 -2
  187. package/src/hub/usage-rollup.ts +361 -0
  188. package/src/local/sandbox.ts +5 -3
  189. package/src/ui/index.html +560 -78
  190. package/src/ui/office-model.ts +3 -1
  191. package/src/ui/peer-palette.ts +44 -0
  192. package/templates/AGENTS.block.md +1 -1
  193. package/templates/profiles/dev.json +6 -1
  194. package/templates/profiles/general.json +6 -1
  195. package/templates/profiles/research.json +6 -1
  196. package/templates/profiles/writing.json +6 -1
  197. package/templates/release-guard.block.md +6 -0
@@ -0,0 +1,223 @@
1
+ import { spawn } from "node:child_process";
2
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, isAbsolute, join, relative } from "node:path";
5
+ import { childEnv, stopOwnedProcess, trackGroup } from "./child-process.ts";
6
+ import { hubGitEnv, hubGitSync } from "./git.ts";
7
+ import { foldSegment } from "./permission-mode.ts";
8
+ import { realPath } from "./project.ts";
9
+ import { releaseTrackedClean } from "./release-worktree.ts";
10
+
11
+ export interface CodeSymbol { name: string; kind: string; path: string; line: number; endLine?: number; ambiguous: boolean; change: "unknown" }
12
+ export type CodeFactResult<T> = { state: "known"; value: T } | { state: "unknown" };
13
+ export interface CodeLocation { repo: string; head: string; private?: boolean }
14
+ const unknown = (): {state:"unknown"} => ({state:"unknown"});
15
+ const SHA = /^[a-f0-9]{40}$/;
16
+ // Keep the owned group leader alive after the CLI exits, until the host has stopped every group member.
17
+ const LAUNCHER = String.raw`
18
+ const {writeSync,closeSync}=require("node:fs");
19
+ setInterval(()=>{},1000);
20
+ let code=null;
21
+ try { code=await Bun.spawn(process.argv.slice(1),{stdin:"ignore",stdout:"inherit",stderr:"ignore"}).exited; } catch {}
22
+ try { closeSync(1); } catch {}
23
+ writeSync(3,JSON.stringify({code})+"\n");
24
+ `;
25
+ const MAX_OUTPUT = 256 * 1024, MAX_SYMBOLS = 256, MAX_FILES = 4096, MAX_TREE_BYTES = 32 * 1024 * 1024;
26
+ const within = (root:string,file:string) => {const r=relative(root,file);return r !== ".." && !r.startsWith("../") && !isAbsolute(r);};
27
+ const text = (value:unknown,cap=512): value is string => typeof value === "string" && !!value && Buffer.byteLength(value)<=cap && !/[\p{Cc}\p{Cf}]/u.test(value);
28
+ const pathName = (value:unknown): value is string => text(value,2048) && !isAbsolute(value) && !value.includes("\\") && !value.split("/").some(p=>!p || [".","..",".git"].includes(foldSegment(p)));
29
+ const integer = (value:unknown) => typeof value === "string" && /^\d+$/.test(value) && Number.isSafeInteger(Number(value));
30
+ interface Xml { tag:string; attrs:Record<string,string>; children:Xml[] }
31
+ /** Small bounded XML data reader: no DTD, entities, processing instructions or external resources. */
32
+ function xml(raw:string): Xml {
33
+ let root:Xml|undefined;const stack:Xml[]=[];let count=0;
34
+ const decode=(s:string)=>s.replace(/&(amp|lt|gt|quot|apos);/g,(_,e:string)=>({amp:"&",lt:"<",gt:">",quot:'"',apos:"'"})[e]!);
35
+ const tokens=raw.match(/<!--[\s\S]*?-->|<[^>]*>|[^<]+/g)??[];if(tokens.join("")!==raw)throw Error();
36
+ for(const part of tokens) {
37
+ if(part.startsWith("<!--"))continue;
38
+ if(!part.startsWith("<")){if(part.trim())throw Error();continue;}
39
+ if(part.startsWith("</")){if(!/^<\/[a-z][a-z0-9_-]*>$/.test(part)||stack.pop()?.tag!==part.slice(2,-1))throw Error();continue;}
40
+ const m=/^<([a-z][a-z0-9_-]*)([\s\S]*?)(\/?)>$/.exec(part);if(!m || ++count>4096 || stack.length>16)throw Error();
41
+ const attrs:Record<string,string>=Object.create(null);let rest=m[2]!;
42
+ while(rest.trim()) {
43
+ const a=/^\s+([a-z_][a-z0-9_-]*)="([^"<>]*)"/.exec(rest);if(!a || Object.hasOwn(attrs,a[1]!) || /&(?!amp;|lt;|gt;|quot;|apos;)/.test(a[2]!))throw Error();
44
+ attrs[a[1]!]=decode(a[2]!);rest=rest.slice(a[0].length);
45
+ }
46
+ const node={tag:m[1]!,attrs,children:[] as Xml[]};
47
+ if(stack.length)stack.at(-1)!.children.push(node);else {if(root)throw Error();root=node;}
48
+ if(!m[3])stack.push(node);
49
+ }
50
+ if(!root || stack.length)throw Error();return root;
51
+ }
52
+ function cleanSymbol(name:unknown,kind:unknown,position:unknown,via?:unknown): CodeSymbol {
53
+ if(via!==undefined && via!=="name" && via!=="resolved")throw Error();
54
+ if(!text(name) || !text(kind,32) || !/^[a-z_]+$/.test(kind) || !text(position,2100))throw Error();
55
+ const p=/^(.*):(\d+)$/.exec(position);if(!p || !pathName(p[1]) || !integer(p[2]) || Number(p[2])<1)throw Error();
56
+ return {name,kind,path:p[1],line:Number(p[2]),ambiguous:via === "name",change:"unknown"};
57
+ }
58
+ function changed(raw:string,head:string,base:string,cwd:string): CodeSymbol[] {
59
+ const root=xml(raw),a=root.attrs;
60
+ if(root.tag!=="pr-context" || a.schema!=="ripwire.pr-context/v1" || !a.root || realPath(a.root)!==realPath(cwd) || !a.at || !head.startsWith(a.at) || a.at.length<9 || !a.base_sha || !base.startsWith(a.base_sha) || a.base_sha.length<9
61
+ || a.anchor!=="merge-base" || a.direction!=="head-since-fork" || ![undefined,"0","false","none"].includes(a.truncated) || ![undefined,"0"].includes(a.trim_level) || ![undefined,"0","1","false","true"].includes(a.counts_floor))throw Error();
62
+ const files=root.children.filter(n=>n.tag==="file");if(!integer(a.files)||Number(a.files)!==files.length)throw Error();
63
+ const out:CodeSymbol[]=[];
64
+ for(const file of files) {
65
+ if(!pathName(file.attrs.p))throw Error();
66
+ const group=file.children.find(n=>n.tag==="changed-symbols");if(!group || !integer(group.attrs.count))throw Error();
67
+ const symbols=group.children.filter(n=>n.tag==="s");if(Number(group.attrs.count)!==symbols.length || !integer(file.attrs.symbols) || Number(file.attrs.symbols)<symbols.length)throw Error();
68
+ for(const symbol of symbols) {
69
+ if(![undefined,"0","false"].includes(symbol.attrs.capped))throw Error();
70
+ const s=cleanSymbol(symbol.attrs.n,symbol.attrs.t,symbol.attrs.p,symbol.attrs.via);if(s.path!==file.attrs.p)throw Error();out.push(s);
71
+ }
72
+ }
73
+ if(out.length>MAX_SYMBOLS)throw Error();return out;
74
+ }
75
+
76
+ /** Sole optional ripwire boundary. No model/config-selected command, no raw output in results or errors. */
77
+ export class CodeFacts {
78
+ private readonly cache=new Map<string,Promise<CodeFactResult<unknown>>>();
79
+ private busy=false;
80
+ private closing=false;
81
+ private pending?:Promise<CodeFactResult<unknown>>;
82
+ private cancelActive?:()=>void;
83
+ private failedChild?:ReturnType<typeof spawn>;
84
+ private retained?:{tree:{cwd:string;close:()=>void};child:ReturnType<typeof spawn>};
85
+ async close():Promise<void> {
86
+ this.closing=true;this.cancelActive?.();await this.pending;
87
+ if(this.retained){
88
+ try{await stopOwnedProcess(this.retained.child,{group:true,termMs:50,killMs:500});this.retained.tree.close();this.retained=undefined;}
89
+ catch{throw Error("code facts cleanup unconfirmed");}
90
+ }
91
+ }
92
+ constructor(private readonly options:{env?:NodeJS.ProcessEnv;timeoutMs?:number}={}) {}
93
+ private git(repo:string,args:string[]) {
94
+ return hubGitSync(["-C",repo,...args],{encoding:"utf8",env:hubGitEnv(childEnv(),{repositoryIsolation:true}),timeout:5000,maxBuffer:1024*1024});
95
+ }
96
+ private value(repo:string,args:string[]):string {const r=this.git(repo,args);if(r.status!==0)throw Error();return r.stdout.trim();}
97
+ private nativeEnv(cwd:string):NodeJS.ProcessEnv {
98
+ const env=hubGitEnv(childEnv(this.options.env ?? process.env),{repositoryIsolation:true});
99
+ const filters=this.git(cwd,["config","--name-only","--get-regexp","^filter\\..*\\.(clean|smudge|process|required)$"]);
100
+ if(filters.status!==0 && filters.status!==1)throw Error();
101
+ const keys=filters.stdout.trim()?filters.stdout.trim().split("\n"):[];
102
+ if(keys.length>128 || keys.some(key=>!/^filter\.[A-Za-z0-9_.-]+\.(clean|smudge|process|required)$/.test(key)))throw Error();
103
+ const config=["core.fsmonitor=false","core.hooksPath=/dev/null","diff.external=","core.pager=cat",...keys.map(key=>`${key}=${key.endsWith(".required")?"false":""}`)];
104
+ env.GIT_CONFIG_GLOBAL="/dev/null";env.GIT_CONFIG_NOSYSTEM="1";env.GIT_ATTR_NOSYSTEM="1";env.GIT_PAGER="cat";delete env.GIT_EXTERNAL_DIFF;
105
+ env.GIT_CONFIG_COUNT=String(config.length);
106
+ config.forEach((item,index)=>{const at=item.indexOf("=");env[`GIT_CONFIG_KEY_${index}`]=item.slice(0,at);env[`GIT_CONFIG_VALUE_${index}`]=item.slice(at+1);});
107
+ return env;
108
+ }
109
+ private async run(bin:string,args:string[],cwd:string):Promise<{code:number;out:string}> {
110
+ if(this.closing)return {code:1,out:""};
111
+ return new Promise(resolve=>{
112
+ let child:ReturnType<typeof spawn>;
113
+ try{child=spawn(process.execPath,["-e",LAUNCHER,"--",bin,...args],{cwd,env:this.nativeEnv(cwd),detached:true,stdio:["ignore","pipe","ignore","pipe"]});}catch{resolve({code:1,out:""});return;}
114
+ let size=0,done=false,failed=false,control="";const parts:Buffer[]=[];
115
+ let ended=false;const outputEnd=new Promise<void>(resolveEnd=>child.stdout!.once("end",()=>{ended=true;resolveEnd();}));
116
+ const finish=async(code:number|null)=>{
117
+ if(done)return;done=true;clearTimeout(timer);if(code===null)failed=true;
118
+ if(code!==null && !ended)await Promise.race([outputEnd,Bun.sleep(500)]);
119
+ if(!ended)code=null;
120
+ try{await stopOwnedProcess(child,{group:true,termMs:50,killMs:500});}catch{this.failedChild=child;code=null;}
121
+ if(this.cancelActive===cancel)this.cancelActive=undefined;
122
+ resolve({code:failed?1:code??1,out:!failed && code!==null?Buffer.concat(parts).toString("utf8"):""});
123
+ };
124
+ const cancel=()=>{void finish(null);};this.cancelActive=cancel;
125
+ const timer=setTimeout(cancel,Math.max(1,Math.min(5000,this.options.timeoutMs ?? 5000)));
126
+ child.once("spawn",()=>trackGroup(child));
127
+ child.stdout!.on("data",(b:Buffer)=>{if(failed)return;if(size+b.length>MAX_OUTPUT){failed=true;void finish(null);return;}parts.push(b);size+=b.length;});
128
+ child.stdio[3]!.on("data",(b:Buffer)=>{
129
+ if(done)return;control+=b.toString("utf8");if(control.length>64){void finish(null);return;}
130
+ if(!control.endsWith("\n"))return;
131
+ try{const message=JSON.parse(control);if(Object.keys(message).length!==1 || !(message.code===null || Number.isSafeInteger(message.code)&&message.code>=0&&message.code<=255))throw Error();void finish(message.code);}catch{void finish(null);}
132
+ });
133
+ child.once("error",()=>{void finish(null);});child.once("close",()=>{void finish(null);});
134
+ });
135
+ }
136
+ private tree(repo:string,head:string):{cwd:string;close:()=>void} {
137
+ const inventory=this.value(repo,["ls-tree","-rz","--full-tree",head]);
138
+ const entries=inventory ? inventory.split("\0").filter(Boolean).map(line=>{
139
+ const m=/^(100644|100755) blob ([a-f0-9]{40})\t([\s\S]+)$/.exec(line);if(!m || !pathName(m[3]))throw Error();return {mode:m[1]!,oid:m[2]!,name:m[3]!};
140
+ }):[];
141
+ // ponytail: regular-file trees only, <=4096 entries/32 MiB/4 MiB per file. Symlink/gitlink trees stay unknown; a verified materializer is the upgrade.
142
+ if(entries.length>MAX_FILES)throw Error();
143
+ // Always materialize: tracked-clean owner trees can still contain untracked or ignored analysis inputs.
144
+ const parent=mkdtempSync(join(tmpdir(),"ahub-code-facts-")),cwd=join(parent,"tree");let registered=false;
145
+ const close=()=>{
146
+ let failed=false;
147
+ if(registered){const r=this.git(repo,["worktree","remove","--force",cwd]);failed=r.status!==0;}
148
+ rmSync(parent,{recursive:true,force:true});if(failed)throw Error();
149
+ };
150
+ try{
151
+ if(this.git(repo,["worktree","add","--detach","--no-checkout",cwd,head]).status!==0)throw Error();registered=true;
152
+ if(this.git(cwd,["read-tree",head]).status!==0)throw Error();
153
+ // --batch needs explicit object ids; no working-tree filters or checkout hooks are invoked.
154
+ const result=hubGitSync(["-C",repo,"cat-file","--batch"],{input:entries.map(e=>e.oid).join("\n")+"\n",encoding:"latin1",env:hubGitEnv(childEnv(),{repositoryIsolation:true}),timeout:5000,maxBuffer:MAX_TREE_BYTES+1024*1024});
155
+ if(result.status!==0)throw Error();
156
+ const bytes=Buffer.from(result.stdout,"latin1");let offset=0,total=0;
157
+ for(const entry of entries){
158
+ const end=bytes.indexOf(10,offset);if(end<0)throw Error();const m=/^([a-f0-9]{40}) blob (\d+)$/.exec(bytes.subarray(offset,end).toString("ascii"));
159
+ if(!m || m[1]!==entry.oid || !integer(m[2]))throw Error();const size=Number(m[2]);total+=size;offset=end+1;
160
+ if(size>4*1024*1024 || total>MAX_TREE_BYTES || offset+size>=bytes.length || bytes[offset+size]!==10)throw Error();
161
+ const file=join(cwd,entry.name);mkdirSync(dirname(file),{recursive:true});writeFileSync(file,bytes.subarray(offset,offset+size));chmodSync(file,entry.mode==="100755"?0o755:0o644);offset+=size+1;
162
+ }
163
+ if(offset!==bytes.length || !releaseTrackedClean(cwd,args=>{const r=this.git(cwd,args);return{code:r.status??1,stdout:r.stdout};}))throw Error();
164
+ return{cwd,close};
165
+ }catch{close();throw Error();}
166
+ }
167
+ private query<T>(at:CodeLocation,key:string,read:(cwd:string,bin:string)=>Promise<T>):Promise<CodeFactResult<T>> {
168
+ if(this.closing || this.retained || at.private || !SHA.test(at.head))return Promise.resolve(unknown());
169
+ let repo:string;try{repo=realPath(at.repo);}catch{return Promise.resolve(unknown());}
170
+ const id=JSON.stringify([repo,at.head,key]),hit=this.cache.get(id);if(hit)return (hit as Promise<CodeFactResult<T>>).then(value=>structuredClone(value));
171
+ if(this.busy)return Promise.resolve(unknown());
172
+ this.busy=true;this.failedChild=undefined;
173
+ const job=(async()=>{
174
+ let tree:ReturnType<CodeFacts["tree"]>|undefined;let answer:CodeFactResult<T>=unknown();
175
+ try{
176
+ const found=Bun.which("ripwire",{PATH:(this.options.env??process.env).PATH});if(!found)throw Error();const bin=realPath(found);if(within(repo,bin))throw Error();
177
+ tree=this.tree(repo,at.head);
178
+ const version=await this.run(bin,["--version"],tree.cwd);if(version.code!==0 || !/^ripwire 0\.6\.7(?:\s|$)/.test(version.out))throw Error();
179
+ const value=await read(tree.cwd,bin);
180
+ if(this.value(tree.cwd,["rev-parse","HEAD"])!==at.head || !releaseTrackedClean(tree.cwd,args=>{const r=this.git(tree!.cwd,args);return{code:r.status??1,stdout:r.stdout};}))throw Error();
181
+ answer={state:"known",value};
182
+ }catch{/* Optional derived evidence is unknown, never a review refusal. */}
183
+ finally{if(tree && this.failedChild){this.retained={tree,child:this.failedChild};answer=unknown();}else try{tree?.close();}catch{answer=unknown();}this.busy=false;}
184
+ return answer;
185
+ })();
186
+ this.pending=job;this.cache.set(id,job);
187
+ if(this.cache.size>128)this.cache.delete(this.cache.keys().next().value!);
188
+ void job.then(answer=>{if(answer.state==="unknown" && this.cache.get(id)===job)this.cache.delete(id);});
189
+ return job.then(value=>structuredClone(value));
190
+ }
191
+ changedSymbols(repo:string,base:string,head:string,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<CodeSymbol[]>> {
192
+ if(!SHA.test(base))return Promise.resolve(unknown());
193
+ return this.query({...options,repo,head},`changed:${base}`,async(cwd,bin)=>{const r=await this.run(bin,[cwd,`--pr-context=${base}`,"--no-cache"],cwd);if(r.code!==0)throw Error();return changed(r.out,head,base,cwd);});
194
+ }
195
+ enclosing(repo:string,head:string,file:string,line:number,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<CodeSymbol[]>> {
196
+ if(!pathName(file)||!Number.isSafeInteger(line)||line<1)return Promise.resolve(unknown());
197
+ return this.query({...options,repo,head},`at:${file}:${line}`,async(cwd,bin)=>{
198
+ const r=await this.run(bin,[cwd,`--at=${file}:${line}`,"--no-cache"],cwd);if(r.code!==0)throw Error();const node=xml(r.out),a=node.attrs;
199
+ if(node.tag!=="at" || a.schema!=="ripwire.at/v1" || a.p!==file || Number(a.l)!==line || !integer(a.chain)||Number(a.chain)!==node.children.length || node.children.length>32)throw Error();
200
+ return node.children.map(n=>{if(n.tag!=="s" || !integer(n.attrs.l)||!integer(n.attrs.el)||Number(n.attrs.el)<line || Number(n.attrs.l)>line)throw Error();return {...cleanSymbol(n.attrs.n,n.attrs.t,`${file}:${n.attrs.l}`,n.attrs.via),endLine:Number(n.attrs.el)};});
201
+ });
202
+ }
203
+ private async relations(cwd:string,bin:string,symbol:string,kind:"callers"|"callees"):Promise<{defs:number;items:CodeSymbol[];countsFloor:boolean}> {
204
+ const r=await this.run(bin,[cwd,`--${kind}=${symbol}`,"--json","--no-cache"],cwd);if(r.code!==0)throw Error();const v=JSON.parse(r.out);
205
+ if(v.of!==symbol || !Number.isSafeInteger(v.defs)||v.defs<1 || !Number.isSafeInteger(v.count)||v.count<0 || !Array.isArray(v[kind])||v.count!==v[kind].length || v.count>MAX_SYMBOLS || realPath(v.root)!==realPath(cwd) || ![0,1,false,true].includes(v.counts_floor)
206
+ || !Number.isSafeInteger(v.hop_tested)||v.hop_tested<0||!Number.isSafeInteger(v.hop_untested)||v.hop_untested<0
207
+ || !Number.isSafeInteger(v.graph_ambiguous)||v.graph_ambiguous<0||!Number.isSafeInteger(v.graph_unresolved)||v.graph_unresolved<0)throw Error();
208
+ return {defs:v.defs,countsFloor:!!v.counts_floor,items:v[kind].map((s:Record<string,unknown>)=>({...cleanSymbol(s.n,s.t,s.p,s.via),ambiguous:s.via==="name" || v.defs>1 || v.graph_ambiguous>0}))};
209
+ }
210
+ exists(repo:string,head:string,symbol:string,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<{exists:boolean;ambiguous:boolean}>> {
211
+ if(!text(symbol))return Promise.resolve(unknown());
212
+ return this.query({...options,repo,head},`exists:${symbol}`,async(cwd,bin)=>{const r=await this.run(bin,[cwd,`--callers=${symbol}`,"--no-cache"],cwd);
213
+ if(r.code!==0){const n=xml(r.out);if(n.tag==="callers" && n.attrs.schema==="ripwire.callers/v1" && n.attrs.of===symbol && n.attrs.found==="0")return{exists:false,ambiguous:false};throw Error();}
214
+ const n=xml(r.out);if(n.tag!=="callers" || n.attrs.schema!=="ripwire.callers/v1" || n.attrs.of!==symbol || !integer(n.attrs.defs)||Number(n.attrs.defs)<1)throw Error();return{exists:true,ambiguous:n.attrs.via==="name" || Number(n.attrs.defs)>1};});
215
+ }
216
+ neighbours(repo:string,head:string,symbol:string,depth=1,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<{callers:CodeSymbol[];callees:CodeSymbol[];hiddenAmbiguous:number;countsFloor:boolean}>> {
217
+ if(depth!==1 || !text(symbol))return Promise.resolve(unknown());
218
+ return this.query({...options,repo,head},`neighbours:${symbol}:1`,async(cwd,bin)=>{
219
+ const incoming=await this.relations(cwd,bin,symbol,"callers"),outgoing=await this.relations(cwd,bin,symbol,"callees"),callers=incoming.items,callees=outgoing.items;
220
+ return {countsFloor:incoming.countsFloor || outgoing.countsFloor,callers:callers.filter(s=>!s.ambiguous),callees:callees.filter(s=>!s.ambiguous),hiddenAmbiguous:[...callers,...callees].filter(s=>s.ambiguous).length};
221
+ });
222
+ }
223
+ }
@@ -1,3 +1,7 @@
1
+ import { ACTION_CLASSES } from "./action-class.ts";
2
+ import { actionPolicyConfig } from "./action-policy.ts";
3
+ import { adapterModuleDeclarations } from "../adapters/external-modules.ts";
4
+ import { externalMetadataDefinitions, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
1
5
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
2
6
  import type { HubConfig } from "./daemon.ts";
3
7
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
@@ -10,7 +14,7 @@ import { resolveProfile } from "./profiles.ts";
10
14
  import { taskSweepConfig } from "./task-sweep.ts";
11
15
  import { projectionConfig } from "./text-projection.ts";
12
16
  import { validateInstances, type InstancesConfig } from "./instances.ts";
13
- import { peerRosterConfig } from "./roster.ts";
17
+ import { peerRosterConfig, staleInstanceKey } from "./roster.ts";
14
18
  import { CHARACTER_IDS } from "../pet/characters.ts";
15
19
  import { petPalette } from "../pet/config.ts";
16
20
 
@@ -18,9 +22,9 @@ export type ApplyClass = "live" | "next use" | "restart";
18
22
  export interface ApplyRule { applies: ApplyClass; reason?: string }
19
23
  export interface ConfigIdentityScope { catalog: ProjectAdapterCatalog; instances: InstancesConfig }
20
24
  /** Plain captured metadata stays cloneable with ConfigSources; native callbacks are compiled outside it. */
21
- export interface ConfigIdentitySnapshot { adapters?: HubConfig["adapters"]; instances: InstancesConfig }
25
+ export interface ConfigIdentitySnapshot { adapters?: HubConfig["adapters"]; externalAdapters?: readonly ExternalAdapterMetadata[]; instances: InstancesConfig }
22
26
  export function configIdentityScope(snapshot: ConfigIdentitySnapshot): ConfigIdentityScope {
23
- return { catalog: createProjectAdapterCatalog(snapshot.adapters), instances: snapshot.instances };
27
+ return { catalog: createProjectAdapterCatalog(snapshot.adapters, externalMetadataDefinitions(snapshot.externalAdapters)), instances: snapshot.instances };
24
28
  }
25
29
  export interface ConfigChange extends ApplyRule { key: string; before: unknown; after: unknown }
26
30
  export interface ConfigLayer { name: "config.json" | "config.local.json"; raw: Record<string, unknown> }
@@ -30,6 +34,8 @@ export interface ConfigAuthorizationInput {
30
34
  before: unknown;
31
35
  after: unknown;
32
36
  source: "config" | "routing";
37
+ /** Captured host filename, not the requested setting or a caller-provided authority. */
38
+ sourceFile?: string;
33
39
  tracked: boolean | undefined;
34
40
  authority: "ordinary" | "settings" | "terminal";
35
41
  /** The daemon supplies this only for the selected setting or an explicitly confirmed reload key. */
@@ -46,6 +52,10 @@ export interface ConfigAuthorizationDecision { key: string; allowed: boolean; gu
46
52
  export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuthorizationDecision {
47
53
  const { key, before, after } = input;
48
54
  const result = (allowed: boolean, guarded: boolean, reason: string): ConfigAuthorizationDecision => ({ key, allowed, guarded, reason });
55
+ if (key === "action_policy" || key.startsWith("action_policy.")) {
56
+ if (input.sourceFile !== "config.local.json") return result(false, true, "action policy requires .agenthub/config.local.json");
57
+ if (input.tracked !== false) return result(false, true, "action policy requires an untracked machine-local source");
58
+ }
49
59
  if (same(before, after)) return result(true, false, "unchanged");
50
60
  const supplied = input.guard;
51
61
  const guard = supplied && (key === supplied.key || key === `${supplied.store === "routing" ? "routing." : ""}${supplied.path.join(".")}`) ? supplied : undefined;
@@ -133,7 +143,9 @@ const nativeValidators = { bool, text, strings, argv, number, words, block };
133
143
  const schema: Record<string, Check> = {
134
144
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
135
145
  watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
146
+ action_policy: block({ enabled: bool, always_ask: (v, k) => { actionPolicyConfig({ always_ask: v }); }, budgets: map(number(0, 1000, true), ACTION_CLASSES) }),
136
147
  permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
148
+ adapter_modules: v => { adapterModuleDeclarations(v); },
137
149
  adapters: v => { createProjectAdapterCatalog(v); }, instances: v => { validateInstances(v, createProjectAdapterCatalog()); }, pet: validatePetConfig,
138
150
  batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
139
151
  memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
@@ -166,27 +178,28 @@ const schema: Record<string, Check> = {
166
178
  capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
167
179
  };
168
180
  export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
169
- export function validateRawConfig(raw: unknown, declarations?: InstancesConfig, context?: ProjectAdapterCatalog): asserts raw is Record<string, unknown> {
181
+ export function validateRawConfig(raw: unknown, declarations?: InstancesConfig, context?: ProjectAdapterCatalog, inertModuleRoster = false): asserts raw is Record<string, unknown> {
170
182
  const value = object(raw, "config");
171
- const catalog = value.adapters !== undefined ? createProjectAdapterCatalog(value.adapters) : context ?? createProjectAdapterCatalog();
183
+ const catalog = value.adapters !== undefined ? createProjectAdapterCatalog(value.adapters, context?.externalDefinitions) : context ?? createProjectAdapterCatalog();
172
184
  const instances = validateInstances(value.instances ?? declarations, catalog);
173
185
  block({ ...schema,
174
186
  instances: v => { validateInstances(v, catalog); },
175
187
  permission_modes: v => { permissionDefaults(v, instances, catalog); },
176
- roster: v => { peerRosterConfig(v, instances, catalog); },
188
+ roster: v => { peerRosterConfig(v, instances, catalog, inertModuleRoster); },
177
189
  peers: v => { peerStartConfig(v, instances, catalog); },
178
190
  })(raw, "config");
179
191
  }
180
- export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
192
+ export function validateEffectiveConfig(value: unknown, catalog?: ProjectAdapterCatalog): asserts value is HubConfig {
181
193
  const raw = { ...object(value, "config") };
182
194
  delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
183
- validateRawConfig(raw);
195
+ validateRawConfig(raw, undefined, catalog);
184
196
  const profile = resolveProfile(raw.profile, raw.profile_overrides);
185
197
  for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
186
198
  for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
187
199
  }
188
200
 
189
201
  const restart: Record<string, string> = {
202
+ adapter_modules: "External adapter modules are verified and imported only at daemon startup",
190
203
  adapters: "Configured adapter identity, commands and capability policy are fixed at daemon startup",
191
204
  instances: "Native instance identity, worktree, ports and account bindings are fixed at daemon startup",
192
205
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
@@ -197,7 +210,7 @@ const restart: Record<string, string> = {
197
210
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
198
211
  text_projection: "Converter and cache ownership are bound at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
199
212
  };
200
- const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify", "pet"]);
213
+ const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify", "pet"]);
201
214
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
202
215
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
203
216
  export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
@@ -214,7 +227,7 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
214
227
  if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
215
228
  else if (shape?.item) { if (shape.keys && !shape.keys.includes(part) && !(top === "roster" && scopes.some(scope => scope.catalog.definitionOf(part, scope.instances)))) return fail(key, "unknown configuration key"); check = shape.item; }
216
229
  else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
217
- else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances", "adapters"].includes(top)) break;
230
+ else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances", "adapters", "adapter_modules"].includes(top)) break;
218
231
  else return fail(key, "unknown configuration key");
219
232
  }
220
233
  }
@@ -237,7 +250,11 @@ export function configChanges(running: HubConfig, next: HubConfig, scopes: reado
237
250
  if (same(before, after)) return;
238
251
  if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
239
252
  for (const child of new Set([...Object.keys(before), ...Object.keys(after)])) walk(at(before, [child]), at(after, [child]), key ? `${key}.${child}` : child);
240
- } else changes.push({ key, before, after, ...configApply(key, scopes) });
253
+ } else {
254
+ // #225: deleting a roster key whose instance was undeclared is live and needs no identity; adding or setting one stays an unknown key.
255
+ const stale = after === undefined && /^roster\.[^.]+$/.test(key) && scopes.some(scope => staleInstanceKey(key.slice("roster.".length), scope.catalog));
256
+ changes.push({ key, before, after, ...(stale ? { applies: "live" as const } : configApply(key, scopes)) });
257
+ }
241
258
  };
242
259
  const clean = (v: HubConfig) => Object.fromEntries(Object.entries(v).filter(([key]) => !["ignored", "retired", "permission_default_sources"].includes(key)));
243
260
  walk(clean(running), clean(next), ""); return changes.sort((a, b) => a.key.localeCompare(b.key));
@@ -1,4 +1,5 @@
1
1
  import { ADAPTERS, createProjectAdapterCatalog } from "../adapters/registry.ts";
2
+ import { externalMetadataDefinitions, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
2
3
  import { createHash } from "node:crypto";
3
4
  import { readFileSync } from "node:fs";
4
5
  import { join } from "node:path";
@@ -86,7 +87,7 @@ function validateRoutingRaw(value: unknown): void {
86
87
  }
87
88
 
88
89
  /** Strict reloads never use routing's last-good fallback. Startup can retain its existing fallback explicitly. */
89
- export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing, cwd?: string): ConfigSources {
90
+ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing, cwd?: string, externalAdapters?: readonly ExternalAdapterMetadata[]): ConfigSources {
90
91
  const layers: ConfigLayer[] = [], trustLayers: ConfigSources["trustLayers"] = [];
91
92
  const jsonLayers: ConfigLayer[] = [];
92
93
  for (const name of ["config.json", "config.local.json"] as const) {
@@ -103,13 +104,15 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
103
104
  const localRaw = jsonLayers.find(layer => layer.name === "config.local.json")?.raw ?? {};
104
105
  let declared;
105
106
  let identityAdapters: ConfigIdentitySnapshot["adapters"];
107
+ let inertModuleRoster = false;
106
108
  let catalog = createProjectAdapterCatalog();
107
109
  try {
108
110
  if (cwd) {
109
- const accepted = readInstanceConfig(cwd, jsonLayers);
111
+ const accepted = readInstanceConfig(cwd, jsonLayers, externalAdapters === undefined ? undefined : externalMetadataDefinitions(externalAdapters));
110
112
  catalog = accepted.catalog; declared = accepted.instances; identityAdapters = accepted.adapters;
113
+ inertModuleRoster = !!accepted.adapter_modules?.length;
111
114
  } else {
112
- catalog = createProjectAdapterCatalog(localRaw?.adapters);
115
+ catalog = createProjectAdapterCatalog(localRaw?.adapters, externalMetadataDefinitions(externalAdapters));
113
116
  identityAdapters = localRaw?.adapters as ConfigIdentitySnapshot["adapters"];
114
117
  declared = validateInstances(localRaw?.instances, catalog);
115
118
  }
@@ -135,7 +138,7 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
135
138
  const structural = { ...raw };
136
139
  // Declaration provenance was checked before compilation; ignored source data is never re-admitted.
137
140
  delete structural.adapters; delete structural.instances;
138
- validateRawConfig(structural, declared, catalog);
141
+ validateRawConfig(structural, declared, catalog, inertModuleRoster);
139
142
  }
140
143
  catch (error) { throw new Error(`.agenthub/${name}: ${(error as Error).message}`); }
141
144
  }
@@ -153,5 +156,5 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
153
156
  try { routing = parseRouting(base, overlay); }
154
157
  catch { throw new Error(".agenthub/routing.toml / routing.local.toml: invalid effective routing"); }
155
158
  }
156
- return { texts, layers, trustLayers, routing, fingerprint: configFingerprint(texts), identity: { ...(identityAdapters ? { adapters: structuredClone(identityAdapters) } : {}), instances: declared ?? validateInstances(undefined, catalog) } };
159
+ return { texts, layers, trustLayers, routing, fingerprint: configFingerprint(texts), identity: { ...(identityAdapters ? { adapters: structuredClone(identityAdapters) } : {}), instances: declared ?? validateInstances(undefined, catalog), ...(catalog.externalDefinitions.length ? { externalAdapters: catalog.externalDefinitions.map(({ kind, displayName }) => ({ kind, displayName })) } : {}) } };
157
160
  }
@@ -29,7 +29,7 @@ export const MACHINE_LOCAL = [
29
29
  "terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
30
30
  ] as const;
31
31
  /** Legacy exported field list stays compatible; new policies use the complete list below. */
32
- export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "text_projection", "instances"] as const;
32
+ export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;
33
33
 
34
34
  /** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
35
35
  export function configRepositoryPresent(cwd: string): boolean {
@@ -95,14 +95,17 @@ const drop = (file: Record<string, unknown>, path: string) => {
95
95
  * once resolved to the project root). A committed copy of the template sets them to defaults and asks git nothing.
96
96
  */
97
97
  export function stripUntrusted(file: Record<string, unknown>, defaults: object, cwd: string, name: string): string | undefined {
98
+ const localOnly = name !== "config.local.json" && file.action_policy !== undefined;
99
+ if (localOnly) delete file.action_policy;
100
+ const originNotice = localOnly ? "action_policy ignored: only .agenthub/config.local.json may set it" : undefined;
98
101
  for (const p of ALL_MACHINE_LOCAL) if (valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
99
102
  const set = ALL_MACHINE_LOCAL.filter((p) => {
100
103
  const v = valueAt(file, p);
101
104
  return v !== undefined && JSON.stringify(v) !== JSON.stringify(valueAt(defaults, p));
102
105
  });
103
- if (!set.length) return undefined;
106
+ if (!set.length) return originNotice;
104
107
  const why = configRefusal(cwd, name);
105
- if (!why) return undefined;
108
+ if (!why) return originNotice;
106
109
  for (const p of set) drop(file, p);
107
- return `${set.join(", ")} in .agenthub/${name} ignored: ${why}`;
110
+ return [originNotice, `${set.join(", ")} in .agenthub/${name} ignored: ${why}`].filter(Boolean).join("; ");
108
111
  }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { projectContext } from "./project.ts";
4
+ import { externalAdapterMetadata, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
4
5
 
5
6
  export function stateDirFor(cwd: string): string {
6
7
  return projectContext(cwd).stateDir;
@@ -8,7 +9,7 @@ export function stateDirFor(cwd: string): string {
8
9
 
9
10
  /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
10
11
  export const PROTOCOL = 16;
11
- export const DAEMON_FEATURES = ["configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
12
+ export const DAEMON_FEATURES = ["external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
12
13
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
13
14
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
14
15
 
@@ -38,6 +39,7 @@ export interface ControlDescriptor {
38
39
  features?: readonly string[];
39
40
  /** The daemon's accepted native start modes, used by launchers when the file value is retained. */
40
41
  startModes?: Readonly<Record<string, "tui" | "headless">>;
42
+ externalAdapters?: readonly ExternalAdapterMetadata[];
41
43
  /** #226: the daemon's processSignature, written into status.json only (not the status reply); absent before 0.12.21. */
42
44
  pidSignature?: string;
43
45
  }
@@ -54,6 +56,7 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
54
56
  return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
55
57
  instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid, features: Array.isArray(status.features) ? status.features.filter((feature: unknown): feature is string => typeof feature === "string") : [],
56
58
  ...(startModes ? { startModes } : {}),
59
+ ...(status.externalAdapters !== undefined ? { externalAdapters: externalAdapterMetadata(status.externalAdapters) } : {}),
57
60
  ...(typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}) };
58
61
  } catch {
59
62
  return undefined;