@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 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.5.1 install <client-kind> \
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.5.1",
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": {
@@ -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(
@@ -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. Claude Code is the only host whose reviewed adapter
62
- // reads these today.
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({
@@ -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 = process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code"),
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 = process.env.CODEX_HOME || join(homedir(), ".codex"),
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: non-Claude hosts have no
146
- // transcript, so this enqueue is how user messages reach the archive.
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: {