@staix/agent-hub 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +16 -6
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +17 -3
- package/docs/agent-notes/budget.md +4 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +14 -9
- package/docs/agent-notes/tasks.md +9 -4
- package/docs/agent-notes/tests.md +3 -3
- package/docs/dogfood.md +9 -4
- package/docs/events.md +21 -7
- package/docs/operations.md +90 -18
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +9 -3
- package/docs/sdlc.md +17 -0
- package/docs/security.md +37 -10
- package/docs/specs/2026-09-19-agent-hub-design.md +74 -10
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verified.json +29 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +992 -312
- package/src/adapters/acp.ts +43 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/compat.ts +4 -0
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +14 -2
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +30 -53
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +28 -1
- package/src/cli/console.ts +25 -1
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +4 -3
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +47 -9
- package/src/cli/output.ts +28 -0
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-enforce.ts +82 -0
- package/src/hub/action-policy.ts +22 -10
- package/src/hub/board.ts +26 -2
- package/src/hub/budget.ts +5 -0
- package/src/hub/bus.ts +5 -5
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/config-apply.ts +5 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +368 -59
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/events.ts +3 -1
- package/src/hub/hub-tools.ts +19 -3
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +144 -0
- package/src/hub/manager.ts +1 -1
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/routing.ts +118 -3
- package/src/hub/settings.ts +1 -1
- package/src/hub/tasks.ts +202 -15
- package/src/hub/token-window.ts +33 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/workflow.ts +16 -0
- package/src/ui/index.html +115 -1
|
@@ -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
|
-
|
|
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,
|
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
15
|
-
|
|
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:
|
|
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
|
|
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(
|
|
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>;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { closeSync, fstatSync, lstatSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { join, sep } from "node:path";
|
|
4
|
+
import { realPath } from "../hub/project.ts";
|
|
5
|
+
import { safeModelLabel } from "../omniroute/usage.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Kimi's session wire log as a usage source (#285 phase 2). This file is the only code that knows the format.
|
|
9
|
+
*
|
|
10
|
+
* Format followed: `<kimi home>/session_index.jsonl` rows `{ sessionId, sessionDir, ... }`, and per session
|
|
11
|
+
* `<sessionDir>/agents/<agent>/wire.jsonl`, one JSON object per line. The first line is
|
|
12
|
+
* `{ type: "metadata", protocol_version }`; each model request writes
|
|
13
|
+
* `{ type: "usage.record", agentId, model, usage: { inputOther, output, inputCacheRead, inputCacheCreation }, usageScope, time }`.
|
|
14
|
+
* Read from the wire logs of 524 sessions on one machine, all `protocol_version` 1.5, with the hub's own Kimi 2.3.0 install (the issue
|
|
15
|
+
* recorded the same rows on 2.1.1; the hub verifies 2.2.0 and 2.3.0 in `compat.ts`). Which build wrote which log is not recorded in
|
|
16
|
+
* them, and the live legs of #285 AC11 are a separate person receipt, so a protocol this file has not seen is `unknown`, never a count.
|
|
17
|
+
*
|
|
18
|
+
* Rules: the file is private and holds prompts, so only the four integers, the model label and the time are copied out of a usage
|
|
19
|
+
* row, nothing is logged, and a line is looked at only when it is short. An error, a shrunk or replaced file, an unknown row shape, a
|
|
20
|
+
* truncated last line or a missing index gives `unknown` and never a partial count: a read commits its offsets only when every file
|
|
21
|
+
* of the session was read cleanly.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const KIMI_WIRE_PROTOCOLS: readonly string[] = ["1.5"];
|
|
25
|
+
|
|
26
|
+
export type KimiWireReason =
|
|
27
|
+
| "no-index" | "outside-home" | "io-error" | "partial-line" | "no-metadata" | "untested-protocol"
|
|
28
|
+
| "malformed-row" | "unknown-row-shape" | "oversized-row" | "shrunk" | "rotated" | "missing" | "moved";
|
|
29
|
+
|
|
30
|
+
/** One model request. `id` is opaque and stable across hub restarts: a hash of the session, the agent and the row's byte offset. */
|
|
31
|
+
export interface KimiWireRow {
|
|
32
|
+
id: string;
|
|
33
|
+
/** epoch ms the row was written, from Kimi's own clock */
|
|
34
|
+
at: number;
|
|
35
|
+
model?: string;
|
|
36
|
+
inputOther: number;
|
|
37
|
+
inputCacheCreation: number;
|
|
38
|
+
inputCacheRead: number;
|
|
39
|
+
output: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type KimiWireRead =
|
|
43
|
+
| { status: "ok"; rows: KimiWireRow[]; /** bytes remain unread because of the per-call bound: read again */ more: boolean }
|
|
44
|
+
| { status: "unknown"; reason: KimiWireReason };
|
|
45
|
+
|
|
46
|
+
/** What counts against `budget.kimi_tokens_5h`: new input, cache writes and output. Cache reads are recorded and shown, never counted. */
|
|
47
|
+
export function kimiCountedTokens(row: Pick<KimiWireRow, "inputOther" | "inputCacheCreation" | "output">): number {
|
|
48
|
+
return row.inputOther + row.inputCacheCreation + row.output;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Everything the row's four counters add up to, the cache reads included (the `usage` event's `totalTokens`). */
|
|
52
|
+
export function kimiRowTotal(row: Pick<KimiWireRow, "inputOther" | "inputCacheCreation" | "inputCacheRead" | "output">): number {
|
|
53
|
+
return kimiCountedTokens(row) + row.inputCacheRead;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface KimiWireOptions {
|
|
57
|
+
/** Kimi's data home (`KIMI_CODE_HOME`, else `~/.kimi-code`). */
|
|
58
|
+
home: string;
|
|
59
|
+
/** The ACP session id the hub owns; matched exactly against `session_index.jsonl`. */
|
|
60
|
+
sessionId: string;
|
|
61
|
+
/** Bytes read per call in total, across every file of the session (the call runs on the daemon's event loop). */
|
|
62
|
+
maxBytes?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
66
|
+
const AGENT_NAME = /^[A-Za-z0-9._-]{1,64}$/;
|
|
67
|
+
const MAX_AGENTS = 256;
|
|
68
|
+
/** A usage row is a few hundred bytes. A longer line is a prompt, a message or a tool output: skipped without being parsed. */
|
|
69
|
+
const LINE_CAP = 64 * 1024;
|
|
70
|
+
const INDEX_CAP = 16 * 1024 * 1024;
|
|
71
|
+
const DEFAULT_CHUNK = 1024 * 1024;
|
|
72
|
+
const MARKER = Buffer.from('"usage.record"');
|
|
73
|
+
const TRANSIENT: ReadonlySet<KimiWireReason> = new Set(["no-index", "io-error", "partial-line"]);
|
|
74
|
+
|
|
75
|
+
/** `print` is the content fingerprint of the consumed region at `offset` (see `fingerprint`): identity that survives inode reuse. */
|
|
76
|
+
interface FileState { dev: number; ino: number; offset: number; skipping: boolean; print: string }
|
|
77
|
+
/** The bytes the fingerprint covers at each end of the consumed region: Kimi's metadata line (with its creation time) and the last bytes read. */
|
|
78
|
+
const PRINT_HEAD = 64, PRINT_TAIL = 256;
|
|
79
|
+
class Unknown extends Error { constructor(readonly reason: KimiWireReason) { super(reason); } }
|
|
80
|
+
|
|
81
|
+
const count = (value: unknown): number | undefined => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
82
|
+
const inside = (root: string, path: string): boolean => path.startsWith(root + sep);
|
|
83
|
+
|
|
84
|
+
export class KimiWireReader {
|
|
85
|
+
private readonly home: string;
|
|
86
|
+
private readonly sessionId: string;
|
|
87
|
+
private readonly chunk: number;
|
|
88
|
+
private dir: string | undefined;
|
|
89
|
+
private files = new Map<string, FileState>();
|
|
90
|
+
private broken: KimiWireReason | undefined;
|
|
91
|
+
|
|
92
|
+
constructor(options: KimiWireOptions) {
|
|
93
|
+
this.home = options.home;
|
|
94
|
+
this.sessionId = options.sessionId;
|
|
95
|
+
this.chunk = Math.max(LINE_CAP * 2, options.maxBytes ?? DEFAULT_CHUNK);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The rows written since the last successful read, sub-agents included. */
|
|
99
|
+
read(): KimiWireRead {
|
|
100
|
+
if (this.broken) return { status: "unknown", reason: this.broken };
|
|
101
|
+
try {
|
|
102
|
+
const dir = this.resolve();
|
|
103
|
+
const next = new Map<string, FileState>();
|
|
104
|
+
const rows: KimiWireRow[] = [];
|
|
105
|
+
let more = false, budget = this.chunk;
|
|
106
|
+
const agents = this.agents(dir);
|
|
107
|
+
for (const name of this.files.keys()) if (!agents.has(name)) throw new Unknown("missing");
|
|
108
|
+
for (const [name, wire] of agents) {
|
|
109
|
+
const previous = this.files.get(name);
|
|
110
|
+
if (budget <= 0) { if (previous) next.set(name, previous); more = true; continue; } // the bound is spent: this file waits for the next call
|
|
111
|
+
const result = this.readFile(name, wire, previous, budget);
|
|
112
|
+
next.set(name, result.state);
|
|
113
|
+
rows.push(...result.rows);
|
|
114
|
+
budget -= result.read;
|
|
115
|
+
more ||= result.more;
|
|
116
|
+
}
|
|
117
|
+
this.files = next;
|
|
118
|
+
return { status: "ok", rows, more };
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const reason: KimiWireReason = error instanceof Unknown ? error.reason : "io-error";
|
|
121
|
+
if (!TRANSIENT.has(reason)) this.broken = reason; // the log no longer matches what was counted: stays unknown for this session
|
|
122
|
+
return { status: "unknown", reason };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The session directory from its index row, resolved once with realPath and kept inside `<home>/sessions`. */
|
|
127
|
+
private resolve(): string {
|
|
128
|
+
if (!SESSION_ID.test(this.sessionId)) throw new Unknown("no-index");
|
|
129
|
+
let root: string;
|
|
130
|
+
try { root = realPath(join(this.home, "sessions")); } catch { throw new Unknown("no-index"); }
|
|
131
|
+
if (this.dir) {
|
|
132
|
+
let again: string;
|
|
133
|
+
try { again = realPath(this.dir); } catch { throw new Unknown("moved"); }
|
|
134
|
+
if (again !== this.dir) throw new Unknown("moved");
|
|
135
|
+
return this.dir;
|
|
136
|
+
}
|
|
137
|
+
const text = this.index();
|
|
138
|
+
let listed: string | undefined;
|
|
139
|
+
for (const line of text.split("\n")) {
|
|
140
|
+
if (!line.includes(this.sessionId)) continue;
|
|
141
|
+
try {
|
|
142
|
+
const row = JSON.parse(line);
|
|
143
|
+
if (row && row.sessionId === this.sessionId && typeof row.sessionDir === "string" && row.sessionDir) listed = row.sessionDir;
|
|
144
|
+
} catch { /* a line that is not ours is not our problem */ }
|
|
145
|
+
}
|
|
146
|
+
if (!listed) throw new Unknown("no-index");
|
|
147
|
+
let dir: string;
|
|
148
|
+
try { dir = realPath(listed); } catch { throw new Unknown("no-index"); }
|
|
149
|
+
if (!inside(root, dir)) throw new Unknown("outside-home");
|
|
150
|
+
this.dir = dir;
|
|
151
|
+
return dir;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The index, opened once and sized from that descriptor, so a link swapped in between a check and the read cannot lift the bound. */
|
|
155
|
+
private index(): string {
|
|
156
|
+
let fd: number;
|
|
157
|
+
try { fd = openSync(join(this.home, "session_index.jsonl"), "r"); } catch { throw new Unknown("no-index"); }
|
|
158
|
+
try {
|
|
159
|
+
const stat = fstatSync(fd);
|
|
160
|
+
if (!stat.isFile() || stat.size > INDEX_CAP) throw new Unknown("io-error");
|
|
161
|
+
const buffer = Buffer.allocUnsafe(stat.size);
|
|
162
|
+
let got = 0;
|
|
163
|
+
while (got < stat.size) {
|
|
164
|
+
const n = readSync(fd, buffer, got, stat.size - got, got);
|
|
165
|
+
if (n <= 0) break;
|
|
166
|
+
got += n;
|
|
167
|
+
}
|
|
168
|
+
return buffer.toString("utf8", 0, got);
|
|
169
|
+
} catch (error) { throw error instanceof Unknown ? error : new Unknown("io-error"); }
|
|
170
|
+
finally { closeSync(fd); }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `agents/<name>/wire.jsonl` of every agent that has one: the main agent and its sub-agents. */
|
|
174
|
+
private agents(dir: string): Map<string, string> {
|
|
175
|
+
const out = new Map<string, string>();
|
|
176
|
+
const files = new Set<string>(); // one file seen under two names (a link, a hard link) is counted once
|
|
177
|
+
let names: string[];
|
|
178
|
+
try { names = readdirSync(join(dir, "agents")).filter(name => AGENT_NAME.test(name)).sort(); }
|
|
179
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return out; throw new Unknown("io-error"); }
|
|
180
|
+
if (names.length > MAX_AGENTS) throw new Unknown("io-error");
|
|
181
|
+
for (const name of names) {
|
|
182
|
+
const path = join(dir, "agents", name, "wire.jsonl");
|
|
183
|
+
try { lstatSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw new Unknown("io-error"); }
|
|
184
|
+
let real: string;
|
|
185
|
+
try { real = realPath(path); } catch { throw new Unknown("io-error"); }
|
|
186
|
+
if (!inside(dir, real)) throw new Unknown("outside-home");
|
|
187
|
+
let identity: string;
|
|
188
|
+
try { const stat = statSync(real); identity = `${stat.dev}:${stat.ino}`; } catch { throw new Unknown("io-error"); }
|
|
189
|
+
if (files.has(identity)) continue;
|
|
190
|
+
files.add(identity);
|
|
191
|
+
out.set(name, real);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A hash of the first and last bytes of what was consumed, a pure function of the file's content and the offset. A file replaced by
|
|
198
|
+
* another (log rotation, a restored session) can get the same device and inode on Linux, and from the old offset its new content parses
|
|
199
|
+
* mid-line: the stored fingerprint no longer matches, which is `rotated`, never a read.
|
|
200
|
+
*/
|
|
201
|
+
private fingerprint(fd: number, offset: number): string {
|
|
202
|
+
if (offset <= 0) return "";
|
|
203
|
+
const hash = createHash("sha256");
|
|
204
|
+
for (const [start, length] of [[0, Math.min(offset, PRINT_HEAD)], [Math.max(0, offset - PRINT_TAIL), Math.min(offset, PRINT_TAIL)]] as const) {
|
|
205
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
206
|
+
let got = 0;
|
|
207
|
+
while (got < length) {
|
|
208
|
+
let n: number;
|
|
209
|
+
try { n = readSync(fd, buffer, got, length - got, start + got); } catch { throw new Unknown("io-error"); }
|
|
210
|
+
if (n <= 0) throw new Unknown("shrunk");
|
|
211
|
+
got += n;
|
|
212
|
+
}
|
|
213
|
+
hash.update(buffer);
|
|
214
|
+
}
|
|
215
|
+
return hash.digest("hex");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private readFile(agent: string, path: string, previous: FileState | undefined, limit: number): { state: FileState; rows: KimiWireRow[]; more: boolean; read: number } {
|
|
219
|
+
let fd: number;
|
|
220
|
+
try { fd = openSync(path, "r"); } catch { throw new Unknown("io-error"); }
|
|
221
|
+
try {
|
|
222
|
+
const stat = fstatSync(fd);
|
|
223
|
+
if (!stat.isFile()) throw new Unknown("io-error");
|
|
224
|
+
if (previous && (previous.dev !== stat.dev || previous.ino !== stat.ino)) throw new Unknown("rotated");
|
|
225
|
+
if (previous && stat.size < previous.offset) throw new Unknown("shrunk");
|
|
226
|
+
if (previous && this.fingerprint(fd, previous.offset) !== previous.print) throw new Unknown("rotated");
|
|
227
|
+
const state: FileState = previous ? { ...previous } : { dev: stat.dev, ino: stat.ino, offset: 0, skipping: false, print: "" };
|
|
228
|
+
const started = state.offset;
|
|
229
|
+
const seal = (done: FileState): FileState => ({ ...done, print: done.offset === started && previous ? done.print : this.fingerprint(fd, done.offset) });
|
|
230
|
+
const rows: KimiWireRow[] = [];
|
|
231
|
+
const length = Math.min(limit, stat.size - state.offset);
|
|
232
|
+
if (length <= 0) return { state, rows, more: false, read: 0 };
|
|
233
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
234
|
+
let got = 0;
|
|
235
|
+
while (got < length) {
|
|
236
|
+
let n: number;
|
|
237
|
+
try { n = readSync(fd, buffer, got, length - got, state.offset + got); } catch { throw new Unknown("io-error"); }
|
|
238
|
+
if (n <= 0) break;
|
|
239
|
+
got += n;
|
|
240
|
+
}
|
|
241
|
+
if (got < length) throw new Unknown("shrunk");
|
|
242
|
+
const base = state.offset;
|
|
243
|
+
const reachedEnd = base + length >= stat.size;
|
|
244
|
+
let pos = 0;
|
|
245
|
+
if (state.skipping) {
|
|
246
|
+
const end = buffer.indexOf(10, 0);
|
|
247
|
+
if (end < 0) return { state: seal({ ...state, offset: base + length }), rows, more: !reachedEnd, read: length };
|
|
248
|
+
pos = end + 1;
|
|
249
|
+
state.skipping = false;
|
|
250
|
+
}
|
|
251
|
+
while (pos < length) {
|
|
252
|
+
const end = buffer.indexOf(10, pos);
|
|
253
|
+
if (end < 0) {
|
|
254
|
+
const tail = length - pos;
|
|
255
|
+
if (tail >= LINE_CAP) {
|
|
256
|
+
// A line longer than any usage row: refuse only when it looks like one, else skip it unread.
|
|
257
|
+
if (buffer.subarray(pos, pos + 256).includes(MARKER) || base + pos === 0) throw new Unknown(base + pos === 0 ? "no-metadata" : "oversized-row");
|
|
258
|
+
state.skipping = true;
|
|
259
|
+
pos = length;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
if (reachedEnd) throw new Unknown("partial-line");
|
|
263
|
+
break; // the chunk ended inside a line: it is read whole next call
|
|
264
|
+
}
|
|
265
|
+
const lineStart = base + pos, size = end - pos;
|
|
266
|
+
if (size > LINE_CAP) {
|
|
267
|
+
if (buffer.subarray(pos, pos + 256).includes(MARKER) || lineStart === 0) throw new Unknown(lineStart === 0 ? "no-metadata" : "oversized-row");
|
|
268
|
+
} else if (lineStart === 0) {
|
|
269
|
+
this.metadata(buffer.toString("utf8", pos, end));
|
|
270
|
+
} else if (buffer.subarray(pos, end).includes(MARKER)) {
|
|
271
|
+
const row = this.row(agent, lineStart, buffer.toString("utf8", pos, end));
|
|
272
|
+
if (row) rows.push(row);
|
|
273
|
+
}
|
|
274
|
+
pos = end + 1;
|
|
275
|
+
}
|
|
276
|
+
state.offset = base + pos;
|
|
277
|
+
return { state: seal(state), rows, more: state.offset < stat.size, read: length };
|
|
278
|
+
} finally { closeSync(fd); }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private metadata(line: string): void {
|
|
282
|
+
let value: any;
|
|
283
|
+
try { value = JSON.parse(line); } catch { throw new Unknown("no-metadata"); }
|
|
284
|
+
if (!value || value.type !== "metadata" || typeof value.protocol_version !== "string") throw new Unknown("no-metadata");
|
|
285
|
+
if (!KIMI_WIRE_PROTOCOLS.includes(value.protocol_version)) throw new Unknown("untested-protocol");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A line that mentions `usage.record`: a usage row, or (valid JSON of another type) something that only quotes it. */
|
|
289
|
+
private row(agent: string, offset: number, line: string): KimiWireRow | undefined {
|
|
290
|
+
let value: any;
|
|
291
|
+
try { value = JSON.parse(line); } catch { throw new Unknown("malformed-row"); }
|
|
292
|
+
if (!value || typeof value !== "object" || value.type !== "usage.record") return undefined;
|
|
293
|
+
const usage = value.usage;
|
|
294
|
+
const inputOther = count(usage?.inputOther), output = count(usage?.output), inputCacheRead = count(usage?.inputCacheRead), inputCacheCreation = count(usage?.inputCacheCreation);
|
|
295
|
+
const at = value.time;
|
|
296
|
+
if (inputOther === undefined || output === undefined || inputCacheRead === undefined || inputCacheCreation === undefined
|
|
297
|
+
|| (value.usageScope !== "turn" && value.usageScope !== "session")
|
|
298
|
+
|| typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0 || at > 8.64e15) throw new Unknown("unknown-row-shape");
|
|
299
|
+
const model = safeModelLabel(value.model);
|
|
300
|
+
const id = createHash("sha256").update(this.sessionId).update("\0").update(agent).update("\0").update(String(offset)).digest("hex").slice(0, 32);
|
|
301
|
+
return { id, at, ...(model ? { model } : {}), inputOther, inputCacheCreation, inputCacheRead, output };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ClaudeImplementerOptions } from "./claude-implementer.ts";
|
|
1
2
|
import type { ReviewerBackend, ReviewerPrintOptions } from "./reviewer-print.ts";
|
|
2
3
|
import type { SettingDef } from "../hub/settings.ts";
|
|
3
4
|
import type { Addon } from "../hub/tooling.ts";
|
|
@@ -20,7 +21,7 @@ import type { BuiltinCliEntry } from "./definitions/native-cli-entry-types.ts";
|
|
|
20
21
|
import type { NativePreviewBuilder } from "./definitions/native-cli-preview-common.ts";
|
|
21
22
|
|
|
22
23
|
export type StartForm = "tui" | "headless";
|
|
23
|
-
export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print" | "reviewer-print";
|
|
24
|
+
export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print" | "reviewer-print" | "claude-implementer";
|
|
24
25
|
|
|
25
26
|
/** Existing buildLaunch inputs plus the startup-bound instance and project identity. */
|
|
26
27
|
export interface LaunchContext {
|
|
@@ -62,7 +63,8 @@ export type DaemonPorts =
|
|
|
62
63
|
| { pi: PiOptions }
|
|
63
64
|
| { local: LocalOptions }
|
|
64
65
|
| { claudePrint: ClaudePrintOptions }
|
|
65
|
-
| { reviewerPrint: ReviewerPrintOptions }
|
|
66
|
+
| { reviewerPrint: ReviewerPrintOptions }
|
|
67
|
+
| { claudeImplementer: ClaudeImplementerOptions };
|
|
66
68
|
|
|
67
69
|
export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
|
|
68
70
|
instance: string;
|
|
@@ -200,7 +202,7 @@ export interface AdapterDefinition {
|
|
|
200
202
|
ownedFlags: readonly string[];
|
|
201
203
|
conductorStartArgs?(args: string[]): { mode?: StartForm } | Error;
|
|
202
204
|
};
|
|
203
|
-
identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin" };
|
|
205
|
+
identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin"; currentChild?: true };
|
|
204
206
|
/** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
|
|
205
207
|
instances?: { mode: "external" | "headless"; roles?: readonly string[];
|
|
206
208
|
/** Each declared instance runs its own native data home (`jcode_home`) and may pass its own provider, model and profile names (#685). Fixed builtins only. */
|
|
@@ -300,6 +302,8 @@ export interface AdapterDefinition {
|
|
|
300
302
|
hooks?: Partial<NativeHooks>;
|
|
301
303
|
/** #223: what a declared headless reviewer-only instance of this kind runs as. Fixed builtins only. */
|
|
302
304
|
reviewerVariant?: AdapterDefinition;
|
|
305
|
+
/** #525: fixed builtin variant for declared headless implementer instances. */
|
|
306
|
+
headlessVariant?: AdapterDefinition;
|
|
303
307
|
/** Fixed host backend; runtime isolation proof is required before bus admission. */
|
|
304
308
|
reviewer?: { backend?: ReviewerBackend; defaultPool: boolean; unavailable?: string; declaration: "automatic" | "explicit" };
|
|
305
309
|
}
|
package/src/adapters/registry.ts
CHANGED
|
@@ -24,7 +24,10 @@ function immutable<T>(value: T, seen = new WeakSet<object>()): T {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION, AGY_DEFINITION, AGY_REVIEWER_VARIANT]);
|
|
27
|
-
for (const definition of [...fixedBuiltinDefinitions])
|
|
27
|
+
for (const definition of [...fixedBuiltinDefinitions]) {
|
|
28
|
+
if (definition.reviewerVariant) fixedBuiltinDefinitions.add(definition.reviewerVariant);
|
|
29
|
+
if (definition.headlessVariant) fixedBuiltinDefinitions.add(definition.headlessVariant);
|
|
30
|
+
}
|
|
28
31
|
|
|
29
32
|
/** Keep the native catalog's order, independently of the legacy default-role object order. */
|
|
30
33
|
export const ADAPTERS: readonly AdapterDefinition[] = immutable([
|
|
@@ -65,6 +68,7 @@ for (const definition of [...ADAPTERS, ...declarationOnlyDefinitions]) {
|
|
|
65
68
|
assertDefinitionCoherence(definition);
|
|
66
69
|
assertNativeCapabilityScope(definition);
|
|
67
70
|
if (definition.reviewerVariant) { assertDefinitionCoherence(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
|
|
71
|
+
if (definition.headlessVariant) { assertDefinitionCoherence(definition.headlessVariant); assertNativeCapabilityScope(definition.headlessVariant); }
|
|
68
72
|
if (definition.api !== ADAPTER_API || definitions.has(definition.kind)) throw new Error("invalid builtin adapter registry");
|
|
69
73
|
definitions.set(definition.kind, definition);
|
|
70
74
|
}
|
|
@@ -114,7 +118,11 @@ function resolvedDefinition(id: string, kind: string | undefined, instances: Kin
|
|
|
114
118
|
const reviewerOnly = Array.isArray(roles) && !types.isProxy(roles) && roles.length === 1 && Object.getOwnPropertyDescriptor(roles, "0")?.value === "reviewer";
|
|
115
119
|
const variant = definition?.reviewerVariant;
|
|
116
120
|
const fresh = variant?.reviewer?.declaration === "automatic" || ownField(declaration, "review_mode") === "fresh";
|
|
117
|
-
|
|
121
|
+
const headless = kind !== id && ownField(declaration, "start_mode") === "headless";
|
|
122
|
+
if (variant && headless && reviewerOnly && fresh) return variant;
|
|
123
|
+
const roleValues = Array.isArray(roles) && !types.isProxy(roles) && roles.length <= 4 ? Array.from({ length: roles.length }, (_, index) => Object.getOwnPropertyDescriptor(roles, String(index))?.value) : [];
|
|
124
|
+
const writerRoles = roleValues.includes("implementer") && roleValues.every(role => role === "implementer" || role === "verifier");
|
|
125
|
+
return headless && writerRoles && definition?.headlessVariant ? definition.headlessVariant : definition;
|
|
118
126
|
}
|
|
119
127
|
export function familyOf(id: string, instances?: KindMap): string | undefined {
|
|
120
128
|
return definitionOf(id, instances)?.family;
|
|
@@ -217,6 +225,7 @@ export function assertDefinitionCoherence(definition: AdapterDefinition): void {
|
|
|
217
225
|
}
|
|
218
226
|
}
|
|
219
227
|
if (definition.reviewerVariant && !definition.reviewerVariant.reviewer) fail("reviewerVariant.reviewer", "reviewer variant lacks reviewer policy");
|
|
228
|
+
if (definition.headlessVariant && (definition.headlessVariant.transport !== "hub-native" || !definition.headlessVariant.start.create || definition.headlessVariant.instances?.mode !== "headless")) fail("headlessVariant", "implementer variant requires its headless hub-native factory");
|
|
220
229
|
if (definition.reviewer?.unavailable && (definition.start.create || definition.reviewer.backend)) fail("reviewer", "unavailable reviewer must remain metadata-only");
|
|
221
230
|
}
|
|
222
231
|
|
|
@@ -233,7 +242,10 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
233
242
|
["routing", definition.routing !== undefined],
|
|
234
243
|
["projection", definition.projection !== undefined],
|
|
235
244
|
["instances.nativeHome", declared(definition.instances, "nativeHome")], // #685: a fixed-builtin assumption, one native data home per declared instance
|
|
245
|
+
["identity.currentChild", declared(definition.identity, "currentChild")],
|
|
236
246
|
["reviewerVariant", definition.reviewerVariant !== undefined],
|
|
247
|
+
["headlessVariant", definition.headlessVariant !== undefined],
|
|
248
|
+
["start.daemonPort claude-implementer", definition.start.daemonPort === "claude-implementer"],
|
|
237
249
|
["reviewer", definition.reviewer !== undefined],
|
|
238
250
|
["hooks.factsHook", declared(definition.hooks, "factsHook")],
|
|
239
251
|
["cli.entry", declared(definition.cli, "entry")],
|