@halofy/agent-connect 0.5.1 → 0.6.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/README.md CHANGED
@@ -25,7 +25,7 @@ bounded recall block formats stay in place and tested for when it returns.
25
25
  There is one setup path for every packaged client:
26
26
 
27
27
  ```bash
28
- npx --yes @halofy/agent-connect@0.5.1 install <client-kind> \
28
+ npx --yes @halofy/agent-connect@0.6.0 install <client-kind> \
29
29
  --server https://app.halofy.ai \
30
30
  --claim '<one-time-claim>'
31
31
  ```
@@ -81,6 +81,20 @@ represented by digest-only placeholders and make coverage partial;
81
81
  encrypted retry queue, and real-host fixtures are verified. The adapter never
82
82
  opens an arbitrary transcript-referenced local file to fill that gap.
83
83
 
84
+ Since 0.6.0, reviewed transcript drivers extend host-reported token usage and
85
+ content-free session metadata to Codex CLI (rollout files) and Kimi Code CLI
86
+ (session wire files). The same containment commitment applies: a driver never
87
+ opens a hook-supplied path. It derives the session file from a validated
88
+ session id (`[A-Za-z0-9_-]{1,128}`) under the host's own root
89
+ (`CODEX_HOME`/`~/.codex`, `KIMI_CODE_HOME`/`~/.kimi-code`), and every
90
+ index-supplied or cached path must realpath-resolve inside that root or the
91
+ read is skipped. Capture additionally requires the installation's frozen
92
+ `tokenUsage` capability — installations consented before 0.6.0 never have
93
+ their transcripts read until reinstalled under the current disclosure. A
94
+ single `install all --claims <kind>=<claim>,…` invocation sweeps the claimed,
95
+ detected hosts with one CONNECT covering the explicitly listed set; each host
96
+ keeps its own installation, capabilities, and status.
97
+
84
98
  Run focused checks from this directory:
85
99
 
86
100
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halofy/agent-connect",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Halofy lifecycle installer and runtime for supported agents; runtime requests are signed with a per-installation Ed25519 key",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "scripts": {
26
26
  "test": "node --test test/*.test.mjs",
27
- "check": "node --check src/*.mjs && node --check bin/*.mjs",
27
+ "check": "node --check src/*.mjs && node --check src/transcript-drivers/*.mjs && node --check bin/*.mjs",
28
28
  "prepack": "npm run check && npm test"
29
29
  },
30
30
  "engines": {
@@ -58,8 +58,9 @@ export const CLIENT_REGISTRY = Object.freeze({
58
58
  subagents: true, compactionCheckpoints: true, contextRecalled: true,
59
59
  // 0.5.0: host-reported model token usage, thinking blocks, structured
60
60
  // tool outcomes, and content-free session metadata from the native
61
- // transcript. Claude Code is the only host whose reviewed adapter
62
- // reads these today.
61
+ // transcript. Since 0.6.0 codex and kimi-cli also report usage and
62
+ // metadata via their reviewed transcript drivers; thinking blocks and
63
+ // structured tool outcomes remain Claude Code-only.
63
64
  tokenUsage: true, sessionMetadata: true, toolOutcomes: true,
64
65
  thinking: true,
65
66
  }),
@@ -104,6 +105,9 @@ export const CLIENT_REGISTRY = Object.freeze({
104
105
  toolInputs: true, toolOutputs: true, toolFailures: true,
105
106
  artifactReferences: true, subagents: true, compactionCheckpoints: true,
106
107
  contextRecalled: true,
108
+ // 0.6.0: host-reported usage and content-free session metadata read
109
+ // from the session wire by the reviewed kimi transcript driver.
110
+ tokenUsage: true, sessionMetadata: true,
107
111
  }),
108
112
  }),
109
113
  vscode: Object.freeze({
@@ -131,6 +135,9 @@ export const CLIENT_REGISTRY = Object.freeze({
131
135
  subagentStart: true, subagentStop: true, postToolUse: true,
132
136
  userMessages: true, toolInputs: true, toolOutputs: true, subagents: true,
133
137
  compactionCheckpoints: true, contextRecalled: true,
138
+ // 0.6.0: host-reported usage and content-free session metadata read
139
+ // from the native rollout by the reviewed codex transcript driver.
140
+ tokenUsage: true, sessionMetadata: true,
134
141
  }),
135
142
  }),
136
143
  cline: Object.freeze({
@@ -2,6 +2,7 @@ import { chmod, mkdir, readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { readJson, writeHostConfigFile } from "./storage.mjs";
5
+ import { codexHome, kimiHome } from "./host-roots.mjs";
5
6
 
6
7
  function commandArg(value) {
7
8
  const text = String(value);
@@ -172,7 +173,7 @@ export async function configureKimi({
172
173
  installationId,
173
174
  nodePath = process.execPath,
174
175
  runtimePath,
175
- kimiRoot = process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code"),
176
+ kimiRoot = kimiHome(),
176
177
  }) {
177
178
  validateInputs({ installationId, runtimePath });
178
179
  const configPath = join(kimiRoot, "config.toml");
@@ -292,7 +293,7 @@ export async function configureCodex({
292
293
  installationId,
293
294
  nodePath = process.execPath,
294
295
  runtimePath,
295
- codexRoot = process.env.CODEX_HOME || join(homedir(), ".codex"),
296
+ codexRoot = codexHome(),
296
297
  }) {
297
298
  validateInputs({ installationId, runtimePath });
298
299
  const hooksPath = join(codexRoot, "hooks.json");
package/src/host-hook.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  } from "./session.mjs";
10
10
  import { defaultRuntimeDirectory } from "./storage.mjs";
11
11
  import { readHookInput } from "./claude-hook.mjs";
12
+ import { transcriptDriverFor } from "./transcript-drivers/index.mjs";
12
13
 
13
14
  const USER_EVENTS = new Set(["UserPromptSubmit", "beforeSubmitPrompt", "BeforeAgent", "pre_llm_call"]);
14
15
  const ASSISTANT_EVENTS = new Set(["afterAgentResponse", "AfterAgent", "post_llm_call", "transform_llm_output"]);
@@ -116,6 +117,29 @@ async function enqueueTool(runtime, session, eventName, input) {
116
117
  ]);
117
118
  }
118
119
 
120
+ /**
121
+ * Best-effort transcript catch-up for hosts with a reviewed driver. Consent
122
+ * is double-gated: the driver must exist for this client kind AND the
123
+ * install-time frozen capability snapshot must declare tokenUsage — an older
124
+ * installation never has its transcript read, even under an upgraded runtime.
125
+ * Failures are swallowed: capture evidence must never degrade the hook path.
126
+ */
127
+ async function catchUpHost(runtime, connection, session, sessionFacts = {}) {
128
+ try {
129
+ if (connection.capabilities?.tokenUsage !== true) return;
130
+ const driver = transcriptDriverFor(connection.clientKind);
131
+ if (driver === null) return;
132
+ const located = await driver.locate(session, {});
133
+ if (located === null) return;
134
+ await runtime.captureHostTranscript(session, driver, located.path, {
135
+ ...(located.sessionFacts ?? {}),
136
+ ...sessionFacts,
137
+ });
138
+ } catch {
139
+ // Transcript evidence is additive; the hook result stands without it.
140
+ }
141
+ }
142
+
119
143
  /** Runs one reviewed host hook without blocking the host when Halofy browns out. */
120
144
  export async function runHostLifecycleHook(connection, eventName, {
121
145
  input,
@@ -142,8 +166,10 @@ export async function runHostLifecycleHook(connection, eventName, {
142
166
  if (output) stdout.write(output);
143
167
  }
144
168
  } else if (USER_EVENTS.has(eventName)) {
145
- // Prompt capture is independent of recall: non-Claude hosts have no
146
- // transcript, so this enqueue is how user messages reach the archive.
169
+ // Prompt capture is independent of recall: hosts without a reviewed
170
+ // transcript driver rely on this enqueue to reach the archive, and
171
+ // hook-sourced events stay authoritative for messages even where a
172
+ // driver adds usage/metadata evidence.
147
173
  const prompt = promptText(hookInput);
148
174
  await enqueueMessage(runtime, connection, session, "user", prompt, eventName, hookInput);
149
175
  if (RECALL_INJECTION_ENABLED && connection.clientKind !== "cursor" &&
@@ -157,6 +183,7 @@ export async function runHostLifecycleHook(connection, eventName, {
157
183
  await runtime.commitIfThreshold(session);
158
184
  } else if (TOOL_EVENTS.has(eventName)) {
159
185
  await enqueueTool(runtime, session, eventName, hookInput);
186
+ await catchUpHost(runtime, connection, session);
160
187
  } else if (COMPACT_EVENTS.has(eventName)) {
161
188
  await runtime.enqueueSequencedEvents(session, ({ sessionHash, nextSequence }) => [
162
189
  normalizeClaudeHookEvent("compaction", {
@@ -164,14 +191,20 @@ export async function runHostLifecycleHook(connection, eventName, {
164
191
  event_id: eventEvidenceId(eventName, hookInput),
165
192
  }, { sessionHash, sequence: nextSequence }),
166
193
  ]);
194
+ await catchUpHost(runtime, connection, session);
167
195
  await runtime.commit(session, "pre_compaction");
168
196
  } else if (SUBAGENT_START_EVENTS.has(eventName)) {
169
197
  await runtime.resolveSession(childSession(hookInput, session), session);
170
198
  } else if (SUBAGENT_STOP_EVENTS.has(eventName)) {
199
+ // Subagent transcript files are a documented per-host limitation; the
200
+ // parent session still catches up so its usage stays current.
201
+ await within(10_000, () => catchUpHost(runtime, connection, session));
171
202
  await within(10_000, () => runtime.close(childSession(hookInput, session), "session_end"));
172
203
  } else if (END_EVENTS.has(eventName)) {
204
+ await within(10_000, () => catchUpHost(runtime, connection, session, { closeReason: "session_end" }));
173
205
  await within(15_000, () => runtime.close(session));
174
206
  } else if (STOP_EVENTS.has(eventName)) {
207
+ await within(10_000, () => catchUpHost(runtime, connection, session));
175
208
  if (connection.clientKind === "vscode" || connection.clientKind === "cline") {
176
209
  await within(15_000, () => runtime.close(session));
177
210
  } else {
@@ -0,0 +1,13 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ // Single authority for each host's on-disk root. host-config.mjs (hook/MCP
5
+ // installation) and transcript-drivers/ (session-file location) must agree on
6
+ // these paths or capture silently reads the wrong tree.
7
+ export function codexHome(env = process.env) {
8
+ return env.CODEX_HOME || join(homedir(), ".codex");
9
+ }
10
+
11
+ export function kimiHome(env = process.env) {
12
+ return env.KIMI_CODE_HOME || join(homedir(), ".kimi-code");
13
+ }
package/src/install.mjs CHANGED
@@ -61,6 +61,54 @@ export async function fetchClaimDisclosure({
61
61
  }
62
62
  }
63
63
 
64
+ /**
65
+ * Batch pre-consumption disclosure for the install-all sweep. One POST — and
66
+ * therefore one throttle token — covers every claim in the command, so a full
67
+ * sweep (1 disclosure + N consumes) fits the per-address budget. Entries come
68
+ * back positionally; an unusable claim is null. Any failure yields null for
69
+ * the whole batch (older server), never an error.
70
+ */
71
+ export async function fetchClaimDisclosures({
72
+ serverUrl,
73
+ claims,
74
+ fetchImpl = globalThis.fetch,
75
+ timeoutMs = 5_000,
76
+ }) {
77
+ try {
78
+ const normalizedServerUrl = normalizeLifecycleServerUrl(serverUrl);
79
+ const controller = new AbortController();
80
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
81
+ let response;
82
+ try {
83
+ response = await fetchImpl(`${normalizedServerUrl}/v1/agent-installations/claim-info`, {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify({ claims }),
87
+ signal: controller.signal,
88
+ });
89
+ } finally {
90
+ clearTimeout(timer);
91
+ }
92
+ if (!response.ok) return null;
93
+ const body = await response.json();
94
+ if (!Array.isArray(body?.disclosures)) return null;
95
+ return claims.map((_, index) => {
96
+ const entry = body.disclosures[index];
97
+ if (entry === null || typeof entry !== "object") return null;
98
+ // Server-supplied text is printed to a terminal: strip control
99
+ // characters (including ANSI escape introducers) and bound the length.
100
+ const organization = typeof entry.organization === "string"
101
+ ? entry.organization.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, 100)
102
+ : "";
103
+ const clientKind = typeof entry.clientKind === "string" &&
104
+ /^[a-z0-9][a-z0-9-]{0,63}$/.test(entry.clientKind) ? entry.clientKind : null;
105
+ return organization ? { organization, clientKind } : null;
106
+ });
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
64
112
  export async function consumeInstallationClaim({
65
113
  serverUrl,
66
114
  claim,
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import {
7
7
  fetchClaimDisclosure,
8
+ fetchClaimDisclosures,
8
9
  heartbeatInstalledConnection,
9
10
  installLocalConnection,
10
11
  installRuntimeBundle,
@@ -16,9 +17,50 @@ import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
16
17
  import { defaultRuntimeDirectory } from "./storage.mjs";
17
18
  import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
18
19
 
20
+ const CLAIM_PATTERN = /^hsc_[A-Za-z0-9_-]{43}$/;
21
+
22
+ function parseAllInstallerArgs(argv) {
23
+ const values = new Map();
24
+ for (let index = 2; index < argv.length; index += 1) {
25
+ const name = argv[index];
26
+ if (!["--server", "--claims", "--claude-project"].includes(name) || values.has(name)) {
27
+ throw new Error("unsupported or duplicate installer argument");
28
+ }
29
+ const value = argv[index + 1];
30
+ if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
31
+ values.set(name, value);
32
+ index += 1;
33
+ }
34
+ const serverUrl = values.get("--server");
35
+ const rawClaims = values.get("--claims");
36
+ const usage = "Usage: agent-connect install all --server <https-url> --claims <client>=<one-use-claim>[,...]";
37
+ if (!serverUrl || !rawClaims) throw new Error(usage);
38
+ const pairs = rawClaims.split(",");
39
+ if (pairs.length < 1 || pairs.length > CLIENT_KINDS.length) throw new Error(usage);
40
+ const selections = [];
41
+ const kinds = new Set();
42
+ for (const pair of pairs) {
43
+ const separator = pair.indexOf("=");
44
+ const clientKind = separator === -1 ? "" : pair.slice(0, separator);
45
+ const claim = separator === -1 ? "" : pair.slice(separator + 1);
46
+ if (!CLIENT_KINDS.includes(clientKind) || !CLAIM_PATTERN.test(claim) || kinds.has(clientKind)) {
47
+ throw new Error(usage);
48
+ }
49
+ kinds.add(clientKind);
50
+ selections.push({ clientKind, claim });
51
+ }
52
+ return {
53
+ mode: "all",
54
+ selections,
55
+ serverUrl,
56
+ projectRoot: resolve(values.get("--claude-project") || process.cwd()),
57
+ };
58
+ }
59
+
19
60
  export function parseInstallerArgs(argv) {
20
61
  const command = argv[0];
21
62
  const clientKind = argv[1];
63
+ if (command === "install" && clientKind === "all") return parseAllInstallerArgs(argv);
22
64
  const values = new Map();
23
65
  for (let index = 2; index < argv.length; index += 1) {
24
66
  const name = argv[index];
@@ -37,6 +79,7 @@ export function parseInstallerArgs(argv) {
37
79
  throw new Error("Usage: agent-connect install <supported-client> --server <https-url> --claim <one-use-claim>");
38
80
  }
39
81
  return {
82
+ mode: "single",
40
83
  clientKind,
41
84
  serverUrl,
42
85
  claim,
@@ -82,8 +125,20 @@ export function detectClient(clientKind) {
82
125
  return String(result.stdout || result.stderr || "").trim().slice(0, 128) || "detected";
83
126
  }
84
127
 
85
- export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion, organization = null }) {
86
- const client = lifecycleClient(clientKind);
128
+ /** Non-throwing detection probe for the install-all sweep. */
129
+ export async function probeClient(clientKind, {
130
+ detectClaude = detectClaudeCode,
131
+ detectHost = detectClient,
132
+ } = {}) {
133
+ try {
134
+ const version = clientKind === "claude-code" ? await detectClaude() : await detectHost(clientKind);
135
+ return { detected: true, version };
136
+ } catch (error) {
137
+ return { detected: false, reason: error?.message || "not detected" };
138
+ }
139
+ }
140
+
141
+ function captureCategories(client) {
87
142
  const observedCategories = [
88
143
  ["user messages", client.capabilities.userMessages],
89
144
  ["assistant messages", client.capabilities.assistantMessages],
@@ -101,8 +156,15 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
101
156
  ["tool outcomes (durations, failure flags, byte sizes; file paths only as salted hashes)", client.capabilities.toolOutcomes],
102
157
  ["host and session metadata (app version, permission mode, effort, session title; directory paths hashed unless your organization enables full device context)", client.capabilities.sessionMetadata],
103
158
  ];
104
- const supported = observedCategories.filter(([, value]) => value === true).map(([name]) => name);
105
- const unsupported = observedCategories.filter(([, value]) => value !== true).map(([name]) => name);
159
+ return {
160
+ supported: observedCategories.filter(([, value]) => value === true).map(([name]) => name),
161
+ unsupported: observedCategories.filter(([, value]) => value !== true).map(([name]) => name),
162
+ };
163
+ }
164
+
165
+ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion, organization = null }) {
166
+ const client = lifecycleClient(clientKind);
167
+ const { supported, unsupported } = captureCategories(client);
106
168
  return [
107
169
  `Halofy ${client.label} lifecycle connection`,
108
170
  `Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
@@ -132,6 +194,55 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
132
194
  ].join("\n");
133
195
  }
134
196
 
197
+ /**
198
+ * One disclosure for the whole sweep: shared header, one capture-category
199
+ * section per detected host, and an explicit statement of which minted claims
200
+ * will expire unused. One CONNECT then covers exactly the listed hosts —
201
+ * explicit per-host consent, never a silent fan-out.
202
+ */
203
+ export function allDisclosureText({ serverUrl, projectRoot, organization = null, hosts, unusedKinds = [] }) {
204
+ const lines = [
205
+ "Halofy all-agents lifecycle connection",
206
+ `Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
207
+ `Server: ${new URL(serverUrl).origin}`,
208
+ // The server named by --server identifies the organization this command
209
+ // binds to, so a spoofed command is recognizable before CONNECT.
210
+ `Organization: ${organization || "unverified (the server did not identify this command's organization)"}`,
211
+ `Project: ${projectRoot}`,
212
+ `Hosts to be connected: ${hosts.map((host) => lifecycleClient(host.clientKind).label).join(", ")}`,
213
+ "",
214
+ "Each host gets its own installation binding, capability record, and connection status:",
215
+ ];
216
+ for (const host of hosts) {
217
+ const client = lifecycleClient(host.clientKind);
218
+ const { supported, unsupported } = captureCategories(client);
219
+ lines.push(
220
+ "",
221
+ `--- ${client.label} (${host.clientVersion}) ---`,
222
+ `Supported capture categories: ${supported.length > 0 ? supported.join(", ") : "none"}.`,
223
+ `Unsupported capture categories: ${unsupported.length > 0 ? unsupported.join(", ") : "none"}.`,
224
+ client.coverage === "complete"
225
+ ? "This reviewed host surface can report complete coverage when all declared evidence is observed."
226
+ : `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
227
+ );
228
+ }
229
+ if (unusedKinds.length > 0) {
230
+ lines.push(
231
+ "",
232
+ `Claims for ${unusedKinds.map((kind) => lifecycleClient(kind).label).join(", ")} were issued ` +
233
+ "but those hosts were not detected here; they expire unused within 10 minutes.",
234
+ );
235
+ }
236
+ lines.push(
237
+ "",
238
+ "Authorized organization managers may review retained conversations and summaries.",
239
+ "This does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
240
+ "Disconnecting stops future capture but does not erase retained data.",
241
+ `Disclosure: ${DISCLOSURE_VERSION}`,
242
+ );
243
+ return lines.join("\n");
244
+ }
245
+
135
246
  export async function confirmDisclosure({ input = process.stdin, output = process.stdout } = {}) {
136
247
  if (!input.isTTY || !output.isTTY) throw new Error("interactive terminal confirmation is required");
137
248
  const prompt = createInterface({ input, output });
@@ -144,6 +255,136 @@ export async function confirmDisclosure({ input = process.stdin, output = proces
144
255
  return true;
145
256
  }
146
257
 
258
+ async function configureHost(clientKind, common, { claudeConfigPath } = {}) {
259
+ if (clientKind === "claude-code") {
260
+ return configureClaudeProject({ ...common, ...(claudeConfigPath ? { claudeConfigPath } : {}) });
261
+ }
262
+ if (clientKind === "cursor") return configureCursor(common);
263
+ if (clientKind === "gemini-cli") return configureGemini(common);
264
+ if (clientKind === "kimi-cli") return configureKimi(common);
265
+ if (clientKind === "vscode") return configureVscode(common);
266
+ if (clientKind === "codex") return configureCodex(common);
267
+ if (clientKind === "cline") return configureCline(common);
268
+ throw new Error("the selected adapter is not packaged yet");
269
+ }
270
+
271
+ async function runAllInstaller(input, {
272
+ root,
273
+ output,
274
+ detectClaude,
275
+ detectHost,
276
+ confirm,
277
+ fetchImpl,
278
+ sourceRoot,
279
+ claudeConfigPath,
280
+ }) {
281
+ const probes = [];
282
+ for (const selection of input.selections) {
283
+ probes.push({ ...selection, probe: await probeClient(selection.clientKind, { detectClaude, detectHost }) });
284
+ }
285
+ const detected = probes.filter((entry) => entry.probe.detected);
286
+ const skipped = probes.filter((entry) => !entry.probe.detected)
287
+ .map((entry) => ({ clientKind: entry.clientKind, reason: "not_detected" }));
288
+ if (detected.length === 0) {
289
+ throw new Error("no claimed agents were detected on this machine: " +
290
+ probes.map((entry) => `${lifecycleClient(entry.clientKind).label} (${entry.probe.reason})`).join("; "));
291
+ }
292
+
293
+ // One batch disclosure call spends one throttle token for the whole sweep.
294
+ const disclosures = await fetchClaimDisclosures({
295
+ serverUrl: input.serverUrl,
296
+ claims: input.selections.map((selection) => selection.claim),
297
+ fetchImpl,
298
+ });
299
+ let organization = null;
300
+ if (disclosures !== null) {
301
+ const organizations = new Set();
302
+ for (let index = 0; index < input.selections.length; index += 1) {
303
+ const disclosure = disclosures[index];
304
+ if (!disclosure) continue;
305
+ // A claim minted for one client kind pasted behind another label is a
306
+ // spoofed or reassembled command — refuse before any consent prompt.
307
+ if (disclosure.clientKind !== null && disclosure.clientKind !== input.selections[index].clientKind) {
308
+ throw new Error(`the claim labeled ${input.selections[index].clientKind} was issued for ` +
309
+ `${disclosure.clientKind}; refuse this command and generate a fresh one`);
310
+ }
311
+ if (disclosure.organization) organizations.add(disclosure.organization);
312
+ }
313
+ if (organizations.size > 1) {
314
+ throw new Error("the claims in this command belong to different organizations; " +
315
+ "refuse this command and generate a fresh one");
316
+ }
317
+ organization = organizations.size === 1 ? [...organizations][0] : null;
318
+ }
319
+
320
+ output.write(`${allDisclosureText({
321
+ serverUrl: input.serverUrl,
322
+ projectRoot: input.projectRoot,
323
+ organization,
324
+ hosts: detected.map((entry) => ({ clientKind: entry.clientKind, clientVersion: entry.probe.version })),
325
+ unusedKinds: skipped.map((entry) => entry.clientKind),
326
+ })}\n`);
327
+ await confirm();
328
+
329
+ // The reviewed runtime bundle installs exactly once for the whole sweep.
330
+ const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
331
+ const results = [];
332
+ for (const host of detected) {
333
+ try {
334
+ const installed = await installLocalConnection({
335
+ serverUrl: input.serverUrl,
336
+ claim: host.claim,
337
+ clientKind: host.clientKind,
338
+ root,
339
+ fetchImpl,
340
+ sendHeartbeat: false,
341
+ });
342
+ const configured = await configureHost(host.clientKind, {
343
+ projectRoot: input.projectRoot,
344
+ installationId: installed.installationId,
345
+ serverUrl: input.serverUrl,
346
+ runtimePath: bundle.runtimePath,
347
+ }, { claudeConfigPath });
348
+ let heartbeat = false;
349
+ try {
350
+ heartbeat = await heartbeatInstalledConnection({
351
+ installationId: installed.installationId, root, fetchImpl,
352
+ });
353
+ } catch {
354
+ heartbeat = false;
355
+ }
356
+ results.push({
357
+ clientKind: host.clientKind,
358
+ status: heartbeat ? "configured_heartbeat_verified" : "configured_heartbeat_unavailable",
359
+ installationId: installed.installationId,
360
+ proofStorage: installed.proofStorage,
361
+ configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
362
+ replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
363
+ nextStep: `Restart ${lifecycleClient(host.clientKind).label}, then check the connection in Halofy.`,
364
+ });
365
+ } catch (error) {
366
+ // One host failing must not abort the others; the failure is reported,
367
+ // never hidden.
368
+ skipped.push({
369
+ clientKind: host.clientKind,
370
+ reason: `failed:${(error?.message || "install_error").slice(0, 200)}`,
371
+ });
372
+ }
373
+ }
374
+ if (results.length === 0) {
375
+ throw new Error("every detected agent failed to install: " +
376
+ skipped.map((entry) => `${entry.clientKind} (${entry.reason})`).join("; "));
377
+ }
378
+ return {
379
+ status: skipped.some((entry) => entry.reason.startsWith("failed:"))
380
+ ? "completed_with_failures" : "completed",
381
+ installerVersion: INSTALLER_VERSION,
382
+ publishedPackage: true,
383
+ results,
384
+ skipped,
385
+ };
386
+ }
387
+
147
388
  export async function runInstaller(argv, {
148
389
  root = defaultRuntimeDirectory(),
149
390
  output = process.stdout,
@@ -155,6 +396,11 @@ export async function runInstaller(argv, {
155
396
  claudeConfigPath,
156
397
  } = {}) {
157
398
  const input = parseInstallerArgs(argv);
399
+ if (input.mode === "all") {
400
+ return runAllInstaller(input, {
401
+ root, output, detectClaude, detectHost, confirm, fetchImpl, sourceRoot, claudeConfigPath,
402
+ });
403
+ }
158
404
  const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
159
405
  const claimDisclosure = await fetchClaimDisclosure({
160
406
  serverUrl: input.serverUrl,
@@ -183,21 +429,7 @@ export async function runInstaller(argv, {
183
429
  serverUrl: input.serverUrl,
184
430
  runtimePath: bundle.runtimePath,
185
431
  };
186
- const configured = input.clientKind === "claude-code"
187
- ? await configureClaudeProject({ ...common, ...(claudeConfigPath ? { claudeConfigPath } : {}) })
188
- : input.clientKind === "cursor"
189
- ? await configureCursor(common)
190
- : input.clientKind === "gemini-cli"
191
- ? await configureGemini(common)
192
- : input.clientKind === "kimi-cli"
193
- ? await configureKimi(common)
194
- : input.clientKind === "vscode"
195
- ? await configureVscode(common)
196
- : input.clientKind === "codex"
197
- ? await configureCodex(common)
198
- : input.clientKind === "cline"
199
- ? await configureCline(common)
200
- : (() => { throw new Error("the selected adapter is not packaged yet"); })();
432
+ const configured = await configureHost(input.clientKind, common, { claudeConfigPath });
201
433
  let heartbeat = false;
202
434
  try {
203
435
  heartbeat = await heartbeatInstalledConnection({
package/src/runtime.mjs CHANGED
@@ -2,12 +2,10 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { join } from "node:path";
3
3
  import { BoundedEncryptedQueue } from "./queue.mjs";
4
4
  import {
5
- buildClaudeMetadataPayload,
6
- claudeMetadataEvent,
7
5
  CursorStore,
8
6
  deriveSessionHash,
9
- readClaudeTranscriptSuffix,
10
7
  } from "./session.mjs";
8
+ import { claudeTranscriptDriver } from "./transcript-drivers/claude.mjs";
11
9
  import { SignedRuntimeTransport } from "./transport.mjs";
12
10
  import { readJson, withFileLock, writePrivateFile } from "./storage.mjs";
13
11
  import { RUNTIME_VERSION } from "./version.mjs";
@@ -113,26 +111,30 @@ export class LifecycleRuntime {
113
111
  }
114
112
 
115
113
  async captureClaudeTranscript(hostSessionId, transcriptPath, sessionFacts = {}) {
114
+ return this.captureHostTranscript(hostSessionId, claudeTranscriptDriver, transcriptPath, sessionFacts);
115
+ }
116
+
117
+ async captureHostTranscript(hostSessionId, driver, transcriptPath, sessionFacts = {}) {
116
118
  const sessionHash = this.sessionHash(hostSessionId);
117
119
  const policy = await this.capturePolicy();
118
120
  const queued = await withFileLock(this.operationLockPath, async () => {
119
121
  const cursor = await this.cursors.get(sessionHash);
120
- const suffix = await readClaudeTranscriptSuffix(transcriptPath, cursor, sessionHash);
122
+ const suffix = await driver.readSuffix(transcriptPath, cursor, sessionHash);
121
123
  const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
122
124
  const unseenEvents = suffix.events.filter((event) => !recentEventKeys.has(event.eventKey));
123
125
  // One metadata event whenever the observed content-free session facts
124
126
  // change. The event key hashes the payload, so an unchanged snapshot is
125
127
  // deduplicated exactly like any repeated event.
126
- const metadataPayload = buildClaudeMetadataPayload(
128
+ const metadataPayload = driver.buildMetadataPayload(
127
129
  { ...suffix.metadata, ...sessionFacts },
128
130
  { installationId: this.connection.installationId, deviceContext: policy.deviceContext },
129
131
  );
130
132
  if (Object.keys(metadataPayload).length > 0) {
131
- const metadataEvent = claudeMetadataEvent(metadataPayload);
133
+ const metadataEvent = driver.metadataEvent(metadataPayload);
132
134
  if (!recentEventKeys.has(metadataEvent.eventKey)) unseenEvents.push(metadataEvent);
133
135
  }
134
136
  const usageGaps = unseenEvents.filter((event) =>
135
- event.type === "usage" && event.eventKey.startsWith("claude:usage-gap:")).length;
137
+ event.type === "usage" && event.eventKey.startsWith(driver.usageGapPrefix)).length;
136
138
  const result = unseenEvents.length === 0
137
139
  ? { queued: 0 }
138
140
  : await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
@@ -144,8 +146,11 @@ export class LifecycleRuntime {
144
146
  // normalized event is already durable in the encrypted queue. Advancing
145
147
  // this byte cursor prevents unbounded reparsing without advancing the
146
148
  // separately acknowledged event sequence.
147
- if (suffix.observedEndOffset > cursor.byteOffset) {
148
- await this.cursors.update(sessionHash, { byteOffset: suffix.observedEndOffset });
149
+ const patch = {};
150
+ if (suffix.observedEndOffset > cursor.byteOffset) patch.byteOffset = suffix.observedEndOffset;
151
+ if (suffix.hostState !== undefined) patch.hostState = suffix.hostState;
152
+ if (Object.keys(patch).length > 0) {
153
+ await this.cursors.update(sessionHash, patch);
149
154
  }
150
155
  await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
151
156
  if (usageGaps > 0) await this.cursors.bumpUsageGaps(usageGaps);
package/src/session.mjs CHANGED
@@ -86,7 +86,7 @@ function boundedCompletePayload(payload, { role, body, format = "json", extra =
86
86
  });
87
87
  }
88
88
 
89
- function normalizedEvent({ eventKey, type, occurredAt, payload, sourceEndOffset, part }) {
89
+ export function normalizedEvent({ eventKey, type, occurredAt, payload, sourceEndOffset, part }) {
90
90
  const {
91
91
  role,
92
92
  contentFormat = "json",
@@ -300,15 +300,15 @@ export function stripInjectedContext(value) {
300
300
  return String(value);
301
301
  }
302
302
 
303
- function usageInt(value) {
303
+ export function usageInt(value) {
304
304
  return Number.isSafeInteger(value) && value >= 0 && value < 2 ** 31 ? value : null;
305
305
  }
306
306
 
307
- function usageLabel(value) {
307
+ export function usageLabel(value) {
308
308
  return typeof value === "string" && /^[\x20-\x7e]{1,128}$/.test(value) ? value : null;
309
309
  }
310
310
 
311
- const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
311
+ export const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._:/-]{1,128}$/;
312
312
 
313
313
  /**
314
314
  * One host-reported usage record per assistant message (PRD §8.1). Claude
@@ -769,15 +769,19 @@ export function buildClaudeMetadataPayload(metadata, { installationId, deviceCon
769
769
  return payload;
770
770
  }
771
771
 
772
- export function claudeMetadataEvent(payload) {
772
+ export function hostMetadataEvent(namespace, payload) {
773
773
  return normalizedEvent({
774
- eventKey: `claude:metadata:${digest(stableJson(payload))}`,
774
+ eventKey: `${namespace}:metadata:${digest(stableJson(payload))}`,
775
775
  type: "metadata",
776
776
  occurredAt: new Date().toISOString(),
777
777
  payload: { contentFormat: "json", captureStatus: "complete", ...payload },
778
778
  });
779
779
  }
780
780
 
781
+ export function claudeMetadataEvent(payload) {
782
+ return hostMetadataEvent("claude", payload);
783
+ }
784
+
781
785
  export class CursorStore {
782
786
  constructor(root) {
783
787
  this.path = join(root, "cursors.json");
@@ -0,0 +1,33 @@
1
+ import {
2
+ buildClaudeMetadataPayload,
3
+ claudeMetadataEvent,
4
+ readClaudeTranscriptSuffix,
5
+ } from "../session.mjs";
6
+
7
+ /**
8
+ * Thin delegation over the reviewed Claude Code adapter. The claude path is
9
+ * frozen — its eventKeys are pinned by the golden fixture test and must stay
10
+ * byte-identical for server-side dedupe history. Claude Code hooks pass the
11
+ * transcript path directly, so this driver has no locate step.
12
+ */
13
+ export const claudeTranscriptDriver = {
14
+ clientKind: "claude-code",
15
+ eventKeyNamespace: "claude",
16
+ usageGapPrefix: "claude:usage-gap:",
17
+
18
+ async locate() {
19
+ return null;
20
+ },
21
+
22
+ readSuffix(path, cursor, sessionHash) {
23
+ return readClaudeTranscriptSuffix(path, cursor, sessionHash);
24
+ },
25
+
26
+ buildMetadataPayload(metadata, context) {
27
+ return buildClaudeMetadataPayload(metadata, context);
28
+ },
29
+
30
+ metadataEvent(payload) {
31
+ return claudeMetadataEvent(payload);
32
+ },
33
+ };
@@ -0,0 +1,188 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ buildClaudeMetadataPayload,
5
+ hostMetadataEvent,
6
+ normalizedEvent,
7
+ usageInt,
8
+ USAGE_MODEL_PATTERN,
9
+ } from "../session.mjs";
10
+ import { codexHome } from "../host-roots.mjs";
11
+ import {
12
+ boundedHostSessionId,
13
+ boundedHostState,
14
+ boundedMetadataText,
15
+ completeJsonlLines,
16
+ containedPath,
17
+ drainedEndOffset,
18
+ MAX_DRIVER_SUFFIX_BYTES,
19
+ readSuffixWindow,
20
+ } from "./shared.mjs";
21
+
22
+ const NAMESPACE = "codex";
23
+ const PROVIDER_PATTERN = /^[a-z0-9-]{1,64}$/;
24
+
25
+ /**
26
+ * Codex rollouts live at
27
+ * `<codexHome>/sessions/YYYY/MM/DD/rollout-<ts>-<session-id>.jsonl` — the
28
+ * session id is the filename suffix. The hook payload is never trusted for a
29
+ * path; only the validated session id is interpolated, and every result is
30
+ * containment-checked under the codex root.
31
+ */
32
+ async function locateRollout(root, sessionId) {
33
+ const sessionsRoot = join(root, "sessions");
34
+ let names;
35
+ try {
36
+ names = await readdir(sessionsRoot, { recursive: true });
37
+ } catch {
38
+ return null;
39
+ }
40
+ const suffix = `-${sessionId}.jsonl`;
41
+ const matches = names.filter((name) => {
42
+ const base = String(name).split(/[\\/]/).pop();
43
+ return base.startsWith("rollout-") && base.endsWith(suffix);
44
+ }).sort();
45
+ if (matches.length === 0) return null;
46
+ // Timestamped names sort chronologically; take the newest.
47
+ return containedPath(sessionsRoot, matches[matches.length - 1]);
48
+ }
49
+
50
+ function usageEventFromTokenCount(info, { model, provider, byteOffset, endOffset, occurredAt }) {
51
+ const usage = info?.last_token_usage;
52
+ const input = usageInt(usage?.input_tokens);
53
+ const output = usageInt(usage?.output_tokens);
54
+ if (usage === null || typeof usage !== "object" || (input === null && output === null)) {
55
+ return normalizedEvent({
56
+ eventKey: `${NAMESPACE}:usage-gap:${byteOffset}`,
57
+ type: "usage",
58
+ occurredAt,
59
+ payload: {
60
+ role: "assistant", contentFormat: "json", captureStatus: "complete",
61
+ gap: true, reason: "usage_unavailable",
62
+ },
63
+ sourceEndOffset: endOffset,
64
+ });
65
+ }
66
+ if (model === null) {
67
+ return normalizedEvent({
68
+ eventKey: `${NAMESPACE}:usage-gap:${byteOffset}`,
69
+ type: "usage",
70
+ occurredAt,
71
+ payload: {
72
+ role: "assistant", contentFormat: "json", captureStatus: "complete",
73
+ gap: true, reason: "model_unknown",
74
+ },
75
+ sourceEndOffset: endOffset,
76
+ });
77
+ }
78
+ const cached = usageInt(usage.cached_input_tokens) ?? 0;
79
+ const cacheWrite = usageInt(usage.cache_write_input_tokens) ?? 0;
80
+ // Verified against real rollouts: input_tokens is inclusive of BOTH the
81
+ // cached-read and cache-write tokens (total_tokens = input + output). The
82
+ // server prices input, cache-read, and cache-write as disjoint buckets, so
83
+ // the uncached-and-unwritten remainder is what belongs in inputTokens;
84
+ // clamp for malformed hosts.
85
+ const inputTokens = input === null ? null : Math.max(0, input - cached - cacheWrite);
86
+ return normalizedEvent({
87
+ eventKey: `${NAMESPACE}:usage:${byteOffset}`,
88
+ type: "usage",
89
+ occurredAt,
90
+ payload: {
91
+ role: "assistant",
92
+ contentFormat: "json",
93
+ captureStatus: "complete",
94
+ provider,
95
+ model,
96
+ providerRequestId: null,
97
+ messageId: null,
98
+ stopReason: null,
99
+ serviceTier: null,
100
+ effort: null,
101
+ sidechain: false,
102
+ latencyMs: null,
103
+ inputTokens,
104
+ outputTokens: output,
105
+ cacheReadTokens: usageInt(usage.cached_input_tokens),
106
+ cacheWriteTokens: usageInt(usage.cache_write_input_tokens),
107
+ cacheWrite1hTokens: null,
108
+ cacheWrite5mTokens: null,
109
+ reasoningTokens: usageInt(usage.reasoning_output_tokens),
110
+ },
111
+ sourceEndOffset: endOffset,
112
+ });
113
+ }
114
+
115
+ export const codexTranscriptDriver = {
116
+ clientKind: "codex",
117
+ eventKeyNamespace: NAMESPACE,
118
+ usageGapPrefix: `${NAMESPACE}:usage-gap:`,
119
+
120
+ async locate(hostSessionId, { env = process.env } = {}) {
121
+ const sessionId = boundedHostSessionId(hostSessionId);
122
+ if (sessionId === null) return null;
123
+ const path = await locateRollout(codexHome(env), sessionId);
124
+ return path === null ? null : { path };
125
+ },
126
+
127
+ async readSuffix(path, cursor, _sessionHash) {
128
+ const { buffer, start } = await readSuffixWindow(path, cursor?.byteOffset ?? 0);
129
+ const priorState = cursor?.hostState !== null && typeof cursor?.hostState === "object"
130
+ ? cursor.hostState : {};
131
+ let model = typeof priorState.model === "string" && USAGE_MODEL_PATTERN.test(priorState.model)
132
+ ? priorState.model : null;
133
+ let provider = typeof priorState.provider === "string" && PROVIDER_PATTERN.test(priorState.provider)
134
+ ? priorState.provider : "openai";
135
+ const metadata = {};
136
+ const events = [];
137
+ let lastEnd = start;
138
+ for (const line of completeJsonlLines(buffer, start)) {
139
+ lastEnd = line.endOffset;
140
+ const entry = line.entry;
141
+ if (entry === null) continue;
142
+ const payload = entry.payload !== null && typeof entry.payload === "object" ? entry.payload : {};
143
+ const occurredAt = typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString();
144
+ if (entry.type === "session_meta") {
145
+ if (typeof payload.model_provider === "string" && PROVIDER_PATTERN.test(payload.model_provider)) {
146
+ provider = payload.model_provider;
147
+ }
148
+ const hostVersion = boundedMetadataText(payload.cli_version, 64);
149
+ if (hostVersion) metadata.hostVersion = hostVersion;
150
+ const entrypoint = boundedMetadataText(payload.originator, 64);
151
+ if (entrypoint) metadata.entrypoint = entrypoint;
152
+ const metaCwd = boundedMetadataText(payload.cwd, 2048);
153
+ if (metaCwd) metadata.cwd = metaCwd;
154
+ continue;
155
+ }
156
+ if (entry.type === "turn_context") {
157
+ if (typeof payload.model === "string" && USAGE_MODEL_PATTERN.test(payload.model)) {
158
+ model = payload.model;
159
+ }
160
+ const turnCwd = boundedMetadataText(payload.cwd, 2048);
161
+ if (turnCwd) metadata.cwd = turnCwd;
162
+ continue;
163
+ }
164
+ if (entry.type === "event_msg" && payload.type === "token_count") {
165
+ events.push(usageEventFromTokenCount(payload.info, {
166
+ model, provider,
167
+ byteOffset: line.byteOffset,
168
+ endOffset: line.endOffset,
169
+ occurredAt,
170
+ }));
171
+ }
172
+ }
173
+ return {
174
+ events,
175
+ observedEndOffset: drainedEndOffset(lastEnd, start, buffer.length, MAX_DRIVER_SUFFIX_BYTES),
176
+ metadata,
177
+ hostState: boundedHostState({ ...priorState, model, provider }),
178
+ };
179
+ },
180
+
181
+ buildMetadataPayload(metadata, context) {
182
+ return buildClaudeMetadataPayload(metadata, context);
183
+ },
184
+
185
+ metadataEvent(payload) {
186
+ return hostMetadataEvent(NAMESPACE, payload);
187
+ },
188
+ };
@@ -0,0 +1,18 @@
1
+ import { claudeTranscriptDriver } from "./claude.mjs";
2
+ import { codexTranscriptDriver } from "./codex.mjs";
3
+ import { kimiTranscriptDriver } from "./kimi.mjs";
4
+
5
+ const DRIVERS = Object.freeze({
6
+ "claude-code": claudeTranscriptDriver,
7
+ codex: codexTranscriptDriver,
8
+ "kimi-cli": kimiTranscriptDriver,
9
+ });
10
+
11
+ /**
12
+ * The reviewed transcript driver for a client kind, or null. Hosts without a
13
+ * reviewed driver keep hooks-only capture — absence is a supported state,
14
+ * never an error.
15
+ */
16
+ export function transcriptDriverFor(clientKind) {
17
+ return DRIVERS[String(clientKind ?? "")] ?? null;
18
+ }
@@ -0,0 +1,180 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ buildClaudeMetadataPayload,
5
+ hostMetadataEvent,
6
+ normalizedEvent,
7
+ usageInt,
8
+ USAGE_MODEL_PATTERN,
9
+ } from "../session.mjs";
10
+ import { kimiHome } from "../host-roots.mjs";
11
+ import {
12
+ boundedHostSessionId,
13
+ boundedHostState,
14
+ boundedMetadataText,
15
+ completeJsonlLines,
16
+ containedPath,
17
+ drainedEndOffset,
18
+ MAX_DRIVER_SUFFIX_BYTES,
19
+ readSuffixWindow,
20
+ } from "./shared.mjs";
21
+
22
+ const NAMESPACE = "kimi-cli";
23
+ const MAX_INDEX_BYTES = 8 * 1024 * 1024;
24
+
25
+ async function readableFile(root, candidate) {
26
+ const contained = await containedPath(root, candidate);
27
+ if (contained === null) return null;
28
+ try {
29
+ return (await stat(contained)).isFile() ? contained : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Kimi's `session_index.jsonl` maps sessionId -> sessionDir. The index is
37
+ * host-written but its paths are still treated as untrusted: every sessionDir
38
+ * is containment-checked under the kimi root before any file inside it is
39
+ * opened. The primary wire is `<sessionDir>/agents/main/wire.jsonl`; older
40
+ * layouts kept `<sessionDir>/wire.jsonl`.
41
+ */
42
+ async function locateWire(root, sessionId) {
43
+ let indexText;
44
+ try {
45
+ indexText = await readFile(join(root, "session_index.jsonl"), "utf8");
46
+ } catch {
47
+ return null;
48
+ }
49
+ if (Buffer.byteLength(indexText, "utf8") > MAX_INDEX_BYTES) return null;
50
+ const wanted = new Set([sessionId, `session_${sessionId}`]);
51
+ let sessionDir = null;
52
+ let workDir = null;
53
+ for (const line of indexText.split("\n")) {
54
+ if (!line) continue;
55
+ let entry;
56
+ try { entry = JSON.parse(line); } catch { continue; }
57
+ if (entry !== null && typeof entry === "object" && wanted.has(entry.sessionId) &&
58
+ typeof entry.sessionDir === "string") {
59
+ sessionDir = entry.sessionDir; // keep last — the index is append-ordered
60
+ workDir = typeof entry.workDir === "string" ? entry.workDir : null;
61
+ }
62
+ }
63
+ if (sessionDir === null) return null;
64
+ const containedDir = await containedPath(root, sessionDir);
65
+ if (containedDir === null) return null;
66
+ const path = await readableFile(root, join(containedDir, "agents", "main", "wire.jsonl")) ||
67
+ await readableFile(root, join(containedDir, "wire.jsonl"));
68
+ if (path === null) return null;
69
+ const statePath = await readableFile(root, join(containedDir, "state.json"));
70
+ return { path, statePath, workDir };
71
+ }
72
+
73
+ function usageEventFromRecord(entry, { byteOffset, endOffset }) {
74
+ const usage = entry.usage !== null && typeof entry.usage === "object" ? entry.usage : null;
75
+ const model = typeof entry.model === "string" && USAGE_MODEL_PATTERN.test(entry.model)
76
+ ? entry.model : null;
77
+ const occurredAt = Number.isSafeInteger(entry.time) && entry.time > 0
78
+ ? new Date(entry.time).toISOString() : new Date().toISOString();
79
+ const input = usageInt(usage?.inputOther);
80
+ const output = usageInt(usage?.output);
81
+ if (usage === null || model === null || (input === null && output === null)) {
82
+ return normalizedEvent({
83
+ eventKey: `${NAMESPACE}:usage-gap:${byteOffset}`,
84
+ type: "usage",
85
+ occurredAt,
86
+ payload: {
87
+ role: "assistant", contentFormat: "json", captureStatus: "complete",
88
+ gap: true, reason: model === null && usage !== null ? "model_unknown" : "usage_unavailable",
89
+ },
90
+ sourceEndOffset: endOffset,
91
+ });
92
+ }
93
+ return normalizedEvent({
94
+ eventKey: `${NAMESPACE}:usage:${byteOffset}`,
95
+ type: "usage",
96
+ occurredAt,
97
+ payload: {
98
+ role: "assistant",
99
+ contentFormat: "json",
100
+ captureStatus: "complete",
101
+ provider: "moonshot",
102
+ model,
103
+ providerRequestId: null,
104
+ messageId: null,
105
+ stopReason: null,
106
+ serviceTier: null,
107
+ effort: null,
108
+ sidechain: false,
109
+ latencyMs: null,
110
+ // usage.record buckets are named as disjoint shares of the prompt.
111
+ inputTokens: input,
112
+ outputTokens: output,
113
+ cacheReadTokens: usageInt(usage.inputCacheRead),
114
+ cacheWriteTokens: usageInt(usage.inputCacheCreation),
115
+ cacheWrite1hTokens: null,
116
+ cacheWrite5mTokens: null,
117
+ reasoningTokens: null,
118
+ },
119
+ sourceEndOffset: endOffset,
120
+ });
121
+ }
122
+
123
+ export const kimiTranscriptDriver = {
124
+ clientKind: "kimi-cli",
125
+ eventKeyNamespace: NAMESPACE,
126
+ usageGapPrefix: `${NAMESPACE}:usage-gap:`,
127
+
128
+ async locate(hostSessionId, { env = process.env } = {}) {
129
+ const raw = String(hostSessionId ?? "");
130
+ const sessionId = boundedHostSessionId(raw.startsWith("session_") ? raw.slice("session_".length) : raw);
131
+ if (sessionId === null) return null;
132
+ const located = await locateWire(kimiHome(env), sessionId);
133
+ if (located === null) return null;
134
+ const sessionFacts = {};
135
+ const cwd = boundedMetadataText(located.workDir, 2048);
136
+ if (cwd) sessionFacts.cwd = cwd;
137
+ if (located.statePath !== null) {
138
+ try {
139
+ const state = JSON.parse(await readFile(located.statePath, "utf8"));
140
+ const title = boundedMetadataText(state?.title, 512);
141
+ if (title) sessionFacts.title = title;
142
+ } catch {
143
+ // state.json is optional evidence, never a failure.
144
+ }
145
+ }
146
+ return { path: located.path, sessionFacts };
147
+ },
148
+
149
+ async readSuffix(path, cursor, _sessionHash) {
150
+ const { buffer, start } = await readSuffixWindow(path, cursor?.byteOffset ?? 0);
151
+ const metadata = {};
152
+ const events = [];
153
+ let lastEnd = start;
154
+ for (const line of completeJsonlLines(buffer, start)) {
155
+ lastEnd = line.endOffset;
156
+ const entry = line.entry;
157
+ if (entry === null) continue;
158
+ if (entry.type === "usage.record") {
159
+ events.push(usageEventFromRecord(entry, {
160
+ byteOffset: line.byteOffset,
161
+ endOffset: line.endOffset,
162
+ }));
163
+ }
164
+ }
165
+ return {
166
+ events,
167
+ observedEndOffset: drainedEndOffset(lastEnd, start, buffer.length, MAX_DRIVER_SUFFIX_BYTES),
168
+ metadata,
169
+ hostState: boundedHostState(cursor?.hostState),
170
+ };
171
+ },
172
+
173
+ buildMetadataPayload(metadata, context) {
174
+ return buildClaudeMetadataPayload(metadata, context);
175
+ },
176
+
177
+ metadataEvent(payload) {
178
+ return hostMetadataEvent(NAMESPACE, payload);
179
+ },
180
+ };
@@ -0,0 +1,104 @@
1
+ import { open, realpath } from "node:fs/promises";
2
+ import { resolve, sep } from "node:path";
3
+
4
+ // Windowed suffix budget per catch-up call. Successive calls drain the
5
+ // remainder; nothing is skipped, memory stays bounded even for giant files.
6
+ export const MAX_DRIVER_SUFFIX_BYTES = 8 * 1024 * 1024;
7
+
8
+ // Host session ids are interpolated into filesystem lookups. Anything with a
9
+ // path character is refused outright — no capture beats a contained read of
10
+ // the wrong file (README containment commitment).
11
+ const HOST_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
12
+
13
+ export function boundedHostSessionId(value) {
14
+ const text = String(value ?? "");
15
+ return HOST_SESSION_ID_PATTERN.test(text) ? text : null;
16
+ }
17
+
18
+ /**
19
+ * Resolve `candidate` and require it to live under `root` after every
20
+ * symlink is followed. Index files and cached paths are untrusted input;
21
+ * any escape resolves to null (no capture), never an error.
22
+ */
23
+ export async function containedPath(root, candidate) {
24
+ try {
25
+ const realRoot = await realpath(root);
26
+ const resolved = resolve(realRoot, String(candidate ?? ""));
27
+ if (resolved !== realRoot && !resolved.startsWith(realRoot + sep)) return null;
28
+ const real = await realpath(resolved);
29
+ if (real !== realRoot && !real.startsWith(realRoot + sep)) return null;
30
+ return real;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /** Positioned read of at most `maxBytes` starting at `byteOffset`. */
37
+ export async function readSuffixWindow(path, byteOffset, maxBytes = MAX_DRIVER_SUFFIX_BYTES) {
38
+ const handle = await open(path, "r");
39
+ try {
40
+ const size = (await handle.stat()).size;
41
+ const start = Number.isSafeInteger(byteOffset) && byteOffset >= 0 && byteOffset <= size
42
+ ? byteOffset : 0;
43
+ const length = Math.min(size - start, maxBytes);
44
+ const buffer = Buffer.alloc(length);
45
+ if (length > 0) await handle.read(buffer, 0, length, start);
46
+ return { buffer, start };
47
+ } finally {
48
+ await handle.close();
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Yield `{ entry, byteOffset, endOffset }` for every complete JSONL line in
54
+ * the window. An unparseable line yields `entry: null` (drivers skip it —
55
+ * unlike the Claude archive, an ignored line is the norm for selective
56
+ * usage/metadata readers). A trailing record without its newline is left
57
+ * unread for the next catch-up.
58
+ */
59
+ export function* completeJsonlLines(buffer, start) {
60
+ let position = 0;
61
+ while (position < buffer.length) {
62
+ const newline = buffer.indexOf(0x0a, position);
63
+ if (newline === -1) break;
64
+ let entry = null;
65
+ try {
66
+ const parsed = JSON.parse(buffer.subarray(position, newline).toString("utf8"));
67
+ entry = parsed !== null && typeof parsed === "object" ? parsed : null;
68
+ } catch {
69
+ entry = null;
70
+ }
71
+ yield { entry, byteOffset: start + position, endOffset: start + newline + 1 };
72
+ position = newline + 1;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * End offset for a drained window. If a full window held no newline at all
78
+ * (pathological single-line file), advance past it rather than re-reading the
79
+ * same bytes forever — usage lines are tiny, so nothing real is lost.
80
+ */
81
+ export function drainedEndOffset(lastEndOffset, start, bufferLength, maxBytes) {
82
+ if (lastEndOffset > start) return lastEndOffset;
83
+ if (bufferLength >= maxBytes) return start + bufferLength;
84
+ return start;
85
+ }
86
+
87
+ const MAX_HOST_STATE_BYTES = 1024;
88
+
89
+ /** Bound the per-session driver scratch state persisted on the cursor. */
90
+ export function boundedHostState(value) {
91
+ if (value === null || typeof value !== "object") return undefined;
92
+ const serialized = JSON.stringify(value);
93
+ return Buffer.byteLength(serialized, "utf8") <= MAX_HOST_STATE_BYTES ? value : undefined;
94
+ }
95
+
96
+ /**
97
+ * Bound for host-supplied metadata strings, mirroring collectClaudeMetadata:
98
+ * non-empty, capped, and free of control characters. The server rejects a
99
+ * whole batch over an oversized field, so bounding here is not cosmetic.
100
+ */
101
+ export function boundedMetadataText(value, max) {
102
+ return typeof value === "string" && value.length > 0 && value.length <= max &&
103
+ !/[\u0000-\u001f\u007f]/.test(value) ? value : undefined;
104
+ }
package/src/version.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  export const PACKAGE_NAME = "@halofy/agent-connect";
2
- export const INSTALLER_VERSION = "0.5.1";
3
- export const RUNTIME_VERSION = "0.5.1";
4
- export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-31.1";
2
+ export const INSTALLER_VERSION = "0.6.0";
3
+ export const RUNTIME_VERSION = "0.6.0";
4
+ export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-31.2";