@halofy/agent-connect 0.5.1 → 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 +15 -1
- package/package.json +2 -2
- package/src/client-registry.mjs +9 -2
- package/src/host-config.mjs +3 -2
- package/src/host-hook.mjs +35 -2
- package/src/host-roots.mjs +13 -0
- package/src/install.mjs +48 -0
- package/src/installer-cli.mjs +251 -19
- package/src/runtime.mjs +14 -9
- package/src/session.mjs +10 -6
- 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/README.md
CHANGED
|
@@ -25,7 +25,7 @@ bounded recall block formats stay in place and tested for when it returns.
|
|
|
25
25
|
There is one setup path for every packaged client:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
npx --yes @halofy/agent-connect@0.
|
|
28
|
+
npx --yes @halofy/agent-connect@0.6.0 install <client-kind> \
|
|
29
29
|
--server https://app.halofy.ai \
|
|
30
30
|
--claim '<one-time-claim>'
|
|
31
31
|
```
|
|
@@ -81,6 +81,20 @@ represented by digest-only placeholders and make coverage partial;
|
|
|
81
81
|
encrypted retry queue, and real-host fixtures are verified. The adapter never
|
|
82
82
|
opens an arbitrary transcript-referenced local file to fill that gap.
|
|
83
83
|
|
|
84
|
+
Since 0.6.0, reviewed transcript drivers extend host-reported token usage and
|
|
85
|
+
content-free session metadata to Codex CLI (rollout files) and Kimi Code CLI
|
|
86
|
+
(session wire files). The same containment commitment applies: a driver never
|
|
87
|
+
opens a hook-supplied path. It derives the session file from a validated
|
|
88
|
+
session id (`[A-Za-z0-9_-]{1,128}`) under the host's own root
|
|
89
|
+
(`CODEX_HOME`/`~/.codex`, `KIMI_CODE_HOME`/`~/.kimi-code`), and every
|
|
90
|
+
index-supplied or cached path must realpath-resolve inside that root or the
|
|
91
|
+
read is skipped. Capture additionally requires the installation's frozen
|
|
92
|
+
`tokenUsage` capability — installations consented before 0.6.0 never have
|
|
93
|
+
their transcripts read until reinstalled under the current disclosure. A
|
|
94
|
+
single `install all --claims <kind>=<claim>,…` invocation sweeps the claimed,
|
|
95
|
+
detected hosts with one CONNECT covering the explicitly listed set; each host
|
|
96
|
+
keeps its own installation, capabilities, and status.
|
|
97
|
+
|
|
84
98
|
Run focused checks from this directory:
|
|
85
99
|
|
|
86
100
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@halofy/agent-connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Halofy lifecycle installer and runtime for supported agents; runtime requests are signed with a per-installation Ed25519 key",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
26
|
"test": "node --test test/*.test.mjs",
|
|
27
|
-
"check": "node --check src/*.mjs && node --check bin/*.mjs",
|
|
27
|
+
"check": "node --check src/*.mjs && node --check src/transcript-drivers/*.mjs && node --check bin/*.mjs",
|
|
28
28
|
"prepack": "npm run check && npm test"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
package/src/client-registry.mjs
CHANGED
|
@@ -58,8 +58,9 @@ export const CLIENT_REGISTRY = Object.freeze({
|
|
|
58
58
|
subagents: true, compactionCheckpoints: true, contextRecalled: true,
|
|
59
59
|
// 0.5.0: host-reported model token usage, thinking blocks, structured
|
|
60
60
|
// tool outcomes, and content-free session metadata from the native
|
|
61
|
-
// transcript.
|
|
62
|
-
//
|
|
61
|
+
// transcript. Since 0.6.0 codex and kimi-cli also report usage and
|
|
62
|
+
// metadata via their reviewed transcript drivers; thinking blocks and
|
|
63
|
+
// structured tool outcomes remain Claude Code-only.
|
|
63
64
|
tokenUsage: true, sessionMetadata: true, toolOutcomes: true,
|
|
64
65
|
thinking: true,
|
|
65
66
|
}),
|
|
@@ -104,6 +105,9 @@ export const CLIENT_REGISTRY = Object.freeze({
|
|
|
104
105
|
toolInputs: true, toolOutputs: true, toolFailures: true,
|
|
105
106
|
artifactReferences: true, subagents: true, compactionCheckpoints: true,
|
|
106
107
|
contextRecalled: true,
|
|
108
|
+
// 0.6.0: host-reported usage and content-free session metadata read
|
|
109
|
+
// from the session wire by the reviewed kimi transcript driver.
|
|
110
|
+
tokenUsage: true, sessionMetadata: true,
|
|
107
111
|
}),
|
|
108
112
|
}),
|
|
109
113
|
vscode: Object.freeze({
|
|
@@ -131,6 +135,9 @@ export const CLIENT_REGISTRY = Object.freeze({
|
|
|
131
135
|
subagentStart: true, subagentStop: true, postToolUse: true,
|
|
132
136
|
userMessages: true, toolInputs: true, toolOutputs: true, subagents: true,
|
|
133
137
|
compactionCheckpoints: true, contextRecalled: true,
|
|
138
|
+
// 0.6.0: host-reported usage and content-free session metadata read
|
|
139
|
+
// from the native rollout by the reviewed codex transcript driver.
|
|
140
|
+
tokenUsage: true, sessionMetadata: true,
|
|
134
141
|
}),
|
|
135
142
|
}),
|
|
136
143
|
cline: Object.freeze({
|
package/src/host-config.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { chmod, mkdir, readFile } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { readJson, writeHostConfigFile } from "./storage.mjs";
|
|
5
|
+
import { codexHome, kimiHome } from "./host-roots.mjs";
|
|
5
6
|
|
|
6
7
|
function commandArg(value) {
|
|
7
8
|
const text = String(value);
|
|
@@ -172,7 +173,7 @@ export async function configureKimi({
|
|
|
172
173
|
installationId,
|
|
173
174
|
nodePath = process.execPath,
|
|
174
175
|
runtimePath,
|
|
175
|
-
kimiRoot =
|
|
176
|
+
kimiRoot = kimiHome(),
|
|
176
177
|
}) {
|
|
177
178
|
validateInputs({ installationId, runtimePath });
|
|
178
179
|
const configPath = join(kimiRoot, "config.toml");
|
|
@@ -292,7 +293,7 @@ export async function configureCodex({
|
|
|
292
293
|
installationId,
|
|
293
294
|
nodePath = process.execPath,
|
|
294
295
|
runtimePath,
|
|
295
|
-
codexRoot =
|
|
296
|
+
codexRoot = codexHome(),
|
|
296
297
|
}) {
|
|
297
298
|
validateInputs({ installationId, runtimePath });
|
|
298
299
|
const hooksPath = join(codexRoot, "hooks.json");
|
package/src/host-hook.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "./session.mjs";
|
|
10
10
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
11
11
|
import { readHookInput } from "./claude-hook.mjs";
|
|
12
|
+
import { transcriptDriverFor } from "./transcript-drivers/index.mjs";
|
|
12
13
|
|
|
13
14
|
const USER_EVENTS = new Set(["UserPromptSubmit", "beforeSubmitPrompt", "BeforeAgent", "pre_llm_call"]);
|
|
14
15
|
const ASSISTANT_EVENTS = new Set(["afterAgentResponse", "AfterAgent", "post_llm_call", "transform_llm_output"]);
|
|
@@ -116,6 +117,29 @@ async function enqueueTool(runtime, session, eventName, input) {
|
|
|
116
117
|
]);
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Best-effort transcript catch-up for hosts with a reviewed driver. Consent
|
|
122
|
+
* is double-gated: the driver must exist for this client kind AND the
|
|
123
|
+
* install-time frozen capability snapshot must declare tokenUsage — an older
|
|
124
|
+
* installation never has its transcript read, even under an upgraded runtime.
|
|
125
|
+
* Failures are swallowed: capture evidence must never degrade the hook path.
|
|
126
|
+
*/
|
|
127
|
+
async function catchUpHost(runtime, connection, session, sessionFacts = {}) {
|
|
128
|
+
try {
|
|
129
|
+
if (connection.capabilities?.tokenUsage !== true) return;
|
|
130
|
+
const driver = transcriptDriverFor(connection.clientKind);
|
|
131
|
+
if (driver === null) return;
|
|
132
|
+
const located = await driver.locate(session, {});
|
|
133
|
+
if (located === null) return;
|
|
134
|
+
await runtime.captureHostTranscript(session, driver, located.path, {
|
|
135
|
+
...(located.sessionFacts ?? {}),
|
|
136
|
+
...sessionFacts,
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
// Transcript evidence is additive; the hook result stands without it.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
119
143
|
/** Runs one reviewed host hook without blocking the host when Halofy browns out. */
|
|
120
144
|
export async function runHostLifecycleHook(connection, eventName, {
|
|
121
145
|
input,
|
|
@@ -142,8 +166,10 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
142
166
|
if (output) stdout.write(output);
|
|
143
167
|
}
|
|
144
168
|
} else if (USER_EVENTS.has(eventName)) {
|
|
145
|
-
// Prompt capture is independent of recall:
|
|
146
|
-
// transcript
|
|
169
|
+
// Prompt capture is independent of recall: hosts without a reviewed
|
|
170
|
+
// transcript driver rely on this enqueue to reach the archive, and
|
|
171
|
+
// hook-sourced events stay authoritative for messages even where a
|
|
172
|
+
// driver adds usage/metadata evidence.
|
|
147
173
|
const prompt = promptText(hookInput);
|
|
148
174
|
await enqueueMessage(runtime, connection, session, "user", prompt, eventName, hookInput);
|
|
149
175
|
if (RECALL_INJECTION_ENABLED && connection.clientKind !== "cursor" &&
|
|
@@ -157,6 +183,7 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
157
183
|
await runtime.commitIfThreshold(session);
|
|
158
184
|
} else if (TOOL_EVENTS.has(eventName)) {
|
|
159
185
|
await enqueueTool(runtime, session, eventName, hookInput);
|
|
186
|
+
await catchUpHost(runtime, connection, session);
|
|
160
187
|
} else if (COMPACT_EVENTS.has(eventName)) {
|
|
161
188
|
await runtime.enqueueSequencedEvents(session, ({ sessionHash, nextSequence }) => [
|
|
162
189
|
normalizeClaudeHookEvent("compaction", {
|
|
@@ -164,14 +191,20 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
164
191
|
event_id: eventEvidenceId(eventName, hookInput),
|
|
165
192
|
}, { sessionHash, sequence: nextSequence }),
|
|
166
193
|
]);
|
|
194
|
+
await catchUpHost(runtime, connection, session);
|
|
167
195
|
await runtime.commit(session, "pre_compaction");
|
|
168
196
|
} else if (SUBAGENT_START_EVENTS.has(eventName)) {
|
|
169
197
|
await runtime.resolveSession(childSession(hookInput, session), session);
|
|
170
198
|
} else if (SUBAGENT_STOP_EVENTS.has(eventName)) {
|
|
199
|
+
// Subagent transcript files are a documented per-host limitation; the
|
|
200
|
+
// parent session still catches up so its usage stays current.
|
|
201
|
+
await within(10_000, () => catchUpHost(runtime, connection, session));
|
|
171
202
|
await within(10_000, () => runtime.close(childSession(hookInput, session), "session_end"));
|
|
172
203
|
} else if (END_EVENTS.has(eventName)) {
|
|
204
|
+
await within(10_000, () => catchUpHost(runtime, connection, session, { closeReason: "session_end" }));
|
|
173
205
|
await within(15_000, () => runtime.close(session));
|
|
174
206
|
} else if (STOP_EVENTS.has(eventName)) {
|
|
207
|
+
await within(10_000, () => catchUpHost(runtime, connection, session));
|
|
175
208
|
if (connection.clientKind === "vscode" || connection.clientKind === "cline") {
|
|
176
209
|
await within(15_000, () => runtime.close(session));
|
|
177
210
|
} else {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Single authority for each host's on-disk root. host-config.mjs (hook/MCP
|
|
5
|
+
// installation) and transcript-drivers/ (session-file location) must agree on
|
|
6
|
+
// these paths or capture silently reads the wrong tree.
|
|
7
|
+
export function codexHome(env = process.env) {
|
|
8
|
+
return env.CODEX_HOME || join(homedir(), ".codex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function kimiHome(env = process.env) {
|
|
12
|
+
return env.KIMI_CODE_HOME || join(homedir(), ".kimi-code");
|
|
13
|
+
}
|
package/src/install.mjs
CHANGED
|
@@ -61,6 +61,54 @@ export async function fetchClaimDisclosure({
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Batch pre-consumption disclosure for the install-all sweep. One POST — and
|
|
66
|
+
* therefore one throttle token — covers every claim in the command, so a full
|
|
67
|
+
* sweep (1 disclosure + N consumes) fits the per-address budget. Entries come
|
|
68
|
+
* back positionally; an unusable claim is null. Any failure yields null for
|
|
69
|
+
* the whole batch (older server), never an error.
|
|
70
|
+
*/
|
|
71
|
+
export async function fetchClaimDisclosures({
|
|
72
|
+
serverUrl,
|
|
73
|
+
claims,
|
|
74
|
+
fetchImpl = globalThis.fetch,
|
|
75
|
+
timeoutMs = 5_000,
|
|
76
|
+
}) {
|
|
77
|
+
try {
|
|
78
|
+
const normalizedServerUrl = normalizeLifecycleServerUrl(serverUrl);
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
81
|
+
let response;
|
|
82
|
+
try {
|
|
83
|
+
response = await fetchImpl(`${normalizedServerUrl}/v1/agent-installations/claim-info`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ claims }),
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
});
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
if (!response.ok) return null;
|
|
93
|
+
const body = await response.json();
|
|
94
|
+
if (!Array.isArray(body?.disclosures)) return null;
|
|
95
|
+
return claims.map((_, index) => {
|
|
96
|
+
const entry = body.disclosures[index];
|
|
97
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
98
|
+
// Server-supplied text is printed to a terminal: strip control
|
|
99
|
+
// characters (including ANSI escape introducers) and bound the length.
|
|
100
|
+
const organization = typeof entry.organization === "string"
|
|
101
|
+
? entry.organization.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, 100)
|
|
102
|
+
: "";
|
|
103
|
+
const clientKind = typeof entry.clientKind === "string" &&
|
|
104
|
+
/^[a-z0-9][a-z0-9-]{0,63}$/.test(entry.clientKind) ? entry.clientKind : null;
|
|
105
|
+
return organization ? { organization, clientKind } : null;
|
|
106
|
+
});
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
64
112
|
export async function consumeInstallationClaim({
|
|
65
113
|
serverUrl,
|
|
66
114
|
claim,
|
package/src/installer-cli.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import {
|
|
7
7
|
fetchClaimDisclosure,
|
|
8
|
+
fetchClaimDisclosures,
|
|
8
9
|
heartbeatInstalledConnection,
|
|
9
10
|
installLocalConnection,
|
|
10
11
|
installRuntimeBundle,
|
|
@@ -16,9 +17,50 @@ import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
|
|
|
16
17
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
17
18
|
import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
|
|
18
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
|
+
|
|
19
60
|
export function parseInstallerArgs(argv) {
|
|
20
61
|
const command = argv[0];
|
|
21
62
|
const clientKind = argv[1];
|
|
63
|
+
if (command === "install" && clientKind === "all") return parseAllInstallerArgs(argv);
|
|
22
64
|
const values = new Map();
|
|
23
65
|
for (let index = 2; index < argv.length; index += 1) {
|
|
24
66
|
const name = argv[index];
|
|
@@ -37,6 +79,7 @@ export function parseInstallerArgs(argv) {
|
|
|
37
79
|
throw new Error("Usage: agent-connect install <supported-client> --server <https-url> --claim <one-use-claim>");
|
|
38
80
|
}
|
|
39
81
|
return {
|
|
82
|
+
mode: "single",
|
|
40
83
|
clientKind,
|
|
41
84
|
serverUrl,
|
|
42
85
|
claim,
|
|
@@ -82,8 +125,20 @@ export function detectClient(clientKind) {
|
|
|
82
125
|
return String(result.stdout || result.stderr || "").trim().slice(0, 128) || "detected";
|
|
83
126
|
}
|
|
84
127
|
|
|
85
|
-
|
|
86
|
-
|
|
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) {
|
|
87
142
|
const observedCategories = [
|
|
88
143
|
["user messages", client.capabilities.userMessages],
|
|
89
144
|
["assistant messages", client.capabilities.assistantMessages],
|
|
@@ -101,8 +156,15 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
101
156
|
["tool outcomes (durations, failure flags, byte sizes; file paths only as salted hashes)", client.capabilities.toolOutcomes],
|
|
102
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],
|
|
103
158
|
];
|
|
104
|
-
|
|
105
|
-
|
|
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);
|
|
106
168
|
return [
|
|
107
169
|
`Halofy ${client.label} lifecycle connection`,
|
|
108
170
|
`Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
|
|
@@ -132,6 +194,55 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
132
194
|
].join("\n");
|
|
133
195
|
}
|
|
134
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
|
+
|
|
135
246
|
export async function confirmDisclosure({ input = process.stdin, output = process.stdout } = {}) {
|
|
136
247
|
if (!input.isTTY || !output.isTTY) throw new Error("interactive terminal confirmation is required");
|
|
137
248
|
const prompt = createInterface({ input, output });
|
|
@@ -144,6 +255,136 @@ export async function confirmDisclosure({ input = process.stdin, output = proces
|
|
|
144
255
|
return true;
|
|
145
256
|
}
|
|
146
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
|
+
|
|
147
388
|
export async function runInstaller(argv, {
|
|
148
389
|
root = defaultRuntimeDirectory(),
|
|
149
390
|
output = process.stdout,
|
|
@@ -155,6 +396,11 @@ export async function runInstaller(argv, {
|
|
|
155
396
|
claudeConfigPath,
|
|
156
397
|
} = {}) {
|
|
157
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
|
+
}
|
|
158
404
|
const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
|
|
159
405
|
const claimDisclosure = await fetchClaimDisclosure({
|
|
160
406
|
serverUrl: input.serverUrl,
|
|
@@ -183,21 +429,7 @@ export async function runInstaller(argv, {
|
|
|
183
429
|
serverUrl: input.serverUrl,
|
|
184
430
|
runtimePath: bundle.runtimePath,
|
|
185
431
|
};
|
|
186
|
-
const configured = input.clientKind
|
|
187
|
-
? await configureClaudeProject({ ...common, ...(claudeConfigPath ? { claudeConfigPath } : {}) })
|
|
188
|
-
: input.clientKind === "cursor"
|
|
189
|
-
? await configureCursor(common)
|
|
190
|
-
: input.clientKind === "gemini-cli"
|
|
191
|
-
? await configureGemini(common)
|
|
192
|
-
: input.clientKind === "kimi-cli"
|
|
193
|
-
? await configureKimi(common)
|
|
194
|
-
: input.clientKind === "vscode"
|
|
195
|
-
? await configureVscode(common)
|
|
196
|
-
: input.clientKind === "codex"
|
|
197
|
-
? await configureCodex(common)
|
|
198
|
-
: input.clientKind === "cline"
|
|
199
|
-
? await configureCline(common)
|
|
200
|
-
: (() => { throw new Error("the selected adapter is not packaged yet"); })();
|
|
432
|
+
const configured = await configureHost(input.clientKind, common, { claudeConfigPath });
|
|
201
433
|
let heartbeat = false;
|
|
202
434
|
try {
|
|
203
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",
|
|
@@ -300,15 +300,15 @@ export function stripInjectedContext(value) {
|
|
|
300
300
|
return String(value);
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
-
function usageInt(value) {
|
|
303
|
+
export function usageInt(value) {
|
|
304
304
|
return Number.isSafeInteger(value) && value >= 0 && value < 2 ** 31 ? value : null;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
function usageLabel(value) {
|
|
307
|
+
export function usageLabel(value) {
|
|
308
308
|
return typeof value === "string" && /^[\x20-\x7e]{1,128}$/.test(value) ? value : null;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._
|
|
311
|
+
export const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._:/-]{1,128}$/;
|
|
312
312
|
|
|
313
313
|
/**
|
|
314
314
|
* One host-reported usage record per assistant message (PRD §8.1). Claude
|
|
@@ -769,15 +769,19 @@ export function buildClaudeMetadataPayload(metadata, { installationId, deviceCon
|
|
|
769
769
|
return payload;
|
|
770
770
|
}
|
|
771
771
|
|
|
772
|
-
export function
|
|
772
|
+
export function hostMetadataEvent(namespace, payload) {
|
|
773
773
|
return normalizedEvent({
|
|
774
|
-
eventKey:
|
|
774
|
+
eventKey: `${namespace}:metadata:${digest(stableJson(payload))}`,
|
|
775
775
|
type: "metadata",
|
|
776
776
|
occurredAt: new Date().toISOString(),
|
|
777
777
|
payload: { contentFormat: "json", captureStatus: "complete", ...payload },
|
|
778
778
|
});
|
|
779
779
|
}
|
|
780
780
|
|
|
781
|
+
export function claudeMetadataEvent(payload) {
|
|
782
|
+
return hostMetadataEvent("claude", payload);
|
|
783
|
+
}
|
|
784
|
+
|
|
781
785
|
export class CursorStore {
|
|
782
786
|
constructor(root) {
|
|
783
787
|
this.path = join(root, "cursors.json");
|
|
@@ -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
|
+
};
|
|
@@ -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-31.
|
|
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";
|