@halofy/agent-connect 0.9.0 → 0.11.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
@@ -1,7 +1,7 @@
1
1
  # Halofy agent lifecycle installer
2
2
 
3
- Status: production release candidate; activation remains gated on exact npm
4
- publication and deployed protocol verification.
3
+ Status: published installer (`@halofy/agent-connect@0.10.0`). The deployment
4
+ workflow pins the verified tarball; host loading requires separate verification.
5
5
 
6
6
  This package contains the host-neutral client pieces for installation-bound
7
7
  agent connections:
@@ -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,85 @@ 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
+
159
+ ## Connection health and local pause (0.11.0 source)
160
+
161
+ This source version adds a signed heartbeat immediately and every 60 seconds
162
+ while the existing MCP proxy runs. It inspects the installation's managed hook
163
+ and MCP configuration without repairing it. Health contains only a state; local
164
+ paths stay on disk. Legacy installations without inspection metadata report
165
+ `unknown`. Missing contact may mean an idle host, sleep, or network loss, and
166
+ cannot establish that an employee uninstalled the integration.
167
+
168
+ Use the installed runtime executable with `pause --connection <installation-id>`
169
+ or `resume --connection <installation-id>`. Pause persists for that installation,
170
+ attempts a bounded flush of already queued events, and skips subsequent capture
171
+ hooks. It does not block MCP. Resume reports inspected health and excludes older
172
+ transcript bodies from catch-up, so paused content is not backfilled. A failed
173
+ state report is retried by the next running-proxy heartbeat or session start.
174
+
175
+ JSON configuration checks require every managed hook registration and MCP entry.
176
+ Changed Kimi/Codex TOML is conservative: removed/disabled managed configuration
177
+ reports `hooks_missing`; other changes report `unknown` pending reinstallation.
178
+ This package does not add a TOML parser or a device-wide monitoring daemon.
179
+
180
+ The 0.11.0 source is not proof of a published or deployed artifact. Release must
181
+ publish the exact reviewed npm package, verify its integrity, update the server's
182
+ pinned installer artifact through its existing release process, and prove an
183
+ updated disposable installation. Existing local runtimes do not auto-upgrade.
184
+
185
+ ## Organization instructions (0.10.0)
186
+
187
+ This source adds Govern instruction delivery alongside the existing skill,
188
+ policy, knowledge and delivery-receipt paths. Activation requires the reviewed
189
+ 0.10.0 npm publication, matching server deployment and a fresh confirmed
190
+ installation. Source versioning alone is not publication evidence. Existing
191
+ connections without the new local instruction consent/profile snapshot do not
192
+ gain global file-writing authority.
193
+
194
+ The installer discloses global rule management alongside existing capture and
195
+ MCP behavior. It freezes the selected profile and server-provided namespace in
196
+ the local connection and fetches instructions with the installation proof.
197
+ Subsequent supported session-start hooks refresh them independently of disabled
198
+ memory recall. There is no resident instruction daemon; an offline device or a
199
+ host session without an installed startup hook does not fetch updates. Claude's
200
+ existing hook installation is project-scoped even though its rule file is global.
201
+
202
+ | Host | Local target | Boundaries |
203
+ |---|---|---|
204
+ | Codex | `CODEX_HOME` or `~/.codex`, active nonempty `AGENTS.override.md` else `AGENTS.md` | Marked section; changing the active target reports a conflict |
205
+ | Claude Code | `CLAUDE_CONFIG_DIR` or `~/.claude`, dedicated `rules/halofy-*.md` | Existing personal/project `CLAUDE.md` remains untouched |
206
+ | Gemini CLI | `GEMINI_CLI_HOME` or home, then `.gemini/GEMINI.md` | Marked section; custom discovery excluding `GEMINI.md` is unsupported |
207
+ | Cursor | `~/.cursor/rules/halofy-*.mdc` with `alwaysApply: true` | Agent Chat only; no claim for Tab, Inline Edit, or cloud hosts |
208
+ | Other packaged hosts | No instruction writes | Reports unsupported; existing capture/MCP continues |
209
+
210
+ Host mechanisms were checked against official documentation on 2026-09-10:
211
+ [Codex](https://learn.chatgpt.com/docs/agent-configuration/agents-md),
212
+ [Claude Code](https://code.claude.com/docs/en/memory),
213
+ [Gemini CLI](https://geminicli.com/docs/cli/gemini-md/), and
214
+ [Cursor](https://prod.cursor.com/help/customization/rules).
215
+ These are file-format adapters, not real-host loading certification. Host
216
+ precedence, context limits, project configuration and exclusion settings still
217
+ apply. Receipts say `pending_restart`, never loaded or obeyed. Other local OS
218
+ accounts, containers and remote/cloud profiles require their own installation.
219
+
220
+ Sync validates exact content and bundle digests, scope ancestry/order and size
221
+ before writing. Personal bytes outside a managed block are preserved exactly.
222
+ An isolated ownership manifest, exclusive profile lock, no-follow reads,
223
+ component symlink checks, private backups, concurrent-edit checks and atomic
224
+ replacement protect local content. Unexpected edits, broken/duplicate markers,
225
+ linked files or changed target selection fail without replacing host content.
226
+ Request failure leaves the current installation’s instructions unchanged. A
227
+ confirmed reconnect first retires only verified predecessor-owned instruction
228
+ bytes; old hooks cannot restore them. Conflicting predecessor edits prevent new
229
+ instruction activation while preserving all user content. Otherwise, only a
230
+ verified empty bundle removes managed content; an empty file and removal manifest remain so a
231
+ future authorized re-enable can be recognized safely. Backup files stay outside
232
+ host rules directories, under the runtime instructions directory.
233
+
234
+ A stale lock is deliberately not automatically deleted; after confirming no
235
+ sync is running, an operator may remove `.halofy-instructions.lock` in the
236
+ selected profile. Local filesystem errors and receipt failures do not interrupt
237
+ capture or MCP. Neither instruction text, filesystem paths nor backups are sent
238
+ in status receipts. Existing policy/knowledge/skill operations and runtime queue
239
+ files are not changed by instruction sync.
@@ -5,6 +5,8 @@ import { runStdioMcpProxy } from "../src/mcp-proxy.mjs";
5
5
  import { BoundedEncryptedQueue } from "../src/queue.mjs";
6
6
  import { runClaudeLifecycleHook } from "../src/claude-hook.mjs";
7
7
  import { HOST_HOOK_EVENTS, runHostLifecycleHook } from "../src/host-hook.mjs";
8
+ import { LifecycleRuntime } from "../src/runtime.mjs";
9
+ import { setCapturePaused } from "../src/health.mjs";
8
10
  import { join } from "node:path";
9
11
 
10
12
  function option(name) {
@@ -18,9 +20,9 @@ const claudeHookEvents = new Set([
18
20
  "SessionStart", "UserPromptSubmit", "Stop", "PreCompact", "SessionEnd",
19
21
  "SubagentStart", "SubagentStop", "PostToolUse", "PostToolUseFailure", "PostToolBatch",
20
22
  ]);
21
- if (!["mcp", "diagnostics", "hook"].includes(command) ||
23
+ if (!["mcp", "diagnostics", "hook", "pause", "resume"].includes(command) ||
22
24
  (command === "hook" && !claudeHookEvents.has(hookEvent) && !HOST_HOOK_EVENTS.has(hookEvent))) {
23
- process.stderr.write("Usage: halofy-agent <mcp|diagnostics|hook EVENT> [--connection <installation-id>]\n");
25
+ process.stderr.write("Usage: halofy-agent <mcp|diagnostics|hook EVENT|pause|resume> [--connection <installation-id>]\n");
24
26
  process.exitCode = 2;
25
27
  } else {
26
28
  try {
@@ -31,6 +33,10 @@ if (!["mcp", "diagnostics", "hook"].includes(command) ||
31
33
  if (!connection) throw new Error("no active installation-bound connection");
32
34
  if (command === "mcp") {
33
35
  await runStdioMcpProxy(connection);
36
+ } else if (command === "pause" || command === "resume") {
37
+ const root = defaultRuntimeDirectory();
38
+ const runtime = new LifecycleRuntime(connection, { root });
39
+ process.stdout.write(`${JSON.stringify(await setCapturePaused(runtime, root, command === "pause"))}\n`);
34
40
  } else if (command === "hook") {
35
41
  if (connection.clientKind === "claude-code") await runClaudeLifecycleHook(connection, hookEvent);
36
42
  else await runHostLifecycleHook(connection, hookEvent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halofy/agent-connect",
3
- "version": "0.9.0",
3
+ "version": "0.11.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": {
@@ -1,3 +1,4 @@
1
+ import { captureStateSnapshot } from "./health.mjs";
1
2
  import { createHash } from "node:crypto";
2
3
  import { basename } from "node:path";
3
4
  import { LifecycleRuntime } from "./runtime.mjs";
@@ -91,11 +92,27 @@ export async function runClaudeLifecycleHook(connection, eventName, {
91
92
  const hookInput = input ?? await readHookInput();
92
93
  const session = hostSession(hookInput);
93
94
  if (!session) return { handled: true };
94
- const runtime = runtimeFactory(connection, { root });
95
+ const capture = await captureStateSnapshot(root, connection.installationId);
96
+ const runtime = runtimeFactory(connection, { root, captureGeneration: capture.generation });
97
+ if (capture.paused) {
98
+ if (eventName === "SessionStart") {
99
+ await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
100
+ // Capture pause does not suspend governed instruction delivery.
101
+ await runtime.syncInstructions?.().catch(() => {});
102
+ await runtime.heartbeat(connection.capabilities || {});
103
+ } else if (eventName === "UserPromptSubmit") {
104
+ const delivery = await syncManagedDelivery({ connection, transport: runtime.transport, root, home, env, stderr, phase: "turn" });
105
+ const output = recallText(null, "UserPromptSubmit", delivery.notice);
106
+ if (output) stdout.write(output);
107
+ }
108
+ return { handled: true, paused: true };
109
+ }
95
110
 
96
111
  if (eventName === "SessionStart") {
97
112
  // Refresh/withdraw local context even when replay or heartbeat fails.
98
113
  await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
114
+ // Instructions have their own brownout boundary, independent of recall.
115
+ await runtime.syncInstructions?.().catch(() => {});
99
116
  await runtime.replay();
100
117
  await runtime.heartbeat(connection.capabilities || {});
101
118
  if (RECALL_INJECTION_ENABLED) {
package/src/health.mjs ADDED
@@ -0,0 +1,156 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { SignedRuntimeTransport } from "./transport.mjs";
6
+ import { readJson, writePrivateFile, withFileLock } from "./storage.mjs";
7
+
8
+ const statePath = (root, id) => join(root, id, "capture-state.json");
9
+ const targetsPath = (root, id) => join(root, id, "health-targets.json");
10
+
11
+ export async function capturePaused(root, installationId) {
12
+ // Corrupt local state cannot silently enable capture.
13
+ try { return (await readJson(statePath(root, installationId)))?.paused === true; }
14
+ catch { return true; }
15
+ }
16
+
17
+ export async function captureStateSnapshot(root, installationId) {
18
+ try {
19
+ const state = await readJson(statePath(root, installationId), {});
20
+ return { paused: state.paused === true, generation: state.generation ?? null, captureAfter: state.captureAfter ?? null };
21
+ } catch { return { paused: true, generation: null, captureAfter: null }; }
22
+ }
23
+
24
+ export async function captureAdmission(root, installationId, generation) {
25
+ const current = await captureStateSnapshot(root, installationId);
26
+ return !current.paused && current.generation === generation;
27
+ }
28
+
29
+ /** Local-only install metadata. Never send paths or commands as health evidence. */
30
+ export async function recordHealthTargets({ root, installationId, configured, runtimePath }) {
31
+ const paths = configured.configuredPaths || [configured.mcpPath, configured.settingsPath];
32
+ const targets = [];
33
+ const foundEvents = new Set();
34
+ for (const path of paths) {
35
+ const text = await readFile(path, "utf8");
36
+ if (path.endsWith(".json")) {
37
+ const value = JSON.parse(text);
38
+ const hooks = {};
39
+ for (const event of configured.hookEvents) {
40
+ const entries = value.hooks?.[event];
41
+ if (!Array.isArray(entries)) continue;
42
+ const managed = entries.filter((entry) => JSON.stringify(entry).includes(installationId) &&
43
+ JSON.stringify(entry).includes("--connection"));
44
+ if (managed.length) { hooks[event] = managed; foundEvents.add(event); }
45
+ }
46
+ const registry = value.mcpServers ? "mcpServers" : value.servers ? "servers" : null;
47
+ targets.push({ path, kind: "json", hooks, ...(registry ? { registry, mcp: value[registry].halofy } : {}) });
48
+ } else {
49
+ // TOML is deliberately conservative: without a bundled TOML parser a
50
+ // changed document cannot prove that hooks are still enabled.
51
+ const managedText = text.match(/# BEGIN HALOFY LIFECYCLE[\s\S]*?# END HALOFY LIFECYCLE/)?.[0];
52
+ targets.push({ path, kind: path.endsWith(".toml") ? "toml" : "script", text,
53
+ ...(managedText ? { managedText } : {}) });
54
+ for (const event of configured.hookEvents) {
55
+ if (text.includes(`event = ${JSON.stringify(event)}`) || path.endsWith(`/${event}`)) foundEvents.add(event);
56
+ }
57
+ }
58
+ }
59
+ if (configured.hookEvents.some((event) => !foundEvents.has(event))) throw new Error("managed hook inspection metadata incomplete");
60
+ await writePrivateFile(targetsPath(root, installationId), JSON.stringify({ version: 1, runtimePath, targets }));
61
+ }
62
+
63
+ function disabled(value) {
64
+ return value?.enabled === false || value?.disabled === true || value?.disableAllHooks === true;
65
+ }
66
+
67
+ export async function inspectCaptureHealth(connection, root) {
68
+ if (await capturePaused(root, connection.installationId)) return { captureState: "paused" };
69
+ let metadata;
70
+ try { metadata = await readJson(targetsPath(root, connection.installationId)); }
71
+ catch { return { captureState: "unknown" }; }
72
+ if (metadata?.version !== 1 || !metadata.targets?.length) return { captureState: "unknown" };
73
+ let unknown = false;
74
+ try {
75
+ if (!(await stat(metadata.runtimePath)).isFile()) return { captureState: "hooks_missing" };
76
+ for (const target of metadata.targets) {
77
+ const text = await readFile(target.path, "utf8");
78
+ if (target.kind === "json") {
79
+ const value = JSON.parse(text);
80
+ if (disabled(value) || disabled(value.hooks) || value.hooksConfig?.enabled === false) return { captureState: "hooks_missing" };
81
+ for (const [event, expected] of Object.entries(target.hooks)) {
82
+ const entries = value.hooks?.[event];
83
+ if (!Array.isArray(entries) || !expected.every((entry) => entries.some((actual) =>
84
+ !disabled(actual) && isDeepStrictEqual(actual, entry)))) return { captureState: "hooks_missing" };
85
+ }
86
+ if (target.registry && (!isDeepStrictEqual(value[target.registry]?.halofy, target.mcp) ||
87
+ disabled(value[target.registry]?.halofy))) return { captureState: "hooks_missing" };
88
+ } else if (target.kind === "script") {
89
+ if (text !== target.text || (process.platform !== "win32" && ((await stat(target.path)).mode & 0o111) === 0)) {
90
+ return { captureState: "hooks_missing" };
91
+ }
92
+ } else if (text !== target.text) {
93
+ if ((target.managedText && !text.includes(target.managedText)) || /^\s*(?:hooks|enabled)\s*=\s*false\b/m.test(text) ||
94
+ !text.includes(connection.installationId) || !text.includes("--connection")) return { captureState: "hooks_missing" };
95
+ unknown = true;
96
+ }
97
+ }
98
+ } catch (error) {
99
+ return { captureState: error?.code === "ENOENT" || error instanceof SyntaxError ? "hooks_missing" : "unknown" };
100
+ }
101
+ return { captureState: unknown ? "unknown" : "active" };
102
+ }
103
+
104
+ /** Pause applies before flushing so concurrent future hooks cannot enqueue. */
105
+ export async function setCapturePaused(runtime, root, paused, { flushTimeoutMs = 5_000 } = {}) {
106
+ // Queue admission and pause persistence share a short lock. A capture read
107
+ // may still be running, but cannot enqueue across this state transition.
108
+ await withFileLock(runtime.queue.lockPath, async () => {
109
+ const previous = await readJson(statePath(root, runtime.connection.installationId), {});
110
+ const now = new Date().toISOString();
111
+ const resumedAt = !paused && previous.paused === true ? now : previous.captureAfter;
112
+ await writePrivateFile(statePath(root, runtime.connection.installationId), JSON.stringify({
113
+ paused, changedAt: now, generation: randomUUID(), ...(resumedAt ? { captureAfter: resumedAt } : {}),
114
+ }));
115
+ });
116
+ let flushed = null;
117
+ if (paused) {
118
+ // Dedicated flush transport has one deadline for the entire replay, not
119
+ // one fresh timeout for every batch. The existing proxy keeps running.
120
+ const controller = new AbortController();
121
+ const timer = setTimeout(() => controller.abort(), flushTimeoutMs);
122
+ try {
123
+ const flushRuntime = new runtime.constructor(runtime.connection, { root,
124
+ transport: new SignedRuntimeTransport(runtime.connection, {
125
+ fetchImpl: runtime.transport.fetch, signal: controller.signal, timeoutMs: flushTimeoutMs,
126
+ }),
127
+ });
128
+ await flushRuntime.replay();
129
+ flushed = true;
130
+ } catch { flushed = false; }
131
+ finally { clearTimeout(timer); }
132
+ }
133
+ let reported = false;
134
+ try { await runtime.heartbeat(runtime.connection.capabilities || {}); reported = true; } catch { /* next proxy tick retries */ }
135
+ return { paused, reported, ...(paused ? { flushed } : {}) };
136
+ }
137
+
138
+ /** No overlapping checks and no timers survive proxy shutdown. */
139
+ export function startHealthHeartbeat(runtime, {
140
+ intervalMs = 60_000,
141
+ setIntervalImpl = setInterval,
142
+ clearIntervalImpl = clearInterval,
143
+ } = {}) {
144
+ let stopped = false;
145
+ let pending = null;
146
+ const tick = () => {
147
+ if (stopped || pending) return pending;
148
+ pending = Promise.resolve().then(() => runtime.heartbeat(runtime.connection.capabilities || {}))
149
+ .catch(() => {}).finally(() => { pending = null; });
150
+ return pending;
151
+ };
152
+ void tick();
153
+ const timer = setIntervalImpl(() => { void tick(); }, intervalMs);
154
+ timer?.unref?.();
155
+ return { tick, stop() { stopped = true; clearIntervalImpl(timer); } };
156
+ }
package/src/host-hook.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { captureStateSnapshot } from "./health.mjs";
1
2
  import { createHash } from "node:crypto";
2
3
  import { basename } from "node:path";
3
4
  import { LifecycleRuntime } from "./runtime.mjs";
@@ -156,11 +157,27 @@ export async function runHostLifecycleHook(connection, eventName, {
156
157
  const hookInput = input ?? await readHookInput();
157
158
  const session = hostSession(hookInput);
158
159
  if (!session) return { handled: true, unavailable: "missing_host_session" };
159
- const runtime = runtimeFactory(connection, { root });
160
+ const capture = await captureStateSnapshot(root, connection.installationId);
161
+ const runtime = runtimeFactory(connection, { root, captureGeneration: capture.generation });
162
+ if (capture.paused) {
163
+ if (START_EVENTS.has(eventName)) {
164
+ await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
165
+ // Capture pause does not suspend governed instruction delivery.
166
+ await runtime.syncInstructions?.().catch(() => {});
167
+ await runtime.heartbeat(connection.capabilities || {});
168
+ } else if (USER_EVENTS.has(eventName)) {
169
+ const delivery = await syncManagedDelivery({ connection, transport: runtime.transport, root, home, env, stderr, phase: "turn" });
170
+ const output = recallOutput(connection.clientKind, eventName, null, delivery.notice);
171
+ if (output) stdout.write(output);
172
+ }
173
+ return { handled: true, paused: true };
174
+ }
160
175
 
161
176
  if (START_EVENTS.has(eventName)) {
162
177
  // Refresh/withdraw local context even when replay or heartbeat fails.
163
178
  await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
179
+ // Instructions have their own brownout boundary, independent of recall.
180
+ await runtime.syncInstructions?.().catch(() => {});
164
181
  await runtime.replay();
165
182
  await runtime.heartbeat(connection.capabilities || {});
166
183
  if (RECALL_INJECTION_ENABLED) {
package/src/install.mjs CHANGED
@@ -4,7 +4,9 @@ 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";
9
+ import { inspectCaptureHealth } from "./health.mjs";
8
10
  import { INSTALLER_VERSION, RUNTIME_VERSION } from "./version.mjs";
9
11
  import { CLIENT_KINDS, CLIENT_REGISTRY, lifecycleClient } from "./client-registry.mjs";
10
12
  import { syncManagedSkills } from "./skills-sync.mjs";
@@ -151,6 +153,7 @@ export async function installLocalConnection({
151
153
  root = defaultRuntimeDirectory(),
152
154
  fetchImpl = globalThis.fetch,
153
155
  sendHeartbeat = true,
156
+ instructionProfile,
154
157
  }) {
155
158
  if (!CLIENT_KINDS.includes(clientKind)) throw new Error("the selected lifecycle adapter is not packaged");
156
159
  const client = lifecycleClient(clientKind);
@@ -201,6 +204,8 @@ export async function installLocalConnection({
201
204
  pluginVersion: RUNTIME_VERSION,
202
205
  proofStorage,
203
206
  installedAt: new Date().toISOString(),
207
+ ...(typeof consumed.namespace === "string" ? { namespace: consumed.namespace } : {}),
208
+ ...(instructionProfile ? { instructionProfile } : {}),
204
209
  };
205
210
  await store.save(connection);
206
211
  await writePrivateFile(join(root, `active-${clientKind}.json`), `${JSON.stringify({
@@ -217,6 +222,7 @@ export async function installLocalConnection({
217
222
  try {
218
223
  await new SignedRuntimeTransport(connection, { fetchImpl }).heartbeat(client.capabilities, {
219
224
  pluginVersion: RUNTIME_VERSION,
225
+ health: await inspectCaptureHealth(connection, root),
220
226
  proofStorage,
221
227
  diagnostics: { queueDepth: 0, oldestPendingAt: null, expiredCount: 0 },
222
228
  });
@@ -225,9 +231,13 @@ export async function installLocalConnection({
225
231
  heartbeat = false;
226
232
  }
227
233
  }
234
+ const instructions = instructionProfile
235
+ ? await syncAgentInstructions(connection, { root, transport: new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 2_000 }) })
236
+ : undefined;
228
237
  return {
229
238
  installationId,
230
239
  previousInstallationId: connection.previousInstallationId ?? null,
240
+ ...(instructions ? { instructions } : {}),
231
241
  heartbeat,
232
242
  reused: false,
233
243
  proofStorage: connection.proofStorage,
@@ -266,6 +276,7 @@ export async function heartbeatInstalledConnection({
266
276
  const connection = await new ConnectionStore(root).load(installationId);
267
277
  await new SignedRuntimeTransport(connection, { fetchImpl }).heartbeat(connection.capabilities, {
268
278
  pluginVersion: connection.pluginVersion,
279
+ health: await inspectCaptureHealth(connection, root),
269
280
  proofStorage: connection.proofStorage,
270
281
  diagnostics: { queueDepth: 0, oldestPendingAt: null, expiredCount: 0 },
271
282
  });
@@ -1,3 +1,4 @@
1
+ import { recordHealthTargets } from "./health.mjs";
1
2
  import { spawnSync } from "node:child_process";
2
3
  import { existsSync, readdirSync } from "node:fs";
3
4
  import { createInterface } from "node:readline/promises";
@@ -16,6 +17,7 @@ import { configureClaudeProject } from "./claude-config.mjs";
16
17
  import { configureCline, configureCodex, configureCursor, configureGemini, configureKimi, configureVscode } from "./host-config.mjs";
17
18
  import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
18
19
  import { defaultRuntimeDirectory } from "./storage.mjs";
20
+ import { instructionProfile } from "./instructions.mjs";
19
21
  import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
20
22
  import { managedSkillsDirectory } from "./skills-sync.mjs";
21
23
 
@@ -195,9 +197,9 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
195
197
  ? "This reviewed host surface can report complete coverage when all declared evidence is observed."
196
198
  : `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
197
199
  "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.",
200
+ "This reads and writes its managed skill folders and instruction sections; it does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
201
+ "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.",
202
+ "Other OS accounts, containers, remote/cloud hosts and unsupported agents are not covered. Instruction precedence remains controlled by the host.",
201
203
  "Disconnecting stops future capture but does not erase retained data.",
202
204
  `Disclosure: ${DISCLOSURE_VERSION}`,
203
205
  ].join("\n");
@@ -250,8 +252,9 @@ export function allDisclosureText({ serverUrl, projectRoot, organization = null,
250
252
  lines.push(
251
253
  "",
252
254
  "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.",
255
+ "This reads and writes its managed skill folders and instruction sections; it does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
256
+ "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.",
257
+ "Other OS accounts, containers, remote/cloud hosts and unsupported agents are not covered. Instruction precedence remains controlled by the host.",
255
258
  "Disconnecting stops future capture but does not erase retained data.",
256
259
  `Disclosure: ${DISCLOSURE_VERSION}`,
257
260
  );
@@ -354,6 +357,7 @@ async function runAllInstaller(input, {
354
357
  root,
355
358
  fetchImpl,
356
359
  sendHeartbeat: false,
360
+ instructionProfile: instructionProfile(host.clientKind, skillsHome ? { home: skillsHome } : {}),
357
361
  });
358
362
  const configured = await configureHost(host.clientKind, {
359
363
  projectRoot: input.projectRoot,
@@ -361,6 +365,7 @@ async function runAllInstaller(input, {
361
365
  serverUrl: input.serverUrl,
362
366
  runtimePath: bundle.runtimePath,
363
367
  }, { claudeConfigPath });
368
+ await recordHealthTargets({ root, installationId: installed.installationId, configured, runtimePath: bundle.runtimePath });
364
369
  let heartbeat = false;
365
370
  try {
366
371
  heartbeat = await heartbeatInstalledConnection({
@@ -383,6 +388,7 @@ async function runAllInstaller(input, {
383
388
  conflicts: skills.conflicts.map((c) => c.skillKey), errors: skills.errors.length }
384
389
  : null,
385
390
  proofStorage: installed.proofStorage,
391
+ instructions: installed.instructions,
386
392
  configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
387
393
  replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
388
394
  nextStep: `Restart ${lifecycleClient(host.clientKind).label}, then check the connection in Halofy.`,
@@ -448,6 +454,7 @@ export async function runInstaller(argv, {
448
454
  root,
449
455
  fetchImpl,
450
456
  sendHeartbeat: false,
457
+ instructionProfile: instructionProfile(input.clientKind, skillsHome ? { home: skillsHome } : {}),
451
458
  });
452
459
  const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
453
460
  const common = {
@@ -457,6 +464,7 @@ export async function runInstaller(argv, {
457
464
  runtimePath: bundle.runtimePath,
458
465
  };
459
466
  const configured = await configureHost(input.clientKind, common, { claudeConfigPath });
467
+ await recordHealthTargets({ root, installationId: installed.installationId, configured, runtimePath: bundle.runtimePath });
460
468
  let heartbeat = false;
461
469
  try {
462
470
  heartbeat = await heartbeatInstalledConnection({
@@ -477,6 +485,7 @@ export async function runInstaller(argv, {
477
485
  installerVersion: INSTALLER_VERSION,
478
486
  publishedPackage: true,
479
487
  projectConfigured: true,
488
+ instructions: installed.instructions,
480
489
  configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
481
490
  replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
482
491
  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/mcp-proxy.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import { LifecycleRuntime } from "./runtime.mjs";
2
+ import { defaultRuntimeDirectory } from "./storage.mjs";
3
+ import { startHealthHeartbeat } from "./health.mjs";
1
4
  import { createInterface } from "node:readline";
2
5
  import { SignedRuntimeTransport } from "./transport.mjs";
3
6
 
@@ -24,34 +27,45 @@ export async function runStdioMcpProxy(connection, {
24
27
  input = process.stdin,
25
28
  output = process.stdout,
26
29
  fetchImpl = globalThis.fetch,
30
+ root = defaultRuntimeDirectory(),
31
+ heartbeatOptions,
27
32
  } = {}) {
33
+ const healthController = new AbortController();
34
+ const healthRuntime = new LifecycleRuntime(connection, { root, transport: new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 5_000, signal: healthController.signal }) });
35
+ const heartbeat = startHealthHeartbeat(healthRuntime, heartbeatOptions);
28
36
  const transport = new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 30_000 });
29
37
  let mcpSessionId = null;
30
38
  const lines = createInterface({ input, crlfDelay: Infinity });
31
- for await (const line of lines) {
32
- if (!line.trim()) continue;
33
- let parsed;
34
- try { parsed = JSON.parse(line); } catch { continue; }
35
- try {
36
- const response = await transport.request("/mcp", {
37
- body: JSON.stringify(parsed),
38
- headers: {
39
- Accept: "application/json, text/event-stream",
40
- ...(mcpSessionId ? { "Mcp-Session-Id": mcpSessionId } : {}),
41
- },
42
- raw: true,
43
- });
44
- mcpSessionId = response.headers.get("mcp-session-id") || mcpSessionId;
45
- await relayResponse(response, parsed, output);
46
- } catch {
47
- if (parsed.id !== undefined) {
48
- output.write(`${JSON.stringify({
49
- jsonrpc: "2.0",
50
- id: parsed.id,
51
- error: { code: -32000, message: "Halofy MCP transport unavailable" },
52
- })}\n`);
39
+ try {
40
+ for await (const line of lines) {
41
+ if (!line.trim()) continue;
42
+ let parsed;
43
+ try { parsed = JSON.parse(line); } catch { continue; }
44
+ try {
45
+ const response = await transport.request("/mcp", {
46
+ body: JSON.stringify(parsed),
47
+ headers: {
48
+ Accept: "application/json, text/event-stream",
49
+ ...(mcpSessionId ? { "Mcp-Session-Id": mcpSessionId } : {}),
50
+ },
51
+ raw: true,
52
+ });
53
+ mcpSessionId = response.headers.get("mcp-session-id") || mcpSessionId;
54
+ await relayResponse(response, parsed, output);
55
+ } catch {
56
+ if (parsed.id !== undefined) {
57
+ output.write(`${JSON.stringify({
58
+ jsonrpc: "2.0",
59
+ id: parsed.id,
60
+ error: { code: -32000, message: "Halofy MCP transport unavailable" },
61
+ })}\n`);
62
+ }
53
63
  }
54
64
  }
65
+ } finally {
66
+ heartbeat.stop();
67
+ healthController.abort();
68
+ lines.close();
55
69
  }
56
70
  }
57
71
 
package/src/queue.mjs CHANGED
@@ -141,10 +141,12 @@ export class BoundedEncryptedQueue {
141
141
  maxBatchEvents = 100,
142
142
  maxBatchBytes = 1024 * 1024,
143
143
  acknowledgedSequence = 0,
144
+ canEnqueue,
144
145
  } = {}) {
145
146
  return withFileLock(this.lockPath, async () => {
146
147
  const state = await this.#read();
147
148
  this.#sweep(state);
149
+ if (canEnqueue && !await canEnqueue()) return { queued: 0, skipped: true };
148
150
  const pending = state.items.filter((item) => item.sessionHash === sessionHash).flatMap((item) => item.events || []);
149
151
  const existingKeys = new Set(pending.map((event) => event.eventKey));
150
152
  let sequence = pending.reduce(
package/src/runtime.mjs CHANGED
@@ -8,19 +8,26 @@ 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 { capturePaused, captureStateSnapshot, captureAdmission, inspectCaptureHealth } from "./health.mjs";
12
+ import { syncAgentInstructions } from "./instructions.mjs";
11
13
  import { RUNTIME_VERSION } from "./version.mjs";
12
14
 
13
15
  export class LifecycleRuntime {
14
16
  constructor(connection, {
15
17
  root,
16
18
  transport = new SignedRuntimeTransport(connection),
19
+ instructionTransport = new SignedRuntimeTransport(connection, { timeoutMs: 2_000 }),
17
20
  maxBatchEvents = 100,
18
21
  maxBatchBytes = 1024 * 1024,
19
22
  commitThreshold = 20,
23
+ captureGeneration,
20
24
  } = {}) {
21
25
  if (!root) throw new Error("runtime storage root is required");
22
26
  this.connection = connection;
27
+ this.root = root;
28
+ this.captureGeneration = captureGeneration;
23
29
  this.transport = transport;
30
+ this.instructionTransport = instructionTransport;
24
31
  const connectionRoot = join(root, connection.installationId);
25
32
  this.queue = new BoundedEncryptedQueue(connectionRoot);
26
33
  this.cursors = new CursorStore(connectionRoot);
@@ -32,6 +39,13 @@ export class LifecycleRuntime {
32
39
  this.sessions = new Map();
33
40
  }
34
41
 
42
+ async syncInstructions() {
43
+ // Legacy bindings have no consent/profile snapshot. Reinstall the reviewed
44
+ // package to enable global instruction management.
45
+ if (!this.connection.instructionProfile) return { status: "unsupported", reason: "reinstall_required" };
46
+ return syncAgentInstructions(this.connection, { root: this.root, transport: this.instructionTransport });
47
+ }
48
+
35
49
  sessionHash(hostSessionId) {
36
50
  return deriveSessionHash({
37
51
  installationId: this.connection.installationId,
@@ -115,18 +129,23 @@ export class LifecycleRuntime {
115
129
  }
116
130
 
117
131
  async captureHostTranscript(hostSessionId, driver, transcriptPath, sessionFacts = {}) {
132
+ const capture = await captureStateSnapshot(this.root, this.connection.installationId);
133
+ if (capture.paused) return { queued: 0, acknowledged: 0, paused: true };
118
134
  const sessionHash = this.sessionHash(hostSessionId);
119
135
  const policy = await this.capturePolicy();
120
136
  const queued = await withFileLock(this.operationLockPath, async () => {
137
+ if (await capturePaused(this.root, this.connection.installationId)) return { queued: 0 };
121
138
  const cursor = await this.cursors.get(sessionHash);
122
- const suffix = await driver.readSuffix(transcriptPath, cursor, sessionHash);
139
+ const resumedAt = capture.captureAfter;
140
+ const suffix = await driver.readSuffix(transcriptPath, cursor, sessionHash, { captureAfter: resumedAt });
123
141
  const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
124
- const unseenEvents = suffix.events.filter((event) => !recentEventKeys.has(event.eventKey));
142
+ const unseenEvents = suffix.events.filter((event) => !recentEventKeys.has(event.eventKey) &&
143
+ (!resumedAt || Date.parse(event.occurredAt) >= Date.parse(resumedAt)));
125
144
  // One metadata event whenever the observed content-free session facts
126
145
  // change. The event key hashes the payload, so an unchanged snapshot is
127
146
  // deduplicated exactly like any repeated event.
128
147
  const metadataPayload = driver.buildMetadataPayload(
129
- { ...suffix.metadata, ...sessionFacts },
148
+ { ...suffix.metadata, ...(!resumedAt ? sessionFacts : {}) },
130
149
  { installationId: this.connection.installationId, deviceContext: policy.deviceContext },
131
150
  );
132
151
  if (Object.keys(metadataPayload).length > 0) {
@@ -141,7 +160,9 @@ export class LifecycleRuntime {
141
160
  maxBatchEvents: this.maxBatchEvents,
142
161
  maxBatchBytes: this.maxBatchBytes,
143
162
  acknowledgedSequence: cursor.sequence,
163
+ canEnqueue: () => captureAdmission(this.root, this.connection.installationId, this.captureGeneration === undefined ? capture.generation : this.captureGeneration),
144
164
  });
165
+ if (result.skipped) return result;
145
166
  // Complete ignored/malformed JSONL records carry no event, while every
146
167
  // normalized event is already durable in the encrypted queue. Advancing
147
168
  // this byte cursor prevents unbounded reparsing without advancing the
@@ -166,36 +187,44 @@ export class LifecycleRuntime {
166
187
  }
167
188
 
168
189
  async enqueueEvents(hostSessionId, events) {
190
+ const capture = await captureStateSnapshot(this.root, this.connection.installationId);
191
+ if (capture.paused) return { queued: 0, acknowledged: 0, paused: true };
169
192
  const sessionHash = this.sessionHash(hostSessionId);
170
193
  await withFileLock(this.operationLockPath, async () => {
171
- await this.#enqueueEventsUnlocked(sessionHash, events);
194
+ if (await capturePaused(this.root, this.connection.installationId)) return;
195
+ await this.#enqueueEventsUnlocked(sessionHash, events, undefined, this.captureGeneration === undefined ? capture.generation : this.captureGeneration);
172
196
  });
173
197
  return this.replay();
174
198
  }
175
199
 
176
200
  async enqueueSequencedEvents(hostSessionId, factory) {
201
+ const capture = await captureStateSnapshot(this.root, this.connection.installationId);
202
+ if (capture.paused) return { queued: 0, acknowledged: 0, paused: true };
177
203
  const sessionHash = this.sessionHash(hostSessionId);
178
204
  await withFileLock(this.operationLockPath, async () => {
205
+ if (await capturePaused(this.root, this.connection.installationId)) return;
179
206
  const cursor = await this.cursors.get(sessionHash);
180
207
  const events = factory({ sessionHash, nextSequence: cursor.sequence + 1 });
181
208
  if (!Array.isArray(events) || events.length < 1 || events.length > this.maxBatchEvents) {
182
209
  throw new Error("sequenced host event batch is invalid");
183
210
  }
184
- await this.#enqueueEventsUnlocked(sessionHash, events, cursor);
211
+ await this.#enqueueEventsUnlocked(sessionHash, events, cursor, this.captureGeneration === undefined ? capture.generation : this.captureGeneration);
185
212
  });
186
213
  return this.replay();
187
214
  }
188
215
 
189
- async #enqueueEventsUnlocked(sessionHash, events, knownCursor) {
216
+ async #enqueueEventsUnlocked(sessionHash, events, knownCursor, generation) {
190
217
  const cursor = knownCursor || await this.cursors.get(sessionHash);
191
218
  const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
192
219
  const unseenEvents = events.filter((event) => !recentEventKeys.has(event.eventKey));
193
220
  if (unseenEvents.length === 0) return;
194
- await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
221
+ const result = await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
195
222
  maxBatchEvents: this.maxBatchEvents,
196
223
  maxBatchBytes: this.maxBatchBytes,
197
224
  acknowledgedSequence: cursor.sequence,
225
+ canEnqueue: () => captureAdmission(this.root, this.connection.installationId, generation),
198
226
  });
227
+ if (result.skipped) return;
199
228
  await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
200
229
  }
201
230
 
@@ -290,6 +319,7 @@ export class LifecycleRuntime {
290
319
  const queue = await this.queue.diagnostics();
291
320
  const usageGaps = await this.cursors.peekUsageGaps();
292
321
  const response = await this.transport.heartbeat(capabilities, {
322
+ health: await inspectCaptureHealth(this.connection, this.root),
293
323
  pluginVersion: this.connection.pluginVersion || `${RUNTIME_VERSION}-local`,
294
324
  proofStorage: this.connection.proofStorage || "unknown",
295
325
  diagnostics: {
package/src/session.mjs CHANGED
@@ -664,7 +664,7 @@ function collectClaudeMetadata(metadata, entry) {
664
664
  if (cwd) metadata.cwd = cwd;
665
665
  }
666
666
 
667
- export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
667
+ export async function readClaudeTranscriptSuffix(path, cursor, sessionHash, { captureAfter } = {}) {
668
668
  const bytes = await readFile(path);
669
669
  const start = Number.isSafeInteger(cursor?.byteOffset) && cursor.byteOffset <= bytes.length ? cursor.byteOffset : 0;
670
670
  const suffix = bytes.subarray(start);
@@ -691,6 +691,10 @@ export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
691
691
  };
692
692
  try {
693
693
  const entry = JSON.parse(raw);
694
+ // Filter raw records before fallback timestamps, metadata collection, or
695
+ // normalization. Unknown source time cannot prove post-resume consent.
696
+ if (captureAfter && !(typeof entry?.timestamp === "string" &&
697
+ Date.parse(entry.timestamp) >= Date.parse(captureAfter))) continue;
694
698
  if (entry !== null && typeof entry === "object") {
695
699
  collectClaudeMetadata(metadata, entry);
696
700
  if (entry.type === "assistant" && Array.isArray(entry.message?.content)) {
@@ -705,7 +709,7 @@ export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
705
709
  }
706
710
  events.push(...normalizeClaudeTranscriptEntry(entry, evidence));
707
711
  } catch {
708
- events.push(normalizeMalformedClaudeTranscriptLine(rawBytes, evidence));
712
+ if (!captureAfter) events.push(normalizeMalformedClaudeTranscriptLine(rawBytes, evidence));
709
713
  }
710
714
  }
711
715
  // Claude repeats the same usage record on every JSONL line of a multi-block
@@ -19,8 +19,8 @@ export const claudeTranscriptDriver = {
19
19
  return null;
20
20
  },
21
21
 
22
- readSuffix(path, cursor, sessionHash) {
23
- return readClaudeTranscriptSuffix(path, cursor, sessionHash);
22
+ readSuffix(path, cursor, sessionHash, options) {
23
+ return readClaudeTranscriptSuffix(path, cursor, sessionHash, options);
24
24
  },
25
25
 
26
26
  buildMetadataPayload(metadata, context) {
@@ -124,7 +124,7 @@ export const codexTranscriptDriver = {
124
124
  return path === null ? null : { path };
125
125
  },
126
126
 
127
- async readSuffix(path, cursor, _sessionHash) {
127
+ async readSuffix(path, cursor, _sessionHash, { captureAfter } = {}) {
128
128
  const { buffer, start } = await readSuffixWindow(path, cursor?.byteOffset ?? 0);
129
129
  const priorState = cursor?.hostState !== null && typeof cursor?.hostState === "object"
130
130
  ? cursor.hostState : {};
@@ -139,6 +139,7 @@ export const codexTranscriptDriver = {
139
139
  lastEnd = line.endOffset;
140
140
  const entry = line.entry;
141
141
  if (entry === null) continue;
142
+ if (captureAfter && !(typeof entry.timestamp === "string" && Date.parse(entry.timestamp) >= Date.parse(captureAfter))) continue;
142
143
  const payload = entry.payload !== null && typeof entry.payload === "object" ? entry.payload : {};
143
144
  const occurredAt = typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString();
144
145
  if (entry.type === "session_meta") {
@@ -146,7 +146,7 @@ export const kimiTranscriptDriver = {
146
146
  return { path: located.path, sessionFacts };
147
147
  },
148
148
 
149
- async readSuffix(path, cursor, _sessionHash) {
149
+ async readSuffix(path, cursor, _sessionHash, { captureAfter } = {}) {
150
150
  const { buffer, start } = await readSuffixWindow(path, cursor?.byteOffset ?? 0);
151
151
  const metadata = {};
152
152
  const events = [];
@@ -155,6 +155,7 @@ export const kimiTranscriptDriver = {
155
155
  lastEnd = line.endOffset;
156
156
  const entry = line.entry;
157
157
  if (entry === null) continue;
158
+ if (captureAfter && !(Number.isSafeInteger(entry.time) && entry.time >= Date.parse(captureAfter))) continue;
158
159
  if (entry.type === "usage.record") {
159
160
  events.push(usageEventFromRecord(entry, {
160
161
  byteOffset: line.byteOffset,
package/src/transport.mjs CHANGED
@@ -15,10 +15,11 @@ export class RuntimeHttpError extends Error {
15
15
  }
16
16
 
17
17
  export class SignedRuntimeTransport {
18
- constructor(connection, { fetchImpl = globalThis.fetch, timeoutMs = 8_000 } = {}) {
18
+ constructor(connection, { fetchImpl = globalThis.fetch, timeoutMs = 8_000, signal } = {}) {
19
19
  this.connection = connection;
20
20
  this.fetch = fetchImpl;
21
21
  this.timeoutMs = timeoutMs;
22
+ this.signal = signal;
22
23
  }
23
24
 
24
25
  async request(path, { method = "POST", body, headers = {}, raw = false, maxResponseBytes } = {}) {
@@ -43,7 +44,7 @@ export class SignedRuntimeTransport {
43
44
  ...headers,
44
45
  },
45
46
  body: body === undefined ? undefined : bodyBytes,
46
- signal: controller.signal,
47
+ signal: this.signal ? AbortSignal.any([controller.signal, this.signal]) : controller.signal,
47
48
  });
48
49
  if (!response.ok) {
49
50
  if (maxResponseBytes !== undefined) {
@@ -76,15 +77,15 @@ export class SignedRuntimeTransport {
76
77
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
77
78
  } finally { reader.releaseLock(); }
78
79
  }
79
- return response.json();
80
+ return await response.json();
80
81
  } finally {
81
82
  clearTimeout(timer);
82
83
  }
83
84
  }
84
85
 
85
- heartbeat(capabilities, { pluginVersion, proofStorage, diagnostics }) {
86
+ heartbeat(capabilities, { pluginVersion, proofStorage, diagnostics, health }) {
86
87
  return this.request("/v1/agent-runtime/heartbeat", {
87
- body: { pluginVersion, capabilities, proofStorage, ...(diagnostics ? { diagnostics } : {}) },
88
+ body: { pluginVersion, capabilities, proofStorage, ...(diagnostics ? { diagnostics } : {}), ...(health ? { health } : {}) },
88
89
  });
89
90
  }
90
91
 
@@ -105,6 +106,8 @@ export class SignedRuntimeTransport {
105
106
  skillDownload(skillId) {
106
107
  return this.request(`/v1/agent-runtime/skills/${encodeURIComponent(skillId)}/download`, { method: "GET" });
107
108
  }
109
+ instructions() { return this.request("/v1/agent-runtime/instructions", { method: "GET", maxResponseBytes: 64 * 1024 }); }
110
+ instructionStatus(body) { return this.request("/v1/agent-runtime/instructions/status", { body, maxResponseBytes: 16 * 1024 }); }
108
111
 
109
112
  openSession(body) { return this.request("/v1/agent-sessions/open", { body }); }
110
113
  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.11.0";
3
+ export const RUNTIME_VERSION = "0.11.0";
4
+ export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-10.1";