@halofy/agent-connect 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@ bounded recall block formats stay in place and tested for when it returns.
25
25
  There is one setup path for every packaged client:
26
26
 
27
27
  ```bash
28
- npx --yes @halofy/agent-connect@0.8.0 install <client-kind> \
28
+ npx --yes @halofy/agent-connect@0.9.0 install <client-kind> \
29
29
  --server https://app.halofy.ai \
30
30
  --claim '<one-time-claim>'
31
31
  ```
@@ -128,3 +128,30 @@ heartbeat. Older backends report context unavailable until the matching backend
128
128
  release is deployed. Reconnect to install this runtime on existing connections.
129
129
  Reconnection retires the previous active installation's owned context. Older
130
130
  project hooks consult the active host marker and cannot restore that retired copy.
131
+
132
+
133
+ ## Scoped badge delivery receipts (0.9.0)
134
+
135
+ This version adds signed `/v1/agent-runtime/delivery/check` and
136
+ `/v1/agent-runtime/delivery/receipt` requests. Installation and session-start
137
+ refreshes report a content-free version calculated from the complete activated
138
+ policy/knowledge set and installed skill manifest. Incomplete discovery, local
139
+ conflicts, unavailable downloads and unsupported hosts cannot acknowledge synced.
140
+ Updates and removals use the existing scoped delivery routes and native folders.
141
+
142
+ Supported user-turn hooks check current eligibility before continuing. An
143
+ unchanged version avoids content downloads. A changed version refreshes the
144
+ managed copies and reports `restart_required`; supported hook output includes a
145
+ fixed notice asking for a new session. Cursor's before-submit hook has no reviewed
146
+ context-injection output, so it receives manager-visible receipt status without
147
+ an injected notice. A successful subsequent session-start refresh clears the
148
+ restart requirement. All delivery passes serialize per installation, including
149
+ separate hook processes; replaced installations cannot restore managed copies.
150
+
151
+ Manager statuses are Not connected, Synced, Sync pending and Needs attention.
152
+ Receipts confirm file delivery only, never host loading or policy compliance.
153
+ Older servers retain installation/session-start refresh behavior but cannot
154
+ confirm delivery status. Existing offline skill limits remain; context refresh
155
+ failures withdraw the managed references. These are source capabilities; package
156
+ publication, installed-client upgrades and production deployment require separate
157
+ release verification.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halofy/agent-connect",
3
- "version": "0.8.0",
3
+ "version": "0.9.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": {
package/src/active.mjs CHANGED
@@ -13,3 +13,11 @@ export async function hasLifecycleMarker(clientKind = "claude-code", root = defa
13
13
  const marker = await readJson(join(root, `active-${clientKind}.json`));
14
14
  return Boolean(marker?.installationId && marker.protocolVersion !== "legacy");
15
15
  }
16
+
17
+ /** Missing markers support legacy installs; corrupt/replaced markers fail closed. */
18
+ export async function isActiveInstallation(connection, root) {
19
+ try {
20
+ const marker = await readJson(join(root, `active-${connection.clientKind}.json`));
21
+ return !marker || marker.installationId === connection.installationId;
22
+ } catch { return false; }
23
+ }
@@ -1,53 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
- import { basename, join } from "node:path";
2
+ import { basename } from "node:path";
3
3
  import { LifecycleRuntime } from "./runtime.mjs";
4
4
  import { normalizeClaudeHookEvent, RECALL_INJECTION_ENABLED, rankedRecallBlocks } from "./session.mjs";
5
- import { defaultRuntimeDirectory, readJson } from "./storage.mjs";
6
- import { describeSkillSync, syncManagedSkills } from "./skills-sync.mjs";
7
- import { describeContextSync, syncManagedContext } from "./context-sync.mjs";
5
+ import { defaultRuntimeDirectory } from "./storage.mjs";
6
+ import { syncManagedDelivery } from "./delivery-sync.mjs";
8
7
 
9
- /**
10
- * Badge skills ride the SessionStart heartbeat (D37): check in, repair, and
11
- * quarantine, but never let a skills problem degrade memory recall.
12
- */
8
+ /** Shared delivery refresh remains independent of capture/heartbeat brownouts. */
13
9
  export async function syncSkillsAtSessionStart(runtime, connection, root, stderr, syncOptions = {}) {
14
- let active;
15
- try {
16
- active = await readJson(join(root, `active-${connection.clientKind}.json`));
17
- } catch {
18
- active = { installationId: null };
19
- }
20
- // Replaced project hooks may still execute. They can retire their copy but
21
- // cannot restore content after this host's active installation changed.
22
- if (active && active.installationId !== connection.installationId) {
23
- const retired = await syncManagedContext({ connection, root, ...syncOptions,
24
- transport: { contextPage: async () => ({ items: [], nextCursor: null }) },
25
- });
26
- stderr.write(`[halofy] inactive installation context: ${describeContextSync(retired)}\n`);
27
- return null;
28
- }
29
- try {
30
- const context = await syncManagedContext({ connection, transport: runtime.transport, root, ...syncOptions,
31
- canActivate: async () => {
32
- const marker = await readJson(join(root, `active-${connection.clientKind}.json`));
33
- return !marker || marker.installationId === connection.installationId;
34
- },
35
- });
36
- stderr.write(`[halofy] ${describeContextSync(context)}\n`);
37
- } catch (error) {
38
- stderr.write(`[halofy] policy and knowledge sync degraded: ${error?.code || "runtime_unavailable"}\n`);
39
- }
40
- try {
41
- const summary = await syncManagedSkills({ connection, transport: runtime.transport, root, ...syncOptions });
42
- if (summary.supported && (summary.installed.length || summary.updated.length ||
43
- summary.quarantined.length || summary.errors.length)) {
44
- stderr.write(`[halofy] ${describeSkillSync(summary)}\n`);
45
- }
46
- return summary;
47
- } catch (error) {
48
- stderr.write(`[halofy] skill sync degraded: ${error?.code || "runtime_unavailable"}\n`);
49
- return null;
50
- }
10
+ const result = await syncManagedDelivery({ ...syncOptions, connection, transport: runtime.transport, root, stderr, phase: "start" });
11
+ return result.skills;
51
12
  }
52
13
 
53
14
  function id(value) {
@@ -87,13 +48,13 @@ function childSession(input) {
87
48
  return child ? `${parent}:subagent:${child}` : parent;
88
49
  }
89
50
 
90
- function recallText(result, eventName) {
51
+ function recallText(result, eventName, notice) {
91
52
  const rankedBlocks = rankedRecallBlocks(result);
92
- if (rankedBlocks.length === 0) return null;
53
+ if (rankedBlocks.length === 0 && !notice) return null;
93
54
  return JSON.stringify({
94
55
  hookSpecificOutput: {
95
56
  hookEventName: eventName,
96
- additionalContext: `<<<HALOFY_CONTEXT_BLOCKS_V1>>>\n${JSON.stringify({ rankedBlocks })}\n<<<END_HALOFY_CONTEXT_BLOCKS_V1>>>`,
57
+ additionalContext: [notice, rankedBlocks.length ? `<<<HALOFY_CONTEXT_BLOCKS_V1>>>\n${JSON.stringify({ rankedBlocks })}\n<<<END_HALOFY_CONTEXT_BLOCKS_V1>>>` : null].filter(Boolean).join("\n\n"),
97
58
  },
98
59
  });
99
60
  }
@@ -147,14 +108,14 @@ export async function runClaudeLifecycleHook(connection, eventName, {
147
108
  if (output) stdout.write(output);
148
109
  }
149
110
  } else if (eventName === "UserPromptSubmit") {
111
+ const delivery = await syncManagedDelivery({ connection, transport: runtime.transport, root, home, env, stderr, phase: "turn" });
112
+ let recalled = null;
150
113
  if (RECALL_INJECTION_ENABLED) {
151
114
  const prompt = String(hookInput.prompt || hookInput.user_prompt || "").slice(0, 8_000);
152
- if (prompt.trim()) {
153
- const recalled = await runtime.recall(session, prompt);
154
- const output = recallText(recalled, "UserPromptSubmit");
155
- if (output) stdout.write(output);
156
- }
115
+ if (prompt.trim()) recalled = await runtime.recall(session, prompt);
157
116
  }
117
+ const output = recallText(recalled, "UserPromptSubmit", delivery.notice);
118
+ if (output) stdout.write(output);
158
119
  } else if (eventName === "Stop") {
159
120
  if (!hookInput.stop_hook_active) {
160
121
  await catchUp(runtime, hookInput);
@@ -2,7 +2,9 @@ import { constants } from "node:fs";
2
2
  import { chmod, link, lstat, mkdir, open, readdir, rename, rm } from "node:fs/promises";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
- import { dirname, join, parse, resolve } from "node:path";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { safeDirectory } from "./storage.mjs";
7
+ export { safeDirectory } from "./storage.mjs";
6
8
  import { managedSkillsDirectory } from "./skills-sync.mjs";
7
9
 
8
10
  // A bounded client must report incomplete rather than silently export a prefix.
@@ -23,24 +25,7 @@ async function state(path) {
23
25
  try { return await lstat(path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
24
26
  }
25
27
 
26
- // Check every ancestor, including host home overrides. Do not chmod existing
27
- // employee directories; only newly created and Halofy-owned directories are private.
28
- async function safeDirectory(path, create = false) {
29
- const absolute = resolve(path);
30
- let current = parse(absolute).root;
31
- for (const part of absolute.slice(current.length).split(/[\\/]/).filter(Boolean)) {
32
- current = join(current, part);
33
- let entry = await state(current);
34
- if (!entry && create) {
35
- try { await mkdir(current, { mode: 0o700 }); } catch (error) { if (error?.code !== "EEXIST") throw error; }
36
- entry = await state(current);
37
- }
38
- if (!entry) throw fail("directory_missing");
39
- if (entry.isSymbolicLink() || !entry.isDirectory()) throw fail("unsafe_directory");
40
- }
41
- }
42
-
43
- async function readPrivateJson(path) {
28
+ export async function readPrivateJson(path) {
44
29
  const entry = await state(path);
45
30
  if (!entry) return null;
46
31
  if (!entry.isFile() || entry.isSymbolicLink() || entry.size > 64 * 1024 * 1024) throw fail("unsafe_manifest");
@@ -59,7 +44,7 @@ async function saveManifest(stateRoot, value) {
59
44
  await rename(temporary, join(stateRoot, "manifest.json"));
60
45
  }
61
46
 
62
- async function withLock(stateRoot, action) {
47
+ export async function withContextLock(stateRoot, action) {
63
48
  const lockPath = join(stateRoot, "refresh.lock");
64
49
  const candidate = join(stateRoot, `lock-${process.pid}-${randomBytes(12).toString("hex")}.tmp`);
65
50
  await privateFile(candidate, JSON.stringify({ pid: process.pid }));
@@ -121,9 +106,11 @@ async function validateTree(target, manifest) {
121
106
  if (!manifest?.files || !manifest.fileDigests) throw fail("incomplete_context_manifest");
122
107
  const directories = new Set(["references", "references/policy", "references/knowledge"]);
123
108
  const expected = new Set([OWNER, "SKILL.md", "references", "references/policy", "references/knowledge", ...manifest.files]);
109
+ const seen = new Set();
124
110
  async function visit(path, relative = "") {
125
111
  for (const name of await readdir(path)) {
126
112
  const rel = relative ? `${relative}/${name}` : name;
113
+ seen.add(rel);
127
114
  const entry = await state(join(path, name));
128
115
  if (!entry || entry.isSymbolicLink()) throw fail("unsafe_context_entry");
129
116
  if (!expected.has(rel)) throw fail("unmanaged_context_entry");
@@ -143,6 +130,7 @@ async function validateTree(target, manifest) {
143
130
  }
144
131
  }
145
132
  await visit(target);
133
+ if ([...expected].some((name) => !seen.has(name))) throw fail("missing_context_entry");
146
134
  }
147
135
 
148
136
  function validateItem(item) {
@@ -202,7 +190,7 @@ export async function syncManagedContext({ connection, transport, root: _root, h
202
190
  }
203
191
  // Existing state is never mutated until its explicit ownership is verified.
204
192
  await chmod(stateRoot, 0o700);
205
- await withLock(stateRoot, async () => {
193
+ await withContextLock(stateRoot, async () => {
206
194
  let manifest;
207
195
  let stage;
208
196
  const withdraw = async () => {
@@ -235,6 +223,7 @@ export async function syncManagedContext({ connection, transport, root: _root, h
235
223
  const ids = new Set();
236
224
  const cursors = new Set();
237
225
  const files = [];
226
+ const tuples = [];
238
227
  const fileDigests = {};
239
228
  let bytes = 0;
240
229
  let cursor;
@@ -253,6 +242,7 @@ export async function syncManagedContext({ connection, transport, root: _root, h
253
242
  const file = `references/${item.kind}/${item.id}.md`;
254
243
  await privateFile(join(stage, file), text);
255
244
  files.push(file);
245
+ tuples.push([item.kind, item.id, item.namespace, item.title, item.enforcement || "", item.sha256]);
256
246
  fileDigests[file] = hash(text);
257
247
  if (item.kind === "policy") summary.policies += 1; else summary.knowledge += 1;
258
248
  }
@@ -266,7 +256,7 @@ export async function syncManagedContext({ connection, transport, root: _root, h
266
256
  fileDigests["SKILL.md"] = hash(skillText);
267
257
  await privateFile(join(stage, OWNER), JSON.stringify({ version: 1, token, installationId: connection.installationId }));
268
258
  // Save ownership before activation so a crash cannot orphan a new copy.
269
- const next = { version: 1, token, installationId: connection.installationId, files, fileDigests, refreshedAt: now().toISOString() };
259
+ const next = { version: 1, token, installationId: connection.installationId, files, fileDigests, tuples, refreshedAt: now().toISOString() };
270
260
  await safeDirectory(skillsRoot);
271
261
  await safeDirectory(stateRoot);
272
262
  if (await state(target)) {
@@ -281,6 +271,7 @@ export async function syncManagedContext({ connection, transport, root: _root, h
281
271
  else await rm(stage, { recursive: true, force: true });
282
272
  stage = null;
283
273
  summary.complete = true;
274
+ Object.defineProperty(summary, "tuples", { value: tuples });
284
275
  } catch (error) {
285
276
  summary.errors.push({ stage: "refresh", code: error?.code || "context_unavailable" });
286
277
  try { await withdraw(); }
@@ -301,3 +292,24 @@ export function describeContextSync(summary) {
301
292
  if (!summary.complete) return `policies and knowledge: incomplete (${summary.errors.map((entry) => entry.code).join(", ") || "unavailable"})${summary.removed ? "; previous reference copy removed from discovery" : ""}`;
302
293
  return `policies and knowledge: refreshed ${summary.policies} policies, ${summary.knowledge} knowledge references`;
303
294
  }
295
+
296
+ /** Verify the active copy before reusing a prior receipt without downloads. */
297
+ export async function readManagedContextTuples({ connection, home = homedir(), env = process.env }) {
298
+ const skillsRoot = managedSkillsDirectory(connection.clientKind, { home, env });
299
+ if (!skillsRoot) throw fail("unsupported_host");
300
+ const name = managedContextName(connection.installationId);
301
+ const stateRoot = join(dirname(skillsRoot), `.${name}-state`);
302
+ const target = join(skillsRoot, name);
303
+ await safeDirectory(stateRoot);
304
+ const manifest = await readPrivateJson(join(stateRoot, "manifest.json"));
305
+ const owner = await readPrivateJson(join(stateRoot, STATE_OWNER));
306
+ if (manifest?.version !== 1 || manifest.installationId !== connection.installationId
307
+ || manifest.token !== owner?.token || !Array.isArray(manifest.tuples)
308
+ || !Array.isArray(manifest.files) || manifest.files.length !== manifest.tuples.length) throw fail("incomplete_context_manifest");
309
+ await safeDirectory(skillsRoot);
310
+ if (manifest.files.length) {
311
+ if (!(await owned(target, owner.token, connection.installationId))) throw fail("missing_context_entry");
312
+ await validateTree(target, manifest);
313
+ } else if (await state(target)) throw fail("unexpected_context_entry");
314
+ return manifest.tuples;
315
+ }
@@ -0,0 +1,108 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { isActiveInstallation } from "./active.mjs";
4
+ import { safeDirectory, readPrivateJson, withContextLock, readManagedContextTuples, syncManagedContext, describeContextSync } from "./context-sync.mjs";
5
+ import { syncManagedSkills, readManagedSkillTuples, describeSkillSync } from "./skills-sync.mjs";
6
+ import { retirePredecessorContent } from "./predecessor-sync.mjs";
7
+ import { writePrivateFile } from "./storage.mjs";
8
+
9
+ export const DELIVERY_NOTICE = "Halofy managed policies, skills, or knowledge references changed during this conversation. Start a new session to load the current references; previously loaded content may be outdated. File delivery does not confirm policy compliance.";
10
+ const SHA = /^[a-f0-9]{64}$/;
11
+ const ID = /^[A-Za-z0-9_-]{1,160}$/;
12
+
13
+ /** Contract hash: lexical serialized tuples, no locale-dependent ordering. */
14
+ export function deliveryVersion(context, skills) {
15
+ const sort = (tuples) => [...tuples].sort((a, b) => {
16
+ const left = JSON.stringify(a), right = JSON.stringify(b);
17
+ return left < right ? -1 : left > right ? 1 : 0;
18
+ });
19
+ return createHash("sha256").update(JSON.stringify({ context: sort(context), skills: sort(skills) })).digest("hex");
20
+ }
21
+
22
+ /** Serializes the whole check/download/receipt, across concurrent hook processes.
23
+ * A dead process lock can be recovered; a live writer is never timed out/stolen. */
24
+ export async function syncManagedDelivery({ connection, transport, root, phase = "start", stderr = process.stderr,
25
+ contextSync = syncManagedContext, ...options }) {
26
+ let result = { outcome: "failed", acknowledged: false, changed: false, context: null, skills: null };
27
+ try {
28
+ if (phase === "turn" && typeof transport?.deliveryCheck !== "function") return { ...result, reason: "upgrade_required" };
29
+ if (typeof connection.installationId !== "string" || !ID.test(connection.installationId)) throw new Error("invalid installation");
30
+ const stateRoot = join(root, `delivery-${connection.installationId}`);
31
+ await safeDirectory(stateRoot, true);
32
+ return await withContextLock(stateRoot, async () => {
33
+ const active = () => isActiveInstallation(connection, root);
34
+ if (!(await active())) {
35
+ const retired = await syncManagedContext({ ...options, connection, root,
36
+ transport: { contextPage: async () => ({ items: [], nextCursor: null }) } });
37
+ stderr.write(`[halofy] inactive installation context: ${describeContextSync(retired)}\n`);
38
+ return { ...result, reason: "inactive_installation" };
39
+ }
40
+ let check = null;
41
+ try {
42
+ check = await transport.deliveryCheck(phase === "turn" ? {} : { refresh: true });
43
+ if (!check || !SHA.test(check.version) || typeof check.syncRequired !== "boolean"
44
+ || (check.syncRequired ? typeof check.attemptId !== "string" || !ID.test(check.attemptId) : check.attemptId !== null)) throw new Error("invalid check");
45
+ } catch (error) {
46
+ if (phase === "turn" && error?.status === 404) return { ...result, reason: "upgrade_required" };
47
+ check = null;
48
+ stderr.write("[halofy] delivery status unavailable; continuing existing reference refresh\n");
49
+ }
50
+ let predecessorError = false;
51
+ try { await retirePredecessorContent({ ...options, connection, root }); }
52
+ catch { predecessorError = true; }
53
+ let previous = null;
54
+ try { previous = await readPrivateJson(join(stateRoot, "receipt.json")); }
55
+ catch { /* Local receipt cache damage cannot prevent a fresh sync. */ }
56
+ if (phase === "turn" && check && !predecessorError) {
57
+ let installedVersion = null;
58
+ try {
59
+ installedVersion = deliveryVersion(
60
+ await readManagedContextTuples({ ...options, connection }),
61
+ await readManagedSkillTuples({ ...options, connection, root }));
62
+ } catch { /* Missing or modified local copies require repair. */ }
63
+ if (installedVersion === check.version && await active()) {
64
+ if (!check.syncRequired) return previous?.version === installedVersion && previous.outcome === "restart_required"
65
+ ? { ...result, outcome: "restart_required", unchanged: true, notice: DELIVERY_NOTICE }
66
+ : { ...result, outcome: "synced", unchanged: true };
67
+ if (previous?.version === installedVersion && previous.outcome === "restart_required") {
68
+ await transport.deliveryReceipt({ attemptId: check.attemptId, version: installedVersion, outcome: "restart_required" });
69
+ return { ...result, outcome: "restart_required", acknowledged: true, unchanged: true, notice: DELIVERY_NOTICE };
70
+ }
71
+ }
72
+ if (!check.syncRequired) check = await transport.deliveryCheck({ refresh: true });
73
+ }
74
+ let context, skills;
75
+ try {
76
+ if (predecessorError) throw new Error("predecessor cleanup incomplete");
77
+ context = await contextSync({ ...options, connection, root, transport, canActivate: active });
78
+ }
79
+ catch { context = { supported: true, complete: false, errors: [{ code: "context_unavailable" }] }; }
80
+ stderr.write(`[halofy] ${describeContextSync(context)}\n`);
81
+ try {
82
+ if (predecessorError) throw new Error("predecessor cleanup incomplete");
83
+ skills = await syncManagedSkills({ ...options, connection, root, transport });
84
+ }
85
+ catch { skills = { supported: true, complete: false, checkedIn: false, installed: [], updated: [],
86
+ quarantined: [], conflicts: [], unchanged: [], errors: [{ code: "storage_unavailable" }] }; }
87
+ if (skills.skillsRoot) stderr.write(`[halofy] ${describeSkillSync(skills)}\n`);
88
+ const complete = context.complete && skills.complete && Array.isArray(context.tuples)
89
+ && Array.isArray(skills.tuples) && await active();
90
+ const version = complete ? deliveryVersion(context.tuples, skills.tuples) : deliveryVersion([], []);
91
+ const changed = complete && previous?.version !== version;
92
+ const outcome = !complete ? "failed" : phase === "turn" ? "restart_required" : "synced";
93
+ result = { ...result, context, skills, outcome, changed,
94
+ ...(outcome === "restart_required" ? { notice: DELIVERY_NOTICE } : {}) };
95
+ // Fresh discovery may race a manager update: server compares the computed
96
+ // activated version against this attempt and current eligibility again.
97
+ if (check?.attemptId) {
98
+ await transport.deliveryReceipt({ attemptId: check.attemptId, version, outcome });
99
+ result.acknowledged = true;
100
+ }
101
+ if (complete) await writePrivateFile(join(stateRoot, "receipt.json"), JSON.stringify({ version, outcome }));
102
+ return result;
103
+ });
104
+ } catch {
105
+ stderr.write("[halofy] delivery sync degraded: unavailable\n");
106
+ return { ...result, acknowledged: false, reason: "unavailable" };
107
+ }
108
+ }
package/src/host-hook.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  } from "./session.mjs";
10
10
  import { defaultRuntimeDirectory } from "./storage.mjs";
11
11
  import { readHookInput, syncSkillsAtSessionStart } from "./claude-hook.mjs";
12
+ import { syncManagedDelivery } from "./delivery-sync.mjs";
12
13
  import { transcriptDriverFor } from "./transcript-drivers/index.mjs";
13
14
 
14
15
  const USER_EVENTS = new Set(["UserPromptSubmit", "beforeSubmitPrompt", "BeforeAgent", "pre_llm_call"]);
@@ -68,10 +69,10 @@ function toolOutput(input) {
68
69
  input.extra?.result ?? input.extra?.error_message;
69
70
  }
70
71
 
71
- function recallOutput(clientKind, eventName, result) {
72
+ function recallOutput(clientKind, eventName, result, notice) {
72
73
  const rankedBlocks = rankedRecallBlocks(result);
73
- if (rankedBlocks.length === 0) return null;
74
- const additionalContext = `<<<HALOFY_CONTEXT_BLOCKS_V1>>>\n${JSON.stringify({ rankedBlocks })}\n<<<END_HALOFY_CONTEXT_BLOCKS_V1>>>`;
74
+ if (rankedBlocks.length === 0 && !notice) return null;
75
+ const additionalContext = [notice, rankedBlocks.length ? `<<<HALOFY_CONTEXT_BLOCKS_V1>>>\n${JSON.stringify({ rankedBlocks })}\n<<<END_HALOFY_CONTEXT_BLOCKS_V1>>>` : null].filter(Boolean).join("\n\n");
75
76
  if (clientKind === "cursor") {
76
77
  return START_EVENTS.has(eventName) ? JSON.stringify({ additional_context: additionalContext }) : null;
77
78
  }
@@ -174,14 +175,16 @@ export async function runHostLifecycleHook(connection, eventName, {
174
175
  // transcript driver rely on this enqueue to reach the archive, and
175
176
  // hook-sourced events stay authoritative for messages even where a
176
177
  // driver adds usage/metadata evidence.
178
+ const delivery = await syncManagedDelivery({ connection, transport: runtime.transport, root, home, env, stderr, phase: "turn" });
177
179
  const prompt = promptText(hookInput);
180
+ let recalled = null;
178
181
  await enqueueMessage(runtime, connection, session, "user", prompt, eventName, hookInput);
179
182
  if (RECALL_INJECTION_ENABLED && connection.clientKind !== "cursor" &&
180
183
  typeof prompt === "string" && prompt.trim()) {
181
- const recalled = await runtime.recall(session, prompt.slice(0, 8_000));
182
- const output = recallOutput(connection.clientKind, eventName, recalled);
183
- if (output) stdout.write(output);
184
+ recalled = await runtime.recall(session, prompt.slice(0, 8_000));
184
185
  }
186
+ const output = recallOutput(connection.clientKind, eventName, recalled, delivery.notice);
187
+ if (output) stdout.write(output);
185
188
  } else if (ASSISTANT_EVENTS.has(eventName)) {
186
189
  await enqueueMessage(runtime, connection, session, "assistant", assistantText(hookInput), eventName, hookInput);
187
190
  await runtime.commitIfThreshold(session);
package/src/install.mjs CHANGED
@@ -8,6 +8,8 @@ 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
10
  import { syncManagedSkills } from "./skills-sync.mjs";
11
+ import { syncManagedDelivery } from "./delivery-sync.mjs";
12
+ import { retirePredecessorContent } from "./predecessor-sync.mjs";
11
13
  import { syncManagedContext } from "./context-sync.mjs";
12
14
 
13
15
  export const CLAUDE_CAPABILITIES = CLIENT_REGISTRY["claude-code"].capabilities;
@@ -294,32 +296,14 @@ export async function syncInstalledSkills({
294
296
 
295
297
  /** Refresh the installation's policy and knowledge references. */
296
298
  export async function syncInstalledContext({
297
- installationId, root = defaultRuntimeDirectory(), fetchImpl = globalThis.fetch, home, env,
299
+ installationId, root = defaultRuntimeDirectory(), fetchImpl = globalThis.fetch, home, env, transport,
298
300
  }) {
299
301
  const store = new ConnectionStore(root);
300
302
  const connection = await store.load(installationId);
301
303
  const options = { root, ...(home ? { home } : {}), ...(env ? { env } : {}) };
302
- const visited = new Set([installationId]);
303
- let previous = connection.previousInstallationId;
304
- while (previous) {
305
- if (typeof previous !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(previous) ||
306
- visited.has(previous) || visited.size > 64) {
307
- throw Object.assign(new Error("invalid context predecessor chain"), { code: "context_predecessor_invalid" });
308
- }
309
- visited.add(previous);
310
- const prior = await readJson(store.path(previous));
311
- if (prior && prior.clientKind !== connection.clientKind) {
312
- throw Object.assign(new Error("context predecessor host mismatch"), { code: "context_predecessor_invalid" });
313
- }
314
- const retired = await syncManagedContext({
315
- ...options, connection: { installationId: previous, clientKind: connection.clientKind },
316
- transport: { contextPage: async () => ({ items: [], nextCursor: null }) },
317
- });
318
- if (!retired.complete && !retired.removed) return retired;
319
- previous = prior?.previousInstallationId;
320
- }
304
+ await retirePredecessorContent({ ...options, connection });
321
305
  return syncManagedContext({
322
- ...options, connection, transport: new SignedRuntimeTransport(connection, { fetchImpl }),
306
+ ...options, connection, transport: transport || new SignedRuntimeTransport(connection, { fetchImpl }),
323
307
  canActivate: async () => {
324
308
  const marker = await readJson(join(root, `active-${connection.clientKind}.json`));
325
309
  return !marker || marker.installationId === installationId;
@@ -327,6 +311,13 @@ export async function syncInstalledContext({
327
311
  });
328
312
  }
329
313
 
314
+ /** Installer uses the same complete, serialized delivery attempt as hooks. */
315
+ export async function syncInstalledDelivery({ installationId, root = defaultRuntimeDirectory(), fetchImpl = globalThis.fetch, home, env, stderr }) {
316
+ const connection = await new ConnectionStore(root).load(installationId);
317
+ const transport = new SignedRuntimeTransport(connection, { fetchImpl });
318
+ return syncManagedDelivery({ connection, root, home, env, transport, stderr, phase: "install" });
319
+ }
320
+
330
321
  export function localMcpSnippet({ nodePath = process.execPath, proxyPath, installationId }) {
331
322
  return {
332
323
  mcpServers: {
@@ -10,16 +10,14 @@ import {
10
10
  installLocalConnection,
11
11
  installRuntimeBundle,
12
12
  localMcpSnippet,
13
- syncInstalledSkills,
14
- syncInstalledContext,
13
+ syncInstalledDelivery,
15
14
  } from "./install.mjs";
16
15
  import { configureClaudeProject } from "./claude-config.mjs";
17
16
  import { configureCline, configureCodex, configureCursor, configureGemini, configureKimi, configureVscode } from "./host-config.mjs";
18
17
  import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
19
18
  import { defaultRuntimeDirectory } from "./storage.mjs";
20
19
  import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
21
- import { describeSkillSync, managedSkillsDirectory } from "./skills-sync.mjs";
22
- import { describeContextSync } from "./context-sync.mjs";
20
+ import { managedSkillsDirectory } from "./skills-sync.mjs";
23
21
 
24
22
  const CLAIM_PATTERN = /^hsc_[A-Za-z0-9_-]{43}$/;
25
23
 
@@ -371,18 +369,8 @@ async function runAllInstaller(input, {
371
369
  } catch {
372
370
  heartbeat = false;
373
371
  }
374
- // Skills land during the sweep too; a skills problem is reported per
375
- // host, never fatal to the install.
376
- let skills = null;
377
- try {
378
- skills = await syncInstalledSkills({
379
- installationId: installed.installationId, root, fetchImpl,
380
- ...(skillsHome ? { home: skillsHome } : {}),
381
- });
382
- } catch {
383
- skills = null;
384
- }
385
- const context = await syncContextForInstall({ installationId: installed.installationId, root, fetchImpl, skillsHome, output });
372
+ const delivery = await syncDeliveryForInstall({ installationId: installed.installationId, root, fetchImpl, skillsHome, output });
373
+ const { skills, context } = delivery;
386
374
  results.push({
387
375
  context,
388
376
  clientKind: host.clientKind,
@@ -479,20 +467,8 @@ export async function runInstaller(argv, {
479
467
  } catch {
480
468
  heartbeat = false;
481
469
  }
482
- // Skills land now, not at the next session: the badge already resolves the
483
- // team + org set server-side, so the folder is populated before the user
484
- // restarts the host. A skills problem is reported, never fatal.
485
- let skills = null;
486
- try {
487
- skills = await syncInstalledSkills({
488
- installationId: installed.installationId, root, fetchImpl, ...(skillsHome ? { home: skillsHome } : {}),
489
- });
490
- output.write(`${describeSkillSync(skills)}\n`);
491
- } catch (error) {
492
- skills = null;
493
- output.write(`skills: unavailable (${error?.code || "runtime_unavailable"})\n`);
494
- }
495
- const context = await syncContextForInstall({ installationId: installed.installationId, root, fetchImpl, skillsHome, output });
470
+ const delivery = await syncDeliveryForInstall({ installationId: installed.installationId, root, fetchImpl, skillsHome, output });
471
+ const { skills, context } = delivery;
496
472
  return {
497
473
  context,
498
474
  status: heartbeat ? "configured_heartbeat_verified" : "configured_heartbeat_unavailable",
@@ -516,15 +492,13 @@ export async function runInstaller(argv, {
516
492
  };
517
493
  }
518
494
 
519
- async function syncContextForInstall({ installationId, root, fetchImpl, skillsHome, output }) {
495
+ async function syncDeliveryForInstall({ installationId, root, fetchImpl, skillsHome, output }) {
520
496
  try {
521
- const summary = await syncInstalledContext({
522
- installationId, root, fetchImpl, ...(skillsHome ? { home: skillsHome } : {}),
497
+ return await syncInstalledDelivery({
498
+ installationId, root, fetchImpl, ...(skillsHome ? { home: skillsHome } : {}), stderr: output,
523
499
  });
524
- output.write(`${describeContextSync(summary)}\n`);
525
- return summary;
526
- } catch (error) {
527
- output.write(`policies and knowledge: unavailable (${error?.code || "runtime_unavailable"})\n`);
528
- return { complete: false, errors: [{ code: error?.code || "runtime_unavailable" }] };
500
+ } catch {
501
+ output.write("delivery: unavailable\n");
502
+ return { skills: null, context: { complete: false, errors: [{ code: "runtime_unavailable" }] } };
529
503
  }
530
504
  }
@@ -0,0 +1,33 @@
1
+ import { join } from "node:path";
2
+ import { ConnectionStore, readJson, safeDirectory } from "./storage.mjs";
3
+ import { syncManagedContext, withContextLock } from "./context-sync.mjs";
4
+ import { retireManagedSkills } from "./skills-sync.mjs";
5
+
6
+ /** Every delivery attempt must finish reconnect cleanup, including hook retries
7
+ * after an interrupted installer. This obligation travels in the connection. */
8
+ export async function retirePredecessorContent({ connection, root, ...options }) {
9
+ const store = new ConnectionStore(root);
10
+ const visited = new Set([connection.installationId]);
11
+ let previous = connection.previousInstallationId;
12
+ while (previous) {
13
+ if (typeof previous !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(previous)
14
+ || visited.has(previous) || visited.size > 64) {
15
+ throw Object.assign(new Error("invalid context predecessor chain"), { code: "context_predecessor_invalid" });
16
+ }
17
+ visited.add(previous);
18
+ const prior = await readJson(store.path(previous));
19
+ if (prior && prior.clientKind !== connection.clientKind) {
20
+ throw Object.assign(new Error("context predecessor host mismatch"), { code: "context_predecessor_invalid" });
21
+ }
22
+ const priorConnection = { installationId: previous, clientKind: connection.clientKind };
23
+ const priorState = join(root, `delivery-${previous}`);
24
+ await safeDirectory(priorState, true);
25
+ await withContextLock(priorState, async () => {
26
+ const retired = await syncManagedContext({ ...options, root, connection: priorConnection,
27
+ transport: { contextPage: async () => ({ items: [], nextCursor: null }) } });
28
+ if (!retired.complete && !retired.removed) throw Object.assign(new Error("predecessor cleanup incomplete"), { code: "context_predecessor_incomplete" });
29
+ await retireManagedSkills({ ...options, root, connection: priorConnection });
30
+ });
31
+ previous = prior?.previousInstallationId;
32
+ }
33
+ }
@@ -1,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, rename, stat } from "node:fs/promises";
2
+ import { mkdir, readFile, rename, lstat } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, join } from "node:path";
5
- import { ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
5
+ import { isActiveInstallation } from "./active.mjs";
6
+ import { ensurePrivateDirectory, readJson, safeDirectory, writePrivateFile } from "./storage.mjs";
6
7
 
7
8
  /**
8
9
  * Badge-delivered skills (CONTRACTS-S16-SKILL-TRACKING D37–D39 on the signed
@@ -62,7 +63,7 @@ function safeKey(skillKey) {
62
63
 
63
64
  async function pathState(path) {
64
65
  try {
65
- return await stat(path);
66
+ return await lstat(path);
66
67
  } catch (error) {
67
68
  if (error?.code === "ENOENT") return null;
68
69
  throw error;
@@ -80,6 +81,9 @@ async function readText(path) {
80
81
 
81
82
  /** Move a managed copy aside with its bytes intact; never `rm`. */
82
83
  async function quarantine(skillsRoot, quarantineRoot, skillKey, now) {
84
+ if (!safeKey(skillKey)) throw new Error("invalid managed skill key");
85
+ await safeDirectory(skillsRoot);
86
+ await safeDirectory(quarantineRoot, true);
83
87
  const from = join(skillsRoot, skillKey);
84
88
  if (!(await pathState(from))) return null;
85
89
  await ensurePrivateDirectory(quarantineRoot);
@@ -99,10 +103,15 @@ async function isManaged(skillsRoot, skillKey, manifestEntry) {
99
103
  }
100
104
 
101
105
  export async function loadSkillsManifest(root, installationId) {
106
+ if (typeof installationId !== "string" || !/^[A-Za-z0-9_-]{1,160}$/.test(installationId)) throw new Error("invalid installation");
107
+ await safeDirectory(root, true);
108
+ const manifestFile = await pathState(skillsManifestPath(root, installationId));
109
+ if (manifestFile && (!manifestFile.isFile() || manifestFile.isSymbolicLink())) throw new Error("unsafe skill manifest");
102
110
  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
- }
111
+ if (!manifestFile) return { version: 1, installationId, installs: [], lastCheckinAt: null };
112
+ if (!manifest || manifest.version !== 1 || !Array.isArray(manifest.installs)) throw new Error("invalid skill manifest");
113
+ if (manifest.installationId !== installationId || manifest.installs.some((entry) =>
114
+ !safeKey(entry?.skillKey) || typeof entry.skillId !== "string" || !/^[a-f0-9]{64}$/.test(entry.sha))) throw new Error("invalid skill manifest");
106
115
  return manifest;
107
116
  }
108
117
 
@@ -126,6 +135,7 @@ export async function syncManagedSkills({
126
135
  supported: skillsRoot !== null,
127
136
  skillsRoot,
128
137
  checkedIn: false,
138
+ complete: false,
129
139
  installed: [],
130
140
  updated: [],
131
141
  quarantined: [],
@@ -134,30 +144,46 @@ export async function syncManagedSkills({
134
144
  errors: [],
135
145
  };
136
146
  if (!skillsRoot) return summary;
147
+ await safeDirectory(skillsRoot, true);
137
148
 
138
149
  const manifestPath = skillsManifestPath(root, connection.installationId);
139
150
  const quarantineRoot = join(root, "skills-quarantine", connection.installationId);
140
151
  const manifest = await loadSkillsManifest(root, connection.installationId);
141
- const byId = new Map(manifest.installs.map((entry) => [entry.skillId, entry]));
142
152
  const current = now();
143
153
 
154
+ const active = () => isActiveInstallation(connection, root);
155
+ if (!(await active())) {
156
+ summary.errors.push({ stage: "checkin", code: "inactive_installation" });
157
+ return summary;
158
+ }
144
159
  let response;
145
160
  try {
146
161
  response = await transport.skillsCheckin(manifest.installs.map(({ skillId, sha }) => ({ skillId, sha })));
162
+ if (!response || !Array.isArray(response.skills) || !Array.isArray(response.items)
163
+ || response.skills.some((skill) => !safeKey(skill?.skillKey) || typeof skill.skillId !== "string"
164
+ || !/^[a-f0-9]{64}$/.test(skill.sha))
165
+ || new Set(response.skills.map((skill) => skill.skillId)).size !== response.skills.length
166
+ || new Set(response.skills.map((skill) => skill.skillKey)).size !== response.skills.length) {
167
+ throw Object.assign(new Error("invalid skill discovery"), { code: "invalid_skill_discovery" });
168
+ }
169
+ if (!(await active())) throw Object.assign(new Error("inactive installation"), { code: "inactive_installation" });
147
170
  summary.checkedIn = true;
148
171
  } catch (error) {
149
172
  summary.errors.push({ stage: "checkin", code: error?.code || "runtime_unavailable" });
173
+ if (error?.code === "inactive_installation") return summary;
150
174
  const last = manifest.lastCheckinAt ? Date.parse(manifest.lastCheckinAt) : NaN;
151
175
  if (Number.isFinite(last) && current.getTime() - last > STALE_AFTER_MS) {
176
+ const failedWithdrawals = [];
152
177
  for (const entry of manifest.installs) {
153
178
  try {
154
179
  const moved = await quarantine(skillsRoot, quarantineRoot, entry.skillKey, current);
155
180
  if (moved) summary.quarantined.push({ skillKey: entry.skillKey, reason: "stale_checkin", to: moved });
156
181
  } catch (fsError) {
182
+ failedWithdrawals.push(entry);
157
183
  summary.errors.push({ stage: "quarantine", skillKey: entry.skillKey, code: fsError?.code || "fs_error" });
158
184
  }
159
185
  }
160
- manifest.installs = [];
186
+ manifest.installs = failedWithdrawals;
161
187
  await writePrivateFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
162
188
  }
163
189
  return summary;
@@ -172,6 +198,7 @@ export async function syncManagedSkills({
172
198
  // lists that the badge no longer resolves.
173
199
  const verdicts = new Map((Array.isArray(response?.items) ? response.items : []).map((item) => [item.skillId, item]));
174
200
  const survivors = [];
201
+ const failedWithdrawals = [];
175
202
  for (const entry of manifest.installs) {
176
203
  const verdict = verdicts.get(entry.skillId);
177
204
  const status = verdict?.status;
@@ -183,6 +210,7 @@ export async function syncManagedSkills({
183
210
  } catch (error) {
184
211
  summary.errors.push({ stage: "quarantine", skillKey: entry.skillKey, code: error?.code || "fs_error" });
185
212
  survivors.push(entry);
213
+ failedWithdrawals.push(entry);
186
214
  }
187
215
  }
188
216
  manifest.installs = survivors;
@@ -196,11 +224,21 @@ export async function syncManagedSkills({
196
224
  entry.skillId = verdict.currentSkillId;
197
225
  }
198
226
  }
199
- const next = [];
227
+ const next = [...failedWithdrawals];
200
228
  for (const skill of wanted.values()) {
201
229
  const entry = manifest.installs.find((candidate) => candidate.skillId === skill.skillId);
202
230
  const dir = join(skillsRoot, skill.skillKey);
203
231
  const file = join(dir, "SKILL.md");
232
+ if (!(await active())) {
233
+ summary.errors.push({ stage: "download", code: "inactive_installation" });
234
+ return summary;
235
+ }
236
+ const directoryEntry = await pathState(dir);
237
+ const fileEntry = await pathState(file);
238
+ if (directoryEntry?.isSymbolicLink() || fileEntry?.isSymbolicLink()) {
239
+ summary.conflicts.push({ skillKey: skill.skillKey, reason: "unsafe_path" });
240
+ continue;
241
+ }
204
242
  const onDisk = await readText(file);
205
243
  const intact = entry && typeof onDisk === "string" && digest(onDisk) === entry.fileSha256;
206
244
  if (entry && entry.sha === skill.sha && intact) {
@@ -216,7 +254,12 @@ export async function syncManagedSkills({
216
254
  const exported = await transport.skillDownload(skill.skillId);
217
255
  const content = typeof exported?.content === "string" ? exported.content : null;
218
256
  if (!content) throw Object.assign(new Error("skill export was empty"), { code: "empty_export" });
219
- await mkdir(dir, { recursive: true });
257
+ if (!content.startsWith("---\n") || !content.split("\n---\n")[0].split("\n").includes(`content_sha256: ${skill.sha}`)) {
258
+ throw Object.assign(new Error("skill digest mismatch"), { code: "skill_digest_mismatch" });
259
+ }
260
+ if (!(await active())) throw Object.assign(new Error("inactive installation"), { code: "inactive_installation" });
261
+ await safeDirectory(dir, true);
262
+ if ((await pathState(file))?.isSymbolicLink()) throw new Error("unsafe skill file");
220
263
  await writePrivateFile(file, content);
221
264
  const record = {
222
265
  skillId: skill.skillId,
@@ -248,6 +291,12 @@ export async function syncManagedSkills({
248
291
  summary.errors.push({ stage: "ack", code: error?.code || "runtime_unavailable" });
249
292
  }
250
293
  }
294
+ summary.complete = summary.checkedIn && !summary.errors.length && !summary.conflicts.length
295
+ && manifest.installs.length === wanted.size && await active();
296
+ if (summary.complete) {
297
+ try { Object.defineProperty(summary, "tuples", { value: await readManagedSkillTuples({ connection, root, home, env }) }); }
298
+ catch { summary.complete = false; }
299
+ }
251
300
  return summary;
252
301
  }
253
302
 
@@ -264,3 +313,45 @@ export function describeSkillSync(summary) {
264
313
  if (!summary.checkedIn) parts.unshift("check-in unavailable");
265
314
  return `skills (${basename(dirname(summary.skillsRoot))}/${basename(summary.skillsRoot)}): ${parts.join(", ") || "nothing to do"}`;
266
315
  }
316
+
317
+ /** Verify exported definition bytes, not just claimed frontmatter/manifest SHAs. */
318
+ export async function readManagedSkillTuples({ connection, root, home = homedir(), env = process.env }) {
319
+ const skillsRoot = managedSkillsDirectory(connection.clientKind, { home, env });
320
+ if (skillsRoot) await safeDirectory(skillsRoot);
321
+ await safeDirectory(root);
322
+ const manifestEntry = await pathState(skillsManifestPath(root, connection.installationId));
323
+ if (!skillsRoot || !manifestEntry?.isFile() || manifestEntry.isSymbolicLink()) throw new Error("missing skill evidence");
324
+ const manifest = await loadSkillsManifest(root, connection.installationId);
325
+ if (manifest.installationId !== connection.installationId || !manifest.lastCheckinAt) throw new Error("incomplete skill evidence");
326
+ for (const entry of manifest.installs) {
327
+ if (!safeKey(entry.skillKey) || typeof entry.skillId !== "string" || !/^[a-f0-9]{64}$/.test(entry.sha)) throw new Error("invalid skill evidence");
328
+ const dir = join(skillsRoot, entry.skillKey), file = join(dir, "SKILL.md");
329
+ if ((await pathState(dir))?.isSymbolicLink() || (await pathState(file))?.isSymbolicLink()) throw new Error("unsafe skill evidence");
330
+ const content = await readText(file);
331
+ const separator = content?.indexOf("\n---\n\n");
332
+ // The export wrapper adds exactly one blank line and one terminal newline.
333
+ const definition = separator >= 0 && content.endsWith("\n") ? content.slice(separator + 6, -1) : null;
334
+ if (definition === null || digest(content) !== entry.fileSha256 || digest(definition) !== entry.sha) throw new Error("modified skill evidence");
335
+ }
336
+ return manifest.installs.map(({ skillId, skillKey, sha }) => [skillId, skillKey, sha]);
337
+ }
338
+
339
+ /** Retire a reconnect predecessor's managed discovery before activating its replacement.
340
+ * Move the owned directory intact, including employee edits, outside discovery;
341
+ * never delete or follow its contents. An unowned collision remains untouched. */
342
+ export async function retireManagedSkills({ connection, root, home = homedir(), env = process.env, now = () => new Date() }) {
343
+ const skillsRoot = managedSkillsDirectory(connection.clientKind, { home, env });
344
+ if (!skillsRoot) return;
345
+ const manifest = await loadSkillsManifest(root, connection.installationId);
346
+ if (!manifest.installs.length) return;
347
+ await safeDirectory(skillsRoot);
348
+ for (const entry of manifest.installs) {
349
+ const dir = join(skillsRoot, entry.skillKey);
350
+ const disk = await pathState(dir);
351
+ if (!disk) continue;
352
+ if (disk.isSymbolicLink() || !disk.isDirectory()) throw new Error("unsafe predecessor skill");
353
+ await quarantine(skillsRoot, join(root, "skills-quarantine", connection.installationId), entry.skillKey, now());
354
+ }
355
+ manifest.installs = [];
356
+ await writePrivateFile(skillsManifestPath(root, connection.installationId), `${JSON.stringify(manifest, null, 2)}\n`);
357
+ }
package/src/storage.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { constants } from "node:fs";
2
- import { access, chmod, mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
2
+ import { access, chmod, mkdir, lstat, open, readFile, rename, stat, unlink } from "node:fs/promises";
3
+ import { dirname, join, parse, resolve } from "node:path";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { randomBytes } from "node:crypto";
6
6
 
@@ -132,3 +132,21 @@ export class ConnectionStore {
132
132
  }
133
133
  }
134
134
  }
135
+
136
+ /** Refuse symlinks at every ancestor before touching managed runtime paths. */
137
+ export async function safeDirectory(path, create = false) {
138
+ const absolute = resolve(path);
139
+ let current = parse(absolute).root;
140
+ for (const part of absolute.slice(current.length).split(/[\\/]/).filter(Boolean)) {
141
+ current = join(current, part);
142
+ let entry;
143
+ try { entry = await lstat(current); }
144
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
145
+ if (!entry && create) {
146
+ try { await mkdir(current, { mode: 0o700 }); } catch (error) { if (error?.code !== "EEXIST") throw error; }
147
+ entry = await lstat(current);
148
+ }
149
+ if (!entry) throw Object.assign(new Error("directory_missing"), { code: "directory_missing" });
150
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw Object.assign(new Error("unsafe_directory"), { code: "unsafe_directory" });
151
+ }
152
+ }
package/src/transport.mjs CHANGED
@@ -90,6 +90,12 @@ export class SignedRuntimeTransport {
90
90
 
91
91
  /** Badge skill check-in beacon: exactly the installed {skillId, sha} pairs (D39). */
92
92
  skillsCheckin(items) { return this.request("/v1/agent-runtime/skills/checkin", { body: { items } }); }
93
+ deliveryCheck({ refresh = false } = {}) {
94
+ return this.request("/v1/agent-runtime/delivery/check", { body: refresh ? { refresh: true } : {}, maxResponseBytes: 16 * 1024 });
95
+ }
96
+ deliveryReceipt(receipt) {
97
+ return this.request("/v1/agent-runtime/delivery/receipt", { body: receipt, maxResponseBytes: 16 * 1024 });
98
+ }
93
99
  contextPage(cursor) {
94
100
  return this.request("/v1/agent-runtime/context", {
95
101
  body: cursor === undefined ? {} : { cursor }, maxResponseBytes: 8 * 1024 * 1024,
package/src/version.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  export const PACKAGE_NAME = "@halofy/agent-connect";
2
- export const INSTALLER_VERSION = "0.8.0";
3
- export const RUNTIME_VERSION = "0.8.0";
2
+ export const INSTALLER_VERSION = "0.9.0";
3
+ export const RUNTIME_VERSION = "0.9.0";
4
4
  export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-08.1";