@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 +11 -2
- package/package.json +2 -2
- package/src/claude-config.mjs +4 -4
- package/src/claude-hook.mjs +18 -23
- package/src/host-config.mjs +14 -14
- package/src/host-hook.mjs +18 -18
- package/src/install.mjs +52 -6
- package/src/installer-cli.mjs +16 -3
- package/src/session.mjs +31 -0
- package/src/storage.mjs +26 -0
- package/src/version.mjs +3 -3
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.
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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"
|
package/src/claude-config.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
-
import { readJson,
|
|
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
|
|
127
|
+
await writeHostConfigFile(claudeConfigPath, `${JSON.stringify(claude, null, 2)}\n`);
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
await
|
|
132
|
-
await
|
|
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,
|
package/src/claude-hook.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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);
|
package/src/host-config.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
96
|
-
await
|
|
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
|
|
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
|
|
201
|
-
await
|
|
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
|
|
237
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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" &&
|
|
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
|
|
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);
|
package/src/installer-cli.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
3
|
-
export const RUNTIME_VERSION = "0.5.
|
|
4
|
-
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-
|
|
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";
|