@pixelbyte-software/pixcode 1.49.7 → 1.49.9
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-Bw6PxVkB.css +32 -0
- package/dist/assets/{index-BqgTbW4j.js → index-DzGkH0cd.js} +138 -138
- package/dist/index.html +2 -2
- package/dist-server/server/index.js +5 -124
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js +128 -7
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js.map +1 -1
- package/dist-server/server/services/hermes-gateway.js +226 -1
- package/dist-server/server/services/hermes-gateway.js.map +1 -1
- package/package.json +1 -1
- package/scripts/smoke/hermes-api-install.mjs +5 -13
- package/scripts/smoke/hermes-rest-chat-api.mjs +109 -0
- package/scripts/smoke/hermes-rest-chat-live.mjs +41 -0
- package/scripts/smoke/hermes-rest-gateway.mjs +5 -0
- package/scripts/smoke/pixcode-workbench-1-48.mjs +15 -9
- package/scripts/smoke/vscode-workbench-polish.mjs +8 -2
- package/server/index.js +5 -129
- package/server/modules/orchestration/hermes/hermes.routes.ts +128 -6
- package/server/services/hermes-gateway.js +245 -1
- package/dist/assets/index-DjKDBqln.css +0 -32
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
runHermesGatewayPrompt,
|
|
9
|
+
stopHermesGateway,
|
|
10
|
+
} from '../../server/services/hermes-gateway.js';
|
|
11
|
+
|
|
12
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
13
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pixcode-hermes-chat-api-'));
|
|
14
|
+
const fakeHermes = path.join(tempRoot, 'hermes');
|
|
15
|
+
const projectPath = path.join(tempRoot, 'project');
|
|
16
|
+
const hermesHome = path.join(tempRoot, 'home');
|
|
17
|
+
|
|
18
|
+
await fs.mkdir(projectPath, { recursive: true });
|
|
19
|
+
await fs.writeFile(fakeHermes, `#!/usr/bin/env node
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
|
|
22
|
+
if (process.argv.includes('--version')) {
|
|
23
|
+
console.log('Hermes Agent v0.0.0 smoke');
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!process.argv.includes('gateway')) {
|
|
28
|
+
console.error('expected gateway');
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const host = process.env.API_SERVER_HOST || '127.0.0.1';
|
|
33
|
+
const port = Number(process.env.API_SERVER_PORT || 8642);
|
|
34
|
+
const key = process.env.API_SERVER_KEY || '';
|
|
35
|
+
const server = http.createServer(async (req, res) => {
|
|
36
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
37
|
+
res.setHeader('content-type', 'application/json');
|
|
38
|
+
if (url.pathname !== '/health' && req.headers.authorization !== \`Bearer \${key}\`) {
|
|
39
|
+
res.statusCode = 401;
|
|
40
|
+
res.end(JSON.stringify({ error: 'bad auth' }));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
44
|
+
res.end(JSON.stringify({ ok: true }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (req.method === 'GET' && url.pathname === '/v1/capabilities') {
|
|
48
|
+
res.end(JSON.stringify({ capabilities: ['chat'] }));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (req.method === 'GET' && url.pathname === '/v1/models') {
|
|
52
|
+
res.end(JSON.stringify({ data: [{ id: 'hermes-agent' }] }));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (req.method === 'POST' && url.pathname === '/v1/chat/completions') {
|
|
56
|
+
let body = '';
|
|
57
|
+
for await (const chunk of req) body += chunk.toString();
|
|
58
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
59
|
+
res.end(JSON.stringify({
|
|
60
|
+
id: 'chatcmpl-smoke',
|
|
61
|
+
choices: [{
|
|
62
|
+
index: 0,
|
|
63
|
+
message: {
|
|
64
|
+
role: 'assistant',
|
|
65
|
+
content: \`pixcode-hermes-chat-ok via \${parsed.model}\`,
|
|
66
|
+
},
|
|
67
|
+
finish_reason: 'stop',
|
|
68
|
+
}],
|
|
69
|
+
}));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
res.statusCode = 404;
|
|
73
|
+
res.end(JSON.stringify({ error: url.pathname }));
|
|
74
|
+
});
|
|
75
|
+
server.listen(port, host);
|
|
76
|
+
`, { mode: 0o755 });
|
|
77
|
+
|
|
78
|
+
process.env.HERMES_CLI_PATH = fakeHermes;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const gateway = await ensureHermesGateway({
|
|
82
|
+
appRoot: repoRoot,
|
|
83
|
+
projectPath,
|
|
84
|
+
hermesHome,
|
|
85
|
+
pixcodeBaseUrl: 'http://127.0.0.1:9',
|
|
86
|
+
pixcodeApiKey: 'px_chat_api_smoke_key',
|
|
87
|
+
port: 18752,
|
|
88
|
+
});
|
|
89
|
+
if (!gateway.running || !gateway.probe?.ok) {
|
|
90
|
+
throw new Error(`Fake Hermes gateway did not start cleanly: ${JSON.stringify(gateway)}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const run = await runHermesGatewayPrompt(projectPath, {
|
|
94
|
+
input: 'selam',
|
|
95
|
+
timeoutMs: 10000,
|
|
96
|
+
});
|
|
97
|
+
if (!run.ok || run.transport !== 'chat.completions' || !String(run.message || '').includes('pixcode-hermes-chat-ok')) {
|
|
98
|
+
throw new Error(`Hermes REST chat did not use chat completions: ${JSON.stringify(run)}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log(JSON.stringify({
|
|
102
|
+
ok: true,
|
|
103
|
+
transport: run.transport,
|
|
104
|
+
message: run.message,
|
|
105
|
+
}, null, 2));
|
|
106
|
+
} finally {
|
|
107
|
+
stopHermesGateway(projectPath);
|
|
108
|
+
delete process.env.HERMES_CLI_PATH;
|
|
109
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ensureHermesGateway,
|
|
6
|
+
runHermesGatewayPrompt,
|
|
7
|
+
stopHermesGateway,
|
|
8
|
+
} from '../../server/services/hermes-gateway.js';
|
|
9
|
+
|
|
10
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
11
|
+
const projectPath = path.resolve(process.argv[2] || repoRoot);
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const gateway = await ensureHermesGateway({
|
|
15
|
+
appRoot: repoRoot,
|
|
16
|
+
projectPath,
|
|
17
|
+
pixcodeBaseUrl: 'http://127.0.0.1:9',
|
|
18
|
+
pixcodeApiKey: 'px_live_chat_smoke_key',
|
|
19
|
+
port: Number(process.env.PIXCODE_HERMES_LIVE_CHAT_PORT || 18652),
|
|
20
|
+
});
|
|
21
|
+
if (!gateway.running || !gateway.probe?.ok) {
|
|
22
|
+
throw new Error(`Hermes gateway did not start cleanly: ${JSON.stringify(gateway)}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const run = await runHermesGatewayPrompt(projectPath, {
|
|
26
|
+
input: 'Reply with exactly: pixcode-hermes-chat-ok',
|
|
27
|
+
timeoutMs: 90000,
|
|
28
|
+
});
|
|
29
|
+
if (!run.ok || !String(run.message || '').includes('pixcode-hermes-chat-ok')) {
|
|
30
|
+
throw new Error(`Hermes chat did not return the expected response: ${JSON.stringify(run)}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log(JSON.stringify({
|
|
34
|
+
ok: true,
|
|
35
|
+
transport: run.transport,
|
|
36
|
+
status: run.status,
|
|
37
|
+
message: run.message,
|
|
38
|
+
}, null, 2));
|
|
39
|
+
} finally {
|
|
40
|
+
stopHermesGateway(projectPath);
|
|
41
|
+
}
|
|
@@ -11,7 +11,10 @@ const settingsTab = read('src/components/settings/view/tabs/HermesSettingsTab.ts
|
|
|
11
11
|
|
|
12
12
|
assert.match(service, /export async function ensureHermesGateway/, 'Pixcode should expose an API-managed Hermes gateway starter.');
|
|
13
13
|
assert.match(service, /export async function probeHermesGateway/, 'Pixcode should probe Hermes through its REST API.');
|
|
14
|
+
assert.match(service, /export async function runHermesGatewayPrompt/, 'Pixcode should submit Hermes prompts through the managed REST gateway.');
|
|
14
15
|
assert.match(service, /export function stopHermesGateway/, 'Pixcode should be able to stop a managed Hermes gateway process.');
|
|
16
|
+
assert.match(service, /\/v1\/chat\/completions/, 'Hermes UI chat should use the documented OpenAI-compatible chat completions endpoint first.');
|
|
17
|
+
assert.match(service, /gatewayExitMessage/, 'Hermes gateway failures should include recent stderr/stdout instead of only exit code 1.');
|
|
15
18
|
assert.match(service, /API_SERVER_ENABLED:\s*'true'/, 'Hermes gateway env should enable the API server.');
|
|
16
19
|
assert.match(service, /API_SERVER_KEY/, 'Hermes gateway env should set a bearer key.');
|
|
17
20
|
assert.match(service, /API_SERVER_PORT/, 'Hermes gateway env should choose a REST port.');
|
|
@@ -24,9 +27,11 @@ assert.match(service, /\/v1\/runs/, 'Gateway probe should support a real run sub
|
|
|
24
27
|
assert.match(routes, /router\.get\('\/gateway\/status'/, 'Hermes router should expose gateway status.');
|
|
25
28
|
assert.match(routes, /router\.post\('\/gateway\/start'/, 'Hermes router should expose gateway start.');
|
|
26
29
|
assert.match(routes, /router\.post\('\/gateway\/probe'/, 'Hermes router should expose a REST probe endpoint.');
|
|
30
|
+
assert.match(routes, /router\.post\('\/gateway\/chat'/, 'Hermes router should expose a REST chat endpoint.');
|
|
27
31
|
assert.match(routes, /router\.post\('\/gateway\/stop'/, 'Hermes router should expose gateway stop.');
|
|
28
32
|
assert.match(routes, /ensureHermesGateway/, 'Hermes router should use the managed gateway service.');
|
|
29
33
|
assert.match(routes, /probeHermesGateway/, 'Hermes router should use the REST probe service.');
|
|
34
|
+
assert.match(routes, /runHermesGatewayPrompt/, 'Hermes router should send chat prompts through the REST gateway service.');
|
|
30
35
|
|
|
31
36
|
assert.match(mcpServer, /pixcode_get_hermes_gateway_status/, 'Pixcode MCP should let Hermes inspect gateway status.');
|
|
32
37
|
assert.match(mcpServer, /pixcode_probe_hermes_gateway/, 'Pixcode MCP should let Hermes trigger a REST probe.');
|
|
@@ -17,6 +17,7 @@ const settings = read('src/components/settings/view/Settings.tsx');
|
|
|
17
17
|
const app = read('src/App.tsx');
|
|
18
18
|
const serverIndex = read('server/index.js');
|
|
19
19
|
const hermesRoutes = read('server/modules/orchestration/hermes/hermes.routes.ts');
|
|
20
|
+
const hermesInstallJobs = read('server/services/hermes-install-jobs.js');
|
|
20
21
|
const shellTerminal = read('src/components/shell/hooks/useShellTerminal.ts');
|
|
21
22
|
const shellConnection = read('src/components/shell/hooks/useShellConnection.ts');
|
|
22
23
|
const geminiCli = read('server/gemini-cli.js');
|
|
@@ -63,7 +64,11 @@ assert.match(workbench, /function WorkbenchBottomTerminal/, 'Terminal activity s
|
|
|
63
64
|
assert.match(workbench, /BOTTOM_TERMINAL_MIN_HEIGHT/, 'Bottom terminal should support height resizing.');
|
|
64
65
|
assert.match(workbench, /isBottomTerminalMinimized/, 'Bottom terminal should support minimizing without closing.');
|
|
65
66
|
assert.match(workbench, /isPlainShell/, 'Bottom terminal should open the selected project folder without starting the selected AI CLI.');
|
|
66
|
-
assert.
|
|
67
|
+
assert.doesNotMatch(workbench, /HERMES_AGENT_START_COMMAND/, 'Hermes Agent should not launch from the bottom terminal through a server-side sentinel.');
|
|
68
|
+
assert.match(workbench, /HermesApiChatPanel/, 'Hermes Agent should render a REST-backed chat panel in the bottom area.');
|
|
69
|
+
assert.match(workbench, /\/api\/orchestration\/hermes\/gateway\/chat/, 'Hermes chat panel should send prompts through the Pixcode gateway chat API.');
|
|
70
|
+
assert.match(workbench, /terminal-launches\/stream/, 'Hermes CLI launch requests should arrive through an EventSource stream.');
|
|
71
|
+
assert.doesNotMatch(workbench, /HERMES_TERMINAL_LAUNCH_POLL_MS|setInterval\([\s\S]*terminal-launches/, 'Hermes CLI launch requests should not be polled every few seconds.');
|
|
67
72
|
assert.doesNotMatch(workbench, /Project-scoped agent terminal\. Installs Hermes when missing/, 'Right CLI panel should not show the old Hermes card.');
|
|
68
73
|
assert.doesNotMatch(workbench, /vscodeWorkbench\.hermes\.docsShort|HERMES_AGENT_DOCS_URL/, 'Hermes terminal header should not include a docs shortcut.');
|
|
69
74
|
assert.match(workbench, /shrinkCliPanel/, 'Right CLI panel should expose a shrink action.');
|
|
@@ -79,14 +84,12 @@ assert.match(workbench, /forceNewSession=\{terminalLaunch\.forceNewSession\}/, '
|
|
|
79
84
|
assert.doesNotMatch(workbench, /suspendAutoConnect/, 'Right CLI provider starts should auto-connect directly instead of showing the shell continue overlay.');
|
|
80
85
|
assert.match(serverIndex, /\/api\/shell\/sessions\/terminate/, 'Backend should expose an authenticated endpoint to terminate cached provider PTYs immediately.');
|
|
81
86
|
assert.match(serverIndex, /isPlainShell && !initialCommand/, 'Backend should spawn an interactive plain shell when no terminal command is provided.');
|
|
82
|
-
assert.
|
|
83
|
-
assert.
|
|
84
|
-
assert.
|
|
85
|
-
assert.
|
|
86
|
-
assert.
|
|
87
|
-
assert.
|
|
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.');
|
|
89
|
-
assert.match(serverIndex, /Resolve-HermesCommand|resolveHermesCommand/, 'Hermes start/install should resolve an existing hermes binary before installing.');
|
|
87
|
+
assert.doesNotMatch(serverIndex, /pixcode:hermes:start/, 'Backend should not need a Hermes terminal sentinel for the workbench Hermes panel.');
|
|
88
|
+
assert.doesNotMatch(serverIndex, /hermesCommand/, 'Provider shell starts should not reference the removed Hermes sentinel variable.');
|
|
89
|
+
assert.doesNotMatch(hermesInstallJobs, /iex \(irm https:\/\/raw\.githubusercontent\.com\/NousResearch\/hermes-agent\/main\/scripts\/install\.ps1\)/, 'Windows Hermes install should avoid the old inline iex pattern.');
|
|
90
|
+
assert.doesNotMatch(hermesInstallJobs, /scriptblock\]::Create\(\(irm https:\/\/raw\.githubusercontent\.com\/NousResearch\/hermes-agent\/main\/scripts\/install\.ps1\)\)/, 'Windows Hermes install should avoid scriptblock Invoke-RestMethod eval patterns.');
|
|
91
|
+
assert.match(hermesInstallJobs, /downloadHermesInstaller/, 'Windows Hermes install should download the installer through backend API code before running it.');
|
|
92
|
+
assert.match(hermesInstallJobs, /resolveHermesCommandCandidates|isUsableHermesCommand/, 'Hermes install/status should resolve and test an existing hermes binary before installing.');
|
|
90
93
|
assert.match(serverIndex, /buildProviderShellCommand/, 'Provider terminal launch should centralize provider-specific permission flags.');
|
|
91
94
|
assert.doesNotMatch(shellTerminal, /new WebglAddon\(\)/, 'Workbench terminal should use the stable xterm renderer.');
|
|
92
95
|
assert.match(workbench, /setActivityPanel\('explorer'\)/, 'Selecting a project should return the side panel to Explorer.');
|
|
@@ -110,6 +113,9 @@ assert.match(serverIndex, /app\.use\('\/hermes', createHermesTaskRouter\(\)\)/,
|
|
|
110
113
|
assert.doesNotMatch(serverIndex, /app\.use\('\/a2a'/, 'Server should not expose the old A2A route.');
|
|
111
114
|
assert.match(hermesRoutes, /createHermesRouter/, 'Hermes should have a dedicated orchestration API router.');
|
|
112
115
|
assert.match(hermesRoutes, /terminal-launches/, 'Hermes MCP should be able to request visible Pixcode CLI terminal launches.');
|
|
116
|
+
assert.match(hermesRoutes, /terminal-launches\/stream/, 'Hermes MCP terminal launch requests should stream to the workbench over SSE.');
|
|
117
|
+
assert.match(hermesRoutes, /hermesTerminalLaunchEmitter/, 'Hermes terminal launch stream should broadcast new events instead of relying on polling.');
|
|
118
|
+
assert.match(hermesRoutes, /router\.post\('\/gateway\/chat'/, 'Hermes should expose a REST chat endpoint for the bottom panel.');
|
|
113
119
|
assert.match(hermesRoutes, /install-status/, 'Hermes settings and terminal UI should have an install-status endpoint.');
|
|
114
120
|
assert.match(hermesRoutes, /router\.post\('\/install'/, 'Hermes should install through the backend API instead of terminal command paste.');
|
|
115
121
|
assert.match(read('scripts/smoke/hermes-api-install.mjs'), /hermes API install smoke passed/, 'Hermes API install behavior should have a focused smoke test.');
|
|
@@ -288,10 +288,16 @@ assert.match(
|
|
|
288
288
|
'Workbench bottom terminal should run a plain shell in the selected project directory.',
|
|
289
289
|
);
|
|
290
290
|
|
|
291
|
-
assert.
|
|
291
|
+
assert.doesNotMatch(
|
|
292
292
|
workbench,
|
|
293
293
|
/HERMES_AGENT_START_COMMAND/,
|
|
294
|
-
'Hermes Agent should launch through the bottom terminal with a server-side command sentinel.',
|
|
294
|
+
'Hermes Agent should not launch through the bottom terminal with a server-side command sentinel.',
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
assert.match(
|
|
298
|
+
workbench,
|
|
299
|
+
/HermesApiChatPanel/,
|
|
300
|
+
'Hermes Agent should use the REST chat panel in the bottom area.',
|
|
295
301
|
);
|
|
296
302
|
|
|
297
303
|
assert.doesNotMatch(
|
package/server/index.js
CHANGED
|
@@ -32,10 +32,6 @@ const SERVER_VERSION = (() => {
|
|
|
32
32
|
return '0.0.0';
|
|
33
33
|
}
|
|
34
34
|
})();
|
|
35
|
-
const HERMES_SHELL_COMMANDS = new Set([
|
|
36
|
-
'pixcode:hermes:start',
|
|
37
|
-
'pixcode:hermes:install',
|
|
38
|
-
]);
|
|
39
35
|
const DAEMON_COMMAND_CONTEXT = {
|
|
40
36
|
appRoot: APP_ROOT,
|
|
41
37
|
cliEntry: path.join(APP_ROOT, 'server', 'cli.js'),
|
|
@@ -328,14 +324,6 @@ function killProviderPtySessions(projectPath, provider) {
|
|
|
328
324
|
return killed;
|
|
329
325
|
}
|
|
330
326
|
|
|
331
|
-
function shellQuotePosix(value) {
|
|
332
|
-
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function shellQuotePowerShell(value) {
|
|
336
|
-
return `'${String(value).replace(/'/g, "''")}'`;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
327
|
function normalizeShellPermissionMode(value) {
|
|
340
328
|
return typeof value === 'string' ? value.trim() : '';
|
|
341
329
|
}
|
|
@@ -421,104 +409,6 @@ function getOrCreateHermesApiKey(userId) {
|
|
|
421
409
|
]).apiKey;
|
|
422
410
|
}
|
|
423
411
|
|
|
424
|
-
function buildHermesShellCommand(kind, env) {
|
|
425
|
-
const configureScript = path.join(APP_ROOT, 'scripts', 'hermes', 'configure-pixcode-mcp.mjs');
|
|
426
|
-
const isWindows = os.platform() === 'win32';
|
|
427
|
-
const quote = isWindows ? shellQuotePowerShell : shellQuotePosix;
|
|
428
|
-
|
|
429
|
-
if (isWindows) {
|
|
430
|
-
const setEnv = [
|
|
431
|
-
`$env:PIXCODE_BASE_URL=${quote(env.PIXCODE_BASE_URL)}`,
|
|
432
|
-
`$env:PIXCODE_API_KEY=${quote(env.PIXCODE_API_KEY)}`,
|
|
433
|
-
`$env:PIXCODE_APP_ROOT=${quote(env.PIXCODE_APP_ROOT)}`,
|
|
434
|
-
].join('; ');
|
|
435
|
-
const resolveHermesCommand = [
|
|
436
|
-
'function Test-HermesCommand($candidate) {',
|
|
437
|
-
'if (-not $candidate) { return $false; }',
|
|
438
|
-
'try {',
|
|
439
|
-
'& $candidate --version *> $null;',
|
|
440
|
-
'return $LASTEXITCODE -eq 0;',
|
|
441
|
-
'} catch { return $false; }',
|
|
442
|
-
'}',
|
|
443
|
-
'function Resolve-HermesCommand {',
|
|
444
|
-
'$candidates = @(',
|
|
445
|
-
'$env:HERMES_CLI_PATH,',
|
|
446
|
-
'(Join-Path $env:LOCALAPPDATA "hermes\\bin\\hermes.cmd"),',
|
|
447
|
-
'(Join-Path $env:LOCALAPPDATA "hermes\\bin\\hermes.bat"),',
|
|
448
|
-
'(Join-Path $env:LOCALAPPDATA "hermes\\bin\\hermes.exe"),',
|
|
449
|
-
'(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\venv\\Scripts\\hermes.cmd"),',
|
|
450
|
-
'(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\venv\\Scripts\\hermes.bat"),',
|
|
451
|
-
'(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\venv\\Scripts\\hermes.exe"),',
|
|
452
|
-
'(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\.venv\\Scripts\\hermes.cmd"),',
|
|
453
|
-
'(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\.venv\\Scripts\\hermes.bat"),',
|
|
454
|
-
'(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\.venv\\Scripts\\hermes.exe"),',
|
|
455
|
-
'(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\hermes.cmd"),',
|
|
456
|
-
'(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\hermes.bat"),',
|
|
457
|
-
'(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\hermes.exe")',
|
|
458
|
-
');',
|
|
459
|
-
'foreach ($candidate in $candidates) { if ($candidate -and (Test-Path $candidate) -and (Test-HermesCommand $candidate)) { return $candidate; } }',
|
|
460
|
-
'$cmd = Get-Command hermes -ErrorAction SilentlyContinue;',
|
|
461
|
-
'if ($cmd -and (Test-HermesCommand $cmd.Source)) { return $cmd.Source; }',
|
|
462
|
-
'return $null;',
|
|
463
|
-
'}',
|
|
464
|
-
].join(' ');
|
|
465
|
-
const configure = [
|
|
466
|
-
'function Invoke-PixcodeHermesConfigure {',
|
|
467
|
-
`& node ${quote(configureScript)};`,
|
|
468
|
-
'if ($LASTEXITCODE -ne 0) { Write-Warning "Pixcode MCP configure failed; starting Hermes anyway."; $global:LASTEXITCODE = 0; }',
|
|
469
|
-
'}',
|
|
470
|
-
].join(' ');
|
|
471
|
-
const installHermesIfMissing = [
|
|
472
|
-
'function Install-HermesIfMissing {',
|
|
473
|
-
'$script:HermesCmd = Resolve-HermesCommand;',
|
|
474
|
-
'if ($script:HermesCmd) { & $script:HermesCmd --version *> $null; return; }',
|
|
475
|
-
'$installer = Join-Path $env:TEMP "pixcode-hermes-install.ps1";',
|
|
476
|
-
'Invoke-WebRequest -Uri "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1" -UseBasicParsing -OutFile $installer;',
|
|
477
|
-
'& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer -SkipSetup -Branch main;',
|
|
478
|
-
'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE; }',
|
|
479
|
-
'$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + $env:Path;',
|
|
480
|
-
'$script:HermesCmd = Resolve-HermesCommand;',
|
|
481
|
-
'if (-not $script:HermesCmd) { throw "Hermes installed, but the hermes command could not be found. Restart Pixcode or add Hermes to PATH."; }',
|
|
482
|
-
'}',
|
|
483
|
-
].join(' ');
|
|
484
|
-
if (kind === 'pixcode:hermes:install') {
|
|
485
|
-
return `${setEnv}; ${resolveHermesCommand}; ${configure}; ${installHermesIfMissing}; Install-HermesIfMissing; Invoke-PixcodeHermesConfigure`;
|
|
486
|
-
}
|
|
487
|
-
return `${setEnv}; ${resolveHermesCommand}; ${configure}; ${installHermesIfMissing}; Install-HermesIfMissing; Write-Host "Hermes Agent is starting..."; Invoke-PixcodeHermesConfigure; & $script:HermesCmd chat`;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const setEnv = [
|
|
491
|
-
`PIXCODE_BASE_URL=${quote(env.PIXCODE_BASE_URL)}`,
|
|
492
|
-
`PIXCODE_API_KEY=${quote(env.PIXCODE_API_KEY)}`,
|
|
493
|
-
`PIXCODE_APP_ROOT=${quote(env.PIXCODE_APP_ROOT)}`,
|
|
494
|
-
].join(' ');
|
|
495
|
-
const resolveHermesCommand = [
|
|
496
|
-
'testHermesCommand() {',
|
|
497
|
-
'[ -n "$1" ] && [ -x "$1" ] && "$1" --version >/dev/null 2>&1;',
|
|
498
|
-
'}',
|
|
499
|
-
'resolveHermesCommand() {',
|
|
500
|
-
'for candidate in "${HERMES_CLI_PATH:-}" "$HOME/.local/bin/hermes" "$HOME/.hermes/hermes-agent/venv/bin/hermes" "$HOME/.hermes/hermes-agent/.venv/bin/hermes" "/usr/local/bin/hermes" "/usr/local/lib/hermes-agent/venv/bin/hermes"; do',
|
|
501
|
-
'if testHermesCommand "$candidate"; then printf "%s\\n" "$candidate"; return 0; fi;',
|
|
502
|
-
'done;',
|
|
503
|
-
'candidate="$(command -v hermes 2>/dev/null || true)";',
|
|
504
|
-
'if testHermesCommand "$candidate"; then printf "%s\\n" "$candidate"; return 0; fi;',
|
|
505
|
-
'return 1;',
|
|
506
|
-
'}',
|
|
507
|
-
].join(' ');
|
|
508
|
-
const installHermesIfMissing = [
|
|
509
|
-
'installHermesIfMissing() {',
|
|
510
|
-
'HERMES_CMD="$(resolveHermesCommand 2>/dev/null || true)";',
|
|
511
|
-
'if [ -n "$HERMES_CMD" ]; then "$HERMES_CMD" --version >/dev/null 2>&1 || true; return 0; fi;',
|
|
512
|
-
'echo "Hermes is not installed. Use Pixcode Settings > Hermes Agent > Install or repair, then start again." >&2;',
|
|
513
|
-
'exit 127;',
|
|
514
|
-
'}',
|
|
515
|
-
].join(' ');
|
|
516
|
-
if (kind === 'pixcode:hermes:install') {
|
|
517
|
-
return `${setEnv} sh -lc ${quote(`${resolveHermesCommand} ${installHermesIfMissing} installHermesIfMissing && { node ${shellQuotePosix(configureScript)} || echo "Pixcode MCP configure failed; continuing."; }`)}`;
|
|
518
|
-
}
|
|
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`)}`;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
412
|
// Single WebSocket server that handles both paths
|
|
523
413
|
const wss = new WebSocketServer({
|
|
524
414
|
server,
|
|
@@ -2290,21 +2180,7 @@ function handleShellConnection(ws, request) {
|
|
|
2290
2180
|
const sessionId = data.sessionId;
|
|
2291
2181
|
const hasSession = data.hasSession;
|
|
2292
2182
|
const provider = data.provider || 'claude';
|
|
2293
|
-
|
|
2294
|
-
const hermesCommand = HERMES_SHELL_COMMANDS.has(initialCommand) ? initialCommand : null;
|
|
2295
|
-
const isHermesShellSession = Boolean(hermesCommand);
|
|
2296
|
-
if (hermesCommand) {
|
|
2297
|
-
const apiKey = getOrCreateHermesApiKey(request?.user?.id);
|
|
2298
|
-
if (!apiKey) {
|
|
2299
|
-
ws.send(JSON.stringify({ type: 'error', message: 'Hermes MCP could not create a Pixcode API key for this user.' }));
|
|
2300
|
-
return;
|
|
2301
|
-
}
|
|
2302
|
-
initialCommand = buildHermesShellCommand(hermesCommand, {
|
|
2303
|
-
PIXCODE_BASE_URL: resolvePublicBaseUrl(request),
|
|
2304
|
-
PIXCODE_API_KEY: apiKey,
|
|
2305
|
-
PIXCODE_APP_ROOT: APP_ROOT,
|
|
2306
|
-
});
|
|
2307
|
-
}
|
|
2183
|
+
const initialCommand = data.initialCommand;
|
|
2308
2184
|
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
|
2309
2185
|
const forceNewSession = Boolean(data.forceNewSession);
|
|
2310
2186
|
const shellPermissionMode = normalizeShellPermissionMode(data.permissionMode);
|
|
@@ -2329,7 +2205,7 @@ function handleShellConnection(ws, request) {
|
|
|
2329
2205
|
|
|
2330
2206
|
// Include command hash in session key so different commands get separate sessions
|
|
2331
2207
|
const commandSuffix = isPlainShell && initialCommand
|
|
2332
|
-
?
|
|
2208
|
+
? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
|
|
2333
2209
|
: '';
|
|
2334
2210
|
// Include provider in the key so a fresh "new session" in OpenCode
|
|
2335
2211
|
// doesn't reattach to a cached Claude PTY for the same project (or
|
|
@@ -2390,7 +2266,7 @@ function handleShellConnection(ws, request) {
|
|
|
2390
2266
|
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
|
|
2391
2267
|
console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
|
|
2392
2268
|
if (initialCommand) {
|
|
2393
|
-
console.log('⚡ Initial command:',
|
|
2269
|
+
console.log('⚡ Initial command:', initialCommand || 'interactive shell');
|
|
2394
2270
|
}
|
|
2395
2271
|
|
|
2396
2272
|
// First send a welcome message
|
|
@@ -2537,7 +2413,7 @@ function handleShellConnection(ws, request) {
|
|
|
2537
2413
|
}
|
|
2538
2414
|
}
|
|
2539
2415
|
|
|
2540
|
-
console.log('🔧 Executing shell command:',
|
|
2416
|
+
console.log('🔧 Executing shell command:', shellCommand || 'interactive shell');
|
|
2541
2417
|
|
|
2542
2418
|
// Use appropriate shell based on platform
|
|
2543
2419
|
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
@@ -2574,7 +2450,7 @@ function handleShellConnection(ws, request) {
|
|
|
2574
2450
|
sessionId,
|
|
2575
2451
|
provider,
|
|
2576
2452
|
isPlainShell,
|
|
2577
|
-
keepAliveUntilExit:
|
|
2453
|
+
keepAliveUntilExit: false,
|
|
2578
2454
|
});
|
|
2579
2455
|
|
|
2580
2456
|
// Handle data output
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
2
3
|
|
|
3
4
|
import express, { type Request, type Response, type Router } from 'express';
|
|
4
5
|
|
|
@@ -15,11 +16,13 @@ import {
|
|
|
15
16
|
ensureHermesGateway,
|
|
16
17
|
getHermesGatewayStatus,
|
|
17
18
|
probeHermesGateway,
|
|
19
|
+
runHermesGatewayPrompt,
|
|
18
20
|
stopHermesGateway,
|
|
19
21
|
} from '@/services/hermes-gateway.js';
|
|
20
22
|
|
|
21
23
|
const HERMES_TERMINAL_LAUNCH_LIMIT = 100;
|
|
22
24
|
const HERMES_TERMINAL_LAUNCH_PROVIDERS = new Set(['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode']);
|
|
25
|
+
const HERMES_TERMINAL_LAUNCH_STREAM_HEARTBEAT_MS = 25000;
|
|
23
26
|
|
|
24
27
|
type HermesTerminalLaunchEvent = {
|
|
25
28
|
id: number;
|
|
@@ -45,6 +48,8 @@ type PixcodeRequest = Request & {
|
|
|
45
48
|
|
|
46
49
|
let nextHermesTerminalLaunchId = 1;
|
|
47
50
|
const hermesTerminalLaunches: HermesTerminalLaunchEvent[] = [];
|
|
51
|
+
const hermesTerminalLaunchEmitter = new EventEmitter();
|
|
52
|
+
hermesTerminalLaunchEmitter.setMaxListeners(200);
|
|
48
53
|
|
|
49
54
|
function writeSse(res: Response, event: string, payload: unknown) {
|
|
50
55
|
res.write(`event: ${event}\n`);
|
|
@@ -55,6 +60,19 @@ function readUserId(req: PixcodeRequest) {
|
|
|
55
60
|
return req.user?.id ?? req.user?.userId ?? null;
|
|
56
61
|
}
|
|
57
62
|
|
|
63
|
+
function readAfterId(req: Request) {
|
|
64
|
+
const after = Number.parseInt(typeof req.query.after === 'string' ? req.query.after : '0', 10);
|
|
65
|
+
return Number.isFinite(after) ? after : 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function rememberHermesTerminalLaunch(event: HermesTerminalLaunchEvent) {
|
|
69
|
+
hermesTerminalLaunches.push(event);
|
|
70
|
+
if (hermesTerminalLaunches.length > HERMES_TERMINAL_LAUNCH_LIMIT) {
|
|
71
|
+
hermesTerminalLaunches.splice(0, hermesTerminalLaunches.length - HERMES_TERMINAL_LAUNCH_LIMIT);
|
|
72
|
+
}
|
|
73
|
+
hermesTerminalLaunchEmitter.emit('terminal-launch', event);
|
|
74
|
+
}
|
|
75
|
+
|
|
58
76
|
export function createHermesRouter(options: HermesRouterOptions = {}): Router {
|
|
59
77
|
const router = express.Router();
|
|
60
78
|
|
|
@@ -171,6 +189,66 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
|
|
|
171
189
|
}
|
|
172
190
|
});
|
|
173
191
|
|
|
192
|
+
router.post('/gateway/chat', async (req: PixcodeRequest, res) => {
|
|
193
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
194
|
+
const projectPath = typeof body.projectPath === 'string' && body.projectPath.trim()
|
|
195
|
+
? body.projectPath.trim()
|
|
196
|
+
: undefined;
|
|
197
|
+
const input = typeof body.input === 'string' ? body.input.trim() : '';
|
|
198
|
+
const sessionId = typeof body.sessionId === 'string' && body.sessionId.trim()
|
|
199
|
+
? body.sessionId.trim()
|
|
200
|
+
: undefined;
|
|
201
|
+
|
|
202
|
+
if (!input) {
|
|
203
|
+
res.status(400).json({
|
|
204
|
+
error: {
|
|
205
|
+
code: 'HERMES_PROMPT_REQUIRED',
|
|
206
|
+
message: 'Hermes prompt is required.',
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const apiKey = options.createHermesApiKey?.(readUserId(req)) ?? null;
|
|
213
|
+
if (!apiKey) {
|
|
214
|
+
res.status(500).json({
|
|
215
|
+
error: {
|
|
216
|
+
code: 'HERMES_API_KEY_UNAVAILABLE',
|
|
217
|
+
message: 'Pixcode could not create a Hermes MCP API key for this user.',
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const gateway = await ensureHermesGateway({
|
|
225
|
+
appRoot: options.appRoot ?? process.cwd(),
|
|
226
|
+
pixcodeApiKey: apiKey,
|
|
227
|
+
pixcodeBaseUrl: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
|
|
228
|
+
projectPath,
|
|
229
|
+
});
|
|
230
|
+
const run = await runHermesGatewayPrompt(projectPath, {
|
|
231
|
+
input,
|
|
232
|
+
sessionId,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
res.status(run.ok ? 200 : 502).json({
|
|
236
|
+
ok: run.ok,
|
|
237
|
+
gateway,
|
|
238
|
+
run,
|
|
239
|
+
message: run.message,
|
|
240
|
+
error: run.error ?? null,
|
|
241
|
+
});
|
|
242
|
+
} catch (error) {
|
|
243
|
+
res.status(500).json({
|
|
244
|
+
error: {
|
|
245
|
+
code: 'HERMES_GATEWAY_CHAT_FAILED',
|
|
246
|
+
message: error instanceof Error ? error.message : String(error),
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
174
252
|
router.post('/gateway/stop', (req, res) => {
|
|
175
253
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
176
254
|
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
|
|
@@ -297,13 +375,60 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
|
|
|
297
375
|
});
|
|
298
376
|
|
|
299
377
|
router.get('/terminal-launches', (req, res) => {
|
|
300
|
-
const
|
|
301
|
-
const afterId = Number.isFinite(after) ? after : 0;
|
|
378
|
+
const afterId = readAfterId(req);
|
|
302
379
|
res.json({
|
|
303
380
|
events: hermesTerminalLaunches.filter((event) => event.id > afterId),
|
|
304
381
|
});
|
|
305
382
|
});
|
|
306
383
|
|
|
384
|
+
router.get('/terminal-launches/stream', (req, res) => {
|
|
385
|
+
const afterId = readAfterId(req);
|
|
386
|
+
|
|
387
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
388
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
389
|
+
res.setHeader('Connection', 'keep-alive');
|
|
390
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
391
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
392
|
+
try {
|
|
393
|
+
(res.socket as NodeJS.Socket & { setNoDelay?: (on: boolean) => void })?.setNoDelay?.(true);
|
|
394
|
+
} catch { /* noop */ }
|
|
395
|
+
|
|
396
|
+
let closed = false;
|
|
397
|
+
const safeWrite = (event: string, payload: unknown) => {
|
|
398
|
+
if (closed) return;
|
|
399
|
+
try { writeSse(res, event, payload); } catch { /* socket gone */ }
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
try { res.write(': start\n\n'); } catch { /* noop */ }
|
|
403
|
+
const replayed = hermesTerminalLaunches.filter((event) => event.id > afterId);
|
|
404
|
+
for (const event of replayed) {
|
|
405
|
+
safeWrite('terminal-launch', event);
|
|
406
|
+
}
|
|
407
|
+
safeWrite('ready', {
|
|
408
|
+
latestId: hermesTerminalLaunches[hermesTerminalLaunches.length - 1]?.id ?? afterId,
|
|
409
|
+
replayed: replayed.length,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const heartbeat = setInterval(() => {
|
|
413
|
+
if (closed) return;
|
|
414
|
+
try { res.write(': ping\n\n'); } catch { /* noop */ }
|
|
415
|
+
}, HERMES_TERMINAL_LAUNCH_STREAM_HEARTBEAT_MS);
|
|
416
|
+
|
|
417
|
+
const onTerminalLaunch = (event: HermesTerminalLaunchEvent) => {
|
|
418
|
+
safeWrite('terminal-launch', event);
|
|
419
|
+
};
|
|
420
|
+
hermesTerminalLaunchEmitter.on('terminal-launch', onTerminalLaunch);
|
|
421
|
+
|
|
422
|
+
const cleanup = () => {
|
|
423
|
+
if (closed) return;
|
|
424
|
+
closed = true;
|
|
425
|
+
clearInterval(heartbeat);
|
|
426
|
+
hermesTerminalLaunchEmitter.off('terminal-launch', onTerminalLaunch);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
req.on('close', cleanup);
|
|
430
|
+
});
|
|
431
|
+
|
|
307
432
|
router.post('/terminal-launches', (req, res) => {
|
|
308
433
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
309
434
|
const provider = typeof body.provider === 'string' ? body.provider.trim() : '';
|
|
@@ -328,10 +453,7 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
|
|
|
328
453
|
createdAt: new Date().toISOString(),
|
|
329
454
|
};
|
|
330
455
|
nextHermesTerminalLaunchId += 1;
|
|
331
|
-
|
|
332
|
-
if (hermesTerminalLaunches.length > HERMES_TERMINAL_LAUNCH_LIMIT) {
|
|
333
|
-
hermesTerminalLaunches.splice(0, hermesTerminalLaunches.length - HERMES_TERMINAL_LAUNCH_LIMIT);
|
|
334
|
-
}
|
|
456
|
+
rememberHermesTerminalLaunch(event);
|
|
335
457
|
|
|
336
458
|
res.status(201).json({ event });
|
|
337
459
|
});
|