@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.
@@ -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.match(workbench, /HERMES_AGENT_START_COMMAND/, 'Hermes Agent should launch from the bottom terminal through a server-side sentinel.');
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.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.');
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.');
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.');
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.match(
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
- let initialCommand = data.initialCommand;
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
- ? (isHermesShellSession ? '_hermes' : `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`)
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:', hermesCommand ? hermesCommand : initialCommand);
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:', hermesCommand ? hermesCommand : (shellCommand || 'interactive shell'));
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: isHermesShellSession,
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 after = Number.parseInt(typeof req.query.after === 'string' ? req.query.after : '0', 10);
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
- hermesTerminalLaunches.push(event);
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
  });