@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.
@@ -10,16 +10,15 @@ 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";
19
+ import { instructionProfile } from "./instructions.mjs";
20
20
  import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
21
- import { describeSkillSync, managedSkillsDirectory } from "./skills-sync.mjs";
22
- import { describeContextSync } from "./context-sync.mjs";
21
+ import { managedSkillsDirectory } from "./skills-sync.mjs";
23
22
 
24
23
  const CLAIM_PATTERN = /^hsc_[A-Za-z0-9_-]{43}$/;
25
24
 
@@ -197,9 +196,9 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
197
196
  ? "This reviewed host surface can report complete coverage when all declared evidence is observed."
198
197
  : `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
199
198
  "Authorized organization managers may review retained conversations and summaries.",
200
- skillsRoot
201
- ? `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.`
202
- : "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.",
203
202
  "Disconnecting stops future capture but does not erase retained data.",
204
203
  `Disclosure: ${DISCLOSURE_VERSION}`,
205
204
  ].join("\n");
@@ -252,8 +251,9 @@ export function allDisclosureText({ serverUrl, projectRoot, organization = null,
252
251
  lines.push(
253
252
  "",
254
253
  "Authorized organization managers may review retained conversations and summaries.",
255
- "This reads and writes only the skill folders it created for the hosts listed above; it does not scan " +
256
- "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.",
257
257
  "Disconnecting stops future capture but does not erase retained data.",
258
258
  `Disclosure: ${DISCLOSURE_VERSION}`,
259
259
  );
@@ -356,6 +356,7 @@ async function runAllInstaller(input, {
356
356
  root,
357
357
  fetchImpl,
358
358
  sendHeartbeat: false,
359
+ instructionProfile: instructionProfile(host.clientKind, skillsHome ? { home: skillsHome } : {}),
359
360
  });
360
361
  const configured = await configureHost(host.clientKind, {
361
362
  projectRoot: input.projectRoot,
@@ -371,18 +372,8 @@ async function runAllInstaller(input, {
371
372
  } catch {
372
373
  heartbeat = false;
373
374
  }
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 });
375
+ const delivery = await syncDeliveryForInstall({ installationId: installed.installationId, root, fetchImpl, skillsHome, output });
376
+ const { skills, context } = delivery;
386
377
  results.push({
387
378
  context,
388
379
  clientKind: host.clientKind,
@@ -395,6 +386,7 @@ async function runAllInstaller(input, {
395
386
  conflicts: skills.conflicts.map((c) => c.skillKey), errors: skills.errors.length }
396
387
  : null,
397
388
  proofStorage: installed.proofStorage,
389
+ instructions: installed.instructions,
398
390
  configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
399
391
  replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
400
392
  nextStep: `Restart ${lifecycleClient(host.clientKind).label}, then check the connection in Halofy.`,
@@ -460,6 +452,7 @@ export async function runInstaller(argv, {
460
452
  root,
461
453
  fetchImpl,
462
454
  sendHeartbeat: false,
455
+ instructionProfile: instructionProfile(input.clientKind, skillsHome ? { home: skillsHome } : {}),
463
456
  });
464
457
  const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
465
458
  const common = {
@@ -479,20 +472,8 @@ export async function runInstaller(argv, {
479
472
  } catch {
480
473
  heartbeat = false;
481
474
  }
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 });
475
+ const delivery = await syncDeliveryForInstall({ installationId: installed.installationId, root, fetchImpl, skillsHome, output });
476
+ const { skills, context } = delivery;
496
477
  return {
497
478
  context,
498
479
  status: heartbeat ? "configured_heartbeat_verified" : "configured_heartbeat_unavailable",
@@ -501,6 +482,7 @@ export async function runInstaller(argv, {
501
482
  installerVersion: INSTALLER_VERSION,
502
483
  publishedPackage: true,
503
484
  projectConfigured: true,
485
+ instructions: installed.instructions,
504
486
  configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
505
487
  replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
506
488
  mcpConfiguration: localMcpSnippet({
@@ -516,15 +498,13 @@ export async function runInstaller(argv, {
516
498
  };
517
499
  }
518
500
 
519
- async function syncContextForInstall({ installationId, root, fetchImpl, skillsHome, output }) {
501
+ async function syncDeliveryForInstall({ installationId, root, fetchImpl, skillsHome, output }) {
520
502
  try {
521
- const summary = await syncInstalledContext({
522
- installationId, root, fetchImpl, ...(skillsHome ? { home: skillsHome } : {}),
503
+ return await syncInstalledDelivery({
504
+ installationId, root, fetchImpl, ...(skillsHome ? { home: skillsHome } : {}), stderr: output,
523
505
  });
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" }] };
506
+ } catch {
507
+ output.write("delivery: unavailable\n");
508
+ return { skills: null, context: { complete: false, errors: [{ code: "runtime_unavailable" }] } };
529
509
  }
530
510
  }
@@ -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
+ }
@@ -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
+ }
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,