codeharbor 0.1.9 → 0.1.11

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/dist/cli.js CHANGED
@@ -24,19 +24,20 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/cli.ts
27
+ var import_node_child_process6 = require("child_process");
27
28
  var import_node_fs11 = __toESM(require("fs"));
28
29
  var import_node_path12 = __toESM(require("path"));
29
30
  var import_commander = require("commander");
30
31
 
31
32
  // src/app.ts
32
- var import_node_child_process3 = require("child_process");
33
+ var import_node_child_process4 = require("child_process");
33
34
  var import_node_util2 = require("util");
34
35
 
35
36
  // src/admin-server.ts
36
- var import_node_child_process = require("child_process");
37
- var import_node_fs2 = __toESM(require("fs"));
37
+ var import_node_child_process2 = require("child_process");
38
+ var import_node_fs4 = __toESM(require("fs"));
38
39
  var import_node_http = __toESM(require("http"));
39
- var import_node_path2 = __toESM(require("path"));
40
+ var import_node_path4 = __toESM(require("path"));
40
41
  var import_node_util = require("util");
41
42
 
42
43
  // src/init.ts
@@ -219,117 +220,413 @@ async function askYesNo(rl, question, defaultValue) {
219
220
  return answer === "y" || answer === "yes";
220
221
  }
221
222
 
222
- // src/admin-server.ts
223
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
224
- var HttpError = class extends Error {
225
- statusCode;
226
- constructor(statusCode, message) {
227
- super(message);
228
- this.statusCode = statusCode;
223
+ // src/service-manager.ts
224
+ var import_node_child_process = require("child_process");
225
+ var import_node_fs3 = __toESM(require("fs"));
226
+ var import_node_os2 = __toESM(require("os"));
227
+ var import_node_path3 = __toESM(require("path"));
228
+
229
+ // src/runtime-home.ts
230
+ var import_node_fs2 = __toESM(require("fs"));
231
+ var import_node_os = __toESM(require("os"));
232
+ var import_node_path2 = __toESM(require("path"));
233
+ var LEGACY_RUNTIME_HOME = "/opt/codeharbor";
234
+ var USER_RUNTIME_HOME_DIR = ".codeharbor";
235
+ var DEFAULT_RUNTIME_HOME = import_node_path2.default.resolve(import_node_os.default.homedir(), USER_RUNTIME_HOME_DIR);
236
+ var RUNTIME_HOME_ENV_KEY = "CODEHARBOR_HOME";
237
+ function resolveRuntimeHome(env = process.env) {
238
+ const configured = env[RUNTIME_HOME_ENV_KEY]?.trim();
239
+ if (configured) {
240
+ return import_node_path2.default.resolve(configured);
229
241
  }
230
- };
231
- var AdminServer = class {
232
- config;
233
- logger;
234
- stateStore;
235
- configService;
236
- host;
237
- port;
238
- adminToken;
239
- adminIpAllowlist;
240
- adminAllowedOrigins;
241
- cwd;
242
- checkCodex;
243
- checkMatrix;
244
- server = null;
245
- address = null;
246
- constructor(config, logger, stateStore, configService, options) {
247
- this.config = config;
248
- this.logger = logger;
249
- this.stateStore = stateStore;
250
- this.configService = configService;
251
- this.host = options.host;
252
- this.port = options.port;
253
- this.adminToken = options.adminToken;
254
- this.adminIpAllowlist = normalizeAllowlist(options.adminIpAllowlist ?? []);
255
- this.adminAllowedOrigins = normalizeOriginAllowlist(options.adminAllowedOrigins ?? []);
256
- this.cwd = options.cwd ?? process.cwd();
257
- this.checkCodex = options.checkCodex ?? defaultCheckCodex;
258
- this.checkMatrix = options.checkMatrix ?? defaultCheckMatrix;
242
+ const legacyEnvPath = import_node_path2.default.resolve(LEGACY_RUNTIME_HOME, ".env");
243
+ if (import_node_fs2.default.existsSync(legacyEnvPath)) {
244
+ return LEGACY_RUNTIME_HOME;
259
245
  }
260
- getAddress() {
261
- return this.address;
246
+ return resolveUserRuntimeHome(env);
247
+ }
248
+ function resolveUserRuntimeHome(env = process.env) {
249
+ const home = env.HOME?.trim() || import_node_os.default.homedir();
250
+ return import_node_path2.default.resolve(home, USER_RUNTIME_HOME_DIR);
251
+ }
252
+
253
+ // src/service-manager.ts
254
+ var SYSTEMD_DIR = "/etc/systemd/system";
255
+ var MAIN_SERVICE_NAME = "codeharbor.service";
256
+ var ADMIN_SERVICE_NAME = "codeharbor-admin.service";
257
+ function resolveDefaultRunUser(env = process.env) {
258
+ const sudoUser = env.SUDO_USER?.trim();
259
+ if (sudoUser) {
260
+ return sudoUser;
262
261
  }
263
- async start() {
264
- if (this.server) {
265
- return;
262
+ const user = env.USER?.trim();
263
+ if (user) {
264
+ return user;
265
+ }
266
+ try {
267
+ return import_node_os2.default.userInfo().username;
268
+ } catch {
269
+ return "root";
270
+ }
271
+ }
272
+ function resolveRuntimeHomeForUser(runUser, env = process.env, explicitRuntimeHome) {
273
+ const configuredRuntimeHome = explicitRuntimeHome?.trim() || env[RUNTIME_HOME_ENV_KEY]?.trim();
274
+ if (configuredRuntimeHome) {
275
+ return import_node_path3.default.resolve(configuredRuntimeHome);
276
+ }
277
+ const userHome = resolveUserHome(runUser);
278
+ if (userHome) {
279
+ return import_node_path3.default.resolve(userHome, USER_RUNTIME_HOME_DIR);
280
+ }
281
+ return import_node_path3.default.resolve(import_node_os2.default.homedir(), USER_RUNTIME_HOME_DIR);
282
+ }
283
+ function buildMainServiceUnit(options) {
284
+ validateUnitOptions(options);
285
+ const runtimeHome2 = import_node_path3.default.resolve(options.runtimeHome);
286
+ return [
287
+ "[Unit]",
288
+ "Description=CodeHarbor main service",
289
+ "After=network-online.target",
290
+ "Wants=network-online.target",
291
+ "",
292
+ "[Service]",
293
+ "Type=simple",
294
+ `User=${options.runUser}`,
295
+ `WorkingDirectory=${runtimeHome2}`,
296
+ `Environment=CODEHARBOR_HOME=${runtimeHome2}`,
297
+ `ExecStart=${import_node_path3.default.resolve(options.nodeBinPath)} ${import_node_path3.default.resolve(options.cliScriptPath)} start`,
298
+ "Restart=always",
299
+ "RestartSec=3",
300
+ "NoNewPrivileges=true",
301
+ "PrivateTmp=true",
302
+ "ProtectSystem=full",
303
+ "ProtectHome=false",
304
+ `ReadWritePaths=${runtimeHome2}`,
305
+ "",
306
+ "[Install]",
307
+ "WantedBy=multi-user.target",
308
+ ""
309
+ ].join("\n");
310
+ }
311
+ function buildAdminServiceUnit(options) {
312
+ validateUnitOptions(options);
313
+ const runtimeHome2 = import_node_path3.default.resolve(options.runtimeHome);
314
+ return [
315
+ "[Unit]",
316
+ "Description=CodeHarbor admin service",
317
+ "After=network-online.target",
318
+ "Wants=network-online.target",
319
+ "",
320
+ "[Service]",
321
+ "Type=simple",
322
+ `User=${options.runUser}`,
323
+ `WorkingDirectory=${runtimeHome2}`,
324
+ `Environment=CODEHARBOR_HOME=${runtimeHome2}`,
325
+ `ExecStart=${import_node_path3.default.resolve(options.nodeBinPath)} ${import_node_path3.default.resolve(options.cliScriptPath)} admin serve`,
326
+ "Restart=always",
327
+ "RestartSec=3",
328
+ "NoNewPrivileges=true",
329
+ "PrivateTmp=true",
330
+ "ProtectSystem=full",
331
+ "ProtectHome=false",
332
+ `ReadWritePaths=${runtimeHome2}`,
333
+ "",
334
+ "[Install]",
335
+ "WantedBy=multi-user.target",
336
+ ""
337
+ ].join("\n");
338
+ }
339
+ function installSystemdServices(options) {
340
+ assertLinuxWithSystemd();
341
+ assertRootPrivileges();
342
+ const output = options.output ?? process.stdout;
343
+ const runUser = options.runUser.trim();
344
+ const runtimeHome2 = import_node_path3.default.resolve(options.runtimeHome);
345
+ validateSimpleValue(runUser, "runUser");
346
+ validateSimpleValue(runtimeHome2, "runtimeHome");
347
+ validateSimpleValue(options.nodeBinPath, "nodeBinPath");
348
+ validateSimpleValue(options.cliScriptPath, "cliScriptPath");
349
+ ensureUserExists(runUser);
350
+ const runGroup = resolveUserGroup(runUser);
351
+ import_node_fs3.default.mkdirSync(runtimeHome2, { recursive: true });
352
+ runCommand("chown", ["-R", `${runUser}:${runGroup}`, runtimeHome2]);
353
+ const mainPath = import_node_path3.default.join(SYSTEMD_DIR, MAIN_SERVICE_NAME);
354
+ const adminPath = import_node_path3.default.join(SYSTEMD_DIR, ADMIN_SERVICE_NAME);
355
+ const unitOptions = {
356
+ runUser,
357
+ runtimeHome: runtimeHome2,
358
+ nodeBinPath: options.nodeBinPath,
359
+ cliScriptPath: options.cliScriptPath
360
+ };
361
+ import_node_fs3.default.writeFileSync(mainPath, buildMainServiceUnit(unitOptions), "utf8");
362
+ if (options.installAdmin) {
363
+ import_node_fs3.default.writeFileSync(adminPath, buildAdminServiceUnit(unitOptions), "utf8");
364
+ }
365
+ runSystemctl(["daemon-reload"]);
366
+ if (options.startNow) {
367
+ runSystemctl(["enable", "--now", MAIN_SERVICE_NAME]);
368
+ if (options.installAdmin) {
369
+ runSystemctl(["enable", "--now", ADMIN_SERVICE_NAME]);
370
+ }
371
+ } else {
372
+ runSystemctl(["enable", MAIN_SERVICE_NAME]);
373
+ if (options.installAdmin) {
374
+ runSystemctl(["enable", ADMIN_SERVICE_NAME]);
266
375
  }
267
- this.server = import_node_http.default.createServer((req, res) => {
268
- void this.handleRequest(req, res);
269
- });
270
- await new Promise((resolve, reject) => {
271
- if (!this.server) {
272
- reject(new Error("admin server is not initialized"));
273
- return;
274
- }
275
- this.server.once("error", reject);
276
- this.server.listen(this.port, this.host, () => {
277
- this.server?.removeListener("error", reject);
278
- const address = this.server?.address();
279
- if (!address || typeof address === "string") {
280
- reject(new Error("failed to resolve admin server address"));
281
- return;
282
- }
283
- this.address = {
284
- host: address.address,
285
- port: address.port
286
- };
287
- resolve();
288
- });
289
- });
290
376
  }
291
- async stop() {
292
- if (!this.server) {
293
- return;
377
+ output.write(`Installed systemd unit: ${mainPath}
378
+ `);
379
+ if (options.installAdmin) {
380
+ output.write(`Installed systemd unit: ${adminPath}
381
+ `);
382
+ }
383
+ output.write("Done. Check status with: systemctl status codeharbor --no-pager\n");
384
+ }
385
+ function uninstallSystemdServices(options) {
386
+ assertLinuxWithSystemd();
387
+ assertRootPrivileges();
388
+ const output = options.output ?? process.stdout;
389
+ const mainPath = import_node_path3.default.join(SYSTEMD_DIR, MAIN_SERVICE_NAME);
390
+ const adminPath = import_node_path3.default.join(SYSTEMD_DIR, ADMIN_SERVICE_NAME);
391
+ stopAndDisableIfPresent(MAIN_SERVICE_NAME);
392
+ if (import_node_fs3.default.existsSync(mainPath)) {
393
+ import_node_fs3.default.unlinkSync(mainPath);
394
+ }
395
+ if (options.removeAdmin) {
396
+ stopAndDisableIfPresent(ADMIN_SERVICE_NAME);
397
+ if (import_node_fs3.default.existsSync(adminPath)) {
398
+ import_node_fs3.default.unlinkSync(adminPath);
294
399
  }
295
- const server = this.server;
296
- this.server = null;
297
- this.address = null;
298
- await new Promise((resolve, reject) => {
299
- server.close((error) => {
300
- if (error) {
301
- reject(error);
302
- return;
303
- }
304
- resolve();
305
- });
306
- });
307
400
  }
308
- async handleRequest(req, res) {
309
- try {
310
- const url = new URL(req.url ?? "/", "http://localhost");
311
- this.setSecurityHeaders(res);
312
- const corsDecision = this.resolveCors(req);
313
- this.setCorsHeaders(res, corsDecision);
314
- if (!this.isClientAllowed(req)) {
315
- this.sendJson(res, 403, {
316
- ok: false,
317
- error: "Forbidden by ADMIN_IP_ALLOWLIST."
318
- });
319
- return;
320
- }
321
- if (url.pathname.startsWith("/api/admin/") && corsDecision.origin && !corsDecision.allowed) {
322
- this.sendJson(res, 403, {
323
- ok: false,
324
- error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
325
- });
326
- return;
327
- }
328
- if (req.method === "OPTIONS") {
329
- if (corsDecision.origin && !corsDecision.allowed) {
330
- this.sendJson(res, 403, {
331
- ok: false,
332
- error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
401
+ runSystemctl(["daemon-reload"]);
402
+ runSystemctlIgnoreFailure(["reset-failed"]);
403
+ output.write(`Removed systemd unit: ${mainPath}
404
+ `);
405
+ if (options.removeAdmin) {
406
+ output.write(`Removed systemd unit: ${adminPath}
407
+ `);
408
+ }
409
+ output.write("Done.\n");
410
+ }
411
+ function restartSystemdServices(options) {
412
+ assertLinuxWithSystemd();
413
+ assertRootPrivileges();
414
+ const output = options.output ?? process.stdout;
415
+ runSystemctl(["restart", MAIN_SERVICE_NAME]);
416
+ output.write(`Restarted service: ${MAIN_SERVICE_NAME}
417
+ `);
418
+ if (options.restartAdmin) {
419
+ runSystemctl(["restart", ADMIN_SERVICE_NAME]);
420
+ output.write(`Restarted service: ${ADMIN_SERVICE_NAME}
421
+ `);
422
+ }
423
+ output.write("Done.\n");
424
+ }
425
+ function resolveUserHome(runUser) {
426
+ try {
427
+ const passwdRaw = import_node_fs3.default.readFileSync("/etc/passwd", "utf8");
428
+ const line = passwdRaw.split(/\r?\n/).find((item) => item.startsWith(`${runUser}:`));
429
+ if (!line) {
430
+ return null;
431
+ }
432
+ const fields = line.split(":");
433
+ return fields[5] ? fields[5].trim() : null;
434
+ } catch {
435
+ return null;
436
+ }
437
+ }
438
+ function validateUnitOptions(options) {
439
+ validateSimpleValue(options.runUser, "runUser");
440
+ validateSimpleValue(options.runtimeHome, "runtimeHome");
441
+ validateSimpleValue(options.nodeBinPath, "nodeBinPath");
442
+ validateSimpleValue(options.cliScriptPath, "cliScriptPath");
443
+ }
444
+ function validateSimpleValue(value, key) {
445
+ if (!value.trim()) {
446
+ throw new Error(`${key} cannot be empty.`);
447
+ }
448
+ if (/[\r\n]/.test(value)) {
449
+ throw new Error(`${key} contains invalid newline characters.`);
450
+ }
451
+ }
452
+ function assertLinuxWithSystemd() {
453
+ if (process.platform !== "linux") {
454
+ throw new Error("Systemd service install only supports Linux.");
455
+ }
456
+ try {
457
+ (0, import_node_child_process.execFileSync)("systemctl", ["--version"], { stdio: "ignore" });
458
+ } catch {
459
+ throw new Error("systemctl is required but not found.");
460
+ }
461
+ }
462
+ function assertRootPrivileges() {
463
+ if (typeof process.getuid !== "function") {
464
+ return;
465
+ }
466
+ if (process.getuid() !== 0) {
467
+ throw new Error("Root privileges are required. Run with sudo.");
468
+ }
469
+ }
470
+ function ensureUserExists(runUser) {
471
+ runCommand("id", ["-u", runUser]);
472
+ }
473
+ function resolveUserGroup(runUser) {
474
+ return runCommand("id", ["-gn", runUser]).trim();
475
+ }
476
+ function runSystemctl(args) {
477
+ runCommand("systemctl", args);
478
+ }
479
+ function stopAndDisableIfPresent(unitName) {
480
+ runSystemctlIgnoreFailure(["disable", "--now", unitName]);
481
+ }
482
+ function runSystemctlIgnoreFailure(args) {
483
+ try {
484
+ runCommand("systemctl", args);
485
+ } catch {
486
+ }
487
+ }
488
+ function runCommand(file, args) {
489
+ try {
490
+ return (0, import_node_child_process.execFileSync)(file, args, {
491
+ encoding: "utf8",
492
+ stdio: ["ignore", "pipe", "pipe"]
493
+ });
494
+ } catch (error) {
495
+ throw new Error(formatCommandError(file, args, error), { cause: error });
496
+ }
497
+ }
498
+ function formatCommandError(file, args, error) {
499
+ const command = `${file} ${args.join(" ")}`.trim();
500
+ if (error && typeof error === "object") {
501
+ const maybeError = error;
502
+ const stderr = bufferToTrimmedString(maybeError.stderr);
503
+ const stdout = bufferToTrimmedString(maybeError.stdout);
504
+ const details = stderr || stdout || maybeError.message || "command failed";
505
+ return `Command failed: ${command}. ${details}`;
506
+ }
507
+ return `Command failed: ${command}. ${String(error)}`;
508
+ }
509
+ function bufferToTrimmedString(value) {
510
+ if (!value) {
511
+ return "";
512
+ }
513
+ const text = typeof value === "string" ? value : value.toString("utf8");
514
+ return text.trim();
515
+ }
516
+
517
+ // src/admin-server.ts
518
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process2.execFile);
519
+ var HttpError = class extends Error {
520
+ statusCode;
521
+ constructor(statusCode, message) {
522
+ super(message);
523
+ this.statusCode = statusCode;
524
+ }
525
+ };
526
+ var AdminServer = class {
527
+ config;
528
+ logger;
529
+ stateStore;
530
+ configService;
531
+ host;
532
+ port;
533
+ adminToken;
534
+ adminIpAllowlist;
535
+ adminAllowedOrigins;
536
+ cwd;
537
+ checkCodex;
538
+ checkMatrix;
539
+ restartServices;
540
+ server = null;
541
+ address = null;
542
+ constructor(config, logger, stateStore, configService, options) {
543
+ this.config = config;
544
+ this.logger = logger;
545
+ this.stateStore = stateStore;
546
+ this.configService = configService;
547
+ this.host = options.host;
548
+ this.port = options.port;
549
+ this.adminToken = options.adminToken;
550
+ this.adminIpAllowlist = normalizeAllowlist(options.adminIpAllowlist ?? []);
551
+ this.adminAllowedOrigins = normalizeOriginAllowlist(options.adminAllowedOrigins ?? []);
552
+ this.cwd = options.cwd ?? process.cwd();
553
+ this.checkCodex = options.checkCodex ?? defaultCheckCodex;
554
+ this.checkMatrix = options.checkMatrix ?? defaultCheckMatrix;
555
+ this.restartServices = options.restartServices ?? defaultRestartServices;
556
+ }
557
+ getAddress() {
558
+ return this.address;
559
+ }
560
+ async start() {
561
+ if (this.server) {
562
+ return;
563
+ }
564
+ this.server = import_node_http.default.createServer((req, res) => {
565
+ void this.handleRequest(req, res);
566
+ });
567
+ await new Promise((resolve, reject) => {
568
+ if (!this.server) {
569
+ reject(new Error("admin server is not initialized"));
570
+ return;
571
+ }
572
+ this.server.once("error", reject);
573
+ this.server.listen(this.port, this.host, () => {
574
+ this.server?.removeListener("error", reject);
575
+ const address = this.server?.address();
576
+ if (!address || typeof address === "string") {
577
+ reject(new Error("failed to resolve admin server address"));
578
+ return;
579
+ }
580
+ this.address = {
581
+ host: address.address,
582
+ port: address.port
583
+ };
584
+ resolve();
585
+ });
586
+ });
587
+ }
588
+ async stop() {
589
+ if (!this.server) {
590
+ return;
591
+ }
592
+ const server = this.server;
593
+ this.server = null;
594
+ this.address = null;
595
+ await new Promise((resolve, reject) => {
596
+ server.close((error) => {
597
+ if (error) {
598
+ reject(error);
599
+ return;
600
+ }
601
+ resolve();
602
+ });
603
+ });
604
+ }
605
+ async handleRequest(req, res) {
606
+ try {
607
+ const url = new URL(req.url ?? "/", "http://localhost");
608
+ this.setSecurityHeaders(res);
609
+ const corsDecision = this.resolveCors(req);
610
+ this.setCorsHeaders(res, corsDecision);
611
+ if (!this.isClientAllowed(req)) {
612
+ this.sendJson(res, 403, {
613
+ ok: false,
614
+ error: "Forbidden by ADMIN_IP_ALLOWLIST."
615
+ });
616
+ return;
617
+ }
618
+ if (url.pathname.startsWith("/api/admin/") && corsDecision.origin && !corsDecision.allowed) {
619
+ this.sendJson(res, 403, {
620
+ ok: false,
621
+ error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
622
+ });
623
+ return;
624
+ }
625
+ if (req.method === "OPTIONS") {
626
+ if (corsDecision.origin && !corsDecision.allowed) {
627
+ this.sendJson(res, 403, {
628
+ ok: false,
629
+ error: "Forbidden by ADMIN_ALLOWED_ORIGINS."
333
630
  });
334
631
  return;
335
632
  }
@@ -419,6 +716,33 @@ var AdminServer = class {
419
716
  });
420
717
  return;
421
718
  }
719
+ if (req.method === "POST" && url.pathname === "/api/admin/service/restart") {
720
+ const body = asObject(await readJsonBody(req), "service restart payload");
721
+ const restartAdmin = normalizeBoolean(body.withAdmin, false);
722
+ const actor = readActor(req);
723
+ try {
724
+ const result = await this.restartServices(restartAdmin);
725
+ this.stateStore.appendConfigRevision(
726
+ actor,
727
+ restartAdmin ? "restart services (main + admin)" : "restart service (main)",
728
+ JSON.stringify({
729
+ type: "service_restart",
730
+ restartAdmin,
731
+ restarted: result.restarted
732
+ })
733
+ );
734
+ this.sendJson(res, 200, {
735
+ ok: true,
736
+ restarted: result.restarted
737
+ });
738
+ return;
739
+ } catch (error) {
740
+ throw new HttpError(
741
+ 500,
742
+ `Service restart failed: ${formatError(error)}. Ensure service has root privileges or run CLI command manually.`
743
+ );
744
+ }
745
+ }
422
746
  this.sendJson(res, 404, {
423
747
  ok: false,
424
748
  error: `Not found: ${req.method ?? "GET"} ${url.pathname}`
@@ -449,7 +773,7 @@ var AdminServer = class {
449
773
  updatedKeys.push("matrixCommandPrefix");
450
774
  }
451
775
  if ("codexWorkdir" in body) {
452
- const workdir = import_node_path2.default.resolve(String(body.codexWorkdir ?? "").trim());
776
+ const workdir = import_node_path4.default.resolve(String(body.codexWorkdir ?? "").trim());
453
777
  ensureDirectory(workdir, "codexWorkdir");
454
778
  this.config.codexWorkdir = workdir;
455
779
  envUpdates.CODEX_WORKDIR = workdir;
@@ -599,6 +923,27 @@ var AdminServer = class {
599
923
  updatedKeys.push("cliCompat.fetchMedia");
600
924
  }
601
925
  }
926
+ if ("agentWorkflow" in body) {
927
+ const workflow = asObject(body.agentWorkflow, "agentWorkflow");
928
+ const currentAgentWorkflow = ensureAgentWorkflowConfig(this.config);
929
+ if ("enabled" in workflow) {
930
+ const value = normalizeBoolean(workflow.enabled, currentAgentWorkflow.enabled);
931
+ currentAgentWorkflow.enabled = value;
932
+ envUpdates.AGENT_WORKFLOW_ENABLED = String(value);
933
+ updatedKeys.push("agentWorkflow.enabled");
934
+ }
935
+ if ("autoRepairMaxRounds" in workflow) {
936
+ const value = normalizePositiveInt(
937
+ workflow.autoRepairMaxRounds,
938
+ currentAgentWorkflow.autoRepairMaxRounds,
939
+ 0,
940
+ 10
941
+ );
942
+ currentAgentWorkflow.autoRepairMaxRounds = value;
943
+ envUpdates.AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS = String(value);
944
+ updatedKeys.push("agentWorkflow.autoRepairMaxRounds");
945
+ }
946
+ }
602
947
  if (updatedKeys.length === 0) {
603
948
  throw new HttpError(400, "No supported global config fields provided.");
604
949
  }
@@ -652,11 +997,11 @@ var AdminServer = class {
652
997
  return this.adminIpAllowlist.includes(normalizedRemote);
653
998
  }
654
999
  persistEnvUpdates(updates) {
655
- const envPath = import_node_path2.default.resolve(this.cwd, ".env");
656
- const examplePath = import_node_path2.default.resolve(this.cwd, ".env.example");
657
- const template = import_node_fs2.default.existsSync(envPath) ? import_node_fs2.default.readFileSync(envPath, "utf8") : import_node_fs2.default.existsSync(examplePath) ? import_node_fs2.default.readFileSync(examplePath, "utf8") : "";
1000
+ const envPath = import_node_path4.default.resolve(this.cwd, ".env");
1001
+ const examplePath = import_node_path4.default.resolve(this.cwd, ".env.example");
1002
+ const template = import_node_fs4.default.existsSync(envPath) ? import_node_fs4.default.readFileSync(envPath, "utf8") : import_node_fs4.default.existsSync(examplePath) ? import_node_fs4.default.readFileSync(examplePath, "utf8") : "";
658
1003
  const next = applyEnvOverrides(template, updates);
659
- import_node_fs2.default.writeFileSync(envPath, next, "utf8");
1004
+ import_node_fs4.default.writeFileSync(envPath, next, "utf8");
660
1005
  }
661
1006
  resolveCors(req) {
662
1007
  const origin = normalizeOriginHeader(req.headers.origin);
@@ -683,7 +1028,7 @@ var AdminServer = class {
683
1028
  }
684
1029
  res.setHeader("Access-Control-Allow-Origin", corsDecision.origin);
685
1030
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Admin-Token, X-Admin-Actor");
686
- res.setHeader("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS");
1031
+ res.setHeader("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS");
687
1032
  appendVaryHeader(res, "Origin");
688
1033
  }
689
1034
  setSecurityHeaders(res) {
@@ -719,9 +1064,23 @@ function buildGlobalConfigSnapshot(config) {
719
1064
  matrixProgressMinIntervalMs: config.matrixProgressMinIntervalMs,
720
1065
  matrixTypingTimeoutMs: config.matrixTypingTimeoutMs,
721
1066
  sessionActiveWindowMinutes: config.sessionActiveWindowMinutes,
722
- cliCompat: { ...config.cliCompat }
1067
+ cliCompat: { ...config.cliCompat },
1068
+ agentWorkflow: { ...ensureAgentWorkflowConfig(config) }
723
1069
  };
724
1070
  }
1071
+ function ensureAgentWorkflowConfig(config) {
1072
+ const mutable = config;
1073
+ const existing = mutable.agentWorkflow;
1074
+ if (existing && typeof existing.enabled === "boolean" && Number.isFinite(existing.autoRepairMaxRounds)) {
1075
+ return existing;
1076
+ }
1077
+ const fallback = {
1078
+ enabled: false,
1079
+ autoRepairMaxRounds: 1
1080
+ };
1081
+ mutable.agentWorkflow = fallback;
1082
+ return fallback;
1083
+ }
725
1084
  function formatAuditEntry(entry) {
726
1085
  return {
727
1086
  id: entry.id,
@@ -740,6 +1099,22 @@ function parseJsonLoose(raw) {
740
1099
  return raw;
741
1100
  }
742
1101
  }
1102
+ async function defaultRestartServices(restartAdmin) {
1103
+ const outputChunks = [];
1104
+ const output = {
1105
+ write: (chunk) => {
1106
+ outputChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
1107
+ return true;
1108
+ }
1109
+ };
1110
+ restartSystemdServices({
1111
+ restartAdmin,
1112
+ output
1113
+ });
1114
+ return {
1115
+ restarted: restartAdmin ? ["codeharbor", "codeharbor-admin"] : ["codeharbor"]
1116
+ };
1117
+ }
743
1118
  function isUiPath(pathname) {
744
1119
  return pathname === "/" || pathname === "/index.html" || pathname === "/settings/global" || pathname === "/settings/rooms" || pathname === "/health" || pathname === "/audit";
745
1120
  }
@@ -888,7 +1263,7 @@ function normalizeNonNegativeInt(value, fallback) {
888
1263
  return normalizePositiveInt(value, fallback, 0, Number.MAX_SAFE_INTEGER);
889
1264
  }
890
1265
  function ensureDirectory(targetPath, fieldName) {
891
- if (!import_node_fs2.default.existsSync(targetPath) || !import_node_fs2.default.statSync(targetPath).isDirectory()) {
1266
+ if (!import_node_fs4.default.existsSync(targetPath) || !import_node_fs4.default.statSync(targetPath).isDirectory()) {
892
1267
  throw new HttpError(400, `${fieldName} must be an existing directory: ${targetPath}`);
893
1268
  }
894
1269
  }
@@ -1221,10 +1596,17 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1221
1596
  <input id="global-cli-throttle" type="number" min="0" />
1222
1597
  </label>
1223
1598
  <label class="checkbox"><input id="global-cli-fetch-media" type="checkbox" /><span>Fetch media attachments</span></label>
1599
+ <label class="checkbox"><input id="global-agent-enabled" type="checkbox" /><span>Enable multi-agent workflow</span></label>
1600
+ <label class="field">
1601
+ <span class="field-label">Workflow auto-repair rounds</span>
1602
+ <input id="global-agent-repair-rounds" type="number" min="0" max="10" />
1603
+ </label>
1224
1604
  </div>
1225
1605
  <div class="actions">
1226
1606
  <button id="global-save-btn" type="button">Save Global Config</button>
1227
1607
  <button id="global-reload-btn" type="button" class="secondary">Reload</button>
1608
+ <button id="global-restart-main-btn" type="button" class="secondary">Restart Main Service</button>
1609
+ <button id="global-restart-all-btn" type="button" class="secondary">Restart Main + Admin</button>
1228
1610
  </div>
1229
1611
  <p class="muted">Saving global config updates .env and requires restart to fully take effect.</p>
1230
1612
  </section>
@@ -1367,6 +1749,12 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1367
1749
 
1368
1750
  document.getElementById("global-save-btn").addEventListener("click", saveGlobal);
1369
1751
  document.getElementById("global-reload-btn").addEventListener("click", loadGlobal);
1752
+ document.getElementById("global-restart-main-btn").addEventListener("click", function () {
1753
+ restartManagedServices(false);
1754
+ });
1755
+ document.getElementById("global-restart-all-btn").addEventListener("click", function () {
1756
+ restartManagedServices(true);
1757
+ });
1370
1758
  document.getElementById("room-load-btn").addEventListener("click", loadRoom);
1371
1759
  document.getElementById("room-save-btn").addEventListener("click", saveRoom);
1372
1760
  document.getElementById("room-delete-btn").addEventListener("click", deleteRoom);
@@ -1488,6 +1876,7 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1488
1876
  var rateLimiter = data.rateLimiter || {};
1489
1877
  var trigger = data.defaultGroupTriggerPolicy || {};
1490
1878
  var cliCompat = data.cliCompat || {};
1879
+ var agentWorkflow = data.agentWorkflow || {};
1491
1880
 
1492
1881
  document.getElementById("global-matrix-prefix").value = data.matrixCommandPrefix || "";
1493
1882
  document.getElementById("global-workdir").value = data.codexWorkdir || "";
@@ -1513,6 +1902,10 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1513
1902
  document.getElementById("global-cli-disable-split").checked = Boolean(cliCompat.disableReplyChunkSplit);
1514
1903
  document.getElementById("global-cli-throttle").value = String(cliCompat.progressThrottleMs || 0);
1515
1904
  document.getElementById("global-cli-fetch-media").checked = Boolean(cliCompat.fetchMedia);
1905
+ document.getElementById("global-agent-enabled").checked = Boolean(agentWorkflow.enabled);
1906
+ document.getElementById("global-agent-repair-rounds").value = String(
1907
+ typeof agentWorkflow.autoRepairMaxRounds === "number" ? agentWorkflow.autoRepairMaxRounds : 1
1908
+ );
1516
1909
 
1517
1910
  showNotice("ok", "Global config loaded.");
1518
1911
  } catch (error) {
@@ -1550,6 +1943,10 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1550
1943
  disableReplyChunkSplit: asBool("global-cli-disable-split"),
1551
1944
  progressThrottleMs: asNumber("global-cli-throttle", 300),
1552
1945
  fetchMedia: asBool("global-cli-fetch-media")
1946
+ },
1947
+ agentWorkflow: {
1948
+ enabled: asBool("global-agent-enabled"),
1949
+ autoRepairMaxRounds: asNumber("global-agent-repair-rounds", 1)
1553
1950
  }
1554
1951
  };
1555
1952
  var response = await apiRequest("/api/admin/config/global", "PUT", body);
@@ -1561,11 +1958,24 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
1561
1958
  }
1562
1959
  }
1563
1960
 
1564
- async function refreshRoomList() {
1961
+ async function restartManagedServices(withAdmin) {
1565
1962
  try {
1566
- var response = await apiRequest("/api/admin/config/rooms", "GET");
1567
- var items = Array.isArray(response.data) ? response.data : [];
1568
- roomListBody.innerHTML = "";
1963
+ var response = await apiRequest("/api/admin/service/restart", "POST", {
1964
+ withAdmin: Boolean(withAdmin)
1965
+ });
1966
+ var restarted = Array.isArray(response.restarted) ? response.restarted.join(", ") : "codeharbor";
1967
+ var suffix = withAdmin ? " Admin page may reconnect during restart." : "";
1968
+ showNotice("warn", "Restart requested: " + restarted + "." + suffix);
1969
+ } catch (error) {
1970
+ showNotice("error", "Failed to restart service(s): " + error.message);
1971
+ }
1972
+ }
1973
+
1974
+ async function refreshRoomList() {
1975
+ try {
1976
+ var response = await apiRequest("/api/admin/config/rooms", "GET");
1977
+ var items = Array.isArray(response.data) ? response.data : [];
1978
+ roomListBody.innerHTML = "";
1569
1979
  if (items.length === 0) {
1570
1980
  renderEmptyRow(roomListBody, 4, "No room settings.");
1571
1981
  return;
@@ -1786,8 +2196,8 @@ async function defaultCheckMatrix(homeserver, timeoutMs) {
1786
2196
 
1787
2197
  // src/channels/matrix-channel.ts
1788
2198
  var import_promises2 = __toESM(require("fs/promises"));
1789
- var import_node_os = __toESM(require("os"));
1790
- var import_node_path3 = __toESM(require("path"));
2199
+ var import_node_os3 = __toESM(require("os"));
2200
+ var import_node_path5 = __toESM(require("path"));
1791
2201
  var import_matrix_js_sdk = require("matrix-js-sdk");
1792
2202
 
1793
2203
  // src/utils/message.ts
@@ -2210,10 +2620,10 @@ var MatrixChannel = class {
2210
2620
  }
2211
2621
  const bytes = Buffer.from(await response.arrayBuffer());
2212
2622
  const extension = resolveFileExtension(fileName, mimeType);
2213
- const directory = import_node_path3.default.join(import_node_os.default.tmpdir(), "codeharbor-media");
2623
+ const directory = import_node_path5.default.join(import_node_os3.default.tmpdir(), "codeharbor-media");
2214
2624
  await import_promises2.default.mkdir(directory, { recursive: true });
2215
2625
  const safeEventId = sanitizeFilename(eventId);
2216
- const targetPath = import_node_path3.default.join(directory, `${safeEventId}-${index}${extension}`);
2626
+ const targetPath = import_node_path5.default.join(directory, `${safeEventId}-${index}${extension}`);
2217
2627
  await import_promises2.default.writeFile(targetPath, bytes);
2218
2628
  return targetPath;
2219
2629
  }
@@ -2298,7 +2708,7 @@ function sanitizeFilename(value) {
2298
2708
  return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80);
2299
2709
  }
2300
2710
  function resolveFileExtension(fileName, mimeType) {
2301
- const ext = import_node_path3.default.extname(fileName).trim();
2711
+ const ext = import_node_path5.default.extname(fileName).trim();
2302
2712
  if (ext) {
2303
2713
  return ext;
2304
2714
  }
@@ -2315,14 +2725,14 @@ function resolveFileExtension(fileName, mimeType) {
2315
2725
  }
2316
2726
 
2317
2727
  // src/config-service.ts
2318
- var import_node_fs3 = __toESM(require("fs"));
2319
- var import_node_path4 = __toESM(require("path"));
2728
+ var import_node_fs5 = __toESM(require("fs"));
2729
+ var import_node_path6 = __toESM(require("path"));
2320
2730
  var ConfigService = class {
2321
2731
  stateStore;
2322
2732
  defaultWorkdir;
2323
2733
  constructor(stateStore, defaultWorkdir) {
2324
2734
  this.stateStore = stateStore;
2325
- this.defaultWorkdir = import_node_path4.default.resolve(defaultWorkdir);
2735
+ this.defaultWorkdir = import_node_path6.default.resolve(defaultWorkdir);
2326
2736
  }
2327
2737
  resolveRoomConfig(roomId, fallbackPolicy) {
2328
2738
  const room = this.stateStore.getRoomSettings(roomId);
@@ -2393,8 +2803,8 @@ function normalizeRoomSettingsInput(input) {
2393
2803
  if (!roomId) {
2394
2804
  throw new Error("roomId is required.");
2395
2805
  }
2396
- const workdir = import_node_path4.default.resolve(input.workdir);
2397
- if (!import_node_fs3.default.existsSync(workdir) || !import_node_fs3.default.statSync(workdir).isDirectory()) {
2806
+ const workdir = import_node_path6.default.resolve(input.workdir);
2807
+ if (!import_node_fs5.default.existsSync(workdir) || !import_node_fs5.default.statSync(workdir).isDirectory()) {
2398
2808
  throw new Error(`workdir does not exist or is not a directory: ${workdir}`);
2399
2809
  }
2400
2810
  return {
@@ -2409,7 +2819,7 @@ function normalizeRoomSettingsInput(input) {
2409
2819
  }
2410
2820
 
2411
2821
  // src/executor/codex-executor.ts
2412
- var import_node_child_process2 = require("child_process");
2822
+ var import_node_child_process3 = require("child_process");
2413
2823
  var import_node_readline = __toESM(require("readline"));
2414
2824
  var CodexExecutionCancelledError = class extends Error {
2415
2825
  constructor(message = "codex execution cancelled") {
@@ -2427,7 +2837,7 @@ var CodexExecutor = class {
2427
2837
  }
2428
2838
  startExecution(prompt, sessionId, onProgress, startOptions) {
2429
2839
  const args = buildCodexArgs(prompt, sessionId, this.options, startOptions);
2430
- const child = (0, import_node_child_process2.spawn)(this.options.bin, args, {
2840
+ const child = (0, import_node_child_process3.spawn)(this.options.bin, args, {
2431
2841
  cwd: startOptions?.workdir ?? this.options.workdir,
2432
2842
  env: {
2433
2843
  ...process.env,
@@ -2663,20 +3073,20 @@ var import_async_mutex = require("async-mutex");
2663
3073
  var import_promises3 = __toESM(require("fs/promises"));
2664
3074
 
2665
3075
  // src/compat/cli-compat-recorder.ts
2666
- var import_node_fs4 = __toESM(require("fs"));
2667
- var import_node_path5 = __toESM(require("path"));
3076
+ var import_node_fs6 = __toESM(require("fs"));
3077
+ var import_node_path7 = __toESM(require("path"));
2668
3078
  var CliCompatRecorder = class {
2669
3079
  filePath;
2670
3080
  chain = Promise.resolve();
2671
3081
  constructor(filePath) {
2672
- this.filePath = import_node_path5.default.resolve(filePath);
2673
- import_node_fs4.default.mkdirSync(import_node_path5.default.dirname(this.filePath), { recursive: true });
3082
+ this.filePath = import_node_path7.default.resolve(filePath);
3083
+ import_node_fs6.default.mkdirSync(import_node_path7.default.dirname(this.filePath), { recursive: true });
2674
3084
  }
2675
3085
  append(entry) {
2676
3086
  const payload = `${JSON.stringify(entry)}
2677
3087
  `;
2678
3088
  this.chain = this.chain.then(async () => {
2679
- await import_node_fs4.default.promises.appendFile(this.filePath, payload, "utf8");
3089
+ await import_node_fs6.default.promises.appendFile(this.filePath, payload, "utf8");
2680
3090
  });
2681
3091
  return this.chain;
2682
3092
  }
@@ -2880,6 +3290,273 @@ function computeRetryAfter(timestamps, windowMs, now) {
2880
3290
  return Math.max(0, oldest + windowMs - now);
2881
3291
  }
2882
3292
 
3293
+ // src/workflow/multi-agent-workflow.ts
3294
+ var MultiAgentWorkflowRunner = class {
3295
+ executor;
3296
+ logger;
3297
+ config;
3298
+ constructor(executor, logger, config) {
3299
+ this.executor = executor;
3300
+ this.logger = logger;
3301
+ this.config = config;
3302
+ }
3303
+ isEnabled() {
3304
+ return this.config.enabled;
3305
+ }
3306
+ async run(input) {
3307
+ const startedAt = Date.now();
3308
+ const objective = input.objective.trim();
3309
+ if (!objective) {
3310
+ throw new Error("workflow objective cannot be empty.");
3311
+ }
3312
+ const maxRepairRounds = Math.max(0, this.config.autoRepairMaxRounds);
3313
+ let plannerSessionId = null;
3314
+ let executorSessionId = null;
3315
+ let reviewerSessionId = null;
3316
+ let activeHandle = null;
3317
+ let cancelled = false;
3318
+ input.onRegisterCancel?.(() => {
3319
+ cancelled = true;
3320
+ activeHandle?.cancel();
3321
+ });
3322
+ await emitProgress(input, {
3323
+ stage: "planner",
3324
+ round: 0,
3325
+ message: "Planner \u6B63\u5728\u751F\u6210\u6267\u884C\u8BA1\u5212"
3326
+ });
3327
+ const planResult = await this.executeRole(
3328
+ "planner",
3329
+ buildPlannerPrompt(objective),
3330
+ plannerSessionId,
3331
+ input.workdir,
3332
+ () => cancelled,
3333
+ (handle) => {
3334
+ activeHandle = handle;
3335
+ }
3336
+ );
3337
+ plannerSessionId = planResult.sessionId;
3338
+ const plan = planResult.reply;
3339
+ await emitProgress(input, {
3340
+ stage: "executor",
3341
+ round: 0,
3342
+ message: "Executor \u6B63\u5728\u6839\u636E\u8BA1\u5212\u6267\u884C\u4EFB\u52A1"
3343
+ });
3344
+ let outputResult = await this.executeRole(
3345
+ "executor",
3346
+ buildExecutorPrompt(objective, plan),
3347
+ executorSessionId,
3348
+ input.workdir,
3349
+ () => cancelled,
3350
+ (handle) => {
3351
+ activeHandle = handle;
3352
+ }
3353
+ );
3354
+ executorSessionId = outputResult.sessionId;
3355
+ let finalReviewReply = "";
3356
+ let approved = false;
3357
+ let repairRounds = 0;
3358
+ for (let attempt = 0; attempt <= maxRepairRounds; attempt += 1) {
3359
+ await emitProgress(input, {
3360
+ stage: "reviewer",
3361
+ round: attempt,
3362
+ message: `Reviewer \u6B63\u5728\u8FDB\u884C\u8D28\u91CF\u5BA1\u67E5\uFF08round ${attempt + 1}\uFF09`
3363
+ });
3364
+ const reviewResult = await this.executeRole(
3365
+ "reviewer",
3366
+ buildReviewerPrompt(objective, plan, outputResult.reply),
3367
+ reviewerSessionId,
3368
+ input.workdir,
3369
+ () => cancelled,
3370
+ (handle) => {
3371
+ activeHandle = handle;
3372
+ }
3373
+ );
3374
+ reviewerSessionId = reviewResult.sessionId;
3375
+ finalReviewReply = reviewResult.reply;
3376
+ const verdict = parseReviewerVerdict(finalReviewReply);
3377
+ if (verdict.approved) {
3378
+ approved = true;
3379
+ break;
3380
+ }
3381
+ if (attempt >= maxRepairRounds) {
3382
+ break;
3383
+ }
3384
+ repairRounds = attempt + 1;
3385
+ await emitProgress(input, {
3386
+ stage: "repair",
3387
+ round: repairRounds,
3388
+ message: `Executor \u6B63\u5728\u6309 Reviewer \u53CD\u9988\u8FDB\u884C\u4FEE\u590D\uFF08round ${repairRounds}\uFF09`
3389
+ });
3390
+ outputResult = await this.executeRole(
3391
+ "executor",
3392
+ buildRepairPrompt(objective, plan, outputResult.reply, verdict.feedback, repairRounds),
3393
+ executorSessionId,
3394
+ input.workdir,
3395
+ () => cancelled,
3396
+ (handle) => {
3397
+ activeHandle = handle;
3398
+ }
3399
+ );
3400
+ executorSessionId = outputResult.sessionId;
3401
+ }
3402
+ const durationMs = Date.now() - startedAt;
3403
+ this.logger.info("Multi-agent workflow finished", {
3404
+ objective,
3405
+ approved,
3406
+ repairRounds,
3407
+ durationMs
3408
+ });
3409
+ return {
3410
+ objective,
3411
+ plan,
3412
+ output: outputResult.reply,
3413
+ review: finalReviewReply,
3414
+ approved,
3415
+ repairRounds,
3416
+ durationMs
3417
+ };
3418
+ }
3419
+ async executeRole(role, prompt, sessionId, workdir, getCancelled, setActiveHandle) {
3420
+ if (getCancelled()) {
3421
+ throw new CodexExecutionCancelledError("workflow cancelled");
3422
+ }
3423
+ const handle = this.executor.startExecution(prompt, sessionId, void 0, { workdir });
3424
+ setActiveHandle(handle);
3425
+ try {
3426
+ const result = await handle.result;
3427
+ return {
3428
+ sessionId: result.sessionId,
3429
+ reply: result.reply
3430
+ };
3431
+ } finally {
3432
+ setActiveHandle(null);
3433
+ }
3434
+ }
3435
+ };
3436
+ async function emitProgress(input, event) {
3437
+ if (!input.onProgress) {
3438
+ return;
3439
+ }
3440
+ await input.onProgress(event);
3441
+ }
3442
+ function buildPlannerPrompt(objective) {
3443
+ return [
3444
+ "[role:planner]",
3445
+ "\u4F60\u662F\u8F6F\u4EF6\u4EA4\u4ED8\u89C4\u5212\u4EE3\u7406\u3002\u8BF7\u57FA\u4E8E\u76EE\u6807\u7ED9\u51FA\u53EF\u6267\u884C\u8BA1\u5212\u3002",
3446
+ "\u8F93\u51FA\u8981\u6C42\uFF1A",
3447
+ "1. \u4EFB\u52A1\u62C6\u89E3\uFF083-7 \u6B65\uFF09",
3448
+ "2. \u6BCF\u6B65\u8F93\u5165/\u8F93\u51FA",
3449
+ "3. \u98CE\u9669\u4E0E\u56DE\u9000\u65B9\u6848",
3450
+ "",
3451
+ `\u76EE\u6807\uFF1A${objective}`
3452
+ ].join("\n");
3453
+ }
3454
+ function buildExecutorPrompt(objective, plan) {
3455
+ return [
3456
+ "[role:executor]",
3457
+ "\u4F60\u662F\u8F6F\u4EF6\u6267\u884C\u4EE3\u7406\u3002\u8BF7\u6839\u636E\u8BA1\u5212\u5B8C\u6210\u4EA4\u4ED8\u5185\u5BB9\u3002",
3458
+ "\u8F93\u51FA\u8981\u6C42\uFF1A",
3459
+ "1. \u76F4\u63A5\u7ED9\u51FA\u6700\u7EC8\u53EF\u6267\u884C\u7ED3\u679C",
3460
+ "2. \u8BF4\u660E\u4F60\u5B9E\u9645\u5B8C\u6210\u4E86\u54EA\u4E9B\u6B65\u9AA4",
3461
+ "3. \u5982\u679C\u9700\u8981\u6587\u4EF6\u843D\u76D8\uFF0C\u8BF7\u7ED9\u51FA\u7EDD\u5BF9\u8DEF\u5F84",
3462
+ "",
3463
+ `\u76EE\u6807\uFF1A${objective}`,
3464
+ "",
3465
+ "[planner_plan]",
3466
+ plan,
3467
+ "[/planner_plan]"
3468
+ ].join("\n");
3469
+ }
3470
+ function buildReviewerPrompt(objective, plan, output) {
3471
+ return [
3472
+ "[role:reviewer]",
3473
+ "\u4F60\u662F\u8D28\u91CF\u5BA1\u67E5\u4EE3\u7406\u3002\u8BF7\u4E25\u683C\u5BA1\u67E5\u6267\u884C\u7ED3\u679C\u662F\u5426\u8FBE\u6210\u76EE\u6807\u3002",
3474
+ "\u8F93\u51FA\u683C\u5F0F\u5FC5\u987B\u5305\u542B\u4EE5\u4E0B\u5B57\u6BB5\uFF1A",
3475
+ "VERDICT: APPROVED \u6216 REJECTED",
3476
+ "SUMMARY: \u4E00\u53E5\u8BDD\u603B\u7ED3",
3477
+ "ISSUES:",
3478
+ "- issue 1",
3479
+ "- issue 2",
3480
+ "SUGGESTIONS:",
3481
+ "- suggestion 1",
3482
+ "- suggestion 2",
3483
+ "",
3484
+ `\u76EE\u6807\uFF1A${objective}`,
3485
+ "",
3486
+ "[planner_plan]",
3487
+ plan,
3488
+ "[/planner_plan]",
3489
+ "",
3490
+ "[executor_output]",
3491
+ output,
3492
+ "[/executor_output]"
3493
+ ].join("\n");
3494
+ }
3495
+ function buildRepairPrompt(objective, plan, previousOutput, reviewerFeedback, round) {
3496
+ return [
3497
+ "[role:executor]",
3498
+ `\u4F60\u662F\u8F6F\u4EF6\u6267\u884C\u4EE3\u7406\u3002\u8BF7\u6839\u636E\u5BA1\u67E5\u53CD\u9988\u8FDB\u884C\u7B2C ${round} \u8F6E\u4FEE\u590D\u5E76\u8F93\u51FA\u6700\u7EC8\u7248\u672C\u3002`,
3499
+ "\u8981\u6C42\uFF1A\u4FDD\u6301\u6B63\u786E\u5185\u5BB9\uFF0C\u4FEE\u590D\u95EE\u9898\uFF0C\u4E0D\u8981\u4E22\u5931\u5DF2\u5B8C\u6210\u90E8\u5206\u3002",
3500
+ "",
3501
+ `\u76EE\u6807\uFF1A${objective}`,
3502
+ "",
3503
+ "[planner_plan]",
3504
+ plan,
3505
+ "[/planner_plan]",
3506
+ "",
3507
+ "[previous_output]",
3508
+ previousOutput,
3509
+ "[/previous_output]",
3510
+ "",
3511
+ "[reviewer_feedback]",
3512
+ reviewerFeedback,
3513
+ "[/reviewer_feedback]"
3514
+ ].join("\n");
3515
+ }
3516
+ function parseReviewerVerdict(review) {
3517
+ const approved = /\bVERDICT\s*:\s*APPROVED\b/i.test(review);
3518
+ const rejected = /\bVERDICT\s*:\s*REJECTED\b/i.test(review);
3519
+ if (approved) {
3520
+ return { approved: true, feedback: review };
3521
+ }
3522
+ if (rejected) {
3523
+ return { approved: false, feedback: review };
3524
+ }
3525
+ return {
3526
+ approved: false,
3527
+ feedback: review.trim() || "Reviewer \u672A\u8FD4\u56DE\u89C4\u8303 verdict\uFF0C\u9ED8\u8BA4\u6309 REJECTED \u5904\u7406\u3002"
3528
+ };
3529
+ }
3530
+ function parseWorkflowCommand(text) {
3531
+ const normalized = text.trim();
3532
+ if (!normalized.startsWith("/agents")) {
3533
+ return null;
3534
+ }
3535
+ const parts = normalized.split(/\s+/);
3536
+ if (parts.length === 1 || parts[1]?.toLowerCase() === "status") {
3537
+ return { kind: "status" };
3538
+ }
3539
+ if (parts[1]?.toLowerCase() !== "run") {
3540
+ return null;
3541
+ }
3542
+ const objective = normalized.replace(/^\/agents\s+run\s*/i, "").trim();
3543
+ return {
3544
+ kind: "run",
3545
+ objective
3546
+ };
3547
+ }
3548
+ function createIdleWorkflowSnapshot() {
3549
+ return {
3550
+ state: "idle",
3551
+ startedAt: null,
3552
+ endedAt: null,
3553
+ objective: null,
3554
+ approved: null,
3555
+ repairRounds: 0,
3556
+ error: null
3557
+ };
3558
+ }
3559
+
2883
3560
  // src/orchestrator.ts
2884
3561
  var RequestMetrics = class {
2885
3562
  total = 0;
@@ -2965,6 +3642,8 @@ var Orchestrator = class {
2965
3642
  rateLimiter;
2966
3643
  cliCompat;
2967
3644
  cliCompatRecorder;
3645
+ workflowRunner;
3646
+ workflowSnapshots = /* @__PURE__ */ new Map();
2968
3647
  metrics = new RequestMetrics();
2969
3648
  lastLockPruneAt = 0;
2970
3649
  constructor(channel, executor, stateStore, logger, options) {
@@ -3011,6 +3690,10 @@ var Orchestrator = class {
3011
3690
  maxConcurrentPerRoom: 4
3012
3691
  }
3013
3692
  );
3693
+ this.workflowRunner = new MultiAgentWorkflowRunner(this.executor, this.logger, {
3694
+ enabled: options?.multiAgentWorkflow?.enabled ?? false,
3695
+ autoRepairMaxRounds: options?.multiAgentWorkflow?.autoRepairMaxRounds ?? 1
3696
+ });
3014
3697
  this.sessionRuntime = new CodexSessionRuntime(this.executor);
3015
3698
  }
3016
3699
  async handleMessage(message) {
@@ -3054,6 +3737,12 @@ var Orchestrator = class {
3054
3737
  this.stateStore.markEventProcessed(sessionKey, message.eventId);
3055
3738
  return;
3056
3739
  }
3740
+ const workflowCommand = this.workflowRunner.isEnabled() ? parseWorkflowCommand(route.prompt) : null;
3741
+ if (workflowCommand?.kind === "status") {
3742
+ await this.handleWorkflowStatusCommand(sessionKey, message);
3743
+ this.stateStore.markEventProcessed(sessionKey, message.eventId);
3744
+ return;
3745
+ }
3057
3746
  const rateDecision = this.rateLimiter.tryAcquire({
3058
3747
  userId: message.senderId,
3059
3748
  roomId: message.conversationId
@@ -3071,6 +3760,37 @@ var Orchestrator = class {
3071
3760
  });
3072
3761
  return;
3073
3762
  }
3763
+ if (workflowCommand?.kind === "run") {
3764
+ const executionStartedAt = Date.now();
3765
+ let sendDurationMs2 = 0;
3766
+ this.stateStore.activateSession(sessionKey, this.sessionActiveWindowMs);
3767
+ try {
3768
+ const sendStartedAt = Date.now();
3769
+ await this.handleWorkflowRunCommand(
3770
+ workflowCommand.objective,
3771
+ sessionKey,
3772
+ message,
3773
+ requestId,
3774
+ roomConfig.workdir
3775
+ );
3776
+ sendDurationMs2 += Date.now() - sendStartedAt;
3777
+ this.stateStore.markEventProcessed(sessionKey, message.eventId);
3778
+ this.metrics.record("success", queueWaitMs, Date.now() - executionStartedAt, sendDurationMs2);
3779
+ } catch (error) {
3780
+ sendDurationMs2 += await this.sendWorkflowFailure(message.conversationId, error);
3781
+ this.stateStore.commitExecutionHandled(sessionKey, message.eventId);
3782
+ const status = classifyExecutionOutcome(error);
3783
+ this.metrics.record(status, queueWaitMs, Date.now() - executionStartedAt, sendDurationMs2);
3784
+ this.logger.error("Workflow request failed", {
3785
+ requestId,
3786
+ sessionKey,
3787
+ error: formatError2(error)
3788
+ });
3789
+ } finally {
3790
+ rateDecision.release?.();
3791
+ }
3792
+ return;
3793
+ }
3074
3794
  this.stateStore.activateSession(sessionKey, this.sessionActiveWindowMs);
3075
3795
  const previousCodexSessionId = this.stateStore.getCodexSessionId(sessionKey);
3076
3796
  const executionPrompt = this.buildExecutionPrompt(route.prompt, message);
@@ -3291,6 +4011,7 @@ var Orchestrator = class {
3291
4011
  const metrics = this.metrics.snapshot(this.runningExecutions.size);
3292
4012
  const limiter = this.rateLimiter.snapshot();
3293
4013
  const runtime = this.sessionRuntime.getRuntimeStats();
4014
+ const workflow = this.workflowSnapshots.get(sessionKey) ?? createIdleWorkflowSnapshot();
3294
4015
  await this.channel.sendNotice(
3295
4016
  message.conversationId,
3296
4017
  `[CodeHarbor] \u5F53\u524D\u72B6\u6001
@@ -3303,9 +4024,122 @@ var Orchestrator = class {
3303
4024
  - \u6307\u6807: total=${metrics.total}, success=${metrics.success}, failed=${metrics.failed}, timeout=${metrics.timeout}, cancelled=${metrics.cancelled}, rate_limited=${metrics.rateLimited}
3304
4025
  - \u5E73\u5747\u8017\u65F6: queue=${metrics.avgQueueMs}ms, exec=${metrics.avgExecMs}ms, send=${metrics.avgSendMs}ms
3305
4026
  - \u9650\u6D41\u5E76\u53D1: global=${limiter.activeGlobal}, users=${limiter.activeUsers}, rooms=${limiter.activeRooms}
3306
- - CLI runtime: workers=${runtime.workerCount}, running=${runtime.runningCount}, compat_mode=${this.cliCompat.enabled ? "on" : "off"}`
4027
+ - CLI runtime: workers=${runtime.workerCount}, running=${runtime.runningCount}, compat_mode=${this.cliCompat.enabled ? "on" : "off"}
4028
+ - Multi-Agent workflow: enabled=${this.workflowRunner.isEnabled() ? "on" : "off"}, state=${workflow.state}`
4029
+ );
4030
+ }
4031
+ async handleWorkflowStatusCommand(sessionKey, message) {
4032
+ const snapshot = this.workflowSnapshots.get(sessionKey) ?? createIdleWorkflowSnapshot();
4033
+ await this.channel.sendNotice(
4034
+ message.conversationId,
4035
+ `[CodeHarbor] Multi-Agent \u5DE5\u4F5C\u6D41\u72B6\u6001
4036
+ - state: ${snapshot.state}
4037
+ - startedAt: ${snapshot.startedAt ?? "N/A"}
4038
+ - endedAt: ${snapshot.endedAt ?? "N/A"}
4039
+ - objective: ${snapshot.objective ?? "N/A"}
4040
+ - approved: ${snapshot.approved === null ? "N/A" : snapshot.approved ? "yes" : "no"}
4041
+ - repairRounds: ${snapshot.repairRounds}
4042
+ - error: ${snapshot.error ?? "N/A"}`
3307
4043
  );
3308
4044
  }
4045
+ async handleWorkflowRunCommand(objective, sessionKey, message, requestId, workdir) {
4046
+ const normalizedObjective = objective.trim();
4047
+ if (!normalizedObjective) {
4048
+ await this.channel.sendNotice(message.conversationId, "[CodeHarbor] /agents run \u9700\u8981\u63D0\u4F9B\u4EFB\u52A1\u76EE\u6807\u3002");
4049
+ return;
4050
+ }
4051
+ const requestStartedAt = Date.now();
4052
+ let progressNoticeEventId = null;
4053
+ const progressCtx = {
4054
+ conversationId: message.conversationId,
4055
+ isDirectMessage: message.isDirectMessage,
4056
+ getProgressNoticeEventId: () => progressNoticeEventId,
4057
+ setProgressNoticeEventId: (next) => {
4058
+ progressNoticeEventId = next;
4059
+ }
4060
+ };
4061
+ const startedAtIso = (/* @__PURE__ */ new Date()).toISOString();
4062
+ this.workflowSnapshots.set(sessionKey, {
4063
+ state: "running",
4064
+ startedAt: startedAtIso,
4065
+ endedAt: null,
4066
+ objective: normalizedObjective,
4067
+ approved: null,
4068
+ repairRounds: 0,
4069
+ error: null
4070
+ });
4071
+ const stopTyping = this.startTypingHeartbeat(message.conversationId);
4072
+ let cancelWorkflow = () => {
4073
+ };
4074
+ let cancelRequested = false;
4075
+ this.runningExecutions.set(sessionKey, {
4076
+ requestId,
4077
+ startedAt: requestStartedAt,
4078
+ cancel: () => {
4079
+ cancelRequested = true;
4080
+ cancelWorkflow();
4081
+ }
4082
+ });
4083
+ await this.sendProgressUpdate(progressCtx, "[CodeHarbor] Multi-Agent workflow \u542F\u52A8\uFF1APlanner -> Executor -> Reviewer");
4084
+ try {
4085
+ const result = await this.workflowRunner.run({
4086
+ objective: normalizedObjective,
4087
+ workdir,
4088
+ onRegisterCancel: (cancel) => {
4089
+ cancelWorkflow = cancel;
4090
+ if (cancelRequested) {
4091
+ cancelWorkflow();
4092
+ }
4093
+ },
4094
+ onProgress: async (event) => {
4095
+ const stepLabel = event.stage.toUpperCase();
4096
+ await this.sendProgressUpdate(progressCtx, `[CodeHarbor] [${stepLabel}] ${event.message}`);
4097
+ }
4098
+ });
4099
+ const endedAtIso = (/* @__PURE__ */ new Date()).toISOString();
4100
+ this.workflowSnapshots.set(sessionKey, {
4101
+ state: "succeeded",
4102
+ startedAt: startedAtIso,
4103
+ endedAt: endedAtIso,
4104
+ objective: normalizedObjective,
4105
+ approved: result.approved,
4106
+ repairRounds: result.repairRounds,
4107
+ error: null
4108
+ });
4109
+ await this.channel.sendMessage(message.conversationId, buildWorkflowResultReply(result));
4110
+ await this.finishProgress(progressCtx, `\u591A\u667A\u80FD\u4F53\u6D41\u7A0B\u5B8C\u6210\uFF08\u8017\u65F6 ${formatDurationMs(Date.now() - requestStartedAt)}\uFF09`);
4111
+ } catch (error) {
4112
+ const status = classifyExecutionOutcome(error);
4113
+ const endedAtIso = (/* @__PURE__ */ new Date()).toISOString();
4114
+ this.workflowSnapshots.set(sessionKey, {
4115
+ state: status === "cancelled" ? "idle" : "failed",
4116
+ startedAt: startedAtIso,
4117
+ endedAt: endedAtIso,
4118
+ objective: normalizedObjective,
4119
+ approved: null,
4120
+ repairRounds: 0,
4121
+ error: formatError2(error)
4122
+ });
4123
+ await this.finishProgress(progressCtx, buildFailureProgressSummary(status, requestStartedAt, error));
4124
+ throw error;
4125
+ } finally {
4126
+ const running = this.runningExecutions.get(sessionKey);
4127
+ if (running?.requestId === requestId) {
4128
+ this.runningExecutions.delete(sessionKey);
4129
+ }
4130
+ await stopTyping();
4131
+ }
4132
+ }
4133
+ async sendWorkflowFailure(conversationId, error) {
4134
+ const startedAt = Date.now();
4135
+ const status = classifyExecutionOutcome(error);
4136
+ if (status === "cancelled") {
4137
+ await this.channel.sendNotice(conversationId, "[CodeHarbor] Multi-Agent workflow \u5DF2\u53D6\u6D88\u3002");
4138
+ return Date.now() - startedAt;
4139
+ }
4140
+ await this.channel.sendMessage(conversationId, `[CodeHarbor] Multi-Agent workflow \u5931\u8D25: ${formatError2(error)}`);
4141
+ return Date.now() - startedAt;
4142
+ }
3309
4143
  async handleStopCommand(sessionKey, message, requestId) {
3310
4144
  this.stateStore.deactivateSession(sessionKey);
3311
4145
  this.stateStore.clearCodexSessionId(sessionKey);
@@ -3619,10 +4453,29 @@ function buildFailureProgressSummary(status, startedAt, error) {
3619
4453
  }
3620
4454
  return `\u5904\u7406\u5931\u8D25\uFF08\u8017\u65F6 ${elapsed}\uFF09: ${formatError2(error)}`;
3621
4455
  }
4456
+ function buildWorkflowResultReply(result) {
4457
+ return `[CodeHarbor] Multi-Agent workflow \u5B8C\u6210
4458
+ - objective: ${result.objective}
4459
+ - approved: ${result.approved ? "yes" : "no"}
4460
+ - repairRounds: ${result.repairRounds}
4461
+ - duration: ${formatDurationMs(result.durationMs)}
4462
+
4463
+ [planner]
4464
+ ${result.plan}
4465
+ [/planner]
4466
+
4467
+ [executor]
4468
+ ${result.output}
4469
+ [/executor]
4470
+
4471
+ [reviewer]
4472
+ ${result.review}
4473
+ [/reviewer]`;
4474
+ }
3622
4475
 
3623
4476
  // src/store/state-store.ts
3624
- var import_node_fs5 = __toESM(require("fs"));
3625
- var import_node_path6 = __toESM(require("path"));
4477
+ var import_node_fs7 = __toESM(require("fs"));
4478
+ var import_node_path8 = __toESM(require("path"));
3626
4479
  var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
3627
4480
  var PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
3628
4481
  var SQLITE_MODULE_ID = `node:${"sqlite"}`;
@@ -3648,7 +4501,7 @@ var StateStore = class {
3648
4501
  this.maxProcessedEventsPerSession = maxProcessedEventsPerSession;
3649
4502
  this.maxSessionAgeMs = maxSessionAgeDays * ONE_DAY_MS;
3650
4503
  this.maxSessions = maxSessions;
3651
- import_node_fs5.default.mkdirSync(import_node_path6.default.dirname(this.dbPath), { recursive: true });
4504
+ import_node_fs7.default.mkdirSync(import_node_path8.default.dirname(this.dbPath), { recursive: true });
3652
4505
  this.db = new DatabaseSync(this.dbPath);
3653
4506
  this.initializeSchema();
3654
4507
  this.importLegacyStateIfNeeded();
@@ -3893,7 +4746,7 @@ var StateStore = class {
3893
4746
  `);
3894
4747
  }
3895
4748
  importLegacyStateIfNeeded() {
3896
- if (!this.legacyJsonPath || !import_node_fs5.default.existsSync(this.legacyJsonPath)) {
4749
+ if (!this.legacyJsonPath || !import_node_fs7.default.existsSync(this.legacyJsonPath)) {
3897
4750
  return;
3898
4751
  }
3899
4752
  const countRow = this.db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
@@ -3976,7 +4829,7 @@ var StateStore = class {
3976
4829
  };
3977
4830
  function loadLegacyState(filePath) {
3978
4831
  try {
3979
- const raw = import_node_fs5.default.readFileSync(filePath, "utf8");
4832
+ const raw = import_node_fs7.default.readFileSync(filePath, "utf8");
3980
4833
  const parsed = JSON.parse(raw);
3981
4834
  if (!parsed.sessions || typeof parsed.sessions !== "object") {
3982
4835
  return null;
@@ -4019,7 +4872,7 @@ function boolToInt(value) {
4019
4872
  }
4020
4873
 
4021
4874
  // src/app.ts
4022
- var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process3.execFile);
4875
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process4.execFile);
4023
4876
  var CodeHarborApp = class {
4024
4877
  config;
4025
4878
  logger;
@@ -4061,6 +4914,7 @@ var CodeHarborApp = class {
4061
4914
  roomTriggerPolicies: config.roomTriggerPolicies,
4062
4915
  rateLimiterOptions: config.rateLimiter,
4063
4916
  cliCompat: config.cliCompat,
4917
+ multiAgentWorkflow: config.agentWorkflow,
4064
4918
  configService: this.configService,
4065
4919
  defaultCodexWorkdir: config.codexWorkdir
4066
4920
  });
@@ -4167,8 +5021,8 @@ function isNonLoopbackHost(host) {
4167
5021
  }
4168
5022
 
4169
5023
  // src/config.ts
4170
- var import_node_fs6 = __toESM(require("fs"));
4171
- var import_node_path7 = __toESM(require("path"));
5024
+ var import_node_fs8 = __toESM(require("fs"));
5025
+ var import_node_path9 = __toESM(require("path"));
4172
5026
  var import_dotenv2 = __toESM(require("dotenv"));
4173
5027
  var import_zod = require("zod");
4174
5028
  var configSchema = import_zod.z.object({
@@ -4185,6 +5039,8 @@ var configSchema = import_zod.z.object({
4185
5039
  CODEX_APPROVAL_POLICY: import_zod.z.string().optional(),
4186
5040
  CODEX_EXTRA_ARGS: import_zod.z.string().default(""),
4187
5041
  CODEX_EXTRA_ENV_JSON: import_zod.z.string().default(""),
5042
+ AGENT_WORKFLOW_ENABLED: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
5043
+ AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS: import_zod.z.string().default("1").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().min(0).max(10)),
4188
5044
  STATE_DB_PATH: import_zod.z.string().default("data/state.db"),
4189
5045
  STATE_PATH: import_zod.z.string().default("data/state.json"),
4190
5046
  MAX_PROCESSED_EVENTS_PER_SESSION: import_zod.z.string().default("200").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
@@ -4227,15 +5083,19 @@ var configSchema = import_zod.z.object({
4227
5083
  matrixCommandPrefix: v.MATRIX_COMMAND_PREFIX,
4228
5084
  codexBin: v.CODEX_BIN,
4229
5085
  codexModel: v.CODEX_MODEL?.trim() || null,
4230
- codexWorkdir: import_node_path7.default.resolve(v.CODEX_WORKDIR),
5086
+ codexWorkdir: import_node_path9.default.resolve(v.CODEX_WORKDIR),
4231
5087
  codexDangerousBypass: v.CODEX_DANGEROUS_BYPASS,
4232
5088
  codexExecTimeoutMs: v.CODEX_EXEC_TIMEOUT_MS,
4233
5089
  codexSandboxMode: v.CODEX_SANDBOX_MODE?.trim() || null,
4234
5090
  codexApprovalPolicy: v.CODEX_APPROVAL_POLICY?.trim() || null,
4235
5091
  codexExtraArgs: parseExtraArgs(v.CODEX_EXTRA_ARGS),
4236
5092
  codexExtraEnv: parseExtraEnv(v.CODEX_EXTRA_ENV_JSON),
4237
- stateDbPath: import_node_path7.default.resolve(v.STATE_DB_PATH),
4238
- legacyStateJsonPath: v.STATE_PATH.trim() ? import_node_path7.default.resolve(v.STATE_PATH) : null,
5093
+ agentWorkflow: {
5094
+ enabled: v.AGENT_WORKFLOW_ENABLED,
5095
+ autoRepairMaxRounds: v.AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS
5096
+ },
5097
+ stateDbPath: import_node_path9.default.resolve(v.STATE_DB_PATH),
5098
+ legacyStateJsonPath: v.STATE_PATH.trim() ? import_node_path9.default.resolve(v.STATE_PATH) : null,
4239
5099
  maxProcessedEventsPerSession: v.MAX_PROCESSED_EVENTS_PER_SESSION,
4240
5100
  maxSessionAgeDays: v.MAX_SESSION_AGE_DAYS,
4241
5101
  maxSessions: v.MAX_SESSIONS,
@@ -4266,7 +5126,7 @@ var configSchema = import_zod.z.object({
4266
5126
  disableReplyChunkSplit: v.CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT,
4267
5127
  progressThrottleMs: v.CLI_COMPAT_PROGRESS_THROTTLE_MS,
4268
5128
  fetchMedia: v.CLI_COMPAT_FETCH_MEDIA,
4269
- recordPath: v.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path7.default.resolve(v.CLI_COMPAT_RECORD_PATH) : null
5129
+ recordPath: v.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path9.default.resolve(v.CLI_COMPAT_RECORD_PATH) : null
4270
5130
  },
4271
5131
  doctorHttpTimeoutMs: v.DOCTOR_HTTP_TIMEOUT_MS,
4272
5132
  adminBindHost: v.ADMIN_BIND_HOST.trim() || "127.0.0.1",
@@ -4276,7 +5136,7 @@ var configSchema = import_zod.z.object({
4276
5136
  adminAllowedOrigins: parseCsvList(v.ADMIN_ALLOWED_ORIGINS),
4277
5137
  logLevel: v.LOG_LEVEL
4278
5138
  }));
4279
- function loadEnvFromFile(filePath = import_node_path7.default.resolve(process.cwd(), ".env"), env = process.env) {
5139
+ function loadEnvFromFile(filePath = import_node_path9.default.resolve(process.cwd(), ".env"), env = process.env) {
4280
5140
  import_dotenv2.default.config({
4281
5141
  path: filePath,
4282
5142
  processEnv: env,
@@ -4289,9 +5149,9 @@ function loadConfig(env = process.env) {
4289
5149
  const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ");
4290
5150
  throw new Error(`Invalid configuration: ${message}`);
4291
5151
  }
4292
- import_node_fs6.default.mkdirSync(import_node_path7.default.dirname(parsed.data.stateDbPath), { recursive: true });
5152
+ import_node_fs8.default.mkdirSync(import_node_path9.default.dirname(parsed.data.stateDbPath), { recursive: true });
4293
5153
  if (parsed.data.legacyStateJsonPath) {
4294
- import_node_fs6.default.mkdirSync(import_node_path7.default.dirname(parsed.data.legacyStateJsonPath), { recursive: true });
5154
+ import_node_fs8.default.mkdirSync(import_node_path9.default.dirname(parsed.data.legacyStateJsonPath), { recursive: true });
4295
5155
  }
4296
5156
  return parsed.data;
4297
5157
  }
@@ -4364,8 +5224,8 @@ function parseCsvList(raw) {
4364
5224
  }
4365
5225
 
4366
5226
  // src/config-snapshot.ts
4367
- var import_node_fs7 = __toESM(require("fs"));
4368
- var import_node_path8 = __toESM(require("path"));
5227
+ var import_node_fs9 = __toESM(require("fs"));
5228
+ var import_node_path10 = __toESM(require("path"));
4369
5229
  var import_zod2 = require("zod");
4370
5230
  var CONFIG_SNAPSHOT_SCHEMA_VERSION = 1;
4371
5231
  var CONFIG_SNAPSHOT_ENV_KEYS = [
@@ -4382,6 +5242,8 @@ var CONFIG_SNAPSHOT_ENV_KEYS = [
4382
5242
  "CODEX_APPROVAL_POLICY",
4383
5243
  "CODEX_EXTRA_ARGS",
4384
5244
  "CODEX_EXTRA_ENV_JSON",
5245
+ "AGENT_WORKFLOW_ENABLED",
5246
+ "AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS",
4385
5247
  "STATE_DB_PATH",
4386
5248
  "STATE_PATH",
4387
5249
  "MAX_PROCESSED_EVENTS_PER_SESSION",
@@ -4444,6 +5306,8 @@ var envSnapshotSchema = import_zod2.z.object({
4444
5306
  CODEX_APPROVAL_POLICY: import_zod2.z.string(),
4445
5307
  CODEX_EXTRA_ARGS: import_zod2.z.string(),
4446
5308
  CODEX_EXTRA_ENV_JSON: jsonObjectStringSchema("CODEX_EXTRA_ENV_JSON", true),
5309
+ AGENT_WORKFLOW_ENABLED: booleanStringSchema("AGENT_WORKFLOW_ENABLED"),
5310
+ AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS: integerStringSchema("AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS", 0, 10),
4447
5311
  STATE_DB_PATH: import_zod2.z.string().min(1),
4448
5312
  STATE_PATH: import_zod2.z.string(),
4449
5313
  MAX_PROCESSED_EVENTS_PER_SESSION: integerStringSchema("MAX_PROCESSED_EVENTS_PER_SESSION", 1),
@@ -4548,9 +5412,9 @@ async function runConfigExportCommand(options = {}) {
4548
5412
  const snapshot = buildConfigSnapshot(config, stateStore.listRoomSettings(), options.now ?? /* @__PURE__ */ new Date());
4549
5413
  const serialized = serializeConfigSnapshot(snapshot);
4550
5414
  if (options.outputPath) {
4551
- const targetPath = import_node_path8.default.resolve(cwd, options.outputPath);
4552
- import_node_fs7.default.mkdirSync(import_node_path8.default.dirname(targetPath), { recursive: true });
4553
- import_node_fs7.default.writeFileSync(targetPath, serialized, "utf8");
5415
+ const targetPath = import_node_path10.default.resolve(cwd, options.outputPath);
5416
+ import_node_fs9.default.mkdirSync(import_node_path10.default.dirname(targetPath), { recursive: true });
5417
+ import_node_fs9.default.writeFileSync(targetPath, serialized, "utf8");
4554
5418
  output.write(`Exported config snapshot to ${targetPath}
4555
5419
  `);
4556
5420
  return;
@@ -4564,8 +5428,8 @@ async function runConfigImportCommand(options) {
4564
5428
  const cwd = options.cwd ?? process.cwd();
4565
5429
  const output = options.output ?? process.stdout;
4566
5430
  const actor = options.actor?.trim() || "cli:config-import";
4567
- const sourcePath = import_node_path8.default.resolve(cwd, options.filePath);
4568
- if (!import_node_fs7.default.existsSync(sourcePath)) {
5431
+ const sourcePath = import_node_path10.default.resolve(cwd, options.filePath);
5432
+ if (!import_node_fs9.default.existsSync(sourcePath)) {
4569
5433
  throw new Error(`Config snapshot file not found: ${sourcePath}`);
4570
5434
  }
4571
5435
  const snapshot = parseConfigSnapshot(parseJsonFile(sourcePath));
@@ -4595,7 +5459,7 @@ async function runConfigImportCommand(options) {
4595
5459
  synchronizeRoomSettings(stateStore, normalizedRooms);
4596
5460
  stateStore.appendConfigRevision(
4597
5461
  actor,
4598
- `import config snapshot from ${import_node_path8.default.basename(sourcePath)}`,
5462
+ `import config snapshot from ${import_node_path10.default.basename(sourcePath)}`,
4599
5463
  JSON.stringify({
4600
5464
  type: "config_snapshot_import",
4601
5465
  sourcePath,
@@ -4609,7 +5473,7 @@ async function runConfigImportCommand(options) {
4609
5473
  output.write(
4610
5474
  [
4611
5475
  `Imported config snapshot from ${sourcePath}`,
4612
- `- updated .env in ${import_node_path8.default.resolve(cwd, ".env")}`,
5476
+ `- updated .env in ${import_node_path10.default.resolve(cwd, ".env")}`,
4613
5477
  `- synchronized room settings: ${normalizedRooms.length}`,
4614
5478
  "- restart required: yes (global env settings are restart-scoped)"
4615
5479
  ].join("\n") + "\n"
@@ -4630,6 +5494,8 @@ function buildSnapshotEnv(config) {
4630
5494
  CODEX_APPROVAL_POLICY: config.codexApprovalPolicy ?? "",
4631
5495
  CODEX_EXTRA_ARGS: config.codexExtraArgs.join(" "),
4632
5496
  CODEX_EXTRA_ENV_JSON: serializeJsonObject(config.codexExtraEnv),
5497
+ AGENT_WORKFLOW_ENABLED: String(config.agentWorkflow.enabled),
5498
+ AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS: String(config.agentWorkflow.autoRepairMaxRounds),
4633
5499
  STATE_DB_PATH: config.stateDbPath,
4634
5500
  STATE_PATH: config.legacyStateJsonPath ?? "",
4635
5501
  MAX_PROCESSED_EVENTS_PER_SESSION: String(config.maxProcessedEventsPerSession),
@@ -4669,7 +5535,7 @@ function buildSnapshotEnv(config) {
4669
5535
  }
4670
5536
  function parseJsonFile(filePath) {
4671
5537
  try {
4672
- const raw = import_node_fs7.default.readFileSync(filePath, "utf8");
5538
+ const raw = import_node_fs9.default.readFileSync(filePath, "utf8");
4673
5539
  return JSON.parse(raw);
4674
5540
  } catch (error) {
4675
5541
  const message = error instanceof Error ? error.message : String(error);
@@ -4681,525 +5547,233 @@ function parseJsonFile(filePath) {
4681
5547
  function normalizeSnapshotEnv(env, cwd) {
4682
5548
  return {
4683
5549
  ...env,
4684
- CODEX_WORKDIR: import_node_path8.default.resolve(cwd, env.CODEX_WORKDIR),
4685
- STATE_DB_PATH: import_node_path8.default.resolve(cwd, env.STATE_DB_PATH),
4686
- STATE_PATH: env.STATE_PATH.trim() ? import_node_path8.default.resolve(cwd, env.STATE_PATH) : "",
4687
- CLI_COMPAT_RECORD_PATH: env.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path8.default.resolve(cwd, env.CLI_COMPAT_RECORD_PATH) : ""
5550
+ CODEX_WORKDIR: import_node_path10.default.resolve(cwd, env.CODEX_WORKDIR),
5551
+ STATE_DB_PATH: import_node_path10.default.resolve(cwd, env.STATE_DB_PATH),
5552
+ STATE_PATH: env.STATE_PATH.trim() ? import_node_path10.default.resolve(cwd, env.STATE_PATH) : "",
5553
+ CLI_COMPAT_RECORD_PATH: env.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path10.default.resolve(cwd, env.CLI_COMPAT_RECORD_PATH) : ""
4688
5554
  };
4689
5555
  }
4690
5556
  function normalizeSnapshotRooms(rooms, cwd) {
4691
- const normalized = [];
4692
- const seen = /* @__PURE__ */ new Set();
4693
- for (const room of rooms) {
4694
- const roomId = room.roomId.trim();
4695
- if (!roomId) {
4696
- throw new Error("roomId is required for every room in snapshot.");
4697
- }
4698
- if (seen.has(roomId)) {
4699
- throw new Error(`Duplicate roomId in snapshot: ${roomId}`);
4700
- }
4701
- seen.add(roomId);
4702
- const workdir = import_node_path8.default.resolve(cwd, room.workdir);
4703
- ensureDirectory2(workdir, `room workdir (${roomId})`);
4704
- normalized.push({
4705
- roomId,
4706
- enabled: room.enabled,
4707
- allowMention: room.allowMention,
4708
- allowReply: room.allowReply,
4709
- allowActiveWindow: room.allowActiveWindow,
4710
- allowPrefix: room.allowPrefix,
4711
- workdir
4712
- });
4713
- }
4714
- return normalized;
4715
- }
4716
- function synchronizeRoomSettings(stateStore, rooms) {
4717
- const incoming = new Map(rooms.map((room) => [room.roomId, room]));
4718
- const existing = stateStore.listRoomSettings();
4719
- for (const room of existing) {
4720
- if (!incoming.has(room.roomId)) {
4721
- stateStore.deleteRoomSettings(room.roomId);
4722
- }
4723
- }
4724
- for (const room of rooms) {
4725
- stateStore.upsertRoomSettings(room);
4726
- }
4727
- }
4728
- function persistEnvSnapshot(cwd, env) {
4729
- const envPath = import_node_path8.default.resolve(cwd, ".env");
4730
- const examplePath = import_node_path8.default.resolve(cwd, ".env.example");
4731
- const template = import_node_fs7.default.existsSync(envPath) ? import_node_fs7.default.readFileSync(envPath, "utf8") : import_node_fs7.default.existsSync(examplePath) ? import_node_fs7.default.readFileSync(examplePath, "utf8") : "";
4732
- const overrides = {};
4733
- for (const key of CONFIG_SNAPSHOT_ENV_KEYS) {
4734
- overrides[key] = env[key];
4735
- }
4736
- const next = applyEnvOverrides(template, overrides);
4737
- import_node_fs7.default.writeFileSync(envPath, next, "utf8");
4738
- }
4739
- function ensureDirectory2(dirPath, label) {
4740
- if (!import_node_fs7.default.existsSync(dirPath) || !import_node_fs7.default.statSync(dirPath).isDirectory()) {
4741
- throw new Error(`${label} does not exist or is not a directory: ${dirPath}`);
4742
- }
4743
- }
4744
- function parseIntStrict(raw) {
4745
- const value = Number.parseInt(raw, 10);
4746
- if (!Number.isFinite(value)) {
4747
- throw new Error(`Invalid integer value: ${raw}`);
4748
- }
4749
- return value;
4750
- }
4751
- function serializeJsonObject(value) {
4752
- return Object.keys(value).length > 0 ? JSON.stringify(value) : "";
4753
- }
4754
- function booleanStringSchema(key) {
4755
- return import_zod2.z.string().refine((value) => BOOLEAN_STRING.test(value), {
4756
- message: `${key} must be a boolean string (true/false).`
4757
- });
4758
- }
4759
- function integerStringSchema(key, min, max = Number.MAX_SAFE_INTEGER) {
4760
- return import_zod2.z.string().refine((value) => {
4761
- const trimmed = value.trim();
4762
- if (!INTEGER_STRING.test(trimmed)) {
4763
- return false;
4764
- }
4765
- const parsed = Number.parseInt(trimmed, 10);
4766
- if (!Number.isFinite(parsed)) {
4767
- return false;
4768
- }
4769
- return parsed >= min && parsed <= max;
4770
- }, {
4771
- message: `${key} must be an integer string in range [${min}, ${max}].`
4772
- });
4773
- }
4774
- function jsonObjectStringSchema(key, allowEmpty) {
4775
- return import_zod2.z.string().refine((value) => {
4776
- const trimmed = value.trim();
4777
- if (!trimmed) {
4778
- return allowEmpty;
4779
- }
4780
- let parsed;
4781
- try {
4782
- parsed = JSON.parse(trimmed);
4783
- } catch {
4784
- return false;
4785
- }
4786
- return Boolean(parsed) && typeof parsed === "object" && !Array.isArray(parsed);
4787
- }, {
4788
- message: `${key} must be an empty string or a JSON object string.`
4789
- });
4790
- }
4791
-
4792
- // src/preflight.ts
4793
- var import_node_child_process4 = require("child_process");
4794
- var import_node_fs8 = __toESM(require("fs"));
4795
- var import_node_path9 = __toESM(require("path"));
4796
- var import_node_util3 = require("util");
4797
- var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
4798
- var REQUIRED_ENV_KEYS = ["MATRIX_HOMESERVER", "MATRIX_USER_ID", "MATRIX_ACCESS_TOKEN"];
4799
- async function runStartupPreflight(options = {}) {
4800
- const env = options.env ?? process.env;
4801
- const cwd = options.cwd ?? process.cwd();
4802
- const checkCodexBinary = options.checkCodexBinary ?? defaultCheckCodexBinary;
4803
- const fileExists = options.fileExists ?? import_node_fs8.default.existsSync;
4804
- const isDirectory = options.isDirectory ?? defaultIsDirectory;
4805
- const issues = [];
4806
- const envPath = import_node_path9.default.resolve(cwd, ".env");
4807
- if (!fileExists(envPath)) {
4808
- issues.push({
4809
- level: "warn",
4810
- code: "missing_dotenv",
4811
- check: ".env",
4812
- message: `No .env file found at ${envPath}.`,
4813
- fix: 'Run "codeharbor init" to create baseline config.'
4814
- });
4815
- }
4816
- for (const key of REQUIRED_ENV_KEYS) {
4817
- if (!readEnv(env, key)) {
4818
- issues.push({
4819
- level: "error",
4820
- code: "missing_env",
4821
- check: key,
4822
- message: `${key} is required.`,
4823
- fix: `Run "codeharbor init" or set ${key} in .env.`
4824
- });
4825
- }
4826
- }
4827
- const matrixHomeserver = readEnv(env, "MATRIX_HOMESERVER");
4828
- if (matrixHomeserver) {
4829
- try {
4830
- new URL(matrixHomeserver);
4831
- } catch {
4832
- issues.push({
4833
- level: "error",
4834
- code: "invalid_matrix_homeserver",
4835
- check: "MATRIX_HOMESERVER",
4836
- message: `Invalid URL: "${matrixHomeserver}".`,
4837
- fix: "Set MATRIX_HOMESERVER to a full URL, for example https://matrix.example.com."
4838
- });
4839
- }
4840
- }
4841
- const matrixUserId = readEnv(env, "MATRIX_USER_ID");
4842
- if (matrixUserId && !/^@[^:\s]+:.+/.test(matrixUserId)) {
4843
- issues.push({
4844
- level: "error",
4845
- code: "invalid_matrix_user_id",
4846
- check: "MATRIX_USER_ID",
4847
- message: `Unexpected Matrix user id format: "${matrixUserId}".`,
4848
- fix: "Set MATRIX_USER_ID like @bot:example.com."
4849
- });
4850
- }
4851
- const codexBin = readEnv(env, "CODEX_BIN") || "codex";
4852
- try {
4853
- await checkCodexBinary(codexBin);
4854
- } catch (error) {
4855
- const reason = error instanceof Error && error.message ? ` (${error.message})` : "";
4856
- issues.push({
4857
- level: "error",
4858
- code: "missing_codex_bin",
4859
- check: "CODEX_BIN",
4860
- message: `Unable to execute "${codexBin}"${reason}.`,
4861
- fix: `Install Codex CLI and ensure "${codexBin}" is in PATH, or set CODEX_BIN=/absolute/path/to/codex.`
4862
- });
4863
- }
4864
- const configuredWorkdir = readEnv(env, "CODEX_WORKDIR");
4865
- const workdir = import_node_path9.default.resolve(cwd, configuredWorkdir || cwd);
4866
- if (!fileExists(workdir) || !isDirectory(workdir)) {
4867
- issues.push({
4868
- level: "error",
4869
- code: "invalid_codex_workdir",
4870
- check: "CODEX_WORKDIR",
4871
- message: `Working directory does not exist or is not a directory: ${workdir}.`,
4872
- fix: `Set CODEX_WORKDIR to an existing directory, for example CODEX_WORKDIR=${cwd}.`
4873
- });
4874
- }
4875
- return {
4876
- ok: issues.every((issue) => issue.level !== "error"),
4877
- issues
4878
- };
4879
- }
4880
- function formatPreflightReport(result, commandName) {
4881
- const lines = [];
4882
- const errors = result.issues.filter((issue) => issue.level === "error").length;
4883
- const warnings = result.issues.filter((issue) => issue.level === "warn").length;
4884
- if (result.ok) {
4885
- lines.push(`Preflight check passed for "codeharbor ${commandName}" with ${warnings} warning(s).`);
4886
- } else {
4887
- lines.push(
4888
- `Preflight check failed for "codeharbor ${commandName}" with ${errors} error(s) and ${warnings} warning(s).`
4889
- );
4890
- }
4891
- for (const issue of result.issues) {
4892
- const level = issue.level.toUpperCase();
4893
- lines.push(`- [${level}] ${issue.check}: ${issue.message}`);
4894
- lines.push(` fix: ${issue.fix}`);
4895
- }
4896
- return `${lines.join("\n")}
4897
- `;
4898
- }
4899
- async function defaultCheckCodexBinary(bin) {
4900
- await execFileAsync3(bin, ["--version"]);
4901
- }
4902
- function defaultIsDirectory(targetPath) {
4903
- try {
4904
- return import_node_fs8.default.statSync(targetPath).isDirectory();
4905
- } catch {
4906
- return false;
4907
- }
4908
- }
4909
- function readEnv(env, key) {
4910
- return env[key]?.trim() ?? "";
4911
- }
4912
-
4913
- // src/runtime-home.ts
4914
- var import_node_fs9 = __toESM(require("fs"));
4915
- var import_node_os2 = __toESM(require("os"));
4916
- var import_node_path10 = __toESM(require("path"));
4917
- var LEGACY_RUNTIME_HOME = "/opt/codeharbor";
4918
- var USER_RUNTIME_HOME_DIR = ".codeharbor";
4919
- var DEFAULT_RUNTIME_HOME = import_node_path10.default.resolve(import_node_os2.default.homedir(), USER_RUNTIME_HOME_DIR);
4920
- var RUNTIME_HOME_ENV_KEY = "CODEHARBOR_HOME";
4921
- function resolveRuntimeHome(env = process.env) {
4922
- const configured = env[RUNTIME_HOME_ENV_KEY]?.trim();
4923
- if (configured) {
4924
- return import_node_path10.default.resolve(configured);
4925
- }
4926
- const legacyEnvPath = import_node_path10.default.resolve(LEGACY_RUNTIME_HOME, ".env");
4927
- if (import_node_fs9.default.existsSync(legacyEnvPath)) {
4928
- return LEGACY_RUNTIME_HOME;
4929
- }
4930
- return resolveUserRuntimeHome(env);
4931
- }
4932
- function resolveUserRuntimeHome(env = process.env) {
4933
- const home = env.HOME?.trim() || import_node_os2.default.homedir();
4934
- return import_node_path10.default.resolve(home, USER_RUNTIME_HOME_DIR);
4935
- }
4936
-
4937
- // src/service-manager.ts
4938
- var import_node_child_process5 = require("child_process");
4939
- var import_node_fs10 = __toESM(require("fs"));
4940
- var import_node_os3 = __toESM(require("os"));
4941
- var import_node_path11 = __toESM(require("path"));
4942
- var SYSTEMD_DIR = "/etc/systemd/system";
4943
- var MAIN_SERVICE_NAME = "codeharbor.service";
4944
- var ADMIN_SERVICE_NAME = "codeharbor-admin.service";
4945
- function resolveDefaultRunUser(env = process.env) {
4946
- const sudoUser = env.SUDO_USER?.trim();
4947
- if (sudoUser) {
4948
- return sudoUser;
4949
- }
4950
- const user = env.USER?.trim();
4951
- if (user) {
4952
- return user;
4953
- }
4954
- try {
4955
- return import_node_os3.default.userInfo().username;
4956
- } catch {
4957
- return "root";
4958
- }
4959
- }
4960
- function resolveRuntimeHomeForUser(runUser, env = process.env, explicitRuntimeHome) {
4961
- const configuredRuntimeHome = explicitRuntimeHome?.trim() || env[RUNTIME_HOME_ENV_KEY]?.trim();
4962
- if (configuredRuntimeHome) {
4963
- return import_node_path11.default.resolve(configuredRuntimeHome);
4964
- }
4965
- const userHome = resolveUserHome(runUser);
4966
- if (userHome) {
4967
- return import_node_path11.default.resolve(userHome, USER_RUNTIME_HOME_DIR);
4968
- }
4969
- return import_node_path11.default.resolve(import_node_os3.default.homedir(), USER_RUNTIME_HOME_DIR);
4970
- }
4971
- function buildMainServiceUnit(options) {
4972
- validateUnitOptions(options);
4973
- const runtimeHome2 = import_node_path11.default.resolve(options.runtimeHome);
4974
- return [
4975
- "[Unit]",
4976
- "Description=CodeHarbor main service",
4977
- "After=network-online.target",
4978
- "Wants=network-online.target",
4979
- "",
4980
- "[Service]",
4981
- "Type=simple",
4982
- `User=${options.runUser}`,
4983
- `WorkingDirectory=${runtimeHome2}`,
4984
- `Environment=CODEHARBOR_HOME=${runtimeHome2}`,
4985
- `ExecStart=${import_node_path11.default.resolve(options.nodeBinPath)} ${import_node_path11.default.resolve(options.cliScriptPath)} start`,
4986
- "Restart=always",
4987
- "RestartSec=3",
4988
- "NoNewPrivileges=true",
4989
- "PrivateTmp=true",
4990
- "ProtectSystem=full",
4991
- "ProtectHome=false",
4992
- `ReadWritePaths=${runtimeHome2}`,
4993
- "",
4994
- "[Install]",
4995
- "WantedBy=multi-user.target",
4996
- ""
4997
- ].join("\n");
4998
- }
4999
- function buildAdminServiceUnit(options) {
5000
- validateUnitOptions(options);
5001
- const runtimeHome2 = import_node_path11.default.resolve(options.runtimeHome);
5002
- return [
5003
- "[Unit]",
5004
- "Description=CodeHarbor admin service",
5005
- "After=network-online.target",
5006
- "Wants=network-online.target",
5007
- "",
5008
- "[Service]",
5009
- "Type=simple",
5010
- `User=${options.runUser}`,
5011
- `WorkingDirectory=${runtimeHome2}`,
5012
- `Environment=CODEHARBOR_HOME=${runtimeHome2}`,
5013
- `ExecStart=${import_node_path11.default.resolve(options.nodeBinPath)} ${import_node_path11.default.resolve(options.cliScriptPath)} admin serve`,
5014
- "Restart=always",
5015
- "RestartSec=3",
5016
- "NoNewPrivileges=true",
5017
- "PrivateTmp=true",
5018
- "ProtectSystem=full",
5019
- "ProtectHome=false",
5020
- `ReadWritePaths=${runtimeHome2}`,
5021
- "",
5022
- "[Install]",
5023
- "WantedBy=multi-user.target",
5024
- ""
5025
- ].join("\n");
5026
- }
5027
- function installSystemdServices(options) {
5028
- assertLinuxWithSystemd();
5029
- assertRootPrivileges();
5030
- const output = options.output ?? process.stdout;
5031
- const runUser = options.runUser.trim();
5032
- const runtimeHome2 = import_node_path11.default.resolve(options.runtimeHome);
5033
- validateSimpleValue(runUser, "runUser");
5034
- validateSimpleValue(runtimeHome2, "runtimeHome");
5035
- validateSimpleValue(options.nodeBinPath, "nodeBinPath");
5036
- validateSimpleValue(options.cliScriptPath, "cliScriptPath");
5037
- ensureUserExists(runUser);
5038
- const runGroup = resolveUserGroup(runUser);
5039
- import_node_fs10.default.mkdirSync(runtimeHome2, { recursive: true });
5040
- runCommand("chown", ["-R", `${runUser}:${runGroup}`, runtimeHome2]);
5041
- const mainPath = import_node_path11.default.join(SYSTEMD_DIR, MAIN_SERVICE_NAME);
5042
- const adminPath = import_node_path11.default.join(SYSTEMD_DIR, ADMIN_SERVICE_NAME);
5043
- const unitOptions = {
5044
- runUser,
5045
- runtimeHome: runtimeHome2,
5046
- nodeBinPath: options.nodeBinPath,
5047
- cliScriptPath: options.cliScriptPath
5048
- };
5049
- import_node_fs10.default.writeFileSync(mainPath, buildMainServiceUnit(unitOptions), "utf8");
5050
- if (options.installAdmin) {
5051
- import_node_fs10.default.writeFileSync(adminPath, buildAdminServiceUnit(unitOptions), "utf8");
5052
- }
5053
- runSystemctl(["daemon-reload"]);
5054
- if (options.startNow) {
5055
- runSystemctl(["enable", "--now", MAIN_SERVICE_NAME]);
5056
- if (options.installAdmin) {
5057
- runSystemctl(["enable", "--now", ADMIN_SERVICE_NAME]);
5058
- }
5059
- } else {
5060
- runSystemctl(["enable", MAIN_SERVICE_NAME]);
5061
- if (options.installAdmin) {
5062
- runSystemctl(["enable", ADMIN_SERVICE_NAME]);
5063
- }
5064
- }
5065
- output.write(`Installed systemd unit: ${mainPath}
5066
- `);
5067
- if (options.installAdmin) {
5068
- output.write(`Installed systemd unit: ${adminPath}
5069
- `);
5070
- }
5071
- output.write("Done. Check status with: systemctl status codeharbor --no-pager\n");
5072
- }
5073
- function uninstallSystemdServices(options) {
5074
- assertLinuxWithSystemd();
5075
- assertRootPrivileges();
5076
- const output = options.output ?? process.stdout;
5077
- const mainPath = import_node_path11.default.join(SYSTEMD_DIR, MAIN_SERVICE_NAME);
5078
- const adminPath = import_node_path11.default.join(SYSTEMD_DIR, ADMIN_SERVICE_NAME);
5079
- stopAndDisableIfPresent(MAIN_SERVICE_NAME);
5080
- if (import_node_fs10.default.existsSync(mainPath)) {
5081
- import_node_fs10.default.unlinkSync(mainPath);
5082
- }
5083
- if (options.removeAdmin) {
5084
- stopAndDisableIfPresent(ADMIN_SERVICE_NAME);
5085
- if (import_node_fs10.default.existsSync(adminPath)) {
5086
- import_node_fs10.default.unlinkSync(adminPath);
5087
- }
5088
- }
5089
- runSystemctl(["daemon-reload"]);
5090
- runSystemctlIgnoreFailure(["reset-failed"]);
5091
- output.write(`Removed systemd unit: ${mainPath}
5092
- `);
5093
- if (options.removeAdmin) {
5094
- output.write(`Removed systemd unit: ${adminPath}
5095
- `);
5096
- }
5097
- output.write("Done.\n");
5098
- }
5099
- function restartSystemdServices(options) {
5100
- assertLinuxWithSystemd();
5101
- assertRootPrivileges();
5102
- const output = options.output ?? process.stdout;
5103
- runSystemctl(["restart", MAIN_SERVICE_NAME]);
5104
- output.write(`Restarted service: ${MAIN_SERVICE_NAME}
5105
- `);
5106
- if (options.restartAdmin) {
5107
- runSystemctl(["restart", ADMIN_SERVICE_NAME]);
5108
- output.write(`Restarted service: ${ADMIN_SERVICE_NAME}
5109
- `);
5110
- }
5111
- output.write("Done.\n");
5112
- }
5113
- function resolveUserHome(runUser) {
5114
- try {
5115
- const passwdRaw = import_node_fs10.default.readFileSync("/etc/passwd", "utf8");
5116
- const line = passwdRaw.split(/\r?\n/).find((item) => item.startsWith(`${runUser}:`));
5117
- if (!line) {
5118
- return null;
5557
+ const normalized = [];
5558
+ const seen = /* @__PURE__ */ new Set();
5559
+ for (const room of rooms) {
5560
+ const roomId = room.roomId.trim();
5561
+ if (!roomId) {
5562
+ throw new Error("roomId is required for every room in snapshot.");
5119
5563
  }
5120
- const fields = line.split(":");
5121
- return fields[5] ? fields[5].trim() : null;
5122
- } catch {
5123
- return null;
5564
+ if (seen.has(roomId)) {
5565
+ throw new Error(`Duplicate roomId in snapshot: ${roomId}`);
5566
+ }
5567
+ seen.add(roomId);
5568
+ const workdir = import_node_path10.default.resolve(cwd, room.workdir);
5569
+ ensureDirectory2(workdir, `room workdir (${roomId})`);
5570
+ normalized.push({
5571
+ roomId,
5572
+ enabled: room.enabled,
5573
+ allowMention: room.allowMention,
5574
+ allowReply: room.allowReply,
5575
+ allowActiveWindow: room.allowActiveWindow,
5576
+ allowPrefix: room.allowPrefix,
5577
+ workdir
5578
+ });
5124
5579
  }
5580
+ return normalized;
5125
5581
  }
5126
- function validateUnitOptions(options) {
5127
- validateSimpleValue(options.runUser, "runUser");
5128
- validateSimpleValue(options.runtimeHome, "runtimeHome");
5129
- validateSimpleValue(options.nodeBinPath, "nodeBinPath");
5130
- validateSimpleValue(options.cliScriptPath, "cliScriptPath");
5131
- }
5132
- function validateSimpleValue(value, key) {
5133
- if (!value.trim()) {
5134
- throw new Error(`${key} cannot be empty.`);
5582
+ function synchronizeRoomSettings(stateStore, rooms) {
5583
+ const incoming = new Map(rooms.map((room) => [room.roomId, room]));
5584
+ const existing = stateStore.listRoomSettings();
5585
+ for (const room of existing) {
5586
+ if (!incoming.has(room.roomId)) {
5587
+ stateStore.deleteRoomSettings(room.roomId);
5588
+ }
5135
5589
  }
5136
- if (/[\r\n]/.test(value)) {
5137
- throw new Error(`${key} contains invalid newline characters.`);
5590
+ for (const room of rooms) {
5591
+ stateStore.upsertRoomSettings(room);
5138
5592
  }
5139
5593
  }
5140
- function assertLinuxWithSystemd() {
5141
- if (process.platform !== "linux") {
5142
- throw new Error("Systemd service install only supports Linux.");
5143
- }
5144
- try {
5145
- (0, import_node_child_process5.execFileSync)("systemctl", ["--version"], { stdio: "ignore" });
5146
- } catch {
5147
- throw new Error("systemctl is required but not found.");
5594
+ function persistEnvSnapshot(cwd, env) {
5595
+ const envPath = import_node_path10.default.resolve(cwd, ".env");
5596
+ const examplePath = import_node_path10.default.resolve(cwd, ".env.example");
5597
+ const template = import_node_fs9.default.existsSync(envPath) ? import_node_fs9.default.readFileSync(envPath, "utf8") : import_node_fs9.default.existsSync(examplePath) ? import_node_fs9.default.readFileSync(examplePath, "utf8") : "";
5598
+ const overrides = {};
5599
+ for (const key of CONFIG_SNAPSHOT_ENV_KEYS) {
5600
+ overrides[key] = env[key];
5148
5601
  }
5602
+ const next = applyEnvOverrides(template, overrides);
5603
+ import_node_fs9.default.writeFileSync(envPath, next, "utf8");
5149
5604
  }
5150
- function assertRootPrivileges() {
5151
- if (typeof process.getuid !== "function") {
5152
- return;
5605
+ function ensureDirectory2(dirPath, label) {
5606
+ if (!import_node_fs9.default.existsSync(dirPath) || !import_node_fs9.default.statSync(dirPath).isDirectory()) {
5607
+ throw new Error(`${label} does not exist or is not a directory: ${dirPath}`);
5153
5608
  }
5154
- if (process.getuid() !== 0) {
5155
- throw new Error("Root privileges are required. Run with sudo.");
5609
+ }
5610
+ function parseIntStrict(raw) {
5611
+ const value = Number.parseInt(raw, 10);
5612
+ if (!Number.isFinite(value)) {
5613
+ throw new Error(`Invalid integer value: ${raw}`);
5156
5614
  }
5615
+ return value;
5157
5616
  }
5158
- function ensureUserExists(runUser) {
5159
- runCommand("id", ["-u", runUser]);
5617
+ function serializeJsonObject(value) {
5618
+ return Object.keys(value).length > 0 ? JSON.stringify(value) : "";
5160
5619
  }
5161
- function resolveUserGroup(runUser) {
5162
- return runCommand("id", ["-gn", runUser]).trim();
5620
+ function booleanStringSchema(key) {
5621
+ return import_zod2.z.string().refine((value) => BOOLEAN_STRING.test(value), {
5622
+ message: `${key} must be a boolean string (true/false).`
5623
+ });
5163
5624
  }
5164
- function runSystemctl(args) {
5165
- runCommand("systemctl", args);
5625
+ function integerStringSchema(key, min, max = Number.MAX_SAFE_INTEGER) {
5626
+ return import_zod2.z.string().refine((value) => {
5627
+ const trimmed = value.trim();
5628
+ if (!INTEGER_STRING.test(trimmed)) {
5629
+ return false;
5630
+ }
5631
+ const parsed = Number.parseInt(trimmed, 10);
5632
+ if (!Number.isFinite(parsed)) {
5633
+ return false;
5634
+ }
5635
+ return parsed >= min && parsed <= max;
5636
+ }, {
5637
+ message: `${key} must be an integer string in range [${min}, ${max}].`
5638
+ });
5166
5639
  }
5167
- function stopAndDisableIfPresent(unitName) {
5168
- runSystemctlIgnoreFailure(["disable", "--now", unitName]);
5640
+ function jsonObjectStringSchema(key, allowEmpty) {
5641
+ return import_zod2.z.string().refine((value) => {
5642
+ const trimmed = value.trim();
5643
+ if (!trimmed) {
5644
+ return allowEmpty;
5645
+ }
5646
+ let parsed;
5647
+ try {
5648
+ parsed = JSON.parse(trimmed);
5649
+ } catch {
5650
+ return false;
5651
+ }
5652
+ return Boolean(parsed) && typeof parsed === "object" && !Array.isArray(parsed);
5653
+ }, {
5654
+ message: `${key} must be an empty string or a JSON object string.`
5655
+ });
5169
5656
  }
5170
- function runSystemctlIgnoreFailure(args) {
5171
- try {
5172
- runCommand("systemctl", args);
5173
- } catch {
5657
+
5658
+ // src/preflight.ts
5659
+ var import_node_child_process5 = require("child_process");
5660
+ var import_node_fs10 = __toESM(require("fs"));
5661
+ var import_node_path11 = __toESM(require("path"));
5662
+ var import_node_util3 = require("util");
5663
+ var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process5.execFile);
5664
+ var REQUIRED_ENV_KEYS = ["MATRIX_HOMESERVER", "MATRIX_USER_ID", "MATRIX_ACCESS_TOKEN"];
5665
+ async function runStartupPreflight(options = {}) {
5666
+ const env = options.env ?? process.env;
5667
+ const cwd = options.cwd ?? process.cwd();
5668
+ const checkCodexBinary = options.checkCodexBinary ?? defaultCheckCodexBinary;
5669
+ const fileExists = options.fileExists ?? import_node_fs10.default.existsSync;
5670
+ const isDirectory = options.isDirectory ?? defaultIsDirectory;
5671
+ const issues = [];
5672
+ const envPath = import_node_path11.default.resolve(cwd, ".env");
5673
+ if (!fileExists(envPath)) {
5674
+ issues.push({
5675
+ level: "warn",
5676
+ code: "missing_dotenv",
5677
+ check: ".env",
5678
+ message: `No .env file found at ${envPath}.`,
5679
+ fix: 'Run "codeharbor init" to create baseline config.'
5680
+ });
5174
5681
  }
5175
- }
5176
- function runCommand(file, args) {
5177
- try {
5178
- return (0, import_node_child_process5.execFileSync)(file, args, {
5179
- encoding: "utf8",
5180
- stdio: ["ignore", "pipe", "pipe"]
5682
+ for (const key of REQUIRED_ENV_KEYS) {
5683
+ if (!readEnv(env, key)) {
5684
+ issues.push({
5685
+ level: "error",
5686
+ code: "missing_env",
5687
+ check: key,
5688
+ message: `${key} is required.`,
5689
+ fix: `Run "codeharbor init" or set ${key} in .env.`
5690
+ });
5691
+ }
5692
+ }
5693
+ const matrixHomeserver = readEnv(env, "MATRIX_HOMESERVER");
5694
+ if (matrixHomeserver) {
5695
+ try {
5696
+ new URL(matrixHomeserver);
5697
+ } catch {
5698
+ issues.push({
5699
+ level: "error",
5700
+ code: "invalid_matrix_homeserver",
5701
+ check: "MATRIX_HOMESERVER",
5702
+ message: `Invalid URL: "${matrixHomeserver}".`,
5703
+ fix: "Set MATRIX_HOMESERVER to a full URL, for example https://matrix.example.com."
5704
+ });
5705
+ }
5706
+ }
5707
+ const matrixUserId = readEnv(env, "MATRIX_USER_ID");
5708
+ if (matrixUserId && !/^@[^:\s]+:.+/.test(matrixUserId)) {
5709
+ issues.push({
5710
+ level: "error",
5711
+ code: "invalid_matrix_user_id",
5712
+ check: "MATRIX_USER_ID",
5713
+ message: `Unexpected Matrix user id format: "${matrixUserId}".`,
5714
+ fix: "Set MATRIX_USER_ID like @bot:example.com."
5181
5715
  });
5716
+ }
5717
+ const codexBin = readEnv(env, "CODEX_BIN") || "codex";
5718
+ try {
5719
+ await checkCodexBinary(codexBin);
5182
5720
  } catch (error) {
5183
- throw new Error(formatCommandError(file, args, error), { cause: error });
5721
+ const reason = error instanceof Error && error.message ? ` (${error.message})` : "";
5722
+ issues.push({
5723
+ level: "error",
5724
+ code: "missing_codex_bin",
5725
+ check: "CODEX_BIN",
5726
+ message: `Unable to execute "${codexBin}"${reason}.`,
5727
+ fix: `Install Codex CLI and ensure "${codexBin}" is in PATH, or set CODEX_BIN=/absolute/path/to/codex.`
5728
+ });
5729
+ }
5730
+ const configuredWorkdir = readEnv(env, "CODEX_WORKDIR");
5731
+ const workdir = import_node_path11.default.resolve(cwd, configuredWorkdir || cwd);
5732
+ if (!fileExists(workdir) || !isDirectory(workdir)) {
5733
+ issues.push({
5734
+ level: "error",
5735
+ code: "invalid_codex_workdir",
5736
+ check: "CODEX_WORKDIR",
5737
+ message: `Working directory does not exist or is not a directory: ${workdir}.`,
5738
+ fix: `Set CODEX_WORKDIR to an existing directory, for example CODEX_WORKDIR=${cwd}.`
5739
+ });
5184
5740
  }
5741
+ return {
5742
+ ok: issues.every((issue) => issue.level !== "error"),
5743
+ issues
5744
+ };
5185
5745
  }
5186
- function formatCommandError(file, args, error) {
5187
- const command = `${file} ${args.join(" ")}`.trim();
5188
- if (error && typeof error === "object") {
5189
- const maybeError = error;
5190
- const stderr = bufferToTrimmedString(maybeError.stderr);
5191
- const stdout = bufferToTrimmedString(maybeError.stdout);
5192
- const details = stderr || stdout || maybeError.message || "command failed";
5193
- return `Command failed: ${command}. ${details}`;
5746
+ function formatPreflightReport(result, commandName) {
5747
+ const lines = [];
5748
+ const errors = result.issues.filter((issue) => issue.level === "error").length;
5749
+ const warnings = result.issues.filter((issue) => issue.level === "warn").length;
5750
+ if (result.ok) {
5751
+ lines.push(`Preflight check passed for "codeharbor ${commandName}" with ${warnings} warning(s).`);
5752
+ } else {
5753
+ lines.push(
5754
+ `Preflight check failed for "codeharbor ${commandName}" with ${errors} error(s) and ${warnings} warning(s).`
5755
+ );
5194
5756
  }
5195
- return `Command failed: ${command}. ${String(error)}`;
5757
+ for (const issue of result.issues) {
5758
+ const level = issue.level.toUpperCase();
5759
+ lines.push(`- [${level}] ${issue.check}: ${issue.message}`);
5760
+ lines.push(` fix: ${issue.fix}`);
5761
+ }
5762
+ return `${lines.join("\n")}
5763
+ `;
5196
5764
  }
5197
- function bufferToTrimmedString(value) {
5198
- if (!value) {
5199
- return "";
5765
+ async function defaultCheckCodexBinary(bin) {
5766
+ await execFileAsync3(bin, ["--version"]);
5767
+ }
5768
+ function defaultIsDirectory(targetPath) {
5769
+ try {
5770
+ return import_node_fs10.default.statSync(targetPath).isDirectory();
5771
+ } catch {
5772
+ return false;
5200
5773
  }
5201
- const text = typeof value === "string" ? value : value.toString("utf8");
5202
- return text.trim();
5774
+ }
5775
+ function readEnv(env, key) {
5776
+ return env[key]?.trim() ?? "";
5203
5777
  }
5204
5778
 
5205
5779
  // src/cli.ts
@@ -5302,6 +5876,7 @@ configCommand.command("import").description("Import config snapshot from JSON").
5302
5876
  });
5303
5877
  serviceCommand.command("install").description("Install and enable codeharbor systemd service (requires root)").option("--run-user <user>", "service user (default: sudo user or current user)").option("--runtime-home <path>", "runtime home used as CODEHARBOR_HOME").option("--with-admin", "also install codeharbor-admin.service").option("--no-start", "enable service without starting immediately").action((options) => {
5304
5878
  try {
5879
+ maybeReexecServiceCommandWithSudo();
5305
5880
  const runUser = options.runUser?.trim() || resolveDefaultRunUser();
5306
5881
  const runtimeHomePath = resolveRuntimeHomeForUser(runUser, process.env, options.runtimeHome);
5307
5882
  installSystemdServices({
@@ -5317,9 +5892,10 @@ serviceCommand.command("install").description("Install and enable codeharbor sys
5317
5892
  `);
5318
5893
  process.stderr.write(
5319
5894
  [
5320
- "Hint: run with sudo and absolute CLI path, for example:",
5321
- ' sudo "$(command -v codeharbor)" service install --with-admin',
5322
- " (remove --with-admin if you only want the main service)",
5895
+ "Hint:",
5896
+ " - Run directly: codeharbor service install --with-admin",
5897
+ " - The command auto-elevates with sudo when needed.",
5898
+ ` - Fallback explicit form: ${buildExplicitSudoCommand("service install --with-admin")}`,
5323
5899
  ""
5324
5900
  ].join("\n")
5325
5901
  );
@@ -5328,6 +5904,7 @@ serviceCommand.command("install").description("Install and enable codeharbor sys
5328
5904
  });
5329
5905
  serviceCommand.command("uninstall").description("Remove codeharbor systemd service (requires root)").option("--with-admin", "also remove codeharbor-admin.service").action((options) => {
5330
5906
  try {
5907
+ maybeReexecServiceCommandWithSudo();
5331
5908
  uninstallSystemdServices({
5332
5909
  removeAdmin: options.withAdmin ?? false
5333
5910
  });
@@ -5336,8 +5913,10 @@ serviceCommand.command("uninstall").description("Remove codeharbor systemd servi
5336
5913
  `);
5337
5914
  process.stderr.write(
5338
5915
  [
5339
- "Hint: run with sudo and absolute CLI path, for example:",
5340
- ' sudo "$(command -v codeharbor)" service uninstall --with-admin',
5916
+ "Hint:",
5917
+ " - Run directly: codeharbor service uninstall --with-admin",
5918
+ " - The command auto-elevates with sudo when needed.",
5919
+ ` - Fallback explicit form: ${buildExplicitSudoCommand("service uninstall --with-admin")}`,
5341
5920
  ""
5342
5921
  ].join("\n")
5343
5922
  );
@@ -5346,6 +5925,7 @@ serviceCommand.command("uninstall").description("Remove codeharbor systemd servi
5346
5925
  });
5347
5926
  serviceCommand.command("restart").description("Restart installed codeharbor systemd service (requires root)").option("--with-admin", "also restart codeharbor-admin.service").action((options) => {
5348
5927
  try {
5928
+ maybeReexecServiceCommandWithSudo();
5349
5929
  restartSystemdServices({
5350
5930
  restartAdmin: options.withAdmin ?? false
5351
5931
  });
@@ -5354,9 +5934,10 @@ serviceCommand.command("restart").description("Restart installed codeharbor syst
5354
5934
  `);
5355
5935
  process.stderr.write(
5356
5936
  [
5357
- "Hint: run with sudo and absolute CLI path, for example:",
5358
- ' sudo "$(command -v codeharbor)" service restart --with-admin',
5359
- " (remove --with-admin if you only want the main service)",
5937
+ "Hint:",
5938
+ " - Run directly: codeharbor service restart --with-admin",
5939
+ " - The command auto-elevates with sudo when needed.",
5940
+ ` - Fallback explicit form: ${buildExplicitSudoCommand("service restart --with-admin")}`,
5360
5941
  ""
5361
5942
  ].join("\n")
5362
5943
  );
@@ -5439,6 +6020,32 @@ function resolveCliScriptPath() {
5439
6020
  }
5440
6021
  return import_node_path12.default.resolve(__dirname, "cli.js");
5441
6022
  }
6023
+ function maybeReexecServiceCommandWithSudo() {
6024
+ if (typeof process.getuid !== "function" || process.getuid() === 0) {
6025
+ return;
6026
+ }
6027
+ const serviceArgs = process.argv.slice(2);
6028
+ if (serviceArgs.length === 0 || serviceArgs[0] !== "service") {
6029
+ return;
6030
+ }
6031
+ const cliScriptPath = resolveCliScriptPath();
6032
+ const child = (0, import_node_child_process6.spawnSync)("sudo", [process.execPath, cliScriptPath, ...serviceArgs], {
6033
+ stdio: "inherit"
6034
+ });
6035
+ if (child.error) {
6036
+ throw new Error(`failed to auto-elevate with sudo: ${child.error.message}`);
6037
+ }
6038
+ process.exit(child.status ?? 1);
6039
+ }
6040
+ function shellQuote(value) {
6041
+ if (!value) {
6042
+ return "''";
6043
+ }
6044
+ return `'${value.replace(/'/g, `'\\''`)}'`;
6045
+ }
6046
+ function buildExplicitSudoCommand(subcommand) {
6047
+ return `sudo ${shellQuote(process.execPath)} ${shellQuote(resolveCliScriptPath())} ${subcommand}`;
6048
+ }
5442
6049
  function formatError3(error) {
5443
6050
  if (error instanceof Error) {
5444
6051
  return error.message;