@halofy/agent-connect 0.1.0 → 0.2.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,56 @@ 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.2.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
+ | `codex` | partial | assistant responses, tool failures, session end, binary bodies |
36
+ | `vscode` | partial | assistant responses, binary bodies, context-use evidence |
37
+ | `cline` | partial | assistant responses, tool failures, subagents, compaction, session end, binary bodies |
38
+
39
+ Other catalog entries remain `Not supported yet` or `Not observed`; the
40
+ installer refuses them before claim consumption. A knowledge connector,
41
+ OAuth-only MCP connection, legacy bearer, or manual MCP URL is never upgraded
42
+ to conversation-capture evidence by its name.
43
+
27
44
  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
45
+ computer where the selected client runs, never in agent chat. The installer displays
29
46
  the capture disclosure, requires the recipient to type `CONNECT`, generates
30
47
  the Ed25519 private key locally, consumes the one-use claim in a JSON body,
31
48
  copies the reviewed runtime out of the transient npx cache, and installs the
32
49
  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.
50
+ bearer MCP entry where the host exposes one and installs its reviewed hooks
51
+ while preserving unrelated settings. It never runs both Halofy capture paths
52
+ for one host session.
35
53
 
36
54
  The current storage backend is the explicitly reported mode-`0600` file
37
55
  fallback (or the closest Windows ACL), not hardware-backed storage. No bearer
38
- or claim is stored in the runtime queue or Claude configuration.
56
+ or claim is stored in the runtime queue or host configuration.
39
57
 
40
58
  The request canonicalization in `src/crypto.mjs` follows AL4's strict raw path
41
59
  and query rules. Publication is fail-closed until the package tarball, current
42
60
  Claude fixtures, cross-implementation signature fixtures, and deployed server
43
- protocol have all passed for the exact version.
61
+ protocol have all passed for the exact version. Server versions configured
62
+ with the Claude-only `0.1.x` artifact keep every additional client fenced as
63
+ `Not supported yet`.
44
64
 
45
- The Claude adapter advertises archive protocol v1 explicitly. Its current
65
+ Every packaged adapter advertises archive protocol v1 explicitly. The Claude
66
+ adapter's current
46
67
  body capabilities are user/assistant text, structured tool inputs/results and
47
68
  failures, and host artifact references. Inline images and artifact bodies are
48
69
  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.2.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,128 @@
1
+ export const CLIENT_KINDS = Object.freeze([
2
+ "claude-code",
3
+ "codex",
4
+ "cursor",
5
+ "vscode",
6
+ "cline",
7
+ "gemini-cli",
8
+ ]);
9
+
10
+ const BASE_CAPABILITIES = Object.freeze({
11
+ sessionStart: false,
12
+ userPromptSubmit: false,
13
+ stop: false,
14
+ preCompact: false,
15
+ sessionEnd: false,
16
+ subagentStart: false,
17
+ subagentStop: false,
18
+ postToolUse: false,
19
+ postToolUseFailure: false,
20
+ postToolBatch: false,
21
+ conversationArchive: true,
22
+ archiveProtocolV1: true,
23
+ userMessages: false,
24
+ assistantMessages: false,
25
+ toolInputs: false,
26
+ toolOutputs: false,
27
+ toolFailures: false,
28
+ images: false,
29
+ contextUseEvidence: false,
30
+ artifactBodies: false,
31
+ artifactReferences: false,
32
+ subagents: false,
33
+ compactionCheckpoints: false,
34
+ contextRecalled: false,
35
+ });
36
+
37
+ function capabilities(overrides) {
38
+ return Object.freeze({ ...BASE_CAPABILITIES, ...overrides });
39
+ }
40
+
41
+ export const CLIENT_REGISTRY = Object.freeze({
42
+ "claude-code": Object.freeze({
43
+ clientKind: "claude-code",
44
+ label: "Claude Code",
45
+ command: "claude",
46
+ coverage: "complete",
47
+ capabilities: capabilities({
48
+ sessionStart: true, userPromptSubmit: true, stop: true, preCompact: true,
49
+ sessionEnd: true, subagentStart: true, subagentStop: true,
50
+ postToolUse: true, postToolUseFailure: true, postToolBatch: true,
51
+ userMessages: true, assistantMessages: true, toolInputs: true,
52
+ toolOutputs: true, toolFailures: true, artifactReferences: true,
53
+ subagents: true, compactionCheckpoints: true, contextRecalled: true,
54
+ }),
55
+ }),
56
+ cursor: Object.freeze({
57
+ clientKind: "cursor",
58
+ label: "Cursor",
59
+ command: "cursor",
60
+ coverage: "complete",
61
+ capabilities: capabilities({
62
+ sessionStart: true, userPromptSubmit: true, stop: true, preCompact: true,
63
+ sessionEnd: true, subagentStart: true, subagentStop: true,
64
+ postToolUse: true, postToolUseFailure: true, userMessages: true,
65
+ assistantMessages: true, toolInputs: true, toolOutputs: true,
66
+ toolFailures: true, artifactReferences: true, subagents: true,
67
+ compactionCheckpoints: true, contextRecalled: true,
68
+ }),
69
+ }),
70
+ "gemini-cli": Object.freeze({
71
+ clientKind: "gemini-cli",
72
+ label: "Gemini CLI",
73
+ command: "gemini",
74
+ coverage: "complete",
75
+ capabilities: capabilities({
76
+ sessionStart: true, userPromptSubmit: true, preCompact: true,
77
+ sessionEnd: true, postToolUse: true, postToolUseFailure: true,
78
+ userMessages: true, assistantMessages: true, toolInputs: true,
79
+ toolOutputs: true, toolFailures: true, artifactReferences: true,
80
+ compactionCheckpoints: true, contextRecalled: true,
81
+ }),
82
+ }),
83
+ vscode: Object.freeze({
84
+ clientKind: "vscode",
85
+ label: "VS Code",
86
+ command: "code",
87
+ coverage: "partial",
88
+ reason: "assistant_message_hook_unavailable",
89
+ capabilities: capabilities({
90
+ sessionStart: true, userPromptSubmit: true, stop: true, preCompact: true,
91
+ subagentStart: true, subagentStop: true, postToolUse: true,
92
+ postToolUseFailure: true, userMessages: true, toolInputs: true,
93
+ toolOutputs: true, toolFailures: true, subagents: true,
94
+ compactionCheckpoints: true, contextRecalled: true,
95
+ }),
96
+ }),
97
+ codex: Object.freeze({
98
+ clientKind: "codex",
99
+ label: "Codex CLI",
100
+ command: "codex",
101
+ coverage: "partial",
102
+ reason: "assistant_message_session_end_and_tool_failure_hooks_unavailable",
103
+ capabilities: capabilities({
104
+ sessionStart: true, userPromptSubmit: true, stop: true, preCompact: true,
105
+ subagentStart: true, subagentStop: true, postToolUse: true,
106
+ userMessages: true, toolInputs: true, toolOutputs: true, subagents: true,
107
+ compactionCheckpoints: true, contextRecalled: true,
108
+ }),
109
+ }),
110
+ cline: Object.freeze({
111
+ clientKind: "cline",
112
+ label: "Cline",
113
+ command: null,
114
+ coverage: "partial",
115
+ reason: "assistant_message_session_end_and_tool_failure_hooks_unavailable",
116
+ capabilities: capabilities({
117
+ sessionStart: true, userPromptSubmit: true, postToolUse: true,
118
+ userMessages: true, toolInputs: true, toolOutputs: true,
119
+ contextRecalled: true,
120
+ }),
121
+ }),
122
+ });
123
+
124
+ export function lifecycleClient(clientKind) {
125
+ const client = CLIENT_REGISTRY[String(clientKind || "")];
126
+ if (!client) throw new Error("unsupported lifecycle client");
127
+ return client;
128
+ }
@@ -0,0 +1,292 @@
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 VSCODE_HOOKS = Object.freeze({
141
+ SessionStart: "SessionStart",
142
+ UserPromptSubmit: "UserPromptSubmit",
143
+ PostToolUse: "PostToolUse",
144
+ PreCompact: "PreCompact",
145
+ Stop: "Stop",
146
+ SubagentStart: "SubagentStart",
147
+ SubagentStop: "SubagentStop",
148
+ });
149
+
150
+ export async function configureVscode({
151
+ projectRoot,
152
+ installationId,
153
+ nodePath = process.execPath,
154
+ runtimePath,
155
+ hooksPath = join(homedir(), ".copilot", "hooks", "halofy.json"),
156
+ }) {
157
+ validateInputs({ installationId, runtimePath });
158
+ const hooks = await readJson(hooksPath, {});
159
+ mergeFlatHooks(hooks, VSCODE_HOOKS, (eventName) => ({
160
+ type: "command",
161
+ command: managedCommand(nodePath, runtimePath, eventName, installationId),
162
+ timeout: 20,
163
+ }));
164
+ const mcpPath = join(resolve(projectRoot), ".vscode", "mcp.json");
165
+ const mcp = await readJson(mcpPath, {});
166
+ mcp.servers = mcp.servers && typeof mcp.servers === "object" ? mcp.servers : {};
167
+ const replacedLegacyMcpEntries = replaceHalofyMcpEntries(
168
+ mcp.servers,
169
+ managedMcp(nodePath, runtimePath, installationId, { explicitType: true }),
170
+ );
171
+ await writePrivateFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
172
+ await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
173
+ return { configuredPaths: [hooksPath, mcpPath], hookEvents: Object.keys(VSCODE_HOOKS), replacedLegacyMcpEntries };
174
+ }
175
+
176
+ const CODEX_HOOKS = Object.freeze({
177
+ SessionStart: "SessionStart",
178
+ UserPromptSubmit: "UserPromptSubmit",
179
+ PostToolUse: "PostToolUse",
180
+ PreCompact: "PreCompact",
181
+ Stop: "Stop",
182
+ SubagentStart: "SubagentStart",
183
+ SubagentStop: "SubagentStop",
184
+ });
185
+
186
+ function replaceTomlSection(source, heading, replacement) {
187
+ const lines = String(source || "").split(/\r?\n/);
188
+ const start = lines.findIndex((line) => line.trim() === `[${heading}]`);
189
+ if (start !== -1) {
190
+ let end = start + 1;
191
+ while (end < lines.length && !/^\s*\[[^\]]+\]\s*$/.test(lines[end])) end += 1;
192
+ lines.splice(start, end - start);
193
+ }
194
+ const body = lines.join("\n").trimEnd();
195
+ return `${body ? `${body}\n\n` : ""}${replacement}\n`;
196
+ }
197
+
198
+ function removeTomlSection(source, heading) {
199
+ const lines = String(source || "").split(/\r?\n/);
200
+ const start = lines.findIndex((line) => line.trim() === `[${heading}]`);
201
+ if (start === -1) return { source: String(source || ""), removed: false, legacy: false };
202
+ let end = start + 1;
203
+ while (end < lines.length && !/^\s*\[[^\]]+\]\s*$/.test(lines[end])) end += 1;
204
+ const section = lines.slice(start, end).join("\n");
205
+ lines.splice(start, end - start);
206
+ return {
207
+ source: `${lines.join("\n").trimEnd()}\n`,
208
+ removed: true,
209
+ legacy: !/halofy-agent\.mjs|--connection/.test(section),
210
+ };
211
+ }
212
+
213
+ function enableTomlFeature(source, name) {
214
+ const lines = String(source || "").split(/\r?\n/);
215
+ const start = lines.findIndex((line) => line.trim() === "[features]");
216
+ if (start === -1) return `${String(source || "").trimEnd()}${String(source || "").trim() ? "\n\n" : ""}[features]\n${name} = true\n`;
217
+ let end = start + 1;
218
+ while (end < lines.length && !/^\s*\[[^\]]+\]\s*$/.test(lines[end])) end += 1;
219
+ const key = new RegExp(`^\\s*${name}\\s*=`);
220
+ const existing = lines.slice(start + 1, end).findIndex((line) => key.test(line));
221
+ if (existing === -1) lines.splice(end, 0, `${name} = true`);
222
+ else lines[start + 1 + existing] = `${name} = true`;
223
+ return `${lines.join("\n").trimEnd()}\n`;
224
+ }
225
+
226
+ export async function configureCodex({
227
+ installationId,
228
+ nodePath = process.execPath,
229
+ runtimePath,
230
+ codexRoot = process.env.CODEX_HOME || join(homedir(), ".codex"),
231
+ }) {
232
+ validateInputs({ installationId, runtimePath });
233
+ const hooksPath = join(codexRoot, "hooks.json");
234
+ const hooks = await readJson(hooksPath, {});
235
+ mergeFlatHooks(hooks, CODEX_HOOKS, (eventName) => ({
236
+ hooks: [{ type: "command", command: managedCommand(nodePath, runtimePath, eventName, installationId), timeout: 20 }],
237
+ }));
238
+ const configPath = join(codexRoot, "config.toml");
239
+ let source = "";
240
+ try { source = await readFile(configPath, "utf8"); } catch (error) { if (error?.code !== "ENOENT") throw error; }
241
+ const section = [
242
+ "[mcp_servers.halofy]",
243
+ `command = ${JSON.stringify(resolve(nodePath))}`,
244
+ `args = ${JSON.stringify([resolve(runtimePath), "mcp", "--connection", installationId])}`,
245
+ ].join("\n");
246
+ await writePrivateFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
247
+ const withoutHalomem = removeTomlSection(source, "mcp_servers.halomem");
248
+ const withoutHalofy = removeTomlSection(withoutHalomem.source, "mcp_servers.halofy");
249
+ const withMcp = replaceTomlSection(withoutHalofy.source, "mcp_servers.halofy", section);
250
+ await writePrivateFile(configPath, enableTomlFeature(withMcp, "hooks"));
251
+ return {
252
+ configuredPaths: [hooksPath, configPath],
253
+ hookEvents: Object.keys(CODEX_HOOKS),
254
+ replacedLegacyMcpEntries: Number(withoutHalomem.legacy) + Number(withoutHalofy.legacy),
255
+ };
256
+ }
257
+
258
+ const CLINE_HOOKS = Object.freeze({
259
+ TaskStart: "TaskStart",
260
+ UserPromptSubmit: "UserPromptSubmit",
261
+ PostToolUse: "PostToolUse",
262
+ TaskCancel: "TaskCancel",
263
+ });
264
+
265
+ export async function configureCline({
266
+ installationId,
267
+ nodePath = process.execPath,
268
+ runtimePath,
269
+ clineRoot = join(homedir(), "Documents", "Cline"),
270
+ }) {
271
+ validateInputs({ installationId, runtimePath });
272
+ const hooksRoot = join(clineRoot, "Hooks");
273
+ await ensurePrivateDirectory(hooksRoot);
274
+ const configuredPaths = [];
275
+ for (const [hostEvent, runtimeEvent] of Object.entries(CLINE_HOOKS)) {
276
+ const path = join(hooksRoot, hostEvent);
277
+ const command = managedCommand(nodePath, runtimePath, runtimeEvent, installationId);
278
+ await writePrivateFile(path, `#!/usr/bin/env sh\nexec ${command}\n`);
279
+ if (process.platform !== "win32") await chmod(path, 0o700);
280
+ configuredPaths.push(path);
281
+ }
282
+ const mcpPath = join(clineRoot, "MCP", "cline_mcp_settings.json");
283
+ const mcp = await readJson(mcpPath, {});
284
+ mcp.mcpServers = mcp.mcpServers && typeof mcp.mcpServers === "object" ? mcp.mcpServers : {};
285
+ const replacedLegacyMcpEntries = replaceHalofyMcpEntries(
286
+ mcp.mcpServers,
287
+ managedMcp(nodePath, runtimePath, installationId),
288
+ );
289
+ await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
290
+ configuredPaths.push(mcpPath);
291
+ return { configuredPaths, hookEvents: Object.keys(CLINE_HOOKS), replacedLegacyMcpEntries };
292
+ }
@@ -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, 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,25 @@ 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 === "vscode"
176
+ ? await configureVscode(common)
177
+ : input.clientKind === "codex"
178
+ ? await configureCodex(common)
179
+ : input.clientKind === "cline"
180
+ ? await configureCline(common)
181
+ : (() => { throw new Error("the selected adapter is not packaged yet"); })();
119
182
  let heartbeat = false;
120
183
  try {
121
184
  heartbeat = await heartbeatInstalledConnection({
@@ -133,12 +196,12 @@ export async function runInstaller(argv, {
133
196
  installerVersion: INSTALLER_VERSION,
134
197
  publishedPackage: true,
135
198
  projectConfigured: true,
136
- configuredPaths: [configured.mcpPath, configured.settingsPath],
137
- replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries,
199
+ configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
200
+ replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
138
201
  mcpConfiguration: localMcpSnippet({
139
202
  proxyPath: bundle.runtimePath,
140
203
  installationId: installed.installationId,
141
204
  }),
142
- nextStep: "Restart Claude Code in this project, then check the connection in Halofy.",
205
+ nextStep: `Restart ${lifecycleClient(input.clientKind).label}, then check the connection in Halofy.`,
143
206
  };
144
207
  }
package/src/runtime.mjs CHANGED
@@ -132,22 +132,39 @@ export class LifecycleRuntime {
132
132
  }
133
133
 
134
134
  async enqueueEvents(hostSessionId, events) {
135
+ const sessionHash = this.sessionHash(hostSessionId);
136
+ await withFileLock(this.operationLockPath, async () => {
137
+ await this.#enqueueEventsUnlocked(sessionHash, events);
138
+ });
139
+ return this.replay();
140
+ }
141
+
142
+ async enqueueSequencedEvents(hostSessionId, factory) {
135
143
  const sessionHash = this.sessionHash(hostSessionId);
136
144
  await withFileLock(this.operationLockPath, async () => {
137
145
  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));
146
+ const events = factory({ sessionHash, nextSequence: cursor.sequence + 1 });
147
+ if (!Array.isArray(events) || events.length < 1 || events.length > this.maxBatchEvents) {
148
+ throw new Error("sequenced host event batch is invalid");
149
+ }
150
+ await this.#enqueueEventsUnlocked(sessionHash, events, cursor);
147
151
  });
148
152
  return this.replay();
149
153
  }
150
154
 
155
+ async #enqueueEventsUnlocked(sessionHash, events, knownCursor) {
156
+ const cursor = knownCursor || await this.cursors.get(sessionHash);
157
+ const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
158
+ const unseenEvents = events.filter((event) => !recentEventKeys.has(event.eventKey));
159
+ if (unseenEvents.length === 0) return;
160
+ await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
161
+ maxBatchEvents: this.maxBatchEvents,
162
+ maxBatchBytes: this.maxBatchBytes,
163
+ acknowledgedSequence: cursor.sequence,
164
+ });
165
+ await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
166
+ }
167
+
151
168
  async replay() {
152
169
  return withFileLock(this.operationLockPath, () => this.#replayUnlocked());
153
170
  }
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.2.0";
3
+ export const RUNTIME_VERSION = "0.2.0";
4
+ export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-29";