@halofy/agent-connect 0.5.0 → 0.6.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 +25 -2
- package/package.json +3 -3
- package/src/claude-config.mjs +4 -4
- package/src/claude-hook.mjs +18 -23
- package/src/client-registry.mjs +9 -2
- package/src/host-config.mjs +17 -16
- package/src/host-hook.mjs +51 -18
- package/src/host-roots.mjs +13 -0
- package/src/install.mjs +100 -6
- package/src/installer-cli.mjs +266 -21
- package/src/runtime.mjs +14 -9
- package/src/session.mjs +41 -6
- package/src/storage.mjs +26 -0
- package/src/transcript-drivers/claude.mjs +33 -0
- package/src/transcript-drivers/codex.mjs +188 -0
- package/src/transcript-drivers/index.mjs +18 -0
- package/src/transcript-drivers/kimi.mjs +180 -0
- package/src/transcript-drivers/shared.mjs +104 -0
- package/src/version.mjs +3 -3
package/README.md
CHANGED
|
@@ -16,10 +16,16 @@ agent connections:
|
|
|
16
16
|
- a stdio-to-signed-Streamable-HTTP MCP proof proxy; and
|
|
17
17
|
- one local claim consumer and proof runtime shared by every packaged adapter.
|
|
18
18
|
|
|
19
|
+
Hook-driven recall injection is disabled in this release
|
|
20
|
+
(`RECALL_INJECTION_ENABLED` in `src/session.mjs`): the runtime captures
|
|
21
|
+
conversations and serves the agent-invoked MCP memory tools, but does not push
|
|
22
|
+
recalled memory into host sessions on session start or prompt submit. The
|
|
23
|
+
bounded recall block formats stay in place and tested for when it returns.
|
|
24
|
+
|
|
19
25
|
There is one setup path for every packaged client:
|
|
20
26
|
|
|
21
27
|
```bash
|
|
22
|
-
npx --yes @halofy/agent-connect@0.
|
|
28
|
+
npx --yes @halofy/agent-connect@0.6.0 install <client-kind> \
|
|
23
29
|
--server https://app.halofy.ai \
|
|
24
30
|
--claim '<one-time-claim>'
|
|
25
31
|
```
|
|
@@ -44,7 +50,10 @@ to conversation-capture evidence by its name.
|
|
|
44
50
|
|
|
45
51
|
Run the server-returned command in the operating-system terminal from the
|
|
46
52
|
computer where the selected client runs, never in agent chat. The installer displays
|
|
47
|
-
the capture disclosure
|
|
53
|
+
the capture disclosure — including the organization the claim binds to, fetched
|
|
54
|
+
from the named server so a spoofed `--server` is recognizable before
|
|
55
|
+
confirmation (`unverified` when the server cannot identify the claim) —
|
|
56
|
+
requires the recipient to type `CONNECT`, generates
|
|
48
57
|
the Ed25519 private key locally, consumes the one-use claim in a JSON body,
|
|
49
58
|
copies the reviewed runtime out of the transient npx cache, and installs the
|
|
50
59
|
signed MCP proxy and lifecycle hooks together. It replaces an existing Halofy
|
|
@@ -72,6 +81,20 @@ represented by digest-only placeholders and make coverage partial;
|
|
|
72
81
|
encrypted retry queue, and real-host fixtures are verified. The adapter never
|
|
73
82
|
opens an arbitrary transcript-referenced local file to fill that gap.
|
|
74
83
|
|
|
84
|
+
Since 0.6.0, reviewed transcript drivers extend host-reported token usage and
|
|
85
|
+
content-free session metadata to Codex CLI (rollout files) and Kimi Code CLI
|
|
86
|
+
(session wire files). The same containment commitment applies: a driver never
|
|
87
|
+
opens a hook-supplied path. It derives the session file from a validated
|
|
88
|
+
session id (`[A-Za-z0-9_-]{1,128}`) under the host's own root
|
|
89
|
+
(`CODEX_HOME`/`~/.codex`, `KIMI_CODE_HOME`/`~/.kimi-code`), and every
|
|
90
|
+
index-supplied or cached path must realpath-resolve inside that root or the
|
|
91
|
+
read is skipped. Capture additionally requires the installation's frozen
|
|
92
|
+
`tokenUsage` capability — installations consented before 0.6.0 never have
|
|
93
|
+
their transcripts read until reinstalled under the current disclosure. A
|
|
94
|
+
single `install all --claims <kind>=<claim>,…` invocation sweeps the claimed,
|
|
95
|
+
detected hosts with one CONNECT covering the explicitly listed set; each host
|
|
96
|
+
keeps its own installation, capabilities, and status.
|
|
97
|
+
|
|
75
98
|
Run focused checks from this directory:
|
|
76
99
|
|
|
77
100
|
```bash
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@halofy/agent-connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Halofy lifecycle installer and runtime for supported agents; runtime requests are signed with a per-installation Ed25519 key",
|
|
6
6
|
"bin": {
|
|
7
7
|
"agent-connect": "bin/install.mjs",
|
|
8
8
|
"halofy-agent": "bin/halofy-agent.mjs"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
26
|
"test": "node --test test/*.test.mjs",
|
|
27
|
-
"check": "node --check src/*.mjs && node --check bin/*.mjs",
|
|
27
|
+
"check": "node --check src/*.mjs && node --check src/transcript-drivers/*.mjs && node --check bin/*.mjs",
|
|
28
28
|
"prepack": "npm run check && npm test"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
package/src/claude-config.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
-
import { readJson,
|
|
3
|
+
import { readJson, writeHostConfigFile } from "./storage.mjs";
|
|
4
4
|
|
|
5
5
|
const CLAUDE_HOOKS = Object.freeze({
|
|
6
6
|
SessionStart: { matcher: "startup|resume|clear|compact", timeout: 15 },
|
|
@@ -124,12 +124,12 @@ export async function configureClaudeProject({
|
|
|
124
124
|
replacedClaudeMcpEntries += removeHalofyEntries(project.mcpServers, server.toString());
|
|
125
125
|
}
|
|
126
126
|
if (replacedClaudeMcpEntries > 0) {
|
|
127
|
-
await
|
|
127
|
+
await writeHostConfigFile(claudeConfigPath, `${JSON.stringify(claude, null, 2)}\n`);
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
await
|
|
132
|
-
await
|
|
131
|
+
await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
|
|
132
|
+
await writeHostConfigFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
133
133
|
return {
|
|
134
134
|
mcpPath,
|
|
135
135
|
settingsPath,
|
package/src/claude-hook.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { LifecycleRuntime } from "./runtime.mjs";
|
|
4
|
-
import { normalizeClaudeHookEvent } from "./session.mjs";
|
|
4
|
+
import { normalizeClaudeHookEvent, RECALL_INJECTION_ENABLED, rankedRecallBlocks } from "./session.mjs";
|
|
5
5
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
6
6
|
|
|
7
7
|
function id(value) {
|
|
@@ -42,16 +42,7 @@ function childSession(input) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function recallText(result, eventName) {
|
|
45
|
-
const
|
|
46
|
-
const rankedBlocks = blocks
|
|
47
|
-
.filter((block) => block && typeof block.recallRef === "string" &&
|
|
48
|
-
typeof block.content === "string" && block.content.trim())
|
|
49
|
-
.slice(0, 12)
|
|
50
|
-
.map((block, rank) => ({
|
|
51
|
-
rank: rank + 1,
|
|
52
|
-
recallRef: block.recallRef.slice(0, 256),
|
|
53
|
-
content: block.content.trim(),
|
|
54
|
-
}));
|
|
45
|
+
const rankedBlocks = rankedRecallBlocks(result);
|
|
55
46
|
if (rankedBlocks.length === 0) return null;
|
|
56
47
|
return JSON.stringify({
|
|
57
48
|
hookSpecificOutput: {
|
|
@@ -95,20 +86,24 @@ export async function runClaudeLifecycleHook(connection, eventName, {
|
|
|
95
86
|
if (eventName === "SessionStart") {
|
|
96
87
|
await runtime.replay();
|
|
97
88
|
await runtime.heartbeat(connection.capabilities || {});
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
} else if (eventName === "UserPromptSubmit") {
|
|
106
|
-
const prompt = String(hookInput.prompt || hookInput.user_prompt || "").slice(0, 8_000);
|
|
107
|
-
if (prompt.trim()) {
|
|
108
|
-
const recalled = await runtime.recall(session, prompt);
|
|
109
|
-
const output = recallText(recalled, "UserPromptSubmit");
|
|
89
|
+
if (RECALL_INJECTION_ENABLED) {
|
|
90
|
+
const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
|
|
91
|
+
const recalled = await runtime.recall(
|
|
92
|
+
session,
|
|
93
|
+
`${project ? `${project} ` : ""}project policy, conventions, decisions, preferences, and known context`,
|
|
94
|
+
);
|
|
95
|
+
const output = recallText(recalled, "SessionStart");
|
|
110
96
|
if (output) stdout.write(output);
|
|
111
97
|
}
|
|
98
|
+
} else if (eventName === "UserPromptSubmit") {
|
|
99
|
+
if (RECALL_INJECTION_ENABLED) {
|
|
100
|
+
const prompt = String(hookInput.prompt || hookInput.user_prompt || "").slice(0, 8_000);
|
|
101
|
+
if (prompt.trim()) {
|
|
102
|
+
const recalled = await runtime.recall(session, prompt);
|
|
103
|
+
const output = recallText(recalled, "UserPromptSubmit");
|
|
104
|
+
if (output) stdout.write(output);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
112
107
|
} else if (eventName === "Stop") {
|
|
113
108
|
if (!hookInput.stop_hook_active) {
|
|
114
109
|
await catchUp(runtime, hookInput);
|
package/src/client-registry.mjs
CHANGED
|
@@ -58,8 +58,9 @@ export const CLIENT_REGISTRY = Object.freeze({
|
|
|
58
58
|
subagents: true, compactionCheckpoints: true, contextRecalled: true,
|
|
59
59
|
// 0.5.0: host-reported model token usage, thinking blocks, structured
|
|
60
60
|
// tool outcomes, and content-free session metadata from the native
|
|
61
|
-
// transcript.
|
|
62
|
-
//
|
|
61
|
+
// transcript. Since 0.6.0 codex and kimi-cli also report usage and
|
|
62
|
+
// metadata via their reviewed transcript drivers; thinking blocks and
|
|
63
|
+
// structured tool outcomes remain Claude Code-only.
|
|
63
64
|
tokenUsage: true, sessionMetadata: true, toolOutcomes: true,
|
|
64
65
|
thinking: true,
|
|
65
66
|
}),
|
|
@@ -104,6 +105,9 @@ export const CLIENT_REGISTRY = Object.freeze({
|
|
|
104
105
|
toolInputs: true, toolOutputs: true, toolFailures: true,
|
|
105
106
|
artifactReferences: true, subagents: true, compactionCheckpoints: true,
|
|
106
107
|
contextRecalled: true,
|
|
108
|
+
// 0.6.0: host-reported usage and content-free session metadata read
|
|
109
|
+
// from the session wire by the reviewed kimi transcript driver.
|
|
110
|
+
tokenUsage: true, sessionMetadata: true,
|
|
107
111
|
}),
|
|
108
112
|
}),
|
|
109
113
|
vscode: Object.freeze({
|
|
@@ -131,6 +135,9 @@ export const CLIENT_REGISTRY = Object.freeze({
|
|
|
131
135
|
subagentStart: true, subagentStop: true, postToolUse: true,
|
|
132
136
|
userMessages: true, toolInputs: true, toolOutputs: true, subagents: true,
|
|
133
137
|
compactionCheckpoints: true, contextRecalled: true,
|
|
138
|
+
// 0.6.0: host-reported usage and content-free session metadata read
|
|
139
|
+
// from the native rollout by the reviewed codex transcript driver.
|
|
140
|
+
tokenUsage: true, sessionMetadata: true,
|
|
134
141
|
}),
|
|
135
142
|
}),
|
|
136
143
|
cline: Object.freeze({
|
package/src/host-config.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { chmod, readFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, readFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { readJson, writeHostConfigFile } from "./storage.mjs";
|
|
5
|
+
import { codexHome, kimiHome } from "./host-roots.mjs";
|
|
5
6
|
|
|
6
7
|
function commandArg(value) {
|
|
7
8
|
const text = String(value);
|
|
@@ -92,8 +93,8 @@ export async function configureCursor({
|
|
|
92
93
|
mcp.mcpServers,
|
|
93
94
|
managedMcp(nodePath, runtimePath, installationId),
|
|
94
95
|
);
|
|
95
|
-
await
|
|
96
|
-
await
|
|
96
|
+
await writeHostConfigFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
|
|
97
|
+
await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
|
|
97
98
|
return { configuredPaths: [hooksPath, mcpPath], hookEvents: Object.keys(CURSOR_HOOKS), replacedLegacyMcpEntries };
|
|
98
99
|
}
|
|
99
100
|
|
|
@@ -133,7 +134,7 @@ export async function configureGemini({
|
|
|
133
134
|
settings.mcpServers,
|
|
134
135
|
managedMcp(nodePath, runtimePath, installationId),
|
|
135
136
|
);
|
|
136
|
-
await
|
|
137
|
+
await writeHostConfigFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
137
138
|
return { configuredPaths: [settingsPath], hookEvents: Object.keys(GEMINI_HOOKS), replacedLegacyMcpEntries };
|
|
138
139
|
}
|
|
139
140
|
|
|
@@ -172,7 +173,7 @@ export async function configureKimi({
|
|
|
172
173
|
installationId,
|
|
173
174
|
nodePath = process.execPath,
|
|
174
175
|
runtimePath,
|
|
175
|
-
kimiRoot =
|
|
176
|
+
kimiRoot = kimiHome(),
|
|
176
177
|
}) {
|
|
177
178
|
validateInputs({ installationId, runtimePath });
|
|
178
179
|
const configPath = join(kimiRoot, "config.toml");
|
|
@@ -197,8 +198,8 @@ export async function configureKimi({
|
|
|
197
198
|
mcp.mcpServers,
|
|
198
199
|
managedMcp(nodePath, runtimePath, installationId),
|
|
199
200
|
);
|
|
200
|
-
await
|
|
201
|
-
await
|
|
201
|
+
await writeHostConfigFile(configPath, replaceKimiManagedHooks(source, hookBlock));
|
|
202
|
+
await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
|
|
202
203
|
return { configuredPaths: [configPath, mcpPath], hookEvents: Object.keys(KIMI_HOOKS), replacedLegacyMcpEntries };
|
|
203
204
|
}
|
|
204
205
|
|
|
@@ -233,8 +234,8 @@ export async function configureVscode({
|
|
|
233
234
|
mcp.servers,
|
|
234
235
|
managedMcp(nodePath, runtimePath, installationId, { explicitType: true }),
|
|
235
236
|
);
|
|
236
|
-
await
|
|
237
|
-
await
|
|
237
|
+
await writeHostConfigFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
|
|
238
|
+
await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
|
|
238
239
|
return { configuredPaths: [hooksPath, mcpPath], hookEvents: Object.keys(VSCODE_HOOKS), replacedLegacyMcpEntries };
|
|
239
240
|
}
|
|
240
241
|
|
|
@@ -292,7 +293,7 @@ export async function configureCodex({
|
|
|
292
293
|
installationId,
|
|
293
294
|
nodePath = process.execPath,
|
|
294
295
|
runtimePath,
|
|
295
|
-
codexRoot =
|
|
296
|
+
codexRoot = codexHome(),
|
|
296
297
|
}) {
|
|
297
298
|
validateInputs({ installationId, runtimePath });
|
|
298
299
|
const hooksPath = join(codexRoot, "hooks.json");
|
|
@@ -308,11 +309,11 @@ export async function configureCodex({
|
|
|
308
309
|
`command = ${JSON.stringify(resolve(nodePath))}`,
|
|
309
310
|
`args = ${JSON.stringify([resolve(runtimePath), "mcp", "--connection", installationId])}`,
|
|
310
311
|
].join("\n");
|
|
311
|
-
await
|
|
312
|
+
await writeHostConfigFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
|
|
312
313
|
const withoutHalomem = removeTomlSection(source, "mcp_servers.halomem");
|
|
313
314
|
const withoutHalofy = removeTomlSection(withoutHalomem.source, "mcp_servers.halofy");
|
|
314
315
|
const withMcp = replaceTomlSection(withoutHalofy.source, "mcp_servers.halofy", section);
|
|
315
|
-
await
|
|
316
|
+
await writeHostConfigFile(configPath, enableTomlFeature(withMcp, "hooks"));
|
|
316
317
|
return {
|
|
317
318
|
configuredPaths: [hooksPath, configPath],
|
|
318
319
|
hookEvents: Object.keys(CODEX_HOOKS),
|
|
@@ -335,12 +336,12 @@ export async function configureCline({
|
|
|
335
336
|
}) {
|
|
336
337
|
validateInputs({ installationId, runtimePath });
|
|
337
338
|
const hooksRoot = join(clineRoot, "Hooks");
|
|
338
|
-
await
|
|
339
|
+
await mkdir(hooksRoot, { recursive: true });
|
|
339
340
|
const configuredPaths = [];
|
|
340
341
|
for (const [hostEvent, runtimeEvent] of Object.entries(CLINE_HOOKS)) {
|
|
341
342
|
const path = join(hooksRoot, hostEvent);
|
|
342
343
|
const command = managedCommand(nodePath, runtimePath, runtimeEvent, installationId);
|
|
343
|
-
await
|
|
344
|
+
await writeHostConfigFile(path, `#!/usr/bin/env sh\nexec ${command}\n`);
|
|
344
345
|
if (process.platform !== "win32") await chmod(path, 0o700);
|
|
345
346
|
configuredPaths.push(path);
|
|
346
347
|
}
|
|
@@ -351,7 +352,7 @@ export async function configureCline({
|
|
|
351
352
|
mcp.mcpServers,
|
|
352
353
|
managedMcp(nodePath, runtimePath, installationId),
|
|
353
354
|
);
|
|
354
|
-
await
|
|
355
|
+
await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
|
|
355
356
|
configuredPaths.push(mcpPath);
|
|
356
357
|
return { configuredPaths, hookEvents: Object.keys(CLINE_HOOKS), replacedLegacyMcpEntries };
|
|
357
358
|
}
|
package/src/host-hook.mjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { LifecycleRuntime } from "./runtime.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
normalizeClaudeHookEvent,
|
|
6
|
+
normalizeHostMessageEvent,
|
|
7
|
+
RECALL_INJECTION_ENABLED,
|
|
8
|
+
rankedRecallBlocks,
|
|
9
|
+
} from "./session.mjs";
|
|
5
10
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
6
11
|
import { readHookInput } from "./claude-hook.mjs";
|
|
12
|
+
import { transcriptDriverFor } from "./transcript-drivers/index.mjs";
|
|
7
13
|
|
|
8
14
|
const USER_EVENTS = new Set(["UserPromptSubmit", "beforeSubmitPrompt", "BeforeAgent", "pre_llm_call"]);
|
|
9
15
|
const ASSISTANT_EVENTS = new Set(["afterAgentResponse", "AfterAgent", "post_llm_call", "transform_llm_output"]);
|
|
@@ -62,18 +68,8 @@ function toolOutput(input) {
|
|
|
62
68
|
input.extra?.result ?? input.extra?.error_message;
|
|
63
69
|
}
|
|
64
70
|
|
|
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
71
|
function recallOutput(clientKind, eventName, result) {
|
|
76
|
-
const rankedBlocks =
|
|
72
|
+
const rankedBlocks = rankedRecallBlocks(result);
|
|
77
73
|
if (rankedBlocks.length === 0) return null;
|
|
78
74
|
const additionalContext = `<<<HALOFY_CONTEXT_BLOCKS_V1>>>\n${JSON.stringify({ rankedBlocks })}\n<<<END_HALOFY_CONTEXT_BLOCKS_V1>>>`;
|
|
79
75
|
if (clientKind === "cursor") {
|
|
@@ -121,6 +117,29 @@ async function enqueueTool(runtime, session, eventName, input) {
|
|
|
121
117
|
]);
|
|
122
118
|
}
|
|
123
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Best-effort transcript catch-up for hosts with a reviewed driver. Consent
|
|
122
|
+
* is double-gated: the driver must exist for this client kind AND the
|
|
123
|
+
* install-time frozen capability snapshot must declare tokenUsage — an older
|
|
124
|
+
* installation never has its transcript read, even under an upgraded runtime.
|
|
125
|
+
* Failures are swallowed: capture evidence must never degrade the hook path.
|
|
126
|
+
*/
|
|
127
|
+
async function catchUpHost(runtime, connection, session, sessionFacts = {}) {
|
|
128
|
+
try {
|
|
129
|
+
if (connection.capabilities?.tokenUsage !== true) return;
|
|
130
|
+
const driver = transcriptDriverFor(connection.clientKind);
|
|
131
|
+
if (driver === null) return;
|
|
132
|
+
const located = await driver.locate(session, {});
|
|
133
|
+
if (located === null) return;
|
|
134
|
+
await runtime.captureHostTranscript(session, driver, located.path, {
|
|
135
|
+
...(located.sessionFacts ?? {}),
|
|
136
|
+
...sessionFacts,
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
// Transcript evidence is additive; the hook result stands without it.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
124
143
|
/** Runs one reviewed host hook without blocking the host when Halofy browns out. */
|
|
125
144
|
export async function runHostLifecycleHook(connection, eventName, {
|
|
126
145
|
input,
|
|
@@ -139,15 +158,22 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
139
158
|
if (START_EVENTS.has(eventName)) {
|
|
140
159
|
await runtime.replay();
|
|
141
160
|
await runtime.heartbeat(connection.capabilities || {});
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
161
|
+
if (RECALL_INJECTION_ENABLED) {
|
|
162
|
+
const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
|
|
163
|
+
const recalled = await runtime.recall(session,
|
|
164
|
+
`${project ? `${project} ` : ""}project policy, conventions, decisions, preferences, and known context`);
|
|
165
|
+
const output = recallOutput(connection.clientKind, eventName, recalled);
|
|
166
|
+
if (output) stdout.write(output);
|
|
167
|
+
}
|
|
147
168
|
} else if (USER_EVENTS.has(eventName)) {
|
|
169
|
+
// Prompt capture is independent of recall: hosts without a reviewed
|
|
170
|
+
// transcript driver rely on this enqueue to reach the archive, and
|
|
171
|
+
// hook-sourced events stay authoritative for messages even where a
|
|
172
|
+
// driver adds usage/metadata evidence.
|
|
148
173
|
const prompt = promptText(hookInput);
|
|
149
174
|
await enqueueMessage(runtime, connection, session, "user", prompt, eventName, hookInput);
|
|
150
|
-
if (connection.clientKind !== "cursor" &&
|
|
175
|
+
if (RECALL_INJECTION_ENABLED && connection.clientKind !== "cursor" &&
|
|
176
|
+
typeof prompt === "string" && prompt.trim()) {
|
|
151
177
|
const recalled = await runtime.recall(session, prompt.slice(0, 8_000));
|
|
152
178
|
const output = recallOutput(connection.clientKind, eventName, recalled);
|
|
153
179
|
if (output) stdout.write(output);
|
|
@@ -157,6 +183,7 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
157
183
|
await runtime.commitIfThreshold(session);
|
|
158
184
|
} else if (TOOL_EVENTS.has(eventName)) {
|
|
159
185
|
await enqueueTool(runtime, session, eventName, hookInput);
|
|
186
|
+
await catchUpHost(runtime, connection, session);
|
|
160
187
|
} else if (COMPACT_EVENTS.has(eventName)) {
|
|
161
188
|
await runtime.enqueueSequencedEvents(session, ({ sessionHash, nextSequence }) => [
|
|
162
189
|
normalizeClaudeHookEvent("compaction", {
|
|
@@ -164,14 +191,20 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
164
191
|
event_id: eventEvidenceId(eventName, hookInput),
|
|
165
192
|
}, { sessionHash, sequence: nextSequence }),
|
|
166
193
|
]);
|
|
194
|
+
await catchUpHost(runtime, connection, session);
|
|
167
195
|
await runtime.commit(session, "pre_compaction");
|
|
168
196
|
} else if (SUBAGENT_START_EVENTS.has(eventName)) {
|
|
169
197
|
await runtime.resolveSession(childSession(hookInput, session), session);
|
|
170
198
|
} else if (SUBAGENT_STOP_EVENTS.has(eventName)) {
|
|
199
|
+
// Subagent transcript files are a documented per-host limitation; the
|
|
200
|
+
// parent session still catches up so its usage stays current.
|
|
201
|
+
await within(10_000, () => catchUpHost(runtime, connection, session));
|
|
171
202
|
await within(10_000, () => runtime.close(childSession(hookInput, session), "session_end"));
|
|
172
203
|
} else if (END_EVENTS.has(eventName)) {
|
|
204
|
+
await within(10_000, () => catchUpHost(runtime, connection, session, { closeReason: "session_end" }));
|
|
173
205
|
await within(15_000, () => runtime.close(session));
|
|
174
206
|
} else if (STOP_EVENTS.has(eventName)) {
|
|
207
|
+
await within(10_000, () => catchUpHost(runtime, connection, session));
|
|
175
208
|
if (connection.clientKind === "vscode" || connection.clientKind === "cline") {
|
|
176
209
|
await within(15_000, () => runtime.close(session));
|
|
177
210
|
} else {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Single authority for each host's on-disk root. host-config.mjs (hook/MCP
|
|
5
|
+
// installation) and transcript-drivers/ (session-file location) must agree on
|
|
6
|
+
// these paths or capture silently reads the wrong tree.
|
|
7
|
+
export function codexHome(env = process.env) {
|
|
8
|
+
return env.CODEX_HOME || join(homedir(), ".codex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function kimiHome(env = process.env) {
|
|
12
|
+
return env.KIMI_CODE_HOME || join(homedir(), ".kimi-code");
|
|
13
|
+
}
|
package/src/install.mjs
CHANGED
|
@@ -10,6 +10,105 @@ import { CLIENT_KINDS, CLIENT_REGISTRY, lifecycleClient } from "./client-registr
|
|
|
10
10
|
|
|
11
11
|
export const CLAUDE_CAPABILITIES = CLIENT_REGISTRY["claude-code"].capabilities;
|
|
12
12
|
|
|
13
|
+
/** HTTPS-or-loopback validation shared by every request that carries the claim. */
|
|
14
|
+
export function normalizeLifecycleServerUrl(serverUrl) {
|
|
15
|
+
const normalizedServer = new URL(serverUrl);
|
|
16
|
+
if (normalizedServer.protocol !== "https:" && normalizedServer.hostname !== "localhost" &&
|
|
17
|
+
normalizedServer.hostname !== "127.0.0.1" && normalizedServer.hostname !== "[::1]") {
|
|
18
|
+
throw new Error("installation claims require HTTPS (localhost is allowed for development)");
|
|
19
|
+
}
|
|
20
|
+
normalizedServer.pathname = normalizedServer.pathname.replace(/\/+$/, "");
|
|
21
|
+
return normalizedServer.toString().replace(/\/$/, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Best-effort pre-consumption disclosure: which organization this claim binds
|
|
26
|
+
* to, shown before the CONNECT confirmation. An older server without the
|
|
27
|
+
* endpoint, or any failure, yields null — the installer then labels the
|
|
28
|
+
* organization unverified instead of failing. The claim is not consumed.
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchClaimDisclosure({
|
|
31
|
+
serverUrl,
|
|
32
|
+
claim,
|
|
33
|
+
fetchImpl = globalThis.fetch,
|
|
34
|
+
timeoutMs = 5_000,
|
|
35
|
+
}) {
|
|
36
|
+
try {
|
|
37
|
+
const normalizedServerUrl = normalizeLifecycleServerUrl(serverUrl);
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
40
|
+
let response;
|
|
41
|
+
try {
|
|
42
|
+
response = await fetchImpl(`${normalizedServerUrl}/v1/agent-installations/claim-info`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "Content-Type": "application/json" },
|
|
45
|
+
body: JSON.stringify({ claim }),
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
});
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
if (!response.ok) return null;
|
|
52
|
+
const body = await response.json();
|
|
53
|
+
// Server-supplied text is printed to a terminal: strip control characters
|
|
54
|
+
// (including ANSI escape introducers) and bound the length.
|
|
55
|
+
const organization = typeof body?.organization === "string"
|
|
56
|
+
? body.organization.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, 100)
|
|
57
|
+
: "";
|
|
58
|
+
return organization ? { organization } : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Batch pre-consumption disclosure for the install-all sweep. One POST — and
|
|
66
|
+
* therefore one throttle token — covers every claim in the command, so a full
|
|
67
|
+
* sweep (1 disclosure + N consumes) fits the per-address budget. Entries come
|
|
68
|
+
* back positionally; an unusable claim is null. Any failure yields null for
|
|
69
|
+
* the whole batch (older server), never an error.
|
|
70
|
+
*/
|
|
71
|
+
export async function fetchClaimDisclosures({
|
|
72
|
+
serverUrl,
|
|
73
|
+
claims,
|
|
74
|
+
fetchImpl = globalThis.fetch,
|
|
75
|
+
timeoutMs = 5_000,
|
|
76
|
+
}) {
|
|
77
|
+
try {
|
|
78
|
+
const normalizedServerUrl = normalizeLifecycleServerUrl(serverUrl);
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
81
|
+
let response;
|
|
82
|
+
try {
|
|
83
|
+
response = await fetchImpl(`${normalizedServerUrl}/v1/agent-installations/claim-info`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ claims }),
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
});
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
if (!response.ok) return null;
|
|
93
|
+
const body = await response.json();
|
|
94
|
+
if (!Array.isArray(body?.disclosures)) return null;
|
|
95
|
+
return claims.map((_, index) => {
|
|
96
|
+
const entry = body.disclosures[index];
|
|
97
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
98
|
+
// Server-supplied text is printed to a terminal: strip control
|
|
99
|
+
// characters (including ANSI escape introducers) and bound the length.
|
|
100
|
+
const organization = typeof entry.organization === "string"
|
|
101
|
+
? entry.organization.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, 100)
|
|
102
|
+
: "";
|
|
103
|
+
const clientKind = typeof entry.clientKind === "string" &&
|
|
104
|
+
/^[a-z0-9][a-z0-9-]{0,63}$/.test(entry.clientKind) ? entry.clientKind : null;
|
|
105
|
+
return organization ? { organization, clientKind } : null;
|
|
106
|
+
});
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
13
112
|
export async function consumeInstallationClaim({
|
|
14
113
|
serverUrl,
|
|
15
114
|
claim,
|
|
@@ -51,12 +150,7 @@ export async function installLocalConnection({
|
|
|
51
150
|
}) {
|
|
52
151
|
if (!CLIENT_KINDS.includes(clientKind)) throw new Error("the selected lifecycle adapter is not packaged");
|
|
53
152
|
const client = lifecycleClient(clientKind);
|
|
54
|
-
const
|
|
55
|
-
if (normalizedServer.protocol !== "https:" && normalizedServer.hostname !== "localhost" && normalizedServer.hostname !== "127.0.0.1") {
|
|
56
|
-
throw new Error("installation claims require HTTPS (localhost is allowed for development)");
|
|
57
|
-
}
|
|
58
|
-
normalizedServer.pathname = normalizedServer.pathname.replace(/\/+$/, "");
|
|
59
|
-
const normalizedServerUrl = normalizedServer.toString().replace(/\/$/, "");
|
|
153
|
+
const normalizedServerUrl = normalizeLifecycleServerUrl(serverUrl);
|
|
60
154
|
const store = new ConnectionStore(root);
|
|
61
155
|
const pendingPath = join(root, `pending-${clientKind}.json`);
|
|
62
156
|
const priorPending = await readJson(pendingPath);
|