@pixelbyte-software/pixcode 1.49.5 → 1.49.7
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-BqgTbW4j.js} +144 -144
- package/dist/index.html +1 -1
- package/dist-server/server/index.js +3 -2
- 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 +12 -2
- 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 +8 -0
- package/server/index.js +3 -2
- 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
|
+
}
|
|
@@ -56,6 +56,8 @@ assert.match(workbench, /WorkbenchCliPanel/, 'Right workbench panel should rende
|
|
|
56
56
|
assert.match(workbench, /setIsTerminalOpen\(true\)/, 'CLI picker should give way to a full-height terminal after the user starts a provider.');
|
|
57
57
|
assert.match(workbench, /onClose=\{closeTerminal\}/, 'Closing the workbench terminal should return to the CLI picker.');
|
|
58
58
|
assert.match(workbench, /WorkbenchCliPanelToolbar/, 'CLI terminal should keep history and new-session actions visible.');
|
|
59
|
+
assert.match(workbench, /onCloseTerminal=\{closeTerminal\}/, 'CLI terminal toolbar should keep a close button that returns to provider selection.');
|
|
60
|
+
assert.match(workbench, /vscodeWorkbench\.cli\.closeTerminal/, 'CLI terminal close action should have a dedicated accessible label.');
|
|
59
61
|
assert.match(workbench, /WORKBENCH_CLI_STATE_STORAGE_KEY/, 'CLI terminal should remember per-project open state across workspace switches.');
|
|
60
62
|
assert.match(workbench, /function WorkbenchBottomTerminal/, 'Terminal activity should render as a bottom plain-shell panel.');
|
|
61
63
|
assert.match(workbench, /BOTTOM_TERMINAL_MIN_HEIGHT/, 'Bottom terminal should support height resizing.');
|
|
@@ -74,9 +76,13 @@ assert.match(themeContext, /return true;/, 'Pixcode should default new installs
|
|
|
74
76
|
assert.match(workbench, /openNewCliSessionPicker/, 'CLI terminal plus should return to provider selection before starting a fresh session.');
|
|
75
77
|
assert.match(workbench, /terminateCurrentCliSession\(selectedProvider\)/, 'CLI terminal plus should terminate the existing provider PTY before showing selection.');
|
|
76
78
|
assert.match(workbench, /forceNewSession=\{terminalLaunch\.forceNewSession\}/, 'Fresh CLI sessions should bypass the cached default PTY.');
|
|
79
|
+
assert.doesNotMatch(workbench, /suspendAutoConnect/, 'Right CLI provider starts should auto-connect directly instead of showing the shell continue overlay.');
|
|
77
80
|
assert.match(serverIndex, /\/api\/shell\/sessions\/terminate/, 'Backend should expose an authenticated endpoint to terminate cached provider PTYs immediately.');
|
|
78
81
|
assert.match(serverIndex, /isPlainShell && !initialCommand/, 'Backend should spawn an interactive plain shell when no terminal command is provided.');
|
|
79
82
|
assert.match(serverIndex, /pixcode:hermes:start/, 'Backend should expand Hermes terminal sentinels on the server host.');
|
|
83
|
+
assert.match(serverIndex, /Hermes Agent is starting/, 'Hermes terminal should show an immediate startup line before the interactive app draws.');
|
|
84
|
+
assert.match(serverIndex, /"\$HERMES_CMD" chat/, 'POSIX Hermes terminal should start the interactive chat command explicitly.');
|
|
85
|
+
assert.match(serverIndex, /& \$script:HermesCmd chat/, 'Windows Hermes terminal should start the interactive chat command explicitly.');
|
|
80
86
|
assert.doesNotMatch(serverIndex, /iex \(irm https:\/\/raw\.githubusercontent\.com\/NousResearch\/hermes-agent\/main\/scripts\/install\.ps1\)/, 'Windows Hermes install should avoid the old inline iex pattern.');
|
|
81
87
|
assert.doesNotMatch(serverIndex, /scriptblock\]::Create\(\(irm https:\/\/raw\.githubusercontent\.com\/NousResearch\/hermes-agent\/main\/scripts\/install\.ps1\)\)/, 'Windows Hermes install should avoid scriptblock Invoke-RestMethod eval patterns.');
|
|
82
88
|
assert.match(serverIndex, /Invoke-WebRequest[\s\S]+install\.ps1[\s\S]+-OutFile/, 'Windows Hermes install should download the installer to a file before running it.');
|
|
@@ -107,6 +113,8 @@ assert.match(hermesRoutes, /terminal-launches/, 'Hermes MCP should be able to re
|
|
|
107
113
|
assert.match(hermesRoutes, /install-status/, 'Hermes settings and terminal UI should have an install-status endpoint.');
|
|
108
114
|
assert.match(hermesRoutes, /router\.post\('\/install'/, 'Hermes should install through the backend API instead of terminal command paste.');
|
|
109
115
|
assert.match(read('scripts/smoke/hermes-api-install.mjs'), /hermes API install smoke passed/, 'Hermes API install behavior should have a focused smoke test.');
|
|
116
|
+
assert.match(workbench, /terminalStartupInput/, 'Hermes terminal launch prompts should be passed into the selected CLI.');
|
|
117
|
+
assert.match(read('src/components/shell/hooks/useShellConnection.ts'), /startupInputRef/, 'Shell connections should support one-shot startup input for Hermes-triggered CLI work.');
|
|
110
118
|
assert.match(serverIndex, /forceNewSession/, 'Shell backend should support explicit fresh-session launches from the workbench.');
|
|
111
119
|
assert.match(serverIndex, /killProviderPtySessions/, 'Shell backend should terminate old provider PTYs when a fresh CLI session is requested.');
|
|
112
120
|
|
package/server/index.js
CHANGED
|
@@ -414,6 +414,7 @@ function getOrCreateHermesApiKey(userId) {
|
|
|
414
414
|
|
|
415
415
|
return apiKeysDb.createApiKey(userId, 'Hermes Agent MCP', [
|
|
416
416
|
'hermes:mcp',
|
|
417
|
+
'hermes:gateway',
|
|
417
418
|
'projects:read',
|
|
418
419
|
'providers:read',
|
|
419
420
|
'terminal:launch',
|
|
@@ -483,7 +484,7 @@ function buildHermesShellCommand(kind, env) {
|
|
|
483
484
|
if (kind === 'pixcode:hermes:install') {
|
|
484
485
|
return `${setEnv}; ${resolveHermesCommand}; ${configure}; ${installHermesIfMissing}; Install-HermesIfMissing; Invoke-PixcodeHermesConfigure`;
|
|
485
486
|
}
|
|
486
|
-
return `${setEnv}; ${resolveHermesCommand}; ${configure}; ${installHermesIfMissing}; Install-HermesIfMissing; Invoke-PixcodeHermesConfigure; & $script:HermesCmd`;
|
|
487
|
+
return `${setEnv}; ${resolveHermesCommand}; ${configure}; ${installHermesIfMissing}; Install-HermesIfMissing; Write-Host "Hermes Agent is starting..."; Invoke-PixcodeHermesConfigure; & $script:HermesCmd chat`;
|
|
487
488
|
}
|
|
488
489
|
|
|
489
490
|
const setEnv = [
|
|
@@ -515,7 +516,7 @@ function buildHermesShellCommand(kind, env) {
|
|
|
515
516
|
if (kind === 'pixcode:hermes:install') {
|
|
516
517
|
return `${setEnv} sh -lc ${quote(`${resolveHermesCommand} ${installHermesIfMissing} installHermesIfMissing && { node ${shellQuotePosix(configureScript)} || echo "Pixcode MCP configure failed; continuing."; }`)}`;
|
|
517
518
|
}
|
|
518
|
-
return `${setEnv} sh -lc ${quote(`${resolveHermesCommand} ${installHermesIfMissing} installHermesIfMissing && { node ${shellQuotePosix(configureScript)} || echo "Pixcode MCP configure failed; starting Hermes anyway."; } && "$HERMES_CMD"`)}`;
|
|
519
|
+
return `${setEnv} sh -lc ${quote(`${resolveHermesCommand} ${installHermesIfMissing} installHermesIfMissing && printf "Hermes Agent is starting...\\n" && { node ${shellQuotePosix(configureScript)} || echo "Pixcode MCP configure failed; starting Hermes anyway."; } && exec "$HERMES_CMD" chat`)}`;
|
|
519
520
|
}
|
|
520
521
|
|
|
521
522
|
// Single WebSocket server that handles both paths
|
|
@@ -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
|
+
}
|