@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
@@ -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);
@@ -2,10 +2,11 @@ import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { createProjectAdapterCatalog, defaultPeerRoles, definitionOf, type ProjectAdapterCatalog, type AdapterDefinition } from "../adapters/registry.ts";
4
4
  import { readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
5
- import { externalMetadataDefinitions } from "../adapters/external-metadata.ts";
5
+ import { externalMetadataDefinitions, moduleArray, moduleDataObject, moduleText } from "../adapters/external-metadata.ts";
6
6
  import { readControl, stateDirFor } from "./control-client.ts";
7
7
  import { conductorPeer } from "./conductor.ts";
8
8
  import { configRefusal } from "./config-trust.ts";
9
+ import { isPermissionMode } from "./permission-mode.ts";
9
10
  import { validateInstances, type InstancesConfig } from "./instances.ts";
10
11
 
11
12
  export const INSTANCE_CONFIG_FILES = ["config.json", "config.local.json"] as const;
@@ -20,13 +21,43 @@ export function readInstanceConfigLayers(cwd: string): InstanceConfigLayer[] {
20
21
  }
21
22
 
22
23
  /** Declaration authority is file provenance, never a suffix or a claim made by a native child. */
24
+ export interface DeclaredExternalInstance {
25
+ readonly id: string;
26
+ readonly kind: string;
27
+ readonly availability: "declared-unknown" | "refused";
28
+ readonly refusalCode: "catalog-unavailable" | "module-refused" | "kind-unavailable";
29
+ }
23
30
  export interface InstanceRoleConfig {
24
31
  adapter_modules?: readonly AdapterModuleDeclaration[];
25
32
  adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
26
33
  instances?: InstancesConfig;
34
+ /** Unresolved or refused module declarations, excluded from executable policy. */
35
+ declaredExternalInstances?: readonly DeclaredExternalInstance[];
27
36
  catalog: ProjectAdapterCatalog;
28
37
  ignored: string[];
29
38
  }
39
+ /** Keep valid dormant policy on disk, but never admit it through an unavailable module's identity. */
40
+ export function withoutInertInstancePolicy(raw: Record<string, unknown>, rows: readonly DeclaredExternalInstance[] = []): Record<string, unknown> {
41
+ if (!rows.length) return raw;
42
+ const ids = new Set(rows.flatMap(row => [row.id, row.kind]));
43
+ const result = { ...raw };
44
+ for (const key of ["permission_modes", "peers"] as const) {
45
+ if (raw[key] === undefined) continue;
46
+ const block = moduleDataObject(raw[key]);
47
+ result[key] = Object.fromEntries(Object.entries(block).filter(([id, value]) => {
48
+ if (!ids.has(id)) return true;
49
+ if (key === "permission_modes") {
50
+ if (!isPermissionMode(value)) throw new Error(`permission_modes.${id}: invalid dormant mode`);
51
+ } else {
52
+ const settings = moduleDataObject(value);
53
+ if (Object.keys(settings).some(field => field !== "start_mode") || settings.start_mode !== undefined && settings.start_mode !== "headless") throw new Error(`peers.${id}: invalid dormant headless setting`);
54
+ }
55
+ return false;
56
+ }));
57
+ }
58
+ return result;
59
+ }
60
+
30
61
  export function readInstanceConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd), externalDefinitions?: readonly AdapterDefinition[]): InstanceRoleConfig {
31
62
  const moduleConfig = readAdapterModuleDeclarations(cwd, layers);
32
63
  const ignored: string[] = [...moduleConfig.ignored];
@@ -47,8 +78,40 @@ export function readInstanceConfig(cwd: string, layers: readonly InstanceConfigL
47
78
  external = externalMetadataDefinitions(metadata);
48
79
  }
49
80
  const catalog = createProjectAdapterCatalog(adapters, external);
50
- const instances = validateInstances(rawInstances, catalog);
51
- return { ...(moduleConfig.modules.length ? { adapter_modules: moduleConfig.modules } : {}), ...(adapters !== undefined ? { adapters } : {}), ...(Object.keys(instances).length ? { instances } : {}), catalog, ignored };
81
+ // A descriptor on disk is display evidence only. It cannot turn module kinds into executable
82
+ // definitions or establish live policy. Only the daemon's explicit captured definitions can.
83
+ const runtimeDefinitions = new Set(externalDefinitions?.filter(definition => typeof definition.start.create === "function"));
84
+ const declaredExternalInstances: DeclaredExternalInstance[] = [];
85
+ let executableInstances = rawInstances;
86
+ if (moduleConfig.modules.length && rawInstances !== undefined) {
87
+ const entries = moduleDataObject(rawInstances);
88
+ if (Object.keys(entries).length > 64) throw new Error("instances: at most 64 extra instances are supported");
89
+ const executable: Record<string, unknown> = Object.create(null);
90
+ for (const [id, value] of Object.entries(entries)) {
91
+ const row = moduleDataObject(value), kind = row.kind;
92
+ const definition = typeof kind === "string" ? catalog.definitionOf(kind) : undefined;
93
+ const inert = !definition || catalog.externalDefinitions.includes(definition) && !runtimeDefinitions.has(definition);
94
+ if (!inert) { executable[id] = value; continue; }
95
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(id) || typeof kind !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(kind)
96
+ || !id.startsWith(`${kind}-`) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(kind.length + 1))) throw new Error("instances: invalid unresolved module instance declaration");
97
+ if (Object.keys(row).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "readOnly"].includes(key))) throw new Error("instances: unsupported external instance field");
98
+ if (Object.hasOwn(row, "roles")) {
99
+ const roles = moduleArray(row.roles, 3);
100
+ if (roles.some(role => typeof role !== "string" || !["implementer", "verifier", "reviewer"].includes(role)) || new Set(roles).size !== roles.length) throw new Error("instances: unsupported external instance role");
101
+ }
102
+ if (Object.hasOwn(row, "start_mode") && row.start_mode !== "headless") throw new Error("instances: external instances require headless mode");
103
+ if (Object.hasOwn(row, "readOnly") && typeof row.readOnly !== "boolean") throw new Error("instances: readOnly must be a boolean");
104
+ if (Object.hasOwn(row, "permission_mode") && (typeof row.permission_mode !== "string" || !["ask", "ask-when-needed", "never-ask"].includes(row.permission_mode))) throw new Error("instances: unknown permission mode");
105
+ if (row.readOnly === true && Object.hasOwn(row, "permission_mode")) throw new Error("instances: read-only instances cannot select a permission mode");
106
+ if (Object.hasOwn(row, "worktree")) moduleText(row.worktree, 4096);
107
+ declaredExternalInstances.push(Object.freeze(externalDefinitions === undefined
108
+ ? { id, kind, availability: "declared-unknown", refusalCode: "catalog-unavailable" }
109
+ : { id, kind, availability: "refused", refusalCode: "kind-unavailable" }));
110
+ }
111
+ executableInstances = executable;
112
+ }
113
+ const instances = validateInstances(executableInstances, catalog);
114
+ return { ...(moduleConfig.modules.length ? { adapter_modules: moduleConfig.modules } : {}), ...(adapters !== undefined ? { adapters } : {}), ...(Object.keys(instances).length ? { instances } : {}), ...(declaredExternalInstances.length ? { declaredExternalInstances: Object.freeze(declaredExternalInstances) } : {}), catalog, ignored };
52
115
  }
53
116
 
54
117
  /** Capture ordinary role policy and trusted instance declarations together, without loading daemon configuration. */
@@ -63,6 +126,9 @@ export function readInstanceRoleConfig(cwd: string, layers: readonly InstanceCon
63
126
  for (const [id, list] of Object.entries(raw.roles as Record<string, string[]>)) roles[id] = [...list];
64
127
  }
65
128
  }
129
+ // Unresolved rows and display-only module definitions grant no roles through id or kind aliases.
130
+ for (const row of declarationConfig.declaredExternalInstances ?? []) { delete roles[row.id]; delete roles[row.kind]; }
131
+ for (const definition of declarationConfig.catalog.externalDefinitions) delete roles[definition.kind];
66
132
  conductorPeer(roles);
67
133
  return { ...declarationConfig, roles };
68
134
  }
@@ -3,7 +3,7 @@ import { join, resolve } from "node:path";
3
3
  import type { ProjectAdapterCatalog } from "../adapters/registry.ts";
4
4
  import { hubGitSync } from "./git.ts";
5
5
  import { realPath } from "./project.ts";
6
- import { validateInstances, type InstancesConfig } from "./instances.ts";
6
+ import { isPlannerOnly, validateInstances, type InstancesConfig } from "./instances.ts";
7
7
 
8
8
  const refuse = (reason: string): never => { throw new Error(`instance execution workspace refused: ${reason}`); };
9
9
  // #225: this runs synchronously on the daemon's event loop (an extra-instance start, turn_revert), and a resolution makes up to four git
@@ -31,8 +31,12 @@ export function resolveInstanceExecutionCwd(projectRoot: string, id: string, dec
31
31
  const git = gitWithin(budgetMs, clock, run);
32
32
  const declaration = validateInstances(declarations, adapterCatalog)[id];
33
33
  if (!declaration) return refuse("extra instance is not declared");
34
+ if (declaration.worktree && adapterCatalog.definitionOf(id, declarations)?.start.daemonPort === "codex-appserver" && isPlannerOnly(roles)) return refuse("a planner-only app-server instance plans on the project checkout; worktree is not supported");
35
+ const moduleInstance = adapterCatalog.externalDefinitions.some(definition => definition.kind === declaration.kind);
34
36
  const implementer = roles.includes("implementer");
37
+ const writing = moduleInstance ? declaration.readOnly !== true : implementer;
35
38
  if (!declaration.worktree) {
39
+ if (moduleInstance) return refuse("every external instance requires its own managed worktree");
36
40
  if (implementer) return refuse("an extra implementer requires an explicit managed worktree");
37
41
  return projectRoot;
38
42
  }
@@ -56,7 +60,7 @@ export function resolveInstanceExecutionCwd(projectRoot: string, id: string, dec
56
60
  if (commonDirectory(git, root) !== commonDirectory(git, cwd)) return refuse("worktree belongs to a different Git repository");
57
61
  const top = git(cwd, ["rev-parse", "--show-toplevel"]).replace(/\n$/, "");
58
62
  if (realPath(top) !== cwd) return refuse("directory is not the registered worktree root");
59
- if (implementer && !matches[0]!.some(field => field.startsWith("branch refs/heads/") && field.length > "branch refs/heads/".length)) return refuse("an implementer worktree requires an attached branch");
63
+ if (writing && !matches[0]!.some(field => field.startsWith("branch refs/heads/") && field.length > "branch refs/heads/".length)) return refuse(moduleInstance ? "a potentially writing external instance requires an attached branch" : "an implementer worktree requires an attached branch");
60
64
  // ponytail: this read cannot bind the branch to a task or make path swaps atomic; task/start authority must recheck before effects.
61
65
  return cwd;
62
66
  }