@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
@@ -1,8 +1,10 @@
1
+ import { assertNativeProofPorts } from "../native-proof-ports.ts";
2
+ export { assertNativeProofPorts } from "../native-proof-ports.ts";
1
3
  import { expect, test } from "bun:test";
2
4
  import type { Envelope } from "../../hub/envelope.ts";
3
5
  import { newEnvelope } from "../../hub/envelope.ts";
4
6
  import type { PeerAdapter } from "../../hub/peers.ts";
5
- import { isFixedBuiltinDefinition, type AdapterDefinition } from "../registry.ts";
7
+ import { type AdapterDefinition } from "../registry.ts";
6
8
 
7
9
  export async function until(condition: () => boolean, label: string): Promise<void> {
8
10
  const deadline = Date.now() + 5000;
@@ -33,14 +35,6 @@ export function assertAcpProductPorts(definition: AdapterDefinition | undefined,
33
35
  if (product.form !== "headless") throw new Error(`${definition.kind}: ACP restrictive port form must be headless`);
34
36
  }
35
37
 
36
- /** Presence itself is forbidden on a foreign product: even an undefined native proof port must fail the kit. */
37
- export function assertNativeProofPorts(definition: AdapterDefinition | undefined, product: PeerAdapter): void {
38
- if (isFixedBuiltinDefinition(definition)) return;
39
- for (const port of ["frontendProxy", "statusNotice", "revertTurn"] as const) {
40
- if (port in product) throw new Error(`${definition?.kind ?? product.id}: ${port} is restricted to fixed builtin adapter products`);
41
- }
42
- }
43
-
44
38
  /** Shared observable contract; every invocation uses the real adapter and its fake native transport. */
45
39
  export function registerConformance(adapter: AdapterConformance): void {
46
40
  test(`P4 ${adapter.kind}: transport delivery, synchronous occupancy and owned teardown`, async () => {
@@ -9,7 +9,6 @@ import { previewCodex } from "./native-cli-preview-codex.ts";
9
9
  import { previewKimi } from "./native-cli-preview-kimi.ts";
10
10
  import { previewPi } from "./native-cli-preview-pi.ts";
11
11
  import { previewJcode } from "./native-cli-preview-jcode.ts";
12
- import type { PermissionMode } from "../../hub/permission-mode.ts";
13
12
  import { claudeReviewerBackend, CODEX_REVIEWER_VARIANT, KIMI_REVIEWER_VARIANT } from "./reviewer-variants.ts";
14
13
  import { kimiAcpCommand } from "./native-daemon-kimi.ts";
15
14
  import { claudeRecoveryLaunch, kimiRecoveryLaunch, claudeRetainedTerminalNotice, piRetainedTerminalNotice, codexRetainedTerminalNotice, codexRosterReturnNotice, jcodeStartPreflight, claudeObservedVersion, codexObservedVersion } from "./native-daemon-policy.ts";
@@ -23,31 +22,9 @@ import { createCodex, createKimi, createJcode, createPi, createLocal, createClau
23
22
  import { CLAUDE_DISPLAY, CODEX_DISPLAY, KIMI_DISPLAY, PI_DISPLAY, LOCAL_DISPLAY, JCODE_DISPLAY } from "./native-display.ts";
24
23
  import { CLAUDE_RECOVERY_SESSION, CODEX_RECOVERY_SESSION, KIMI_RECOVERY_SESSION, PI_RECOVERY_SESSION, LOCAL_RECOVERY_SESSION, JCODE_RECOVERY_SESSION } from "./native-recovery.ts";
25
24
  import { claudeWindows, codexWindows } from "./native-quota.ts";
26
- import { claudeContext, codexContext, acpContextValue } from "./native-context.ts";
27
- import type { AdapterDefinition, LaunchContext, LaunchPlan, StartForm, NativeModeSpec } from "../registry-types.ts";
25
+ import { claudeContext, codexContext, acpContextValue, jcodeContextValue } from "./native-context.ts";
26
+ import type { AdapterDefinition } from "../registry-types.ts";
28
27
  import type { PeerAdapter } from "../../hub/peers.ts";
29
- import { shellQuote } from "../../cli/shell-quote.ts";
30
-
31
- type CompatibilityKind = "claude" | "codex" | "kimi" | "pi" | "local" | "jcode";
32
-
33
- /**
34
- * Preserve CLI-wrapper argv plans only for the frozen A1 compatibility export.
35
- * Native startup and daemon factories are supplied separately by the runtime definitions.
36
- */
37
- function compatibilityLaunch(kind: CompatibilityKind, ctx: LaunchContext, explicitMode = false): LaunchPlan {
38
- const mode = explicitMode || kind === "kimi" || kind === "pi" ? ["--mode", ctx.form] : [];
39
- const unattended = ctx.unattended && (kind === "claude" || kind === "codex") ? ["--unattended"] : [];
40
- const args = [ctx.entrypoint, "--project", ctx.projectRoot, kind, ...mode, ...unattended,
41
- ...(ctx.model && !ctx.userArgs.includes("--model") ? ["--model", ctx.model] : []), ...ctx.userArgs];
42
- const argv = [process.execPath, ...args];
43
- const env = {
44
- ...(ctx.env.CODEX_HOME !== undefined ? { CODEX_HOME: ctx.env.CODEX_HOME } : {}),
45
- ...(ctx.env.CLAUDE_CONFIG_DIR !== undefined ? { CLAUDE_CONFIG_DIR: ctx.env.CLAUDE_CONFIG_DIR } : {}),
46
- };
47
- const prefix = Object.entries(env).map(([name, value]) => `${name}=${shellQuote(value)}`);
48
- return { cmd: process.execPath, args, ...(unattended.length ? { unattended: true } : {}),
49
- metadata: { packageEntrypoint: ctx.entrypoint, argv, env, command: [...prefix, ...argv.map(shellQuote)].join(" ") } };
50
- }
51
28
 
52
29
  function sessionId(field: "sessionId" | "threadId"): (peer: PeerAdapter) => string | undefined {
53
30
  return peer => {
@@ -175,96 +152,8 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
175
152
  identity: { mcp: "project-file" }, instances: { mode: "headless", nativeHome: true },
176
153
  permission: { unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
177
154
  ask: "no verified hub mode mapping; native policy only", "ask-when-needed": "no verified hub mode mapping; native policy only", "never-ask": "no verified hub mode mapping; native policy only" } },
178
- session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode" },
155
+ session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode", context: { source: "jcode_usage_update", read: jcodeContextValue } },
179
156
  // Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
180
157
  cli: { command: "jcode", entry: { lifecycleAdmission: true, execute: runJcodeEntry }, preview: previewJcode, usage: [["ahub jcode [--mode tui|headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>] [--print-command]", "optional Jcode (roster.jcode, default off): its TUI on the hub-owned ACP session, opened once that session is saved; --mode headless for ACP only; native policy unmanaged, no hub sandbox"]], authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords: mode => ["jcode", "--mode", mode] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
181
158
  },
182
159
  ] satisfies AdapterDefinition[]);
183
-
184
- // Frozen A1 metadata is a compatibility snapshot, not the runtime definition contract.
185
- export type LegacyAdapterDefinition = AdapterDefinition & {
186
- init?: { templateFragment?: string; mcpFile?: string };
187
- start: AdapterDefinition["start"] & { ownsTerminal(form: StartForm): boolean; launch(ctx: LaunchContext): LaunchPlan };
188
- permission: AdapterDefinition["permission"] & { modes: Partial<Record<PermissionMode, NativeModeSpec>>; readOnly?: NativeModeSpec };
189
- };
190
- const terminal = (form: StartForm) => form === "tui";
191
- const headless = () => false;
192
- /**
193
- * ponytail: retain the released A1 metadata export for source/library compatibility until A4 retires it.
194
- * Core runtime consumers use NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN through the registry exclusively.
195
- * The frozen jcode entry stays byte-identical: its headless `launch` emits `jcode` without `--mode`, which the CLI now
196
- * resolves to the TUI (#464, #559). Callers of this export must pass `--mode headless` themselves.
197
- */
198
- const palette = (fg: string) => ({ palette: { fg, bg: "transparent" } });
199
- export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly LegacyAdapterDefinition[] = Object.freeze([
200
- {
201
- api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
202
- roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
203
- start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("claude", ctx), conductorStartable: true,
204
- unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]) },
205
- identity: { mcp: "channel-plugin" },
206
- permission: { modes: {}, grants: { ask: "Claude's own permission rules decide",
207
- "ask-when-needed": "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules",
208
- "never-ask": "every tool runs without asking" } },
209
- session: { idField: "sessionId", currentId: sessionId("sessionId"), configDirEnv: "CLAUDE_CONFIG_DIR", zeroTurnFresh: true, launchKind: "claude-channel", launchRecord: true },
210
- turnEnd: "native-hook", budget: { account: "claude", checkpoint: true },
211
- cli: { command: "claude", startWords: () => ["claude"] }, display: palette("#6495ed"), hooks: { recallPlatform: "claude" },
212
- },
213
- {
214
- api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
215
- roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
216
- start: { forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("codex", ctx), conductorStartable: true,
217
- unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--remote-auth-token-env", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
218
- identity: { mcp: "env" },
219
- permission: { modes: { ask: { kind: "native-only", note: "restore the session's captured native approval policy" },
220
- "ask-when-needed": { kind: "codex-approval", policy: "on-request" }, "never-ask": { kind: "codex-approval", policy: "never" } },
221
- grants: { ask: "the session's own approval policy applies again", "ask-when-needed": "approval policy on-request on every turn", "never-ask": "approval policy never on every turn: nothing asks" } },
222
- session: { idField: "threadId", currentId: sessionId("threadId"), configDirEnv: "CODEX_HOME", zeroTurnFresh: true, launchKind: "codex", launchRecord: true },
223
- turnEnd: "adapter", budget: { account: "codex", checkpoint: true },
224
- cli: { command: "codex", startWords: () => ["codex"] }, display: palette("#22b8cf"), hooks: { recallPlatform: "codex" },
225
- },
226
- {
227
- api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
228
- roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
229
- start: { forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("kimi", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
230
- // ACP sessions carry headless identity; the existing CLI alone owns the TUI's project MCP entry.
231
- identity: { mcp: "acp-session" }, attach: { pullOnlyForms: Object.freeze(["tui"] as const) },
232
- permission: { modes: { ask: { kind: "acp-mode", modeId: "default" }, "ask-when-needed": { kind: "acp-mode", modeId: "yolo" }, "never-ask": { kind: "acp-mode", modeId: "auto" } },
233
- grants: { ask: "Kimi asks as it does on its own", "ask-when-needed": "Kimi's yolo mode: it asks only where it judges it must", "never-ask": "Kimi's auto mode: nothing asks" },
234
- unmanagedForms: Object.freeze(["tui"] as const), unmanagedNote: "Kimi approves tools and selects its permission mode in its own TUI; no hub permission mode" },
235
- session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp" },
236
- turnEnd: "adapter", budget: { account: "kimi" }, capabilities: { watch: true }, init: { mcpFile: ".kimi-code/mcp.json" },
237
- cli: { command: "kimi", startWords: form => ["kimi", "--mode", form] }, display: palette("#bc73ca"),
238
- },
239
- {
240
- api: 1, kind: "pi", displayName: "Pi", family: "pi", transport: "hub-native",
241
- roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
242
- start: { forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("pi", ctx), conductorStartable: true, autoStart: "pi.auto_start", hubSwitches: [], ownedFlags: [] },
243
- identity: { mcp: "env" },
244
- permission: { modes: {}, grants: { ...hubGrants } },
245
- session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "pi", launchRecord: true },
246
- turnEnd: "adapter", budget: { account: "pi", noQuotaReason: "on-prem backends have no provider quota" },
247
- cli: { command: "pi", startWords: form => ["pi", "--mode", form] }, display: palette("#c79936"),
248
- },
249
- {
250
- api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
251
- roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
252
- start: { forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("local", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
253
- identity: { mcp: "env" },
254
- permission: { modes: {}, grants: { ...hubGrants } },
255
- session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local" },
256
- turnEnd: "adapter",
257
- cli: { command: "local", startWords: () => ["local"] }, display: palette("#4caa77"),
258
- },
259
- {
260
- api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
261
- roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
262
- start: { forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("jcode", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
263
- identity: { mcp: "project-file" },
264
- permission: { modes: {}, unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
265
- ask: "no verified hub mode mapping; native policy only", "ask-when-needed": "no verified hub mode mapping; native policy only", "never-ask": "no verified hub mode mapping; native policy only" } },
266
- session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode" }, turnEnd: "adapter", budget: { account: "jcode" },
267
- // Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
268
- cli: { command: "jcode", startWords: () => ["jcode"] }, display: palette("#8a8f99"),
269
- },
270
- ] satisfies LegacyAdapterDefinition[]);
@@ -9,7 +9,7 @@ export function nativeConfigurationDefaults(ctx: { home: string; env: Readonly<R
9
9
  commands: { kimi_cmd: ["kimi", "acp"], qwen_cmd: ["qwen", "--acp"], jcode_cmd: ["jcode"], jcode_home: ctx.env.JCODE_HOME ?? join(ctx.home, ".jcode"), jcode_provider_profile: "" },
10
10
  proxy: { codex_bin: "codex" },
11
11
  rpc: { pi: { enabled: false, auto_start: false, cmd: ["pi"], backend: "auto" as const, dgx_coding: "coding", dgx_fast: "fast", max_steps: 30 } },
12
- sandbox: { local: { deny: [] as string[], bash_network: false, network_allow: ctx.networkAllow, max_steps: 30, read_allow: [] as string[] } },
12
+ sandbox: { local: { deny: [] as string[], bash_network: false, network_allow: ctx.networkAllow, max_steps: 30, read_allow: [] as string[], doc_read: false, web_fetch: { allow: [] as string[] } } },
13
13
  };
14
14
  }
15
15
  export function mergeNativeConfiguration(file: Record<string, any>, defaults: HubConfig): Pick<HubConfig, "local" | "pi"> {
@@ -1,12 +1,13 @@
1
1
  import type { NativeConfigCheck, NativeConfigValidators } from "../registry-types.ts";
2
2
  import type { SettingDef } from "../../hub/settings.ts";
3
+ import { webFetchAllow } from "../../local/web-fetch-config.ts";
3
4
 
4
5
  /** Native config uses the host's checked schema vocabulary, retaining its shape/path introspection. */
5
6
  export function piConfigSchema(v: NativeConfigValidators): Record<string, NativeConfigCheck> {
6
7
  return { pi: v.block({ enabled: v.bool, auto_start: v.bool, cmd: v.argv, backend: v.words(["auto", "dgx", "mlx"]), dgx_coding: v.text, dgx_fast: v.text, max_steps: v.number(1, 1_000_000, true) }) };
7
8
  }
8
9
  export function localConfigSchema(v: NativeConfigValidators): Record<string, NativeConfigCheck> {
9
- return { local: v.block({ deny: v.strings, bash_network: (value, key) => { if (value !== "direct") v.bool(value, key); }, network_allow: v.strings, max_steps: v.number(1, 1_000_000, true), read_allow: v.strings }) };
10
+ return { local: v.block({ deny: v.strings, bash_network: (value, key) => { if (value !== "direct") v.bool(value, key); }, network_allow: v.strings, max_steps: v.number(1, 1_000_000, true), read_allow: v.strings, doc_read: v.bool, web_fetch: v.block({ allow: (value, key) => { webFetchAllow(value); void key; } }) }) };
10
11
  }
11
12
  export function piRoutingSchema(v: NativeConfigValidators): Record<string, NativeConfigCheck> {
12
13
  return { pi: v.block({ dgx_max_context_tokens: v.number(), mlx_max_context_tokens: v.number(), efficient_wait_ms: v.number(0, 119999) }) };
@@ -24,7 +25,7 @@ export const PI_RESTART_REASONS: Readonly<Record<string, string>> = {
24
25
  };
25
26
  export const LOCAL_RESTART_REASONS: Readonly<Record<string, string>> = {
26
27
  "local.deny": "Existing native sessions retain their sandbox deny policy", "local.read_allow": "Existing native sessions retain their sandbox read grants",
27
- "local.bash_network": "Existing sandbox and egress proxy contracts require a new instance", "local.network_allow": "Existing egress proxy ownership requires a new instance",
28
+ "local.bash_network": "Existing sandbox and egress proxy contracts require a new instance", "local.network_allow": "Existing egress proxy ownership requires a new instance", "local.doc_read": "The worker's tool list is bound when its session starts", "local.web_fetch": "The worker's tool list and its web egress proxy are bound when the hub starts",
28
29
  };
29
30
  export function piEffectiveConfig(raw: Record<string, unknown>, fail: (key: string, reason: string) => never): void {
30
31
  const object = (value: unknown, key: string): Record<string, unknown> => {
@@ -33,3 +33,15 @@ export function acpContextValue(value: unknown, sessionId: string, measuredAt: n
33
33
  const raw = object(value);
34
34
  return acpContext({ used: raw.used as number, size: raw.size as number }, sessionId, measuredAt);
35
35
  }
36
+
37
+ /**
38
+ * Jcode 0.99.1's ACP `usage_update` is `{ used, size }` (docs/verification/2026-10-28-jcode-0.99.1-usage.md): `used` is the context the last provider call held (its input, and the
39
+ * cached part where the provider reports it apart), `size` the model's context limit. It is occupancy of the live session, so it is a reading, never consumption; it says nothing
40
+ * of quota. Anything but two finite integers with a positive size is unknown, and a `used` above `size` clamps the fraction only (tokens keeps what was reported).
41
+ */
42
+ export function jcodeContextValue(value: unknown, sessionId: string, measuredAt: number): ContextReading {
43
+ const raw = object(value);
44
+ const okay = Number.isSafeInteger(raw.used) && (raw.used as number) >= 0 && Number.isSafeInteger(raw.size) && (raw.size as number) > 0;
45
+ const tokens = okay ? raw.used as number : null, window = okay ? raw.size as number : null;
46
+ return { source: "jcode_usage_update", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
47
+ }
@@ -46,3 +46,51 @@ export function externalMetadataDefinitions(raw: unknown): readonly AdapterDefin
46
46
  cli: { command: kind, startWords: () => [kind] }, display: { palette: { fg: "white", bg: "black" } },
47
47
  })));
48
48
  }
49
+
50
+ export type ExternalInstanceRole = "implementer" | "verifier" | "reviewer";
51
+ export type ExternalInstanceRefusal = "catalog-unavailable" | "module-refused" | "kind-unavailable" | "binding-changed" | "ownership-unverified" | "off-roster" | "not-started";
52
+ export interface ExternalInstanceMetadata {
53
+ readonly id: string;
54
+ readonly kind: string;
55
+ readonly displayName: string;
56
+ readonly roles: readonly ExternalInstanceRole[];
57
+ readonly form: "headless";
58
+ readonly readOnly: boolean;
59
+ readonly permissionState: "ask" | "ask-when-needed" | "never-ask" | "unmanaged" | "unknown";
60
+ readonly availability: "ready" | "offline" | "declared-unknown" | "refused";
61
+ readonly refusalCode?: ExternalInstanceRefusal;
62
+ }
63
+ /** An authenticated observation, never an executable definition or runtime admission receipt. */
64
+ export interface ExternalInstanceCatalog {
65
+ readonly schema: 1;
66
+ readonly projectRoot: string;
67
+ readonly epoch: string;
68
+ readonly fingerprint: string;
69
+ readonly instances: readonly ExternalInstanceMetadata[];
70
+ }
71
+ const INSTANCE_ID = /^[a-z][a-z0-9-]{0,31}$/;
72
+ const INSTANCE_ROLES: readonly string[] = ["implementer", "verifier", "reviewer"];
73
+ const INSTANCE_REFUSALS: readonly string[] = ["catalog-unavailable", "module-refused", "kind-unavailable", "binding-changed", "ownership-unverified", "off-roster", "not-started"];
74
+ /** Validate the whole bounded projection; an incomplete or widened row is never silently accepted. */
75
+ export function externalInstanceCatalog(raw: unknown): ExternalInstanceCatalog {
76
+ const catalog = moduleDataObject(raw);
77
+ if (Object.keys(catalog).some(key => !["schema", "projectRoot", "epoch", "fingerprint", "instances"].includes(key)) || catalog.schema !== 1
78
+ || typeof catalog.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(catalog.fingerprint)) return fail("invalid instance catalog metadata");
79
+ const instances = moduleArray(catalog.instances, 64).map(value => {
80
+ const row = moduleDataObject(value);
81
+ if (Object.keys(row).some(key => !["id", "kind", "displayName", "roles", "form", "readOnly", "permissionState", "availability", "refusalCode"].includes(key))
82
+ || typeof row.id !== "string" || !INSTANCE_ID.test(row.id) || typeof row.kind !== "string" || !INSTANCE_ID.test(row.kind)
83
+ || row.form !== "headless" || typeof row.readOnly !== "boolean"
84
+ || typeof row.permissionState !== "string" || !["ask", "ask-when-needed", "never-ask", "unmanaged", "unknown"].includes(row.permissionState)
85
+ || typeof row.availability !== "string" || !["ready", "offline", "declared-unknown", "refused"].includes(row.availability)
86
+ || Object.hasOwn(row, "refusalCode") && (typeof row.refusalCode !== "string" || !INSTANCE_REFUSALS.includes(row.refusalCode))) return fail("invalid instance display metadata");
87
+ const roles = moduleArray(row.roles, 3);
88
+ if (roles.some(role => typeof role !== "string" || !INSTANCE_ROLES.includes(role)) || new Set(roles).size !== roles.length) return fail("invalid instance role metadata");
89
+ return Object.freeze({ id: row.id, kind: row.kind, displayName: moduleText(row.displayName, 256), roles: Object.freeze(roles as ExternalInstanceRole[]),
90
+ form: "headless" as const, readOnly: row.readOnly, permissionState: row.permissionState as ExternalInstanceMetadata["permissionState"],
91
+ availability: row.availability as ExternalInstanceMetadata["availability"], ...(row.refusalCode !== undefined ? { refusalCode: row.refusalCode as ExternalInstanceRefusal } : {}) });
92
+ });
93
+ if (new Set(instances.map(row => row.id)).size !== instances.length) return fail("duplicate external instance metadata");
94
+ return Object.freeze({ schema: 1, projectRoot: moduleText(catalog.projectRoot, 4096), epoch: moduleText(catalog.epoch, 128),
95
+ fingerprint: catalog.fingerprint, instances: Object.freeze(instances) });
96
+ }
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { constants, closeSync, fstatSync, lstatSync, openSync, readSync } from "node:fs";
2
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readFileSync, readSync } from "node:fs";
3
3
  import { isBuiltin } from "node:module";
4
4
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { moduleDataObject as dataObject, moduleText as text, moduleArray as array } from "./external-metadata.ts";
@@ -18,6 +18,38 @@ const ADAPTER_IMPORT = "@staix/agent-hub/adapter";
18
18
  class ExternalAdapterRefusal extends Error {}
19
19
  const fail = (reason: string): never => { throw new ExternalAdapterRefusal(`external adapter: ${reason}`); };
20
20
  const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
21
+ export interface ExternalModuleIdentity {
22
+ readonly projectRoot: string;
23
+ readonly modulePath: string;
24
+ readonly sha256: string;
25
+ readonly api: 1;
26
+ readonly capabilityDigest: string;
27
+ }
28
+ const capturedIdentities = new WeakMap<AdapterDefinition, ExternalModuleIdentity>();
29
+ /** Only the verified startup loader can establish this provenance; display metadata and caller copies cannot. */
30
+ export function externalModuleIdentity(definition: AdapterDefinition): ExternalModuleIdentity | undefined {
31
+ return capturedIdentities.get(definition);
32
+ }
33
+ /** Revalidate captured provenance synchronously without evaluating module code or trusting definition getters. */
34
+ export function externalModuleIdentityCurrent(definition: AdapterDefinition): boolean {
35
+ const identity = capturedIdentities.get(definition);
36
+ if (!identity) return false;
37
+ try {
38
+ const root = identity.projectRoot, file = join(root, ".agenthub", "config.local.json");
39
+ if (realPath(root) !== root || configRefusal(root, "config.local.json")) return false;
40
+ const local = regularPath(root, file);
41
+ if ((local.mode & 0o002) !== 0) return false;
42
+ const source = readFileSync(file, "utf8");
43
+ const raw = dataObject(JSON.parse(source));
44
+ const { modules } = readAdapterModuleDeclarations(root, [{ name: "config.local.json", raw }]);
45
+ const matching = modules.filter(module => relative(root, resolve(root, module.path)).split(sep).join("/") === identity.modulePath);
46
+ if (matching.length !== 1 || matching[0]!.sha256 !== identity.sha256) return false;
47
+ captureBundle(root, matching[0]!);
48
+ const current = regularPath(root, file);
49
+ return current.dev === local.dev && current.ino === local.ino && current.mtimeMs === local.mtimeMs && current.ctimeMs === local.ctimeMs
50
+ && (current.mode & 0o002) === 0 && readFileSync(file, "utf8") === source && !configRefusal(root, "config.local.json");
51
+ } catch { return false; }
52
+ }
21
53
  export function adapterModuleDeclarations(raw: unknown): readonly AdapterModuleDeclaration[] {
22
54
  if (raw === undefined) return Object.freeze([]);
23
55
  return Object.freeze(array(raw, MAX_MODULES).map(value => {
@@ -113,6 +145,29 @@ function only(value: unknown, keys: readonly string[]): Record<string, unknown>
113
145
  if (Object.keys(fields).some(key => !keys.includes(key))) return fail("field is outside the supported external definition subset");
114
146
  return fields;
115
147
  }
148
+ function headlessInstancePolicy(definition: AdapterDefinition): AdapterDefinition["headlessInstances"] {
149
+ if (!Object.hasOwn(definition, "headlessInstances")) return undefined;
150
+ const policy = only(definition.headlessInstances, ["form", "roles"]);
151
+ const roles = array(policy.roles, 3);
152
+ if (policy.form !== "headless" || !roles.length || new Set(roles).size !== roles.length
153
+ || roles.some(role => typeof role !== "string" || !["implementer", "verifier", "reviewer"].includes(role) || !definition.roles.includes(role))) return fail("headless instance roles must fit the module role ceiling");
154
+ return Object.freeze({ form: "headless", roles: Object.freeze(roles as ("implementer" | "verifier" | "reviewer")[]) });
155
+ }
156
+
157
+ /** Canonical own-data serialization: never stringify callbacks or module-controlled toJSON methods. */
158
+ function capabilityDigest(definition: AdapterDefinition): string {
159
+ const canonical = (value: unknown): unknown => Array.isArray(value) ? value.map(canonical)
160
+ : value && typeof value === "object" ? Object.fromEntries(Object.entries(dataObject(value)).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, member]) => [key, canonical(member)])) : value;
161
+ const ceiling = {
162
+ api: definition.api, kind: definition.kind, family: definition.family, transport: definition.transport,
163
+ roles: [...definition.roles].sort(), trust: definition.trust,
164
+ headlessInstances: definition.headlessInstances ? { form: "headless", roles: [...definition.headlessInstances.roles].sort() } : null,
165
+ start: { forms: definition.start.forms, defaultForm: definition.start.defaultForm, daemonPort: definition.start.daemonPort, conductorStartable: definition.start.conductorStartable },
166
+ permission: definition.permission, identity: definition.identity, turnEnd: definition.turnEnd,
167
+ session: { idField: definition.session.idField, resumeById: definition.session.resumeById, launchKind: definition.session.launchKind },
168
+ };
169
+ return digest(new TextEncoder().encode(JSON.stringify(canonical(ceiling))));
170
+ }
116
171
  export function validateExternalDefinition(raw: unknown): AdapterDefinition {
117
172
  const definition = snapshot(raw) as AdapterDefinition;
118
173
  if (definition.api !== ADAPTER_API) return fail("adapter API mismatch");
@@ -123,9 +178,10 @@ export function validateExternalDefinition(raw: unknown): AdapterDefinition {
123
178
  if (definition.family !== definition.kind || definition.trust?.onPrem !== false || definition.trust.hubSandboxed !== false
124
179
  || definition.defaultOnRoster !== false || definition.optional !== true) return fail("external kinds must retain their own family and optional off-roster cloud trust");
125
180
  if (!Array.isArray(definition.roles) || !definition.roles.length || definition.roles.some(role => !["planner", "implementer", "verifier", "reviewer"].includes(role)) || new Set(definition.roles).size !== definition.roles.length) return fail("unsupported default roles");
126
- if (definition.instances !== undefined) return fail("external adapter instances are unsupported in A4");
181
+ if (Object.hasOwn(definition, "instances")) return fail("external builtin instance policy is unsupported; use headlessInstances");
182
+ const instancePolicy = headlessInstancePolicy(definition);
127
183
  try { assertNativeCapabilityScope(definition); } catch { return fail("fixed-builtin capability refused"); }
128
- only(definition, ["api", "kind", "family", "displayName", "transport", "roles", "optional", "defaultOnRoster", "trust", "start", "identity", "permission", "session", "turnEnd", "cli", "display"]);
184
+ only(definition, ["api", "kind", "family", "displayName", "transport", "roles", "optional", "defaultOnRoster", "trust", "start", "identity", "permission", "session", "turnEnd", "cli", "display", "headlessInstances"]);
129
185
  only(definition.trust, ["onPrem", "hubSandboxed"]);
130
186
  const start = only(definition.start, ["forms", "defaultForm", "daemonPort", "launch", "create", "preflight", "version", "conductorStartable", "hubSwitches", "ownedFlags", "exclusiveStartRefusal"]);
131
187
  if (definition.transport !== "acp" || definition.turnEnd !== "adapter" || definition.start.daemonPort !== "acp"
@@ -159,6 +215,7 @@ export function validateExternalDefinition(raw: unknown): AdapterDefinition {
159
215
  try { return callback(); } catch { return fail(`${name} refused (details withheld)`); }
160
216
  };
161
217
  return snapshot({ ...definition,
218
+ ...(instancePolicy ? { headlessInstances: instancePolicy, instances: { mode: "headless", roles: instancePolicy.roles } } : {}),
162
219
  start: { ...definition.start,
163
220
  create: (context: Parameters<NonNullable<AdapterDefinition["start"]["create"]>>[0]) => invoke("factory", () => definition.start.create!(context)),
164
221
  launch: (context: Parameters<NonNullable<AdapterDefinition["start"]["launch"]>>[0]): LaunchPlan => invoke("launch", () => {
@@ -216,6 +273,7 @@ export async function loadExternalAdapterDefinitions(cwd: string, layers: readon
216
273
  const definitions: AdapterDefinition[] = [];
217
274
  for (const [index, declaration] of modules.entries()) {
218
275
  try {
276
+ const projectRoot = realPath(cwd);
219
277
  if (configRefusal(cwd, "config.local.json")) return fail("module configuration provenance changed");
220
278
  const local = regularPath(realPath(cwd), join(realPath(cwd), ".agenthub", "config.local.json"));
221
279
  if ((local.mode & 0o002) !== 0) return fail("world-writable module configuration refused");
@@ -228,6 +286,8 @@ export async function loadExternalAdapterDefinitions(cwd: string, layers: readon
228
286
  const definition = validateExternalDefinition((imported as { definition?: unknown }).definition);
229
287
  if ([...existingKinds].some(kind => definition.kind === kind || definition.kind.startsWith(`${kind}-`))) return fail("kind collides with a configured adapter namespace");
230
288
  if (definitions.some(item => item.kind === definition.kind)) return fail("duplicate external kind");
289
+ capturedIdentities.set(definition, Object.freeze({ projectRoot, modulePath: relative(projectRoot, resolve(projectRoot, declaration.path)).split(sep).join("/"),
290
+ sha256: declaration.sha256, api: ADAPTER_API, capabilityDigest: capabilityDigest(definition) }));
231
291
  definitions.push(definition);
232
292
  } catch (error) {
233
293
  const refusal = error instanceof ExternalAdapterRefusal ? error : new ExternalAdapterRefusal("external adapter: module could not be verified or loaded (details withheld)");
@@ -5,7 +5,7 @@ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdtempSync, op
5
5
  import { tmpdir } from "node:os";
6
6
  import { isAbsolute, join } from "node:path";
7
7
  import type { Readable } from "node:stream";
8
- import { renderDigest, type Envelope, type PeerState } from "../hub/envelope.ts";
8
+ import { JCODE_DEFERRED_TOOLS_INSTRUCTION, renderDigest, type Envelope, type PeerState } from "../hub/envelope.ts";
9
9
  import { AcpPeer, type AcpOptions } from "./acp.ts";
10
10
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
11
11
 
@@ -25,6 +25,10 @@ export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: s
25
25
  return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(provider ? ["-p", provider] : []), ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
26
26
  ...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name", `agent-hub-${peer}`] : ["--mcp-tools", "eager"])];
27
27
  }
28
+ /** The session preamble of a Jcode peer: the deferred-tools guidance (#912), then the hub's role contract when there is one. Sent once, with the session's first prompt. */
29
+ export function jcodePreamble(roleContract?: string): string {
30
+ return roleContract ? `${JCODE_DEFERRED_TOOLS_INSTRUCTION}\n\n${roleContract}` : JCODE_DEFERRED_TOOLS_INSTRUCTION;
31
+ }
28
32
  export interface JcodeTuiBinding { launchId: string; sessionId: string }
29
33
  export type JcodeTuiState = { state: "waiting"; binding: JcodeTuiBinding } |
30
34
  { state: "ready"; binding: JcodeTuiBinding; launch: { cmd: string; args: string[]; env: NodeJS.ProcessEnv } };
@@ -201,7 +205,7 @@ export class JcodePeer extends AcpPeer {
201
205
  const launchId = crypto.randomUUID();
202
206
  // The ACP child gets peerChildEnv(this.id): the instance id, so its hub tools act as this peer and never as the base Jcode. The launch id
203
207
  // goes with it: the daemon admits a hub-tools hello for this peer only when it carries the id of this peer's current owned launch (#368).
204
- super(peer, { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, AGENTHUB_LAUNCH_ID: launchId, ...JCODE_OWNED_ENV }, mcpServers: [], requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: jcodeVersionSupported } });
208
+ super(peer, { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, AGENTHUB_LAUNCH_ID: launchId, ...JCODE_OWNED_ENV }, mcpServers: [], preamble: jcodePreamble(jcode.preamble), requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: jcodeVersionSupported } });
205
209
  this.root = root; this.socket = socket; this.launchId = launchId;
206
210
  }
207
211
 
@@ -9,7 +9,9 @@ import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, pro
9
9
  import { BasePeer } from "../hub/peers.ts";
10
10
  import type { PublishResult } from "../hub/bus.ts";
11
11
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
12
- import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, ApprovalWaitStop, type ToolApproval, type ApprovalProvenance, type ToolContext } from "../local/tools.ts";
12
+ import type { DocReadPort } from "../local/doc-read.ts";
13
+ import type { WebFetchPort } from "../local/web-fetch.ts";
14
+ import { DOC_READ_SCHEMA, WEB_FETCH_SCHEMA, runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, ApprovalWaitStop, type ToolApproval, type ApprovalProvenance, type ToolContext } from "../local/tools.ts";
13
15
  import type { Capture } from "../memory/capture.ts";
14
16
  import type { ChatMessage, ChatResult, OmniRoute, ToolCall } from "../omniroute/client.ts";
15
17
  import { safeModelLabel } from "../omniroute/usage.ts";
@@ -36,7 +38,7 @@ export interface LocalOptions {
36
38
  turnId?: () => string | undefined;
37
39
  onApprovalStop?: (reason: string) => void;
38
40
  onTool?: (observation: ToolObservation, task?: string) => void;
39
- tools: { deny: string[]; permit: ToolContext["permit"]; shellExecutionCheck?: ToolContext["shellExecutionCheck"]; bashNetwork?: SandboxNetwork; readAllow?: string[]; readOnly?: string[] };
41
+ tools: { docRead?: DocReadPort; webFetch?: WebFetchPort; deny: string[]; permit: ToolContext["permit"]; shellExecutionCheck?: ToolContext["shellExecutionCheck"]; bashNetwork?: SandboxNetwork; readAllow?: string[]; readOnly?: string[] };
40
42
  capture?: Capture;
41
43
  /** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
42
44
  taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
@@ -111,7 +113,7 @@ export class LocalPeer extends BasePeer {
111
113
  await this.requireBudget(envs, "model_calls");
112
114
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
113
115
  if (signal.aborted) throw new Error("turn cancelled before model request");
114
- const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
116
+ const tools = judge ? undefined : [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
115
117
  const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
116
118
  this.recordUsage(result, model, task);
117
119
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
@@ -167,6 +169,8 @@ export class LocalPeer extends BasePeer {
167
169
  const progress = { sideEffects: 0, last: "" };
168
170
  const requestedPolicy = this.opts.turnPolicy?.(envs);
169
171
  const policy = envs.some(env => env.private) ? { ...requestedPolicy, pii: true } : requestedPolicy;
172
+ const remoteTurn = envs.some(env => env.from.includes("@"));
173
+ const preserveHistory = !policy?.pii && !remoteTurn;
170
174
  const labelTurn = this.beginRouteTurn(turn, !!policy?.pii || envs.some(env => env.private), policy?.task);
171
175
  let completed = false;
172
176
  // A PII turn speaks to the console user only, and privately: its answer must not be broadcast to cloud-hosted peers.
@@ -175,7 +179,7 @@ export class LocalPeer extends BasePeer {
175
179
  this.run(envs, turn, msgs, progress, policy, reply)
176
180
  .then((answer) => {
177
181
  if (turn !== this.turn) return;
178
- if (!policy?.pii) this.commit(msgs); // a PII turn leaves nothing in the history later turns send along
182
+ if (preserveHistory) this.commit(msgs); // Private and remote turns never enter later local capture through model history.
179
183
  if (answer) this.onMessage?.(answer, reply);
180
184
  if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed" });
181
185
  completed = true;
@@ -183,31 +187,33 @@ export class LocalPeer extends BasePeer {
183
187
  .catch((e: Error) => {
184
188
  if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
185
189
  if (e instanceof ApprovalWaitStop) {
190
+ const message = remoteTurn ? "remote turn failed" : e.message;
186
191
  this.completeMissingToolResults(msgs, "not run: turn ended after unanswered approvals");
187
- msgs.push({ role: "assistant", content: e.message });
188
- if (!policy?.pii) this.commit(msgs);
189
- this.onMessage?.(e.message, reply);
190
- if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: e.message });
192
+ msgs.push({ role: "assistant", content: message });
193
+ if (preserveHistory) this.commit(msgs);
194
+ this.onMessage?.(message, reply);
195
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: message });
191
196
  return; // even without effects, an unanswered approval must not trigger safe replay
192
197
  }
193
198
  if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
194
- this.opts.log?.(`[${this.id}] turn failed: ${e.message}`);
199
+ const message = remoteTurn ? "remote turn failed" : e.message;
200
+ this.opts.log?.(`[${this.id}] turn failed: ${message}`);
195
201
  if (e instanceof ExecutionBudgetStop) {
196
- this.reportBudgetStop(e, msgs, progress, reply, !!policy?.pii, deliveryId);
202
+ this.reportBudgetStop(e, msgs, progress, reply, !preserveHistory, deliveryId, remoteTurn);
197
203
  return;
198
204
  }
199
205
  if (!progress.sideEffects) {
200
- if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: e.message });
206
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: message });
201
207
  else this.onFailed?.(envs); // legacy delivery: safe to redeliver
202
208
  return;
203
209
  }
204
210
  // Tools already changed things. Redelivering would redo approved writes and commits, so report instead.
205
211
  // A model call is the only thing that throws here, and every tool call before it has its result: msgs is consistent.
206
- const note = `(turn failed after ${progress.sideEffects} tool call(s) with side effects: ${e.message.slice(0, 200)}. The work may be partial; check before repeating it.) ${progress.last}`.trim();
212
+ const note = remoteTurn ? message : `(turn failed after ${progress.sideEffects} tool call(s) with side effects: ${message.slice(0, 200)}. The work may be partial; check before repeating it.) ${progress.last}`.trim();
207
213
  msgs.push({ role: "assistant", content: note });
208
- if (!policy?.pii) this.commit(msgs);
214
+ if (preserveHistory) this.commit(msgs);
209
215
  this.onMessage?.(note, reply);
210
- if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: e.message });
216
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: message });
211
217
  })
212
218
  .finally(() => {
213
219
  this.endRouteTurn(labelTurn, completed ? "completed" : "failed");
@@ -237,8 +243,9 @@ export class LocalPeer extends BasePeer {
237
243
  ): Promise<string> {
238
244
  const { maxSteps = 30 } = this.opts;
239
245
  const turnSignal = this.abort!.signal;
240
- // Local capture preserves the host privacy fence: nothing of a PII turn is recorded.
241
- const capture = policy?.pii ? undefined : this.opts.capture;
246
+ // Remote text/tool output is never memory evidence, even beside ordinary local work in the same turn.
247
+ const remoteTurn = envs.some(env => env.from.includes("@"));
248
+ const capture = policy?.pii || remoteTurn ? undefined : this.opts.capture;
242
249
  // Unknown gateway state is never permission to send PII.
243
250
  if (policy?.pii && !(await this.opts.omni.onCampus())) {
244
251
  return "Refused: this is a PII task and the only reachable gateway is off campus (Cloudflare Access). Connect the VPN and assign it again.";
@@ -281,9 +288,11 @@ export class LocalPeer extends BasePeer {
281
288
  const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
282
289
  msgs.push(result); results.push(result);
283
290
  if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
284
- this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
291
+ if (!remoteTurn) this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
285
292
  // Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
286
- if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
293
+ // doc_read text is converted document content (a converter can surface hidden text), never memory evidence: not captured, whatever the turn.
294
+ // web_fetch text is a third party's page and its URL is the model's own request: neither is memory evidence either (#515b).
295
+ if (name !== "hub_send" && name !== "doc_read" && name !== "web_fetch" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
287
296
  }
288
297
  } finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
289
298
  }
@@ -291,9 +300,14 @@ export class LocalPeer extends BasePeer {
291
300
  return `(stopped after ${maxSteps} steps) ${progress.last}`.trim();
292
301
  }
293
302
 
303
+ /** The base tools, plus `doc_read` and `web_fetch` only when the project opted in (`local.doc_read`, `local.web_fetch.allow`; both off by default). */
304
+ private toolSchemas() { const extra = [...(this.opts.tools.docRead ? [DOC_READ_SCHEMA] : []), ...(this.opts.tools.webFetch ? [WEB_FETCH_SCHEMA] : [])]; return extra.length ? [...TOOL_SCHEMAS, ...extra] : TOOL_SCHEMAS; }
305
+
294
306
  private toolContext(envs: Envelope[], turnSignal: AbortSignal, pii: boolean, reply: EnvelopeOpts): ToolContext {
295
307
  const executionGeneration=this.turn;
296
308
  return {
309
+ ...(this.opts.tools.docRead ? { docRead: { ...this.opts.tools.docRead, pii } } : {}),
310
+ ...(this.opts.tools.webFetch ? { webFetch: { ...this.opts.tools.webFetch, pii } } : {}),
297
311
  cwd: this.opts.cwd,
298
312
  deny: this.opts.tools.deny,
299
313
  readOnly: this.opts.tools.readOnly,
@@ -339,15 +353,16 @@ export class LocalPeer extends BasePeer {
339
353
  };
340
354
  }
341
355
 
342
- private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts, pii: boolean, deliveryId?: string): void {
343
- this.completeMissingToolResults(msgs, `not run: ${e.message}`);
344
- const detail = progress.sideEffects
356
+ private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts, privateHistory: boolean, deliveryId?: string, remoteTurn = false): void {
357
+ const message = remoteTurn ? "remote turn failed" : e.message;
358
+ this.completeMissingToolResults(msgs, `not run: ${message}`);
359
+ const detail = remoteTurn ? message : progress.sideEffects
345
360
  ? `(execution budget stopped after ${progress.sideEffects} tool call(s) with side effects; partial work may exist and needs review) ${progress.last}`.trim()
346
361
  : `(execution budget stopped before any tool side effects; no work was repeated) ${progress.last}`.trim();
347
362
  msgs.push({ role: "assistant", content: detail });
348
- if (!pii) this.commit(msgs);
363
+ if (!privateHistory) this.commit(msgs);
349
364
  this.onMessage?.(detail, reply);
350
- if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: e.message });
365
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: message });
351
366
  }
352
367
 
353
368
  private async requireBudget(envs: Envelope[], unit: "model_calls" | "tool_calls"): Promise<void> {
@@ -386,7 +401,7 @@ export class LocalPeer extends BasePeer {
386
401
  }
387
402
  const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
388
403
  const task = policy?.task === undefined ? undefined : Number(policy.task);
389
- const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
404
+ const tools = [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
390
405
  const signal = this.abort!.signal;
391
406
  const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
392
407
  const routed = await this.hubCall(route, messages, policy, signal);
@@ -446,7 +461,7 @@ export class LocalPeer extends BasePeer {
446
461
  }
447
462
 
448
463
  private observeTool(call: ToolCall, output: string, policy: { pii: boolean; task?: string } | undefined, nativeTurn: string): void {
449
- if (policy?.pii) return;
464
+ if (policy?.pii || call.function.name === "doc_read" || call.function.name === "web_fetch") return; // document and web text: no research observation carries it
450
465
  try {
451
466
  const command = safeParse(call.function.arguments).command;
452
467
  this.opts.onTool?.({ name: call.function.name, ...(typeof command === "string" ? { command } : {}), resultText: output, isError: toolResultFailed(call.function.name, output), source: "local", turn: nativeTurn }, policy?.task);
@@ -0,0 +1,10 @@
1
+ import type { PeerAdapter } from "../hub/peers.ts";
2
+ import { isFixedBuiltinDefinition, type AdapterDefinition } from "./registry.ts";
3
+
4
+ /** Presence is forbidden on a foreign runtime, including a member whose value is undefined. */
5
+ export function assertNativeProofPorts(definition: AdapterDefinition | undefined, product: PeerAdapter): void {
6
+ if (isFixedBuiltinDefinition(definition)) return;
7
+ for (const port of ["frontendProxy", "statusNotice", "revertTurn"] as const) {
8
+ if (port in product) throw new Error(`${definition?.kind ?? product.id}: ${port} is restricted to fixed builtin adapter products`);
9
+ }
10
+ }