@pixelbyte-software/pixcode 1.49.10 → 1.49.11

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.
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ import path from 'node:path';
2
3
  import readline from 'node:readline';
4
+ import { fileURLToPath } from 'node:url';
3
5
 
4
6
  const baseUrl = (process.env.PIXCODE_BASE_URL || '').replace(/\/$/, '');
5
7
  const apiKey = process.env.PIXCODE_API_KEY || '';
8
+ const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
9
+ const mcpServerPath = path.join(appRoot, 'scripts', 'hermes', 'pixcode-mcp-server.mjs');
6
10
 
7
11
  const tools = [
8
12
  {
@@ -133,6 +137,30 @@ async function pixcodeFetch(endpoint, options = {}) {
133
137
  return body;
134
138
  }
135
139
 
140
+ async function readProviderStatus(provider) {
141
+ const body = await pixcodeFetch(`/api/providers/${encodeURIComponent(provider)}/auth/status?refresh=1`);
142
+ return body?.data ?? body;
143
+ }
144
+
145
+ async function ensureProviderPixcodeMcp(provider, projectPath) {
146
+ const body = await pixcodeFetch(`/api/providers/${encodeURIComponent(provider)}/mcp/servers`, {
147
+ method: 'POST',
148
+ body: JSON.stringify({
149
+ name: 'pixcode',
150
+ transport: 'stdio',
151
+ scope: 'project',
152
+ workspacePath: projectPath || process.cwd(),
153
+ command: process.execPath,
154
+ args: [mcpServerPath],
155
+ env: {
156
+ PIXCODE_BASE_URL: baseUrl,
157
+ PIXCODE_API_KEY: apiKey,
158
+ },
159
+ }),
160
+ });
161
+ return body?.data?.server ?? body?.server ?? body;
162
+ }
163
+
136
164
  async function callTool(name, args = {}) {
137
165
  if (name === 'pixcode_list_projects') {
138
166
  const projects = await pixcodeFetch('/api/projects');
@@ -147,20 +175,50 @@ async function callTool(name, args = {}) {
147
175
 
148
176
  if (name === 'pixcode_get_provider_status') {
149
177
  const provider = String(args.provider || '');
150
- const body = await pixcodeFetch(`/api/providers/${encodeURIComponent(provider)}/auth/status?refresh=1`);
151
- return textResult(JSON.stringify(body?.data ?? body, null, 2));
178
+ const status = await readProviderStatus(provider);
179
+ return textResult(JSON.stringify(status, null, 2));
152
180
  }
153
181
 
154
182
  if (name === 'pixcode_open_cli_terminal') {
183
+ const provider = String(args.provider || '');
184
+ const projectPath = typeof args.projectPath === 'string' && args.projectPath.trim()
185
+ ? args.projectPath.trim()
186
+ : null;
187
+ const status = await readProviderStatus(provider);
188
+ if (status?.installed === false) {
189
+ return textResult(JSON.stringify({
190
+ launched: false,
191
+ provider,
192
+ reason: 'not_installed',
193
+ message: `${provider} CLI is not installed. Install it in Pixcode before launching a terminal.`,
194
+ status,
195
+ }, null, 2));
196
+ }
197
+
198
+ let mcpConfigured = false;
199
+ let mcpError = null;
200
+ try {
201
+ await ensureProviderPixcodeMcp(provider, projectPath);
202
+ mcpConfigured = true;
203
+ } catch (error) {
204
+ mcpError = error instanceof Error ? error.message : String(error);
205
+ }
206
+
155
207
  const body = await pixcodeFetch('/api/orchestration/hermes/terminal-launches', {
156
208
  method: 'POST',
157
209
  body: JSON.stringify({
158
- provider: args.provider,
159
- projectPath: args.projectPath || null,
210
+ provider,
211
+ projectPath,
160
212
  prompt: args.prompt || null,
161
213
  }),
162
214
  });
163
- return textResult(JSON.stringify(body?.event ?? body, null, 2));
215
+ return textResult(JSON.stringify({
216
+ launched: true,
217
+ pixcodeMcpConfigured: mcpConfigured,
218
+ pixcodeMcpError: mcpError,
219
+ event: body?.event ?? body,
220
+ status,
221
+ }, null, 2));
164
222
  }
165
223
 
166
224
  if (name === 'pixcode_get_hermes_gateway_status') {
@@ -0,0 +1,104 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ import {
8
+ ensureHermesGateway,
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-persist-'));
14
+ const fakeHermes = path.join(tempRoot, 'hermes');
15
+ const projectPath = path.join(tempRoot, 'project');
16
+ const hermesHome = path.join(tempRoot, 'home');
17
+ const startCountFile = path.join(tempRoot, 'starts.txt');
18
+ const failProbeFile = path.join(tempRoot, 'fail-probe');
19
+
20
+ await fs.mkdir(projectPath, { recursive: true });
21
+ await fs.writeFile(fakeHermes, `#!/usr/bin/env node
22
+ import fs from 'node:fs';
23
+ import http from 'node:http';
24
+
25
+ if (process.argv.includes('--version')) {
26
+ console.log('Hermes Agent v0.0.0 smoke');
27
+ process.exit(0);
28
+ }
29
+
30
+ fs.appendFileSync(${JSON.stringify(startCountFile)}, '1\\n');
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 failProbeFile = ${JSON.stringify(failProbeFile)};
36
+
37
+ const server = http.createServer(async (req, res) => {
38
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
39
+ res.setHeader('content-type', 'application/json');
40
+ if (url.pathname !== '/health' && req.headers.authorization !== \`Bearer \${key}\`) {
41
+ res.statusCode = 401;
42
+ res.end(JSON.stringify({ error: 'bad auth' }));
43
+ return;
44
+ }
45
+ if (req.method === 'GET' && url.pathname === '/health') {
46
+ res.end(JSON.stringify({ ok: true }));
47
+ return;
48
+ }
49
+ if (req.method === 'GET' && url.pathname === '/v1/capabilities') {
50
+ if (fs.existsSync(failProbeFile)) {
51
+ res.statusCode = 503;
52
+ res.end(JSON.stringify({ error: 'temporary probe failure' }));
53
+ return;
54
+ }
55
+ res.end(JSON.stringify({ capabilities: ['chat'] }));
56
+ return;
57
+ }
58
+ if (req.method === 'GET' && url.pathname === '/v1/models') {
59
+ res.end(JSON.stringify({ data: [{ id: 'hermes-agent' }] }));
60
+ return;
61
+ }
62
+ res.statusCode = 404;
63
+ res.end(JSON.stringify({ error: url.pathname }));
64
+ });
65
+
66
+ server.listen(port, host);
67
+ `, { mode: 0o755 });
68
+
69
+ process.env.HERMES_CLI_PATH = fakeHermes;
70
+
71
+ try {
72
+ const first = await ensureHermesGateway({
73
+ appRoot: repoRoot,
74
+ projectPath,
75
+ hermesHome,
76
+ pixcodeBaseUrl: 'http://127.0.0.1:9',
77
+ pixcodeApiKey: 'px_persist_smoke_key',
78
+ port: 18772,
79
+ allowSmokeHermes: true,
80
+ repairLaunchers: false,
81
+ });
82
+ assert.equal(first.running, true, 'first gateway should start');
83
+
84
+ await fs.writeFile(failProbeFile, '1');
85
+ const second = await ensureHermesGateway({
86
+ appRoot: repoRoot,
87
+ projectPath,
88
+ hermesHome,
89
+ pixcodeBaseUrl: 'http://127.0.0.1:9',
90
+ pixcodeApiKey: 'px_persist_smoke_key',
91
+ port: 18772,
92
+ allowSmokeHermes: true,
93
+ repairLaunchers: false,
94
+ });
95
+
96
+ const starts = (await fs.readFile(startCountFile, 'utf8')).trim().split('\\n').filter(Boolean).length;
97
+ assert.equal(starts, 1, 'existing Hermes gateway must not be killed and relaunched for a transient probe failure');
98
+ assert.equal(second.baseUrl, first.baseUrl, 'existing Hermes gateway base URL should be reused');
99
+ assert.equal(second.running, true, 'existing Hermes gateway should still be reported as running');
100
+ console.log('hermes gateway persistence smoke passed');
101
+ } finally {
102
+ stopHermesGateway(projectPath);
103
+ delete process.env.HERMES_CLI_PATH;
104
+ }
@@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url';
7
7
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
8
8
  const apiKey = 'px_smoke_key';
9
9
  const seen = [];
10
+ const providerMcpUpserts = [];
11
+ const terminalLaunches = [];
10
12
 
11
13
  function readJson(req) {
12
14
  return new Promise((resolve, reject) => {
@@ -50,6 +52,19 @@ const server = createServer(async (req, res) => {
50
52
  return;
51
53
  }
52
54
 
55
+ if (req.method === 'GET' && url.pathname === '/api/providers/qwen/auth/status') {
56
+ res.end(JSON.stringify({ data: { provider: 'qwen', installed: false, authenticated: false, error: 'Qwen Code CLI is not installed' } }));
57
+ return;
58
+ }
59
+
60
+ if (req.method === 'POST' && url.pathname === '/api/providers/codex/mcp/servers') {
61
+ const body = await readJson(req);
62
+ providerMcpUpserts.push(body);
63
+ res.statusCode = 201;
64
+ res.end(JSON.stringify({ data: { server: { provider: 'codex', name: body.name, scope: body.scope, transport: body.transport } } }));
65
+ return;
66
+ }
67
+
53
68
  if (req.method === 'GET' && url.pathname === '/api/orchestration/hermes/gateway/status') {
54
69
  res.end(JSON.stringify({ running: true, baseUrl: 'http://127.0.0.1:8642', projectPath: '/root/pixcode' }));
55
70
  return;
@@ -71,6 +86,7 @@ const server = createServer(async (req, res) => {
71
86
 
72
87
  if (req.method === 'POST' && url.pathname === '/api/orchestration/hermes/terminal-launches') {
73
88
  const body = await readJson(req);
89
+ terminalLaunches.push(body);
74
90
  res.statusCode = 201;
75
91
  res.end(JSON.stringify({
76
92
  event: {
@@ -170,6 +186,17 @@ try {
170
186
  arguments: { provider: 'codex', projectPath: '/root/pixcode', prompt: 'smoke' },
171
187
  });
172
188
  assert.match(launch.content[0].text, /hermes-mcp/, 'terminal launch should roundtrip through Pixcode API');
189
+ assert.match(launch.content[0].text, /"pixcodeMcpConfigured": true/, 'terminal launch should configure Pixcode MCP for the selected provider first');
190
+ assert.equal(providerMcpUpserts.length, 1, 'Codex launch should upsert a project-scoped Pixcode MCP server');
191
+ assert.equal(providerMcpUpserts[0].name, 'pixcode', 'Provider MCP server should be named pixcode');
192
+
193
+ const blockedLaunch = await callMcp(child, 'tools/call', {
194
+ name: 'pixcode_open_cli_terminal',
195
+ arguments: { provider: 'qwen', projectPath: '/root/pixcode', prompt: 'smoke' },
196
+ });
197
+ assert.match(blockedLaunch.content[0].text, /"launched": false/, 'uninstalled providers should not create terminal launches');
198
+ assert.match(blockedLaunch.content[0].text, /"reason": "not_installed"/, 'uninstalled provider response should explain the block');
199
+ assert.equal(terminalLaunches.length, 1, 'Only the installed Codex provider should be launched');
173
200
 
174
201
  assert(seen.every((entry) => entry.auth === `Bearer ${apiKey}`), 'all MCP calls should use the Pixcode bearer key');
175
202
  console.log('hermes MCP Pixcode roundtrip smoke passed');
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import os from 'node:os';
2
3
  import { fileURLToPath } from 'node:url';
3
4
 
4
5
  import {
@@ -9,11 +10,14 @@ import {
9
10
 
10
11
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
11
12
  const projectPath = path.resolve(process.argv[2] || repoRoot);
13
+ const hermesHome = process.env.PIXCODE_HERMES_LIVE_HOME
14
+ || path.join(os.tmpdir(), `pixcode-hermes-live-${process.getuid?.() ?? 'user'}`);
12
15
 
13
16
  try {
14
17
  const gateway = await ensureHermesGateway({
15
18
  appRoot: repoRoot,
16
19
  projectPath,
20
+ hermesHome,
17
21
  pixcodeBaseUrl: 'http://127.0.0.1:9',
18
22
  pixcodeApiKey: 'px_live_chat_smoke_key',
19
23
  port: Number(process.env.PIXCODE_HERMES_LIVE_CHAT_PORT || 18652),
@@ -16,7 +16,12 @@ assert.match(service, /export function stopHermesGateway/, 'Pixcode should be ab
16
16
  assert.match(service, /\/v1\/chat\/completions/, 'Hermes UI chat should use the documented OpenAI-compatible chat completions endpoint first.');
17
17
  assert.match(service, /\/v1\/responses/, 'Hermes UI chat should use the stateful OpenAI-compatible responses endpoint before legacy chat fallback.');
18
18
  assert.match(service, /transport:\s*'responses'/, 'Hermes REST responses should report their transport for terminal proof output.');
19
- assert.match(service, /gatewayArgs[\s\S]+\['gateway', 'run', '--replace'\]/, 'Pixcode should start Hermes gateway in replace mode so an existing gateway does not crash REST chat.');
19
+ assert.match(service, /resolveHermesGatewayHome/, 'Hermes REST gateway should run from a Pixcode-managed Hermes profile.');
20
+ assert.match(service, /seedHermesGatewayHome/, 'Hermes REST gateway should seed the managed profile from the user Hermes profile.');
21
+ assert.match(service, /PIXCODE_HERMES_GATEWAY_HOME/, 'Hermes REST gateway profile path should be overrideable for tests and advanced installs.');
22
+ assert.match(service, /PIXCODE_MANAGED_HERMES_ENV_PREFIXES/, 'Managed Hermes gateway profile should strip messaging platform env vars.');
23
+ assert.match(service, /copyHermesProfileEnv/, 'Managed Hermes gateway profile should copy a sanitized .env instead of raw platform credentials.');
24
+ assert.match(service, /gatewayArgs[\s\S]+\['gateway', 'run', '--replace'\]/, 'Pixcode can use replace mode safely inside its managed Hermes gateway profile.');
20
25
  assert.match(service, /gatewayExitMessage/, 'Hermes gateway failures should include recent stderr/stdout instead of only exit code 1.');
21
26
  assert.match(service, /API_SERVER_ENABLED:\s*'true'/, 'Hermes gateway env should enable the API server.');
22
27
  assert.match(service, /API_SERVER_KEY/, 'Hermes gateway env should set a bearer key.');
@@ -35,11 +40,14 @@ assert.match(routes, /router\.post\('\/gateway\/stop'/, 'Hermes router should ex
35
40
  assert.match(routes, /ensureHermesGateway/, 'Hermes router should use the managed gateway service.');
36
41
  assert.match(routes, /probeHermesGateway/, 'Hermes router should use the REST probe service.');
37
42
  assert.match(routes, /runHermesGatewayPrompt/, 'Hermes router should send chat prompts through the REST gateway service.');
43
+ assert.match(routes, /resolveHermesMcpBaseUrl/, 'Hermes MCP should be configured against the local Pixcode API URL instead of the browser request host.');
44
+ assert.match(routes, /probeExisting:\s*false/, 'Hermes chat should reuse a running gateway instead of killing it on a transient probe failure.');
38
45
 
39
46
  assert.match(mcpServer, /pixcode_get_hermes_gateway_status/, 'Pixcode MCP should let Hermes inspect gateway status.');
40
47
  assert.match(mcpServer, /pixcode_probe_hermes_gateway/, 'Pixcode MCP should let Hermes trigger a REST probe.');
41
48
  assert.match(configureMcp, /pixcode_get_hermes_gateway_status/, 'Hermes MCP config should include gateway status tool.');
42
49
  assert.match(configureMcp, /pixcode_probe_hermes_gateway/, 'Hermes MCP config should include gateway probe tool.');
50
+ assert.match(configureMcp, /mcp-pixcode/, 'Hermes MCP config should enable the Pixcode MCP toolset for the real CLI.');
43
51
 
44
52
  assert.match(settingsTab, /gateway\/status/, 'Hermes settings should read gateway status.');
45
53
  assert.match(settingsTab, /gateway\/start/, 'Hermes settings should start the REST gateway via API.');
@@ -65,10 +65,10 @@ assert.match(workbench, /BOTTOM_TERMINAL_MIN_HEIGHT/, 'Bottom terminal should su
65
65
  assert.match(workbench, /isBottomTerminalMinimized/, 'Bottom terminal should support minimizing without closing.');
66
66
  assert.match(workbench, /isPlainShell/, 'Bottom terminal should open the selected project folder without starting the selected AI CLI.');
67
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, /HermesTerminalTranscript/, 'Hermes REST panel should render as a terminal transcript, not chat bubbles.');
71
- assert.match(workbench, /REST POST \//, 'Hermes terminal transcript should show the REST endpoint used for each reply.');
68
+ assert.doesNotMatch(workbench, /HermesApiChatPanel|HermesTerminalTranscript/, 'Hermes Agent should use the real PTY terminal UI, not a custom REST chat transcript.');
69
+ assert.doesNotMatch(workbench, /REST POST \/|transport=|response=|gateway=http/, 'Hermes terminal UI must not expose REST debug internals to the user.');
70
+ assert.match(workbench, /command="hermes"/, 'Hermes Agent bottom panel should launch the actual `hermes` CLI in a PTY.');
71
+ assert.match(workbench, /Pixcode MCP Live/, 'Hermes terminal should show a user-facing Pixcode MCP live badge.');
72
72
  assert.doesNotMatch(workbench, /ml-auto border-blue-500\/40 bg-blue-500\/10/, 'Hermes REST panel must not use right-aligned chat bubbles.');
73
73
  assert.match(workbench, /terminal-launches\/stream/, 'Hermes CLI launch requests should arrive through an EventSource stream.');
74
74
  assert.doesNotMatch(workbench, /HERMES_TERMINAL_LAUNCH_POLL_MS|setInterval\([\s\S]*terminal-launches/, 'Hermes CLI launch requests should not be polled every few seconds.');
@@ -89,6 +89,8 @@ assert.match(serverIndex, /\/api\/shell\/sessions\/terminate/, 'Backend should e
89
89
  assert.match(serverIndex, /isPlainShell && !initialCommand/, 'Backend should spawn an interactive plain shell when no terminal command is provided.');
90
90
  assert.doesNotMatch(serverIndex, /pixcode:hermes:start/, 'Backend should not need a Hermes terminal sentinel for the workbench Hermes panel.');
91
91
  assert.doesNotMatch(serverIndex, /hermesCommand/, 'Provider shell starts should not reference the removed Hermes sentinel variable.');
92
+ assert.match(serverIndex, /configure-pixcode-mcp\.mjs/, 'Hermes PTY launches should configure Pixcode MCP before starting the CLI.');
93
+ assert.match(serverIndex, /resolveHermesMcpBaseUrl/, 'Hermes MCP should use the local Pixcode API base URL from the host process.');
92
94
  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.');
93
95
  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.');
94
96
  assert.match(hermesInstallJobs, /downloadHermesInstaller/, 'Windows Hermes install should download the installer through backend API code before running it.');
@@ -118,12 +120,18 @@ assert.match(hermesRoutes, /createHermesRouter/, 'Hermes should have a dedicated
118
120
  assert.match(hermesRoutes, /terminal-launches/, 'Hermes MCP should be able to request visible Pixcode CLI terminal launches.');
119
121
  assert.match(hermesRoutes, /terminal-launches\/stream/, 'Hermes MCP terminal launch requests should stream to the workbench over SSE.');
120
122
  assert.match(hermesRoutes, /hermesTerminalLaunchEmitter/, 'Hermes terminal launch stream should broadcast new events instead of relying on polling.');
121
- assert.match(hermesRoutes, /router\.post\('\/gateway\/chat'/, 'Hermes should expose a REST chat endpoint for the bottom panel.');
123
+ assert.match(hermesRoutes, /router\.post\('\/gateway\/chat'/, 'Hermes should still expose a REST chat endpoint for health checks and integrations.');
122
124
  assert.match(hermesRoutes, /install-status/, 'Hermes settings and terminal UI should have an install-status endpoint.');
123
125
  assert.match(hermesRoutes, /router\.post\('\/install'/, 'Hermes should install through the backend API instead of terminal command paste.');
124
126
  assert.match(read('scripts/smoke/hermes-api-install.mjs'), /hermes API install smoke passed/, 'Hermes API install behavior should have a focused smoke test.');
125
127
  assert.match(workbench, /terminalStartupInput/, 'Hermes terminal launch prompts should be passed into the selected CLI.');
126
128
  assert.match(read('src/components/shell/hooks/useShellConnection.ts'), /startupInputRef/, 'Shell connections should support one-shot startup input for Hermes-triggered CLI work.');
129
+ assert.match(read('src/components/shell/hooks/useShellConnection.ts'), /isCliReadyForStartupInput/, 'Hermes-triggered CLI input should wait until the provider TUI is ready.');
130
+ assert.doesNotMatch(read('src/components/shell/hooks/useShellConnection.ts'), /TERMINAL_INIT_DELAY_MS \* 3/, 'Hermes-triggered CLI input should not be sent on a blind fixed delay.');
131
+ assert.match(shellTerminal, /handleTerminalPaste/, 'Terminal should support browser paste events.');
132
+ assert.match(shellTerminal, /handleCopyPasteShortcut/, 'Terminal should normalize Ctrl/Cmd copy and paste shortcuts.');
133
+ assert.match(shellTerminal, /event\.shiftKey/, 'Terminal should support Ctrl+Shift+C/V style shortcuts.');
134
+ assert.match(shellTerminal, /copyTerminalSelection/, 'Terminal should copy selected terminal text through shortcuts.');
127
135
  assert.match(serverIndex, /forceNewSession/, 'Shell backend should support explicit fresh-session launches from the workbench.');
128
136
  assert.match(serverIndex, /killProviderPtySessions/, 'Shell backend should terminate old provider PTYs when a fresh CLI session is requested.');
129
137
 
package/server/index.js CHANGED
@@ -390,6 +390,34 @@ function resolvePublicBaseUrl(request) {
390
390
  return `${proto}://${String(host).split(',')[0].trim()}`;
391
391
  }
392
392
 
393
+ function resolveHermesMcpBaseUrl() {
394
+ const configured = process.env.PIXCODE_INTERNAL_BASE_URL || process.env.PIXCODE_HERMES_BASE_URL;
395
+ if (configured) return configured.replace(/\/$/, '');
396
+
397
+ return `http://127.0.0.1:${process.env.SERVER_PORT || process.env.PORT || '3001'}`;
398
+ }
399
+
400
+ function quoteBashArg(value) {
401
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
402
+ }
403
+
404
+ function quotePowerShellArg(value) {
405
+ return `"${String(value).replace(/`/g, '``').replace(/\$/g, '`$').replace(/"/g, '`"')}"`;
406
+ }
407
+
408
+ function isHermesCliCommand(command) {
409
+ return typeof command === 'string' && command.trim() === 'hermes';
410
+ }
411
+
412
+ function buildHermesCliCommand(command) {
413
+ const configureScript = path.join(APP_ROOT, 'scripts', 'hermes', 'configure-pixcode-mcp.mjs');
414
+ if (os.platform() === 'win32') {
415
+ return `& ${quotePowerShellArg(process.execPath)} ${quotePowerShellArg(configureScript)} *> $null; ${command}`;
416
+ }
417
+
418
+ return `${quoteBashArg(process.execPath)} ${quoteBashArg(configureScript)} >/dev/null 2>&1; exec ${command}`;
419
+ }
420
+
393
421
  function getOrCreateHermesApiKey(userId) {
394
422
  if (!userId) return null;
395
423
 
@@ -2182,10 +2210,14 @@ function handleShellConnection(ws, request) {
2182
2210
  const provider = data.provider || 'claude';
2183
2211
  const initialCommand = data.initialCommand;
2184
2212
  const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
2213
+ const isHermesCliLaunch = isPlainShell && isHermesCliCommand(initialCommand);
2185
2214
  const forceNewSession = Boolean(data.forceNewSession);
2186
2215
  const shellPermissionMode = normalizeShellPermissionMode(data.permissionMode);
2187
2216
  const shellSkipPermissions = Boolean(data.skipPermissions);
2188
2217
  const shellPermissionFlags = buildProviderShellPermissionFlags(provider, shellPermissionMode, shellSkipPermissions);
2218
+ const hermesApiKey = isHermesCliLaunch
2219
+ ? getOrCreateHermesApiKey(request.user?.id ?? request.user?.userId ?? null)
2220
+ : null;
2189
2221
  urlDetectionBuffer = '';
2190
2222
  announcedAuthUrls.clear();
2191
2223
 
@@ -2314,7 +2346,9 @@ function handleShellConnection(ws, request) {
2314
2346
  let shellCommand;
2315
2347
  if (isPlainShell) {
2316
2348
  // Plain shell mode without an initial command must stay interactive.
2317
- shellCommand = initialCommand || null;
2349
+ shellCommand = isHermesCliLaunch
2350
+ ? buildHermesCliCommand(initialCommand)
2351
+ : initialCommand || null;
2318
2352
  } else if (provider === 'cursor') {
2319
2353
  const command = buildProviderShellCommand('cursor-agent', shellPermissionFlags);
2320
2354
  if (hasSession && sessionId) {
@@ -2429,6 +2463,11 @@ function handleShellConnection(ws, request) {
2429
2463
  TERM: 'xterm-256color',
2430
2464
  COLORTERM: 'truecolor',
2431
2465
  FORCE_COLOR: '3',
2466
+ ...(isHermesCliLaunch ? {
2467
+ PIXCODE_BASE_URL: resolveHermesMcpBaseUrl(),
2468
+ PIXCODE_API_KEY: hermesApiKey || '',
2469
+ PIXCODE_APP_ROOT: APP_ROOT,
2470
+ } : {}),
2432
2471
  });
2433
2472
 
2434
2473
  shellProcess = pty.spawn(shell, shellArgs, {
@@ -60,6 +60,13 @@ function readUserId(req: PixcodeRequest) {
60
60
  return req.user?.id ?? req.user?.userId ?? null;
61
61
  }
62
62
 
63
+ function resolveHermesMcpBaseUrl() {
64
+ const configured = process.env.PIXCODE_INTERNAL_BASE_URL || process.env.PIXCODE_HERMES_BASE_URL;
65
+ if (configured) return configured.replace(/\/$/, '');
66
+
67
+ return `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`;
68
+ }
69
+
63
70
  function readAfterId(req: Request) {
64
71
  const after = Number.parseInt(typeof req.query.after === 'string' ? req.query.after : '0', 10);
65
72
  return Number.isFinite(after) ? after : 0;
@@ -137,7 +144,7 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
137
144
  const gateway = await ensureHermesGateway({
138
145
  appRoot: options.appRoot ?? process.cwd(),
139
146
  pixcodeApiKey: apiKey,
140
- pixcodeBaseUrl: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
147
+ pixcodeBaseUrl: resolveHermesMcpBaseUrl(),
141
148
  projectPath: typeof body.projectPath === 'string' ? body.projectPath : undefined,
142
149
  });
143
150
  res.status(202).json(gateway);
@@ -172,7 +179,7 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
172
179
  await ensureHermesGateway({
173
180
  appRoot: options.appRoot ?? process.cwd(),
174
181
  pixcodeApiKey: apiKey,
175
- pixcodeBaseUrl: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
182
+ pixcodeBaseUrl: resolveHermesMcpBaseUrl(),
176
183
  projectPath: projectPath ?? undefined,
177
184
  });
178
185
  }
@@ -224,8 +231,9 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
224
231
  const gateway = await ensureHermesGateway({
225
232
  appRoot: options.appRoot ?? process.cwd(),
226
233
  pixcodeApiKey: apiKey,
227
- pixcodeBaseUrl: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
234
+ pixcodeBaseUrl: resolveHermesMcpBaseUrl(),
228
235
  projectPath,
236
+ probeExisting: false,
229
237
  });
230
238
  const run = await runHermesGatewayPrompt(projectPath, {
231
239
  input,
@@ -272,7 +280,7 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
272
280
  appRoot: options.appRoot ?? process.cwd(),
273
281
  force: Boolean(body.force),
274
282
  pixcodeApiKey: apiKey,
275
- pixcodeBaseUrl: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
283
+ pixcodeBaseUrl: resolveHermesMcpBaseUrl(),
276
284
  skipBrowser: body.skipBrowser !== false,
277
285
  });
278
286