@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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
buildClaudeMetadataPayload,
|
|
5
|
+
hostMetadataEvent,
|
|
6
|
+
normalizedEvent,
|
|
7
|
+
usageInt,
|
|
8
|
+
USAGE_MODEL_PATTERN,
|
|
9
|
+
} from "../session.mjs";
|
|
10
|
+
import { codexHome } from "../host-roots.mjs";
|
|
11
|
+
import {
|
|
12
|
+
boundedHostSessionId,
|
|
13
|
+
boundedHostState,
|
|
14
|
+
boundedMetadataText,
|
|
15
|
+
completeJsonlLines,
|
|
16
|
+
containedPath,
|
|
17
|
+
drainedEndOffset,
|
|
18
|
+
MAX_DRIVER_SUFFIX_BYTES,
|
|
19
|
+
readSuffixWindow,
|
|
20
|
+
} from "./shared.mjs";
|
|
21
|
+
|
|
22
|
+
const NAMESPACE = "codex";
|
|
23
|
+
const PROVIDER_PATTERN = /^[a-z0-9-]{1,64}$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Codex rollouts live at
|
|
27
|
+
* `<codexHome>/sessions/YYYY/MM/DD/rollout-<ts>-<session-id>.jsonl` — the
|
|
28
|
+
* session id is the filename suffix. The hook payload is never trusted for a
|
|
29
|
+
* path; only the validated session id is interpolated, and every result is
|
|
30
|
+
* containment-checked under the codex root.
|
|
31
|
+
*/
|
|
32
|
+
async function locateRollout(root, sessionId) {
|
|
33
|
+
const sessionsRoot = join(root, "sessions");
|
|
34
|
+
let names;
|
|
35
|
+
try {
|
|
36
|
+
names = await readdir(sessionsRoot, { recursive: true });
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const suffix = `-${sessionId}.jsonl`;
|
|
41
|
+
const matches = names.filter((name) => {
|
|
42
|
+
const base = String(name).split(/[\\/]/).pop();
|
|
43
|
+
return base.startsWith("rollout-") && base.endsWith(suffix);
|
|
44
|
+
}).sort();
|
|
45
|
+
if (matches.length === 0) return null;
|
|
46
|
+
// Timestamped names sort chronologically; take the newest.
|
|
47
|
+
return containedPath(sessionsRoot, matches[matches.length - 1]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function usageEventFromTokenCount(info, { model, provider, byteOffset, endOffset, occurredAt }) {
|
|
51
|
+
const usage = info?.last_token_usage;
|
|
52
|
+
const input = usageInt(usage?.input_tokens);
|
|
53
|
+
const output = usageInt(usage?.output_tokens);
|
|
54
|
+
if (usage === null || typeof usage !== "object" || (input === null && output === null)) {
|
|
55
|
+
return normalizedEvent({
|
|
56
|
+
eventKey: `${NAMESPACE}:usage-gap:${byteOffset}`,
|
|
57
|
+
type: "usage",
|
|
58
|
+
occurredAt,
|
|
59
|
+
payload: {
|
|
60
|
+
role: "assistant", contentFormat: "json", captureStatus: "complete",
|
|
61
|
+
gap: true, reason: "usage_unavailable",
|
|
62
|
+
},
|
|
63
|
+
sourceEndOffset: endOffset,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (model === null) {
|
|
67
|
+
return normalizedEvent({
|
|
68
|
+
eventKey: `${NAMESPACE}:usage-gap:${byteOffset}`,
|
|
69
|
+
type: "usage",
|
|
70
|
+
occurredAt,
|
|
71
|
+
payload: {
|
|
72
|
+
role: "assistant", contentFormat: "json", captureStatus: "complete",
|
|
73
|
+
gap: true, reason: "model_unknown",
|
|
74
|
+
},
|
|
75
|
+
sourceEndOffset: endOffset,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const cached = usageInt(usage.cached_input_tokens) ?? 0;
|
|
79
|
+
const cacheWrite = usageInt(usage.cache_write_input_tokens) ?? 0;
|
|
80
|
+
// Verified against real rollouts: input_tokens is inclusive of BOTH the
|
|
81
|
+
// cached-read and cache-write tokens (total_tokens = input + output). The
|
|
82
|
+
// server prices input, cache-read, and cache-write as disjoint buckets, so
|
|
83
|
+
// the uncached-and-unwritten remainder is what belongs in inputTokens;
|
|
84
|
+
// clamp for malformed hosts.
|
|
85
|
+
const inputTokens = input === null ? null : Math.max(0, input - cached - cacheWrite);
|
|
86
|
+
return normalizedEvent({
|
|
87
|
+
eventKey: `${NAMESPACE}:usage:${byteOffset}`,
|
|
88
|
+
type: "usage",
|
|
89
|
+
occurredAt,
|
|
90
|
+
payload: {
|
|
91
|
+
role: "assistant",
|
|
92
|
+
contentFormat: "json",
|
|
93
|
+
captureStatus: "complete",
|
|
94
|
+
provider,
|
|
95
|
+
model,
|
|
96
|
+
providerRequestId: null,
|
|
97
|
+
messageId: null,
|
|
98
|
+
stopReason: null,
|
|
99
|
+
serviceTier: null,
|
|
100
|
+
effort: null,
|
|
101
|
+
sidechain: false,
|
|
102
|
+
latencyMs: null,
|
|
103
|
+
inputTokens,
|
|
104
|
+
outputTokens: output,
|
|
105
|
+
cacheReadTokens: usageInt(usage.cached_input_tokens),
|
|
106
|
+
cacheWriteTokens: usageInt(usage.cache_write_input_tokens),
|
|
107
|
+
cacheWrite1hTokens: null,
|
|
108
|
+
cacheWrite5mTokens: null,
|
|
109
|
+
reasoningTokens: usageInt(usage.reasoning_output_tokens),
|
|
110
|
+
},
|
|
111
|
+
sourceEndOffset: endOffset,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const codexTranscriptDriver = {
|
|
116
|
+
clientKind: "codex",
|
|
117
|
+
eventKeyNamespace: NAMESPACE,
|
|
118
|
+
usageGapPrefix: `${NAMESPACE}:usage-gap:`,
|
|
119
|
+
|
|
120
|
+
async locate(hostSessionId, { env = process.env } = {}) {
|
|
121
|
+
const sessionId = boundedHostSessionId(hostSessionId);
|
|
122
|
+
if (sessionId === null) return null;
|
|
123
|
+
const path = await locateRollout(codexHome(env), sessionId);
|
|
124
|
+
return path === null ? null : { path };
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async readSuffix(path, cursor, _sessionHash) {
|
|
128
|
+
const { buffer, start } = await readSuffixWindow(path, cursor?.byteOffset ?? 0);
|
|
129
|
+
const priorState = cursor?.hostState !== null && typeof cursor?.hostState === "object"
|
|
130
|
+
? cursor.hostState : {};
|
|
131
|
+
let model = typeof priorState.model === "string" && USAGE_MODEL_PATTERN.test(priorState.model)
|
|
132
|
+
? priorState.model : null;
|
|
133
|
+
let provider = typeof priorState.provider === "string" && PROVIDER_PATTERN.test(priorState.provider)
|
|
134
|
+
? priorState.provider : "openai";
|
|
135
|
+
const metadata = {};
|
|
136
|
+
const events = [];
|
|
137
|
+
let lastEnd = start;
|
|
138
|
+
for (const line of completeJsonlLines(buffer, start)) {
|
|
139
|
+
lastEnd = line.endOffset;
|
|
140
|
+
const entry = line.entry;
|
|
141
|
+
if (entry === null) continue;
|
|
142
|
+
const payload = entry.payload !== null && typeof entry.payload === "object" ? entry.payload : {};
|
|
143
|
+
const occurredAt = typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString();
|
|
144
|
+
if (entry.type === "session_meta") {
|
|
145
|
+
if (typeof payload.model_provider === "string" && PROVIDER_PATTERN.test(payload.model_provider)) {
|
|
146
|
+
provider = payload.model_provider;
|
|
147
|
+
}
|
|
148
|
+
const hostVersion = boundedMetadataText(payload.cli_version, 64);
|
|
149
|
+
if (hostVersion) metadata.hostVersion = hostVersion;
|
|
150
|
+
const entrypoint = boundedMetadataText(payload.originator, 64);
|
|
151
|
+
if (entrypoint) metadata.entrypoint = entrypoint;
|
|
152
|
+
const metaCwd = boundedMetadataText(payload.cwd, 2048);
|
|
153
|
+
if (metaCwd) metadata.cwd = metaCwd;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (entry.type === "turn_context") {
|
|
157
|
+
if (typeof payload.model === "string" && USAGE_MODEL_PATTERN.test(payload.model)) {
|
|
158
|
+
model = payload.model;
|
|
159
|
+
}
|
|
160
|
+
const turnCwd = boundedMetadataText(payload.cwd, 2048);
|
|
161
|
+
if (turnCwd) metadata.cwd = turnCwd;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (entry.type === "event_msg" && payload.type === "token_count") {
|
|
165
|
+
events.push(usageEventFromTokenCount(payload.info, {
|
|
166
|
+
model, provider,
|
|
167
|
+
byteOffset: line.byteOffset,
|
|
168
|
+
endOffset: line.endOffset,
|
|
169
|
+
occurredAt,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
events,
|
|
175
|
+
observedEndOffset: drainedEndOffset(lastEnd, start, buffer.length, MAX_DRIVER_SUFFIX_BYTES),
|
|
176
|
+
metadata,
|
|
177
|
+
hostState: boundedHostState({ ...priorState, model, provider }),
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
buildMetadataPayload(metadata, context) {
|
|
182
|
+
return buildClaudeMetadataPayload(metadata, context);
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
metadataEvent(payload) {
|
|
186
|
+
return hostMetadataEvent(NAMESPACE, payload);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { claudeTranscriptDriver } from "./claude.mjs";
|
|
2
|
+
import { codexTranscriptDriver } from "./codex.mjs";
|
|
3
|
+
import { kimiTranscriptDriver } from "./kimi.mjs";
|
|
4
|
+
|
|
5
|
+
const DRIVERS = Object.freeze({
|
|
6
|
+
"claude-code": claudeTranscriptDriver,
|
|
7
|
+
codex: codexTranscriptDriver,
|
|
8
|
+
"kimi-cli": kimiTranscriptDriver,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The reviewed transcript driver for a client kind, or null. Hosts without a
|
|
13
|
+
* reviewed driver keep hooks-only capture — absence is a supported state,
|
|
14
|
+
* never an error.
|
|
15
|
+
*/
|
|
16
|
+
export function transcriptDriverFor(clientKind) {
|
|
17
|
+
return DRIVERS[String(clientKind ?? "")] ?? null;
|
|
18
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
buildClaudeMetadataPayload,
|
|
5
|
+
hostMetadataEvent,
|
|
6
|
+
normalizedEvent,
|
|
7
|
+
usageInt,
|
|
8
|
+
USAGE_MODEL_PATTERN,
|
|
9
|
+
} from "../session.mjs";
|
|
10
|
+
import { kimiHome } from "../host-roots.mjs";
|
|
11
|
+
import {
|
|
12
|
+
boundedHostSessionId,
|
|
13
|
+
boundedHostState,
|
|
14
|
+
boundedMetadataText,
|
|
15
|
+
completeJsonlLines,
|
|
16
|
+
containedPath,
|
|
17
|
+
drainedEndOffset,
|
|
18
|
+
MAX_DRIVER_SUFFIX_BYTES,
|
|
19
|
+
readSuffixWindow,
|
|
20
|
+
} from "./shared.mjs";
|
|
21
|
+
|
|
22
|
+
const NAMESPACE = "kimi-cli";
|
|
23
|
+
const MAX_INDEX_BYTES = 8 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
async function readableFile(root, candidate) {
|
|
26
|
+
const contained = await containedPath(root, candidate);
|
|
27
|
+
if (contained === null) return null;
|
|
28
|
+
try {
|
|
29
|
+
return (await stat(contained)).isFile() ? contained : null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Kimi's `session_index.jsonl` maps sessionId -> sessionDir. The index is
|
|
37
|
+
* host-written but its paths are still treated as untrusted: every sessionDir
|
|
38
|
+
* is containment-checked under the kimi root before any file inside it is
|
|
39
|
+
* opened. The primary wire is `<sessionDir>/agents/main/wire.jsonl`; older
|
|
40
|
+
* layouts kept `<sessionDir>/wire.jsonl`.
|
|
41
|
+
*/
|
|
42
|
+
async function locateWire(root, sessionId) {
|
|
43
|
+
let indexText;
|
|
44
|
+
try {
|
|
45
|
+
indexText = await readFile(join(root, "session_index.jsonl"), "utf8");
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (Buffer.byteLength(indexText, "utf8") > MAX_INDEX_BYTES) return null;
|
|
50
|
+
const wanted = new Set([sessionId, `session_${sessionId}`]);
|
|
51
|
+
let sessionDir = null;
|
|
52
|
+
let workDir = null;
|
|
53
|
+
for (const line of indexText.split("\n")) {
|
|
54
|
+
if (!line) continue;
|
|
55
|
+
let entry;
|
|
56
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
57
|
+
if (entry !== null && typeof entry === "object" && wanted.has(entry.sessionId) &&
|
|
58
|
+
typeof entry.sessionDir === "string") {
|
|
59
|
+
sessionDir = entry.sessionDir; // keep last — the index is append-ordered
|
|
60
|
+
workDir = typeof entry.workDir === "string" ? entry.workDir : null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (sessionDir === null) return null;
|
|
64
|
+
const containedDir = await containedPath(root, sessionDir);
|
|
65
|
+
if (containedDir === null) return null;
|
|
66
|
+
const path = await readableFile(root, join(containedDir, "agents", "main", "wire.jsonl")) ||
|
|
67
|
+
await readableFile(root, join(containedDir, "wire.jsonl"));
|
|
68
|
+
if (path === null) return null;
|
|
69
|
+
const statePath = await readableFile(root, join(containedDir, "state.json"));
|
|
70
|
+
return { path, statePath, workDir };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function usageEventFromRecord(entry, { byteOffset, endOffset }) {
|
|
74
|
+
const usage = entry.usage !== null && typeof entry.usage === "object" ? entry.usage : null;
|
|
75
|
+
const model = typeof entry.model === "string" && USAGE_MODEL_PATTERN.test(entry.model)
|
|
76
|
+
? entry.model : null;
|
|
77
|
+
const occurredAt = Number.isSafeInteger(entry.time) && entry.time > 0
|
|
78
|
+
? new Date(entry.time).toISOString() : new Date().toISOString();
|
|
79
|
+
const input = usageInt(usage?.inputOther);
|
|
80
|
+
const output = usageInt(usage?.output);
|
|
81
|
+
if (usage === null || model === null || (input === null && output === null)) {
|
|
82
|
+
return normalizedEvent({
|
|
83
|
+
eventKey: `${NAMESPACE}:usage-gap:${byteOffset}`,
|
|
84
|
+
type: "usage",
|
|
85
|
+
occurredAt,
|
|
86
|
+
payload: {
|
|
87
|
+
role: "assistant", contentFormat: "json", captureStatus: "complete",
|
|
88
|
+
gap: true, reason: model === null && usage !== null ? "model_unknown" : "usage_unavailable",
|
|
89
|
+
},
|
|
90
|
+
sourceEndOffset: endOffset,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return normalizedEvent({
|
|
94
|
+
eventKey: `${NAMESPACE}:usage:${byteOffset}`,
|
|
95
|
+
type: "usage",
|
|
96
|
+
occurredAt,
|
|
97
|
+
payload: {
|
|
98
|
+
role: "assistant",
|
|
99
|
+
contentFormat: "json",
|
|
100
|
+
captureStatus: "complete",
|
|
101
|
+
provider: "moonshot",
|
|
102
|
+
model,
|
|
103
|
+
providerRequestId: null,
|
|
104
|
+
messageId: null,
|
|
105
|
+
stopReason: null,
|
|
106
|
+
serviceTier: null,
|
|
107
|
+
effort: null,
|
|
108
|
+
sidechain: false,
|
|
109
|
+
latencyMs: null,
|
|
110
|
+
// usage.record buckets are named as disjoint shares of the prompt.
|
|
111
|
+
inputTokens: input,
|
|
112
|
+
outputTokens: output,
|
|
113
|
+
cacheReadTokens: usageInt(usage.inputCacheRead),
|
|
114
|
+
cacheWriteTokens: usageInt(usage.inputCacheCreation),
|
|
115
|
+
cacheWrite1hTokens: null,
|
|
116
|
+
cacheWrite5mTokens: null,
|
|
117
|
+
reasoningTokens: null,
|
|
118
|
+
},
|
|
119
|
+
sourceEndOffset: endOffset,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const kimiTranscriptDriver = {
|
|
124
|
+
clientKind: "kimi-cli",
|
|
125
|
+
eventKeyNamespace: NAMESPACE,
|
|
126
|
+
usageGapPrefix: `${NAMESPACE}:usage-gap:`,
|
|
127
|
+
|
|
128
|
+
async locate(hostSessionId, { env = process.env } = {}) {
|
|
129
|
+
const raw = String(hostSessionId ?? "");
|
|
130
|
+
const sessionId = boundedHostSessionId(raw.startsWith("session_") ? raw.slice("session_".length) : raw);
|
|
131
|
+
if (sessionId === null) return null;
|
|
132
|
+
const located = await locateWire(kimiHome(env), sessionId);
|
|
133
|
+
if (located === null) return null;
|
|
134
|
+
const sessionFacts = {};
|
|
135
|
+
const cwd = boundedMetadataText(located.workDir, 2048);
|
|
136
|
+
if (cwd) sessionFacts.cwd = cwd;
|
|
137
|
+
if (located.statePath !== null) {
|
|
138
|
+
try {
|
|
139
|
+
const state = JSON.parse(await readFile(located.statePath, "utf8"));
|
|
140
|
+
const title = boundedMetadataText(state?.title, 512);
|
|
141
|
+
if (title) sessionFacts.title = title;
|
|
142
|
+
} catch {
|
|
143
|
+
// state.json is optional evidence, never a failure.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { path: located.path, sessionFacts };
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async readSuffix(path, cursor, _sessionHash) {
|
|
150
|
+
const { buffer, start } = await readSuffixWindow(path, cursor?.byteOffset ?? 0);
|
|
151
|
+
const metadata = {};
|
|
152
|
+
const events = [];
|
|
153
|
+
let lastEnd = start;
|
|
154
|
+
for (const line of completeJsonlLines(buffer, start)) {
|
|
155
|
+
lastEnd = line.endOffset;
|
|
156
|
+
const entry = line.entry;
|
|
157
|
+
if (entry === null) continue;
|
|
158
|
+
if (entry.type === "usage.record") {
|
|
159
|
+
events.push(usageEventFromRecord(entry, {
|
|
160
|
+
byteOffset: line.byteOffset,
|
|
161
|
+
endOffset: line.endOffset,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
events,
|
|
167
|
+
observedEndOffset: drainedEndOffset(lastEnd, start, buffer.length, MAX_DRIVER_SUFFIX_BYTES),
|
|
168
|
+
metadata,
|
|
169
|
+
hostState: boundedHostState(cursor?.hostState),
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
buildMetadataPayload(metadata, context) {
|
|
174
|
+
return buildClaudeMetadataPayload(metadata, context);
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
metadataEvent(payload) {
|
|
178
|
+
return hostMetadataEvent(NAMESPACE, payload);
|
|
179
|
+
},
|
|
180
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { open, realpath } from "node:fs/promises";
|
|
2
|
+
import { resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Windowed suffix budget per catch-up call. Successive calls drain the
|
|
5
|
+
// remainder; nothing is skipped, memory stays bounded even for giant files.
|
|
6
|
+
export const MAX_DRIVER_SUFFIX_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
// Host session ids are interpolated into filesystem lookups. Anything with a
|
|
9
|
+
// path character is refused outright — no capture beats a contained read of
|
|
10
|
+
// the wrong file (README containment commitment).
|
|
11
|
+
const HOST_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
12
|
+
|
|
13
|
+
export function boundedHostSessionId(value) {
|
|
14
|
+
const text = String(value ?? "");
|
|
15
|
+
return HOST_SESSION_ID_PATTERN.test(text) ? text : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve `candidate` and require it to live under `root` after every
|
|
20
|
+
* symlink is followed. Index files and cached paths are untrusted input;
|
|
21
|
+
* any escape resolves to null (no capture), never an error.
|
|
22
|
+
*/
|
|
23
|
+
export async function containedPath(root, candidate) {
|
|
24
|
+
try {
|
|
25
|
+
const realRoot = await realpath(root);
|
|
26
|
+
const resolved = resolve(realRoot, String(candidate ?? ""));
|
|
27
|
+
if (resolved !== realRoot && !resolved.startsWith(realRoot + sep)) return null;
|
|
28
|
+
const real = await realpath(resolved);
|
|
29
|
+
if (real !== realRoot && !real.startsWith(realRoot + sep)) return null;
|
|
30
|
+
return real;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Positioned read of at most `maxBytes` starting at `byteOffset`. */
|
|
37
|
+
export async function readSuffixWindow(path, byteOffset, maxBytes = MAX_DRIVER_SUFFIX_BYTES) {
|
|
38
|
+
const handle = await open(path, "r");
|
|
39
|
+
try {
|
|
40
|
+
const size = (await handle.stat()).size;
|
|
41
|
+
const start = Number.isSafeInteger(byteOffset) && byteOffset >= 0 && byteOffset <= size
|
|
42
|
+
? byteOffset : 0;
|
|
43
|
+
const length = Math.min(size - start, maxBytes);
|
|
44
|
+
const buffer = Buffer.alloc(length);
|
|
45
|
+
if (length > 0) await handle.read(buffer, 0, length, start);
|
|
46
|
+
return { buffer, start };
|
|
47
|
+
} finally {
|
|
48
|
+
await handle.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Yield `{ entry, byteOffset, endOffset }` for every complete JSONL line in
|
|
54
|
+
* the window. An unparseable line yields `entry: null` (drivers skip it —
|
|
55
|
+
* unlike the Claude archive, an ignored line is the norm for selective
|
|
56
|
+
* usage/metadata readers). A trailing record without its newline is left
|
|
57
|
+
* unread for the next catch-up.
|
|
58
|
+
*/
|
|
59
|
+
export function* completeJsonlLines(buffer, start) {
|
|
60
|
+
let position = 0;
|
|
61
|
+
while (position < buffer.length) {
|
|
62
|
+
const newline = buffer.indexOf(0x0a, position);
|
|
63
|
+
if (newline === -1) break;
|
|
64
|
+
let entry = null;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(buffer.subarray(position, newline).toString("utf8"));
|
|
67
|
+
entry = parsed !== null && typeof parsed === "object" ? parsed : null;
|
|
68
|
+
} catch {
|
|
69
|
+
entry = null;
|
|
70
|
+
}
|
|
71
|
+
yield { entry, byteOffset: start + position, endOffset: start + newline + 1 };
|
|
72
|
+
position = newline + 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* End offset for a drained window. If a full window held no newline at all
|
|
78
|
+
* (pathological single-line file), advance past it rather than re-reading the
|
|
79
|
+
* same bytes forever — usage lines are tiny, so nothing real is lost.
|
|
80
|
+
*/
|
|
81
|
+
export function drainedEndOffset(lastEndOffset, start, bufferLength, maxBytes) {
|
|
82
|
+
if (lastEndOffset > start) return lastEndOffset;
|
|
83
|
+
if (bufferLength >= maxBytes) return start + bufferLength;
|
|
84
|
+
return start;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const MAX_HOST_STATE_BYTES = 1024;
|
|
88
|
+
|
|
89
|
+
/** Bound the per-session driver scratch state persisted on the cursor. */
|
|
90
|
+
export function boundedHostState(value) {
|
|
91
|
+
if (value === null || typeof value !== "object") return undefined;
|
|
92
|
+
const serialized = JSON.stringify(value);
|
|
93
|
+
return Buffer.byteLength(serialized, "utf8") <= MAX_HOST_STATE_BYTES ? value : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Bound for host-supplied metadata strings, mirroring collectClaudeMetadata:
|
|
98
|
+
* non-empty, capped, and free of control characters. The server rejects a
|
|
99
|
+
* whole batch over an oversized field, so bounding here is not cosmetic.
|
|
100
|
+
*/
|
|
101
|
+
export function boundedMetadataText(value, max) {
|
|
102
|
+
return typeof value === "string" && value.length > 0 && value.length <= max &&
|
|
103
|
+
!/[\u0000-\u001f\u007f]/.test(value) ? value : undefined;
|
|
104
|
+
}
|
package/src/version.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export const PACKAGE_NAME = "@halofy/agent-connect";
|
|
2
|
-
export const INSTALLER_VERSION = "0.
|
|
3
|
-
export const RUNTIME_VERSION = "0.
|
|
4
|
-
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-
|
|
2
|
+
export const INSTALLER_VERSION = "0.6.0";
|
|
3
|
+
export const RUNTIME_VERSION = "0.6.0";
|
|
4
|
+
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-31.2";
|