@halofy/agent-connect 0.5.1 → 0.7.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/claude-hook.mjs +20 -0
- package/src/client-registry.mjs +9 -2
- package/src/host-config.mjs +3 -2
- package/src/host-hook.mjs +37 -3
- package/src/host-roots.mjs +13 -0
- package/src/install.mjs +71 -0
- package/src/installer-cli.mjs +305 -22
- package/src/runtime.mjs +14 -9
- package/src/session.mjs +10 -6
- package/src/skills-sync.mjs +266 -0
- package/src/transcript-drivers/claude.mjs +33 -0
- package/src/transcript-drivers/codex.mjs +188 -0
- package/src/transcript-drivers/index.mjs +18 -0
- package/src/transcript-drivers/kimi.mjs +180 -0
- package/src/transcript-drivers/shared.mjs +104 -0
- package/src/transport.mjs +7 -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.7.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/claude-hook.mjs
CHANGED
|
@@ -3,6 +3,25 @@ import { basename } from "node:path";
|
|
|
3
3
|
import { LifecycleRuntime } from "./runtime.mjs";
|
|
4
4
|
import { normalizeClaudeHookEvent, RECALL_INJECTION_ENABLED, rankedRecallBlocks } from "./session.mjs";
|
|
5
5
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
6
|
+
import { describeSkillSync, syncManagedSkills } from "./skills-sync.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Badge skills ride the SessionStart heartbeat (D37): check in, repair, and
|
|
10
|
+
* quarantine, but never let a skills problem degrade memory recall.
|
|
11
|
+
*/
|
|
12
|
+
export async function syncSkillsAtSessionStart(runtime, connection, root, stderr) {
|
|
13
|
+
try {
|
|
14
|
+
const summary = await syncManagedSkills({ connection, transport: runtime.transport, root });
|
|
15
|
+
if (summary.supported && (summary.installed.length || summary.updated.length ||
|
|
16
|
+
summary.quarantined.length || summary.errors.length)) {
|
|
17
|
+
stderr.write(`[halofy] ${describeSkillSync(summary)}\n`);
|
|
18
|
+
}
|
|
19
|
+
return summary;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
stderr.write(`[halofy] skill sync degraded: ${error?.code || "runtime_unavailable"}\n`);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
6
25
|
|
|
7
26
|
function id(value) {
|
|
8
27
|
return createHash("sha256").update(String(value)).digest("hex");
|
|
@@ -86,6 +105,7 @@ export async function runClaudeLifecycleHook(connection, eventName, {
|
|
|
86
105
|
if (eventName === "SessionStart") {
|
|
87
106
|
await runtime.replay();
|
|
88
107
|
await runtime.heartbeat(connection.capabilities || {});
|
|
108
|
+
await syncSkillsAtSessionStart(runtime, connection, root, stderr);
|
|
89
109
|
if (RECALL_INJECTION_ENABLED) {
|
|
90
110
|
const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
|
|
91
111
|
const recalled = await runtime.recall(
|
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
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
rankedRecallBlocks,
|
|
9
9
|
} from "./session.mjs";
|
|
10
10
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
11
|
-
import { readHookInput } from "./claude-hook.mjs";
|
|
11
|
+
import { readHookInput, syncSkillsAtSessionStart } 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,
|
|
@@ -134,6 +158,7 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
134
158
|
if (START_EVENTS.has(eventName)) {
|
|
135
159
|
await runtime.replay();
|
|
136
160
|
await runtime.heartbeat(connection.capabilities || {});
|
|
161
|
+
await syncSkillsAtSessionStart(runtime, connection, root, stderr);
|
|
137
162
|
if (RECALL_INJECTION_ENABLED) {
|
|
138
163
|
const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
|
|
139
164
|
const recalled = await runtime.recall(session,
|
|
@@ -142,8 +167,10 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
142
167
|
if (output) stdout.write(output);
|
|
143
168
|
}
|
|
144
169
|
} else if (USER_EVENTS.has(eventName)) {
|
|
145
|
-
// Prompt capture is independent of recall:
|
|
146
|
-
// transcript
|
|
170
|
+
// Prompt capture is independent of recall: hosts without a reviewed
|
|
171
|
+
// transcript driver rely on this enqueue to reach the archive, and
|
|
172
|
+
// hook-sourced events stay authoritative for messages even where a
|
|
173
|
+
// driver adds usage/metadata evidence.
|
|
147
174
|
const prompt = promptText(hookInput);
|
|
148
175
|
await enqueueMessage(runtime, connection, session, "user", prompt, eventName, hookInput);
|
|
149
176
|
if (RECALL_INJECTION_ENABLED && connection.clientKind !== "cursor" &&
|
|
@@ -157,6 +184,7 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
157
184
|
await runtime.commitIfThreshold(session);
|
|
158
185
|
} else if (TOOL_EVENTS.has(eventName)) {
|
|
159
186
|
await enqueueTool(runtime, session, eventName, hookInput);
|
|
187
|
+
await catchUpHost(runtime, connection, session);
|
|
160
188
|
} else if (COMPACT_EVENTS.has(eventName)) {
|
|
161
189
|
await runtime.enqueueSequencedEvents(session, ({ sessionHash, nextSequence }) => [
|
|
162
190
|
normalizeClaudeHookEvent("compaction", {
|
|
@@ -164,14 +192,20 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
164
192
|
event_id: eventEvidenceId(eventName, hookInput),
|
|
165
193
|
}, { sessionHash, sequence: nextSequence }),
|
|
166
194
|
]);
|
|
195
|
+
await catchUpHost(runtime, connection, session);
|
|
167
196
|
await runtime.commit(session, "pre_compaction");
|
|
168
197
|
} else if (SUBAGENT_START_EVENTS.has(eventName)) {
|
|
169
198
|
await runtime.resolveSession(childSession(hookInput, session), session);
|
|
170
199
|
} else if (SUBAGENT_STOP_EVENTS.has(eventName)) {
|
|
200
|
+
// Subagent transcript files are a documented per-host limitation; the
|
|
201
|
+
// parent session still catches up so its usage stays current.
|
|
202
|
+
await within(10_000, () => catchUpHost(runtime, connection, session));
|
|
171
203
|
await within(10_000, () => runtime.close(childSession(hookInput, session), "session_end"));
|
|
172
204
|
} else if (END_EVENTS.has(eventName)) {
|
|
205
|
+
await within(10_000, () => catchUpHost(runtime, connection, session, { closeReason: "session_end" }));
|
|
173
206
|
await within(15_000, () => runtime.close(session));
|
|
174
207
|
} else if (STOP_EVENTS.has(eventName)) {
|
|
208
|
+
await within(10_000, () => catchUpHost(runtime, connection, session));
|
|
175
209
|
if (connection.clientKind === "vscode" || connection.clientKind === "cline") {
|
|
176
210
|
await within(15_000, () => runtime.close(session));
|
|
177
211
|
} 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
|
@@ -7,6 +7,7 @@ import { ConnectionStore, defaultRuntimeDirectory, ensurePrivateDirectory, readJ
|
|
|
7
7
|
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
8
8
|
import { INSTALLER_VERSION, RUNTIME_VERSION } from "./version.mjs";
|
|
9
9
|
import { CLIENT_KINDS, CLIENT_REGISTRY, lifecycleClient } from "./client-registry.mjs";
|
|
10
|
+
import { syncManagedSkills } from "./skills-sync.mjs";
|
|
10
11
|
|
|
11
12
|
export const CLAUDE_CAPABILITIES = CLIENT_REGISTRY["claude-code"].capabilities;
|
|
12
13
|
|
|
@@ -61,6 +62,54 @@ export async function fetchClaimDisclosure({
|
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Batch pre-consumption disclosure for the install-all sweep. One POST — and
|
|
67
|
+
* therefore one throttle token — covers every claim in the command, so a full
|
|
68
|
+
* sweep (1 disclosure + N consumes) fits the per-address budget. Entries come
|
|
69
|
+
* back positionally; an unusable claim is null. Any failure yields null for
|
|
70
|
+
* the whole batch (older server), never an error.
|
|
71
|
+
*/
|
|
72
|
+
export async function fetchClaimDisclosures({
|
|
73
|
+
serverUrl,
|
|
74
|
+
claims,
|
|
75
|
+
fetchImpl = globalThis.fetch,
|
|
76
|
+
timeoutMs = 5_000,
|
|
77
|
+
}) {
|
|
78
|
+
try {
|
|
79
|
+
const normalizedServerUrl = normalizeLifecycleServerUrl(serverUrl);
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
82
|
+
let response;
|
|
83
|
+
try {
|
|
84
|
+
response = await fetchImpl(`${normalizedServerUrl}/v1/agent-installations/claim-info`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
body: JSON.stringify({ claims }),
|
|
88
|
+
signal: controller.signal,
|
|
89
|
+
});
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
if (!response.ok) return null;
|
|
94
|
+
const body = await response.json();
|
|
95
|
+
if (!Array.isArray(body?.disclosures)) return null;
|
|
96
|
+
return claims.map((_, index) => {
|
|
97
|
+
const entry = body.disclosures[index];
|
|
98
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
99
|
+
// Server-supplied text is printed to a terminal: strip control
|
|
100
|
+
// characters (including ANSI escape introducers) and bound the length.
|
|
101
|
+
const organization = typeof entry.organization === "string"
|
|
102
|
+
? entry.organization.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, 100)
|
|
103
|
+
: "";
|
|
104
|
+
const clientKind = typeof entry.clientKind === "string" &&
|
|
105
|
+
/^[a-z0-9][a-z0-9-]{0,63}$/.test(entry.clientKind) ? entry.clientKind : null;
|
|
106
|
+
return organization ? { organization, clientKind } : null;
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
64
113
|
export async function consumeInstallationClaim({
|
|
65
114
|
serverUrl,
|
|
66
115
|
claim,
|
|
@@ -211,6 +260,28 @@ export async function heartbeatInstalledConnection({
|
|
|
211
260
|
return true;
|
|
212
261
|
}
|
|
213
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Install-time skill delivery: the moment the badge is bound, the approved
|
|
265
|
+
* team + org skills land in the host's skills folder (D37/D38). Returns the
|
|
266
|
+
* content-free sync summary; never throws for a server or skill problem.
|
|
267
|
+
*/
|
|
268
|
+
export async function syncInstalledSkills({
|
|
269
|
+
installationId,
|
|
270
|
+
root = defaultRuntimeDirectory(),
|
|
271
|
+
fetchImpl = globalThis.fetch,
|
|
272
|
+
home,
|
|
273
|
+
env,
|
|
274
|
+
}) {
|
|
275
|
+
const connection = await new ConnectionStore(root).load(installationId);
|
|
276
|
+
return syncManagedSkills({
|
|
277
|
+
connection,
|
|
278
|
+
transport: new SignedRuntimeTransport(connection, { fetchImpl }),
|
|
279
|
+
root,
|
|
280
|
+
...(home ? { home } : {}),
|
|
281
|
+
...(env ? { env } : {}),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
214
285
|
export function localMcpSnippet({ nodePath = process.execPath, proxyPath, installationId }) {
|
|
215
286
|
return {
|
|
216
287
|
mcpServers: {
|