@halofy/agent-connect 0.6.0 → 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/package.json +1 -1
- package/src/claude-hook.mjs +20 -0
- package/src/host-hook.mjs +2 -1
- package/src/install.mjs +23 -0
- package/src/installer-cli.mjs +55 -4
- package/src/skills-sync.mjs +266 -0
- package/src/transport.mjs +7 -0
- package/src/version.mjs +3 -3
package/package.json
CHANGED
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/host-hook.mjs
CHANGED
|
@@ -8,7 +8,7 @@ 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
12
|
import { transcriptDriverFor } from "./transcript-drivers/index.mjs";
|
|
13
13
|
|
|
14
14
|
const USER_EVENTS = new Set(["UserPromptSubmit", "beforeSubmitPrompt", "BeforeAgent", "pre_llm_call"]);
|
|
@@ -158,6 +158,7 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
158
158
|
if (START_EVENTS.has(eventName)) {
|
|
159
159
|
await runtime.replay();
|
|
160
160
|
await runtime.heartbeat(connection.capabilities || {});
|
|
161
|
+
await syncSkillsAtSessionStart(runtime, connection, root, stderr);
|
|
161
162
|
if (RECALL_INJECTION_ENABLED) {
|
|
162
163
|
const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
|
|
163
164
|
const recalled = await runtime.recall(session,
|
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
|
|
|
@@ -259,6 +260,28 @@ export async function heartbeatInstalledConnection({
|
|
|
259
260
|
return true;
|
|
260
261
|
}
|
|
261
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
|
+
|
|
262
285
|
export function localMcpSnippet({ nodePath = process.execPath, proxyPath, installationId }) {
|
|
263
286
|
return {
|
|
264
287
|
mcpServers: {
|
package/src/installer-cli.mjs
CHANGED
|
@@ -10,12 +10,14 @@ import {
|
|
|
10
10
|
installLocalConnection,
|
|
11
11
|
installRuntimeBundle,
|
|
12
12
|
localMcpSnippet,
|
|
13
|
+
syncInstalledSkills,
|
|
13
14
|
} from "./install.mjs";
|
|
14
15
|
import { configureClaudeProject } from "./claude-config.mjs";
|
|
15
16
|
import { configureCline, configureCodex, configureCursor, configureGemini, configureKimi, configureVscode } from "./host-config.mjs";
|
|
16
17
|
import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
|
|
17
18
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
18
19
|
import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
|
|
20
|
+
import { describeSkillSync, managedSkillsDirectory } from "./skills-sync.mjs";
|
|
19
21
|
|
|
20
22
|
const CLAIM_PATTERN = /^hsc_[A-Za-z0-9_-]{43}$/;
|
|
21
23
|
|
|
@@ -165,6 +167,7 @@ function captureCategories(client) {
|
|
|
165
167
|
export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersion, organization = null }) {
|
|
166
168
|
const client = lifecycleClient(clientKind);
|
|
167
169
|
const { supported, unsupported } = captureCategories(client);
|
|
170
|
+
const skillsRoot = managedSkillsDirectory(clientKind);
|
|
168
171
|
return [
|
|
169
172
|
`Halofy ${client.label} lifecycle connection`,
|
|
170
173
|
`Installer: @halofy/agent-connect@${INSTALLER_VERSION}`,
|
|
@@ -179,8 +182,11 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
179
182
|
"- governed memory tools the agent invokes explicitly (no automatic recall is injected into sessions),",
|
|
180
183
|
`- conversation events exposed by ${client.label}'s reviewed hooks,`,
|
|
181
184
|
"- explicitly supported tool, subagent, compaction, and close evidence,",
|
|
182
|
-
"- encrypted local retry queue and governed retained conversations,
|
|
183
|
-
"- 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).",
|
|
184
190
|
"",
|
|
185
191
|
`Supported capture categories: ${supported.length > 0 ? supported.join(", ") : "none"}.`,
|
|
186
192
|
`Unsupported capture categories: ${unsupported.length > 0 ? unsupported.join(", ") : "none"}.`,
|
|
@@ -188,7 +194,9 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
188
194
|
? "This reviewed host surface can report complete coverage when all declared evidence is observed."
|
|
189
195
|
: `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
|
|
190
196
|
"Authorized organization managers may review retained conversations and summaries.",
|
|
191
|
-
|
|
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.",
|
|
192
200
|
"Disconnecting stops future capture but does not erase retained data.",
|
|
193
201
|
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
194
202
|
].join("\n");
|
|
@@ -224,6 +232,10 @@ export function allDisclosureText({ serverUrl, projectRoot, organization = null,
|
|
|
224
232
|
client.coverage === "complete"
|
|
225
233
|
? "This reviewed host surface can report complete coverage when all declared evidence is observed."
|
|
226
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.",
|
|
227
239
|
);
|
|
228
240
|
}
|
|
229
241
|
if (unusedKinds.length > 0) {
|
|
@@ -236,7 +248,8 @@ export function allDisclosureText({ serverUrl, projectRoot, organization = null,
|
|
|
236
248
|
lines.push(
|
|
237
249
|
"",
|
|
238
250
|
"Authorized organization managers may review retained conversations and summaries.",
|
|
239
|
-
"This
|
|
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.",
|
|
240
253
|
"Disconnecting stops future capture but does not erase retained data.",
|
|
241
254
|
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
242
255
|
);
|
|
@@ -277,6 +290,7 @@ async function runAllInstaller(input, {
|
|
|
277
290
|
fetchImpl,
|
|
278
291
|
sourceRoot,
|
|
279
292
|
claudeConfigPath,
|
|
293
|
+
skillsHome,
|
|
280
294
|
}) {
|
|
281
295
|
const probes = [];
|
|
282
296
|
for (const selection of input.selections) {
|
|
@@ -353,10 +367,27 @@ async function runAllInstaller(input, {
|
|
|
353
367
|
} catch {
|
|
354
368
|
heartbeat = false;
|
|
355
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
|
+
}
|
|
356
381
|
results.push({
|
|
357
382
|
clientKind: host.clientKind,
|
|
358
383
|
status: heartbeat ? "configured_heartbeat_verified" : "configured_heartbeat_unavailable",
|
|
359
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,
|
|
360
391
|
proofStorage: installed.proofStorage,
|
|
361
392
|
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
|
362
393
|
replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
|
|
@@ -394,11 +425,13 @@ export async function runInstaller(argv, {
|
|
|
394
425
|
fetchImpl = globalThis.fetch,
|
|
395
426
|
sourceRoot,
|
|
396
427
|
claudeConfigPath,
|
|
428
|
+
skillsHome,
|
|
397
429
|
} = {}) {
|
|
398
430
|
const input = parseInstallerArgs(argv);
|
|
399
431
|
if (input.mode === "all") {
|
|
400
432
|
return runAllInstaller(input, {
|
|
401
433
|
root, output, detectClaude, detectHost, confirm, fetchImpl, sourceRoot, claudeConfigPath,
|
|
434
|
+
skillsHome,
|
|
402
435
|
});
|
|
403
436
|
}
|
|
404
437
|
const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
|
|
@@ -440,6 +473,19 @@ export async function runInstaller(argv, {
|
|
|
440
473
|
} catch {
|
|
441
474
|
heartbeat = false;
|
|
442
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
|
+
}
|
|
443
489
|
return {
|
|
444
490
|
status: heartbeat ? "configured_heartbeat_verified" : "configured_heartbeat_unavailable",
|
|
445
491
|
installationId: installed.installationId,
|
|
@@ -453,6 +499,11 @@ export async function runInstaller(argv, {
|
|
|
453
499
|
proxyPath: bundle.runtimePath,
|
|
454
500
|
installationId: installed.installationId,
|
|
455
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,
|
|
456
507
|
nextStep: `Restart ${lifecycleClient(input.clientKind).label}, then check the connection in Halofy.`,
|
|
457
508
|
};
|
|
458
509
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, stat } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Badge-delivered skills (CONTRACTS-S16-SKILL-TRACKING D37–D39 on the signed
|
|
9
|
+
* badge runtime). The badge resolves, server-side, every approved and vetted
|
|
10
|
+
* skill on its team + org chain; this module mirrors that set into the host's
|
|
11
|
+
* own skills folder and keeps it there.
|
|
12
|
+
*
|
|
13
|
+
* Every managed copy is written as `<skillsRoot>/<skillKey>/SKILL.md`, the
|
|
14
|
+
* layout Claude Code and Codex both read. A manifest under the runtime home
|
|
15
|
+
* records what was written (id, sha, file digest) so the next check-in can
|
|
16
|
+
* report it, drift can be repaired, and a withdrawn skill can be quarantined
|
|
17
|
+
* — moved, never deleted (D7 on the client mirror).
|
|
18
|
+
*
|
|
19
|
+
* A folder this runtime did not create is never touched: an employee's own
|
|
20
|
+
* skill with a colliding name is reported as a conflict and left alone.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
24
|
+
const SKILL_KEY = /^[a-z0-9][a-z0-9._-]{0,199}$/i;
|
|
25
|
+
const MANAGED_MARKER = "content_sha256:";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The user-level Agent Skills folder each host scans, `<dir>/<name>/SKILL.md`.
|
|
29
|
+
* Every host also reads a workspace folder and most read the generic
|
|
30
|
+
* `~/.agents/skills`; the badge writes the host's own brand folder so one
|
|
31
|
+
* badge maps to one host and nothing leaks into a host the badge was not
|
|
32
|
+
* issued for. Verified 2026-09-04: Claude Code + Codex by install; Gemini CLI
|
|
33
|
+
* 0.58 (`GEMINI_CLI_HOME` replaces the HOME directory, so the folder is
|
|
34
|
+
* `<home>/.gemini/skills`) and Kimi Code 0.40 (`KIMI_CODE_HOME || ~/.kimi-code`;
|
|
35
|
+
* `~/.kimi/skills` is its legacy tree) from the installed binaries; Cursor,
|
|
36
|
+
* VS Code Copilot, and Cline from their published docs.
|
|
37
|
+
*/
|
|
38
|
+
export function managedSkillsDirectory(clientKind, { home = homedir(), env = process.env } = {}) {
|
|
39
|
+
switch (clientKind) {
|
|
40
|
+
case "claude-code": return join(env.CLAUDE_CONFIG_DIR || join(home, ".claude"), "skills");
|
|
41
|
+
case "codex": return join(env.CODEX_HOME || join(home, ".codex"), "skills");
|
|
42
|
+
case "cursor": return join(home, ".cursor", "skills");
|
|
43
|
+
case "gemini-cli": return join(env.GEMINI_CLI_HOME || home, ".gemini", "skills");
|
|
44
|
+
case "kimi-cli": return join(env.KIMI_CODE_HOME || join(home, ".kimi-code"), "skills");
|
|
45
|
+
case "vscode": return join(home, ".copilot", "skills");
|
|
46
|
+
case "cline": return join(home, ".cline", "skills");
|
|
47
|
+
default: return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function skillsManifestPath(root, installationId) {
|
|
52
|
+
return join(root, `skills-${installationId}.json`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function digest(text) {
|
|
56
|
+
return createHash("sha256").update(text).digest("hex");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeKey(skillKey) {
|
|
60
|
+
return typeof skillKey === "string" && SKILL_KEY.test(skillKey) && !skillKey.includes("..");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function pathState(path) {
|
|
64
|
+
try {
|
|
65
|
+
return await stat(path);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error?.code === "ENOENT") return null;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readText(path) {
|
|
73
|
+
try {
|
|
74
|
+
return await readFile(path, "utf8");
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error?.code === "ENOENT") return null;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Move a managed copy aside with its bytes intact; never `rm`. */
|
|
82
|
+
async function quarantine(skillsRoot, quarantineRoot, skillKey, now) {
|
|
83
|
+
const from = join(skillsRoot, skillKey);
|
|
84
|
+
if (!(await pathState(from))) return null;
|
|
85
|
+
await ensurePrivateDirectory(quarantineRoot);
|
|
86
|
+
const to = join(quarantineRoot, `${skillKey}-${now.toISOString().replace(/[:.]/g, "-")}`);
|
|
87
|
+
await rename(from, to);
|
|
88
|
+
return to;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A folder is "ours" when the manifest lists it, or when its SKILL.md carries
|
|
93
|
+
* the kernel export frontmatter. Anything else is the employee's own skill.
|
|
94
|
+
*/
|
|
95
|
+
async function isManaged(skillsRoot, skillKey, manifestEntry) {
|
|
96
|
+
if (manifestEntry) return true;
|
|
97
|
+
const body = await readText(join(skillsRoot, skillKey, "SKILL.md"));
|
|
98
|
+
return typeof body === "string" && body.startsWith("---\n") && body.includes(`\n${MANAGED_MARKER}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function loadSkillsManifest(root, installationId) {
|
|
102
|
+
const manifest = await readJson(skillsManifestPath(root, installationId), null);
|
|
103
|
+
if (!manifest || manifest.version !== 1 || !Array.isArray(manifest.installs)) {
|
|
104
|
+
return { version: 1, installationId, installs: [], lastCheckinAt: null };
|
|
105
|
+
}
|
|
106
|
+
return manifest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One sync pass. Never throws for server or filesystem trouble on a single
|
|
111
|
+
* skill; the summary names what happened so callers (installer, SessionStart
|
|
112
|
+
* hooks) can report it. A failed check-in leaves installed copies usable
|
|
113
|
+
* until the 7-day staleness allowance runs out, then quarantines them (D37).
|
|
114
|
+
*/
|
|
115
|
+
export async function syncManagedSkills({
|
|
116
|
+
connection,
|
|
117
|
+
transport,
|
|
118
|
+
root,
|
|
119
|
+
home = homedir(),
|
|
120
|
+
env = process.env,
|
|
121
|
+
now = () => new Date(),
|
|
122
|
+
}) {
|
|
123
|
+
const clientKind = connection.clientKind;
|
|
124
|
+
const skillsRoot = managedSkillsDirectory(clientKind, { home, env });
|
|
125
|
+
const summary = {
|
|
126
|
+
supported: skillsRoot !== null,
|
|
127
|
+
skillsRoot,
|
|
128
|
+
checkedIn: false,
|
|
129
|
+
installed: [],
|
|
130
|
+
updated: [],
|
|
131
|
+
quarantined: [],
|
|
132
|
+
conflicts: [],
|
|
133
|
+
unchanged: [],
|
|
134
|
+
errors: [],
|
|
135
|
+
};
|
|
136
|
+
if (!skillsRoot) return summary;
|
|
137
|
+
|
|
138
|
+
const manifestPath = skillsManifestPath(root, connection.installationId);
|
|
139
|
+
const quarantineRoot = join(root, "skills-quarantine", connection.installationId);
|
|
140
|
+
const manifest = await loadSkillsManifest(root, connection.installationId);
|
|
141
|
+
const byId = new Map(manifest.installs.map((entry) => [entry.skillId, entry]));
|
|
142
|
+
const current = now();
|
|
143
|
+
|
|
144
|
+
let response;
|
|
145
|
+
try {
|
|
146
|
+
response = await transport.skillsCheckin(manifest.installs.map(({ skillId, sha }) => ({ skillId, sha })));
|
|
147
|
+
summary.checkedIn = true;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
summary.errors.push({ stage: "checkin", code: error?.code || "runtime_unavailable" });
|
|
150
|
+
const last = manifest.lastCheckinAt ? Date.parse(manifest.lastCheckinAt) : NaN;
|
|
151
|
+
if (Number.isFinite(last) && current.getTime() - last > STALE_AFTER_MS) {
|
|
152
|
+
for (const entry of manifest.installs) {
|
|
153
|
+
try {
|
|
154
|
+
const moved = await quarantine(skillsRoot, quarantineRoot, entry.skillKey, current);
|
|
155
|
+
if (moved) summary.quarantined.push({ skillKey: entry.skillKey, reason: "stale_checkin", to: moved });
|
|
156
|
+
} catch (fsError) {
|
|
157
|
+
summary.errors.push({ stage: "quarantine", skillKey: entry.skillKey, code: fsError?.code || "fs_error" });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
manifest.installs = [];
|
|
161
|
+
await writePrivateFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
162
|
+
}
|
|
163
|
+
return summary;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const wanted = new Map();
|
|
167
|
+
for (const skill of Array.isArray(response?.skills) ? response.skills : []) {
|
|
168
|
+
if (safeKey(skill.skillKey)) wanted.set(skill.skillId, skill);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 1. Withdrawn copies: revoked / unknown verdicts, and anything the manifest
|
|
172
|
+
// lists that the badge no longer resolves.
|
|
173
|
+
const verdicts = new Map((Array.isArray(response?.items) ? response.items : []).map((item) => [item.skillId, item]));
|
|
174
|
+
const survivors = [];
|
|
175
|
+
for (const entry of manifest.installs) {
|
|
176
|
+
const verdict = verdicts.get(entry.skillId);
|
|
177
|
+
const status = verdict?.status;
|
|
178
|
+
const withdrawn = status === "revoked" || status === "unknown" || (!wanted.has(entry.skillId) && status !== "stale");
|
|
179
|
+
if (!withdrawn) { survivors.push(entry); continue; }
|
|
180
|
+
try {
|
|
181
|
+
const moved = await quarantine(skillsRoot, quarantineRoot, entry.skillKey, current);
|
|
182
|
+
summary.quarantined.push({ skillKey: entry.skillKey, reason: status || "not_resolved", to: moved });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
summary.errors.push({ stage: "quarantine", skillKey: entry.skillKey, code: error?.code || "fs_error" });
|
|
185
|
+
survivors.push(entry);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
manifest.installs = survivors;
|
|
189
|
+
|
|
190
|
+
// 2. Missing, stale, or drifted copies: fetch through the governed export
|
|
191
|
+
// gate and write. A stale entry keyed on a superseded row is re-keyed
|
|
192
|
+
// to the row that replaced it.
|
|
193
|
+
for (const entry of manifest.installs) {
|
|
194
|
+
const verdict = verdicts.get(entry.skillId);
|
|
195
|
+
if (verdict?.status === "stale" && verdict.currentSkillId && !wanted.has(entry.skillId)) {
|
|
196
|
+
entry.skillId = verdict.currentSkillId;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const next = [];
|
|
200
|
+
for (const skill of wanted.values()) {
|
|
201
|
+
const entry = manifest.installs.find((candidate) => candidate.skillId === skill.skillId);
|
|
202
|
+
const dir = join(skillsRoot, skill.skillKey);
|
|
203
|
+
const file = join(dir, "SKILL.md");
|
|
204
|
+
const onDisk = await readText(file);
|
|
205
|
+
const intact = entry && typeof onDisk === "string" && digest(onDisk) === entry.fileSha256;
|
|
206
|
+
if (entry && entry.sha === skill.sha && intact) {
|
|
207
|
+
summary.unchanged.push(skill.skillKey);
|
|
208
|
+
next.push({ ...entry, lastCheckinAt: current.toISOString() });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!(await isManaged(skillsRoot, skill.skillKey, entry)) && (await pathState(dir))) {
|
|
212
|
+
summary.conflicts.push({ skillKey: skill.skillKey, reason: "unmanaged_folder" });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const exported = await transport.skillDownload(skill.skillId);
|
|
217
|
+
const content = typeof exported?.content === "string" ? exported.content : null;
|
|
218
|
+
if (!content) throw Object.assign(new Error("skill export was empty"), { code: "empty_export" });
|
|
219
|
+
await mkdir(dir, { recursive: true });
|
|
220
|
+
await writePrivateFile(file, content);
|
|
221
|
+
const record = {
|
|
222
|
+
skillId: skill.skillId,
|
|
223
|
+
skillKey: skill.skillKey,
|
|
224
|
+
sha: skill.sha,
|
|
225
|
+
fileSha256: digest(content),
|
|
226
|
+
namespace: skill.namespace,
|
|
227
|
+
installedAt: entry?.installedAt || current.toISOString(),
|
|
228
|
+
lastCheckinAt: current.toISOString(),
|
|
229
|
+
};
|
|
230
|
+
next.push(record);
|
|
231
|
+
(entry ? summary.updated : summary.installed).push(skill.skillKey);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
summary.errors.push({ stage: "download", skillKey: skill.skillKey, code: error?.code || "runtime_unavailable" });
|
|
234
|
+
if (entry) next.push(entry);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
manifest.installs = next;
|
|
238
|
+
manifest.lastCheckinAt = current.toISOString();
|
|
239
|
+
manifest.clientKind = clientKind;
|
|
240
|
+
await writePrivateFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
241
|
+
|
|
242
|
+
// 3. Acknowledge: a second beacon carrying what is now on disk so the
|
|
243
|
+
// console's "installed on N badges" reflects this pass, not the last.
|
|
244
|
+
if (summary.installed.length > 0 || summary.updated.length > 0 || summary.quarantined.length > 0) {
|
|
245
|
+
try {
|
|
246
|
+
await transport.skillsCheckin(manifest.installs.map(({ skillId, sha }) => ({ skillId, sha })));
|
|
247
|
+
} catch (error) {
|
|
248
|
+
summary.errors.push({ stage: "ack", code: error?.code || "runtime_unavailable" });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return summary;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One-line, content-free summary for installer output and hook stderr. */
|
|
255
|
+
export function describeSkillSync(summary) {
|
|
256
|
+
if (!summary.supported) return "skills: not managed for this host";
|
|
257
|
+
const parts = [];
|
|
258
|
+
if (summary.installed.length) parts.push(`installed ${summary.installed.length}`);
|
|
259
|
+
if (summary.updated.length) parts.push(`updated ${summary.updated.length}`);
|
|
260
|
+
if (summary.unchanged.length) parts.push(`unchanged ${summary.unchanged.length}`);
|
|
261
|
+
if (summary.quarantined.length) parts.push(`quarantined ${summary.quarantined.length}`);
|
|
262
|
+
if (summary.conflicts.length) parts.push(`skipped ${summary.conflicts.length} unmanaged`);
|
|
263
|
+
if (summary.errors.length) parts.push(`${summary.errors.length} error${summary.errors.length === 1 ? "" : "s"}`);
|
|
264
|
+
if (!summary.checkedIn) parts.unshift("check-in unavailable");
|
|
265
|
+
return `skills (${basename(dirname(summary.skillsRoot))}/${basename(summary.skillsRoot)}): ${parts.join(", ") || "nothing to do"}`;
|
|
266
|
+
}
|
package/src/transport.mjs
CHANGED
|
@@ -64,6 +64,13 @@ export class SignedRuntimeTransport {
|
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** Badge skill check-in beacon: exactly the installed {skillId, sha} pairs (D39). */
|
|
68
|
+
skillsCheckin(items) { return this.request("/v1/agent-runtime/skills/checkin", { body: { items } }); }
|
|
69
|
+
/** Governed skill export — the same gate as a console download (D38). */
|
|
70
|
+
skillDownload(skillId) {
|
|
71
|
+
return this.request(`/v1/agent-runtime/skills/${encodeURIComponent(skillId)}/download`, { method: "GET" });
|
|
72
|
+
}
|
|
73
|
+
|
|
67
74
|
openSession(body) { return this.request("/v1/agent-sessions/open", { body }); }
|
|
68
75
|
appendEvents(sessionId, events) { return this.request(`/v1/agent-sessions/${encodeURIComponent(sessionId)}/events`, { body: { events } }); }
|
|
69
76
|
recall(sessionId, body) { return this.request(`/v1/agent-sessions/${encodeURIComponent(sessionId)}/recall`, { body }); }
|
package/src/version.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export const PACKAGE_NAME = "@halofy/agent-connect";
|
|
2
|
-
export const INSTALLER_VERSION = "0.
|
|
3
|
-
export const RUNTIME_VERSION = "0.
|
|
4
|
-
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-
|
|
2
|
+
export const INSTALLER_VERSION = "0.7.0";
|
|
3
|
+
export const RUNTIME_VERSION = "0.7.0";
|
|
4
|
+
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-03.1";
|