@halofy/agent-connect 0.1.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.
@@ -0,0 +1,201 @@
1
+ import { join, resolve } from "node:path";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmod, cp, rename, rm, unlink } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ import { generateInstallationKeyPair } from "./crypto.mjs";
6
+ import { ConnectionStore, defaultRuntimeDirectory, ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
7
+ import { SignedRuntimeTransport } from "./transport.mjs";
8
+ import { INSTALLER_VERSION, RUNTIME_VERSION } from "./version.mjs";
9
+
10
+ export const CLAUDE_CAPABILITIES = Object.freeze({
11
+ sessionStart: true,
12
+ userPromptSubmit: true,
13
+ stop: true,
14
+ preCompact: true,
15
+ sessionEnd: true,
16
+ subagentStart: true,
17
+ subagentStop: true,
18
+ postToolUse: true,
19
+ postToolUseFailure: true,
20
+ postToolBatch: true,
21
+ conversationArchive: true,
22
+ archiveProtocolV1: true,
23
+ userMessages: true,
24
+ assistantMessages: true,
25
+ toolInputs: true,
26
+ toolOutputs: true,
27
+ toolFailures: true,
28
+ images: false,
29
+ contextUseEvidence: false,
30
+ artifactBodies: false,
31
+ artifactReferences: true,
32
+ subagents: true,
33
+ compactionCheckpoints: true,
34
+ contextRecalled: true,
35
+ });
36
+
37
+ export async function consumeInstallationClaim({
38
+ serverUrl,
39
+ claim,
40
+ clientKind,
41
+ publicJwk,
42
+ installationIdCandidate,
43
+ proofStorage,
44
+ installerVersion = INSTALLER_VERSION,
45
+ pluginVersion = RUNTIME_VERSION,
46
+ capabilities = CLAUDE_CAPABILITIES,
47
+ fetchImpl = globalThis.fetch,
48
+ }) {
49
+ const response = await fetchImpl(`${serverUrl}/v1/agent-installations/claim`, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify({
53
+ claim,
54
+ publicKeyJwk: publicJwk,
55
+ clientKind,
56
+ installerVersion,
57
+ pluginVersion,
58
+ protocolVersion: "1",
59
+ capabilities,
60
+ installationIdCandidate,
61
+ proofStorage,
62
+ }),
63
+ });
64
+ if (!response.ok) throw new Error(response.status === 429 ? "claim rate limit reached" : "claim unavailable");
65
+ return response.json();
66
+ }
67
+
68
+ export async function installLocalConnection({
69
+ serverUrl,
70
+ claim,
71
+ clientKind = "claude-code",
72
+ root = defaultRuntimeDirectory(),
73
+ fetchImpl = globalThis.fetch,
74
+ sendHeartbeat = true,
75
+ }) {
76
+ if (clientKind !== "claude-code") throw new Error("only the local Claude Code adapter is available");
77
+ const normalizedServer = new URL(serverUrl);
78
+ if (normalizedServer.protocol !== "https:" && normalizedServer.hostname !== "localhost" && normalizedServer.hostname !== "127.0.0.1") {
79
+ throw new Error("installation claims require HTTPS (localhost is allowed for development)");
80
+ }
81
+ normalizedServer.pathname = normalizedServer.pathname.replace(/\/+$/, "");
82
+ const normalizedServerUrl = normalizedServer.toString().replace(/\/$/, "");
83
+ const store = new ConnectionStore(root);
84
+ const pendingPath = join(root, `pending-${clientKind}.json`);
85
+ const priorPending = await readJson(pendingPath);
86
+ const keyPair = priorPending?.publicJwk && priorPending?.privateJwk
87
+ ? priorPending
88
+ : { ...generateInstallationKeyPair(), installationIdCandidate: `local_${randomUUID()}` };
89
+ const proofStorage = process.platform === "win32" ? "unknown" : "file-0600";
90
+ if (!priorPending) {
91
+ // Persist proof material before claim consumption. If the HTTP response is
92
+ // lost after server commit, retrying the command reuses the same public
93
+ // thumbprint without ever persisting the claim itself.
94
+ await writePrivateFile(pendingPath, `${JSON.stringify(keyPair)}\n`);
95
+ }
96
+ const consumed = await consumeInstallationClaim({
97
+ serverUrl: normalizedServerUrl,
98
+ claim,
99
+ clientKind,
100
+ publicJwk: keyPair.publicJwk,
101
+ installationIdCandidate: keyPair.installationIdCandidate,
102
+ proofStorage,
103
+ fetchImpl,
104
+ });
105
+ const installationId = consumed.installationId || consumed.installation?.id || consumed.id;
106
+ if (!installationId) throw new Error("claim response did not contain an installation id");
107
+ const connection = {
108
+ version: 1,
109
+ protocolVersion: "1",
110
+ installationId,
111
+ serverUrl: normalizedServerUrl,
112
+ clientKind,
113
+ publicJwk: keyPair.publicJwk,
114
+ privateJwk: keyPair.privateJwk,
115
+ keyThumbprint: keyPair.thumbprint,
116
+ capabilities: CLAUDE_CAPABILITIES,
117
+ installerVersion: INSTALLER_VERSION,
118
+ pluginVersion: RUNTIME_VERSION,
119
+ proofStorage,
120
+ installedAt: new Date().toISOString(),
121
+ };
122
+ await store.save(connection);
123
+ await writePrivateFile(join(root, `active-${clientKind}.json`), `${JSON.stringify({
124
+ version: 1,
125
+ installationId,
126
+ protocolVersion: "1",
127
+ })}\n`);
128
+ try { await unlink(pendingPath); } catch (error) { if (error?.code !== "ENOENT") throw error; }
129
+
130
+ // A failed heartbeat leaves a valid binding on disk for repair/retry. It is
131
+ // reported honestly and never converted into an "active" result.
132
+ let heartbeat = false;
133
+ if (sendHeartbeat) {
134
+ try {
135
+ await new SignedRuntimeTransport(connection, { fetchImpl }).heartbeat(CLAUDE_CAPABILITIES, {
136
+ pluginVersion: RUNTIME_VERSION,
137
+ proofStorage,
138
+ diagnostics: { queueDepth: 0, oldestPendingAt: null, expiredCount: 0 },
139
+ });
140
+ heartbeat = true;
141
+ } catch {
142
+ heartbeat = false;
143
+ }
144
+ }
145
+ return {
146
+ installationId,
147
+ heartbeat,
148
+ reused: false,
149
+ proofStorage: connection.proofStorage,
150
+ connectionPath: store.path(installationId),
151
+ };
152
+ }
153
+
154
+ /** Copy the reviewed package out of the transient npx directory. */
155
+ export async function installRuntimeBundle({
156
+ root = defaultRuntimeDirectory(),
157
+ sourceRoot = fileURLToPath(new URL("../", import.meta.url)),
158
+ } = {}) {
159
+ const runtimeRoot = join(root, "runtime", RUNTIME_VERSION);
160
+ const staging = `${runtimeRoot}.staging-${process.pid}`;
161
+ await rm(staging, { recursive: true, force: true });
162
+ await ensurePrivateDirectory(staging);
163
+ await cp(join(sourceRoot, "src"), join(staging, "src"), { recursive: true, force: false });
164
+ await cp(join(sourceRoot, "bin"), join(staging, "bin"), { recursive: true, force: false });
165
+ await rm(runtimeRoot, { recursive: true, force: true });
166
+ await rename(staging, runtimeRoot);
167
+ if (process.platform !== "win32") {
168
+ await chmod(join(runtimeRoot, "bin", "halofy-agent.mjs"), 0o700);
169
+ await chmod(join(runtimeRoot, "bin", "install.mjs"), 0o700);
170
+ }
171
+ return {
172
+ runtimeRoot,
173
+ runtimePath: join(runtimeRoot, "bin", "halofy-agent.mjs"),
174
+ };
175
+ }
176
+
177
+ export async function heartbeatInstalledConnection({
178
+ installationId,
179
+ root = defaultRuntimeDirectory(),
180
+ fetchImpl = globalThis.fetch,
181
+ }) {
182
+ const connection = await new ConnectionStore(root).load(installationId);
183
+ await new SignedRuntimeTransport(connection, { fetchImpl }).heartbeat(connection.capabilities, {
184
+ pluginVersion: connection.pluginVersion,
185
+ proofStorage: connection.proofStorage,
186
+ diagnostics: { queueDepth: 0, oldestPendingAt: null, expiredCount: 0 },
187
+ });
188
+ return true;
189
+ }
190
+
191
+ export function localMcpSnippet({ nodePath = process.execPath, proxyPath, installationId }) {
192
+ return {
193
+ mcpServers: {
194
+ halofy: {
195
+ type: "stdio",
196
+ command: resolve(nodePath),
197
+ args: [resolve(proxyPath), "mcp", "--connection", installationId],
198
+ },
199
+ },
200
+ };
201
+ }
@@ -0,0 +1,144 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { resolve } from "node:path";
4
+ import {
5
+ heartbeatInstalledConnection,
6
+ installLocalConnection,
7
+ installRuntimeBundle,
8
+ localMcpSnippet,
9
+ } from "./install.mjs";
10
+ import { configureClaudeProject } from "./claude-config.mjs";
11
+ import { defaultRuntimeDirectory } from "./storage.mjs";
12
+ import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
13
+
14
+ export function parseInstallerArgs(argv) {
15
+ const command = argv[0];
16
+ const clientKind = argv[1];
17
+ const values = new Map();
18
+ for (let index = 2; index < argv.length; index += 1) {
19
+ const name = argv[index];
20
+ if (!["--server", "--claim", "--claude-project"].includes(name) || values.has(name)) {
21
+ throw new Error("unsupported or duplicate installer argument");
22
+ }
23
+ const value = argv[index + 1];
24
+ if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
25
+ values.set(name, value);
26
+ index += 1;
27
+ }
28
+ const serverUrl = values.get("--server");
29
+ const claim = values.get("--claim");
30
+ if (command !== "install" || clientKind !== "claude-code" || !serverUrl || !claim ||
31
+ !/^hsc_[A-Za-z0-9_-]{43}$/.test(claim)) {
32
+ throw new Error("Usage: agent-connect install claude-code --server <https-url> --claim <one-use-claim>");
33
+ }
34
+ return {
35
+ clientKind,
36
+ serverUrl,
37
+ claim,
38
+ projectRoot: resolve(values.get("--claude-project") || process.cwd()),
39
+ };
40
+ }
41
+
42
+ export function detectClaudeCode() {
43
+ const result = spawnSync("claude", ["--version"], {
44
+ encoding: "utf8",
45
+ shell: false,
46
+ timeout: 10_000,
47
+ windowsHide: true,
48
+ });
49
+ if (result.error || result.status !== 0) throw new Error("Claude Code was not found on PATH");
50
+ const version = String(result.stdout || result.stderr || "").trim().slice(0, 128);
51
+ return version || "detected";
52
+ }
53
+
54
+ export function disclosureText({ serverUrl, projectRoot, claudeVersion }) {
55
+ return [
56
+ "Halofy Claude Code lifecycle connection",
57
+ `Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
58
+ `Server: ${new URL(serverUrl).origin}`,
59
+ `Claude Code: ${claudeVersion}`,
60
+ `Project: ${projectRoot}`,
61
+ "",
62
+ "This single installation replaces an existing Halofy bearer MCP entry in this project and enables:",
63
+ "- governed memory tools and recall",
64
+ "- prompts and assistant responses exposed by Claude's transcript hooks,",
65
+ "- supported tool inputs/results/failures, subagents, compaction, and session end,",
66
+ "- encrypted local retry queue and governed retained conversations, and",
67
+ "- canonical learning when the selected badge permits writes.",
68
+ "",
69
+ "Images and artifact bodies are recorded as explicit unsupported placeholders; coverage may be Partial.",
70
+ "Authorized organization managers may review retained conversations and summaries.",
71
+ "This does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
72
+ "Disconnecting stops future capture but does not erase retained data.",
73
+ `Disclosure: ${DISCLOSURE_VERSION}`,
74
+ ].join("\n");
75
+ }
76
+
77
+ export async function confirmDisclosure({ input = process.stdin, output = process.stdout } = {}) {
78
+ if (!input.isTTY || !output.isTTY) throw new Error("interactive terminal confirmation is required");
79
+ const prompt = createInterface({ input, output });
80
+ try {
81
+ const answer = await prompt.question("\nType CONNECT to install conversation capture and memory: ");
82
+ if (answer.trim() !== "CONNECT") throw new Error("installation was not confirmed");
83
+ } finally {
84
+ prompt.close();
85
+ }
86
+ return true;
87
+ }
88
+
89
+ export async function runInstaller(argv, {
90
+ root = defaultRuntimeDirectory(),
91
+ output = process.stdout,
92
+ detectClaude = detectClaudeCode,
93
+ confirm = confirmDisclosure,
94
+ fetchImpl = globalThis.fetch,
95
+ sourceRoot,
96
+ claudeConfigPath,
97
+ } = {}) {
98
+ const input = parseInstallerArgs(argv);
99
+ const claudeVersion = await detectClaude();
100
+ output.write(`${disclosureText({ ...input, claudeVersion })}\n`);
101
+ await confirm();
102
+
103
+ const installed = await installLocalConnection({
104
+ serverUrl: input.serverUrl,
105
+ claim: input.claim,
106
+ clientKind: input.clientKind,
107
+ root,
108
+ fetchImpl,
109
+ sendHeartbeat: false,
110
+ });
111
+ const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
112
+ const configured = await configureClaudeProject({
113
+ projectRoot: input.projectRoot,
114
+ installationId: installed.installationId,
115
+ serverUrl: input.serverUrl,
116
+ runtimePath: bundle.runtimePath,
117
+ ...(claudeConfigPath ? { claudeConfigPath } : {}),
118
+ });
119
+ let heartbeat = false;
120
+ try {
121
+ heartbeat = await heartbeatInstalledConnection({
122
+ installationId: installed.installationId,
123
+ root,
124
+ fetchImpl,
125
+ });
126
+ } catch {
127
+ heartbeat = false;
128
+ }
129
+ return {
130
+ status: heartbeat ? "configured_heartbeat_verified" : "configured_heartbeat_unavailable",
131
+ installationId: installed.installationId,
132
+ proofStorage: installed.proofStorage,
133
+ installerVersion: INSTALLER_VERSION,
134
+ publishedPackage: true,
135
+ projectConfigured: true,
136
+ configuredPaths: [configured.mcpPath, configured.settingsPath],
137
+ replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries,
138
+ mcpConfiguration: localMcpSnippet({
139
+ proxyPath: bundle.runtimePath,
140
+ installationId: installed.installationId,
141
+ }),
142
+ nextStep: "Restart Claude Code in this project, then check the connection in Halofy.",
143
+ };
144
+ }
@@ -0,0 +1,94 @@
1
+ import { createInterface } from "node:readline";
2
+ import { SignedRuntimeTransport } from "./transport.mjs";
3
+
4
+ function sseMessages(text) {
5
+ return text.split(/\r?\n\r?\n/).flatMap((event) => {
6
+ const data = event.split(/\r?\n/)
7
+ .filter((line) => line.startsWith("data:"))
8
+ .map((line) => line.slice(5).trim())
9
+ .join("\n");
10
+ return data ? [data] : [];
11
+ });
12
+ }
13
+
14
+ async function relayResponse(response, parsed, output) {
15
+ if (response.status === 202 || response.status === 204) return;
16
+ const text = await response.text();
17
+ const payloads = (response.headers.get("content-type") || "").includes("text/event-stream")
18
+ ? sseMessages(text)
19
+ : [text];
20
+ for (const payload of payloads) if (payload.trim()) output.write(`${payload.trim()}\n`);
21
+ }
22
+
23
+ export async function runStdioMcpProxy(connection, {
24
+ input = process.stdin,
25
+ output = process.stdout,
26
+ fetchImpl = globalThis.fetch,
27
+ } = {}) {
28
+ const transport = new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 30_000 });
29
+ let mcpSessionId = null;
30
+ const lines = createInterface({ input, crlfDelay: Infinity });
31
+ for await (const line of lines) {
32
+ if (!line.trim()) continue;
33
+ let parsed;
34
+ try { parsed = JSON.parse(line); } catch { continue; }
35
+ try {
36
+ const response = await transport.request("/mcp", {
37
+ body: JSON.stringify(parsed),
38
+ headers: {
39
+ Accept: "application/json, text/event-stream",
40
+ ...(mcpSessionId ? { "Mcp-Session-Id": mcpSessionId } : {}),
41
+ },
42
+ raw: true,
43
+ });
44
+ mcpSessionId = response.headers.get("mcp-session-id") || mcpSessionId;
45
+ await relayResponse(response, parsed, output);
46
+ } catch {
47
+ if (parsed.id !== undefined) {
48
+ output.write(`${JSON.stringify({
49
+ jsonrpc: "2.0",
50
+ id: parsed.id,
51
+ error: { code: -32000, message: "Halofy MCP transport unavailable" },
52
+ })}\n`);
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+
59
+ /** Compatibility bridge for existing HALOMEM_URL + HALOMEM_API_KEY users. */
60
+ export async function runLegacyStdioMcpProxy({ url, key }, {
61
+ input = process.stdin,
62
+ output = process.stdout,
63
+ fetchImpl = globalThis.fetch,
64
+ } = {}) {
65
+ let mcpSessionId = null;
66
+ const lines = createInterface({ input, crlfDelay: Infinity });
67
+ for await (const line of lines) {
68
+ if (!line.trim()) continue;
69
+ let parsed;
70
+ try { parsed = JSON.parse(line); } catch { continue; }
71
+ try {
72
+ const response = await fetchImpl(`${url}/mcp`, {
73
+ method: "POST",
74
+ headers: {
75
+ Authorization: `Bearer ${key}`,
76
+ "Content-Type": "application/json",
77
+ Accept: "application/json, text/event-stream",
78
+ ...(mcpSessionId ? { "Mcp-Session-Id": mcpSessionId } : {}),
79
+ },
80
+ body: JSON.stringify(parsed),
81
+ });
82
+ if (!response.ok) throw new Error("legacy MCP transport rejected");
83
+ mcpSessionId = response.headers.get("mcp-session-id") || mcpSessionId;
84
+ await relayResponse(response, parsed, output);
85
+ } catch {
86
+ if (parsed.id !== undefined) {
87
+ output.write(`${JSON.stringify({ jsonrpc: "2.0", id: parsed.id, error: {
88
+ code: -32000,
89
+ message: "Halofy MCP transport unavailable",
90
+ } })}\n`);
91
+ }
92
+ }
93
+ }
94
+ }
package/src/queue.mjs ADDED
@@ -0,0 +1,183 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { ensurePrivateDirectory, fileExists, withFileLock, writePrivateFile } from "./storage.mjs";
5
+
6
+ export class QueueOverflowError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "QueueOverflowError";
10
+ this.code = "queue_overflow";
11
+ }
12
+ }
13
+
14
+ async function loadOrCreateKey(path) {
15
+ if (await fileExists(path)) {
16
+ const key = await readFile(path);
17
+ if (key.length !== 32) throw new Error("invalid local queue key");
18
+ return key;
19
+ }
20
+ const key = randomBytes(32);
21
+ try {
22
+ await writePrivateFile(path, key);
23
+ return key;
24
+ } catch (error) {
25
+ if (error?.code !== "EEXIST") throw error;
26
+ return readFile(path);
27
+ }
28
+ }
29
+
30
+ function encrypt(key, state) {
31
+ const iv = randomBytes(12);
32
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
33
+ const ciphertext = Buffer.concat([cipher.update(JSON.stringify(state)), cipher.final()]);
34
+ return JSON.stringify({
35
+ version: 1,
36
+ algorithm: "aes-256-gcm",
37
+ iv: iv.toString("base64url"),
38
+ tag: cipher.getAuthTag().toString("base64url"),
39
+ ciphertext: ciphertext.toString("base64url"),
40
+ });
41
+ }
42
+
43
+ function decrypt(key, envelope) {
44
+ const parsed = JSON.parse(envelope);
45
+ if (parsed.version !== 1 || parsed.algorithm !== "aes-256-gcm") throw new Error("unsupported queue envelope");
46
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(parsed.iv, "base64url"));
47
+ decipher.setAuthTag(Buffer.from(parsed.tag, "base64url"));
48
+ return JSON.parse(Buffer.concat([
49
+ decipher.update(Buffer.from(parsed.ciphertext, "base64url")),
50
+ decipher.final(),
51
+ ]).toString("utf8"));
52
+ }
53
+
54
+ export class BoundedEncryptedQueue {
55
+ constructor(root, {
56
+ maxEntries = 5_000,
57
+ maxPlaintextBytes = 8 * 1024 * 1024,
58
+ maxAgeMs = 7 * 24 * 60 * 60 * 1_000,
59
+ } = {}) {
60
+ this.root = root;
61
+ this.path = join(root, "pending.queue.enc");
62
+ this.keyPath = join(root, "pending.queue.key");
63
+ this.lockPath = join(root, "pending.queue.lock");
64
+ this.maxEntries = maxEntries;
65
+ this.maxPlaintextBytes = maxPlaintextBytes;
66
+ this.maxAgeMs = maxAgeMs;
67
+ }
68
+
69
+ async #read() {
70
+ await ensurePrivateDirectory(dirname(this.path));
71
+ const key = await loadOrCreateKey(this.keyPath);
72
+ if (!(await fileExists(this.path))) return { version: 1, items: [], expiredCount: 0 };
73
+ const state = decrypt(key, await readFile(this.path, "utf8"));
74
+ return { version: 1, items: state.items || [], expiredCount: state.expiredCount || 0 };
75
+ }
76
+
77
+ #sweep(state) {
78
+ const cutoff = Date.now() - this.maxAgeMs;
79
+ const retained = [];
80
+ let expired = 0;
81
+ for (const item of state.items) {
82
+ const created = Date.parse(item.createdAt);
83
+ if (Number.isFinite(created) && created < cutoff) expired += 1;
84
+ else retained.push(item);
85
+ }
86
+ state.items = retained;
87
+ state.expiredCount = Number(state.expiredCount || 0) + expired;
88
+ return expired;
89
+ }
90
+
91
+ async #write(state) {
92
+ const serialized = JSON.stringify(state);
93
+ if (state.items.length > this.maxEntries || Buffer.byteLength(serialized) > this.maxPlaintextBytes) {
94
+ throw new QueueOverflowError("local lifecycle queue is full; no event was discarded");
95
+ }
96
+ const key = await loadOrCreateKey(this.keyPath);
97
+ await writePrivateFile(this.path, encrypt(key, state));
98
+ }
99
+
100
+ async enqueue(item) {
101
+ return withFileLock(this.lockPath, async () => {
102
+ const state = await this.#read();
103
+ if (!item?.id) throw new Error("queue item requires an id");
104
+ if (!state.items.some((existing) => existing.id === item.id)) state.items.push(item);
105
+ await this.#write(state);
106
+ return state.items.length;
107
+ });
108
+ }
109
+
110
+ async peek() {
111
+ return withFileLock(this.lockPath, async () => {
112
+ const state = await this.#read();
113
+ if (this.#sweep(state) > 0) await this.#write(state);
114
+ return state.items[0] ?? null;
115
+ });
116
+ }
117
+
118
+ async replaceHead(item) {
119
+ return withFileLock(this.lockPath, async () => {
120
+ const state = await this.#read();
121
+ if (state.items.length === 0) return;
122
+ if (item) state.items[0] = item;
123
+ else state.items.shift();
124
+ await this.#write(state);
125
+ });
126
+ }
127
+
128
+ async diagnostics() {
129
+ return withFileLock(this.lockPath, async () => {
130
+ const state = await this.#read();
131
+ if (this.#sweep(state) > 0) await this.#write(state);
132
+ return {
133
+ depth: state.items.length,
134
+ oldestPendingAt: state.items[0]?.createdAt ?? null,
135
+ expiredCount: state.expiredCount,
136
+ };
137
+ });
138
+ }
139
+
140
+ async enqueueSessionEvents(sessionHash, events, {
141
+ maxBatchEvents = 100,
142
+ maxBatchBytes = 1024 * 1024,
143
+ acknowledgedSequence = 0,
144
+ } = {}) {
145
+ return withFileLock(this.lockPath, async () => {
146
+ const state = await this.#read();
147
+ this.#sweep(state);
148
+ const pending = state.items.filter((item) => item.sessionHash === sessionHash).flatMap((item) => item.events || []);
149
+ const existingKeys = new Set(pending.map((event) => event.eventKey));
150
+ let sequence = pending.reduce(
151
+ (maximum, event) => Math.max(maximum, Number(event.sequence) || 0),
152
+ acknowledgedSequence,
153
+ );
154
+ const unique = [];
155
+ for (const event of events) {
156
+ if (existingKeys.has(event.eventKey)) continue;
157
+ existingKeys.add(event.eventKey);
158
+ unique.push({ ...event, sequence: ++sequence });
159
+ }
160
+ for (let offset = 0; offset < unique.length;) {
161
+ const batch = [];
162
+ let batchBytes = 0;
163
+ while (offset < unique.length && batch.length < maxBatchEvents) {
164
+ const event = unique[offset];
165
+ const eventBytes = Buffer.byteLength(JSON.stringify(event));
166
+ if (batch.length > 0 && batchBytes + eventBytes > maxBatchBytes) break;
167
+ if (eventBytes > maxBatchBytes) {
168
+ throw new QueueOverflowError("one normalized event exceeds the append body limit");
169
+ }
170
+ batch.push(event);
171
+ batchBytes += eventBytes;
172
+ offset += 1;
173
+ }
174
+ const id = createHash("sha256")
175
+ .update(`${sessionHash}\0${batch.map((event) => event.eventKey).join("\0")}`)
176
+ .digest("hex");
177
+ state.items.push({ id, createdAt: new Date().toISOString(), sessionHash, events: batch });
178
+ }
179
+ await this.#write(state);
180
+ return { queued: unique.length, lastSequence: sequence };
181
+ });
182
+ }
183
+ }