@pixelbyte-software/pixcode 1.49.0 → 1.49.2

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/server/index.js CHANGED
@@ -335,6 +335,64 @@ function shellQuotePowerShell(value) {
335
335
  return `'${String(value).replace(/'/g, "''")}'`;
336
336
  }
337
337
 
338
+ function normalizeShellPermissionMode(value) {
339
+ return typeof value === 'string' ? value.trim() : '';
340
+ }
341
+
342
+ function shouldBypassShellPermissions(permissionMode, skipPermissions) {
343
+ return Boolean(skipPermissions) || permissionMode === 'bypassPermissions' || permissionMode === 'acceptEdits' || permissionMode === 'yolo';
344
+ }
345
+
346
+ function buildProviderShellPermissionFlags(provider, permissionMode, skipPermissions) {
347
+ const mode = normalizeShellPermissionMode(permissionMode);
348
+ const bypass = shouldBypassShellPermissions(mode, skipPermissions);
349
+
350
+ if (provider === 'codex') {
351
+ if (mode === 'bypassPermissions' || mode === 'yolo') {
352
+ return ['--dangerously-bypass-approvals-and-sandbox'];
353
+ }
354
+ if (mode === 'acceptEdits' || mode === 'auto_edit' || bypass) {
355
+ return ['--sandbox', 'workspace-write', '--ask-for-approval', 'never'];
356
+ }
357
+ return [];
358
+ }
359
+
360
+ if (provider === 'gemini' || provider === 'qwen') {
361
+ if (bypass) {
362
+ return ['--yolo'];
363
+ }
364
+ if (mode === 'auto_edit') {
365
+ return ['--approval-mode', 'auto_edit'];
366
+ }
367
+ if (mode === 'plan') {
368
+ return ['--approval-mode', 'plan'];
369
+ }
370
+ return [];
371
+ }
372
+
373
+ if (provider === 'cursor') {
374
+ return bypass ? ['-f'] : [];
375
+ }
376
+
377
+ if (provider === 'opencode') {
378
+ if (mode === 'plan') {
379
+ return ['--agent', 'plan'];
380
+ }
381
+ return bypass ? ['--dangerously-skip-permissions'] : [];
382
+ }
383
+
384
+ if (provider === 'claude') {
385
+ return bypass ? ['--dangerously-skip-permissions'] : [];
386
+ }
387
+
388
+ return [];
389
+ }
390
+
391
+ function buildProviderShellCommand(command, permissionFlags = []) {
392
+ const flags = Array.isArray(permissionFlags) ? permissionFlags.filter(Boolean) : [];
393
+ return flags.length > 0 ? `${command} ${flags.join(' ')}` : command;
394
+ }
395
+
338
396
  function resolvePublicBaseUrl(request) {
339
397
  const headers = request?.headers || {};
340
398
  const forwardedProto = String(headers['x-forwarded-proto'] || '').split(',')[0].trim();
@@ -373,11 +431,37 @@ function buildHermesShellCommand(kind, env) {
373
431
  `$env:PIXCODE_API_KEY=${quote(env.PIXCODE_API_KEY)}`,
374
432
  `$env:PIXCODE_APP_ROOT=${quote(env.PIXCODE_APP_ROOT)}`,
375
433
  ].join('; ');
376
- const install = '& ([scriptblock]::Create((irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1))) -SkipSetup -Branch main';
434
+ const resolveHermesCommand = [
435
+ 'function Resolve-HermesCommand {',
436
+ '$cmd = Get-Command hermes -ErrorAction SilentlyContinue;',
437
+ 'if ($cmd) { return $cmd.Source; }',
438
+ '$candidates = @(',
439
+ '$env:HERMES_CLI_PATH,',
440
+ '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\venv\\Scripts\\hermes.exe"),',
441
+ '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\.venv\\Scripts\\hermes.exe"),',
442
+ '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\hermes.exe")',
443
+ ');',
444
+ 'foreach ($candidate in $candidates) { if ($candidate -and (Test-Path $candidate)) { return $candidate; } }',
445
+ 'return $null;',
446
+ '}',
447
+ ].join(' ');
448
+ const installHermesIfMissing = [
449
+ 'function Install-HermesIfMissing {',
450
+ '$script:HermesCmd = Resolve-HermesCommand;',
451
+ 'if ($script:HermesCmd) { Write-Host "Hermes already installed:"; & $script:HermesCmd --version; return; }',
452
+ '$installer = Join-Path $env:TEMP "pixcode-hermes-install.ps1";',
453
+ 'Invoke-WebRequest -Uri "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1" -UseBasicParsing -OutFile $installer;',
454
+ '& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer -SkipSetup -Branch main;',
455
+ 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE; }',
456
+ '$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + $env:Path;',
457
+ '$script:HermesCmd = Resolve-HermesCommand;',
458
+ 'if (-not $script:HermesCmd) { throw "Hermes installed, but the hermes command could not be found. Restart Pixcode or add Hermes to PATH."; }',
459
+ '}',
460
+ ].join(' ');
377
461
  if (kind === 'pixcode:hermes:install') {
378
- return `${setEnv}; ${install}; ${configure}`;
462
+ return `${setEnv}; ${resolveHermesCommand}; ${installHermesIfMissing}; Install-HermesIfMissing; ${configure}`;
379
463
  }
380
- return `${setEnv}; ${configure}; if (-not (Get-Command hermes -ErrorAction SilentlyContinue)) { ${install} }; hermes chat --toolsets "hermes-cli,mcp-pixcode"`;
464
+ return `${setEnv}; ${resolveHermesCommand}; ${installHermesIfMissing}; Install-HermesIfMissing; ${configure}; & $script:HermesCmd chat --toolsets "hermes-cli,mcp-pixcode"`;
381
465
  }
382
466
 
383
467
  const setEnv = [
@@ -385,11 +469,30 @@ function buildHermesShellCommand(kind, env) {
385
469
  `PIXCODE_API_KEY=${quote(env.PIXCODE_API_KEY)}`,
386
470
  `PIXCODE_APP_ROOT=${quote(env.PIXCODE_APP_ROOT)}`,
387
471
  ].join(' ');
472
+ const resolveHermesCommand = [
473
+ '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;',
479
+ 'return 1;',
480
+ '}',
481
+ ].join(' ');
388
482
  const install = 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash';
483
+ const installHermesIfMissing = [
484
+ 'installHermesIfMissing() {',
485
+ 'HERMES_CMD="$(resolveHermesCommand 2>/dev/null || true)";',
486
+ '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;',
490
+ '}',
491
+ ].join(' ');
389
492
  if (kind === 'pixcode:hermes:install') {
390
- return `${setEnv} sh -lc ${quote(`${install} && node ${shellQuotePosix(configureScript)}`)}`;
493
+ return `${setEnv} sh -lc ${quote(`${resolveHermesCommand} ${installHermesIfMissing} installHermesIfMissing && node ${shellQuotePosix(configureScript)}`)}`;
391
494
  }
392
- return `${setEnv} sh -lc ${quote(`node ${shellQuotePosix(configureScript)} && if ! command -v hermes >/dev/null 2>&1; then ${install}; fi; hermes chat --toolsets "hermes-cli,mcp-pixcode"`)}`;
495
+ return `${setEnv} sh -lc ${quote(`${resolveHermesCommand} ${installHermesIfMissing} installHermesIfMissing && node ${shellQuotePosix(configureScript)} && "$HERMES_CMD" chat --toolsets "hermes-cli,mcp-pixcode"`)}`;
393
496
  }
394
497
 
395
498
  // Single WebSocket server that handles both paths
@@ -548,7 +651,11 @@ adapterRegistry.register(new OpenCodeA2AAdapter());
548
651
  app.use('/hermes', createHermesTaskRouter());
549
652
  app.use('/preview', authenticateToken, createPreviewProxyRouter());
550
653
  app.use('/api/orchestration', authenticateToken, createOrchestrationTaskRouter());
551
- app.use('/api/orchestration/hermes', authenticateToken, createHermesRouter());
654
+ app.use('/api/orchestration/hermes', authenticateToken, createHermesRouter({
655
+ appRoot: APP_ROOT,
656
+ createHermesApiKey: getOrCreateHermesApiKey,
657
+ resolvePublicBaseUrl,
658
+ }));
552
659
  app.use('/api/orchestration', authenticateToken, createWorkflowRouter());
553
660
  app.use('/live', createLiveViewPublicRouter());
554
661
 
@@ -2175,6 +2282,9 @@ function handleShellConnection(ws, request) {
2175
2282
  }
2176
2283
  const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
2177
2284
  const forceNewSession = Boolean(data.forceNewSession);
2285
+ const shellPermissionMode = normalizeShellPermissionMode(data.permissionMode);
2286
+ const shellSkipPermissions = Boolean(data.skipPermissions);
2287
+ const shellPermissionFlags = buildProviderShellPermissionFlags(provider, shellPermissionMode, shellSkipPermissions);
2178
2288
  urlDetectionBuffer = '';
2179
2289
  announcedAuthUrls.clear();
2180
2290
 
@@ -2305,25 +2415,27 @@ function handleShellConnection(ws, request) {
2305
2415
  // Plain shell mode without an initial command must stay interactive.
2306
2416
  shellCommand = initialCommand || null;
2307
2417
  } else if (provider === 'cursor') {
2418
+ const command = buildProviderShellCommand('cursor-agent', shellPermissionFlags);
2308
2419
  if (hasSession && sessionId) {
2309
- shellCommand = `cursor-agent --resume="${sessionId}"`;
2420
+ shellCommand = `${command} --resume="${sessionId}"`;
2310
2421
  } else {
2311
- shellCommand = 'cursor-agent';
2422
+ shellCommand = command;
2312
2423
  }
2313
2424
  } else if (provider === 'codex') {
2314
2425
  // Use codex command; attempt to resume and fall back to a new session when the resume fails.
2426
+ const command = buildProviderShellCommand('codex', shellPermissionFlags);
2315
2427
  if (hasSession && sessionId) {
2316
2428
  if (os.platform() === 'win32') {
2317
2429
  // PowerShell syntax for fallback
2318
- shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
2430
+ shellCommand = `${command} resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { ${command} }`;
2319
2431
  } else {
2320
- shellCommand = `codex resume "${sessionId}" || codex`;
2432
+ shellCommand = `${command} resume "${sessionId}" || ${command}`;
2321
2433
  }
2322
2434
  } else {
2323
- shellCommand = 'codex';
2435
+ shellCommand = command;
2324
2436
  }
2325
2437
  } else if (provider === 'gemini') {
2326
- const command = initialCommand || 'gemini';
2438
+ const command = buildProviderShellCommand(initialCommand || 'gemini', shellPermissionFlags);
2327
2439
  let resumeId = sessionId;
2328
2440
  if (hasSession && sessionId) {
2329
2441
  try {
@@ -2352,7 +2464,7 @@ function handleShellConnection(ws, request) {
2352
2464
  // Qwen Code shares Gemini CLI's --resume semantics (it's a fork),
2353
2465
  // so the resume path resolves the backend-tracked cliSessionId the
2354
2466
  // same way. Falls back to a fresh session when the ID can't be found.
2355
- const command = initialCommand || 'qwen';
2467
+ const command = buildProviderShellCommand(initialCommand || 'qwen', shellPermissionFlags);
2356
2468
  let resumeId = sessionId;
2357
2469
  if (hasSession && sessionId) {
2358
2470
  try {
@@ -2380,7 +2492,7 @@ function handleShellConnection(ws, request) {
2380
2492
  // we pass them straight through without a cliSessionId
2381
2493
  // mapping layer — OpenCode doesn't renumber IDs the way
2382
2494
  // Gemini does.
2383
- const command = initialCommand || 'opencode';
2495
+ const command = buildProviderShellCommand(initialCommand || 'opencode', shellPermissionFlags);
2384
2496
  if (hasSession && sessionId && safeSessionIdPattern.test(sessionId)) {
2385
2497
  shellCommand = `${command} --session "${sessionId}"`;
2386
2498
  } else {
@@ -2388,12 +2500,12 @@ function handleShellConnection(ws, request) {
2388
2500
  }
2389
2501
  } else {
2390
2502
  // Claude (default provider)
2391
- const command = initialCommand || 'claude';
2503
+ const command = buildProviderShellCommand(initialCommand || 'claude', shellPermissionFlags);
2392
2504
  if (hasSession && sessionId) {
2393
2505
  if (os.platform() === 'win32') {
2394
- shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
2506
+ shellCommand = `${command} --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { ${command} }`;
2395
2507
  } else {
2396
- shellCommand = `claude --resume "${sessionId}" || claude`;
2508
+ shellCommand = `${command} --resume "${sessionId}" || ${command}`;
2397
2509
  }
2398
2510
  } else {
2399
2511
  shellCommand = command;
@@ -1,9 +1,16 @@
1
1
  import os from 'node:os';
2
2
 
3
- import express, { type Router } from 'express';
3
+ import express, { type Request, type Response, type Router } from 'express';
4
4
 
5
5
  import { adapterRegistry } from '@/modules/orchestration/a2a/adapter-registry.js';
6
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';
7
14
 
8
15
  const HERMES_TERMINAL_LAUNCH_LIMIT = 100;
9
16
  const HERMES_TERMINAL_LAUNCH_PROVIDERS = new Set(['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode']);
@@ -17,10 +24,32 @@ type HermesTerminalLaunchEvent = {
17
24
  createdAt: string;
18
25
  };
19
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
+
20
40
  let nextHermesTerminalLaunchId = 1;
21
41
  const hermesTerminalLaunches: HermesTerminalLaunchEvent[] = [];
22
42
 
23
- export function createHermesRouter(): Router {
43
+ function writeSse(res: Response, event: string, payload: unknown) {
44
+ res.write(`event: ${event}\n`);
45
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
46
+ }
47
+
48
+ function readUserId(req: PixcodeRequest) {
49
+ return req.user?.id ?? req.user?.userId ?? null;
50
+ }
51
+
52
+ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
24
53
  const router = express.Router();
25
54
 
26
55
  router.get('/status', (_req, res) => {
@@ -58,6 +87,119 @@ export function createHermesRouter(): Router {
58
87
  });
59
88
  });
60
89
 
90
+ router.get('/install-status', (_req, res) => {
91
+ res.json(readHermesInstallStatus());
92
+ });
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
+
61
203
  router.get('/agents', (_req, res) => {
62
204
  res.json({
63
205
  agent: 'hermes',
@@ -102,7 +102,13 @@ async function spawnQwen(command, options = {}, ws) {
102
102
  args.push('--model', modelToUse);
103
103
  args.push('--output-format', 'stream-json');
104
104
 
105
- if (settings.skipPermissions || options.skipPermissions || permissionMode === 'yolo') {
105
+ if (
106
+ settings.skipPermissions ||
107
+ options.skipPermissions ||
108
+ permissionMode === 'yolo' ||
109
+ permissionMode === 'bypassPermissions' ||
110
+ permissionMode === 'acceptEdits'
111
+ ) {
106
112
  args.push('--yolo');
107
113
  } else if (permissionMode === 'auto_edit') {
108
114
  args.push('--approval-mode', 'auto_edit');