@staix/agent-hub 0.20.0 → 0.22.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 (183) hide show
  1. package/CHANGELOG.md +66 -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 +3 -3
  8. package/docs/agent-notes/daemon.md +17 -8
  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 +39 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +610 -62
  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 +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +133 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1101 -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/2026-10-30-0.22.0.md +49 -0
  37. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  38. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  39. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  40. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  41. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  42. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  43. package/docs/verification/claude-permission-transport-native.py +71 -14
  44. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  45. package/docs/verified.json +15 -15
  46. package/package.json +2 -2
  47. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  48. package/plugins/agent-hub/server.js +1016 -280
  49. package/src/adapters/acp.ts +79 -3
  50. package/src/adapters/channel-peer.ts +4 -0
  51. package/src/adapters/claude-channel.ts +13 -6
  52. package/src/adapters/claude-implementer.ts +60 -7
  53. package/src/adapters/codex-appserver.ts +77 -4
  54. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  55. package/src/adapters/conformance/kit.ts +3 -9
  56. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  57. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  58. package/src/adapters/definitions/native-config-policy.ts +3 -2
  59. package/src/adapters/definitions/native-context.ts +12 -0
  60. package/src/adapters/external-metadata.ts +48 -0
  61. package/src/adapters/external-modules.ts +63 -3
  62. package/src/adapters/jcode.ts +6 -2
  63. package/src/adapters/local-worker.ts +40 -25
  64. package/src/adapters/native-proof-ports.ts +10 -0
  65. package/src/adapters/registry-types.ts +4 -0
  66. package/src/broker/protocol.ts +203 -0
  67. package/src/broker/server.ts +314 -0
  68. package/src/broker/store.ts +548 -0
  69. package/src/cli/bench.ts +3 -2
  70. package/src/cli/broker.ts +58 -0
  71. package/src/cli/conductor-recovery.ts +142 -0
  72. package/src/cli/configured-acp-entry.ts +26 -6
  73. package/src/cli/console-state.ts +59 -17
  74. package/src/cli/console.ts +17 -2
  75. package/src/cli/facts-hook.ts +3 -1
  76. package/src/cli/help.ts +29 -16
  77. package/src/cli/identity.ts +25 -10
  78. package/src/cli/init.ts +34 -7
  79. package/src/cli/intake-output.ts +33 -0
  80. package/src/cli/learn-output.ts +38 -0
  81. package/src/cli/main.ts +334 -73
  82. package/src/cli/output.ts +41 -1
  83. package/src/cli/recovery-package.ts +18 -1
  84. package/src/cli/recovery-waits.ts +26 -0
  85. package/src/cli/role-holders.ts +35 -0
  86. package/src/cli/room.ts +304 -0
  87. package/src/cli/say-input.ts +16 -0
  88. package/src/cli/schedule-output.ts +49 -0
  89. package/src/cli/sdlc-output.ts +44 -6
  90. package/src/cli/smoke-pty-bridge.ts +82 -0
  91. package/src/cli/smoke-pty.ts +153 -0
  92. package/src/cli/smoke-run.ts +139 -0
  93. package/src/cli/smoke-target.ts +130 -0
  94. package/src/cli/source-output.ts +29 -0
  95. package/src/cli/task-output.ts +3 -2
  96. package/src/cli/upgrade-runtime.ts +112 -28
  97. package/src/cli/upgrade.ts +68 -8
  98. package/src/hub/ask.ts +76 -12
  99. package/src/hub/board.ts +132 -22
  100. package/src/hub/bus.ts +416 -65
  101. package/src/hub/child-process.ts +2 -0
  102. package/src/hub/ci-watch.ts +42 -20
  103. package/src/hub/claude-failure-stage.ts +85 -0
  104. package/src/hub/claude-shell-wrapper.ts +11 -5
  105. package/src/hub/conductor.ts +17 -4
  106. package/src/hub/config-apply.ts +34 -6
  107. package/src/hub/config-reload.ts +7 -5
  108. package/src/hub/config-trust.ts +7 -1
  109. package/src/hub/context-window.ts +33 -3
  110. package/src/hub/control-client.ts +14 -2
  111. package/src/hub/corpus-config.ts +34 -0
  112. package/src/hub/corpus-evidence.ts +184 -0
  113. package/src/hub/corpus.ts +373 -0
  114. package/src/hub/cost-governance.ts +26 -0
  115. package/src/hub/daemon.ts +1209 -183
  116. package/src/hub/deliverable-files.ts +8 -2
  117. package/src/hub/delivery-journal.ts +200 -73
  118. package/src/hub/dogfood-profile.ts +32 -5
  119. package/src/hub/dogfood-read.ts +6 -2
  120. package/src/hub/envelope.ts +11 -0
  121. package/src/hub/eval-gate.ts +131 -0
  122. package/src/hub/events.ts +1 -1
  123. package/src/hub/external-instance.ts +69 -0
  124. package/src/hub/facts.ts +15 -5
  125. package/src/hub/folder-trigger.ts +223 -0
  126. package/src/hub/hub-tools.ts +2 -1
  127. package/src/hub/inference.ts +20 -5
  128. package/src/hub/instance-config.ts +69 -3
  129. package/src/hub/instance-execution.ts +6 -2
  130. package/src/hub/instances.ts +32 -4
  131. package/src/hub/intake.ts +125 -0
  132. package/src/hub/learning.ts +117 -0
  133. package/src/hub/lifecycle.ts +7 -3
  134. package/src/hub/limits.ts +12 -5
  135. package/src/hub/native-permission-request.ts +77 -0
  136. package/src/hub/operational-authority.ts +63 -5
  137. package/src/hub/operational-settings.ts +154 -0
  138. package/src/hub/operational-view.ts +1 -1
  139. package/src/hub/packs.ts +132 -0
  140. package/src/hub/path-policy.ts +7 -0
  141. package/src/hub/peer-doing.ts +130 -0
  142. package/src/hub/peers.ts +12 -0
  143. package/src/hub/queue-backpressure.ts +58 -0
  144. package/src/hub/read-tools.ts +2 -0
  145. package/src/hub/recovery-authority.ts +151 -0
  146. package/src/hub/recovery-policy.ts +220 -0
  147. package/src/hub/research.ts +24 -14
  148. package/src/hub/restart.ts +7 -0
  149. package/src/hub/routing.ts +54 -11
  150. package/src/hub/schedules.ts +266 -0
  151. package/src/hub/sdlc-merge.ts +38 -14
  152. package/src/hub/sdlc-metrics.ts +32 -10
  153. package/src/hub/sdlc-queue.ts +7 -1
  154. package/src/hub/sdlc-release.ts +1 -1
  155. package/src/hub/sdlc.ts +37 -7
  156. package/src/hub/security-baseline.ts +38 -0
  157. package/src/hub/settings.ts +27 -2
  158. package/src/hub/shipped-packs.ts +8 -0
  159. package/src/hub/snapshots.ts +72 -6
  160. package/src/hub/source-read.ts +160 -0
  161. package/src/hub/sources.ts +311 -0
  162. package/src/hub/tasks.ts +987 -110
  163. package/src/hub/workflow.ts +1 -1
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/tools.ts +37 -3
  167. package/src/local/web-fetch-config.ts +43 -0
  168. package/src/local/web-fetch.ts +183 -0
  169. package/src/memory/brief.ts +85 -13
  170. package/src/memory/capture.ts +1 -1
  171. package/src/rooms/application.ts +114 -0
  172. package/src/rooms/boundary.ts +103 -0
  173. package/src/rooms/commands.ts +294 -0
  174. package/src/rooms/crypto.ts +371 -0
  175. package/src/rooms/link.ts +584 -0
  176. package/src/rooms/policy.ts +82 -0
  177. package/src/rooms/remote-peer.ts +80 -0
  178. package/src/rooms/service.ts +426 -0
  179. package/src/rooms/status.ts +146 -0
  180. package/src/rooms/store.ts +1210 -0
  181. package/src/rooms/wire.ts +465 -0
  182. package/src/ui/index.html +91 -4
  183. package/templates/packs/example.pack.json +13 -0
@@ -0,0 +1,131 @@
1
+ import type { PrEvidence, SdlcConfig, SdlcGh } from "./sdlc.ts";
2
+ import { completePlanInventory } from "./plan-conformance.ts";
3
+
4
+ /**
5
+ * #481 S5: the gate MECHANISM for agent-configuration PRs. A PR whose changed paths match `config_paths` needs the one check named by `eval_check`.
6
+ * The hub chooses, creates and defaults to no suite: `eval_check` has no default, and the person names it (suite choice pending the person).
7
+ */
8
+ export const DEFAULT_CONFIG_PATHS: readonly string[] = [
9
+ "AGENTS.md", "docs/agent-notes/**", "templates/**", ".agenthub/roles/**", ".agenthub/routing.toml", "routing.toml", "src/hub/hub-tools.ts",
10
+ ];
11
+ const MAX_PATTERNS = 32;
12
+
13
+ /** Repository-relative patterns: literal names, `*` and `?` inside one segment, and a whole-segment `**`. Anything else is refused at load. */
14
+ export function configPathList(value: unknown, what: string): string[] {
15
+ const bad = () => new Error(`${what}: expected at most ${MAX_PATTERNS} unique repository-relative patterns (literal names, * and ? inside a segment, whole-segment **)`);
16
+ if (!Array.isArray(value) || value.length > MAX_PATTERNS || new Set(value).size !== value.length) throw bad();
17
+ for (const pattern of value) {
18
+ if (typeof pattern !== "string" || !pattern || pattern.length > 200 || /[\p{Cc}\p{Cf}\\]/u.test(pattern) || pattern.startsWith("/") || /^[A-Za-z]:/.test(pattern)) throw bad();
19
+ const parts = pattern.split("/");
20
+ if (parts.some(part => part === "" || part === "." || part === ".." || (part.includes("**") && part !== "**"))) throw bad();
21
+ }
22
+ return [...value] as string[];
23
+ }
24
+
25
+ const segmentRegex = (part: string) => new RegExp(`^${part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")}$`);
26
+ /** Pure, lexical, no filesystem. `dir/**` matches everything under `dir`, never `dir` itself. */
27
+ export function matchConfigPath(pattern: string, path: string): boolean {
28
+ const p = pattern.split("/"), s = path.split("/");
29
+ const walk = (i: number, j: number): boolean => {
30
+ if (i === p.length) return j === s.length;
31
+ if (p[i] === "**") { for (let k = j + 1; k <= s.length; k++) if (walk(i + 1, k)) return true; return false; }
32
+ return j < s.length && segmentRegex(p[i]!).test(s[j]!) && walk(i + 1, j + 1);
33
+ };
34
+ return walk(0, 0);
35
+ }
36
+
37
+ export type EvalApplies = "config" | "code" | "unknown";
38
+ export const configPathsOf = (config: Pick<SdlcConfig, "configPaths">): readonly string[] => config.configPaths ?? DEFAULT_CONFIG_PATHS;
39
+
40
+ /** `unknown` when the file inventory is not complete (never a clean code-only claim); `code` for a PR touching no configured path, an empty `config_paths` included. */
41
+ export function evalApplies(config: Pick<SdlcConfig, "configPaths">, evidence: PrEvidence | undefined): { applies: EvalApplies; matched: string[] } {
42
+ const paths = configPathsOf(config);
43
+ if (!paths.length) return { applies: "code", matched: [] };
44
+ const inventory = completePlanInventory(evidence);
45
+ if (inventory === undefined) return { applies: "unknown", matched: [] };
46
+ const matched = inventory.filter(path => paths.some(pattern => matchConfigPath(pattern, path))).sort();
47
+ return { applies: matched.length ? "config" : "code", matched };
48
+ }
49
+
50
+ /** The extra required check for this reading of the PR: the configured eval check, only for a configuration PR. */
51
+ export function evalRequiredChecks(config: Pick<SdlcConfig, "configPaths" | "evalCheck" | "mode">, evidence: PrEvidence | undefined): string[] {
52
+ if (config.mode === "off" || !config.evalCheck) return [];
53
+ return evalApplies(config, evidence).applies === "config" ? [config.evalCheck] : [];
54
+ }
55
+
56
+ /** What the eval check run reported, never its text: two bounded values read from a fixed marker (`eval_pass_rate=<0..1>`, `eval_suite=<version>`). */
57
+ export interface EvalResult { check: string; head: string; passRate?: number; suite?: string; read: boolean }
58
+ export interface EvalGate {
59
+ applies: "config" | "unknown";
60
+ /** How many changed files matched `config_paths`; names stay out of the view (counts and identities only). */
61
+ files: number;
62
+ check?: string;
63
+ state: "not_configured" | "coverage_unknown" | "passed" | "failed" | "pending" | "missing" | "unknown";
64
+ passRate?: number;
65
+ suite?: string;
66
+ /** Evidence gaps that were required of a configured gate: shown in every enabled mode, blockers only in enforce. */
67
+ gaps: string[];
68
+ /** Shown, never a blocker in any mode: the gate has no suite behind it yet (suite choice pending the person). */
69
+ notes?: string[];
70
+ }
71
+ export const EVAL_NOT_CONFIGURED = "eval check not configured: the person names one with eval_check in sdlc.toml (suite choice pending the person)";
72
+ export const EVAL_COVERAGE_UNKNOWN = "eval gate: PR file coverage incomplete or unknown";
73
+ export const evalReportedGap = (check: string) => `eval gate: pass rate or suite version not reported by ${check}`;
74
+ const reported = evalReportedGap;
75
+
76
+ /** Undefined for off mode and for a code-only PR: those outputs stay byte-identical. */
77
+ export function evalGateView(config: Pick<SdlcConfig, "configPaths" | "evalCheck" | "mode">, evidence: PrEvidence | undefined, result?: EvalResult): EvalGate | undefined {
78
+ if (config.mode === "off") return undefined;
79
+ const { applies, matched } = evalApplies(config, evidence);
80
+ if (applies === "code") return undefined;
81
+ const check = config.evalCheck;
82
+ // With no eval check named there is nothing to require: say so, and never block. Enforce users gain no new refusal until the person names a suite.
83
+ if (!check) return applies === "unknown" ? undefined : { applies, files: matched.length, state: "not_configured", gaps: [], notes: [EVAL_NOT_CONFIGURED] };
84
+ if (applies === "unknown") return { applies, files: 0, state: "coverage_unknown", check, gaps: [EVAL_COVERAGE_UNKNOWN] };
85
+ const found = evidence!.checks.filter(c => c.name === check);
86
+ const state = !found.length ? "missing" : found.some(c => c.state === "failed") ? "failed" : found.some(c => c.state === "pending") ? "pending" : found.every(c => c.state === "passed") ? "passed" : "unknown";
87
+ const view: EvalGate = { applies, files: matched.length, check, state, gaps: [] };
88
+ // The record is read only for a passed check at this head; a result read for another head or check is never shown.
89
+ if (result && result.read && result.check === check && result.head === evidence!.head) {
90
+ if (result.passRate !== undefined) view.passRate = result.passRate;
91
+ if (result.suite !== undefined) view.suite = result.suite;
92
+ }
93
+ if (state === "passed" && result && (view.passRate === undefined || view.suite === undefined)) view.gaps.push(reported(check));
94
+ return view;
95
+ }
96
+ /** Evidence gaps that block in enforce. The state of the check itself is an ordinary required-check blocker. */
97
+ export const evalGapBlockers = (view: EvalGate | undefined): string[] => view?.gaps ?? [];
98
+ /** What advise shows beside the gaps: gaps and notes, each once. */
99
+ export const evalAdvice = (view: EvalGate | undefined): string[] => [...(view?.gaps ?? []), ...(view?.notes ?? [])];
100
+
101
+ const MARK_RATE = /(?:^|\s)eval_pass_rate=(0(?:\.[0-9]{1,6})?|1(?:\.0{1,6})?)(?=\s|$)/;
102
+ const MARK_SUITE = /(?:^|\s)eval_suite=([A-Za-z0-9][A-Za-z0-9._-]{0,39})(?=\s|$)/;
103
+ const SHA = /^[a-f0-9]{40}$/;
104
+ /** Pure: the two markers, or nothing. The rest of the text is never kept. */
105
+ export function parseEvalMarkers(text: string): { passRate?: number; suite?: string } {
106
+ const rate = MARK_RATE.exec(text.slice(0, 2000))?.[1], suite = MARK_SUITE.exec(text.slice(0, 2000))?.[1];
107
+ return { ...(rate === undefined ? {} : { passRate: Number(rate) }), ...(suite === undefined ? {} : { suite }) };
108
+ }
109
+
110
+ /** Read-only, fixed argv, bounded output and time. One completed check run of that exact name at that exact head, else `read: false`. */
111
+ export async function readEvalResult(repository: string, head: string, check: string, run: SdlcGh): Promise<EvalResult> {
112
+ const none: EvalResult = { check, head, read: false };
113
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || repository.startsWith("-") || !SHA.test(head) || !check || check.length > 120 || /[\x00-\x1f]/.test(check)) return none;
114
+ try {
115
+ const reply = await run(["api", `repos/${repository}/commits/${head}/check-runs?check_name=${encodeURIComponent(check)}&per_page=10`, "--jq",
116
+ '{total_count,check_runs:[.check_runs[]|{name,status,conclusion,head_sha,text:(((.output.title // "")+" "+(.output.summary // ""))|.[0:1000])}]}'], 10_000, undefined, 32 * 1024);
117
+ if (reply.code !== 0) return none;
118
+ const parsed = JSON.parse(reply.stdout), runs = parsed?.check_runs;
119
+ if (!Array.isArray(runs) || runs.length > 10 || parsed.total_count !== runs.length) return none;
120
+ const same = runs.filter((r: { name?: string }) => r?.name === check);
121
+ const only = same[0];
122
+ if (same.length !== 1 || only.head_sha !== head || only.status !== "completed" || typeof only.text !== "string") return none;
123
+ return { check, head, read: true, ...parseEvalMarkers(only.text) };
124
+ } catch { return none; }
125
+ }
126
+
127
+ /** The read happens only where it can matter: an enabled mode, a configured eval check and a configuration PR. */
128
+ export async function readEvalFor(config: Pick<SdlcConfig, "configPaths" | "evalCheck" | "mode">, evidence: PrEvidence | undefined, repository: string | undefined, run: SdlcGh): Promise<EvalResult | undefined> {
129
+ if (!evidence || !repository || !config.evalCheck || evalRequiredChecks(config, evidence).length === 0) return undefined;
130
+ return readEvalResult(repository, evidence.head, config.evalCheck, run);
131
+ }
package/src/hub/events.ts CHANGED
@@ -53,7 +53,7 @@ export type HubEvent =
53
53
  | { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
54
54
  | { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
55
55
  | ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
56
- | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript" | "claude_result" | "codex_appserver" | "kimi_wire"; id: string; reasoningTokens?: number; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
56
+ | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript" | "claude_result" | "codex_appserver" | "kimi_wire" | "jcode_acp"; id: string; reasoningTokens?: number; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
57
57
  | { type: "planning"; by: string; task?: number; release?: number; fields: string[]; revision: number }
58
58
  | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; profile?: string; profileClass?: string; role?: string; roleIdentity?: { id: string; layer: "shipped" | "project"; hash: string }; pii: boolean; reason?: string; release?: number | null; priority?: number | null; plannedStart?: number | null; due?: number | null }
59
59
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
@@ -0,0 +1,69 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, lstatSync, mkdirSync } from "node:fs";
3
+ import { types } from "node:util";
4
+ import { atomicPrivateJSON } from "./recovery-store.ts";
5
+ import { realPath } from "./project.ts";
6
+ import { isAbsolute, join } from "node:path";
7
+ import type { ExternalModuleIdentity } from "../adapters/external-modules.ts";
8
+ import type { InstanceConfig } from "./instances.ts";
9
+
10
+ /** Portable identity only. No catalog epoch, child handle or live-owner authority survives a restart. */
11
+ export interface ExternalInstanceBinding {
12
+ readonly schema: 1;
13
+ readonly projectRoot: string;
14
+ readonly instance: string;
15
+ readonly kind: string;
16
+ readonly modulePath: string;
17
+ readonly sha256: string;
18
+ readonly api: number;
19
+ readonly capabilityDigest: string;
20
+ readonly policyDigest: string;
21
+ readonly executionCwd: string;
22
+ }
23
+ const KEYS = ["schema", "projectRoot", "instance", "kind", "modulePath", "sha256", "api", "capabilityDigest", "policyDigest", "executionCwd"];
24
+ const hash = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
25
+ const word = (value: unknown, max: number): value is string => typeof value === "string" && !!value.trim() && Buffer.byteLength(value) <= max && !/[\p{Cc}\p{Cf}]/u.test(value);
26
+ export function validExternalInstanceBinding(value: unknown, projectRoot?: string, instance?: string): value is ExternalInstanceBinding {
27
+ if (!value || typeof value !== "object" || Array.isArray(value) || types.isProxy(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return false;
28
+ if (Object.values(Object.getOwnPropertyDescriptors(value)).some(field => !Object.hasOwn(field, "value") || !field.enumerable) || Object.getOwnPropertySymbols(value).length) return false;
29
+ const row = value as Record<string, unknown>;
30
+ if (Object.keys(row).length !== KEYS.length || Object.keys(row).some(key => !KEYS.includes(key)) || row.schema !== 1 || row.api !== 1) return false;
31
+ if (!["sha256", "capabilityDigest", "policyDigest"].every(key => typeof row[key] === "string" && /^[a-f0-9]{64}$/.test(row[key] as string))) return false;
32
+ if (!["instance", "kind"].every(key => typeof row[key] === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(row[key] as string))) return false;
33
+ if (!word(row.projectRoot, 4096) || !isAbsolute(row.projectRoot) || !word(row.executionCwd, 4096) || !isAbsolute(row.executionCwd) || !word(row.modulePath, 4096) || isAbsolute(row.modulePath) || row.modulePath.split("/").some(part => part === ".." || !part)) return false;
34
+ return (projectRoot === undefined || row.projectRoot === projectRoot) && (instance === undefined || row.instance === instance);
35
+ }
36
+ export function externalInstanceBinding(identity: ExternalModuleIdentity, instance: string, declaration: InstanceConfig, roles: readonly string[], executionCwd: string): ExternalInstanceBinding {
37
+ const policyDigest = hash({ kind: declaration.kind, roles: [...roles].sort(), form: "headless", worktree: declaration.worktree, readOnly: declaration.readOnly === true, permission: declaration.permission_mode ?? null });
38
+ return Object.freeze({ schema: 1, ...identity, instance, kind: declaration.kind, policyDigest, executionCwd });
39
+ }
40
+ export function sameExternalInstanceBinding(left: ExternalInstanceBinding, right: ExternalInstanceBinding): boolean {
41
+ return KEYS.every(key => left[key as keyof ExternalInstanceBinding] === right[key as keyof ExternalInstanceBinding]);
42
+ }
43
+
44
+ /** Private host record; native ids are correlation, never liveness/ownership proof. */
45
+ export interface ExternalInstanceRecord { readonly externalInstance: ExternalInstanceBinding; readonly sessionId: string }
46
+ export function externalInstanceStateDir(stateDir: string, instance: string): string {
47
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(instance)) throw new Error("invalid external instance id");
48
+ const parent = join(realPath(stateDir), "instances"), result = join(parent, instance);
49
+ for (const directory of [parent, result]) {
50
+ try { const info = lstatSync(directory); if (!info.isDirectory() || info.isSymbolicLink() || realPath(directory) !== directory) throw new Error("external instance state namespace changed"); }
51
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; mkdirSync(directory, { mode: 0o700 }); }
52
+ const info = lstatSync(directory);
53
+ if (!info.isDirectory() || info.isSymbolicLink() || realPath(directory) !== directory) throw new Error("external instance state namespace changed");
54
+ }
55
+ return result;
56
+ }
57
+ export function readExternalInstanceRecord(stateDir: string, instance: string): ExternalInstanceRecord | undefined {
58
+ const file = join(stateDir, "instances", instance, "external-instance.json");
59
+ let raw: unknown;
60
+ try { const parent = lstatSync(join(stateDir, "instances")); if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error("external instance state namespace changed"); if (realPath(join(stateDir, "instances", instance)) !== join(realPath(stateDir), "instances", instance)) throw new Error("external instance state namespace changed"); if (!lstatSync(file).isFile() || lstatSync(file).isSymbolicLink()) throw new Error("invalid external instance record"); raw = JSON.parse(readFileSync(file, "utf8")); }
61
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw new Error("external instance record is unreadable"); }
62
+ const row = raw as Partial<ExternalInstanceRecord>;
63
+ if (!row || Object.keys(row).length !== 2 || !validExternalInstanceBinding(row.externalInstance, undefined, instance) || !word(row.sessionId, 4096)) throw new Error("invalid external instance record");
64
+ return Object.freeze({ externalInstance: Object.freeze(row.externalInstance), sessionId: row.sessionId });
65
+ }
66
+ export function writeExternalInstanceRecord(stateDir: string, binding: ExternalInstanceBinding, sessionId: string): void {
67
+ if (!validExternalInstanceBinding(binding) || !word(sessionId, 4096)) throw new Error("invalid external instance record");
68
+ atomicPrivateJSON(join(externalInstanceStateDir(stateDir, binding.instance), "external-instance.json"), { externalInstance: binding, sessionId });
69
+ }
package/src/hub/facts.ts CHANGED
@@ -7,6 +7,7 @@ import { sanitize, type PeerId } from "./envelope.ts";
7
7
  import { isDenied } from "../local/deny.ts";
8
8
  import { hubGitArgv, hubGitEnv } from "./git.ts";
9
9
  import { realPath } from "./project.ts";
10
+ import { insideRoot } from "./path-policy.ts";
10
11
 
11
12
  /**
12
13
  * Turn-free facts (issue #108): what changed in an owner's files since it last acknowledged them, as a diff with who
@@ -221,13 +222,22 @@ export class Facts {
221
222
  }
222
223
  }
223
224
  const r = relative(this.root, real);
224
- if (!r || r.startsWith("..") || isAbsolute(r) || this.o.repo?.shadow && isHubWritePath(r)) return undefined;
225
+ // Never trust the resolved name alone (#552, the #541 class): Bun's realpath can name another hardlink of the same file, so the name the
226
+ // caller gave is judged too. A refusal on either name stands.
227
+ const typed = relative(this.root, abs);
228
+ // The resolved name keeps main's original (stricter) test: a directory named `..x` is refused there, as before. Only the typed name this change adds uses the exact segment test.
229
+ if (!r || r.startsWith("..") || isAbsolute(r) || this.o.repo?.shadow && (isHubWritePath(r) || insideRoot(typed) && isHubWritePath(typed))) return undefined;
225
230
  if (this.o.repo?.shadow) {
226
- const ignored = Bun.spawnSync(this.gitArgs(["check-ignore", "--no-index", "--quiet", "--", r]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: this.gitEnv(), timeout: 3000 });
227
- if (ignored.exitCode !== 1) return undefined; // ignored or unavailable policy is not readable fact evidence
231
+ // The shadow repository's ignore policy is name-based too, so it is asked about both names: an ignored result, or one that is unavailable (a non-1 exit, a timeout), on either refuses.
232
+ const insideTyped = !!typed && insideRoot(typed);
233
+ for (const name of insideTyped && typed !== r ? [r, typed] : [r]) {
234
+ const ignored = Bun.spawnSync(this.gitArgs(["check-ignore", "--no-index", "--quiet", "--", name]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: this.gitEnv(), timeout: 3000 });
235
+ if (ignored.exitCode !== 1) return undefined; // ignored or unavailable policy is not readable fact evidence
236
+ }
228
237
  }
229
- // Never git's own files, and never what the denylist keeps from every agent.
230
- return /(^|\/)\.git(\/|$)/i.test(r) || isDenied(r, this.o.deny ?? []) ? undefined : r;
238
+ // Never git's own files, and never what the denylist keeps from every agent (the typed name counts as well when it lies inside the project).
239
+ const named = (name: string) => /(^|\/)\.git(\/|$)/i.test(name) || isDenied(name, this.o.deny ?? []);
240
+ return named(r) || insideRoot(typed) && named(typed) ? undefined : r;
231
241
  }
232
242
 
233
243
  /**
@@ -0,0 +1,223 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync, realpathSync } from "node:fs";
3
+ import { join, resolve, sep } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { isDenied } from "../local/deny.ts";
6
+ import { checkCorpusRoot } from "./corpus.ts";
7
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
8
+ import { realPath } from "./project.ts";
9
+ import { folderGlobMatches, MAX_NEW_PER_POLL, type FolderEntry, type FolderRunResult, type FolderScan, type ScannedFile } from "./schedules.ts";
10
+ import { MAX_SOURCE_BYTES, screenRegistration, type SourceRegistry } from "./sources.ts";
11
+ import { matchesPolicy } from "./path-policy.ts";
12
+
13
+ // agent-hub #512c (hub task #336): the polled folder trigger. Spec: "Schedules and folder triggers (#512)". The hub polls a folder on the schedules timer and never
14
+ // watches it. A file it has not recorded is a new file; the caller registers it as a source and proposes one task. Nothing in this file writes the board or the
15
+ // registry: it reads the disk and keeps its own table in hub.db.
16
+
17
+ /** Entries a folder may hold before it is refused: a listing is names only, but a folder this size is not an inbox. */
18
+ export const MAX_FOLDER_ENTRIES = 100_000;
19
+ /** Files one poll reads and hashes, and the bytes it reads doing so. Files already recorded at their path, size and mtime cost an `lstat` only. */
20
+ export const MAX_HASH_FILES = 200;
21
+ export const MAX_HASH_BYTES = 256 * 1024 * 1024;
22
+ const CHUNK = 64 * 1024;
23
+ export { MAX_NEW_PER_POLL };
24
+ export type { FolderRunResult, FolderScan, ScannedFile };
25
+
26
+ export interface FolderPolicy {
27
+ cwd: string;
28
+ /** `data.readonly`: declared data folders a trigger may lie inside, besides the project. Project-relative or absolute, as the config holds them. */
29
+ dataPaths: readonly string[];
30
+ /** `pii.paths`: a trigger folder under one is refused here. A file that is not under one but whose text matches a PII pattern registers as private: the caller screens it. */
31
+ piiPaths: readonly string[];
32
+ /** `local.deny`: substrings, on top of the built-in secrets denylist. */
33
+ deny: readonly string[];
34
+ }
35
+
36
+
37
+ const errno = (error: unknown): string | undefined => (error as NodeJS.ErrnoException | undefined)?.code;
38
+ const beneath = (child: string, parent: string): boolean => child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
39
+ const printable = (name: string): string => name.replace(/[\x00-\x1f\x7f]/g, "?").slice(0, 80);
40
+
41
+ /**
42
+ * Where a trigger folder may lie: inside the project, or inside a declared data folder. Both sides are `realPath`s, so a link out of either is judged by where it
43
+ * lands, and a folder that is a vault, on the denylist, or under `pii.paths` is refused by the same check the corpus uses. A reason string, never a throw.
44
+ */
45
+ export function checkTriggerFolder(entry: FolderEntry, policy: FolderPolicy): { ok: true; real: string } | { ok: false; reason: string } {
46
+ const checked = checkCorpusRoot(entry.folder, { cwd: policy.cwd, paths: [entry.folder], piiPaths: policy.piiPaths, deny: policy.deny });
47
+ if (!checked.ok) return { ok: false, reason: checked.reason.replace("a corpus", "a folder trigger").replace("corpus", "folder trigger") };
48
+ let project: string;
49
+ const allowed: string[] = [];
50
+ try {
51
+ project = realPath(policy.cwd);
52
+ allowed.push(project);
53
+ for (const data of policy.dataPaths) { try { allowed.push(realPath(resolve(project, data))); } catch { /* a data folder that is not there allows nothing */ } }
54
+ } catch { return { ok: false, reason: `${entry.folder} cannot be checked against the project` }; }
55
+ if (!allowed.some(root => beneath(checked.real, root))) return { ok: false, reason: `${entry.folder} lies outside the project and every declared data.readonly folder` };
56
+ return { ok: true, real: checked.real };
57
+ }
58
+
59
+ /**
60
+ * Read one folder, once, and keep no file's bytes. Regular files directly inside it only (no recursion: a subfolder is not a drop). A name that matches the glob and is
61
+ * not a dot file is `lstat`ed: a symlink is never read (one that stays inside the folder is skipped as a link, one that leaves as leaving). A file `known` already, by
62
+ * its real path, size and mtime, returns its recorded hash without being opened. Any other file is opened with O_NOFOLLOW, checked against its `lstat` (size and
63
+ * inode) and hashed in 64 KiB chunks from that descriptor, so memory stays one chunk wide. At most MAX_HASH_FILES files and MAX_HASH_BYTES bytes are hashed per scan;
64
+ * the rest are left for the next poll (`truncated`), and the listing is never cut, so a file late in name order is still reached once earlier ones are recorded.
65
+ * ponytail: a file whose bytes change without its size or mtime changing is not noticed; the upgrade is to hash every poll, which the budget would then have to bound.
66
+ */
67
+ export function scanFolder(entry: FolderEntry, policy: FolderPolicy, known: (real: string, size: number, mtime: number) => string | undefined = () => undefined, limits: { entries?: number } = {}): FolderScan {
68
+ const checked = checkTriggerFolder(entry, policy);
69
+ if (!checked.ok) return { files: [], skipped: [], refused: checked.reason, truncated: false };
70
+ const folder = checked.real, files: ScannedFile[] = [], skipped: { name: string; reason: string }[] = [];
71
+ let names: string[];
72
+ try { names = readdirSync(folder); } catch (error) { return { files: [], skipped: [], refused: `${entry.folder} cannot be listed (${errno(error) ?? "unreadable"})`, truncated: false }; }
73
+ const maxEntries = limits.entries ?? MAX_FOLDER_ENTRIES; // a parameter so a test can reach the refusal without a hundred thousand files
74
+ if (names.length > maxEntries) return { files: [], skipped: [], refused: `${entry.folder} holds more than ${maxEntries} entries, which is not an inbox`, truncated: false };
75
+ names.sort();
76
+ let truncated = false, hashedFiles = 0, hashedBytes = 0;
77
+ for (const name of names) {
78
+ if (!folderGlobMatches(entry.glob, name)) continue;
79
+ const path = join(folder, name);
80
+ let stat;
81
+ try { stat = lstatSync(path); } catch { skipped.push({ name: printable(name), reason: "unreadable" }); continue; }
82
+ if (stat.isSymbolicLink()) {
83
+ let target: string | undefined;
84
+ try { target = realpathSync(path); } catch { /* dangling */ }
85
+ skipped.push({ name: printable(name), reason: target !== undefined && beneath(target, folder) ? "symlink" : "symlink leaving the folder" });
86
+ continue;
87
+ }
88
+ if (!stat.isFile()) { skipped.push({ name: printable(name), reason: "not a regular file" }); continue; }
89
+ if (isDenied(path, [...policy.deny])) { skipped.push({ name: printable(name), reason: "on the denylist" }); continue; }
90
+ if (stat.size > MAX_SOURCE_BYTES) { skipped.push({ name: printable(name), reason: `over the ${MAX_SOURCE_BYTES} byte source cap` }); continue; }
91
+ const mtime = Math.floor(stat.mtimeMs);
92
+ const recorded = known(path, stat.size, mtime);
93
+ if (recorded !== undefined) { files.push({ real: path, name, size: stat.size, mtime, sha256: recorded, seen: true }); continue; }
94
+ if (hashedFiles >= MAX_HASH_FILES || hashedFiles > 0 && hashedBytes + stat.size > MAX_HASH_BYTES) { truncated = true; continue; }
95
+ const sha256 = hashFile(path, stat.size, stat.ino);
96
+ if (sha256 === undefined) { skipped.push({ name: printable(name), reason: "changed while it was read" }); continue; }
97
+ hashedFiles++; hashedBytes += stat.size;
98
+ files.push({ real: path, name, size: stat.size, mtime, sha256 });
99
+ }
100
+ return { folder, files, skipped, truncated };
101
+ }
102
+
103
+ /** The SHA-256 of a regular file, read in chunks from a descriptor opened without following a link; undefined when it changed under the read or cannot be read. */
104
+ function hashFile(path: string, size: number, ino: number): string | undefined {
105
+ let fd: number;
106
+ try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch { return undefined; }
107
+ try {
108
+ const open = fstatSync(fd);
109
+ if (!open.isFile() || open.size !== size || open.ino !== ino) return undefined;
110
+ const hash = createHash("sha256"), chunk = Buffer.alloc(Math.min(CHUNK, Math.max(size, 1)));
111
+ let total = 0;
112
+ for (;;) { const n = readSync(fd, chunk, 0, chunk.length, total); if (!n) break; hash.update(chunk.subarray(0, n)); total += n; }
113
+ return total === size ? hash.digest("hex") : undefined;
114
+ } catch { return undefined; } finally { closeSync(fd); }
115
+ }
116
+
117
+ /**
118
+ * The bytes of a scanned file, for the one file being registered now: read again from a descriptor opened without following a link, and returned only when they still
119
+ * hash to what the scan recorded. A file that changed since is undefined, so it is tried again on the next poll under its new content. At most one file is in memory.
120
+ */
121
+ export function readScanned(file: Pick<ScannedFile, "real" | "size" | "sha256">): Buffer | undefined {
122
+ let fd: number;
123
+ try { fd = openSync(file.real, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch { return undefined; }
124
+ try {
125
+ const open = fstatSync(fd);
126
+ if (!open.isFile() || open.size !== file.size || open.size > MAX_SOURCE_BYTES) return undefined;
127
+ const bytes = Buffer.alloc(open.size);
128
+ let got = 0;
129
+ while (got < bytes.length) { const n = readSync(fd, bytes, got, bytes.length - got, got); if (!n) break; got += n; }
130
+ return got === open.size && createHash("sha256").update(bytes).digest("hex") === file.sha256 ? bytes : undefined;
131
+ } catch { return undefined; } finally { closeSync(fd); }
132
+ }
133
+
134
+ /** What registering a scanned file needs from the host: the registry, the PII policy, the routing's patterns and the text projection. */
135
+ export interface FolderRegistration {
136
+ registry: Pick<SourceRegistry, "add">;
137
+ cwd: string;
138
+ piiPaths: readonly string[];
139
+ /** Read at each call: the routing can be reloaded between polls. */
140
+ piiPatterns: () => readonly string[];
141
+ /** The projected text of a file's bytes by its extension, or undefined when no converter handles it. */
142
+ project?: (extension: string, bytes: Buffer) => string | undefined;
143
+ }
144
+
145
+ /**
146
+ * Register one scanned file as a source, the same two steps `ahub source add` takes: one decision about privacy, then the registry. The scan kept no bytes, so the file is
147
+ * read again here and refused (a throw, so the file is tried again on the next poll as what it has become) when it no longer hashes to what the scan saw. A file under
148
+ * `pii.paths`, or whose text matches a PII pattern, is a private source. This is the function the daemon calls; the tests call it too.
149
+ */
150
+ export function registerScanned(file: ScannedFile, host: FolderRegistration): { id: string; private: boolean } {
151
+ const bytes = readScanned(file);
152
+ if (!bytes) throw new Error("the file changed or became unreadable before it was registered");
153
+ const extension = /\.[A-Za-z0-9]{1,16}$/.exec(file.name)?.[0]?.toLowerCase() ?? "";
154
+ const verdict = screenRegistration({ bytes, kind: "file", path: file.real }, {
155
+ piiPaths: [...host.piiPaths], piiPatterns: [...host.piiPatterns()], cwd: host.cwd, matchesPolicy,
156
+ ...(host.project ? { project: data => extension ? host.project!(extension, data) : undefined } : {}),
157
+ });
158
+ const added = host.registry.add({ bytes, kind: "file", origin: file.real, private: verdict.private, ...(verdict.text !== undefined ? { text: verdict.text } : {}) });
159
+ return { id: added.id, private: added.private };
160
+ }
161
+
162
+ export interface FolderSeenRow { trigger: string; real_path: string; size: number; mtime: number; sha256: string; task: number | null; at: number }
163
+
164
+ /**
165
+ * `folder_seen` in hub.db: what each trigger has already acted on, by real path, size, mtime and SHA-256. A file is NEW to a trigger when its content hash was not
166
+ * recorded for it. A rename keeps the hash and a touch keeps the hash too, so neither proposes again; the same bytes under another name are the same file to the
167
+ * trigger (the source registry holds one record per hash as well). A path whose content changed has a new hash, so it is a new file: different bytes.
168
+ */
169
+ export class FolderStore {
170
+ private readonly db: Database;
171
+ constructor(dbPath: string) {
172
+ this.db = new Database(dbPath, { create: true });
173
+ this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
174
+ this.db.run("CREATE TABLE IF NOT EXISTS folder_seen (trigger TEXT NOT NULL, real_path TEXT NOT NULL, size INTEGER NOT NULL, mtime INTEGER NOT NULL, sha256 TEXT NOT NULL, task INTEGER, at INTEGER NOT NULL, PRIMARY KEY (trigger, real_path, sha256))");
175
+ this.db.run("CREATE INDEX IF NOT EXISTS folder_seen_hash ON folder_seen (trigger, sha256)");
176
+ this.db.run("CREATE TABLE IF NOT EXISTS folder_poll (trigger TEXT PRIMARY KEY, started INTEGER NOT NULL, baselined INTEGER NOT NULL DEFAULT 0, last_poll INTEGER, last_error TEXT)");
177
+ }
178
+ /**
179
+ * A trigger's baseline is the files already in its folder when it starts: they are recorded and propose nothing, so adding a trigger to a full inbox does not flood the
180
+ * board. A big folder takes several polls to hash, so the baseline is complete only when a poll finished the whole folder (`markBaselined`); until then every file a poll
181
+ * reads is recorded and none proposes.
182
+ */
183
+ baselined(trigger: string): boolean {
184
+ const row = this.db.query("SELECT baselined FROM folder_poll WHERE trigger = ?").get(trigger) as { baselined: number } | null;
185
+ return row?.baselined === 1;
186
+ }
187
+ begin(trigger: string, now: number): void { this.db.query("INSERT OR IGNORE INTO folder_poll(trigger, started) VALUES(?, ?)").run(trigger, now); }
188
+ markBaselined(trigger: string, now: number): void { this.db.query("UPDATE folder_poll SET baselined = 1, last_poll = ? WHERE trigger = ?").run(now, trigger); }
189
+ /** The recorded hash of a file at this exact path, size and mtime, or undefined. Such a file need not be read again. */
190
+ known(trigger: string, real: string, size: number, mtime: number): string | undefined {
191
+ const row = this.db.query("SELECT sha256 FROM folder_seen WHERE trigger = ? AND real_path = ? AND size = ? AND mtime = ? ORDER BY at DESC LIMIT 1").get(trigger, real, size, mtime) as { sha256: string } | null;
192
+ return row?.sha256;
193
+ }
194
+ /** The last poll's time and error. A trigger that has not begun (its folder was refused every time so far) has no row, and none is made: refusing is not beginning. */
195
+ polled(trigger: string, now: number, error?: string): void { this.db.query("UPDATE folder_poll SET last_poll = ?, last_error = ? WHERE trigger = ?").run(now, error?.slice(0, 200) ?? null, trigger); }
196
+ isNew(trigger: string, sha256: string): boolean {
197
+ return !this.db.query("SELECT 1 FROM folder_seen WHERE trigger = ? AND sha256 = ? LIMIT 1").get(trigger, sha256);
198
+ }
199
+ record(trigger: string, file: Pick<ScannedFile, "real" | "size" | "mtime" | "sha256">, task: number | null, now: number): void {
200
+ this.db.query("INSERT OR REPLACE INTO folder_seen(trigger, real_path, size, mtime, sha256, task, at) VALUES(?, ?, ?, ?, ?, ?, ?)").run(trigger, file.real, file.size, file.mtime, file.sha256, task, now);
201
+ }
202
+ /**
203
+ * Record where a file whose content the trigger already knows (a rename, a touch, a copy of earlier bytes) is NOW, by path, size and mtime, so the next scan finds it with
204
+ * `known` and does not hash it again. It keeps the task the content already has and proposes nothing. Without this a pile of such files would be hashed on every poll and
205
+ * spend the whole hash budget ahead of a genuinely new file, which would then wait forever.
206
+ */
207
+ observe(trigger: string, file: Pick<ScannedFile, "real" | "size" | "mtime" | "sha256">, now: number): void {
208
+ const prior = this.db.query("SELECT task FROM folder_seen WHERE trigger = ? AND sha256 = ? AND task IS NOT NULL ORDER BY at LIMIT 1").get(trigger, file.sha256) as { task: number } | null;
209
+ this.record(trigger, file, prior?.task ?? null, now);
210
+ }
211
+ rows(trigger?: string): FolderSeenRow[] {
212
+ return (trigger === undefined ? this.db.query("SELECT * FROM folder_seen ORDER BY trigger, at, real_path").all() : this.db.query("SELECT * FROM folder_seen WHERE trigger = ? ORDER BY at, real_path").all(trigger)) as FolderSeenRow[];
213
+ }
214
+ /** A trigger removed from the config keeps no rows. */
215
+ prune(keep: readonly string[]): void {
216
+ for (const row of this.db.query("SELECT DISTINCT trigger FROM folder_seen UNION SELECT trigger FROM folder_poll").all() as { trigger: string }[]) {
217
+ if (keep.includes(row.trigger)) continue;
218
+ this.db.query("DELETE FROM folder_seen WHERE trigger = ?").run(row.trigger);
219
+ this.db.query("DELETE FROM folder_poll WHERE trigger = ?").run(row.trigger);
220
+ }
221
+ }
222
+ close(): void { try { this.db.close(); } catch { /* already closed */ } }
223
+ }
@@ -110,6 +110,7 @@ export const TASK_TOOLS: HubTool[] = [
110
110
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
111
111
  tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, findings: reviewFindings, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet; when the review request lists acceptance criteria, start an item with its id (\"AC1: what is missing\") so the verdict names it" }, coverage: { type: "object", description: "the review brief's reviewable paths you read (reviewed) and any you did not (skipped, each with a reason); in enforce an approval must account for every one", properties: { reviewed: { type: "array", items: str }, skipped: { type: "array", items: { type: "object", properties: { path: str, reason: str }, required: ["path", "reason"] } } } } }, ["id", "verdict"]),
112
112
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
113
+ tool("hub_source_read", "Read a registered source snapshot. Unicode character range, at most 32 KiB of sanitized UTF-8 text with next character offset. Private sources return only id, kind and private unless called by the on-prem worker inside a PII task. Excluded from capture.", { id: { type: "string", pattern: "^src:[0-9a-f]{16}$" }, range: { type: "object", properties: { offset: { type: "integer", minimum: 0 }, length: { type: "integer", minimum: 1 } }, required: ["offset", "length"], additionalProperties: false } }, ["id"]),
113
114
  tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
114
115
  tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
115
116
  tool("hub_memory_graph", "Read screened memory neighbourhoods, fails first. Symbol starts also include optional current-head one-hop code neighbours as derived SHA-stamped edges; unknown coverage is explicit. Up to 8 starts (<=200 UTF-8 bytes each), 1 or 2 hops; default 60/max 120 nodes, max 240 edges, whole output <=6 KB. No project argument.", { starts: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 1, maxLength: 200, description: "At most 200 UTF-8 bytes" } }, hops: { type: "integer", enum: [1, 2] }, limit: { type: "integer", minimum: 1, maximum: 120 } }, ["starts"]),
@@ -168,7 +169,7 @@ export function configuredToolContext(status: unknown, peer: string): ProjectToo
168
169
  return { startPeers, readOnly: readOnlyPeers.includes(peer) };
169
170
  }
170
171
 
171
- const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_status"]);
172
+ const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
172
173
  /** Publication restricts exposure only; server-side authority remains mandatory for every call. */
173
174
  export function projectToolList<T extends { name: string }>(tools: T[], readOnly = false): T[] {
174
175
  return readOnly ? tools.filter(tool => READ_ONLY_PROJECT_TOOLS.includes(tool.name)) : tools;
@@ -47,15 +47,19 @@ export class Inference {
47
47
  * `interactive`: a person is waiting for this one answer (`ahub ask`). It gets its own, longer clock and neither obeys
48
48
  * nor trips the backoff that protects the delivery path, so a slow answer cannot switch off triage and digests.
49
49
  */
50
- async complete(system: string, user: string, maxTokens: number, opts: { interactive?: boolean; timeoutMs?: number } = {}): Promise<string | undefined> {
50
+ async complete(system: string, user: string, maxTokens: number, opts: { interactive?: boolean; timeoutMs?: number; onCampusOnly?: boolean; allowed?: () => boolean } = {}): Promise<string | undefined> {
51
51
  if (!this.cfg.enabled || (!opts.interactive && Date.now() < this.offUntil)) return undefined;
52
+ try { if (opts.allowed?.() === false) return undefined; } catch { return undefined; }
53
+ // Pin the host-selected on-prem fixed model before any gateway await.
54
+ let pinnedModel: string | undefined;
55
+ try { pinnedModel = opts.onCampusOnly ? this.d.fixedModel() : undefined; } catch { return undefined; }
52
56
  const abort = new AbortController();
53
57
  const limit = opts.timeoutMs ?? this.d.timeoutMs ?? TIMEOUT_MS;
54
58
  const quiet = !!opts.interactive;
55
59
  let timer: ReturnType<typeof setTimeout> | undefined;
56
60
  // One clock over everything, the gateway probe included, not only the model call.
57
61
  const timeout = new Promise<"timeout">((resolve) => (timer = setTimeout(() => (abort.abort(), resolve("timeout")), limit)));
58
- const result = await Promise.race([this.run(system, user, maxTokens, abort.signal, quiet), timeout]);
62
+ const result = await Promise.race([this.run(system, user, maxTokens, abort.signal, quiet, opts.onCampusOnly, pinnedModel, opts.allowed), timeout]);
59
63
  clearTimeout(timer);
60
64
  if (result === "timeout") return quiet ? undefined : this.backOff(`no answer within ${limit / 1000} s`);
61
65
  return result;
@@ -68,14 +72,16 @@ export class Inference {
68
72
  return undefined;
69
73
  }
70
74
 
71
- private async run(system: string, user: string, maxTokens: number, signal: AbortSignal, quiet = false): Promise<string | undefined> {
75
+ private async run(system: string, user: string, maxTokens: number, signal: AbortSignal, quiet = false, onCampusOnly = false, pinnedModel?: string, allowed?: () => boolean): Promise<string | undefined> {
72
76
  try {
73
77
  // No gateway configured is the normal state of a fresh install; one that is configured but unreachable costs a
74
78
  // probe every time, so both back off.
75
79
  if (!(await this.d.omni.base())) return this.backOff("no gateway configured or reachable", quiet);
80
+ if (signal.aborted || allowed?.() === false) return undefined;
76
81
  const messages = [{ role: "system" as const, content: system }, { role: "user" as const, content: user }];
77
- const body = { model: this.d.fixedModel(), messages, max_tokens: maxTokens };
78
- const res = await this.d.omni.chat(body, { signal });
82
+ const body = { model: pinnedModel ?? this.d.fixedModel(), messages, max_tokens: maxTokens };
83
+ const res = await this.d.omni.chat(body, { signal, ...(onCampusOnly ? { onCampusOnly: true } : {}) });
84
+ if (allowed?.() === false) return undefined;
79
85
  return res.message.content?.trim() || undefined;
80
86
  } catch (e) {
81
87
  return signal.aborted ? undefined : this.backOff((e as Error).message, quiet);
@@ -127,6 +133,15 @@ export class Inference {
127
133
  return CLASSES.find((c) => c === word);
128
134
  }
129
135
 
136
+ /**
137
+ * #334: the hub model's JSON split of a person's request. The person waits for this one answer, so it is `interactive`: its own, longer clock that
138
+ * neither obeys nor trips the delivery backoff. The answer is parsed and validated by the caller (`parseIntakeSplit`), never trusted here.
139
+ */
140
+ async split(system: string, user: string, opts: { campusOnly?: boolean } = {}): Promise<string | undefined> {
141
+ // `campusOnly`: the request is PII, so transport itself refuses the Access endpoint, as the PII screen does, whatever an earlier check saw.
142
+ return this.complete(system, user, 3000, { interactive: true, timeoutMs: 60_000, ...(opts.campusOnly ? { onCampusOnly: true } : {}) });
143
+ }
144
+
130
145
  /** A closed escalation verdict, or no verdict when inference is unavailable or malformed. */
131
146
  async escalate(conversation: Conversation, turn: number): Promise<EscalationVerdict | undefined> {
132
147
  const request = buildEscalationJudgeRequest(conversation, turn);