@pixelbyte-software/pixcode 1.49.5 → 1.49.6
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/dist/assets/{index-77u-_XIT.js → index-BuW3PILy.js} +136 -136
- package/dist/index.html +1 -1
- package/dist-server/server/index.js +1 -0
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js +76 -0
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js.map +1 -1
- package/dist-server/server/services/hermes-gateway.js +362 -0
- package/dist-server/server/services/hermes-gateway.js.map +1 -0
- package/dist-server/server/services/hermes-install-jobs.js +45 -3
- package/dist-server/server/services/hermes-install-jobs.js.map +1 -1
- package/package.json +1 -1
- package/scripts/hermes/configure-pixcode-mcp.mjs +32 -1
- package/scripts/hermes/pixcode-mcp-server.mjs +56 -0
- package/scripts/smoke/hermes-api-install.mjs +5 -0
- package/scripts/smoke/hermes-mcp-pixcode-roundtrip.mjs +179 -0
- package/scripts/smoke/hermes-rest-codex-launch.mjs +186 -0
- package/scripts/smoke/hermes-rest-gateway.mjs +40 -0
- package/scripts/smoke/hermes-rest-live.mjs +42 -0
- package/scripts/smoke/pixcode-workbench-1-48.mjs +2 -0
- package/server/index.js +1 -0
- package/server/modules/orchestration/hermes/hermes.routes.ts +86 -0
- package/server/services/hermes-gateway.js +400 -0
- package/server/services/hermes-install-jobs.js +49 -3
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
|
|
4
|
+
const read = (path) => fs.readFileSync(path, 'utf8');
|
|
5
|
+
|
|
6
|
+
const service = read('server/services/hermes-gateway.js');
|
|
7
|
+
const routes = read('server/modules/orchestration/hermes/hermes.routes.ts');
|
|
8
|
+
const mcpServer = read('scripts/hermes/pixcode-mcp-server.mjs');
|
|
9
|
+
const configureMcp = read('scripts/hermes/configure-pixcode-mcp.mjs');
|
|
10
|
+
const settingsTab = read('src/components/settings/view/tabs/HermesSettingsTab.tsx');
|
|
11
|
+
|
|
12
|
+
assert.match(service, /export async function ensureHermesGateway/, 'Pixcode should expose an API-managed Hermes gateway starter.');
|
|
13
|
+
assert.match(service, /export async function probeHermesGateway/, 'Pixcode should probe Hermes through its REST API.');
|
|
14
|
+
assert.match(service, /export function stopHermesGateway/, 'Pixcode should be able to stop a managed Hermes gateway process.');
|
|
15
|
+
assert.match(service, /API_SERVER_ENABLED:\s*'true'/, 'Hermes gateway env should enable the API server.');
|
|
16
|
+
assert.match(service, /API_SERVER_KEY/, 'Hermes gateway env should set a bearer key.');
|
|
17
|
+
assert.match(service, /API_SERVER_PORT/, 'Hermes gateway env should choose a REST port.');
|
|
18
|
+
assert.match(service, /spawn\(installStatus\.command,\s*\['gateway'\]/, 'Pixcode should start Hermes with `hermes gateway` for REST control.');
|
|
19
|
+
assert.match(service, /\/health/, 'Gateway probe should call Hermes health.');
|
|
20
|
+
assert.match(service, /\/v1\/capabilities/, 'Gateway probe should verify Hermes capabilities.');
|
|
21
|
+
assert.match(service, /\/v1\/models/, 'Gateway probe should verify OpenAI-compatible model discovery.');
|
|
22
|
+
assert.match(service, /\/v1\/runs/, 'Gateway probe should support a real run submission when requested.');
|
|
23
|
+
|
|
24
|
+
assert.match(routes, /router\.get\('\/gateway\/status'/, 'Hermes router should expose gateway status.');
|
|
25
|
+
assert.match(routes, /router\.post\('\/gateway\/start'/, 'Hermes router should expose gateway start.');
|
|
26
|
+
assert.match(routes, /router\.post\('\/gateway\/probe'/, 'Hermes router should expose a REST probe endpoint.');
|
|
27
|
+
assert.match(routes, /router\.post\('\/gateway\/stop'/, 'Hermes router should expose gateway stop.');
|
|
28
|
+
assert.match(routes, /ensureHermesGateway/, 'Hermes router should use the managed gateway service.');
|
|
29
|
+
assert.match(routes, /probeHermesGateway/, 'Hermes router should use the REST probe service.');
|
|
30
|
+
|
|
31
|
+
assert.match(mcpServer, /pixcode_get_hermes_gateway_status/, 'Pixcode MCP should let Hermes inspect gateway status.');
|
|
32
|
+
assert.match(mcpServer, /pixcode_probe_hermes_gateway/, 'Pixcode MCP should let Hermes trigger a REST probe.');
|
|
33
|
+
assert.match(configureMcp, /pixcode_get_hermes_gateway_status/, 'Hermes MCP config should include gateway status tool.');
|
|
34
|
+
assert.match(configureMcp, /pixcode_probe_hermes_gateway/, 'Hermes MCP config should include gateway probe tool.');
|
|
35
|
+
|
|
36
|
+
assert.match(settingsTab, /gateway\/status/, 'Hermes settings should read gateway status.');
|
|
37
|
+
assert.match(settingsTab, /gateway\/start/, 'Hermes settings should start the REST gateway via API.');
|
|
38
|
+
assert.match(settingsTab, /gateway\/probe/, 'Hermes settings should run REST probe via API.');
|
|
39
|
+
|
|
40
|
+
console.log('hermes REST gateway smoke passed');
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ensureHermesGateway,
|
|
8
|
+
probeHermesGateway,
|
|
9
|
+
stopHermesGateway,
|
|
10
|
+
} from '../../server/services/hermes-gateway.js';
|
|
11
|
+
|
|
12
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
13
|
+
const projectPath = path.resolve(process.argv[2] || repoRoot);
|
|
14
|
+
const hermesHome = await fs.mkdtemp(path.join(os.tmpdir(), 'pixcode-hermes-rest-live-'));
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const gateway = await ensureHermesGateway({
|
|
18
|
+
appRoot: repoRoot,
|
|
19
|
+
projectPath,
|
|
20
|
+
hermesHome,
|
|
21
|
+
pixcodeBaseUrl: 'http://127.0.0.1:9',
|
|
22
|
+
pixcodeApiKey: 'px_live_smoke_key',
|
|
23
|
+
port: Number(process.env.PIXCODE_HERMES_LIVE_PORT || 18642),
|
|
24
|
+
});
|
|
25
|
+
if (!gateway.running || !gateway.probe?.ok) {
|
|
26
|
+
throw new Error(`Hermes gateway did not start cleanly: ${JSON.stringify(gateway)}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const probe = await probeHermesGateway(projectPath);
|
|
30
|
+
if (!probe.ok) {
|
|
31
|
+
throw new Error(`Hermes REST probe failed: ${JSON.stringify(probe)}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log(JSON.stringify({
|
|
35
|
+
ok: true,
|
|
36
|
+
baseUrl: probe.baseUrl,
|
|
37
|
+
projectPath: probe.projectPath,
|
|
38
|
+
checks: Object.fromEntries(Object.entries(probe.checks).map(([name, check]) => [name, check.status])),
|
|
39
|
+
}, null, 2));
|
|
40
|
+
} finally {
|
|
41
|
+
stopHermesGateway(projectPath);
|
|
42
|
+
}
|
|
@@ -107,6 +107,8 @@ assert.match(hermesRoutes, /terminal-launches/, 'Hermes MCP should be able to re
|
|
|
107
107
|
assert.match(hermesRoutes, /install-status/, 'Hermes settings and terminal UI should have an install-status endpoint.');
|
|
108
108
|
assert.match(hermesRoutes, /router\.post\('\/install'/, 'Hermes should install through the backend API instead of terminal command paste.');
|
|
109
109
|
assert.match(read('scripts/smoke/hermes-api-install.mjs'), /hermes API install smoke passed/, 'Hermes API install behavior should have a focused smoke test.');
|
|
110
|
+
assert.match(workbench, /terminalStartupInput/, 'Hermes terminal launch prompts should be passed into the selected CLI.');
|
|
111
|
+
assert.match(read('src/components/shell/hooks/useShellConnection.ts'), /startupInputRef/, 'Shell connections should support one-shot startup input for Hermes-triggered CLI work.');
|
|
110
112
|
assert.match(serverIndex, /forceNewSession/, 'Shell backend should support explicit fresh-session launches from the workbench.');
|
|
111
113
|
assert.match(serverIndex, /killProviderPtySessions/, 'Shell backend should terminate old provider PTYs when a fresh CLI session is requested.');
|
|
112
114
|
|
package/server/index.js
CHANGED
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
readHermesInstallStatus,
|
|
12
12
|
snapshotHermesInstallDonePayload,
|
|
13
13
|
} from '@/services/hermes-install-jobs.js';
|
|
14
|
+
import {
|
|
15
|
+
ensureHermesGateway,
|
|
16
|
+
getHermesGatewayStatus,
|
|
17
|
+
probeHermesGateway,
|
|
18
|
+
stopHermesGateway,
|
|
19
|
+
} from '@/services/hermes-gateway.js';
|
|
14
20
|
|
|
15
21
|
const HERMES_TERMINAL_LAUNCH_LIMIT = 100;
|
|
16
22
|
const HERMES_TERMINAL_LAUNCH_PROVIDERS = new Set(['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode']);
|
|
@@ -91,6 +97,86 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
|
|
|
91
97
|
res.json(readHermesInstallStatus());
|
|
92
98
|
});
|
|
93
99
|
|
|
100
|
+
router.get('/gateway/status', (req, res) => {
|
|
101
|
+
const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : null;
|
|
102
|
+
res.json(getHermesGatewayStatus(projectPath));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
router.post('/gateway/start', async (req: PixcodeRequest, res) => {
|
|
106
|
+
const apiKey = options.createHermesApiKey?.(readUserId(req)) ?? null;
|
|
107
|
+
if (!apiKey) {
|
|
108
|
+
res.status(500).json({
|
|
109
|
+
error: {
|
|
110
|
+
code: 'HERMES_API_KEY_UNAVAILABLE',
|
|
111
|
+
message: 'Pixcode could not create a Hermes MCP API key for this user.',
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
118
|
+
try {
|
|
119
|
+
const gateway = await ensureHermesGateway({
|
|
120
|
+
appRoot: options.appRoot ?? process.cwd(),
|
|
121
|
+
pixcodeApiKey: apiKey,
|
|
122
|
+
pixcodeBaseUrl: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
|
|
123
|
+
projectPath: typeof body.projectPath === 'string' ? body.projectPath : undefined,
|
|
124
|
+
});
|
|
125
|
+
res.status(202).json(gateway);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
res.status(500).json({
|
|
128
|
+
error: {
|
|
129
|
+
code: 'HERMES_GATEWAY_START_FAILED',
|
|
130
|
+
message: error instanceof Error ? error.message : String(error),
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
router.post('/gateway/probe', async (req: PixcodeRequest, res) => {
|
|
137
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
138
|
+
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
|
|
139
|
+
const input = typeof body.input === 'string' ? body.input : undefined;
|
|
140
|
+
const shouldStart = body.startIfNeeded === true;
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
if (shouldStart) {
|
|
144
|
+
const apiKey = options.createHermesApiKey?.(readUserId(req)) ?? null;
|
|
145
|
+
if (!apiKey) {
|
|
146
|
+
res.status(500).json({
|
|
147
|
+
error: {
|
|
148
|
+
code: 'HERMES_API_KEY_UNAVAILABLE',
|
|
149
|
+
message: 'Pixcode could not create a Hermes MCP API key for this user.',
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
await ensureHermesGateway({
|
|
155
|
+
appRoot: options.appRoot ?? process.cwd(),
|
|
156
|
+
pixcodeApiKey: apiKey,
|
|
157
|
+
pixcodeBaseUrl: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
|
|
158
|
+
projectPath: projectPath ?? undefined,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const probe = await probeHermesGateway(projectPath, { input });
|
|
163
|
+
res.status(probe.ok ? 200 : 503).json(probe);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
res.status(500).json({
|
|
166
|
+
error: {
|
|
167
|
+
code: 'HERMES_GATEWAY_PROBE_FAILED',
|
|
168
|
+
message: error instanceof Error ? error.message : String(error),
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
router.post('/gateway/stop', (req, res) => {
|
|
175
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
176
|
+
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
|
|
177
|
+
res.json(stopHermesGateway(projectPath));
|
|
178
|
+
});
|
|
179
|
+
|
|
94
180
|
router.post('/install', (req: PixcodeRequest, res) => {
|
|
95
181
|
const apiKey = options.createHermesApiKey?.(readUserId(req)) ?? null;
|
|
96
182
|
if (!apiKey) {
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import spawn from 'cross-spawn';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
buildHermesPathEnv,
|
|
10
|
+
readHermesInstallStatus,
|
|
11
|
+
} from './hermes-install-jobs.js';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
14
|
+
const DEFAULT_PORT = 8642;
|
|
15
|
+
const PORT_SCAN_LIMIT = 80;
|
|
16
|
+
const STARTUP_TIMEOUT_MS = 30000;
|
|
17
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
18
|
+
const LOG_LIMIT = 800;
|
|
19
|
+
|
|
20
|
+
const gateways = new Map();
|
|
21
|
+
|
|
22
|
+
function nowIso() {
|
|
23
|
+
return new Date().toISOString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeProjectPath(projectPath) {
|
|
27
|
+
return path.resolve(projectPath || os.homedir());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function appendGatewayLog(gateway, stream, chunk) {
|
|
31
|
+
const entry = { stream, chunk: String(chunk || ''), at: Date.now() };
|
|
32
|
+
gateway.logs.push(entry);
|
|
33
|
+
if (gateway.logs.length > LOG_LIMIT) {
|
|
34
|
+
gateway.logs.splice(0, gateway.logs.length - LOG_LIMIT);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isGatewayRunning(gateway) {
|
|
39
|
+
return Boolean(gateway?.child && gateway.exitCode === null && gateway.exitSignal === null);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function gatewayBaseUrl(host, port) {
|
|
43
|
+
return `http://${host}:${port}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeApiServerKey() {
|
|
47
|
+
return `pixcode-hermes-${randomBytes(24).toString('hex')}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildHermesGatewayEnv(baseEnv = process.env, options = {}) {
|
|
51
|
+
const host = options.host || DEFAULT_HOST;
|
|
52
|
+
const port = String(options.port || DEFAULT_PORT);
|
|
53
|
+
return buildHermesPathEnv(baseEnv, {
|
|
54
|
+
API_SERVER_ENABLED: 'true',
|
|
55
|
+
API_SERVER_HOST: host,
|
|
56
|
+
API_SERVER_PORT: port,
|
|
57
|
+
API_SERVER_KEY: options.apiServerKey || makeApiServerKey(),
|
|
58
|
+
API_SERVER_CORS_ORIGINS: options.corsOrigins || options.pixcodeBaseUrl || '',
|
|
59
|
+
PIXCODE_BASE_URL: options.pixcodeBaseUrl || '',
|
|
60
|
+
PIXCODE_API_KEY: options.pixcodeApiKey || '',
|
|
61
|
+
PIXCODE_APP_ROOT: options.appRoot || process.cwd(),
|
|
62
|
+
HERMES_HOME: options.hermesHome || '',
|
|
63
|
+
HERMES_INSTALL_DIR: options.installDir || '',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isPortAvailable(port, host) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const server = net.createServer();
|
|
70
|
+
server.once('error', () => resolve(false));
|
|
71
|
+
server.once('listening', () => {
|
|
72
|
+
server.close(() => resolve(true));
|
|
73
|
+
});
|
|
74
|
+
server.listen(port, host);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function findAvailablePort(preferredPort, host) {
|
|
79
|
+
const start = Number.isFinite(preferredPort) ? preferredPort : DEFAULT_PORT;
|
|
80
|
+
for (let offset = 0; offset < PORT_SCAN_LIMIT; offset += 1) {
|
|
81
|
+
const port = start + offset;
|
|
82
|
+
if (await isPortAvailable(port, host)) {
|
|
83
|
+
return port;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`No available Hermes API server port found from ${start} to ${start + PORT_SCAN_LIMIT - 1}.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fetchJson(url, options = {}) {
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs || FETCH_TIMEOUT_MS);
|
|
92
|
+
return fetch(url, {
|
|
93
|
+
...options,
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
headers: {
|
|
96
|
+
accept: 'application/json',
|
|
97
|
+
...(options.headers || {}),
|
|
98
|
+
},
|
|
99
|
+
}).then(async (response) => {
|
|
100
|
+
const text = await response.text();
|
|
101
|
+
let body = null;
|
|
102
|
+
try {
|
|
103
|
+
body = text ? JSON.parse(text) : null;
|
|
104
|
+
} catch {
|
|
105
|
+
body = text;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
ok: response.ok,
|
|
110
|
+
status: response.status,
|
|
111
|
+
body,
|
|
112
|
+
};
|
|
113
|
+
}).finally(() => clearTimeout(timeout));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function callGateway(gateway, endpoint, options = {}) {
|
|
117
|
+
return fetchJson(`${gateway.baseUrl}${endpoint}`, {
|
|
118
|
+
...options,
|
|
119
|
+
headers: {
|
|
120
|
+
Authorization: `Bearer ${gateway.apiServerKey}`,
|
|
121
|
+
'content-type': 'application/json',
|
|
122
|
+
...(options.headers || {}),
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function waitForGatewayReady(gateway) {
|
|
128
|
+
const started = Date.now();
|
|
129
|
+
let lastError = null;
|
|
130
|
+
|
|
131
|
+
while (Date.now() - started < STARTUP_TIMEOUT_MS) {
|
|
132
|
+
if (!isGatewayRunning(gateway)) {
|
|
133
|
+
throw new Error(gateway.error || `Hermes gateway exited with code ${gateway.exitCode ?? 'unknown'}.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const probe = await probeHermesGateway(gateway.projectPath, { requireRunning: true });
|
|
138
|
+
if (probe.ok) {
|
|
139
|
+
return probe;
|
|
140
|
+
}
|
|
141
|
+
lastError = probe.error || 'Gateway probe failed.';
|
|
142
|
+
} catch (error) {
|
|
143
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
throw new Error(`Hermes gateway did not become ready within ${STARTUP_TIMEOUT_MS / 1000}s: ${lastError || 'no response'}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function runProcess(command, args, options, onData) {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const child = spawn(command, args, {
|
|
155
|
+
...options,
|
|
156
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
157
|
+
windowsHide: true,
|
|
158
|
+
});
|
|
159
|
+
child.stdout?.on('data', (buf) => onData?.('stdout', buf.toString()));
|
|
160
|
+
child.stderr?.on('data', (buf) => onData?.('stderr', buf.toString()));
|
|
161
|
+
child.on('error', reject);
|
|
162
|
+
child.on('close', (code, signal) => {
|
|
163
|
+
if (signal) {
|
|
164
|
+
reject(new Error(`${command} killed by ${signal}`));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
resolve(code ?? 0);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function configurePixcodeMcp({ appRoot, env, gateway }) {
|
|
173
|
+
const configureScript = path.join(appRoot, 'scripts', 'hermes', 'configure-pixcode-mcp.mjs');
|
|
174
|
+
const code = await runProcess(process.execPath, [configureScript], {
|
|
175
|
+
cwd: appRoot,
|
|
176
|
+
env,
|
|
177
|
+
}, (stream, chunk) => appendGatewayLog(gateway, stream, chunk));
|
|
178
|
+
|
|
179
|
+
if (code !== 0) {
|
|
180
|
+
throw new Error(`Pixcode MCP configuration exited with code ${code}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function snapshotGateway(gateway) {
|
|
185
|
+
if (!gateway) {
|
|
186
|
+
return {
|
|
187
|
+
running: false,
|
|
188
|
+
projectPath: null,
|
|
189
|
+
baseUrl: null,
|
|
190
|
+
host: null,
|
|
191
|
+
port: null,
|
|
192
|
+
pid: null,
|
|
193
|
+
startedAt: null,
|
|
194
|
+
exitedAt: null,
|
|
195
|
+
exitCode: null,
|
|
196
|
+
exitSignal: null,
|
|
197
|
+
error: null,
|
|
198
|
+
lastProbe: null,
|
|
199
|
+
logs: [],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
running: isGatewayRunning(gateway),
|
|
205
|
+
projectPath: gateway.projectPath,
|
|
206
|
+
baseUrl: gateway.baseUrl,
|
|
207
|
+
host: gateway.host,
|
|
208
|
+
port: gateway.port,
|
|
209
|
+
pid: gateway.child?.pid ?? null,
|
|
210
|
+
startedAt: gateway.startedAt,
|
|
211
|
+
exitedAt: gateway.exitedAt,
|
|
212
|
+
exitCode: gateway.exitCode,
|
|
213
|
+
exitSignal: gateway.exitSignal,
|
|
214
|
+
error: gateway.error,
|
|
215
|
+
lastProbe: gateway.lastProbe,
|
|
216
|
+
logs: gateway.logs.slice(-80),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function getHermesGatewayStatus(projectPath) {
|
|
221
|
+
if (projectPath) {
|
|
222
|
+
return snapshotGateway(gateways.get(normalizeProjectPath(projectPath)));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const active = Array.from(gateways.values()).filter(isGatewayRunning);
|
|
226
|
+
return {
|
|
227
|
+
running: active.length > 0,
|
|
228
|
+
gateways: Array.from(gateways.values()).map(snapshotGateway),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function ensureHermesGateway(options = {}) {
|
|
233
|
+
const projectPath = normalizeProjectPath(options.projectPath);
|
|
234
|
+
const existing = gateways.get(projectPath);
|
|
235
|
+
if (isGatewayRunning(existing)) {
|
|
236
|
+
const probe = await probeHermesGateway(projectPath, { requireRunning: true }).catch((error) => ({
|
|
237
|
+
ok: false,
|
|
238
|
+
error: error instanceof Error ? error.message : String(error),
|
|
239
|
+
}));
|
|
240
|
+
if (probe.ok) {
|
|
241
|
+
return {
|
|
242
|
+
...snapshotGateway(existing),
|
|
243
|
+
probe,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
stopHermesGateway(projectPath);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const host = options.host || DEFAULT_HOST;
|
|
250
|
+
const port = await findAvailablePort(Number(options.port || process.env.HERMES_API_SERVER_PORT || DEFAULT_PORT), host);
|
|
251
|
+
const apiServerKey = options.apiServerKey || makeApiServerKey();
|
|
252
|
+
const appRoot = options.appRoot || process.cwd();
|
|
253
|
+
const env = buildHermesGatewayEnv(process.env, {
|
|
254
|
+
...options,
|
|
255
|
+
host,
|
|
256
|
+
port,
|
|
257
|
+
apiServerKey,
|
|
258
|
+
appRoot,
|
|
259
|
+
});
|
|
260
|
+
const installStatus = readHermesInstallStatus(env);
|
|
261
|
+
if (!installStatus.installed || !installStatus.command) {
|
|
262
|
+
throw new Error(installStatus.error || 'Hermes Agent CLI is not installed.');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const gateway = {
|
|
266
|
+
id: `${projectPath}:${port}`,
|
|
267
|
+
projectPath,
|
|
268
|
+
host,
|
|
269
|
+
port,
|
|
270
|
+
baseUrl: gatewayBaseUrl(host, port),
|
|
271
|
+
apiServerKey,
|
|
272
|
+
command: installStatus.command,
|
|
273
|
+
child: null,
|
|
274
|
+
startedAt: nowIso(),
|
|
275
|
+
exitedAt: null,
|
|
276
|
+
exitCode: null,
|
|
277
|
+
exitSignal: null,
|
|
278
|
+
error: null,
|
|
279
|
+
lastProbe: null,
|
|
280
|
+
logs: [],
|
|
281
|
+
};
|
|
282
|
+
gateways.set(projectPath, gateway);
|
|
283
|
+
|
|
284
|
+
await configurePixcodeMcp({ appRoot, env, gateway });
|
|
285
|
+
|
|
286
|
+
const child = spawn(installStatus.command, ['gateway'], {
|
|
287
|
+
cwd: projectPath,
|
|
288
|
+
env,
|
|
289
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
290
|
+
windowsHide: true,
|
|
291
|
+
});
|
|
292
|
+
gateway.child = child;
|
|
293
|
+
appendGatewayLog(gateway, 'meta', `$ ${installStatus.command} gateway\n`);
|
|
294
|
+
|
|
295
|
+
child.stdout?.on('data', (buf) => appendGatewayLog(gateway, 'stdout', buf.toString()));
|
|
296
|
+
child.stderr?.on('data', (buf) => appendGatewayLog(gateway, 'stderr', buf.toString()));
|
|
297
|
+
child.on('error', (error) => {
|
|
298
|
+
gateway.error = error instanceof Error ? error.message : String(error);
|
|
299
|
+
appendGatewayLog(gateway, 'stderr', `${gateway.error}\n`);
|
|
300
|
+
});
|
|
301
|
+
child.on('exit', (code, signal) => {
|
|
302
|
+
gateway.exitCode = code;
|
|
303
|
+
gateway.exitSignal = signal;
|
|
304
|
+
gateway.exitedAt = nowIso();
|
|
305
|
+
appendGatewayLog(gateway, 'meta', `Hermes gateway exited with code ${code}${signal ? ` (${signal})` : ''}\n`);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const probe = await waitForGatewayReady(gateway);
|
|
309
|
+
return {
|
|
310
|
+
...snapshotGateway(gateway),
|
|
311
|
+
probe,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function probeHermesGateway(projectPath, options = {}) {
|
|
316
|
+
const gateway = projectPath
|
|
317
|
+
? gateways.get(normalizeProjectPath(projectPath))
|
|
318
|
+
: Array.from(gateways.values()).find(isGatewayRunning);
|
|
319
|
+
|
|
320
|
+
if (!isGatewayRunning(gateway)) {
|
|
321
|
+
const result = {
|
|
322
|
+
ok: false,
|
|
323
|
+
error: 'Hermes gateway is not running.',
|
|
324
|
+
projectPath: projectPath ? normalizeProjectPath(projectPath) : null,
|
|
325
|
+
baseUrl: null,
|
|
326
|
+
checks: {},
|
|
327
|
+
};
|
|
328
|
+
if (options.requireRunning) return result;
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const checks = {};
|
|
333
|
+
try {
|
|
334
|
+
checks.health = await fetchJson(`${gateway.baseUrl}/health`);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
checks.health = { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
checks.capabilities = await callGateway(gateway, '/v1/capabilities');
|
|
341
|
+
} catch (error) {
|
|
342
|
+
checks.capabilities = { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
checks.models = await callGateway(gateway, '/v1/models');
|
|
347
|
+
} catch (error) {
|
|
348
|
+
checks.models = { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (typeof options.input === 'string' && options.input.trim()) {
|
|
352
|
+
try {
|
|
353
|
+
checks.run = await callGateway(gateway, '/v1/runs', {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
body: JSON.stringify({
|
|
356
|
+
input: options.input.trim(),
|
|
357
|
+
session_id: options.sessionId || `pixcode-${Date.now()}`,
|
|
358
|
+
instructions: options.instructions || 'Respond briefly for a Pixcode REST integration check.',
|
|
359
|
+
}),
|
|
360
|
+
timeoutMs: options.runTimeoutMs || 15000,
|
|
361
|
+
});
|
|
362
|
+
} catch (error) {
|
|
363
|
+
checks.run = { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const ok = Boolean(
|
|
368
|
+
checks.health?.ok &&
|
|
369
|
+
checks.capabilities?.ok &&
|
|
370
|
+
checks.models?.ok &&
|
|
371
|
+
(!checks.run || checks.run.ok),
|
|
372
|
+
);
|
|
373
|
+
const result = {
|
|
374
|
+
ok,
|
|
375
|
+
projectPath: gateway.projectPath,
|
|
376
|
+
baseUrl: gateway.baseUrl,
|
|
377
|
+
checkedAt: nowIso(),
|
|
378
|
+
checks,
|
|
379
|
+
error: ok ? null : 'One or more Hermes REST checks failed.',
|
|
380
|
+
};
|
|
381
|
+
gateway.lastProbe = result;
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function stopHermesGateway(projectPath) {
|
|
386
|
+
const targets = projectPath
|
|
387
|
+
? [gateways.get(normalizeProjectPath(projectPath))].filter(Boolean)
|
|
388
|
+
: Array.from(gateways.values());
|
|
389
|
+
let stopped = 0;
|
|
390
|
+
for (const gateway of targets) {
|
|
391
|
+
if (!isGatewayRunning(gateway)) continue;
|
|
392
|
+
try {
|
|
393
|
+
gateway.child.kill();
|
|
394
|
+
stopped += 1;
|
|
395
|
+
} catch (error) {
|
|
396
|
+
gateway.error = error instanceof Error ? error.message : String(error);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return { stopped };
|
|
400
|
+
}
|
|
@@ -455,7 +455,39 @@ function scheduleCleanup(job) {
|
|
|
455
455
|
}, FINISHED_TTL_MS);
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
-
function
|
|
458
|
+
function isWindowsEpermSpawnError(error) {
|
|
459
|
+
return process.platform === 'win32' && (
|
|
460
|
+
error?.code === 'EPERM' ||
|
|
461
|
+
/spawn\s+EPERM/i.test(String(error?.message || ''))
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function isWindowsPowerShellCommand(command) {
|
|
466
|
+
if (process.platform !== 'win32') return false;
|
|
467
|
+
const name = path.basename(String(command || '')).toLowerCase();
|
|
468
|
+
return name === 'powershell.exe' || name === 'powershell' || name === 'pwsh.exe' || name === 'pwsh';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function quoteWindowsCmdArg(value) {
|
|
472
|
+
const text = String(value ?? '');
|
|
473
|
+
if (!text) return '""';
|
|
474
|
+
if (!/[\s"&|<>^()]/.test(text)) return text;
|
|
475
|
+
return `"${text.replace(/"/g, '\\"')}"`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function windowsCmdFallbackCommand(command, args) {
|
|
479
|
+
return {
|
|
480
|
+
command: process.env.ComSpec || 'cmd.exe',
|
|
481
|
+
args: [
|
|
482
|
+
'/d',
|
|
483
|
+
'/s',
|
|
484
|
+
'/c',
|
|
485
|
+
[command, ...args].map(quoteWindowsCmdArg).join(' '),
|
|
486
|
+
],
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function spawnLoggedOnce(job, command, args, options) {
|
|
459
491
|
appendLog(job, 'meta', `$ ${command} ${args.join(' ')}\n`);
|
|
460
492
|
const child = spawn(command, args, {
|
|
461
493
|
...options,
|
|
@@ -463,8 +495,8 @@ function spawnLogged(job, command, args, options) {
|
|
|
463
495
|
windowsHide: true,
|
|
464
496
|
});
|
|
465
497
|
job.child = child;
|
|
466
|
-
child.stdout
|
|
467
|
-
child.stderr
|
|
498
|
+
child.stdout?.on('data', (buf) => appendLog(job, 'stdout', buf.toString()));
|
|
499
|
+
child.stderr?.on('data', (buf) => appendLog(job, 'stderr', buf.toString()));
|
|
468
500
|
return new Promise((resolve, reject) => {
|
|
469
501
|
child.on('error', reject);
|
|
470
502
|
child.on('close', (code, signal) => {
|
|
@@ -477,6 +509,20 @@ function spawnLogged(job, command, args, options) {
|
|
|
477
509
|
});
|
|
478
510
|
}
|
|
479
511
|
|
|
512
|
+
async function spawnLogged(job, command, args, options) {
|
|
513
|
+
try {
|
|
514
|
+
return await spawnLoggedOnce(job, command, args, options);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (!isWindowsEpermSpawnError(error) || !isWindowsPowerShellCommand(command)) {
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
appendLog(job, 'stderr', 'PowerShell direct launch was blocked by Windows (EPERM); retrying through cmd.exe without elevation.\n');
|
|
521
|
+
const fallback = windowsCmdFallbackCommand(command, args);
|
|
522
|
+
return spawnLoggedOnce(job, fallback.command, fallback.args, options);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
480
526
|
async function runConfigureScript(job, env, appRoot) {
|
|
481
527
|
const configureScript = path.join(appRoot, 'scripts', 'hermes', 'configure-pixcode-mcp.mjs');
|
|
482
528
|
if (!fs.existsSync(configureScript)) {
|