@halofy/agent-connect 0.4.0 → 0.5.1
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 +11 -2
- package/package.json +2 -2
- package/src/claude-config.mjs +4 -4
- package/src/claude-hook.mjs +26 -27
- package/src/client-registry.mjs +10 -0
- package/src/host-config.mjs +14 -14
- package/src/host-hook.mjs +18 -18
- package/src/install.mjs +52 -6
- package/src/installer-cli.mjs +20 -3
- package/src/runtime.mjs +45 -4
- package/src/session.mjs +375 -19
- package/src/storage.mjs +26 -0
- package/src/version.mjs +3 -3
package/README.md
CHANGED
|
@@ -16,10 +16,16 @@ agent connections:
|
|
|
16
16
|
- a stdio-to-signed-Streamable-HTTP MCP proof proxy; and
|
|
17
17
|
- one local claim consumer and proof runtime shared by every packaged adapter.
|
|
18
18
|
|
|
19
|
+
Hook-driven recall injection is disabled in this release
|
|
20
|
+
(`RECALL_INJECTION_ENABLED` in `src/session.mjs`): the runtime captures
|
|
21
|
+
conversations and serves the agent-invoked MCP memory tools, but does not push
|
|
22
|
+
recalled memory into host sessions on session start or prompt submit. The
|
|
23
|
+
bounded recall block formats stay in place and tested for when it returns.
|
|
24
|
+
|
|
19
25
|
There is one setup path for every packaged client:
|
|
20
26
|
|
|
21
27
|
```bash
|
|
22
|
-
npx --yes @halofy/agent-connect@0.
|
|
28
|
+
npx --yes @halofy/agent-connect@0.5.1 install <client-kind> \
|
|
23
29
|
--server https://app.halofy.ai \
|
|
24
30
|
--claim '<one-time-claim>'
|
|
25
31
|
```
|
|
@@ -44,7 +50,10 @@ to conversation-capture evidence by its name.
|
|
|
44
50
|
|
|
45
51
|
Run the server-returned command in the operating-system terminal from the
|
|
46
52
|
computer where the selected client runs, never in agent chat. The installer displays
|
|
47
|
-
the capture disclosure
|
|
53
|
+
the capture disclosure — including the organization the claim binds to, fetched
|
|
54
|
+
from the named server so a spoofed `--server` is recognizable before
|
|
55
|
+
confirmation (`unverified` when the server cannot identify the claim) —
|
|
56
|
+
requires the recipient to type `CONNECT`, generates
|
|
48
57
|
the Ed25519 private key locally, consumes the one-use claim in a JSON body,
|
|
49
58
|
copies the reviewed runtime out of the transient npx cache, and installs the
|
|
50
59
|
signed MCP proxy and lifecycle hooks together. It replaces an existing Halofy
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@halofy/agent-connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Halofy lifecycle installer and runtime for supported agents; runtime requests are signed with a per-installation Ed25519 key",
|
|
6
6
|
"bin": {
|
|
7
7
|
"agent-connect": "bin/install.mjs",
|
|
8
8
|
"halofy-agent": "bin/halofy-agent.mjs"
|
package/src/claude-config.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
-
import { readJson,
|
|
3
|
+
import { readJson, writeHostConfigFile } from "./storage.mjs";
|
|
4
4
|
|
|
5
5
|
const CLAUDE_HOOKS = Object.freeze({
|
|
6
6
|
SessionStart: { matcher: "startup|resume|clear|compact", timeout: 15 },
|
|
@@ -124,12 +124,12 @@ export async function configureClaudeProject({
|
|
|
124
124
|
replacedClaudeMcpEntries += removeHalofyEntries(project.mcpServers, server.toString());
|
|
125
125
|
}
|
|
126
126
|
if (replacedClaudeMcpEntries > 0) {
|
|
127
|
-
await
|
|
127
|
+
await writeHostConfigFile(claudeConfigPath, `${JSON.stringify(claude, null, 2)}\n`);
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
await
|
|
132
|
-
await
|
|
131
|
+
await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
|
|
132
|
+
await writeHostConfigFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
133
133
|
return {
|
|
134
134
|
mcpPath,
|
|
135
135
|
settingsPath,
|
package/src/claude-hook.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { LifecycleRuntime } from "./runtime.mjs";
|
|
4
|
-
import { normalizeClaudeHookEvent } from "./session.mjs";
|
|
4
|
+
import { normalizeClaudeHookEvent, RECALL_INJECTION_ENABLED, rankedRecallBlocks } from "./session.mjs";
|
|
5
5
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
6
6
|
|
|
7
7
|
function id(value) {
|
|
@@ -42,16 +42,7 @@ function childSession(input) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function recallText(result, eventName) {
|
|
45
|
-
const
|
|
46
|
-
const rankedBlocks = blocks
|
|
47
|
-
.filter((block) => block && typeof block.recallRef === "string" &&
|
|
48
|
-
typeof block.content === "string" && block.content.trim())
|
|
49
|
-
.slice(0, 12)
|
|
50
|
-
.map((block, rank) => ({
|
|
51
|
-
rank: rank + 1,
|
|
52
|
-
recallRef: block.recallRef.slice(0, 256),
|
|
53
|
-
content: block.content.trim(),
|
|
54
|
-
}));
|
|
45
|
+
const rankedBlocks = rankedRecallBlocks(result);
|
|
55
46
|
if (rankedBlocks.length === 0) return null;
|
|
56
47
|
return JSON.stringify({
|
|
57
48
|
hookSpecificOutput: {
|
|
@@ -61,10 +52,10 @@ function recallText(result, eventName) {
|
|
|
61
52
|
});
|
|
62
53
|
}
|
|
63
54
|
|
|
64
|
-
async function catchUp(runtime, input, session = hostSession(input)) {
|
|
55
|
+
async function catchUp(runtime, input, session = hostSession(input), sessionFacts = {}) {
|
|
65
56
|
const transcriptPath = input.agent_transcript_path || input.transcript_path;
|
|
66
57
|
if (!transcriptPath || !session) return;
|
|
67
|
-
await runtime.captureClaudeTranscript(session, String(transcriptPath));
|
|
58
|
+
await runtime.captureClaudeTranscript(session, String(transcriptPath), sessionFacts);
|
|
68
59
|
}
|
|
69
60
|
|
|
70
61
|
async function within(milliseconds, action) {
|
|
@@ -95,20 +86,24 @@ export async function runClaudeLifecycleHook(connection, eventName, {
|
|
|
95
86
|
if (eventName === "SessionStart") {
|
|
96
87
|
await runtime.replay();
|
|
97
88
|
await runtime.heartbeat(connection.capabilities || {});
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
} else if (eventName === "UserPromptSubmit") {
|
|
106
|
-
const prompt = String(hookInput.prompt || hookInput.user_prompt || "").slice(0, 8_000);
|
|
107
|
-
if (prompt.trim()) {
|
|
108
|
-
const recalled = await runtime.recall(session, prompt);
|
|
109
|
-
const output = recallText(recalled, "UserPromptSubmit");
|
|
89
|
+
if (RECALL_INJECTION_ENABLED) {
|
|
90
|
+
const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
|
|
91
|
+
const recalled = await runtime.recall(
|
|
92
|
+
session,
|
|
93
|
+
`${project ? `${project} ` : ""}project policy, conventions, decisions, preferences, and known context`,
|
|
94
|
+
);
|
|
95
|
+
const output = recallText(recalled, "SessionStart");
|
|
110
96
|
if (output) stdout.write(output);
|
|
111
97
|
}
|
|
98
|
+
} else if (eventName === "UserPromptSubmit") {
|
|
99
|
+
if (RECALL_INJECTION_ENABLED) {
|
|
100
|
+
const prompt = String(hookInput.prompt || hookInput.user_prompt || "").slice(0, 8_000);
|
|
101
|
+
if (prompt.trim()) {
|
|
102
|
+
const recalled = await runtime.recall(session, prompt);
|
|
103
|
+
const output = recallText(recalled, "UserPromptSubmit");
|
|
104
|
+
if (output) stdout.write(output);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
112
107
|
} else if (eventName === "Stop") {
|
|
113
108
|
if (!hookInput.stop_hook_active) {
|
|
114
109
|
await catchUp(runtime, hookInput);
|
|
@@ -124,7 +119,9 @@ export async function runClaudeLifecycleHook(connection, eventName, {
|
|
|
124
119
|
await runtime.commit(session, "pre_compaction");
|
|
125
120
|
} else if (eventName === "SessionEnd") {
|
|
126
121
|
await within(15_000, async () => {
|
|
127
|
-
await catchUp(runtime, hookInput)
|
|
122
|
+
await catchUp(runtime, hookInput, hostSession(hookInput), {
|
|
123
|
+
...(hookInput.reason ? { closeReason: String(hookInput.reason).slice(0, 64) } : {}),
|
|
124
|
+
});
|
|
128
125
|
await runtime.close(session);
|
|
129
126
|
});
|
|
130
127
|
} else if (eventName === "SubagentStart") {
|
|
@@ -132,7 +129,9 @@ export async function runClaudeLifecycleHook(connection, eventName, {
|
|
|
132
129
|
} else if (eventName === "SubagentStop") {
|
|
133
130
|
const child = childSession(hookInput);
|
|
134
131
|
await within(10_000, async () => {
|
|
135
|
-
await catchUp(runtime, hookInput, child
|
|
132
|
+
await catchUp(runtime, hookInput, child, {
|
|
133
|
+
...(hookInput.agent_type ? { subagentType: String(hookInput.agent_type).slice(0, 128) } : {}),
|
|
134
|
+
});
|
|
136
135
|
await runtime.close(child, "session_end");
|
|
137
136
|
});
|
|
138
137
|
} else if (["PostToolUse", "PostToolUseFailure"].includes(eventName)) {
|
package/src/client-registry.mjs
CHANGED
|
@@ -33,6 +33,10 @@ const BASE_CAPABILITIES = Object.freeze({
|
|
|
33
33
|
subagents: false,
|
|
34
34
|
compactionCheckpoints: false,
|
|
35
35
|
contextRecalled: false,
|
|
36
|
+
tokenUsage: false,
|
|
37
|
+
sessionMetadata: false,
|
|
38
|
+
toolOutcomes: false,
|
|
39
|
+
thinking: false,
|
|
36
40
|
});
|
|
37
41
|
|
|
38
42
|
function capabilities(overrides) {
|
|
@@ -52,6 +56,12 @@ export const CLIENT_REGISTRY = Object.freeze({
|
|
|
52
56
|
userMessages: true, assistantMessages: true, toolInputs: true,
|
|
53
57
|
toolOutputs: true, toolFailures: true, artifactReferences: true,
|
|
54
58
|
subagents: true, compactionCheckpoints: true, contextRecalled: true,
|
|
59
|
+
// 0.5.0: host-reported model token usage, thinking blocks, structured
|
|
60
|
+
// tool outcomes, and content-free session metadata from the native
|
|
61
|
+
// transcript. Claude Code is the only host whose reviewed adapter
|
|
62
|
+
// reads these today.
|
|
63
|
+
tokenUsage: true, sessionMetadata: true, toolOutcomes: true,
|
|
64
|
+
thinking: true,
|
|
55
65
|
}),
|
|
56
66
|
}),
|
|
57
67
|
cursor: Object.freeze({
|
package/src/host-config.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { chmod, readFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, readFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { readJson, writeHostConfigFile } from "./storage.mjs";
|
|
5
5
|
|
|
6
6
|
function commandArg(value) {
|
|
7
7
|
const text = String(value);
|
|
@@ -92,8 +92,8 @@ export async function configureCursor({
|
|
|
92
92
|
mcp.mcpServers,
|
|
93
93
|
managedMcp(nodePath, runtimePath, installationId),
|
|
94
94
|
);
|
|
95
|
-
await
|
|
96
|
-
await
|
|
95
|
+
await writeHostConfigFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
|
|
96
|
+
await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
|
|
97
97
|
return { configuredPaths: [hooksPath, mcpPath], hookEvents: Object.keys(CURSOR_HOOKS), replacedLegacyMcpEntries };
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -133,7 +133,7 @@ export async function configureGemini({
|
|
|
133
133
|
settings.mcpServers,
|
|
134
134
|
managedMcp(nodePath, runtimePath, installationId),
|
|
135
135
|
);
|
|
136
|
-
await
|
|
136
|
+
await writeHostConfigFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
137
137
|
return { configuredPaths: [settingsPath], hookEvents: Object.keys(GEMINI_HOOKS), replacedLegacyMcpEntries };
|
|
138
138
|
}
|
|
139
139
|
|
|
@@ -197,8 +197,8 @@ export async function configureKimi({
|
|
|
197
197
|
mcp.mcpServers,
|
|
198
198
|
managedMcp(nodePath, runtimePath, installationId),
|
|
199
199
|
);
|
|
200
|
-
await
|
|
201
|
-
await
|
|
200
|
+
await writeHostConfigFile(configPath, replaceKimiManagedHooks(source, hookBlock));
|
|
201
|
+
await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
|
|
202
202
|
return { configuredPaths: [configPath, mcpPath], hookEvents: Object.keys(KIMI_HOOKS), replacedLegacyMcpEntries };
|
|
203
203
|
}
|
|
204
204
|
|
|
@@ -233,8 +233,8 @@ export async function configureVscode({
|
|
|
233
233
|
mcp.servers,
|
|
234
234
|
managedMcp(nodePath, runtimePath, installationId, { explicitType: true }),
|
|
235
235
|
);
|
|
236
|
-
await
|
|
237
|
-
await
|
|
236
|
+
await writeHostConfigFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
|
|
237
|
+
await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
|
|
238
238
|
return { configuredPaths: [hooksPath, mcpPath], hookEvents: Object.keys(VSCODE_HOOKS), replacedLegacyMcpEntries };
|
|
239
239
|
}
|
|
240
240
|
|
|
@@ -308,11 +308,11 @@ export async function configureCodex({
|
|
|
308
308
|
`command = ${JSON.stringify(resolve(nodePath))}`,
|
|
309
309
|
`args = ${JSON.stringify([resolve(runtimePath), "mcp", "--connection", installationId])}`,
|
|
310
310
|
].join("\n");
|
|
311
|
-
await
|
|
311
|
+
await writeHostConfigFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
|
|
312
312
|
const withoutHalomem = removeTomlSection(source, "mcp_servers.halomem");
|
|
313
313
|
const withoutHalofy = removeTomlSection(withoutHalomem.source, "mcp_servers.halofy");
|
|
314
314
|
const withMcp = replaceTomlSection(withoutHalofy.source, "mcp_servers.halofy", section);
|
|
315
|
-
await
|
|
315
|
+
await writeHostConfigFile(configPath, enableTomlFeature(withMcp, "hooks"));
|
|
316
316
|
return {
|
|
317
317
|
configuredPaths: [hooksPath, configPath],
|
|
318
318
|
hookEvents: Object.keys(CODEX_HOOKS),
|
|
@@ -335,12 +335,12 @@ export async function configureCline({
|
|
|
335
335
|
}) {
|
|
336
336
|
validateInputs({ installationId, runtimePath });
|
|
337
337
|
const hooksRoot = join(clineRoot, "Hooks");
|
|
338
|
-
await
|
|
338
|
+
await mkdir(hooksRoot, { recursive: true });
|
|
339
339
|
const configuredPaths = [];
|
|
340
340
|
for (const [hostEvent, runtimeEvent] of Object.entries(CLINE_HOOKS)) {
|
|
341
341
|
const path = join(hooksRoot, hostEvent);
|
|
342
342
|
const command = managedCommand(nodePath, runtimePath, runtimeEvent, installationId);
|
|
343
|
-
await
|
|
343
|
+
await writeHostConfigFile(path, `#!/usr/bin/env sh\nexec ${command}\n`);
|
|
344
344
|
if (process.platform !== "win32") await chmod(path, 0o700);
|
|
345
345
|
configuredPaths.push(path);
|
|
346
346
|
}
|
|
@@ -351,7 +351,7 @@ export async function configureCline({
|
|
|
351
351
|
mcp.mcpServers,
|
|
352
352
|
managedMcp(nodePath, runtimePath, installationId),
|
|
353
353
|
);
|
|
354
|
-
await
|
|
354
|
+
await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
|
|
355
355
|
configuredPaths.push(mcpPath);
|
|
356
356
|
return { configuredPaths, hookEvents: Object.keys(CLINE_HOOKS), replacedLegacyMcpEntries };
|
|
357
357
|
}
|
package/src/host-hook.mjs
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { LifecycleRuntime } from "./runtime.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
normalizeClaudeHookEvent,
|
|
6
|
+
normalizeHostMessageEvent,
|
|
7
|
+
RECALL_INJECTION_ENABLED,
|
|
8
|
+
rankedRecallBlocks,
|
|
9
|
+
} from "./session.mjs";
|
|
5
10
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
6
11
|
import { readHookInput } from "./claude-hook.mjs";
|
|
7
12
|
|
|
@@ -62,18 +67,8 @@ function toolOutput(input) {
|
|
|
62
67
|
input.extra?.result ?? input.extra?.error_message;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
function recallBlocks(result) {
|
|
66
|
-
const blocks = Array.isArray(result?.blocks) ? result.blocks : Array.isArray(result) ? result : [];
|
|
67
|
-
return blocks.filter((block) => block && typeof block.recallRef === "string" &&
|
|
68
|
-
typeof block.content === "string" && block.content.trim()).slice(0, 12).map((block, rank) => ({
|
|
69
|
-
rank: rank + 1,
|
|
70
|
-
recallRef: block.recallRef.slice(0, 256),
|
|
71
|
-
content: block.content.trim(),
|
|
72
|
-
}));
|
|
73
|
-
}
|
|
74
|
-
|
|
75
70
|
function recallOutput(clientKind, eventName, result) {
|
|
76
|
-
const rankedBlocks =
|
|
71
|
+
const rankedBlocks = rankedRecallBlocks(result);
|
|
77
72
|
if (rankedBlocks.length === 0) return null;
|
|
78
73
|
const additionalContext = `<<<HALOFY_CONTEXT_BLOCKS_V1>>>\n${JSON.stringify({ rankedBlocks })}\n<<<END_HALOFY_CONTEXT_BLOCKS_V1>>>`;
|
|
79
74
|
if (clientKind === "cursor") {
|
|
@@ -139,15 +134,20 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
139
134
|
if (START_EVENTS.has(eventName)) {
|
|
140
135
|
await runtime.replay();
|
|
141
136
|
await runtime.heartbeat(connection.capabilities || {});
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
137
|
+
if (RECALL_INJECTION_ENABLED) {
|
|
138
|
+
const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
|
|
139
|
+
const recalled = await runtime.recall(session,
|
|
140
|
+
`${project ? `${project} ` : ""}project policy, conventions, decisions, preferences, and known context`);
|
|
141
|
+
const output = recallOutput(connection.clientKind, eventName, recalled);
|
|
142
|
+
if (output) stdout.write(output);
|
|
143
|
+
}
|
|
147
144
|
} else if (USER_EVENTS.has(eventName)) {
|
|
145
|
+
// Prompt capture is independent of recall: non-Claude hosts have no
|
|
146
|
+
// transcript, so this enqueue is how user messages reach the archive.
|
|
148
147
|
const prompt = promptText(hookInput);
|
|
149
148
|
await enqueueMessage(runtime, connection, session, "user", prompt, eventName, hookInput);
|
|
150
|
-
if (connection.clientKind !== "cursor" &&
|
|
149
|
+
if (RECALL_INJECTION_ENABLED && connection.clientKind !== "cursor" &&
|
|
150
|
+
typeof prompt === "string" && prompt.trim()) {
|
|
151
151
|
const recalled = await runtime.recall(session, prompt.slice(0, 8_000));
|
|
152
152
|
const output = recallOutput(connection.clientKind, eventName, recalled);
|
|
153
153
|
if (output) stdout.write(output);
|
package/src/install.mjs
CHANGED
|
@@ -10,6 +10,57 @@ import { CLIENT_KINDS, CLIENT_REGISTRY, lifecycleClient } from "./client-registr
|
|
|
10
10
|
|
|
11
11
|
export const CLAUDE_CAPABILITIES = CLIENT_REGISTRY["claude-code"].capabilities;
|
|
12
12
|
|
|
13
|
+
/** HTTPS-or-loopback validation shared by every request that carries the claim. */
|
|
14
|
+
export function normalizeLifecycleServerUrl(serverUrl) {
|
|
15
|
+
const normalizedServer = new URL(serverUrl);
|
|
16
|
+
if (normalizedServer.protocol !== "https:" && normalizedServer.hostname !== "localhost" &&
|
|
17
|
+
normalizedServer.hostname !== "127.0.0.1" && normalizedServer.hostname !== "[::1]") {
|
|
18
|
+
throw new Error("installation claims require HTTPS (localhost is allowed for development)");
|
|
19
|
+
}
|
|
20
|
+
normalizedServer.pathname = normalizedServer.pathname.replace(/\/+$/, "");
|
|
21
|
+
return normalizedServer.toString().replace(/\/$/, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Best-effort pre-consumption disclosure: which organization this claim binds
|
|
26
|
+
* to, shown before the CONNECT confirmation. An older server without the
|
|
27
|
+
* endpoint, or any failure, yields null — the installer then labels the
|
|
28
|
+
* organization unverified instead of failing. The claim is not consumed.
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchClaimDisclosure({
|
|
31
|
+
serverUrl,
|
|
32
|
+
claim,
|
|
33
|
+
fetchImpl = globalThis.fetch,
|
|
34
|
+
timeoutMs = 5_000,
|
|
35
|
+
}) {
|
|
36
|
+
try {
|
|
37
|
+
const normalizedServerUrl = normalizeLifecycleServerUrl(serverUrl);
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
40
|
+
let response;
|
|
41
|
+
try {
|
|
42
|
+
response = await fetchImpl(`${normalizedServerUrl}/v1/agent-installations/claim-info`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "Content-Type": "application/json" },
|
|
45
|
+
body: JSON.stringify({ claim }),
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
});
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
if (!response.ok) return null;
|
|
52
|
+
const body = await response.json();
|
|
53
|
+
// Server-supplied text is printed to a terminal: strip control characters
|
|
54
|
+
// (including ANSI escape introducers) and bound the length.
|
|
55
|
+
const organization = typeof body?.organization === "string"
|
|
56
|
+
? body.organization.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, 100)
|
|
57
|
+
: "";
|
|
58
|
+
return organization ? { organization } : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
13
64
|
export async function consumeInstallationClaim({
|
|
14
65
|
serverUrl,
|
|
15
66
|
claim,
|
|
@@ -51,12 +102,7 @@ export async function installLocalConnection({
|
|
|
51
102
|
}) {
|
|
52
103
|
if (!CLIENT_KINDS.includes(clientKind)) throw new Error("the selected lifecycle adapter is not packaged");
|
|
53
104
|
const client = lifecycleClient(clientKind);
|
|
54
|
-
const
|
|
55
|
-
if (normalizedServer.protocol !== "https:" && normalizedServer.hostname !== "localhost" && normalizedServer.hostname !== "127.0.0.1") {
|
|
56
|
-
throw new Error("installation claims require HTTPS (localhost is allowed for development)");
|
|
57
|
-
}
|
|
58
|
-
normalizedServer.pathname = normalizedServer.pathname.replace(/\/+$/, "");
|
|
59
|
-
const normalizedServerUrl = normalizedServer.toString().replace(/\/$/, "");
|
|
105
|
+
const normalizedServerUrl = normalizeLifecycleServerUrl(serverUrl);
|
|
60
106
|
const store = new ConnectionStore(root);
|
|
61
107
|
const pendingPath = join(root, `pending-${clientKind}.json`);
|
|
62
108
|
const priorPending = await readJson(pendingPath);
|
package/src/installer-cli.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import {
|
|
7
|
+
fetchClaimDisclosure,
|
|
7
8
|
heartbeatInstalledConnection,
|
|
8
9
|
installLocalConnection,
|
|
9
10
|
installRuntimeBundle,
|
|
@@ -81,7 +82,7 @@ export function detectClient(clientKind) {
|
|
|
81
82
|
return String(result.stdout || result.stderr || "").trim().slice(0, 128) || "detected";
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion }) {
|
|
85
|
+
export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion, organization = null }) {
|
|
85
86
|
const client = lifecycleClient(clientKind);
|
|
86
87
|
const observedCategories = [
|
|
87
88
|
["user messages", client.capabilities.userMessages],
|
|
@@ -95,6 +96,10 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
95
96
|
["subagents", client.capabilities.subagents],
|
|
96
97
|
["compaction checkpoints", client.capabilities.compactionCheckpoints],
|
|
97
98
|
["context-use evidence", client.capabilities.contextUseEvidence],
|
|
99
|
+
["model token usage (counts, model name, and provider request id for each reply; never prompt or reply text)", client.capabilities.tokenUsage],
|
|
100
|
+
["thinking blocks", client.capabilities.thinking],
|
|
101
|
+
["tool outcomes (durations, failure flags, byte sizes; file paths only as salted hashes)", client.capabilities.toolOutcomes],
|
|
102
|
+
["host and session metadata (app version, permission mode, effort, session title; directory paths hashed unless your organization enables full device context)", client.capabilities.sessionMetadata],
|
|
98
103
|
];
|
|
99
104
|
const supported = observedCategories.filter(([, value]) => value === true).map(([name]) => name);
|
|
100
105
|
const unsupported = observedCategories.filter(([, value]) => value !== true).map(([name]) => name);
|
|
@@ -102,11 +107,14 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
102
107
|
`Halofy ${client.label} lifecycle connection`,
|
|
103
108
|
`Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
|
|
104
109
|
`Server: ${new URL(serverUrl).origin}`,
|
|
110
|
+
// The server named by --server identifies the organization this claim
|
|
111
|
+
// binds to, so a spoofed command is recognizable before CONNECT.
|
|
112
|
+
`Organization: ${organization || "unverified (the server did not identify this claim's organization)"}`,
|
|
105
113
|
`${client.label}: ${clientVersion}`,
|
|
106
114
|
`Project: ${projectRoot}`,
|
|
107
115
|
"",
|
|
108
116
|
"This single installation replaces an existing Halofy bearer MCP entry where supported and enables:",
|
|
109
|
-
"- governed memory tools
|
|
117
|
+
"- governed memory tools the agent invokes explicitly (no automatic recall is injected into sessions),",
|
|
110
118
|
`- conversation events exposed by ${client.label}'s reviewed hooks,`,
|
|
111
119
|
"- explicitly supported tool, subagent, compaction, and close evidence,",
|
|
112
120
|
"- encrypted local retry queue and governed retained conversations, and",
|
|
@@ -148,7 +156,16 @@ export async function runInstaller(argv, {
|
|
|
148
156
|
} = {}) {
|
|
149
157
|
const input = parseInstallerArgs(argv);
|
|
150
158
|
const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
|
|
151
|
-
|
|
159
|
+
const claimDisclosure = await fetchClaimDisclosure({
|
|
160
|
+
serverUrl: input.serverUrl,
|
|
161
|
+
claim: input.claim,
|
|
162
|
+
fetchImpl,
|
|
163
|
+
});
|
|
164
|
+
output.write(`${disclosureText({
|
|
165
|
+
...input,
|
|
166
|
+
clientVersion,
|
|
167
|
+
organization: claimDisclosure?.organization ?? null,
|
|
168
|
+
})}\n`);
|
|
152
169
|
await confirm();
|
|
153
170
|
|
|
154
171
|
const installed = await installLocalConnection({
|
package/src/runtime.mjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { BoundedEncryptedQueue } from "./queue.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildClaudeMetadataPayload,
|
|
6
|
+
claudeMetadataEvent,
|
|
7
|
+
CursorStore,
|
|
8
|
+
deriveSessionHash,
|
|
9
|
+
readClaudeTranscriptSuffix,
|
|
10
|
+
} from "./session.mjs";
|
|
5
11
|
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
6
|
-
import { withFileLock } from "./storage.mjs";
|
|
12
|
+
import { readJson, withFileLock, writePrivateFile } from "./storage.mjs";
|
|
7
13
|
import { RUNTIME_VERSION } from "./version.mjs";
|
|
8
14
|
|
|
9
15
|
export class LifecycleRuntime {
|
|
@@ -20,6 +26,7 @@ export class LifecycleRuntime {
|
|
|
20
26
|
const connectionRoot = join(root, connection.installationId);
|
|
21
27
|
this.queue = new BoundedEncryptedQueue(connectionRoot);
|
|
22
28
|
this.cursors = new CursorStore(connectionRoot);
|
|
29
|
+
this.policyPath = join(connectionRoot, "policy.json");
|
|
23
30
|
this.operationLockPath = join(connectionRoot, "runtime.operation.lock");
|
|
24
31
|
this.maxBatchEvents = Math.min(100, Math.max(1, maxBatchEvents));
|
|
25
32
|
this.maxBatchBytes = Math.min(1024 * 1024, Math.max(1024, maxBatchBytes));
|
|
@@ -99,13 +106,33 @@ export class LifecycleRuntime {
|
|
|
99
106
|
});
|
|
100
107
|
}
|
|
101
108
|
|
|
102
|
-
|
|
109
|
+
/** The server-issued capture policy from the last heartbeat (P9). */
|
|
110
|
+
async capturePolicy() {
|
|
111
|
+
const stored = await readJson(this.policyPath, { deviceContext: "hashed" });
|
|
112
|
+
return { deviceContext: stored?.deviceContext === "full" ? "full" : "hashed" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async captureClaudeTranscript(hostSessionId, transcriptPath, sessionFacts = {}) {
|
|
103
116
|
const sessionHash = this.sessionHash(hostSessionId);
|
|
117
|
+
const policy = await this.capturePolicy();
|
|
104
118
|
const queued = await withFileLock(this.operationLockPath, async () => {
|
|
105
119
|
const cursor = await this.cursors.get(sessionHash);
|
|
106
120
|
const suffix = await readClaudeTranscriptSuffix(transcriptPath, cursor, sessionHash);
|
|
107
121
|
const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
|
|
108
122
|
const unseenEvents = suffix.events.filter((event) => !recentEventKeys.has(event.eventKey));
|
|
123
|
+
// One metadata event whenever the observed content-free session facts
|
|
124
|
+
// change. The event key hashes the payload, so an unchanged snapshot is
|
|
125
|
+
// deduplicated exactly like any repeated event.
|
|
126
|
+
const metadataPayload = buildClaudeMetadataPayload(
|
|
127
|
+
{ ...suffix.metadata, ...sessionFacts },
|
|
128
|
+
{ installationId: this.connection.installationId, deviceContext: policy.deviceContext },
|
|
129
|
+
);
|
|
130
|
+
if (Object.keys(metadataPayload).length > 0) {
|
|
131
|
+
const metadataEvent = claudeMetadataEvent(metadataPayload);
|
|
132
|
+
if (!recentEventKeys.has(metadataEvent.eventKey)) unseenEvents.push(metadataEvent);
|
|
133
|
+
}
|
|
134
|
+
const usageGaps = unseenEvents.filter((event) =>
|
|
135
|
+
event.type === "usage" && event.eventKey.startsWith("claude:usage-gap:")).length;
|
|
109
136
|
const result = unseenEvents.length === 0
|
|
110
137
|
? { queued: 0 }
|
|
111
138
|
: await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
|
|
@@ -121,6 +148,7 @@ export class LifecycleRuntime {
|
|
|
121
148
|
await this.cursors.update(sessionHash, { byteOffset: suffix.observedEndOffset });
|
|
122
149
|
}
|
|
123
150
|
await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
|
|
151
|
+
if (usageGaps > 0) await this.cursors.bumpUsageGaps(usageGaps);
|
|
124
152
|
return result;
|
|
125
153
|
});
|
|
126
154
|
if (queued.queued === 0) return { queued: 0, acknowledged: 0 };
|
|
@@ -255,15 +283,28 @@ export class LifecycleRuntime {
|
|
|
255
283
|
|
|
256
284
|
async heartbeat(capabilities) {
|
|
257
285
|
const queue = await this.queue.diagnostics();
|
|
258
|
-
|
|
286
|
+
const usageGaps = await this.cursors.peekUsageGaps();
|
|
287
|
+
const response = await this.transport.heartbeat(capabilities, {
|
|
259
288
|
pluginVersion: this.connection.pluginVersion || `${RUNTIME_VERSION}-local`,
|
|
260
289
|
proofStorage: this.connection.proofStorage || "unknown",
|
|
261
290
|
diagnostics: {
|
|
262
291
|
queueDepth: queue.depth,
|
|
263
292
|
oldestPendingAt: queue.oldestPendingAt,
|
|
264
293
|
expiredCount: queue.expiredCount,
|
|
294
|
+
...(usageGaps > 0 ? { usageGaps } : {}),
|
|
265
295
|
},
|
|
266
296
|
});
|
|
297
|
+
// The server accumulates reported gaps, so only a delivered delta is
|
|
298
|
+
// cleared — a failed heartbeat keeps the count for the next attempt.
|
|
299
|
+
if (usageGaps > 0) await this.cursors.clearUsageGaps(usageGaps);
|
|
300
|
+
// P9: persist the server-issued capture policy; the adapter consults it
|
|
301
|
+
// before SENDING device context, and the server re-checks regardless.
|
|
302
|
+
if (response && response.policy && typeof response.policy === "object") {
|
|
303
|
+
await writePrivateFile(this.policyPath, `${JSON.stringify({
|
|
304
|
+
deviceContext: response.policy.deviceContext === "full" ? "full" : "hashed",
|
|
305
|
+
})}\n`);
|
|
306
|
+
}
|
|
307
|
+
return response;
|
|
267
308
|
}
|
|
268
309
|
}
|
|
269
310
|
|
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 }) {
|
|
89
|
+
function normalizedEvent({ eventKey, type, occurredAt, payload, sourceEndOffset, part }) {
|
|
90
90
|
const {
|
|
91
91
|
role,
|
|
92
92
|
contentFormat = "json",
|
|
@@ -104,6 +104,7 @@ function normalizedEvent({ eventKey, type, occurredAt, payload, sourceEndOffset
|
|
|
104
104
|
contentFormat,
|
|
105
105
|
captureStatus,
|
|
106
106
|
...(captureReasonCode ? { captureReasonCode } : {}),
|
|
107
|
+
...(part ? { part } : {}),
|
|
107
108
|
payload: wirePayload,
|
|
108
109
|
payloadSha256: digest(contentFormat === "utf8" ? wirePayload : JSON.stringify(wirePayload)),
|
|
109
110
|
...(sourceEndOffset ? { sourceEndOffset } : {}),
|
|
@@ -131,19 +132,36 @@ function textEvent({ nativeId, index, role, text, occurredAt, sourceEndOffset })
|
|
|
131
132
|
});
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
const MAX_INLINE_HOST_RESULT_BYTES = 16 * 1024;
|
|
136
|
+
|
|
137
|
+
/** Bounded structured host result: inline when small, digest evidence above. */
|
|
138
|
+
function boundedHostResult(raw) {
|
|
139
|
+
if (raw === null || raw === undefined || typeof raw !== "object") return undefined;
|
|
140
|
+
const stableBody = stableClone(raw);
|
|
141
|
+
const bytes = bodyBytes(stableBody, "json");
|
|
142
|
+
if (bytes.length <= MAX_INLINE_HOST_RESULT_BYTES) return stableBody;
|
|
143
|
+
return { digestOnly: true, bodySha256: digest(bytes), originalBytes: bytes.length };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function toolEvent({ nativeId, index, type, role = "tool", toolName, toolUseId, body, occurredAt, sourceEndOffset, failed = false, hostResult, outcome }) {
|
|
135
147
|
const field = type === "tool_call" ? "input" : "result";
|
|
136
148
|
const stableBody = stableClone(body ?? null);
|
|
149
|
+
const boundedResult = type === "tool_result" ? boundedHostResult(hostResult) : undefined;
|
|
137
150
|
const toolMetadata = {
|
|
138
151
|
toolName: boundedIdentifier(toolName),
|
|
139
152
|
toolUseId: boundedIdentifier(toolUseId),
|
|
140
153
|
...(type === "tool_result" ? { failed: Boolean(failed) } : {}),
|
|
154
|
+
// Content-free outcome scalars survive digest-only fallbacks so the
|
|
155
|
+
// server's plaintext tool-call row is populated even when the body is
|
|
156
|
+
// too large to retain inline.
|
|
157
|
+
...(outcome ? { outcome } : {}),
|
|
141
158
|
};
|
|
142
159
|
const completePayload = {
|
|
143
160
|
role,
|
|
144
161
|
contentFormat: "json",
|
|
145
162
|
captureStatus: "complete",
|
|
146
163
|
...toolMetadata,
|
|
164
|
+
...(boundedResult === undefined ? {} : { hostResult: boundedResult }),
|
|
147
165
|
[field]: stableBody,
|
|
148
166
|
};
|
|
149
167
|
const payload = containsInlineArtifactBody(stableBody)
|
|
@@ -238,6 +256,37 @@ function unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourc
|
|
|
238
256
|
});
|
|
239
257
|
}
|
|
240
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Hook-driven recall injection is disabled for now: the runtime captures
|
|
261
|
+
* conversations and serves the agent-invoked MCP memory tools, but does not
|
|
262
|
+
* push recalled memory into host sessions on SessionStart/UserPromptSubmit.
|
|
263
|
+
* Flip to true to restore injection — the bounded block formats below and the
|
|
264
|
+
* per-host output shapes in the hook modules stay in place and tested.
|
|
265
|
+
*/
|
|
266
|
+
export const RECALL_INJECTION_ENABLED = false;
|
|
267
|
+
|
|
268
|
+
export const MAX_RECALL_BLOCKS = 12;
|
|
269
|
+
export const MAX_RECALL_BLOCK_CHARS = 8_000;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Ranked recall blocks bounded on the client before they are injected into a
|
|
273
|
+
* host session. The server's token budget is the primary bound; these caps are
|
|
274
|
+
* defense in depth so poisoned or oversized memory content cannot flood the
|
|
275
|
+
* host context through a hook response.
|
|
276
|
+
*/
|
|
277
|
+
export function rankedRecallBlocks(result) {
|
|
278
|
+
const blocks = Array.isArray(result?.blocks) ? result.blocks : Array.isArray(result) ? result : [];
|
|
279
|
+
return blocks
|
|
280
|
+
.filter((block) => block && typeof block.recallRef === "string" &&
|
|
281
|
+
typeof block.content === "string" && block.content.trim())
|
|
282
|
+
.slice(0, MAX_RECALL_BLOCKS)
|
|
283
|
+
.map((block, rank) => ({
|
|
284
|
+
rank: rank + 1,
|
|
285
|
+
recallRef: block.recallRef.slice(0, 256),
|
|
286
|
+
content: block.content.trim().slice(0, MAX_RECALL_BLOCK_CHARS),
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
|
|
241
290
|
export function deriveSessionHash({ installationId, clientKind, hostSessionId }) {
|
|
242
291
|
if (!installationId || !clientKind || !hostSessionId) throw new Error("session identity is incomplete");
|
|
243
292
|
return digest(`halofy-session-v1\0${installationId}\0${clientKind}\0${hostSessionId}`);
|
|
@@ -251,21 +300,182 @@ export function stripInjectedContext(value) {
|
|
|
251
300
|
return String(value);
|
|
252
301
|
}
|
|
253
302
|
|
|
303
|
+
function usageInt(value) {
|
|
304
|
+
return Number.isSafeInteger(value) && value >= 0 && value < 2 ** 31 ? value : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function usageLabel(value) {
|
|
308
|
+
return typeof value === "string" && /^[\x20-\x7e]{1,128}$/.test(value) ? value : null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* One host-reported usage record per assistant message (PRD §8.1). Claude
|
|
315
|
+
* writes one JSONL line per content block of the same message, each repeating
|
|
316
|
+
* `message.usage`; the stable per-message eventKey plus keep-last suffix
|
|
317
|
+
* dedupe make that one record, never a sum of repeats (the ccusage bug).
|
|
318
|
+
* Counts come from the top-level usage totals only — `iterations[]` is the
|
|
319
|
+
* per-iteration breakdown of the same totals and is ignored.
|
|
320
|
+
*/
|
|
321
|
+
function usageEventFromEntry(entry, { nativeId, occurredAt, sourceEndOffset }) {
|
|
322
|
+
const message = entry.message;
|
|
323
|
+
const usage = message.usage;
|
|
324
|
+
const model = typeof message.model === "string" && USAGE_MODEL_PATTERN.test(message.model)
|
|
325
|
+
? message.model : null;
|
|
326
|
+
const usable = model !== null && usage !== null && typeof usage === "object" &&
|
|
327
|
+
(usageInt(usage.input_tokens) !== null || usageInt(usage.output_tokens) !== null);
|
|
328
|
+
if (!usable) {
|
|
329
|
+
return normalizedEvent({
|
|
330
|
+
eventKey: `claude:usage-gap:${nativeId}`,
|
|
331
|
+
type: "usage",
|
|
332
|
+
occurredAt,
|
|
333
|
+
payload: {
|
|
334
|
+
role: "assistant", contentFormat: "json", captureStatus: "complete",
|
|
335
|
+
gap: true, reason: "usage_unavailable",
|
|
336
|
+
},
|
|
337
|
+
sourceEndOffset,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const cacheCreation = usage.cache_creation && typeof usage.cache_creation === "object"
|
|
341
|
+
? usage.cache_creation : {};
|
|
342
|
+
const details = usage.output_tokens_details && typeof usage.output_tokens_details === "object"
|
|
343
|
+
? usage.output_tokens_details : {};
|
|
344
|
+
const messageId = usageLabel(message.id);
|
|
345
|
+
const payload = {
|
|
346
|
+
role: "assistant",
|
|
347
|
+
contentFormat: "json",
|
|
348
|
+
captureStatus: "complete",
|
|
349
|
+
provider: "anthropic",
|
|
350
|
+
model,
|
|
351
|
+
providerRequestId: usageLabel(entry.requestId),
|
|
352
|
+
messageId,
|
|
353
|
+
stopReason: usageLabel(message.stop_reason),
|
|
354
|
+
serviceTier: usageLabel(usage.service_tier),
|
|
355
|
+
effort: usageLabel(entry.effort),
|
|
356
|
+
sidechain: entry.isSidechain === true,
|
|
357
|
+
latencyMs: null,
|
|
358
|
+
inputTokens: usageInt(usage.input_tokens),
|
|
359
|
+
outputTokens: usageInt(usage.output_tokens),
|
|
360
|
+
cacheReadTokens: usageInt(usage.cache_read_input_tokens),
|
|
361
|
+
cacheWriteTokens: usageInt(usage.cache_creation_input_tokens),
|
|
362
|
+
cacheWrite1hTokens: usageInt(cacheCreation.ephemeral_1h_input_tokens),
|
|
363
|
+
cacheWrite5mTokens: usageInt(cacheCreation.ephemeral_5m_input_tokens),
|
|
364
|
+
reasoningTokens: usageInt(details.thinking_tokens),
|
|
365
|
+
};
|
|
366
|
+
return normalizedEvent({
|
|
367
|
+
eventKey: `claude:usage:${messageId || nativeId}`,
|
|
368
|
+
type: "usage",
|
|
369
|
+
occurredAt,
|
|
370
|
+
payload,
|
|
371
|
+
sourceEndOffset,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Content-free outcome scalars for one tool_result block, joining the
|
|
377
|
+
* structured `toolUseResult` the host attached to the record and the tool_use
|
|
378
|
+
* timestamp observed earlier in the same suffix window.
|
|
379
|
+
*/
|
|
380
|
+
function toolOutcomeFromEntry(entry, block, evidence) {
|
|
381
|
+
const raw = entry?.toolUseResult !== null && typeof entry?.toolUseResult === "object" &&
|
|
382
|
+
!Array.isArray(entry.toolUseResult) ? entry.toolUseResult : null;
|
|
383
|
+
const failed = block.is_error === true || raw?.status === "failed" || raw?.is_error === true;
|
|
384
|
+
const calledMs = evidence.toolUseTimestamps instanceof Map
|
|
385
|
+
? evidence.toolUseTimestamps.get(String(block.tool_use_id ?? "")) : undefined;
|
|
386
|
+
const resultMs = Date.parse(String(entry?.timestamp ?? ""));
|
|
387
|
+
const durationMs = Number.isFinite(calledMs) && Number.isFinite(resultMs) && resultMs >= calledMs
|
|
388
|
+
? Math.min(resultMs - calledMs, 86_400_000) : null;
|
|
389
|
+
const outputBytes = bodyBytes(stableClone(block.content ?? null), "json").length;
|
|
390
|
+
const resolvedModel = typeof raw?.resolvedModel === "string" &&
|
|
391
|
+
USAGE_MODEL_PATTERN.test(raw.resolvedModel) ? raw.resolvedModel : null;
|
|
392
|
+
return {
|
|
393
|
+
failed: Boolean(failed),
|
|
394
|
+
interrupted: raw?.interrupted === true,
|
|
395
|
+
...(typeof raw?.status === "string" ? { status: reasonCode(raw.status) } : {}),
|
|
396
|
+
...(durationMs === null ? {} : { durationMs }),
|
|
397
|
+
outputBytes,
|
|
398
|
+
...(resolvedModel === null ? {} : { resolvedModel }),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function thinkingEvent({ nativeId, index, text, occurredAt, sourceEndOffset }) {
|
|
403
|
+
const payload = boundedCompletePayload({
|
|
404
|
+
role: "assistant",
|
|
405
|
+
contentFormat: "utf8",
|
|
406
|
+
captureStatus: "complete",
|
|
407
|
+
text,
|
|
408
|
+
}, { role: "assistant", body: text, format: "utf8" });
|
|
409
|
+
return normalizedEvent({
|
|
410
|
+
eventKey: `claude:${nativeId}:thinking:${index}`,
|
|
411
|
+
type: "message",
|
|
412
|
+
occurredAt,
|
|
413
|
+
payload,
|
|
414
|
+
sourceEndOffset,
|
|
415
|
+
part: "thinking",
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const MAX_INLINE_ATTACHMENT_BYTES = 8 * 1024;
|
|
420
|
+
|
|
254
421
|
/**
|
|
255
422
|
* Normalize one current Claude transcript record into zero or more archive
|
|
256
423
|
* events. Text is never trimmed or prefix-truncated. Unsupported/oversized
|
|
257
424
|
* bodies become explicit digest-only events, so advancing the byte cursor
|
|
258
|
-
* cannot silently turn a capture gap into success.
|
|
425
|
+
* cannot silently turn a capture gap into success. Opaque host tokens
|
|
426
|
+
* (`atis-latch`) are never read at all.
|
|
259
427
|
*/
|
|
260
428
|
export function normalizeClaudeTranscriptEntry(entry, evidence) {
|
|
429
|
+
const recordType = entry?.type;
|
|
430
|
+
const sourceEndOffset = evidence.endOffset;
|
|
431
|
+
if (recordType === "queue-operation") {
|
|
432
|
+
// A user message queued mid-turn. Only the enqueue marks a queued message;
|
|
433
|
+
// the content itself reaches the archive through the transcript when the
|
|
434
|
+
// host delivers it as a real user turn.
|
|
435
|
+
if (entry.operation !== "enqueue") return [];
|
|
436
|
+
const occurredAt = entry.timestamp || evidence.occurredAt;
|
|
437
|
+
return [normalizedEvent({
|
|
438
|
+
eventKey: `claude:queued:${digest(`${entry.timestamp ?? ""}\0${evidence.byteOffset}`)}`,
|
|
439
|
+
type: "checkpoint",
|
|
440
|
+
occurredAt,
|
|
441
|
+
payload: { contentFormat: "json", captureStatus: "complete", kind: "queued_message" },
|
|
442
|
+
sourceEndOffset,
|
|
443
|
+
})];
|
|
444
|
+
}
|
|
445
|
+
if (recordType === "attachment") {
|
|
446
|
+
const occurredAt = entry.timestamp || evidence.occurredAt;
|
|
447
|
+
const attachment = entry.attachment !== null && typeof entry.attachment === "object"
|
|
448
|
+
? entry.attachment : {};
|
|
449
|
+
const attachmentType = boundedIdentifier(attachment.type || "unknown", 100);
|
|
450
|
+
const stableBody = stableClone(attachment);
|
|
451
|
+
const bytes = bodyBytes(stableBody, "json");
|
|
452
|
+
const payload = bytes.length <= MAX_INLINE_ATTACHMENT_BYTES
|
|
453
|
+
? boundedCompletePayload({
|
|
454
|
+
contentFormat: "json", captureStatus: "complete",
|
|
455
|
+
kind: "attachment", attachmentType, body: stableBody,
|
|
456
|
+
}, { body: stableBody, format: "json", extra: { kind: "attachment", attachmentType } })
|
|
457
|
+
: digestOnlyPayload({
|
|
458
|
+
body: bytes, status: "truncated", reason: "attachment_body_too_large",
|
|
459
|
+
extra: { kind: "attachment", attachmentType },
|
|
460
|
+
});
|
|
461
|
+
return [normalizedEvent({
|
|
462
|
+
eventKey: `claude:attachment:${entry.uuid || `offset-${evidence.byteOffset}`}`,
|
|
463
|
+
type: "checkpoint",
|
|
464
|
+
occurredAt,
|
|
465
|
+
payload,
|
|
466
|
+
sourceEndOffset,
|
|
467
|
+
})];
|
|
468
|
+
}
|
|
261
469
|
const message = entry?.message;
|
|
262
470
|
if (!message || !["user", "assistant"].includes(message.role)) return [];
|
|
263
471
|
const role = message.role;
|
|
264
472
|
const occurredAt = entry.timestamp || evidence.occurredAt;
|
|
265
|
-
const sourceEndOffset = evidence.endOffset;
|
|
266
473
|
const nativeId = nativeEntryId(entry, evidence);
|
|
474
|
+
const usageEvents = role === "assistant"
|
|
475
|
+
? [usageEventFromEntry(entry, { nativeId, occurredAt, sourceEndOffset })]
|
|
476
|
+
: [];
|
|
267
477
|
if (typeof message.content === "string") {
|
|
268
|
-
return [textEvent({ nativeId, index: 0, role, text: message.content, occurredAt, sourceEndOffset })];
|
|
478
|
+
return [textEvent({ nativeId, index: 0, role, text: message.content, occurredAt, sourceEndOffset }), ...usageEvents];
|
|
269
479
|
}
|
|
270
480
|
if (!Array.isArray(message.content)) {
|
|
271
481
|
return [unsupportedBlockEvent({
|
|
@@ -275,20 +485,28 @@ export function normalizeClaudeTranscriptEntry(entry, evidence) {
|
|
|
275
485
|
block: message.content,
|
|
276
486
|
occurredAt,
|
|
277
487
|
sourceEndOffset,
|
|
278
|
-
})];
|
|
488
|
+
}), ...usageEvents];
|
|
279
489
|
}
|
|
280
490
|
if (message.content.length === 0) {
|
|
281
|
-
return [textEvent({ nativeId, index: 0, role, text: "", occurredAt, sourceEndOffset })];
|
|
491
|
+
return [textEvent({ nativeId, index: 0, role, text: "", occurredAt, sourceEndOffset }), ...usageEvents];
|
|
282
492
|
}
|
|
283
|
-
return message.content.
|
|
493
|
+
return [...message.content.flatMap((block, index) => {
|
|
284
494
|
if (!block || typeof block !== "object") {
|
|
285
|
-
return unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset });
|
|
495
|
+
return [unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset })];
|
|
286
496
|
}
|
|
287
497
|
if (block.type === "text" && typeof block.text === "string") {
|
|
288
|
-
return textEvent({ nativeId, index, role, text: block.text, occurredAt, sourceEndOffset });
|
|
498
|
+
return [textEvent({ nativeId, index, role, text: block.text, occurredAt, sourceEndOffset })];
|
|
499
|
+
}
|
|
500
|
+
if (block.type === "thinking" && typeof block.thinking === "string") {
|
|
501
|
+
// Reasoning content, captured as a marked message part so a session
|
|
502
|
+
// with thinking no longer reads as partial. Empty blocks (signature
|
|
503
|
+
// only) carry no content and produce no event.
|
|
504
|
+
return block.thinking.length > 0
|
|
505
|
+
? [thinkingEvent({ nativeId, index, text: block.thinking, occurredAt, sourceEndOffset })]
|
|
506
|
+
: [];
|
|
289
507
|
}
|
|
290
508
|
if (block.type === "tool_use") {
|
|
291
|
-
return toolEvent({
|
|
509
|
+
return [toolEvent({
|
|
292
510
|
nativeId,
|
|
293
511
|
index,
|
|
294
512
|
type: "tool_call",
|
|
@@ -297,10 +515,10 @@ export function normalizeClaudeTranscriptEntry(entry, evidence) {
|
|
|
297
515
|
body: block.input,
|
|
298
516
|
occurredAt,
|
|
299
517
|
sourceEndOffset,
|
|
300
|
-
});
|
|
518
|
+
})];
|
|
301
519
|
}
|
|
302
520
|
if (block.type === "tool_result") {
|
|
303
|
-
return toolEvent({
|
|
521
|
+
return [toolEvent({
|
|
304
522
|
nativeId,
|
|
305
523
|
index,
|
|
306
524
|
type: "tool_result",
|
|
@@ -310,13 +528,15 @@ export function normalizeClaudeTranscriptEntry(entry, evidence) {
|
|
|
310
528
|
occurredAt,
|
|
311
529
|
sourceEndOffset,
|
|
312
530
|
failed: block.is_error,
|
|
313
|
-
|
|
531
|
+
hostResult: entry?.toolUseResult,
|
|
532
|
+
outcome: toolOutcomeFromEntry(entry, block, evidence),
|
|
533
|
+
})];
|
|
314
534
|
}
|
|
315
535
|
if (["image", "document", "artifact", "file"].includes(block.type)) {
|
|
316
|
-
return artifactEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset });
|
|
536
|
+
return [artifactEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset })];
|
|
317
537
|
}
|
|
318
|
-
return unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset });
|
|
319
|
-
});
|
|
538
|
+
return [unsupportedBlockEvent({ nativeId, index, role, block, occurredAt, sourceEndOffset })];
|
|
539
|
+
}), ...usageEvents];
|
|
320
540
|
}
|
|
321
541
|
|
|
322
542
|
export function normalizeMalformedClaudeTranscriptLine(rawBytes, evidence) {
|
|
@@ -418,11 +638,39 @@ export function normalizeHostMessageEvent({
|
|
|
418
638
|
};
|
|
419
639
|
}
|
|
420
640
|
|
|
641
|
+
function collectClaudeMetadata(metadata, entry) {
|
|
642
|
+
const bounded = (value, max) =>
|
|
643
|
+
typeof value === "string" && value.length > 0 && value.length <= max &&
|
|
644
|
+
!/[\u0000-\u001f\u007f]/.test(value) ? value : undefined;
|
|
645
|
+
const hostVersion = bounded(entry.version, 64);
|
|
646
|
+
if (hostVersion) metadata.hostVersion = hostVersion;
|
|
647
|
+
const entrypoint = bounded(entry.entrypoint, 64);
|
|
648
|
+
if (entrypoint) metadata.entrypoint = entrypoint;
|
|
649
|
+
const userType = bounded(entry.userType, 64);
|
|
650
|
+
if (userType) metadata.userType = userType;
|
|
651
|
+
const effort = bounded(entry.effort, 64);
|
|
652
|
+
if (effort) metadata.effort = effort;
|
|
653
|
+
const permissionMode = entry.type === "permission-mode"
|
|
654
|
+
? bounded(entry.permissionMode, 64)
|
|
655
|
+
: bounded(entry.permissionMode, 64);
|
|
656
|
+
if (permissionMode) metadata.permissionMode = permissionMode;
|
|
657
|
+
if (entry.type === "ai-title") {
|
|
658
|
+
const title = bounded(entry.aiTitle ?? entry.title, 512);
|
|
659
|
+
if (title) metadata.title = title;
|
|
660
|
+
}
|
|
661
|
+
const gitBranch = bounded(entry.gitBranch, 512);
|
|
662
|
+
if (gitBranch) metadata.gitBranch = gitBranch;
|
|
663
|
+
const cwd = bounded(entry.cwd, 2048);
|
|
664
|
+
if (cwd) metadata.cwd = cwd;
|
|
665
|
+
}
|
|
666
|
+
|
|
421
667
|
export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
|
|
422
668
|
const bytes = await readFile(path);
|
|
423
669
|
const start = Number.isSafeInteger(cursor?.byteOffset) && cursor.byteOffset <= bytes.length ? cursor.byteOffset : 0;
|
|
424
670
|
const suffix = bytes.subarray(start);
|
|
425
671
|
const events = [];
|
|
672
|
+
const metadata = {};
|
|
673
|
+
const toolUseTimestamps = new Map();
|
|
426
674
|
let position = 0;
|
|
427
675
|
while (position < suffix.length) {
|
|
428
676
|
const newline = suffix.indexOf(0x0a, position);
|
|
@@ -439,14 +687,95 @@ export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
|
|
|
439
687
|
endOffset,
|
|
440
688
|
rawBytes,
|
|
441
689
|
occurredAt: new Date().toISOString(),
|
|
690
|
+
toolUseTimestamps,
|
|
442
691
|
};
|
|
443
692
|
try {
|
|
444
|
-
|
|
693
|
+
const entry = JSON.parse(raw);
|
|
694
|
+
if (entry !== null && typeof entry === "object") {
|
|
695
|
+
collectClaudeMetadata(metadata, entry);
|
|
696
|
+
if (entry.type === "assistant" && Array.isArray(entry.message?.content)) {
|
|
697
|
+
const calledMs = Date.parse(String(entry.timestamp ?? ""));
|
|
698
|
+
for (const block of entry.message.content) {
|
|
699
|
+
if (block && typeof block === "object" && block.type === "tool_use" &&
|
|
700
|
+
typeof block.id === "string" && Number.isFinite(calledMs)) {
|
|
701
|
+
toolUseTimestamps.set(block.id, calledMs);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
events.push(...normalizeClaudeTranscriptEntry(entry, evidence));
|
|
445
707
|
} catch {
|
|
446
708
|
events.push(normalizeMalformedClaudeTranscriptLine(rawBytes, evidence));
|
|
447
709
|
}
|
|
448
710
|
}
|
|
449
|
-
|
|
711
|
+
// Claude repeats the same usage record on every JSONL line of a multi-block
|
|
712
|
+
// message. One usage event per message.id, keep-last: later lines carry the
|
|
713
|
+
// completed totals. A gap event is withdrawn the moment any line of the
|
|
714
|
+
// same message produced a real usage record.
|
|
715
|
+
const usageByKey = new Map();
|
|
716
|
+
for (const event of events) {
|
|
717
|
+
if (event.type === "usage") usageByKey.set(event.eventKey, event);
|
|
718
|
+
}
|
|
719
|
+
const usageKeysWithData = new Set(
|
|
720
|
+
[...usageByKey.keys()].filter((key) => key.startsWith("claude:usage:")),
|
|
721
|
+
);
|
|
722
|
+
const emittedUsageKeys = new Set();
|
|
723
|
+
const deduped = [];
|
|
724
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
725
|
+
const event = events[index];
|
|
726
|
+
if (event.type === "usage") {
|
|
727
|
+
if (emittedUsageKeys.has(event.eventKey)) continue;
|
|
728
|
+
if (event.eventKey.startsWith("claude:usage-gap:")) {
|
|
729
|
+
const nativeId = event.eventKey.slice("claude:usage-gap:".length);
|
|
730
|
+
if (usageKeysWithData.has(`claude:usage:${nativeId}`)) continue;
|
|
731
|
+
}
|
|
732
|
+
emittedUsageKeys.add(event.eventKey);
|
|
733
|
+
}
|
|
734
|
+
deduped.unshift(event);
|
|
735
|
+
}
|
|
736
|
+
return { events: deduped, observedEndOffset: start + position, metadata };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function buildClaudeMetadataPayload(metadata, { installationId, deviceContext }) {
|
|
740
|
+
const payload = {};
|
|
741
|
+
for (const field of ["hostVersion", "entrypoint", "userType", "permissionMode", "effort", "title"]) {
|
|
742
|
+
if (typeof metadata[field] === "string" && metadata[field].length > 0) {
|
|
743
|
+
payload[field] = metadata[field];
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (typeof metadata.cwd === "string" && metadata.cwd.length > 0) {
|
|
747
|
+
// The path itself leaves the device only under an explicit org policy
|
|
748
|
+
// (deviceContext=full); the salted hash and basename always travel so
|
|
749
|
+
// spend can still be grouped per repository.
|
|
750
|
+
payload.cwdHash = digest(`halofy-cwd-v1\0${installationId}\0${metadata.cwd}`);
|
|
751
|
+
const segments = metadata.cwd.split(/[\\/]/).filter((segment) => segment.length > 0);
|
|
752
|
+
const base = segments.length > 0 ? segments[segments.length - 1].slice(0, 256) : "";
|
|
753
|
+
if (base) payload.cwdBasename = base;
|
|
754
|
+
if (deviceContext === "full") payload.cwdPath = metadata.cwd;
|
|
755
|
+
}
|
|
756
|
+
if (typeof metadata.gitBranch === "string" && metadata.gitBranch.length > 0 &&
|
|
757
|
+
deviceContext === "full") {
|
|
758
|
+
payload.gitBranch = metadata.gitBranch;
|
|
759
|
+
}
|
|
760
|
+
if (typeof metadata.startSource === "string" && metadata.startSource.length > 0) {
|
|
761
|
+
payload.startSource = metadata.startSource;
|
|
762
|
+
}
|
|
763
|
+
if (typeof metadata.closeReason === "string" && metadata.closeReason.length > 0) {
|
|
764
|
+
payload.closeReason = metadata.closeReason;
|
|
765
|
+
}
|
|
766
|
+
if (typeof metadata.subagentType === "string" && metadata.subagentType.length > 0) {
|
|
767
|
+
payload.subagentType = metadata.subagentType;
|
|
768
|
+
}
|
|
769
|
+
return payload;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export function claudeMetadataEvent(payload) {
|
|
773
|
+
return normalizedEvent({
|
|
774
|
+
eventKey: `claude:metadata:${digest(stableJson(payload))}`,
|
|
775
|
+
type: "metadata",
|
|
776
|
+
occurredAt: new Date().toISOString(),
|
|
777
|
+
payload: { contentFormat: "json", captureStatus: "complete", ...payload },
|
|
778
|
+
});
|
|
450
779
|
}
|
|
451
780
|
|
|
452
781
|
export class CursorStore {
|
|
@@ -493,4 +822,31 @@ export class CursorStore {
|
|
|
493
822
|
return current;
|
|
494
823
|
});
|
|
495
824
|
}
|
|
825
|
+
|
|
826
|
+
// Usage-gap diagnostics (PRD §8.3): a content-free count of host records
|
|
827
|
+
// the adapter could not read usage from, reported on the next heartbeat as
|
|
828
|
+
// a delta and cleared only after successful delivery.
|
|
829
|
+
async peekUsageGaps() {
|
|
830
|
+
const state = await readJson(this.path, { version: 1, sessions: {} });
|
|
831
|
+
const count = Number(state.usageGaps ?? 0);
|
|
832
|
+
return Number.isSafeInteger(count) && count > 0 ? count : 0;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async bumpUsageGaps(count) {
|
|
836
|
+
if (!Number.isSafeInteger(count) || count <= 0) return;
|
|
837
|
+
await withFileLock(this.lockPath, async () => {
|
|
838
|
+
const state = await readJson(this.path, { version: 1, sessions: {} });
|
|
839
|
+
state.usageGaps = Math.max(0, Number(state.usageGaps ?? 0) || 0) + count;
|
|
840
|
+
await writePrivateFile(this.path, `${JSON.stringify(state)}\n`);
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async clearUsageGaps(delivered) {
|
|
845
|
+
if (!Number.isSafeInteger(delivered) || delivered <= 0) return;
|
|
846
|
+
await withFileLock(this.lockPath, async () => {
|
|
847
|
+
const state = await readJson(this.path, { version: 1, sessions: {} });
|
|
848
|
+
state.usageGaps = Math.max(0, (Number(state.usageGaps ?? 0) || 0) - delivered);
|
|
849
|
+
await writePrivateFile(this.path, `${JSON.stringify(state)}\n`);
|
|
850
|
+
});
|
|
851
|
+
}
|
|
496
852
|
}
|
package/src/storage.mjs
CHANGED
|
@@ -31,6 +31,32 @@ export async function writePrivateFile(path, value) {
|
|
|
31
31
|
if (platform() !== "win32") await chmod(path, 0o600);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Atomic write for a host application's config file. The parent directory is
|
|
36
|
+
* created if missing, but the permissions of an existing directory are never
|
|
37
|
+
* changed — a project root, $HOME, or ~/.cursor is not Halofy's to tighten.
|
|
38
|
+
* An existing file keeps its mode; a new file starts private (0600).
|
|
39
|
+
*/
|
|
40
|
+
export async function writeHostConfigFile(path, value) {
|
|
41
|
+
await mkdir(dirname(path), { recursive: true });
|
|
42
|
+
let mode = 0o600;
|
|
43
|
+
try {
|
|
44
|
+
mode = (await stat(path)).mode & 0o777;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error?.code !== "ENOENT") throw error;
|
|
47
|
+
}
|
|
48
|
+
const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
49
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
50
|
+
try {
|
|
51
|
+
await handle.writeFile(value);
|
|
52
|
+
await handle.sync();
|
|
53
|
+
} finally {
|
|
54
|
+
await handle.close();
|
|
55
|
+
}
|
|
56
|
+
await rename(temporary, path);
|
|
57
|
+
if (platform() !== "win32") await chmod(path, mode);
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
export async function withFileLock(path, action, { timeoutMs = 3_000, staleMs = 30_000 } = {}) {
|
|
35
61
|
await ensurePrivateDirectory(dirname(path));
|
|
36
62
|
const started = Date.now();
|
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.5.1";
|
|
3
|
+
export const RUNTIME_VERSION = "0.5.1";
|
|
4
|
+
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-31.1";
|