@halofy/agent-connect 0.4.0 → 0.5.1

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
@@ -16,10 +16,16 @@ agent connections:
16
16
  - a stdio-to-signed-Streamable-HTTP MCP proof proxy; and
17
17
  - one local claim consumer and proof runtime shared by every packaged adapter.
18
18
 
19
+ Hook-driven recall injection is disabled in this release
20
+ (`RECALL_INJECTION_ENABLED` in `src/session.mjs`): the runtime captures
21
+ conversations and serves the agent-invoked MCP memory tools, but does not push
22
+ recalled memory into host sessions on session start or prompt submit. The
23
+ bounded recall block formats stay in place and tested for when it returns.
24
+
19
25
  There is one setup path for every packaged client:
20
26
 
21
27
  ```bash
22
- npx --yes @halofy/agent-connect@0.4.0 install <client-kind> \
28
+ npx --yes @halofy/agent-connect@0.5.1 install <client-kind> \
23
29
  --server https://app.halofy.ai \
24
30
  --claim '<one-time-claim>'
25
31
  ```
@@ -44,7 +50,10 @@ to conversation-capture evidence by its name.
44
50
 
45
51
  Run the server-returned command in the operating-system terminal from the
46
52
  computer where the selected client runs, never in agent chat. The installer displays
47
- the capture disclosure, requires the recipient to type `CONNECT`, generates
53
+ the capture disclosure — including the organization the claim binds to, fetched
54
+ from the named server so a spoofed `--server` is recognizable before
55
+ confirmation (`unverified` when the server cannot identify the claim) —
56
+ requires the recipient to type `CONNECT`, generates
48
57
  the Ed25519 private key locally, consumes the one-use claim in a JSON body,
49
58
  copies the reviewed runtime out of the transient npx cache, and installs the
50
59
  signed MCP proxy and lifecycle hooks together. It replaces an existing Halofy
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@halofy/agent-connect",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
- "description": "Signed Halofy lifecycle installer and runtime for supported agents",
5
+ "description": "Halofy lifecycle installer and runtime for supported agents; runtime requests are signed with a per-installation Ed25519 key",
6
6
  "bin": {
7
7
  "agent-connect": "bin/install.mjs",
8
8
  "halofy-agent": "bin/halofy-agent.mjs"
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join, resolve } from "node:path";
3
- import { readJson, writePrivateFile } from "./storage.mjs";
3
+ import { readJson, writeHostConfigFile } from "./storage.mjs";
4
4
 
5
5
  const CLAUDE_HOOKS = Object.freeze({
6
6
  SessionStart: { matcher: "startup|resume|clear|compact", timeout: 15 },
@@ -124,12 +124,12 @@ export async function configureClaudeProject({
124
124
  replacedClaudeMcpEntries += removeHalofyEntries(project.mcpServers, server.toString());
125
125
  }
126
126
  if (replacedClaudeMcpEntries > 0) {
127
- await writePrivateFile(claudeConfigPath, `${JSON.stringify(claude, null, 2)}\n`);
127
+ await writeHostConfigFile(claudeConfigPath, `${JSON.stringify(claude, null, 2)}\n`);
128
128
  }
129
129
  }
130
130
 
131
- await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
132
- await writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
131
+ await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
132
+ await writeHostConfigFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
133
133
  return {
134
134
  mcpPath,
135
135
  settingsPath,
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { basename } from "node:path";
3
3
  import { LifecycleRuntime } from "./runtime.mjs";
4
- import { normalizeClaudeHookEvent } from "./session.mjs";
4
+ import { normalizeClaudeHookEvent, RECALL_INJECTION_ENABLED, rankedRecallBlocks } from "./session.mjs";
5
5
  import { defaultRuntimeDirectory } from "./storage.mjs";
6
6
 
7
7
  function id(value) {
@@ -42,16 +42,7 @@ function childSession(input) {
42
42
  }
43
43
 
44
44
  function recallText(result, eventName) {
45
- const blocks = Array.isArray(result?.blocks) ? result.blocks : Array.isArray(result) ? result : [];
46
- const rankedBlocks = blocks
47
- .filter((block) => block && typeof block.recallRef === "string" &&
48
- typeof block.content === "string" && block.content.trim())
49
- .slice(0, 12)
50
- .map((block, rank) => ({
51
- rank: rank + 1,
52
- recallRef: block.recallRef.slice(0, 256),
53
- content: block.content.trim(),
54
- }));
45
+ const rankedBlocks = rankedRecallBlocks(result);
55
46
  if (rankedBlocks.length === 0) return null;
56
47
  return JSON.stringify({
57
48
  hookSpecificOutput: {
@@ -61,10 +52,10 @@ function recallText(result, eventName) {
61
52
  });
62
53
  }
63
54
 
64
- async function catchUp(runtime, input, session = hostSession(input)) {
55
+ async function catchUp(runtime, input, session = hostSession(input), sessionFacts = {}) {
65
56
  const transcriptPath = input.agent_transcript_path || input.transcript_path;
66
57
  if (!transcriptPath || !session) return;
67
- await runtime.captureClaudeTranscript(session, String(transcriptPath));
58
+ await runtime.captureClaudeTranscript(session, String(transcriptPath), sessionFacts);
68
59
  }
69
60
 
70
61
  async function within(milliseconds, action) {
@@ -95,20 +86,24 @@ export async function runClaudeLifecycleHook(connection, eventName, {
95
86
  if (eventName === "SessionStart") {
96
87
  await runtime.replay();
97
88
  await runtime.heartbeat(connection.capabilities || {});
98
- const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
99
- const recalled = await runtime.recall(
100
- session,
101
- `${project ? `${project} ` : ""}project policy, conventions, decisions, preferences, and known context`,
102
- );
103
- const output = recallText(recalled, "SessionStart");
104
- if (output) stdout.write(output);
105
- } else if (eventName === "UserPromptSubmit") {
106
- const prompt = String(hookInput.prompt || hookInput.user_prompt || "").slice(0, 8_000);
107
- if (prompt.trim()) {
108
- const recalled = await runtime.recall(session, prompt);
109
- const output = recallText(recalled, "UserPromptSubmit");
89
+ if (RECALL_INJECTION_ENABLED) {
90
+ const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
91
+ const recalled = await runtime.recall(
92
+ session,
93
+ `${project ? `${project} ` : ""}project policy, conventions, decisions, preferences, and known context`,
94
+ );
95
+ const output = recallText(recalled, "SessionStart");
110
96
  if (output) stdout.write(output);
111
97
  }
98
+ } else if (eventName === "UserPromptSubmit") {
99
+ if (RECALL_INJECTION_ENABLED) {
100
+ const prompt = String(hookInput.prompt || hookInput.user_prompt || "").slice(0, 8_000);
101
+ if (prompt.trim()) {
102
+ const recalled = await runtime.recall(session, prompt);
103
+ const output = recallText(recalled, "UserPromptSubmit");
104
+ if (output) stdout.write(output);
105
+ }
106
+ }
112
107
  } else if (eventName === "Stop") {
113
108
  if (!hookInput.stop_hook_active) {
114
109
  await catchUp(runtime, hookInput);
@@ -124,7 +119,9 @@ export async function runClaudeLifecycleHook(connection, eventName, {
124
119
  await runtime.commit(session, "pre_compaction");
125
120
  } else if (eventName === "SessionEnd") {
126
121
  await within(15_000, async () => {
127
- await catchUp(runtime, hookInput);
122
+ await catchUp(runtime, hookInput, hostSession(hookInput), {
123
+ ...(hookInput.reason ? { closeReason: String(hookInput.reason).slice(0, 64) } : {}),
124
+ });
128
125
  await runtime.close(session);
129
126
  });
130
127
  } else if (eventName === "SubagentStart") {
@@ -132,7 +129,9 @@ export async function runClaudeLifecycleHook(connection, eventName, {
132
129
  } else if (eventName === "SubagentStop") {
133
130
  const child = childSession(hookInput);
134
131
  await within(10_000, async () => {
135
- await catchUp(runtime, hookInput, child);
132
+ await catchUp(runtime, hookInput, child, {
133
+ ...(hookInput.agent_type ? { subagentType: String(hookInput.agent_type).slice(0, 128) } : {}),
134
+ });
136
135
  await runtime.close(child, "session_end");
137
136
  });
138
137
  } 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({
@@ -1,7 +1,7 @@
1
- import { chmod, readFile } from "node:fs/promises";
1
+ import { chmod, mkdir, readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
- import { ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
4
+ import { readJson, writeHostConfigFile } from "./storage.mjs";
5
5
 
6
6
  function commandArg(value) {
7
7
  const text = String(value);
@@ -92,8 +92,8 @@ export async function configureCursor({
92
92
  mcp.mcpServers,
93
93
  managedMcp(nodePath, runtimePath, installationId),
94
94
  );
95
- await writePrivateFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
96
- await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
95
+ await writeHostConfigFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
96
+ await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
97
97
  return { configuredPaths: [hooksPath, mcpPath], hookEvents: Object.keys(CURSOR_HOOKS), replacedLegacyMcpEntries };
98
98
  }
99
99
 
@@ -133,7 +133,7 @@ export async function configureGemini({
133
133
  settings.mcpServers,
134
134
  managedMcp(nodePath, runtimePath, installationId),
135
135
  );
136
- await writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
136
+ await writeHostConfigFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
137
137
  return { configuredPaths: [settingsPath], hookEvents: Object.keys(GEMINI_HOOKS), replacedLegacyMcpEntries };
138
138
  }
139
139
 
@@ -197,8 +197,8 @@ export async function configureKimi({
197
197
  mcp.mcpServers,
198
198
  managedMcp(nodePath, runtimePath, installationId),
199
199
  );
200
- await writePrivateFile(configPath, replaceKimiManagedHooks(source, hookBlock));
201
- await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
200
+ await writeHostConfigFile(configPath, replaceKimiManagedHooks(source, hookBlock));
201
+ await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
202
202
  return { configuredPaths: [configPath, mcpPath], hookEvents: Object.keys(KIMI_HOOKS), replacedLegacyMcpEntries };
203
203
  }
204
204
 
@@ -233,8 +233,8 @@ export async function configureVscode({
233
233
  mcp.servers,
234
234
  managedMcp(nodePath, runtimePath, installationId, { explicitType: true }),
235
235
  );
236
- await writePrivateFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
237
- await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
236
+ await writeHostConfigFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
237
+ await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
238
238
  return { configuredPaths: [hooksPath, mcpPath], hookEvents: Object.keys(VSCODE_HOOKS), replacedLegacyMcpEntries };
239
239
  }
240
240
 
@@ -308,11 +308,11 @@ export async function configureCodex({
308
308
  `command = ${JSON.stringify(resolve(nodePath))}`,
309
309
  `args = ${JSON.stringify([resolve(runtimePath), "mcp", "--connection", installationId])}`,
310
310
  ].join("\n");
311
- await writePrivateFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
311
+ await writeHostConfigFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
312
312
  const withoutHalomem = removeTomlSection(source, "mcp_servers.halomem");
313
313
  const withoutHalofy = removeTomlSection(withoutHalomem.source, "mcp_servers.halofy");
314
314
  const withMcp = replaceTomlSection(withoutHalofy.source, "mcp_servers.halofy", section);
315
- await writePrivateFile(configPath, enableTomlFeature(withMcp, "hooks"));
315
+ await writeHostConfigFile(configPath, enableTomlFeature(withMcp, "hooks"));
316
316
  return {
317
317
  configuredPaths: [hooksPath, configPath],
318
318
  hookEvents: Object.keys(CODEX_HOOKS),
@@ -335,12 +335,12 @@ export async function configureCline({
335
335
  }) {
336
336
  validateInputs({ installationId, runtimePath });
337
337
  const hooksRoot = join(clineRoot, "Hooks");
338
- await ensurePrivateDirectory(hooksRoot);
338
+ await mkdir(hooksRoot, { recursive: true });
339
339
  const configuredPaths = [];
340
340
  for (const [hostEvent, runtimeEvent] of Object.entries(CLINE_HOOKS)) {
341
341
  const path = join(hooksRoot, hostEvent);
342
342
  const command = managedCommand(nodePath, runtimePath, runtimeEvent, installationId);
343
- await writePrivateFile(path, `#!/usr/bin/env sh\nexec ${command}\n`);
343
+ await writeHostConfigFile(path, `#!/usr/bin/env sh\nexec ${command}\n`);
344
344
  if (process.platform !== "win32") await chmod(path, 0o700);
345
345
  configuredPaths.push(path);
346
346
  }
@@ -351,7 +351,7 @@ export async function configureCline({
351
351
  mcp.mcpServers,
352
352
  managedMcp(nodePath, runtimePath, installationId),
353
353
  );
354
- await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
354
+ await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
355
355
  configuredPaths.push(mcpPath);
356
356
  return { configuredPaths, hookEvents: Object.keys(CLINE_HOOKS), replacedLegacyMcpEntries };
357
357
  }
package/src/host-hook.mjs CHANGED
@@ -1,7 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { basename } from "node:path";
3
3
  import { LifecycleRuntime } from "./runtime.mjs";
4
- import { normalizeClaudeHookEvent, normalizeHostMessageEvent } from "./session.mjs";
4
+ import {
5
+ normalizeClaudeHookEvent,
6
+ normalizeHostMessageEvent,
7
+ RECALL_INJECTION_ENABLED,
8
+ rankedRecallBlocks,
9
+ } from "./session.mjs";
5
10
  import { defaultRuntimeDirectory } from "./storage.mjs";
6
11
  import { readHookInput } from "./claude-hook.mjs";
7
12
 
@@ -62,18 +67,8 @@ function toolOutput(input) {
62
67
  input.extra?.result ?? input.extra?.error_message;
63
68
  }
64
69
 
65
- function recallBlocks(result) {
66
- const blocks = Array.isArray(result?.blocks) ? result.blocks : Array.isArray(result) ? result : [];
67
- return blocks.filter((block) => block && typeof block.recallRef === "string" &&
68
- typeof block.content === "string" && block.content.trim()).slice(0, 12).map((block, rank) => ({
69
- rank: rank + 1,
70
- recallRef: block.recallRef.slice(0, 256),
71
- content: block.content.trim(),
72
- }));
73
- }
74
-
75
70
  function recallOutput(clientKind, eventName, result) {
76
- const rankedBlocks = recallBlocks(result);
71
+ const rankedBlocks = rankedRecallBlocks(result);
77
72
  if (rankedBlocks.length === 0) return null;
78
73
  const additionalContext = `<<<HALOFY_CONTEXT_BLOCKS_V1>>>\n${JSON.stringify({ rankedBlocks })}\n<<<END_HALOFY_CONTEXT_BLOCKS_V1>>>`;
79
74
  if (clientKind === "cursor") {
@@ -139,15 +134,20 @@ export async function runHostLifecycleHook(connection, eventName, {
139
134
  if (START_EVENTS.has(eventName)) {
140
135
  await runtime.replay();
141
136
  await runtime.heartbeat(connection.capabilities || {});
142
- const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
143
- const recalled = await runtime.recall(session,
144
- `${project ? `${project} ` : ""}project policy, conventions, decisions, preferences, and known context`);
145
- const output = recallOutput(connection.clientKind, eventName, recalled);
146
- if (output) stdout.write(output);
137
+ if (RECALL_INJECTION_ENABLED) {
138
+ const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
139
+ const recalled = await runtime.recall(session,
140
+ `${project ? `${project} ` : ""}project policy, conventions, decisions, preferences, and known context`);
141
+ const output = recallOutput(connection.clientKind, eventName, recalled);
142
+ if (output) stdout.write(output);
143
+ }
147
144
  } else if (USER_EVENTS.has(eventName)) {
145
+ // Prompt capture is independent of recall: non-Claude hosts have no
146
+ // transcript, so this enqueue is how user messages reach the archive.
148
147
  const prompt = promptText(hookInput);
149
148
  await enqueueMessage(runtime, connection, session, "user", prompt, eventName, hookInput);
150
- if (connection.clientKind !== "cursor" && typeof prompt === "string" && prompt.trim()) {
149
+ if (RECALL_INJECTION_ENABLED && connection.clientKind !== "cursor" &&
150
+ typeof prompt === "string" && prompt.trim()) {
151
151
  const recalled = await runtime.recall(session, prompt.slice(0, 8_000));
152
152
  const output = recallOutput(connection.clientKind, eventName, recalled);
153
153
  if (output) stdout.write(output);
package/src/install.mjs CHANGED
@@ -10,6 +10,57 @@ import { CLIENT_KINDS, CLIENT_REGISTRY, lifecycleClient } from "./client-registr
10
10
 
11
11
  export const CLAUDE_CAPABILITIES = CLIENT_REGISTRY["claude-code"].capabilities;
12
12
 
13
+ /** HTTPS-or-loopback validation shared by every request that carries the claim. */
14
+ export function normalizeLifecycleServerUrl(serverUrl) {
15
+ const normalizedServer = new URL(serverUrl);
16
+ if (normalizedServer.protocol !== "https:" && normalizedServer.hostname !== "localhost" &&
17
+ normalizedServer.hostname !== "127.0.0.1" && normalizedServer.hostname !== "[::1]") {
18
+ throw new Error("installation claims require HTTPS (localhost is allowed for development)");
19
+ }
20
+ normalizedServer.pathname = normalizedServer.pathname.replace(/\/+$/, "");
21
+ return normalizedServer.toString().replace(/\/$/, "");
22
+ }
23
+
24
+ /**
25
+ * Best-effort pre-consumption disclosure: which organization this claim binds
26
+ * to, shown before the CONNECT confirmation. An older server without the
27
+ * endpoint, or any failure, yields null — the installer then labels the
28
+ * organization unverified instead of failing. The claim is not consumed.
29
+ */
30
+ export async function fetchClaimDisclosure({
31
+ serverUrl,
32
+ claim,
33
+ fetchImpl = globalThis.fetch,
34
+ timeoutMs = 5_000,
35
+ }) {
36
+ try {
37
+ const normalizedServerUrl = normalizeLifecycleServerUrl(serverUrl);
38
+ const controller = new AbortController();
39
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
40
+ let response;
41
+ try {
42
+ response = await fetchImpl(`${normalizedServerUrl}/v1/agent-installations/claim-info`, {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/json" },
45
+ body: JSON.stringify({ claim }),
46
+ signal: controller.signal,
47
+ });
48
+ } finally {
49
+ clearTimeout(timer);
50
+ }
51
+ if (!response.ok) return null;
52
+ const body = await response.json();
53
+ // Server-supplied text is printed to a terminal: strip control characters
54
+ // (including ANSI escape introducers) and bound the length.
55
+ const organization = typeof body?.organization === "string"
56
+ ? body.organization.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, 100)
57
+ : "";
58
+ return organization ? { organization } : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
13
64
  export async function consumeInstallationClaim({
14
65
  serverUrl,
15
66
  claim,
@@ -51,12 +102,7 @@ export async function installLocalConnection({
51
102
  }) {
52
103
  if (!CLIENT_KINDS.includes(clientKind)) throw new Error("the selected lifecycle adapter is not packaged");
53
104
  const client = lifecycleClient(clientKind);
54
- const normalizedServer = new URL(serverUrl);
55
- if (normalizedServer.protocol !== "https:" && normalizedServer.hostname !== "localhost" && normalizedServer.hostname !== "127.0.0.1") {
56
- throw new Error("installation claims require HTTPS (localhost is allowed for development)");
57
- }
58
- normalizedServer.pathname = normalizedServer.pathname.replace(/\/+$/, "");
59
- const normalizedServerUrl = normalizedServer.toString().replace(/\/$/, "");
105
+ const normalizedServerUrl = normalizeLifecycleServerUrl(serverUrl);
60
106
  const store = new ConnectionStore(root);
61
107
  const pendingPath = join(root, `pending-${clientKind}.json`);
62
108
  const priorPending = await readJson(pendingPath);
@@ -4,6 +4,7 @@ import { createInterface } from "node:readline/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import {
7
+ fetchClaimDisclosure,
7
8
  heartbeatInstalledConnection,
8
9
  installLocalConnection,
9
10
  installRuntimeBundle,
@@ -81,7 +82,7 @@ export function detectClient(clientKind) {
81
82
  return String(result.stdout || result.stderr || "").trim().slice(0, 128) || "detected";
82
83
  }
83
84
 
84
- export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion }) {
85
+ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion, organization = null }) {
85
86
  const client = lifecycleClient(clientKind);
86
87
  const observedCategories = [
87
88
  ["user messages", client.capabilities.userMessages],
@@ -95,6 +96,10 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
95
96
  ["subagents", client.capabilities.subagents],
96
97
  ["compaction checkpoints", client.capabilities.compactionCheckpoints],
97
98
  ["context-use evidence", client.capabilities.contextUseEvidence],
99
+ ["model token usage (counts, model name, and provider request id for each reply; never prompt or reply text)", client.capabilities.tokenUsage],
100
+ ["thinking blocks", client.capabilities.thinking],
101
+ ["tool outcomes (durations, failure flags, byte sizes; file paths only as salted hashes)", client.capabilities.toolOutcomes],
102
+ ["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
103
  ];
99
104
  const supported = observedCategories.filter(([, value]) => value === true).map(([name]) => name);
100
105
  const unsupported = observedCategories.filter(([, value]) => value !== true).map(([name]) => name);
@@ -102,11 +107,14 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
102
107
  `Halofy ${client.label} lifecycle connection`,
103
108
  `Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
104
109
  `Server: ${new URL(serverUrl).origin}`,
110
+ // The server named by --server identifies the organization this claim
111
+ // binds to, so a spoofed command is recognizable before CONNECT.
112
+ `Organization: ${organization || "unverified (the server did not identify this claim's organization)"}`,
105
113
  `${client.label}: ${clientVersion}`,
106
114
  `Project: ${projectRoot}`,
107
115
  "",
108
116
  "This single installation replaces an existing Halofy bearer MCP entry where supported and enables:",
109
- "- governed memory tools and recall",
117
+ "- governed memory tools the agent invokes explicitly (no automatic recall is injected into sessions),",
110
118
  `- conversation events exposed by ${client.label}'s reviewed hooks,`,
111
119
  "- explicitly supported tool, subagent, compaction, and close evidence,",
112
120
  "- encrypted local retry queue and governed retained conversations, and",
@@ -148,7 +156,16 @@ export async function runInstaller(argv, {
148
156
  } = {}) {
149
157
  const input = parseInstallerArgs(argv);
150
158
  const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
151
- output.write(`${disclosureText({ ...input, clientVersion })}\n`);
159
+ const claimDisclosure = await fetchClaimDisclosure({
160
+ serverUrl: input.serverUrl,
161
+ claim: input.claim,
162
+ fetchImpl,
163
+ });
164
+ output.write(`${disclosureText({
165
+ ...input,
166
+ clientVersion,
167
+ organization: claimDisclosure?.organization ?? null,
168
+ })}\n`);
152
169
  await confirm();
153
170
 
154
171
  const installed = await installLocalConnection({
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)
@@ -238,6 +256,37 @@ function unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourc
238
256
  });
239
257
  }
240
258
 
259
+ /**
260
+ * Hook-driven recall injection is disabled for now: the runtime captures
261
+ * conversations and serves the agent-invoked MCP memory tools, but does not
262
+ * push recalled memory into host sessions on SessionStart/UserPromptSubmit.
263
+ * Flip to true to restore injection — the bounded block formats below and the
264
+ * per-host output shapes in the hook modules stay in place and tested.
265
+ */
266
+ export const RECALL_INJECTION_ENABLED = false;
267
+
268
+ export const MAX_RECALL_BLOCKS = 12;
269
+ export const MAX_RECALL_BLOCK_CHARS = 8_000;
270
+
271
+ /**
272
+ * Ranked recall blocks bounded on the client before they are injected into a
273
+ * host session. The server's token budget is the primary bound; these caps are
274
+ * defense in depth so poisoned or oversized memory content cannot flood the
275
+ * host context through a hook response.
276
+ */
277
+ export function rankedRecallBlocks(result) {
278
+ const blocks = Array.isArray(result?.blocks) ? result.blocks : Array.isArray(result) ? result : [];
279
+ return blocks
280
+ .filter((block) => block && typeof block.recallRef === "string" &&
281
+ typeof block.content === "string" && block.content.trim())
282
+ .slice(0, MAX_RECALL_BLOCKS)
283
+ .map((block, rank) => ({
284
+ rank: rank + 1,
285
+ recallRef: block.recallRef.slice(0, 256),
286
+ content: block.content.trim().slice(0, MAX_RECALL_BLOCK_CHARS),
287
+ }));
288
+ }
289
+
241
290
  export function deriveSessionHash({ installationId, clientKind, hostSessionId }) {
242
291
  if (!installationId || !clientKind || !hostSessionId) throw new Error("session identity is incomplete");
243
292
  return digest(`halofy-session-v1\0${installationId}\0${clientKind}\0${hostSessionId}`);
@@ -251,21 +300,182 @@ export function stripInjectedContext(value) {
251
300
  return String(value);
252
301
  }
253
302
 
303
+ function usageInt(value) {
304
+ return Number.isSafeInteger(value) && value >= 0 && value < 2 ** 31 ? value : null;
305
+ }
306
+
307
+ function usageLabel(value) {
308
+ return typeof value === "string" && /^[\x20-\x7e]{1,128}$/.test(value) ? value : null;
309
+ }
310
+
311
+ const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
312
+
313
+ /**
314
+ * One host-reported usage record per assistant message (PRD §8.1). Claude
315
+ * writes one JSONL line per content block of the same message, each repeating
316
+ * `message.usage`; the stable per-message eventKey plus keep-last suffix
317
+ * dedupe make that one record, never a sum of repeats (the ccusage bug).
318
+ * Counts come from the top-level usage totals only — `iterations[]` is the
319
+ * per-iteration breakdown of the same totals and is ignored.
320
+ */
321
+ function usageEventFromEntry(entry, { nativeId, occurredAt, sourceEndOffset }) {
322
+ const message = entry.message;
323
+ const usage = message.usage;
324
+ const model = typeof message.model === "string" && USAGE_MODEL_PATTERN.test(message.model)
325
+ ? message.model : null;
326
+ const usable = model !== null && usage !== null && typeof usage === "object" &&
327
+ (usageInt(usage.input_tokens) !== null || usageInt(usage.output_tokens) !== null);
328
+ if (!usable) {
329
+ return normalizedEvent({
330
+ eventKey: `claude:usage-gap:${nativeId}`,
331
+ type: "usage",
332
+ occurredAt,
333
+ payload: {
334
+ role: "assistant", contentFormat: "json", captureStatus: "complete",
335
+ gap: true, reason: "usage_unavailable",
336
+ },
337
+ sourceEndOffset,
338
+ });
339
+ }
340
+ const cacheCreation = usage.cache_creation && typeof usage.cache_creation === "object"
341
+ ? usage.cache_creation : {};
342
+ const details = usage.output_tokens_details && typeof usage.output_tokens_details === "object"
343
+ ? usage.output_tokens_details : {};
344
+ const messageId = usageLabel(message.id);
345
+ const payload = {
346
+ role: "assistant",
347
+ contentFormat: "json",
348
+ captureStatus: "complete",
349
+ provider: "anthropic",
350
+ model,
351
+ providerRequestId: usageLabel(entry.requestId),
352
+ messageId,
353
+ stopReason: usageLabel(message.stop_reason),
354
+ serviceTier: usageLabel(usage.service_tier),
355
+ effort: usageLabel(entry.effort),
356
+ sidechain: entry.isSidechain === true,
357
+ latencyMs: null,
358
+ inputTokens: usageInt(usage.input_tokens),
359
+ outputTokens: usageInt(usage.output_tokens),
360
+ cacheReadTokens: usageInt(usage.cache_read_input_tokens),
361
+ cacheWriteTokens: usageInt(usage.cache_creation_input_tokens),
362
+ cacheWrite1hTokens: usageInt(cacheCreation.ephemeral_1h_input_tokens),
363
+ cacheWrite5mTokens: usageInt(cacheCreation.ephemeral_5m_input_tokens),
364
+ reasoningTokens: usageInt(details.thinking_tokens),
365
+ };
366
+ return normalizedEvent({
367
+ eventKey: `claude:usage:${messageId || nativeId}`,
368
+ type: "usage",
369
+ occurredAt,
370
+ payload,
371
+ sourceEndOffset,
372
+ });
373
+ }
374
+
375
+ /**
376
+ * Content-free outcome scalars for one tool_result block, joining the
377
+ * structured `toolUseResult` the host attached to the record and the tool_use
378
+ * timestamp observed earlier in the same suffix window.
379
+ */
380
+ function toolOutcomeFromEntry(entry, block, evidence) {
381
+ const raw = entry?.toolUseResult !== null && typeof entry?.toolUseResult === "object" &&
382
+ !Array.isArray(entry.toolUseResult) ? entry.toolUseResult : null;
383
+ const failed = block.is_error === true || raw?.status === "failed" || raw?.is_error === true;
384
+ const calledMs = evidence.toolUseTimestamps instanceof Map
385
+ ? evidence.toolUseTimestamps.get(String(block.tool_use_id ?? "")) : undefined;
386
+ const resultMs = Date.parse(String(entry?.timestamp ?? ""));
387
+ const durationMs = Number.isFinite(calledMs) && Number.isFinite(resultMs) && resultMs >= calledMs
388
+ ? Math.min(resultMs - calledMs, 86_400_000) : null;
389
+ const outputBytes = bodyBytes(stableClone(block.content ?? null), "json").length;
390
+ const resolvedModel = typeof raw?.resolvedModel === "string" &&
391
+ USAGE_MODEL_PATTERN.test(raw.resolvedModel) ? raw.resolvedModel : null;
392
+ return {
393
+ failed: Boolean(failed),
394
+ interrupted: raw?.interrupted === true,
395
+ ...(typeof raw?.status === "string" ? { status: reasonCode(raw.status) } : {}),
396
+ ...(durationMs === null ? {} : { durationMs }),
397
+ outputBytes,
398
+ ...(resolvedModel === null ? {} : { resolvedModel }),
399
+ };
400
+ }
401
+
402
+ function thinkingEvent({ nativeId, index, text, occurredAt, sourceEndOffset }) {
403
+ const payload = boundedCompletePayload({
404
+ role: "assistant",
405
+ contentFormat: "utf8",
406
+ captureStatus: "complete",
407
+ text,
408
+ }, { role: "assistant", body: text, format: "utf8" });
409
+ return normalizedEvent({
410
+ eventKey: `claude:${nativeId}:thinking:${index}`,
411
+ type: "message",
412
+ occurredAt,
413
+ payload,
414
+ sourceEndOffset,
415
+ part: "thinking",
416
+ });
417
+ }
418
+
419
+ const MAX_INLINE_ATTACHMENT_BYTES = 8 * 1024;
420
+
254
421
  /**
255
422
  * Normalize one current Claude transcript record into zero or more archive
256
423
  * events. Text is never trimmed or prefix-truncated. Unsupported/oversized
257
424
  * bodies become explicit digest-only events, so advancing the byte cursor
258
- * cannot silently turn a capture gap into success.
425
+ * cannot silently turn a capture gap into success. Opaque host tokens
426
+ * (`atis-latch`) are never read at all.
259
427
  */
260
428
  export function normalizeClaudeTranscriptEntry(entry, evidence) {
429
+ const recordType = entry?.type;
430
+ const sourceEndOffset = evidence.endOffset;
431
+ if (recordType === "queue-operation") {
432
+ // A user message queued mid-turn. Only the enqueue marks a queued message;
433
+ // the content itself reaches the archive through the transcript when the
434
+ // host delivers it as a real user turn.
435
+ if (entry.operation !== "enqueue") return [];
436
+ const occurredAt = entry.timestamp || evidence.occurredAt;
437
+ return [normalizedEvent({
438
+ eventKey: `claude:queued:${digest(`${entry.timestamp ?? ""}\0${evidence.byteOffset}`)}`,
439
+ type: "checkpoint",
440
+ occurredAt,
441
+ payload: { contentFormat: "json", captureStatus: "complete", kind: "queued_message" },
442
+ sourceEndOffset,
443
+ })];
444
+ }
445
+ if (recordType === "attachment") {
446
+ const occurredAt = entry.timestamp || evidence.occurredAt;
447
+ const attachment = entry.attachment !== null && typeof entry.attachment === "object"
448
+ ? entry.attachment : {};
449
+ const attachmentType = boundedIdentifier(attachment.type || "unknown", 100);
450
+ const stableBody = stableClone(attachment);
451
+ const bytes = bodyBytes(stableBody, "json");
452
+ const payload = bytes.length <= MAX_INLINE_ATTACHMENT_BYTES
453
+ ? boundedCompletePayload({
454
+ contentFormat: "json", captureStatus: "complete",
455
+ kind: "attachment", attachmentType, body: stableBody,
456
+ }, { body: stableBody, format: "json", extra: { kind: "attachment", attachmentType } })
457
+ : digestOnlyPayload({
458
+ body: bytes, status: "truncated", reason: "attachment_body_too_large",
459
+ extra: { kind: "attachment", attachmentType },
460
+ });
461
+ return [normalizedEvent({
462
+ eventKey: `claude:attachment:${entry.uuid || `offset-${evidence.byteOffset}`}`,
463
+ type: "checkpoint",
464
+ occurredAt,
465
+ payload,
466
+ sourceEndOffset,
467
+ })];
468
+ }
261
469
  const message = entry?.message;
262
470
  if (!message || !["user", "assistant"].includes(message.role)) return [];
263
471
  const role = message.role;
264
472
  const occurredAt = entry.timestamp || evidence.occurredAt;
265
- const sourceEndOffset = evidence.endOffset;
266
473
  const nativeId = nativeEntryId(entry, evidence);
474
+ const usageEvents = role === "assistant"
475
+ ? [usageEventFromEntry(entry, { nativeId, occurredAt, sourceEndOffset })]
476
+ : [];
267
477
  if (typeof message.content === "string") {
268
- return [textEvent({ nativeId, index: 0, role, text: message.content, occurredAt, sourceEndOffset })];
478
+ return [textEvent({ nativeId, index: 0, role, text: message.content, occurredAt, sourceEndOffset }), ...usageEvents];
269
479
  }
270
480
  if (!Array.isArray(message.content)) {
271
481
  return [unsupportedBlockEvent({
@@ -275,20 +485,28 @@ export function normalizeClaudeTranscriptEntry(entry, evidence) {
275
485
  block: message.content,
276
486
  occurredAt,
277
487
  sourceEndOffset,
278
- })];
488
+ }), ...usageEvents];
279
489
  }
280
490
  if (message.content.length === 0) {
281
- return [textEvent({ nativeId, index: 0, role, text: "", occurredAt, sourceEndOffset })];
491
+ return [textEvent({ nativeId, index: 0, role, text: "", occurredAt, sourceEndOffset }), ...usageEvents];
282
492
  }
283
- return message.content.map((block, index) => {
493
+ return [...message.content.flatMap((block, index) => {
284
494
  if (!block || typeof block !== "object") {
285
- return unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset });
495
+ return [unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset })];
286
496
  }
287
497
  if (block.type === "text" && typeof block.text === "string") {
288
- return textEvent({ nativeId, index, role, text: block.text, occurredAt, sourceEndOffset });
498
+ return [textEvent({ nativeId, index, role, text: block.text, occurredAt, sourceEndOffset })];
499
+ }
500
+ if (block.type === "thinking" && typeof block.thinking === "string") {
501
+ // Reasoning content, captured as a marked message part so a session
502
+ // with thinking no longer reads as partial. Empty blocks (signature
503
+ // only) carry no content and produce no event.
504
+ return block.thinking.length > 0
505
+ ? [thinkingEvent({ nativeId, index, text: block.thinking, occurredAt, sourceEndOffset })]
506
+ : [];
289
507
  }
290
508
  if (block.type === "tool_use") {
291
- return toolEvent({
509
+ return [toolEvent({
292
510
  nativeId,
293
511
  index,
294
512
  type: "tool_call",
@@ -297,10 +515,10 @@ export function normalizeClaudeTranscriptEntry(entry, evidence) {
297
515
  body: block.input,
298
516
  occurredAt,
299
517
  sourceEndOffset,
300
- });
518
+ })];
301
519
  }
302
520
  if (block.type === "tool_result") {
303
- return toolEvent({
521
+ return [toolEvent({
304
522
  nativeId,
305
523
  index,
306
524
  type: "tool_result",
@@ -310,13 +528,15 @@ export function normalizeClaudeTranscriptEntry(entry, evidence) {
310
528
  occurredAt,
311
529
  sourceEndOffset,
312
530
  failed: block.is_error,
313
- });
531
+ hostResult: entry?.toolUseResult,
532
+ outcome: toolOutcomeFromEntry(entry, block, evidence),
533
+ })];
314
534
  }
315
535
  if (["image", "document", "artifact", "file"].includes(block.type)) {
316
- return artifactEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset });
536
+ return [artifactEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset })];
317
537
  }
318
- return unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset });
319
- });
538
+ return [unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset })];
539
+ }), ...usageEvents];
320
540
  }
321
541
 
322
542
  export function normalizeMalformedClaudeTranscriptLine(rawBytes, evidence) {
@@ -418,11 +638,39 @@ export function normalizeHostMessageEvent({
418
638
  };
419
639
  }
420
640
 
641
+ function collectClaudeMetadata(metadata, entry) {
642
+ const bounded = (value, max) =>
643
+ typeof value === "string" && value.length > 0 && value.length <= max &&
644
+ !/[\u0000-\u001f\u007f]/.test(value) ? value : undefined;
645
+ const hostVersion = bounded(entry.version, 64);
646
+ if (hostVersion) metadata.hostVersion = hostVersion;
647
+ const entrypoint = bounded(entry.entrypoint, 64);
648
+ if (entrypoint) metadata.entrypoint = entrypoint;
649
+ const userType = bounded(entry.userType, 64);
650
+ if (userType) metadata.userType = userType;
651
+ const effort = bounded(entry.effort, 64);
652
+ if (effort) metadata.effort = effort;
653
+ const permissionMode = entry.type === "permission-mode"
654
+ ? bounded(entry.permissionMode, 64)
655
+ : bounded(entry.permissionMode, 64);
656
+ if (permissionMode) metadata.permissionMode = permissionMode;
657
+ if (entry.type === "ai-title") {
658
+ const title = bounded(entry.aiTitle ?? entry.title, 512);
659
+ if (title) metadata.title = title;
660
+ }
661
+ const gitBranch = bounded(entry.gitBranch, 512);
662
+ if (gitBranch) metadata.gitBranch = gitBranch;
663
+ const cwd = bounded(entry.cwd, 2048);
664
+ if (cwd) metadata.cwd = cwd;
665
+ }
666
+
421
667
  export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
422
668
  const bytes = await readFile(path);
423
669
  const start = Number.isSafeInteger(cursor?.byteOffset) && cursor.byteOffset <= bytes.length ? cursor.byteOffset : 0;
424
670
  const suffix = bytes.subarray(start);
425
671
  const events = [];
672
+ const metadata = {};
673
+ const toolUseTimestamps = new Map();
426
674
  let position = 0;
427
675
  while (position < suffix.length) {
428
676
  const newline = suffix.indexOf(0x0a, position);
@@ -439,14 +687,95 @@ export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
439
687
  endOffset,
440
688
  rawBytes,
441
689
  occurredAt: new Date().toISOString(),
690
+ toolUseTimestamps,
442
691
  };
443
692
  try {
444
- events.push(...normalizeClaudeTranscriptEntry(JSON.parse(raw), evidence));
693
+ const entry = JSON.parse(raw);
694
+ if (entry !== null && typeof entry === "object") {
695
+ collectClaudeMetadata(metadata, entry);
696
+ if (entry.type === "assistant" && Array.isArray(entry.message?.content)) {
697
+ const calledMs = Date.parse(String(entry.timestamp ?? ""));
698
+ for (const block of entry.message.content) {
699
+ if (block && typeof block === "object" && block.type === "tool_use" &&
700
+ typeof block.id === "string" && Number.isFinite(calledMs)) {
701
+ toolUseTimestamps.set(block.id, calledMs);
702
+ }
703
+ }
704
+ }
705
+ }
706
+ events.push(...normalizeClaudeTranscriptEntry(entry, evidence));
445
707
  } catch {
446
708
  events.push(normalizeMalformedClaudeTranscriptLine(rawBytes, evidence));
447
709
  }
448
710
  }
449
- return { events, observedEndOffset: start + position };
711
+ // Claude repeats the same usage record on every JSONL line of a multi-block
712
+ // message. One usage event per message.id, keep-last: later lines carry the
713
+ // completed totals. A gap event is withdrawn the moment any line of the
714
+ // same message produced a real usage record.
715
+ const usageByKey = new Map();
716
+ for (const event of events) {
717
+ if (event.type === "usage") usageByKey.set(event.eventKey, event);
718
+ }
719
+ const usageKeysWithData = new Set(
720
+ [...usageByKey.keys()].filter((key) => key.startsWith("claude:usage:")),
721
+ );
722
+ const emittedUsageKeys = new Set();
723
+ const deduped = [];
724
+ for (let index = events.length - 1; index >= 0; index -= 1) {
725
+ const event = events[index];
726
+ if (event.type === "usage") {
727
+ if (emittedUsageKeys.has(event.eventKey)) continue;
728
+ if (event.eventKey.startsWith("claude:usage-gap:")) {
729
+ const nativeId = event.eventKey.slice("claude:usage-gap:".length);
730
+ if (usageKeysWithData.has(`claude:usage:${nativeId}`)) continue;
731
+ }
732
+ emittedUsageKeys.add(event.eventKey);
733
+ }
734
+ deduped.unshift(event);
735
+ }
736
+ return { events: deduped, observedEndOffset: start + position, metadata };
737
+ }
738
+
739
+ export function buildClaudeMetadataPayload(metadata, { installationId, deviceContext }) {
740
+ const payload = {};
741
+ for (const field of ["hostVersion", "entrypoint", "userType", "permissionMode", "effort", "title"]) {
742
+ if (typeof metadata[field] === "string" && metadata[field].length > 0) {
743
+ payload[field] = metadata[field];
744
+ }
745
+ }
746
+ if (typeof metadata.cwd === "string" && metadata.cwd.length > 0) {
747
+ // The path itself leaves the device only under an explicit org policy
748
+ // (deviceContext=full); the salted hash and basename always travel so
749
+ // spend can still be grouped per repository.
750
+ payload.cwdHash = digest(`halofy-cwd-v1\0${installationId}\0${metadata.cwd}`);
751
+ const segments = metadata.cwd.split(/[\\/]/).filter((segment) => segment.length > 0);
752
+ const base = segments.length > 0 ? segments[segments.length - 1].slice(0, 256) : "";
753
+ if (base) payload.cwdBasename = base;
754
+ if (deviceContext === "full") payload.cwdPath = metadata.cwd;
755
+ }
756
+ if (typeof metadata.gitBranch === "string" && metadata.gitBranch.length > 0 &&
757
+ deviceContext === "full") {
758
+ payload.gitBranch = metadata.gitBranch;
759
+ }
760
+ if (typeof metadata.startSource === "string" && metadata.startSource.length > 0) {
761
+ payload.startSource = metadata.startSource;
762
+ }
763
+ if (typeof metadata.closeReason === "string" && metadata.closeReason.length > 0) {
764
+ payload.closeReason = metadata.closeReason;
765
+ }
766
+ if (typeof metadata.subagentType === "string" && metadata.subagentType.length > 0) {
767
+ payload.subagentType = metadata.subagentType;
768
+ }
769
+ return payload;
770
+ }
771
+
772
+ export function claudeMetadataEvent(payload) {
773
+ return normalizedEvent({
774
+ eventKey: `claude:metadata:${digest(stableJson(payload))}`,
775
+ type: "metadata",
776
+ occurredAt: new Date().toISOString(),
777
+ payload: { contentFormat: "json", captureStatus: "complete", ...payload },
778
+ });
450
779
  }
451
780
 
452
781
  export class CursorStore {
@@ -493,4 +822,31 @@ export class CursorStore {
493
822
  return current;
494
823
  });
495
824
  }
825
+
826
+ // Usage-gap diagnostics (PRD §8.3): a content-free count of host records
827
+ // the adapter could not read usage from, reported on the next heartbeat as
828
+ // a delta and cleared only after successful delivery.
829
+ async peekUsageGaps() {
830
+ const state = await readJson(this.path, { version: 1, sessions: {} });
831
+ const count = Number(state.usageGaps ?? 0);
832
+ return Number.isSafeInteger(count) && count > 0 ? count : 0;
833
+ }
834
+
835
+ async bumpUsageGaps(count) {
836
+ if (!Number.isSafeInteger(count) || count <= 0) return;
837
+ await withFileLock(this.lockPath, async () => {
838
+ const state = await readJson(this.path, { version: 1, sessions: {} });
839
+ state.usageGaps = Math.max(0, Number(state.usageGaps ?? 0) || 0) + count;
840
+ await writePrivateFile(this.path, `${JSON.stringify(state)}\n`);
841
+ });
842
+ }
843
+
844
+ async clearUsageGaps(delivered) {
845
+ if (!Number.isSafeInteger(delivered) || delivered <= 0) return;
846
+ await withFileLock(this.lockPath, async () => {
847
+ const state = await readJson(this.path, { version: 1, sessions: {} });
848
+ state.usageGaps = Math.max(0, (Number(state.usageGaps ?? 0) || 0) - delivered);
849
+ await writePrivateFile(this.path, `${JSON.stringify(state)}\n`);
850
+ });
851
+ }
496
852
  }
package/src/storage.mjs CHANGED
@@ -31,6 +31,32 @@ export async function writePrivateFile(path, value) {
31
31
  if (platform() !== "win32") await chmod(path, 0o600);
32
32
  }
33
33
 
34
+ /**
35
+ * Atomic write for a host application's config file. The parent directory is
36
+ * created if missing, but the permissions of an existing directory are never
37
+ * changed — a project root, $HOME, or ~/.cursor is not Halofy's to tighten.
38
+ * An existing file keeps its mode; a new file starts private (0600).
39
+ */
40
+ export async function writeHostConfigFile(path, value) {
41
+ await mkdir(dirname(path), { recursive: true });
42
+ let mode = 0o600;
43
+ try {
44
+ mode = (await stat(path)).mode & 0o777;
45
+ } catch (error) {
46
+ if (error?.code !== "ENOENT") throw error;
47
+ }
48
+ const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
49
+ const handle = await open(temporary, "wx", 0o600);
50
+ try {
51
+ await handle.writeFile(value);
52
+ await handle.sync();
53
+ } finally {
54
+ await handle.close();
55
+ }
56
+ await rename(temporary, path);
57
+ if (platform() !== "win32") await chmod(path, mode);
58
+ }
59
+
34
60
  export async function withFileLock(path, action, { timeoutMs = 3_000, staleMs = 30_000 } = {}) {
35
61
  await ensurePrivateDirectory(dirname(path));
36
62
  const started = Date.now();
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.1";
3
+ export const RUNTIME_VERSION = "0.5.1";
4
+ export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-31.1";