@halofy/agent-connect 0.4.0 → 0.5.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
@@ -19,7 +19,7 @@ agent connections:
19
19
  There is one setup path for every packaged client:
20
20
 
21
21
  ```bash
22
- npx --yes @halofy/agent-connect@0.4.0 install <client-kind> \
22
+ npx --yes @halofy/agent-connect@0.5.0 install <client-kind> \
23
23
  --server https://app.halofy.ai \
24
24
  --claim '<one-time-claim>'
25
25
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halofy/agent-connect",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Signed Halofy lifecycle installer and runtime for supported agents",
6
6
  "bin": {
@@ -61,10 +61,10 @@ function recallText(result, eventName) {
61
61
  });
62
62
  }
63
63
 
64
- async function catchUp(runtime, input, session = hostSession(input)) {
64
+ async function catchUp(runtime, input, session = hostSession(input), sessionFacts = {}) {
65
65
  const transcriptPath = input.agent_transcript_path || input.transcript_path;
66
66
  if (!transcriptPath || !session) return;
67
- await runtime.captureClaudeTranscript(session, String(transcriptPath));
67
+ await runtime.captureClaudeTranscript(session, String(transcriptPath), sessionFacts);
68
68
  }
69
69
 
70
70
  async function within(milliseconds, action) {
@@ -124,7 +124,9 @@ export async function runClaudeLifecycleHook(connection, eventName, {
124
124
  await runtime.commit(session, "pre_compaction");
125
125
  } else if (eventName === "SessionEnd") {
126
126
  await within(15_000, async () => {
127
- await catchUp(runtime, hookInput);
127
+ await catchUp(runtime, hookInput, hostSession(hookInput), {
128
+ ...(hookInput.reason ? { closeReason: String(hookInput.reason).slice(0, 64) } : {}),
129
+ });
128
130
  await runtime.close(session);
129
131
  });
130
132
  } else if (eventName === "SubagentStart") {
@@ -132,7 +134,9 @@ export async function runClaudeLifecycleHook(connection, eventName, {
132
134
  } else if (eventName === "SubagentStop") {
133
135
  const child = childSession(hookInput);
134
136
  await within(10_000, async () => {
135
- await catchUp(runtime, hookInput, child);
137
+ await catchUp(runtime, hookInput, child, {
138
+ ...(hookInput.agent_type ? { subagentType: String(hookInput.agent_type).slice(0, 128) } : {}),
139
+ });
136
140
  await runtime.close(child, "session_end");
137
141
  });
138
142
  } else if (["PostToolUse", "PostToolUseFailure"].includes(eventName)) {
@@ -33,6 +33,10 @@ const BASE_CAPABILITIES = Object.freeze({
33
33
  subagents: false,
34
34
  compactionCheckpoints: false,
35
35
  contextRecalled: false,
36
+ tokenUsage: false,
37
+ sessionMetadata: false,
38
+ toolOutcomes: false,
39
+ thinking: false,
36
40
  });
37
41
 
38
42
  function capabilities(overrides) {
@@ -52,6 +56,12 @@ export const CLIENT_REGISTRY = Object.freeze({
52
56
  userMessages: true, assistantMessages: true, toolInputs: true,
53
57
  toolOutputs: true, toolFailures: true, artifactReferences: true,
54
58
  subagents: true, compactionCheckpoints: true, contextRecalled: true,
59
+ // 0.5.0: host-reported model token usage, thinking blocks, structured
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.
63
+ tokenUsage: true, sessionMetadata: true, toolOutcomes: true,
64
+ thinking: true,
55
65
  }),
56
66
  }),
57
67
  cursor: Object.freeze({
@@ -95,6 +95,10 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
95
95
  ["subagents", client.capabilities.subagents],
96
96
  ["compaction checkpoints", client.capabilities.compactionCheckpoints],
97
97
  ["context-use evidence", client.capabilities.contextUseEvidence],
98
+ ["model token usage (counts, model name, and provider request id for each reply; never prompt or reply text)", client.capabilities.tokenUsage],
99
+ ["thinking blocks", client.capabilities.thinking],
100
+ ["tool outcomes (durations, failure flags, byte sizes; file paths only as salted hashes)", client.capabilities.toolOutcomes],
101
+ ["host and session metadata (app version, permission mode, effort, session title; directory paths hashed unless your organization enables full device context)", client.capabilities.sessionMetadata],
98
102
  ];
99
103
  const supported = observedCategories.filter(([, value]) => value === true).map(([name]) => name);
100
104
  const unsupported = observedCategories.filter(([, value]) => value !== true).map(([name]) => name);
package/src/runtime.mjs CHANGED
@@ -1,9 +1,15 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { join } from "node:path";
3
3
  import { BoundedEncryptedQueue } from "./queue.mjs";
4
- import { CursorStore, deriveSessionHash, readClaudeTranscriptSuffix } from "./session.mjs";
4
+ import {
5
+ buildClaudeMetadataPayload,
6
+ claudeMetadataEvent,
7
+ CursorStore,
8
+ deriveSessionHash,
9
+ readClaudeTranscriptSuffix,
10
+ } from "./session.mjs";
5
11
  import { SignedRuntimeTransport } from "./transport.mjs";
6
- import { withFileLock } from "./storage.mjs";
12
+ import { readJson, withFileLock, writePrivateFile } from "./storage.mjs";
7
13
  import { RUNTIME_VERSION } from "./version.mjs";
8
14
 
9
15
  export class LifecycleRuntime {
@@ -20,6 +26,7 @@ export class LifecycleRuntime {
20
26
  const connectionRoot = join(root, connection.installationId);
21
27
  this.queue = new BoundedEncryptedQueue(connectionRoot);
22
28
  this.cursors = new CursorStore(connectionRoot);
29
+ this.policyPath = join(connectionRoot, "policy.json");
23
30
  this.operationLockPath = join(connectionRoot, "runtime.operation.lock");
24
31
  this.maxBatchEvents = Math.min(100, Math.max(1, maxBatchEvents));
25
32
  this.maxBatchBytes = Math.min(1024 * 1024, Math.max(1024, maxBatchBytes));
@@ -99,13 +106,33 @@ export class LifecycleRuntime {
99
106
  });
100
107
  }
101
108
 
102
- async captureClaudeTranscript(hostSessionId, transcriptPath) {
109
+ /** The server-issued capture policy from the last heartbeat (P9). */
110
+ async capturePolicy() {
111
+ const stored = await readJson(this.policyPath, { deviceContext: "hashed" });
112
+ return { deviceContext: stored?.deviceContext === "full" ? "full" : "hashed" };
113
+ }
114
+
115
+ async captureClaudeTranscript(hostSessionId, transcriptPath, sessionFacts = {}) {
103
116
  const sessionHash = this.sessionHash(hostSessionId);
117
+ const policy = await this.capturePolicy();
104
118
  const queued = await withFileLock(this.operationLockPath, async () => {
105
119
  const cursor = await this.cursors.get(sessionHash);
106
120
  const suffix = await readClaudeTranscriptSuffix(transcriptPath, cursor, sessionHash);
107
121
  const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
108
122
  const unseenEvents = suffix.events.filter((event) => !recentEventKeys.has(event.eventKey));
123
+ // One metadata event whenever the observed content-free session facts
124
+ // change. The event key hashes the payload, so an unchanged snapshot is
125
+ // deduplicated exactly like any repeated event.
126
+ const metadataPayload = buildClaudeMetadataPayload(
127
+ { ...suffix.metadata, ...sessionFacts },
128
+ { installationId: this.connection.installationId, deviceContext: policy.deviceContext },
129
+ );
130
+ if (Object.keys(metadataPayload).length > 0) {
131
+ const metadataEvent = claudeMetadataEvent(metadataPayload);
132
+ if (!recentEventKeys.has(metadataEvent.eventKey)) unseenEvents.push(metadataEvent);
133
+ }
134
+ const usageGaps = unseenEvents.filter((event) =>
135
+ event.type === "usage" && event.eventKey.startsWith("claude:usage-gap:")).length;
109
136
  const result = unseenEvents.length === 0
110
137
  ? { queued: 0 }
111
138
  : await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
@@ -121,6 +148,7 @@ export class LifecycleRuntime {
121
148
  await this.cursors.update(sessionHash, { byteOffset: suffix.observedEndOffset });
122
149
  }
123
150
  await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
151
+ if (usageGaps > 0) await this.cursors.bumpUsageGaps(usageGaps);
124
152
  return result;
125
153
  });
126
154
  if (queued.queued === 0) return { queued: 0, acknowledged: 0 };
@@ -255,15 +283,28 @@ export class LifecycleRuntime {
255
283
 
256
284
  async heartbeat(capabilities) {
257
285
  const queue = await this.queue.diagnostics();
258
- return this.transport.heartbeat(capabilities, {
286
+ const usageGaps = await this.cursors.peekUsageGaps();
287
+ const response = await this.transport.heartbeat(capabilities, {
259
288
  pluginVersion: this.connection.pluginVersion || `${RUNTIME_VERSION}-local`,
260
289
  proofStorage: this.connection.proofStorage || "unknown",
261
290
  diagnostics: {
262
291
  queueDepth: queue.depth,
263
292
  oldestPendingAt: queue.oldestPendingAt,
264
293
  expiredCount: queue.expiredCount,
294
+ ...(usageGaps > 0 ? { usageGaps } : {}),
265
295
  },
266
296
  });
297
+ // The server accumulates reported gaps, so only a delivered delta is
298
+ // cleared — a failed heartbeat keeps the count for the next attempt.
299
+ if (usageGaps > 0) await this.cursors.clearUsageGaps(usageGaps);
300
+ // P9: persist the server-issued capture policy; the adapter consults it
301
+ // before SENDING device context, and the server re-checks regardless.
302
+ if (response && response.policy && typeof response.policy === "object") {
303
+ await writePrivateFile(this.policyPath, `${JSON.stringify({
304
+ deviceContext: response.policy.deviceContext === "full" ? "full" : "hashed",
305
+ })}\n`);
306
+ }
307
+ return response;
267
308
  }
268
309
  }
269
310
 
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 }) {
89
+ function normalizedEvent({ eventKey, type, occurredAt, payload, sourceEndOffset, part }) {
90
90
  const {
91
91
  role,
92
92
  contentFormat = "json",
@@ -104,6 +104,7 @@ function normalizedEvent({ eventKey, type, occurredAt, payload, sourceEndOffset
104
104
  contentFormat,
105
105
  captureStatus,
106
106
  ...(captureReasonCode ? { captureReasonCode } : {}),
107
+ ...(part ? { part } : {}),
107
108
  payload: wirePayload,
108
109
  payloadSha256: digest(contentFormat === "utf8" ? wirePayload : JSON.stringify(wirePayload)),
109
110
  ...(sourceEndOffset ? { sourceEndOffset } : {}),
@@ -131,19 +132,36 @@ function textEvent({ nativeId, index, role, text, occurredAt, sourceEndOffset })
131
132
  });
132
133
  }
133
134
 
134
- function toolEvent({ nativeId, index, type, role = "tool", toolName, toolUseId, body, occurredAt, sourceEndOffset, failed = false }) {
135
+ const MAX_INLINE_HOST_RESULT_BYTES = 16 * 1024;
136
+
137
+ /** Bounded structured host result: inline when small, digest evidence above. */
138
+ function boundedHostResult(raw) {
139
+ if (raw === null || raw === undefined || typeof raw !== "object") return undefined;
140
+ const stableBody = stableClone(raw);
141
+ const bytes = bodyBytes(stableBody, "json");
142
+ if (bytes.length <= MAX_INLINE_HOST_RESULT_BYTES) return stableBody;
143
+ return { digestOnly: true, bodySha256: digest(bytes), originalBytes: bytes.length };
144
+ }
145
+
146
+ function toolEvent({ nativeId, index, type, role = "tool", toolName, toolUseId, body, occurredAt, sourceEndOffset, failed = false, hostResult, outcome }) {
135
147
  const field = type === "tool_call" ? "input" : "result";
136
148
  const stableBody = stableClone(body ?? null);
149
+ const boundedResult = type === "tool_result" ? boundedHostResult(hostResult) : undefined;
137
150
  const toolMetadata = {
138
151
  toolName: boundedIdentifier(toolName),
139
152
  toolUseId: boundedIdentifier(toolUseId),
140
153
  ...(type === "tool_result" ? { failed: Boolean(failed) } : {}),
154
+ // Content-free outcome scalars survive digest-only fallbacks so the
155
+ // server's plaintext tool-call row is populated even when the body is
156
+ // too large to retain inline.
157
+ ...(outcome ? { outcome } : {}),
141
158
  };
142
159
  const completePayload = {
143
160
  role,
144
161
  contentFormat: "json",
145
162
  captureStatus: "complete",
146
163
  ...toolMetadata,
164
+ ...(boundedResult === undefined ? {} : { hostResult: boundedResult }),
147
165
  [field]: stableBody,
148
166
  };
149
167
  const payload = containsInlineArtifactBody(stableBody)
@@ -251,21 +269,182 @@ export function stripInjectedContext(value) {
251
269
  return String(value);
252
270
  }
253
271
 
272
+ function usageInt(value) {
273
+ return Number.isSafeInteger(value) && value >= 0 && value < 2 ** 31 ? value : null;
274
+ }
275
+
276
+ function usageLabel(value) {
277
+ return typeof value === "string" && /^[\x20-\x7e]{1,128}$/.test(value) ? value : null;
278
+ }
279
+
280
+ const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
281
+
282
+ /**
283
+ * One host-reported usage record per assistant message (PRD §8.1). Claude
284
+ * writes one JSONL line per content block of the same message, each repeating
285
+ * `message.usage`; the stable per-message eventKey plus keep-last suffix
286
+ * dedupe make that one record, never a sum of repeats (the ccusage bug).
287
+ * Counts come from the top-level usage totals only — `iterations[]` is the
288
+ * per-iteration breakdown of the same totals and is ignored.
289
+ */
290
+ function usageEventFromEntry(entry, { nativeId, occurredAt, sourceEndOffset }) {
291
+ const message = entry.message;
292
+ const usage = message.usage;
293
+ const model = typeof message.model === "string" && USAGE_MODEL_PATTERN.test(message.model)
294
+ ? message.model : null;
295
+ const usable = model !== null && usage !== null && typeof usage === "object" &&
296
+ (usageInt(usage.input_tokens) !== null || usageInt(usage.output_tokens) !== null);
297
+ if (!usable) {
298
+ return normalizedEvent({
299
+ eventKey: `claude:usage-gap:${nativeId}`,
300
+ type: "usage",
301
+ occurredAt,
302
+ payload: {
303
+ role: "assistant", contentFormat: "json", captureStatus: "complete",
304
+ gap: true, reason: "usage_unavailable",
305
+ },
306
+ sourceEndOffset,
307
+ });
308
+ }
309
+ const cacheCreation = usage.cache_creation && typeof usage.cache_creation === "object"
310
+ ? usage.cache_creation : {};
311
+ const details = usage.output_tokens_details && typeof usage.output_tokens_details === "object"
312
+ ? usage.output_tokens_details : {};
313
+ const messageId = usageLabel(message.id);
314
+ const payload = {
315
+ role: "assistant",
316
+ contentFormat: "json",
317
+ captureStatus: "complete",
318
+ provider: "anthropic",
319
+ model,
320
+ providerRequestId: usageLabel(entry.requestId),
321
+ messageId,
322
+ stopReason: usageLabel(message.stop_reason),
323
+ serviceTier: usageLabel(usage.service_tier),
324
+ effort: usageLabel(entry.effort),
325
+ sidechain: entry.isSidechain === true,
326
+ latencyMs: null,
327
+ inputTokens: usageInt(usage.input_tokens),
328
+ outputTokens: usageInt(usage.output_tokens),
329
+ cacheReadTokens: usageInt(usage.cache_read_input_tokens),
330
+ cacheWriteTokens: usageInt(usage.cache_creation_input_tokens),
331
+ cacheWrite1hTokens: usageInt(cacheCreation.ephemeral_1h_input_tokens),
332
+ cacheWrite5mTokens: usageInt(cacheCreation.ephemeral_5m_input_tokens),
333
+ reasoningTokens: usageInt(details.thinking_tokens),
334
+ };
335
+ return normalizedEvent({
336
+ eventKey: `claude:usage:${messageId || nativeId}`,
337
+ type: "usage",
338
+ occurredAt,
339
+ payload,
340
+ sourceEndOffset,
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Content-free outcome scalars for one tool_result block, joining the
346
+ * structured `toolUseResult` the host attached to the record and the tool_use
347
+ * timestamp observed earlier in the same suffix window.
348
+ */
349
+ function toolOutcomeFromEntry(entry, block, evidence) {
350
+ const raw = entry?.toolUseResult !== null && typeof entry?.toolUseResult === "object" &&
351
+ !Array.isArray(entry.toolUseResult) ? entry.toolUseResult : null;
352
+ const failed = block.is_error === true || raw?.status === "failed" || raw?.is_error === true;
353
+ const calledMs = evidence.toolUseTimestamps instanceof Map
354
+ ? evidence.toolUseTimestamps.get(String(block.tool_use_id ?? "")) : undefined;
355
+ const resultMs = Date.parse(String(entry?.timestamp ?? ""));
356
+ const durationMs = Number.isFinite(calledMs) && Number.isFinite(resultMs) && resultMs >= calledMs
357
+ ? Math.min(resultMs - calledMs, 86_400_000) : null;
358
+ const outputBytes = bodyBytes(stableClone(block.content ?? null), "json").length;
359
+ const resolvedModel = typeof raw?.resolvedModel === "string" &&
360
+ USAGE_MODEL_PATTERN.test(raw.resolvedModel) ? raw.resolvedModel : null;
361
+ return {
362
+ failed: Boolean(failed),
363
+ interrupted: raw?.interrupted === true,
364
+ ...(typeof raw?.status === "string" ? { status: reasonCode(raw.status) } : {}),
365
+ ...(durationMs === null ? {} : { durationMs }),
366
+ outputBytes,
367
+ ...(resolvedModel === null ? {} : { resolvedModel }),
368
+ };
369
+ }
370
+
371
+ function thinkingEvent({ nativeId, index, text, occurredAt, sourceEndOffset }) {
372
+ const payload = boundedCompletePayload({
373
+ role: "assistant",
374
+ contentFormat: "utf8",
375
+ captureStatus: "complete",
376
+ text,
377
+ }, { role: "assistant", body: text, format: "utf8" });
378
+ return normalizedEvent({
379
+ eventKey: `claude:${nativeId}:thinking:${index}`,
380
+ type: "message",
381
+ occurredAt,
382
+ payload,
383
+ sourceEndOffset,
384
+ part: "thinking",
385
+ });
386
+ }
387
+
388
+ const MAX_INLINE_ATTACHMENT_BYTES = 8 * 1024;
389
+
254
390
  /**
255
391
  * Normalize one current Claude transcript record into zero or more archive
256
392
  * events. Text is never trimmed or prefix-truncated. Unsupported/oversized
257
393
  * bodies become explicit digest-only events, so advancing the byte cursor
258
- * cannot silently turn a capture gap into success.
394
+ * cannot silently turn a capture gap into success. Opaque host tokens
395
+ * (`atis-latch`) are never read at all.
259
396
  */
260
397
  export function normalizeClaudeTranscriptEntry(entry, evidence) {
398
+ const recordType = entry?.type;
399
+ const sourceEndOffset = evidence.endOffset;
400
+ if (recordType === "queue-operation") {
401
+ // A user message queued mid-turn. Only the enqueue marks a queued message;
402
+ // the content itself reaches the archive through the transcript when the
403
+ // host delivers it as a real user turn.
404
+ if (entry.operation !== "enqueue") return [];
405
+ const occurredAt = entry.timestamp || evidence.occurredAt;
406
+ return [normalizedEvent({
407
+ eventKey: `claude:queued:${digest(`${entry.timestamp ?? ""}\0${evidence.byteOffset}`)}`,
408
+ type: "checkpoint",
409
+ occurredAt,
410
+ payload: { contentFormat: "json", captureStatus: "complete", kind: "queued_message" },
411
+ sourceEndOffset,
412
+ })];
413
+ }
414
+ if (recordType === "attachment") {
415
+ const occurredAt = entry.timestamp || evidence.occurredAt;
416
+ const attachment = entry.attachment !== null && typeof entry.attachment === "object"
417
+ ? entry.attachment : {};
418
+ const attachmentType = boundedIdentifier(attachment.type || "unknown", 100);
419
+ const stableBody = stableClone(attachment);
420
+ const bytes = bodyBytes(stableBody, "json");
421
+ const payload = bytes.length <= MAX_INLINE_ATTACHMENT_BYTES
422
+ ? boundedCompletePayload({
423
+ contentFormat: "json", captureStatus: "complete",
424
+ kind: "attachment", attachmentType, body: stableBody,
425
+ }, { body: stableBody, format: "json", extra: { kind: "attachment", attachmentType } })
426
+ : digestOnlyPayload({
427
+ body: bytes, status: "truncated", reason: "attachment_body_too_large",
428
+ extra: { kind: "attachment", attachmentType },
429
+ });
430
+ return [normalizedEvent({
431
+ eventKey: `claude:attachment:${entry.uuid || `offset-${evidence.byteOffset}`}`,
432
+ type: "checkpoint",
433
+ occurredAt,
434
+ payload,
435
+ sourceEndOffset,
436
+ })];
437
+ }
261
438
  const message = entry?.message;
262
439
  if (!message || !["user", "assistant"].includes(message.role)) return [];
263
440
  const role = message.role;
264
441
  const occurredAt = entry.timestamp || evidence.occurredAt;
265
- const sourceEndOffset = evidence.endOffset;
266
442
  const nativeId = nativeEntryId(entry, evidence);
443
+ const usageEvents = role === "assistant"
444
+ ? [usageEventFromEntry(entry, { nativeId, occurredAt, sourceEndOffset })]
445
+ : [];
267
446
  if (typeof message.content === "string") {
268
- return [textEvent({ nativeId, index: 0, role, text: message.content, occurredAt, sourceEndOffset })];
447
+ return [textEvent({ nativeId, index: 0, role, text: message.content, occurredAt, sourceEndOffset }), ...usageEvents];
269
448
  }
270
449
  if (!Array.isArray(message.content)) {
271
450
  return [unsupportedBlockEvent({
@@ -275,20 +454,28 @@ export function normalizeClaudeTranscriptEntry(entry, evidence) {
275
454
  block: message.content,
276
455
  occurredAt,
277
456
  sourceEndOffset,
278
- })];
457
+ }), ...usageEvents];
279
458
  }
280
459
  if (message.content.length === 0) {
281
- return [textEvent({ nativeId, index: 0, role, text: "", occurredAt, sourceEndOffset })];
460
+ return [textEvent({ nativeId, index: 0, role, text: "", occurredAt, sourceEndOffset }), ...usageEvents];
282
461
  }
283
- return message.content.map((block, index) => {
462
+ return [...message.content.flatMap((block, index) => {
284
463
  if (!block || typeof block !== "object") {
285
- return unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset });
464
+ return [unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset })];
286
465
  }
287
466
  if (block.type === "text" && typeof block.text === "string") {
288
- return textEvent({ nativeId, index, role, text: block.text, occurredAt, sourceEndOffset });
467
+ return [textEvent({ nativeId, index, role, text: block.text, occurredAt, sourceEndOffset })];
468
+ }
469
+ if (block.type === "thinking" && typeof block.thinking === "string") {
470
+ // Reasoning content, captured as a marked message part so a session
471
+ // with thinking no longer reads as partial. Empty blocks (signature
472
+ // only) carry no content and produce no event.
473
+ return block.thinking.length > 0
474
+ ? [thinkingEvent({ nativeId, index, text: block.thinking, occurredAt, sourceEndOffset })]
475
+ : [];
289
476
  }
290
477
  if (block.type === "tool_use") {
291
- return toolEvent({
478
+ return [toolEvent({
292
479
  nativeId,
293
480
  index,
294
481
  type: "tool_call",
@@ -297,10 +484,10 @@ export function normalizeClaudeTranscriptEntry(entry, evidence) {
297
484
  body: block.input,
298
485
  occurredAt,
299
486
  sourceEndOffset,
300
- });
487
+ })];
301
488
  }
302
489
  if (block.type === "tool_result") {
303
- return toolEvent({
490
+ return [toolEvent({
304
491
  nativeId,
305
492
  index,
306
493
  type: "tool_result",
@@ -310,13 +497,15 @@ export function normalizeClaudeTranscriptEntry(entry, evidence) {
310
497
  occurredAt,
311
498
  sourceEndOffset,
312
499
  failed: block.is_error,
313
- });
500
+ hostResult: entry?.toolUseResult,
501
+ outcome: toolOutcomeFromEntry(entry, block, evidence),
502
+ })];
314
503
  }
315
504
  if (["image", "document", "artifact", "file"].includes(block.type)) {
316
- return artifactEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset });
505
+ return [artifactEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset })];
317
506
  }
318
- return unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset });
319
- });
507
+ return [unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset })];
508
+ }), ...usageEvents];
320
509
  }
321
510
 
322
511
  export function normalizeMalformedClaudeTranscriptLine(rawBytes, evidence) {
@@ -418,11 +607,39 @@ export function normalizeHostMessageEvent({
418
607
  };
419
608
  }
420
609
 
610
+ function collectClaudeMetadata(metadata, entry) {
611
+ const bounded = (value, max) =>
612
+ typeof value === "string" && value.length > 0 && value.length <= max &&
613
+ !/[\u0000-\u001f\u007f]/.test(value) ? value : undefined;
614
+ const hostVersion = bounded(entry.version, 64);
615
+ if (hostVersion) metadata.hostVersion = hostVersion;
616
+ const entrypoint = bounded(entry.entrypoint, 64);
617
+ if (entrypoint) metadata.entrypoint = entrypoint;
618
+ const userType = bounded(entry.userType, 64);
619
+ if (userType) metadata.userType = userType;
620
+ const effort = bounded(entry.effort, 64);
621
+ if (effort) metadata.effort = effort;
622
+ const permissionMode = entry.type === "permission-mode"
623
+ ? bounded(entry.permissionMode, 64)
624
+ : bounded(entry.permissionMode, 64);
625
+ if (permissionMode) metadata.permissionMode = permissionMode;
626
+ if (entry.type === "ai-title") {
627
+ const title = bounded(entry.aiTitle ?? entry.title, 512);
628
+ if (title) metadata.title = title;
629
+ }
630
+ const gitBranch = bounded(entry.gitBranch, 512);
631
+ if (gitBranch) metadata.gitBranch = gitBranch;
632
+ const cwd = bounded(entry.cwd, 2048);
633
+ if (cwd) metadata.cwd = cwd;
634
+ }
635
+
421
636
  export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
422
637
  const bytes = await readFile(path);
423
638
  const start = Number.isSafeInteger(cursor?.byteOffset) && cursor.byteOffset <= bytes.length ? cursor.byteOffset : 0;
424
639
  const suffix = bytes.subarray(start);
425
640
  const events = [];
641
+ const metadata = {};
642
+ const toolUseTimestamps = new Map();
426
643
  let position = 0;
427
644
  while (position < suffix.length) {
428
645
  const newline = suffix.indexOf(0x0a, position);
@@ -439,14 +656,95 @@ export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
439
656
  endOffset,
440
657
  rawBytes,
441
658
  occurredAt: new Date().toISOString(),
659
+ toolUseTimestamps,
442
660
  };
443
661
  try {
444
- events.push(...normalizeClaudeTranscriptEntry(JSON.parse(raw), evidence));
662
+ const entry = JSON.parse(raw);
663
+ if (entry !== null && typeof entry === "object") {
664
+ collectClaudeMetadata(metadata, entry);
665
+ if (entry.type === "assistant" && Array.isArray(entry.message?.content)) {
666
+ const calledMs = Date.parse(String(entry.timestamp ?? ""));
667
+ for (const block of entry.message.content) {
668
+ if (block && typeof block === "object" && block.type === "tool_use" &&
669
+ typeof block.id === "string" && Number.isFinite(calledMs)) {
670
+ toolUseTimestamps.set(block.id, calledMs);
671
+ }
672
+ }
673
+ }
674
+ }
675
+ events.push(...normalizeClaudeTranscriptEntry(entry, evidence));
445
676
  } catch {
446
677
  events.push(normalizeMalformedClaudeTranscriptLine(rawBytes, evidence));
447
678
  }
448
679
  }
449
- return { events, observedEndOffset: start + position };
680
+ // Claude repeats the same usage record on every JSONL line of a multi-block
681
+ // message. One usage event per message.id, keep-last: later lines carry the
682
+ // completed totals. A gap event is withdrawn the moment any line of the
683
+ // same message produced a real usage record.
684
+ const usageByKey = new Map();
685
+ for (const event of events) {
686
+ if (event.type === "usage") usageByKey.set(event.eventKey, event);
687
+ }
688
+ const usageKeysWithData = new Set(
689
+ [...usageByKey.keys()].filter((key) => key.startsWith("claude:usage:")),
690
+ );
691
+ const emittedUsageKeys = new Set();
692
+ const deduped = [];
693
+ for (let index = events.length - 1; index >= 0; index -= 1) {
694
+ const event = events[index];
695
+ if (event.type === "usage") {
696
+ if (emittedUsageKeys.has(event.eventKey)) continue;
697
+ if (event.eventKey.startsWith("claude:usage-gap:")) {
698
+ const nativeId = event.eventKey.slice("claude:usage-gap:".length);
699
+ if (usageKeysWithData.has(`claude:usage:${nativeId}`)) continue;
700
+ }
701
+ emittedUsageKeys.add(event.eventKey);
702
+ }
703
+ deduped.unshift(event);
704
+ }
705
+ return { events: deduped, observedEndOffset: start + position, metadata };
706
+ }
707
+
708
+ export function buildClaudeMetadataPayload(metadata, { installationId, deviceContext }) {
709
+ const payload = {};
710
+ for (const field of ["hostVersion", "entrypoint", "userType", "permissionMode", "effort", "title"]) {
711
+ if (typeof metadata[field] === "string" && metadata[field].length > 0) {
712
+ payload[field] = metadata[field];
713
+ }
714
+ }
715
+ if (typeof metadata.cwd === "string" && metadata.cwd.length > 0) {
716
+ // The path itself leaves the device only under an explicit org policy
717
+ // (deviceContext=full); the salted hash and basename always travel so
718
+ // spend can still be grouped per repository.
719
+ payload.cwdHash = digest(`halofy-cwd-v1\0${installationId}\0${metadata.cwd}`);
720
+ const segments = metadata.cwd.split(/[\\/]/).filter((segment) => segment.length > 0);
721
+ const base = segments.length > 0 ? segments[segments.length - 1].slice(0, 256) : "";
722
+ if (base) payload.cwdBasename = base;
723
+ if (deviceContext === "full") payload.cwdPath = metadata.cwd;
724
+ }
725
+ if (typeof metadata.gitBranch === "string" && metadata.gitBranch.length > 0 &&
726
+ deviceContext === "full") {
727
+ payload.gitBranch = metadata.gitBranch;
728
+ }
729
+ if (typeof metadata.startSource === "string" && metadata.startSource.length > 0) {
730
+ payload.startSource = metadata.startSource;
731
+ }
732
+ if (typeof metadata.closeReason === "string" && metadata.closeReason.length > 0) {
733
+ payload.closeReason = metadata.closeReason;
734
+ }
735
+ if (typeof metadata.subagentType === "string" && metadata.subagentType.length > 0) {
736
+ payload.subagentType = metadata.subagentType;
737
+ }
738
+ return payload;
739
+ }
740
+
741
+ export function claudeMetadataEvent(payload) {
742
+ return normalizedEvent({
743
+ eventKey: `claude:metadata:${digest(stableJson(payload))}`,
744
+ type: "metadata",
745
+ occurredAt: new Date().toISOString(),
746
+ payload: { contentFormat: "json", captureStatus: "complete", ...payload },
747
+ });
450
748
  }
451
749
 
452
750
  export class CursorStore {
@@ -493,4 +791,31 @@ export class CursorStore {
493
791
  return current;
494
792
  });
495
793
  }
794
+
795
+ // Usage-gap diagnostics (PRD §8.3): a content-free count of host records
796
+ // the adapter could not read usage from, reported on the next heartbeat as
797
+ // a delta and cleared only after successful delivery.
798
+ async peekUsageGaps() {
799
+ const state = await readJson(this.path, { version: 1, sessions: {} });
800
+ const count = Number(state.usageGaps ?? 0);
801
+ return Number.isSafeInteger(count) && count > 0 ? count : 0;
802
+ }
803
+
804
+ async bumpUsageGaps(count) {
805
+ if (!Number.isSafeInteger(count) || count <= 0) return;
806
+ await withFileLock(this.lockPath, async () => {
807
+ const state = await readJson(this.path, { version: 1, sessions: {} });
808
+ state.usageGaps = Math.max(0, Number(state.usageGaps ?? 0) || 0) + count;
809
+ await writePrivateFile(this.path, `${JSON.stringify(state)}\n`);
810
+ });
811
+ }
812
+
813
+ async clearUsageGaps(delivered) {
814
+ if (!Number.isSafeInteger(delivered) || delivered <= 0) return;
815
+ await withFileLock(this.lockPath, async () => {
816
+ const state = await readJson(this.path, { version: 1, sessions: {} });
817
+ state.usageGaps = Math.max(0, (Number(state.usageGaps ?? 0) || 0) - delivered);
818
+ await writePrivateFile(this.path, `${JSON.stringify(state)}\n`);
819
+ });
820
+ }
496
821
  }
package/src/version.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  export const PACKAGE_NAME = "@halofy/agent-connect";
2
- export const INSTALLER_VERSION = "0.4.0";
3
- export const RUNTIME_VERSION = "0.4.0";
4
- export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-29";
2
+ export const INSTALLER_VERSION = "0.5.0";
3
+ export const RUNTIME_VERSION = "0.5.0";
4
+ export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-29.2";