@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.
- package/README.md +65 -0
- package/bin/halofy-agent.mjs +50 -0
- package/bin/install.mjs +10 -0
- package/package.json +33 -0
- package/src/active.mjs +15 -0
- package/src/claude-config.mjs +139 -0
- package/src/claude-hook.mjs +154 -0
- package/src/crypto.mjs +143 -0
- package/src/install.mjs +201 -0
- package/src/installer-cli.mjs +144 -0
- package/src/mcp-proxy.mjs +94 -0
- package/src/queue.mjs +183 -0
- package/src/runtime.mjs +254 -0
- package/src/session.mjs +465 -0
- package/src/storage.mjs +108 -0
- package/src/transport.mjs +78 -0
- package/src/version.mjs +4 -0
package/src/session.mjs
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readJson, withFileLock, writePrivateFile } from "./storage.mjs";
|
|
5
|
+
|
|
6
|
+
// This is the complete serialized payload limit, not merely the body limit.
|
|
7
|
+
// It leaves the event envelope inside the server's 64 KiB inline-event bound.
|
|
8
|
+
export const MAX_CLAUDE_EVENT_PAYLOAD_BYTES = 64 * 1024;
|
|
9
|
+
const MAX_TOOL_NAME_CHARS = 300;
|
|
10
|
+
const MAX_RECENT_EVENT_KEYS = 4_096;
|
|
11
|
+
|
|
12
|
+
function digest(value) {
|
|
13
|
+
return createHash("sha256").update(value).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function boundedIdentifier(value, limit = MAX_TOOL_NAME_CHARS) {
|
|
17
|
+
return String(value ?? "").slice(0, limit);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function reasonCode(value) {
|
|
21
|
+
return String(value ?? "capture_unavailable")
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9_.-]/g, "_")
|
|
24
|
+
.slice(0, 64) || "capture_unavailable";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function boundedEventKey(value) {
|
|
28
|
+
const key = String(value);
|
|
29
|
+
return key.length <= 256 ? key : `claude-key:${digest(key)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stableClone(value) {
|
|
33
|
+
if (Array.isArray(value)) return value.map(stableClone);
|
|
34
|
+
if (!value || typeof value !== "object") return value;
|
|
35
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableClone(value[key])]));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stableJson(value) {
|
|
39
|
+
return JSON.stringify(stableClone(value)) ?? "null";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function containsInlineArtifactBody(value) {
|
|
43
|
+
if (Array.isArray(value)) return value.some(containsInlineArtifactBody);
|
|
44
|
+
if (!value || typeof value !== "object") return false;
|
|
45
|
+
if (["image", "document", "artifact", "file"].includes(value.type)) {
|
|
46
|
+
const source = value.source && typeof value.source === "object" ? value.source : {};
|
|
47
|
+
if (typeof source.data === "string" || typeof value.data === "string") return true;
|
|
48
|
+
}
|
|
49
|
+
return Object.values(value).some(containsInlineArtifactBody);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function bodyBytes(value, format) {
|
|
53
|
+
if (format === "utf8") return Buffer.from(String(value), "utf8");
|
|
54
|
+
return Buffer.from(stableJson(value), "utf8");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function digestOnlyPayload({
|
|
58
|
+
role = null,
|
|
59
|
+
body,
|
|
60
|
+
format = "json",
|
|
61
|
+
status = "unsupported",
|
|
62
|
+
reason,
|
|
63
|
+
extra = {},
|
|
64
|
+
}) {
|
|
65
|
+
const bytes = Buffer.isBuffer(body) ? body : bodyBytes(body, format);
|
|
66
|
+
return {
|
|
67
|
+
...(role ? { role } : {}),
|
|
68
|
+
contentFormat: "digest_only",
|
|
69
|
+
captureStatus: status,
|
|
70
|
+
captureReasonCode: reasonCode(reason),
|
|
71
|
+
bodySha256: digest(bytes),
|
|
72
|
+
originalBytes: bytes.length,
|
|
73
|
+
...extra,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function boundedCompletePayload(payload, { role, body, format = "json", extra = {} } = {}) {
|
|
78
|
+
if (Buffer.byteLength(JSON.stringify(payload)) <= MAX_CLAUDE_EVENT_PAYLOAD_BYTES) return payload;
|
|
79
|
+
return digestOnlyPayload({
|
|
80
|
+
role,
|
|
81
|
+
body: body ?? payload,
|
|
82
|
+
format,
|
|
83
|
+
status: "truncated",
|
|
84
|
+
reason: "inline_body_too_large",
|
|
85
|
+
extra,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizedEvent({ eventKey, type, occurredAt, payload, sourceEndOffset }) {
|
|
90
|
+
const {
|
|
91
|
+
role,
|
|
92
|
+
contentFormat = "json",
|
|
93
|
+
captureStatus = "complete",
|
|
94
|
+
captureReasonCode,
|
|
95
|
+
text,
|
|
96
|
+
...structuredPayload
|
|
97
|
+
} = payload;
|
|
98
|
+
const wirePayload = contentFormat === "utf8" ? text : structuredPayload;
|
|
99
|
+
return {
|
|
100
|
+
eventKey: boundedEventKey(eventKey),
|
|
101
|
+
type,
|
|
102
|
+
occurredAt,
|
|
103
|
+
...(role ? { role } : {}),
|
|
104
|
+
contentFormat,
|
|
105
|
+
captureStatus,
|
|
106
|
+
...(captureReasonCode ? { captureReasonCode } : {}),
|
|
107
|
+
payload: wirePayload,
|
|
108
|
+
payloadSha256: digest(contentFormat === "utf8" ? wirePayload : JSON.stringify(wirePayload)),
|
|
109
|
+
...(sourceEndOffset ? { sourceEndOffset } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function nativeEntryId(entry, evidence) {
|
|
114
|
+
return entry?.uuid || entry?.message_id || entry?.message?.id ||
|
|
115
|
+
`offset-${evidence.byteOffset}-${digest(evidence.rawBytes ?? stableJson(entry)).slice(0, 16)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function textEvent({ nativeId, index, role, text, occurredAt, sourceEndOffset }) {
|
|
119
|
+
const payload = boundedCompletePayload({
|
|
120
|
+
role,
|
|
121
|
+
contentFormat: "utf8",
|
|
122
|
+
captureStatus: "complete",
|
|
123
|
+
text,
|
|
124
|
+
}, { role, body: text, format: "utf8" });
|
|
125
|
+
return normalizedEvent({
|
|
126
|
+
eventKey: `claude:${nativeId}:message:${index}`,
|
|
127
|
+
type: "message",
|
|
128
|
+
occurredAt,
|
|
129
|
+
payload,
|
|
130
|
+
sourceEndOffset,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function toolEvent({ nativeId, index, type, role = "tool", toolName, toolUseId, body, occurredAt, sourceEndOffset, failed = false }) {
|
|
135
|
+
const field = type === "tool_call" ? "input" : "result";
|
|
136
|
+
const stableBody = stableClone(body ?? null);
|
|
137
|
+
const toolMetadata = {
|
|
138
|
+
toolName: boundedIdentifier(toolName),
|
|
139
|
+
toolUseId: boundedIdentifier(toolUseId),
|
|
140
|
+
...(type === "tool_result" ? { failed: Boolean(failed) } : {}),
|
|
141
|
+
};
|
|
142
|
+
const completePayload = {
|
|
143
|
+
role,
|
|
144
|
+
contentFormat: "json",
|
|
145
|
+
captureStatus: "complete",
|
|
146
|
+
...toolMetadata,
|
|
147
|
+
[field]: stableBody,
|
|
148
|
+
};
|
|
149
|
+
const payload = containsInlineArtifactBody(stableBody)
|
|
150
|
+
? digestOnlyPayload({
|
|
151
|
+
role,
|
|
152
|
+
body: stableBody,
|
|
153
|
+
status: "unsupported",
|
|
154
|
+
reason: "unsupported_artifact_body",
|
|
155
|
+
extra: toolMetadata,
|
|
156
|
+
})
|
|
157
|
+
: boundedCompletePayload(completePayload, {
|
|
158
|
+
role,
|
|
159
|
+
body: stableBody,
|
|
160
|
+
format: "json",
|
|
161
|
+
extra: toolMetadata,
|
|
162
|
+
});
|
|
163
|
+
const stableToolId = boundedIdentifier(toolUseId, 256);
|
|
164
|
+
return normalizedEvent({
|
|
165
|
+
eventKey: stableToolId
|
|
166
|
+
? `claude-tool:${stableToolId}:${type === "tool_call" ? "call" : "result"}`
|
|
167
|
+
: `claude:${nativeId}:${type}:${index}`,
|
|
168
|
+
type,
|
|
169
|
+
occurredAt,
|
|
170
|
+
payload,
|
|
171
|
+
sourceEndOffset,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function base64Bytes(value) {
|
|
176
|
+
if (typeof value !== "string" || value.length === 0 || value.length % 4 === 1 ||
|
|
177
|
+
!/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return null;
|
|
178
|
+
try { return Buffer.from(value, "base64"); } catch { return null; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function artifactEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset }) {
|
|
182
|
+
const source = block?.source && typeof block.source === "object" ? block.source : {};
|
|
183
|
+
const mediaType = boundedIdentifier(source.media_type || block.media_type, 256);
|
|
184
|
+
const reference = source.path || source.url || source.file_id || block.path || block.url || block.file_id;
|
|
185
|
+
if (typeof reference === "string" && reference.length > 0 && source.data === undefined && block.data === undefined) {
|
|
186
|
+
const payload = boundedCompletePayload({
|
|
187
|
+
role,
|
|
188
|
+
contentFormat: "artifact_ref",
|
|
189
|
+
captureStatus: "complete",
|
|
190
|
+
artifactKind: boundedIdentifier(block.type || "artifact", 100),
|
|
191
|
+
mediaType,
|
|
192
|
+
referenceType: boundedIdentifier(source.type || "host", 100),
|
|
193
|
+
reference,
|
|
194
|
+
}, { role, body: reference, format: "utf8", extra: { artifactKind: "artifact", mediaType } });
|
|
195
|
+
return normalizedEvent({
|
|
196
|
+
eventKey: `claude:${nativeId}:artifact:${index}`,
|
|
197
|
+
type: "artifact",
|
|
198
|
+
occurredAt,
|
|
199
|
+
payload,
|
|
200
|
+
sourceEndOffset,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const encoded = source.data ?? block.data;
|
|
204
|
+
const decoded = base64Bytes(encoded);
|
|
205
|
+
const body = decoded ?? Buffer.from(typeof encoded === "string" ? encoded : stableJson(block), "utf8");
|
|
206
|
+
const payload = digestOnlyPayload({
|
|
207
|
+
role,
|
|
208
|
+
body,
|
|
209
|
+
status: "unsupported",
|
|
210
|
+
reason: decoded ? "artifact_upload_unavailable" : "unsupported_artifact_body",
|
|
211
|
+
extra: {
|
|
212
|
+
artifactKind: boundedIdentifier(block.type || "artifact", 100),
|
|
213
|
+
mediaType,
|
|
214
|
+
referenceType: boundedIdentifier(source.type || "unknown", 100),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
return normalizedEvent({
|
|
218
|
+
eventKey: `claude:${nativeId}:artifact:${index}`,
|
|
219
|
+
type: "artifact",
|
|
220
|
+
occurredAt,
|
|
221
|
+
payload,
|
|
222
|
+
sourceEndOffset,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset }) {
|
|
227
|
+
return normalizedEvent({
|
|
228
|
+
eventKey: `claude:${nativeId}:unsupported:${index}`,
|
|
229
|
+
type: "message",
|
|
230
|
+
occurredAt,
|
|
231
|
+
payload: digestOnlyPayload({
|
|
232
|
+
role,
|
|
233
|
+
body: block,
|
|
234
|
+
status: "unsupported",
|
|
235
|
+
reason: `unsupported_claude_content_${boundedIdentifier(block?.type || "unknown", 80)}`,
|
|
236
|
+
}),
|
|
237
|
+
sourceEndOffset,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function deriveSessionHash({ installationId, clientKind, hostSessionId }) {
|
|
242
|
+
if (!installationId || !clientKind || !hostSessionId) throw new Error("session identity is incomplete");
|
|
243
|
+
return digest(`halofy-session-v1\0${installationId}\0${clientKind}\0${hostSessionId}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function stripInjectedContext(value) {
|
|
247
|
+
// Content delimiters are forgeable by the user and by recalled text. Claude
|
|
248
|
+
// delivers hook `additionalContext` outside the user message, while this
|
|
249
|
+
// normalizer admits only native transcript records, so loop filtering is
|
|
250
|
+
// structural and must never delete delimiter-shaped user text.
|
|
251
|
+
return String(value);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Normalize one current Claude transcript record into zero or more archive
|
|
256
|
+
* events. Text is never trimmed or prefix-truncated. Unsupported/oversized
|
|
257
|
+
* bodies become explicit digest-only events, so advancing the byte cursor
|
|
258
|
+
* cannot silently turn a capture gap into success.
|
|
259
|
+
*/
|
|
260
|
+
export function normalizeClaudeTranscriptEntry(entry, evidence) {
|
|
261
|
+
const message = entry?.message;
|
|
262
|
+
if (!message || !["user", "assistant"].includes(message.role)) return [];
|
|
263
|
+
const role = message.role;
|
|
264
|
+
const occurredAt = entry.timestamp || evidence.occurredAt;
|
|
265
|
+
const sourceEndOffset = evidence.endOffset;
|
|
266
|
+
const nativeId = nativeEntryId(entry, evidence);
|
|
267
|
+
if (typeof message.content === "string") {
|
|
268
|
+
return [textEvent({ nativeId, index: 0, role, text: message.content, occurredAt, sourceEndOffset })];
|
|
269
|
+
}
|
|
270
|
+
if (!Array.isArray(message.content)) {
|
|
271
|
+
return [unsupportedBlockEvent({
|
|
272
|
+
nativeId,
|
|
273
|
+
index: 0,
|
|
274
|
+
role,
|
|
275
|
+
block: message.content,
|
|
276
|
+
occurredAt,
|
|
277
|
+
sourceEndOffset,
|
|
278
|
+
})];
|
|
279
|
+
}
|
|
280
|
+
if (message.content.length === 0) {
|
|
281
|
+
return [textEvent({ nativeId, index: 0, role, text: "", occurredAt, sourceEndOffset })];
|
|
282
|
+
}
|
|
283
|
+
return message.content.map((block, index) => {
|
|
284
|
+
if (!block || typeof block !== "object") {
|
|
285
|
+
return unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset });
|
|
286
|
+
}
|
|
287
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
288
|
+
return textEvent({ nativeId, index, role, text: block.text, occurredAt, sourceEndOffset });
|
|
289
|
+
}
|
|
290
|
+
if (block.type === "tool_use") {
|
|
291
|
+
return toolEvent({
|
|
292
|
+
nativeId,
|
|
293
|
+
index,
|
|
294
|
+
type: "tool_call",
|
|
295
|
+
toolName: block.name,
|
|
296
|
+
toolUseId: block.id,
|
|
297
|
+
body: block.input,
|
|
298
|
+
occurredAt,
|
|
299
|
+
sourceEndOffset,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (block.type === "tool_result") {
|
|
303
|
+
return toolEvent({
|
|
304
|
+
nativeId,
|
|
305
|
+
index,
|
|
306
|
+
type: "tool_result",
|
|
307
|
+
toolName: block.name,
|
|
308
|
+
toolUseId: block.tool_use_id,
|
|
309
|
+
body: block.content,
|
|
310
|
+
occurredAt,
|
|
311
|
+
sourceEndOffset,
|
|
312
|
+
failed: block.is_error,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (["image", "document", "artifact", "file"].includes(block.type)) {
|
|
316
|
+
return artifactEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset });
|
|
317
|
+
}
|
|
318
|
+
return unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset });
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function normalizeMalformedClaudeTranscriptLine(rawBytes, evidence) {
|
|
323
|
+
const bytes = Buffer.isBuffer(rawBytes) ? rawBytes : Buffer.from(rawBytes, "utf8");
|
|
324
|
+
const payload = digestOnlyPayload({
|
|
325
|
+
body: bytes,
|
|
326
|
+
status: "missing",
|
|
327
|
+
reason: "malformed_transcript_record",
|
|
328
|
+
});
|
|
329
|
+
return normalizedEvent({
|
|
330
|
+
eventKey: `claude-malformed:${evidence.byteOffset}:${payload.bodySha256}`,
|
|
331
|
+
type: "checkpoint",
|
|
332
|
+
occurredAt: evidence.occurredAt,
|
|
333
|
+
payload,
|
|
334
|
+
sourceEndOffset: evidence.endOffset,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function normalizeClaudeHookEvent(type, input, { sessionHash, sequence }) {
|
|
339
|
+
const occurredAt = new Date().toISOString();
|
|
340
|
+
const nativeId = input.event_id || `${sessionHash}:${sequence}`;
|
|
341
|
+
let event;
|
|
342
|
+
switch (type) {
|
|
343
|
+
case "tool_call":
|
|
344
|
+
event = toolEvent({
|
|
345
|
+
nativeId,
|
|
346
|
+
index: 0,
|
|
347
|
+
type,
|
|
348
|
+
toolName: input.tool_name ?? input.name,
|
|
349
|
+
toolUseId: input.tool_use_id ?? input.id,
|
|
350
|
+
body: input.tool_input ?? input.input,
|
|
351
|
+
occurredAt,
|
|
352
|
+
});
|
|
353
|
+
break;
|
|
354
|
+
case "tool_result":
|
|
355
|
+
event = toolEvent({
|
|
356
|
+
nativeId,
|
|
357
|
+
index: 0,
|
|
358
|
+
type,
|
|
359
|
+
toolName: input.tool_name ?? input.name,
|
|
360
|
+
toolUseId: input.tool_use_id ?? input.id,
|
|
361
|
+
body: input.tool_response ?? input.tool_result ?? input.output ?? input.error,
|
|
362
|
+
occurredAt,
|
|
363
|
+
failed: Boolean(input.tool_error || input.error || input.is_error),
|
|
364
|
+
});
|
|
365
|
+
break;
|
|
366
|
+
case "compaction": {
|
|
367
|
+
const payload = {
|
|
368
|
+
contentFormat: "json",
|
|
369
|
+
captureStatus: "complete",
|
|
370
|
+
trigger: boundedIdentifier(input.trigger, 100) || "host",
|
|
371
|
+
};
|
|
372
|
+
event = normalizedEvent({ eventKey: `claude-hook:compaction:${nativeId}`, type, occurredAt, payload });
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
case "checkpoint": {
|
|
376
|
+
const payload = {
|
|
377
|
+
contentFormat: "json",
|
|
378
|
+
captureStatus: "complete",
|
|
379
|
+
boundary: boundedIdentifier(input.hook_event_name, 100) || "host",
|
|
380
|
+
};
|
|
381
|
+
event = normalizedEvent({ eventKey: `claude-hook:checkpoint:${nativeId}`, type, occurredAt, payload });
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
default:
|
|
385
|
+
throw new Error(`unsupported normalized hook event: ${type}`);
|
|
386
|
+
}
|
|
387
|
+
return { ...event, sequence };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
|
|
391
|
+
const bytes = await readFile(path);
|
|
392
|
+
const start = Number.isSafeInteger(cursor?.byteOffset) && cursor.byteOffset <= bytes.length ? cursor.byteOffset : 0;
|
|
393
|
+
const suffix = bytes.subarray(start);
|
|
394
|
+
const events = [];
|
|
395
|
+
let position = 0;
|
|
396
|
+
while (position < suffix.length) {
|
|
397
|
+
const newline = suffix.indexOf(0x0a, position);
|
|
398
|
+
if (newline === -1) break; // keep an incomplete JSONL record for the next hook
|
|
399
|
+
const endOffset = start + newline + 1;
|
|
400
|
+
const rawBytes = suffix.subarray(position, newline);
|
|
401
|
+
const raw = rawBytes.toString("utf8");
|
|
402
|
+
const byteOffset = start + position;
|
|
403
|
+
position = newline + 1;
|
|
404
|
+
if (!raw.trim()) continue;
|
|
405
|
+
const evidence = {
|
|
406
|
+
sessionHash,
|
|
407
|
+
byteOffset,
|
|
408
|
+
endOffset,
|
|
409
|
+
rawBytes,
|
|
410
|
+
occurredAt: new Date().toISOString(),
|
|
411
|
+
};
|
|
412
|
+
try {
|
|
413
|
+
events.push(...normalizeClaudeTranscriptEntry(JSON.parse(raw), evidence));
|
|
414
|
+
} catch {
|
|
415
|
+
events.push(normalizeMalformedClaudeTranscriptLine(rawBytes, evidence));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return { events, observedEndOffset: start + position };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export class CursorStore {
|
|
422
|
+
constructor(root) {
|
|
423
|
+
this.path = join(root, "cursors.json");
|
|
424
|
+
this.lockPath = join(root, "cursors.lock");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async get(sessionHash) {
|
|
428
|
+
const state = await readJson(this.path, { version: 1, sessions: {} });
|
|
429
|
+
return {
|
|
430
|
+
byteOffset: 0,
|
|
431
|
+
sequence: 0,
|
|
432
|
+
committedSequence: 0,
|
|
433
|
+
recentEventKeys: [],
|
|
434
|
+
...(state.sessions[sessionHash] ?? {}),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async update(sessionHash, patch) {
|
|
439
|
+
return withFileLock(this.lockPath, async () => {
|
|
440
|
+
const state = await readJson(this.path, { version: 1, sessions: {} });
|
|
441
|
+
state.sessions[sessionHash] = { ...(state.sessions[sessionHash] ?? {}), ...patch };
|
|
442
|
+
await writePrivateFile(this.path, `${JSON.stringify(state)}\n`);
|
|
443
|
+
return state.sessions[sessionHash];
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async rememberEventKeys(sessionHash, eventKeys) {
|
|
448
|
+
if (!Array.isArray(eventKeys) || eventKeys.length === 0) return this.get(sessionHash);
|
|
449
|
+
return withFileLock(this.lockPath, async () => {
|
|
450
|
+
const state = await readJson(this.path, { version: 1, sessions: {} });
|
|
451
|
+
const current = {
|
|
452
|
+
byteOffset: 0,
|
|
453
|
+
sequence: 0,
|
|
454
|
+
committedSequence: 0,
|
|
455
|
+
recentEventKeys: [],
|
|
456
|
+
...(state.sessions[sessionHash] ?? {}),
|
|
457
|
+
};
|
|
458
|
+
const recent = [...(Array.isArray(current.recentEventKeys) ? current.recentEventKeys : []), ...eventKeys];
|
|
459
|
+
current.recentEventKeys = [...new Set(recent)].slice(-MAX_RECENT_EVENT_KEYS);
|
|
460
|
+
state.sessions[sessionHash] = current;
|
|
461
|
+
await writePrivateFile(this.path, `${JSON.stringify(state)}\n`);
|
|
462
|
+
return current;
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
package/src/storage.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, chmod, mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { homedir, platform } from "node:os";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
export function defaultRuntimeDirectory() {
|
|
8
|
+
if (process.env.HALOFY_AGENT_HOME) return process.env.HALOFY_AGENT_HOME;
|
|
9
|
+
if (platform() === "win32") {
|
|
10
|
+
return join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "Halofy", "agent-runtime");
|
|
11
|
+
}
|
|
12
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "halofy", "agent-runtime");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function ensurePrivateDirectory(path) {
|
|
16
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
17
|
+
if (platform() !== "win32") await chmod(path, 0o700);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function writePrivateFile(path, value) {
|
|
21
|
+
await ensurePrivateDirectory(dirname(path));
|
|
22
|
+
const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
23
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
24
|
+
try {
|
|
25
|
+
await handle.writeFile(value);
|
|
26
|
+
await handle.sync();
|
|
27
|
+
} finally {
|
|
28
|
+
await handle.close();
|
|
29
|
+
}
|
|
30
|
+
await rename(temporary, path);
|
|
31
|
+
if (platform() !== "win32") await chmod(path, 0o600);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function withFileLock(path, action, { timeoutMs = 3_000, staleMs = 30_000 } = {}) {
|
|
35
|
+
await ensurePrivateDirectory(dirname(path));
|
|
36
|
+
const started = Date.now();
|
|
37
|
+
while (true) {
|
|
38
|
+
let handle;
|
|
39
|
+
try {
|
|
40
|
+
handle = await open(path, "wx", 0o600);
|
|
41
|
+
await handle.writeFile(`${process.pid}\n`);
|
|
42
|
+
return await action();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error?.code !== "EEXIST") throw error;
|
|
45
|
+
try {
|
|
46
|
+
const lock = await stat(path);
|
|
47
|
+
if (Date.now() - lock.mtimeMs > staleMs) await unlink(path);
|
|
48
|
+
} catch (statError) {
|
|
49
|
+
if (statError?.code !== "ENOENT") throw statError;
|
|
50
|
+
}
|
|
51
|
+
if (Date.now() - started >= timeoutMs) throw new Error("local runtime storage is busy");
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
53
|
+
} finally {
|
|
54
|
+
if (handle) {
|
|
55
|
+
await handle.close();
|
|
56
|
+
try { await unlink(path); } catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function readJson(path, fallback = null) {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error?.code === "ENOENT") return fallback;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function fileExists(path) {
|
|
72
|
+
try {
|
|
73
|
+
await access(path, constants.F_OK);
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class ConnectionStore {
|
|
81
|
+
constructor(root = defaultRuntimeDirectory()) {
|
|
82
|
+
this.root = root;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
path(id) {
|
|
86
|
+
if (!/^[A-Za-z0-9_-]{1,160}$/.test(id)) throw new Error("invalid installation id");
|
|
87
|
+
return join(this.root, "connections", `${id}.json`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async save(connection) {
|
|
91
|
+
if (!connection?.installationId || !connection?.privateJwk) throw new Error("incomplete connection");
|
|
92
|
+
await writePrivateFile(this.path(connection.installationId), `${JSON.stringify(connection, null, 2)}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async load(id) {
|
|
96
|
+
const connection = await readJson(this.path(id));
|
|
97
|
+
if (!connection) throw new Error(`connection ${id} is not installed`);
|
|
98
|
+
return connection;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async remove(id) {
|
|
102
|
+
try {
|
|
103
|
+
await unlink(this.path(id));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error?.code !== "ENOENT") throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { signInstallationRequest } from "./crypto.mjs";
|
|
2
|
+
|
|
3
|
+
function contentType(response) {
|
|
4
|
+
return response.headers.get("content-type") || "";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class RuntimeHttpError extends Error {
|
|
8
|
+
constructor(status, code, detail = null) {
|
|
9
|
+
super(`Halofy runtime request failed (${status}${code ? `: ${code}` : ""})`);
|
|
10
|
+
this.name = "RuntimeHttpError";
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.detail = detail;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class SignedRuntimeTransport {
|
|
18
|
+
constructor(connection, { fetchImpl = globalThis.fetch, timeoutMs = 8_000 } = {}) {
|
|
19
|
+
this.connection = connection;
|
|
20
|
+
this.fetch = fetchImpl;
|
|
21
|
+
this.timeoutMs = timeoutMs;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async request(path, { method = "POST", body, headers = {}, raw = false } = {}) {
|
|
25
|
+
const bodyBytes = body === undefined
|
|
26
|
+
? Buffer.alloc(0)
|
|
27
|
+
: Buffer.from(typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body));
|
|
28
|
+
const proof = signInstallationRequest({
|
|
29
|
+
installationId: this.connection.installationId,
|
|
30
|
+
privateJwk: this.connection.privateJwk,
|
|
31
|
+
method,
|
|
32
|
+
path,
|
|
33
|
+
bodyBytes,
|
|
34
|
+
});
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
37
|
+
try {
|
|
38
|
+
const response = await this.fetch(`${this.connection.serverUrl}${path}`, {
|
|
39
|
+
method,
|
|
40
|
+
headers: {
|
|
41
|
+
...proof.headers,
|
|
42
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
43
|
+
...headers,
|
|
44
|
+
},
|
|
45
|
+
body: body === undefined ? undefined : bodyBytes,
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
let detail = null;
|
|
50
|
+
try { detail = await response.json(); } catch { /* content-free fallback */ }
|
|
51
|
+
throw new RuntimeHttpError(response.status, detail?.code || "request_rejected", detail);
|
|
52
|
+
}
|
|
53
|
+
if (raw) return response;
|
|
54
|
+
if (response.status === 204 || !contentType(response).includes("json")) return null;
|
|
55
|
+
return response.json();
|
|
56
|
+
} finally {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
heartbeat(capabilities, { pluginVersion, proofStorage, diagnostics }) {
|
|
62
|
+
return this.request("/v1/agent-runtime/heartbeat", {
|
|
63
|
+
body: { pluginVersion, capabilities, proofStorage, ...(diagnostics ? { diagnostics } : {}) },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
openSession(body) { return this.request("/v1/agent-sessions/open", { body }); }
|
|
68
|
+
appendEvents(sessionId, events) { return this.request(`/v1/agent-sessions/${encodeURIComponent(sessionId)}/events`, { body: { events } }); }
|
|
69
|
+
recall(sessionId, body) { return this.request(`/v1/agent-sessions/${encodeURIComponent(sessionId)}/recall`, { body }); }
|
|
70
|
+
contextUsed(sessionId, body) { return this.request(`/v1/agent-sessions/${encodeURIComponent(sessionId)}/context-used`, { body }); }
|
|
71
|
+
commit(sessionId, throughSequence, reason) {
|
|
72
|
+
return this.request(`/v1/agent-sessions/${encodeURIComponent(sessionId)}/commits`, { body: { throughSequence, reason } });
|
|
73
|
+
}
|
|
74
|
+
commitStatus(commitId) { return this.request(`/v1/agent-session-commits/${encodeURIComponent(commitId)}`, { method: "GET" }); }
|
|
75
|
+
close(sessionId, body = {}) {
|
|
76
|
+
return this.request(`/v1/agent-sessions/${encodeURIComponent(sessionId)}/close`, { body });
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/version.mjs
ADDED