@halofy/agent-connect 0.10.0 → 0.12.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 +52 -4
- package/bin/halofy-agent.mjs +8 -2
- package/package.json +1 -1
- package/src/claude-hook.mjs +16 -1
- package/src/enrollment.mjs +83 -0
- package/src/health.mjs +156 -0
- package/src/host-hook.mjs +16 -1
- package/src/install.mjs +3 -0
- package/src/installer-cli.mjs +17 -4
- package/src/mcp-proxy.mjs +36 -22
- package/src/queue.mjs +2 -0
- package/src/runtime.mjs +26 -7
- package/src/session.mjs +6 -2
- package/src/transcript-drivers/claude.mjs +2 -2
- package/src/transcript-drivers/codex.mjs +2 -1
- package/src/transcript-drivers/kimi.mjs +2 -1
- package/src/transport.mjs +6 -5
- package/src/version.mjs +2 -2
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Halofy agent lifecycle installer
|
|
2
2
|
|
|
3
|
-
Status:
|
|
4
|
-
|
|
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:
|
|
@@ -22,7 +22,28 @@ conversations and serves the agent-invoked MCP memory tools, but does not push
|
|
|
22
22
|
recalled memory into host sessions on session start or prompt submit. The
|
|
23
23
|
bounded recall block formats stay in place and tested for when it returns.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
## Browser sign-in (0.12.0 source)
|
|
26
|
+
|
|
27
|
+
Users assigned to a team can run the installer without a manually copied badge:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx @halofy/agent-connect@0.12.0 install claude-code --server https://app.halofy.ai
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`login <client-kind> --server <origin>` is an alias for the same installation
|
|
34
|
+
flow. The installer opens the existing Halofy sign-in page, displays a public
|
|
35
|
+
verification code, and waits for the user to confirm their authorized team and
|
|
36
|
+
device. Google sign-in is available through the existing console authentication.
|
|
37
|
+
A per-installation Ed25519 key is saved privately before enrollment; the approval
|
|
38
|
+
claim is bound to that same key. Device secrets and claims are never printed or
|
|
39
|
+
put in browser URLs. Retry a failed/expired sign-in with the same command; the
|
|
40
|
+
pending key survives retries. Installation still requires the terminal's
|
|
41
|
+
explicit CONNECT disclosure before changing host configuration.
|
|
42
|
+
|
|
43
|
+
This source version requires the matching server enrollment endpoints and a
|
|
44
|
+
published 0.12.0 package before the npx example can be used remotely. The existing
|
|
45
|
+
manual claim path remains supported:
|
|
46
|
+
|
|
26
47
|
|
|
27
48
|
```bash
|
|
28
49
|
npx --yes @halofy/agent-connect@0.10.0 install <client-kind> \
|
|
@@ -155,7 +176,34 @@ confirm delivery status. Existing offline skill limits remain; context refresh
|
|
|
155
176
|
failures withdraw the managed references. These are source capabilities; package
|
|
156
177
|
publication, installed-client upgrades and production deployment require separate
|
|
157
178
|
release verification.
|
|
158
|
-
|
|
179
|
+
|
|
180
|
+
## Connection health and local pause (0.11.0 source)
|
|
181
|
+
|
|
182
|
+
This source version adds a signed heartbeat immediately and every 60 seconds
|
|
183
|
+
while the existing MCP proxy runs. It inspects the installation's managed hook
|
|
184
|
+
and MCP configuration without repairing it. Health contains only a state; local
|
|
185
|
+
paths stay on disk. Legacy installations without inspection metadata report
|
|
186
|
+
`unknown`. Missing contact may mean an idle host, sleep, or network loss, and
|
|
187
|
+
cannot establish that an employee uninstalled the integration.
|
|
188
|
+
|
|
189
|
+
Use the installed runtime executable with `pause --connection <installation-id>`
|
|
190
|
+
or `resume --connection <installation-id>`. Pause persists for that installation,
|
|
191
|
+
attempts a bounded flush of already queued events, and skips subsequent capture
|
|
192
|
+
hooks. It does not block MCP. Resume reports inspected health and excludes older
|
|
193
|
+
transcript bodies from catch-up, so paused content is not backfilled. A failed
|
|
194
|
+
state report is retried by the next running-proxy heartbeat or session start.
|
|
195
|
+
|
|
196
|
+
JSON configuration checks require every managed hook registration and MCP entry.
|
|
197
|
+
Changed Kimi/Codex TOML is conservative: removed/disabled managed configuration
|
|
198
|
+
reports `hooks_missing`; other changes report `unknown` pending reinstallation.
|
|
199
|
+
This package does not add a TOML parser or a device-wide monitoring daemon.
|
|
200
|
+
|
|
201
|
+
The 0.11.0 source is not proof of a published or deployed artifact. Release must
|
|
202
|
+
publish the exact reviewed npm package, verify its integrity, update the server's
|
|
203
|
+
pinned installer artifact through its existing release process, and prove an
|
|
204
|
+
updated disposable installation. Existing local runtimes do not auto-upgrade.
|
|
205
|
+
|
|
206
|
+
## Organization instructions (0.10.0)
|
|
159
207
|
|
|
160
208
|
This source adds Govern instruction delivery alongside the existing skill,
|
|
161
209
|
policy, knowledge and delivery-receipt paths. Activation requires the reviewed
|
package/bin/halofy-agent.mjs
CHANGED
|
@@ -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
package/src/claude-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";
|
|
@@ -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
|
|
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.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { generateInstallationKeyPair } from "./crypto.mjs";
|
|
5
|
+
import { normalizeLifecycleServerUrl } from "./install.mjs";
|
|
6
|
+
import { readJson, writePrivateFile, defaultRuntimeDirectory } from "./storage.mjs";
|
|
7
|
+
import { CLIENT_KINDS } from "./client-registry.mjs";
|
|
8
|
+
|
|
9
|
+
export async function prepareInstallationKey(root, clientKind) {
|
|
10
|
+
const path = join(root, `pending-${clientKind}.json`);
|
|
11
|
+
const previous = await readJson(path);
|
|
12
|
+
if (previous) {
|
|
13
|
+
if (!previous.publicJwk || !previous.privateJwk || !previous.installationIdCandidate) {
|
|
14
|
+
throw new Error("pending installation key is invalid; repair local runtime storage before retrying");
|
|
15
|
+
}
|
|
16
|
+
return previous;
|
|
17
|
+
}
|
|
18
|
+
const key = { ...generateInstallationKeyPair(), installationIdCandidate: `local_${randomUUID()}` };
|
|
19
|
+
await writePrivateFile(path, `${JSON.stringify(key)}\n`);
|
|
20
|
+
return key;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function openVerificationBrowser(uri) {
|
|
24
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
|
|
25
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", uri] : [uri];
|
|
26
|
+
const child = spawn(command, args, { shell: false, detached: true, stdio: "ignore" });
|
|
27
|
+
child.on("error", () => {}); // Printed URL remains usable when there is no desktop browser.
|
|
28
|
+
child.unref();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Device secrets stay in memory. Only the public human code reaches browser/terminal. */
|
|
32
|
+
export async function enrollWithBrowser({ serverUrl, clientKind, root = defaultRuntimeDirectory(),
|
|
33
|
+
fetchImpl = globalThis.fetch, output = process.stdout, openBrowser = openVerificationBrowser,
|
|
34
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now }) {
|
|
35
|
+
if (!CLIENT_KINDS.includes(clientKind)) throw new Error("unsupported enrollment client");
|
|
36
|
+
const server = normalizeLifecycleServerUrl(serverUrl);
|
|
37
|
+
const base = new URL(server);
|
|
38
|
+
if (!["https:", "http:"].includes(base.protocol) || base.username || base.password || base.search || base.hash || base.pathname !== "/") {
|
|
39
|
+
throw new Error("sign-in requires a server origin without credentials, path or query");
|
|
40
|
+
}
|
|
41
|
+
const key = await prepareInstallationKey(root, clientKind);
|
|
42
|
+
async function request(path, body) {
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetchImpl(`${server}/v1/agent-enrollment/${path}`, {
|
|
46
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
47
|
+
redirect: "error", signal: AbortSignal.timeout(15_000),
|
|
48
|
+
});
|
|
49
|
+
} catch { throw new Error("sign-in connection unavailable; retry sign-in"); }
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(response.status === 429 ? "sign-in rate limit reached; try again later" :
|
|
52
|
+
response.status === 410 ? "sign-in expired; retry sign-in" : "sign-in denied or unavailable; retry sign-in");
|
|
53
|
+
}
|
|
54
|
+
try { return await response.json(); } catch { throw new Error("invalid sign-in response"); }
|
|
55
|
+
}
|
|
56
|
+
const start = await request("start", { clientKind, publicKeyJwk: key.publicJwk });
|
|
57
|
+
let uri;
|
|
58
|
+
try { uri = new URL(start.verificationUri); } catch { throw new Error("invalid sign-in response"); }
|
|
59
|
+
if (typeof start.deviceCode !== "string" || !/^[A-Za-z0-9_-]{32,128}$/.test(start.deviceCode) ||
|
|
60
|
+
typeof start.userCode !== "string" || !/^[A-Za-z0-9-]{4,32}$/.test(start.userCode) ||
|
|
61
|
+
uri.origin !== base.origin || uri.username || uri.password || uri.pathname !== "/console/connect" ||
|
|
62
|
+
uri.hash || uri.search !== `?code=${start.userCode}` ||
|
|
63
|
+
!Number.isFinite(start.expiresIn) || start.expiresIn <= 0 || start.expiresIn > 1800 ||
|
|
64
|
+
!Number.isFinite(start.interval) || start.interval < 1 || start.interval > 60) {
|
|
65
|
+
throw new Error("invalid sign-in response");
|
|
66
|
+
}
|
|
67
|
+
const deadline = now() + start.expiresIn * 1000;
|
|
68
|
+
output.write(`Sign in and confirm this device: ${uri.href}\nCode: ${start.userCode}\n`);
|
|
69
|
+
try { await openBrowser(uri.href); } catch { /* URL is available for manual opening. */ }
|
|
70
|
+
while (now() < deadline) {
|
|
71
|
+
await sleep(start.interval * 1000);
|
|
72
|
+
if (now() >= deadline) break;
|
|
73
|
+
const token = await request("token", { deviceCode: start.deviceCode });
|
|
74
|
+
if (token.status === "pending") continue;
|
|
75
|
+
if (token.status === "expired") throw new Error("sign-in expired; retry sign-in");
|
|
76
|
+
if (token.status === "denied") throw new Error("sign-in denied; retry sign-in");
|
|
77
|
+
if (token.status !== "approved" || token.clientKind !== clientKind ||
|
|
78
|
+
typeof token.claim !== "string" || !/^hsc_[A-Za-z0-9_-]{43}$/.test(token.claim) ||
|
|
79
|
+
typeof token.organization !== "string" || !token.organization.trim()) throw new Error("invalid sign-in response");
|
|
80
|
+
return { claim: token.claim, organization: token.organization.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, 100) };
|
|
81
|
+
}
|
|
82
|
+
throw new Error("sign-in expired; retry sign-in");
|
|
83
|
+
}
|
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
|
|
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
|
});
|
package/src/installer-cli.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { enrollWithBrowser } from "./enrollment.mjs";
|
|
2
|
+
import { recordHealthTargets } from "./health.mjs";
|
|
1
3
|
import { spawnSync } from "node:child_process";
|
|
2
4
|
import { existsSync, readdirSync } from "node:fs";
|
|
3
5
|
import { createInterface } from "node:readline/promises";
|
|
@@ -77,9 +79,9 @@ export function parseInstallerArgs(argv) {
|
|
|
77
79
|
}
|
|
78
80
|
const serverUrl = values.get("--server");
|
|
79
81
|
const claim = values.get("--claim");
|
|
80
|
-
if (
|
|
81
|
-
!/^hsc_[A-Za-z0-9_-]{43}$/.test(claim)) {
|
|
82
|
-
throw new Error("Usage: agent-connect install <supported-client> --server <https-url> --claim <one-use-claim>");
|
|
82
|
+
if (!["install", "login"].includes(command) || !CLIENT_KINDS.includes(clientKind) || !serverUrl ||
|
|
83
|
+
(claim !== undefined && !/^hsc_[A-Za-z0-9_-]{43}$/.test(claim))) {
|
|
84
|
+
throw new Error("Usage: agent-connect install|login <supported-client> --server <https-url> [--claim <one-use-claim>]");
|
|
83
85
|
}
|
|
84
86
|
return {
|
|
85
87
|
mode: "single",
|
|
@@ -364,6 +366,7 @@ async function runAllInstaller(input, {
|
|
|
364
366
|
serverUrl: input.serverUrl,
|
|
365
367
|
runtimePath: bundle.runtimePath,
|
|
366
368
|
}, { claudeConfigPath });
|
|
369
|
+
await recordHealthTargets({ root, installationId: installed.installationId, configured, runtimePath: bundle.runtimePath });
|
|
367
370
|
let heartbeat = false;
|
|
368
371
|
try {
|
|
369
372
|
heartbeat = await heartbeatInstalledConnection({
|
|
@@ -424,6 +427,9 @@ export async function runInstaller(argv, {
|
|
|
424
427
|
sourceRoot,
|
|
425
428
|
claudeConfigPath,
|
|
426
429
|
skillsHome,
|
|
430
|
+
openBrowser,
|
|
431
|
+
enrollmentSleep,
|
|
432
|
+
enrollmentNow,
|
|
427
433
|
} = {}) {
|
|
428
434
|
const input = parseInstallerArgs(argv);
|
|
429
435
|
if (input.mode === "all") {
|
|
@@ -433,7 +439,13 @@ export async function runInstaller(argv, {
|
|
|
433
439
|
});
|
|
434
440
|
}
|
|
435
441
|
const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
|
|
436
|
-
const
|
|
442
|
+
const enrollment = !input.claim ? await enrollWithBrowser({
|
|
443
|
+
serverUrl: input.serverUrl, clientKind: input.clientKind, root, fetchImpl, output,
|
|
444
|
+
...(openBrowser ? { openBrowser } : {}), ...(enrollmentSleep ? { sleep: enrollmentSleep } : {}),
|
|
445
|
+
...(enrollmentNow ? { now: enrollmentNow } : {}),
|
|
446
|
+
}) : null;
|
|
447
|
+
if (enrollment) input.claim = enrollment.claim;
|
|
448
|
+
const claimDisclosure = enrollment || await fetchClaimDisclosure({
|
|
437
449
|
serverUrl: input.serverUrl,
|
|
438
450
|
claim: input.claim,
|
|
439
451
|
fetchImpl,
|
|
@@ -462,6 +474,7 @@ export async function runInstaller(argv, {
|
|
|
462
474
|
runtimePath: bundle.runtimePath,
|
|
463
475
|
};
|
|
464
476
|
const configured = await configureHost(input.clientKind, common, { claudeConfigPath });
|
|
477
|
+
await recordHealthTargets({ root, installationId: installed.installationId, configured, runtimePath: bundle.runtimePath });
|
|
465
478
|
let heartbeat = false;
|
|
466
479
|
try {
|
|
467
480
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
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.
|
|
3
|
-
export const RUNTIME_VERSION = "0.
|
|
2
|
+
export const INSTALLER_VERSION = "0.12.0";
|
|
3
|
+
export const RUNTIME_VERSION = "0.12.0";
|
|
4
4
|
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-10.1";
|