@halofy/agent-connect 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,35 +14,57 @@ agent connections:
14
14
  - signed heartbeat, open, append, recall, context-use, commit, status, and
15
15
  close transport methods;
16
16
  - a stdio-to-signed-Streamable-HTTP MCP proof proxy; and
17
- - the local claim consumer used by the Claude Code adapter.
17
+ - one local claim consumer and proof runtime shared by every packaged adapter.
18
18
 
19
- The one supported Claude Code setup path is the lifecycle installer:
19
+ There is one setup path for every packaged client:
20
20
 
21
21
  ```bash
22
- npx --yes @halofy/agent-connect@0.1.0 install claude-code \
22
+ npx --yes @halofy/agent-connect@0.3.0 install <client-kind> \
23
23
  --server https://app.halofy.ai \
24
24
  --claim '<one-time-claim>'
25
25
  ```
26
26
 
27
+ The server, not the browser, selects the exact published package version and
28
+ permits only these reviewed client kinds:
29
+
30
+ | Client kind | Adapter coverage | Explicit current gaps |
31
+ |---|---|---|
32
+ | `claude-code` | complete for declared text/tool lifecycle hooks | image bodies, artifact bodies, context-use evidence |
33
+ | `cursor` | complete for declared text/tool lifecycle hooks | image bodies, artifact bodies, context-use evidence |
34
+ | `gemini-cli` | complete for declared text/tool lifecycle hooks | image bodies, artifact bodies, subagents, context-use evidence |
35
+ | `kimi-cli` | partial | assistant responses, image and artifact bodies, context-use evidence |
36
+ | `codex` | partial | assistant responses, tool failures, session end, binary bodies |
37
+ | `vscode` | partial | assistant responses, binary bodies, context-use evidence |
38
+ | `cline` | partial | assistant responses, tool failures, subagents, compaction, session end, binary bodies |
39
+
40
+ Other catalog entries remain `Not supported yet` or `Not observed`; the
41
+ installer refuses them before claim consumption. A knowledge connector,
42
+ OAuth-only MCP connection, legacy bearer, or manual MCP URL is never upgraded
43
+ to conversation-capture evidence by its name.
44
+
27
45
  Run the server-returned command in the operating-system terminal from the
28
- project where Claude Code runs, never in agent chat. The installer displays
46
+ computer where the selected client runs, never in agent chat. The installer displays
29
47
  the capture disclosure, requires the recipient to type `CONNECT`, generates
30
48
  the Ed25519 private key locally, consumes the one-use claim in a JSON body,
31
49
  copies the reviewed runtime out of the transient npx cache, and installs the
32
50
  signed MCP proxy and lifecycle hooks together. It replaces an existing Halofy
33
- bearer MCP entry and legacy Halofy hooks in place while preserving unrelated
34
- Claude settings. It never runs both Halofy capture paths for one host session.
51
+ bearer MCP entry where the host exposes one and installs its reviewed hooks
52
+ while preserving unrelated settings. It never runs both Halofy capture paths
53
+ for one host session.
35
54
 
36
55
  The current storage backend is the explicitly reported mode-`0600` file
37
56
  fallback (or the closest Windows ACL), not hardware-backed storage. No bearer
38
- or claim is stored in the runtime queue or Claude configuration.
57
+ or claim is stored in the runtime queue or host configuration.
39
58
 
40
59
  The request canonicalization in `src/crypto.mjs` follows AL4's strict raw path
41
60
  and query rules. Publication is fail-closed until the package tarball, current
42
61
  Claude fixtures, cross-implementation signature fixtures, and deployed server
43
- protocol have all passed for the exact version.
62
+ protocol have all passed for the exact version. Server versions configured
63
+ with the Claude-only `0.1.x` artifact keep every additional client fenced as
64
+ `Not supported yet`; `0.2.x` keeps Kimi fenced until its `0.3.x` adapter.
44
65
 
45
- The Claude adapter advertises archive protocol v1 explicitly. Its current
66
+ Every packaged adapter advertises archive protocol v1 explicitly. The Claude
67
+ adapter's current
46
68
  body capabilities are user/assistant text, structured tool inputs/results and
47
69
  failures, and host artifact references. Inline images and artifact bodies are
48
70
  represented by digest-only placeholders and make coverage partial;
@@ -4,6 +4,7 @@ import { loadActiveConnection } from "../src/active.mjs";
4
4
  import { runStdioMcpProxy } from "../src/mcp-proxy.mjs";
5
5
  import { BoundedEncryptedQueue } from "../src/queue.mjs";
6
6
  import { runClaudeLifecycleHook } from "../src/claude-hook.mjs";
7
+ import { HOST_HOOK_EVENTS, runHostLifecycleHook } from "../src/host-hook.mjs";
7
8
  import { join } from "node:path";
8
9
 
9
10
  function option(name) {
@@ -13,12 +14,12 @@ function option(name) {
13
14
 
14
15
  const command = process.argv[2];
15
16
  const hookEvent = command === "hook" ? process.argv[3] : null;
16
- const hookEvents = new Set([
17
+ const claudeHookEvents = new Set([
17
18
  "SessionStart", "UserPromptSubmit", "Stop", "PreCompact", "SessionEnd",
18
19
  "SubagentStart", "SubagentStop", "PostToolUse", "PostToolUseFailure", "PostToolBatch",
19
20
  ]);
20
21
  if (!["mcp", "diagnostics", "hook"].includes(command) ||
21
- (command === "hook" && !hookEvents.has(hookEvent))) {
22
+ (command === "hook" && !claudeHookEvents.has(hookEvent) && !HOST_HOOK_EVENTS.has(hookEvent))) {
22
23
  process.stderr.write("Usage: halofy-agent <mcp|diagnostics|hook EVENT> [--connection <installation-id>]\n");
23
24
  process.exitCode = 2;
24
25
  } else {
@@ -31,7 +32,8 @@ if (!["mcp", "diagnostics", "hook"].includes(command) ||
31
32
  if (command === "mcp") {
32
33
  await runStdioMcpProxy(connection);
33
34
  } else if (command === "hook") {
34
- await runClaudeLifecycleHook(connection, hookEvent);
35
+ if (connection.clientKind === "claude-code") await runClaudeLifecycleHook(connection, hookEvent);
36
+ else await runHostLifecycleHook(connection, hookEvent);
35
37
  } else {
36
38
  const queue = new BoundedEncryptedQueue(join(defaultRuntimeDirectory(), connection.installationId));
37
39
  process.stdout.write(`${JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halofy/agent-connect",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Signed Halofy lifecycle installer and runtime for supported agents",
6
6
  "bin": {
@@ -8,12 +8,23 @@ function id(value) {
8
8
  return createHash("sha256").update(String(value)).digest("hex");
9
9
  }
10
10
 
11
- export function readHookInput(stream = process.stdin) {
11
+ export function readHookInput(stream = process.stdin, maxBytes = 1024 * 1024) {
12
12
  return new Promise((resolve) => {
13
13
  let raw = "";
14
+ let bytes = 0;
15
+ let oversized = false;
14
16
  stream.setEncoding("utf8");
15
- stream.on("data", (chunk) => { raw += chunk; });
17
+ stream.on("data", (chunk) => {
18
+ bytes += Buffer.byteLength(chunk, "utf8");
19
+ if (bytes > maxBytes) {
20
+ oversized = true;
21
+ raw = "";
22
+ return;
23
+ }
24
+ if (!oversized) raw += chunk;
25
+ });
16
26
  stream.on("end", () => {
27
+ if (oversized) return resolve({});
17
28
  try { resolve(JSON.parse(raw)); } catch { resolve({}); }
18
29
  });
19
30
  stream.on("error", () => resolve({}));
@@ -57,10 +68,15 @@ async function catchUp(runtime, input, session = hostSession(input)) {
57
68
  }
58
69
 
59
70
  async function within(milliseconds, action) {
60
- return Promise.race([
61
- action(),
62
- new Promise((resolve) => setTimeout(() => resolve(null), milliseconds)),
63
- ]);
71
+ let timer;
72
+ try {
73
+ return await Promise.race([
74
+ action(),
75
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), milliseconds); }),
76
+ ]);
77
+ } finally {
78
+ if (timer) clearTimeout(timer);
79
+ }
64
80
  }
65
81
 
66
82
  /** Runs one reviewed Claude hook. Brownouts never block the host session. */
@@ -0,0 +1,144 @@
1
+ export const CLIENT_KINDS = Object.freeze([
2
+ "claude-code",
3
+ "codex",
4
+ "cursor",
5
+ "vscode",
6
+ "cline",
7
+ "gemini-cli",
8
+ "kimi-cli",
9
+ ]);
10
+
11
+ const BASE_CAPABILITIES = Object.freeze({
12
+ sessionStart: false,
13
+ userPromptSubmit: false,
14
+ stop: false,
15
+ preCompact: false,
16
+ sessionEnd: false,
17
+ subagentStart: false,
18
+ subagentStop: false,
19
+ postToolUse: false,
20
+ postToolUseFailure: false,
21
+ postToolBatch: false,
22
+ conversationArchive: true,
23
+ archiveProtocolV1: true,
24
+ userMessages: false,
25
+ assistantMessages: false,
26
+ toolInputs: false,
27
+ toolOutputs: false,
28
+ toolFailures: false,
29
+ images: false,
30
+ contextUseEvidence: false,
31
+ artifactBodies: false,
32
+ artifactReferences: false,
33
+ subagents: false,
34
+ compactionCheckpoints: false,
35
+ contextRecalled: false,
36
+ });
37
+
38
+ function capabilities(overrides) {
39
+ return Object.freeze({ ...BASE_CAPABILITIES, ...overrides });
40
+ }
41
+
42
+ export const CLIENT_REGISTRY = Object.freeze({
43
+ "claude-code": Object.freeze({
44
+ clientKind: "claude-code",
45
+ label: "Claude Code",
46
+ command: "claude",
47
+ coverage: "complete",
48
+ capabilities: capabilities({
49
+ sessionStart: true, userPromptSubmit: true, stop: true, preCompact: true,
50
+ sessionEnd: true, subagentStart: true, subagentStop: true,
51
+ postToolUse: true, postToolUseFailure: true, postToolBatch: true,
52
+ userMessages: true, assistantMessages: true, toolInputs: true,
53
+ toolOutputs: true, toolFailures: true, artifactReferences: true,
54
+ subagents: true, compactionCheckpoints: true, contextRecalled: true,
55
+ }),
56
+ }),
57
+ cursor: Object.freeze({
58
+ clientKind: "cursor",
59
+ label: "Cursor",
60
+ command: "cursor",
61
+ coverage: "complete",
62
+ capabilities: capabilities({
63
+ sessionStart: true, userPromptSubmit: true, stop: true, preCompact: true,
64
+ sessionEnd: true, subagentStart: true, subagentStop: true,
65
+ postToolUse: true, postToolUseFailure: true, userMessages: true,
66
+ assistantMessages: true, toolInputs: true, toolOutputs: true,
67
+ toolFailures: true, artifactReferences: true, subagents: true,
68
+ compactionCheckpoints: true, contextRecalled: true,
69
+ }),
70
+ }),
71
+ "gemini-cli": Object.freeze({
72
+ clientKind: "gemini-cli",
73
+ label: "Gemini CLI",
74
+ command: "gemini",
75
+ coverage: "complete",
76
+ capabilities: capabilities({
77
+ sessionStart: true, userPromptSubmit: true, preCompact: true,
78
+ sessionEnd: true, postToolUse: true, postToolUseFailure: true,
79
+ userMessages: true, assistantMessages: true, toolInputs: true,
80
+ toolOutputs: true, toolFailures: true, artifactReferences: true,
81
+ compactionCheckpoints: true, contextRecalled: true,
82
+ }),
83
+ }),
84
+ "kimi-cli": Object.freeze({
85
+ clientKind: "kimi-cli",
86
+ label: "Kimi Code CLI",
87
+ command: "kimi",
88
+ coverage: "partial",
89
+ reason: "assistant_message_hook_unavailable",
90
+ capabilities: capabilities({
91
+ sessionStart: true, userPromptSubmit: true, stop: true, preCompact: true,
92
+ sessionEnd: true, subagentStart: true, subagentStop: true,
93
+ postToolUse: true, postToolUseFailure: true, userMessages: true,
94
+ toolInputs: true, toolOutputs: true, toolFailures: true,
95
+ artifactReferences: true, subagents: true, compactionCheckpoints: true,
96
+ contextRecalled: true,
97
+ }),
98
+ }),
99
+ vscode: Object.freeze({
100
+ clientKind: "vscode",
101
+ label: "VS Code",
102
+ command: "code",
103
+ coverage: "partial",
104
+ reason: "assistant_message_hook_unavailable",
105
+ capabilities: capabilities({
106
+ sessionStart: true, userPromptSubmit: true, stop: true, preCompact: true,
107
+ subagentStart: true, subagentStop: true, postToolUse: true,
108
+ postToolUseFailure: true, userMessages: true, toolInputs: true,
109
+ toolOutputs: true, toolFailures: true, subagents: true,
110
+ compactionCheckpoints: true, contextRecalled: true,
111
+ }),
112
+ }),
113
+ codex: Object.freeze({
114
+ clientKind: "codex",
115
+ label: "Codex CLI",
116
+ command: "codex",
117
+ coverage: "partial",
118
+ reason: "assistant_message_session_end_and_tool_failure_hooks_unavailable",
119
+ capabilities: capabilities({
120
+ sessionStart: true, userPromptSubmit: true, stop: true, preCompact: true,
121
+ subagentStart: true, subagentStop: true, postToolUse: true,
122
+ userMessages: true, toolInputs: true, toolOutputs: true, subagents: true,
123
+ compactionCheckpoints: true, contextRecalled: true,
124
+ }),
125
+ }),
126
+ cline: Object.freeze({
127
+ clientKind: "cline",
128
+ label: "Cline",
129
+ command: null,
130
+ coverage: "partial",
131
+ reason: "assistant_message_session_end_and_tool_failure_hooks_unavailable",
132
+ capabilities: capabilities({
133
+ sessionStart: true, userPromptSubmit: true, postToolUse: true,
134
+ userMessages: true, toolInputs: true, toolOutputs: true,
135
+ contextRecalled: true,
136
+ }),
137
+ }),
138
+ });
139
+
140
+ export function lifecycleClient(clientKind) {
141
+ const client = CLIENT_REGISTRY[String(clientKind || "")];
142
+ if (!client) throw new Error("unsupported lifecycle client");
143
+ return client;
144
+ }
@@ -0,0 +1,354 @@
1
+ import { chmod, readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
5
+
6
+ function commandArg(value) {
7
+ const text = String(value);
8
+ if (/[^A-Za-z0-9_./:@+-]/.test(text)) {
9
+ if (/[\0\r\n"`$\\]/.test(text)) throw new Error("local integration path cannot be encoded safely for a host hook");
10
+ return `"${text}"`;
11
+ }
12
+ return text;
13
+ }
14
+
15
+ function managedCommand(nodePath, runtimePath, eventName, installationId) {
16
+ return [nodePath, runtimePath, "hook", eventName, "--connection", installationId].map(commandArg).join(" ");
17
+ }
18
+
19
+ function managedMcp(nodePath, runtimePath, installationId, { explicitType = false } = {}) {
20
+ return {
21
+ ...(explicitType ? { type: "stdio" } : {}),
22
+ command: resolve(nodePath),
23
+ args: [resolve(runtimePath), "mcp", "--connection", installationId],
24
+ };
25
+ }
26
+
27
+ function isManagedMcp(entry) {
28
+ return Array.isArray(entry?.args) && entry.args.some((value) =>
29
+ /halofy-agent\.mjs$/.test(String(value))) && entry.args.includes("--connection");
30
+ }
31
+
32
+ function replaceHalofyMcpEntries(registry, nextEntry) {
33
+ let replacedLegacyMcpEntries = 0;
34
+ for (const [name, entry] of Object.entries(registry)) {
35
+ if (!["halofy", "halomem"].includes(name.toLowerCase())) continue;
36
+ if (!isManagedMcp(entry)) replacedLegacyMcpEntries += 1;
37
+ delete registry[name];
38
+ }
39
+ registry.halofy = nextEntry;
40
+ return replacedLegacyMcpEntries;
41
+ }
42
+
43
+ function isManaged(entry) {
44
+ return /halofy-agent\.mjs\s+hook\s+/.test(String(entry?.command || entry?.hooks?.[0]?.command || ""));
45
+ }
46
+
47
+ function mergeFlatHooks(settings, definitions, registration) {
48
+ settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
49
+ for (const [hostEvent, runtimeEvent] of Object.entries(definitions)) {
50
+ const current = Array.isArray(settings.hooks[hostEvent]) ? settings.hooks[hostEvent] : [];
51
+ settings.hooks[hostEvent] = [...current.filter((entry) => !isManaged(entry)), registration(runtimeEvent)];
52
+ }
53
+ }
54
+
55
+ function validateInputs({ installationId, runtimePath }) {
56
+ if (!/^[A-Za-z0-9_-]{1,160}$/.test(String(installationId))) throw new Error("invalid installation id");
57
+ if (!runtimePath) throw new Error("installed runtime path is required");
58
+ }
59
+
60
+ const CURSOR_HOOKS = Object.freeze({
61
+ sessionStart: "sessionStart",
62
+ beforeSubmitPrompt: "beforeSubmitPrompt",
63
+ afterAgentResponse: "afterAgentResponse",
64
+ postToolUse: "postToolUse",
65
+ postToolUseFailure: "postToolUseFailure",
66
+ preCompact: "preCompact",
67
+ stop: "stop",
68
+ sessionEnd: "sessionEnd",
69
+ subagentStart: "subagentStart",
70
+ subagentStop: "subagentStop",
71
+ });
72
+
73
+ export async function configureCursor({
74
+ installationId,
75
+ nodePath = process.execPath,
76
+ runtimePath,
77
+ cursorRoot = join(homedir(), ".cursor"),
78
+ }) {
79
+ validateInputs({ installationId, runtimePath });
80
+ const hooksPath = join(cursorRoot, "hooks.json");
81
+ const mcpPath = join(cursorRoot, "mcp.json");
82
+ const hooks = await readJson(hooksPath, {});
83
+ hooks.version = 1;
84
+ mergeFlatHooks(hooks, CURSOR_HOOKS, (eventName) => ({
85
+ command: managedCommand(nodePath, runtimePath, eventName, installationId),
86
+ timeout: 20,
87
+ failClosed: false,
88
+ }));
89
+ const mcp = await readJson(mcpPath, {});
90
+ mcp.mcpServers = mcp.mcpServers && typeof mcp.mcpServers === "object" ? mcp.mcpServers : {};
91
+ const replacedLegacyMcpEntries = replaceHalofyMcpEntries(
92
+ mcp.mcpServers,
93
+ managedMcp(nodePath, runtimePath, installationId),
94
+ );
95
+ await writePrivateFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
96
+ await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
97
+ return { configuredPaths: [hooksPath, mcpPath], hookEvents: Object.keys(CURSOR_HOOKS), replacedLegacyMcpEntries };
98
+ }
99
+
100
+ const GEMINI_HOOKS = Object.freeze({
101
+ SessionStart: "SessionStart",
102
+ BeforeAgent: "BeforeAgent",
103
+ AfterAgent: "AfterAgent",
104
+ AfterTool: "AfterTool",
105
+ PreCompress: "PreCompress",
106
+ SessionEnd: "SessionEnd",
107
+ });
108
+
109
+ export async function configureGemini({
110
+ installationId,
111
+ nodePath = process.execPath,
112
+ runtimePath,
113
+ settingsPath = join(homedir(), ".gemini", "settings.json"),
114
+ }) {
115
+ validateInputs({ installationId, runtimePath });
116
+ const settings = await readJson(settingsPath, {});
117
+ settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
118
+ for (const [hostEvent, runtimeEvent] of Object.entries(GEMINI_HOOKS)) {
119
+ const current = Array.isArray(settings.hooks[hostEvent]) ? settings.hooks[hostEvent] : [];
120
+ const unrelated = current.filter((entry) => !Array.isArray(entry?.hooks) || !entry.hooks.some(isManaged));
121
+ settings.hooks[hostEvent] = [...unrelated, {
122
+ sequential: true,
123
+ hooks: [{
124
+ type: "command",
125
+ name: "Halofy lifecycle",
126
+ command: managedCommand(nodePath, runtimePath, runtimeEvent, installationId),
127
+ timeout: 20_000,
128
+ }],
129
+ }];
130
+ }
131
+ settings.mcpServers = settings.mcpServers && typeof settings.mcpServers === "object" ? settings.mcpServers : {};
132
+ const replacedLegacyMcpEntries = replaceHalofyMcpEntries(
133
+ settings.mcpServers,
134
+ managedMcp(nodePath, runtimePath, installationId),
135
+ );
136
+ await writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
137
+ return { configuredPaths: [settingsPath], hookEvents: Object.keys(GEMINI_HOOKS), replacedLegacyMcpEntries };
138
+ }
139
+
140
+ const KIMI_HOOKS = Object.freeze({
141
+ SessionStart: "SessionStart",
142
+ UserPromptSubmit: "UserPromptSubmit",
143
+ Stop: "Stop",
144
+ PostToolUse: "PostToolUse",
145
+ PostToolUseFailure: "PostToolUseFailure",
146
+ PreCompact: "PreCompact",
147
+ SessionEnd: "SessionEnd",
148
+ SubagentStart: "SubagentStart",
149
+ SubagentStop: "SubagentStop",
150
+ });
151
+
152
+ const KIMI_MANAGED_BEGIN = "# BEGIN HALOFY LIFECYCLE";
153
+ const KIMI_MANAGED_END = "# END HALOFY LIFECYCLE";
154
+
155
+ function replaceKimiManagedHooks(source, replacement) {
156
+ const text = String(source || "");
157
+ const start = text.indexOf(KIMI_MANAGED_BEGIN);
158
+ const end = text.indexOf(KIMI_MANAGED_END);
159
+ if ((start === -1) !== (end === -1) || (start !== -1 && end < start)) {
160
+ throw new Error("existing Halofy Kimi hook configuration is malformed");
161
+ }
162
+ const withoutManaged = start === -1
163
+ ? text
164
+ : `${text.slice(0, start)}${text.slice(end + KIMI_MANAGED_END.length)}`;
165
+ const body = withoutManaged.trimEnd();
166
+ return `${body ? `${body}\n\n` : ""}${replacement}\n`;
167
+ }
168
+
169
+ export async function configureKimi({
170
+ installationId,
171
+ nodePath = process.execPath,
172
+ runtimePath,
173
+ kimiRoot = process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code"),
174
+ }) {
175
+ validateInputs({ installationId, runtimePath });
176
+ const configPath = join(kimiRoot, "config.toml");
177
+ let source = "";
178
+ try { source = await readFile(configPath, "utf8"); } catch (error) { if (error?.code !== "ENOENT") throw error; }
179
+ const hookBlock = [
180
+ KIMI_MANAGED_BEGIN,
181
+ ...Object.entries(KIMI_HOOKS).flatMap(([hostEvent, runtimeEvent]) => [
182
+ "[[hooks]]",
183
+ `event = ${JSON.stringify(hostEvent)}`,
184
+ `command = ${JSON.stringify(managedCommand(nodePath, runtimePath, runtimeEvent, installationId))}`,
185
+ "timeout = 20",
186
+ "",
187
+ ]),
188
+ KIMI_MANAGED_END,
189
+ ].join("\n");
190
+ const mcpPath = join(kimiRoot, "mcp.json");
191
+ const mcp = await readJson(mcpPath, {});
192
+ mcp.mcpServers = mcp.mcpServers && typeof mcp.mcpServers === "object" ? mcp.mcpServers : {};
193
+ const replacedLegacyMcpEntries = replaceHalofyMcpEntries(
194
+ mcp.mcpServers,
195
+ managedMcp(nodePath, runtimePath, installationId),
196
+ );
197
+ await writePrivateFile(configPath, replaceKimiManagedHooks(source, hookBlock));
198
+ await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
199
+ return { configuredPaths: [configPath, mcpPath], hookEvents: Object.keys(KIMI_HOOKS), replacedLegacyMcpEntries };
200
+ }
201
+
202
+ const VSCODE_HOOKS = Object.freeze({
203
+ SessionStart: "SessionStart",
204
+ UserPromptSubmit: "UserPromptSubmit",
205
+ PostToolUse: "PostToolUse",
206
+ PreCompact: "PreCompact",
207
+ Stop: "Stop",
208
+ SubagentStart: "SubagentStart",
209
+ SubagentStop: "SubagentStop",
210
+ });
211
+
212
+ export async function configureVscode({
213
+ projectRoot,
214
+ installationId,
215
+ nodePath = process.execPath,
216
+ runtimePath,
217
+ hooksPath = join(homedir(), ".copilot", "hooks", "halofy.json"),
218
+ }) {
219
+ validateInputs({ installationId, runtimePath });
220
+ const hooks = await readJson(hooksPath, {});
221
+ mergeFlatHooks(hooks, VSCODE_HOOKS, (eventName) => ({
222
+ type: "command",
223
+ command: managedCommand(nodePath, runtimePath, eventName, installationId),
224
+ timeout: 20,
225
+ }));
226
+ const mcpPath = join(resolve(projectRoot), ".vscode", "mcp.json");
227
+ const mcp = await readJson(mcpPath, {});
228
+ mcp.servers = mcp.servers && typeof mcp.servers === "object" ? mcp.servers : {};
229
+ const replacedLegacyMcpEntries = replaceHalofyMcpEntries(
230
+ mcp.servers,
231
+ managedMcp(nodePath, runtimePath, installationId, { explicitType: true }),
232
+ );
233
+ await writePrivateFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
234
+ await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
235
+ return { configuredPaths: [hooksPath, mcpPath], hookEvents: Object.keys(VSCODE_HOOKS), replacedLegacyMcpEntries };
236
+ }
237
+
238
+ const CODEX_HOOKS = Object.freeze({
239
+ SessionStart: "SessionStart",
240
+ UserPromptSubmit: "UserPromptSubmit",
241
+ PostToolUse: "PostToolUse",
242
+ PreCompact: "PreCompact",
243
+ Stop: "Stop",
244
+ SubagentStart: "SubagentStart",
245
+ SubagentStop: "SubagentStop",
246
+ });
247
+
248
+ function replaceTomlSection(source, heading, replacement) {
249
+ const lines = String(source || "").split(/\r?\n/);
250
+ const start = lines.findIndex((line) => line.trim() === `[${heading}]`);
251
+ if (start !== -1) {
252
+ let end = start + 1;
253
+ while (end < lines.length && !/^\s*\[[^\]]+\]\s*$/.test(lines[end])) end += 1;
254
+ lines.splice(start, end - start);
255
+ }
256
+ const body = lines.join("\n").trimEnd();
257
+ return `${body ? `${body}\n\n` : ""}${replacement}\n`;
258
+ }
259
+
260
+ function removeTomlSection(source, heading) {
261
+ const lines = String(source || "").split(/\r?\n/);
262
+ const start = lines.findIndex((line) => line.trim() === `[${heading}]`);
263
+ if (start === -1) return { source: String(source || ""), removed: false, legacy: false };
264
+ let end = start + 1;
265
+ while (end < lines.length && !/^\s*\[[^\]]+\]\s*$/.test(lines[end])) end += 1;
266
+ const section = lines.slice(start, end).join("\n");
267
+ lines.splice(start, end - start);
268
+ return {
269
+ source: `${lines.join("\n").trimEnd()}\n`,
270
+ removed: true,
271
+ legacy: !/halofy-agent\.mjs|--connection/.test(section),
272
+ };
273
+ }
274
+
275
+ function enableTomlFeature(source, name) {
276
+ const lines = String(source || "").split(/\r?\n/);
277
+ const start = lines.findIndex((line) => line.trim() === "[features]");
278
+ if (start === -1) return `${String(source || "").trimEnd()}${String(source || "").trim() ? "\n\n" : ""}[features]\n${name} = true\n`;
279
+ let end = start + 1;
280
+ while (end < lines.length && !/^\s*\[[^\]]+\]\s*$/.test(lines[end])) end += 1;
281
+ const key = new RegExp(`^\\s*${name}\\s*=`);
282
+ const existing = lines.slice(start + 1, end).findIndex((line) => key.test(line));
283
+ if (existing === -1) lines.splice(end, 0, `${name} = true`);
284
+ else lines[start + 1 + existing] = `${name} = true`;
285
+ return `${lines.join("\n").trimEnd()}\n`;
286
+ }
287
+
288
+ export async function configureCodex({
289
+ installationId,
290
+ nodePath = process.execPath,
291
+ runtimePath,
292
+ codexRoot = process.env.CODEX_HOME || join(homedir(), ".codex"),
293
+ }) {
294
+ validateInputs({ installationId, runtimePath });
295
+ const hooksPath = join(codexRoot, "hooks.json");
296
+ const hooks = await readJson(hooksPath, {});
297
+ mergeFlatHooks(hooks, CODEX_HOOKS, (eventName) => ({
298
+ hooks: [{ type: "command", command: managedCommand(nodePath, runtimePath, eventName, installationId), timeout: 20 }],
299
+ }));
300
+ const configPath = join(codexRoot, "config.toml");
301
+ let source = "";
302
+ try { source = await readFile(configPath, "utf8"); } catch (error) { if (error?.code !== "ENOENT") throw error; }
303
+ const section = [
304
+ "[mcp_servers.halofy]",
305
+ `command = ${JSON.stringify(resolve(nodePath))}`,
306
+ `args = ${JSON.stringify([resolve(runtimePath), "mcp", "--connection", installationId])}`,
307
+ ].join("\n");
308
+ await writePrivateFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
309
+ const withoutHalomem = removeTomlSection(source, "mcp_servers.halomem");
310
+ const withoutHalofy = removeTomlSection(withoutHalomem.source, "mcp_servers.halofy");
311
+ const withMcp = replaceTomlSection(withoutHalofy.source, "mcp_servers.halofy", section);
312
+ await writePrivateFile(configPath, enableTomlFeature(withMcp, "hooks"));
313
+ return {
314
+ configuredPaths: [hooksPath, configPath],
315
+ hookEvents: Object.keys(CODEX_HOOKS),
316
+ replacedLegacyMcpEntries: Number(withoutHalomem.legacy) + Number(withoutHalofy.legacy),
317
+ };
318
+ }
319
+
320
+ const CLINE_HOOKS = Object.freeze({
321
+ TaskStart: "TaskStart",
322
+ UserPromptSubmit: "UserPromptSubmit",
323
+ PostToolUse: "PostToolUse",
324
+ TaskCancel: "TaskCancel",
325
+ });
326
+
327
+ export async function configureCline({
328
+ installationId,
329
+ nodePath = process.execPath,
330
+ runtimePath,
331
+ clineRoot = join(homedir(), "Documents", "Cline"),
332
+ }) {
333
+ validateInputs({ installationId, runtimePath });
334
+ const hooksRoot = join(clineRoot, "Hooks");
335
+ await ensurePrivateDirectory(hooksRoot);
336
+ const configuredPaths = [];
337
+ for (const [hostEvent, runtimeEvent] of Object.entries(CLINE_HOOKS)) {
338
+ const path = join(hooksRoot, hostEvent);
339
+ const command = managedCommand(nodePath, runtimePath, runtimeEvent, installationId);
340
+ await writePrivateFile(path, `#!/usr/bin/env sh\nexec ${command}\n`);
341
+ if (process.platform !== "win32") await chmod(path, 0o700);
342
+ configuredPaths.push(path);
343
+ }
344
+ const mcpPath = join(clineRoot, "MCP", "cline_mcp_settings.json");
345
+ const mcp = await readJson(mcpPath, {});
346
+ mcp.mcpServers = mcp.mcpServers && typeof mcp.mcpServers === "object" ? mcp.mcpServers : {};
347
+ const replacedLegacyMcpEntries = replaceHalofyMcpEntries(
348
+ mcp.mcpServers,
349
+ managedMcp(nodePath, runtimePath, installationId),
350
+ );
351
+ await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
352
+ configuredPaths.push(mcpPath);
353
+ return { configuredPaths, hookEvents: Object.keys(CLINE_HOOKS), replacedLegacyMcpEntries };
354
+ }
@@ -0,0 +1,188 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename } from "node:path";
3
+ import { LifecycleRuntime } from "./runtime.mjs";
4
+ import { normalizeClaudeHookEvent, normalizeHostMessageEvent } from "./session.mjs";
5
+ import { defaultRuntimeDirectory } from "./storage.mjs";
6
+ import { readHookInput } from "./claude-hook.mjs";
7
+
8
+ const USER_EVENTS = new Set(["UserPromptSubmit", "beforeSubmitPrompt", "BeforeAgent", "pre_llm_call"]);
9
+ const ASSISTANT_EVENTS = new Set(["afterAgentResponse", "AfterAgent", "post_llm_call", "transform_llm_output"]);
10
+ const TOOL_EVENTS = new Set(["PostToolUse", "PostToolUseFailure", "postToolUse", "postToolUseFailure", "AfterTool", "post_tool_call", "after_tool_call"]);
11
+ const START_EVENTS = new Set(["SessionStart", "sessionStart", "TaskStart", "on_session_start", "session_start"]);
12
+ const END_EVENTS = new Set(["SessionEnd", "sessionEnd", "on_session_finalize", "session_end"]);
13
+ const STOP_EVENTS = new Set(["Stop", "stop", "TaskCancel", "on_session_end", "agent_end"]);
14
+ const COMPACT_EVENTS = new Set(["PreCompact", "preCompact", "PreCompress", "before_compaction"]);
15
+ const SUBAGENT_START_EVENTS = new Set(["SubagentStart", "subagentStart", "subagent_start", "subagent_spawned"]);
16
+ const SUBAGENT_STOP_EVENTS = new Set(["SubagentStop", "subagentStop", "subagent_stop", "subagent_ended"]);
17
+
18
+ export const HOST_HOOK_EVENTS = Object.freeze(new Set([
19
+ ...USER_EVENTS, ...ASSISTANT_EVENTS, ...TOOL_EVENTS, ...START_EVENTS,
20
+ ...END_EVENTS, ...STOP_EVENTS, ...COMPACT_EVENTS,
21
+ ...SUBAGENT_START_EVENTS, ...SUBAGENT_STOP_EVENTS,
22
+ ]));
23
+
24
+ function digest(value) {
25
+ return createHash("sha256").update(String(value)).digest("hex");
26
+ }
27
+
28
+ function hostSession(input) {
29
+ return String(input.session_id || input.conversation_id || input.taskId || input.task_id ||
30
+ input.sessionKey || input.session_key || input.extra?.session_id || "");
31
+ }
32
+
33
+ function childSession(input, parent) {
34
+ const child = input.agent_id || input.subagent_id || input.child_subagent_id ||
35
+ input.childSessionKey || input.child_session_id || input.extra?.child_session_id;
36
+ return child ? `${parent}:subagent:${String(child)}` : parent;
37
+ }
38
+
39
+ function eventEvidenceId(eventName, input) {
40
+ const native = input.event_id || input.generation_id || input.turn_id || input.tool_call_id ||
41
+ input.taskId || input.task_id || input.extra?.turn_id || input.extra?.tool_call_id;
42
+ return native ? `${eventName}:${String(native)}` : `${eventName}:${digest(JSON.stringify(input))}`;
43
+ }
44
+
45
+ function promptText(input) {
46
+ return input.prompt ?? input.user_prompt ?? input.user_message ??
47
+ input.userPromptSubmit?.prompt ?? input.extra?.user_message;
48
+ }
49
+
50
+ function assistantText(input) {
51
+ return input.text ?? input.prompt_response ?? input.assistant_response ?? input.response_text ??
52
+ input.extra?.assistant_response ?? input.extra?.response_text;
53
+ }
54
+
55
+ function toolInput(input) {
56
+ return input.tool_input ?? input.parameters ?? input.preToolUse?.parameters ?? input.postToolUse?.parameters ??
57
+ input.extra?.args ?? input.args;
58
+ }
59
+
60
+ function toolOutput(input) {
61
+ return input.tool_response ?? input.tool_output ?? input.tool_result ?? input.result ?? input.postToolUse?.result ??
62
+ input.extra?.result ?? input.extra?.error_message;
63
+ }
64
+
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
+ function recallOutput(clientKind, eventName, result) {
76
+ const rankedBlocks = recallBlocks(result);
77
+ if (rankedBlocks.length === 0) return null;
78
+ const additionalContext = `<<<HALOFY_CONTEXT_BLOCKS_V1>>>\n${JSON.stringify({ rankedBlocks })}\n<<<END_HALOFY_CONTEXT_BLOCKS_V1>>>`;
79
+ if (clientKind === "cursor") {
80
+ return START_EVENTS.has(eventName) ? JSON.stringify({ additional_context: additionalContext }) : null;
81
+ }
82
+ if (clientKind === "cline") return JSON.stringify({ cancel: false, contextModification: additionalContext });
83
+ if (clientKind === "hermes-agents") return JSON.stringify({ context: additionalContext });
84
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext } });
85
+ }
86
+
87
+ async function within(milliseconds, action) {
88
+ let timer;
89
+ try {
90
+ return await Promise.race([
91
+ action(),
92
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), milliseconds); }),
93
+ ]);
94
+ } finally {
95
+ if (timer) clearTimeout(timer);
96
+ }
97
+ }
98
+
99
+ async function enqueueMessage(runtime, connection, session, role, text, eventName, input) {
100
+ if (typeof text !== "string") return;
101
+ await runtime.enqueueSequencedEvents(session, ({ nextSequence }) => [normalizeHostMessageEvent({
102
+ clientKind: connection.clientKind, role, text,
103
+ eventId: eventEvidenceId(eventName, input),
104
+ occurredAt: input.timestamp || new Date().toISOString(),
105
+ }, { sequence: nextSequence })]);
106
+ }
107
+
108
+ async function enqueueTool(runtime, session, eventName, input) {
109
+ const failed = /failure/i.test(eventName) || Boolean(input.error || input.extra?.error_message || input.postToolUse?.success === false);
110
+ const common = {
111
+ event_id: eventEvidenceId(eventName, input),
112
+ tool_name: input.tool_name ?? input.toolName ?? input.postToolUse?.toolName ?? input.extra?.tool_name,
113
+ tool_use_id: input.tool_use_id ?? input.tool_call_id ?? input.extra?.tool_call_id,
114
+ tool_input: toolInput(input),
115
+ tool_response: toolOutput(input),
116
+ ...(failed ? { tool_error: input.error || input.extra?.error_message || true } : {}),
117
+ };
118
+ await runtime.enqueueSequencedEvents(session, ({ sessionHash, nextSequence }) => [
119
+ normalizeClaudeHookEvent("tool_call", common, { sessionHash, sequence: nextSequence }),
120
+ normalizeClaudeHookEvent("tool_result", common, { sessionHash, sequence: nextSequence + 1 }),
121
+ ]);
122
+ }
123
+
124
+ /** Runs one reviewed host hook without blocking the host when Halofy browns out. */
125
+ export async function runHostLifecycleHook(connection, eventName, {
126
+ input,
127
+ root = defaultRuntimeDirectory(),
128
+ stdout = process.stdout,
129
+ stderr = process.stderr,
130
+ runtimeFactory = (activeConnection, options) => new LifecycleRuntime(activeConnection, options),
131
+ } = {}) {
132
+ try {
133
+ if (!HOST_HOOK_EVENTS.has(eventName)) throw new Error("unsupported host lifecycle hook");
134
+ const hookInput = input ?? await readHookInput();
135
+ const session = hostSession(hookInput);
136
+ if (!session) return { handled: true, unavailable: "missing_host_session" };
137
+ const runtime = runtimeFactory(connection, { root });
138
+
139
+ if (START_EVENTS.has(eventName)) {
140
+ await runtime.replay();
141
+ 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);
147
+ } else if (USER_EVENTS.has(eventName)) {
148
+ const prompt = promptText(hookInput);
149
+ await enqueueMessage(runtime, connection, session, "user", prompt, eventName, hookInput);
150
+ if (connection.clientKind !== "cursor" && typeof prompt === "string" && prompt.trim()) {
151
+ const recalled = await runtime.recall(session, prompt.slice(0, 8_000));
152
+ const output = recallOutput(connection.clientKind, eventName, recalled);
153
+ if (output) stdout.write(output);
154
+ }
155
+ } else if (ASSISTANT_EVENTS.has(eventName)) {
156
+ await enqueueMessage(runtime, connection, session, "assistant", assistantText(hookInput), eventName, hookInput);
157
+ await runtime.commitIfThreshold(session);
158
+ } else if (TOOL_EVENTS.has(eventName)) {
159
+ await enqueueTool(runtime, session, eventName, hookInput);
160
+ } else if (COMPACT_EVENTS.has(eventName)) {
161
+ await runtime.enqueueSequencedEvents(session, ({ sessionHash, nextSequence }) => [
162
+ normalizeClaudeHookEvent("compaction", {
163
+ ...hookInput,
164
+ event_id: eventEvidenceId(eventName, hookInput),
165
+ }, { sessionHash, sequence: nextSequence }),
166
+ ]);
167
+ await runtime.commit(session, "pre_compaction");
168
+ } else if (SUBAGENT_START_EVENTS.has(eventName)) {
169
+ await runtime.resolveSession(childSession(hookInput, session), session);
170
+ } else if (SUBAGENT_STOP_EVENTS.has(eventName)) {
171
+ await within(10_000, () => runtime.close(childSession(hookInput, session), "session_end"));
172
+ } else if (END_EVENTS.has(eventName)) {
173
+ await within(15_000, () => runtime.close(session));
174
+ } else if (STOP_EVENTS.has(eventName)) {
175
+ if (connection.clientKind === "vscode" || connection.clientKind === "cline") {
176
+ await within(15_000, () => runtime.close(session));
177
+ } else {
178
+ await runtime.commitIfThreshold(session);
179
+ }
180
+ }
181
+ return { handled: true };
182
+ } catch (error) {
183
+ const code = error && typeof error === "object" && typeof error.code === "string"
184
+ ? error.code : "runtime_unavailable";
185
+ stderr.write(`[halofy] lifecycle hook degraded: ${code}\n`);
186
+ return { handled: true, degraded: true };
187
+ }
188
+ }
package/src/install.mjs CHANGED
@@ -6,33 +6,9 @@ import { generateInstallationKeyPair } from "./crypto.mjs";
6
6
  import { ConnectionStore, defaultRuntimeDirectory, ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
7
7
  import { SignedRuntimeTransport } from "./transport.mjs";
8
8
  import { INSTALLER_VERSION, RUNTIME_VERSION } from "./version.mjs";
9
+ import { CLIENT_KINDS, CLIENT_REGISTRY, lifecycleClient } from "./client-registry.mjs";
9
10
 
10
- export const CLAUDE_CAPABILITIES = Object.freeze({
11
- sessionStart: true,
12
- userPromptSubmit: true,
13
- stop: true,
14
- preCompact: true,
15
- sessionEnd: true,
16
- subagentStart: true,
17
- subagentStop: true,
18
- postToolUse: true,
19
- postToolUseFailure: true,
20
- postToolBatch: true,
21
- conversationArchive: true,
22
- archiveProtocolV1: true,
23
- userMessages: true,
24
- assistantMessages: true,
25
- toolInputs: true,
26
- toolOutputs: true,
27
- toolFailures: true,
28
- images: false,
29
- contextUseEvidence: false,
30
- artifactBodies: false,
31
- artifactReferences: true,
32
- subagents: true,
33
- compactionCheckpoints: true,
34
- contextRecalled: true,
35
- });
11
+ export const CLAUDE_CAPABILITIES = CLIENT_REGISTRY["claude-code"].capabilities;
36
12
 
37
13
  export async function consumeInstallationClaim({
38
14
  serverUrl,
@@ -43,7 +19,7 @@ export async function consumeInstallationClaim({
43
19
  proofStorage,
44
20
  installerVersion = INSTALLER_VERSION,
45
21
  pluginVersion = RUNTIME_VERSION,
46
- capabilities = CLAUDE_CAPABILITIES,
22
+ capabilities = lifecycleClient(clientKind).capabilities,
47
23
  fetchImpl = globalThis.fetch,
48
24
  }) {
49
25
  const response = await fetchImpl(`${serverUrl}/v1/agent-installations/claim`, {
@@ -73,7 +49,8 @@ export async function installLocalConnection({
73
49
  fetchImpl = globalThis.fetch,
74
50
  sendHeartbeat = true,
75
51
  }) {
76
- if (clientKind !== "claude-code") throw new Error("only the local Claude Code adapter is available");
52
+ if (!CLIENT_KINDS.includes(clientKind)) throw new Error("the selected lifecycle adapter is not packaged");
53
+ const client = lifecycleClient(clientKind);
77
54
  const normalizedServer = new URL(serverUrl);
78
55
  if (normalizedServer.protocol !== "https:" && normalizedServer.hostname !== "localhost" && normalizedServer.hostname !== "127.0.0.1") {
79
56
  throw new Error("installation claims require HTTPS (localhost is allowed for development)");
@@ -113,7 +90,7 @@ export async function installLocalConnection({
113
90
  publicJwk: keyPair.publicJwk,
114
91
  privateJwk: keyPair.privateJwk,
115
92
  keyThumbprint: keyPair.thumbprint,
116
- capabilities: CLAUDE_CAPABILITIES,
93
+ capabilities: client.capabilities,
117
94
  installerVersion: INSTALLER_VERSION,
118
95
  pluginVersion: RUNTIME_VERSION,
119
96
  proofStorage,
@@ -132,7 +109,7 @@ export async function installLocalConnection({
132
109
  let heartbeat = false;
133
110
  if (sendHeartbeat) {
134
111
  try {
135
- await new SignedRuntimeTransport(connection, { fetchImpl }).heartbeat(CLAUDE_CAPABILITIES, {
112
+ await new SignedRuntimeTransport(connection, { fetchImpl }).heartbeat(client.capabilities, {
136
113
  pluginVersion: RUNTIME_VERSION,
137
114
  proofStorage,
138
115
  diagnostics: { queueDepth: 0, oldestPendingAt: null, expiredCount: 0 },
@@ -1,6 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync } from "node:fs";
2
3
  import { createInterface } from "node:readline/promises";
3
- import { resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { join, resolve } from "node:path";
4
6
  import {
5
7
  heartbeatInstalledConnection,
6
8
  installLocalConnection,
@@ -8,6 +10,8 @@ import {
8
10
  localMcpSnippet,
9
11
  } from "./install.mjs";
10
12
  import { configureClaudeProject } from "./claude-config.mjs";
13
+ import { configureCline, configureCodex, configureCursor, configureGemini, configureKimi, configureVscode } from "./host-config.mjs";
14
+ import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
11
15
  import { defaultRuntimeDirectory } from "./storage.mjs";
12
16
  import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
13
17
 
@@ -27,9 +31,9 @@ export function parseInstallerArgs(argv) {
27
31
  }
28
32
  const serverUrl = values.get("--server");
29
33
  const claim = values.get("--claim");
30
- if (command !== "install" || clientKind !== "claude-code" || !serverUrl || !claim ||
34
+ if (command !== "install" || !CLIENT_KINDS.includes(clientKind) || !serverUrl || !claim ||
31
35
  !/^hsc_[A-Za-z0-9_-]{43}$/.test(claim)) {
32
- throw new Error("Usage: agent-connect install claude-code --server <https-url> --claim <one-use-claim>");
36
+ throw new Error("Usage: agent-connect install <supported-client> --server <https-url> --claim <one-use-claim>");
33
37
  }
34
38
  return {
35
39
  clientKind,
@@ -51,22 +55,68 @@ export function detectClaudeCode() {
51
55
  return version || "detected";
52
56
  }
53
57
 
54
- export function disclosureText({ serverUrl, projectRoot, claudeVersion }) {
58
+ export function detectCline(home = homedir()) {
59
+ if (existsSync(join(home, "Documents", "Cline"))) return "detected";
60
+ for (const root of [join(home, ".vscode", "extensions"), join(home, ".cursor", "extensions")]) {
61
+ try {
62
+ if (readdirSync(root).some((name) => name.startsWith("saoudrizwan.claude-dev-"))) return "detected";
63
+ } catch (error) {
64
+ if (error?.code !== "ENOENT") throw error;
65
+ }
66
+ }
67
+ throw new Error("Cline was not found in VS Code, Cursor, or ~/Documents/Cline");
68
+ }
69
+
70
+ export function detectClient(clientKind) {
71
+ const client = lifecycleClient(clientKind);
72
+ if (clientKind === "cline") return detectCline();
73
+ if (!client.command) throw new Error(`${client.label} does not have a packaged detector`);
74
+ const result = spawnSync(client.command, ["--version"], {
75
+ encoding: "utf8",
76
+ shell: false,
77
+ timeout: 10_000,
78
+ windowsHide: true,
79
+ });
80
+ if (result.error || result.status !== 0) throw new Error(`${client.label} was not found on PATH`);
81
+ return String(result.stdout || result.stderr || "").trim().slice(0, 128) || "detected";
82
+ }
83
+
84
+ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion }) {
85
+ const client = lifecycleClient(clientKind);
86
+ const observedCategories = [
87
+ ["user messages", client.capabilities.userMessages],
88
+ ["assistant messages", client.capabilities.assistantMessages],
89
+ ["tool inputs", client.capabilities.toolInputs],
90
+ ["tool outputs", client.capabilities.toolOutputs],
91
+ ["tool failures", client.capabilities.toolFailures],
92
+ ["images", client.capabilities.images],
93
+ ["artifact bodies", client.capabilities.artifactBodies],
94
+ ["artifact references", client.capabilities.artifactReferences],
95
+ ["subagents", client.capabilities.subagents],
96
+ ["compaction checkpoints", client.capabilities.compactionCheckpoints],
97
+ ["context-use evidence", client.capabilities.contextUseEvidence],
98
+ ];
99
+ const supported = observedCategories.filter(([, value]) => value === true).map(([name]) => name);
100
+ const unsupported = observedCategories.filter(([, value]) => value !== true).map(([name]) => name);
55
101
  return [
56
- "Halofy Claude Code lifecycle connection",
102
+ `Halofy ${client.label} lifecycle connection`,
57
103
  `Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
58
104
  `Server: ${new URL(serverUrl).origin}`,
59
- `Claude Code: ${claudeVersion}`,
105
+ `${client.label}: ${clientVersion}`,
60
106
  `Project: ${projectRoot}`,
61
107
  "",
62
- "This single installation replaces an existing Halofy bearer MCP entry in this project and enables:",
108
+ "This single installation replaces an existing Halofy bearer MCP entry where supported and enables:",
63
109
  "- governed memory tools and recall",
64
- "- prompts and assistant responses exposed by Claude's transcript hooks,",
65
- "- supported tool inputs/results/failures, subagents, compaction, and session end,",
110
+ `- conversation events exposed by ${client.label}'s reviewed hooks,`,
111
+ "- explicitly supported tool, subagent, compaction, and close evidence,",
66
112
  "- encrypted local retry queue and governed retained conversations, and",
67
113
  "- canonical learning when the selected badge permits writes.",
68
114
  "",
69
- "Images and artifact bodies are recorded as explicit unsupported placeholders; coverage may be Partial.",
115
+ `Supported capture categories: ${supported.length > 0 ? supported.join(", ") : "none"}.`,
116
+ `Unsupported capture categories: ${unsupported.length > 0 ? unsupported.join(", ") : "none"}.`,
117
+ client.coverage === "complete"
118
+ ? "This reviewed host surface can report complete coverage when all declared evidence is observed."
119
+ : `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
70
120
  "Authorized organization managers may review retained conversations and summaries.",
71
121
  "This does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
72
122
  "Disconnecting stops future capture but does not erase retained data.",
@@ -90,14 +140,15 @@ export async function runInstaller(argv, {
90
140
  root = defaultRuntimeDirectory(),
91
141
  output = process.stdout,
92
142
  detectClaude = detectClaudeCode,
143
+ detectHost = detectClient,
93
144
  confirm = confirmDisclosure,
94
145
  fetchImpl = globalThis.fetch,
95
146
  sourceRoot,
96
147
  claudeConfigPath,
97
148
  } = {}) {
98
149
  const input = parseInstallerArgs(argv);
99
- const claudeVersion = await detectClaude();
100
- output.write(`${disclosureText({ ...input, claudeVersion })}\n`);
150
+ const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
151
+ output.write(`${disclosureText({ ...input, clientVersion })}\n`);
101
152
  await confirm();
102
153
 
103
154
  const installed = await installLocalConnection({
@@ -109,13 +160,27 @@ export async function runInstaller(argv, {
109
160
  sendHeartbeat: false,
110
161
  });
111
162
  const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
112
- const configured = await configureClaudeProject({
163
+ const common = {
113
164
  projectRoot: input.projectRoot,
114
165
  installationId: installed.installationId,
115
166
  serverUrl: input.serverUrl,
116
167
  runtimePath: bundle.runtimePath,
117
- ...(claudeConfigPath ? { claudeConfigPath } : {}),
118
- });
168
+ };
169
+ const configured = input.clientKind === "claude-code"
170
+ ? await configureClaudeProject({ ...common, ...(claudeConfigPath ? { claudeConfigPath } : {}) })
171
+ : input.clientKind === "cursor"
172
+ ? await configureCursor(common)
173
+ : input.clientKind === "gemini-cli"
174
+ ? await configureGemini(common)
175
+ : input.clientKind === "kimi-cli"
176
+ ? await configureKimi(common)
177
+ : input.clientKind === "vscode"
178
+ ? await configureVscode(common)
179
+ : input.clientKind === "codex"
180
+ ? await configureCodex(common)
181
+ : input.clientKind === "cline"
182
+ ? await configureCline(common)
183
+ : (() => { throw new Error("the selected adapter is not packaged yet"); })();
119
184
  let heartbeat = false;
120
185
  try {
121
186
  heartbeat = await heartbeatInstalledConnection({
@@ -133,12 +198,12 @@ export async function runInstaller(argv, {
133
198
  installerVersion: INSTALLER_VERSION,
134
199
  publishedPackage: true,
135
200
  projectConfigured: true,
136
- configuredPaths: [configured.mcpPath, configured.settingsPath],
137
- replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries,
201
+ configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
202
+ replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
138
203
  mcpConfiguration: localMcpSnippet({
139
204
  proxyPath: bundle.runtimePath,
140
205
  installationId: installed.installationId,
141
206
  }),
142
- nextStep: "Restart Claude Code in this project, then check the connection in Halofy.",
207
+ nextStep: `Restart ${lifecycleClient(input.clientKind).label}, then check the connection in Halofy.`,
143
208
  };
144
209
  }
package/src/runtime.mjs CHANGED
@@ -4,6 +4,7 @@ import { BoundedEncryptedQueue } from "./queue.mjs";
4
4
  import { CursorStore, deriveSessionHash, readClaudeTranscriptSuffix } from "./session.mjs";
5
5
  import { SignedRuntimeTransport } from "./transport.mjs";
6
6
  import { withFileLock } from "./storage.mjs";
7
+ import { RUNTIME_VERSION } from "./version.mjs";
7
8
 
8
9
  export class LifecycleRuntime {
9
10
  constructor(connection, {
@@ -132,22 +133,39 @@ export class LifecycleRuntime {
132
133
  }
133
134
 
134
135
  async enqueueEvents(hostSessionId, events) {
136
+ const sessionHash = this.sessionHash(hostSessionId);
137
+ await withFileLock(this.operationLockPath, async () => {
138
+ await this.#enqueueEventsUnlocked(sessionHash, events);
139
+ });
140
+ return this.replay();
141
+ }
142
+
143
+ async enqueueSequencedEvents(hostSessionId, factory) {
135
144
  const sessionHash = this.sessionHash(hostSessionId);
136
145
  await withFileLock(this.operationLockPath, async () => {
137
146
  const cursor = await this.cursors.get(sessionHash);
138
- const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
139
- const unseenEvents = events.filter((event) => !recentEventKeys.has(event.eventKey));
140
- if (unseenEvents.length === 0) return;
141
- await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
142
- maxBatchEvents: this.maxBatchEvents,
143
- maxBatchBytes: this.maxBatchBytes,
144
- acknowledgedSequence: cursor.sequence,
145
- });
146
- await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
147
+ const events = factory({ sessionHash, nextSequence: cursor.sequence + 1 });
148
+ if (!Array.isArray(events) || events.length < 1 || events.length > this.maxBatchEvents) {
149
+ throw new Error("sequenced host event batch is invalid");
150
+ }
151
+ await this.#enqueueEventsUnlocked(sessionHash, events, cursor);
147
152
  });
148
153
  return this.replay();
149
154
  }
150
155
 
156
+ async #enqueueEventsUnlocked(sessionHash, events, knownCursor) {
157
+ const cursor = knownCursor || await this.cursors.get(sessionHash);
158
+ const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
159
+ const unseenEvents = events.filter((event) => !recentEventKeys.has(event.eventKey));
160
+ if (unseenEvents.length === 0) return;
161
+ await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
162
+ maxBatchEvents: this.maxBatchEvents,
163
+ maxBatchBytes: this.maxBatchBytes,
164
+ acknowledgedSequence: cursor.sequence,
165
+ });
166
+ await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
167
+ }
168
+
151
169
  async replay() {
152
170
  return withFileLock(this.operationLockPath, () => this.#replayUnlocked());
153
171
  }
@@ -238,7 +256,7 @@ export class LifecycleRuntime {
238
256
  async heartbeat(capabilities) {
239
257
  const queue = await this.queue.diagnostics();
240
258
  return this.transport.heartbeat(capabilities, {
241
- pluginVersion: this.connection.pluginVersion || "0.2.0-local",
259
+ pluginVersion: this.connection.pluginVersion || `${RUNTIME_VERSION}-local`,
242
260
  proofStorage: this.connection.proofStorage || "unknown",
243
261
  diagnostics: {
244
262
  queueDepth: queue.depth,
package/src/session.mjs CHANGED
@@ -387,6 +387,37 @@ export function normalizeClaudeHookEvent(type, input, { sessionHash, sequence })
387
387
  return { ...event, sequence };
388
388
  }
389
389
 
390
+ /** Normalize text exposed directly by a reviewed non-Claude host hook. */
391
+ export function normalizeHostMessageEvent({
392
+ clientKind,
393
+ role,
394
+ text,
395
+ eventId,
396
+ occurredAt = new Date().toISOString(),
397
+ }, { sequence }) {
398
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(String(clientKind)) ||
399
+ !["user", "assistant", "system"].includes(String(role)) ||
400
+ typeof text !== "string" || !eventId) {
401
+ throw new Error("host message evidence is incomplete");
402
+ }
403
+ const payload = boundedCompletePayload({
404
+ role,
405
+ contentFormat: "utf8",
406
+ captureStatus: "complete",
407
+ text,
408
+ }, { role, body: text, format: "utf8" });
409
+ const stableId = digest(`${clientKind}\0${String(eventId)}`);
410
+ return {
411
+ ...normalizedEvent({
412
+ eventKey: `${clientKind}:message:${stableId}`,
413
+ type: "message",
414
+ occurredAt,
415
+ payload,
416
+ }),
417
+ sequence,
418
+ };
419
+ }
420
+
390
421
  export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
391
422
  const bytes = await readFile(path);
392
423
  const start = Number.isSafeInteger(cursor?.byteOffset) && cursor.byteOffset <= bytes.length ? cursor.byteOffset : 0;
package/src/version.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  export const PACKAGE_NAME = "@halofy/agent-connect";
2
- export const INSTALLER_VERSION = "0.1.0";
3
- export const RUNTIME_VERSION = "0.1.0";
4
- export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-28";
2
+ export const INSTALLER_VERSION = "0.3.0";
3
+ export const RUNTIME_VERSION = "0.3.0";
4
+ export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-29";