@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/src/installer-cli.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { createInterface } from "node:readline/promises";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import {
|
|
7
|
+
fetchClaimDisclosure,
|
|
8
|
+
fetchClaimDisclosures,
|
|
7
9
|
heartbeatInstalledConnection,
|
|
8
10
|
installLocalConnection,
|
|
9
11
|
installRuntimeBundle,
|
|
@@ -15,9 +17,50 @@ import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
|
|
|
15
17
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
16
18
|
import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
|
|
17
19
|
|
|
20
|
+
const CLAIM_PATTERN = /^hsc_[A-Za-z0-9_-]{43}$/;
|
|
21
|
+
|
|
22
|
+
function parseAllInstallerArgs(argv) {
|
|
23
|
+
const values = new Map();
|
|
24
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
25
|
+
const name = argv[index];
|
|
26
|
+
if (!["--server", "--claims", "--claude-project"].includes(name) || values.has(name)) {
|
|
27
|
+
throw new Error("unsupported or duplicate installer argument");
|
|
28
|
+
}
|
|
29
|
+
const value = argv[index + 1];
|
|
30
|
+
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
|
31
|
+
values.set(name, value);
|
|
32
|
+
index += 1;
|
|
33
|
+
}
|
|
34
|
+
const serverUrl = values.get("--server");
|
|
35
|
+
const rawClaims = values.get("--claims");
|
|
36
|
+
const usage = "Usage: agent-connect install all --server <https-url> --claims <client>=<one-use-claim>[,...]";
|
|
37
|
+
if (!serverUrl || !rawClaims) throw new Error(usage);
|
|
38
|
+
const pairs = rawClaims.split(",");
|
|
39
|
+
if (pairs.length < 1 || pairs.length > CLIENT_KINDS.length) throw new Error(usage);
|
|
40
|
+
const selections = [];
|
|
41
|
+
const kinds = new Set();
|
|
42
|
+
for (const pair of pairs) {
|
|
43
|
+
const separator = pair.indexOf("=");
|
|
44
|
+
const clientKind = separator === -1 ? "" : pair.slice(0, separator);
|
|
45
|
+
const claim = separator === -1 ? "" : pair.slice(separator + 1);
|
|
46
|
+
if (!CLIENT_KINDS.includes(clientKind) || !CLAIM_PATTERN.test(claim) || kinds.has(clientKind)) {
|
|
47
|
+
throw new Error(usage);
|
|
48
|
+
}
|
|
49
|
+
kinds.add(clientKind);
|
|
50
|
+
selections.push({ clientKind, claim });
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
mode: "all",
|
|
54
|
+
selections,
|
|
55
|
+
serverUrl,
|
|
56
|
+
projectRoot: resolve(values.get("--claude-project") || process.cwd()),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
18
60
|
export function parseInstallerArgs(argv) {
|
|
19
61
|
const command = argv[0];
|
|
20
62
|
const clientKind = argv[1];
|
|
63
|
+
if (command === "install" && clientKind === "all") return parseAllInstallerArgs(argv);
|
|
21
64
|
const values = new Map();
|
|
22
65
|
for (let index = 2; index < argv.length; index += 1) {
|
|
23
66
|
const name = argv[index];
|
|
@@ -36,6 +79,7 @@ export function parseInstallerArgs(argv) {
|
|
|
36
79
|
throw new Error("Usage: agent-connect install <supported-client> --server <https-url> --claim <one-use-claim>");
|
|
37
80
|
}
|
|
38
81
|
return {
|
|
82
|
+
mode: "single",
|
|
39
83
|
clientKind,
|
|
40
84
|
serverUrl,
|
|
41
85
|
claim,
|
|
@@ -81,8 +125,20 @@ export function detectClient(clientKind) {
|
|
|
81
125
|
return String(result.stdout || result.stderr || "").trim().slice(0, 128) || "detected";
|
|
82
126
|
}
|
|
83
127
|
|
|
84
|
-
|
|
85
|
-
|
|
128
|
+
/** Non-throwing detection probe for the install-all sweep. */
|
|
129
|
+
export async function probeClient(clientKind, {
|
|
130
|
+
detectClaude = detectClaudeCode,
|
|
131
|
+
detectHost = detectClient,
|
|
132
|
+
} = {}) {
|
|
133
|
+
try {
|
|
134
|
+
const version = clientKind === "claude-code" ? await detectClaude() : await detectHost(clientKind);
|
|
135
|
+
return { detected: true, version };
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return { detected: false, reason: error?.message || "not detected" };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function captureCategories(client) {
|
|
86
142
|
const observedCategories = [
|
|
87
143
|
["user messages", client.capabilities.userMessages],
|
|
88
144
|
["assistant messages", client.capabilities.assistantMessages],
|
|
@@ -100,17 +156,27 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
100
156
|
["tool outcomes (durations, failure flags, byte sizes; file paths only as salted hashes)", client.capabilities.toolOutcomes],
|
|
101
157
|
["host and session metadata (app version, permission mode, effort, session title; directory paths hashed unless your organization enables full device context)", client.capabilities.sessionMetadata],
|
|
102
158
|
];
|
|
103
|
-
|
|
104
|
-
|
|
159
|
+
return {
|
|
160
|
+
supported: observedCategories.filter(([, value]) => value === true).map(([name]) => name),
|
|
161
|
+
unsupported: observedCategories.filter(([, value]) => value !== true).map(([name]) => name),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion, organization = null }) {
|
|
166
|
+
const client = lifecycleClient(clientKind);
|
|
167
|
+
const { supported, unsupported } = captureCategories(client);
|
|
105
168
|
return [
|
|
106
169
|
`Halofy ${client.label} lifecycle connection`,
|
|
107
170
|
`Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
|
|
108
171
|
`Server: ${new URL(serverUrl).origin}`,
|
|
172
|
+
// The server named by --server identifies the organization this claim
|
|
173
|
+
// binds to, so a spoofed command is recognizable before CONNECT.
|
|
174
|
+
`Organization: ${organization || "unverified (the server did not identify this claim's organization)"}`,
|
|
109
175
|
`${client.label}: ${clientVersion}`,
|
|
110
176
|
`Project: ${projectRoot}`,
|
|
111
177
|
"",
|
|
112
178
|
"This single installation replaces an existing Halofy bearer MCP entry where supported and enables:",
|
|
113
|
-
"- governed memory tools
|
|
179
|
+
"- governed memory tools the agent invokes explicitly (no automatic recall is injected into sessions),",
|
|
114
180
|
`- conversation events exposed by ${client.label}'s reviewed hooks,`,
|
|
115
181
|
"- explicitly supported tool, subagent, compaction, and close evidence,",
|
|
116
182
|
"- encrypted local retry queue and governed retained conversations, and",
|
|
@@ -128,6 +194,55 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
128
194
|
].join("\n");
|
|
129
195
|
}
|
|
130
196
|
|
|
197
|
+
/**
|
|
198
|
+
* One disclosure for the whole sweep: shared header, one capture-category
|
|
199
|
+
* section per detected host, and an explicit statement of which minted claims
|
|
200
|
+
* will expire unused. One CONNECT then covers exactly the listed hosts —
|
|
201
|
+
* explicit per-host consent, never a silent fan-out.
|
|
202
|
+
*/
|
|
203
|
+
export function allDisclosureText({ serverUrl, projectRoot, organization = null, hosts, unusedKinds = [] }) {
|
|
204
|
+
const lines = [
|
|
205
|
+
"Halofy all-agents lifecycle connection",
|
|
206
|
+
`Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
|
|
207
|
+
`Server: ${new URL(serverUrl).origin}`,
|
|
208
|
+
// The server named by --server identifies the organization this command
|
|
209
|
+
// binds to, so a spoofed command is recognizable before CONNECT.
|
|
210
|
+
`Organization: ${organization || "unverified (the server did not identify this command's organization)"}`,
|
|
211
|
+
`Project: ${projectRoot}`,
|
|
212
|
+
`Hosts to be connected: ${hosts.map((host) => lifecycleClient(host.clientKind).label).join(", ")}`,
|
|
213
|
+
"",
|
|
214
|
+
"Each host gets its own installation binding, capability record, and connection status:",
|
|
215
|
+
];
|
|
216
|
+
for (const host of hosts) {
|
|
217
|
+
const client = lifecycleClient(host.clientKind);
|
|
218
|
+
const { supported, unsupported } = captureCategories(client);
|
|
219
|
+
lines.push(
|
|
220
|
+
"",
|
|
221
|
+
`--- ${client.label} (${host.clientVersion}) ---`,
|
|
222
|
+
`Supported capture categories: ${supported.length > 0 ? supported.join(", ") : "none"}.`,
|
|
223
|
+
`Unsupported capture categories: ${unsupported.length > 0 ? unsupported.join(", ") : "none"}.`,
|
|
224
|
+
client.coverage === "complete"
|
|
225
|
+
? "This reviewed host surface can report complete coverage when all declared evidence is observed."
|
|
226
|
+
: `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (unusedKinds.length > 0) {
|
|
230
|
+
lines.push(
|
|
231
|
+
"",
|
|
232
|
+
`Claims for ${unusedKinds.map((kind) => lifecycleClient(kind).label).join(", ")} were issued ` +
|
|
233
|
+
"but those hosts were not detected here; they expire unused within 10 minutes.",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
lines.push(
|
|
237
|
+
"",
|
|
238
|
+
"Authorized organization managers may review retained conversations and summaries.",
|
|
239
|
+
"This does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
|
|
240
|
+
"Disconnecting stops future capture but does not erase retained data.",
|
|
241
|
+
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
242
|
+
);
|
|
243
|
+
return lines.join("\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
131
246
|
export async function confirmDisclosure({ input = process.stdin, output = process.stdout } = {}) {
|
|
132
247
|
if (!input.isTTY || !output.isTTY) throw new Error("interactive terminal confirmation is required");
|
|
133
248
|
const prompt = createInterface({ input, output });
|
|
@@ -140,6 +255,136 @@ export async function confirmDisclosure({ input = process.stdin, output = proces
|
|
|
140
255
|
return true;
|
|
141
256
|
}
|
|
142
257
|
|
|
258
|
+
async function configureHost(clientKind, common, { claudeConfigPath } = {}) {
|
|
259
|
+
if (clientKind === "claude-code") {
|
|
260
|
+
return configureClaudeProject({ ...common, ...(claudeConfigPath ? { claudeConfigPath } : {}) });
|
|
261
|
+
}
|
|
262
|
+
if (clientKind === "cursor") return configureCursor(common);
|
|
263
|
+
if (clientKind === "gemini-cli") return configureGemini(common);
|
|
264
|
+
if (clientKind === "kimi-cli") return configureKimi(common);
|
|
265
|
+
if (clientKind === "vscode") return configureVscode(common);
|
|
266
|
+
if (clientKind === "codex") return configureCodex(common);
|
|
267
|
+
if (clientKind === "cline") return configureCline(common);
|
|
268
|
+
throw new Error("the selected adapter is not packaged yet");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function runAllInstaller(input, {
|
|
272
|
+
root,
|
|
273
|
+
output,
|
|
274
|
+
detectClaude,
|
|
275
|
+
detectHost,
|
|
276
|
+
confirm,
|
|
277
|
+
fetchImpl,
|
|
278
|
+
sourceRoot,
|
|
279
|
+
claudeConfigPath,
|
|
280
|
+
}) {
|
|
281
|
+
const probes = [];
|
|
282
|
+
for (const selection of input.selections) {
|
|
283
|
+
probes.push({ ...selection, probe: await probeClient(selection.clientKind, { detectClaude, detectHost }) });
|
|
284
|
+
}
|
|
285
|
+
const detected = probes.filter((entry) => entry.probe.detected);
|
|
286
|
+
const skipped = probes.filter((entry) => !entry.probe.detected)
|
|
287
|
+
.map((entry) => ({ clientKind: entry.clientKind, reason: "not_detected" }));
|
|
288
|
+
if (detected.length === 0) {
|
|
289
|
+
throw new Error("no claimed agents were detected on this machine: " +
|
|
290
|
+
probes.map((entry) => `${lifecycleClient(entry.clientKind).label} (${entry.probe.reason})`).join("; "));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// One batch disclosure call spends one throttle token for the whole sweep.
|
|
294
|
+
const disclosures = await fetchClaimDisclosures({
|
|
295
|
+
serverUrl: input.serverUrl,
|
|
296
|
+
claims: input.selections.map((selection) => selection.claim),
|
|
297
|
+
fetchImpl,
|
|
298
|
+
});
|
|
299
|
+
let organization = null;
|
|
300
|
+
if (disclosures !== null) {
|
|
301
|
+
const organizations = new Set();
|
|
302
|
+
for (let index = 0; index < input.selections.length; index += 1) {
|
|
303
|
+
const disclosure = disclosures[index];
|
|
304
|
+
if (!disclosure) continue;
|
|
305
|
+
// A claim minted for one client kind pasted behind another label is a
|
|
306
|
+
// spoofed or reassembled command — refuse before any consent prompt.
|
|
307
|
+
if (disclosure.clientKind !== null && disclosure.clientKind !== input.selections[index].clientKind) {
|
|
308
|
+
throw new Error(`the claim labeled ${input.selections[index].clientKind} was issued for ` +
|
|
309
|
+
`${disclosure.clientKind}; refuse this command and generate a fresh one`);
|
|
310
|
+
}
|
|
311
|
+
if (disclosure.organization) organizations.add(disclosure.organization);
|
|
312
|
+
}
|
|
313
|
+
if (organizations.size > 1) {
|
|
314
|
+
throw new Error("the claims in this command belong to different organizations; " +
|
|
315
|
+
"refuse this command and generate a fresh one");
|
|
316
|
+
}
|
|
317
|
+
organization = organizations.size === 1 ? [...organizations][0] : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
output.write(`${allDisclosureText({
|
|
321
|
+
serverUrl: input.serverUrl,
|
|
322
|
+
projectRoot: input.projectRoot,
|
|
323
|
+
organization,
|
|
324
|
+
hosts: detected.map((entry) => ({ clientKind: entry.clientKind, clientVersion: entry.probe.version })),
|
|
325
|
+
unusedKinds: skipped.map((entry) => entry.clientKind),
|
|
326
|
+
})}\n`);
|
|
327
|
+
await confirm();
|
|
328
|
+
|
|
329
|
+
// The reviewed runtime bundle installs exactly once for the whole sweep.
|
|
330
|
+
const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
|
|
331
|
+
const results = [];
|
|
332
|
+
for (const host of detected) {
|
|
333
|
+
try {
|
|
334
|
+
const installed = await installLocalConnection({
|
|
335
|
+
serverUrl: input.serverUrl,
|
|
336
|
+
claim: host.claim,
|
|
337
|
+
clientKind: host.clientKind,
|
|
338
|
+
root,
|
|
339
|
+
fetchImpl,
|
|
340
|
+
sendHeartbeat: false,
|
|
341
|
+
});
|
|
342
|
+
const configured = await configureHost(host.clientKind, {
|
|
343
|
+
projectRoot: input.projectRoot,
|
|
344
|
+
installationId: installed.installationId,
|
|
345
|
+
serverUrl: input.serverUrl,
|
|
346
|
+
runtimePath: bundle.runtimePath,
|
|
347
|
+
}, { claudeConfigPath });
|
|
348
|
+
let heartbeat = false;
|
|
349
|
+
try {
|
|
350
|
+
heartbeat = await heartbeatInstalledConnection({
|
|
351
|
+
installationId: installed.installationId, root, fetchImpl,
|
|
352
|
+
});
|
|
353
|
+
} catch {
|
|
354
|
+
heartbeat = false;
|
|
355
|
+
}
|
|
356
|
+
results.push({
|
|
357
|
+
clientKind: host.clientKind,
|
|
358
|
+
status: heartbeat ? "configured_heartbeat_verified" : "configured_heartbeat_unavailable",
|
|
359
|
+
installationId: installed.installationId,
|
|
360
|
+
proofStorage: installed.proofStorage,
|
|
361
|
+
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
|
362
|
+
replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
|
|
363
|
+
nextStep: `Restart ${lifecycleClient(host.clientKind).label}, then check the connection in Halofy.`,
|
|
364
|
+
});
|
|
365
|
+
} catch (error) {
|
|
366
|
+
// One host failing must not abort the others; the failure is reported,
|
|
367
|
+
// never hidden.
|
|
368
|
+
skipped.push({
|
|
369
|
+
clientKind: host.clientKind,
|
|
370
|
+
reason: `failed:${(error?.message || "install_error").slice(0, 200)}`,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (results.length === 0) {
|
|
375
|
+
throw new Error("every detected agent failed to install: " +
|
|
376
|
+
skipped.map((entry) => `${entry.clientKind} (${entry.reason})`).join("; "));
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
status: skipped.some((entry) => entry.reason.startsWith("failed:"))
|
|
380
|
+
? "completed_with_failures" : "completed",
|
|
381
|
+
installerVersion: INSTALLER_VERSION,
|
|
382
|
+
publishedPackage: true,
|
|
383
|
+
results,
|
|
384
|
+
skipped,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
143
388
|
export async function runInstaller(argv, {
|
|
144
389
|
root = defaultRuntimeDirectory(),
|
|
145
390
|
output = process.stdout,
|
|
@@ -151,8 +396,22 @@ export async function runInstaller(argv, {
|
|
|
151
396
|
claudeConfigPath,
|
|
152
397
|
} = {}) {
|
|
153
398
|
const input = parseInstallerArgs(argv);
|
|
399
|
+
if (input.mode === "all") {
|
|
400
|
+
return runAllInstaller(input, {
|
|
401
|
+
root, output, detectClaude, detectHost, confirm, fetchImpl, sourceRoot, claudeConfigPath,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
154
404
|
const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
|
|
155
|
-
|
|
405
|
+
const claimDisclosure = await fetchClaimDisclosure({
|
|
406
|
+
serverUrl: input.serverUrl,
|
|
407
|
+
claim: input.claim,
|
|
408
|
+
fetchImpl,
|
|
409
|
+
});
|
|
410
|
+
output.write(`${disclosureText({
|
|
411
|
+
...input,
|
|
412
|
+
clientVersion,
|
|
413
|
+
organization: claimDisclosure?.organization ?? null,
|
|
414
|
+
})}\n`);
|
|
156
415
|
await confirm();
|
|
157
416
|
|
|
158
417
|
const installed = await installLocalConnection({
|
|
@@ -170,21 +429,7 @@ export async function runInstaller(argv, {
|
|
|
170
429
|
serverUrl: input.serverUrl,
|
|
171
430
|
runtimePath: bundle.runtimePath,
|
|
172
431
|
};
|
|
173
|
-
const configured = input.clientKind
|
|
174
|
-
? await configureClaudeProject({ ...common, ...(claudeConfigPath ? { claudeConfigPath } : {}) })
|
|
175
|
-
: input.clientKind === "cursor"
|
|
176
|
-
? await configureCursor(common)
|
|
177
|
-
: input.clientKind === "gemini-cli"
|
|
178
|
-
? await configureGemini(common)
|
|
179
|
-
: input.clientKind === "kimi-cli"
|
|
180
|
-
? await configureKimi(common)
|
|
181
|
-
: input.clientKind === "vscode"
|
|
182
|
-
? await configureVscode(common)
|
|
183
|
-
: input.clientKind === "codex"
|
|
184
|
-
? await configureCodex(common)
|
|
185
|
-
: input.clientKind === "cline"
|
|
186
|
-
? await configureCline(common)
|
|
187
|
-
: (() => { throw new Error("the selected adapter is not packaged yet"); })();
|
|
432
|
+
const configured = await configureHost(input.clientKind, common, { claudeConfigPath });
|
|
188
433
|
let heartbeat = false;
|
|
189
434
|
try {
|
|
190
435
|
heartbeat = await heartbeatInstalledConnection({
|
package/src/runtime.mjs
CHANGED
|
@@ -2,12 +2,10 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { BoundedEncryptedQueue } from "./queue.mjs";
|
|
4
4
|
import {
|
|
5
|
-
buildClaudeMetadataPayload,
|
|
6
|
-
claudeMetadataEvent,
|
|
7
5
|
CursorStore,
|
|
8
6
|
deriveSessionHash,
|
|
9
|
-
readClaudeTranscriptSuffix,
|
|
10
7
|
} from "./session.mjs";
|
|
8
|
+
import { claudeTranscriptDriver } from "./transcript-drivers/claude.mjs";
|
|
11
9
|
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
12
10
|
import { readJson, withFileLock, writePrivateFile } from "./storage.mjs";
|
|
13
11
|
import { RUNTIME_VERSION } from "./version.mjs";
|
|
@@ -113,26 +111,30 @@ export class LifecycleRuntime {
|
|
|
113
111
|
}
|
|
114
112
|
|
|
115
113
|
async captureClaudeTranscript(hostSessionId, transcriptPath, sessionFacts = {}) {
|
|
114
|
+
return this.captureHostTranscript(hostSessionId, claudeTranscriptDriver, transcriptPath, sessionFacts);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async captureHostTranscript(hostSessionId, driver, transcriptPath, sessionFacts = {}) {
|
|
116
118
|
const sessionHash = this.sessionHash(hostSessionId);
|
|
117
119
|
const policy = await this.capturePolicy();
|
|
118
120
|
const queued = await withFileLock(this.operationLockPath, async () => {
|
|
119
121
|
const cursor = await this.cursors.get(sessionHash);
|
|
120
|
-
const suffix = await
|
|
122
|
+
const suffix = await driver.readSuffix(transcriptPath, cursor, sessionHash);
|
|
121
123
|
const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
|
|
122
124
|
const unseenEvents = suffix.events.filter((event) => !recentEventKeys.has(event.eventKey));
|
|
123
125
|
// One metadata event whenever the observed content-free session facts
|
|
124
126
|
// change. The event key hashes the payload, so an unchanged snapshot is
|
|
125
127
|
// deduplicated exactly like any repeated event.
|
|
126
|
-
const metadataPayload =
|
|
128
|
+
const metadataPayload = driver.buildMetadataPayload(
|
|
127
129
|
{ ...suffix.metadata, ...sessionFacts },
|
|
128
130
|
{ installationId: this.connection.installationId, deviceContext: policy.deviceContext },
|
|
129
131
|
);
|
|
130
132
|
if (Object.keys(metadataPayload).length > 0) {
|
|
131
|
-
const metadataEvent =
|
|
133
|
+
const metadataEvent = driver.metadataEvent(metadataPayload);
|
|
132
134
|
if (!recentEventKeys.has(metadataEvent.eventKey)) unseenEvents.push(metadataEvent);
|
|
133
135
|
}
|
|
134
136
|
const usageGaps = unseenEvents.filter((event) =>
|
|
135
|
-
event.type === "usage" && event.eventKey.startsWith(
|
|
137
|
+
event.type === "usage" && event.eventKey.startsWith(driver.usageGapPrefix)).length;
|
|
136
138
|
const result = unseenEvents.length === 0
|
|
137
139
|
? { queued: 0 }
|
|
138
140
|
: await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
|
|
@@ -144,8 +146,11 @@ export class LifecycleRuntime {
|
|
|
144
146
|
// normalized event is already durable in the encrypted queue. Advancing
|
|
145
147
|
// this byte cursor prevents unbounded reparsing without advancing the
|
|
146
148
|
// separately acknowledged event sequence.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
const patch = {};
|
|
150
|
+
if (suffix.observedEndOffset > cursor.byteOffset) patch.byteOffset = suffix.observedEndOffset;
|
|
151
|
+
if (suffix.hostState !== undefined) patch.hostState = suffix.hostState;
|
|
152
|
+
if (Object.keys(patch).length > 0) {
|
|
153
|
+
await this.cursors.update(sessionHash, patch);
|
|
149
154
|
}
|
|
150
155
|
await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
|
|
151
156
|
if (usageGaps > 0) await this.cursors.bumpUsageGaps(usageGaps);
|
package/src/session.mjs
CHANGED
|
@@ -86,7 +86,7 @@ function boundedCompletePayload(payload, { role, body, format = "json", extra =
|
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function normalizedEvent({ eventKey, type, occurredAt, payload, sourceEndOffset, part }) {
|
|
89
|
+
export function normalizedEvent({ eventKey, type, occurredAt, payload, sourceEndOffset, part }) {
|
|
90
90
|
const {
|
|
91
91
|
role,
|
|
92
92
|
contentFormat = "json",
|
|
@@ -256,6 +256,37 @@ function unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourc
|
|
|
256
256
|
});
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Hook-driven recall injection is disabled for now: the runtime captures
|
|
261
|
+
* conversations and serves the agent-invoked MCP memory tools, but does not
|
|
262
|
+
* push recalled memory into host sessions on SessionStart/UserPromptSubmit.
|
|
263
|
+
* Flip to true to restore injection — the bounded block formats below and the
|
|
264
|
+
* per-host output shapes in the hook modules stay in place and tested.
|
|
265
|
+
*/
|
|
266
|
+
export const RECALL_INJECTION_ENABLED = false;
|
|
267
|
+
|
|
268
|
+
export const MAX_RECALL_BLOCKS = 12;
|
|
269
|
+
export const MAX_RECALL_BLOCK_CHARS = 8_000;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Ranked recall blocks bounded on the client before they are injected into a
|
|
273
|
+
* host session. The server's token budget is the primary bound; these caps are
|
|
274
|
+
* defense in depth so poisoned or oversized memory content cannot flood the
|
|
275
|
+
* host context through a hook response.
|
|
276
|
+
*/
|
|
277
|
+
export function rankedRecallBlocks(result) {
|
|
278
|
+
const blocks = Array.isArray(result?.blocks) ? result.blocks : Array.isArray(result) ? result : [];
|
|
279
|
+
return blocks
|
|
280
|
+
.filter((block) => block && typeof block.recallRef === "string" &&
|
|
281
|
+
typeof block.content === "string" && block.content.trim())
|
|
282
|
+
.slice(0, MAX_RECALL_BLOCKS)
|
|
283
|
+
.map((block, rank) => ({
|
|
284
|
+
rank: rank + 1,
|
|
285
|
+
recallRef: block.recallRef.slice(0, 256),
|
|
286
|
+
content: block.content.trim().slice(0, MAX_RECALL_BLOCK_CHARS),
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
|
|
259
290
|
export function deriveSessionHash({ installationId, clientKind, hostSessionId }) {
|
|
260
291
|
if (!installationId || !clientKind || !hostSessionId) throw new Error("session identity is incomplete");
|
|
261
292
|
return digest(`halofy-session-v1\0${installationId}\0${clientKind}\0${hostSessionId}`);
|
|
@@ -269,15 +300,15 @@ export function stripInjectedContext(value) {
|
|
|
269
300
|
return String(value);
|
|
270
301
|
}
|
|
271
302
|
|
|
272
|
-
function usageInt(value) {
|
|
303
|
+
export function usageInt(value) {
|
|
273
304
|
return Number.isSafeInteger(value) && value >= 0 && value < 2 ** 31 ? value : null;
|
|
274
305
|
}
|
|
275
306
|
|
|
276
|
-
function usageLabel(value) {
|
|
307
|
+
export function usageLabel(value) {
|
|
277
308
|
return typeof value === "string" && /^[\x20-\x7e]{1,128}$/.test(value) ? value : null;
|
|
278
309
|
}
|
|
279
310
|
|
|
280
|
-
const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._
|
|
311
|
+
export const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._:/-]{1,128}$/;
|
|
281
312
|
|
|
282
313
|
/**
|
|
283
314
|
* One host-reported usage record per assistant message (PRD §8.1). Claude
|
|
@@ -738,15 +769,19 @@ export function buildClaudeMetadataPayload(metadata, { installationId, deviceCon
|
|
|
738
769
|
return payload;
|
|
739
770
|
}
|
|
740
771
|
|
|
741
|
-
export function
|
|
772
|
+
export function hostMetadataEvent(namespace, payload) {
|
|
742
773
|
return normalizedEvent({
|
|
743
|
-
eventKey:
|
|
774
|
+
eventKey: `${namespace}:metadata:${digest(stableJson(payload))}`,
|
|
744
775
|
type: "metadata",
|
|
745
776
|
occurredAt: new Date().toISOString(),
|
|
746
777
|
payload: { contentFormat: "json", captureStatus: "complete", ...payload },
|
|
747
778
|
});
|
|
748
779
|
}
|
|
749
780
|
|
|
781
|
+
export function claudeMetadataEvent(payload) {
|
|
782
|
+
return hostMetadataEvent("claude", payload);
|
|
783
|
+
}
|
|
784
|
+
|
|
750
785
|
export class CursorStore {
|
|
751
786
|
constructor(root) {
|
|
752
787
|
this.path = join(root, "cursors.json");
|
package/src/storage.mjs
CHANGED
|
@@ -31,6 +31,32 @@ export async function writePrivateFile(path, value) {
|
|
|
31
31
|
if (platform() !== "win32") await chmod(path, 0o600);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Atomic write for a host application's config file. The parent directory is
|
|
36
|
+
* created if missing, but the permissions of an existing directory are never
|
|
37
|
+
* changed — a project root, $HOME, or ~/.cursor is not Halofy's to tighten.
|
|
38
|
+
* An existing file keeps its mode; a new file starts private (0600).
|
|
39
|
+
*/
|
|
40
|
+
export async function writeHostConfigFile(path, value) {
|
|
41
|
+
await mkdir(dirname(path), { recursive: true });
|
|
42
|
+
let mode = 0o600;
|
|
43
|
+
try {
|
|
44
|
+
mode = (await stat(path)).mode & 0o777;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error?.code !== "ENOENT") throw error;
|
|
47
|
+
}
|
|
48
|
+
const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
49
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
50
|
+
try {
|
|
51
|
+
await handle.writeFile(value);
|
|
52
|
+
await handle.sync();
|
|
53
|
+
} finally {
|
|
54
|
+
await handle.close();
|
|
55
|
+
}
|
|
56
|
+
await rename(temporary, path);
|
|
57
|
+
if (platform() !== "win32") await chmod(path, mode);
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
export async function withFileLock(path, action, { timeoutMs = 3_000, staleMs = 30_000 } = {}) {
|
|
35
61
|
await ensurePrivateDirectory(dirname(path));
|
|
36
62
|
const started = Date.now();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildClaudeMetadataPayload,
|
|
3
|
+
claudeMetadataEvent,
|
|
4
|
+
readClaudeTranscriptSuffix,
|
|
5
|
+
} from "../session.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Thin delegation over the reviewed Claude Code adapter. The claude path is
|
|
9
|
+
* frozen — its eventKeys are pinned by the golden fixture test and must stay
|
|
10
|
+
* byte-identical for server-side dedupe history. Claude Code hooks pass the
|
|
11
|
+
* transcript path directly, so this driver has no locate step.
|
|
12
|
+
*/
|
|
13
|
+
export const claudeTranscriptDriver = {
|
|
14
|
+
clientKind: "claude-code",
|
|
15
|
+
eventKeyNamespace: "claude",
|
|
16
|
+
usageGapPrefix: "claude:usage-gap:",
|
|
17
|
+
|
|
18
|
+
async locate() {
|
|
19
|
+
return null;
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
readSuffix(path, cursor, sessionHash) {
|
|
23
|
+
return readClaudeTranscriptSuffix(path, cursor, sessionHash);
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
buildMetadataPayload(metadata, context) {
|
|
27
|
+
return buildClaudeMetadataPayload(metadata, context);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
metadataEvent(payload) {
|
|
31
|
+
return claudeMetadataEvent(payload);
|
|
32
|
+
},
|
|
33
|
+
};
|