@halofy/agent-connect 0.9.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.9.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
  ```
@@ -155,3 +155,58 @@ confirm delivery status. Existing offline skill limits remain; context refresh
155
155
  failures withdraw the managed references. These are source capabilities; package
156
156
  publication, installed-client upgrades and production deployment require separate
157
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.9.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": {
@@ -96,6 +96,8 @@ export async function runClaudeLifecycleHook(connection, eventName, {
96
96
  if (eventName === "SessionStart") {
97
97
  // Refresh/withdraw local context even when replay or heartbeat fails.
98
98
  await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
99
+ // Instructions have their own brownout boundary, independent of recall.
100
+ await runtime.syncInstructions?.().catch(() => {});
99
101
  await runtime.replay();
100
102
  await runtime.heartbeat(connection.capabilities || {});
101
103
  if (RECALL_INJECTION_ENABLED) {
package/src/host-hook.mjs CHANGED
@@ -161,6 +161,8 @@ export async function runHostLifecycleHook(connection, eventName, {
161
161
  if (START_EVENTS.has(eventName)) {
162
162
  // Refresh/withdraw local context even when replay or heartbeat fails.
163
163
  await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
164
+ // Instructions have their own brownout boundary, independent of recall.
165
+ await runtime.syncInstructions?.().catch(() => {});
164
166
  await runtime.replay();
165
167
  await runtime.heartbeat(connection.capabilities || {});
166
168
  if (RECALL_INJECTION_ENABLED) {
package/src/install.mjs CHANGED
@@ -4,6 +4,7 @@ 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";
@@ -151,6 +152,7 @@ export async function installLocalConnection({
151
152
  root = defaultRuntimeDirectory(),
152
153
  fetchImpl = globalThis.fetch,
153
154
  sendHeartbeat = true,
155
+ instructionProfile,
154
156
  }) {
155
157
  if (!CLIENT_KINDS.includes(clientKind)) throw new Error("the selected lifecycle adapter is not packaged");
156
158
  const client = lifecycleClient(clientKind);
@@ -201,6 +203,8 @@ export async function installLocalConnection({
201
203
  pluginVersion: RUNTIME_VERSION,
202
204
  proofStorage,
203
205
  installedAt: new Date().toISOString(),
206
+ ...(typeof consumed.namespace === "string" ? { namespace: consumed.namespace } : {}),
207
+ ...(instructionProfile ? { instructionProfile } : {}),
204
208
  };
205
209
  await store.save(connection);
206
210
  await writePrivateFile(join(root, `active-${clientKind}.json`), `${JSON.stringify({
@@ -225,9 +229,13 @@ export async function installLocalConnection({
225
229
  heartbeat = false;
226
230
  }
227
231
  }
232
+ const instructions = instructionProfile
233
+ ? await syncAgentInstructions(connection, { root, transport: new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 2_000 }) })
234
+ : undefined;
228
235
  return {
229
236
  installationId,
230
237
  previousInstallationId: connection.previousInstallationId ?? null,
238
+ ...(instructions ? { instructions } : {}),
231
239
  heartbeat,
232
240
  reused: false,
233
241
  proofStorage: connection.proofStorage,
@@ -16,6 +16,7 @@ import { configureClaudeProject } from "./claude-config.mjs";
16
16
  import { configureCline, configureCodex, configureCursor, configureGemini, configureKimi, configureVscode } from "./host-config.mjs";
17
17
  import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
18
18
  import { defaultRuntimeDirectory } from "./storage.mjs";
19
+ import { instructionProfile } from "./instructions.mjs";
19
20
  import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
20
21
  import { managedSkillsDirectory } from "./skills-sync.mjs";
21
22
 
@@ -195,9 +196,9 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
195
196
  ? "This reviewed host surface can report complete coverage when all declared evidence is observed."
196
197
  : `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
197
198
  "Authorized organization managers may review retained conversations and summaries.",
198
- skillsRoot
199
- ? `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.`
200
- : "This does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
199
+ "This reads and writes its managed skill folders and instruction sections; it does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
200
+ "Organization instructions: supported adapters manage an additive global rule file or marked section across projects in the selected local agent profile. Existing personal instructions are preserved. Updates sync at installation and supported session starts; restart the host to load changes.",
201
+ "Other OS accounts, containers, remote/cloud hosts and unsupported agents are not covered. Instruction precedence remains controlled by the host.",
201
202
  "Disconnecting stops future capture but does not erase retained data.",
202
203
  `Disclosure: ${DISCLOSURE_VERSION}`,
203
204
  ].join("\n");
@@ -250,8 +251,9 @@ export function allDisclosureText({ serverUrl, projectRoot, organization = null,
250
251
  lines.push(
251
252
  "",
252
253
  "Authorized organization managers may review retained conversations and summaries.",
253
- "This reads and writes only the skill folders it created for the hosts listed above; it does not scan " +
254
- "historical files, other applications, clipboard, keystrokes, or other agents.",
254
+ "This reads and writes its managed skill folders and instruction sections; it does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
255
+ "Organization instructions: supported adapters manage an additive global rule file or marked section across projects in the selected local agent profile. Existing personal instructions are preserved. Updates sync at installation and supported session starts; restart the host to load changes.",
256
+ "Other OS accounts, containers, remote/cloud hosts and unsupported agents are not covered. Instruction precedence remains controlled by the host.",
255
257
  "Disconnecting stops future capture but does not erase retained data.",
256
258
  `Disclosure: ${DISCLOSURE_VERSION}`,
257
259
  );
@@ -354,6 +356,7 @@ async function runAllInstaller(input, {
354
356
  root,
355
357
  fetchImpl,
356
358
  sendHeartbeat: false,
359
+ instructionProfile: instructionProfile(host.clientKind, skillsHome ? { home: skillsHome } : {}),
357
360
  });
358
361
  const configured = await configureHost(host.clientKind, {
359
362
  projectRoot: input.projectRoot,
@@ -383,6 +386,7 @@ async function runAllInstaller(input, {
383
386
  conflicts: skills.conflicts.map((c) => c.skillKey), errors: skills.errors.length }
384
387
  : null,
385
388
  proofStorage: installed.proofStorage,
389
+ instructions: installed.instructions,
386
390
  configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
387
391
  replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
388
392
  nextStep: `Restart ${lifecycleClient(host.clientKind).label}, then check the connection in Halofy.`,
@@ -448,6 +452,7 @@ export async function runInstaller(argv, {
448
452
  root,
449
453
  fetchImpl,
450
454
  sendHeartbeat: false,
455
+ instructionProfile: instructionProfile(input.clientKind, skillsHome ? { home: skillsHome } : {}),
451
456
  });
452
457
  const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
453
458
  const common = {
@@ -477,6 +482,7 @@ export async function runInstaller(argv, {
477
482
  installerVersion: INSTALLER_VERSION,
478
483
  publishedPackage: true,
479
484
  projectConfigured: true,
485
+ instructions: installed.instructions,
480
486
  configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
481
487
  replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
482
488
  mcpConfiguration: localMcpSnippet({
@@ -0,0 +1,266 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { isActiveInstallation } from "./active.mjs";
7
+ import { ConnectionStore, readJson } from "./storage.mjs";
8
+ import { SignedRuntimeTransport } from "./transport.mjs";
9
+
10
+ const sha = (value) => createHash("sha256").update(value).digest("hex");
11
+ const HEX = /^[a-f0-9]{64}$/;
12
+ const MARKER = "<!-- HALOFY-INSTRUCTIONS:";
13
+ const fail = (code) => { throw Object.assign(new Error(code), { instructionCode: code }); };
14
+ const plain = (value) => value && typeof value === "object" && !Array.isArray(value);
15
+ const scope = (value) => typeof value === "string" && value.length <= 1024 && !/[\u0000-\u001f\u007f]/.test(value) &&
16
+ (value === "" || value.split("/").every((part) => part && part !== "." && part !== ".."));
17
+
18
+ /** Local consent/profile snapshot; never derive a writable path from a server response. */
19
+ export function instructionProfile(clientKind, { home = homedir(), env = process.env } = {}) {
20
+ const roots = {
21
+ codex: env.CODEX_HOME || join(home, ".codex"),
22
+ "claude-code": env.CLAUDE_CONFIG_DIR || join(home, ".claude"),
23
+ "gemini-cli": join(env.GEMINI_CLI_HOME || home, ".gemini"),
24
+ cursor: join(home, ".cursor"),
25
+ };
26
+ return { version: 1, consent: "organization-instructions-v1", clientKind, root: roots[clientKind] ? resolve(roots[clientKind]) : null };
27
+ }
28
+
29
+ export function validateInstructionBundle(bundle, expectedNamespace) {
30
+ if (!plain(bundle) || bundle.schemaVersion !== 1 || !scope(expectedNamespace) ||
31
+ bundle.namespace !== expectedNamespace || !HEX.test(bundle.digest) || !Array.isArray(bundle.documents) ||
32
+ Buffer.byteLength(JSON.stringify(bundle)) > 64 * 1024) fail("invalid_bundle");
33
+ let previous = null;
34
+ const documents = bundle.documents.map((doc) => {
35
+ if (!plain(doc) || typeof doc.id !== "string" || !/^[A-Za-z0-9_-]{1,160}$/.test(doc.id) || !scope(doc.namespace) ||
36
+ !(doc.namespace === "" || doc.namespace === expectedNamespace || expectedNamespace.startsWith(`${doc.namespace}/`)) ||
37
+ (previous !== null && !(doc.namespace !== previous && (previous === "" || doc.namespace.startsWith(`${previous}/`)))) ||
38
+ !Number.isSafeInteger(doc.version) || doc.version < 1 || typeof doc.title !== "string" || !doc.title.trim() || doc.title.length > 160 || doc.title.includes(MARKER) ||
39
+ /[\u0000-\u001f\u007f]/.test(doc.title) || typeof doc.content !== "string" || !doc.content.trim() ||
40
+ Buffer.from(doc.title, "utf8").toString("utf8") !== doc.title || Buffer.from(doc.content, "utf8").toString("utf8") !== doc.content ||
41
+ doc.content.includes("\0") || doc.content.includes(MARKER) || Buffer.byteLength(doc.content) > 16 * 1024 ||
42
+ !HEX.test(doc.sha256) || sha(doc.content) !== doc.sha256) fail("invalid_bundle");
43
+ previous = doc.namespace;
44
+ return { id: doc.id, namespace: doc.namespace, version: doc.version, title: doc.title, content: doc.content, sha256: doc.sha256 };
45
+ });
46
+ if (sha(JSON.stringify(documents)) !== bundle.digest) fail("invalid_bundle");
47
+ return { ...bundle, documents };
48
+ }
49
+
50
+ // Inspect every existing path component, including the final file. mkdir is
51
+ // deliberately incremental: recursive mkdir can follow a linked ancestor.
52
+ async function safePath(path, { createParents = false } = {}) {
53
+ if (typeof path !== "string" || !isAbsolute(path) || resolve(path) !== path || path.includes("\0")) fail("unsafe_path");
54
+ const parsed = parse(path);
55
+ const parts = path.slice(parsed.root.length).split(sep).filter(Boolean);
56
+ let current = parsed.root;
57
+ const chain = [];
58
+ for (let i = 0; i < parts.length; i++) {
59
+ current = join(current, parts[i]);
60
+ let info;
61
+ try { info = await lstat(current); } catch (error) {
62
+ if (error.code !== "ENOENT") throw error;
63
+ if (createParents && i < parts.length - 1) {
64
+ try { await mkdir(current, { mode: 0o700 }); } catch (made) { if (made.code !== "EEXIST") throw made; }
65
+ info = await lstat(current);
66
+ } else return chain;
67
+ }
68
+ if (info.isSymbolicLink() || (i < parts.length - 1 ? !info.isDirectory() : !info.isFile() && !info.isDirectory())) fail("unsafe_path");
69
+ chain.push(`${current}:${info.dev}:${info.ino}`);
70
+ }
71
+ return chain;
72
+ }
73
+
74
+ async function snapshot(path) {
75
+ await safePath(path);
76
+ let handle;
77
+ try {
78
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
79
+ const info = await handle.stat();
80
+ if (!info.isFile() || info.nlink !== 1 || info.size > 1024 * 1024) fail("unsafe_file");
81
+ const bytes = await handle.readFile();
82
+ return { bytes, mode: info.mode & 0o777, identity: `${info.dev}:${info.ino}:${info.mtimeMs}:${info.ctimeMs}`, exists: true };
83
+ } catch (error) {
84
+ if (error.code === "ENOENT") return { bytes: Buffer.alloc(0), mode: 0o600, identity: null, exists: false };
85
+ throw error;
86
+ } finally { await handle?.close(); }
87
+ }
88
+
89
+ async function atomic(path, bytes, prior) {
90
+ const ancestors = await safePath(path, { createParents: true });
91
+ const temp = `${path}.${process.pid}.${randomBytes(10).toString("hex")}.tmp`;
92
+ const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), prior.mode);
93
+ try {
94
+ await handle.writeFile(bytes);
95
+ await handle.sync();
96
+ await handle.close();
97
+ const now = await snapshot(path);
98
+ if (now.identity !== prior.identity || !now.bytes.equals(prior.bytes) ||
99
+ JSON.stringify(await safePath(path)) !== JSON.stringify(ancestors)) fail("concurrent_edit");
100
+ await rename(temp, path);
101
+ } finally {
102
+ await handle.close().catch(() => {});
103
+ await unlink(temp).catch((error) => { if (error.code !== "ENOENT") throw error; });
104
+ }
105
+ }
106
+
107
+ async function targetFor(connection, owner) {
108
+ const profile = connection.instructionProfile;
109
+ if (!plain(profile) || profile.version !== 1 || profile.consent !== "organization-instructions-v1" ||
110
+ profile.clientKind !== connection.clientKind) fail("reinstall_required");
111
+ if (!["codex", "claude-code", "gemini-cli", "cursor"].includes(connection.clientKind)) return null;
112
+ const root = profile.root;
113
+ await safePath(root);
114
+ if (connection.clientKind === "codex") {
115
+ const override = join(root, "AGENTS.override.md");
116
+ const file = await snapshot(override);
117
+ return { path: file.bytes.toString("utf8").trim() ? override : join(root, "AGENTS.md"), dedicated: false };
118
+ }
119
+ if (connection.clientKind === "gemini-cli") {
120
+ const settings = await snapshot(join(root, "settings.json"));
121
+ let parsed;
122
+ try { parsed = settings.exists ? JSON.parse(settings.bytes.toString("utf8")) : {}; } catch { fail("host_settings_invalid"); }
123
+ const filename = parsed.context?.fileName;
124
+ // A non-default discovery configuration can be overridden again by project
125
+ // settings. Do not guess a writable global filename or change host settings.
126
+ if (filename !== undefined && filename !== "GEMINI.md" &&
127
+ !(Array.isArray(filename) && filename.includes("GEMINI.md"))) fail("custom_context_unsupported");
128
+ return { path: join(root, "GEMINI.md"), dedicated: false };
129
+ }
130
+ return { path: join(root, "rules", `halofy-${owner}.${connection.clientKind === "cursor" ? "mdc" : "md"}`), dedicated: true };
131
+ }
132
+
133
+ function render(bundle, owner, cursor) {
134
+ const begin = `${MARKER}${owner}:BEGIN -->`;
135
+ const end = `${MARKER}${owner}:END -->`;
136
+ const content = bundle.documents.map((doc) => `## ${doc.title}\n\n${doc.content}`).join("\n\n");
137
+ const text = `${cursor ? "---\nalwaysApply: true\n---\n" : ""}${begin}\n# Organization agent instructions\n\n${content}\n${end}\n`;
138
+ return { begin: Buffer.from(begin), end: Buffer.from(end), bytes: Buffer.from(text) };
139
+ }
140
+
141
+ /** Writes only owned bytes. Any ambiguity leaves the host file unchanged. */
142
+ export async function applyInstructionBundle(connection, bundle, { root, beforeWrite, canActivate = async () => true } = {}) {
143
+ bundle = validateInstructionBundle(bundle, connection.namespace);
144
+ const owner = sha(`${connection.serverUrl}\0${connection.namespace}`).slice(0, 24);
145
+ const target = await targetFor(connection, owner);
146
+ if (!target) return { digest: bundle.digest, status: "unsupported", reason: "host_unsupported" };
147
+ const stateKey = sha(`${connection.clientKind}\0${connection.instructionProfile.root}\0${owner}`);
148
+ const statePath = join(resolve(root), "instructions", `${stateKey}.json`);
149
+ await safePath(statePath, { createParents: true });
150
+ // Profile-level lock remains the same if Codex switches its override file.
151
+ const lockPath = join(connection.instructionProfile.root, ".halofy-instructions.lock");
152
+ await safePath(lockPath, { createParents: true });
153
+ let lock;
154
+ try {
155
+ try { lock = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600); }
156
+ catch (error) { if (error.code === "EEXIST") fail("sync_busy"); throw error; }
157
+ if (!(await canActivate())) fail("inactive_installation");
158
+ const initial = await snapshot(target.path);
159
+ const stateSnapshot = await snapshot(statePath);
160
+ let state;
161
+ try { state = stateSnapshot.exists ? JSON.parse(stateSnapshot.bytes.toString("utf8")) : null; } catch { fail("ownership_invalid"); }
162
+ if (state && (state.version !== 1 || state.path !== target.path || state.owner !== owner ||
163
+ typeof state.managedHash !== "string" || !HEX.test(state.managedHash))) fail("ownership_conflict");
164
+ if (state?.removed) {
165
+ if (target.dedicated && sha(initial.bytes) !== state.managedHash) fail("managed_content_changed");
166
+ if (!target.dedicated && initial.bytes.includes(Buffer.from(`${MARKER}${owner}:`))) fail("managed_content_changed");
167
+ if (bundle.documents.length === 0) return { digest: bundle.digest, status: "pending_restart", reason: "no_managed_content" };
168
+ state = null;
169
+ }
170
+ const rendered = render(bundle, owner, connection.clientKind === "cursor");
171
+ const bytes = initial.bytes;
172
+ const start = bytes.indexOf(rendered.begin);
173
+ const end = bytes.indexOf(rendered.end);
174
+ let prefix = bytes;
175
+ let suffix = Buffer.alloc(0);
176
+ let separator = "";
177
+ if (state) {
178
+ if (target.dedicated) {
179
+ if (!initial.exists || sha(bytes) !== state.managedHash) fail("managed_content_changed");
180
+ prefix = Buffer.alloc(0);
181
+ } else {
182
+ if (start < 0 || end < start || bytes.indexOf(rendered.begin, start + 1) !== -1 ||
183
+ bytes.indexOf(rendered.end, end + 1) !== -1) fail("markers_invalid");
184
+ const managedEnd = end + rendered.end.length + 1;
185
+ if (bytes[managedEnd - 1] !== 10 || sha(bytes.subarray(start, managedEnd)) !== state.managedHash) fail("managed_content_changed");
186
+ separator = state.separator === "\n\n" ? state.separator : "";
187
+ const prefixEnd = start - Buffer.byteLength(separator);
188
+ if (prefixEnd < 0 || bytes.subarray(prefixEnd, start).toString() !== separator) fail("managed_content_changed");
189
+ prefix = bytes.subarray(0, prefixEnd);
190
+ suffix = bytes.subarray(managedEnd);
191
+ }
192
+ } else if (target.dedicated && initial.exists && initial.bytes.length > 0 || start >= 0 || end >= 0 || bytes.includes(Buffer.from(MARKER))) fail("unowned_content");
193
+ // Refuse malformed owned-looking markers outside the verified region too.
194
+ if (prefix.includes(Buffer.from(MARKER)) || suffix.includes(Buffer.from(MARKER))) fail("markers_invalid");
195
+ const removing = bundle.documents.length === 0;
196
+ if (removing && !state) return { digest: bundle.digest, status: "pending_restart", reason: "no_managed_content" };
197
+ if (!state && !target.dedicated) separator = prefix.length ? "\n\n" : "";
198
+ const updated = removing ? Buffer.concat([prefix, suffix]) : Buffer.concat([prefix, Buffer.from(separator), rendered.bytes, suffix]);
199
+ const next = { version: 1, owner, path: target.path, managedHash: sha(rendered.bytes), separator, digest: bundle.digest };
200
+ if (!updated.equals(bytes)) {
201
+ // Backup lives outside the host's rules directory, so it cannot be loaded
202
+ // as a second rule. Never send backup or filesystem paths to the server.
203
+ const backup = `${statePath}.${Date.now()}.${randomBytes(6).toString("hex")}.bak`;
204
+ await atomic(backup, bytes, await snapshot(backup));
205
+ await beforeWrite?.();
206
+ if (!(await canActivate())) fail("inactive_installation");
207
+ if ((await targetFor(connection, owner))?.path !== target.path) fail("host_profile_changed");
208
+ await atomic(target.path, updated, initial);
209
+ }
210
+ if (removing) {
211
+ // Shared empty files remain to avoid a delete race with personal edits.
212
+ // Dedicated empty files are harmless and no longer include instructions.
213
+ await atomic(statePath, Buffer.from(`${JSON.stringify({ ...next, removed: true, managedHash: sha(updated) })}\n`), stateSnapshot);
214
+ } else {
215
+ await atomic(statePath, Buffer.from(`${JSON.stringify(next)}\n`), stateSnapshot);
216
+ }
217
+ return { digest: bundle.digest, status: "pending_restart", reason: removing ? "managed_content_removed" : "host_reload_required" };
218
+ } finally {
219
+ if (lock) { await lock.close(); await safePath(lockPath); await unlink(lockPath); }
220
+ }
221
+ }
222
+
223
+ /** A confirmed reconnect retires only verified instruction bytes owned by its
224
+ * predecessors. The complete chain is validated before the first removal.
225
+ * Same-owner retries share the manifest and keep their current instruction copy. */
226
+ async function retirePredecessorInstructions(connection, root) {
227
+ const sameOwner = (left, right) => left.serverUrl === right.serverUrl && left.namespace === right.namespace &&
228
+ left.clientKind === right.clientKind && left.instructionProfile?.root === right.instructionProfile?.root;
229
+ const store = new ConnectionStore(root);
230
+ const visited = new Set([connection.installationId]);
231
+ const predecessors = [];
232
+ let previous = connection.previousInstallationId;
233
+ while (previous) {
234
+ if (typeof previous !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(previous) || visited.has(previous) || visited.size > 64) fail("predecessor_invalid");
235
+ visited.add(previous);
236
+ const prior = await readJson(store.path(previous));
237
+ if (!prior || prior.clientKind !== connection.clientKind) fail("predecessor_invalid");
238
+ predecessors.push(prior);
239
+ previous = prior.previousInstallationId;
240
+ }
241
+ const retired = [];
242
+ for (const prior of predecessors) {
243
+ if (!prior.instructionProfile || sameOwner(prior, connection) || retired.some((entry) => sameOwner(entry, prior))) continue;
244
+ const empty = { schemaVersion: 1, namespace: prior.namespace, digest: sha("[]"), documents: [] };
245
+ await applyInstructionBundle(prior, empty, { root, canActivate: () => isActiveInstallation(connection, root) });
246
+ retired.push(prior);
247
+ }
248
+ }
249
+
250
+ /** Separate best-effort operation: a failed GET never means remove instructions. */
251
+ export async function syncAgentInstructions(connection, { root, transport = new SignedRuntimeTransport(connection, { timeoutMs: 2_000 }) } = {}) {
252
+ let receipt = { digest: sha("[]"), status: "unavailable", reason: "fetch_unavailable" };
253
+ try {
254
+ if (!(await isActiveInstallation(connection, root))) return { ...receipt, status: "unsupported", reason: "inactive_installation" };
255
+ await retirePredecessorInstructions(connection, root);
256
+ const bundle = await transport.instructions();
257
+ receipt.digest = typeof bundle?.digest === "string" && HEX.test(bundle.digest) ? bundle.digest : receipt.digest;
258
+ receipt = await applyInstructionBundle(connection, bundle, { root, canActivate: () => isActiveInstallation(connection, root) });
259
+ } catch (error) {
260
+ const reason = error.instructionCode;
261
+ const unsupported = ["custom_context_unsupported", "reinstall_required", "inactive_installation"].includes(reason);
262
+ receipt = { ...receipt, status: unsupported ? "unsupported" : reason && reason !== "invalid_bundle" ? "conflict" : "unavailable", reason: reason || "fetch_unavailable" };
263
+ }
264
+ try { await transport.instructionStatus(receipt); } catch { /* A receipt failure cannot disable capture/MCP. */ }
265
+ return receipt;
266
+ }
package/src/runtime.mjs CHANGED
@@ -8,19 +8,23 @@ import {
8
8
  import { claudeTranscriptDriver } from "./transcript-drivers/claude.mjs";
9
9
  import { SignedRuntimeTransport } from "./transport.mjs";
10
10
  import { readJson, withFileLock, writePrivateFile } from "./storage.mjs";
11
+ import { syncAgentInstructions } from "./instructions.mjs";
11
12
  import { RUNTIME_VERSION } from "./version.mjs";
12
13
 
13
14
  export class LifecycleRuntime {
14
15
  constructor(connection, {
15
16
  root,
16
17
  transport = new SignedRuntimeTransport(connection),
18
+ instructionTransport = new SignedRuntimeTransport(connection, { timeoutMs: 2_000 }),
17
19
  maxBatchEvents = 100,
18
20
  maxBatchBytes = 1024 * 1024,
19
21
  commitThreshold = 20,
20
22
  } = {}) {
21
23
  if (!root) throw new Error("runtime storage root is required");
22
24
  this.connection = connection;
25
+ this.root = root;
23
26
  this.transport = transport;
27
+ this.instructionTransport = instructionTransport;
24
28
  const connectionRoot = join(root, connection.installationId);
25
29
  this.queue = new BoundedEncryptedQueue(connectionRoot);
26
30
  this.cursors = new CursorStore(connectionRoot);
@@ -32,6 +36,13 @@ export class LifecycleRuntime {
32
36
  this.sessions = new Map();
33
37
  }
34
38
 
39
+ async syncInstructions() {
40
+ // Legacy bindings have no consent/profile snapshot. Reinstall the reviewed
41
+ // package to enable global instruction management.
42
+ if (!this.connection.instructionProfile) return { status: "unsupported", reason: "reinstall_required" };
43
+ return syncAgentInstructions(this.connection, { root: this.root, transport: this.instructionTransport });
44
+ }
45
+
35
46
  sessionHash(hostSessionId) {
36
47
  return deriveSessionHash({
37
48
  installationId: this.connection.installationId,
package/src/transport.mjs CHANGED
@@ -105,6 +105,8 @@ export class SignedRuntimeTransport {
105
105
  skillDownload(skillId) {
106
106
  return this.request(`/v1/agent-runtime/skills/${encodeURIComponent(skillId)}/download`, { method: "GET" });
107
107
  }
108
+ instructions() { return this.request("/v1/agent-runtime/instructions", { method: "GET", maxResponseBytes: 64 * 1024 }); }
109
+ instructionStatus(body) { return this.request("/v1/agent-runtime/instructions/status", { body, maxResponseBytes: 16 * 1024 }); }
108
110
 
109
111
  openSession(body) { return this.request("/v1/agent-sessions/open", { body }); }
110
112
  appendEvents(sessionId, events) { return this.request(`/v1/agent-sessions/${encodeURIComponent(sessionId)}/events`, { body: { events } }); }
package/src/version.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  export const PACKAGE_NAME = "@halofy/agent-connect";
2
- export const INSTALLER_VERSION = "0.9.0";
3
- export const RUNTIME_VERSION = "0.9.0";
4
- export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-08.1";
2
+ export const INSTALLER_VERSION = "0.10.0";
3
+ export const RUNTIME_VERSION = "0.10.0";
4
+ export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-10.1";