@staix/agent-hub 0.12.43 → 0.12.44

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 (89) hide show
  1. package/CHANGELOG.md +16 -3
  2. package/README.md +3 -3
  3. package/docs/adding-an-ai-tool.md +140 -0
  4. package/docs/agent-notes/README.md +5 -0
  5. package/docs/agent-notes/adapters.md +8 -3
  6. package/docs/agent-notes/budget.md +2 -2
  7. package/docs/agent-notes/daemon.md +9 -5
  8. package/docs/agent-notes/tasks.md +6 -2
  9. package/docs/agent-notes/tests.md +3 -1
  10. package/docs/operations.md +64 -5
  11. package/docs/quickstart.md +6 -2
  12. package/docs/security.md +5 -3
  13. package/docs/specs/2026-09-19-agent-hub-design.md +160 -4
  14. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  15. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  16. package/docs/verified.json +34 -19
  17. package/package.json +1 -1
  18. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  19. package/plugins/agent-hub/server.js +743 -229
  20. package/src/adapters/acp-owned-group.ts +124 -0
  21. package/src/adapters/acp.ts +92 -25
  22. package/src/adapters/agy/definition.ts +1 -3
  23. package/src/adapters/agy/entry.ts +4 -1
  24. package/src/adapters/channel-peer.ts +143 -0
  25. package/src/adapters/claude-channel.ts +7 -7
  26. package/src/adapters/codex-appserver.ts +16 -2
  27. package/src/adapters/configured-acp.ts +124 -0
  28. package/src/adapters/definitions/builtin-metadata.ts +37 -33
  29. package/src/adapters/definitions/native-channel-state.ts +41 -0
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
  31. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  32. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  33. package/src/adapters/definitions/qwen.ts +3 -8
  34. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  35. package/src/adapters/jcode.ts +4 -0
  36. package/src/adapters/local-worker.ts +4 -0
  37. package/src/adapters/pi.ts +4 -0
  38. package/src/adapters/registry-types.ts +18 -16
  39. package/src/adapters/registry.ts +68 -6
  40. package/src/adapters/reviewer-print.ts +4 -0
  41. package/src/cli/agy-mcp.ts +3 -2
  42. package/src/cli/configured-acp-entry.ts +58 -0
  43. package/src/cli/console-state.ts +9 -4
  44. package/src/cli/facts-hook.ts +2 -1
  45. package/src/cli/help.ts +6 -0
  46. package/src/cli/identity.ts +7 -6
  47. package/src/cli/launch.ts +5 -4
  48. package/src/cli/main.ts +78 -27
  49. package/src/cli/memory.ts +13 -10
  50. package/src/cli/output.ts +1 -0
  51. package/src/cli/preview.ts +9 -0
  52. package/src/cli/recovery-guidance.ts +1 -1
  53. package/src/cli/terminal-recovery.ts +16 -13
  54. package/src/cli/upgrade-restore-state.ts +3 -2
  55. package/src/cli/upgrade-runtime.ts +21 -6
  56. package/src/cli/upgrade.ts +3 -2
  57. package/src/hub/board.ts +9 -6
  58. package/src/hub/budget.ts +9 -2
  59. package/src/hub/bus.ts +6 -2
  60. package/src/hub/claude-state.ts +2 -37
  61. package/src/hub/conductor.ts +8 -6
  62. package/src/hub/config-apply.ts +26 -14
  63. package/src/hub/config-reload.ts +39 -11
  64. package/src/hub/config-trust.ts +1 -1
  65. package/src/hub/context-window.ts +6 -0
  66. package/src/hub/control-client.ts +1 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +341 -313
  69. package/src/hub/hub-tools.ts +36 -0
  70. package/src/hub/instance-config.ts +20 -11
  71. package/src/hub/instance-execution.ts +4 -4
  72. package/src/hub/instances.ts +45 -21
  73. package/src/hub/lifecycle-schema.ts +5 -3
  74. package/src/hub/memory-profile.ts +16 -3
  75. package/src/hub/no-writes.ts +50 -0
  76. package/src/hub/permission-mode.ts +9 -7
  77. package/src/hub/roster.ts +7 -4
  78. package/src/hub/routing.ts +21 -20
  79. package/src/hub/settings.ts +50 -16
  80. package/src/hub/snapshots.ts +26 -2
  81. package/src/hub/start-mode.ts +10 -8
  82. package/src/hub/task-sweep.ts +8 -2
  83. package/src/hub/tasks.ts +85 -17
  84. package/src/memory/capture.ts +2 -1
  85. package/src/ui/index.html +5 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/profiles/general.json +1 -1
  88. package/templates/profiles/research.json +1 -1
  89. package/templates/profiles/writing.json +1 -1
@@ -5,6 +5,9 @@ import type { PermissionMode } from "../hub/permission-mode.ts";
5
5
  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
+ import type { ContextReader } from "./registry-types.ts";
9
+
10
+ const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
8
11
 
9
12
  export interface CodexOptions {
10
13
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
@@ -26,6 +29,8 @@ export interface CodexOptions {
26
29
  /** Tokens the thread used since the previous update (see `tokenTotal` for how they are counted). */
27
30
  onTokens?: (added: number) => void;
28
31
  onContext?: (reading: ContextReading) => void;
32
+ /** The definition's native occupancy reader; standalone proxies retain the native default. */
33
+ contextReader?: ContextReader;
29
34
  /** The native id of each turn as it starts, after the peer turned busy (issue #33: `ahub undo --context`). */
30
35
  onTurn?: (turnId: string) => void;
31
36
  /** Each thread the TUI starts (`fresh`) or resumes, as it is adopted (#215: which turns belong to which thread). */
@@ -90,6 +95,10 @@ export class CodexPeer extends BasePeer {
90
95
  /** Cancellation tombstones last until that exact native completion or session replacement. */
91
96
  private readonly retiredTurns = new Set<string>();
92
97
  private unidentifiedCancellation = false;
98
+ /** Fixed operator guidance only; native identifiers and agent text never enter status. */
99
+ get statusNotice(): { code: "unidentified-cancellation"; message: string } | undefined {
100
+ return this.unidentifiedCancellation ? { code: "unidentified-cancellation", message: UNIDENTIFIED_CANCELLATION_NOTICE } : undefined;
101
+ }
93
102
  private unboundAccepted = false;
94
103
  private nextId = -1;
95
104
  private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void; invalidate?: (reason: string) => void }>();
@@ -342,7 +351,7 @@ export class CodexPeer extends BasePeer {
342
351
  // ponytail: after one unidentifiable cancellation, lifecycle attribution requires a session reattach.
343
352
  // Never guess that an unnamed late report or a later named start belongs to a new turn.
344
353
  this.unidentifiedCancellation = true;
345
- this.opts.log?.("Codex cancelled turn has no verified native id; reattach the native session before further hub delivery");
354
+ this.opts.log?.(UNIDENTIFIED_CANCELLATION_NOTICE);
346
355
  }
347
356
  for (const turnId of known) this.retireTurn(turnId);
348
357
  for (const pending of pendingStarts) pending.invalidate?.("Codex turn watchdog timeout before native acceptance was confirmed");
@@ -634,7 +643,8 @@ export class CodexPeer extends BasePeer {
634
643
  if (this.unidentifiedCancellation) return; // native traffic is still forwarded to its TUI; hub attribution is fenced
635
644
  const unknownTurn = () => [...this.activeTurns].some(id => id.startsWith("unknown:"));
636
645
  if (method === "thread/tokenUsage/updated") {
637
- this.opts.onContext?.(codexContext(params.tokenUsage, this.threadId, Date.now()));
646
+ const reader = this.opts.contextReader;
647
+ this.opts.onContext?.(reader ? reader.read(params.tokenUsage, this.threadId, Date.now()) : codexContext(params.tokenUsage, this.threadId, Date.now()));
638
648
  const total = Number(params.tokenUsage?.total?.totalTokens);
639
649
  if (!Number.isFinite(total)) return;
640
650
  const added = this.tokenTotal === undefined ? (this.freshThread ? total : 0) : Math.max(0, total - this.tokenTotal);
@@ -707,3 +717,7 @@ function parse(raw: string): any {
707
717
  return undefined;
708
718
  }
709
719
  }
720
+
721
+ export function isCodexPeer(value: unknown): value is CodexPeer {
722
+ return value instanceof CodexPeer;
723
+ }
@@ -0,0 +1,124 @@
1
+ import { basename } from "node:path";
2
+ import { types } from "node:util";
3
+ import { AcpPeer } from "./acp.ts";
4
+ import type { AdapterDefinition } from "./registry-types.ts";
5
+ import type { PermissionMode } from "../hub/permission-mode.ts";
6
+ import { shellQuote } from "../cli/shell-quote.ts";
7
+
8
+ export interface ConfiguredAcpAdapterConfig {
9
+ transport: "acp";
10
+ command: string;
11
+ args?: readonly string[];
12
+ displayName: string;
13
+ roles: readonly string[];
14
+ identity?: { name: string; version: string };
15
+ permission?: Partial<Record<PermissionMode, string>>;
16
+ readOnly?: string;
17
+ }
18
+ const fail = (field: string, reason: string): never => { throw new Error(`${field}: ${reason}`); };
19
+ /** Core command namespace is reserved; configured kinds cannot shadow operational CLI actions. */
20
+ export const CONFIGURED_ACP_RESERVED_COMMANDS = Object.freeze(["help", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "recovery-run", "upgrade", "restart", "recovery", "models", "say", "queue", "watch", "tail", "budget", "backlog", "release", "board", "sdlc", "task", "review", "remember", "ask", "route", "pause", "resume", "stop", "permission", "roster", "settings", "permit", "pet", "status", "logs", "export", "bench", "memory", "goal", "research", "report", "facts", "check-path", "turns", "undo", "kill", "reset", "role", "doctor"]);
21
+ const MODES = ["ask", "ask-when-needed", "never-ask"];
22
+ const ROLES = ["planner", "implementer", "verifier", "reviewer"];
23
+ function fields(raw: unknown, at: string): Record<string, PropertyDescriptor> {
24
+ if (!raw || typeof raw !== "object" || types.isProxy(raw) || Array.isArray(raw) || ![Object.prototype, null].includes(Object.getPrototypeOf(raw)) || Object.getOwnPropertySymbols(raw).length) return fail(at, "must be a plain own-data object");
25
+ const result: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(raw));
26
+ if (Object.values(result).some(d => !Object.hasOwn(d, "value") || !d.enumerable)) return fail(at, "enumerable data fields required");
27
+ return result;
28
+ }
29
+ function text(raw: unknown, at: string, max = 4096): string {
30
+ if (typeof raw !== "string" || !raw.trim() || Buffer.byteLength(raw) > max || /[\p{Cc}\p{Cf}]/u.test(raw)) return fail(at, `must be nonempty control-free text of at most ${max} bytes`);
31
+ return raw;
32
+ }
33
+ function list(raw: unknown, at: string, max: number): string[] {
34
+ if (!Array.isArray(raw) || types.isProxy(raw) || Object.getPrototypeOf(raw) !== Array.prototype || Object.getOwnPropertySymbols(raw).length) return fail(at, "must be a string array");
35
+ const descriptors: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(raw));
36
+ const length = descriptors.length?.value;
37
+ if (!Number.isSafeInteger(length) || length > max || Object.keys(descriptors).length !== length + 1) return fail(at, "array is sparse or too large");
38
+ return Array.from({ length }, (_, index) => {
39
+ const d = descriptors[String(index)];
40
+ if (!d || !Object.hasOwn(d, "value") || !d.enumerable) return fail(at, "array data fields required");
41
+ return text(d.value, `${at}.${index}`);
42
+ });
43
+ }
44
+ function freeze<T>(value: T): T {
45
+ if (value && typeof value === "object") { for (const item of Object.values(value)) freeze(item); Object.freeze(value); }
46
+ return value;
47
+ }
48
+
49
+ /** Pure local declaration compiler; file provenance and runtime process ownership remain host checks. */
50
+ export function configuredAcpDefinitions(raw: unknown, builtinKinds: readonly string[]): readonly AdapterDefinition[] {
51
+ if (raw === undefined) return Object.freeze([]);
52
+ const declarations = fields(raw, "adapters");
53
+ if (Object.keys(declarations).length > 64) return fail("adapters", "at most 64 configured kinds supported");
54
+ const reserved = new Set([...builtinKinds, ...CONFIGURED_ACP_RESERVED_COMMANDS, "user", "hub", "digest", "constructor", "prototype", "__proto__"]);
55
+ return Object.freeze(Object.entries(declarations).map(([kind, descriptor]) => {
56
+ const at = `adapters.${kind}`;
57
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(kind) || reserved.has(kind)) return fail(at, "kind is invalid, reserved or collides with a builtin");
58
+ const input = fields(descriptor.value, at);
59
+ if (Object.keys(input).some(key => !["transport", "command", "args", "displayName", "roles", "identity", "permission", "readOnly"].includes(key))) return fail(at, "unknown or privileged field");
60
+ if (input.transport?.value !== "acp") return fail(`${at}.transport`, "must be acp");
61
+ const command = text(input.command?.value, `${at}.command`), args = input.args ? list(input.args.value, `${at}.args`, 128) : [];
62
+ // The supported contract is a foreground owned stdio process, never an attachment/proxy selector.
63
+ if ([command, ...args].some(arg => /^--(?:leader|attach|connect|remote)(?:-|=|$)/.test(arg))) return fail(`${at}.args`, "attach/leader selectors are unsupported; command must run foreground owned ACP stdio");
64
+ const displayName = text(input.displayName?.value, `${at}.displayName`, 256), roles = list(input.roles?.value, `${at}.roles`, ROLES.length);
65
+ if (roles.includes("conductor")) return fail(`${at}.roles`, "conductor requires explicit project role policy, not adapter defaults");
66
+ if (!roles.length || new Set(roles).size !== roles.length || roles.some(role => !ROLES.includes(role))) return fail(`${at}.roles`, "roles must be nonempty, known and unique");
67
+ let identity: ConfiguredAcpAdapterConfig["identity"];
68
+ if (input.identity) {
69
+ const value = fields(input.identity.value, `${at}.identity`);
70
+ if (Object.keys(value).some(key => !["name", "version"].includes(key))) return fail(`${at}.identity`, "unknown identity field");
71
+ identity = { name: text(value.name?.value, `${at}.identity.name`, 256), version: text(value.version?.value, `${at}.identity.version`, 256) };
72
+ }
73
+ const grokContract = kind === "grok" || /^grok(?:\.exe)?$/i.test(basename(command)) || identity?.name.toLowerCase() === "grok";
74
+ const noLeader = args.indexOf("--no-leader"), endOptions = args.indexOf("--");
75
+ if (grokContract && (noLeader < 0 || (endOptions >= 0 && noLeader > endOptions) || args.some(arg => arg.startsWith("--no-leader=")))) {
76
+ return fail(`${at}.args`, "Grok requires explicit --no-leader before argv termination; valued opt-outs are unsupported");
77
+ }
78
+ let modes: ConfiguredAcpAdapterConfig["permission"];
79
+ if (input.permission) {
80
+ const value = fields(input.permission.value, `${at}.permission`);
81
+ modes = {};
82
+ for (const [mode, d] of Object.entries(value)) {
83
+ if (!MODES.includes(mode)) return fail(`${at}.permission.${mode}`, "unknown hub mode");
84
+ modes[mode as PermissionMode] = text(d.value, `${at}.permission.${mode}`, 256);
85
+ }
86
+ if (!Object.keys(modes).length) modes = undefined; // `permission: {}` maps nothing: unmanaged, exactly like an omitted map
87
+ }
88
+ const readOnly = input.readOnly ? text(input.readOnly.value, `${at}.readOnly`, 256) : undefined;
89
+ const nativePolicy = freeze({ ...(modes ? { modes } : {}), ...(readOnly ? { readOnly } : {}) });
90
+ const argv = freeze([command, ...args]);
91
+ identity = identity && freeze(identity);
92
+ const definition: AdapterDefinition = {
93
+ api: 1, kind, family: kind, displayName, transport: "acp", roles, optional: true, defaultOnRoster: false,
94
+ trust: { onPrem: false, hubSandboxed: false }, identity: { mcp: "acp-session" }, instances: { mode: "headless" },
95
+ start: {
96
+ daemonPort: "acp", forms: ["headless"], defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [],
97
+ exclusiveStartRefusal: "configured ACP start already in progress; retry after it settles",
98
+ preflight: request => request.form !== undefined && request.form !== "headless" ? `${kind}: configured ACP supports headless only`
99
+ : !identity ? `${kind}: native identity is unverified; configure the observed name/version pin before starting` : undefined,
100
+ launch: context => {
101
+ if (context.form !== "headless") throw new Error(`${kind}: configured ACP supports headless only`);
102
+ return { cmd: command, args: [...args], metadata: { packageEntrypoint: context.entrypoint, argv: [...argv], command: argv.map(shellQuote).join(" "), env: {} } };
103
+ },
104
+ create: context => {
105
+ if (!("acp" in context.daemon)) throw new Error(`${kind}: ACP transport options required`);
106
+ if (context.permission === "read-only" && !readOnly) throw new Error(`${kind}: read-only native mode is not configured`);
107
+ if (!identity) throw new Error(`${kind}: native identity is unverified; configure the observed name/version pin before starting`);
108
+ return new AcpPeer(context.instance, { ...context.daemon.acp, kind, cmd: [...argv], qwen025: false,
109
+ expectedAgent: identity, requireLoadSessionEcho: true, requireOwnedGroup: true,
110
+ permissionMode: context.permission === "read-only" ? "ask" : context.permission,
111
+ nativePolicy: { ...nativePolicy, lockedReadOnly: context.permission === "read-only" }, mcpServers: context.mcpServers });
112
+ },
113
+ conductorStartArgs: args => args.length ? new Error("configured ACP starts accept no launch overrides") : {},
114
+ },
115
+ permission: { genericAcp: nativePolicy, grants: Object.fromEntries(MODES.map(mode => [mode, modes?.[mode as PermissionMode] ? `${kind}: asks native ACP to select configured ${mode} mode; native tool policy applies; NO hub sandbox` : `${kind}: no configured native mapping for ${mode}; NO hub sandbox`])), ...(!modes || !Object.keys(modes).length ? { unmanaged: `${kind}: native policy unmanaged; no configured permission map; NO hub sandbox` } : {}) },
116
+ // ponytail: configured ACP has no verified controlled-recovery driver. Add catalog-bound restoration
117
+ // with native session/group receipts before enabling controlled recovery; explicit/crash load stays fenced.
118
+ session: { idField: "sessionId", currentId: peer => { const id = peer.recoveryMetadata?.().sessionId; return typeof id === "string" ? id : undefined; }, launchKind: "acp", resumeById: true, recoveryRefusal: () => "configured ACP has no verified controlled-recovery driver; stop it explicitly and restart with its recorded native session id" },
119
+ turnEnd: "adapter", cli: { command: kind, launchConfigKeys: [`adapters.${kind}.command`, `adapters.${kind}.args`], startWords: () => [kind] },
120
+ display: { palette: { fg: "white", bg: "black" } },
121
+ };
122
+ return freeze(definition);
123
+ }));
124
+ }
@@ -1,3 +1,4 @@
1
+ import type { PermissionMode } from "../../hub/permission-mode.ts";
1
2
  import { claudeReviewerBackend, CODEX_REVIEWER_VARIANT, KIMI_REVIEWER_VARIANT } from "./reviewer-variants.ts";
2
3
  import { kimiAcpCommand } from "./native-daemon-kimi.ts";
3
4
  import { claudeRecoveryLaunch, kimiRecoveryLaunch, claudeRetainedTerminalNotice, piRetainedTerminalNotice, codexRetainedTerminalNotice, codexRosterReturnNotice, jcodeStartPreflight, claudeObservedVersion, codexObservedVersion } from "./native-daemon-policy.ts";
@@ -12,14 +13,14 @@ import { CLAUDE_DISPLAY, CODEX_DISPLAY, KIMI_DISPLAY, PI_DISPLAY, LOCAL_DISPLAY,
12
13
  import { CLAUDE_RECOVERY_SESSION, CODEX_RECOVERY_SESSION, KIMI_RECOVERY_SESSION, PI_RECOVERY_SESSION, LOCAL_RECOVERY_SESSION, JCODE_RECOVERY_SESSION } from "./native-recovery.ts";
13
14
  import { claudeWindows, codexWindows } from "./native-quota.ts";
14
15
  import { claudeContext, codexContext, acpContextValue } from "./native-context.ts";
15
- import type { AdapterDefinition, LaunchContext, LaunchPlan, StartForm } from "../registry-types.ts";
16
+ import type { AdapterDefinition, LaunchContext, LaunchPlan, StartForm, NativeModeSpec } from "../registry-types.ts";
16
17
  import type { PeerAdapter } from "../../hub/peers.ts";
17
18
  import { shellQuote } from "../../cli/shell-quote.ts";
18
19
 
19
20
  type CompatibilityKind = "claude" | "codex" | "kimi" | "pi" | "local" | "jcode";
20
21
 
21
22
  /**
22
- * Preserve CLI-wrapper argv plans for definitions that still declare them and the frozen A1 compatibility export.
23
+ * Preserve CLI-wrapper argv plans only for the frozen A1 compatibility export.
23
24
  * Native startup and daemon factories are supplied separately by the runtime definitions.
24
25
  */
25
26
  function compatibilityLaunch(kind: CompatibilityKind, ctx: LaunchContext, explicitMode = false): LaunchPlan {
@@ -43,8 +44,6 @@ function sessionId(field: "sessionId" | "threadId"): (peer: PeerAdapter) => stri
43
44
  return typeof value === "string" && value ? value : undefined;
44
45
  };
45
46
  }
46
- const terminal = (form: StartForm) => form === "tui";
47
- const headless = () => false;
48
47
  const switches = Object.freeze(["--unattended", "--safe", "--new"]);
49
48
  const implementerRoles = Object.freeze(["implementer", "verifier"]);
50
49
  const dualForms = Object.freeze(["tui", "headless"] as const);
@@ -62,9 +61,9 @@ const hubGrants = {
62
61
  const CLAUDE_REVIEWER_VARIANT: AdapterDefinition = {
63
62
  api: 1, kind: "claude", displayName: "Claude Code reviewer", family: "claude", transport: "hub-native",
64
63
  roles: Object.freeze(["reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
65
- start: { create: createClaudeReviewer, daemonPort: "claude-print", forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("claude", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
66
- identity: { mcp: "env" },
67
- permission: { modes: {}, unmanaged: "each review runs read-only (Read, Grep, Glob) and may call only hub_review and hub_task_show" },
64
+ start: { create: createClaudeReviewer, daemonPort: "claude-print", forms: headlessForms, defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
65
+ identity: { mcp: "env" }, instances: { mode: "headless" },
66
+ permission: { unmanaged: "each review runs read-only (Read, Grep, Glob) and may call only hub_review and hub_task_show" },
68
67
  session: { idField: "sessionId", currentId: () => undefined, freshOnRestore: true, launchKind: "claude-print", crashPlan: () => ({ resume: {}, how: "claude reviewer: starts again; every review runs in a new session" }) },
69
68
  turnEnd: "adapter", budget: { account: "claude" }, routing: { reviewOnly: true }, reviewer: { backend: claudeReviewerBackend, defaultPool: true, declaration: "automatic" },
70
69
  cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
@@ -76,11 +75,11 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
76
75
  {
77
76
  api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
78
77
  roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
79
- start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", ownsTerminal: terminal, launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true,
78
+ start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true,
80
79
  unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]) },
81
- identity: { mcp: "channel-plugin" }, attach: { recoveryLaunch: claudeRecoveryLaunch },
82
- // Existing daemon permission hooks enforce these grants; an empty native mode map is neither unmanaged nor a refusal.
83
- permission: { modes: {}, grants: { ask: "Claude's own permission rules decide",
80
+ identity: { mcp: "channel-plugin" }, instances: { mode: "external", roles: ["reviewer", "planner"] }, attach: { externalHello: true, recoveryLaunch: claudeRecoveryLaunch },
81
+ // Approval enforcement belongs to the verified native transport and host permission gate.
82
+ permission: { grants: { ask: "Claude's own permission rules decide",
84
83
  "ask-when-needed": "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules",
85
84
  "never-ask": "every tool runs without asking" } },
86
85
  session: { idField: "sessionId", currentId: sessionId("sessionId"), configDirEnv: "CLAUDE_CONFIG_DIR", zeroTurnFresh: true, launchKind: "claude-channel", launchRecord: true, ...CLAUDE_RECOVERY_SESSION, crashPlan: claudeCrashPlan, unrecordedTerminal: "always" },
@@ -92,12 +91,10 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
92
91
  reviewerVariant: CODEX_REVIEWER_VARIANT,
93
92
  api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
94
93
  roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
95
- start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true,
94
+ start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true,
96
95
  unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--remote-auth-token-env", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
97
- identity: { mcp: "env" },
98
- permission: { trackUnattended: true, modes: { ask: { kind: "native-only", note: "restore the session's captured native approval policy" },
99
- "ask-when-needed": { kind: "codex-approval", policy: "on-request" }, "never-ask": { kind: "codex-approval", policy: "never" } },
100
- 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" } },
96
+ identity: { mcp: "env" }, instances: { mode: "headless" },
97
+ permission: { trackUnattended: true, 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" } },
101
98
  session: { nativeRevert: true, idField: "threadId", currentId: sessionId("threadId"), configDirEnv: "CODEX_HOME", zeroTurnFresh: true, launchKind: "codex", launchRecord: true, ...CODEX_RECOVERY_SESSION, crashPlan: codexCrashPlan, unrecordedTerminal: "always" },
102
99
  turnEnd: "adapter", budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
103
100
  cli: { command: "codex", launchConfigKeys: ["codex_bin"], retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
@@ -106,25 +103,24 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
106
103
  reviewerVariant: KIMI_REVIEWER_VARIANT,
107
104
  api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
108
105
  roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
109
- start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("kimi", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
106
+ start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
110
107
  // ACP sessions carry headless identity; the existing CLI alone owns the TUI's project MCP entry.
111
- identity: { mcp: "acp-session" }, attach: { pullOnlyForms: Object.freeze(["tui"] as const), recoveryLaunch: kimiRecoveryLaunch,
108
+ identity: { mcp: "acp-session" }, instances: { mode: "headless" }, attach: { pullOnlyForms: Object.freeze(["tui"] as const), recoveryLaunch: kimiRecoveryLaunch,
112
109
  pullOnlyNotice: "pull-only: messages wait for hub_inbox; no push, steer, watchdog or automatic quota handoff",
113
110
  admission: { reservedRefusal: "Kimi attaches pull-only in TUI mode; run ahub kimi", watchRefusal: "Kimi is in pull-only TUI mode; use its native terminal or start headless" } },
114
- permission: { modes: { ask: { kind: "acp-mode", modeId: "default" }, "ask-when-needed": { kind: "acp-mode", modeId: "yolo" }, "never-ask": { kind: "acp-mode", modeId: "auto" } },
115
- 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" },
111
+ permission: { 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" },
116
112
  unmanagedForms: Object.freeze(["tui"] as const), unmanagedStatusNote: "Kimi approves tools in its own TUI; no hub permission mode", unmanagedNote: "Kimi approves tools and selects its permission mode in its own TUI; no hub permission mode" },
117
113
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp", ...KIMI_RECOVERY_SESSION, crashPlan: kimiCrashPlan },
118
- turnEnd: "adapter", budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, init: { mcpFile: ".kimi-code/mcp.json" }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
114
+ turnEnd: "adapter", budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
119
115
  cli: { command: "kimi", launchConfigKeys: ["kimi_cmd", "peers.kimi.start_mode"], stopNotice: form => form === "tui" ? "Kimi belongs to its own TUI terminal; end it in that terminal" : "", startWords: form => ["kimi", "--mode", form] }, display: KIMI_DISPLAY,
120
116
  },
121
117
  {
122
118
  api: 1, kind: "pi", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
123
119
  roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
124
- start: { create: createPi, exclusiveStartRefusal: "Pi start is in progress; inspect status before retrying", refreshTools: true, explicitHeadlessMode: true, daemonPort: "pi-rpc", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("pi", ctx), conductorStartable: true, autoStart: "pi.auto_start", conductorStartArgs: piConductorStartArgs, hubSwitches: [], ownedFlags: [] },
125
- identity: { mcp: "env" },
126
- // Existing daemon permission hooks enforce these grants; an empty native mode map is neither unmanaged nor a refusal.
127
- permission: { modes: {}, grants: { ...hubGrants } },
120
+ start: { create: createPi, exclusiveStartRefusal: "Pi start is in progress; inspect status before retrying", refreshTools: true, explicitHeadlessMode: true, daemonPort: "pi-rpc", forms: dualForms, defaultForm: "tui", conductorStartable: true, autoStart: "pi.auto_start", conductorStartArgs: piConductorStartArgs, hubSwitches: [], ownedFlags: [] },
121
+ identity: { mcp: "env" }, instances: { mode: "headless" },
122
+ // Approval enforcement belongs to the verified native transport and host permission gate.
123
+ permission: { grants: { ...hubGrants } },
128
124
  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 } },
129
125
  turnEnd: "adapter", budget: { account: "pi", instrumented: true }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
130
126
  routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
@@ -133,10 +129,10 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
133
129
  {
134
130
  api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
135
131
  roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
136
- start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("local", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
137
- identity: { mcp: "env" },
138
- // Existing daemon permission hooks enforce these grants; an empty native mode map is neither unmanaged nor a refusal.
139
- permission: { modes: {}, grants: { ...hubGrants } },
132
+ start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
133
+ identity: { mcp: "env" }, instances: { mode: "headless" },
134
+ // Approval enforcement belongs to the verified native transport and host permission gate.
135
+ permission: { grants: { ...hubGrants } },
140
136
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
141
137
  turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
142
138
  routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
@@ -145,9 +141,9 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
145
141
  {
146
142
  api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
147
143
  roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
148
- start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, explicitHeadlessMode: true, launch: ctx => compatibilityLaunch("jcode", ctx, true), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
144
+ start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui", explicitHeadlessMode: true, conductorStartable: true, hubSwitches: [], ownedFlags: [] },
149
145
  identity: { mcp: "project-file" },
150
- permission: { modes: {}, unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
146
+ permission: { unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
151
147
  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" } },
152
148
  session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode" },
153
149
  // Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
@@ -155,6 +151,14 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
155
151
  },
156
152
  ] satisfies AdapterDefinition[]);
157
153
 
154
+ // Frozen A1 metadata is a compatibility snapshot, not the runtime definition contract.
155
+ export type LegacyAdapterDefinition = AdapterDefinition & {
156
+ init?: { templateFragment?: string; mcpFile?: string };
157
+ start: AdapterDefinition["start"] & { ownsTerminal(form: StartForm): boolean; launch(ctx: LaunchContext): LaunchPlan };
158
+ permission: AdapterDefinition["permission"] & { modes: Partial<Record<PermissionMode, NativeModeSpec>>; readOnly?: NativeModeSpec };
159
+ };
160
+ const terminal = (form: StartForm) => form === "tui";
161
+ const headless = () => false;
158
162
  /**
159
163
  * ponytail: retain the released A1 metadata export for source/library compatibility until A4 retires it.
160
164
  * Core runtime consumers use NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN through the registry exclusively.
@@ -162,7 +166,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
162
166
  * resolves to the TUI (#464, #559). Callers of this export must pass `--mode headless` themselves.
163
167
  */
164
168
  const palette = (fg: string) => ({ palette: { fg, bg: "transparent" } });
165
- export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
169
+ export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly LegacyAdapterDefinition[] = Object.freeze([
166
170
  {
167
171
  api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
168
172
  roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
@@ -233,4 +237,4 @@ export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Ob
233
237
  // Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
234
238
  cli: { command: "jcode", startWords: () => ["jcode"] }, display: palette("#8a8f99"),
235
239
  },
236
- ] satisfies AdapterDefinition[]);
240
+ ] satisfies LegacyAdapterDefinition[]);
@@ -0,0 +1,41 @@
1
+ // No imports: the statusline tee and the CLI load this without the daemon (#450 I3).
2
+ const PEER = /^[a-z][a-z0-9-]{0,31}$/;
3
+
4
+ /** The peer a Claude process writes for: its AGENTHUB_PEER_ID when well formed, otherwise the builtin claude peer. */
5
+ export function claudePeer(value: string | undefined): string {
6
+ return value !== undefined && PEER.test(value) ? value : "claude";
7
+ }
8
+
9
+ // Instance ids are always <kind>-<name>, so a Claude peer is the builtin id or a claude-<name> instance.
10
+ const CLAUDE_PEER = /^claude(?:-[a-z0-9-]+)?$/;
11
+
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 {
14
+ // #624: another kind (agy is a hyphen-free builtin too) must never resolve to Claude's own files.
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
+ return peer.includes("-") ? `${base}.${peer}.json` : `${base}.json`;
17
+ }
18
+
19
+ /** A Claude peer's own row in terminal-recovery.json for this daemon, never a sibling instance's (row order is arbitrary). */
20
+ export function ownTerminalRow(records: unknown, peer: string, projectRoot: string, instanceId: string): { launchId?: unknown } | undefined {
21
+ if (!Array.isArray(records)) return undefined;
22
+ return records.find((row) => row?.peer === peer && row?.projectRoot === projectRoot && row?.instanceId === instanceId);
23
+ }
24
+
25
+ /**
26
+ * Whether a Claude session's transcript exists: the path the peer's own status line reported for that session, else
27
+ * the builtin's default location. An instance may run under its own CLAUDE_CONFIG_DIR, which the hub cannot see:
28
+ * without its reported path the answer is unknown, and unknown counts as persisted (it never waives session identity).
29
+ */
30
+ export function transcriptPersisted(reported: { sessionId?: string; transcriptPath?: string }, sessionId: string, instance: boolean, fallbackPath: string, exists: (path: string) => boolean | "unknown"): boolean {
31
+ // Only a positive "missing" (ENOENT) is not persisted; an unreadable path is unknown and keeps the identity strict.
32
+ const found = (path: string) => exists(path) !== false;
33
+ if (reported.sessionId === sessionId && reported.transcriptPath) return found(reported.transcriptPath);
34
+ return instance || found(fallbackPath);
35
+ }
36
+
37
+ /** lstat evidence: true when present, false only on ENOENT/ENOTDIR, "unknown" for any other failure (EACCES included). */
38
+ export function pathEvidence(path: string, lstat: (path: string) => unknown): boolean | "unknown" {
39
+ try { lstat(path); return true; }
40
+ catch (error) { const code = (error as { code?: string }).code; return code === "ENOENT" || code === "ENOTDIR" ? false : "unknown"; }
41
+ }
@@ -41,9 +41,11 @@ export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
41
41
  const admission = await hub.request({ t: "start", peer, args: { mode: "tui" }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
42
42
  if (!admission.ok) fail(admission.error);
43
43
  const runningCommand = admission.retainedLaunchConfig?.kimi_cmd;
44
- const launch = buildKimiTuiLaunch(Array.isArray(runningCommand) && runningCommand.every((arg: unknown) => typeof arg === "string") ? runningCommand : projectConfig().kimi_cmd, model);
44
+ let launch: ReturnType<typeof buildKimiTuiLaunch>;
45
45
  let result: ReturnType<typeof spawnSync>;
46
46
  try {
47
+ // Inside the try: a kimi_cmd with no TUI form throws here, and the admission must still be released (#549).
48
+ launch = buildKimiTuiLaunch(Array.isArray(runningCommand) && runningCommand.every((arg: unknown) => typeof arg === "string") ? runningCommand : projectConfig().kimi_cmd, model);
47
49
  // The daemon's own bundle, so the MCP server matches the hub it talks to (#502); this CLI's copy only for an older hub.
48
50
  const written = configureKimiMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"), "tui");
49
51
  if (written.tracked) console.error(".kimi-code/mcp.json is tracked; the agent-hub entry contains machine paths. Review this local change before committing it.");
@@ -14,7 +14,7 @@ export function previewQwen(ctx: NativePreviewContext) {
14
14
  const sessionId = value("--session-id");
15
15
  if (sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId }).sessionId) throw new Error("invalid Qwen session identity");
16
16
  if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
17
- launch = adapter.start.launch({ instance: tool, form: "headless", projectRoot: cwd, stateDir, entrypoint: join(cliDir, "main.js"),
17
+ launch = adapter.start.launch!({ instance: tool, form: "headless", projectRoot: cwd, stateDir, entrypoint: join(cliDir, "main.js"),
18
18
  userArgs: [], unattended: false, env: {}, preview: true, nativeCommand: nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model: model ? REDACTED : undefined });
19
19
  unresolved.push({ field: "nativeIdentity", reason: "Qwen Code 0.25.0 identity and permission modes are checked at ACP initialization; preview starts nothing" });
20
20
 
@@ -0,0 +1,14 @@
1
+ import type { AdapterDefinition } from "../registry-types.ts";
2
+
3
+ /** Legacy native shell marker defaults; the host validates markers and conflicts before selection. */
4
+ export function nativeCliMarkerPeer(channel: boolean, appserver: boolean): string | undefined {
5
+ return channel ? "claude" : appserver ? "codex" : undefined;
6
+ }
7
+
8
+ /** Historical identity for captures whose host did not supply an agent id. */
9
+ export function defaultCapturePeer(): string { return "local"; }
10
+
11
+ /** Released recovery receipts retain their native/channel group vocabulary. */
12
+ export function nativeRestoreGroup(transport: AdapterDefinition["transport"] | undefined): "claude" | "native" {
13
+ return transport === "claude-channel" ? "claude" : "native";
14
+ }
@@ -2,7 +2,7 @@ import { QWEN_DISPLAY } from "./native-display.ts";
2
2
  import { QWEN_RECOVERY_SESSION } from "./native-recovery.ts";
3
3
  import { AcpPeer } from "../acp.ts";
4
4
  import type { AdapterDefinition } from "../registry-types.ts";
5
- import { buildQwenLaunch, qwenNativeIdentity, qwenSessionId, QWEN_MODE_IDS } from "./qwen-native.ts";
5
+ import { buildQwenLaunch, qwenSessionId } from "./qwen-native.ts";
6
6
  import { shellQuote } from "../../cli/shell-quote.ts";
7
7
 
8
8
  /** Production Qwen policy. Legacy AcpPeer callers opt in separately through qwen025. */
@@ -20,7 +20,6 @@ export const QWEN_DEFINITION: AdapterDefinition = {
20
20
  daemonPort: "acp",
21
21
  forms: ["headless"],
22
22
  defaultForm: "headless",
23
- ownsTerminal: () => false,
24
23
  conductorStartable: true,
25
24
  hubSwitches: [],
26
25
  ownedFlags: [],
@@ -40,12 +39,8 @@ export const QWEN_DEFINITION: AdapterDefinition = {
40
39
  conductorStartArgs: args => args.length ? new Error("conductor starts accept no launch overrides; use ahub qwen") : {},
41
40
  },
42
41
  identity: { mcp: "acp-session" },
42
+ instances: { mode: "headless" },
43
43
  permission: {
44
- modes: {
45
- ask: { kind: "acp-mode", modeId: QWEN_MODE_IDS.ask },
46
- "ask-when-needed": { kind: "acp-mode", modeId: QWEN_MODE_IDS["ask-when-needed"] },
47
- "never-ask": { kind: "acp-mode", modeId: QWEN_MODE_IDS["never-ask"] },
48
- },
49
44
  grants: {
50
45
  ask: "every write, edit and shell command asks",
51
46
  "ask-when-needed": "Qwen auto-edit approves file edits; shell commands ask; NO hub sandbox",
@@ -71,5 +66,5 @@ export const QWEN_DEFINITION: AdapterDefinition = {
71
66
  capabilities: { watch: true },
72
67
  cli: { command: "qwen", launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
73
68
  display: QWEN_DISPLAY,
74
- hooks: { agentIdentity: init => { qwenNativeIdentity("qwen", true, init); }, recallPlatform: "qwen" },
69
+ hooks: { recallPlatform: "qwen" },
75
70
  };
@@ -6,18 +6,14 @@ import { createReviewer, createCodexReviewer } from "./native-factories.ts";
6
6
  import { CODEX_DISPLAY, KIMI_DISPLAY } from "./native-display.ts";
7
7
 
8
8
  export { claudeReviewerBackend };
9
- /** No launch path is exposed by metadata-only placeholders. */
10
- function unavailableLaunch(): never { throw new Error("Reviewer unavailable: verified runtime support is deferred to #533"); }
11
-
12
9
  function reviewerVariant(kind: string, displayName: string, reviewer: NonNullable<AdapterDefinition["reviewer"]>, display: AdapterDefinition["display"]): AdapterDefinition {
13
10
  return {
14
11
  api: 1, kind, family: kind, displayName, transport: "hub-native", roles: ["reviewer"],
15
12
  defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
16
- start: { ...(reviewer.backend ? { create: createReviewer, daemonPort: "reviewer-print" as const } : {}), forms: ["headless"], defaultForm: "headless", ownsTerminal: () => false,
17
- launch: unavailableLaunch,
13
+ start: { ...(reviewer.backend ? { create: createReviewer, daemonPort: "reviewer-print" as const } : {}), forms: ["headless"], defaultForm: "headless",
18
14
  conductorStartable: true, hubSwitches: [], ownedFlags: [] },
19
- identity: { mcp: "env" },
20
- permission: { modes: {}, readOnly: { kind: "hub-gate", allow: ["read", "search", "think"] }, unmanaged: "fresh read-only reviewer; an isolation receipt is required before startup" },
15
+ identity: { mcp: "env" }, instances: { mode: "headless" },
16
+ permission: { unmanaged: "fresh read-only reviewer; an isolation receipt is required before startup" },
21
17
  session: { idField: "sessionId", currentId: () => undefined, freshOnRestore: true, launchKind: "reviewer-print", crashPlan: () => ({ resume: {}, how: "starts a fresh isolated review session" }) },
22
18
  turnEnd: "adapter", budget: { account: kind }, routing: { reviewOnly: true },
23
19
  reviewer, cli: { command: kind, startWords: () => [] }, display,
@@ -286,3 +286,7 @@ export class JcodePeer extends AcpPeer {
286
286
  return { ...super.recoveryMetadata(), launch: { kind: "jcode", peer: "jcode", mode: "headless", ...(this.jcode.launchModel ? { model: this.jcode.launchModel } : {}), ...(this.jcode.providerProfile ? { providerProfile: this.jcode.providerProfile } : {}) } };
287
287
  }
288
288
  }
289
+
290
+ export function isJcodePeer(value: unknown): value is JcodePeer {
291
+ return value instanceof JcodePeer;
292
+ }
@@ -490,3 +490,7 @@ function safeParse(json: string): Record<string, unknown> {
490
490
  return {};
491
491
  }
492
492
  }
493
+
494
+ export function isLocalPeer(value: unknown): value is LocalPeer {
495
+ return value instanceof LocalPeer;
496
+ }
@@ -945,3 +945,7 @@ export class PiPeer extends BasePeer {
945
945
  void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"));
946
946
  }
947
947
  }
948
+
949
+ export function isPiPeer(value: unknown): value is PiPeer {
950
+ return value instanceof PiPeer;
951
+ }