@halofy/agent-connect 0.10.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:
@@ -155,7 +155,34 @@ confirm delivery status. Existing offline skill limits remain; context refresh
155
155
  failures withdraw the managed references. These are source capabilities; package
156
156
  publication, installed-client upgrades and production deployment require separate
157
157
  release verification.
158
- ## Organization instructions (0.10.0 release candidate)
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)
159
186
 
160
187
  This source adds Govern instruction delivery alongside the existing skill,
161
188
  policy, knowledge and delivery-receipt paths. Activation requires the reviewed
@@ -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.10.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,7 +92,21 @@ 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.
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,7 +157,21 @@ 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.
package/src/install.mjs CHANGED
@@ -6,6 +6,7 @@ import { generateInstallationKeyPair } from "./crypto.mjs";
6
6
  import { ConnectionStore, defaultRuntimeDirectory, ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
7
7
  import { syncAgentInstructions } from "./instructions.mjs";
8
8
  import { SignedRuntimeTransport } from "./transport.mjs";
9
+ import { inspectCaptureHealth } from "./health.mjs";
9
10
  import { INSTALLER_VERSION, RUNTIME_VERSION } from "./version.mjs";
10
11
  import { CLIENT_KINDS, CLIENT_REGISTRY, lifecycleClient } from "./client-registry.mjs";
11
12
  import { syncManagedSkills } from "./skills-sync.mjs";
@@ -221,6 +222,7 @@ export async function installLocalConnection({
221
222
  try {
222
223
  await new SignedRuntimeTransport(connection, { fetchImpl }).heartbeat(client.capabilities, {
223
224
  pluginVersion: RUNTIME_VERSION,
225
+ health: await inspectCaptureHealth(connection, root),
224
226
  proofStorage,
225
227
  diagnostics: { queueDepth: 0, oldestPendingAt: null, expiredCount: 0 },
226
228
  });
@@ -274,6 +276,7 @@ export async function heartbeatInstalledConnection({
274
276
  const connection = await new ConnectionStore(root).load(installationId);
275
277
  await new SignedRuntimeTransport(connection, { fetchImpl }).heartbeat(connection.capabilities, {
276
278
  pluginVersion: connection.pluginVersion,
279
+ health: await inspectCaptureHealth(connection, root),
277
280
  proofStorage: connection.proofStorage,
278
281
  diagnostics: { queueDepth: 0, oldestPendingAt: null, expiredCount: 0 },
279
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";
@@ -364,6 +365,7 @@ async function runAllInstaller(input, {
364
365
  serverUrl: input.serverUrl,
365
366
  runtimePath: bundle.runtimePath,
366
367
  }, { claudeConfigPath });
368
+ await recordHealthTargets({ root, installationId: installed.installationId, configured, runtimePath: bundle.runtimePath });
367
369
  let heartbeat = false;
368
370
  try {
369
371
  heartbeat = await heartbeatInstalledConnection({
@@ -462,6 +464,7 @@ export async function runInstaller(argv, {
462
464
  runtimePath: bundle.runtimePath,
463
465
  };
464
466
  const configured = await configureHost(input.clientKind, common, { claudeConfigPath });
467
+ await recordHealthTargets({ root, installationId: installed.installationId, configured, runtimePath: bundle.runtimePath });
465
468
  let heartbeat = false;
466
469
  try {
467
470
  heartbeat = await heartbeatInstalledConnection({
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,6 +8,7 @@ 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";
11
12
  import { syncAgentInstructions } from "./instructions.mjs";
12
13
  import { RUNTIME_VERSION } from "./version.mjs";
13
14
 
@@ -19,10 +20,12 @@ export class LifecycleRuntime {
19
20
  maxBatchEvents = 100,
20
21
  maxBatchBytes = 1024 * 1024,
21
22
  commitThreshold = 20,
23
+ captureGeneration,
22
24
  } = {}) {
23
25
  if (!root) throw new Error("runtime storage root is required");
24
26
  this.connection = connection;
25
27
  this.root = root;
28
+ this.captureGeneration = captureGeneration;
26
29
  this.transport = transport;
27
30
  this.instructionTransport = instructionTransport;
28
31
  const connectionRoot = join(root, connection.installationId);
@@ -126,18 +129,23 @@ export class LifecycleRuntime {
126
129
  }
127
130
 
128
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 };
129
134
  const sessionHash = this.sessionHash(hostSessionId);
130
135
  const policy = await this.capturePolicy();
131
136
  const queued = await withFileLock(this.operationLockPath, async () => {
137
+ if (await capturePaused(this.root, this.connection.installationId)) return { queued: 0 };
132
138
  const cursor = await this.cursors.get(sessionHash);
133
- const suffix = await driver.readSuffix(transcriptPath, cursor, sessionHash);
139
+ const resumedAt = capture.captureAfter;
140
+ const suffix = await driver.readSuffix(transcriptPath, cursor, sessionHash, { captureAfter: resumedAt });
134
141
  const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
135
- 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)));
136
144
  // One metadata event whenever the observed content-free session facts
137
145
  // change. The event key hashes the payload, so an unchanged snapshot is
138
146
  // deduplicated exactly like any repeated event.
139
147
  const metadataPayload = driver.buildMetadataPayload(
140
- { ...suffix.metadata, ...sessionFacts },
148
+ { ...suffix.metadata, ...(!resumedAt ? sessionFacts : {}) },
141
149
  { installationId: this.connection.installationId, deviceContext: policy.deviceContext },
142
150
  );
143
151
  if (Object.keys(metadataPayload).length > 0) {
@@ -152,7 +160,9 @@ export class LifecycleRuntime {
152
160
  maxBatchEvents: this.maxBatchEvents,
153
161
  maxBatchBytes: this.maxBatchBytes,
154
162
  acknowledgedSequence: cursor.sequence,
163
+ canEnqueue: () => captureAdmission(this.root, this.connection.installationId, this.captureGeneration === undefined ? capture.generation : this.captureGeneration),
155
164
  });
165
+ if (result.skipped) return result;
156
166
  // Complete ignored/malformed JSONL records carry no event, while every
157
167
  // normalized event is already durable in the encrypted queue. Advancing
158
168
  // this byte cursor prevents unbounded reparsing without advancing the
@@ -177,36 +187,44 @@ export class LifecycleRuntime {
177
187
  }
178
188
 
179
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 };
180
192
  const sessionHash = this.sessionHash(hostSessionId);
181
193
  await withFileLock(this.operationLockPath, async () => {
182
- 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);
183
196
  });
184
197
  return this.replay();
185
198
  }
186
199
 
187
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 };
188
203
  const sessionHash = this.sessionHash(hostSessionId);
189
204
  await withFileLock(this.operationLockPath, async () => {
205
+ if (await capturePaused(this.root, this.connection.installationId)) return;
190
206
  const cursor = await this.cursors.get(sessionHash);
191
207
  const events = factory({ sessionHash, nextSequence: cursor.sequence + 1 });
192
208
  if (!Array.isArray(events) || events.length < 1 || events.length > this.maxBatchEvents) {
193
209
  throw new Error("sequenced host event batch is invalid");
194
210
  }
195
- await this.#enqueueEventsUnlocked(sessionHash, events, cursor);
211
+ await this.#enqueueEventsUnlocked(sessionHash, events, cursor, this.captureGeneration === undefined ? capture.generation : this.captureGeneration);
196
212
  });
197
213
  return this.replay();
198
214
  }
199
215
 
200
- async #enqueueEventsUnlocked(sessionHash, events, knownCursor) {
216
+ async #enqueueEventsUnlocked(sessionHash, events, knownCursor, generation) {
201
217
  const cursor = knownCursor || await this.cursors.get(sessionHash);
202
218
  const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
203
219
  const unseenEvents = events.filter((event) => !recentEventKeys.has(event.eventKey));
204
220
  if (unseenEvents.length === 0) return;
205
- await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
221
+ const result = await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
206
222
  maxBatchEvents: this.maxBatchEvents,
207
223
  maxBatchBytes: this.maxBatchBytes,
208
224
  acknowledgedSequence: cursor.sequence,
225
+ canEnqueue: () => captureAdmission(this.root, this.connection.installationId, generation),
209
226
  });
227
+ if (result.skipped) return;
210
228
  await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
211
229
  }
212
230
 
@@ -301,6 +319,7 @@ export class LifecycleRuntime {
301
319
  const queue = await this.queue.diagnostics();
302
320
  const usageGaps = await this.cursors.peekUsageGaps();
303
321
  const response = await this.transport.heartbeat(capabilities, {
322
+ health: await inspectCaptureHealth(this.connection, this.root),
304
323
  pluginVersion: this.connection.pluginVersion || `${RUNTIME_VERSION}-local`,
305
324
  proofStorage: this.connection.proofStorage || "unknown",
306
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
 
package/src/version.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  export const PACKAGE_NAME = "@halofy/agent-connect";
2
- export const INSTALLER_VERSION = "0.10.0";
3
- export const RUNTIME_VERSION = "0.10.0";
2
+ export const INSTALLER_VERSION = "0.11.0";
3
+ export const RUNTIME_VERSION = "0.11.0";
4
4
  export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-10.1";