@yancyyu/openhermit 1.6.12 → 1.6.14

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,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { pathToFileURL } from 'node:url';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
 
5
- const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
5
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
6
6
 
7
7
  const ALIASES = [
8
8
  ['@features/', 'src/features/'],
package/bin/hermit.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { spawn, execSync } from 'node:child_process';
18
18
  import crypto from 'node:crypto';
19
- import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
19
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
20
20
  import { createRequire } from 'node:module';
21
21
  import os from 'node:os';
22
22
  import path from 'node:path';
@@ -55,15 +55,21 @@ Usage:
55
55
 
56
56
  Options:
57
57
  --port <number> HTTP server port (default: 5680)
58
- --no-cc-connect Do not auto-start bundled cc-connect
58
+ --no-cc-connect Do not auto-start bundled runtime service
59
+ --daemon Run openHermit in the background
59
60
  --version Show current version
60
61
  --help Show this help message
62
+ status Show background service status
63
+ stop Stop background service
61
64
  update Check and install updates
62
65
 
63
66
  Examples:
64
67
  npx @yancyyu/openhermit # Run without installing
65
68
  npx @yancyyu/openhermit --port 8080
66
69
  openhermit # After global install
70
+ openhermit --daemon
71
+ openhermit status
72
+ openhermit stop
67
73
  openhermit --version
68
74
  openhermit update
69
75
  `);
@@ -80,6 +86,10 @@ const portIndex = args.indexOf('--port');
80
86
  const port = portIndex !== -1 && args[portIndex + 1] ? args[portIndex + 1] : '5680';
81
87
  const skipCcConnect = args.includes('--no-cc-connect') || process.env.HERMIT_NO_CC_CONNECT === '1';
82
88
  const hermitHome = process.env.HERMIT_HOME || path.join(os.homedir(), '.hermit');
89
+ const daemonRequested = args.includes('--daemon');
90
+ const daemonChild = process.env.HERMIT_DAEMON_CHILD === '1';
91
+ const daemonPidPath = path.join(hermitHome, 'openhermit.pid');
92
+ const daemonLogPath = path.join(hermitHome, 'logs', 'openhermit.log');
83
93
  const ccConnectConfigPath =
84
94
  process.env.HERMIT_CC_CONNECT_CONFIG ||
85
95
  process.env.CC_CONNECT_CONFIG ||
@@ -142,6 +152,171 @@ async function runUpdate() {
142
152
  }
143
153
  }
144
154
 
155
+ function readDaemonPid() {
156
+ try {
157
+ const raw = readFileSync(daemonPidPath, 'utf-8').trim();
158
+ const pid = Number.parseInt(raw, 10);
159
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ function isPidRunning(pid) {
166
+ try {
167
+ process.kill(pid, 0);
168
+ return true;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ function removeDaemonPidFile() {
175
+ try {
176
+ unlinkSync(daemonPidPath);
177
+ } catch {
178
+ // Already gone.
179
+ }
180
+ }
181
+
182
+ function signalDaemon(pid, signal) {
183
+ if (!pid) return false;
184
+ try {
185
+ process.kill(-pid, signal);
186
+ return true;
187
+ } catch {
188
+ // Fall back to direct process signal.
189
+ }
190
+ try {
191
+ process.kill(pid, signal);
192
+ return true;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ function collectFallbackPids() {
199
+ const pids = new Set();
200
+ const commands = [
201
+ `lsof -tiTCP:${port} -sTCP:LISTEN 2>/dev/null || true`,
202
+ 'lsof -tiTCP:9810 -sTCP:LISTEN 2>/dev/null || true',
203
+ 'lsof -tiTCP:9820 -sTCP:LISTEN 2>/dev/null || true',
204
+ "pgrep -f '@yancyyu/openhermit|openhermit/bin/hermit\\.mjs|src/main/server\\.ts|cc-connect' 2>/dev/null || true",
205
+ ];
206
+
207
+ for (const command of commands) {
208
+ try {
209
+ const out = execSync(command, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
210
+ for (const line of out.split(/\s+/)) {
211
+ const pid = Number.parseInt(line, 10);
212
+ if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) {
213
+ pids.add(pid);
214
+ }
215
+ }
216
+ } catch {
217
+ // Ignore missing lsof/pgrep or races with exiting processes.
218
+ }
219
+ }
220
+
221
+ return [...pids];
222
+ }
223
+
224
+ async function stopFallbackProcesses() {
225
+ const pids = collectFallbackPids();
226
+ if (pids.length === 0) return false;
227
+
228
+ for (const pid of pids) {
229
+ signalDaemon(pid, 'SIGTERM');
230
+ }
231
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
232
+ for (const pid of pids) {
233
+ if (isPidRunning(pid)) {
234
+ signalDaemon(pid, 'SIGKILL');
235
+ }
236
+ }
237
+ return true;
238
+ }
239
+
240
+ function printDaemonStatus() {
241
+ const pid = readDaemonPid();
242
+ if (pid && isPidRunning(pid)) {
243
+ console.log(`[openHermit] Running in background (pid ${pid})`);
244
+ console.log(`[openHermit] Log: ${daemonLogPath}`);
245
+ process.exit(0);
246
+ }
247
+ if (pid) removeDaemonPidFile();
248
+ const fallbackPids = collectFallbackPids();
249
+ if (fallbackPids.length > 0) {
250
+ console.log(`[openHermit] Running without daemon pidfile (pids ${fallbackPids.join(', ')})`);
251
+ process.exit(0);
252
+ }
253
+ console.log('[openHermit] Not running');
254
+ process.exit(1);
255
+ }
256
+
257
+ async function stopDaemon() {
258
+ const pid = readDaemonPid();
259
+ if (!pid || !isPidRunning(pid)) {
260
+ if (pid) removeDaemonPidFile();
261
+ const stoppedFallback = await stopFallbackProcesses();
262
+ console.log(stoppedFallback ? '[openHermit] Stopped orphaned service processes' : '[openHermit] Not running');
263
+ process.exit(0);
264
+ }
265
+ console.log(`[openHermit] Stopping background service (pid ${pid})...`);
266
+ signalDaemon(pid, 'SIGTERM');
267
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
268
+ if (isPidRunning(pid)) {
269
+ signalDaemon(pid, 'SIGKILL');
270
+ }
271
+ removeDaemonPidFile();
272
+ console.log('[openHermit] Stopped');
273
+ process.exit(0);
274
+ }
275
+
276
+ function startDaemon() {
277
+ const existingPid = readDaemonPid();
278
+ if (existingPid && isPidRunning(existingPid)) {
279
+ console.log(`[openHermit] Already running in background (pid ${existingPid})`);
280
+ console.log(`[openHermit] Log: ${daemonLogPath}`);
281
+ process.exit(0);
282
+ }
283
+
284
+ mkdirSync(path.dirname(daemonPidPath), { recursive: true });
285
+ mkdirSync(path.dirname(daemonLogPath), { recursive: true });
286
+ const out = openSync(daemonLogPath, 'a');
287
+ const err = openSync(daemonLogPath, 'a');
288
+ const childArgs = process.argv.slice(2).filter((arg) => arg !== '--daemon');
289
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
290
+ cwd: repoRoot,
291
+ detached: true,
292
+ env: {
293
+ ...process.env,
294
+ HERMIT_DAEMON_CHILD: '1',
295
+ },
296
+ stdio: ['ignore', out, err],
297
+ });
298
+ child.unref();
299
+ closeSync(out);
300
+ closeSync(err);
301
+ writeFileSync(daemonPidPath, String(child.pid), 'utf-8');
302
+ console.log(`[openHermit] Started in background (pid ${child.pid})`);
303
+ console.log(`[openHermit] URL: http://127.0.0.1:${port}`);
304
+ console.log(`[openHermit] Log: ${daemonLogPath}`);
305
+ process.exit(0);
306
+ }
307
+
308
+ if (args.includes('status')) {
309
+ printDaemonStatus();
310
+ }
311
+
312
+ if (args.includes('stop')) {
313
+ await stopDaemon();
314
+ }
315
+
316
+ if (daemonRequested && !daemonChild) {
317
+ startDaemon();
318
+ }
319
+
145
320
  // ---------------------------------------------------------------------------
146
321
  // Check dependencies
147
322
  // ---------------------------------------------------------------------------
@@ -342,12 +517,13 @@ if (!skipCcConnect) {
342
517
  const ccBaseUrl = process.env.CC_CONNECT_BASE_URL || 'http://127.0.0.1:9820';
343
518
  const alreadyRunning = await waitForCcConnect(ccBaseUrl, ccTokens.managementToken, 1_000);
344
519
  if (alreadyRunning) {
345
- console.log(`[openHermit] cc-connect already running: ${ccBaseUrl}`);
520
+ console.log(`[openHermit] Runtime service already running: ${ccBaseUrl}`);
346
521
  } else {
347
- console.log('[openHermit] Starting bundled cc-connect...');
348
- console.log(`[openHermit] cc-connect config: ${ccConnectConfigPath}`);
522
+ console.log('[openHermit] Starting bundled runtime service...');
523
+ console.log(`[openHermit] Runtime config: ${ccConnectConfigPath}`);
349
524
  ccConnectProcess = spawn(process.execPath, [resolveCcConnectRunner(), '-config', ccConnectConfigPath], {
350
525
  cwd: repoRoot,
526
+ detached: true,
351
527
  env: {
352
528
  ...process.env,
353
529
  CC_CONNECT_TOKEN: ccTokens.managementToken,
@@ -358,7 +534,7 @@ if (!skipCcConnect) {
358
534
  });
359
535
  const ready = await waitForCcConnect(ccBaseUrl, ccTokens.managementToken, 30_000);
360
536
  if (!ready) {
361
- console.warn('[openHermit] cc-connect did not become ready within 30s; openHermit will keep trying via API.');
537
+ console.warn('[openHermit] Runtime service did not become ready within 30s; openHermit will keep trying via API.');
362
538
  }
363
539
  }
364
540
  }
@@ -402,6 +578,7 @@ console.log('[openHermit] Launching server...\n');
402
578
 
403
579
  const serverProcess = spawn(process.execPath, ['--import', resolveAliasLoaderRegister(), '--import', resolveTsxLoader(), 'src/main/server.ts'], {
404
580
  cwd: repoRoot,
581
+ detached: true,
405
582
  env: {
406
583
  ...process.env,
407
584
  PORT: port,
@@ -417,22 +594,34 @@ const serverProcess = spawn(process.execPath, ['--import', resolveAliasLoaderReg
417
594
  });
418
595
 
419
596
  serverProcess.on('exit', (code) => {
597
+ if (shuttingDown) return;
598
+ signalDaemon(ccConnectProcess?.pid, 'SIGTERM');
420
599
  if (code !== 0) {
421
600
  console.error(`[openHermit] Server exited with code ${code}`);
422
601
  process.exit(code ?? 1);
423
602
  }
424
603
  });
425
604
 
426
- process.on('SIGINT', () => {
605
+ let shuttingDown = false;
606
+ function shutdown(exitCode = 0) {
607
+ if (shuttingDown) return;
608
+ shuttingDown = true;
427
609
  console.log('\n[openHermit] Shutting down...');
428
- serverProcess.kill('SIGINT');
429
- ccConnectProcess?.kill('SIGINT');
610
+ signalDaemon(serverProcess?.pid, 'SIGTERM');
611
+ signalDaemon(ccConnectProcess?.pid, 'SIGTERM');
612
+ setTimeout(() => {
613
+ signalDaemon(serverProcess?.pid, 'SIGKILL');
614
+ signalDaemon(ccConnectProcess?.pid, 'SIGKILL');
615
+ process.exit(exitCode);
616
+ }, 2_000).unref();
617
+ }
618
+
619
+ process.on('SIGINT', () => {
620
+ shutdown(0);
430
621
  });
431
622
 
432
623
  process.on('SIGTERM', () => {
433
- console.log('\n[openHermit] Shutting down...');
434
- serverProcess.kill('SIGTERM');
435
- ccConnectProcess?.kill('SIGTERM');
624
+ shutdown(0);
436
625
  });
437
626
 
438
627
  console.log(`[openHermit] Server starting on http://127.0.0.1:${port}`);
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
+
12
+ let version = 'unknown';
13
+ try {
14
+ version = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf-8')).version;
15
+ } catch {
16
+ // Keep install non-blocking.
17
+ }
18
+
19
+ let runtimeVersion = 'bundled';
20
+ try {
21
+ runtimeVersion = JSON.parse(readFileSync(require.resolve('cc-connect/package.json'), 'utf-8')).version;
22
+ } catch {
23
+ // Keep install non-blocking.
24
+ }
25
+
26
+ const hermitHome = process.env.HERMIT_HOME || path.join(os.homedir(), '.hermit');
27
+
28
+ console.log(`[openHermit] Installed ${version}`);
29
+ console.log(`[openHermit] Bundled runtime service: ${runtimeVersion}`);
30
+ console.log(`[openHermit] Data directory: ${hermitHome}`);
31
+ console.log('[openHermit] Start with: openhermit');
32
+ console.log('[openHermit] Background mode: openhermit --daemon');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yancyyu/openhermit",
3
3
  "type": "module",
4
- "version": "1.6.12",
4
+ "version": "1.6.14",
5
5
  "description": "openHermit: team-oriented agent management workbench atop cc-connect.",
6
6
  "license": "AGPL-3.0",
7
7
  "author": {
@@ -34,6 +34,7 @@
34
34
  "dev:server": "tsx watch src/main/server.ts",
35
35
  "dev:web": "vite --config vite.web.config.ts",
36
36
  "build:web": "vite build --config vite.web.config.ts --outDir ../../dist-renderer --emptyOutDir",
37
+ "postinstall": "node ./bin/postinstall.mjs",
37
38
  "prepack": "pnpm build:web",
38
39
  "start": "node bin/hermit.mjs",
39
40
  "typecheck": "tsc --noEmit",
@@ -1037,25 +1037,16 @@ app.delete<{ Params: { name: string }; Querystring: { deleteFiles?: string } }>(
1037
1037
  try {
1038
1038
  const result = await cc.deleteProject(teamName);
1039
1039
  restartRequired = result.restart_required === true;
1040
- await svc.updateTeam(teamName, {
1041
- pendingDelete: true,
1042
- restartRequired,
1043
- });
1044
1040
  } catch (err) {
1045
1041
  request.log.warn({ err, teamName }, 'delete cc-connect project failed or project missing');
1046
- try {
1047
- await svc.updateTeam(teamName, {
1048
- pendingDelete: true,
1049
- restartRequired: true,
1050
- });
1051
- restartRequired = true;
1052
- } catch {
1053
- // Local metadata may already be gone; keep deletion best-effort.
1054
- }
1055
1042
  }
1056
- if (request.query.deleteFiles === 'true') {
1057
- await svc.deleteTeam(teamName, { deleteFiles: true });
1043
+
1044
+ try {
1045
+ await svc.deleteTeam(teamName, { deleteFiles: request.query.deleteFiles === 'true' });
1046
+ } catch (err) {
1047
+ request.log.warn({ err, teamName }, 'delete local team metadata failed or already missing');
1058
1048
  }
1049
+
1059
1050
  return { ok: true, restartRequired };
1060
1051
  } catch (err) {
1061
1052
  return reply.code(500).send(reply500(err));
@@ -7,6 +7,8 @@
7
7
 
8
8
  import { useCallback, useEffect, useState } from 'react';
9
9
 
10
+ import { api } from '@renderer/api';
11
+ import { confirm } from '@renderer/components/common/ConfirmDialog';
10
12
  import { Button } from '@renderer/components/ui/button';
11
13
  import {
12
14
  Dialog,
@@ -198,14 +200,20 @@ async function addPlatform(
198
200
  projectName: string,
199
201
  type: string,
200
202
  options: Record<string, string>
201
- ): Promise<void> {
203
+ ): Promise<{ restartRequired: boolean }> {
202
204
  const res = await fetch(`/api/v1/projects/${encodeURIComponent(projectName)}/add-platform`, {
203
205
  method: 'POST',
204
206
  headers: { 'Content-Type': 'application/json' },
205
207
  body: JSON.stringify({ type, options }),
206
208
  });
207
- const json = (await res.json()) as { ok: boolean; error?: string };
209
+ const json = (await res.json()) as {
210
+ ok: boolean;
211
+ error?: string;
212
+ data?: { restart_required?: boolean };
213
+ restart_required?: boolean;
214
+ };
208
215
  if (!json.ok) throw new Error(json.error ?? '添加失败');
216
+ return { restartRequired: json.data?.restart_required === true || json.restart_required === true };
209
217
  }
210
218
 
211
219
  // ---------------------------------------------------------------------------
@@ -482,8 +490,19 @@ function AddPlatformDialog({
482
490
  else if (v === 'false') ccOptions[k] = false;
483
491
  else ccOptions[k] = v;
484
492
  }
485
- await addPlatform(projectName, platformType, ccOptions as Record<string, string>);
493
+ const result = await addPlatform(projectName, platformType, ccOptions as Record<string, string>);
486
494
  onAdded(projectName);
495
+ if (result.restartRequired) {
496
+ const shouldRestart = await confirm({
497
+ title: '重启 cc-connect',
498
+ message: '渠道已添加。需要重启 cc-connect 才会生效。',
499
+ confirmLabel: '立即重启',
500
+ cancelLabel: '稍后重启',
501
+ });
502
+ if (shouldRestart) {
503
+ await api.ccSettings.restart();
504
+ }
505
+ }
487
506
  } catch (e) {
488
507
  setError(e instanceof Error ? e.message : '添加失败');
489
508
  } finally {
@@ -1833,9 +1833,8 @@ export const TeamDetailView = ({
1833
1833
  const result = await deleteTeam(teamName);
1834
1834
  if (result.restartRequired) {
1835
1835
  const shouldRestart = await confirm({
1836
- title: '需要重启 cc-connect',
1837
- message:
1838
- '已调用 cc-connect 删除团队配置。cc-connect 需要重启后才会真正移除该团队并停止相关运行时。',
1836
+ title: '重启 cc-connect',
1837
+ message: '团队已从配置中删除。需要重启 cc-connect 才会停止对应运行时。',
1839
1838
  confirmLabel: '立即重启',
1840
1839
  cancelLabel: '稍后重启',
1841
1840
  variant: 'danger',
@@ -531,9 +531,9 @@ export const TeamListView = (): React.JSX.Element => {
531
531
  return;
532
532
  }
533
533
  const confirmed = await confirm({
534
- title: '移入回收站',
535
- message: `确定将团队“${teamDisplayName}”移入回收站吗?之后可以恢复。`,
536
- confirmLabel: '移入回收站',
534
+ title: '删除团队',
535
+ message: `确定删除团队“${teamDisplayName}”吗?此操作会同步删除 cc-connect 项目并移除本地团队数据。`,
536
+ confirmLabel: '删除',
537
537
  cancelLabel: '取消',
538
538
  variant: 'danger',
539
539
  });
@@ -542,9 +542,8 @@ export const TeamListView = (): React.JSX.Element => {
542
542
  const result = await deleteTeam(teamName);
543
543
  if (result.restartRequired) {
544
544
  const shouldRestart = await confirm({
545
- title: '需要重启 cc-connect',
546
- message:
547
- '已调用 cc-connect 删除团队配置。cc-connect 需要重启后才会真正移除该团队并停止相关运行时。',
545
+ title: '重启 cc-connect',
546
+ message: '团队已从配置中删除。需要重启 cc-connect 才会停止对应运行时。',
548
547
  confirmLabel: '立即重启',
549
548
  cancelLabel: '稍后重启',
550
549
  variant: 'danger',