@teamclaws/teamclaw 2026.3.24-5 → 2026.3.24-7

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
@@ -3,6 +3,7 @@
3
3
  import { spawnSync } from "node:child_process";
4
4
  import fsSync from "node:fs";
5
5
  import fs from "node:fs/promises";
6
+ import http from "node:http";
6
7
  import { createRequire } from "node:module";
7
8
  import os from "node:os";
8
9
  import path from "node:path";
@@ -25,7 +26,7 @@ const DEFAULT_TEAM_NAME = "default";
25
26
  const DEFAULT_TASK_TIMEOUT_MS = 1_800_000;
26
27
  const DEFAULT_AGENT_TIMEOUT_SECONDS = 2_400;
27
28
  const DEFAULT_LOCAL_ROLES = ["architect", "developer", "qa"];
28
- const DEFAULT_PROVISIONING_ROLES = ["architect", "developer", "qa"];
29
+ const LEGACY_DEFAULT_PROVISIONING_ROLES = ["architect", "developer", "qa"];
29
30
 
30
31
  const ROLE_OPTIONS = [
31
32
  { value: "pm", label: "Product Manager" },
@@ -189,6 +190,30 @@ function resolveDefaultOpenClawWorkspaceDir(env = process.env) {
189
190
  return path.join(resolveDefaultOpenClawStateDir(env), "workspace");
190
191
  }
191
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
+
192
217
  async function pathExists(targetPath) {
193
218
  try {
194
219
  await fs.access(targetPath);
@@ -309,10 +334,40 @@ function getCurrentWorkspacePath(config) {
309
334
  return typeof defaults.workspace === "string" ? expandUserPath(defaults.workspace) : "";
310
335
  }
311
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
+
312
346
  function dedupeStrings(values) {
313
347
  return Array.from(new Set(values.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim())));
314
348
  }
315
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
+
316
371
  function extractModelOptions(config) {
317
372
  const currentModel = getCurrentModel(config);
318
373
  const models = [];
@@ -519,6 +574,28 @@ async function promptRoleList(prompter, message, defaultRoles) {
519
574
  return parseRoleList(raw).values;
520
575
  }
521
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(", ")}. Leave empty to allow all roles.`,
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
+
522
599
  function buildStartCommand(configPath) {
523
600
  const defaultPath = resolveDefaultOpenClawConfigPath();
524
601
  if (path.resolve(configPath) === path.resolve(defaultPath)) {
@@ -534,6 +611,57 @@ function shellEscape(value) {
534
611
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
535
612
  }
536
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(", ")
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
+
537
665
  function installPluginWithCommand(command, args, env) {
538
666
  const result = spawnSync(command, args, {
539
667
  stdio: "inherit",
@@ -546,6 +674,50 @@ function installPluginWithCommand(command, args, env) {
546
674
  };
547
675
  }
548
676
 
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
+
549
721
  function createPackageTarball(env) {
550
722
  let tempDir = "";
551
723
  try {
@@ -591,11 +763,82 @@ function createPackageTarball(env) {
591
763
  }
592
764
  }
593
765
 
766
+ function attemptPluginUninstall({ configPath }) {
767
+ const env = {
768
+ ...process.env,
769
+ OPENCLAW_CONFIG_PATH: configPath,
770
+ };
771
+ const candidates = [
772
+ {
773
+ label: "openclaw",
774
+ command: "openclaw",
775
+ args: ["plugins", "uninstall", PLUGIN_ID, "--force"],
776
+ },
777
+ {
778
+ label: "npm exec fallback",
779
+ command: "npm",
780
+ args: ["exec", "-y", "openclaw@latest", "--", "plugins", "uninstall", PLUGIN_ID, "--force"],
781
+ },
782
+ ];
783
+ const failures = [];
784
+ for (let index = 0; index < candidates.length; index += 1) {
785
+ const candidate = candidates[index];
786
+ console.log(`\nRemoving existing ${PLUGIN_ID} plugin with ${candidate.label}...`);
787
+ const result = installPluginWithCommand(candidate.command, candidate.args, env);
788
+ if (result.status === 0 && !result.error) {
789
+ return {
790
+ ok: true,
791
+ method: candidate.label,
792
+ };
793
+ }
794
+ const errorCode = result.error && typeof result.error === "object" ? result.error.code : "";
795
+ const detail = result.error
796
+ ? result.error.message
797
+ : result.signal
798
+ ? `terminated by signal ${result.signal}`
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
+
594
813
  function attemptPluginInstall({ configPath }) {
595
814
  const env = {
596
815
  ...process.env,
597
816
  OPENCLAW_CONFIG_PATH: configPath,
598
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
+ }
599
842
  const candidates = [];
600
843
  const tarballResult = createPackageTarball(env);
601
844
  if (tarballResult.ok) {
@@ -674,10 +917,127 @@ function attemptPluginInstall({ configPath }) {
674
917
  }
675
918
  }
676
919
 
677
- async function collectInstallChoices(config, prompter) {
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
+ }
958
+ return {
959
+ ok: false,
960
+ error: failures.join("; "),
961
+ };
962
+ }
963
+
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) {
678
1023
  const existingTeamClaw = getExistingTeamClawConfig(config);
679
1024
  const existingMode = typeof existingTeamClaw.mode === "string" ? existingTeamClaw.mode.trim() : "";
680
- 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
+ }
681
1041
 
682
1042
  const installMode = await prompter.select({
683
1043
  message: "Choose an installation mode",
@@ -710,7 +1070,7 @@ async function collectInstallChoices(config, prompter) {
710
1070
  });
711
1071
  const workspacePath = expandUserPath(await prompter.text({
712
1072
  message: "OpenClaw workspace directory",
713
- defaultValue: getCurrentWorkspacePath(config) || resolveDefaultOpenClawWorkspaceDir(),
1073
+ defaultValue: resolveInstallerWorkspaceDefault(configPath, config, teamName),
714
1074
  }));
715
1075
 
716
1076
  if (installMode === "worker") {
@@ -790,12 +1150,10 @@ async function collectInstallChoices(config, prompter) {
790
1150
  };
791
1151
  }
792
1152
 
793
- const provisioningRoles = await promptRoleList(
1153
+ const provisioningRoles = await promptOptionalRoleList(
794
1154
  prompter,
795
- "On-demand roles to launch (comma-separated)",
796
- Array.isArray(existingTeamClaw.workerProvisioningRoles) && existingTeamClaw.workerProvisioningRoles.length > 0
797
- ? existingTeamClaw.workerProvisioningRoles
798
- : DEFAULT_PROVISIONING_ROLES,
1155
+ "On-demand roles to allow (comma-separated, leave empty for all roles)",
1156
+ resolveDefaultProvisioningRoles(existingTeamClaw),
799
1157
  );
800
1158
  const maxPerRole = await prompter.number({
801
1159
  message: "Maximum on-demand workers per role",
@@ -838,11 +1196,11 @@ async function collectInstallChoices(config, prompter) {
838
1196
  : DEFAULT_TEAMCLAW_IMAGE,
839
1197
  });
840
1198
  const dockerWorkspaceVolume = await prompter.text({
841
- 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)",
842
1200
  defaultValue:
843
1201
  typeof existingTeamClaw.workerProvisioningDockerWorkspaceVolume === "string"
844
1202
  ? existingTeamClaw.workerProvisioningDockerWorkspaceVolume.trim()
845
- : "teamclaw-workspaces",
1203
+ : "",
846
1204
  allowEmpty: true,
847
1205
  });
848
1206
  return {
@@ -893,7 +1251,7 @@ async function collectInstallChoices(config, prompter) {
893
1251
  : "teamclaw-worker",
894
1252
  });
895
1253
  const kubernetesWorkspacePersistentVolumeClaim = await prompter.text({
896
- message: "Kubernetes workspace PVC (leave empty for ephemeral workspaces)",
1254
+ message: "Kubernetes workspace PVC (leave empty for isolated ephemeral workspaces)",
897
1255
  defaultValue:
898
1256
  typeof existingTeamClaw.workerProvisioningKubernetesWorkspacePersistentVolumeClaim === "string"
899
1257
  ? existingTeamClaw.workerProvisioningKubernetesWorkspacePersistentVolumeClaim.trim()
@@ -1133,19 +1491,38 @@ function buildSummaryLines(params) {
1133
1491
  }
1134
1492
  if (params.pluginInstallStatus === "installed") {
1135
1493
  lines.push(`Plugin install: completed via ${params.pluginInstallMethod}`);
1494
+ } else if (params.pluginInstallStatus === "already-installed") {
1495
+ lines.push(`Plugin install: ${params.pluginInstallMethod}`);
1136
1496
  } else if (params.pluginInstallStatus === "skipped") {
1137
1497
  lines.push("Plugin install: skipped");
1138
1498
  } else if (params.pluginInstallError) {
1139
1499
  lines.push(`Plugin install: ${params.pluginInstallError}`);
1140
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
+ }
1141
1511
  lines.push(`Start command: ${buildStartCommand(params.configPath)}`);
1142
1512
 
1143
- if (params.choices.installMode === "single-local") {
1144
- 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)}`);
1145
1519
  }
1146
1520
  if (params.choices.installMode === "controller-docker" || params.choices.installMode === "controller-kubernetes") {
1147
1521
  lines.push(`Provisioning image: ${params.choices.workerImage}`);
1148
1522
  }
1523
+ if (isOnDemandControllerInstallMode(params.choices.installMode)) {
1524
+ lines.push(`On-demand roles: ${describeProvisioningRoles(params.choices.provisioningRoles)}`);
1525
+ }
1149
1526
  if (params.choices.installMode === "controller-docker" && params.choices.dockerWorkspaceVolume) {
1150
1527
  lines.push(`Docker workspace volume: ${params.choices.dockerWorkspaceVolume}`);
1151
1528
  }
@@ -1197,7 +1574,7 @@ async function runInstall(options) {
1197
1574
  if (!options.skipPluginInstall && !options.dryRun) {
1198
1575
  const installResult = attemptPluginInstall({ configPath });
1199
1576
  if (installResult.ok) {
1200
- pluginInstallStatus = "installed";
1577
+ pluginInstallStatus = installResult.skipped ? "already-installed" : "installed";
1201
1578
  pluginInstallMethod = installResult.method;
1202
1579
  } else {
1203
1580
  pluginInstallStatus = "failed";
@@ -1214,7 +1591,7 @@ async function runInstall(options) {
1214
1591
  }
1215
1592
 
1216
1593
  const config = await readOpenClawConfig(configPath);
1217
- const choices = await collectInstallChoices(config, prompter);
1594
+ const choices = await collectInstallChoices(configPath, config, prompter);
1218
1595
  const nextConfig = applyInstallerChoices(config, choices);
1219
1596
 
1220
1597
  if (options.dryRun) {
@@ -1223,6 +1600,29 @@ async function runInstall(options) {
1223
1600
  await writeConfig(configPath, nextConfig);
1224
1601
  }
1225
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
+
1226
1626
  const summaryLines = buildSummaryLines({
1227
1627
  configPath,
1228
1628
  choices,
@@ -1230,6 +1630,12 @@ async function runInstall(options) {
1230
1630
  pluginInstallStatus,
1231
1631
  pluginInstallMethod,
1232
1632
  pluginInstallError,
1633
+ gatewayRestartStatus,
1634
+ gatewayRestartMethod,
1635
+ gatewayRestartError,
1636
+ controllerHealthStatus,
1637
+ controllerHealthUrl,
1638
+ controllerHealthError,
1233
1639
  });
1234
1640
 
1235
1641
  prompter.note("\nTeamClaw installer summary");
package/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { definePluginEntry, type OpenClawPluginApi } from "./api.js";
2
2
  import { parsePluginConfig } from "./src/types.js";
3
- import type { TaskExecutionEventInput, WorkerIdentity } from "./src/types.js";
3
+ import type { TaskExecutionEventInput, TeamState, WorkerIdentity } from "./src/types.js";
4
4
  import { buildConfigSchema } from "./src/config.js";
5
5
  import { loadTeamState } from "./src/state.js";
6
6
  import { createRoleTaskExecutor } from "./src/task-executor.js";
@@ -39,9 +39,18 @@ function registerController(api: OpenClawPluginApi, config: ReturnType<typeof pa
39
39
  logger,
40
40
  runtime: api.runtime,
41
41
  });
42
+ let getControllerTeamState = (): TeamState | null => null;
42
43
 
43
44
  // Service (starts HTTP server + mDNS + WebSocket)
44
- api.registerService(createControllerService({ config, logger, runtime: api.runtime, localWorkerManager }));
45
+ api.registerService(createControllerService({
46
+ config,
47
+ logger,
48
+ runtime: api.runtime,
49
+ localWorkerManager,
50
+ onTeamStateAvailable: (getter) => {
51
+ getControllerTeamState = getter;
52
+ },
53
+ }));
45
54
 
46
55
  // Prompt injection
47
56
  api.on("before_prompt_build", async (_event: unknown, ctx: { sessionKey?: string | null }) => {
@@ -56,7 +65,7 @@ function registerController(api: OpenClawPluginApi, config: ReturnType<typeof pa
56
65
  return injector() ?? {};
57
66
  }
58
67
 
59
- const state = await loadTeamState(config.teamName);
68
+ const state = getControllerTeamState() ?? await loadTeamState(config.teamName);
60
69
  const injector = createControllerPromptInjector({
61
70
  config,
62
71
  getTeamState: () => state,
@@ -78,7 +87,7 @@ function registerController(api: OpenClawPluginApi, config: ReturnType<typeof pa
78
87
  return createControllerTools({
79
88
  config,
80
89
  controllerUrl,
81
- getTeamState: () => null,
90
+ getTeamState: getControllerTeamState,
82
91
  });
83
92
  });
84
93
  }
@@ -2,7 +2,7 @@
2
2
  "id": "teamclaw",
3
3
  "name": "TeamClaw",
4
4
  "description": "Virtual team collaboration - multiple OpenClaw instances form a virtual software company with role-based task routing.",
5
- "version": "2026.3.24-5",
5
+ "version": "2026.3.24-7",
6
6
  "uiHints": {
7
7
  "mode": {
8
8
  "label": "Mode",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamclaws/teamclaw",
3
- "version": "2026.3.24-5",
3
+ "version": "2026.3.24-7",
4
4
  "description": "OpenClaw virtual software team orchestration plugin",
5
5
  "private": false,
6
6
  "keywords": [
@@ -0,0 +1,23 @@
1
+ import type { PluginConfig, TeamState } from "../types.js";
2
+
3
+ export function hasOnDemandWorkerProvisioning(
4
+ config: Pick<PluginConfig, "workerProvisioningType">,
5
+ ): boolean {
6
+ return config.workerProvisioningType !== "none";
7
+ }
8
+
9
+ export function shouldBlockControllerWithoutWorkers(
10
+ config: Pick<PluginConfig, "workerProvisioningType">,
11
+ state: TeamState | null,
12
+ ): boolean {
13
+ return !!state && Object.keys(state.workers).length === 0 && !hasOnDemandWorkerProvisioning(config);
14
+ }
15
+
16
+ export function buildControllerNoWorkersMessage(): string {
17
+ return [
18
+ "No TeamClaw workers are registered and on-demand provisioning is disabled.",
19
+ "You may analyze the requirement and identify the roles that would be needed,",
20
+ "but do not create TeamClaw tasks and do not do the worker-role work yourself.",
21
+ "Ask the human to bring workers online or enable process/docker/kubernetes provisioning first.",
22
+ ].join(" ");
23
+ }
@@ -1,4 +1,5 @@
1
1
  import type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "../../api.js";
2
+ import os from "node:os";
2
3
  import type { PluginConfig, TeamState } from "../types.js";
3
4
  import { loadTeamState, saveTeamState } from "../state.js";
4
5
  import { MDnsAdvertiser } from "../discovery.js";
@@ -17,8 +18,27 @@ export type ControllerServiceDeps = {
17
18
  logger: PluginLogger;
18
19
  runtime: OpenClawPluginApi["runtime"];
19
20
  localWorkerManager?: LocalWorkerManager;
21
+ onTeamStateAvailable?: (getter: () => TeamState | null) => void;
20
22
  };
21
23
 
24
+ function getPreferredLanUiUrl(port: number): string | null {
25
+ const candidates: string[] = [];
26
+ const interfaces = os.networkInterfaces();
27
+ for (const records of Object.values(interfaces)) {
28
+ for (const record of records ?? []) {
29
+ if (!record || record.internal || record.family !== "IPv4") {
30
+ continue;
31
+ }
32
+ candidates.push(record.address);
33
+ }
34
+ }
35
+ candidates.sort((left, right) => left.localeCompare(right));
36
+ if (candidates.length === 0) {
37
+ return null;
38
+ }
39
+ return `http://${candidates[0]}:${port}/ui`;
40
+ }
41
+
22
42
  export function createControllerService(deps: ControllerServiceDeps): OpenClawPluginService {
23
43
  const { config, logger, localWorkerManager } = deps;
24
44
  let teamState: TeamState | null = null;
@@ -61,6 +81,7 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
61
81
  repoStateChanged = JSON.stringify(teamState.repo ?? null) !== previousRepoState;
62
82
  logger.info(`Controller: restored team "${config.teamName}" with ${Object.keys(teamState.workers).length} workers`);
63
83
  }
84
+ deps.onTeamStateAvailable?.(() => teamState);
64
85
 
65
86
  const updateState = (updater: (state: TeamState) => void): TeamState => {
66
87
  updater(teamState!);
@@ -108,7 +129,11 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
108
129
  await new Promise<void>((resolve, reject) => {
109
130
  server.listen(config.port, () => {
110
131
  logger.info(`Controller: HTTP server listening on port ${config.port}`);
111
- logger.info(`Controller: Web UI available at http://localhost:${config.port}/ui`);
132
+ logger.info(`Controller: Web UI available at http://127.0.0.1:${config.port}/ui`);
133
+ const lanUiUrl = getPreferredLanUiUrl(config.port);
134
+ if (lanUiUrl) {
135
+ logger.info(`Controller: Web UI available on LAN at ${lanUiUrl}`);
136
+ }
112
137
  resolve();
113
138
  });
114
139
  server.on("error", reject);
@@ -180,6 +205,7 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
180
205
  }
181
206
  },
182
207
  async stop() {
208
+ deps.onTeamStateAvailable?.(() => null);
183
209
  if (timeoutTimer) {
184
210
  clearInterval(timeoutTimer);
185
211
  timeoutTimer = null;
@@ -1,5 +1,6 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import type { PluginConfig, TaskInfo, TeamState } from "../types.js";
3
+ import { buildControllerNoWorkersMessage, hasOnDemandWorkerProvisioning, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js";
3
4
 
4
5
  export type ControllerToolsDeps = {
5
6
  config: PluginConfig;
@@ -46,6 +47,16 @@ export function createControllerTools(deps: ControllerToolsDeps) {
46
47
  return { content: [{ type: "text" as const, text: "title is required." }] };
47
48
  }
48
49
 
50
+ const state = getTeamState();
51
+ if (shouldBlockControllerWithoutWorkers(config, state)) {
52
+ return {
53
+ content: [{
54
+ type: "text" as const,
55
+ text: `${buildControllerNoWorkersMessage()} Stop after reporting this block to the human.`,
56
+ }],
57
+ };
58
+ }
59
+
49
60
  const blocker = detectExecutionReadyBlocker(description);
50
61
  if (blocker) {
51
62
  return {
@@ -80,7 +91,9 @@ export function createControllerTools(deps: ControllerToolsDeps) {
80
91
  const assigned = task.assignedWorkerId
81
92
  ? ` -> assigned to ${task.assignedWorkerId}`
82
93
  : task.status === "pending"
83
- ? " (pending - no available worker)"
94
+ ? hasOnDemandWorkerProvisioning(config)
95
+ ? " (pending - waiting for worker provisioning or an available worker)"
96
+ : " (pending - no registered/available worker)"
84
97
  : "";
85
98
  const recommended = Array.isArray(task.recommendedSkills) && task.recommendedSkills.length > 0
86
99
  ? ` | skills: ${task.recommendedSkills.join(", ")}`
@@ -38,6 +38,8 @@ import { TaskRouter } from "./task-router.js";
38
38
  import { MessageRouter } from "./message-router.js";
39
39
  import { TeamWebSocketServer } from "./websocket.js";
40
40
  import type { WorkerProvisioningManager } from "./worker-provisioning.js";
41
+ import { createControllerPromptInjector } from "./prompt-injector.js";
42
+ import { buildControllerNoWorkersMessage, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js";
41
43
 
42
44
  export type ControllerHttpDeps = {
43
45
  config: PluginConfig;
@@ -59,6 +61,16 @@ const MAX_TASK_CONTEXT_SUMMARY_CHARS = 500;
59
61
  const CONTROLLER_INTAKE_TIMEOUT_CAP_MS = 180_000;
60
62
  const CONTROLLER_INTAKE_SESSION_PREFIX = "teamclaw-controller-web:";
61
63
 
64
+ export function buildControllerIntakeSystemPrompt(
65
+ deps: Pick<ControllerHttpDeps, "config" | "getTeamState">,
66
+ ): string {
67
+ const injector = createControllerPromptInjector({
68
+ config: deps.config,
69
+ getTeamState: deps.getTeamState,
70
+ });
71
+ return injector()?.prependSystemContext ?? "";
72
+ }
73
+
62
74
  function mapTaskStatusToExecutionStatus(taskStatus: TaskStatus, current?: TaskExecution["status"]): TaskExecution["status"] {
63
75
  switch (taskStatus) {
64
76
  case "completed":
@@ -524,6 +536,7 @@ async function runControllerIntake(
524
536
  const runResult = await deps.runtime.subagent.run({
525
537
  sessionKey,
526
538
  message,
539
+ extraSystemPrompt: buildControllerIntakeSystemPrompt(deps),
527
540
  idempotencyKey: `controller-intake-${generateId()}`,
528
541
  });
529
542
  recordControllerRunEvent(controllerRun.id, {
@@ -1241,6 +1254,13 @@ async function handleRequest(
1241
1254
  const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1242
1255
 
1243
1256
  // ==================== Web UI ====================
1257
+ if (req.method === "GET" && pathname === "/") {
1258
+ res.statusCode = 302;
1259
+ res.setHeader("Location", "/ui");
1260
+ res.end();
1261
+ return;
1262
+ }
1263
+
1244
1264
  if (req.method === "GET" && (pathname === "/ui" || pathname === "/ui/")) {
1245
1265
  const uiPath = path.join(import.meta.dirname, "..", "ui");
1246
1266
  serveStaticFile(res, path.join(uiPath, "index.html"), "text/html; charset=utf-8");
@@ -1456,6 +1476,10 @@ async function handleRequest(
1456
1476
  sendError(res, 400, "title is required");
1457
1477
  return;
1458
1478
  }
1479
+ if (createdBy === "controller" && shouldBlockControllerWithoutWorkers(deps.config, getTeamState())) {
1480
+ sendError(res, 409, buildControllerNoWorkersMessage());
1481
+ return;
1482
+ }
1459
1483
 
1460
1484
  const taskId = generateId();
1461
1485
  const now = Date.now();
@@ -1,5 +1,6 @@
1
1
  import type { PluginConfig, TeamState } from "../types.js";
2
2
  import { ROLES } from "../roles.js";
3
+ import { hasOnDemandWorkerProvisioning, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js";
3
4
 
4
5
  const TEAMCLAW_ROLE_IDS_TEXT = [
5
6
  "pm",
@@ -22,15 +23,13 @@ export type ControllerPromptDeps = {
22
23
  export function createControllerPromptInjector(deps: ControllerPromptDeps) {
23
24
  return () => {
24
25
  const state = deps.getTeamState();
25
- if (!state) return null;
26
-
27
- const workers = Object.values(state.workers);
28
- const tasks = Object.values(state.tasks);
26
+ const workers = Object.values(state?.workers ?? {});
27
+ const tasks = Object.values(state?.tasks ?? {});
29
28
  const pendingTasks = tasks.filter((t) => t.status === "pending");
30
29
  const activeTasks = tasks.filter((t) => t.status === "in_progress" || t.status === "assigned");
31
30
  const blockedTasks = tasks.filter((t) => t.status === "blocked");
32
31
  const completedTasks = tasks.filter((t) => t.status === "completed");
33
- const pendingClarifications = Object.values(state.clarifications).filter((c) => c.status === "pending");
32
+ const pendingClarifications = Object.values(state?.clarifications ?? {}).filter((c) => c.status === "pending");
34
33
 
35
34
  const parts: string[] = [
36
35
  "## TeamClaw Controller Mode",
@@ -46,8 +45,17 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
46
45
  "### Current Team Status",
47
46
  ];
48
47
 
49
- if (workers.length === 0) {
50
- parts.push("- No workers registered yet");
48
+ if (!state) {
49
+ parts.push("- Team state is not loaded yet; treat this as a fresh controller intake and establish execution-ready tasks from the human requirement.");
50
+ } else if (workers.length === 0) {
51
+ if (shouldBlockControllerWithoutWorkers(deps.config, state)) {
52
+ parts.push("- No workers are registered and on-demand provisioning is disabled.");
53
+ parts.push("- Blocking rule: you may analyze the requirement and identify the needed roles, but do not create TeamClaw tasks yet.");
54
+ parts.push("- Do not start doing the worker-role work yourself. Tell the human to bring workers online or enable process/docker/kubernetes provisioning first.");
55
+ } else {
56
+ parts.push("- No workers are registered yet, but on-demand provisioning is enabled.");
57
+ parts.push("- You may still create execution-ready TeamClaw tasks for the required roles; the controller will provision workers on demand.");
58
+ }
51
59
  } else {
52
60
  for (const w of workers) {
53
61
  const roleDef = ROLES.find((r) => r.id === w.role);
@@ -86,6 +94,13 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
86
94
  parts.push(`- ${role.icon} ${role.label}: ${role.description}.${skillLine}`);
87
95
  }
88
96
 
97
+ parts.push("");
98
+ parts.push("## Controller Workflow");
99
+ parts.push("- First determine which TeamClaw roles are needed for the human requirement.");
100
+ parts.push("- Then translate the requirement into the minimum execution-ready TeamClaw tasks owned by those roles.");
101
+ parts.push("- TeamClaw workers, not the controller, do the specialist work in the shared repo/workspace.");
102
+ parts.push("- After workers report progress, results, or handoffs, create only the next tasks whose prerequisites are now satisfied.");
103
+
89
104
  parts.push("");
90
105
  parts.push("## Requirement Intake Rules");
91
106
  parts.push("- Human messages are the initial requirement, not an already-decomposed task tree.");
@@ -111,6 +126,13 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
111
126
  parts.push("- Do not let a worker task turn itself into a controller/coordinator workflow.");
112
127
  parts.push("- If the correct role is busy, prefer waiting, messaging, or explicit reassignment over routing core work to an unrelated role.");
113
128
  parts.push("- If a task is blocked by missing information, keep it in the clarification queue until the human answers; do not guess on the user's behalf.");
129
+ parts.push("- You are never a substitute worker. Do not personally perform architecture, implementation, QA, release, infra, design, marketing, research, or other specialist work.");
130
+ parts.push("- Your own reply must stay at the orchestration layer: clarification, role selection, task decomposition, assignment decisions, and concise status updates.");
131
+ if (hasOnDemandWorkerProvisioning(deps.config)) {
132
+ parts.push("- If no workers are currently registered but on-demand provisioning is enabled, you may still create execution-ready tasks so the required roles can be provisioned.");
133
+ } else {
134
+ parts.push("- If no workers are registered, you may mention which roles would be needed, but stop there and report the worker-capacity block to the human.");
135
+ }
114
136
  parts.push("- Use the controller itself for requirement analysis; use the PM role only for PM-owned deliverables after intake is clear.");
115
137
  parts.push(`- Use exact TeamClaw role IDs only: ${TEAMCLAW_ROLE_IDS_TEXT}.`);
116
138