@yancyyu/openhermit 1.6.19 → 1.6.23

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/bin/hermit.mjs CHANGED
@@ -15,9 +15,9 @@
15
15
  */
16
16
 
17
17
  import { spawn, execSync } from 'node:child_process';
18
- import crypto from 'node:crypto';
19
18
  import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
20
19
  import { createRequire } from 'node:module';
20
+ import net from 'node:net';
21
21
  import os from 'node:os';
22
22
  import path from 'node:path';
23
23
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -350,10 +350,6 @@ Please install dependencies first:
350
350
  // cc-connect sidecar
351
351
  // ---------------------------------------------------------------------------
352
352
 
353
- function randomToken() {
354
- return crypto.randomBytes(16).toString('hex');
355
- }
356
-
357
353
  function escapeTomlPath(value) {
358
354
  return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
359
355
  }
@@ -384,51 +380,6 @@ function isManagedBootstrapBlock(block) {
384
380
  );
385
381
  }
386
382
 
387
- function buildStarterConfigToml(managementToken, bridgeToken) {
388
- return `# cc-connect configuration
389
- # Docs: https://github.com/chenhg5/cc-connect
390
-
391
- data_dir = "${escapeTomlPath(path.join(hermitHome, 'cc-connect', 'data'))}"
392
- language = "zh"
393
-
394
- [management]
395
- enabled = true
396
- host = "127.0.0.1"
397
- port = 9820
398
- token = "${managementToken}"
399
-
400
- [bridge]
401
- enabled = true
402
- host = "127.0.0.1"
403
- port = 9810
404
- token = "${bridgeToken}"
405
- path = "/bridge/ws"
406
-
407
- [log]
408
- level = "info"
409
-
410
- [[projects]]
411
- name = "my-project"
412
-
413
- [projects.agent]
414
- type = "claudecode" # "claudecode", "codex", "cursor", "gemini", "qoder", "opencode", or "iflow"
415
-
416
- [projects.agent.options]
417
- work_dir = "/path/to/your/project"
418
- mode = "default"
419
- # model = "claude-sonnet-4-20250514"
420
-
421
- # --- Choose at least one platform below ---
422
-
423
- [[projects.platforms]]
424
- type = "feishu"
425
-
426
- [projects.platforms.options]
427
- app_id = "your-feishu-app-id"
428
- app_secret = "your-feishu-app-secret"
429
- `;
430
- }
431
-
432
383
  function isStarterProjectConfig(raw) {
433
384
  const block = findProjectBlock(raw, starterProjectName);
434
385
  if (!block) return false;
@@ -442,27 +393,61 @@ function isStarterProjectConfig(raw) {
442
393
  );
443
394
  }
444
395
 
445
- function hasRunnableProjectEntries(raw) {
396
+ function configRequiresClaudeCode(raw) {
397
+ return /type\s*=\s*"claudecode"/.test(raw);
398
+ }
399
+
400
+ function hasProjectEntries(raw) {
446
401
  const projectPattern = /\[\[projects\]\]\nname\s*=\s*"([^"]+)"[\s\S]*?(?=\n\[\[projects\]\]|\s*$)/g;
447
- for (const match of raw.matchAll(projectPattern)) {
448
- if (!isStarterProjectConfig(match[0]) && !isManagedBootstrapBlock(match[0])) {
449
- return true;
450
- }
402
+ return [...raw.matchAll(projectPattern)].some((match) => !isManagedBootstrapBlock(match[0]));
403
+ }
404
+
405
+ function commandExists(command) {
406
+ try {
407
+ execSync(`${command} --version`, { stdio: 'ignore', shell: true });
408
+ return true;
409
+ } catch {
410
+ return false;
451
411
  }
452
- return false;
453
412
  }
454
413
 
455
- function ensureCcConnectConfig() {
414
+ function ensureClaudeCodeCliIfNeeded(raw) {
415
+ if (!configRequiresClaudeCode(raw) || commandExists('claude')) return;
416
+
417
+ console.log('[openHermit] Claude Code CLI not found; installing @anthropic-ai/claude-code...');
418
+ try {
419
+ execSync('npm install -g @anthropic-ai/claude-code@latest --prefer-online', {
420
+ stdio: 'inherit',
421
+ shell: true,
422
+ });
423
+ } catch (err) {
424
+ console.error('[openHermit] Failed to install Claude Code CLI automatically.');
425
+ console.error('[openHermit] Please install it manually: npm install -g @anthropic-ai/claude-code@latest');
426
+ throw err;
427
+ }
428
+
429
+ if (!commandExists('claude')) {
430
+ throw new Error('Claude Code CLI was installed but `claude` is still not available in PATH');
431
+ }
432
+ console.log('[openHermit] Claude Code CLI installed.');
433
+ }
434
+
435
+ function readCcConnectConfigState() {
456
436
  mkdirSync(path.dirname(ccConnectConfigPath), { recursive: true });
457
437
  if (!existsSync(ccConnectConfigPath)) {
458
- const managementToken = randomToken();
459
- const bridgeToken = randomToken();
460
- writeFileSync(ccConnectConfigPath, buildStarterConfigToml(managementToken, bridgeToken), 'utf-8');
438
+ return {
439
+ configExists: false,
440
+ managementToken: process.env.CC_CONNECT_TOKEN || process.env.CC_CONNECT_MANAGEMENT_TOKEN || '',
441
+ bridgeToken: process.env.CC_CONNECT_BRIDGE_TOKEN || process.env.CC_CONNECT_TOKEN || '',
442
+ hasRunnableProjects: false,
443
+ isStarterConfig: false,
444
+ };
461
445
  }
462
446
 
463
447
  const raw = readFileSync(ccConnectConfigPath, 'utf-8');
464
448
 
465
449
  return {
450
+ configExists: true,
466
451
  managementToken:
467
452
  process.env.CC_CONNECT_TOKEN ||
468
453
  process.env.CC_CONNECT_MANAGEMENT_TOKEN ||
@@ -471,8 +456,9 @@ function ensureCcConnectConfig() {
471
456
  process.env.CC_CONNECT_BRIDGE_TOKEN ||
472
457
  process.env.CC_CONNECT_TOKEN ||
473
458
  parseTomlToken(raw, 'bridge'),
474
- hasRunnableProjects: hasRunnableProjectEntries(raw),
459
+ hasProjects: hasProjectEntries(raw),
475
460
  isStarterConfig: isStarterProjectConfig(raw),
461
+ raw,
476
462
  };
477
463
  }
478
464
 
@@ -554,25 +540,124 @@ function resolveAliasLoaderRegister() {
554
540
  return `data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register(${JSON.stringify(aliasLoaderUrl)}, pathToFileURL("./"));`;
555
541
  }
556
542
 
543
+ async function checkExistingOpenHermitServer() {
544
+ const url = `http://127.0.0.1:${port}`;
545
+ try {
546
+ const res = await fetch(`${url}/api/version`, { signal: AbortSignal.timeout(1000) });
547
+ if (res.ok) {
548
+ const version = (await res.text()).trim() || 'unknown';
549
+ return { running: true, version, url };
550
+ }
551
+ } catch {
552
+ // Port may be unused or owned by another process.
553
+ }
554
+ return { running: false, version: '', url };
555
+ }
556
+
557
+ async function isTcpPortAvailable(portNumber) {
558
+ return new Promise((resolve) => {
559
+ const server = net.createServer();
560
+ server.once('error', () => resolve(false));
561
+ server.once('listening', () => {
562
+ server.close(() => resolve(true));
563
+ });
564
+ server.listen(portNumber, '127.0.0.1');
565
+ });
566
+ }
567
+
568
+ async function assertWebPortAvailable() {
569
+ const existingServer = await checkExistingOpenHermitServer();
570
+ if (existingServer.running) {
571
+ console.log(`[openHermit] Already running: ${existingServer.url}`);
572
+ console.log(`[openHermit] Version: ${existingServer.version}`);
573
+ console.log('[openHermit] Run `openhermit stop` first, or use `openhermit --port <port>` for another instance.');
574
+ process.exit(0);
575
+ }
576
+
577
+ const available = await isTcpPortAvailable(Number.parseInt(port, 10));
578
+ if (!available) {
579
+ console.error(`[openHermit] Port ${port} is already in use.`);
580
+ console.error('[openHermit] Stop the existing process first, or start with another port:');
581
+ console.error(` openhermit --port ${Number.parseInt(port, 10) + 1}`);
582
+ console.error('[openHermit] macOS/Linux: lsof -nP -iTCP:' + port + ' -sTCP:LISTEN');
583
+ console.error('[openHermit] Windows: netstat -ano | findstr :' + port);
584
+ process.exit(1);
585
+ }
586
+ }
587
+
557
588
  let ccConnectProcess = null;
558
589
  let ccTokens = {
559
590
  managementToken: process.env.CC_CONNECT_TOKEN || process.env.CC_CONNECT_MANAGEMENT_TOKEN || '',
560
591
  bridgeToken: process.env.CC_CONNECT_BRIDGE_TOKEN || process.env.CC_CONNECT_TOKEN || '',
561
592
  };
593
+ let runtimeSetupMode = false;
594
+
595
+ await assertWebPortAvailable();
562
596
 
563
597
  if (!skipCcConnect) {
564
- ccTokens = ensureCcConnectConfig();
598
+ let shouldStartRuntime = false;
599
+ ccTokens = readCcConnectConfigState();
565
600
  const ccBaseUrl = process.env.CC_CONNECT_BASE_URL || 'http://127.0.0.1:9820';
566
601
  const alreadyRunning = await waitForCcConnect(ccBaseUrl, ccTokens.managementToken, 1_000);
567
602
  if (alreadyRunning) {
568
603
  console.log(`[openHermit] Runtime service already running: ${ccBaseUrl}`);
569
- } else if (!ccTokens.hasRunnableProjects) {
570
- console.log('[openHermit] Runtime config is a starter template; starting control panel only.');
571
- console.log(`[openHermit] Edit runtime config in the UI before starting the runtime service.`);
572
- if (ccTokens.isStarterConfig) {
573
- console.log(`[openHermit] Starter config: ${ccConnectConfigPath}`);
604
+ } else if (!ccTokens.configExists) {
605
+ console.log('[openHermit] Initializing runtime config with bundled runtime service...');
606
+ console.log(`[openHermit] Runtime config: ${ccConnectConfigPath}`);
607
+ const initProcess = spawn(process.execPath, [resolveCcConnectRunner(), '-config', ccConnectConfigPath], {
608
+ cwd: repoRoot,
609
+ env: {
610
+ ...process.env,
611
+ CC_CONNECT_TOKEN: ccTokens.managementToken,
612
+ CC_CONNECT_MANAGEMENT_TOKEN: ccTokens.managementToken,
613
+ CC_CONNECT_BRIDGE_TOKEN: ccTokens.bridgeToken,
614
+ },
615
+ stdio: ['ignore', 'pipe', 'pipe'],
616
+ });
617
+ initProcess.stdout?.on('data', (chunk) => {
618
+ process.stdout.write(chunk);
619
+ appendLog(runtimeLogPath, chunk);
620
+ });
621
+ initProcess.stderr?.on('data', (chunk) => {
622
+ process.stderr.write(chunk);
623
+ appendLog(runtimeLogPath, chunk);
624
+ });
625
+
626
+ const initCode = await new Promise((resolve) => {
627
+ initProcess.on('exit', (code) => resolve(code ?? 1));
628
+ initProcess.on('error', () => resolve(1));
629
+ });
630
+
631
+ ccTokens = readCcConnectConfigState();
632
+ if (initCode === 0 && ccTokens.configExists) {
633
+ console.log('[openHermit] Runtime starter config created.');
634
+ try {
635
+ ensureClaudeCodeCliIfNeeded(ccTokens.raw);
636
+ } catch {
637
+ printLogTail('Runtime', runtimeLogPath);
638
+ process.exit(1);
639
+ }
640
+ shouldStartRuntime = true;
641
+ } else {
642
+ console.error(`[openHermit] Runtime config initialization failed (code ${initCode}).`);
643
+ printLogTail('Runtime', runtimeLogPath);
644
+ process.exit(1);
574
645
  }
646
+ } else if (ccTokens.hasProjects) {
647
+ try {
648
+ ensureClaudeCodeCliIfNeeded(ccTokens.raw);
649
+ } catch {
650
+ printLogTail('Runtime', runtimeLogPath);
651
+ process.exit(1);
652
+ }
653
+ shouldStartRuntime = true;
575
654
  } else {
655
+ console.error('[openHermit] Runtime config has no projects. Please edit the config and try again.');
656
+ console.error(`[openHermit] Runtime config: ${ccConnectConfigPath}`);
657
+ process.exit(1);
658
+ }
659
+
660
+ if (shouldStartRuntime) {
576
661
  console.log('[openHermit] Starting bundled runtime service...');
577
662
  console.log(`[openHermit] Runtime config: ${ccConnectConfigPath}`);
578
663
  ccConnectProcess = spawn(process.execPath, [resolveCcConnectRunner(), '-config', ccConnectConfigPath], {
@@ -660,6 +745,7 @@ const serverProcess = spawn(process.execPath, ['--import', resolveAliasLoaderReg
660
745
  HOST: process.env.HOST || '127.0.0.1',
661
746
  NODE_ENV: 'production',
662
747
  HERMIT_HOME: hermitHome,
748
+ HERMIT_RUNTIME_SETUP_MODE: runtimeSetupMode ? '1' : '0',
663
749
  CC_CONNECT_TOKEN: ccTokens.managementToken,
664
750
  CC_CONNECT_MANAGEMENT_TOKEN: ccTokens.managementToken,
665
751
  CC_CONNECT_BRIDGE_TOKEN: ccTokens.bridgeToken,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yancyyu/openhermit",
3
3
  "type": "module",
4
- "version": "1.6.19",
4
+ "version": "1.6.23",
5
5
  "description": "openHermit: team-oriented agent management workbench atop cc-connect.",
6
6
  "license": "AGPL-3.0",
7
7
  "author": {
@@ -68,6 +68,7 @@ const HOST = process.env.HOST ?? '127.0.0.1';
68
68
  const PORT = Number.parseInt(process.env.PORT ?? '5680', 10);
69
69
  const STATIC_DIR = process.env.STATIC_DIR ?? path.resolve(REPO_ROOT, 'dist-renderer');
70
70
  const HARNESS_BRIDGE_CONNECT_TIMEOUT_MS = 10_000;
71
+ const RUNTIME_SETUP_MODE = process.env.HERMIT_RUNTIME_SETUP_MODE === '1';
71
72
 
72
73
  // ===========================================================================
73
74
  // Hermit runtime config — ~/.hermit/config.json
@@ -2183,6 +2184,11 @@ function isCronNotFoundError(error: unknown): boolean {
2183
2184
  return /(\b404\b|not found|no matching|does not exist|不存在)/i.test(message);
2184
2185
  }
2185
2186
 
2187
+ function isRuntimeUnavailableError(error: unknown): boolean {
2188
+ const message = error instanceof Error ? error.message : String(error);
2189
+ return /ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|fetch failed|cc-connect 不可达/i.test(message);
2190
+ }
2191
+
2186
2192
  app.get('/api/schedules', async () => {
2187
2193
  try {
2188
2194
  const jobs = await cc.listCronJobs();
@@ -2190,6 +2196,9 @@ app.get('/api/schedules', async () => {
2190
2196
  const workDirMap = await resolveTeamWorkDirs(jobs.map((job) => job.project));
2191
2197
  return jobs.map((job) => mapCronJobToSchedule(job, workDirMap.get(job.project) ?? ''));
2192
2198
  } catch (err) {
2199
+ if (RUNTIME_SETUP_MODE && isRuntimeUnavailableError(err)) {
2200
+ return [];
2201
+ }
2193
2202
  app.log.warn({ err }, 'list schedules from cc-connect failed');
2194
2203
  return [];
2195
2204
  }