@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 +1 -1
- package/package.json +1 -1
- package/src/claude-hook.mjs +8 -4
- package/src/client-registry.mjs +10 -0
- package/src/installer-cli.mjs +4 -0
- package/src/runtime.mjs +45 -4
- package/src/session.mjs +344 -19
- package/src/version.mjs +3 -3
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.
|
|
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
package/src/claude-hook.mjs
CHANGED
|
@@ -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)) {
|
package/src/client-registry.mjs
CHANGED
|
@@ -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({
|
package/src/installer-cli.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
-
export const RUNTIME_VERSION = "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";
|