@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.
- package/CHANGELOG.md +40 -0
- package/README.md +83 -116
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +15 -0
- package/docs/agent-notes/adapters.md +63 -46
- package/docs/agent-notes/budget.md +7 -2
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +6 -4
- package/docs/agent-notes/tasks.md +18 -5
- package/docs/agent-notes/tests.md +11 -9
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +349 -26
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +12 -2
- package/docs/sdlc.md +61 -13
- package/docs/security.md +24 -12
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10120 -9623
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +32 -12
- package/src/adapters/codex-appserver.ts +19 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +25 -7
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +5 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +13 -1
- package/src/adapters/registry.ts +52 -15
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +36 -21
- package/src/cli/identity.ts +30 -7
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +145 -16
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +72 -11
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +28 -11
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +332 -61
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +45 -7
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +75 -2
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +48 -11
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +71 -18
- package/src/hub/settings.ts +10 -3
- package/src/hub/snapshots.ts +101 -14
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +556 -88
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +2 -2
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +560 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- 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
|
+
}
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -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
|
|
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));
|
package/src/hub/config-reload.ts
CHANGED
|
@@ -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
|
}
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -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
|
|
106
|
+
if (!set.length) return originNotice;
|
|
104
107
|
const why = configRefusal(cwd, name);
|
|
105
|
-
if (!why) return
|
|
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;
|