@halofy/agent-connect 0.5.1 → 0.7.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.
@@ -5,20 +5,64 @@ 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,
11
12
  localMcpSnippet,
13
+ syncInstalledSkills,
12
14
  } from "./install.mjs";
13
15
  import { configureClaudeProject } from "./claude-config.mjs";
14
16
  import { configureCline, configureCodex, configureCursor, configureGemini, configureKimi, configureVscode } from "./host-config.mjs";
15
17
  import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
16
18
  import { defaultRuntimeDirectory } from "./storage.mjs";
17
19
  import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
20
+ import { describeSkillSync, managedSkillsDirectory } from "./skills-sync.mjs";
21
+
22
+ const CLAIM_PATTERN = /^hsc_[A-Za-z0-9_-]{43}$/;
23
+
24
+ function parseAllInstallerArgs(argv) {
25
+ const values = new Map();
26
+ for (let index = 2; index < argv.length; index += 1) {
27
+ const name = argv[index];
28
+ if (!["--server", "--claims", "--claude-project"].includes(name) || values.has(name)) {
29
+ throw new Error("unsupported or duplicate installer argument");
30
+ }
31
+ const value = argv[index + 1];
32
+ if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
33
+ values.set(name, value);
34
+ index += 1;
35
+ }
36
+ const serverUrl = values.get("--server");
37
+ const rawClaims = values.get("--claims");
38
+ const usage = "Usage: agent-connect install all --server <https-url> --claims <client>=<one-use-claim>[,...]";
39
+ if (!serverUrl || !rawClaims) throw new Error(usage);
40
+ const pairs = rawClaims.split(",");
41
+ if (pairs.length < 1 || pairs.length > CLIENT_KINDS.length) throw new Error(usage);
42
+ const selections = [];
43
+ const kinds = new Set();
44
+ for (const pair of pairs) {
45
+ const separator = pair.indexOf("=");
46
+ const clientKind = separator === -1 ? "" : pair.slice(0, separator);
47
+ const claim = separator === -1 ? "" : pair.slice(separator + 1);
48
+ if (!CLIENT_KINDS.includes(clientKind) || !CLAIM_PATTERN.test(claim) || kinds.has(clientKind)) {
49
+ throw new Error(usage);
50
+ }
51
+ kinds.add(clientKind);
52
+ selections.push({ clientKind, claim });
53
+ }
54
+ return {
55
+ mode: "all",
56
+ selections,
57
+ serverUrl,
58
+ projectRoot: resolve(values.get("--claude-project") || process.cwd()),
59
+ };
60
+ }
18
61
 
19
62
  export function parseInstallerArgs(argv) {
20
63
  const command = argv[0];
21
64
  const clientKind = argv[1];
65
+ if (command === "install" && clientKind === "all") return parseAllInstallerArgs(argv);
22
66
  const values = new Map();
23
67
  for (let index = 2; index < argv.length; index += 1) {
24
68
  const name = argv[index];
@@ -37,6 +81,7 @@ export function parseInstallerArgs(argv) {
37
81
  throw new Error("Usage: agent-connect install <supported-client> --server <https-url> --claim <one-use-claim>");
38
82
  }
39
83
  return {
84
+ mode: "single",
40
85
  clientKind,
41
86
  serverUrl,
42
87
  claim,
@@ -82,8 +127,20 @@ export function detectClient(clientKind) {
82
127
  return String(result.stdout || result.stderr || "").trim().slice(0, 128) || "detected";
83
128
  }
84
129
 
85
- export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion, organization = null }) {
86
- const client = lifecycleClient(clientKind);
130
+ /** Non-throwing detection probe for the install-all sweep. */
131
+ export async function probeClient(clientKind, {
132
+ detectClaude = detectClaudeCode,
133
+ detectHost = detectClient,
134
+ } = {}) {
135
+ try {
136
+ const version = clientKind === "claude-code" ? await detectClaude() : await detectHost(clientKind);
137
+ return { detected: true, version };
138
+ } catch (error) {
139
+ return { detected: false, reason: error?.message || "not detected" };
140
+ }
141
+ }
142
+
143
+ function captureCategories(client) {
87
144
  const observedCategories = [
88
145
  ["user messages", client.capabilities.userMessages],
89
146
  ["assistant messages", client.capabilities.assistantMessages],
@@ -101,8 +158,16 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
101
158
  ["tool outcomes (durations, failure flags, byte sizes; file paths only as salted hashes)", client.capabilities.toolOutcomes],
102
159
  ["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
160
  ];
104
- const supported = observedCategories.filter(([, value]) => value === true).map(([name]) => name);
105
- const unsupported = observedCategories.filter(([, value]) => value !== true).map(([name]) => name);
161
+ return {
162
+ supported: observedCategories.filter(([, value]) => value === true).map(([name]) => name),
163
+ unsupported: observedCategories.filter(([, value]) => value !== true).map(([name]) => name),
164
+ };
165
+ }
166
+
167
+ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion, organization = null }) {
168
+ const client = lifecycleClient(clientKind);
169
+ const { supported, unsupported } = captureCategories(client);
170
+ const skillsRoot = managedSkillsDirectory(clientKind);
106
171
  return [
107
172
  `Halofy ${client.label} lifecycle connection`,
108
173
  `Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
@@ -117,8 +182,11 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
117
182
  "- governed memory tools the agent invokes explicitly (no automatic recall is injected into sessions),",
118
183
  `- conversation events exposed by ${client.label}'s reviewed hooks,`,
119
184
  "- explicitly supported tool, subagent, compaction, and close evidence,",
120
- "- encrypted local retry queue and governed retained conversations, and",
121
- "- canonical learning when the selected badge permits writes.",
185
+ "- encrypted local retry queue and governed retained conversations,",
186
+ "- canonical learning when the selected badge permits writes, and",
187
+ skillsRoot
188
+ ? `- approved company and team skills written to ${skillsRoot}/<skill>/SKILL.md and kept current at each session start; withdrawn skills are moved aside, never deleted.`
189
+ : "- no managed skills folder for this host (skills stay available through skill_invoke).",
122
190
  "",
123
191
  `Supported capture categories: ${supported.length > 0 ? supported.join(", ") : "none"}.`,
124
192
  `Unsupported capture categories: ${unsupported.length > 0 ? unsupported.join(", ") : "none"}.`,
@@ -126,12 +194,68 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
126
194
  ? "This reviewed host surface can report complete coverage when all declared evidence is observed."
127
195
  : `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
128
196
  "Authorized organization managers may review retained conversations and summaries.",
129
- "This does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
197
+ skillsRoot
198
+ ? `This reads and writes only the skill folders it created under ${skillsRoot}; it does not scan historical files, other applications, clipboard, keystrokes, or other agents.`
199
+ : "This does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
130
200
  "Disconnecting stops future capture but does not erase retained data.",
131
201
  `Disclosure: ${DISCLOSURE_VERSION}`,
132
202
  ].join("\n");
133
203
  }
134
204
 
205
+ /**
206
+ * One disclosure for the whole sweep: shared header, one capture-category
207
+ * section per detected host, and an explicit statement of which minted claims
208
+ * will expire unused. One CONNECT then covers exactly the listed hosts —
209
+ * explicit per-host consent, never a silent fan-out.
210
+ */
211
+ export function allDisclosureText({ serverUrl, projectRoot, organization = null, hosts, unusedKinds = [] }) {
212
+ const lines = [
213
+ "Halofy all-agents lifecycle connection",
214
+ `Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
215
+ `Server: ${new URL(serverUrl).origin}`,
216
+ // The server named by --server identifies the organization this command
217
+ // binds to, so a spoofed command is recognizable before CONNECT.
218
+ `Organization: ${organization || "unverified (the server did not identify this command's organization)"}`,
219
+ `Project: ${projectRoot}`,
220
+ `Hosts to be connected: ${hosts.map((host) => lifecycleClient(host.clientKind).label).join(", ")}`,
221
+ "",
222
+ "Each host gets its own installation binding, capability record, and connection status:",
223
+ ];
224
+ for (const host of hosts) {
225
+ const client = lifecycleClient(host.clientKind);
226
+ const { supported, unsupported } = captureCategories(client);
227
+ lines.push(
228
+ "",
229
+ `--- ${client.label} (${host.clientVersion}) ---`,
230
+ `Supported capture categories: ${supported.length > 0 ? supported.join(", ") : "none"}.`,
231
+ `Unsupported capture categories: ${unsupported.length > 0 ? unsupported.join(", ") : "none"}.`,
232
+ client.coverage === "complete"
233
+ ? "This reviewed host surface can report complete coverage when all declared evidence is observed."
234
+ : `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
235
+ managedSkillsDirectory(host.clientKind)
236
+ ? `Approved company and team skills are written to ${managedSkillsDirectory(host.clientKind)}/<skill>/SKILL.md ` +
237
+ "and kept current at each session start; withdrawn skills are moved aside, never deleted."
238
+ : "No managed skills folder for this host; skills stay available through skill_invoke.",
239
+ );
240
+ }
241
+ if (unusedKinds.length > 0) {
242
+ lines.push(
243
+ "",
244
+ `Claims for ${unusedKinds.map((kind) => lifecycleClient(kind).label).join(", ")} were issued ` +
245
+ "but those hosts were not detected here; they expire unused within 10 minutes.",
246
+ );
247
+ }
248
+ lines.push(
249
+ "",
250
+ "Authorized organization managers may review retained conversations and summaries.",
251
+ "This reads and writes only the skill folders it created for the hosts listed above; it does not scan " +
252
+ "historical files, other applications, clipboard, keystrokes, or other agents.",
253
+ "Disconnecting stops future capture but does not erase retained data.",
254
+ `Disclosure: ${DISCLOSURE_VERSION}`,
255
+ );
256
+ return lines.join("\n");
257
+ }
258
+
135
259
  export async function confirmDisclosure({ input = process.stdin, output = process.stdout } = {}) {
136
260
  if (!input.isTTY || !output.isTTY) throw new Error("interactive terminal confirmation is required");
137
261
  const prompt = createInterface({ input, output });
@@ -144,6 +268,154 @@ export async function confirmDisclosure({ input = process.stdin, output = proces
144
268
  return true;
145
269
  }
146
270
 
271
+ async function configureHost(clientKind, common, { claudeConfigPath } = {}) {
272
+ if (clientKind === "claude-code") {
273
+ return configureClaudeProject({ ...common, ...(claudeConfigPath ? { claudeConfigPath } : {}) });
274
+ }
275
+ if (clientKind === "cursor") return configureCursor(common);
276
+ if (clientKind === "gemini-cli") return configureGemini(common);
277
+ if (clientKind === "kimi-cli") return configureKimi(common);
278
+ if (clientKind === "vscode") return configureVscode(common);
279
+ if (clientKind === "codex") return configureCodex(common);
280
+ if (clientKind === "cline") return configureCline(common);
281
+ throw new Error("the selected adapter is not packaged yet");
282
+ }
283
+
284
+ async function runAllInstaller(input, {
285
+ root,
286
+ output,
287
+ detectClaude,
288
+ detectHost,
289
+ confirm,
290
+ fetchImpl,
291
+ sourceRoot,
292
+ claudeConfigPath,
293
+ skillsHome,
294
+ }) {
295
+ const probes = [];
296
+ for (const selection of input.selections) {
297
+ probes.push({ ...selection, probe: await probeClient(selection.clientKind, { detectClaude, detectHost }) });
298
+ }
299
+ const detected = probes.filter((entry) => entry.probe.detected);
300
+ const skipped = probes.filter((entry) => !entry.probe.detected)
301
+ .map((entry) => ({ clientKind: entry.clientKind, reason: "not_detected" }));
302
+ if (detected.length === 0) {
303
+ throw new Error("no claimed agents were detected on this machine: " +
304
+ probes.map((entry) => `${lifecycleClient(entry.clientKind).label} (${entry.probe.reason})`).join("; "));
305
+ }
306
+
307
+ // One batch disclosure call spends one throttle token for the whole sweep.
308
+ const disclosures = await fetchClaimDisclosures({
309
+ serverUrl: input.serverUrl,
310
+ claims: input.selections.map((selection) => selection.claim),
311
+ fetchImpl,
312
+ });
313
+ let organization = null;
314
+ if (disclosures !== null) {
315
+ const organizations = new Set();
316
+ for (let index = 0; index < input.selections.length; index += 1) {
317
+ const disclosure = disclosures[index];
318
+ if (!disclosure) continue;
319
+ // A claim minted for one client kind pasted behind another label is a
320
+ // spoofed or reassembled command — refuse before any consent prompt.
321
+ if (disclosure.clientKind !== null && disclosure.clientKind !== input.selections[index].clientKind) {
322
+ throw new Error(`the claim labeled ${input.selections[index].clientKind} was issued for ` +
323
+ `${disclosure.clientKind}; refuse this command and generate a fresh one`);
324
+ }
325
+ if (disclosure.organization) organizations.add(disclosure.organization);
326
+ }
327
+ if (organizations.size > 1) {
328
+ throw new Error("the claims in this command belong to different organizations; " +
329
+ "refuse this command and generate a fresh one");
330
+ }
331
+ organization = organizations.size === 1 ? [...organizations][0] : null;
332
+ }
333
+
334
+ output.write(`${allDisclosureText({
335
+ serverUrl: input.serverUrl,
336
+ projectRoot: input.projectRoot,
337
+ organization,
338
+ hosts: detected.map((entry) => ({ clientKind: entry.clientKind, clientVersion: entry.probe.version })),
339
+ unusedKinds: skipped.map((entry) => entry.clientKind),
340
+ })}\n`);
341
+ await confirm();
342
+
343
+ // The reviewed runtime bundle installs exactly once for the whole sweep.
344
+ const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
345
+ const results = [];
346
+ for (const host of detected) {
347
+ try {
348
+ const installed = await installLocalConnection({
349
+ serverUrl: input.serverUrl,
350
+ claim: host.claim,
351
+ clientKind: host.clientKind,
352
+ root,
353
+ fetchImpl,
354
+ sendHeartbeat: false,
355
+ });
356
+ const configured = await configureHost(host.clientKind, {
357
+ projectRoot: input.projectRoot,
358
+ installationId: installed.installationId,
359
+ serverUrl: input.serverUrl,
360
+ runtimePath: bundle.runtimePath,
361
+ }, { claudeConfigPath });
362
+ let heartbeat = false;
363
+ try {
364
+ heartbeat = await heartbeatInstalledConnection({
365
+ installationId: installed.installationId, root, fetchImpl,
366
+ });
367
+ } catch {
368
+ heartbeat = false;
369
+ }
370
+ // Skills land during the sweep too; a skills problem is reported per
371
+ // host, never fatal to the install.
372
+ let skills = null;
373
+ try {
374
+ skills = await syncInstalledSkills({
375
+ installationId: installed.installationId, root, fetchImpl,
376
+ ...(skillsHome ? { home: skillsHome } : {}),
377
+ });
378
+ } catch {
379
+ skills = null;
380
+ }
381
+ results.push({
382
+ clientKind: host.clientKind,
383
+ status: heartbeat ? "configured_heartbeat_verified" : "configured_heartbeat_unavailable",
384
+ installationId: installed.installationId,
385
+ skills: skills
386
+ ? { supported: skills.supported, skillsRoot: skills.skillsRoot, checkedIn: skills.checkedIn,
387
+ installed: skills.installed, updated: skills.updated,
388
+ quarantined: skills.quarantined.map((q) => q.skillKey),
389
+ conflicts: skills.conflicts.map((c) => c.skillKey), errors: skills.errors.length }
390
+ : null,
391
+ proofStorage: installed.proofStorage,
392
+ configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
393
+ replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
394
+ nextStep: `Restart ${lifecycleClient(host.clientKind).label}, then check the connection in Halofy.`,
395
+ });
396
+ } catch (error) {
397
+ // One host failing must not abort the others; the failure is reported,
398
+ // never hidden.
399
+ skipped.push({
400
+ clientKind: host.clientKind,
401
+ reason: `failed:${(error?.message || "install_error").slice(0, 200)}`,
402
+ });
403
+ }
404
+ }
405
+ if (results.length === 0) {
406
+ throw new Error("every detected agent failed to install: " +
407
+ skipped.map((entry) => `${entry.clientKind} (${entry.reason})`).join("; "));
408
+ }
409
+ return {
410
+ status: skipped.some((entry) => entry.reason.startsWith("failed:"))
411
+ ? "completed_with_failures" : "completed",
412
+ installerVersion: INSTALLER_VERSION,
413
+ publishedPackage: true,
414
+ results,
415
+ skipped,
416
+ };
417
+ }
418
+
147
419
  export async function runInstaller(argv, {
148
420
  root = defaultRuntimeDirectory(),
149
421
  output = process.stdout,
@@ -153,8 +425,15 @@ export async function runInstaller(argv, {
153
425
  fetchImpl = globalThis.fetch,
154
426
  sourceRoot,
155
427
  claudeConfigPath,
428
+ skillsHome,
156
429
  } = {}) {
157
430
  const input = parseInstallerArgs(argv);
431
+ if (input.mode === "all") {
432
+ return runAllInstaller(input, {
433
+ root, output, detectClaude, detectHost, confirm, fetchImpl, sourceRoot, claudeConfigPath,
434
+ skillsHome,
435
+ });
436
+ }
158
437
  const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
159
438
  const claimDisclosure = await fetchClaimDisclosure({
160
439
  serverUrl: input.serverUrl,
@@ -183,21 +462,7 @@ export async function runInstaller(argv, {
183
462
  serverUrl: input.serverUrl,
184
463
  runtimePath: bundle.runtimePath,
185
464
  };
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"); })();
465
+ const configured = await configureHost(input.clientKind, common, { claudeConfigPath });
201
466
  let heartbeat = false;
202
467
  try {
203
468
  heartbeat = await heartbeatInstalledConnection({
@@ -208,6 +473,19 @@ export async function runInstaller(argv, {
208
473
  } catch {
209
474
  heartbeat = false;
210
475
  }
476
+ // Skills land now, not at the next session: the badge already resolves the
477
+ // team + org set server-side, so the folder is populated before the user
478
+ // restarts the host. A skills problem is reported, never fatal.
479
+ let skills = null;
480
+ try {
481
+ skills = await syncInstalledSkills({
482
+ installationId: installed.installationId, root, fetchImpl, ...(skillsHome ? { home: skillsHome } : {}),
483
+ });
484
+ output.write(`${describeSkillSync(skills)}\n`);
485
+ } catch (error) {
486
+ skills = null;
487
+ output.write(`skills: unavailable (${error?.code || "runtime_unavailable"})\n`);
488
+ }
211
489
  return {
212
490
  status: heartbeat ? "configured_heartbeat_verified" : "configured_heartbeat_unavailable",
213
491
  installationId: installed.installationId,
@@ -221,6 +499,11 @@ export async function runInstaller(argv, {
221
499
  proxyPath: bundle.runtimePath,
222
500
  installationId: installed.installationId,
223
501
  }),
502
+ skills: skills
503
+ ? { supported: skills.supported, skillsRoot: skills.skillsRoot, checkedIn: skills.checkedIn,
504
+ installed: skills.installed, updated: skills.updated, quarantined: skills.quarantined.map((q) => q.skillKey),
505
+ conflicts: skills.conflicts.map((c) => c.skillKey), errors: skills.errors.length }
506
+ : null,
224
507
  nextStep: `Restart ${lifecycleClient(input.clientKind).label}, then check the connection in Halofy.`,
225
508
  };
226
509
  }
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");