@halofy/agent-connect 0.8.0 → 0.10.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.10.0 install <client-kind> \
29
29
  --server https://app.halofy.ai \
30
30
  --claim '<one-time-claim>'
31
31
  ```
@@ -128,3 +128,85 @@ 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.
158
+ ## Organization instructions (0.10.0 release candidate)
159
+
160
+ This source adds Govern instruction delivery alongside the existing skill,
161
+ policy, knowledge and delivery-receipt paths. Activation requires the reviewed
162
+ 0.10.0 npm publication, matching server deployment and a fresh confirmed
163
+ installation. Source versioning alone is not publication evidence. Existing
164
+ connections without the new local instruction consent/profile snapshot do not
165
+ gain global file-writing authority.
166
+
167
+ The installer discloses global rule management alongside existing capture and
168
+ MCP behavior. It freezes the selected profile and server-provided namespace in
169
+ the local connection and fetches instructions with the installation proof.
170
+ Subsequent supported session-start hooks refresh them independently of disabled
171
+ memory recall. There is no resident instruction daemon; an offline device or a
172
+ host session without an installed startup hook does not fetch updates. Claude's
173
+ existing hook installation is project-scoped even though its rule file is global.
174
+
175
+ | Host | Local target | Boundaries |
176
+ |---|---|---|
177
+ | Codex | `CODEX_HOME` or `~/.codex`, active nonempty `AGENTS.override.md` else `AGENTS.md` | Marked section; changing the active target reports a conflict |
178
+ | Claude Code | `CLAUDE_CONFIG_DIR` or `~/.claude`, dedicated `rules/halofy-*.md` | Existing personal/project `CLAUDE.md` remains untouched |
179
+ | Gemini CLI | `GEMINI_CLI_HOME` or home, then `.gemini/GEMINI.md` | Marked section; custom discovery excluding `GEMINI.md` is unsupported |
180
+ | Cursor | `~/.cursor/rules/halofy-*.mdc` with `alwaysApply: true` | Agent Chat only; no claim for Tab, Inline Edit, or cloud hosts |
181
+ | Other packaged hosts | No instruction writes | Reports unsupported; existing capture/MCP continues |
182
+
183
+ Host mechanisms were checked against official documentation on 2026-09-10:
184
+ [Codex](https://learn.chatgpt.com/docs/agent-configuration/agents-md),
185
+ [Claude Code](https://code.claude.com/docs/en/memory),
186
+ [Gemini CLI](https://geminicli.com/docs/cli/gemini-md/), and
187
+ [Cursor](https://prod.cursor.com/help/customization/rules).
188
+ These are file-format adapters, not real-host loading certification. Host
189
+ precedence, context limits, project configuration and exclusion settings still
190
+ apply. Receipts say `pending_restart`, never loaded or obeyed. Other local OS
191
+ accounts, containers and remote/cloud profiles require their own installation.
192
+
193
+ Sync validates exact content and bundle digests, scope ancestry/order and size
194
+ before writing. Personal bytes outside a managed block are preserved exactly.
195
+ An isolated ownership manifest, exclusive profile lock, no-follow reads,
196
+ component symlink checks, private backups, concurrent-edit checks and atomic
197
+ replacement protect local content. Unexpected edits, broken/duplicate markers,
198
+ linked files or changed target selection fail without replacing host content.
199
+ Request failure leaves the current installation’s instructions unchanged. A
200
+ confirmed reconnect first retires only verified predecessor-owned instruction
201
+ bytes; old hooks cannot restore them. Conflicting predecessor edits prevent new
202
+ instruction activation while preserving all user content. Otherwise, only a
203
+ verified empty bundle removes managed content; an empty file and removal manifest remain so a
204
+ future authorized re-enable can be recognized safely. Backup files stay outside
205
+ host rules directories, under the runtime instructions directory.
206
+
207
+ A stale lock is deliberately not automatically deleted; after confirming no
208
+ sync is running, an operator may remove `.halofy-instructions.lock` in the
209
+ selected profile. Local filesystem errors and receipt failures do not interrupt
210
+ capture or MCP. Neither instruction text, filesystem paths nor backups are sent
211
+ in status receipts. Existing policy/knowledge/skill operations and runtime queue
212
+ files are not changed by instruction sync.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halofy/agent-connect",
3
- "version": "0.8.0",
3
+ "version": "0.10.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
  }
@@ -135,6 +96,8 @@ export async function runClaudeLifecycleHook(connection, eventName, {
135
96
  if (eventName === "SessionStart") {
136
97
  // Refresh/withdraw local context even when replay or heartbeat fails.
137
98
  await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
99
+ // Instructions have their own brownout boundary, independent of recall.
100
+ await runtime.syncInstructions?.().catch(() => {});
138
101
  await runtime.replay();
139
102
  await runtime.heartbeat(connection.capabilities || {});
140
103
  if (RECALL_INJECTION_ENABLED) {
@@ -147,14 +110,14 @@ export async function runClaudeLifecycleHook(connection, eventName, {
147
110
  if (output) stdout.write(output);
148
111
  }
149
112
  } else if (eventName === "UserPromptSubmit") {
113
+ const delivery = await syncManagedDelivery({ connection, transport: runtime.transport, root, home, env, stderr, phase: "turn" });
114
+ let recalled = null;
150
115
  if (RECALL_INJECTION_ENABLED) {
151
116
  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
- }
117
+ if (prompt.trim()) recalled = await runtime.recall(session, prompt);
157
118
  }
119
+ const output = recallText(recalled, "UserPromptSubmit", delivery.notice);
120
+ if (output) stdout.write(output);
158
121
  } else if (eventName === "Stop") {
159
122
  if (!hookInput.stop_hook_active) {
160
123
  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
  }
@@ -160,6 +161,8 @@ export async function runHostLifecycleHook(connection, eventName, {
160
161
  if (START_EVENTS.has(eventName)) {
161
162
  // Refresh/withdraw local context even when replay or heartbeat fails.
162
163
  await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
164
+ // Instructions have their own brownout boundary, independent of recall.
165
+ await runtime.syncInstructions?.().catch(() => {});
163
166
  await runtime.replay();
164
167
  await runtime.heartbeat(connection.capabilities || {});
165
168
  if (RECALL_INJECTION_ENABLED) {
@@ -174,14 +177,16 @@ export async function runHostLifecycleHook(connection, eventName, {
174
177
  // transcript driver rely on this enqueue to reach the archive, and
175
178
  // hook-sourced events stay authoritative for messages even where a
176
179
  // driver adds usage/metadata evidence.
180
+ const delivery = await syncManagedDelivery({ connection, transport: runtime.transport, root, home, env, stderr, phase: "turn" });
177
181
  const prompt = promptText(hookInput);
182
+ let recalled = null;
178
183
  await enqueueMessage(runtime, connection, session, "user", prompt, eventName, hookInput);
179
184
  if (RECALL_INJECTION_ENABLED && connection.clientKind !== "cursor" &&
180
185
  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);
186
+ recalled = await runtime.recall(session, prompt.slice(0, 8_000));
184
187
  }
188
+ const output = recallOutput(connection.clientKind, eventName, recalled, delivery.notice);
189
+ if (output) stdout.write(output);
185
190
  } else if (ASSISTANT_EVENTS.has(eventName)) {
186
191
  await enqueueMessage(runtime, connection, session, "assistant", assistantText(hookInput), eventName, hookInput);
187
192
  await runtime.commitIfThreshold(session);
package/src/install.mjs CHANGED
@@ -4,10 +4,13 @@ import { chmod, cp, rename, rm, unlink } from "node:fs/promises";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { generateInstallationKeyPair } from "./crypto.mjs";
6
6
  import { ConnectionStore, defaultRuntimeDirectory, ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
7
+ import { syncAgentInstructions } from "./instructions.mjs";
7
8
  import { SignedRuntimeTransport } from "./transport.mjs";
8
9
  import { INSTALLER_VERSION, RUNTIME_VERSION } from "./version.mjs";
9
10
  import { CLIENT_KINDS, CLIENT_REGISTRY, lifecycleClient } from "./client-registry.mjs";
10
11
  import { syncManagedSkills } from "./skills-sync.mjs";
12
+ import { syncManagedDelivery } from "./delivery-sync.mjs";
13
+ import { retirePredecessorContent } from "./predecessor-sync.mjs";
11
14
  import { syncManagedContext } from "./context-sync.mjs";
12
15
 
13
16
  export const CLAUDE_CAPABILITIES = CLIENT_REGISTRY["claude-code"].capabilities;
@@ -149,6 +152,7 @@ export async function installLocalConnection({
149
152
  root = defaultRuntimeDirectory(),
150
153
  fetchImpl = globalThis.fetch,
151
154
  sendHeartbeat = true,
155
+ instructionProfile,
152
156
  }) {
153
157
  if (!CLIENT_KINDS.includes(clientKind)) throw new Error("the selected lifecycle adapter is not packaged");
154
158
  const client = lifecycleClient(clientKind);
@@ -199,6 +203,8 @@ export async function installLocalConnection({
199
203
  pluginVersion: RUNTIME_VERSION,
200
204
  proofStorage,
201
205
  installedAt: new Date().toISOString(),
206
+ ...(typeof consumed.namespace === "string" ? { namespace: consumed.namespace } : {}),
207
+ ...(instructionProfile ? { instructionProfile } : {}),
202
208
  };
203
209
  await store.save(connection);
204
210
  await writePrivateFile(join(root, `active-${clientKind}.json`), `${JSON.stringify({
@@ -223,9 +229,13 @@ export async function installLocalConnection({
223
229
  heartbeat = false;
224
230
  }
225
231
  }
232
+ const instructions = instructionProfile
233
+ ? await syncAgentInstructions(connection, { root, transport: new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 2_000 }) })
234
+ : undefined;
226
235
  return {
227
236
  installationId,
228
237
  previousInstallationId: connection.previousInstallationId ?? null,
238
+ ...(instructions ? { instructions } : {}),
229
239
  heartbeat,
230
240
  reused: false,
231
241
  proofStorage: connection.proofStorage,
@@ -294,32 +304,14 @@ export async function syncInstalledSkills({
294
304
 
295
305
  /** Refresh the installation's policy and knowledge references. */
296
306
  export async function syncInstalledContext({
297
- installationId, root = defaultRuntimeDirectory(), fetchImpl = globalThis.fetch, home, env,
307
+ installationId, root = defaultRuntimeDirectory(), fetchImpl = globalThis.fetch, home, env, transport,
298
308
  }) {
299
309
  const store = new ConnectionStore(root);
300
310
  const connection = await store.load(installationId);
301
311
  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
- }
312
+ await retirePredecessorContent({ ...options, connection });
321
313
  return syncManagedContext({
322
- ...options, connection, transport: new SignedRuntimeTransport(connection, { fetchImpl }),
314
+ ...options, connection, transport: transport || new SignedRuntimeTransport(connection, { fetchImpl }),
323
315
  canActivate: async () => {
324
316
  const marker = await readJson(join(root, `active-${connection.clientKind}.json`));
325
317
  return !marker || marker.installationId === installationId;
@@ -327,6 +319,13 @@ export async function syncInstalledContext({
327
319
  });
328
320
  }
329
321
 
322
+ /** Installer uses the same complete, serialized delivery attempt as hooks. */
323
+ export async function syncInstalledDelivery({ installationId, root = defaultRuntimeDirectory(), fetchImpl = globalThis.fetch, home, env, stderr }) {
324
+ const connection = await new ConnectionStore(root).load(installationId);
325
+ const transport = new SignedRuntimeTransport(connection, { fetchImpl });
326
+ return syncManagedDelivery({ connection, root, home, env, transport, stderr, phase: "install" });
327
+ }
328
+
330
329
  export function localMcpSnippet({ nodePath = process.execPath, proxyPath, installationId }) {
331
330
  return {
332
331
  mcpServers: {