@halofy/agent-connect 0.5.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.5.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.5.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: {
@@ -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);
@@ -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],
@@ -106,11 +107,14 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
106
107
  `Halofy ${client.label} lifecycle connection`,
107
108
  `Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
108
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)"}`,
109
113
  `${client.label}: ${clientVersion}`,
110
114
  `Project: ${projectRoot}`,
111
115
  "",
112
116
  "This single installation replaces an existing Halofy bearer MCP entry where supported and enables:",
113
- "- governed memory tools and recall",
117
+ "- governed memory tools the agent invokes explicitly (no automatic recall is injected into sessions),",
114
118
  `- conversation events exposed by ${client.label}'s reviewed hooks,`,
115
119
  "- explicitly supported tool, subagent, compaction, and close evidence,",
116
120
  "- encrypted local retry queue and governed retained conversations, and",
@@ -152,7 +156,16 @@ export async function runInstaller(argv, {
152
156
  } = {}) {
153
157
  const input = parseInstallerArgs(argv);
154
158
  const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
155
- 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`);
156
169
  await confirm();
157
170
 
158
171
  const installed = await installLocalConnection({
package/src/session.mjs CHANGED
@@ -256,6 +256,37 @@ function unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourc
256
256
  });
257
257
  }
258
258
 
259
+ /**
260
+ * Hook-driven recall injection is disabled for now: the runtime captures
261
+ * conversations and serves the agent-invoked MCP memory tools, but does not
262
+ * push recalled memory into host sessions on SessionStart/UserPromptSubmit.
263
+ * Flip to true to restore injection — the bounded block formats below and the
264
+ * per-host output shapes in the hook modules stay in place and tested.
265
+ */
266
+ export const RECALL_INJECTION_ENABLED = false;
267
+
268
+ export const MAX_RECALL_BLOCKS = 12;
269
+ export const MAX_RECALL_BLOCK_CHARS = 8_000;
270
+
271
+ /**
272
+ * Ranked recall blocks bounded on the client before they are injected into a
273
+ * host session. The server's token budget is the primary bound; these caps are
274
+ * defense in depth so poisoned or oversized memory content cannot flood the
275
+ * host context through a hook response.
276
+ */
277
+ export function rankedRecallBlocks(result) {
278
+ const blocks = Array.isArray(result?.blocks) ? result.blocks : Array.isArray(result) ? result : [];
279
+ return blocks
280
+ .filter((block) => block && typeof block.recallRef === "string" &&
281
+ typeof block.content === "string" && block.content.trim())
282
+ .slice(0, MAX_RECALL_BLOCKS)
283
+ .map((block, rank) => ({
284
+ rank: rank + 1,
285
+ recallRef: block.recallRef.slice(0, 256),
286
+ content: block.content.trim().slice(0, MAX_RECALL_BLOCK_CHARS),
287
+ }));
288
+ }
289
+
259
290
  export function deriveSessionHash({ installationId, clientKind, hostSessionId }) {
260
291
  if (!installationId || !clientKind || !hostSessionId) throw new Error("session identity is incomplete");
261
292
  return digest(`halofy-session-v1\0${installationId}\0${clientKind}\0${hostSessionId}`);
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.5.0";
3
- export const RUNTIME_VERSION = "0.5.0";
4
- export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-29.2";
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";