@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.
Files changed (184) hide show
  1. package/CHANGELOG.md +70 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -5
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +20 -7
  9. package/docs/agent-notes/local-worker.md +5 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +20 -10
  12. package/docs/agent-notes/tests.md +13 -5
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +41 -5
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +598 -63
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +66 -10
  23. package/docs/security.md +397 -35
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  29. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  30. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  31. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  32. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  33. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  34. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  35. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  36. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  37. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  38. package/docs/verification/claude-permission-transport-native.py +71 -14
  39. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  40. package/docs/verified.json +26 -18
  41. package/package.json +2 -2
  42. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  43. package/plugins/agent-hub/server.js +1116 -284
  44. package/src/adapters/acp.ts +87 -3
  45. package/src/adapters/channel-peer.ts +11 -5
  46. package/src/adapters/claude-channel.ts +13 -6
  47. package/src/adapters/claude-implementer.ts +61 -7
  48. package/src/adapters/codex-appserver.ts +88 -5
  49. package/src/adapters/conformance/enrollment.ts +4 -4
  50. package/src/adapters/conformance/factories.ts +2 -1
  51. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  52. package/src/adapters/conformance/kit.ts +10 -6
  53. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  54. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  55. package/src/adapters/definitions/native-config-policy.ts +3 -2
  56. package/src/adapters/definitions/native-context.ts +12 -0
  57. package/src/adapters/external-metadata.ts +48 -0
  58. package/src/adapters/external-modules.ts +63 -3
  59. package/src/adapters/jcode.ts +6 -2
  60. package/src/adapters/local-worker.ts +42 -25
  61. package/src/adapters/native-proof-ports.ts +10 -0
  62. package/src/adapters/pi.ts +7 -0
  63. package/src/adapters/registry-types.ts +4 -0
  64. package/src/adapters/reviewer-print.ts +1 -0
  65. package/src/broker/protocol.ts +203 -0
  66. package/src/broker/server.ts +314 -0
  67. package/src/broker/store.ts +548 -0
  68. package/src/cli/bench.ts +3 -2
  69. package/src/cli/broker.ts +58 -0
  70. package/src/cli/conductor-recovery.ts +142 -0
  71. package/src/cli/configured-acp-entry.ts +26 -6
  72. package/src/cli/console-state.ts +59 -17
  73. package/src/cli/console.ts +17 -2
  74. package/src/cli/facts-hook.ts +3 -1
  75. package/src/cli/help.ts +28 -16
  76. package/src/cli/identity.ts +23 -10
  77. package/src/cli/init.ts +34 -7
  78. package/src/cli/intake-output.ts +33 -0
  79. package/src/cli/main.ts +314 -71
  80. package/src/cli/output.ts +41 -1
  81. package/src/cli/recovery-package.ts +18 -1
  82. package/src/cli/role-holders.ts +35 -0
  83. package/src/cli/room.ts +304 -0
  84. package/src/cli/say-input.ts +16 -0
  85. package/src/cli/schedule-output.ts +49 -0
  86. package/src/cli/sdlc-output.ts +44 -6
  87. package/src/cli/smoke-pty-bridge.ts +33 -0
  88. package/src/cli/smoke-pty.ts +145 -0
  89. package/src/cli/smoke-run.ts +139 -0
  90. package/src/cli/smoke-target.ts +130 -0
  91. package/src/cli/source-output.ts +29 -0
  92. package/src/cli/task-output.ts +3 -2
  93. package/src/cli/upgrade-runtime.ts +98 -16
  94. package/src/cli/upgrade.ts +66 -7
  95. package/src/hub/ask.ts +76 -12
  96. package/src/hub/board.ts +132 -22
  97. package/src/hub/bus.ts +399 -56
  98. package/src/hub/child-process.ts +2 -0
  99. package/src/hub/ci-watch.ts +162 -0
  100. package/src/hub/claude-failure-stage.ts +85 -0
  101. package/src/hub/claude-launch-proof.ts +67 -2
  102. package/src/hub/claude-shell-wrapper.ts +11 -5
  103. package/src/hub/conductor.ts +17 -4
  104. package/src/hub/config-apply.ts +32 -5
  105. package/src/hub/config-reload.ts +7 -5
  106. package/src/hub/config-trust.ts +8 -1
  107. package/src/hub/context-window.ts +33 -3
  108. package/src/hub/control-client.ts +14 -2
  109. package/src/hub/corpus-config.ts +34 -0
  110. package/src/hub/corpus-evidence.ts +184 -0
  111. package/src/hub/corpus.ts +373 -0
  112. package/src/hub/cost-governance.ts +26 -0
  113. package/src/hub/daemon.ts +1228 -242
  114. package/src/hub/deliverable-files.ts +8 -2
  115. package/src/hub/delivery-journal.ts +123 -17
  116. package/src/hub/dogfood-profile.ts +32 -5
  117. package/src/hub/dogfood-read.ts +6 -2
  118. package/src/hub/envelope.ts +11 -0
  119. package/src/hub/eval-gate.ts +131 -0
  120. package/src/hub/events.ts +1 -1
  121. package/src/hub/external-instance.ts +69 -0
  122. package/src/hub/facts.ts +15 -5
  123. package/src/hub/folder-trigger.ts +223 -0
  124. package/src/hub/hub-tools.ts +2 -1
  125. package/src/hub/inference.ts +20 -5
  126. package/src/hub/instance-config.ts +69 -3
  127. package/src/hub/instance-execution.ts +6 -2
  128. package/src/hub/instances.ts +32 -4
  129. package/src/hub/intake.ts +125 -0
  130. package/src/hub/learning.ts +85 -0
  131. package/src/hub/lifecycle.ts +7 -3
  132. package/src/hub/limits.ts +12 -5
  133. package/src/hub/native-permission-request.ts +77 -0
  134. package/src/hub/operational-authority.ts +63 -5
  135. package/src/hub/operational-settings.ts +76 -0
  136. package/src/hub/operational-view.ts +1 -1
  137. package/src/hub/packs.ts +132 -0
  138. package/src/hub/path-policy.ts +7 -0
  139. package/src/hub/peer-doing.ts +130 -0
  140. package/src/hub/peers.ts +46 -0
  141. package/src/hub/queue-backpressure.ts +58 -0
  142. package/src/hub/read-tools.ts +2 -0
  143. package/src/hub/recovery-authority.ts +151 -0
  144. package/src/hub/recovery-policy.ts +220 -0
  145. package/src/hub/research.ts +24 -14
  146. package/src/hub/restart.ts +7 -0
  147. package/src/hub/routing.ts +54 -11
  148. package/src/hub/schedules.ts +266 -0
  149. package/src/hub/sdlc-merge.ts +38 -14
  150. package/src/hub/sdlc-metrics.ts +32 -10
  151. package/src/hub/sdlc-queue.ts +7 -1
  152. package/src/hub/sdlc-release.ts +1 -1
  153. package/src/hub/sdlc.ts +37 -7
  154. package/src/hub/security-baseline.ts +38 -0
  155. package/src/hub/settings.ts +7 -1
  156. package/src/hub/shipped-packs.ts +8 -0
  157. package/src/hub/snapshots.ts +72 -6
  158. package/src/hub/source-read.ts +160 -0
  159. package/src/hub/sources.ts +311 -0
  160. package/src/hub/task-shell-default.ts +3 -2
  161. package/src/hub/tasks.ts +1047 -115
  162. package/src/hub/workflow.ts +1 -1
  163. package/src/local/deny.ts +21 -0
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/sandbox.ts +3 -1
  167. package/src/local/tools.ts +37 -3
  168. package/src/local/web-fetch-config.ts +43 -0
  169. package/src/local/web-fetch.ts +183 -0
  170. package/src/memory/brief.ts +85 -13
  171. package/src/memory/capture.ts +1 -1
  172. package/src/rooms/application.ts +114 -0
  173. package/src/rooms/boundary.ts +103 -0
  174. package/src/rooms/commands.ts +294 -0
  175. package/src/rooms/crypto.ts +371 -0
  176. package/src/rooms/link.ts +584 -0
  177. package/src/rooms/policy.ts +82 -0
  178. package/src/rooms/remote-peer.ts +80 -0
  179. package/src/rooms/service.ts +426 -0
  180. package/src/rooms/status.ts +146 -0
  181. package/src/rooms/store.ts +1210 -0
  182. package/src/rooms/wire.ts +465 -0
  183. package/src/ui/index.html +91 -4
  184. 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)) link.tracked.set(msg.id, 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
- /** Reserved until 2b supplies the restrictive ACP product members; not an executable ACP leg yet. */
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) || !(ACP_LEGS as readonly string[]).includes(leg) && leg !== ACP_PORTS_LEG) throw new Error(`${key}: stale or unknown unsupported ACP leg`);
22
- if (leg === ACP_PORTS_LEG && reason !== "ports added in 2b") throw new Error(`${key}: reserved ACP ports leg requires reason ports added in 2b`);
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
- send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: args.includes("--max-tokens-stop") ? "max_tokens" : "end_turn" } });
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 { isFixedBuiltinDefinition, type AdapterDefinition } from "../registry.ts";
7
+ import { type AdapterDefinition } from "../registry.ts";
6
8
 
7
9
  export async function until(condition: () => boolean, label: string): Promise<void> {
8
10
  const deadline = Date.now() + 5000;
@@ -24,12 +26,13 @@ export interface AdapterConformance {
24
26
  create(): Promise<ConformanceFixture>;
25
27
  }
26
28
 
27
- /** Presence itself is forbidden on a foreign product: even an undefined native proof port must fail the kit. */
28
- export function assertNativeProofPorts(definition: AdapterDefinition | undefined, product: PeerAdapter): void {
29
- if (isFixedBuiltinDefinition(definition)) return;
30
- for (const port of ["frontendProxy", "statusNotice", "revertTurn"] as const) {
31
- if (port in product) throw new Error(`${definition?.kind ?? product.id}: ${port} is restricted to fixed builtin adapter products`);
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, LaunchContext, LaunchPlan, StartForm, NativeModeSpec } from "../registry-types.ts";
25
+ import { claudeContext, codexContext, acpContextValue, jcodeContextValue } from "./native-context.ts";
26
+ import type { AdapterDefinition } from "../registry-types.ts";
28
27
  import type { PeerAdapter } from "../../hub/peers.ts";
29
- import { shellQuote } from "../../cli/shell-quote.ts";
30
-
31
- type CompatibilityKind = "claude" | "codex" | "kimi" | "pi" | "local" | "jcode";
32
-
33
- /**
34
- * Preserve CLI-wrapper argv plans only for the frozen A1 compatibility export.
35
- * Native startup and daemon factories are supplied separately by the runtime definitions.
36
- */
37
- function compatibilityLaunch(kind: CompatibilityKind, ctx: LaunchContext, explicitMode = false): LaunchPlan {
38
- const mode = explicitMode || kind === "kimi" || kind === "pi" ? ["--mode", ctx.form] : [];
39
- const unattended = ctx.unattended && (kind === "claude" || kind === "codex") ? ["--unattended"] : [];
40
- const args = [ctx.entrypoint, "--project", ctx.projectRoot, kind, ...mode, ...unattended,
41
- ...(ctx.model && !ctx.userArgs.includes("--model") ? ["--model", ctx.model] : []), ...ctx.userArgs];
42
- const argv = [process.execPath, ...args];
43
- const env = {
44
- ...(ctx.env.CODEX_HOME !== undefined ? { CODEX_HOME: ctx.env.CODEX_HOME } : {}),
45
- ...(ctx.env.CLAUDE_CONFIG_DIR !== undefined ? { CLAUDE_CONFIG_DIR: ctx.env.CLAUDE_CONFIG_DIR } : {}),
46
- };
47
- const prefix = Object.entries(env).map(([name, value]) => `${name}=${shellQuote(value)}`);
48
- return { cmd: process.execPath, args, ...(unattended.length ? { unattended: true } : {}),
49
- metadata: { packageEntrypoint: ctx.entrypoint, argv, env, command: [...prefix, ...argv.map(shellQuote)].join(" ") } };
50
- }
51
28
 
52
29
  function sessionId(field: "sessionId" | "threadId"): (peer: PeerAdapter) => string | undefined {
53
30
  return peer => {
@@ -175,96 +152,8 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
175
152
  identity: { mcp: "project-file" }, instances: { mode: "headless", nativeHome: true },
176
153
  permission: { unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
177
154
  ask: "no verified hub mode mapping; native policy only", "ask-when-needed": "no verified hub mode mapping; native policy only", "never-ask": "no verified hub mode mapping; native policy only" } },
178
- session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode" },
155
+ session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode", context: { source: "jcode_usage_update", read: jcodeContextValue } },
179
156
  // Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
180
157
  cli: { command: "jcode", entry: { lifecycleAdmission: true, execute: runJcodeEntry }, preview: previewJcode, usage: [["ahub jcode [--mode tui|headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>] [--print-command]", "optional Jcode (roster.jcode, default off): its TUI on the hub-owned ACP session, opened once that session is saved; --mode headless for ACP only; native policy unmanaged, no hub sandbox"]], authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords: mode => ["jcode", "--mode", mode] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
181
158
  },
182
159
  ] satisfies AdapterDefinition[]);
183
-
184
- // Frozen A1 metadata is a compatibility snapshot, not the runtime definition contract.
185
- export type LegacyAdapterDefinition = AdapterDefinition & {
186
- init?: { templateFragment?: string; mcpFile?: string };
187
- start: AdapterDefinition["start"] & { ownsTerminal(form: StartForm): boolean; launch(ctx: LaunchContext): LaunchPlan };
188
- permission: AdapterDefinition["permission"] & { modes: Partial<Record<PermissionMode, NativeModeSpec>>; readOnly?: NativeModeSpec };
189
- };
190
- const terminal = (form: StartForm) => form === "tui";
191
- const headless = () => false;
192
- /**
193
- * ponytail: retain the released A1 metadata export for source/library compatibility until A4 retires it.
194
- * Core runtime consumers use NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN through the registry exclusively.
195
- * The frozen jcode entry stays byte-identical: its headless `launch` emits `jcode` without `--mode`, which the CLI now
196
- * resolves to the TUI (#464, #559). Callers of this export must pass `--mode headless` themselves.
197
- */
198
- const palette = (fg: string) => ({ palette: { fg, bg: "transparent" } });
199
- export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly LegacyAdapterDefinition[] = Object.freeze([
200
- {
201
- api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
202
- roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
203
- start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("claude", ctx), conductorStartable: true,
204
- unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]) },
205
- identity: { mcp: "channel-plugin" },
206
- permission: { modes: {}, grants: { ask: "Claude's own permission rules decide",
207
- "ask-when-needed": "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules",
208
- "never-ask": "every tool runs without asking" } },
209
- session: { idField: "sessionId", currentId: sessionId("sessionId"), configDirEnv: "CLAUDE_CONFIG_DIR", zeroTurnFresh: true, launchKind: "claude-channel", launchRecord: true },
210
- turnEnd: "native-hook", budget: { account: "claude", checkpoint: true },
211
- cli: { command: "claude", startWords: () => ["claude"] }, display: palette("#6495ed"), hooks: { recallPlatform: "claude" },
212
- },
213
- {
214
- api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
215
- roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
216
- start: { forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("codex", ctx), conductorStartable: true,
217
- unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--remote-auth-token-env", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
218
- identity: { mcp: "env" },
219
- permission: { modes: { ask: { kind: "native-only", note: "restore the session's captured native approval policy" },
220
- "ask-when-needed": { kind: "codex-approval", policy: "on-request" }, "never-ask": { kind: "codex-approval", policy: "never" } },
221
- grants: { ask: "the session's own approval policy applies again", "ask-when-needed": "approval policy on-request on every turn", "never-ask": "approval policy never on every turn: nothing asks" } },
222
- session: { idField: "threadId", currentId: sessionId("threadId"), configDirEnv: "CODEX_HOME", zeroTurnFresh: true, launchKind: "codex", launchRecord: true },
223
- turnEnd: "adapter", budget: { account: "codex", checkpoint: true },
224
- cli: { command: "codex", startWords: () => ["codex"] }, display: palette("#22b8cf"), hooks: { recallPlatform: "codex" },
225
- },
226
- {
227
- api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
228
- roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
229
- start: { forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("kimi", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
230
- // ACP sessions carry headless identity; the existing CLI alone owns the TUI's project MCP entry.
231
- identity: { mcp: "acp-session" }, attach: { pullOnlyForms: Object.freeze(["tui"] as const) },
232
- permission: { modes: { ask: { kind: "acp-mode", modeId: "default" }, "ask-when-needed": { kind: "acp-mode", modeId: "yolo" }, "never-ask": { kind: "acp-mode", modeId: "auto" } },
233
- grants: { ask: "Kimi asks as it does on its own", "ask-when-needed": "Kimi's yolo mode: it asks only where it judges it must", "never-ask": "Kimi's auto mode: nothing asks" },
234
- unmanagedForms: Object.freeze(["tui"] as const), unmanagedNote: "Kimi approves tools and selects its permission mode in its own TUI; no hub permission mode" },
235
- session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp" },
236
- turnEnd: "adapter", budget: { account: "kimi" }, capabilities: { watch: true }, init: { mcpFile: ".kimi-code/mcp.json" },
237
- cli: { command: "kimi", startWords: form => ["kimi", "--mode", form] }, display: palette("#bc73ca"),
238
- },
239
- {
240
- api: 1, kind: "pi", displayName: "Pi", family: "pi", transport: "hub-native",
241
- roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
242
- start: { forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("pi", ctx), conductorStartable: true, autoStart: "pi.auto_start", hubSwitches: [], ownedFlags: [] },
243
- identity: { mcp: "env" },
244
- permission: { modes: {}, grants: { ...hubGrants } },
245
- session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "pi", launchRecord: true },
246
- turnEnd: "adapter", budget: { account: "pi", noQuotaReason: "on-prem backends have no provider quota" },
247
- cli: { command: "pi", startWords: form => ["pi", "--mode", form] }, display: palette("#c79936"),
248
- },
249
- {
250
- api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
251
- roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
252
- start: { forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("local", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
253
- identity: { mcp: "env" },
254
- permission: { modes: {}, grants: { ...hubGrants } },
255
- session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local" },
256
- turnEnd: "adapter",
257
- cli: { command: "local", startWords: () => ["local"] }, display: palette("#4caa77"),
258
- },
259
- {
260
- api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
261
- roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
262
- start: { forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("jcode", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
263
- identity: { mcp: "project-file" },
264
- permission: { modes: {}, unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
265
- ask: "no verified hub mode mapping; native policy only", "ask-when-needed": "no verified hub mode mapping; native policy only", "never-ask": "no verified hub mode mapping; native policy only" } },
266
- session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode" }, turnEnd: "adapter", budget: { account: "jcode" },
267
- // Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
268
- cli: { command: "jcode", startWords: () => ["jcode"] }, display: palette("#8a8f99"),
269
- },
270
- ] satisfies LegacyAdapterDefinition[]);
@@ -9,7 +9,7 @@ export function nativeConfigurationDefaults(ctx: { home: string; env: Readonly<R
9
9
  commands: { kimi_cmd: ["kimi", "acp"], qwen_cmd: ["qwen", "--acp"], jcode_cmd: ["jcode"], jcode_home: ctx.env.JCODE_HOME ?? join(ctx.home, ".jcode"), jcode_provider_profile: "" },
10
10
  proxy: { codex_bin: "codex" },
11
11
  rpc: { pi: { enabled: false, auto_start: false, cmd: ["pi"], backend: "auto" as const, dgx_coding: "coding", dgx_fast: "fast", max_steps: 30 } },
12
- sandbox: { local: { deny: [] as string[], bash_network: false, network_allow: ctx.networkAllow, max_steps: 30, read_allow: [] as string[] } },
12
+ sandbox: { local: { deny: [] as string[], bash_network: false, network_allow: ctx.networkAllow, max_steps: 30, read_allow: [] as string[], doc_read: false, web_fetch: { allow: [] as string[] } } },
13
13
  };
14
14
  }
15
15
  export function mergeNativeConfiguration(file: Record<string, any>, defaults: HubConfig): Pick<HubConfig, "local" | "pi"> {
@@ -1,12 +1,13 @@
1
1
  import type { NativeConfigCheck, NativeConfigValidators } from "../registry-types.ts";
2
2
  import type { SettingDef } from "../../hub/settings.ts";
3
+ import { webFetchAllow } from "../../local/web-fetch-config.ts";
3
4
 
4
5
  /** Native config uses the host's checked schema vocabulary, retaining its shape/path introspection. */
5
6
  export function piConfigSchema(v: NativeConfigValidators): Record<string, NativeConfigCheck> {
6
7
  return { pi: v.block({ enabled: v.bool, auto_start: v.bool, cmd: v.argv, backend: v.words(["auto", "dgx", "mlx"]), dgx_coding: v.text, dgx_fast: v.text, max_steps: v.number(1, 1_000_000, true) }) };
7
8
  }
8
9
  export function localConfigSchema(v: NativeConfigValidators): Record<string, NativeConfigCheck> {
9
- return { local: v.block({ deny: v.strings, bash_network: (value, key) => { if (value !== "direct") v.bool(value, key); }, network_allow: v.strings, max_steps: v.number(1, 1_000_000, true), read_allow: v.strings }) };
10
+ return { local: v.block({ deny: v.strings, bash_network: (value, key) => { if (value !== "direct") v.bool(value, key); }, network_allow: v.strings, max_steps: v.number(1, 1_000_000, true), read_allow: v.strings, doc_read: v.bool, web_fetch: v.block({ allow: (value, key) => { webFetchAllow(value); void key; } }) }) };
10
11
  }
11
12
  export function piRoutingSchema(v: NativeConfigValidators): Record<string, NativeConfigCheck> {
12
13
  return { pi: v.block({ dgx_max_context_tokens: v.number(), mlx_max_context_tokens: v.number(), efficient_wait_ms: v.number(0, 119999) }) };
@@ -24,7 +25,7 @@ export const PI_RESTART_REASONS: Readonly<Record<string, string>> = {
24
25
  };
25
26
  export const LOCAL_RESTART_REASONS: Readonly<Record<string, string>> = {
26
27
  "local.deny": "Existing native sessions retain their sandbox deny policy", "local.read_allow": "Existing native sessions retain their sandbox read grants",
27
- "local.bash_network": "Existing sandbox and egress proxy contracts require a new instance", "local.network_allow": "Existing egress proxy ownership requires a new instance",
28
+ "local.bash_network": "Existing sandbox and egress proxy contracts require a new instance", "local.network_allow": "Existing egress proxy ownership requires a new instance", "local.doc_read": "The worker's tool list is bound when its session starts", "local.web_fetch": "The worker's tool list and its web egress proxy are bound when the hub starts",
28
29
  };
29
30
  export function piEffectiveConfig(raw: Record<string, unknown>, fail: (key: string, reason: string) => never): void {
30
31
  const object = (value: unknown, key: string): Record<string, unknown> => {
@@ -33,3 +33,15 @@ export function acpContextValue(value: unknown, sessionId: string, measuredAt: n
33
33
  const raw = object(value);
34
34
  return acpContext({ used: raw.used as number, size: raw.size as number }, sessionId, measuredAt);
35
35
  }
36
+
37
+ /**
38
+ * Jcode 0.99.1's ACP `usage_update` is `{ used, size }` (docs/verification/2026-10-28-jcode-0.99.1-usage.md): `used` is the context the last provider call held (its input, and the
39
+ * cached part where the provider reports it apart), `size` the model's context limit. It is occupancy of the live session, so it is a reading, never consumption; it says nothing
40
+ * of quota. Anything but two finite integers with a positive size is unknown, and a `used` above `size` clamps the fraction only (tokens keeps what was reported).
41
+ */
42
+ export function jcodeContextValue(value: unknown, sessionId: string, measuredAt: number): ContextReading {
43
+ const raw = object(value);
44
+ const okay = Number.isSafeInteger(raw.used) && (raw.used as number) >= 0 && Number.isSafeInteger(raw.size) && (raw.size as number) > 0;
45
+ const tokens = okay ? raw.used as number : null, window = okay ? raw.size as number : null;
46
+ return { source: "jcode_usage_update", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
47
+ }
@@ -46,3 +46,51 @@ export function externalMetadataDefinitions(raw: unknown): readonly AdapterDefin
46
46
  cli: { command: kind, startWords: () => [kind] }, display: { palette: { fg: "white", bg: "black" } },
47
47
  })));
48
48
  }
49
+
50
+ export type ExternalInstanceRole = "implementer" | "verifier" | "reviewer";
51
+ export type ExternalInstanceRefusal = "catalog-unavailable" | "module-refused" | "kind-unavailable" | "binding-changed" | "ownership-unverified" | "off-roster" | "not-started";
52
+ export interface ExternalInstanceMetadata {
53
+ readonly id: string;
54
+ readonly kind: string;
55
+ readonly displayName: string;
56
+ readonly roles: readonly ExternalInstanceRole[];
57
+ readonly form: "headless";
58
+ readonly readOnly: boolean;
59
+ readonly permissionState: "ask" | "ask-when-needed" | "never-ask" | "unmanaged" | "unknown";
60
+ readonly availability: "ready" | "offline" | "declared-unknown" | "refused";
61
+ readonly refusalCode?: ExternalInstanceRefusal;
62
+ }
63
+ /** An authenticated observation, never an executable definition or runtime admission receipt. */
64
+ export interface ExternalInstanceCatalog {
65
+ readonly schema: 1;
66
+ readonly projectRoot: string;
67
+ readonly epoch: string;
68
+ readonly fingerprint: string;
69
+ readonly instances: readonly ExternalInstanceMetadata[];
70
+ }
71
+ const INSTANCE_ID = /^[a-z][a-z0-9-]{0,31}$/;
72
+ const INSTANCE_ROLES: readonly string[] = ["implementer", "verifier", "reviewer"];
73
+ const INSTANCE_REFUSALS: readonly string[] = ["catalog-unavailable", "module-refused", "kind-unavailable", "binding-changed", "ownership-unverified", "off-roster", "not-started"];
74
+ /** Validate the whole bounded projection; an incomplete or widened row is never silently accepted. */
75
+ export function externalInstanceCatalog(raw: unknown): ExternalInstanceCatalog {
76
+ const catalog = moduleDataObject(raw);
77
+ if (Object.keys(catalog).some(key => !["schema", "projectRoot", "epoch", "fingerprint", "instances"].includes(key)) || catalog.schema !== 1
78
+ || typeof catalog.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(catalog.fingerprint)) return fail("invalid instance catalog metadata");
79
+ const instances = moduleArray(catalog.instances, 64).map(value => {
80
+ const row = moduleDataObject(value);
81
+ if (Object.keys(row).some(key => !["id", "kind", "displayName", "roles", "form", "readOnly", "permissionState", "availability", "refusalCode"].includes(key))
82
+ || typeof row.id !== "string" || !INSTANCE_ID.test(row.id) || typeof row.kind !== "string" || !INSTANCE_ID.test(row.kind)
83
+ || row.form !== "headless" || typeof row.readOnly !== "boolean"
84
+ || typeof row.permissionState !== "string" || !["ask", "ask-when-needed", "never-ask", "unmanaged", "unknown"].includes(row.permissionState)
85
+ || typeof row.availability !== "string" || !["ready", "offline", "declared-unknown", "refused"].includes(row.availability)
86
+ || Object.hasOwn(row, "refusalCode") && (typeof row.refusalCode !== "string" || !INSTANCE_REFUSALS.includes(row.refusalCode))) return fail("invalid instance display metadata");
87
+ const roles = moduleArray(row.roles, 3);
88
+ if (roles.some(role => typeof role !== "string" || !INSTANCE_ROLES.includes(role)) || new Set(roles).size !== roles.length) return fail("invalid instance role metadata");
89
+ return Object.freeze({ id: row.id, kind: row.kind, displayName: moduleText(row.displayName, 256), roles: Object.freeze(roles as ExternalInstanceRole[]),
90
+ form: "headless" as const, readOnly: row.readOnly, permissionState: row.permissionState as ExternalInstanceMetadata["permissionState"],
91
+ availability: row.availability as ExternalInstanceMetadata["availability"], ...(row.refusalCode !== undefined ? { refusalCode: row.refusalCode as ExternalInstanceRefusal } : {}) });
92
+ });
93
+ if (new Set(instances.map(row => row.id)).size !== instances.length) return fail("duplicate external instance metadata");
94
+ return Object.freeze({ schema: 1, projectRoot: moduleText(catalog.projectRoot, 4096), epoch: moduleText(catalog.epoch, 128),
95
+ fingerprint: catalog.fingerprint, instances: Object.freeze(instances) });
96
+ }