@staix/agent-hub 0.19.0 → 0.21.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 (184) hide show
  1. package/CHANGELOG.md +70 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -5
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +20 -7
  9. package/docs/agent-notes/local-worker.md +5 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +20 -10
  12. package/docs/agent-notes/tests.md +13 -5
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +41 -5
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +598 -63
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +66 -10
  23. package/docs/security.md +397 -35
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  29. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  30. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  31. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  32. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  33. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  34. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  35. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  36. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  37. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  38. package/docs/verification/claude-permission-transport-native.py +71 -14
  39. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  40. package/docs/verified.json +26 -18
  41. package/package.json +2 -2
  42. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  43. package/plugins/agent-hub/server.js +1116 -284
  44. package/src/adapters/acp.ts +87 -3
  45. package/src/adapters/channel-peer.ts +11 -5
  46. package/src/adapters/claude-channel.ts +13 -6
  47. package/src/adapters/claude-implementer.ts +61 -7
  48. package/src/adapters/codex-appserver.ts +88 -5
  49. package/src/adapters/conformance/enrollment.ts +4 -4
  50. package/src/adapters/conformance/factories.ts +2 -1
  51. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  52. package/src/adapters/conformance/kit.ts +10 -6
  53. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  54. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  55. package/src/adapters/definitions/native-config-policy.ts +3 -2
  56. package/src/adapters/definitions/native-context.ts +12 -0
  57. package/src/adapters/external-metadata.ts +48 -0
  58. package/src/adapters/external-modules.ts +63 -3
  59. package/src/adapters/jcode.ts +6 -2
  60. package/src/adapters/local-worker.ts +42 -25
  61. package/src/adapters/native-proof-ports.ts +10 -0
  62. package/src/adapters/pi.ts +7 -0
  63. package/src/adapters/registry-types.ts +4 -0
  64. package/src/adapters/reviewer-print.ts +1 -0
  65. package/src/broker/protocol.ts +203 -0
  66. package/src/broker/server.ts +314 -0
  67. package/src/broker/store.ts +548 -0
  68. package/src/cli/bench.ts +3 -2
  69. package/src/cli/broker.ts +58 -0
  70. package/src/cli/conductor-recovery.ts +142 -0
  71. package/src/cli/configured-acp-entry.ts +26 -6
  72. package/src/cli/console-state.ts +59 -17
  73. package/src/cli/console.ts +17 -2
  74. package/src/cli/facts-hook.ts +3 -1
  75. package/src/cli/help.ts +28 -16
  76. package/src/cli/identity.ts +23 -10
  77. package/src/cli/init.ts +34 -7
  78. package/src/cli/intake-output.ts +33 -0
  79. package/src/cli/main.ts +314 -71
  80. package/src/cli/output.ts +41 -1
  81. package/src/cli/recovery-package.ts +18 -1
  82. package/src/cli/role-holders.ts +35 -0
  83. package/src/cli/room.ts +304 -0
  84. package/src/cli/say-input.ts +16 -0
  85. package/src/cli/schedule-output.ts +49 -0
  86. package/src/cli/sdlc-output.ts +44 -6
  87. package/src/cli/smoke-pty-bridge.ts +33 -0
  88. package/src/cli/smoke-pty.ts +145 -0
  89. package/src/cli/smoke-run.ts +139 -0
  90. package/src/cli/smoke-target.ts +130 -0
  91. package/src/cli/source-output.ts +29 -0
  92. package/src/cli/task-output.ts +3 -2
  93. package/src/cli/upgrade-runtime.ts +98 -16
  94. package/src/cli/upgrade.ts +66 -7
  95. package/src/hub/ask.ts +76 -12
  96. package/src/hub/board.ts +132 -22
  97. package/src/hub/bus.ts +399 -56
  98. package/src/hub/child-process.ts +2 -0
  99. package/src/hub/ci-watch.ts +162 -0
  100. package/src/hub/claude-failure-stage.ts +85 -0
  101. package/src/hub/claude-launch-proof.ts +67 -2
  102. package/src/hub/claude-shell-wrapper.ts +11 -5
  103. package/src/hub/conductor.ts +17 -4
  104. package/src/hub/config-apply.ts +32 -5
  105. package/src/hub/config-reload.ts +7 -5
  106. package/src/hub/config-trust.ts +8 -1
  107. package/src/hub/context-window.ts +33 -3
  108. package/src/hub/control-client.ts +14 -2
  109. package/src/hub/corpus-config.ts +34 -0
  110. package/src/hub/corpus-evidence.ts +184 -0
  111. package/src/hub/corpus.ts +373 -0
  112. package/src/hub/cost-governance.ts +26 -0
  113. package/src/hub/daemon.ts +1228 -242
  114. package/src/hub/deliverable-files.ts +8 -2
  115. package/src/hub/delivery-journal.ts +123 -17
  116. package/src/hub/dogfood-profile.ts +32 -5
  117. package/src/hub/dogfood-read.ts +6 -2
  118. package/src/hub/envelope.ts +11 -0
  119. package/src/hub/eval-gate.ts +131 -0
  120. package/src/hub/events.ts +1 -1
  121. package/src/hub/external-instance.ts +69 -0
  122. package/src/hub/facts.ts +15 -5
  123. package/src/hub/folder-trigger.ts +223 -0
  124. package/src/hub/hub-tools.ts +2 -1
  125. package/src/hub/inference.ts +20 -5
  126. package/src/hub/instance-config.ts +69 -3
  127. package/src/hub/instance-execution.ts +6 -2
  128. package/src/hub/instances.ts +32 -4
  129. package/src/hub/intake.ts +125 -0
  130. package/src/hub/learning.ts +85 -0
  131. package/src/hub/lifecycle.ts +7 -3
  132. package/src/hub/limits.ts +12 -5
  133. package/src/hub/native-permission-request.ts +77 -0
  134. package/src/hub/operational-authority.ts +63 -5
  135. package/src/hub/operational-settings.ts +76 -0
  136. package/src/hub/operational-view.ts +1 -1
  137. package/src/hub/packs.ts +132 -0
  138. package/src/hub/path-policy.ts +7 -0
  139. package/src/hub/peer-doing.ts +130 -0
  140. package/src/hub/peers.ts +46 -0
  141. package/src/hub/queue-backpressure.ts +58 -0
  142. package/src/hub/read-tools.ts +2 -0
  143. package/src/hub/recovery-authority.ts +151 -0
  144. package/src/hub/recovery-policy.ts +220 -0
  145. package/src/hub/research.ts +24 -14
  146. package/src/hub/restart.ts +7 -0
  147. package/src/hub/routing.ts +54 -11
  148. package/src/hub/schedules.ts +266 -0
  149. package/src/hub/sdlc-merge.ts +38 -14
  150. package/src/hub/sdlc-metrics.ts +32 -10
  151. package/src/hub/sdlc-queue.ts +7 -1
  152. package/src/hub/sdlc-release.ts +1 -1
  153. package/src/hub/sdlc.ts +37 -7
  154. package/src/hub/security-baseline.ts +38 -0
  155. package/src/hub/settings.ts +7 -1
  156. package/src/hub/shipped-packs.ts +8 -0
  157. package/src/hub/snapshots.ts +72 -6
  158. package/src/hub/source-read.ts +160 -0
  159. package/src/hub/sources.ts +311 -0
  160. package/src/hub/task-shell-default.ts +3 -2
  161. package/src/hub/tasks.ts +1047 -115
  162. package/src/hub/workflow.ts +1 -1
  163. package/src/local/deny.ts +21 -0
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/sandbox.ts +3 -1
  167. package/src/local/tools.ts +37 -3
  168. package/src/local/web-fetch-config.ts +43 -0
  169. package/src/local/web-fetch.ts +183 -0
  170. package/src/memory/brief.ts +85 -13
  171. package/src/memory/capture.ts +1 -1
  172. package/src/rooms/application.ts +114 -0
  173. package/src/rooms/boundary.ts +103 -0
  174. package/src/rooms/commands.ts +294 -0
  175. package/src/rooms/crypto.ts +371 -0
  176. package/src/rooms/link.ts +584 -0
  177. package/src/rooms/policy.ts +82 -0
  178. package/src/rooms/remote-peer.ts +80 -0
  179. package/src/rooms/service.ts +426 -0
  180. package/src/rooms/status.ts +146 -0
  181. package/src/rooms/store.ts +1210 -0
  182. package/src/rooms/wire.ts +465 -0
  183. package/src/ui/index.html +91 -4
  184. package/templates/packs/example.pack.json +13 -0
@@ -19,10 +19,14 @@ export function parseRows(markdown: string | undefined): BriefItem[] {
19
19
  }
20
20
 
21
21
  /** Index first, then the neighbourhood of the top hit: the retrieval both task briefs and `ahub ask` use. Deduplicated, in order. */
22
- export async function related(client: MemoryClient, project: string, query: string, around = 3): Promise<BriefItem[]> {
23
- const hits = parseRows(await client.search(query.slice(0, 300), project, 10));
22
+ export async function related(client: MemoryClient, project: string, query: string, around = 3, current?: () => boolean): Promise<BriefItem[]> {
23
+ const search = await client.search(query.slice(0, 300), project, 10);
24
+ if (current && !current()) throw new Error("brief context changed");
25
+ const hits = parseRows(search);
24
26
  if (!hits.length) return [];
25
- const near = parseRows(await client.timeline(hits[0]!.id, project, around, around));
27
+ const timeline = await client.timeline(hits[0]!.id, project, around, around);
28
+ if (current && !current()) throw new Error("brief context changed");
29
+ const near = parseRows(timeline);
26
30
  const out: BriefItem[] = [];
27
31
  for (const item of [...hits, ...near]) if (!out.some((o) => o.id === item.id)) out.push(item);
28
32
  return out;
@@ -34,7 +38,14 @@ export type NativeBrief = (task: BriefTask) => NativeItem[] | Promise<NativeItem
34
38
  export interface IntentBriefContext { source: IntentBriefSource; options: IntentBriefOptions }
35
39
  /** Host-owned policy and store binding; the library owns relevance, ordering and text. */
36
40
  export type IntentBriefHost = (task: BriefTask) => IntentBriefContext | Promise<IntentBriefContext>;
37
- export interface BriefTask { id?: number; created?: number; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
41
+ export interface BriefTask { id?: number; created?: number; updated?: number; owner?: string | null; history?: readonly unknown[]; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
42
+
43
+ /** Host-authorized corpus reads. The port screens and sanitizes its returned public note lines. */
44
+ export interface CorpusBriefPort {
45
+ allowed(task: BriefTask): boolean;
46
+ policyVersion(): string;
47
+ read(task: BriefTask): NativeItem[] | Promise<NativeItem[]>;
48
+ }
38
49
 
39
50
  /**
40
51
  * Production task briefs use the library's intent retrieval and rendering with host policy and bindings.
@@ -55,23 +66,49 @@ export class Briefs {
55
66
  private readonly observe?: (peer: PeerId, task: number, counts: { native: number; fails: number; claudeMem: number }) => void,
56
67
  private readonly displayNative?: (items: readonly NativeItem[]) => string[],
57
68
  private readonly intent?: IntentBriefHost,
69
+ private readonly corpus?: CorpusBriefPort,
58
70
  ) {}
59
71
 
60
72
  async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
61
73
  // A brief snapshots its configured cap once, before either optional source is selected.
62
74
  const maximum = typeof this.maxItems === "function" ? this.maxItems() : this.maxItems;
75
+ let corpusRevision: string | undefined;
76
+ const allowed = (): boolean => {
77
+ if (!this.corpus) return true;
78
+ try { return this.corpus.allowed(task); } catch { return false; }
79
+ };
80
+ const current = (): boolean => {
81
+ if (!this.corpus) return true;
82
+ try { return allowed() && this.corpus.policyVersion() === corpusRevision; } catch { return false; }
83
+ };
84
+ if (this.corpus) {
85
+ if (!allowed()) return undefined;
86
+ try { corpusRevision = this.corpus.policyVersion(); } catch { return undefined; }
87
+ if (!current()) return undefined;
88
+ }
89
+ const corpusItems = async (room: number): Promise<NativeItem[] | undefined> => {
90
+ if (!this.corpus || room < 1) return [];
91
+ if (!current()) return undefined;
92
+ try {
93
+ const items = await this.corpus.read(task);
94
+ if (!current()) return undefined;
95
+ return items;
96
+ } catch { return current() ? [] : undefined; }
97
+ };
63
98
  if (this.intent) {
64
99
  // Serialize only this peer's library reads, so awaited policy cannot duplicate a delivered note.
65
100
  const previous = this.libraryRuns.get(peer);
66
101
  const run = (async () => {
67
102
  await previous?.catch(() => undefined);
103
+ if (!current()) return undefined;
68
104
  if (maximum < 1) return undefined;
69
105
  try {
70
106
  const context = await this.intent!(task);
107
+ if (!current()) return undefined;
71
108
  const seen = this.seen.get(peer) ?? new Set<string>();
72
- this.seen.set(peer, seen);
73
109
  const revision = context.options.policyVersion();
74
110
  const result = await buildIntentBrief(context.source, context.options);
111
+ if (!current()) return undefined;
75
112
  if (result.state === "unknown" || context.options.allowed?.() === false || context.options.policyVersion() !== revision) return undefined;
76
113
  context.options.assertCurrent?.();
77
114
  const lines = result.text.split("\n");
@@ -84,28 +121,49 @@ export class Briefs {
84
121
  const id = `mem:${item.id}`;
85
122
  if (selected.length < maximum && !seen.has(id) && !selected.some(row => row.id === id)) selected.push({ id, line: rendered.get(id)!, kind: item.kind });
86
123
  }
124
+ const corpus = await corpusItems(maximum - selected.length);
125
+ if (corpus === undefined || !current()) return undefined;
126
+ for (const item of corpus) if (selected.length < maximum && !seen.has(item.id) && !selected.some(row => row.id === item.id)) selected.push(item);
87
127
  if (!selected.length) return undefined;
88
128
  // Preserve complete library rows (including graph neighbours after a seen direct bridge).
89
- const text = [lines[0] || "Live fail notes for this plan (advice, not a block):", ...selected.map(item => item.line)].join("\n");
129
+ if (!current()) return undefined;
130
+ const header = selected.some(item => item.kind === "note") ? "Knowledge brief (advice, not a block):" : lines[0] || "Live fail notes for this plan (advice, not a block):";
131
+ const text = [header, ...selected.map(item => item.line)].join("\n");
132
+ if (!current()) return undefined;
133
+ this.seen.set(peer, seen);
90
134
  for (const item of selected) seen.add(item.id);
91
- if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: selected.length, fails: selected.filter(item => item.kind === "fail").length, claudeMem: 0 }); } catch { /* telemetry never fails a brief */ }
135
+ if (!current()) return undefined;
136
+ if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: selected.filter(item => item.kind !== "note").length, fails: selected.filter(item => item.kind === "fail").length, claudeMem: 0 }); } catch { /* telemetry never fails a brief */ }
92
137
  return text;
93
138
  } catch { return undefined; }
94
139
  })();
95
140
  this.libraryRuns.set(peer, run);
96
141
  try { return await run; } finally { if (this.libraryRuns.get(peer) === run) this.libraryRuns.delete(peer); }
97
142
  }
143
+ if (!current()) return undefined;
98
144
  const seen = this.seen.get(peer) ?? new Set<string>();
145
+ // Publish an empty peer set before the first await so concurrent briefs share dedupe state.
146
+ // No ids enter it until the final policy guard, so an invalidated read cannot poison delivery.
99
147
  this.seen.set(peer, seen);
100
148
  let native: NativeItem[] = [];
101
- try { native = await (this.native?.(task) ?? []); } catch { /* fail-open: the store and derived code context are optional */ }
149
+ try {
150
+ if (!current()) return undefined;
151
+ native = await (this.native?.(task) ?? []);
152
+ if (!current()) return undefined;
153
+ } catch { if (!current()) return undefined; /* fail-open: the store and derived code context are optional */ }
102
154
  const own: NativeItem[] = [];
103
155
  // A plan fail is task-specific guidance even when a previous task already carried it.
104
156
  const priorityIds = new Set(priority.map(item => item.id));
105
157
  for (const item of priority) if (own.length < maximum && !own.some(existing => existing.id === item.id)) own.push(item);
106
158
  // Native order is host-supplied (linked fails, then nearer/newer); preserve it for synchronous callers too.
107
159
  try { for (const item of native) if (!seen.has(item.id) && !own.some(existing => existing.id === item.id) && own.length < maximum) own.push(item); } catch { /* optional native iteration stays fail-open */ }
108
- const found = this.client && own.length < maximum ? await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" ")) : [];
160
+ let found: BriefItem[] = [];
161
+ if (this.client && own.length < maximum) {
162
+ if (!current()) return undefined;
163
+ try { found = await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" "), 3, current); }
164
+ catch { if (!current()) return undefined; }
165
+ if (!current()) return undefined;
166
+ }
109
167
  // Another brief for this peer may finish while either optional source is awaited.
110
168
  const currentOwn = own.filter(item => priorityIds.has(item.id) || !seen.has(item.id)).slice(0, maximum);
111
169
  own.splice(0, own.length, ...currentOwn);
@@ -116,12 +174,26 @@ export class Briefs {
116
174
  items.push(item);
117
175
  if (own.length + items.length === maximum) break;
118
176
  }
177
+ const corpus = await corpusItems(maximum - own.length - items.length);
178
+ if (corpus === undefined || !current()) return undefined;
179
+ const corpusRows: NativeItem[] = [];
180
+ for (const item of corpus) {
181
+ if (own.length + items.length + corpusRows.length >= maximum) break;
182
+ if (seen.has(item.id) || own.some(row => row.id === item.id) || corpusRows.some(row => row.id === item.id)) continue;
183
+ corpusRows.push(item);
184
+ }
185
+ if (!current()) return undefined;
119
186
  const notice = this.client?.readNotice();
120
- if (!own.length && !items.length && !notice) return undefined;
187
+ if (!own.length && !items.length && !corpusRows.length && !notice) return undefined;
188
+ if (!current()) return undefined;
121
189
  for (const item of own) seen.add(item.id);
122
190
  for (const item of items) seen.add(`#${item.id}`);
123
- if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: own.length, fails: own.filter((i) => i.kind === "fail").length, claudeMem: items.length }); } catch { /* telemetry never fails a brief */ }
124
- const header = own.length ? "Memory brief (hub memory first, fails first: ahub memory show <mem id>; amem by mem:id):" : "Memory brief (amem curated notes; fetch details by mem:id):";
125
- return [header, ...(notice ? [notice] : []), ...(this.displayNative?.(own) ?? own.map((i) => i.line)), ...items.map((i) => `${i.id.toString().startsWith("mem:") ? i.id : `#${i.id}`} ${i.time} ${i.type} ${i.title}`)].join("\n");
191
+ for (const item of corpusRows) seen.add(item.id);
192
+ if (!current()) return undefined;
193
+ if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: own.filter(item => item.kind !== "note").length, fails: own.filter((i) => i.kind === "fail").length, claudeMem: items.length }); } catch { /* telemetry never fails a brief */ }
194
+ if (!current()) return undefined;
195
+ const nativeRows = own.filter(item => item.kind !== "note"), noteRows = [...own.filter(item => item.kind === "note"), ...corpusRows];
196
+ const header = noteRows.length ? "Knowledge brief (advice, not a block):" : own.length ? "Memory brief (hub memory first, fails first: ahub memory show <mem id>; amem by mem:id):" : "Memory brief (amem curated notes; fetch details by mem:id):";
197
+ return [header, ...(notice ? [notice] : []), ...(this.displayNative?.(nativeRows) ?? nativeRows.map((i) => i.line)), ...items.map((i) => `${i.id.toString().startsWith("mem:") ? i.id : `#${i.id}`} ${i.time} ${i.type} ${i.title}`), ...noteRows.map(item => item.line)].join("\n");
126
198
  }
127
199
  }
@@ -38,7 +38,7 @@ export class Capture {
38
38
  }
39
39
 
40
40
  observe(call: { tool: string; args: string; output: string; id: string; paths: string[]; sourceId?: string }): void {
41
- if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || this.opts.skip?.includes(call.tool)) return;
41
+ if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || this.opts.skip?.includes(call.tool)) return;
42
42
  // Anything that names a denylisted path is kept out of memory entirely, input and output alike.
43
43
  if (call.paths.some((p) => isDenied(p, this.opts.deny) || p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
44
44
  // ponytail: legacy callers without a host sourceId dedupe native ids within a summary interval; all live LocalPeer calls supply invocation identity. Require that port for other hosts.
@@ -0,0 +1,114 @@
1
+ /** Encrypted X4 application payloads. Transport signatures/epochs remain in wire.ts. */
2
+ import { MAX_HOP, type Priority } from "../hub/envelope.ts";
3
+ import { decodeBase64url } from "./crypto.ts";
4
+ import { canonicalBytes, ROOM_WIRE_LIMITS } from "./wire.ts";
5
+
6
+ export const ROOM_APPLICATION_MAX_BYTES = ROOM_WIRE_LIMITS.plaintext;
7
+ export interface RoomChatEnvelope {
8
+ id: string; trace: string; hop: number; from: string; to: string;
9
+ kind: "chat" | "status"; priority: Priority; body: string; ts: number;
10
+ }
11
+ export type RoomReceiptReason = "admitted" | "inbox_admitted" | "inbox_full" | "inbox_oversize" | "inbox_expired" | "inbox_age_unavailable" | "not_exposed" | "boundary_refused" | "invalid_message" | "rate_limited" | "hop" | "person_inbox_unsupported";
12
+ export type RoomMessage =
13
+ | { v: 1; type: "chat"; envelope: RoomChatEnvelope }
14
+ | { v: 1; type: "receipt"; frame: string; application: string; peer: string; outcome: "completed" | "failed_safe"; reason: RoomReceiptReason };
15
+ export class RoomApplicationError extends Error {
16
+ constructor(readonly code: "invalid_message" | "limit") { super(`Room application: ${code}`); this.name = "RoomApplicationError"; }
17
+ }
18
+ function fail(code: "invalid_message" | "limit" = "invalid_message"): never { throw new RoomApplicationError(code); }
19
+ const LOCAL_ID = /^[a-z][a-z0-9-]{0,31}$/;
20
+ const REMOTE_ID = /^([a-z][a-z0-9-]{0,31})@([a-z][a-z0-9-]{0,31})$/;
21
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
22
+ const REASON_TEXT: Readonly<Record<RoomReceiptReason, string>> = Object.freeze({
23
+ admitted: "admitted", inbox_admitted: "admitted to person inbox", inbox_full: "person inbox full",
24
+ inbox_oversize: "person inbox message too large", inbox_expired: "person inbox message expired",
25
+ inbox_age_unavailable: "person inbox admission age unavailable", not_exposed: "peer not exposed", boundary_refused: "room boundary refused",
26
+ invalid_message: "invalid room message", rate_limited: "room rate limit", hop: "hop limit",
27
+ person_inbox_unsupported: "person inbox not supported yet (X5)",
28
+ });
29
+ const REASONS: readonly RoomReceiptReason[] = ["admitted", "inbox_admitted", "inbox_full", "inbox_oversize", "inbox_expired", "inbox_age_unavailable", "not_exposed", "boundary_refused", "invalid_message", "rate_limited", "hop", "person_inbox_unsupported"];
30
+ /** Fixed operator text only; a diagnostic or remote field can never become a rendered reason. */
31
+ export function roomReceiptReasonText(reason: RoomReceiptReason): string {
32
+ return Object.hasOwn(REASON_TEXT, reason) ? REASON_TEXT[reason] : "room receipt unavailable";
33
+ }
34
+ const decoder = new TextDecoder("utf-8", { fatal: true });
35
+ const encoder = new TextEncoder();
36
+
37
+ export function isRoomLocalPeerId(value: unknown): value is string {
38
+ return typeof value === "string" && LOCAL_ID.exec(value)?.[0] === value && value !== "hub" && value !== "digest";
39
+ }
40
+ /** Remote humans are user@label, never local console authority. Labels come from the signed private roster. */
41
+ export function parseRemotePeerId(value: unknown): { peer: string; label: string } | undefined {
42
+ if (typeof value !== "string") return undefined;
43
+ const match = REMOTE_ID.exec(value);
44
+ if (!match || match[0] !== value || !isRoomLocalPeerId(match[1])) return undefined;
45
+ return { peer: match[1], label: match[2]! };
46
+ }
47
+ function object(value: unknown, fields: readonly string[]): Record<string, unknown> {
48
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) fail();
49
+ const row = value as Record<string, unknown>;
50
+ if (Reflect.ownKeys(row).length !== fields.length || fields.some(key => !Object.hasOwn(row, key))) fail();
51
+ return row;
52
+ }
53
+ function uuid(value: unknown): string {
54
+ if (typeof value !== "string" || value.length !== 36 || !UUID.test(value)) fail();
55
+ return value;
56
+ }
57
+ function localPeer(value: unknown): string { if (!isRoomLocalPeerId(value)) fail(); return value; }
58
+ function integer(value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): number {
59
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || Object.is(value, -0) || value < min || value > max) fail();
60
+ return value;
61
+ }
62
+ function text(value: unknown): string {
63
+ if (typeof value !== "string" || !value.isWellFormed()) fail();
64
+ if (encoder.encode(value).length > ROOM_APPLICATION_MAX_BYTES) fail("limit");
65
+ return value;
66
+ }
67
+ function parseEnvelope(value: unknown): RoomChatEnvelope {
68
+ const row = object(value, ["id", "trace", "hop", "from", "to", "kind", "priority", "body", "ts"]);
69
+ if (row.kind !== "chat" && row.kind !== "status") fail();
70
+ if (row.priority !== "important" && row.priority !== "status" && row.priority !== "fyi") fail();
71
+ return { id: uuid(row.id), trace: uuid(row.trace), hop: integer(row.hop, 0, MAX_HOP), from: localPeer(row.from), to: localPeer(row.to), kind: row.kind, priority: row.priority, body: text(row.body), ts: integer(row.ts, 0) };
72
+ }
73
+ function frameDigest(value: unknown): string {
74
+ if (typeof value !== "string" || value.length !== 43) fail();
75
+ try { decodeBase64url(value, 32); } catch { fail(); }
76
+ return value;
77
+ }
78
+ function parseMessage(value: unknown): RoomMessage {
79
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail();
80
+ const type = (value as Record<string, unknown>).type;
81
+ if (type === "chat") {
82
+ const row = object(value, ["v", "type", "envelope"]);
83
+ if (row.v !== 1) fail();
84
+ return { v: 1, type, envelope: parseEnvelope(row.envelope) };
85
+ }
86
+ if (type === "receipt") {
87
+ const row = object(value, ["v", "type", "frame", "application", "peer", "outcome", "reason"]);
88
+ if (row.v !== 1 || row.outcome !== "completed" && row.outcome !== "failed_safe" || !REASONS.includes(row.reason as RoomReceiptReason)
89
+ || row.outcome === "completed" && row.reason !== "admitted" && row.reason !== "inbox_admitted"
90
+ || row.outcome === "failed_safe" && (row.reason === "admitted" || row.reason === "inbox_admitted")) fail();
91
+ return { v: 1, type, frame: frameDigest(row.frame), application: uuid(row.application), peer: localPeer(row.peer), outcome: row.outcome, reason: row.reason as RoomReceiptReason };
92
+ }
93
+ return fail();
94
+ }
95
+ function applicationError(error: unknown): never {
96
+ if (error instanceof RoomApplicationError) throw error;
97
+ return fail();
98
+ }
99
+ export function encodeRoomMessage(message: RoomMessage): Uint8Array {
100
+ try {
101
+ const wire = canonicalBytes(parseMessage(message));
102
+ if (wire.length > ROOM_APPLICATION_MAX_BYTES) fail("limit");
103
+ return wire;
104
+ } catch (error) { return applicationError(error); }
105
+ }
106
+ export function decodeRoomMessage(wire: Uint8Array): RoomMessage {
107
+ try {
108
+ if (!(wire instanceof Uint8Array) || wire.length === 0) fail();
109
+ if (wire.length > ROOM_APPLICATION_MAX_BYTES) fail("limit");
110
+ const bytes = Uint8Array.from(wire), message = parseMessage(JSON.parse(decoder.decode(bytes))), canonical = canonicalBytes(message);
111
+ if (canonical.length !== bytes.length || canonical.some((byte, index) => byte !== bytes[index])) fail();
112
+ return message;
113
+ } catch (error) { return applicationError(error); }
114
+ }
@@ -0,0 +1,103 @@
1
+ /** The one X4 outbound view. A proof never carries refs, private task text or untrusted authority. */
2
+ import { looksSecret } from "@staix/agent-mem";
3
+ import { HUB, DIGEST, MAX_HOP, USER, sanitize, type Envelope } from "../hub/envelope.ts";
4
+ import { mentionsPrivatePath } from "../hub/memory-policy.ts";
5
+ import { detectSignals, type Routing } from "../hub/routing.ts";
6
+ import type { PiiVerdict } from "../hub/inference.ts";
7
+ import { encodeRoomMessage, isRoomLocalPeerId, parseRemotePeerId, type RoomChatEnvelope } from "./application.ts";
8
+
9
+ export interface RoomBoundaryContext {
10
+ cwd: string; target: string; current: () => boolean;
11
+ senderExposed: (id: string) => boolean;
12
+ senderPrivate: (id: string) => boolean | undefined;
13
+ taskPrivate: (id: number) => boolean | undefined;
14
+ routing: () => Routing; piiPaths: () => readonly string[];
15
+ modelScreenOn: () => boolean;
16
+ /** The existing host-bound screenPii port owns device/campus admission, deadlines and fixed models. */
17
+ screen?: (text: string) => Promise<PiiVerdict>;
18
+ }
19
+ export type RoomBoundaryCode = "invalid_message" | "not_exposed" | "boundary_refused" | "hop";
20
+ declare const proofBrand: unique symbol;
21
+ export interface RoomOutboundProof { readonly [proofBrand]: true }
22
+ export type RoomBoundaryResult = { status: "refused"; code: RoomBoundaryCode } | { status: "ready"; proof: RoomOutboundProof; withheld: boolean };
23
+ const proofs = new WeakMap<RoomOutboundProof, { envelope: Readonly<RoomChatEnvelope>; current: () => boolean }>();
24
+ const ENVELOPE_FIELDS = new Set(["id", "trace", "hop", "from", "to", "kind", "priority", "body", "refs", "private", "ts"]);
25
+ const encoder = new TextEncoder();
26
+ const refused = (code: RoomBoundaryCode): RoomBoundaryResult => ({ status: "refused", code });
27
+
28
+ interface BodyPolicy { routing: Routing; paths: string[]; model: boolean; signature: string }
29
+ function bodyPolicy(context: RoomBoundaryContext): BodyPolicy | undefined {
30
+ try {
31
+ const routing = context.routing(), paths = [...context.piiPaths()], model = context.modelScreenOn();
32
+ if (typeof model !== "boolean" || paths.some(path => typeof path !== "string") || !Array.isArray(routing.signals.pii_patterns) || routing.signals.pii_patterns.some(pattern => typeof pattern !== "string")) return undefined;
33
+ const patterns = [...routing.signals.pii_patterns];
34
+ return { routing: { ...routing, signals: { ...routing.signals, pii_patterns: patterns } }, paths, model, signature: JSON.stringify([patterns, paths, model]) };
35
+ } catch { return undefined; }
36
+ }
37
+ function clearBody(body: string, paths: readonly string[], context: RoomBoundaryContext, policy: BodyPolicy | undefined): boolean {
38
+ if (!policy) return false;
39
+ try {
40
+ return !looksSecret(body) && !mentionsPrivatePath(body, paths.map(path => `path:${path}`), policy.paths, context.cwd)
41
+ && !detectSignals({ title: "", detail: body, refs: {} }, policy.routing, context.cwd).includes("pii");
42
+ } catch { return false; }
43
+ }
44
+ /** Snapshot before any await; host ports remain captured, while their decisions are read fresh. */
45
+ export async function roomBoundary(input: Envelope, ctx: RoomBoundaryContext): Promise<RoomBoundaryResult> {
46
+ try {
47
+ const env = structuredClone(input), context: RoomBoundaryContext = { ...ctx };
48
+ const target = parseRemotePeerId(context.target);
49
+ if (!target || !env || typeof env !== "object" || Array.isArray(env) || Object.keys(env).some(key => !ENVELOPE_FIELDS.has(key))) return refused("invalid_message");
50
+ if (env.from === HUB || env.from === DIGEST || !isRoomLocalPeerId(env.from)) return refused("boundary_refused");
51
+ if (env.kind !== "chat" && env.kind !== "status" || env.private === true) return refused("boundary_refused");
52
+ if (env.private !== undefined && typeof env.private !== "boolean") return refused("invalid_message");
53
+ if (!Array.isArray(env.to) || !env.to.includes(context.target)) return refused("boundary_refused");
54
+ if (env.to.length > 64 || env.to.some(id => !isRoomLocalPeerId(id) && !parseRemotePeerId(id))) return refused("invalid_message");
55
+ if (!Number.isSafeInteger(env.hop) || env.hop < 0 || env.hop > MAX_HOP) return refused("hop");
56
+ if (env.refs !== undefined && (!env.refs || typeof env.refs !== "object" || Array.isArray(env.refs))) return refused("invalid_message");
57
+ const paths = env.refs?.paths ?? [];
58
+ if (!Array.isArray(paths) || paths.length > 64 || paths.some(path => typeof path !== "string" || encoder.encode(path).length > 4096)) return refused("invalid_message");
59
+ const task = env.refs?.task;
60
+ if (task !== undefined && (typeof task !== "string" || !Number.isSafeInteger(Number(task)) || Number(task) < 1 || String(Number(task)) !== task)) return refused("invalid_message");
61
+ const taskId = task === undefined ? undefined : Number(task);
62
+ const authorized = (): RoomBoundaryCode | undefined => {
63
+ try {
64
+ if (context.current() !== true) return "boundary_refused";
65
+ if (env.from !== USER && context.senderExposed(env.from) !== true) return "not_exposed";
66
+ if (context.senderPrivate(env.from) !== false || taskId !== undefined && context.taskPrivate(taskId) !== false) return "boundary_refused";
67
+ return context.current() === true ? undefined : "boundary_refused";
68
+ } catch { return "boundary_refused"; }
69
+ };
70
+ const initial = authorized(); if (initial) return refused(initial);
71
+ const envelope: RoomChatEnvelope = { id: env.id, trace: env.trace, hop: env.hop, from: env.from, to: target.peer, kind: env.kind, priority: env.priority === "important" ? "status" : env.priority, body: env.body, ts: env.ts };
72
+ encodeRoomMessage({ v: 1, type: "chat", envelope }); // Validate metadata/body budget before any model sees it.
73
+ const policy = bodyPolicy(context);
74
+ let withheld = !clearBody(env.body, paths, context, policy);
75
+ if (!withheld && policy?.model) {
76
+ const before = authorized(); if (before) return refused(before);
77
+ let verdict: PiiVerdict | undefined;
78
+ try { verdict = await context.screen?.(env.body); } catch { /* unknown fails closed to the fixed stub */ }
79
+ const after = authorized(); if (after) return refused(after);
80
+ withheld = verdict?.label !== "clear";
81
+ const fresh = bodyPolicy(context);
82
+ if (!fresh || fresh.signature !== policy.signature) return refused("boundary_refused");
83
+ withheld ||= !clearBody(env.body, paths, context, fresh);
84
+ }
85
+ const final = authorized(); if (final) return refused(final);
86
+ const outgoing = Object.freeze({ ...envelope, body: withheld ? `[room message ${envelope.id} withheld]` : sanitize(envelope.body) });
87
+ encodeRoomMessage({ v: 1, type: "chat", envelope: outgoing });
88
+ const current = () => authorized() === undefined && (withheld || (() => {
89
+ const latest = bodyPolicy(context);
90
+ return latest !== undefined && latest.signature === policy?.signature && clearBody(env.body, paths, context, latest);
91
+ })());
92
+ const proof = Object.freeze({}) as RoomOutboundProof;
93
+ proofs.set(proof, { envelope: outgoing, current });
94
+ return { status: "ready", proof, withheld };
95
+ } catch { return refused("invalid_message"); }
96
+ }
97
+ export function readRoomOutbound(proof: RoomOutboundProof): RoomChatEnvelope | undefined {
98
+ if (!proof || typeof proof !== "object") return undefined;
99
+ const state = proofs.get(proof);
100
+ if (!state) return undefined;
101
+ try { return state.current() === true ? { ...state.envelope } : undefined; }
102
+ catch { return undefined; }
103
+ }