@staix/agent-hub 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +17 -8
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +39 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +610 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +133 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/2026-10-30-0.22.0.md +49 -0
- package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
- package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
- package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
- package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
- package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +29 -16
- package/src/cli/identity.ts +25 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/learn-output.ts +38 -0
- package/src/cli/main.ts +334 -73
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/recovery-waits.ts +26 -0
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +82 -0
- package/src/cli/smoke-pty.ts +153 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +112 -28
- package/src/cli/upgrade.ts +68 -8
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +416 -65
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +34 -6
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1209 -183
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +200 -73
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +117 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +154 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +27 -2
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +987 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- 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 {
|
|
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
|
|
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
|
|
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)");
|
package/src/adapters/jcode.ts
CHANGED
|
@@ -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
|
|
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 : [...
|
|
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 (
|
|
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:
|
|
188
|
-
if (
|
|
189
|
-
this.onMessage?.(
|
|
190
|
-
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason:
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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: ${
|
|
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 (
|
|
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:
|
|
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
|
-
//
|
|
241
|
-
const
|
|
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
|
-
|
|
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,
|
|
343
|
-
|
|
344
|
-
|
|
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 (!
|
|
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:
|
|
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 = [...
|
|
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
|
+
}
|