@staix/agent-hub 0.13.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +19 -7
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +18 -4
  5. package/docs/agent-notes/budget.md +5 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +19 -7
  8. package/docs/agent-notes/tasks.md +15 -7
  9. package/docs/agent-notes/tests.md +4 -4
  10. package/docs/dogfood.md +18 -10
  11. package/docs/events.md +21 -7
  12. package/docs/operations.md +131 -27
  13. package/docs/profiles.md +1 -0
  14. package/docs/quickstart.md +13 -3
  15. package/docs/sdlc.md +40 -2
  16. package/docs/security.md +45 -14
  17. package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
  18. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
  19. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  20. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  21. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  22. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  23. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  24. package/docs/verified.json +34 -17
  25. package/package.json +1 -1
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +1078 -327
  28. package/src/adapters/acp.ts +43 -7
  29. package/src/adapters/claude-channel.ts +26 -4
  30. package/src/adapters/claude-implementer.ts +243 -0
  31. package/src/adapters/claude-print-child.ts +90 -0
  32. package/src/adapters/claude-task-sessions.ts +80 -0
  33. package/src/adapters/codex-review-isolation.ts +18 -7
  34. package/src/adapters/compat.ts +4 -0
  35. package/src/adapters/conformance/enrollment.ts +2 -1
  36. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  37. package/src/adapters/definitions/builtin-metadata.ts +15 -3
  38. package/src/adapters/definitions/native-channel-state.ts +1 -1
  39. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  40. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  41. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  42. package/src/adapters/definitions/native-factories.ts +8 -0
  43. package/src/adapters/kimi-wire-usage.ts +303 -0
  44. package/src/adapters/registry-types.ts +7 -3
  45. package/src/adapters/registry.ts +14 -2
  46. package/src/adapters/reviewer-print.ts +64 -60
  47. package/src/cli/console-config.ts +123 -0
  48. package/src/cli/console-state.ts +28 -1
  49. package/src/cli/console.ts +25 -1
  50. package/src/cli/facts-hook.ts +9 -5
  51. package/src/cli/help.ts +6 -4
  52. package/src/cli/launch.ts +10 -4
  53. package/src/cli/main.ts +52 -9
  54. package/src/cli/output.ts +75 -2
  55. package/src/cli/reset.ts +1 -1
  56. package/src/cli/settings-edit.ts +89 -0
  57. package/src/cli/status-lines.ts +1 -1
  58. package/src/cli/terminal-recovery.ts +1 -6
  59. package/src/hub/action-enforce.ts +82 -0
  60. package/src/hub/action-policy.ts +22 -10
  61. package/src/hub/board.ts +48 -3
  62. package/src/hub/budget.ts +5 -0
  63. package/src/hub/bus.ts +5 -5
  64. package/src/hub/claude-launch-proof.ts +33 -0
  65. package/src/hub/config-apply.ts +5 -0
  66. package/src/hub/config-edit-host.ts +173 -0
  67. package/src/hub/config-edit.ts +282 -0
  68. package/src/hub/config-tier.ts +89 -0
  69. package/src/hub/control-client.ts +24 -3
  70. package/src/hub/daemon.ts +450 -71
  71. package/src/hub/deliverable-files.ts +131 -0
  72. package/src/hub/deliverables.ts +125 -0
  73. package/src/hub/delivery-journal.ts +18 -2
  74. package/src/hub/events.ts +3 -1
  75. package/src/hub/hub-tools.ts +21 -4
  76. package/src/hub/instances.ts +19 -6
  77. package/src/hub/kimi-usage.ts +144 -0
  78. package/src/hub/manager.ts +3 -3
  79. package/src/hub/peers.ts +7 -0
  80. package/src/hub/permission-payload.ts +15 -0
  81. package/src/hub/routing.ts +118 -3
  82. package/src/hub/sdlc-metrics.ts +260 -0
  83. package/src/hub/sdlc.ts +5 -3
  84. package/src/hub/settings.ts +1 -1
  85. package/src/hub/spec-read.ts +31 -0
  86. package/src/hub/task-code-group.ts +52 -0
  87. package/src/hub/tasks.ts +374 -37
  88. package/src/hub/token-window.ts +33 -0
  89. package/src/hub/ui.ts +3 -0
  90. package/src/hub/usage-rollup.ts +74 -6
  91. package/src/hub/workflow.ts +16 -0
  92. package/src/ui/index.html +388 -3
@@ -8,7 +8,7 @@ import { homedir, tmpdir } from "node:os";
8
8
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
9
9
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
10
10
  import { realPath } from "../hub/project.ts";
11
- import type { ReviewerBackend, ReviewerLaunch, ReviewerPrepareContext } from "./reviewer-print.ts";
11
+ import type { ReviewerBackend, ReviewerCauseClass, ReviewerLaunch, ReviewerPrepareContext } from "./reviewer-print.ts";
12
12
 
13
13
  const TOOLS = ["hub_review", "hub_task_show"];
14
14
  /** A version refusal names only versions and a command, so the daemon may show it (#691). */
@@ -176,8 +176,10 @@ function ownServer(text: string): Record<string, unknown> {
176
176
  }
177
177
 
178
178
  /** All reads are model-free and bounded; stdout/config/env values never become diagnostic text. */
179
- async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }> {
179
+ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string; causeClass?: ReviewerCauseClass; detail?: string }> {
180
180
  let root: string | undefined;
181
+ // #722: the proof leg being run, so a refusal names a class and never text from the native CLI. `timedOut` is set by `run` only.
182
+ let leg: ReviewerCauseClass = "binary", timedOut = false;
181
183
  try {
182
184
  const cwd = realPath(ctx.cwd), env = peerChildEnv(ctx.peer), bound = resolveCommand(ctx, cwd);
183
185
  const initialBinaryMetadata = bound.files.map(metadata).join(";");
@@ -191,12 +193,12 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
191
193
  return bound.files.map(metadata).join(";") === initialBinaryMetadata;
192
194
  };
193
195
  const run = (args: readonly string[], capture = true, maxBytes = CAP) => new Promise<string>((resolveOutput, reject) => {
194
- if (ctx.signal.aborted || performance.now() >= deadline) { reject(new Error("metadata deadline")); return; }
196
+ if (ctx.signal.aborted || performance.now() >= deadline) { if (!ctx.signal.aborted) timedOut = true; reject(new Error("metadata deadline")); return; }
195
197
  const [cmd, ...prefix] = bound.command;
196
198
  const child = spawn(cmd!, [...prefix, ...args], { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
197
199
  let output = "", bytes = 0, failed = false, settled = false;
198
200
  const stop = () => { failed = true; void finish(null); };
199
- const timer = setTimeout(stop, Math.max(1, Math.min(2000, deadline - performance.now())));
201
+ const timer = setTimeout(() => { if (!ctx.signal.aborted) timedOut = true; stop(); }, Math.max(1, Math.min(2000, deadline - performance.now())));
200
202
  ctx.signal.addEventListener("abort", stop, { once: true });
201
203
  const finish = async (code: number | null) => {
202
204
  if (settled) return; settled = true; clearTimeout(timer); ctx.signal.removeEventListener("abort", stop);
@@ -211,14 +213,18 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
211
213
  child.once("close", code => { void finish(code); });
212
214
  });
213
215
  // #691: the verified versions and the person's trust live in compat.ts; every proof below still runs after trust.
216
+ leg = "version";
214
217
  const verdict = assessVersion("codex-review", COMPAT["codex-review"].parse(await run(["--version"])));
215
218
  if (!verdict.ok) throw new VersionRefusal(verdict.reason);
216
219
  const version = verdict.version;
220
+ leg = "mcp_inventory";
217
221
  if (Buffer.byteLength(ctx.mcpConfig) > CAP || ctx.privatePaths.length > 128) throw new Error("policy bounds exceeded");
218
222
  const hub = ownServer(ctx.mcpConfig);
219
223
  // Apps creates a runtime MCP server outside the configured inventory; independently pin and read back its gate.
220
224
  const policy = ["-c", "notify=[]", "--disable", "plugins", "--disable", "hooks", "--disable", "apps", "-c", 'web_search="disabled"', "-c", `projects=${toml({ [cwd]: { trust_level: "untrusted" } })}`];
225
+ leg = "plugins_hooks_apps";
221
226
  proveDisabledFeatures(await run(["features", "list", ...policy]));
227
+ leg = "mcp_inventory";
222
228
  const before = inventory(await run(["mcp", "list", "--json", ...policy]));
223
229
  if (before.some(row => row.name === "agent-hub")) throw new Error("ambient hub MCP collision");
224
230
  const servers: Record<string, unknown> = Object.assign(Object.create(null), { "agent-hub": hub });
@@ -232,6 +238,7 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
232
238
  const observedEnv = object(observedTransport.env ?? {}), intendedEnv = object(hub.env);
233
239
  if (observedTransport.type !== "stdio" || observedTransport.command !== hub.command || JSON.stringify(observedTransport.args ?? []) !== JSON.stringify(hub.args) || Object.keys(observedEnv).length !== Object.keys(intendedEnv).length || Object.entries(intendedEnv).some(([key, value]) => observedEnv[key] !== value)) throw new Error("hub transport changed");
234
240
  // A real native sandbox probes a fresh private fixture: the model never sees these files.
241
+ leg = "sandbox_proof";
235
242
  root = realPath(mkdtempSync(join(tmpdir(), "ahub-codex-review-isolation-")));
236
243
  const privateFile = join(root, "private.env"), publicFile = join(root, "public.txt"), writeFile = join(root, "write-proof");
237
244
  const coreName = join(root, ".env"), coreStateDir = join(root, ".agenthub", "state"), coreState = join(coreStateDir, "private.txt");
@@ -245,8 +252,10 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
245
252
  writeFileSync(privateFile, "private fixture"); writeFileSync(publicFile, "public fixture"); writeFileSync(globFile, "glob private fixture");
246
253
  symlinkSync(globFile, alias);
247
254
  const filesystem: Record<string, string> = { "/": "read", [privateFile]: "deny", [join(globRoot, "**", "*.env")]: "deny" };
255
+ leg = "credential_root";
248
256
  for (const glob of codexCoreDenyGlobs()) filesystem[glob] = "deny";
249
257
  denyCredentialRoots(filesystem, [...hostCredentialRoots(env, cwd), ...hostCredentialRoots({ HOME: fixtureHome }, cwd)], cwd);
258
+ leg = "sandbox_proof";
250
259
  for (const entry of ctx.privatePaths) {
251
260
  if (typeof entry !== "string" || !entry || Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry)) throw new Error("invalid private path policy");
252
261
  // Native Seatbelt supports *, ?, and closed character classes, not brace alternatives.
@@ -268,6 +277,7 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
268
277
  const script = 'if /bin/cat "$1" >/dev/null 2>&1; then echo AHUB_CODEX_PUBLIC_READ; fi; if /bin/cat "$2" >/dev/null 2>&1; then exit 12; else echo AHUB_CODEX_PRIVATE_DENIED; fi; if (echo no > "$3") 2>/dev/null; then exit 13; else echo AHUB_CODEX_WRITE_DENIED; fi; if /bin/cat "$4" >/dev/null 2>&1; then exit 14; else echo AHUB_CODEX_GLOB_PRIVATE_DENIED; fi; if /bin/cat "$5" >/dev/null 2>&1; then exit 15; else echo AHUB_CODEX_ALIAS_PRIVATE_DENIED; fi; if /bin/cat "$6" >/dev/null 2>&1; then exit 16; else echo AHUB_CODEX_CORE_NAME_DENIED; fi; if /bin/cat "$7" >/dev/null 2>&1; then exit 17; else echo AHUB_CODEX_CORE_STATE_DENIED; fi; if /bin/cat "$8" >/dev/null 2>&1; then exit 18; else echo AHUB_CODEX_HOST_CONFIG_DENIED; fi; if /bin/cat "$9" >/dev/null 2>&1; then exit 19; else echo AHUB_CODEX_HOST_KUBE_DENIED; fi; if /bin/cat "${10}" >/dev/null 2>&1; then exit 20; else echo AHUB_CODEX_HOST_DOCKER_DENIED; fi; if /bin/cat "${11}" >/dev/null 2>&1; then exit 21; else echo AHUB_CODEX_HOST_ALIAS_DENIED; fi';
269
278
  const proof = await run(["sandbox", "-P", "ahub-review", "-C", cwd, ...policy, ...permissions, "/bin/sh", "-c", script, "proof", publicFile, privateFile, writeFile, globFile, alias, coreName, coreState, hostConfig, hostKube, hostDocker, hostAlias]);
270
279
  if (proof.trim() !== "AHUB_CODEX_PUBLIC_READ\nAHUB_CODEX_PRIVATE_DENIED\nAHUB_CODEX_WRITE_DENIED\nAHUB_CODEX_GLOB_PRIVATE_DENIED\nAHUB_CODEX_ALIAS_PRIVATE_DENIED\nAHUB_CODEX_CORE_NAME_DENIED\nAHUB_CODEX_CORE_STATE_DENIED\nAHUB_CODEX_HOST_CONFIG_DENIED\nAHUB_CODEX_HOST_KUBE_DENIED\nAHUB_CODEX_HOST_DOCKER_DENIED\nAHUB_CODEX_HOST_ALIAS_DENIED" || existsSync(writeFile)) throw new Error("native sandbox isolation unverified");
280
+ leg = "binary";
271
281
  if (!current()) throw new Error("binary or config changed during proof");
272
282
  const cleanupRoot = root;
273
283
  const launchProfile = digest(toml({ profile, servers, policy }));
@@ -279,12 +289,13 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
279
289
  } catch (error) {
280
290
  if (root) { try { rmSync(root, { recursive: true, force: true }); } catch { /* unavailable remains refused */ } }
281
291
  // Name only the path class of an unusable credential root so a stale ~/.docker link is fixable; never a path or contents.
282
- if (error instanceof VersionRefusal) return { unavailable: error.message };
292
+ if (error instanceof VersionRefusal) return { unavailable: error.message, causeClass: "version" };
283
293
  if (error instanceof CredentialRootError) {
284
294
  const why = { dangling: "a dangling link", unreadable: "an unreadable path", unsupported: "unsupported syntax or overlapping the checkout" }[error.kind];
285
- return { unavailable: `Codex reviewer isolation receipt unavailable (credential root ${error.label} is ${why}; remove or repair it)` };
295
+ return { unavailable: `Codex reviewer isolation receipt unavailable (credential root ${error.label} is ${why}; remove or repair it)`, causeClass: "credential_root", detail: `${error.label}: ${why}` };
286
296
  }
287
- return { unavailable: "Codex reviewer isolation receipt unavailable (version, MCP inventory, sandbox, configuration or process proof)" };
297
+ // A timeout is a proof command that ran out of time, whichever leg it was on; every other refusal names the leg that was running.
298
+ return { unavailable: "Codex reviewer isolation receipt unavailable (version, MCP inventory, sandbox, configuration or process proof)", causeClass: timedOut ? "timeout" : leg };
288
299
  }
289
300
  }
290
301
 
@@ -27,6 +27,10 @@ export interface CompatPolicy {
27
27
 
28
28
  const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
29
29
  export const COMPAT = {
30
+ // Receipt: docs/verification/2026-10-23-headless-claude-permissions.md
31
+ "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309"], gate: "contract", probe: ["claude", "--version"],
32
+ parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
33
+ trustNote: "The native prompt-tool allow, deny, MCP error/disconnect and hook-failure fallthrough have an isolated receipt. No dontAsk/bypass fallback exists; another version's permission behavior is unverified." },
30
34
  // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md
31
35
  "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
32
36
  parse: out => plain(out.trim().replace(/^codex-cli /, "")),
@@ -20,7 +20,8 @@ export function assertAcpLegEnrollment(definitions: readonly AdapterDefinition[]
20
20
  }
21
21
  export function definitionIdentities(definitions: readonly AdapterDefinition[]): { id: string; definition: AdapterDefinition }[] {
22
22
  return definitions.flatMap(definition => [{ id: definition.kind, definition },
23
- ...(definition.reviewerVariant ? [{ id: `${definition.kind}:reviewer`, definition: definition.reviewerVariant }] : [])]);
23
+ ...(definition.reviewerVariant ? [{ id: `${definition.kind}:reviewer`, definition: definition.reviewerVariant }] : []),
24
+ ...(definition.headlessVariant ? [{ id: `${definition.kind}:implementer`, definition: definition.headlessVariant }] : [])]);
24
25
  }
25
26
  export function assertEnrollment(definitions: readonly AdapterDefinition[], enrolled: readonly string[], unsupported: Readonly<Record<string, string>>, unsupportedLegs: Readonly<Record<string, string>> = {}, coherence?: (definition: AdapterDefinition) => void): void {
26
27
  assertAcpLegEnrollment(definitions, unsupportedLegs);
@@ -103,7 +103,9 @@ async function prompt(id: number, text: string) {
103
103
  if (text.includes("PERMISSION")) {
104
104
  // Kimi 2.0.1's shape: the arguments travel on the tool_call update, the permission request has none.
105
105
  const announced = text.includes("ANNOUNCED");
106
- if (announced) send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "s1", update: { sessionUpdate: "tool_call", toolCallId: "tc1", title: "Bash", status: "pending", rawInput: "rm -rf build && make" } } });
106
+ // `ACTIONCMD[<command>]` (#636) replaces the announced command, so a test can send a classed or an unclassed shell call.
107
+ const actionCommand = text.match(/ACTIONCMD\[([^\]]+)\]/)?.[1];
108
+ if (announced) send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "s1", update: { sessionUpdate: "tool_call", toolCallId: "tc1", title: "Bash", status: "pending", rawInput: actionCommand ? { command: actionCommand } : "rm -rf build && make" } } });
107
109
  // Kimi 2.1.1's shape (captured 2026-09-30, issue #72): no rawInput before the answer; the argument JSON
108
110
  // streams as content text on tool_call_update, cumulatively, and the request itself carries none.
109
111
  const tool = text.includes("HUBTOOL") || text.includes("NOONCE") ? "mcp__agent-hub__hub_task_list" : text.includes("SPOOF") ? "mcp__agent-hub__rm_rf" : "Bash";
@@ -19,7 +19,7 @@ import { piRecoveryContinuity } from "./native-recovery-continuity.ts";
19
19
  import { piConductorStartArgs } from "./native-cli-pi.ts";
20
20
  import { claudeNativeLaunch, CLAUDE_LAUNCH_POLICY } from "./native-cli-claude-launch.ts";
21
21
  import { codexNativeLaunch, CODEX_LAUNCH_POLICY } from "./native-cli-codex-launch.ts";
22
- import { createCodex, createKimi, createJcode, createPi, createLocal, createClaudeReviewer } from "./native-factories.ts";
22
+ import { createCodex, createKimi, createJcode, createPi, createLocal, createClaudeReviewer, createClaudeImplementer } from "./native-factories.ts";
23
23
  import { CLAUDE_DISPLAY, CODEX_DISPLAY, KIMI_DISPLAY, PI_DISPLAY, LOCAL_DISPLAY, JCODE_DISPLAY } from "./native-display.ts";
24
24
  import { CLAUDE_RECOVERY_SESSION, CODEX_RECOVERY_SESSION, KIMI_RECOVERY_SESSION, PI_RECOVERY_SESSION, LOCAL_RECOVERY_SESSION, JCODE_RECOVERY_SESSION } from "./native-recovery.ts";
25
25
  import { claudeWindows, codexWindows } from "./native-quota.ts";
@@ -80,6 +80,18 @@ const CLAUDE_REVIEWER_VARIANT: AdapterDefinition = {
80
80
  cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
81
81
  };
82
82
 
83
+ /** #525 R5a: no idle child; the host binds an owned worktree and one native session per task. */
84
+ const CLAUDE_IMPLEMENTER_VARIANT: AdapterDefinition = {
85
+ api: 1, kind: "claude", displayName: "Claude Code implementer", family: "claude", transport: "hub-native",
86
+ roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
87
+ start: { create: createClaudeImplementer, daemonPort: "claude-implementer", forms: headlessForms, defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
88
+ identity: { mcp: "env", currentChild: true }, instances: { mode: "headless", roles: ["implementer", "verifier"] },
89
+ permission: { grants: { ask: "every write, edit and shell command asks", "ask-when-needed": "file tools inside the owned worktree may run automatically; shell and protected paths ask" } },
90
+ session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: false, launchKind: "claude-implementer", crashPlan: () => ({ resume: {}, how: "Claude implementer resumes its recorded per-task sessions" }) },
91
+ turnEnd: "adapter", budget: { account: "claude" },
92
+ cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
93
+ };
94
+
83
95
  // Sources: peer-catalog.ts roles, roster.ts defaults, start-mode.ts forms, permission-mode.ts grants,
84
96
  // daemon.ts runtime mappings/stop policy, budget.ts exemptions, and ui/peer-palette.ts CSS foregrounds.
85
97
  export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
@@ -89,7 +101,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
89
101
  roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
90
102
  start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true,
91
103
  unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]) },
92
- identity: { mcp: "channel-plugin" }, instances: { mode: "external", roles: ["reviewer", "planner"] }, attach: { externalHello: true, recoveryLaunch: claudeRecoveryLaunch },
104
+ identity: { mcp: "channel-plugin" }, instances: { mode: "external", roles: ["planner", "implementer", "verifier", "reviewer"] }, attach: { externalHello: true, recoveryLaunch: claudeRecoveryLaunch },
93
105
  // Approval enforcement belongs to the verified native transport and host permission gate.
94
106
  permission: { grants: { ask: "Claude's own permission rules decide",
95
107
  "ask-when-needed": "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules",
@@ -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
- // #450 I3: a claude-<name> instance keeps its own identity, launch and recovery records; it never owns a worktree.
11
+ // Each terminal instance keeps its canonical hub identity and its separately validated execution root.
12
12
  const peer = ctx.instance ?? "claude";
13
13
  if (ctx.hubStarted) {
14
- // #223: the hub starts a headless reviewer and runs one `claude -p` per review; nothing runs in this terminal.
15
- if (args.includes("--print-command") || args.includes("--dry-run")) return console.log(JSON.stringify({ peer, start: "hub", note: "a headless reviewer the hub runs per review" }, null, 2));
14
+ // Both builtin headless variants are hub-owned; nothing runs in this terminal.
15
+ const declaration = ctx.instance ? projectConfig().instances?.[ctx.instance] : undefined;
16
+ const writer = declaration?.roles?.includes("implementer") && declaration.start_mode === "headless";
17
+ const description = writer ? "a headless implementer the hub runs per task" : "a headless reviewer the hub runs per review";
18
+ if (args.includes("--print-command") || args.includes("--dry-run")) return console.log(JSON.stringify({ peer, start: "hub", note: description }, null, 2));
16
19
  const hub = await ctx.connect();
17
20
  try {
18
21
  const snapshot = (await hub.request({ t: "status" })).status;
@@ -20,17 +23,19 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
20
23
  const res = await hub.request({ t: "start", peer, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
21
24
  if (res.ok !== true) fail(String(res.error ?? `${peer} was not started`));
22
25
  } finally { hub.close(); }
23
- return console.log(`${peer} started: a headless reviewer the hub runs per review (stop it with ahub stop ${peer})`);
26
+ return console.log(`${peer} started: ${description} (stop it with ahub stop ${peer})`);
24
27
  }
25
28
  const { launcherPreview, readControl, prepareTerminalLaunch, registerTerminalLaunch, claudeObservationHooks, buildLaunch, runClaudeLaunch, processSignature, recordClaudeLaunch, cleanupStaleClaudeSettings, nativeLaunchEnv, childEnv, cleanupClaudeSettings } = ctx.helpers;
26
29
  if (args.includes("--print-command") || args.includes("--dry-run")) {
27
30
  try { return console.log(JSON.stringify(launcherPreview("claude", args, cwd, stateDir, unattendedEnv), null, 2)); }
28
31
  catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
29
32
  }
33
+ const executionCwd = ctx.instance ? ctx.helpers.resolveExecutionCwd(peer) : cwd;
34
+ ctx.executionCwd = executionCwd;
30
35
  const control = readControl(stateDir);
31
36
  if (control?.instanceId) {
32
37
  process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
33
- const terminal = await prepareTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: cwd } : undefined);
38
+ const terminal = await prepareTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd } : undefined);
34
39
  if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
35
40
  else {
36
41
  const hub = await ctx.connect();
@@ -41,7 +46,7 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
41
46
  // `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
42
47
  // project local, then project, then user.
43
48
  let original: { command?: string; refreshInterval?: number; padding?: number } | undefined;
44
- for (const file of [join(cwd, ".claude", "settings.local.json"), join(cwd, ".claude", "settings.json"), join(process.env.HOME ?? "", ".claude", "settings.json")]) {
49
+ for (const file of [join(executionCwd, ".claude", "settings.local.json"), join(executionCwd, ".claude", "settings.json"), join(process.env.HOME ?? "", ".claude", "settings.json")]) {
45
50
  try {
46
51
  original ??= JSON.parse(readFileSync(file, "utf8")).statusLine;
47
52
  } catch {
@@ -58,14 +63,14 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
58
63
  let launchRecord: ClaudeLaunchRecord | undefined;
59
64
  if (control?.instanceId) {
60
65
  // Publish only the final launch's installed hook and effective permission flags.
61
- launchRecord = { ...(ctx.instance ? { peer } : {}), instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID!, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
66
+ launchRecord = { ...(ctx.instance ? { peer, executionCwd } : {}), instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID!, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
62
67
  recordClaudeLaunch(stateDir, launchRecord);
63
68
  cleanupStaleClaudeSettings(stateDir, previousLaunch);
64
69
  }
65
70
  if (launch.warning) console.error(launch.warning);
66
71
  const env = nativeLaunchEnv("claude", childEnv());
67
72
  if (ctx.instance) env.AGENTHUB_PEER_ID = peer; // the channel plugin and the statusline tee write for this instance
68
- result = await runClaudeLaunch(launch, { cwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...(launchRecord ? { record: launchRecord } : {}) });
73
+ result = await runClaudeLaunch(launch, { cwd: executionCwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...(launchRecord ? { record: launchRecord } : {}) });
69
74
  } finally {
70
75
  cleanupClaudeSettings(stateDir, launch.settingsFile);
71
76
  }
@@ -5,6 +5,19 @@ export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
5
5
  const peer = ctx.instance ?? "codex";
6
6
  const { args, cwd, stateDir, unattendedEnv, projectConfig, connect, fail, exec } = ctx;
7
7
  const { launcherPreview, buildLaunch, readControl, prepareTerminalLaunch, registerTerminalLaunch } = ctx.helpers;
8
+ if (ctx.hubStarted) {
9
+ // #720: the hub starts a headless reviewer (a fresh Codex per review); nothing runs in this terminal, so there is no TUI launch,
10
+ // no terminal recovery and no exec. Launch arguments were already refused by instanceLaunchArguments.
11
+ if (args.includes("--print-command") || args.includes("--dry-run")) return console.log(JSON.stringify({ peer, start: "hub", note: "a headless reviewer the hub runs per review" }, null, 2));
12
+ const hub = await connect();
13
+ try {
14
+ const snapshot = (await hub.request({ t: "status" })).status;
15
+ const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
16
+ const res = await hub.request({ t: "start", peer, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
17
+ if (res.ok !== true) fail(String(res.error ?? `${peer} was not started`));
18
+ } finally { hub.close(); }
19
+ return console.log(`${peer} started: a headless reviewer the hub runs per review (stop it with ahub stop ${peer})`);
20
+ }
8
21
  if (args.includes("--print-command") || args.includes("--dry-run")) {
9
22
  try { return console.log(JSON.stringify(launcherPreview("codex", args, cwd, stateDir, unattendedEnv), null, 2)); }
10
23
  catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
@@ -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
+ }