@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/runtime.mjs
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { BoundedEncryptedQueue } from "./queue.mjs";
|
|
4
|
+
import { CursorStore, deriveSessionHash, readClaudeTranscriptSuffix } from "./session.mjs";
|
|
5
|
+
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
6
|
+
import { withFileLock } from "./storage.mjs";
|
|
7
|
+
|
|
8
|
+
export class LifecycleRuntime {
|
|
9
|
+
constructor(connection, {
|
|
10
|
+
root,
|
|
11
|
+
transport = new SignedRuntimeTransport(connection),
|
|
12
|
+
maxBatchEvents = 100,
|
|
13
|
+
maxBatchBytes = 1024 * 1024,
|
|
14
|
+
commitThreshold = 20,
|
|
15
|
+
} = {}) {
|
|
16
|
+
if (!root) throw new Error("runtime storage root is required");
|
|
17
|
+
this.connection = connection;
|
|
18
|
+
this.transport = transport;
|
|
19
|
+
const connectionRoot = join(root, connection.installationId);
|
|
20
|
+
this.queue = new BoundedEncryptedQueue(connectionRoot);
|
|
21
|
+
this.cursors = new CursorStore(connectionRoot);
|
|
22
|
+
this.operationLockPath = join(connectionRoot, "runtime.operation.lock");
|
|
23
|
+
this.maxBatchEvents = Math.min(100, Math.max(1, maxBatchEvents));
|
|
24
|
+
this.maxBatchBytes = Math.min(1024 * 1024, Math.max(1024, maxBatchBytes));
|
|
25
|
+
this.commitThreshold = commitThreshold;
|
|
26
|
+
this.sessions = new Map();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
sessionHash(hostSessionId) {
|
|
30
|
+
return deriveSessionHash({
|
|
31
|
+
installationId: this.connection.installationId,
|
|
32
|
+
clientKind: this.connection.clientKind,
|
|
33
|
+
hostSessionId,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async resolveSessionByHash(sessionHash, parentSessionId) {
|
|
38
|
+
if (this.sessions.has(sessionHash)) return this.sessions.get(sessionHash);
|
|
39
|
+
const opened = await this.transport.openSession({
|
|
40
|
+
externalSessionId: sessionHash,
|
|
41
|
+
...(parentSessionId ? { parentSessionId } : {}),
|
|
42
|
+
});
|
|
43
|
+
const sessionId = opened?.sessionId || opened?.id;
|
|
44
|
+
if (!sessionId) throw new Error("session open returned no session id");
|
|
45
|
+
this.sessions.set(sessionHash, sessionId);
|
|
46
|
+
return sessionId;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async resolveSession(hostSessionId, parentHostSessionId) {
|
|
50
|
+
const parentSessionId = parentHostSessionId
|
|
51
|
+
? await this.resolveSessionByHash(this.sessionHash(parentHostSessionId))
|
|
52
|
+
: undefined;
|
|
53
|
+
return this.resolveSessionByHash(this.sessionHash(hostSessionId), parentSessionId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async recall(hostSessionId, query, budget = 1_500) {
|
|
57
|
+
const sessionId = await this.resolveSession(hostSessionId);
|
|
58
|
+
const recalled = await this.transport.recall(sessionId, { query: String(query).slice(0, 8_000), tokenBudget: budget });
|
|
59
|
+
const blocks = Array.isArray(recalled?.blocks) ? recalled.blocks : Array.isArray(recalled) ? recalled : [];
|
|
60
|
+
const references = (Array.isArray(recalled?.refs) ? recalled.refs : blocks.map((block) => block?.recallRef))
|
|
61
|
+
.filter((reference) => typeof reference === "string" && reference.length > 0)
|
|
62
|
+
.slice(0, 64)
|
|
63
|
+
.map((reference) => reference.slice(0, 256));
|
|
64
|
+
if (references.length > 0) {
|
|
65
|
+
try {
|
|
66
|
+
await this.enqueueEvent(hostSessionId, {
|
|
67
|
+
eventKey: `context-recalled:${newLocalEventId()}`,
|
|
68
|
+
occurredAt: new Date().toISOString(),
|
|
69
|
+
type: "context_recalled",
|
|
70
|
+
payload: { refs: references },
|
|
71
|
+
payloadSha256: createHash("sha256").update(JSON.stringify({ refs: references })).digest("hex"),
|
|
72
|
+
});
|
|
73
|
+
} catch {
|
|
74
|
+
// enqueueEvent persists before replay. A brownout must not turn ranked
|
|
75
|
+
// recall blocks into an unavailable hook response.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return recalled;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async recordContextUsed(hostSessionId, references) {
|
|
82
|
+
const bounded = references.filter((value) => typeof value === "string").slice(0, 64).map((value) => value.slice(0, 256));
|
|
83
|
+
if (bounded.length === 0) return null;
|
|
84
|
+
return withFileLock(this.operationLockPath, async () => {
|
|
85
|
+
await this.#replayUnlocked();
|
|
86
|
+
const sessionHash = this.sessionHash(hostSessionId);
|
|
87
|
+
const cursor = await this.cursors.get(sessionHash);
|
|
88
|
+
const response = await this.transport.contextUsed(await this.resolveSession(hostSessionId), {
|
|
89
|
+
eventId: `context-used:${newLocalEventId()}`,
|
|
90
|
+
sequence: cursor.sequence + 1,
|
|
91
|
+
occurredAt: new Date().toISOString(),
|
|
92
|
+
refs: bounded,
|
|
93
|
+
});
|
|
94
|
+
if (Number(response?.accepted ?? 0) + Number(response?.duplicates ?? 0) > 0) {
|
|
95
|
+
await this.cursors.update(sessionHash, { sequence: cursor.sequence + 1 });
|
|
96
|
+
}
|
|
97
|
+
return response;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async captureClaudeTranscript(hostSessionId, transcriptPath) {
|
|
102
|
+
const sessionHash = this.sessionHash(hostSessionId);
|
|
103
|
+
const queued = await withFileLock(this.operationLockPath, async () => {
|
|
104
|
+
const cursor = await this.cursors.get(sessionHash);
|
|
105
|
+
const suffix = await readClaudeTranscriptSuffix(transcriptPath, cursor, sessionHash);
|
|
106
|
+
const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
|
|
107
|
+
const unseenEvents = suffix.events.filter((event) => !recentEventKeys.has(event.eventKey));
|
|
108
|
+
const result = unseenEvents.length === 0
|
|
109
|
+
? { queued: 0 }
|
|
110
|
+
: await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
|
|
111
|
+
maxBatchEvents: this.maxBatchEvents,
|
|
112
|
+
maxBatchBytes: this.maxBatchBytes,
|
|
113
|
+
acknowledgedSequence: cursor.sequence,
|
|
114
|
+
});
|
|
115
|
+
// Complete ignored/malformed JSONL records carry no event, while every
|
|
116
|
+
// normalized event is already durable in the encrypted queue. Advancing
|
|
117
|
+
// this byte cursor prevents unbounded reparsing without advancing the
|
|
118
|
+
// separately acknowledged event sequence.
|
|
119
|
+
if (suffix.observedEndOffset > cursor.byteOffset) {
|
|
120
|
+
await this.cursors.update(sessionHash, { byteOffset: suffix.observedEndOffset });
|
|
121
|
+
}
|
|
122
|
+
await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
|
|
123
|
+
return result;
|
|
124
|
+
});
|
|
125
|
+
if (queued.queued === 0) return { queued: 0, acknowledged: 0 };
|
|
126
|
+
const replay = await this.replay();
|
|
127
|
+
return { queued: queued.queued, acknowledged: replay.acknowledged };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async enqueueEvent(hostSessionId, event) {
|
|
131
|
+
return this.enqueueEvents(hostSessionId, [event]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async enqueueEvents(hostSessionId, events) {
|
|
135
|
+
const sessionHash = this.sessionHash(hostSessionId);
|
|
136
|
+
await withFileLock(this.operationLockPath, async () => {
|
|
137
|
+
const cursor = await this.cursors.get(sessionHash);
|
|
138
|
+
const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
|
|
139
|
+
const unseenEvents = events.filter((event) => !recentEventKeys.has(event.eventKey));
|
|
140
|
+
if (unseenEvents.length === 0) return;
|
|
141
|
+
await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
|
|
142
|
+
maxBatchEvents: this.maxBatchEvents,
|
|
143
|
+
maxBatchBytes: this.maxBatchBytes,
|
|
144
|
+
acknowledgedSequence: cursor.sequence,
|
|
145
|
+
});
|
|
146
|
+
await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
|
|
147
|
+
});
|
|
148
|
+
return this.replay();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async replay() {
|
|
152
|
+
return withFileLock(this.operationLockPath, () => this.#replayUnlocked());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async #replayUnlocked() {
|
|
156
|
+
let acknowledged = 0;
|
|
157
|
+
while (true) {
|
|
158
|
+
const head = await this.queue.peek();
|
|
159
|
+
if (!head) break;
|
|
160
|
+
const sessionId = await this.resolveSessionByHash(head.sessionHash);
|
|
161
|
+
const response = await this.transport.appendEvents(sessionId, head.events.map(({
|
|
162
|
+
sourceEndOffset,
|
|
163
|
+
eventKey,
|
|
164
|
+
payloadSha256,
|
|
165
|
+
...event
|
|
166
|
+
}) => ({ ...event, eventId: eventKey })));
|
|
167
|
+
const accepted = Math.max(0, Math.min(
|
|
168
|
+
head.events.length,
|
|
169
|
+
Number(response?.acceptedCount ?? response?.acceptedPrefix ??
|
|
170
|
+
(response?.accepted === undefined ? head.events.length : Number(response.accepted) + Number(response.duplicates ?? 0))),
|
|
171
|
+
));
|
|
172
|
+
if (accepted === 0) {
|
|
173
|
+
if (Number.isSafeInteger(response?.nextExpectedSequence)) {
|
|
174
|
+
const expected = response.nextExpectedSequence;
|
|
175
|
+
const firstExpected = head.events.findIndex((event) => event.sequence >= expected);
|
|
176
|
+
if (firstExpected > 0) {
|
|
177
|
+
await this.#acknowledge(head, firstExpected);
|
|
178
|
+
acknowledged += firstExpected;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
await this.#acknowledge(head, accepted);
|
|
185
|
+
acknowledged += accepted;
|
|
186
|
+
}
|
|
187
|
+
return { acknowledged, ...(await this.queue.diagnostics()) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async #acknowledge(head, accepted) {
|
|
191
|
+
const acceptedEvents = head.events.slice(0, accepted);
|
|
192
|
+
const last = acceptedEvents.at(-1);
|
|
193
|
+
const cursor = await this.cursors.get(head.sessionHash);
|
|
194
|
+
await this.cursors.update(head.sessionHash, {
|
|
195
|
+
sequence: Math.max(cursor.sequence, last.sequence),
|
|
196
|
+
...(last.sourceEndOffset ? { byteOffset: Math.max(cursor.byteOffset, last.sourceEndOffset) } : {}),
|
|
197
|
+
});
|
|
198
|
+
const suffix = head.events.slice(accepted);
|
|
199
|
+
const suffixId = suffix.length ? createHash("sha256")
|
|
200
|
+
.update(`${head.sessionHash}\0${suffix.map((event) => event.eventKey).join("\0")}`)
|
|
201
|
+
.digest("hex") : null;
|
|
202
|
+
await this.queue.replaceHead(suffix.length ? { ...head, events: suffix, id: suffixId } : null);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async commit(hostSessionId, reason, throughSequence) {
|
|
206
|
+
const sessionHash = this.sessionHash(hostSessionId);
|
|
207
|
+
const cursor = await this.cursors.get(sessionHash);
|
|
208
|
+
const target = throughSequence ?? cursor.sequence;
|
|
209
|
+
if (target <= cursor.committedSequence) return { skipped: true, throughSequence: target };
|
|
210
|
+
const sessionId = await this.resolveSessionByHash(sessionHash);
|
|
211
|
+
const result = await this.transport.commit(sessionId, target, reason);
|
|
212
|
+
await this.cursors.update(sessionHash, { committedSequence: target });
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async commitIfThreshold(hostSessionId, reason = "threshold") {
|
|
217
|
+
const cursor = await this.cursors.get(this.sessionHash(hostSessionId));
|
|
218
|
+
if (cursor.sequence - cursor.committedSequence < this.commitThreshold) return { skipped: true };
|
|
219
|
+
return this.commit(hostSessionId, reason);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async close(hostSessionId, reason = "session_end") {
|
|
223
|
+
return withFileLock(this.operationLockPath, async () => {
|
|
224
|
+
await this.#replayUnlocked();
|
|
225
|
+
const sessionHash = this.sessionHash(hostSessionId);
|
|
226
|
+
const cursor = await this.cursors.get(sessionHash);
|
|
227
|
+
await this.commit(hostSessionId, reason, cursor.sequence);
|
|
228
|
+
// Refresh the server's content-free queue evidence after replay. Session
|
|
229
|
+
// completeness is computed from both the final acknowledged sequence and
|
|
230
|
+
// these independently reported queue diagnostics.
|
|
231
|
+
await this.heartbeat(this.connection.capabilities || {});
|
|
232
|
+
return this.transport.close(await this.resolveSessionByHash(sessionHash), {
|
|
233
|
+
closedThroughSequence: cursor.sequence,
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async heartbeat(capabilities) {
|
|
239
|
+
const queue = await this.queue.diagnostics();
|
|
240
|
+
return this.transport.heartbeat(capabilities, {
|
|
241
|
+
pluginVersion: this.connection.pluginVersion || "0.2.0-local",
|
|
242
|
+
proofStorage: this.connection.proofStorage || "unknown",
|
|
243
|
+
diagnostics: {
|
|
244
|
+
queueDepth: queue.depth,
|
|
245
|
+
oldestPendingAt: queue.oldestPendingAt,
|
|
246
|
+
expiredCount: queue.expiredCount,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function newLocalEventId() {
|
|
253
|
+
return randomBytes(16).toString("base64url");
|
|
254
|
+
}
|