@staix/agent-hub 0.14.0 → 0.16.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 (120) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +23 -9
  3. package/docs/agent-notes/README.md +8 -0
  4. package/docs/agent-notes/adapters.md +21 -4
  5. package/docs/agent-notes/budget.md +6 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +20 -9
  8. package/docs/agent-notes/local-worker.md +2 -2
  9. package/docs/agent-notes/tasks.md +21 -4
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/dogfood.md +19 -5
  12. package/docs/events.md +21 -7
  13. package/docs/memory.md +78 -10
  14. package/docs/operations.md +197 -39
  15. package/docs/profiles.md +1 -0
  16. package/docs/quickstart.md +16 -6
  17. package/docs/sdlc.md +17 -0
  18. package/docs/security.md +110 -21
  19. package/docs/smoke.md +7 -3
  20. package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
  21. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  22. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  23. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  24. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  25. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  26. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  27. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  28. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  29. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  30. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  31. package/docs/verified.json +40 -19
  32. package/package.json +2 -2
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +3109 -2046
  35. package/src/adapters/acp.ts +46 -7
  36. package/src/adapters/claude-channel.ts +26 -4
  37. package/src/adapters/claude-implementer.ts +243 -0
  38. package/src/adapters/claude-print-child.ts +90 -0
  39. package/src/adapters/claude-task-sessions.ts +80 -0
  40. package/src/adapters/codex-appserver.ts +69 -1
  41. package/src/adapters/compat.ts +6 -2
  42. package/src/adapters/conformance/enrollment.ts +2 -1
  43. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  44. package/src/adapters/definitions/builtin-metadata.ts +17 -5
  45. package/src/adapters/definitions/native-channel-state.ts +1 -1
  46. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  47. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  48. package/src/adapters/definitions/native-factories.ts +8 -0
  49. package/src/adapters/kimi-wire-usage.ts +303 -0
  50. package/src/adapters/local-worker.ts +5 -4
  51. package/src/adapters/registry-types.ts +8 -4
  52. package/src/adapters/registry.ts +14 -2
  53. package/src/adapters/reviewer-print.ts +30 -53
  54. package/src/cli/console-config.ts +123 -0
  55. package/src/cli/console-state.ts +48 -3
  56. package/src/cli/console-symbols.ts +3 -2
  57. package/src/cli/console-usage.ts +5 -2
  58. package/src/cli/console.ts +30 -2
  59. package/src/cli/facts-hook.ts +9 -5
  60. package/src/cli/help.ts +10 -7
  61. package/src/cli/identity.ts +3 -1
  62. package/src/cli/launch.ts +10 -4
  63. package/src/cli/main.ts +91 -19
  64. package/src/cli/memory.ts +119 -7
  65. package/src/cli/output.ts +40 -4
  66. package/src/cli/reset.ts +1 -1
  67. package/src/cli/settings-edit.ts +89 -0
  68. package/src/cli/terminal-recovery.ts +1 -6
  69. package/src/hub/action-class.ts +211 -17
  70. package/src/hub/action-enforce.ts +90 -0
  71. package/src/hub/action-policy.ts +40 -17
  72. package/src/hub/ask.ts +16 -4
  73. package/src/hub/board.ts +110 -31
  74. package/src/hub/budget-notes.ts +65 -0
  75. package/src/hub/budget.ts +44 -7
  76. package/src/hub/bus.ts +6 -6
  77. package/src/hub/claude-launch-proof.ts +33 -0
  78. package/src/hub/code-facts.ts +56 -16
  79. package/src/hub/conductor.ts +5 -2
  80. package/src/hub/config-apply.ts +12 -0
  81. package/src/hub/config-edit-host.ts +173 -0
  82. package/src/hub/config-edit.ts +282 -0
  83. package/src/hub/config-tier.ts +89 -0
  84. package/src/hub/control-client.ts +24 -3
  85. package/src/hub/daemon.ts +614 -104
  86. package/src/hub/deliverable-files.ts +131 -0
  87. package/src/hub/deliverables.ts +125 -0
  88. package/src/hub/delivery-journal.ts +18 -2
  89. package/src/hub/envelope.ts +1 -1
  90. package/src/hub/events.ts +5 -3
  91. package/src/hub/hub-tools.ts +22 -6
  92. package/src/hub/instances.ts +7 -4
  93. package/src/hub/kimi-usage.ts +156 -0
  94. package/src/hub/lifecycle.ts +10 -5
  95. package/src/hub/manager.ts +1 -1
  96. package/src/hub/memory-code-head.ts +44 -0
  97. package/src/hub/memory-code-overlay.ts +119 -0
  98. package/src/hub/memory-plan-guard.ts +10 -1
  99. package/src/hub/memory-stale-anchors.ts +159 -0
  100. package/src/hub/memory-symbols.ts +74 -0
  101. package/src/hub/peers.ts +7 -0
  102. package/src/hub/permission-payload.ts +15 -0
  103. package/src/hub/planning.ts +10 -4
  104. package/src/hub/read-tools.ts +21 -3
  105. package/src/hub/routing.ts +118 -3
  106. package/src/hub/sdlc.ts +34 -0
  107. package/src/hub/settings.ts +18 -3
  108. package/src/hub/settled-roles.ts +15 -3
  109. package/src/hub/task-sweep.ts +25 -3
  110. package/src/hub/tasks.ts +551 -75
  111. package/src/hub/token-window.ts +46 -0
  112. package/src/hub/usage-rollup.ts +16 -6
  113. package/src/hub/window-kind.ts +13 -0
  114. package/src/hub/workflow.ts +16 -0
  115. package/src/local/tools.ts +5 -5
  116. package/src/memory/brief.ts +22 -12
  117. package/src/memory/capture.ts +26 -40
  118. package/src/memory/client.ts +137 -65
  119. package/src/memory/recall.ts +13 -22
  120. package/src/ui/index.html +215 -19
@@ -6,6 +6,7 @@ import { BasePeer } 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
+ import { ACTION_BUDGET_OPTION, type ActionGateVerdict, type PermissionRequest } from "./acp.ts";
9
10
 
10
11
  const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
11
12
 
@@ -15,6 +16,9 @@ export interface CodexOptions {
15
16
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
16
17
  proxyPort: number;
17
18
  permissionMode?: PermissionMode;
19
+ /** Host-only always-ask gate, shared with ACP; an absent verdict preserves native handling. */
20
+ actionGate?: (call: { names: readonly string[]; kind?: string; input: unknown }, session: string) => ActionGateVerdict | undefined;
21
+ onPermission?: (request: PermissionRequest, signal?: AbortSignal) => Promise<string | undefined>;
18
22
  /** Port for the spawned `codex app-server`. Ignored when `upstreamUrl` is set. */
19
23
  appPort: number;
20
24
  /** Attach to an already running app-server instead of spawning one (tests). */
@@ -65,6 +69,7 @@ interface Link {
65
69
  tui: ServerWebSocket<Link>;
66
70
  up: WebSocket;
67
71
  backlog: string[]; // TUI frames that arrived before the upstream socket opened
72
+ approvalIds: Set<string | number>; // intercepted native ids stay tombstoned for this socket lifetime
68
73
  tracked: Map<string | number, string>; // request id -> method, for thread/start and thread/resume
69
74
  }
70
75
 
@@ -78,6 +83,8 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
78
83
  */
79
84
  export class CodexPeer extends BasePeer {
80
85
  private permissionMode: PermissionMode;
86
+ private readonly actionWaits = new Map<AbortController, { link: Link; turn: string | undefined }>();
87
+ get actionGated(): boolean { return this.opts.actionGate !== undefined; }
81
88
  private readonly nativeApprovalPolicies = new Map<string, unknown>();
82
89
  private readonly overriddenApprovals = new Set<string>();
83
90
  private readonly restoringApprovals = new Map<string | number, string>();
@@ -200,6 +207,7 @@ export class CodexPeer extends BasePeer {
200
207
  }
201
208
 
202
209
  async stop(): Promise<void> {
210
+ this.abortActionWaits();
203
211
  this.server?.stop(true);
204
212
  this.claimedTui?.tui.close(1001, "hub shutting down");
205
213
  const proc = this.proc;
@@ -353,6 +361,7 @@ export class CodexPeer extends BasePeer {
353
361
 
354
362
  /** A silent turn is interrupted before the peer is declared idle, or the next turn/start would land inside it. */
355
363
  protected override onWatchdog(): void {
364
+ this.abortActionWaits();
356
365
  this.deliveryEpoch++;
357
366
  const known = new Set([...this.activeTurns, ...this.turnDeliveries.keys()].filter(id => !id.startsWith("unknown:")));
358
367
  const pendingStarts = [...this.pending.values()].filter(pending => pending.kind === "deliver");
@@ -379,6 +388,7 @@ export class CodexPeer extends BasePeer {
379
388
  }
380
389
 
381
390
  private retireTurn(turnId: string): void {
391
+ this.abortActionWaits(undefined, turnId);
382
392
  this.retiredTurns.add(turnId);
383
393
  if (this.link?.up.readyState === WebSocket.OPEN) this.link.up.send(JSON.stringify({ method: "turn/interrupt", id: this.nextId--, params: { threadId: this.threadId, turnId } }));
384
394
  }
@@ -445,7 +455,7 @@ export class CodexPeer extends BasePeer {
445
455
 
446
456
  private attach(tui: ServerWebSocket<Link>, upstream: string): void {
447
457
  const up = new WebSocket(upstream);
448
- const link: Link = { tui, up, backlog: [], tracked: new Map(), ...(tui.data.launchId ? { launchId: tui.data.launchId } : {}) };
458
+ const link: Link = { tui, up, backlog: [], tracked: new Map(), approvalIds: new Set(), ...(tui.data.launchId ? { launchId: tui.data.launchId } : {}) };
449
459
  this.claimedTui = link;
450
460
  tui.data = link;
451
461
  up.onopen = () => {
@@ -456,6 +466,7 @@ export class CodexPeer extends BasePeer {
456
466
  }
457
467
 
458
468
  private detach(link: Link): void {
469
+ this.abortActionWaits(link);
459
470
  link.up.close();
460
471
  if (this.claimedTui === link) this.claimedTui = undefined;
461
472
  if (this.link !== link) return;
@@ -539,6 +550,8 @@ export class CodexPeer extends BasePeer {
539
550
 
540
551
  private fromTui(link: Link, raw: string): void {
541
552
  const msg = parse(raw);
553
+ if (msg?.id !== undefined && !msg.method && link.approvalIds.has(msg.id)) return;
554
+ if (msg?.method === "turn/interrupt" && link === this.link) this.abortActionWaits(link, typeof msg.params?.turnId === "string" ? msg.params.turnId : undefined);
542
555
  if (msg?.method === "turn/start" && msg.params && link === this.link) {
543
556
  const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
544
557
  // A resumed TUI can echo the sticky hub overlay. The restoring turn must use the kept baseline.
@@ -561,6 +574,7 @@ export class CodexPeer extends BasePeer {
561
574
  if (this.state === "busy" && !(typeof nativeId === "string" && this.retiredTurns.has(nativeId))) this.touch();
562
575
  if (!msg) return void link.tui.send(raw);
563
576
 
577
+ if (this.interceptActionApproval(link, msg)) return;
564
578
  if (msg.id !== undefined && !msg.method) this.settlePermissionRestore(msg.id, !!msg.error);
565
579
  if (typeof msg.id === "number" && msg.id < 0 && !msg.method) {
566
580
  const p = this.pending.get(msg.id);
@@ -583,8 +597,61 @@ export class CodexPeer extends BasePeer {
583
597
  link.tui.send(raw);
584
598
  }
585
599
 
600
+ private abortActionWaits(link?: Link, turn?: string): void {
601
+ for (const [controller, wait] of this.actionWaits) if ((!link || wait.link === link) && (!turn || wait.turn === turn)) controller.abort();
602
+ }
603
+
604
+ /** Only the two native command schemas verified on 0.163.0. Missing payloads keep their native policy. */
605
+ private interceptActionApproval(link: Link, msg: any): boolean {
606
+ if (!this.opts.actionGate || !(typeof msg.id === "string" || Number.isSafeInteger(msg.id))) return false;
607
+ const legacy = msg.method === "execCommandApproval", params = msg.params;
608
+ if (!legacy && msg.method !== "item/commandExecution/requestApproval") return false;
609
+ if (!params || typeof params !== "object") return false;
610
+ if (link.approvalIds.has(msg.id)) return true; // one intercepted callback has exactly one host answer
611
+ if (legacy ? !Array.isArray(params.command) || !params.command.length || !params.command.every((word: unknown) => typeof word === "string")
612
+ : typeof params.command !== "string" || !params.command.length || params.kind != null && params.kind !== "command") return false;
613
+ const thread = legacy ? params.conversationId : params.threadId;
614
+ let gate: ActionGateVerdict | undefined;
615
+ try { gate = this.opts.actionGate({ names: ["shell"], kind: "execute", input: { command: params.command, cwd: params.cwd } }, typeof thread === "string" ? thread : ""); }
616
+ catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; }
617
+ if (!gate) return false;
618
+ const verdict = gate;
619
+ // ponytail: retain at most 1024 intercepted ids per socket, never evict a tombstone and accept a late native answer.
620
+ // Reattach is the upgrade path after this finite lifetime cap; closing both sides fails closed.
621
+ if (link.approvalIds.size >= 1024) { this.abortActionWaits(link); link.up.close(); link.tui.close(1008, "Codex action approval id capacity reached; reattach"); return true; }
622
+ link.approvalIds.add(msg.id);
623
+ const named = [...this.activeTurns].filter(turn => !turn.startsWith("unknown:") && !this.retiredTurns.has(turn));
624
+ const turn = legacy ? named.length === 1 ? named[0] : undefined : typeof params.turnId === "string" ? params.turnId : undefined;
625
+ const epoch = this.contextEpoch, controller = new AbortController();
626
+ const live = () => !controller.signal.aborted && link === this.link && epoch === this.contextEpoch && thread === this.threadId &&
627
+ typeof turn === "string" && this.activeTurns.has(turn) && !this.retiredTurns.has(turn) && !this.unidentifiedCancellation && this.state === "busy" &&
628
+ link.tui.readyState === WebSocket.OPEN && link.up.readyState === WebSocket.OPEN;
629
+ const answer = (allow: boolean) => { if (link.up.readyState === WebSocket.OPEN) link.up.send(JSON.stringify({ id: msg.id, result: { decision: legacy ? allow ? "approved" : "abort" : allow ? "accept" : "decline" } })); };
630
+ if (!live()) { answer(false); return true; } // no invented native turn binding, even when the command is classed
631
+ this.actionWaits.set(controller, { link, turn });
632
+ const options: PermissionRequest["options"] = [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }];
633
+ if (verdict.denyOnly) options.splice(0, 1); // keep only denial, even if a broken callback later returns allow
634
+ if (!verdict.denyOnly && verdict.budget) options.push({ optionId: ACTION_BUDGET_OPTION, name: `Allow ${verdict.budget.label} for this session`, kind: "allow_once" });
635
+ const alive = setInterval(() => { if (live()) this.touch(); }, Math.max(10, Math.min(30_000, Math.floor(this.watchdogMs / 3))));
636
+ void (async () => {
637
+ let picked: string | undefined;
638
+ try {
639
+ const cancelled = new Promise<undefined>(resolve => controller.signal.addEventListener("abort", () => resolve(undefined), { once: true }));
640
+ picked = await Promise.race([this.opts.onPermission?.({ peer: this.id, title: verdict.title, tool: "shell", kind: "execute", classed: true, options }, controller.signal), cancelled]);
641
+ } catch { /* a broken or missing approval handler denies */ }
642
+ finally { clearInterval(alive); this.actionWaits.delete(controller); }
643
+ let allow = !verdict.denyOnly && live() && picked === "allow";
644
+ if (!verdict.denyOnly && live() && verdict.budget && picked === ACTION_BUDGET_OPTION) {
645
+ try { allow = verdict.budget.approve() && live(); } catch { allow = false; }
646
+ }
647
+ answer(allow);
648
+ })();
649
+ return true;
650
+ }
651
+
586
652
  private adopt(link: Link, threadId: unknown, fresh = false): void {
587
653
  if (typeof threadId !== "string" || !threadId || link !== this.claimedTui || link.tui.readyState !== WebSocket.OPEN || link.up.readyState !== WebSocket.OPEN) return;
654
+ this.abortActionWaits();
588
655
  this.abandonSteers("thread changed");
589
656
  for (const [id, pending] of this.pending) if (pending.kind === "deliver") {
590
657
  pending.invalidate?.("Codex native thread changed before acceptance was confirmed");
@@ -699,6 +766,7 @@ export class CodexPeer extends BasePeer {
699
766
  if (item.phase !== "commentary" && text.trim()) this.lastAnswer = text.trim();
700
767
  } else if (method === "turn/completed") {
701
768
  const nativeTurn = params.turn?.id;
769
+ this.abortActionWaits(link, typeof nativeTurn === "string" ? nativeTurn : undefined);
702
770
  // A watchdog or a newer turn has invalidated this completion. Unknown-id legacy starts retain their fallback.
703
771
  if (!this.activeTurns.size || typeof nativeTurn === "string" && !this.activeTurns.has(nativeTurn) && ![...this.activeTurns].some(id => id.startsWith("unknown:"))) return;
704
772
  this.activeTurns.delete(nativeTurn);
@@ -27,8 +27,12 @@ export interface CompatPolicy {
27
27
 
28
28
  const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
29
29
  export const COMPAT = {
30
- // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md
31
- "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
30
+ // Receipt: docs/verification/2026-10-23-headless-claude-permissions.md
31
+ "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309"], gate: "contract", probe: ["claude", "--version"],
32
+ parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
33
+ trustNote: "The native prompt-tool allow, deny, MCP error/disconnect and hook-failure fallthrough have an isolated receipt. No dontAsk/bypass fallback exists; another version's permission behavior is unverified." },
34
+ // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md
35
+ "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
32
36
  parse: out => plain(out.trim().replace(/^codex-cli /, "")),
33
37
  trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md)." },
34
38
  // Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md
@@ -20,7 +20,8 @@ export function assertAcpLegEnrollment(definitions: readonly AdapterDefinition[]
20
20
  }
21
21
  export function definitionIdentities(definitions: readonly AdapterDefinition[]): { id: string; definition: AdapterDefinition }[] {
22
22
  return definitions.flatMap(definition => [{ id: definition.kind, definition },
23
- ...(definition.reviewerVariant ? [{ id: `${definition.kind}:reviewer`, definition: definition.reviewerVariant }] : [])]);
23
+ ...(definition.reviewerVariant ? [{ id: `${definition.kind}:reviewer`, definition: definition.reviewerVariant }] : []),
24
+ ...(definition.headlessVariant ? [{ id: `${definition.kind}:implementer`, definition: definition.headlessVariant }] : [])]);
24
25
  }
25
26
  export function assertEnrollment(definitions: readonly AdapterDefinition[], enrolled: readonly string[], unsupported: Readonly<Record<string, string>>, unsupportedLegs: Readonly<Record<string, string>> = {}, coherence?: (definition: AdapterDefinition) => void): void {
26
27
  assertAcpLegEnrollment(definitions, unsupportedLegs);
@@ -103,7 +103,9 @@ async function prompt(id: number, text: string) {
103
103
  if (text.includes("PERMISSION")) {
104
104
  // Kimi 2.0.1's shape: the arguments travel on the tool_call update, the permission request has none.
105
105
  const announced = text.includes("ANNOUNCED");
106
- if (announced) send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "s1", update: { sessionUpdate: "tool_call", toolCallId: "tc1", title: "Bash", status: "pending", rawInput: "rm -rf build && make" } } });
106
+ // `ACTIONCMD[<command>]` (#636) replaces the announced command, so a test can send a classed or an unclassed shell call.
107
+ const actionCommand = text.match(/ACTIONCMD\[([^\]]+)\]/)?.[1];
108
+ if (announced) send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "s1", update: { sessionUpdate: "tool_call", toolCallId: "tc1", title: "Bash", status: "pending", rawInput: actionCommand ? { command: actionCommand } : "rm -rf build && make" } } });
107
109
  // Kimi 2.1.1's shape (captured 2026-09-30, issue #72): no rawInput before the answer; the argument JSON
108
110
  // streams as content text on tool_call_update, cumulatively, and the request itself carries none.
109
111
  const tool = text.includes("HUBTOOL") || text.includes("NOONCE") ? "mcp__agent-hub__hub_task_list" : text.includes("SPOOF") ? "mcp__agent-hub__rm_rf" : "Bash";
@@ -19,7 +19,7 @@ import { piRecoveryContinuity } from "./native-recovery-continuity.ts";
19
19
  import { piConductorStartArgs } from "./native-cli-pi.ts";
20
20
  import { claudeNativeLaunch, CLAUDE_LAUNCH_POLICY } from "./native-cli-claude-launch.ts";
21
21
  import { codexNativeLaunch, CODEX_LAUNCH_POLICY } from "./native-cli-codex-launch.ts";
22
- import { createCodex, createKimi, createJcode, createPi, createLocal, createClaudeReviewer } from "./native-factories.ts";
22
+ import { createCodex, createKimi, createJcode, createPi, createLocal, createClaudeReviewer, createClaudeImplementer } from "./native-factories.ts";
23
23
  import { CLAUDE_DISPLAY, CODEX_DISPLAY, KIMI_DISPLAY, PI_DISPLAY, LOCAL_DISPLAY, JCODE_DISPLAY } from "./native-display.ts";
24
24
  import { CLAUDE_RECOVERY_SESSION, CODEX_RECOVERY_SESSION, KIMI_RECOVERY_SESSION, PI_RECOVERY_SESSION, LOCAL_RECOVERY_SESSION, JCODE_RECOVERY_SESSION } from "./native-recovery.ts";
25
25
  import { claudeWindows, codexWindows } from "./native-quota.ts";
@@ -80,6 +80,18 @@ const CLAUDE_REVIEWER_VARIANT: AdapterDefinition = {
80
80
  cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
81
81
  };
82
82
 
83
+ /** #525 R5a: no idle child; the host binds an owned worktree and one native session per task. */
84
+ const CLAUDE_IMPLEMENTER_VARIANT: AdapterDefinition = {
85
+ api: 1, kind: "claude", displayName: "Claude Code implementer", family: "claude", transport: "hub-native",
86
+ roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
87
+ start: { create: createClaudeImplementer, daemonPort: "claude-implementer", forms: headlessForms, defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
88
+ identity: { mcp: "env", currentChild: true }, instances: { mode: "headless", roles: ["implementer", "verifier"] },
89
+ permission: { grants: { ask: "every write, edit and shell command asks", "ask-when-needed": "file tools inside the owned worktree may run automatically; shell and protected paths ask" } },
90
+ session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: false, launchKind: "claude-implementer", crashPlan: () => ({ resume: {}, how: "Claude implementer resumes its recorded per-task sessions" }) },
91
+ turnEnd: "adapter", budget: { account: "claude" },
92
+ cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
93
+ };
94
+
83
95
  // Sources: peer-catalog.ts roles, roster.ts defaults, start-mode.ts forms, permission-mode.ts grants,
84
96
  // daemon.ts runtime mappings/stop policy, budget.ts exemptions, and ui/peer-palette.ts CSS foregrounds.
85
97
  export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
@@ -97,7 +109,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
97
109
  session: { idField: "sessionId", currentId: sessionId("sessionId"), configDirEnv: "CLAUDE_CONFIG_DIR", zeroTurnFresh: true, launchKind: "claude-channel", launchRecord: true, ...CLAUDE_RECOVERY_SESSION, crashPlan: claudeCrashPlan, unrecordedTerminal: "always" },
98
110
  turnEnd: "native-hook", budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
99
111
  cli: { command: "claude", entry: { lifecycleAdmission: true, execute: runClaudeEntry }, preview: previewClaude, usage: [["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"]], retainedTerminalNotice: claudeRetainedTerminalNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY, hooks: { recallPlatform: "claude", recallSource: "claude", recallInjected: true, pluginInventory: claudePluginInventory },
100
- reviewerVariant: CLAUDE_REVIEWER_VARIANT,
112
+ reviewerVariant: CLAUDE_REVIEWER_VARIANT, headlessVariant: CLAUDE_IMPLEMENTER_VARIANT,
101
113
  },
102
114
  {
103
115
  reviewerVariant: CODEX_REVIEWER_VARIANT,
@@ -137,7 +149,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
137
149
  // Approval enforcement belongs to the verified native transport and host permission gate.
138
150
  permission: { grants: { ...hubGrants } },
139
151
  session: { idField: "sessionId", currentId: sessionId("sessionId"), restoreOnStart: true, launchKind: "pi", launchRecord: true, ...PI_RECOVERY_SESSION, crashPlan: piCrashPlan, unrecordedTerminal: "tui", continuityBookmark: piRecoveryContinuity, continuity: { key: "pi", parse: parsePiContinuity } },
140
- turnEnd: "adapter", budget: { account: "pi", instrumented: true }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
152
+ turnEnd: "adapter", budget: { account: "pi", instrumented: true, noQuotaReason: "on-prem backends have no provider quota" }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
141
153
  routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
142
154
  cli: { command: "pi", entry: { lifecycleAdmission: false, execute: runPiEntry }, preview: previewPi, usage: [["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id>] [--session-file <path>] [--replace-session]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"]], launchConfigKeys: ["pi.cmd", "pi.backend", "pi.dgx_coding", "pi.dgx_fast", "pi.max_steps", "routing.pi", "peers.pi.start_mode"], previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
143
155
  },
@@ -150,7 +162,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
150
162
  // Approval enforcement belongs to the verified native transport and host permission gate.
151
163
  permission: { grants: { ...hubGrants } },
152
164
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
153
- turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
165
+ turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true, noQuotaReason: "on-prem gateway has no provider quota" }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
154
166
  routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
155
167
  cli: { command: "local", entry: { lifecycleAdmission: false, execute: runLocalEntry }, usage: [["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"]], launchConfigKeys: ["local.deny", "local.bash_network", "local.network_allow", "local.max_steps", "local.read_allow", "routing.local"], startWords: () => ["local"] }, display: LOCAL_DISPLAY,
156
168
  },
@@ -231,7 +243,7 @@ export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly LegacyAdapterDefinition[
231
243
  identity: { mcp: "env" },
232
244
  permission: { modes: {}, grants: { ...hubGrants } },
233
245
  session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "pi", launchRecord: true },
234
- turnEnd: "adapter", budget: { account: "pi" },
246
+ turnEnd: "adapter", budget: { account: "pi", noQuotaReason: "on-prem backends have no provider quota" },
235
247
  cli: { command: "pi", startWords: form => ["pi", "--mode", form] }, display: palette("#c79936"),
236
248
  },
237
249
  {
@@ -10,7 +10,7 @@ export function claudePeer(value: string | undefined): string {
10
10
  const CLAUDE_PEER = /^claude(?:-[a-z0-9-]+)?$/;
11
11
 
12
12
  /** Per-peer Claude state file names. The builtin claude peer keeps its original names, so nothing migrates. */
13
- export function claudeStateFile(base: "claude-launch" | "claude-session" | "claude-context", peer: string): string {
13
+ export function claudeStateFile(base: "claude-launch" | "claude-session" | "claude-context" | "claude-task-sessions", peer: string): string {
14
14
  // #624: another kind (agy is a hyphen-free builtin too) must never resolve to Claude's own files.
15
15
  if (!CLAUDE_PEER.test(peer) || !PEER.test(peer)) throw new Error(`${peer} is not a Claude peer; Claude state files belong to Claude peers only`);
16
16
  return peer.includes("-") ? `${base}.${peer}.json` : `${base}.json`;
@@ -8,11 +8,14 @@ import { claudeStateFile } from "../../hub/claude-state.ts";
8
8
 
9
9
  export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void> {
10
10
  const { args, cwd, stateDir, cliDir, unattendedEnv, projectConfig, fail } = ctx;
11
- // #450 I3: a claude-<name> instance keeps its own identity, launch and recovery records; it never owns a worktree.
11
+ // Each terminal instance keeps its canonical hub identity and its separately validated execution root.
12
12
  const peer = ctx.instance ?? "claude";
13
13
  if (ctx.hubStarted) {
14
- // #223: the hub starts a headless reviewer and runs one `claude -p` per review; nothing runs in this terminal.
15
- if (args.includes("--print-command") || args.includes("--dry-run")) return console.log(JSON.stringify({ peer, start: "hub", note: "a headless reviewer the hub runs per review" }, null, 2));
14
+ // Both builtin headless variants are hub-owned; nothing runs in this terminal.
15
+ const declaration = ctx.instance ? projectConfig().instances?.[ctx.instance] : undefined;
16
+ const writer = declaration?.roles?.includes("implementer") && declaration.start_mode === "headless";
17
+ const description = writer ? "a headless implementer the hub runs per task" : "a headless reviewer the hub runs per review";
18
+ if (args.includes("--print-command") || args.includes("--dry-run")) return console.log(JSON.stringify({ peer, start: "hub", note: description }, null, 2));
16
19
  const hub = await ctx.connect();
17
20
  try {
18
21
  const snapshot = (await hub.request({ t: "status" })).status;
@@ -20,17 +23,19 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
20
23
  const res = await hub.request({ t: "start", peer, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
21
24
  if (res.ok !== true) fail(String(res.error ?? `${peer} was not started`));
22
25
  } finally { hub.close(); }
23
- return console.log(`${peer} started: a headless reviewer the hub runs per review (stop it with ahub stop ${peer})`);
26
+ return console.log(`${peer} started: ${description} (stop it with ahub stop ${peer})`);
24
27
  }
25
28
  const { launcherPreview, readControl, prepareTerminalLaunch, registerTerminalLaunch, claudeObservationHooks, buildLaunch, runClaudeLaunch, processSignature, recordClaudeLaunch, cleanupStaleClaudeSettings, nativeLaunchEnv, childEnv, cleanupClaudeSettings } = ctx.helpers;
26
29
  if (args.includes("--print-command") || args.includes("--dry-run")) {
27
30
  try { return console.log(JSON.stringify(launcherPreview("claude", args, cwd, stateDir, unattendedEnv), null, 2)); }
28
31
  catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
29
32
  }
33
+ const executionCwd = ctx.instance ? ctx.helpers.resolveExecutionCwd(peer) : cwd;
34
+ ctx.executionCwd = executionCwd;
30
35
  const control = readControl(stateDir);
31
36
  if (control?.instanceId) {
32
37
  process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
33
- const terminal = await prepareTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: cwd } : undefined);
38
+ const terminal = await prepareTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd } : undefined);
34
39
  if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
35
40
  else {
36
41
  const hub = await ctx.connect();
@@ -41,7 +46,7 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
41
46
  // `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
42
47
  // project local, then project, then user.
43
48
  let original: { command?: string; refreshInterval?: number; padding?: number } | undefined;
44
- for (const file of [join(cwd, ".claude", "settings.local.json"), join(cwd, ".claude", "settings.json"), join(process.env.HOME ?? "", ".claude", "settings.json")]) {
49
+ for (const file of [join(executionCwd, ".claude", "settings.local.json"), join(executionCwd, ".claude", "settings.json"), join(process.env.HOME ?? "", ".claude", "settings.json")]) {
45
50
  try {
46
51
  original ??= JSON.parse(readFileSync(file, "utf8")).statusLine;
47
52
  } catch {
@@ -58,14 +63,14 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
58
63
  let launchRecord: ClaudeLaunchRecord | undefined;
59
64
  if (control?.instanceId) {
60
65
  // Publish only the final launch's installed hook and effective permission flags.
61
- launchRecord = { ...(ctx.instance ? { peer } : {}), instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID!, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
66
+ launchRecord = { ...(ctx.instance ? { peer, executionCwd } : {}), instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID!, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
62
67
  recordClaudeLaunch(stateDir, launchRecord);
63
68
  cleanupStaleClaudeSettings(stateDir, previousLaunch);
64
69
  }
65
70
  if (launch.warning) console.error(launch.warning);
66
71
  const env = nativeLaunchEnv("claude", childEnv());
67
72
  if (ctx.instance) env.AGENTHUB_PEER_ID = peer; // the channel plugin and the statusline tee write for this instance
68
- result = await runClaudeLaunch(launch, { cwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...(launchRecord ? { record: launchRecord } : {}) });
73
+ result = await runClaudeLaunch(launch, { cwd: executionCwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...(launchRecord ? { record: launchRecord } : {}) });
69
74
  } finally {
70
75
  cleanupClaudeSettings(stateDir, launch.settingsFile);
71
76
  }
@@ -16,6 +16,8 @@ export interface NativeCliEntryContext {
16
16
  execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): never;
17
17
  takeFlags(argv: string[], single: string[], repeated: string[]): { one: Record<string, string>; many: Record<string, string[]>; rest: string[] };
18
18
  helpers: {
19
+ /** Fixed host validation; the entry never chooses or creates an execution worktree. */
20
+ resolveExecutionCwd(peer: string): string;
19
21
  launcherPreview: typeof import("../../cli/preview.ts").launcherPreview;
20
22
  readControl: typeof import("../../hub/control-client.ts").readControl;
21
23
  recordTerminalLaunch: typeof import("../../cli/terminal-recovery.ts").recordTerminalLaunch;
@@ -60,6 +60,13 @@ export function createReviewer(ctx: CreateContext): PeerAdapter {
60
60
  return new ReviewerPrintPeer(ctx.instance, ctx.daemon.reviewerPrint);
61
61
  }
62
62
 
63
+ export function createClaudeImplementer(ctx: CreateContext): PeerAdapter {
64
+ if (!("claudeImplementer" in ctx.daemon)) throw new Error("Claude implementer requires its per-task transport options");
65
+ if (ctx.permission !== "ask" && ctx.permission !== "ask-when-needed") throw new Error("Claude implementer requires ask or ask-when-needed");
66
+ const { ClaudeImplementerPeer } = require("../claude-implementer.ts") as typeof import("../claude-implementer.ts");
67
+ return new ClaudeImplementerPeer(ctx.instance, ctx.daemon.claudeImplementer);
68
+ }
69
+
63
70
  /** Preserve existing transport option types and object identity; selection stays host-owned. */
64
71
  export const DAEMON_PORTS = {
65
72
  "codex-appserver": (options: Extract<DaemonPorts, { codex: unknown }>["codex"]) => ({ codex: options }),
@@ -68,5 +75,6 @@ export const DAEMON_PORTS = {
68
75
  "pi-rpc": (options: Extract<DaemonPorts, { pi: unknown }>["pi"]) => ({ pi: options }),
69
76
  "local-worker": (options: Extract<DaemonPorts, { local: unknown }>["local"]) => ({ local: options }),
70
77
  "reviewer-print": (options: Extract<DaemonPorts, { reviewerPrint: unknown }>["reviewerPrint"]) => ({ reviewerPrint: options }),
78
+ "claude-implementer": (options: Extract<DaemonPorts, { claudeImplementer: unknown }>["claudeImplementer"]) => ({ claudeImplementer: options }),
71
79
  "claude-print": (options: Extract<DaemonPorts, { claudePrint: unknown }>["claudePrint"]) => ({ claudePrint: options }),
72
80
  } satisfies Record<DaemonEngine, unknown>;