@pixelbyte-software/pixcode 1.49.1 → 1.49.3

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,40 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
7
+
8
+ const read = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
9
+
10
+ const hermesRoutes = read('server/modules/orchestration/hermes/hermes.routes.ts');
11
+ const hermesInstallJobs = fs.existsSync(path.join(repoRoot, 'server/services/hermes-install-jobs.js'))
12
+ ? read('server/services/hermes-install-jobs.js')
13
+ : '';
14
+ const workbench = read('src/components/vscode-workbench/view/VSCodeWorkbench.tsx');
15
+ const serverIndex = read('server/index.js');
16
+ const smoke = read('scripts/smoke/pixcode-workbench-1-48.mjs');
17
+
18
+ assert.match(hermesRoutes, /createHermesInstallJob/, 'Hermes API should start backend install jobs.');
19
+ assert.match(hermesRoutes, /router\.post\('\/install'/, 'Hermes should expose POST /api/orchestration/hermes/install.');
20
+ assert.match(hermesRoutes, /router\.get\('\/install\/:jobId\/stream'/, 'Hermes install jobs should expose an EventSource stream.');
21
+ assert.match(hermesRoutes, /router\.delete\('\/install\/:jobId'/, 'Hermes install jobs should be cancellable.');
22
+ assert.match(hermesInstallJobs, /downloadHermesInstaller/, 'Hermes installer should be downloaded by backend code, not pasted into the terminal.');
23
+ assert.match(hermesInstallJobs, /--skip-setup/, 'POSIX Hermes API install should skip the interactive setup wizard.');
24
+ assert.match(hermesInstallJobs, /--skip-browser/, 'Hermes API install should skip browser downloads by default for reliable headless installs.');
25
+ assert.doesNotMatch(hermesInstallJobs, /curl -fsSL .* \| bash/, 'Hermes API install must not pipe curl directly into bash.');
26
+ assert.match(workbench, /\/api\/orchestration\/hermes\/install/, 'Workbench Hermes install button should call the Hermes install API.');
27
+ assert.doesNotMatch(workbench, /HERMES_AGENT_INSTALL_COMMAND/, 'Workbench should not launch Hermes install through a terminal command.');
28
+ assert.match(hermesInstallJobs, /formatHermesVersionOutput/, 'Hermes install status should collapse multi-line --version output before showing it in UI badges.');
29
+ assert.match(hermesInstallJobs, /repairHermesCommandLaunchers/, 'Hermes installer should repair stale or text launcher shims after install/status checks.');
30
+ assert.match(hermesInstallJobs, /hermes\.cmd/, 'Windows Hermes repair should create or prefer a hermes.cmd shim so typing hermes does not open the Python launcher as text.');
31
+ assert.match(hermesInstallJobs, /isUsableHermesCommand/, 'Hermes status should verify candidates before treating them as installed.');
32
+ assert.match(serverIndex, /Test-HermesCommand/, 'Hermes terminal start should verify a resolved command before running it.');
33
+ assert.doesNotMatch(serverIndex, /if command -v hermes >\/dev\/null 2>&1; then command -v hermes; return 0; fi;/, 'POSIX Hermes start must not accept a stale PATH shim without testing it.');
34
+ assert.match(workbench, /HermesActivityButton/, 'Workbench activity rail should expose a dedicated Hermes H button under Terminal.');
35
+ assert.match(workbench, /installLogRef/, 'Hermes install log panel should keep a scroll ref.');
36
+ assert.match(workbench, /scrollTop = installLogRef\.current\.scrollHeight/, 'Hermes install logs should auto-scroll to the latest line.');
37
+ assert.match(workbench, /suspendAutoConnect/, 'Right CLI auto-connect should be suspendable while Hermes opens in the bottom terminal.');
38
+ assert.match(smoke, /hermes-api-install\.mjs/, 'Main workbench smoke should mention the dedicated Hermes API install smoke.');
39
+
40
+ console.log('hermes API install smoke passed');
@@ -13,6 +13,7 @@ const settingsSidebar = read('src/components/settings/view/SettingsSidebar.tsx')
13
13
  const settingsMainTabs = read('src/components/settings/view/SettingsMainTabs.tsx');
14
14
  const settingsTypes = read('src/components/settings/types/types.ts');
15
15
  const settingsController = read('src/components/settings/hooks/useSettingsController.ts');
16
+ const settings = read('src/components/settings/view/Settings.tsx');
16
17
  const app = read('src/App.tsx');
17
18
  const serverIndex = read('server/index.js');
18
19
  const hermesRoutes = read('server/modules/orchestration/hermes/hermes.routes.ts');
@@ -104,12 +105,17 @@ assert.doesNotMatch(serverIndex, /app\.use\('\/a2a'/, 'Server should not expose
104
105
  assert.match(hermesRoutes, /createHermesRouter/, 'Hermes should have a dedicated orchestration API router.');
105
106
  assert.match(hermesRoutes, /terminal-launches/, 'Hermes MCP should be able to request visible Pixcode CLI terminal launches.');
106
107
  assert.match(hermesRoutes, /install-status/, 'Hermes settings and terminal UI should have an install-status endpoint.');
108
+ assert.match(hermesRoutes, /router\.post\('\/install'/, 'Hermes should install through the backend API instead of terminal command paste.');
109
+ assert.match(read('scripts/smoke/hermes-api-install.mjs'), /hermes API install smoke passed/, 'Hermes API install behavior should have a focused smoke test.');
107
110
  assert.match(serverIndex, /forceNewSession/, 'Shell backend should support explicit fresh-session launches from the workbench.');
108
111
  assert.match(serverIndex, /killProviderPtySessions/, 'Shell backend should terminate old provider PTYs when a fresh CLI session is requested.');
109
112
 
110
- assert.match(settingsTypes, /'hermes'/, 'Settings Agents should support Hermes Agent as a first-class agent.');
111
- assert.match(agentSettings, /'hermes'/, 'Settings Agents should list Hermes Agent.');
113
+ assert.match(settingsTypes, /'hermes'/, 'Settings should support Hermes Agent as a first-class tab.');
114
+ assert.match(settingsSidebar, /id: 'hermes'/, 'Settings sidebar should show Hermes Agent as its own page instead of hiding it at the end of Agents.');
115
+ assert.match(settings, /<HermesSettingsTab/, 'Settings should render the dedicated Hermes Agent page.');
116
+ assert.doesNotMatch(agentSettings, /'hermes'/, 'Settings Agents picker should not bury Hermes Agent at the end of the provider list.');
112
117
  assert.match(workbench, /hermesInstallStatus/, 'Workbench should hide Hermes install actions when Hermes is already installed.');
118
+ assert.match(workbench, /HermesActivityButton/, 'Workbench activity rail should include a dedicated Hermes launcher button.');
113
119
  assert.match(shellConnection, /cursor-tools-settings/, 'Cursor shell launches should read Cursor permission settings, not Claude settings.');
114
120
  assert.match(shellConnection, /permissionMode/, 'Shell websocket init should send provider permission mode to the backend.');
115
121
  assert.match(serverIndex, /--dangerously-bypass-approvals-and-sandbox/, 'Codex terminal bypass mode should use the Codex CLI bypass flag.');
package/server/index.js CHANGED
@@ -432,16 +432,32 @@ function buildHermesShellCommand(kind, env) {
432
432
  `$env:PIXCODE_APP_ROOT=${quote(env.PIXCODE_APP_ROOT)}`,
433
433
  ].join('; ');
434
434
  const resolveHermesCommand = [
435
+ 'function Test-HermesCommand($candidate) {',
436
+ 'if (-not $candidate) { return $false; }',
437
+ 'try {',
438
+ '& $candidate --version *> $null;',
439
+ 'return $LASTEXITCODE -eq 0;',
440
+ '} catch { return $false; }',
441
+ '}',
435
442
  'function Resolve-HermesCommand {',
436
- '$cmd = Get-Command hermes -ErrorAction SilentlyContinue;',
437
- 'if ($cmd) { return $cmd.Source; }',
438
443
  '$candidates = @(',
439
444
  '$env:HERMES_CLI_PATH,',
445
+ '(Join-Path $env:LOCALAPPDATA "hermes\\bin\\hermes.cmd"),',
446
+ '(Join-Path $env:LOCALAPPDATA "hermes\\bin\\hermes.bat"),',
447
+ '(Join-Path $env:LOCALAPPDATA "hermes\\bin\\hermes.exe"),',
448
+ '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\venv\\Scripts\\hermes.cmd"),',
449
+ '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\venv\\Scripts\\hermes.bat"),',
440
450
  '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\venv\\Scripts\\hermes.exe"),',
451
+ '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\.venv\\Scripts\\hermes.cmd"),',
452
+ '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\.venv\\Scripts\\hermes.bat"),',
441
453
  '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\.venv\\Scripts\\hermes.exe"),',
454
+ '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\hermes.cmd"),',
455
+ '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\hermes.bat"),',
442
456
  '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\hermes.exe")',
443
457
  ');',
444
- 'foreach ($candidate in $candidates) { if ($candidate -and (Test-Path $candidate)) { return $candidate; } }',
458
+ 'foreach ($candidate in $candidates) { if ($candidate -and (Test-Path $candidate) -and (Test-HermesCommand $candidate)) { return $candidate; } }',
459
+ '$cmd = Get-Command hermes -ErrorAction SilentlyContinue;',
460
+ 'if ($cmd -and (Test-HermesCommand $cmd.Source)) { return $cmd.Source; }',
445
461
  'return $null;',
446
462
  '}',
447
463
  ].join(' ');
@@ -470,23 +486,24 @@ function buildHermesShellCommand(kind, env) {
470
486
  `PIXCODE_APP_ROOT=${quote(env.PIXCODE_APP_ROOT)}`,
471
487
  ].join(' ');
472
488
  const resolveHermesCommand = [
489
+ 'testHermesCommand() {',
490
+ '[ -n "$1" ] && [ -x "$1" ] && "$1" --version >/dev/null 2>&1;',
491
+ '}',
473
492
  'resolveHermesCommand() {',
474
- 'if command -v hermes >/dev/null 2>&1; then command -v hermes; return 0; fi;',
475
- 'if [ -n "${HERMES_CLI_PATH:-}" ] && [ -x "$HERMES_CLI_PATH" ]; then printf "%s\\n" "$HERMES_CLI_PATH"; return 0; fi;',
476
- 'if [ -x "$HOME/.local/bin/hermes" ]; then printf "%s\\n" "$HOME/.local/bin/hermes"; return 0; fi;',
477
- 'if [ -x "$HOME/.hermes/hermes-agent/venv/bin/hermes" ]; then printf "%s\\n" "$HOME/.hermes/hermes-agent/venv/bin/hermes"; return 0; fi;',
478
- 'if [ -x "/usr/local/bin/hermes" ]; then printf "%s\\n" "/usr/local/bin/hermes"; return 0; fi;',
493
+ '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',
494
+ 'if testHermesCommand "$candidate"; then printf "%s\\n" "$candidate"; return 0; fi;',
495
+ 'done;',
496
+ 'candidate="$(command -v hermes 2>/dev/null || true)";',
497
+ 'if testHermesCommand "$candidate"; then printf "%s\\n" "$candidate"; return 0; fi;',
479
498
  'return 1;',
480
499
  '}',
481
500
  ].join(' ');
482
- const install = 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash';
483
501
  const installHermesIfMissing = [
484
502
  'installHermesIfMissing() {',
485
503
  'HERMES_CMD="$(resolveHermesCommand 2>/dev/null || true)";',
486
504
  'if [ -n "$HERMES_CMD" ]; then echo "Hermes already installed:"; "$HERMES_CMD" --version 2>/dev/null || true; return 0; fi;',
487
- `${install};`,
488
- 'HERMES_CMD="$(resolveHermesCommand 2>/dev/null || true)";',
489
- 'if [ -z "$HERMES_CMD" ]; then echo "Hermes installed, but the hermes command could not be found. Reload PATH and retry." >&2; exit 127; fi;',
505
+ 'echo "Hermes is not installed. Use Pixcode Settings > Hermes Agent > Install or repair, then start again." >&2;',
506
+ 'exit 127;',
490
507
  '}',
491
508
  ].join(' ');
492
509
  if (kind === 'pixcode:hermes:install') {
@@ -651,7 +668,11 @@ adapterRegistry.register(new OpenCodeA2AAdapter());
651
668
  app.use('/hermes', createHermesTaskRouter());
652
669
  app.use('/preview', authenticateToken, createPreviewProxyRouter());
653
670
  app.use('/api/orchestration', authenticateToken, createOrchestrationTaskRouter());
654
- app.use('/api/orchestration/hermes', authenticateToken, createHermesRouter());
671
+ app.use('/api/orchestration/hermes', authenticateToken, createHermesRouter({
672
+ appRoot: APP_ROOT,
673
+ createHermesApiKey: getOrCreateHermesApiKey,
674
+ resolvePublicBaseUrl,
675
+ }));
655
676
  app.use('/api/orchestration', authenticateToken, createWorkflowRouter());
656
677
  app.use('/live', createLiveViewPublicRouter());
657
678
 
@@ -1,12 +1,16 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
1
  import os from 'node:os';
4
- import path from 'node:path';
5
2
 
6
- import express, { type Router } from 'express';
3
+ import express, { type Request, type Response, type Router } from 'express';
7
4
 
8
5
  import { adapterRegistry } from '@/modules/orchestration/a2a/adapter-registry.js';
9
6
  import { a2aTaskStore as hermesTaskStore } from '@/modules/orchestration/a2a/task-store.js';
7
+ import {
8
+ cancelHermesInstallJob,
9
+ createHermesInstallJob,
10
+ getHermesInstallJob,
11
+ readHermesInstallStatus,
12
+ snapshotHermesInstallDonePayload,
13
+ } from '@/services/hermes-install-jobs.js';
10
14
 
11
15
  const HERMES_TERMINAL_LAUNCH_LIMIT = 100;
12
16
  const HERMES_TERMINAL_LAUNCH_PROVIDERS = new Set(['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode']);
@@ -20,67 +24,32 @@ type HermesTerminalLaunchEvent = {
20
24
  createdAt: string;
21
25
  };
22
26
 
27
+ type HermesRouterOptions = {
28
+ appRoot?: string;
29
+ createHermesApiKey?: (userId: number | string | null | undefined) => string | null;
30
+ resolvePublicBaseUrl?: (req: Request) => string;
31
+ };
32
+
33
+ type PixcodeRequest = Request & {
34
+ user?: {
35
+ id?: number | string;
36
+ userId?: number | string;
37
+ };
38
+ };
39
+
23
40
  let nextHermesTerminalLaunchId = 1;
24
41
  const hermesTerminalLaunches: HermesTerminalLaunchEvent[] = [];
25
42
 
26
- function hermesCommandCandidates(): string[] {
27
- const candidates = [process.env.HERMES_CLI_PATH, 'hermes'].filter((candidate): candidate is string => (
28
- typeof candidate === 'string' && candidate.trim().length > 0
29
- ));
30
-
31
- if (process.platform === 'win32') {
32
- const localAppData = process.env.LOCALAPPDATA;
33
- if (localAppData) {
34
- candidates.push(
35
- path.join(localAppData, 'hermes', 'hermes-agent', 'venv', 'Scripts', 'hermes.exe'),
36
- path.join(localAppData, 'hermes', 'hermes-agent', '.venv', 'Scripts', 'hermes.exe'),
37
- path.join(localAppData, 'hermes', 'hermes-agent', 'hermes.exe'),
38
- );
39
- }
40
- } else {
41
- candidates.push(
42
- path.join(os.homedir(), '.local', 'bin', 'hermes'),
43
- path.join(os.homedir(), '.hermes', 'hermes-agent', 'venv', 'bin', 'hermes'),
44
- '/usr/local/bin/hermes',
45
- );
46
- }
47
-
48
- return [...new Set(candidates)];
43
+ function writeSse(res: Response, event: string, payload: unknown) {
44
+ res.write(`event: ${event}\n`);
45
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
49
46
  }
50
47
 
51
- function readHermesInstallStatus() {
52
- for (const candidate of hermesCommandCandidates()) {
53
- const isBareCommand = candidate === 'hermes';
54
- if (!isBareCommand && !existsSync(candidate)) {
55
- continue;
56
- }
57
-
58
- const result = spawnSync(candidate, ['--version'], {
59
- encoding: 'utf8',
60
- stdio: ['ignore', 'pipe', 'pipe'],
61
- timeout: 5000,
62
- shell: false,
63
- });
64
- if (!result.error && result.status === 0) {
65
- const version = `${result.stdout || result.stderr || ''}`.trim() || null;
66
- return {
67
- installed: true,
68
- command: candidate,
69
- version,
70
- error: null,
71
- };
72
- }
73
- }
74
-
75
- return {
76
- installed: false,
77
- command: null,
78
- version: null,
79
- error: 'Hermes Agent CLI is not installed or is not on PATH.',
80
- };
48
+ function readUserId(req: PixcodeRequest) {
49
+ return req.user?.id ?? req.user?.userId ?? null;
81
50
  }
82
51
 
83
- export function createHermesRouter(): Router {
52
+ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
84
53
  const router = express.Router();
85
54
 
86
55
  router.get('/status', (_req, res) => {
@@ -122,6 +91,115 @@ export function createHermesRouter(): Router {
122
91
  res.json(readHermesInstallStatus());
123
92
  });
124
93
 
94
+ router.post('/install', (req: PixcodeRequest, res) => {
95
+ const apiKey = options.createHermesApiKey?.(readUserId(req)) ?? null;
96
+ if (!apiKey) {
97
+ res.status(500).json({
98
+ error: {
99
+ code: 'HERMES_API_KEY_UNAVAILABLE',
100
+ message: 'Pixcode could not create a Hermes MCP API key for this user.',
101
+ },
102
+ });
103
+ return;
104
+ }
105
+
106
+ const body = (req.body ?? {}) as Record<string, unknown>;
107
+ const job = createHermesInstallJob({
108
+ appRoot: options.appRoot ?? process.cwd(),
109
+ force: Boolean(body.force),
110
+ pixcodeApiKey: apiKey,
111
+ pixcodeBaseUrl: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
112
+ skipBrowser: body.skipBrowser !== false,
113
+ });
114
+
115
+ res.status(202).json({
116
+ jobId: job.id,
117
+ provider: 'hermes',
118
+ status: job.status,
119
+ startedAt: job.startedAt,
120
+ });
121
+ });
122
+
123
+ router.get('/install/:jobId/stream', (req, res) => {
124
+ const job = getHermesInstallJob(req.params.jobId);
125
+ if (!job) {
126
+ res.status(404).json({
127
+ error: {
128
+ code: 'HERMES_INSTALL_JOB_NOT_FOUND',
129
+ message: 'Hermes install job not found or already expired.',
130
+ },
131
+ });
132
+ return;
133
+ }
134
+
135
+ res.setHeader('Content-Type', 'text/event-stream');
136
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
137
+ res.setHeader('Connection', 'keep-alive');
138
+ res.setHeader('X-Accel-Buffering', 'no');
139
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
140
+ try {
141
+ (res.socket as NodeJS.Socket & { setNoDelay?: (on: boolean) => void })?.setNoDelay?.(true);
142
+ } catch { /* noop */ }
143
+
144
+ let closed = false;
145
+ const safeWrite = (event: string, payload: unknown) => {
146
+ if (closed) return;
147
+ try { writeSse(res, event, payload); } catch { /* socket gone */ }
148
+ };
149
+
150
+ try { res.write(': start\n\n'); } catch { /* noop */ }
151
+ const heartbeat = setInterval(() => {
152
+ if (closed) return;
153
+ try { res.write(': ping\n\n'); } catch { /* noop */ }
154
+ }, 5000);
155
+
156
+ for (const entry of job.logs) {
157
+ safeWrite('log', { stream: entry.stream, chunk: entry.chunk });
158
+ }
159
+
160
+ const onLog = (entry: { stream: string; chunk: string }) => {
161
+ safeWrite('log', { stream: entry.stream, chunk: entry.chunk });
162
+ };
163
+ const onDone = (payload: Record<string, unknown>) => {
164
+ safeWrite('done', payload);
165
+ cleanup();
166
+ try { res.end(); } catch { /* noop */ }
167
+ };
168
+ function cleanup() {
169
+ if (closed) return;
170
+ closed = true;
171
+ clearInterval(heartbeat);
172
+ job.emitter.off('log', onLog);
173
+ job.emitter.off('done', onDone);
174
+ }
175
+
176
+ if (job.status !== 'running') {
177
+ safeWrite('done', snapshotHermesInstallDonePayload(job));
178
+ cleanup();
179
+ try { res.end(); } catch { /* noop */ }
180
+ return;
181
+ }
182
+
183
+ job.emitter.on('log', onLog);
184
+ job.emitter.once('done', onDone);
185
+ req.on('close', cleanup);
186
+ });
187
+
188
+ router.delete('/install/:jobId', (req, res) => {
189
+ const job = getHermesInstallJob(req.params.jobId);
190
+ if (!job) {
191
+ res.status(404).json({
192
+ error: {
193
+ code: 'HERMES_INSTALL_JOB_NOT_FOUND',
194
+ message: 'Hermes install job not found.',
195
+ },
196
+ });
197
+ return;
198
+ }
199
+
200
+ res.json({ cancelled: cancelHermesInstallJob(req.params.jobId) });
201
+ });
202
+
125
203
  router.get('/agents', (_req, res) => {
126
204
  res.json({
127
205
  agent: 'hermes',