@halofy/agent-connect 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.5.0 install <client-kind> \
28
+ npx --yes @halofy/agent-connect@0.6.0 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, requires the recipient to type `CONNECT`, generates
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
@@ -72,6 +81,20 @@ represented by digest-only placeholders and make coverage partial;
72
81
  encrypted retry queue, and real-host fixtures are verified. The adapter never
73
82
  opens an arbitrary transcript-referenced local file to fill that gap.
74
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
+
75
98
  Run focused checks from this directory:
76
99
 
77
100
  ```bash
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@halofy/agent-connect",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
- "description": "Signed Halofy lifecycle installer and runtime for supported agents",
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"
@@ -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": {
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join, resolve } from "node:path";
3
- import { readJson, writePrivateFile } from "./storage.mjs";
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 writePrivateFile(claudeConfigPath, `${JSON.stringify(claude, null, 2)}\n`);
127
+ await writeHostConfigFile(claudeConfigPath, `${JSON.stringify(claude, null, 2)}\n`);
128
128
  }
129
129
  }
130
130
 
131
- await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
132
- await writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
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,
@@ -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 blocks = Array.isArray(result?.blocks) ? result.blocks : Array.isArray(result) ? result : [];
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: {
@@ -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
- const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
99
- const recalled = await runtime.recall(
100
- session,
101
- `${project ? `${project} ` : ""}project policy, conventions, decisions, preferences, and known context`,
102
- );
103
- const output = recallText(recalled, "SessionStart");
104
- if (output) stdout.write(output);
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);
@@ -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({
@@ -1,7 +1,8 @@
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 { ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
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);
@@ -92,8 +93,8 @@ export async function configureCursor({
92
93
  mcp.mcpServers,
93
94
  managedMcp(nodePath, runtimePath, installationId),
94
95
  );
95
- await writePrivateFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
96
- await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
96
+ await writeHostConfigFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
97
+ await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
97
98
  return { configuredPaths: [hooksPath, mcpPath], hookEvents: Object.keys(CURSOR_HOOKS), replacedLegacyMcpEntries };
98
99
  }
99
100
 
@@ -133,7 +134,7 @@ export async function configureGemini({
133
134
  settings.mcpServers,
134
135
  managedMcp(nodePath, runtimePath, installationId),
135
136
  );
136
- await writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
137
+ await writeHostConfigFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
137
138
  return { configuredPaths: [settingsPath], hookEvents: Object.keys(GEMINI_HOOKS), replacedLegacyMcpEntries };
138
139
  }
139
140
 
@@ -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");
@@ -197,8 +198,8 @@ export async function configureKimi({
197
198
  mcp.mcpServers,
198
199
  managedMcp(nodePath, runtimePath, installationId),
199
200
  );
200
- await writePrivateFile(configPath, replaceKimiManagedHooks(source, hookBlock));
201
- await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
201
+ await writeHostConfigFile(configPath, replaceKimiManagedHooks(source, hookBlock));
202
+ await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
202
203
  return { configuredPaths: [configPath, mcpPath], hookEvents: Object.keys(KIMI_HOOKS), replacedLegacyMcpEntries };
203
204
  }
204
205
 
@@ -233,8 +234,8 @@ export async function configureVscode({
233
234
  mcp.servers,
234
235
  managedMcp(nodePath, runtimePath, installationId, { explicitType: true }),
235
236
  );
236
- await writePrivateFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
237
- await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
237
+ await writeHostConfigFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
238
+ await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
238
239
  return { configuredPaths: [hooksPath, mcpPath], hookEvents: Object.keys(VSCODE_HOOKS), replacedLegacyMcpEntries };
239
240
  }
240
241
 
@@ -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");
@@ -308,11 +309,11 @@ export async function configureCodex({
308
309
  `command = ${JSON.stringify(resolve(nodePath))}`,
309
310
  `args = ${JSON.stringify([resolve(runtimePath), "mcp", "--connection", installationId])}`,
310
311
  ].join("\n");
311
- await writePrivateFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
312
+ await writeHostConfigFile(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
312
313
  const withoutHalomem = removeTomlSection(source, "mcp_servers.halomem");
313
314
  const withoutHalofy = removeTomlSection(withoutHalomem.source, "mcp_servers.halofy");
314
315
  const withMcp = replaceTomlSection(withoutHalofy.source, "mcp_servers.halofy", section);
315
- await writePrivateFile(configPath, enableTomlFeature(withMcp, "hooks"));
316
+ await writeHostConfigFile(configPath, enableTomlFeature(withMcp, "hooks"));
316
317
  return {
317
318
  configuredPaths: [hooksPath, configPath],
318
319
  hookEvents: Object.keys(CODEX_HOOKS),
@@ -335,12 +336,12 @@ export async function configureCline({
335
336
  }) {
336
337
  validateInputs({ installationId, runtimePath });
337
338
  const hooksRoot = join(clineRoot, "Hooks");
338
- await ensurePrivateDirectory(hooksRoot);
339
+ await mkdir(hooksRoot, { recursive: true });
339
340
  const configuredPaths = [];
340
341
  for (const [hostEvent, runtimeEvent] of Object.entries(CLINE_HOOKS)) {
341
342
  const path = join(hooksRoot, hostEvent);
342
343
  const command = managedCommand(nodePath, runtimePath, runtimeEvent, installationId);
343
- await writePrivateFile(path, `#!/usr/bin/env sh\nexec ${command}\n`);
344
+ await writeHostConfigFile(path, `#!/usr/bin/env sh\nexec ${command}\n`);
344
345
  if (process.platform !== "win32") await chmod(path, 0o700);
345
346
  configuredPaths.push(path);
346
347
  }
@@ -351,7 +352,7 @@ export async function configureCline({
351
352
  mcp.mcpServers,
352
353
  managedMcp(nodePath, runtimePath, installationId),
353
354
  );
354
- await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
355
+ await writeHostConfigFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
355
356
  configuredPaths.push(mcpPath);
356
357
  return { configuredPaths, hookEvents: Object.keys(CLINE_HOOKS), replacedLegacyMcpEntries };
357
358
  }
package/src/host-hook.mjs CHANGED
@@ -1,9 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { basename } from "node:path";
3
3
  import { LifecycleRuntime } from "./runtime.mjs";
4
- import { normalizeClaudeHookEvent, normalizeHostMessageEvent } from "./session.mjs";
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";
12
+ import { transcriptDriverFor } from "./transcript-drivers/index.mjs";
7
13
 
8
14
  const USER_EVENTS = new Set(["UserPromptSubmit", "beforeSubmitPrompt", "BeforeAgent", "pre_llm_call"]);
9
15
  const ASSISTANT_EVENTS = new Set(["afterAgentResponse", "AfterAgent", "post_llm_call", "transform_llm_output"]);
@@ -62,18 +68,8 @@ function toolOutput(input) {
62
68
  input.extra?.result ?? input.extra?.error_message;
63
69
  }
64
70
 
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
71
  function recallOutput(clientKind, eventName, result) {
76
- const rankedBlocks = recallBlocks(result);
72
+ const rankedBlocks = rankedRecallBlocks(result);
77
73
  if (rankedBlocks.length === 0) return null;
78
74
  const additionalContext = `<<<HALOFY_CONTEXT_BLOCKS_V1>>>\n${JSON.stringify({ rankedBlocks })}\n<<<END_HALOFY_CONTEXT_BLOCKS_V1>>>`;
79
75
  if (clientKind === "cursor") {
@@ -121,6 +117,29 @@ async function enqueueTool(runtime, session, eventName, input) {
121
117
  ]);
122
118
  }
123
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
+
124
143
  /** Runs one reviewed host hook without blocking the host when Halofy browns out. */
125
144
  export async function runHostLifecycleHook(connection, eventName, {
126
145
  input,
@@ -139,15 +158,22 @@ export async function runHostLifecycleHook(connection, eventName, {
139
158
  if (START_EVENTS.has(eventName)) {
140
159
  await runtime.replay();
141
160
  await runtime.heartbeat(connection.capabilities || {});
142
- const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
143
- const recalled = await runtime.recall(session,
144
- `${project ? `${project} ` : ""}project policy, conventions, decisions, preferences, and known context`);
145
- const output = recallOutput(connection.clientKind, eventName, recalled);
146
- if (output) stdout.write(output);
161
+ if (RECALL_INJECTION_ENABLED) {
162
+ const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
163
+ const recalled = await runtime.recall(session,
164
+ `${project ? `${project} ` : ""}project policy, conventions, decisions, preferences, and known context`);
165
+ const output = recallOutput(connection.clientKind, eventName, recalled);
166
+ if (output) stdout.write(output);
167
+ }
147
168
  } else if (USER_EVENTS.has(eventName)) {
169
+ // Prompt capture is independent of recall: hosts without a reviewed
170
+ // transcript driver rely on this enqueue to reach the archive, and
171
+ // hook-sourced events stay authoritative for messages even where a
172
+ // driver adds usage/metadata evidence.
148
173
  const prompt = promptText(hookInput);
149
174
  await enqueueMessage(runtime, connection, session, "user", prompt, eventName, hookInput);
150
- if (connection.clientKind !== "cursor" && typeof prompt === "string" && prompt.trim()) {
175
+ if (RECALL_INJECTION_ENABLED && connection.clientKind !== "cursor" &&
176
+ typeof prompt === "string" && prompt.trim()) {
151
177
  const recalled = await runtime.recall(session, prompt.slice(0, 8_000));
152
178
  const output = recallOutput(connection.clientKind, eventName, recalled);
153
179
  if (output) stdout.write(output);
@@ -157,6 +183,7 @@ export async function runHostLifecycleHook(connection, eventName, {
157
183
  await runtime.commitIfThreshold(session);
158
184
  } else if (TOOL_EVENTS.has(eventName)) {
159
185
  await enqueueTool(runtime, session, eventName, hookInput);
186
+ await catchUpHost(runtime, connection, session);
160
187
  } else if (COMPACT_EVENTS.has(eventName)) {
161
188
  await runtime.enqueueSequencedEvents(session, ({ sessionHash, nextSequence }) => [
162
189
  normalizeClaudeHookEvent("compaction", {
@@ -164,14 +191,20 @@ export async function runHostLifecycleHook(connection, eventName, {
164
191
  event_id: eventEvidenceId(eventName, hookInput),
165
192
  }, { sessionHash, sequence: nextSequence }),
166
193
  ]);
194
+ await catchUpHost(runtime, connection, session);
167
195
  await runtime.commit(session, "pre_compaction");
168
196
  } else if (SUBAGENT_START_EVENTS.has(eventName)) {
169
197
  await runtime.resolveSession(childSession(hookInput, session), session);
170
198
  } else if (SUBAGENT_STOP_EVENTS.has(eventName)) {
199
+ // Subagent transcript files are a documented per-host limitation; the
200
+ // parent session still catches up so its usage stays current.
201
+ await within(10_000, () => catchUpHost(runtime, connection, session));
171
202
  await within(10_000, () => runtime.close(childSession(hookInput, session), "session_end"));
172
203
  } else if (END_EVENTS.has(eventName)) {
204
+ await within(10_000, () => catchUpHost(runtime, connection, session, { closeReason: "session_end" }));
173
205
  await within(15_000, () => runtime.close(session));
174
206
  } else if (STOP_EVENTS.has(eventName)) {
207
+ await within(10_000, () => catchUpHost(runtime, connection, session));
175
208
  if (connection.clientKind === "vscode" || connection.clientKind === "cline") {
176
209
  await within(15_000, () => runtime.close(session));
177
210
  } else {
@@ -0,0 +1,13 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ // Single authority for each host's on-disk root. host-config.mjs (hook/MCP
5
+ // installation) and transcript-drivers/ (session-file location) must agree on
6
+ // these paths or capture silently reads the wrong tree.
7
+ export function codexHome(env = process.env) {
8
+ return env.CODEX_HOME || join(homedir(), ".codex");
9
+ }
10
+
11
+ export function kimiHome(env = process.env) {
12
+ return env.KIMI_CODE_HOME || join(homedir(), ".kimi-code");
13
+ }
package/src/install.mjs CHANGED
@@ -10,6 +10,105 @@ 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
+
64
+ /**
65
+ * Batch pre-consumption disclosure for the install-all sweep. One POST — and
66
+ * therefore one throttle token — covers every claim in the command, so a full
67
+ * sweep (1 disclosure + N consumes) fits the per-address budget. Entries come
68
+ * back positionally; an unusable claim is null. Any failure yields null for
69
+ * the whole batch (older server), never an error.
70
+ */
71
+ export async function fetchClaimDisclosures({
72
+ serverUrl,
73
+ claims,
74
+ fetchImpl = globalThis.fetch,
75
+ timeoutMs = 5_000,
76
+ }) {
77
+ try {
78
+ const normalizedServerUrl = normalizeLifecycleServerUrl(serverUrl);
79
+ const controller = new AbortController();
80
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
81
+ let response;
82
+ try {
83
+ response = await fetchImpl(`${normalizedServerUrl}/v1/agent-installations/claim-info`, {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify({ claims }),
87
+ signal: controller.signal,
88
+ });
89
+ } finally {
90
+ clearTimeout(timer);
91
+ }
92
+ if (!response.ok) return null;
93
+ const body = await response.json();
94
+ if (!Array.isArray(body?.disclosures)) return null;
95
+ return claims.map((_, index) => {
96
+ const entry = body.disclosures[index];
97
+ if (entry === null || typeof entry !== "object") return null;
98
+ // Server-supplied text is printed to a terminal: strip control
99
+ // characters (including ANSI escape introducers) and bound the length.
100
+ const organization = typeof entry.organization === "string"
101
+ ? entry.organization.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, 100)
102
+ : "";
103
+ const clientKind = typeof entry.clientKind === "string" &&
104
+ /^[a-z0-9][a-z0-9-]{0,63}$/.test(entry.clientKind) ? entry.clientKind : null;
105
+ return organization ? { organization, clientKind } : null;
106
+ });
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
13
112
  export async function consumeInstallationClaim({
14
113
  serverUrl,
15
114
  claim,
@@ -51,12 +150,7 @@ export async function installLocalConnection({
51
150
  }) {
52
151
  if (!CLIENT_KINDS.includes(clientKind)) throw new Error("the selected lifecycle adapter is not packaged");
53
152
  const client = lifecycleClient(clientKind);
54
- const normalizedServer = new URL(serverUrl);
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(/\/$/, "");
153
+ const normalizedServerUrl = normalizeLifecycleServerUrl(serverUrl);
60
154
  const store = new ConnectionStore(root);
61
155
  const pendingPath = join(root, `pending-${clientKind}.json`);
62
156
  const priorPending = await readJson(pendingPath);