@staix/agent-hub 0.19.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.
- package/CHANGELOG.md +70 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -5
- 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 +20 -7
- package/docs/agent-notes/local-worker.md +5 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +20 -10
- package/docs/agent-notes/tests.md +13 -5
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +41 -5
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +598 -63
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +66 -10
- package/docs/security.md +397 -35
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- 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/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 +26 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1116 -284
- package/src/adapters/acp.ts +87 -3
- package/src/adapters/channel-peer.ts +11 -5
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +61 -7
- package/src/adapters/codex-appserver.ts +88 -5
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +10 -6
- 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 +42 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/reviewer-print.ts +1 -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 +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- 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 +33 -0
- package/src/cli/smoke-pty.ts +145 -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 +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +162 -0
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +8 -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 +1228 -242
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- 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 +85 -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 +76 -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 +46 -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 +7 -1
- 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/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +1047 -115
- package/src/hub/workflow.ts +1 -1
- package/src/local/deny.ts +21 -0
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/sandbox.ts +3 -1
- 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
|
@@ -2,13 +2,22 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|
|
2
2
|
import type { Server, ServerWebSocket } from "bun";
|
|
3
3
|
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
4
|
import type { PermissionMode } from "../hub/permission-mode.ts";
|
|
5
|
-
import { BasePeer } from "../hub/peers.ts";
|
|
5
|
+
import { BasePeer, type FrontendProxyPort } from "../hub/peers.ts";
|
|
6
6
|
import { codexContext, type ContextReading } from "../hub/context-window.ts";
|
|
7
7
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
8
8
|
import type { ContextReader } from "./registry-types.ts";
|
|
9
9
|
import { ACTION_BUDGET_OPTION, type ActionGateVerdict, type PermissionRequest } from "./acp.ts";
|
|
10
10
|
|
|
11
11
|
const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
|
|
12
|
+
const PLANNER_SANDBOX_REFUSAL = "Codex planner thread requires a confirmed read-only sandbox";
|
|
13
|
+
|
|
14
|
+
/** Codex 0.164.0 SandboxPolicy readOnly variant; an unreadable or widened policy is not proof. */
|
|
15
|
+
function confirmsReadOnlySandbox(value: unknown): boolean {
|
|
16
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
17
|
+
const policy = value as Record<string, unknown>;
|
|
18
|
+
return policy.type === "readOnly" && Object.keys(policy).every(key => key === "type" || key === "networkAccess")
|
|
19
|
+
&& (!Object.hasOwn(policy, "networkAccess") || typeof policy.networkAccess === "boolean");
|
|
20
|
+
}
|
|
12
21
|
|
|
13
22
|
/** A counter Codex reported: a finite non-negative number, otherwise 0 (an absent kind is not a negative delta). */
|
|
14
23
|
const counter = (value: unknown): number => { const n = Number(value); return Number.isFinite(n) && n >= 0 ? n : 0; };
|
|
@@ -16,6 +25,8 @@ export interface CodexOptions {
|
|
|
16
25
|
/** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
|
|
17
26
|
proxyPort: number;
|
|
18
27
|
permissionMode?: PermissionMode;
|
|
28
|
+
/** Live host planner ceiling on initial thread start/resume; the attached person may change later turn policy. */
|
|
29
|
+
threadSandbox?: "read-only" | (() => "read-only" | "unavailable" | undefined);
|
|
19
30
|
/** Host-only always-ask gate, shared with ACP; an absent verdict preserves native handling. */
|
|
20
31
|
actionGate?: (call: { names: readonly string[]; kind?: string; input: unknown }, session: string) => ActionGateVerdict | undefined;
|
|
21
32
|
onPermission?: (request: PermissionRequest, signal?: AbortSignal) => Promise<string | undefined>;
|
|
@@ -71,6 +82,10 @@ interface Link {
|
|
|
71
82
|
backlog: string[]; // TUI frames that arrived before the upstream socket opened
|
|
72
83
|
approvalIds: Set<string | number>; // intercepted native ids stay tombstoned for this socket lifetime
|
|
73
84
|
tracked: Map<string | number, string>; // request id -> method, for thread/start and thread/resume
|
|
85
|
+
/** Correlated native initial policy, retained even before this instance gains the planner role. */
|
|
86
|
+
readOnlyThread?: string;
|
|
87
|
+
/** An ordinary client reused an outstanding id; this link cannot attest a planner thread until reattach. */
|
|
88
|
+
ambiguousThreadRpc?: true;
|
|
74
89
|
}
|
|
75
90
|
|
|
76
91
|
const TRACKED = new Set(["thread/start", "thread/resume"]);
|
|
@@ -82,6 +97,7 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
|
|
|
82
97
|
* TUI's, and their responses are swallowed.
|
|
83
98
|
*/
|
|
84
99
|
export class CodexPeer extends BasePeer {
|
|
100
|
+
readonly frontendProxy: FrontendProxyPort;
|
|
85
101
|
private permissionMode: PermissionMode;
|
|
86
102
|
private readonly actionWaits = new Map<AbortController, { link: Link; turn: string | undefined }>();
|
|
87
103
|
get actionGated(): boolean { return this.opts.actionGate !== undefined; }
|
|
@@ -136,6 +152,13 @@ export class CodexPeer extends BasePeer {
|
|
|
136
152
|
) {
|
|
137
153
|
super(id, opts.watchdogMs);
|
|
138
154
|
this.permissionMode = opts.permissionMode ?? "ask";
|
|
155
|
+
const owner = this;
|
|
156
|
+
this.frontendProxy = {
|
|
157
|
+
get url() { return owner.proxyUrl; },
|
|
158
|
+
get attached() { return owner.proxyAttached; },
|
|
159
|
+
setStartupPermissionMode: this.setStartupPermissionMode.bind(this),
|
|
160
|
+
clearPermissionMode: this.clearPermissionMode.bind(this),
|
|
161
|
+
};
|
|
139
162
|
}
|
|
140
163
|
|
|
141
164
|
recoveryMetadata(): Record<string, unknown> {
|
|
@@ -149,6 +172,7 @@ export class CodexPeer extends BasePeer {
|
|
|
149
172
|
get thread(): string {
|
|
150
173
|
return this.threadId;
|
|
151
174
|
}
|
|
175
|
+
get nativeSession(): string | undefined { return this.threadId || undefined; }
|
|
152
176
|
|
|
153
177
|
get proxyUrl(): string {
|
|
154
178
|
return `ws://127.0.0.1:${this.server?.port ?? this.opts.proxyPort}`;
|
|
@@ -224,7 +248,7 @@ export class CodexPeer extends BasePeer {
|
|
|
224
248
|
/** Resolves when app-server accepts the turn. Only called while idle, i.e. a thread exists and no turn runs. */
|
|
225
249
|
deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
226
250
|
const link = this.link;
|
|
227
|
-
if (this.state !== "idle" || !link || link.up.readyState !== WebSocket.OPEN) {
|
|
251
|
+
if (this.state !== "idle" || !link || link.up.readyState !== WebSocket.OPEN || !this.threadSandboxReady(link)) {
|
|
228
252
|
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is not injectable` });
|
|
229
253
|
return Promise.reject(new Error(`${this.id} is not injectable`));
|
|
230
254
|
}
|
|
@@ -276,6 +300,10 @@ export class CodexPeer extends BasePeer {
|
|
|
276
300
|
reject(e);
|
|
277
301
|
},
|
|
278
302
|
});
|
|
303
|
+
if (!this.threadSandboxReady(link)) {
|
|
304
|
+
const pending = this.pending.get(id); this.pending.delete(id);
|
|
305
|
+
pending?.reject(new Error(`${this.id} is not injectable`)); return;
|
|
306
|
+
}
|
|
279
307
|
link.up.send(
|
|
280
308
|
JSON.stringify({ method: "turn/start", id, params: this.applyPermissionPolicy({ threadId: this.threadId, input: [{ type: "text", text }] }, id) }),
|
|
281
309
|
);
|
|
@@ -290,7 +318,7 @@ export class CodexPeer extends BasePeer {
|
|
|
290
318
|
steer(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
291
319
|
const link = this.link;
|
|
292
320
|
const expectedTurnId = [...this.activeTurns].reverse().find((t) => !t.startsWith("unknown:"));
|
|
293
|
-
if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId) {
|
|
321
|
+
if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId || !this.threadSandboxReady(link)) {
|
|
294
322
|
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} has no steerable turn` });
|
|
295
323
|
return Promise.reject(new Error(`${this.id} has no steerable turn`));
|
|
296
324
|
}
|
|
@@ -337,6 +365,10 @@ export class CodexPeer extends BasePeer {
|
|
|
337
365
|
reject: fail,
|
|
338
366
|
});
|
|
339
367
|
const input = [{ type: "text", text: this.render(envs) }];
|
|
368
|
+
if (!this.threadSandboxReady(link)) {
|
|
369
|
+
const pending = this.pending.get(id); this.pending.delete(id);
|
|
370
|
+
pending?.reject(new Error(`${this.id} is not injectable`)); return;
|
|
371
|
+
}
|
|
340
372
|
link.up.send(JSON.stringify({ method: "turn/steer", id, params: { threadId: this.threadId, expectedTurnId, input } }));
|
|
341
373
|
});
|
|
342
374
|
}
|
|
@@ -349,12 +381,16 @@ export class CodexPeer extends BasePeer {
|
|
|
349
381
|
steerText(text: string): Promise<"accepted" | "refused" | "unanswered"> {
|
|
350
382
|
const link = this.link;
|
|
351
383
|
const expectedTurnId = [...this.activeTurns].reverse().find((t) => !t.startsWith("unknown:"));
|
|
352
|
-
if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId) return Promise.resolve("refused");
|
|
384
|
+
if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId || !this.threadSandboxReady(link)) return Promise.resolve("refused");
|
|
353
385
|
const id = this.nextId--;
|
|
354
386
|
return new Promise((resolve) => {
|
|
355
387
|
const timer = setTimeout(() => this.pending.delete(id) && resolve("unanswered"), this.opts.steerTimeoutMs ?? 10_000);
|
|
356
388
|
timer.unref?.();
|
|
357
389
|
this.pending.set(id, { resolve: () => (clearTimeout(timer), resolve("accepted")), reject: () => (clearTimeout(timer), resolve("refused")) });
|
|
390
|
+
if (!this.threadSandboxReady(link)) {
|
|
391
|
+
const pending = this.pending.get(id); this.pending.delete(id);
|
|
392
|
+
pending?.reject(new Error(`${this.id} is not injectable`)); return;
|
|
393
|
+
}
|
|
358
394
|
link.up.send(JSON.stringify({ method: "turn/steer", id, params: { threadId: this.threadId, expectedTurnId, input: [{ type: "text", text }] } }));
|
|
359
395
|
});
|
|
360
396
|
}
|
|
@@ -474,6 +510,7 @@ export class CodexPeer extends BasePeer {
|
|
|
474
510
|
this.link = undefined;
|
|
475
511
|
this.restoringApprovals.clear();
|
|
476
512
|
this.threadId = "";
|
|
513
|
+
this.onDoing?.("reset");
|
|
477
514
|
this.contextEpoch++;
|
|
478
515
|
this.deliveryEpoch++;
|
|
479
516
|
this.retiredTurns.clear(); this.unidentifiedCancellation = false;
|
|
@@ -551,6 +588,9 @@ export class CodexPeer extends BasePeer {
|
|
|
551
588
|
private fromTui(link: Link, raw: string): void {
|
|
552
589
|
const msg = parse(raw);
|
|
553
590
|
if (msg?.id !== undefined && !msg.method && link.approvalIds.has(msg.id)) return;
|
|
591
|
+
const sandbox = this.threadSandboxPolicy();
|
|
592
|
+
const threadRequest = TRACKED.has(msg?.method), turnRequest = msg?.method === "turn/start" || msg?.method === "turn/steer";
|
|
593
|
+
if (sandbox === "unavailable" && (threadRequest || turnRequest) || sandbox === "read-only" && turnRequest && (link !== this.link || link.tracked.size > 0 || link.readOnlyThread !== this.threadId)) return this.refusePlannerThread(link, msg.id);
|
|
554
594
|
if (msg?.method === "turn/interrupt" && link === this.link) this.abortActionWaits(link, typeof msg.params?.turnId === "string" ? msg.params.turnId : undefined);
|
|
555
595
|
if (msg?.method === "turn/start" && msg.params && link === this.link) {
|
|
556
596
|
const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
|
|
@@ -563,7 +603,15 @@ export class CodexPeer extends BasePeer {
|
|
|
563
603
|
raw = JSON.stringify(msg);
|
|
564
604
|
}
|
|
565
605
|
}
|
|
566
|
-
if (msg?.id !== undefined && TRACKED.has(msg.method))
|
|
606
|
+
if (msg?.id !== undefined && TRACKED.has(msg.method)) {
|
|
607
|
+
if (link.tracked.has(msg.id)) link.ambiguousThreadRpc = true;
|
|
608
|
+
if (sandbox === "read-only") {
|
|
609
|
+
if (link.ambiguousThreadRpc || msg.params !== undefined && (!msg.params || typeof msg.params !== "object" || Array.isArray(msg.params))) return this.refusePlannerThread(link, msg.id);
|
|
610
|
+
msg.params = { ...msg.params, sandbox: "read-only" };
|
|
611
|
+
raw = JSON.stringify(msg);
|
|
612
|
+
}
|
|
613
|
+
link.tracked.set(msg.id, msg.method);
|
|
614
|
+
}
|
|
567
615
|
if (link.up.readyState === WebSocket.OPEN) link.up.send(raw);
|
|
568
616
|
else link.backlog.push(raw);
|
|
569
617
|
}
|
|
@@ -586,7 +634,11 @@ export class CodexPeer extends BasePeer {
|
|
|
586
634
|
if (typeof msg.result?.userAgent === "string") this.version = /^[^/\s]+\/(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(msg.result.userAgent)?.[1] ?? this.version;
|
|
587
635
|
const tracked = msg.id !== undefined && !msg.method ? link.tracked.get(msg.id) : undefined;
|
|
588
636
|
if (tracked && link.tracked.delete(msg.id)) {
|
|
637
|
+
const sandbox = this.threadSandboxPolicy();
|
|
638
|
+
const readOnly = !link.ambiguousThreadRpc && msg.error === undefined && typeof msg.result?.thread?.id === "string" && !!msg.result.thread.id && confirmsReadOnlySandbox(msg.result?.sandbox);
|
|
639
|
+
if (sandbox === "unavailable" || sandbox === "read-only" && !readOnly) return this.refusePlannerThread(link, msg.id);
|
|
589
640
|
if (typeof msg.result?.thread?.id === "string") {
|
|
641
|
+
link.readOnlyThread = readOnly ? msg.result.thread.id : undefined;
|
|
590
642
|
// A resumed response may report the sticky hub override, not the original native choice.
|
|
591
643
|
const thread = msg.result.thread.id;
|
|
592
644
|
if (!this.overriddenApprovals.has(thread) && msg.result.approvalPolicy != null) this.nativeApprovalPolicies.set(thread, msg.result.approvalPolicy);
|
|
@@ -597,6 +649,32 @@ export class CodexPeer extends BasePeer {
|
|
|
597
649
|
link.tui.send(raw);
|
|
598
650
|
}
|
|
599
651
|
|
|
652
|
+
private threadSandboxPolicy(): "read-only" | "unavailable" | undefined {
|
|
653
|
+
try {
|
|
654
|
+
const value = typeof this.opts.threadSandbox === "function" ? this.opts.threadSandbox() : this.opts.threadSandbox;
|
|
655
|
+
return value === undefined || value === "read-only" ? value : "unavailable";
|
|
656
|
+
} catch { return "unavailable"; }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Role changes never turn an ordinary writable/unknown thread into a proved planner thread. */
|
|
660
|
+
private threadSandboxReady(link: Link): boolean {
|
|
661
|
+
const sandbox = this.threadSandboxPolicy();
|
|
662
|
+
if (sandbox === "unavailable") { this.refusePlannerThread(link, undefined); return false; }
|
|
663
|
+
if (sandbox === "read-only") {
|
|
664
|
+
if (link.tracked.size > 0) return false;
|
|
665
|
+
if (link !== this.link || link.readOnlyThread !== this.threadId) { this.refusePlannerThread(link, undefined); return false; }
|
|
666
|
+
}
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Refuse before adoption or another host delivery, including while a previous thread was idle. */
|
|
671
|
+
private refusePlannerThread(link: Link, id: unknown): void {
|
|
672
|
+
if (link.tui.readyState === WebSocket.OPEN && (typeof id === "string" || typeof id === "number")) link.tui.send(JSON.stringify({ id, error: { code: -32000, message: PLANNER_SANDBOX_REFUSAL } }));
|
|
673
|
+
link.tracked.clear();
|
|
674
|
+
this.detach(link);
|
|
675
|
+
link.tui.close(1008, PLANNER_SANDBOX_REFUSAL);
|
|
676
|
+
}
|
|
677
|
+
|
|
600
678
|
private abortActionWaits(link?: Link, turn?: string): void {
|
|
601
679
|
for (const [controller, wait] of this.actionWaits) if ((!link || wait.link === link) && (!turn || wait.turn === turn)) controller.abort();
|
|
602
680
|
}
|
|
@@ -664,6 +742,7 @@ export class CodexPeer extends BasePeer {
|
|
|
664
742
|
this.freshThread = fresh;
|
|
665
743
|
this.link = link;
|
|
666
744
|
this.threadId = threadId;
|
|
745
|
+
this.onDoing?.("reset");
|
|
667
746
|
this.contextEpoch++;
|
|
668
747
|
this.deliveryEpoch++;
|
|
669
748
|
this.retiredTurns.clear(); this.unidentifiedCancellation = false;
|
|
@@ -694,6 +773,7 @@ export class CodexPeer extends BasePeer {
|
|
|
694
773
|
link.up.send(JSON.stringify({ method: "thread/revert", id, params: { threadId: this.threadId, beforeTurnId: turnId } }));
|
|
695
774
|
});
|
|
696
775
|
}
|
|
776
|
+
revertTurn(nativeTurn: string): Promise<void> { return this.revert(nativeTurn); }
|
|
697
777
|
|
|
698
778
|
/** account/rateLimits/read through the TUI's connection, with a hub id so the answer never reaches the TUI. */
|
|
699
779
|
private readUsage(): void {
|
|
@@ -712,6 +792,9 @@ export class CodexPeer extends BasePeer {
|
|
|
712
792
|
this.opts.onUsage?.({ rateLimitReachedType: "usageLimitExceeded" }, true);
|
|
713
793
|
}
|
|
714
794
|
if (link !== this.link || params.threadId !== this.threadId) return;
|
|
795
|
+
// #878: the bound thread's name and its plan's in-progress step, for the hub's DOING column only.
|
|
796
|
+
if (method === "thread/name/updated") return void this.onDoing?.("title", params.threadName);
|
|
797
|
+
if (method === "turn/plan/updated") return void this.onDoing?.("todo", Array.isArray(params.plan) ? params.plan.find((s: any) => s?.status === "inProgress")?.step : undefined);
|
|
715
798
|
const nativeId = method === "turn/started" || method === "turn/completed" ? params.turn?.id : params.turnId;
|
|
716
799
|
if (typeof nativeId === "string" && this.retiredTurns.has(nativeId)) {
|
|
717
800
|
if (method === "turn/completed") this.retiredTurns.delete(nativeId);
|
|
@@ -4,7 +4,7 @@ import { isFixedBuiltinDefinition } from "../registry.ts";
|
|
|
4
4
|
/** Stable generic ACP legs; both actual test registration and enrollment validation consume this catalog. */
|
|
5
5
|
export const ACP_LEGS = Object.freeze(["native-approval-rpc", "native-mode-ack", "native-quota", "watchdog-generation"] as const);
|
|
6
6
|
export type AcpLeg = typeof ACP_LEGS[number];
|
|
7
|
-
/**
|
|
7
|
+
/** Mandatory product invariant, asserted for generic and owned ACP factories rather than skipped protocol legs. */
|
|
8
8
|
export const ACP_PORTS_LEG = "restrictive-ports";
|
|
9
9
|
export const NATIVE_PROOF_PORTS_LEG = "native-proof-ports";
|
|
10
10
|
/** No unsupported key or non-generic ACP engine can silently escape an executable catalog leg. */
|
|
@@ -18,8 +18,9 @@ export function assertAcpLegEnrollment(definitions: readonly AdapterDefinition[]
|
|
|
18
18
|
if (!row || !isFixedBuiltinDefinition(row.definition)) throw new Error(`${key}: only fixed builtin proof legs may be nonapplicable`);
|
|
19
19
|
continue;
|
|
20
20
|
}
|
|
21
|
-
if (separator < 1 || !acp.has(kind)
|
|
22
|
-
if (leg === ACP_PORTS_LEG
|
|
21
|
+
if (separator < 1 || !acp.has(kind)) throw new Error(`${key}: stale or unknown unsupported ACP leg`);
|
|
22
|
+
if (leg === ACP_PORTS_LEG) throw new Error(`${key}: mandatory ACP restrictive ports leg cannot be unsupported`);
|
|
23
|
+
if (!(ACP_LEGS as readonly string[]).includes(leg)) throw new Error(`${key}: stale or unknown unsupported ACP leg`);
|
|
23
24
|
}
|
|
24
25
|
for (const definition of acp.values()) if (definition.start.daemonPort !== "acp") {
|
|
25
26
|
for (const leg of ACP_LEGS) {
|
|
@@ -50,7 +51,6 @@ export function assertEnrollment(definitions: readonly AdapterDefinition[], enro
|
|
|
50
51
|
}
|
|
51
52
|
for (const { id, definition } of rows) {
|
|
52
53
|
if (isFixedBuiltinDefinition(definition) && !Object.hasOwn(unsupportedLegs, `${id}:${NATIVE_PROOF_PORTS_LEG}`)) throw new Error(`${id}: native proof ports need an explicit nonapplicable reason`);
|
|
53
|
-
if (definition.transport === "acp" && !Object.hasOwn(unsupportedLegs, `${id}:${ACP_PORTS_LEG}`)) throw new Error(`${id}: ACP restrictive ports require the reserved 2b reason`);
|
|
54
54
|
if (!enrolled.includes(id) && !Object.hasOwn(unsupported, id)) throw new Error(`${id}: no conformance fixture or unsupported reason`);
|
|
55
55
|
if (definition.transport === "acp" && !enrolled.includes(id)) throw new Error(`${id}: every ACP definition must run the ACP legs`);
|
|
56
56
|
if (id.endsWith(":reviewer") && !definition.reviewer) throw new Error(`${id}: reviewer variant lacks reviewer policy`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AdapterDefinition, CreateContext, DaemonPorts } from "../registry-types.ts";
|
|
2
2
|
import type { PeerAdapter } from "../../hub/peers.ts";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { assertNativeProofPorts } from "./kit.ts";
|
|
4
|
+
import { assertAcpProductPorts, assertNativeProofPorts } from "./kit.ts";
|
|
5
5
|
|
|
6
6
|
/** The supplied definition creates its actual adapter, with only this fixture's transport options. */
|
|
7
7
|
export function createFromDefinition<T extends PeerAdapter>(definition: AdapterDefinition, daemon: DaemonPorts): T {
|
|
@@ -10,6 +10,7 @@ export function createFromDefinition<T extends PeerAdapter>(definition: AdapterD
|
|
|
10
10
|
const product = definition.start.create({ instance: definition.kind, projectRoot: options.cwd, stateDir: options.stateDir ?? join(options.cwd, "state"),
|
|
11
11
|
kindConfig: {}, permission: "ask", mcpServers: options.mcpServers ?? [], daemon }) as T;
|
|
12
12
|
assertNativeProofPorts(definition, product);
|
|
13
|
+
assertAcpProductPorts(definition, product);
|
|
13
14
|
return product;
|
|
14
15
|
}
|
|
15
16
|
/** Explicit watchdog seam on the factory-created implementation, replacing direct test subclasses. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer } from "node:net";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
|
-
import { writeSync, appendFileSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { writeSync, appendFileSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
5
|
const args = process.argv.slice(2), value = (key: string) => args[args.indexOf(key) + 1];
|
|
6
6
|
const record = args.includes("--record") ? value("--record") : undefined;
|
|
@@ -76,7 +76,11 @@ if (args.includes("version")) {
|
|
|
76
76
|
}
|
|
77
77
|
const finish = () => {
|
|
78
78
|
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: session, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "JCODE_REPLY" } } } });
|
|
79
|
-
|
|
79
|
+
// `--usage-script F`: the 0.99.1 shapes, as probed (docs/verification/2026-10-28-jcode-0.99.1-usage.md). F is a JSON object `{ updates: [...], usage: {...} | null, sessionId?: string }`:
|
|
80
|
+
// each `updates` entry becomes a `usage_update` notification (for `sessionId` when given, else this session), and `usage` is the prompt result's `usage` member (absent when null).
|
|
81
|
+
const script = args.includes("--usage-script") && existsSync(value("--usage-script")!) ? JSON.parse(readFileSync(value("--usage-script")!, "utf8")) : undefined;
|
|
82
|
+
for (const update of script?.updates ?? []) send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: script.sessionId ?? session, update: { sessionUpdate: "usage_update", ...update } } });
|
|
83
|
+
send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: args.includes("--max-tokens-stop") ? "max_tokens" : "end_turn", ...(script?.usage ? { usage: script.usage } : {}) } });
|
|
80
84
|
if (args.includes("--exit-after-prompt")) setTimeout(() => process.exit(0), 30);
|
|
81
85
|
};
|
|
82
86
|
const steerFile = args.includes("--steer-file") ? value("--steer-file")! : undefined;
|
|
@@ -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;
|
|
@@ -24,12 +26,13 @@ export interface AdapterConformance {
|
|
|
24
26
|
create(): Promise<ConformanceFixture>;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
/**
|
|
28
|
-
export function
|
|
29
|
-
if (
|
|
30
|
-
for (const port of ["
|
|
31
|
-
if (port
|
|
29
|
+
/** Every ACP factory product keeps the transport's restrictive delivery and ownership contract. */
|
|
30
|
+
export function assertAcpProductPorts(definition: AdapterDefinition | undefined, product: PeerAdapter): void {
|
|
31
|
+
if (definition?.transport !== "acp") return;
|
|
32
|
+
for (const port of ["deliveryOnly", "hubOwned"] as const) {
|
|
33
|
+
if (product[port] !== true) throw new Error(`${definition.kind}: ACP restrictive port ${port} must be true`);
|
|
32
34
|
}
|
|
35
|
+
if (product.form !== "headless") throw new Error(`${definition.kind}: ACP restrictive port form must be headless`);
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** Shared observable contract; every invocation uses the real adapter and its fake native transport. */
|
|
@@ -38,6 +41,7 @@ export function registerConformance(adapter: AdapterConformance): void {
|
|
|
38
41
|
const f = await adapter.create();
|
|
39
42
|
try {
|
|
40
43
|
assertNativeProofPorts(adapter.definition, f.peer);
|
|
44
|
+
assertAcpProductPorts(adapter.definition, f.peer);
|
|
41
45
|
expect(f.peer.id).toBe(adapter.kind);
|
|
42
46
|
expect(f.peer.state).toBe("idle");
|
|
43
47
|
const envelope: Envelope = newEnvelope("user", "conformance ping", { to: [adapter.kind] });
|
|
@@ -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
|
+
}
|