@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halofy/agent-connect",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Halofy lifecycle installer and runtime for supported agents; runtime requests are signed with a per-installation Ed25519 key",
6
6
  "bin": {
@@ -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: {
@@ -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, and",
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
- "This does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
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 does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
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.6.0";
3
- export const RUNTIME_VERSION = "0.6.0";
4
- export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-08-31.2";
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";