@yancyyu/openhermit 1.6.11 → 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,11 +86,16 @@ 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 ||
86
96
  path.join(hermitHome, 'cc-connect', 'config.toml');
87
- const bootstrapProjectName = '__openhermit_bootstrap__';
97
+ const bootstrapProjectName = 'default';
98
+ const legacyBootstrapProjectName = '__openhermit_bootstrap__';
88
99
 
89
100
  // ---------------------------------------------------------------------------
90
101
  // Update command
@@ -141,6 +152,171 @@ async function runUpdate() {
141
152
  }
142
153
  }
143
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
+
144
320
  // ---------------------------------------------------------------------------
145
321
  // Check dependencies
146
322
  // ---------------------------------------------------------------------------
@@ -216,21 +392,37 @@ callback_path = "/openhermit-bootstrap"
216
392
  `;
217
393
  }
218
394
 
219
- function migrateManagedBootstrapProject(raw) {
220
- const projectPattern = /(^|\n)(#.*\n)*\[\[projects\]\]\nname\s*=\s*"__openhermit_bootstrap__"[\s\S]*?(?=\n(#.*\n)*\[\[projects\]\]|\s*$)/m;
395
+ function escapeRegExp(value) {
396
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
397
+ }
398
+
399
+ function findProjectBlock(raw, name) {
400
+ const projectPattern = new RegExp(
401
+ `\\[\\[projects\\]\\]\\nname\\s*=\\s*"${escapeRegExp(name)}"[\\s\\S]*?(?=\\n\\[\\[projects\\]\\]|\\s*$)`
402
+ );
221
403
  const match = raw.match(projectPattern);
222
- if (!match) return raw;
404
+ return match ? { pattern: projectPattern, match } : null;
405
+ }
223
406
 
224
- const block = match[0];
225
- const isLegacyManagedBootstrap =
226
- block.includes('type = "feishu"') &&
227
- block.includes('app_id = "placeholder"') &&
228
- block.includes('app_secret = "placeholder"');
407
+ function isManagedBootstrapBlock(block) {
408
+ return (
409
+ block.includes('disabled_commands = ["*"]') &&
410
+ (block.includes('app_id = "placeholder"') ||
411
+ block.includes('channel_token = "openhermit-bootstrap"') ||
412
+ block.includes('callback_path = "/openhermit-bootstrap"'))
413
+ );
414
+ }
229
415
 
230
- if (!isLegacyManagedBootstrap) return raw;
416
+ function migrateManagedBootstrapProject(raw) {
417
+ const legacyBlock = findProjectBlock(raw, legacyBootstrapProjectName);
418
+ if (!legacyBlock || !isManagedBootstrapBlock(legacyBlock.match[0])) return raw;
231
419
 
232
- const prefix = match[1] === '\n' ? '\n' : '';
233
- return raw.replace(projectPattern, `${prefix}${buildBootstrapProjectToml().trimStart()}`);
420
+ const withoutLegacy = raw.replace(legacyBlock.pattern, '').replace(/\n{3,}/g, '\n\n').trimEnd();
421
+ if (findProjectBlock(withoutLegacy, bootstrapProjectName)) {
422
+ return `${withoutLegacy}\n`;
423
+ }
424
+
425
+ return `${withoutLegacy}\n${buildBootstrapProjectToml()}`;
234
426
  }
235
427
 
236
428
  function ensureCcConnectConfig() {
@@ -331,6 +523,7 @@ if (!skipCcConnect) {
331
523
  console.log(`[openHermit] cc-connect config: ${ccConnectConfigPath}`);
332
524
  ccConnectProcess = spawn(process.execPath, [resolveCcConnectRunner(), '-config', ccConnectConfigPath], {
333
525
  cwd: repoRoot,
526
+ detached: true,
334
527
  env: {
335
528
  ...process.env,
336
529
  CC_CONNECT_TOKEN: ccTokens.managementToken,
@@ -385,6 +578,7 @@ console.log('[openHermit] Launching server...\n');
385
578
 
386
579
  const serverProcess = spawn(process.execPath, ['--import', resolveAliasLoaderRegister(), '--import', resolveTsxLoader(), 'src/main/server.ts'], {
387
580
  cwd: repoRoot,
581
+ detached: true,
388
582
  env: {
389
583
  ...process.env,
390
584
  PORT: port,
@@ -400,22 +594,34 @@ const serverProcess = spawn(process.execPath, ['--import', resolveAliasLoaderReg
400
594
  });
401
595
 
402
596
  serverProcess.on('exit', (code) => {
597
+ if (shuttingDown) return;
598
+ signalDaemon(ccConnectProcess?.pid, 'SIGTERM');
403
599
  if (code !== 0) {
404
600
  console.error(`[openHermit] Server exited with code ${code}`);
405
601
  process.exit(code ?? 1);
406
602
  }
407
603
  });
408
604
 
409
- process.on('SIGINT', () => {
605
+ let shuttingDown = false;
606
+ function shutdown(exitCode = 0) {
607
+ if (shuttingDown) return;
608
+ shuttingDown = true;
410
609
  console.log('\n[openHermit] Shutting down...');
411
- serverProcess.kill('SIGINT');
412
- 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);
413
621
  });
414
622
 
415
623
  process.on('SIGTERM', () => {
416
- console.log('\n[openHermit] Shutting down...');
417
- serverProcess.kill('SIGTERM');
418
- ccConnectProcess?.kill('SIGTERM');
624
+ shutdown(0);
419
625
  });
420
626
 
421
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.11",
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
  }