@staix/agent-hub 0.20.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 (175) hide show
  1. package/CHANGELOG.md +59 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  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 +2 -2
  8. package/docs/agent-notes/daemon.md +15 -6
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +37 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +565 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +14 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  37. package/docs/verification/claude-permission-transport-native.py +71 -14
  38. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  39. package/docs/verified.json +15 -15
  40. package/package.json +2 -2
  41. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  42. package/plugins/agent-hub/server.js +1016 -280
  43. package/src/adapters/acp.ts +79 -3
  44. package/src/adapters/channel-peer.ts +4 -0
  45. package/src/adapters/claude-channel.ts +13 -6
  46. package/src/adapters/claude-implementer.ts +60 -7
  47. package/src/adapters/codex-appserver.ts +77 -4
  48. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  49. package/src/adapters/conformance/kit.ts +3 -9
  50. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  51. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  52. package/src/adapters/definitions/native-config-policy.ts +3 -2
  53. package/src/adapters/definitions/native-context.ts +12 -0
  54. package/src/adapters/external-metadata.ts +48 -0
  55. package/src/adapters/external-modules.ts +63 -3
  56. package/src/adapters/jcode.ts +6 -2
  57. package/src/adapters/local-worker.ts +40 -25
  58. package/src/adapters/native-proof-ports.ts +10 -0
  59. package/src/adapters/registry-types.ts +4 -0
  60. package/src/broker/protocol.ts +203 -0
  61. package/src/broker/server.ts +314 -0
  62. package/src/broker/store.ts +548 -0
  63. package/src/cli/bench.ts +3 -2
  64. package/src/cli/broker.ts +58 -0
  65. package/src/cli/conductor-recovery.ts +142 -0
  66. package/src/cli/configured-acp-entry.ts +26 -6
  67. package/src/cli/console-state.ts +59 -17
  68. package/src/cli/console.ts +17 -2
  69. package/src/cli/facts-hook.ts +3 -1
  70. package/src/cli/help.ts +28 -16
  71. package/src/cli/identity.ts +23 -10
  72. package/src/cli/init.ts +34 -7
  73. package/src/cli/intake-output.ts +33 -0
  74. package/src/cli/main.ts +314 -71
  75. package/src/cli/output.ts +41 -1
  76. package/src/cli/recovery-package.ts +18 -1
  77. package/src/cli/role-holders.ts +35 -0
  78. package/src/cli/room.ts +304 -0
  79. package/src/cli/say-input.ts +16 -0
  80. package/src/cli/schedule-output.ts +49 -0
  81. package/src/cli/sdlc-output.ts +44 -6
  82. package/src/cli/smoke-pty-bridge.ts +33 -0
  83. package/src/cli/smoke-pty.ts +145 -0
  84. package/src/cli/smoke-run.ts +139 -0
  85. package/src/cli/smoke-target.ts +130 -0
  86. package/src/cli/source-output.ts +29 -0
  87. package/src/cli/task-output.ts +3 -2
  88. package/src/cli/upgrade-runtime.ts +98 -16
  89. package/src/cli/upgrade.ts +66 -7
  90. package/src/hub/ask.ts +76 -12
  91. package/src/hub/board.ts +132 -22
  92. package/src/hub/bus.ts +399 -56
  93. package/src/hub/child-process.ts +2 -0
  94. package/src/hub/ci-watch.ts +42 -20
  95. package/src/hub/claude-failure-stage.ts +85 -0
  96. package/src/hub/claude-shell-wrapper.ts +11 -5
  97. package/src/hub/conductor.ts +17 -4
  98. package/src/hub/config-apply.ts +32 -5
  99. package/src/hub/config-reload.ts +7 -5
  100. package/src/hub/config-trust.ts +7 -1
  101. package/src/hub/context-window.ts +33 -3
  102. package/src/hub/control-client.ts +14 -2
  103. package/src/hub/corpus-config.ts +34 -0
  104. package/src/hub/corpus-evidence.ts +184 -0
  105. package/src/hub/corpus.ts +373 -0
  106. package/src/hub/cost-governance.ts +26 -0
  107. package/src/hub/daemon.ts +1150 -171
  108. package/src/hub/deliverable-files.ts +8 -2
  109. package/src/hub/delivery-journal.ts +123 -17
  110. package/src/hub/dogfood-profile.ts +32 -5
  111. package/src/hub/dogfood-read.ts +6 -2
  112. package/src/hub/envelope.ts +11 -0
  113. package/src/hub/eval-gate.ts +131 -0
  114. package/src/hub/events.ts +1 -1
  115. package/src/hub/external-instance.ts +69 -0
  116. package/src/hub/facts.ts +15 -5
  117. package/src/hub/folder-trigger.ts +223 -0
  118. package/src/hub/hub-tools.ts +2 -1
  119. package/src/hub/inference.ts +20 -5
  120. package/src/hub/instance-config.ts +69 -3
  121. package/src/hub/instance-execution.ts +6 -2
  122. package/src/hub/instances.ts +32 -4
  123. package/src/hub/intake.ts +125 -0
  124. package/src/hub/learning.ts +85 -0
  125. package/src/hub/lifecycle.ts +7 -3
  126. package/src/hub/limits.ts +12 -5
  127. package/src/hub/native-permission-request.ts +77 -0
  128. package/src/hub/operational-authority.ts +63 -5
  129. package/src/hub/operational-settings.ts +76 -0
  130. package/src/hub/operational-view.ts +1 -1
  131. package/src/hub/packs.ts +132 -0
  132. package/src/hub/path-policy.ts +7 -0
  133. package/src/hub/peer-doing.ts +130 -0
  134. package/src/hub/peers.ts +12 -0
  135. package/src/hub/queue-backpressure.ts +58 -0
  136. package/src/hub/read-tools.ts +2 -0
  137. package/src/hub/recovery-authority.ts +151 -0
  138. package/src/hub/recovery-policy.ts +220 -0
  139. package/src/hub/research.ts +24 -14
  140. package/src/hub/restart.ts +7 -0
  141. package/src/hub/routing.ts +54 -11
  142. package/src/hub/schedules.ts +266 -0
  143. package/src/hub/sdlc-merge.ts +38 -14
  144. package/src/hub/sdlc-metrics.ts +32 -10
  145. package/src/hub/sdlc-queue.ts +7 -1
  146. package/src/hub/sdlc-release.ts +1 -1
  147. package/src/hub/sdlc.ts +37 -7
  148. package/src/hub/security-baseline.ts +38 -0
  149. package/src/hub/settings.ts +6 -1
  150. package/src/hub/shipped-packs.ts +8 -0
  151. package/src/hub/snapshots.ts +72 -6
  152. package/src/hub/source-read.ts +160 -0
  153. package/src/hub/sources.ts +311 -0
  154. package/src/hub/tasks.ts +910 -110
  155. package/src/hub/workflow.ts +1 -1
  156. package/src/local/doc-read.ts +69 -0
  157. package/src/local/proxy.ts +4 -13
  158. package/src/local/tools.ts +37 -3
  159. package/src/local/web-fetch-config.ts +43 -0
  160. package/src/local/web-fetch.ts +183 -0
  161. package/src/memory/brief.ts +85 -13
  162. package/src/memory/capture.ts +1 -1
  163. package/src/rooms/application.ts +114 -0
  164. package/src/rooms/boundary.ts +103 -0
  165. package/src/rooms/commands.ts +294 -0
  166. package/src/rooms/crypto.ts +371 -0
  167. package/src/rooms/link.ts +584 -0
  168. package/src/rooms/policy.ts +82 -0
  169. package/src/rooms/remote-peer.ts +80 -0
  170. package/src/rooms/service.ts +426 -0
  171. package/src/rooms/status.ts +146 -0
  172. package/src/rooms/store.ts +1210 -0
  173. package/src/rooms/wire.ts +465 -0
  174. package/src/ui/index.html +91 -4
  175. package/templates/packs/example.pack.json +13 -0
@@ -11,7 +11,7 @@ export interface CriteriaReceipt {
11
11
  /** A checklist stored under `phase` before #481 S8. Its task list is shown as ignored; board split-undo still treats it as a reference. */
12
12
  export interface LegacyPhaseReceipt extends CriteriaReceipt { tasks: { id: number; after: number[] }[]; }
13
13
  const name = (s: unknown): s is string => typeof s === "string" && /^[a-z][a-z0-9-]{0,47}$/.test(s);
14
- const criterionId = (s: unknown): s is string => typeof s === "string" && /^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(s);
14
+ export const criterionId = (s: unknown): s is string => typeof s === "string" && /^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(s);
15
15
  const hash = (s: unknown): s is string => typeof s === "string" && /^[0-9a-f]{40}$/.test(s);
16
16
  const fields = (v: unknown, keys: string[], label: string): Record<string, unknown> => {
17
17
  if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(v).some(k => !keys.includes(k))) throw new Error(`${label}: invalid fields`);
@@ -0,0 +1,69 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { extname } from "node:path";
3
+ import { sanitize } from "../hub/envelope.ts";
4
+ import { matchesPolicy } from "../hub/path-policy.ts";
5
+ import { MAX_PROJECTION_INPUT, type ProjectedText } from "../hub/text-projection.ts";
6
+ import { OUTPUT_CAP } from "./sandbox.ts";
7
+
8
+ // agent-hub #515 (hub task #320): the local worker's `doc_read`. Spec: "On-prem web reading (#515)", AC2 and AC3 for doc_read.
9
+ // The model names a path and nothing else: the converter, its arguments and its sandbox are the person's `text_projection` config, run by `TextProjections`.
10
+
11
+ /** What the daemon hands the worker when a project opts in (`local.doc_read: true`). Getters, so a policy reload applies to the next call. */
12
+ export interface DocReadPort {
13
+ /** The projections of this worker's execution root: the same instance the hub's own facts and review projections use. */
14
+ projections: () => { handles(file: string): boolean; project(file: string): ProjectedText | undefined };
15
+ /** `pii.paths`: a document under it is PII. */
16
+ piiPaths: () => readonly string[];
17
+ /** `signals.pii_patterns`: a document whose text matches is PII. */
18
+ piiPatterns: () => readonly string[];
19
+ }
20
+ /** The port as a tool call sees it: with whether this turn is a PII task turn. */
21
+ export interface DocReadContext extends DocReadPort { pii: boolean }
22
+
23
+ export const DEFAULT_DOC_LINES = 2000;
24
+
25
+ /** The refusal text for a document the turn may not read, so the model is told why instead of retrying. */
26
+ const PII_PATH = "is under pii.paths: only a PII task turn on the on-prem worker may read it; ask for it in a PII task";
27
+ const PII_TEXT = "has text that matches signals.pii_patterns, so it is PII: only a PII task turn on the on-prem worker may read it; ask for it in a PII task";
28
+
29
+ /**
30
+ * Text of one document. `file` is already the real path `guardPath` returned (inside the project, not on the denylist, no link escapes). Never throws: every refusal
31
+ * is text for the model. Read-only: the converter runs in the projection sandbox on a private copy of the bytes and its cache lives in the hub state directory,
32
+ * so nothing is written beside the document, whatever `data.readonly` says.
33
+ * ponytail: the conversion is synchronous in the daemon process (bounded by the converter's 8 s timeout, then cached by content); upgrade path is an async spawn if long
34
+ * documents ever stall the hub.
35
+ */
36
+ export function docRead(file: string, shown: string, args: Record<string, unknown>, port: DocReadContext, cwd: string): string {
37
+ const projections = port.projections();
38
+ if (!projections.handles(file)) {
39
+ const ext = extname(file).toLowerCase() || "(no extension)";
40
+ return `error: no converter is configured for ${ext} files (text_projection in .agenthub/config.local.json names one per extension); use read for plain text`;
41
+ }
42
+ let st;
43
+ try { st = lstatSync(file); } catch { return `error: ${shown} cannot be read`; }
44
+ if (!st.isFile()) return `error: ${shown} is not a regular file`;
45
+ if (st.nlink !== 1) return `error: ${shown} has several names (a hard link), so its path policy cannot be checked; copy it first`;
46
+ if (st.size > MAX_PROJECTION_INPUT) return `error: ${shown} is larger than ${MAX_PROJECTION_INPUT / 1024 / 1024} MiB`;
47
+ // pii.paths decides by the document's own path first: a non-PII turn never even converts it.
48
+ if (!port.pii && matchesPolicy(cwd, file, [...port.piiPaths()])) return `error: ${shown} ${PII_PATH}`;
49
+ const projected = projections.project(file);
50
+ if (!projected) return `error: ${shown} could not be converted (the converter failed or timed out, it is not available here, or the sandbox is unavailable)`;
51
+ const text = projected.text.replace(/\0/g, "");
52
+ if (!port.pii) {
53
+ // A pattern that cannot be evaluated is not a clear verdict.
54
+ try { if (port.piiPatterns().some(pattern => new RegExp(pattern).test(text))) return `error: ${shown} ${PII_TEXT}`; }
55
+ catch { return `error: ${shown} could not be screened against signals.pii_patterns, so it is not shown`; }
56
+ }
57
+ const lines = sanitize(text).split(/\r?\n/);
58
+ const from = Math.max(1, Math.floor(Number(args.offset)) || 1), limit = Math.max(1, Math.floor(Number(args.limit)) || DEFAULT_DOC_LINES);
59
+ if (from > lines.length) return `error: offset ${from} is past the end (${lines.length} lines)`;
60
+ let out = "", shownTo = from - 1;
61
+ for (let i = from - 1; i < Math.min(lines.length, from - 1 + limit); i++) {
62
+ const row = `${i + 1}\t${lines[i]}\n`;
63
+ if (out.length + row.length > OUTPUT_CAP) { if (!out) out = row.slice(0, OUTPUT_CAP); break; }
64
+ out += row; shownTo = i + 1;
65
+ }
66
+ if (!shownTo || shownTo < from) shownTo = from;
67
+ out = out.replace(/\n$/, "");
68
+ return shownTo < lines.length ? `${out}\n(lines ${from}-${shownTo} of ${lines.length}; read again with offset=${shownTo + 1})` : out;
69
+ }
@@ -31,17 +31,8 @@ export interface EgressProxy {
31
31
  close(): Promise<void>;
32
32
  }
33
33
 
34
- /** `host` or `host:port`; a name also matches its subdomains, an address only itself. Port 443 unless named. */
35
- export function allowed(list: string[], host: string, port: number): boolean {
36
- const h = host.toLowerCase().replace(/\.$/, "");
37
- return list.some((raw) => {
38
- const entry = raw.trim().toLowerCase();
39
- const at = entry.lastIndexOf(":");
40
- const [name, p] = at > 0 && !entry.includes("]") && /^\d+$/.test(entry.slice(at + 1)) ? [entry.slice(0, at), Number(entry.slice(at + 1))] : [entry, 443];
41
- if (!name || p !== port) return false;
42
- return h === name || (!isIP(name) && h.endsWith(`.${name}`));
43
- });
44
- }
34
+ import { allowed } from "./web-fetch-config.ts";
35
+ export { allowed };
45
36
 
46
37
  // Parsed, not prefix-matched: an IPv4-mapped IPv6 address in any spelling (`::ffff:7f00:1`) checks as its IPv4 address.
47
38
  const INTERNAL = new BlockList();
@@ -81,7 +72,7 @@ const hostOf = (target: string) => {
81
72
  }
82
73
  };
83
74
 
84
- export function startEgressProxy(opts: { allow: string[]; log: (line: string) => void }): Promise<EgressProxy> {
75
+ export function startEgressProxy(opts: { allow: string[]; log: (line: string) => void; /** Read at each CONNECT instead of `allow`, so a reloaded allowlist applies to the next request (the `web_fetch` proxy uses it). */ allowFn?: () => readonly string[] }): Promise<EgressProxy> {
85
76
  const sockets = new Set<Socket>();
86
77
  // Half-open on both sides: a client that sends its request and shuts its write side still gets the answer.
87
78
  const server: Server = createServer({ allowHalfOpen: true }, (client) => {
@@ -119,7 +110,7 @@ export function startEgressProxy(opts: { allow: string[]; log: (line: string) =>
119
110
  const host = m?.[1]!.replace(/^\[|\]$/g, "") ?? "";
120
111
  if (!m || !(isIP(host) || /^[a-z0-9.-]+$/i.test(host))) return refuse(client, 400, "bad CONNECT target", "a CONNECT request");
121
112
  const port = Number(m[2]);
122
- if (!allowed(opts.allow, host, port)) return refuse(client, 403, `${host}:${port} is not in local.network_allow`, `${host}:${port}`);
113
+ if (!allowed([...(opts.allowFn ? opts.allowFn() : opts.allow)], host, port)) return refuse(client, 403, `${host}:${port} is not in local.network_allow`, `${host}:${port}`);
123
114
  let address = host;
124
115
  if (!isIP(host)) {
125
116
  try {
@@ -2,8 +2,10 @@ import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "nod
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
3
  import { realPath } from "../hub/project.ts";
4
4
  import { isHubWritePath } from "../hub/permission-mode.ts";
5
- import { matchesPolicy } from "../hub/path-policy.ts";
5
+ import { insideRoot, matchesPolicy } from "../hub/path-policy.ts";
6
6
  import { isDenied } from "./deny.ts";
7
+ import { docRead, type DocReadContext, type DocReadPort } from "./doc-read.ts";
8
+ import { webFetch, type WebFetchContext, type WebFetchPort } from "./web-fetch.ts";
7
9
  import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
8
10
 
9
11
  export { isDenied };
@@ -34,6 +36,10 @@ export interface ToolContext {
34
36
  permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown, policy?: ShellPermitPolicy) => Promise<ToolApproval>;
35
37
  /** Host-only synchronous recheck, immediately before a shell process can be spawned. */
36
38
  shellExecutionCheck?: (input: unknown) => ShellExecutionCheck;
39
+ /** #515: present only when the project opted in (`local.doc_read`), so `doc_read` exists for this worker and nothing else gets it. `pii`: this turn is a PII task turn. */
40
+ docRead?: DocReadPort & { pii: boolean };
41
+ /** #515b: present only when the project opted in (`local.web_fetch.allow` names a host), so `web_fetch` exists for this worker and nothing else gets it. `pii`: this turn is a PII task turn, for which it is refused. */
42
+ webFetch?: WebFetchPort & { pii: boolean };
37
43
  /** Trusted process status, independent of what its stdout says. */
38
44
  onProcessExit?: (code: number | null) => void;
39
45
  /** Publish a message to other peers mid-turn. Returns a one-line receipt. */
@@ -66,10 +72,14 @@ export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny" | "readOnly">, p
66
72
  const real = join(realExisting, relative(existing, abs));
67
73
  const rel = relative(root, real);
68
74
  if (rel.startsWith("..") || resolve(root, rel) !== real) throw new Error(`${path} is outside the project directory`);
69
- if (isDenied(rel, ctx.deny)) throw new Error(`${path} is on the secrets denylist`);
75
+ // Never trust the resolved name alone (#541): on a hardlinked file Bun's realpath sometimes names another link of the same inode, so the
76
+ // name the caller typed is judged too. A refusal on either name stands; the typed name only ever adds refusals. Only a typed name INSIDE the project is judged
77
+ // (`insideRoot`, #555): a name outside it (`../o/.env`, a link into the project) is no project path for any policy to name, and it was never refused before #541.
78
+ const typed = relative(root, abs), inside = insideRoot(typed);
79
+ if (isDenied(rel, ctx.deny) || inside && isDenied(typed, ctx.deny)) throw new Error(`${path} is on the secrets denylist`);
70
80
  // Not secret, but writing .git or .agenthub changes what runs outside the worker's control. Names are compared
71
81
  // folded: a new `.GIT/config` is `.git/config` to a case-insensitive disk, and git then runs what it names.
72
- if (mode === "write" && isHubWritePath(rel)) throw new Error(`${path} is not writable by the local worker`);
82
+ if (mode === "write" && (isHubWritePath(rel) || inside && isHubWritePath(typed))) throw new Error(`${path} is not writable by the local worker`);
73
83
  // Identity refusal is stable even when realPath names a protected hardlink alias.
74
84
  if (mode === "write" && ctx.readOnly?.length && (lstatSync(real, { throwIfNoEntry: false })?.nlink ?? 1) > 1) throw new Error("hardlinked write target cannot be verified with data.readonly");
75
85
  if (mode === "write" && matchesPolicy(root, path, ctx.readOnly ?? [])) throw new Error("target is protected by data.readonly");
@@ -111,6 +121,20 @@ export const TOOL_SCHEMAS = [
111
121
  fn("hub_send", "Send a message to the other agents now, before your turn ends. Conclusions only. Your final answer is shared anyway; use this for something that cannot wait.", { text: str, to: { type: "array", items: str } }, ["text"]),
112
122
  ];
113
123
 
124
+ /**
125
+ * #515: offered only to a worker whose project set `local.doc_read` (off by default), never part of TOOL_SCHEMAS. The model names a path and a line window; the converter
126
+ * is the person's `text_projection` entry for the extension, run in the projection sandbox, and the text is bounded and passed through `sanitize()`.
127
+ */
128
+ export const DOC_READ_SCHEMA = fn("doc_read", "Read a PDF, DOCX or HWPX document inside the project as plain text, through the converters the project configured. Returns numbered lines, bounded; read again with offset for more. Read-only.", { path: str, offset: { type: "integer", minimum: 1 }, limit: { type: "integer", minimum: 1 } }, ["path"]);
129
+ export type { DocReadContext, DocReadPort };
130
+
131
+ /**
132
+ * #515b: offered only to a worker whose project listed at least one host in `local.web_fetch.allow` (off by default), never part of TOOL_SCHEMAS. The model names a URL and a line
133
+ * window and nothing else: no method, header or body. It is refused for the whole turn of a PII task, and otherwise runs only through the egress proxy, to listed hosts.
134
+ */
135
+ export const WEB_FETCH_SCHEMA = fn("web_fetch", "Read one web page as plain text. https only, and only for hosts the project listed in local.web_fetch.allow; refused in a PII task. A plain GET with no headers or body of yours. Returns numbered lines, bounded; read again with offset for more. Never put anything private or from the task into the URL.", { url: str, offset: { type: "integer", minimum: 1 }, limit: { type: "integer", minimum: 1 } }, ["url"]);
136
+ export type { WebFetchContext, WebFetchPort };
137
+
114
138
  /** Every path-like argument of a call, for the memory capture filter. */
115
139
  export function touchedPaths(name: string, args: Record<string, unknown>): string[] {
116
140
  if (name === "read" || name === "write" || name === "edit") return [String(args.path ?? "")];
@@ -213,6 +237,16 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
213
237
  try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
214
238
  return `${res.output}\n(exit ${res.code})`;
215
239
  }
240
+ case "doc_read": {
241
+ if (!ctx.docRead) return "error: doc_read is not enabled for this project (local.doc_read in .agenthub/config.local.json)";
242
+ const file = guardPath(ctx, String(a.path), "read");
243
+ return docRead(file, String(a.path), a, ctx.docRead, ctx.cwd);
244
+ }
245
+ case "web_fetch": {
246
+ // The refusal for a PII turn is the first thing this tool does, before the URL is read (see `webFetch`); the opt-in check only says whether the tool exists.
247
+ if (!ctx.webFetch) return "error: web_fetch is not enabled for this project (local.web_fetch.allow in .agenthub/config.local.json)";
248
+ return webFetch(String(a.url ?? ""), a, ctx.webFetch, ctx.signal);
249
+ }
216
250
  case "hub_send":
217
251
  return a.text ? ctx.send(String(a.text), Array.isArray(a.to) ? a.to.map(String) : undefined) : "error: text is required";
218
252
  default:
@@ -0,0 +1,43 @@
1
+ // agent-hub #515 (hub task #332): the `local.web_fetch` config block and the host matcher both the tool and the egress proxy use. Pure on purpose: the config schema reaches the
2
+ // Claude channel bundle, so this file imports nothing that touches the file system, opens a socket or spawns a process. The tool is in `web-fetch.ts`, the proxy in `proxy.ts`.
3
+ import { isIP } from "node:net";
4
+
5
+ /** `host` or `host:port`; a name also matches its subdomains, an address only itself. Port 443 unless named. */
6
+ export function allowed(list: string[], host: string, port: number): boolean {
7
+ const h = host.toLowerCase().replace(/\.$/, "");
8
+ return list.some((raw) => {
9
+ const entry = raw.trim().toLowerCase();
10
+ const at = entry.lastIndexOf(":");
11
+ const [name, p] = at > 0 && !entry.includes("]") && /^\d+$/.test(entry.slice(at + 1)) ? [entry.slice(0, at), Number(entry.slice(at + 1))] : [entry, 443];
12
+ if (!name || p !== port) return false;
13
+ return h === name || (!isIP(name) && h.endsWith(`.${name}`));
14
+ });
15
+ }
16
+ /** At most this many hosts: a list is a small, deliberate thing. */
17
+ export const MAX_WEB_HOSTS = 64;
18
+ /**
19
+ * Why one `local.web_fetch.allow` entry is not accepted, or undefined. An entry is a host name, optionally `:port`: letters, digits, `-` and `.`, at least one dot, never an
20
+ * address, a URL, a path, a wildcard or a bare top-level name (`com` would open every site). Checked when the config loads, so a typo fails there and not in a turn.
21
+ */
22
+ export function webHostProblem(value: unknown): string | undefined {
23
+ if (typeof value !== "string" || !value || value.length > 253 + 6) return "a host is a non-empty name of at most 253 characters";
24
+ const match = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+)(?::([0-9]{1,5}))?$/.exec(value);
25
+ if (!match) return "a host is a name such as docs.example.org or docs.example.org:8443: no scheme, path, wildcard, spaces or bare top-level name";
26
+ if (isIP(match[1]!)) return "a host is a name, never an address";
27
+ if (match[2] !== undefined && (Number(match[2]) < 1 || Number(match[2]) > 65535)) return "a port is 1 to 65535";
28
+ return undefined;
29
+ }
30
+
31
+ /** The checked `local.web_fetch.allow`. Nothing here reads the network or the disk. */
32
+ export function webFetchAllow(input: unknown): string[] {
33
+ if (input === undefined) return [];
34
+ if (!Array.isArray(input) || input.length > MAX_WEB_HOSTS) throw new Error(`local.web_fetch.allow takes a list of at most ${MAX_WEB_HOSTS} hosts`);
35
+ const seen = new Set<string>(), out: string[] = [];
36
+ input.forEach((host, index) => {
37
+ const problem = webHostProblem(host);
38
+ if (problem) throw new Error(`local.web_fetch.allow[${index}]: ${problem}`);
39
+ const key = (host as string).toLowerCase();
40
+ if (!seen.has(key)) { seen.add(key); out.push(key); }
41
+ });
42
+ return out;
43
+ }
@@ -0,0 +1,183 @@
1
+ import { isIP } from "node:net";
2
+ import { sanitize } from "../hub/envelope.ts";
3
+ import { OUTPUT_CAP } from "./sandbox.ts";
4
+ import { allowed } from "./web-fetch-config.ts";
5
+ export { MAX_WEB_HOSTS, webFetchAllow, webHostProblem } from "./web-fetch-config.ts";
6
+
7
+ // agent-hub #515 (hub task #332): the local worker's `web_fetch`. Spec: "On-prem web reading (#515)", the owner decision of 2026-10-22.
8
+ // It is refused for the whole turn of a PII task, whatever the host: the URL or a query string could carry PII to the web, and `pii.paths` and `sanitize()` cover
9
+ // what comes back, not what goes out. Outside a PII turn it runs only through the egress proxy, to hosts the person listed for this project, and returns bounded,
10
+ // sanitized text only. Off by default.
11
+
12
+ /** The refusal for a PII turn. It is the same text for every URL, so it says nothing about the request. */
13
+ export const WEB_FETCH_PII = "error: web_fetch is not available in a PII task turn, whatever the host: the URL or a query could carry PII to the web. Use doc_read or read for what the project already holds";
14
+
15
+ /** What the daemon hands the worker when a project opts in (`local.web_fetch.allow` names at least one host). Getters, so a policy reload applies to the next call. */
16
+ export interface WebFetchPort {
17
+ /** `local.web_fetch.allow`: hosts (`host` or `host:port`, subdomains included for a name) the model may read. */
18
+ allow: () => readonly string[];
19
+ /** The loopback URL of the egress proxy that opens tunnels to `allow` and nothing else; undefined while it is not running. */
20
+ proxyUrl: () => string | undefined;
21
+ /** Test seam: the fetch to use. Production leaves it unset (the global `fetch`). */
22
+ fetch?: (url: string, init: { proxy: string; redirect: "manual"; signal: AbortSignal; headers: Record<string, string>; method: "GET" }) => Promise<Response>;
23
+ }
24
+ /** The port as a tool call sees it: with whether this turn is a PII task turn. */
25
+ export interface WebFetchContext extends WebFetchPort { pii: boolean }
26
+
27
+ export const WEB_FETCH_TIMEOUT_MS = 15_000;
28
+ /** Bytes read from a response, however large it says it is. Text past it is cut, never buffered. */
29
+ export const WEB_FETCH_MAX_BYTES = 512 * 1024;
30
+ export const WEB_FETCH_MAX_URL = 2000;
31
+ /** Redirects followed, each one re-checked against the allowlist before it is requested. */
32
+ export const WEB_FETCH_MAX_REDIRECTS = 3;
33
+ export const DEFAULT_WEB_LINES = 2000;
34
+
35
+ const TEXT_TYPE = /^(?:text\/[a-z0-9.+-]+|application\/(?:json|xml|xhtml\+xml|[a-z0-9.+-]+\+(?:json|xml)))(?:\s*;.*)?$/i;
36
+
37
+ /**
38
+ * Whether a URL may be requested at all: https, a host the list names, port 443 unless the entry says otherwise, no userinfo, no address literal, no fragment-only tricks.
39
+ * A reason string, never a throw. The reason names the host (the model already wrote it) and never anything the person configured beyond "not in the list".
40
+ */
41
+ export function checkWebUrl(raw: string, allow: readonly string[]): { ok: true; url: URL } | { ok: false; reason: string } {
42
+ if (typeof raw !== "string" || !raw.trim() || raw.length > WEB_FETCH_MAX_URL || /[\x00-\x20\x7f]/.test(raw)) return { ok: false, reason: "url must be one https URL of at most 2000 characters with no spaces or control characters" };
43
+ let url: URL;
44
+ try { url = new URL(raw); } catch { return { ok: false, reason: "url is not a valid URL" }; }
45
+ if (url.protocol !== "https:") return { ok: false, reason: "only https URLs are fetched" };
46
+ if (url.username || url.password) return { ok: false, reason: "a URL with a user name or password is not fetched" };
47
+ const host = url.hostname.replace(/^\[|\]$/g, "");
48
+ if (isIP(host)) return { ok: false, reason: "an address is not a host: name the site, and the person lists it in local.web_fetch.allow" };
49
+ const port = url.port ? Number(url.port) : 443;
50
+ if (!allowed([...allow], host, port)) return { ok: false, reason: `${host}:${port} is not in local.web_fetch.allow` };
51
+ url.hash = "";
52
+ return { ok: true, url };
53
+ }
54
+
55
+ /** The decoded text of a response body, read at most `WEB_FETCH_MAX_BYTES` bytes deep. */
56
+ async function readBounded(res: Response): Promise<{ text: string; cut: boolean }> {
57
+ const reader = res.body?.getReader();
58
+ if (!reader) return { text: "", cut: false };
59
+ const chunks: Uint8Array[] = [];
60
+ let total = 0, cut = false;
61
+ for (;;) {
62
+ const { done, value } = await reader.read();
63
+ if (done) break;
64
+ if (total + value.length > WEB_FETCH_MAX_BYTES) { chunks.push(value.subarray(0, WEB_FETCH_MAX_BYTES - total)); total = WEB_FETCH_MAX_BYTES; cut = true; void reader.cancel().catch(() => undefined); break; }
65
+ chunks.push(value); total += value.length;
66
+ }
67
+ return { text: new TextDecoder("utf-8", { fatal: false }).decode(Buffer.concat(chunks)), cut };
68
+ }
69
+
70
+ const SKIPPED_ELEMENTS = new Set(["script", "style", "noscript", "template", "svg", "head"]);
71
+ /** Their content is never markup, so an unclosed one swallows the rest of the page (what a browser does); the others lose only their tag. */
72
+ const RAW_TEXT_ELEMENTS = new Set(["script", "style"]);
73
+ const BLOCK_CLOSERS = new Set(["p", "div", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "section", "article", "br", "pre", "blockquote", "table"]);
74
+ const isAsciiLetter = (code: number) => (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
75
+ const isNameCode = (code: number) => isAsciiLetter(code) || (code >= 48 && code <= 57) || code === 45;
76
+
77
+ /**
78
+ * Markup reduced to its text, enough to read a page: scripts and styles go, tags go, entities are decoded for the few that matter, runs of blank lines collapse.
79
+ * One forward pass over a body that a hostile page controls: every index only moves ahead, and a search that finds nothing is never repeated for the same
80
+ * target, so the work is linear in the body whatever is left unclosed (a repeated `<script>`, `<!--` or `<` costs one scan, not one per opener).
81
+ * The 512 KiB body cap bounds the input; this bounds the CPU, because an AbortSignal cannot interrupt synchronous code.
82
+ */
83
+ export function htmlText(html: string): string {
84
+ const out: string[] = [];
85
+ const closerMissing = new Set<string>(); // a failed search covered the whole suffix, so a later one for the same name would fail too
86
+ const n = html.length;
87
+ let i = 0, text = 0; // text: start of the plain run not yet copied
88
+ const flush = (to: number) => { if (to > text) out.push(html.slice(text, to)); };
89
+ while (i < n) {
90
+ const lt = html.indexOf("<", i);
91
+ if (lt === -1) break;
92
+ const next = html.charCodeAt(lt + 1);
93
+ if (html.startsWith("<!--", lt)) {
94
+ flush(lt);
95
+ const end = html.indexOf("-->", lt + 4);
96
+ if (end === -1) { text = i = n; break; } // an unterminated comment runs to the end of the page
97
+ out.push(" "); text = i = end + 3;
98
+ continue;
99
+ }
100
+ const closing = next === 47;
101
+ if (!(isAsciiLetter(next) || (closing && isAsciiLetter(html.charCodeAt(lt + 2))) || next === 33 || next === 63)) { i = lt + 1; continue; } // `a < b` is text
102
+ flush(lt);
103
+ const gt = html.indexOf(">", lt + 1);
104
+ if (gt === -1) { text = i = n; break; } // an unterminated tag at the end of the page is dropped
105
+ let nameStart = lt + (closing ? 2 : 1), nameEnd = nameStart;
106
+ while (nameEnd < gt && isNameCode(html.charCodeAt(nameEnd))) nameEnd++;
107
+ const name = html.slice(nameStart, nameEnd).toLowerCase(), rest = html.slice(nameEnd, gt).trim();
108
+ i = text = gt + 1;
109
+ if (closing) { out.push(BLOCK_CLOSERS.has(name) && rest === "" ? "\n" : " "); continue; }
110
+ if (name === "br" && (rest === "" || rest === "/")) { out.push("\n"); continue; }
111
+ out.push(" ");
112
+ if (SKIPPED_ELEMENTS.has(name) && !rest.endsWith("/")) {
113
+ if (closerMissing.has(name)) continue; // only a non-raw-text element gets here: an unclosed script or style already ended the page
114
+ const closer = new RegExp(`</${name}\\s*>`, "gi");
115
+ closer.lastIndex = i;
116
+ const found = closer.exec(html);
117
+ if (found) { i = text = found.index + found[0].length; }
118
+ else { closerMissing.add(name); if (RAW_TEXT_ELEMENTS.has(name)) i = text = n; }
119
+ }
120
+ }
121
+ flush(n);
122
+ return out.join("")
123
+ .replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&amp;/g, "&")
124
+ .replace(/[ \t\f\v]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n").trim();
125
+ }
126
+
127
+ /**
128
+ * One page as text. Never throws: every refusal is text for the model. Order matters and is the contract:
129
+ * 1. a PII task turn is refused before the URL is read at all, whatever it says, so no host check, error or timing differs for it;
130
+ * 2. the URL must be an allowed https host, and the proxy must be running (without it there is no way out, so no request is made);
131
+ * 3. the request is a bare GET through the proxy, with no header or body from the model, and no redirect is followed without being checked again;
132
+ * 4. only a text type is read, bounded in bytes and time, then `sanitize()` quotes a forged hub header and the output is cut at the shared output cap.
133
+ */
134
+ export async function webFetch(url: string, args: Record<string, unknown>, port: WebFetchContext, signal?: AbortSignal): Promise<string> {
135
+ if (port.pii) return WEB_FETCH_PII;
136
+ const proxy = port.proxyUrl();
137
+ let current = checkWebUrl(url, port.allow());
138
+ if (!current.ok) return `error: ${current.reason}`;
139
+ if (!proxy) return "error: web_fetch has no egress proxy right now, so nothing was requested";
140
+ const doFetch = port.fetch ?? ((target, init) => fetch(target, init as RequestInit & { proxy: string }));
141
+ let response: Response | undefined;
142
+ try {
143
+ for (let hop = 0; ; hop++) {
144
+ const abort = AbortSignal.any([AbortSignal.timeout(WEB_FETCH_TIMEOUT_MS), ...(signal ? [signal] : [])]);
145
+ response = await doFetch(current.url.toString(), { method: "GET", proxy, redirect: "manual", signal: abort, headers: { accept: "text/html, text/plain, application/json;q=0.9, */*;q=0.1", "user-agent": "agent-hub-web-fetch" } });
146
+ if (response.status < 300 || response.status >= 400) break;
147
+ const location = response.headers.get("location");
148
+ void response.body?.cancel().catch(() => undefined);
149
+ if (!location) return `error: the server answered ${response.status} with no location`;
150
+ if (hop >= WEB_FETCH_MAX_REDIRECTS) return `error: more than ${WEB_FETCH_MAX_REDIRECTS} redirects`;
151
+ let next: string;
152
+ try { next = new URL(location, current.url).toString(); } catch { return "error: the server sent a redirect that is not a URL"; }
153
+ const checked = checkWebUrl(next, port.allow());
154
+ if (!checked.ok) return `error: the page redirects to a URL that is not allowed (${checked.reason})`;
155
+ current = checked;
156
+ }
157
+ } catch (error) {
158
+ if (signal?.aborted) return "error: turn cancelled before the page was read";
159
+ const name = (error as { name?: string; code?: string })?.name, code = (error as { code?: string })?.code;
160
+ if (name === "TimeoutError" || name === "AbortError") return `error: the page did not answer within ${WEB_FETCH_TIMEOUT_MS / 1000} seconds`;
161
+ return `error: the page could not be fetched${typeof code === "string" && /^[A-Z_]{3,40}$/.test(code) ? ` (${code})` : ""}`;
162
+ }
163
+ if (!response.ok) { void response.body?.cancel().catch(() => undefined); return `error: the server answered ${response.status}`; }
164
+ const type = response.headers.get("content-type") ?? "";
165
+ if (!TEXT_TYPE.test(type)) { void response.body?.cancel().catch(() => undefined); return `error: ${type.split(";")[0] || "an unknown type"} is not text: only text, JSON and XML pages are read`; }
166
+ let body: { text: string; cut: boolean };
167
+ try { body = await readBounded(response); } catch { return "error: the page could not be read to the end"; }
168
+ const isHtml = /^text\/html|^application\/xhtml\+xml/i.test(type);
169
+ const text = sanitize((isHtml ? htmlText(body.text) : body.text).replace(/\0/g, ""));
170
+ const lines = text.split(/\r?\n/);
171
+ const from = Math.max(1, Math.floor(Number(args.offset)) || 1), limit = Math.max(1, Math.floor(Number(args.limit)) || DEFAULT_WEB_LINES);
172
+ if (from > lines.length) return `error: offset ${from} is past the end (${lines.length} lines)`;
173
+ let out = "", shownTo = from - 1;
174
+ for (let i = from - 1; i < Math.min(lines.length, from - 1 + limit); i++) {
175
+ const row = `${i + 1}\t${lines[i]}\n`;
176
+ if (out.length + row.length > OUTPUT_CAP) { if (!out) out = row.slice(0, OUTPUT_CAP); break; }
177
+ out += row; shownTo = i + 1;
178
+ }
179
+ if (!shownTo || shownTo < from) shownTo = from;
180
+ out = out.replace(/\n$/, "");
181
+ const more = shownTo < lines.length ? `(lines ${from}-${shownTo} of ${lines.length}; read again with offset=${shownTo + 1})` : body.cut ? `(the page was cut at ${WEB_FETCH_MAX_BYTES / 1024} KiB)` : "";
182
+ return more ? `${out}\n${more}` : out;
183
+ }
@@ -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.