@staix/agent-hub 0.15.0 → 0.16.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 (80) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +6 -3
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +13 -1
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/dogfood.md +13 -4
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +116 -30
  14. package/docs/quickstart.md +8 -4
  15. package/docs/security.md +80 -18
  16. package/docs/smoke.md +7 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
  18. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  19. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  20. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  21. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  22. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  23. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  24. package/docs/verified.json +26 -17
  25. package/package.json +2 -2
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +2085 -1702
  28. package/src/adapters/acp.ts +5 -2
  29. package/src/adapters/codex-appserver.ts +69 -1
  30. package/src/adapters/compat.ts +2 -2
  31. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  32. package/src/adapters/local-worker.ts +5 -4
  33. package/src/adapters/registry-types.ts +1 -1
  34. package/src/cli/console-state.ts +20 -2
  35. package/src/cli/console-symbols.ts +3 -2
  36. package/src/cli/console-usage.ts +5 -2
  37. package/src/cli/console.ts +5 -1
  38. package/src/cli/help.ts +7 -5
  39. package/src/cli/identity.ts +3 -1
  40. package/src/cli/main.ts +44 -10
  41. package/src/cli/memory.ts +119 -7
  42. package/src/cli/output.ts +12 -4
  43. package/src/hub/action-class.ts +211 -17
  44. package/src/hub/action-enforce.ts +13 -5
  45. package/src/hub/action-policy.ts +19 -8
  46. package/src/hub/ask.ts +16 -4
  47. package/src/hub/board.ts +86 -31
  48. package/src/hub/budget-notes.ts +65 -0
  49. package/src/hub/budget.ts +42 -10
  50. package/src/hub/bus.ts +1 -1
  51. package/src/hub/code-facts.ts +56 -16
  52. package/src/hub/conductor.ts +5 -2
  53. package/src/hub/config-apply.ts +7 -0
  54. package/src/hub/control-client.ts +1 -1
  55. package/src/hub/daemon.ts +250 -49
  56. package/src/hub/envelope.ts +1 -1
  57. package/src/hub/events.ts +2 -2
  58. package/src/hub/hub-tools.ts +3 -3
  59. package/src/hub/kimi-usage.ts +12 -0
  60. package/src/hub/lifecycle.ts +10 -5
  61. package/src/hub/memory-code-head.ts +44 -0
  62. package/src/hub/memory-code-overlay.ts +119 -0
  63. package/src/hub/memory-plan-guard.ts +10 -1
  64. package/src/hub/memory-stale-anchors.ts +159 -0
  65. package/src/hub/memory-symbols.ts +74 -0
  66. package/src/hub/planning.ts +10 -4
  67. package/src/hub/read-tools.ts +21 -3
  68. package/src/hub/sdlc.ts +34 -0
  69. package/src/hub/settings.ts +17 -2
  70. package/src/hub/settled-roles.ts +15 -3
  71. package/src/hub/task-sweep.ts +25 -3
  72. package/src/hub/tasks.ts +353 -64
  73. package/src/hub/token-window.ts +15 -2
  74. package/src/hub/window-kind.ts +13 -0
  75. package/src/local/tools.ts +5 -5
  76. package/src/memory/brief.ts +22 -12
  77. package/src/memory/capture.ts +26 -40
  78. package/src/memory/client.ts +137 -65
  79. package/src/memory/recall.ts +13 -22
  80. package/src/ui/index.html +100 -18
@@ -10,9 +10,12 @@ import { releaseTrackedClean } from "./release-worktree.ts";
10
10
 
11
11
  export interface CodeSymbol { name: string; kind: string; path: string; line: number; endLine?: number; ambiguous: boolean; change: "unknown" }
12
12
  export type CodeFactResult<T> = { state: "known"; value: T } | { state: "unknown" };
13
- export interface CodeLocation { repo: string; head: string; private?: boolean }
13
+ export interface CodeLocation { repo: string; head: string; private?: boolean; priority?: "optional" }
14
+ export const OPTIONAL_CODE_UNKNOWN_TTL_MS = 5000;
14
15
  const unknown = (): {state:"unknown"} => ({state:"unknown"});
15
16
  const SHA = /^[a-f0-9]{40}$/;
17
+ /** These host anchor spellings have no indexed-name lookup attestation in ripwire 0.6.7. */
18
+ export const unindexedSymbolSyntax = (name: string): boolean => /#|:|->/.test(name);
16
19
  // Keep the owned group leader alive after the CLI exits, until the host has stopped every group member.
17
20
  const LAUNCHER = String.raw`
18
21
  const {writeSync,closeSync}=require("node:fs");
@@ -76,6 +79,9 @@ function changed(raw:string,head:string,base:string,cwd:string): CodeSymbol[] {
76
79
  /** Sole optional ripwire boundary. No model/config-selected command, no raw output in results or errors. */
77
80
  export class CodeFacts {
78
81
  private readonly cache=new Map<string,Promise<CodeFactResult<unknown>>>();
82
+ private readonly optionalUnknown=new Map<string,number>();
83
+ private priorityWaiting=0;
84
+ private active?:{id:string;optional:boolean;cancelled:boolean;done:Promise<void>};
79
85
  private busy=false;
80
86
  private closing=false;
81
87
  private pending?:Promise<CodeFactResult<unknown>>;
@@ -83,13 +89,13 @@ export class CodeFacts {
83
89
  private failedChild?:ReturnType<typeof spawn>;
84
90
  private retained?:{tree:{cwd:string;close:()=>void};child:ReturnType<typeof spawn>};
85
91
  async close():Promise<void> {
86
- this.closing=true;this.cancelActive?.();await this.pending;
92
+ this.closing=true;this.cancelActive?.();await (this.active?.done ?? this.pending);
87
93
  if(this.retained){
88
94
  try{await stopOwnedProcess(this.retained.child,{group:true,termMs:50,killMs:500});this.retained.tree.close();this.retained=undefined;}
89
95
  catch{throw Error("code facts cleanup unconfirmed");}
90
96
  }
91
97
  }
92
- constructor(private readonly options:{env?:NodeJS.ProcessEnv;timeoutMs?:number}={}) {}
98
+ constructor(private readonly options:{env?:NodeJS.ProcessEnv;timeoutMs?:number;now?:()=>number}={}) {}
93
99
  private git(repo:string,args:string[]) {
94
100
  return hubGitSync(["-C",repo,...args],{encoding:"utf8",env:hubGitEnv(childEnv(),{repositoryIsolation:true}),timeout:5000,maxBuffer:1024*1024});
95
101
  }
@@ -107,7 +113,7 @@ export class CodeFacts {
107
113
  return env;
108
114
  }
109
115
  private async run(bin:string,args:string[],cwd:string):Promise<{code:number;out:string}> {
110
- if(this.closing)return {code:1,out:""};
116
+ if(this.closing || this.active?.cancelled)return {code:1,out:""};
111
117
  return new Promise(resolve=>{
112
118
  let child:ReturnType<typeof spawn>;
113
119
  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;}
@@ -164,29 +170,58 @@ export class CodeFacts {
164
170
  return{cwd,close};
165
171
  }catch{close();throw Error();}
166
172
  }
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
+ private async query<T>(at:CodeLocation,key:string,read:(cwd:string,bin:string)=>Promise<T>):Promise<CodeFactResult<T>> {
174
+ if(this.closing || this.retained || at.private || !SHA.test(at.head))return unknown();
175
+ const optional=at.priority==="optional";
176
+ // A read-time graph poll must never take the next slot from review/completion work.
177
+ if(optional && (this.priorityWaiting>0 || this.active && !this.active.optional))return unknown();
178
+ let repo:string;try{repo=realPath(at.repo);}catch{return unknown();}
179
+ const id=JSON.stringify([repo,at.head,key]),hit=this.cache.get(id);
180
+ if(hit && !(this.active?.optional && !optional && this.active.id===id))return structuredClone(await hit) as CodeFactResult<T>;
181
+ if(!optional && this.active?.optional) {
182
+ const interrupted=this.active;
183
+ this.priorityWaiting++;
184
+ interrupted.cancelled=true;this.cache.delete(interrupted.id);this.cancelActive?.();
185
+ try{await interrupted.done;}finally{this.priorityWaiting--;}
186
+ return this.query(at,key,read);
187
+ }
188
+ if(optional) {
189
+ const now=(this.options.now ?? performance.now.bind(performance))(),until=this.optionalUnknown.get(id);
190
+ if(until!==undefined && until>now)return unknown();
191
+ this.optionalUnknown.delete(id);
192
+ }
193
+ if(this.busy)return unknown(); // Preserve default-vs-default contention semantics.
194
+ let finished!:()=>void;
195
+ const active={id,optional,cancelled:false,done:new Promise<void>(resolve=>{finished=resolve;})};
196
+ this.active=active;this.busy=true;this.failedChild=undefined;
173
197
  const job=(async()=>{
174
198
  let tree:ReturnType<CodeFacts["tree"]>|undefined;let answer:CodeFactResult<T>=unknown();
175
199
  try{
176
200
  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
201
  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();
202
+ const version=await this.run(bin,["--version"],tree.cwd);if(active.cancelled || version.code!==0 || !/^ripwire 0\.6\.7(?:\s|$)/.test(version.out))throw Error();
179
203
  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();
204
+ if(active.cancelled || 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
205
  answer={state:"known",value};
182
206
  }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;}
207
+ finally{
208
+ if(tree && this.failedChild){this.retained={tree,child:this.failedChild};answer=unknown();}else try{tree?.close();}catch{answer=unknown();}
209
+ this.busy=false;if(this.active===active)this.active=undefined;finished();
210
+ }
184
211
  return answer;
185
212
  })();
186
213
  this.pending=job;this.cache.set(id,job);
187
214
  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));
215
+ void job.then(answer=>{
216
+ if(answer.state==="unknown") {
217
+ if(this.cache.get(id)===job)this.cache.delete(id);
218
+ if(optional && !this.closing) {
219
+ this.optionalUnknown.set(id,(this.options.now ?? performance.now.bind(performance))()+OPTIONAL_CODE_UNKNOWN_TTL_MS);
220
+ if(this.optionalUnknown.size>128)this.optionalUnknown.delete(this.optionalUnknown.keys().next().value!);
221
+ }
222
+ } else this.optionalUnknown.delete(id);
223
+ });
224
+ return structuredClone(await job);
190
225
  }
191
226
  changedSymbols(repo:string,base:string,head:string,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<CodeSymbol[]>> {
192
227
  if(!SHA.test(base))return Promise.resolve(unknown());
@@ -205,12 +240,17 @@ export class CodeFacts {
205
240
  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
241
  || !Number.isSafeInteger(v.hop_tested)||v.hop_tested<0||!Number.isSafeInteger(v.hop_untested)||v.hop_untested<0
207
242
  || !Number.isSafeInteger(v.graph_ambiguous)||v.graph_ambiguous<0||!Number.isSafeInteger(v.graph_unresolved)||v.graph_unresolved<0)throw Error();
243
+ if(v.defs!==1 || v.via!==undefined && v.via!=="resolved")throw Error(); // A name-only or multiply-defined seed is unknown, never a known empty neighbourhood.
208
244
  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
245
  }
210
246
  exists(repo:string,head:string,symbol:string,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<{exists:boolean;ambiguous:boolean}>> {
211
247
  if(!text(symbol))return Promise.resolve(unknown());
212
248
  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();}
249
+ 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") {
250
+ // ponytail: ripwire 0.6.7 can miss a qualified name whose bare method exists. Require a CLI/schema qualified-lookup attestation before trusting its absence.
251
+ if(symbol.includes(".") || unindexedSymbolSyntax(symbol))throw Error();
252
+ return{exists:false,ambiguous:false};
253
+ }throw Error();}
214
254
  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
255
  }
216
256
  neighbours(repo:string,head:string,symbol:string,depth=1,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<{callers:CodeSymbol[];callees:CodeSymbol[];hiddenAmbiguous:number;countsFloor:boolean}>> {
@@ -9,6 +9,7 @@ import type { StartMode } from "./start-mode.ts";
9
9
  import type { HubEvent } from "./events.ts";
10
10
  import type { SupervisionFeed } from "./supervision.ts";
11
11
  import type { Budget } from "./budget.ts";
12
+ import { windowKind, type WindowKind } from "./window-kind.ts";
12
13
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
13
14
  import { supportsHeadlessInstances } from "./instances.ts";
14
15
 
@@ -17,9 +18,11 @@ export function conductorProgressSink(record: (event: HubEvent) => void, feed: P
17
18
  return event => { record(event); if (event.type === "stuck") feed.milestone(event.task, "stuck", { reason: event.category }); };
18
19
  }
19
20
 
20
- export function publicPeerBudget(status: ReturnType<Budget["status"]>, nameable: (text: string) => boolean): Record<string, unknown> {
21
+ /** What `Budget.status` returns, with the fields added after it optional so an older shape still projects. */
22
+ type PublicBudgetStatus = Record<string, { windows: (Omit<ReturnType<Budget["status"]>[string]["windows"][number], "kind" | "superseded"> & { kind?: WindowKind; superseded?: boolean })[]; paused?: ReturnType<Budget["status"]>[string]["paused"] }>;
23
+ export function publicPeerBudget(status: PublicBudgetStatus, nameable: (text: string) => boolean): Record<string, unknown> {
21
24
  return Object.fromEntries(Object.entries(status).map(([peer, value]) => [peer, {
22
- windows: value.windows.map(window => ({ id: window.id, used: window.used, at: window.at, stale: window.stale,
25
+ windows: value.windows.map(window => ({ id: window.id, kind: windowKind(window), used: window.used, at: window.at, stale: window.stale, ...(window.superseded ? { superseded: true } : {}),
23
26
  ...(window.resetsAt === undefined ? {} : { resetsAt: window.resetsAt }),
24
27
  ...(window.windowMins === undefined ? {} : { windowMins: window.windowMins }),
25
28
  source: nameable(window.source) ? window.source : "[quota source withheld]",
@@ -17,6 +17,7 @@ import { projectionConfig } from "./text-projection.ts";
17
17
  import { validateInstances, type InstancesConfig } from "./instances.ts";
18
18
  import { peerRosterConfig, staleInstanceKey } from "./roster.ts";
19
19
  import { CHARACTER_IDS } from "../pet/characters.ts";
20
+ import { checkSettingValue, settingDef } from "./settings.ts";
20
21
  import { petPalette } from "../pet/config.ts";
21
22
 
22
23
  export type ApplyClass = "live" | "next use" | "restart";
@@ -189,6 +190,12 @@ export function validateRawConfig(raw: unknown, declarations?: InstancesConfig,
189
190
  const instances = validateInstances(value.instances ?? declarations, catalog);
190
191
  block({ ...schema,
191
192
  instances: v => { validateInstances(v, catalog); },
193
+ planner: v => {
194
+ if (v === null) fail("config.planner", "takes a peer id or a nonempty list");
195
+ const def = settingDef("planner", instances, catalog);
196
+ if (typeof def === "string") return fail("config.planner", def);
197
+ checkSettingValue(def, v);
198
+ },
192
199
  permission_modes: v => { permissionDefaults(v, instances, catalog); },
193
200
  roster: v => { peerRosterConfig(v, instances, catalog, inertModuleRoster); },
194
201
  peers: v => { peerStartConfig(v, instances, catalog); },
@@ -9,7 +9,7 @@ export function stateDirFor(cwd: string): string {
9
9
 
10
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. */
11
11
  export const PROTOCOL = 16;
12
- export const DAEMON_FEATURES = ["external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "role_stages", "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", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
13
13
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
14
14
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
15
15