@teamclaws/teamclaw 2026.3.25 → 2026.3.26-2

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/cli.mjs CHANGED
@@ -1,14 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawnSync } from "node:child_process";
4
+ import fsSync from "node:fs";
4
5
  import fs from "node:fs/promises";
6
+ import http from "node:http";
7
+ import { createRequire } from "node:module";
5
8
  import os from "node:os";
6
9
  import path from "node:path";
7
10
  import process from "node:process";
8
11
  import { createInterface } from "node:readline/promises";
9
12
  import JSON5 from "json5";
10
13
 
11
- const PACKAGE_NAME = "@teamclaws/teamclaw";
14
+ const require = createRequire(import.meta.url);
15
+ const packageMetadata = require("./package.json");
16
+ const PACKAGE_ROOT = path.dirname(require.resolve("./package.json"));
17
+ const PACKAGE_NAME = packageMetadata.name;
18
+ const PACKAGE_VERSION = packageMetadata.version;
19
+ const PACKAGE_INSTALL_SPEC = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
12
20
  const PLUGIN_ID = "teamclaw";
13
21
  const DEFAULT_TEAMCLAW_IMAGE = "ghcr.io/topcheer/teamclaw-openclaw:latest";
14
22
  const DEFAULT_CONTROLLER_PORT = 9527;
@@ -18,7 +26,7 @@ const DEFAULT_TEAM_NAME = "default";
18
26
  const DEFAULT_TASK_TIMEOUT_MS = 1_800_000;
19
27
  const DEFAULT_AGENT_TIMEOUT_SECONDS = 2_400;
20
28
  const DEFAULT_LOCAL_ROLES = ["architect", "developer", "qa"];
21
- const DEFAULT_PROVISIONING_ROLES = ["architect", "developer", "qa"];
29
+ const LEGACY_DEFAULT_PROVISIONING_ROLES = ["architect", "developer", "qa"];
22
30
 
23
31
  const ROLE_OPTIONS = [
24
32
  { value: "pm", label: "Product Manager" },
@@ -182,6 +190,30 @@ function resolveDefaultOpenClawWorkspaceDir(env = process.env) {
182
190
  return path.join(resolveDefaultOpenClawStateDir(env), "workspace");
183
191
  }
184
192
 
193
+ function resolveOpenClawStateDirForConfigPath(configPath) {
194
+ return path.dirname(path.resolve(configPath));
195
+ }
196
+
197
+ function resolveOpenClawWorkspaceDirForConfigPath(configPath) {
198
+ return path.join(resolveOpenClawStateDirForConfigPath(configPath), "workspace");
199
+ }
200
+
201
+ function sanitizeInstallerPathSegment(value) {
202
+ const normalized = String(value || "")
203
+ .toLowerCase()
204
+ .replace(/[^a-z0-9-]+/g, "-")
205
+ .replace(/^-+|-+$/g, "");
206
+ return normalized || "default";
207
+ }
208
+
209
+ function resolveDefaultTeamClawWorkspaceDir(configPath, teamName) {
210
+ return path.join(
211
+ resolveOpenClawStateDirForConfigPath(configPath),
212
+ "teamclaw-workspaces",
213
+ sanitizeInstallerPathSegment(teamName),
214
+ );
215
+ }
216
+
185
217
  async function pathExists(targetPath) {
186
218
  try {
187
219
  await fs.access(targetPath);
@@ -302,10 +334,40 @@ function getCurrentWorkspacePath(config) {
302
334
  return typeof defaults.workspace === "string" ? expandUserPath(defaults.workspace) : "";
303
335
  }
304
336
 
337
+ function resolveInstallerWorkspaceDefault(configPath, config, teamName) {
338
+ const currentWorkspacePath = getCurrentWorkspacePath(config);
339
+ const sharedWorkspacePath = resolveOpenClawWorkspaceDirForConfigPath(configPath);
340
+ if (currentWorkspacePath && path.resolve(currentWorkspacePath) !== path.resolve(sharedWorkspacePath)) {
341
+ return currentWorkspacePath;
342
+ }
343
+ return resolveDefaultTeamClawWorkspaceDir(configPath, teamName);
344
+ }
345
+
305
346
  function dedupeStrings(values) {
306
347
  return Array.from(new Set(values.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim())));
307
348
  }
308
349
 
350
+ function hasSameStringSet(left, right) {
351
+ const normalizedLeft = dedupeStrings(left).slice().sort();
352
+ const normalizedRight = dedupeStrings(right).slice().sort();
353
+ if (normalizedLeft.length !== normalizedRight.length) {
354
+ return false;
355
+ }
356
+ return normalizedLeft.every((value, index) => value === normalizedRight[index]);
357
+ }
358
+
359
+ function normalizeConfiguredRoleList(raw) {
360
+ return Array.isArray(raw) ? dedupeStrings(raw) : [];
361
+ }
362
+
363
+ function resolveDefaultProvisioningRoles(existingTeamClaw) {
364
+ const existingRoles = normalizeConfiguredRoleList(existingTeamClaw.workerProvisioningRoles);
365
+ if (existingRoles.length === 0) {
366
+ return [];
367
+ }
368
+ return hasSameStringSet(existingRoles, LEGACY_DEFAULT_PROVISIONING_ROLES) ? [] : existingRoles;
369
+ }
370
+
309
371
  function extractModelOptions(config) {
310
372
  const currentModel = getCurrentModel(config);
311
373
  const models = [];
@@ -512,6 +574,28 @@ async function promptRoleList(prompter, message, defaultRoles) {
512
574
  return parseRoleList(raw).values;
513
575
  }
514
576
 
577
+ async function promptOptionalRoleList(prompter, message, defaultRoles) {
578
+ const defaultValue = defaultRoles.join(",");
579
+ if (!prompter.yes) {
580
+ console.log(
581
+ `Available roles: ${ROLE_OPTIONS.map((option) => `${option.value} (${option.label})`).join(", ")}. These are preferred defaults only; task-required roles can still launch automatically.`,
582
+ );
583
+ }
584
+ const raw = await prompter.text({
585
+ message,
586
+ defaultValue,
587
+ allowEmpty: true,
588
+ validate: (value) => {
589
+ const parsed = parseRoleList(value);
590
+ if (parsed.invalid.length > 0) {
591
+ return `Unknown role ids: ${parsed.invalid.join(", ")}`;
592
+ }
593
+ return "";
594
+ },
595
+ });
596
+ return parseRoleList(raw).values;
597
+ }
598
+
515
599
  function buildStartCommand(configPath) {
516
600
  const defaultPath = resolveDefaultOpenClawConfigPath();
517
601
  if (path.resolve(configPath) === path.resolve(defaultPath)) {
@@ -527,6 +611,57 @@ function shellEscape(value) {
527
611
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
528
612
  }
529
613
 
614
+ function isControllerInstallMode(installMode) {
615
+ return installMode !== "worker";
616
+ }
617
+
618
+ function isOnDemandControllerInstallMode(installMode) {
619
+ return installMode === "controller-process" || installMode === "controller-docker" || installMode === "controller-kubernetes";
620
+ }
621
+
622
+ function describeProvisioningRoles(roles) {
623
+ return Array.isArray(roles) && roles.length > 0
624
+ ? `${roles.join(", ")} (plus any task-required roles)`
625
+ : "all TeamClaw roles (controller decides at runtime)";
626
+ }
627
+
628
+ function getLocalUiUrl(port) {
629
+ return `http://127.0.0.1:${port}/ui`;
630
+ }
631
+
632
+ function rankLanAddress(address) {
633
+ if (address.startsWith("192.168.")) {
634
+ return 0;
635
+ }
636
+ if (address.startsWith("10.")) {
637
+ return 1;
638
+ }
639
+ const parts = address.split(".").map((value) => Number.parseInt(value, 10));
640
+ if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) {
641
+ return 2;
642
+ }
643
+ return 3;
644
+ }
645
+
646
+ function listLanUiUrls(port) {
647
+ const urls = [];
648
+ const interfaces = os.networkInterfaces();
649
+ for (const records of Object.values(interfaces)) {
650
+ for (const record of records ?? []) {
651
+ if (!record || record.internal || record.family !== "IPv4") {
652
+ continue;
653
+ }
654
+ urls.push({
655
+ address: record.address,
656
+ url: `http://${record.address}:${port}/ui`,
657
+ });
658
+ }
659
+ }
660
+ return urls
661
+ .sort((left, right) => rankLanAddress(left.address) - rankLanAddress(right.address) || left.address.localeCompare(right.address))
662
+ .map((entry) => entry.url);
663
+ }
664
+
530
665
  function installPluginWithCommand(command, args, env) {
531
666
  const result = spawnSync(command, args, {
532
667
  stdio: "inherit",
@@ -539,7 +674,96 @@ function installPluginWithCommand(command, args, env) {
539
674
  };
540
675
  }
541
676
 
542
- function attemptPluginInstall({ configPath }) {
677
+ function runGatewayCommand(command, args, env) {
678
+ const result = spawnSync(command, args, {
679
+ env,
680
+ encoding: "utf8",
681
+ });
682
+ return {
683
+ status: result.status ?? 1,
684
+ signal: result.signal ?? null,
685
+ error: result.error ?? null,
686
+ stdout: result.stdout ?? "",
687
+ stderr: result.stderr ?? "",
688
+ };
689
+ }
690
+
691
+ function readJsonIfExists(filePath) {
692
+ try {
693
+ if (!fsSync.existsSync(filePath)) {
694
+ return null;
695
+ }
696
+ return JSON.parse(fsSync.readFileSync(filePath, "utf8"));
697
+ } catch {
698
+ return null;
699
+ }
700
+ }
701
+
702
+ function inspectInstalledPlugin(configPath) {
703
+ const stateDir = path.dirname(path.resolve(configPath));
704
+ const pluginDir = path.join(stateDir, "extensions", PLUGIN_ID);
705
+ if (!fsSync.existsSync(pluginDir)) {
706
+ return null;
707
+ }
708
+ const manifest = readJsonIfExists(path.join(pluginDir, "openclaw.plugin.json"));
709
+ const packageJson = readJsonIfExists(path.join(pluginDir, "package.json"));
710
+ const version = typeof manifest?.version === "string" && manifest.version.trim()
711
+ ? manifest.version.trim()
712
+ : typeof packageJson?.version === "string" && packageJson.version.trim()
713
+ ? packageJson.version.trim()
714
+ : "";
715
+ return {
716
+ pluginDir,
717
+ version: version || null,
718
+ };
719
+ }
720
+
721
+ function createPackageTarball(env) {
722
+ let tempDir = "";
723
+ try {
724
+ tempDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "teamclaw-installer-pack-"));
725
+ const result = spawnSync(
726
+ "npm",
727
+ ["pack", PACKAGE_ROOT, "--pack-destination", tempDir, "--json", "--ignore-scripts"],
728
+ {
729
+ env,
730
+ encoding: "utf8",
731
+ },
732
+ );
733
+ if (result.status !== 0 || result.error) {
734
+ const detail = result.error
735
+ ? result.error.message
736
+ : (result.stderr || result.stdout || `exited with code ${result.status}`).trim();
737
+ throw new Error(detail || "npm pack failed");
738
+ }
739
+ const payload = JSON.parse(result.stdout);
740
+ const filename = Array.isArray(payload) && payload[0] && typeof payload[0].filename === "string"
741
+ ? payload[0].filename.trim()
742
+ : "";
743
+ if (!filename) {
744
+ throw new Error("npm pack did not report a tarball filename");
745
+ }
746
+ const tarballPath = path.join(tempDir, filename);
747
+ if (!fsSync.existsSync(tarballPath)) {
748
+ throw new Error(`tarball was not created at ${tarballPath}`);
749
+ }
750
+ return {
751
+ ok: true,
752
+ tempDir,
753
+ tarballPath,
754
+ };
755
+ } catch (error) {
756
+ if (tempDir) {
757
+ fsSync.rmSync(tempDir, { recursive: true, force: true });
758
+ }
759
+ return {
760
+ ok: false,
761
+ error: error instanceof Error ? error.message : String(error),
762
+ };
763
+ }
764
+ }
765
+
766
+ function attemptPluginUninstall({ configPath }) {
543
767
  const env = {
544
768
  ...process.env,
545
769
  OPENCLAW_CONFIG_PATH: configPath,
@@ -548,18 +772,18 @@ function attemptPluginInstall({ configPath }) {
548
772
  {
549
773
  label: "openclaw",
550
774
  command: "openclaw",
551
- args: ["plugins", "install", PACKAGE_NAME],
775
+ args: ["plugins", "uninstall", PLUGIN_ID, "--force"],
552
776
  },
553
777
  {
554
778
  label: "npm exec fallback",
555
779
  command: "npm",
556
- args: ["exec", "-y", "openclaw@latest", "--", "plugins", "install", PACKAGE_NAME],
780
+ args: ["exec", "-y", "openclaw@latest", "--", "plugins", "uninstall", PLUGIN_ID, "--force"],
557
781
  },
558
782
  ];
559
-
783
+ const failures = [];
560
784
  for (let index = 0; index < candidates.length; index += 1) {
561
785
  const candidate = candidates[index];
562
- console.log(`\nInstalling ${PACKAGE_NAME} with ${candidate.label}...`);
786
+ console.log(`\nRemoving existing ${PLUGIN_ID} plugin with ${candidate.label}...`);
563
787
  const result = installPluginWithCommand(candidate.command, candidate.args, env);
564
788
  if (result.status === 0 && !result.error) {
565
789
  return {
@@ -568,31 +792,252 @@ function attemptPluginInstall({ configPath }) {
568
792
  };
569
793
  }
570
794
  const errorCode = result.error && typeof result.error === "object" ? result.error.code : "";
571
- if (errorCode === "ENOENT" && index < candidates.length - 1) {
572
- console.log(`${candidate.command} was not found. Trying the npm exec fallback...`);
573
- continue;
574
- }
575
795
  const detail = result.error
576
796
  ? result.error.message
577
797
  : result.signal
578
798
  ? `terminated by signal ${result.signal}`
579
799
  : `exited with code ${result.status}`;
800
+ failures.push(`${candidate.label} failed: ${detail}`);
801
+ if (errorCode === "ENOENT" && index < candidates.length - 1) {
802
+ console.log(`${candidate.command} was not found. Trying the next uninstall fallback...`);
803
+ continue;
804
+ }
805
+ break;
806
+ }
807
+ return {
808
+ ok: false,
809
+ error: failures.join("; "),
810
+ };
811
+ }
812
+
813
+ function attemptPluginInstall({ configPath }) {
814
+ const env = {
815
+ ...process.env,
816
+ OPENCLAW_CONFIG_PATH: configPath,
817
+ };
818
+ const installedPlugin = inspectInstalledPlugin(configPath);
819
+ if (installedPlugin?.version === PACKAGE_VERSION) {
820
+ console.log(
821
+ `\nFound existing TeamClaw plugin at ${installedPlugin.pluginDir} (version ${installedPlugin.version}). Skipping plugin reinstall.`,
822
+ );
823
+ return {
824
+ ok: true,
825
+ method: `already installed (${installedPlugin.version})`,
826
+ skipped: true,
827
+ };
828
+ }
829
+ if (installedPlugin) {
830
+ const installedVersion = installedPlugin.version ? `version ${installedPlugin.version}` : "an unknown version";
831
+ console.log(
832
+ `\nFound existing TeamClaw plugin at ${installedPlugin.pluginDir} (${installedVersion}). Removing it before install...`,
833
+ );
834
+ const uninstallResult = attemptPluginUninstall({ configPath });
835
+ if (!uninstallResult.ok) {
836
+ return {
837
+ ok: false,
838
+ error: `Could not remove existing TeamClaw plugin at ${installedPlugin.pluginDir}: ${uninstallResult.error}`,
839
+ };
840
+ }
841
+ }
842
+ const candidates = [];
843
+ const tarballResult = createPackageTarball(env);
844
+ if (tarballResult.ok) {
845
+ console.log(
846
+ `\nPacked ${PACKAGE_INSTALL_SPEC} into ${path.basename(tarballResult.tarballPath)} for local plugin install.`,
847
+ );
848
+ candidates.push(
849
+ {
850
+ label: "openclaw (local tarball)",
851
+ command: "openclaw",
852
+ args: ["plugins", "install", tarballResult.tarballPath],
853
+ targetDescription: tarballResult.tarballPath,
854
+ },
855
+ {
856
+ label: "npm exec fallback (local tarball)",
857
+ command: "npm",
858
+ args: ["exec", "-y", "openclaw@latest", "--", "plugins", "install", tarballResult.tarballPath],
859
+ targetDescription: tarballResult.tarballPath,
860
+ },
861
+ );
862
+ } else {
863
+ console.log(
864
+ `\nCould not pack ${PACKAGE_INSTALL_SPEC} into a local tarball (${tarballResult.error}). Falling back to registry install...`,
865
+ );
866
+ }
867
+ candidates.push(
868
+ {
869
+ label: "openclaw (exact version fallback)",
870
+ command: "openclaw",
871
+ args: ["plugins", "install", PACKAGE_INSTALL_SPEC],
872
+ targetDescription: PACKAGE_INSTALL_SPEC,
873
+ },
874
+ {
875
+ label: "npm exec fallback (exact version fallback)",
876
+ command: "npm",
877
+ args: ["exec", "-y", "openclaw@latest", "--", "plugins", "install", PACKAGE_INSTALL_SPEC],
878
+ targetDescription: PACKAGE_INSTALL_SPEC,
879
+ },
880
+ );
881
+
882
+ try {
883
+ const failures = [];
884
+ for (let index = 0; index < candidates.length; index += 1) {
885
+ const candidate = candidates[index];
886
+ console.log(`\nInstalling ${candidate.targetDescription} with ${candidate.label}...`);
887
+ const result = installPluginWithCommand(candidate.command, candidate.args, env);
888
+ if (result.status === 0 && !result.error) {
889
+ return {
890
+ ok: true,
891
+ method: candidate.label,
892
+ };
893
+ }
894
+ const errorCode = result.error && typeof result.error === "object" ? result.error.code : "";
895
+ const detail = result.error
896
+ ? result.error.message
897
+ : result.signal
898
+ ? `terminated by signal ${result.signal}`
899
+ : `exited with code ${result.status}`;
900
+ failures.push(`${candidate.label} failed: ${detail}`);
901
+ if (errorCode === "ENOENT" && index < candidates.length - 1) {
902
+ console.log(`${candidate.command} was not found. Trying the next install fallback...`);
903
+ continue;
904
+ }
905
+ if (index < candidates.length - 1) {
906
+ console.log(`${candidate.label} failed (${detail}). Trying the next install fallback...`);
907
+ }
908
+ }
580
909
  return {
581
910
  ok: false,
582
- error: `${candidate.label} failed: ${detail}`,
911
+ error: failures.length > 0 ? failures.join("; ") : "No install command was available.",
583
912
  };
913
+ } finally {
914
+ if (tarballResult.ok) {
915
+ fsSync.rmSync(tarballResult.tempDir, { recursive: true, force: true });
916
+ }
584
917
  }
918
+ }
585
919
 
920
+ function attemptGatewayRestart({ configPath }) {
921
+ const env = {
922
+ ...process.env,
923
+ OPENCLAW_CONFIG_PATH: configPath,
924
+ };
925
+ const candidates = [
926
+ {
927
+ label: "openclaw",
928
+ command: "openclaw",
929
+ args: ["gateway", "restart"],
930
+ },
931
+ {
932
+ label: "npm exec fallback",
933
+ command: "npm",
934
+ args: ["exec", "-y", "openclaw@latest", "--", "gateway", "restart"],
935
+ },
936
+ ];
937
+ const failures = [];
938
+ for (let index = 0; index < candidates.length; index += 1) {
939
+ const candidate = candidates[index];
940
+ const result = runGatewayCommand(candidate.command, candidate.args, env);
941
+ if (result.status === 0 && !result.error) {
942
+ return {
943
+ ok: true,
944
+ method: candidate.label,
945
+ };
946
+ }
947
+ const detail = result.error
948
+ ? result.error.message
949
+ : (result.stderr || result.stdout || (result.signal
950
+ ? `terminated by signal ${result.signal}`
951
+ : `exited with code ${result.status}`)).trim();
952
+ failures.push(`${candidate.label} failed: ${detail}`);
953
+ const errorCode = result.error && typeof result.error === "object" ? result.error.code : "";
954
+ if (!(errorCode === "ENOENT" && index < candidates.length - 1)) {
955
+ break;
956
+ }
957
+ }
586
958
  return {
587
959
  ok: false,
588
- error: "No install command was available.",
960
+ error: failures.join("; "),
589
961
  };
590
962
  }
591
963
 
592
- async function collectInstallChoices(config, prompter) {
964
+ async function waitForControllerHealth(port) {
965
+ const url = `http://127.0.0.1:${port}/api/v1/health`;
966
+ const deadline = Date.now() + 30_000;
967
+ let lastError = "";
968
+ while (Date.now() < deadline) {
969
+ try {
970
+ const response = await new Promise((resolve, reject) => {
971
+ const request = http.get(
972
+ url,
973
+ {
974
+ agent: false,
975
+ headers: {
976
+ Connection: "close",
977
+ },
978
+ },
979
+ (incoming) => {
980
+ let body = "";
981
+ incoming.setEncoding("utf8");
982
+ incoming.on("data", (chunk) => {
983
+ body += chunk;
984
+ });
985
+ incoming.on("end", () => {
986
+ resolve({
987
+ statusCode: incoming.statusCode ?? 0,
988
+ body,
989
+ });
990
+ });
991
+ },
992
+ );
993
+ request.setTimeout(5_000, () => {
994
+ request.destroy(new Error("request timed out"));
995
+ });
996
+ request.on("error", reject);
997
+ });
998
+ if (response.statusCode >= 200 && response.statusCode < 300) {
999
+ const payload = JSON.parse(response.body);
1000
+ if (payload && payload.status === "ok" && payload.mode === "controller") {
1001
+ return {
1002
+ ok: true,
1003
+ url,
1004
+ };
1005
+ }
1006
+ lastError = "unexpected health payload";
1007
+ } else {
1008
+ lastError = `HTTP ${response.statusCode}`;
1009
+ }
1010
+ } catch (error) {
1011
+ lastError = error instanceof Error ? error.message : String(error);
1012
+ }
1013
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
1014
+ }
1015
+ return {
1016
+ ok: false,
1017
+ url,
1018
+ error: lastError || "timed out after 30s",
1019
+ };
1020
+ }
1021
+
1022
+ async function collectInstallChoices(configPath, config, prompter) {
593
1023
  const existingTeamClaw = getExistingTeamClawConfig(config);
594
1024
  const existingMode = typeof existingTeamClaw.mode === "string" ? existingTeamClaw.mode.trim() : "";
595
- const modeDefault = existingMode === "worker" ? "worker" : "single-local";
1025
+ const existingProvisioningType =
1026
+ typeof existingTeamClaw.workerProvisioningType === "string" ? existingTeamClaw.workerProvisioningType.trim() : "";
1027
+ let modeDefault = "single-local";
1028
+ if (existingMode === "worker") {
1029
+ modeDefault = "worker";
1030
+ } else if (existingMode === "controller") {
1031
+ if (existingProvisioningType === "docker") {
1032
+ modeDefault = "controller-docker";
1033
+ } else if (existingProvisioningType === "kubernetes") {
1034
+ modeDefault = "controller-kubernetes";
1035
+ } else if (existingProvisioningType === "process") {
1036
+ modeDefault = "controller-process";
1037
+ } else if (!Array.isArray(existingTeamClaw.localRoles) || existingTeamClaw.localRoles.length === 0) {
1038
+ modeDefault = "controller-manual";
1039
+ }
1040
+ }
596
1041
 
597
1042
  const installMode = await prompter.select({
598
1043
  message: "Choose an installation mode",
@@ -625,7 +1070,7 @@ async function collectInstallChoices(config, prompter) {
625
1070
  });
626
1071
  const workspacePath = expandUserPath(await prompter.text({
627
1072
  message: "OpenClaw workspace directory",
628
- defaultValue: getCurrentWorkspacePath(config) || resolveDefaultOpenClawWorkspaceDir(),
1073
+ defaultValue: resolveInstallerWorkspaceDefault(configPath, config, teamName),
629
1074
  }));
630
1075
 
631
1076
  if (installMode === "worker") {
@@ -705,12 +1150,10 @@ async function collectInstallChoices(config, prompter) {
705
1150
  };
706
1151
  }
707
1152
 
708
- const provisioningRoles = await promptRoleList(
1153
+ const provisioningRoles = await promptOptionalRoleList(
709
1154
  prompter,
710
- "On-demand roles to launch (comma-separated)",
711
- Array.isArray(existingTeamClaw.workerProvisioningRoles) && existingTeamClaw.workerProvisioningRoles.length > 0
712
- ? existingTeamClaw.workerProvisioningRoles
713
- : DEFAULT_PROVISIONING_ROLES,
1155
+ "Preferred on-demand roles (comma-separated, leave empty for controller-decided defaults)",
1156
+ resolveDefaultProvisioningRoles(existingTeamClaw),
714
1157
  );
715
1158
  const maxPerRole = await prompter.number({
716
1159
  message: "Maximum on-demand workers per role",
@@ -753,11 +1196,11 @@ async function collectInstallChoices(config, prompter) {
753
1196
  : DEFAULT_TEAMCLAW_IMAGE,
754
1197
  });
755
1198
  const dockerWorkspaceVolume = await prompter.text({
756
- message: "Docker workspace volume or host path (leave empty for ephemeral workspaces)",
1199
+ message: "Docker workspace volume or host path (leave empty for isolated ephemeral workspaces)",
757
1200
  defaultValue:
758
1201
  typeof existingTeamClaw.workerProvisioningDockerWorkspaceVolume === "string"
759
1202
  ? existingTeamClaw.workerProvisioningDockerWorkspaceVolume.trim()
760
- : "teamclaw-workspaces",
1203
+ : "",
761
1204
  allowEmpty: true,
762
1205
  });
763
1206
  return {
@@ -808,7 +1251,7 @@ async function collectInstallChoices(config, prompter) {
808
1251
  : "teamclaw-worker",
809
1252
  });
810
1253
  const kubernetesWorkspacePersistentVolumeClaim = await prompter.text({
811
- message: "Kubernetes workspace PVC (leave empty for ephemeral workspaces)",
1254
+ message: "Kubernetes workspace PVC (leave empty for isolated ephemeral workspaces)",
812
1255
  defaultValue:
813
1256
  typeof existingTeamClaw.workerProvisioningKubernetesWorkspacePersistentVolumeClaim === "string"
814
1257
  ? existingTeamClaw.workerProvisioningKubernetesWorkspacePersistentVolumeClaim.trim()
@@ -1048,19 +1491,38 @@ function buildSummaryLines(params) {
1048
1491
  }
1049
1492
  if (params.pluginInstallStatus === "installed") {
1050
1493
  lines.push(`Plugin install: completed via ${params.pluginInstallMethod}`);
1494
+ } else if (params.pluginInstallStatus === "already-installed") {
1495
+ lines.push(`Plugin install: ${params.pluginInstallMethod}`);
1051
1496
  } else if (params.pluginInstallStatus === "skipped") {
1052
1497
  lines.push("Plugin install: skipped");
1053
1498
  } else if (params.pluginInstallError) {
1054
1499
  lines.push(`Plugin install: ${params.pluginInstallError}`);
1055
1500
  }
1501
+ if (params.gatewayRestartStatus === "restarted") {
1502
+ lines.push(`Gateway restart: completed via ${params.gatewayRestartMethod}`);
1503
+ } else if (params.gatewayRestartStatus === "failed") {
1504
+ lines.push(`Gateway restart: ${params.gatewayRestartError}`);
1505
+ }
1506
+ if (params.controllerHealthStatus === "ok") {
1507
+ lines.push(`Controller health: ok (${params.controllerHealthUrl})`);
1508
+ } else if (params.controllerHealthStatus === "failed") {
1509
+ lines.push(`Controller health: ${params.controllerHealthError} (${params.controllerHealthUrl})`);
1510
+ }
1056
1511
  lines.push(`Start command: ${buildStartCommand(params.configPath)}`);
1057
1512
 
1058
- if (params.choices.installMode === "single-local") {
1059
- lines.push(`Open UI: http://127.0.0.1:${params.choices.controllerPort}/ui`);
1513
+ if (isControllerInstallMode(params.choices.installMode)) {
1514
+ const lanUiUrls = listLanUiUrls(params.choices.controllerPort);
1515
+ if (lanUiUrls.length > 0) {
1516
+ lines.push(`Open UI (LAN): ${lanUiUrls[0]}`);
1517
+ }
1518
+ lines.push(`Open UI (local): ${getLocalUiUrl(params.choices.controllerPort)}`);
1060
1519
  }
1061
1520
  if (params.choices.installMode === "controller-docker" || params.choices.installMode === "controller-kubernetes") {
1062
1521
  lines.push(`Provisioning image: ${params.choices.workerImage}`);
1063
1522
  }
1523
+ if (isOnDemandControllerInstallMode(params.choices.installMode)) {
1524
+ lines.push(`On-demand roles: ${describeProvisioningRoles(params.choices.provisioningRoles)}`);
1525
+ }
1064
1526
  if (params.choices.installMode === "controller-docker" && params.choices.dockerWorkspaceVolume) {
1065
1527
  lines.push(`Docker workspace volume: ${params.choices.dockerWorkspaceVolume}`);
1066
1528
  }
@@ -1112,7 +1574,7 @@ async function runInstall(options) {
1112
1574
  if (!options.skipPluginInstall && !options.dryRun) {
1113
1575
  const installResult = attemptPluginInstall({ configPath });
1114
1576
  if (installResult.ok) {
1115
- pluginInstallStatus = "installed";
1577
+ pluginInstallStatus = installResult.skipped ? "already-installed" : "installed";
1116
1578
  pluginInstallMethod = installResult.method;
1117
1579
  } else {
1118
1580
  pluginInstallStatus = "failed";
@@ -1129,7 +1591,7 @@ async function runInstall(options) {
1129
1591
  }
1130
1592
 
1131
1593
  const config = await readOpenClawConfig(configPath);
1132
- const choices = await collectInstallChoices(config, prompter);
1594
+ const choices = await collectInstallChoices(configPath, config, prompter);
1133
1595
  const nextConfig = applyInstallerChoices(config, choices);
1134
1596
 
1135
1597
  if (options.dryRun) {
@@ -1138,6 +1600,29 @@ async function runInstall(options) {
1138
1600
  await writeConfig(configPath, nextConfig);
1139
1601
  }
1140
1602
 
1603
+ let gatewayRestartStatus = "skipped";
1604
+ let gatewayRestartMethod = "";
1605
+ let gatewayRestartError = "";
1606
+ let controllerHealthStatus = "skipped";
1607
+ let controllerHealthUrl = "";
1608
+ let controllerHealthError = "";
1609
+ if (!options.dryRun) {
1610
+ const restartResult = attemptGatewayRestart({ configPath });
1611
+ if (restartResult.ok) {
1612
+ gatewayRestartStatus = "restarted";
1613
+ gatewayRestartMethod = restartResult.method;
1614
+ if (isControllerInstallMode(choices.installMode)) {
1615
+ const healthResult = await waitForControllerHealth(choices.controllerPort);
1616
+ controllerHealthStatus = healthResult.ok ? "ok" : "failed";
1617
+ controllerHealthUrl = healthResult.url;
1618
+ controllerHealthError = healthResult.error ?? "";
1619
+ }
1620
+ } else {
1621
+ gatewayRestartStatus = "failed";
1622
+ gatewayRestartError = restartResult.error;
1623
+ }
1624
+ }
1625
+
1141
1626
  const summaryLines = buildSummaryLines({
1142
1627
  configPath,
1143
1628
  choices,
@@ -1145,6 +1630,12 @@ async function runInstall(options) {
1145
1630
  pluginInstallStatus,
1146
1631
  pluginInstallMethod,
1147
1632
  pluginInstallError,
1633
+ gatewayRestartStatus,
1634
+ gatewayRestartMethod,
1635
+ gatewayRestartError,
1636
+ controllerHealthStatus,
1637
+ controllerHealthUrl,
1638
+ controllerHealthError,
1148
1639
  });
1149
1640
 
1150
1641
  prompter.note("\nTeamClaw installer summary");