@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 +31 -9
- package/bin/halofy-agent.mjs +5 -3
- package/package.json +1 -1
- package/src/claude-hook.mjs +22 -6
- package/src/client-registry.mjs +144 -0
- package/src/host-config.mjs +354 -0
- package/src/host-hook.mjs +188 -0
- package/src/install.mjs +7 -30
- package/src/installer-cli.mjs +83 -18
- package/src/runtime.mjs +28 -10
- package/src/session.mjs +31 -0
- package/src/version.mjs +3 -3
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
|
-
-
|
|
17
|
+
- one local claim consumer and proof runtime shared by every packaged adapter.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
There is one setup path for every packaged client:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
npx --yes @halofy/agent-connect@0.
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/bin/halofy-agent.mjs
CHANGED
|
@@ -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
|
|
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" && !
|
|
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
package/src/claude-hook.mjs
CHANGED
|
@@ -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) => {
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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:
|
|
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(
|
|
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 },
|
package/src/installer-cli.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
102
|
+
`Halofy ${client.label} lifecycle connection`,
|
|
57
103
|
`Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
|
|
58
104
|
`Server: ${new URL(serverUrl).origin}`,
|
|
59
|
-
|
|
105
|
+
`${client.label}: ${clientVersion}`,
|
|
60
106
|
`Project: ${projectRoot}`,
|
|
61
107
|
"",
|
|
62
|
-
"This single installation replaces an existing Halofy bearer MCP entry
|
|
108
|
+
"This single installation replaces an existing Halofy bearer MCP entry where supported and enables:",
|
|
63
109
|
"- governed memory tools and recall",
|
|
64
|
-
|
|
65
|
-
"- supported tool
|
|
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
|
-
|
|
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
|
|
100
|
-
output.write(`${disclosureText({ ...input,
|
|
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
|
|
163
|
+
const common = {
|
|
113
164
|
projectRoot: input.projectRoot,
|
|
114
165
|
installationId: installed.installationId,
|
|
115
166
|
serverUrl: input.serverUrl,
|
|
116
167
|
runtimePath: bundle.runtimePath,
|
|
117
|
-
|
|
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:
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 ||
|
|
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.
|
|
3
|
-
export const RUNTIME_VERSION = "0.
|
|
4
|
-
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-
|
|
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";
|