@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/src/installer-cli.mjs
CHANGED
|
@@ -5,20 +5,64 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import {
|
|
7
7
|
fetchClaimDisclosure,
|
|
8
|
+
fetchClaimDisclosures,
|
|
8
9
|
heartbeatInstalledConnection,
|
|
9
10
|
installLocalConnection,
|
|
10
11
|
installRuntimeBundle,
|
|
11
12
|
localMcpSnippet,
|
|
13
|
+
syncInstalledSkills,
|
|
12
14
|
} from "./install.mjs";
|
|
13
15
|
import { configureClaudeProject } from "./claude-config.mjs";
|
|
14
16
|
import { configureCline, configureCodex, configureCursor, configureGemini, configureKimi, configureVscode } from "./host-config.mjs";
|
|
15
17
|
import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
|
|
16
18
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
17
19
|
import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
|
|
20
|
+
import { describeSkillSync, managedSkillsDirectory } from "./skills-sync.mjs";
|
|
21
|
+
|
|
22
|
+
const CLAIM_PATTERN = /^hsc_[A-Za-z0-9_-]{43}$/;
|
|
23
|
+
|
|
24
|
+
function parseAllInstallerArgs(argv) {
|
|
25
|
+
const values = new Map();
|
|
26
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
27
|
+
const name = argv[index];
|
|
28
|
+
if (!["--server", "--claims", "--claude-project"].includes(name) || values.has(name)) {
|
|
29
|
+
throw new Error("unsupported or duplicate installer argument");
|
|
30
|
+
}
|
|
31
|
+
const value = argv[index + 1];
|
|
32
|
+
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
|
33
|
+
values.set(name, value);
|
|
34
|
+
index += 1;
|
|
35
|
+
}
|
|
36
|
+
const serverUrl = values.get("--server");
|
|
37
|
+
const rawClaims = values.get("--claims");
|
|
38
|
+
const usage = "Usage: agent-connect install all --server <https-url> --claims <client>=<one-use-claim>[,...]";
|
|
39
|
+
if (!serverUrl || !rawClaims) throw new Error(usage);
|
|
40
|
+
const pairs = rawClaims.split(",");
|
|
41
|
+
if (pairs.length < 1 || pairs.length > CLIENT_KINDS.length) throw new Error(usage);
|
|
42
|
+
const selections = [];
|
|
43
|
+
const kinds = new Set();
|
|
44
|
+
for (const pair of pairs) {
|
|
45
|
+
const separator = pair.indexOf("=");
|
|
46
|
+
const clientKind = separator === -1 ? "" : pair.slice(0, separator);
|
|
47
|
+
const claim = separator === -1 ? "" : pair.slice(separator + 1);
|
|
48
|
+
if (!CLIENT_KINDS.includes(clientKind) || !CLAIM_PATTERN.test(claim) || kinds.has(clientKind)) {
|
|
49
|
+
throw new Error(usage);
|
|
50
|
+
}
|
|
51
|
+
kinds.add(clientKind);
|
|
52
|
+
selections.push({ clientKind, claim });
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
mode: "all",
|
|
56
|
+
selections,
|
|
57
|
+
serverUrl,
|
|
58
|
+
projectRoot: resolve(values.get("--claude-project") || process.cwd()),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
18
61
|
|
|
19
62
|
export function parseInstallerArgs(argv) {
|
|
20
63
|
const command = argv[0];
|
|
21
64
|
const clientKind = argv[1];
|
|
65
|
+
if (command === "install" && clientKind === "all") return parseAllInstallerArgs(argv);
|
|
22
66
|
const values = new Map();
|
|
23
67
|
for (let index = 2; index < argv.length; index += 1) {
|
|
24
68
|
const name = argv[index];
|
|
@@ -37,6 +81,7 @@ export function parseInstallerArgs(argv) {
|
|
|
37
81
|
throw new Error("Usage: agent-connect install <supported-client> --server <https-url> --claim <one-use-claim>");
|
|
38
82
|
}
|
|
39
83
|
return {
|
|
84
|
+
mode: "single",
|
|
40
85
|
clientKind,
|
|
41
86
|
serverUrl,
|
|
42
87
|
claim,
|
|
@@ -82,8 +127,20 @@ export function detectClient(clientKind) {
|
|
|
82
127
|
return String(result.stdout || result.stderr || "").trim().slice(0, 128) || "detected";
|
|
83
128
|
}
|
|
84
129
|
|
|
85
|
-
|
|
86
|
-
|
|
130
|
+
/** Non-throwing detection probe for the install-all sweep. */
|
|
131
|
+
export async function probeClient(clientKind, {
|
|
132
|
+
detectClaude = detectClaudeCode,
|
|
133
|
+
detectHost = detectClient,
|
|
134
|
+
} = {}) {
|
|
135
|
+
try {
|
|
136
|
+
const version = clientKind === "claude-code" ? await detectClaude() : await detectHost(clientKind);
|
|
137
|
+
return { detected: true, version };
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return { detected: false, reason: error?.message || "not detected" };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function captureCategories(client) {
|
|
87
144
|
const observedCategories = [
|
|
88
145
|
["user messages", client.capabilities.userMessages],
|
|
89
146
|
["assistant messages", client.capabilities.assistantMessages],
|
|
@@ -101,8 +158,16 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
101
158
|
["tool outcomes (durations, failure flags, byte sizes; file paths only as salted hashes)", client.capabilities.toolOutcomes],
|
|
102
159
|
["host and session metadata (app version, permission mode, effort, session title; directory paths hashed unless your organization enables full device context)", client.capabilities.sessionMetadata],
|
|
103
160
|
];
|
|
104
|
-
|
|
105
|
-
|
|
161
|
+
return {
|
|
162
|
+
supported: observedCategories.filter(([, value]) => value === true).map(([name]) => name),
|
|
163
|
+
unsupported: observedCategories.filter(([, value]) => value !== true).map(([name]) => name),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion, organization = null }) {
|
|
168
|
+
const client = lifecycleClient(clientKind);
|
|
169
|
+
const { supported, unsupported } = captureCategories(client);
|
|
170
|
+
const skillsRoot = managedSkillsDirectory(clientKind);
|
|
106
171
|
return [
|
|
107
172
|
`Halofy ${client.label} lifecycle connection`,
|
|
108
173
|
`Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
|
|
@@ -117,8 +182,11 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
117
182
|
"- governed memory tools the agent invokes explicitly (no automatic recall is injected into sessions),",
|
|
118
183
|
`- conversation events exposed by ${client.label}'s reviewed hooks,`,
|
|
119
184
|
"- explicitly supported tool, subagent, compaction, and close evidence,",
|
|
120
|
-
"- encrypted local retry queue and governed retained conversations,
|
|
121
|
-
"- canonical learning when the selected badge permits writes
|
|
185
|
+
"- encrypted local retry queue and governed retained conversations,",
|
|
186
|
+
"- canonical learning when the selected badge permits writes, and",
|
|
187
|
+
skillsRoot
|
|
188
|
+
? `- approved company and team skills written to ${skillsRoot}/<skill>/SKILL.md and kept current at each session start; withdrawn skills are moved aside, never deleted.`
|
|
189
|
+
: "- no managed skills folder for this host (skills stay available through skill_invoke).",
|
|
122
190
|
"",
|
|
123
191
|
`Supported capture categories: ${supported.length > 0 ? supported.join(", ") : "none"}.`,
|
|
124
192
|
`Unsupported capture categories: ${unsupported.length > 0 ? unsupported.join(", ") : "none"}.`,
|
|
@@ -126,12 +194,68 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
126
194
|
? "This reviewed host surface can report complete coverage when all declared evidence is observed."
|
|
127
195
|
: `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
|
|
128
196
|
"Authorized organization managers may review retained conversations and summaries.",
|
|
129
|
-
|
|
197
|
+
skillsRoot
|
|
198
|
+
? `This reads and writes only the skill folders it created under ${skillsRoot}; it does not scan historical files, other applications, clipboard, keystrokes, or other agents.`
|
|
199
|
+
: "This does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
|
|
130
200
|
"Disconnecting stops future capture but does not erase retained data.",
|
|
131
201
|
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
132
202
|
].join("\n");
|
|
133
203
|
}
|
|
134
204
|
|
|
205
|
+
/**
|
|
206
|
+
* One disclosure for the whole sweep: shared header, one capture-category
|
|
207
|
+
* section per detected host, and an explicit statement of which minted claims
|
|
208
|
+
* will expire unused. One CONNECT then covers exactly the listed hosts —
|
|
209
|
+
* explicit per-host consent, never a silent fan-out.
|
|
210
|
+
*/
|
|
211
|
+
export function allDisclosureText({ serverUrl, projectRoot, organization = null, hosts, unusedKinds = [] }) {
|
|
212
|
+
const lines = [
|
|
213
|
+
"Halofy all-agents lifecycle connection",
|
|
214
|
+
`Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
|
|
215
|
+
`Server: ${new URL(serverUrl).origin}`,
|
|
216
|
+
// The server named by --server identifies the organization this command
|
|
217
|
+
// binds to, so a spoofed command is recognizable before CONNECT.
|
|
218
|
+
`Organization: ${organization || "unverified (the server did not identify this command's organization)"}`,
|
|
219
|
+
`Project: ${projectRoot}`,
|
|
220
|
+
`Hosts to be connected: ${hosts.map((host) => lifecycleClient(host.clientKind).label).join(", ")}`,
|
|
221
|
+
"",
|
|
222
|
+
"Each host gets its own installation binding, capability record, and connection status:",
|
|
223
|
+
];
|
|
224
|
+
for (const host of hosts) {
|
|
225
|
+
const client = lifecycleClient(host.clientKind);
|
|
226
|
+
const { supported, unsupported } = captureCategories(client);
|
|
227
|
+
lines.push(
|
|
228
|
+
"",
|
|
229
|
+
`--- ${client.label} (${host.clientVersion}) ---`,
|
|
230
|
+
`Supported capture categories: ${supported.length > 0 ? supported.join(", ") : "none"}.`,
|
|
231
|
+
`Unsupported capture categories: ${unsupported.length > 0 ? unsupported.join(", ") : "none"}.`,
|
|
232
|
+
client.coverage === "complete"
|
|
233
|
+
? "This reviewed host surface can report complete coverage when all declared evidence is observed."
|
|
234
|
+
: `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
|
|
235
|
+
managedSkillsDirectory(host.clientKind)
|
|
236
|
+
? `Approved company and team skills are written to ${managedSkillsDirectory(host.clientKind)}/<skill>/SKILL.md ` +
|
|
237
|
+
"and kept current at each session start; withdrawn skills are moved aside, never deleted."
|
|
238
|
+
: "No managed skills folder for this host; skills stay available through skill_invoke.",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (unusedKinds.length > 0) {
|
|
242
|
+
lines.push(
|
|
243
|
+
"",
|
|
244
|
+
`Claims for ${unusedKinds.map((kind) => lifecycleClient(kind).label).join(", ")} were issued ` +
|
|
245
|
+
"but those hosts were not detected here; they expire unused within 10 minutes.",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
lines.push(
|
|
249
|
+
"",
|
|
250
|
+
"Authorized organization managers may review retained conversations and summaries.",
|
|
251
|
+
"This reads and writes only the skill folders it created for the hosts listed above; it does not scan " +
|
|
252
|
+
"historical files, other applications, clipboard, keystrokes, or other agents.",
|
|
253
|
+
"Disconnecting stops future capture but does not erase retained data.",
|
|
254
|
+
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
255
|
+
);
|
|
256
|
+
return lines.join("\n");
|
|
257
|
+
}
|
|
258
|
+
|
|
135
259
|
export async function confirmDisclosure({ input = process.stdin, output = process.stdout } = {}) {
|
|
136
260
|
if (!input.isTTY || !output.isTTY) throw new Error("interactive terminal confirmation is required");
|
|
137
261
|
const prompt = createInterface({ input, output });
|
|
@@ -144,6 +268,154 @@ export async function confirmDisclosure({ input = process.stdin, output = proces
|
|
|
144
268
|
return true;
|
|
145
269
|
}
|
|
146
270
|
|
|
271
|
+
async function configureHost(clientKind, common, { claudeConfigPath } = {}) {
|
|
272
|
+
if (clientKind === "claude-code") {
|
|
273
|
+
return configureClaudeProject({ ...common, ...(claudeConfigPath ? { claudeConfigPath } : {}) });
|
|
274
|
+
}
|
|
275
|
+
if (clientKind === "cursor") return configureCursor(common);
|
|
276
|
+
if (clientKind === "gemini-cli") return configureGemini(common);
|
|
277
|
+
if (clientKind === "kimi-cli") return configureKimi(common);
|
|
278
|
+
if (clientKind === "vscode") return configureVscode(common);
|
|
279
|
+
if (clientKind === "codex") return configureCodex(common);
|
|
280
|
+
if (clientKind === "cline") return configureCline(common);
|
|
281
|
+
throw new Error("the selected adapter is not packaged yet");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function runAllInstaller(input, {
|
|
285
|
+
root,
|
|
286
|
+
output,
|
|
287
|
+
detectClaude,
|
|
288
|
+
detectHost,
|
|
289
|
+
confirm,
|
|
290
|
+
fetchImpl,
|
|
291
|
+
sourceRoot,
|
|
292
|
+
claudeConfigPath,
|
|
293
|
+
skillsHome,
|
|
294
|
+
}) {
|
|
295
|
+
const probes = [];
|
|
296
|
+
for (const selection of input.selections) {
|
|
297
|
+
probes.push({ ...selection, probe: await probeClient(selection.clientKind, { detectClaude, detectHost }) });
|
|
298
|
+
}
|
|
299
|
+
const detected = probes.filter((entry) => entry.probe.detected);
|
|
300
|
+
const skipped = probes.filter((entry) => !entry.probe.detected)
|
|
301
|
+
.map((entry) => ({ clientKind: entry.clientKind, reason: "not_detected" }));
|
|
302
|
+
if (detected.length === 0) {
|
|
303
|
+
throw new Error("no claimed agents were detected on this machine: " +
|
|
304
|
+
probes.map((entry) => `${lifecycleClient(entry.clientKind).label} (${entry.probe.reason})`).join("; "));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// One batch disclosure call spends one throttle token for the whole sweep.
|
|
308
|
+
const disclosures = await fetchClaimDisclosures({
|
|
309
|
+
serverUrl: input.serverUrl,
|
|
310
|
+
claims: input.selections.map((selection) => selection.claim),
|
|
311
|
+
fetchImpl,
|
|
312
|
+
});
|
|
313
|
+
let organization = null;
|
|
314
|
+
if (disclosures !== null) {
|
|
315
|
+
const organizations = new Set();
|
|
316
|
+
for (let index = 0; index < input.selections.length; index += 1) {
|
|
317
|
+
const disclosure = disclosures[index];
|
|
318
|
+
if (!disclosure) continue;
|
|
319
|
+
// A claim minted for one client kind pasted behind another label is a
|
|
320
|
+
// spoofed or reassembled command — refuse before any consent prompt.
|
|
321
|
+
if (disclosure.clientKind !== null && disclosure.clientKind !== input.selections[index].clientKind) {
|
|
322
|
+
throw new Error(`the claim labeled ${input.selections[index].clientKind} was issued for ` +
|
|
323
|
+
`${disclosure.clientKind}; refuse this command and generate a fresh one`);
|
|
324
|
+
}
|
|
325
|
+
if (disclosure.organization) organizations.add(disclosure.organization);
|
|
326
|
+
}
|
|
327
|
+
if (organizations.size > 1) {
|
|
328
|
+
throw new Error("the claims in this command belong to different organizations; " +
|
|
329
|
+
"refuse this command and generate a fresh one");
|
|
330
|
+
}
|
|
331
|
+
organization = organizations.size === 1 ? [...organizations][0] : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
output.write(`${allDisclosureText({
|
|
335
|
+
serverUrl: input.serverUrl,
|
|
336
|
+
projectRoot: input.projectRoot,
|
|
337
|
+
organization,
|
|
338
|
+
hosts: detected.map((entry) => ({ clientKind: entry.clientKind, clientVersion: entry.probe.version })),
|
|
339
|
+
unusedKinds: skipped.map((entry) => entry.clientKind),
|
|
340
|
+
})}\n`);
|
|
341
|
+
await confirm();
|
|
342
|
+
|
|
343
|
+
// The reviewed runtime bundle installs exactly once for the whole sweep.
|
|
344
|
+
const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
|
|
345
|
+
const results = [];
|
|
346
|
+
for (const host of detected) {
|
|
347
|
+
try {
|
|
348
|
+
const installed = await installLocalConnection({
|
|
349
|
+
serverUrl: input.serverUrl,
|
|
350
|
+
claim: host.claim,
|
|
351
|
+
clientKind: host.clientKind,
|
|
352
|
+
root,
|
|
353
|
+
fetchImpl,
|
|
354
|
+
sendHeartbeat: false,
|
|
355
|
+
});
|
|
356
|
+
const configured = await configureHost(host.clientKind, {
|
|
357
|
+
projectRoot: input.projectRoot,
|
|
358
|
+
installationId: installed.installationId,
|
|
359
|
+
serverUrl: input.serverUrl,
|
|
360
|
+
runtimePath: bundle.runtimePath,
|
|
361
|
+
}, { claudeConfigPath });
|
|
362
|
+
let heartbeat = false;
|
|
363
|
+
try {
|
|
364
|
+
heartbeat = await heartbeatInstalledConnection({
|
|
365
|
+
installationId: installed.installationId, root, fetchImpl,
|
|
366
|
+
});
|
|
367
|
+
} catch {
|
|
368
|
+
heartbeat = false;
|
|
369
|
+
}
|
|
370
|
+
// Skills land during the sweep too; a skills problem is reported per
|
|
371
|
+
// host, never fatal to the install.
|
|
372
|
+
let skills = null;
|
|
373
|
+
try {
|
|
374
|
+
skills = await syncInstalledSkills({
|
|
375
|
+
installationId: installed.installationId, root, fetchImpl,
|
|
376
|
+
...(skillsHome ? { home: skillsHome } : {}),
|
|
377
|
+
});
|
|
378
|
+
} catch {
|
|
379
|
+
skills = null;
|
|
380
|
+
}
|
|
381
|
+
results.push({
|
|
382
|
+
clientKind: host.clientKind,
|
|
383
|
+
status: heartbeat ? "configured_heartbeat_verified" : "configured_heartbeat_unavailable",
|
|
384
|
+
installationId: installed.installationId,
|
|
385
|
+
skills: skills
|
|
386
|
+
? { supported: skills.supported, skillsRoot: skills.skillsRoot, checkedIn: skills.checkedIn,
|
|
387
|
+
installed: skills.installed, updated: skills.updated,
|
|
388
|
+
quarantined: skills.quarantined.map((q) => q.skillKey),
|
|
389
|
+
conflicts: skills.conflicts.map((c) => c.skillKey), errors: skills.errors.length }
|
|
390
|
+
: null,
|
|
391
|
+
proofStorage: installed.proofStorage,
|
|
392
|
+
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
|
393
|
+
replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
|
|
394
|
+
nextStep: `Restart ${lifecycleClient(host.clientKind).label}, then check the connection in Halofy.`,
|
|
395
|
+
});
|
|
396
|
+
} catch (error) {
|
|
397
|
+
// One host failing must not abort the others; the failure is reported,
|
|
398
|
+
// never hidden.
|
|
399
|
+
skipped.push({
|
|
400
|
+
clientKind: host.clientKind,
|
|
401
|
+
reason: `failed:${(error?.message || "install_error").slice(0, 200)}`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (results.length === 0) {
|
|
406
|
+
throw new Error("every detected agent failed to install: " +
|
|
407
|
+
skipped.map((entry) => `${entry.clientKind} (${entry.reason})`).join("; "));
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
status: skipped.some((entry) => entry.reason.startsWith("failed:"))
|
|
411
|
+
? "completed_with_failures" : "completed",
|
|
412
|
+
installerVersion: INSTALLER_VERSION,
|
|
413
|
+
publishedPackage: true,
|
|
414
|
+
results,
|
|
415
|
+
skipped,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
147
419
|
export async function runInstaller(argv, {
|
|
148
420
|
root = defaultRuntimeDirectory(),
|
|
149
421
|
output = process.stdout,
|
|
@@ -153,8 +425,15 @@ export async function runInstaller(argv, {
|
|
|
153
425
|
fetchImpl = globalThis.fetch,
|
|
154
426
|
sourceRoot,
|
|
155
427
|
claudeConfigPath,
|
|
428
|
+
skillsHome,
|
|
156
429
|
} = {}) {
|
|
157
430
|
const input = parseInstallerArgs(argv);
|
|
431
|
+
if (input.mode === "all") {
|
|
432
|
+
return runAllInstaller(input, {
|
|
433
|
+
root, output, detectClaude, detectHost, confirm, fetchImpl, sourceRoot, claudeConfigPath,
|
|
434
|
+
skillsHome,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
158
437
|
const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
|
|
159
438
|
const claimDisclosure = await fetchClaimDisclosure({
|
|
160
439
|
serverUrl: input.serverUrl,
|
|
@@ -183,21 +462,7 @@ export async function runInstaller(argv, {
|
|
|
183
462
|
serverUrl: input.serverUrl,
|
|
184
463
|
runtimePath: bundle.runtimePath,
|
|
185
464
|
};
|
|
186
|
-
const configured = input.clientKind
|
|
187
|
-
? await configureClaudeProject({ ...common, ...(claudeConfigPath ? { claudeConfigPath } : {}) })
|
|
188
|
-
: input.clientKind === "cursor"
|
|
189
|
-
? await configureCursor(common)
|
|
190
|
-
: input.clientKind === "gemini-cli"
|
|
191
|
-
? await configureGemini(common)
|
|
192
|
-
: input.clientKind === "kimi-cli"
|
|
193
|
-
? await configureKimi(common)
|
|
194
|
-
: input.clientKind === "vscode"
|
|
195
|
-
? await configureVscode(common)
|
|
196
|
-
: input.clientKind === "codex"
|
|
197
|
-
? await configureCodex(common)
|
|
198
|
-
: input.clientKind === "cline"
|
|
199
|
-
? await configureCline(common)
|
|
200
|
-
: (() => { throw new Error("the selected adapter is not packaged yet"); })();
|
|
465
|
+
const configured = await configureHost(input.clientKind, common, { claudeConfigPath });
|
|
201
466
|
let heartbeat = false;
|
|
202
467
|
try {
|
|
203
468
|
heartbeat = await heartbeatInstalledConnection({
|
|
@@ -208,6 +473,19 @@ export async function runInstaller(argv, {
|
|
|
208
473
|
} catch {
|
|
209
474
|
heartbeat = false;
|
|
210
475
|
}
|
|
476
|
+
// Skills land now, not at the next session: the badge already resolves the
|
|
477
|
+
// team + org set server-side, so the folder is populated before the user
|
|
478
|
+
// restarts the host. A skills problem is reported, never fatal.
|
|
479
|
+
let skills = null;
|
|
480
|
+
try {
|
|
481
|
+
skills = await syncInstalledSkills({
|
|
482
|
+
installationId: installed.installationId, root, fetchImpl, ...(skillsHome ? { home: skillsHome } : {}),
|
|
483
|
+
});
|
|
484
|
+
output.write(`${describeSkillSync(skills)}\n`);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
skills = null;
|
|
487
|
+
output.write(`skills: unavailable (${error?.code || "runtime_unavailable"})\n`);
|
|
488
|
+
}
|
|
211
489
|
return {
|
|
212
490
|
status: heartbeat ? "configured_heartbeat_verified" : "configured_heartbeat_unavailable",
|
|
213
491
|
installationId: installed.installationId,
|
|
@@ -221,6 +499,11 @@ export async function runInstaller(argv, {
|
|
|
221
499
|
proxyPath: bundle.runtimePath,
|
|
222
500
|
installationId: installed.installationId,
|
|
223
501
|
}),
|
|
502
|
+
skills: skills
|
|
503
|
+
? { supported: skills.supported, skillsRoot: skills.skillsRoot, checkedIn: skills.checkedIn,
|
|
504
|
+
installed: skills.installed, updated: skills.updated, quarantined: skills.quarantined.map((q) => q.skillKey),
|
|
505
|
+
conflicts: skills.conflicts.map((c) => c.skillKey), errors: skills.errors.length }
|
|
506
|
+
: null,
|
|
224
507
|
nextStep: `Restart ${lifecycleClient(input.clientKind).label}, then check the connection in Halofy.`,
|
|
225
508
|
};
|
|
226
509
|
}
|
package/src/runtime.mjs
CHANGED
|
@@ -2,12 +2,10 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { BoundedEncryptedQueue } from "./queue.mjs";
|
|
4
4
|
import {
|
|
5
|
-
buildClaudeMetadataPayload,
|
|
6
|
-
claudeMetadataEvent,
|
|
7
5
|
CursorStore,
|
|
8
6
|
deriveSessionHash,
|
|
9
|
-
readClaudeTranscriptSuffix,
|
|
10
7
|
} from "./session.mjs";
|
|
8
|
+
import { claudeTranscriptDriver } from "./transcript-drivers/claude.mjs";
|
|
11
9
|
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
12
10
|
import { readJson, withFileLock, writePrivateFile } from "./storage.mjs";
|
|
13
11
|
import { RUNTIME_VERSION } from "./version.mjs";
|
|
@@ -113,26 +111,30 @@ export class LifecycleRuntime {
|
|
|
113
111
|
}
|
|
114
112
|
|
|
115
113
|
async captureClaudeTranscript(hostSessionId, transcriptPath, sessionFacts = {}) {
|
|
114
|
+
return this.captureHostTranscript(hostSessionId, claudeTranscriptDriver, transcriptPath, sessionFacts);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async captureHostTranscript(hostSessionId, driver, transcriptPath, sessionFacts = {}) {
|
|
116
118
|
const sessionHash = this.sessionHash(hostSessionId);
|
|
117
119
|
const policy = await this.capturePolicy();
|
|
118
120
|
const queued = await withFileLock(this.operationLockPath, async () => {
|
|
119
121
|
const cursor = await this.cursors.get(sessionHash);
|
|
120
|
-
const suffix = await
|
|
122
|
+
const suffix = await driver.readSuffix(transcriptPath, cursor, sessionHash);
|
|
121
123
|
const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
|
|
122
124
|
const unseenEvents = suffix.events.filter((event) => !recentEventKeys.has(event.eventKey));
|
|
123
125
|
// One metadata event whenever the observed content-free session facts
|
|
124
126
|
// change. The event key hashes the payload, so an unchanged snapshot is
|
|
125
127
|
// deduplicated exactly like any repeated event.
|
|
126
|
-
const metadataPayload =
|
|
128
|
+
const metadataPayload = driver.buildMetadataPayload(
|
|
127
129
|
{ ...suffix.metadata, ...sessionFacts },
|
|
128
130
|
{ installationId: this.connection.installationId, deviceContext: policy.deviceContext },
|
|
129
131
|
);
|
|
130
132
|
if (Object.keys(metadataPayload).length > 0) {
|
|
131
|
-
const metadataEvent =
|
|
133
|
+
const metadataEvent = driver.metadataEvent(metadataPayload);
|
|
132
134
|
if (!recentEventKeys.has(metadataEvent.eventKey)) unseenEvents.push(metadataEvent);
|
|
133
135
|
}
|
|
134
136
|
const usageGaps = unseenEvents.filter((event) =>
|
|
135
|
-
event.type === "usage" && event.eventKey.startsWith(
|
|
137
|
+
event.type === "usage" && event.eventKey.startsWith(driver.usageGapPrefix)).length;
|
|
136
138
|
const result = unseenEvents.length === 0
|
|
137
139
|
? { queued: 0 }
|
|
138
140
|
: await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
|
|
@@ -144,8 +146,11 @@ export class LifecycleRuntime {
|
|
|
144
146
|
// normalized event is already durable in the encrypted queue. Advancing
|
|
145
147
|
// this byte cursor prevents unbounded reparsing without advancing the
|
|
146
148
|
// separately acknowledged event sequence.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
const patch = {};
|
|
150
|
+
if (suffix.observedEndOffset > cursor.byteOffset) patch.byteOffset = suffix.observedEndOffset;
|
|
151
|
+
if (suffix.hostState !== undefined) patch.hostState = suffix.hostState;
|
|
152
|
+
if (Object.keys(patch).length > 0) {
|
|
153
|
+
await this.cursors.update(sessionHash, patch);
|
|
149
154
|
}
|
|
150
155
|
await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
|
|
151
156
|
if (usageGaps > 0) await this.cursors.bumpUsageGaps(usageGaps);
|
package/src/session.mjs
CHANGED
|
@@ -86,7 +86,7 @@ function boundedCompletePayload(payload, { role, body, format = "json", extra =
|
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function normalizedEvent({ eventKey, type, occurredAt, payload, sourceEndOffset, part }) {
|
|
89
|
+
export function normalizedEvent({ eventKey, type, occurredAt, payload, sourceEndOffset, part }) {
|
|
90
90
|
const {
|
|
91
91
|
role,
|
|
92
92
|
contentFormat = "json",
|
|
@@ -300,15 +300,15 @@ export function stripInjectedContext(value) {
|
|
|
300
300
|
return String(value);
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
-
function usageInt(value) {
|
|
303
|
+
export function usageInt(value) {
|
|
304
304
|
return Number.isSafeInteger(value) && value >= 0 && value < 2 ** 31 ? value : null;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
function usageLabel(value) {
|
|
307
|
+
export function usageLabel(value) {
|
|
308
308
|
return typeof value === "string" && /^[\x20-\x7e]{1,128}$/.test(value) ? value : null;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._
|
|
311
|
+
export const USAGE_MODEL_PATTERN = /^[A-Za-z0-9._:/-]{1,128}$/;
|
|
312
312
|
|
|
313
313
|
/**
|
|
314
314
|
* One host-reported usage record per assistant message (PRD §8.1). Claude
|
|
@@ -769,15 +769,19 @@ export function buildClaudeMetadataPayload(metadata, { installationId, deviceCon
|
|
|
769
769
|
return payload;
|
|
770
770
|
}
|
|
771
771
|
|
|
772
|
-
export function
|
|
772
|
+
export function hostMetadataEvent(namespace, payload) {
|
|
773
773
|
return normalizedEvent({
|
|
774
|
-
eventKey:
|
|
774
|
+
eventKey: `${namespace}:metadata:${digest(stableJson(payload))}`,
|
|
775
775
|
type: "metadata",
|
|
776
776
|
occurredAt: new Date().toISOString(),
|
|
777
777
|
payload: { contentFormat: "json", captureStatus: "complete", ...payload },
|
|
778
778
|
});
|
|
779
779
|
}
|
|
780
780
|
|
|
781
|
+
export function claudeMetadataEvent(payload) {
|
|
782
|
+
return hostMetadataEvent("claude", payload);
|
|
783
|
+
}
|
|
784
|
+
|
|
781
785
|
export class CursorStore {
|
|
782
786
|
constructor(root) {
|
|
783
787
|
this.path = join(root, "cursors.json");
|