@halofy/agent-connect 0.5.0 → 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.
@@ -4,6 +4,8 @@ import { createInterface } from "node:readline/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import {
7
+ fetchClaimDisclosure,
8
+ fetchClaimDisclosures,
7
9
  heartbeatInstalledConnection,
8
10
  installLocalConnection,
9
11
  installRuntimeBundle,
@@ -15,9 +17,50 @@ import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
15
17
  import { defaultRuntimeDirectory } from "./storage.mjs";
16
18
  import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
17
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
+
18
60
  export function parseInstallerArgs(argv) {
19
61
  const command = argv[0];
20
62
  const clientKind = argv[1];
63
+ if (command === "install" && clientKind === "all") return parseAllInstallerArgs(argv);
21
64
  const values = new Map();
22
65
  for (let index = 2; index < argv.length; index += 1) {
23
66
  const name = argv[index];
@@ -36,6 +79,7 @@ export function parseInstallerArgs(argv) {
36
79
  throw new Error("Usage: agent-connect install <supported-client> --server <https-url> --claim <one-use-claim>");
37
80
  }
38
81
  return {
82
+ mode: "single",
39
83
  clientKind,
40
84
  serverUrl,
41
85
  claim,
@@ -81,8 +125,20 @@ export function detectClient(clientKind) {
81
125
  return String(result.stdout || result.stderr || "").trim().slice(0, 128) || "detected";
82
126
  }
83
127
 
84
- export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion }) {
85
- 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) {
86
142
  const observedCategories = [
87
143
  ["user messages", client.capabilities.userMessages],
88
144
  ["assistant messages", client.capabilities.assistantMessages],
@@ -100,17 +156,27 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
100
156
  ["tool outcomes (durations, failure flags, byte sizes; file paths only as salted hashes)", client.capabilities.toolOutcomes],
101
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],
102
158
  ];
103
- const supported = observedCategories.filter(([, value]) => value === true).map(([name]) => name);
104
- 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);
105
168
  return [
106
169
  `Halofy ${client.label} lifecycle connection`,
107
170
  `Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
108
171
  `Server: ${new URL(serverUrl).origin}`,
172
+ // The server named by --server identifies the organization this claim
173
+ // binds to, so a spoofed command is recognizable before CONNECT.
174
+ `Organization: ${organization || "unverified (the server did not identify this claim's organization)"}`,
109
175
  `${client.label}: ${clientVersion}`,
110
176
  `Project: ${projectRoot}`,
111
177
  "",
112
178
  "This single installation replaces an existing Halofy bearer MCP entry where supported and enables:",
113
- "- governed memory tools and recall",
179
+ "- governed memory tools the agent invokes explicitly (no automatic recall is injected into sessions),",
114
180
  `- conversation events exposed by ${client.label}'s reviewed hooks,`,
115
181
  "- explicitly supported tool, subagent, compaction, and close evidence,",
116
182
  "- encrypted local retry queue and governed retained conversations, and",
@@ -128,6 +194,55 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
128
194
  ].join("\n");
129
195
  }
130
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
+
131
246
  export async function confirmDisclosure({ input = process.stdin, output = process.stdout } = {}) {
132
247
  if (!input.isTTY || !output.isTTY) throw new Error("interactive terminal confirmation is required");
133
248
  const prompt = createInterface({ input, output });
@@ -140,6 +255,136 @@ export async function confirmDisclosure({ input = process.stdin, output = proces
140
255
  return true;
141
256
  }
142
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
+
143
388
  export async function runInstaller(argv, {
144
389
  root = defaultRuntimeDirectory(),
145
390
  output = process.stdout,
@@ -151,8 +396,22 @@ export async function runInstaller(argv, {
151
396
  claudeConfigPath,
152
397
  } = {}) {
153
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
+ }
154
404
  const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
155
- output.write(`${disclosureText({ ...input, clientVersion })}\n`);
405
+ const claimDisclosure = await fetchClaimDisclosure({
406
+ serverUrl: input.serverUrl,
407
+ claim: input.claim,
408
+ fetchImpl,
409
+ });
410
+ output.write(`${disclosureText({
411
+ ...input,
412
+ clientVersion,
413
+ organization: claimDisclosure?.organization ?? null,
414
+ })}\n`);
156
415
  await confirm();
157
416
 
158
417
  const installed = await installLocalConnection({
@@ -170,21 +429,7 @@ export async function runInstaller(argv, {
170
429
  serverUrl: input.serverUrl,
171
430
  runtimePath: bundle.runtimePath,
172
431
  };
173
- const configured = input.clientKind === "claude-code"
174
- ? await configureClaudeProject({ ...common, ...(claudeConfigPath ? { claudeConfigPath } : {}) })
175
- : input.clientKind === "cursor"
176
- ? await configureCursor(common)
177
- : input.clientKind === "gemini-cli"
178
- ? await configureGemini(common)
179
- : input.clientKind === "kimi-cli"
180
- ? await configureKimi(common)
181
- : input.clientKind === "vscode"
182
- ? await configureVscode(common)
183
- : input.clientKind === "codex"
184
- ? await configureCodex(common)
185
- : input.clientKind === "cline"
186
- ? await configureCline(common)
187
- : (() => { throw new Error("the selected adapter is not packaged yet"); })();
432
+ const configured = await configureHost(input.clientKind, common, { claudeConfigPath });
188
433
  let heartbeat = false;
189
434
  try {
190
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",
@@ -256,6 +256,37 @@ function unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourc
256
256
  });
257
257
  }
258
258
 
259
+ /**
260
+ * Hook-driven recall injection is disabled for now: the runtime captures
261
+ * conversations and serves the agent-invoked MCP memory tools, but does not
262
+ * push recalled memory into host sessions on SessionStart/UserPromptSubmit.
263
+ * Flip to true to restore injection — the bounded block formats below and the
264
+ * per-host output shapes in the hook modules stay in place and tested.
265
+ */
266
+ export const RECALL_INJECTION_ENABLED = false;
267
+
268
+ export const MAX_RECALL_BLOCKS = 12;
269
+ export const MAX_RECALL_BLOCK_CHARS = 8_000;
270
+
271
+ /**
272
+ * Ranked recall blocks bounded on the client before they are injected into a
273
+ * host session. The server's token budget is the primary bound; these caps are
274
+ * defense in depth so poisoned or oversized memory content cannot flood the
275
+ * host context through a hook response.
276
+ */
277
+ export function rankedRecallBlocks(result) {
278
+ const blocks = Array.isArray(result?.blocks) ? result.blocks : Array.isArray(result) ? result : [];
279
+ return blocks
280
+ .filter((block) => block && typeof block.recallRef === "string" &&
281
+ typeof block.content === "string" && block.content.trim())
282
+ .slice(0, MAX_RECALL_BLOCKS)
283
+ .map((block, rank) => ({
284
+ rank: rank + 1,
285
+ recallRef: block.recallRef.slice(0, 256),
286
+ content: block.content.trim().slice(0, MAX_RECALL_BLOCK_CHARS),
287
+ }));
288
+ }
289
+
259
290
  export function deriveSessionHash({ installationId, clientKind, hostSessionId }) {
260
291
  if (!installationId || !clientKind || !hostSessionId) throw new Error("session identity is incomplete");
261
292
  return digest(`halofy-session-v1\0${installationId}\0${clientKind}\0${hostSessionId}`);
@@ -269,15 +300,15 @@ export function stripInjectedContext(value) {
269
300
  return String(value);
270
301
  }
271
302
 
272
- function usageInt(value) {
303
+ export function usageInt(value) {
273
304
  return Number.isSafeInteger(value) && value >= 0 && value < 2 ** 31 ? value : null;
274
305
  }
275
306
 
276
- function usageLabel(value) {
307
+ export function usageLabel(value) {
277
308
  return typeof value === "string" && /^[\x20-\x7e]{1,128}$/.test(value) ? value : null;
278
309
  }
279
310
 
280
- const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
311
+ export const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._:/-]{1,128}$/;
281
312
 
282
313
  /**
283
314
  * One host-reported usage record per assistant message (PRD §8.1). Claude
@@ -738,15 +769,19 @@ export function buildClaudeMetadataPayload(metadata, { installationId, deviceCon
738
769
  return payload;
739
770
  }
740
771
 
741
- export function claudeMetadataEvent(payload) {
772
+ export function hostMetadataEvent(namespace, payload) {
742
773
  return normalizedEvent({
743
- eventKey: `claude:metadata:${digest(stableJson(payload))}`,
774
+ eventKey: `${namespace}:metadata:${digest(stableJson(payload))}`,
744
775
  type: "metadata",
745
776
  occurredAt: new Date().toISOString(),
746
777
  payload: { contentFormat: "json", captureStatus: "complete", ...payload },
747
778
  });
748
779
  }
749
780
 
781
+ export function claudeMetadataEvent(payload) {
782
+ return hostMetadataEvent("claude", payload);
783
+ }
784
+
750
785
  export class CursorStore {
751
786
  constructor(root) {
752
787
  this.path = join(root, "cursors.json");
package/src/storage.mjs CHANGED
@@ -31,6 +31,32 @@ export async function writePrivateFile(path, value) {
31
31
  if (platform() !== "win32") await chmod(path, 0o600);
32
32
  }
33
33
 
34
+ /**
35
+ * Atomic write for a host application's config file. The parent directory is
36
+ * created if missing, but the permissions of an existing directory are never
37
+ * changed — a project root, $HOME, or ~/.cursor is not Halofy's to tighten.
38
+ * An existing file keeps its mode; a new file starts private (0600).
39
+ */
40
+ export async function writeHostConfigFile(path, value) {
41
+ await mkdir(dirname(path), { recursive: true });
42
+ let mode = 0o600;
43
+ try {
44
+ mode = (await stat(path)).mode & 0o777;
45
+ } catch (error) {
46
+ if (error?.code !== "ENOENT") throw error;
47
+ }
48
+ const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
49
+ const handle = await open(temporary, "wx", 0o600);
50
+ try {
51
+ await handle.writeFile(value);
52
+ await handle.sync();
53
+ } finally {
54
+ await handle.close();
55
+ }
56
+ await rename(temporary, path);
57
+ if (platform() !== "win32") await chmod(path, mode);
58
+ }
59
+
34
60
  export async function withFileLock(path, action, { timeoutMs = 3_000, staleMs = 30_000 } = {}) {
35
61
  await ensurePrivateDirectory(dirname(path));
36
62
  const started = Date.now();
@@ -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
+ };