@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.
- package/dist/assets/{index-BT6txdBK.css → index-DjKDBqln.css} +1 -1
- package/dist/assets/{index-DVpGrdpT.js → index-Q-GU9EZQ.js} +138 -138
- package/dist/index.html +2 -2
- package/dist-server/server/index.js +34 -1
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js +11 -4
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js.map +1 -1
- package/dist-server/server/services/hermes-gateway.js +115 -1
- package/dist-server/server/services/hermes-gateway.js.map +1 -1
- package/dist-server/server/services/hermes-install-jobs.js +9 -1
- package/dist-server/server/services/hermes-install-jobs.js.map +1 -1
- package/package.json +1 -1
- package/scripts/hermes/configure-pixcode-mcp.mjs +36 -1
- package/scripts/hermes/pixcode-mcp-server.mjs +63 -5
- package/scripts/smoke/hermes-gateway-persistence.mjs +104 -0
- package/scripts/smoke/hermes-mcp-pixcode-roundtrip.mjs +27 -0
- package/scripts/smoke/hermes-rest-chat-live.mjs +4 -0
- package/scripts/smoke/hermes-rest-gateway.mjs +9 -1
- package/scripts/smoke/pixcode-workbench-1-48.mjs +13 -5
- package/server/index.js +40 -1
- package/server/modules/orchestration/hermes/hermes.routes.ts +12 -4
- package/server/services/hermes-gateway.js +128 -1
- package/server/services/hermes-install-jobs.js +11 -1
|
@@ -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
|
|
151
|
-
return textResult(JSON.stringify(
|
|
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
|
|
159
|
-
projectPath
|
|
210
|
+
provider,
|
|
211
|
+
projectPath,
|
|
160
212
|
prompt: args.prompt || null,
|
|
161
213
|
}),
|
|
162
214
|
});
|
|
163
|
-
return textResult(JSON.stringify(
|
|
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, /
|
|
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.
|
|
69
|
-
assert.
|
|
70
|
-
assert.match(workbench, /
|
|
71
|
-
assert.match(workbench, /
|
|
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
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
283
|
+
pixcodeBaseUrl: resolveHermesMcpBaseUrl(),
|
|
276
284
|
skipBrowser: body.skipBrowser !== false,
|
|
277
285
|
});
|
|
278
286
|
|