@staix/agent-hub 0.19.0 → 0.20.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 (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/adapters.md +3 -2
  4. package/docs/agent-notes/bus.md +1 -1
  5. package/docs/agent-notes/daemon.md +6 -2
  6. package/docs/agent-notes/local-worker.md +1 -0
  7. package/docs/agent-notes/tasks.md +4 -2
  8. package/docs/agent-notes/tests.md +4 -3
  9. package/docs/dogfood.md +4 -1
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +40 -8
  12. package/docs/quickstart.md +7 -3
  13. package/docs/sdlc.md +12 -0
  14. package/docs/security.md +4 -4
  15. package/docs/specs/2026-09-19-agent-hub-design.md +85 -3
  16. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  17. package/docs/verified.json +24 -16
  18. package/package.json +2 -2
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +103 -7
  21. package/src/adapters/acp.ts +8 -0
  22. package/src/adapters/channel-peer.ts +7 -5
  23. package/src/adapters/claude-implementer.ts +1 -0
  24. package/src/adapters/codex-appserver.ts +11 -1
  25. package/src/adapters/conformance/enrollment.ts +4 -4
  26. package/src/adapters/conformance/factories.ts +2 -1
  27. package/src/adapters/conformance/kit.ts +10 -0
  28. package/src/adapters/local-worker.ts +2 -0
  29. package/src/adapters/pi.ts +7 -0
  30. package/src/adapters/reviewer-print.ts +1 -0
  31. package/src/hub/ci-watch.ts +140 -0
  32. package/src/hub/claude-launch-proof.ts +67 -2
  33. package/src/hub/config-apply.ts +2 -2
  34. package/src/hub/config-trust.ts +1 -0
  35. package/src/hub/daemon.ts +86 -79
  36. package/src/hub/peers.ts +34 -0
  37. package/src/hub/settings.ts +1 -0
  38. package/src/hub/task-shell-default.ts +3 -2
  39. package/src/hub/tasks.ts +139 -7
  40. package/src/local/deny.ts +21 -0
  41. package/src/local/sandbox.ts +3 -1
@@ -85,7 +85,8 @@ function memoryGitEnv(env = process.env, options = {}) {
85
85
  }
86
86
  function memoryGitSync(args, options) {
87
87
  const argv = memoryGitArgv(args);
88
- return childProcess().spawnSync(argv[0], argv.slice(1), { ...ambientGitTimeout !== undefined ? { timeout: ambientGitTimeout, killSignal: "SIGKILL" } : {}, ...options, env: memoryGitEnv(options.env ?? process.env) });
88
+ const timeout = gitTimeoutContext?.getStore();
89
+ return childProcess().spawnSync(argv[0], argv.slice(1), { ...timeout !== undefined ? { timeout, killSignal: "SIGKILL" } : {}, ...options, env: memoryGitEnv(options.env ?? process.env) });
89
90
  }
90
91
  function processSignature(pid) {
91
92
  try {
@@ -121,7 +122,7 @@ var require2, processes, childProcess = () => processes ??= require2("node:child
121
122
  const parent = dirname(absolute);
122
123
  return parent === absolute ? absolute : join(canonical(parent), basename(absolute));
123
124
  }
124
- }, DENY_SEGMENTS, DENY_NAMES, denyNamePatterns, hasSegment = (rel, seg) => `/${rel}/`.includes(`/${seg}/`), SAFE_CONFIG, CREDENTIAL_KEYS, memoryGitArgv = (args, opts = {}) => ["git", "--no-pager", ...SAFE_CONFIG.filter((c) => !opts.credentials || !CREDENTIAL_KEYS.has(c)).flatMap((c) => ["-c", c]), ...args], ambientGitTimeout;
125
+ }, DENY_SEGMENTS, DENY_NAMES, denyNamePatterns, hasSegment = (rel, seg) => `/${rel}/`.includes(`/${seg}/`), SAFE_CONFIG, CREDENTIAL_KEYS, memoryGitArgv = (args, opts = {}) => ["git", "--no-pager", ...SAFE_CONFIG.filter((c) => !opts.credentials || !CREDENTIAL_KEYS.has(c)).flatMap((c) => ["-c", c]), ...args], gitTimeoutContext;
125
126
  var init_platform = __esm(() => {
126
127
  require2 = createRequire(import.meta.url);
127
128
  DENY_SEGMENTS = [".maru/secrets", ".agenthub/state", ".agenthub/archive"];
@@ -1415,6 +1416,7 @@ var init_reviewer_print = __esm(() => {
1415
1416
  ReviewerPrintPeer = class ReviewerPrintPeer extends BasePeer {
1416
1417
  opts;
1417
1418
  hubNative = true;
1419
+ hubOwned = true;
1418
1420
  generation = 0;
1419
1421
  child;
1420
1422
  childCleanup;
@@ -2943,6 +2945,9 @@ var init_acp = __esm(() => {
2943
2945
  };
2944
2946
  AcpPeer = class AcpPeer extends BasePeer {
2945
2947
  opts;
2948
+ deliveryOnly = true;
2949
+ hubOwned = true;
2950
+ form = "headless";
2946
2951
  permissionMode;
2947
2952
  nativePolicy;
2948
2953
  modeUnknown = false;
@@ -3058,6 +3063,10 @@ var init_acp = __esm(() => {
3058
3063
  recoveryMetadata() {
3059
3064
  return { launch: { kind: "acp", ...this.opts.launchModel ? { model: this.opts.launchModel } : {} }, ...this.sessionId ? { sessionId: this.sessionId } : {} };
3060
3065
  }
3066
+ get nativeSession() {
3067
+ const session = this.recoveryMetadata().sessionId;
3068
+ return typeof session === "string" ? session : undefined;
3069
+ }
3061
3070
  async start() {
3062
3071
  if (this.opts.requireOwnedGroup && this.proc)
3063
3072
  throw new Error("Configured ACP still has a process; stop it before starting another");
@@ -3664,6 +3673,7 @@ var init_codex_appserver = __esm(() => {
3664
3673
  TRACKED = new Set(["thread/start", "thread/resume"]);
3665
3674
  CodexPeer = class CodexPeer extends BasePeer {
3666
3675
  opts;
3676
+ frontendProxy;
3667
3677
  permissionMode;
3668
3678
  actionWaits = new Map;
3669
3679
  get actionGated() {
@@ -3708,6 +3718,17 @@ var init_codex_appserver = __esm(() => {
3708
3718
  super(id, opts.watchdogMs);
3709
3719
  this.opts = opts;
3710
3720
  this.permissionMode = opts.permissionMode ?? "ask";
3721
+ const owner = this;
3722
+ this.frontendProxy = {
3723
+ get url() {
3724
+ return owner.proxyUrl;
3725
+ },
3726
+ get attached() {
3727
+ return owner.proxyAttached;
3728
+ },
3729
+ setStartupPermissionMode: this.setStartupPermissionMode.bind(this),
3730
+ clearPermissionMode: this.clearPermissionMode.bind(this)
3731
+ };
3711
3732
  }
3712
3733
  recoveryMetadata() {
3713
3734
  return {
@@ -3718,6 +3739,9 @@ var init_codex_appserver = __esm(() => {
3718
3739
  get thread() {
3719
3740
  return this.threadId;
3720
3741
  }
3742
+ get nativeSession() {
3743
+ return this.threadId || undefined;
3744
+ }
3721
3745
  get proxyUrl() {
3722
3746
  return `ws://127.0.0.1:${this.server?.port ?? this.opts.proxyPort}`;
3723
3747
  }
@@ -4324,6 +4348,9 @@ ${renderDigest(envs, false)}`;
4324
4348
  link.up.send(JSON.stringify({ method: "thread/revert", id, params: { threadId: this.threadId, beforeTurnId: turnId } }));
4325
4349
  });
4326
4350
  }
4351
+ revertTurn(nativeTurn) {
4352
+ return this.revert(nativeTurn);
4353
+ }
4327
4354
  readUsage() {
4328
4355
  const link = this.link;
4329
4356
  if (!this.opts.onUsage || !link || link.up.readyState !== WebSocket.OPEN)
@@ -5902,6 +5929,15 @@ function hubWriteRegexes(root, gitDirs = [], moduleGit = [], worktreesRoot) {
5902
5929
  ...external
5903
5930
  ];
5904
5931
  }
5932
+ function objectStoreRules(common) {
5933
+ const objects = `${common}/objects`, o = sbplEscape(objects);
5934
+ const re = (s) => `(regex ${sbplString(s)})`;
5935
+ return [
5936
+ `(deny file-write* (subpath ${sbplString(`${objects}/info`)}) (subpath ${sbplString(`${objects}/pack`)}) ${[objects, `${objects}/info`, `${objects}/pack`].map((p) => `(literal ${sbplString(p)})`).join(" ")})`,
5937
+ `(deny file-write-unlink ${re(`^${o}/[0-9a-f][0-9a-f]$`)})`,
5938
+ `(deny file-write-data file-write-unlink file-write-mode file-write-flags ${re(`^${o}/[0-9a-f][0-9a-f]/[0-9a-f]+$`)})`
5939
+ ];
5940
+ }
5905
5941
  function denyRegexes(root, extra = [], withNames = true) {
5906
5942
  const names = DENY_NAMES.map((re) => {
5907
5943
  const body = re.startsWith("^") ? re.slice(1) : `[^/]*${re}`;
@@ -6045,6 +6081,7 @@ function profile(cwd, network, readAllow = [], deny = [], readOnly = []) {
6045
6081
  "(deny file-write*)",
6046
6082
  ...readOnly.length ? ['(allow file-write* (literal "/dev/null"))'] : [`(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`],
6047
6083
  `(deny file-write* ${hubWriteRegexes(root, gitDirs, moduleGit, worktrees).join(" ")})`,
6084
+ ...worktrees ? objectStoreRules(dirname9(worktrees)) : [],
6048
6085
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join12(home, c))})`).join(" ")})`,
6049
6086
  `(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
6050
6087
  ...network ? [`(allow file-read* ${CA_BUNDLES.map((p) => `(literal ${q(p)})`).join(" ")} (regex ${q("/certifi/cacert\\.pem$")}))`, `(deny file-read* file-write* ${places})`] : []
@@ -6464,6 +6501,7 @@ var init_pi = __esm(() => {
6464
6501
  opts;
6465
6502
  preflight;
6466
6503
  hubNative = true;
6504
+ hubOwned = true;
6467
6505
  proc;
6468
6506
  server;
6469
6507
  buffer = "";
@@ -6577,6 +6615,10 @@ var init_pi = __esm(() => {
6577
6615
  const file = this.sessionFile && (exists || !empty) ? this.sessionFile : undefined;
6578
6616
  return { launch: { kind: "pi", cwd: this.opts.cwd, mode: this.opts.mode, backend: this.opts.backend, ...this.opts.model ? { model: this.opts.model } : {}, ...file ? { sessionFile: file } : {} }, ...this.sessionId ? { sessionId: this.sessionId } : {}, ...file ? { sessionFile: file } : {} };
6579
6617
  }
6618
+ get nativeSession() {
6619
+ const session = this.recoveryMetadata().sessionId;
6620
+ return typeof session === "string" ? session : undefined;
6621
+ }
6580
6622
  async captureResume() {
6581
6623
  if (this.state === "offline") {
6582
6624
  if (this.canReuseVerifiedEmptyResume())
@@ -6941,6 +6983,9 @@ var init_pi = __esm(() => {
6941
6983
  get mode() {
6942
6984
  return this.opts.mode;
6943
6985
  }
6986
+ get form() {
6987
+ return this.opts.mode;
6988
+ }
6944
6989
  async detachForRoster() {
6945
6990
  if (this.opts.mode !== "tui")
6946
6991
  throw new Error("Pi roster detach requires a personal TUI");
@@ -10319,6 +10364,8 @@ var init_local_worker = __esm(() => {
10319
10364
  LocalPeer = class LocalPeer extends BasePeer {
10320
10365
  opts;
10321
10366
  hubNative = true;
10367
+ deliveryOnly = true;
10368
+ hubOwned = true;
10322
10369
  history = [];
10323
10370
  sessionId = `agent-hub-local-${randomUUID6()}`;
10324
10371
  routes;
@@ -11015,6 +11062,7 @@ var init_claude_implementer = __esm(() => {
11015
11062
  ClaudeImplementerPeer = class ClaudeImplementerPeer extends BasePeer {
11016
11063
  options;
11017
11064
  hubNative = true;
11065
+ hubOwned = true;
11018
11066
  generation = 0;
11019
11067
  owned;
11020
11068
  running;
@@ -13995,8 +14043,49 @@ var init_lifecycle = __esm(() => {
13995
14043
  init_shard();
13996
14044
  });
13997
14045
 
14046
+ // node_modules/@staix/agent-mem/src/memory/search-score.ts
14047
+ function sql(expr, rank) {
14048
+ const get = (part) => sql(part, rank);
14049
+ switch (expr.op) {
14050
+ case "rank":
14051
+ return rank;
14052
+ case "tier":
14053
+ return `CASE tier ${Object.entries(TIER_WEIGHTS).map(([name, value]) => `WHEN '${name}' THEN ${real(value)}`).join(" ")} ELSE ${real(DEFAULT_TIER_WEIGHT)} END`;
14054
+ case "age":
14055
+ return `((julianday(?) - julianday(at)) / ${real(MONTH_DAYS)})`;
14056
+ case "constant":
14057
+ return real(expr.value);
14058
+ case "negative":
14059
+ return `(-(${get(expr.value)}))`;
14060
+ case "reciprocal":
14061
+ return `(1.0 / ${get(expr.value)})`;
14062
+ case "add":
14063
+ return `(${get(expr.left)} + ${get(expr.right)})`;
14064
+ case "multiply":
14065
+ return expr.right.op === "reciprocal" ? `(${get(expr.left)} / ${get(expr.right.value)})` : `(${get(expr.left)} * ${get(expr.right)})`;
14066
+ case "max":
14067
+ return `max(${get(expr.left)}, ${get(expr.right)})`;
14068
+ }
14069
+ }
14070
+ function rankedScoreSql(rank = "bm25(docs)") {
14071
+ return sql(SCORE, rank);
14072
+ }
14073
+ var MONTH_DAYS = 30, TIER_WEIGHTS, DEFAULT_TIER_WEIGHT = 0.9, TIER_FACTOR, DENOMINATOR, RECENCY_FACTOR, SCORE, real = (value) => Number.isInteger(value) ? `${value}.0` : String(value);
14074
+ var init_search_score = __esm(() => {
14075
+ TIER_WEIGHTS = { long: 1, capture: 0.5 };
14076
+ TIER_FACTOR = { op: "tier" };
14077
+ DENOMINATOR = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
14078
+ RECENCY_FACTOR = { op: "reciprocal", value: DENOMINATOR };
14079
+ SCORE = { op: "multiply", left: { op: "multiply", left: { op: "negative", value: { op: "rank" } }, right: TIER_FACTOR }, right: RECENCY_FACTOR };
14080
+ });
14081
+
13998
14082
  // node_modules/@staix/agent-mem/src/memory/search-fts.ts
13999
- var init_search_fts = () => {};
14083
+ var SCREENED_SCORE, SCREENED_SUBSTRING_SCORE;
14084
+ var init_search_fts = __esm(() => {
14085
+ init_search_score();
14086
+ SCREENED_SCORE = rankedScoreSql();
14087
+ SCREENED_SUBSTRING_SCORE = rankedScoreSql("-1.0");
14088
+ });
14000
14089
 
14001
14090
  // node_modules/@staix/agent-mem/src/memory/capture-input.ts
14002
14091
  var init_capture_input = __esm(() => {
@@ -14252,6 +14341,7 @@ var init_store = __esm(() => {
14252
14341
  init_machine();
14253
14342
  init_lifecycle();
14254
14343
  init_machine();
14344
+ init_search_score();
14255
14345
  init_search_fts();
14256
14346
  init_shard();
14257
14347
  init_capture();
@@ -21344,7 +21434,8 @@ var MACHINE_LOCAL = [
21344
21434
  "local.read_allow",
21345
21435
  "local.bash_network",
21346
21436
  "local.network_allow",
21347
- "terminal.open"
21437
+ "terminal.open",
21438
+ "tasks.auto_pull"
21348
21439
  ];
21349
21440
  var ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"];
21350
21441
  function configRefusal(cwd, name) {
@@ -21715,7 +21806,7 @@ var tool2 = (name, description, inputSchema, readOnlyHint = true) => ({
21715
21806
  annotations: { readOnlyHint, destructiveHint: false, openWorldHint: false }
21716
21807
  });
21717
21808
  var MCP_TOOLS = [
21718
- tool2("amem_search", "Search screened memory in this project. Capture results are recall, not curated evidence.", object6({ query: string(1000), limit, since: string(40), includeCapture: { type: "boolean" } }, ["query"])),
21809
+ tool2("amem_search", "Search screened memory in this project. Capture results are recall, not curated evidence.", object6({ query: string(1000), limit, since: string(40), includeCapture: { type: "boolean" }, explain: { type: "boolean" } }, ["query"])),
21719
21810
  tool2("amem_evidence", "Return a bounded bundle of cited, live curated entries for a question. Untrusted memory data only: no answer or model call. Capture, repo-only notes, pending candidates and model answers are not evidence.", object6({ question: string(1000), limit: { type: "integer", minimum: 1, maximum: 20 }, maxBytes: { type: "integer", minimum: 256, maximum: 8192 } }, ["question"])),
21720
21811
  tool2("amem_show", "Show a screened memory entry in this project by its exact id or unique prefix.", object6({ id: string(80) }, ["id"])),
21721
21812
  tool2("amem_timeline", "Read screened memory near an entry or time in this project.", object6({ id: string(80), since: string(40), limit })),
@@ -21874,7 +21965,7 @@ init_sdlc_metrics();
21874
21965
  // package.json
21875
21966
  var package_default = {
21876
21967
  name: "@staix/agent-hub",
21877
- version: "0.19.0",
21968
+ version: "0.20.0",
21878
21969
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
21879
21970
  license: "MIT",
21880
21971
  type: "module",
@@ -21891,7 +21982,7 @@ var package_default = {
21891
21982
  bun: ">=1.4.2"
21892
21983
  },
21893
21984
  dependencies: {
21894
- "@staix/agent-mem": "0.10.0"
21985
+ "@staix/agent-mem": "0.11.0"
21895
21986
  },
21896
21987
  devDependencies: {
21897
21988
  "@modelcontextprotocol/sdk": "^1.27.1",
@@ -22007,6 +22098,10 @@ init_registry();
22007
22098
  // src/hub/task-sweep.ts
22008
22099
  var GROOMING_CODES = new Set(["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "runner", "approvals", "quota", "dependency", "no-writes"]);
22009
22100
 
22101
+ // src/hub/ci-watch.ts
22102
+ init_sdlc();
22103
+ var CI_WATCH_DEADLINE_MS = 45 * 60000;
22104
+
22010
22105
  // src/hub/tasks.ts
22011
22106
  init_sdlc();
22012
22107
  init_routing();
@@ -22016,6 +22111,7 @@ var QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory
22016
22111
  QUIET_EVENTS.add("ready held");
22017
22112
  QUIET_EVENTS.add("plan observed");
22018
22113
  QUIET_EVENTS.add("planner changed");
22114
+ QUIET_EVENTS.add("shell branch bound");
22019
22115
  var OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassigned"]);
22020
22116
  var GROOM_FYI_WINDOW_MS = 10 * 60000;
22021
22117
 
@@ -10,6 +10,7 @@ export { QwenIdentityError } from "./definitions/qwen-native.ts";
10
10
  import { BasePeer } from "../hub/peers.ts";
11
11
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
12
12
  import { AcpOwnedGroup } from "./acp-owned-group.ts";
13
+ import type { StartForm } from "./registry-types.ts";
13
14
 
14
15
  export interface PermissionOption {
15
16
  optionId: string;
@@ -163,6 +164,9 @@ function jsonObject(text: string | undefined): object | undefined {
163
164
 
164
165
  /** ACP client (JSON-RPC 2.0, newline-delimited, over the child's stdio). One session, one prompt in flight. */
165
166
  export class AcpPeer extends BasePeer {
167
+ readonly deliveryOnly: boolean = true;
168
+ readonly hubOwned: boolean = true;
169
+ readonly form: StartForm = "headless";
166
170
  private permissionMode: PermissionMode;
167
171
  private readonly nativePolicy: AcpOptions["nativePolicy"];
168
172
  private modeUnknown = false;
@@ -256,6 +260,10 @@ export class AcpPeer extends BasePeer {
256
260
  recoveryMetadata(): Record<string, unknown> {
257
261
  return { launch: { kind: "acp", ...(this.opts.launchModel ? { model: this.opts.launchModel } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}) };
258
262
  }
263
+ get nativeSession(): string | undefined {
264
+ const session = this.recoveryMetadata().sessionId;
265
+ return typeof session === "string" ? session : undefined;
266
+ }
259
267
 
260
268
  async start(): Promise<void> {
261
269
  if (this.opts.requireOwnedGroup && this.proc) throw new Error("Configured ACP still has a process; stop it before starting another");
@@ -1,6 +1,7 @@
1
1
  import { BasePeer, type PeerAdapter } from "../hub/peers.ts";
2
2
  import type { Envelope } from "../hub/envelope.ts";
3
3
  import { definitionOf } from "./registry.ts";
4
+ import type { StartForm } from "./registry-types.ts";
4
5
 
5
6
  /** Only the control socket operations the peer owns; authentication remains in the daemon. */
6
7
  export interface ChannelSocket {
@@ -12,6 +13,7 @@ export interface ChannelSocket {
12
13
 
13
14
  /** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
14
15
  export class WsPeer extends BasePeer {
16
+ readonly form: StartForm = "tui";
15
17
  private generation = crypto.randomUUID();
16
18
  // Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
17
19
  private claimGeneration = crypto.randomUUID();
@@ -23,7 +25,7 @@ export class WsPeer extends BasePeer {
23
25
  private delivered = new Set<string>();
24
26
  private sock: ChannelSocket | undefined;
25
27
  private claimed: ChannelSocket | undefined;
26
- private nativeSession: string | undefined;
28
+ private nativeTurnSession: string | undefined;
27
29
  private nativeActive = false;
28
30
  /** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
29
31
  pullOnly = false;
@@ -66,7 +68,7 @@ export class WsPeer extends BasePeer {
66
68
  this.sock = sock;
67
69
  this.generation = crypto.randomUUID();
68
70
  this.delivered.clear();
69
- this.nativeSession = undefined;
71
+ this.nativeTurnSession = undefined;
70
72
  this.nativeActive = false;
71
73
  this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
72
74
  // Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
@@ -112,13 +114,13 @@ export class WsPeer extends BasePeer {
112
114
  owns(sock: ChannelSocket): boolean { return this.sock === sock; }
113
115
  nativeStarted(session: string): void {
114
116
  if (!this.sock) return;
115
- this.nativeSession = session;
117
+ this.nativeTurnSession = session;
116
118
  this.nativeActive = true;
117
119
  this.setState("busy");
118
120
  }
119
121
  nativeStopped(session: string): boolean {
120
- if (!this.sock || (this.nativeSession !== undefined && this.nativeSession !== session)) return false;
121
- this.nativeSession = session;
122
+ if (!this.sock || (this.nativeTurnSession !== undefined && this.nativeTurnSession !== session)) return false;
123
+ this.nativeTurnSession = session;
122
124
  this.nativeActive = false;
123
125
  this.setState("idle");
124
126
  return true;
@@ -72,6 +72,7 @@ export async function proveClaudeImplementer(peer: string, command: readonly str
72
72
  /** One owned print child at a time. Idle holds no native process; task continuity is ids-only on disk. */
73
73
  export class ClaudeImplementerPeer extends BasePeer {
74
74
  readonly hubNative = true;
75
+ readonly hubOwned: boolean = true;
75
76
  private generation = 0;
76
77
  private owned: OwnedPrintChild | undefined;
77
78
  private running: Promise<void> | undefined;
@@ -2,7 +2,7 @@ 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";
@@ -82,6 +82,7 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
82
82
  * TUI's, and their responses are swallowed.
83
83
  */
84
84
  export class CodexPeer extends BasePeer {
85
+ readonly frontendProxy: FrontendProxyPort;
85
86
  private permissionMode: PermissionMode;
86
87
  private readonly actionWaits = new Map<AbortController, { link: Link; turn: string | undefined }>();
87
88
  get actionGated(): boolean { return this.opts.actionGate !== undefined; }
@@ -136,6 +137,13 @@ export class CodexPeer extends BasePeer {
136
137
  ) {
137
138
  super(id, opts.watchdogMs);
138
139
  this.permissionMode = opts.permissionMode ?? "ask";
140
+ const owner = this;
141
+ this.frontendProxy = {
142
+ get url() { return owner.proxyUrl; },
143
+ get attached() { return owner.proxyAttached; },
144
+ setStartupPermissionMode: this.setStartupPermissionMode.bind(this),
145
+ clearPermissionMode: this.clearPermissionMode.bind(this),
146
+ };
139
147
  }
140
148
 
141
149
  recoveryMetadata(): Record<string, unknown> {
@@ -149,6 +157,7 @@ export class CodexPeer extends BasePeer {
149
157
  get thread(): string {
150
158
  return this.threadId;
151
159
  }
160
+ get nativeSession(): string | undefined { return this.threadId || undefined; }
152
161
 
153
162
  get proxyUrl(): string {
154
163
  return `ws://127.0.0.1:${this.server?.port ?? this.opts.proxyPort}`;
@@ -694,6 +703,7 @@ export class CodexPeer extends BasePeer {
694
703
  link.up.send(JSON.stringify({ method: "thread/revert", id, params: { threadId: this.threadId, beforeTurnId: turnId } }));
695
704
  });
696
705
  }
706
+ revertTurn(nativeTurn: string): Promise<void> { return this.revert(nativeTurn); }
697
707
 
698
708
  /** account/rateLimits/read through the TUI's connection, with a hub id so the answer never reaches the TUI. */
699
709
  private readUsage(): void {
@@ -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. */
@@ -24,6 +24,15 @@ export interface AdapterConformance {
24
24
  create(): Promise<ConformanceFixture>;
25
25
  }
26
26
 
27
+ /** Every ACP factory product keeps the transport's restrictive delivery and ownership contract. */
28
+ export function assertAcpProductPorts(definition: AdapterDefinition | undefined, product: PeerAdapter): void {
29
+ if (definition?.transport !== "acp") return;
30
+ for (const port of ["deliveryOnly", "hubOwned"] as const) {
31
+ if (product[port] !== true) throw new Error(`${definition.kind}: ACP restrictive port ${port} must be true`);
32
+ }
33
+ if (product.form !== "headless") throw new Error(`${definition.kind}: ACP restrictive port form must be headless`);
34
+ }
35
+
27
36
  /** Presence itself is forbidden on a foreign product: even an undefined native proof port must fail the kit. */
28
37
  export function assertNativeProofPorts(definition: AdapterDefinition | undefined, product: PeerAdapter): void {
29
38
  if (isFixedBuiltinDefinition(definition)) return;
@@ -38,6 +47,7 @@ export function registerConformance(adapter: AdapterConformance): void {
38
47
  const f = await adapter.create();
39
48
  try {
40
49
  assertNativeProofPorts(adapter.definition, f.peer);
50
+ assertAcpProductPorts(adapter.definition, f.peer);
41
51
  expect(f.peer.id).toBe(adapter.kind);
42
52
  expect(f.peer.state).toBe("idle");
43
53
  const envelope: Envelope = newEnvelope("user", "conformance ping", { to: [adapter.kind] });
@@ -77,6 +77,8 @@ const system = (cwd: string, preamble = "") =>
77
77
  /** Hub-native agent loop: the hub owns every model call, so it can choose the model per call (L2) and the gateway (L3). */
78
78
  export class LocalPeer extends BasePeer {
79
79
  readonly hubNative = true;
80
+ readonly deliveryOnly: boolean = true;
81
+ readonly hubOwned: boolean = true;
80
82
  private readonly history: ChatMessage[] = [];
81
83
  private readonly sessionId = `agent-hub-local-${randomUUID()}`;
82
84
  private readonly routes: HubRouteRuntime;
@@ -12,6 +12,7 @@ import { PiSessionBookmarkMismatch, validatePiSession } from "../pi/session.ts";
12
12
  import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
13
13
  import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
14
14
  import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
15
+ import type { StartForm } from "./registry-types.ts";
15
16
 
16
17
  export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
17
18
  export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
@@ -99,6 +100,7 @@ function ownerStillAlive(pid: number, signature: string | undefined): boolean {
99
100
 
100
101
  export class PiPeer extends BasePeer {
101
102
  readonly hubNative = true;
103
+ readonly hubOwned: boolean = true;
102
104
  private proc?: ChildProcessWithoutNullStreams;
103
105
  private server?: ReturnType<typeof Bun.serve>;
104
106
  private buffer = "";
@@ -202,6 +204,10 @@ export class PiPeer extends BasePeer {
202
204
  const file = this.sessionFile && (exists || !empty) ? this.sessionFile : undefined;
203
205
  return { launch: { kind: "pi", cwd: this.opts.cwd, mode: this.opts.mode, backend: this.opts.backend, ...(this.opts.model ? { model: this.opts.model } : {}), ...(file ? { sessionFile: file } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}), ...(file ? { sessionFile: file } : {}) };
204
206
  }
207
+ get nativeSession(): string | undefined {
208
+ const session = this.recoveryMetadata().sessionId;
209
+ return typeof session === "string" ? session : undefined;
210
+ }
205
211
 
206
212
  /** Verify the live source before choosing ID-only restoration. Never infer emptiness from a missing file. */
207
213
  async captureResume(): Promise<Record<string, unknown>> {
@@ -502,6 +508,7 @@ export class PiPeer extends BasePeer {
502
508
  return pending.map((call) => call.settled);
503
509
  }
504
510
  get mode(): "headless" | "tui" { return this.opts.mode; }
511
+ get form(): StartForm { return this.opts.mode; }
505
512
 
506
513
  /** Withdraw hub admission without shutting down a person's Pi TUI or its bridge. */
507
514
  async detachForRoster(): Promise<void> {
@@ -156,6 +156,7 @@ const reviewTask = (env: Envelope): number | undefined => {
156
156
 
157
157
  export class ReviewerPrintPeer extends BasePeer {
158
158
  readonly hubNative = true;
159
+ readonly hubOwned: boolean = true;
159
160
  private generation = 0;
160
161
  private child: OwnedPrintChild | undefined;
161
162
  private childCleanup: (() => Promise<void>) | undefined;