@yancyyu/openhermit 1.6.12 → 1.6.13

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
@@ -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';
@@ -56,14 +56,20 @@ Usage:
56
56
  Options:
57
57
  --port <number> HTTP server port (default: 5680)
58
58
  --no-cc-connect Do not auto-start bundled cc-connect
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
  // ---------------------------------------------------------------------------
@@ -348,6 +523,7 @@ if (!skipCcConnect) {
348
523
  console.log(`[openHermit] cc-connect 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,
@@ -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}`);
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.13",
5
5
  "description": "openHermit: team-oriented agent management workbench atop cc-connect.",
6
6
  "license": "AGPL-3.0",
7
7
  "author": {
@@ -1033,30 +1033,22 @@ app.delete<{ Params: { name: string }; Querystring: { deleteFiles?: string } }>(
1033
1033
  async (request, reply) => {
1034
1034
  const teamName = request.params.name;
1035
1035
  try {
1036
- let restartRequired = false;
1037
1036
  try {
1038
1037
  const result = await cc.deleteProject(teamName);
1039
- restartRequired = result.restart_required === true;
1040
- await svc.updateTeam(teamName, {
1041
- pendingDelete: true,
1042
- restartRequired,
1043
- });
1038
+ if (result.restart_required) {
1039
+ await cc.restart();
1040
+ }
1044
1041
  } catch (err) {
1045
1042
  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
1043
  }
1056
- if (request.query.deleteFiles === 'true') {
1057
- await svc.deleteTeam(teamName, { deleteFiles: true });
1044
+
1045
+ try {
1046
+ await svc.deleteTeam(teamName, { deleteFiles: request.query.deleteFiles === 'true' });
1047
+ } catch (err) {
1048
+ request.log.warn({ err, teamName }, 'delete local team metadata failed or already missing');
1058
1049
  }
1059
- return { ok: true, restartRequired };
1050
+
1051
+ return { ok: true, restartRequired: false };
1060
1052
  } catch (err) {
1061
1053
  return reply.code(500).send(reply500(err));
1062
1054
  }
@@ -1830,20 +1830,7 @@ export const TeamDetailView = ({
1830
1830
  setDeleteConfirmOpen(false);
1831
1831
  void (async () => {
1832
1832
  try {
1833
- const result = await deleteTeam(teamName);
1834
- if (result.restartRequired) {
1835
- const shouldRestart = await confirm({
1836
- title: '需要重启 cc-connect',
1837
- message:
1838
- '已调用 cc-connect 删除团队配置。cc-connect 需要重启后才会真正移除该团队并停止相关运行时。',
1839
- confirmLabel: '立即重启',
1840
- cancelLabel: '稍后重启',
1841
- variant: 'danger',
1842
- });
1843
- if (shouldRestart) {
1844
- await api.ccSettings.restart();
1845
- }
1846
- }
1833
+ await deleteTeam(teamName);
1847
1834
  if (tabId) closeTab(tabId);
1848
1835
  openTeamsTab();
1849
1836
  } catch {
@@ -531,28 +531,15 @@ 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
  });
540
540
  if (confirmed) {
541
541
  try {
542
- const result = await deleteTeam(teamName);
543
- if (result.restartRequired) {
544
- const shouldRestart = await confirm({
545
- title: '需要重启 cc-connect',
546
- message:
547
- '已调用 cc-connect 删除团队配置。cc-connect 需要重启后才会真正移除该团队并停止相关运行时。',
548
- confirmLabel: '立即重启',
549
- cancelLabel: '稍后重启',
550
- variant: 'danger',
551
- });
552
- if (shouldRestart) {
553
- await api.ccSettings.restart();
554
- }
555
- }
542
+ await deleteTeam(teamName);
556
543
  } catch {
557
544
  // error via store
558
545
  }